feat(db): Datenmodell fuer Benutzer, Sitzungen und Passkeys
Neue Tabellen:
- User Konto mit Rolle, optionalem Passwort-Hash, Sperrzaehler.
Das Passwort ist optional, weil ein Konto auch ausschliesslich
per Passkey nutzbar sein darf.
- Session serverseitige Sitzung. Gespeichert wird nur der SHA-256-Hash
des Cookie-Tokens, damit ein Datenbankleck keine uebernehmbaren
Sitzungen ergibt.
- Credential ein Passkey je Zeile, mit oeffentlichem Schluessel, Transporten
und Signaturzaehler.
- WebAuthnChallenge kurzlebig und einmalig verwendbar, deshalb in der
Datenbank statt im Cookie.
- AppSettings Betriebsart und Registrierung, genau eine Zeile.
Project und GitConnection bekommen einen Besitzer. Bewusst nullable: so
ueberstehen Bestandsdaten die Migration und werden beim Anlegen des ersten
Kontos uebernommen. Slug und Verbindungsname sind dadurch nur noch je
Besitzer eindeutig - zwei Personen duerfen beide ein Projekt "dashboard"
haben.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,143 @@
|
||||
-- CreateEnum
|
||||
CREATE TYPE "UserRole" AS ENUM ('ADMIN', 'USER');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "AppMode" AS ENUM ('SINGLE_USER', 'MULTI_USER');
|
||||
|
||||
-- DropIndex
|
||||
DROP INDEX "GitConnection_name_key";
|
||||
|
||||
-- DropIndex
|
||||
DROP INDEX "Project_slug_key";
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "GitConnection" ADD COLUMN "ownerId" TEXT;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "Project" ADD COLUMN "ownerId" TEXT;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "User" (
|
||||
"id" TEXT NOT NULL,
|
||||
"username" TEXT NOT NULL,
|
||||
"displayName" TEXT,
|
||||
"email" TEXT,
|
||||
"passwordHash" TEXT,
|
||||
"role" "UserRole" NOT NULL DEFAULT 'USER',
|
||||
"isActive" BOOLEAN NOT NULL DEFAULT true,
|
||||
"lastLoginAt" TIMESTAMP(3),
|
||||
"failedLoginCount" INTEGER NOT NULL DEFAULT 0,
|
||||
"lockedUntil" TIMESTAMP(3),
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "User_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Session" (
|
||||
"id" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"tokenHash" TEXT NOT NULL,
|
||||
"expiresAt" TIMESTAMP(3) NOT NULL,
|
||||
"lastSeenAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"userAgent" TEXT,
|
||||
"ip" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "Session_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Credential" (
|
||||
"id" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"credentialId" TEXT NOT NULL,
|
||||
"publicKey" BYTEA NOT NULL,
|
||||
"counter" INTEGER NOT NULL DEFAULT 0,
|
||||
"transports" TEXT[] DEFAULT ARRAY[]::TEXT[],
|
||||
"deviceType" TEXT,
|
||||
"backedUp" BOOLEAN NOT NULL DEFAULT false,
|
||||
"name" TEXT,
|
||||
"lastUsedAt" TIMESTAMP(3),
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "Credential_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "WebAuthnChallenge" (
|
||||
"id" TEXT NOT NULL,
|
||||
"challenge" TEXT NOT NULL,
|
||||
"userId" TEXT,
|
||||
"purpose" TEXT NOT NULL,
|
||||
"expiresAt" TIMESTAMP(3) NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "WebAuthnChallenge_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "AppSettings" (
|
||||
"id" TEXT NOT NULL DEFAULT 'singleton',
|
||||
"mode" "AppMode" NOT NULL DEFAULT 'SINGLE_USER',
|
||||
"allowRegistration" BOOLEAN NOT NULL DEFAULT false,
|
||||
"rpName" TEXT NOT NULL DEFAULT 'Nebula',
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "AppSettings_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "User_username_key" ON "User"("username");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "User_role_idx" ON "User"("role");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Session_tokenHash_key" ON "Session"("tokenHash");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Session_userId_idx" ON "Session"("userId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Session_expiresAt_idx" ON "Session"("expiresAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Credential_credentialId_key" ON "Credential"("credentialId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Credential_userId_idx" ON "Credential"("userId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "WebAuthnChallenge_challenge_key" ON "WebAuthnChallenge"("challenge");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "WebAuthnChallenge_expiresAt_idx" ON "WebAuthnChallenge"("expiresAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "GitConnection_ownerId_idx" ON "GitConnection"("ownerId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "GitConnection_ownerId_name_key" ON "GitConnection"("ownerId", "name");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Project_ownerId_idx" ON "Project"("ownerId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Project_ownerId_slug_key" ON "Project"("ownerId", "slug");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Session" ADD CONSTRAINT "Session_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Credential" ADD CONSTRAINT "Credential_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "GitConnection" ADD CONSTRAINT "GitConnection_ownerId_fkey" FOREIGN KEY ("ownerId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Project" ADD CONSTRAINT "Project_ownerId_fkey" FOREIGN KEY ("ownerId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
+124
-2
@@ -33,6 +33,113 @@ enum GitProvider {
|
||||
GITEA
|
||||
}
|
||||
|
||||
/// ADMIN darf Benutzer und Einstellungen verwalten. Auf fremde Projekte hat
|
||||
/// auch ein Admin keinen Zugriff – die Trennung ist bewusst hart.
|
||||
enum UserRole {
|
||||
ADMIN
|
||||
USER
|
||||
}
|
||||
|
||||
/// SINGLE_USER: genau ein Konto, keine Registrierung, keine Benutzerliste.
|
||||
/// MULTI_USER: mehrere Konten, jedes mit streng getrennten Daten.
|
||||
enum AppMode {
|
||||
SINGLE_USER
|
||||
MULTI_USER
|
||||
}
|
||||
|
||||
/// Ein Benutzerkonto. Das Passwort ist optional, weil ein Konto auch
|
||||
/// ausschliesslich per Passkey nutzbar sein darf.
|
||||
model User {
|
||||
id String @id @default(cuid())
|
||||
/// Immer kleingeschrieben gespeichert, damit die Anmeldung
|
||||
/// gross-/kleinschreibungsunabhaengig funktioniert.
|
||||
username String @unique
|
||||
displayName String?
|
||||
email String? @unique
|
||||
/// scrypt-Hash im Format scrypt$N$r$p$<salt>$<hash>; null = nur Passkey.
|
||||
passwordHash String?
|
||||
role UserRole @default(USER)
|
||||
isActive Boolean @default(true)
|
||||
|
||||
lastLoginAt DateTime?
|
||||
/// Zaehler und Sperre gegen Passwortraten.
|
||||
failedLoginCount Int @default(0)
|
||||
lockedUntil DateTime?
|
||||
|
||||
sessions Session[]
|
||||
credentials Credential[]
|
||||
projects Project[]
|
||||
connections GitConnection[]
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([role])
|
||||
}
|
||||
|
||||
/// Serverseitige Sitzung. In der Datenbank liegt nur der SHA-256-Hash des
|
||||
/// Cookie-Tokens, damit ein Datenbankleck keine uebernehmbaren Sitzungen ergibt.
|
||||
model Session {
|
||||
id String @id @default(cuid())
|
||||
userId String
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
tokenHash String @unique
|
||||
expiresAt DateTime
|
||||
lastSeenAt DateTime @default(now())
|
||||
userAgent String?
|
||||
ip String?
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@index([userId])
|
||||
@@index([expiresAt])
|
||||
}
|
||||
|
||||
/// Ein registrierter Passkey (WebAuthn-Credential).
|
||||
model Credential {
|
||||
id String @id @default(cuid())
|
||||
userId String
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
/// base64url-kodierte Credential-ID des Authenticators.
|
||||
credentialId String @unique
|
||||
publicKey Bytes
|
||||
counter Int @default(0)
|
||||
transports String[] @default([])
|
||||
deviceType String?
|
||||
backedUp Boolean @default(false)
|
||||
/// Frei waehlbarer Name, z. B. "MacBook Touch ID".
|
||||
name String?
|
||||
lastUsedAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@index([userId])
|
||||
}
|
||||
|
||||
/// Kurzlebige WebAuthn-Challenge. Wird nach Gebrauch geloescht, damit sie
|
||||
/// kein zweites Mal verwendet werden kann.
|
||||
model WebAuthnChallenge {
|
||||
id String @id @default(cuid())
|
||||
challenge String @unique
|
||||
/// null bei der Anmeldung ueber auffindbare Passkeys.
|
||||
userId String?
|
||||
/// "registration" oder "authentication"
|
||||
purpose String
|
||||
expiresAt DateTime
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@index([expiresAt])
|
||||
}
|
||||
|
||||
/// Einstellungen der Instanz. Genau eine Zeile mit der id "singleton".
|
||||
model AppSettings {
|
||||
id String @id @default("singleton")
|
||||
mode AppMode @default(SINGLE_USER)
|
||||
/// Nur im Mehrbenutzerbetrieb wirksam.
|
||||
allowRegistration Boolean @default(false)
|
||||
/// Anzeigename gegenueber dem Authenticator beim Anlegen eines Passkeys.
|
||||
rpName String @default("Nebula")
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
||||
/// Eine Verbindung zu einem Git-System inkl. verschluesseltem Token.
|
||||
model GitConnection {
|
||||
id String @id @default(cuid())
|
||||
@@ -53,20 +160,28 @@ model GitConnection {
|
||||
lastCheckOk Boolean?
|
||||
lastCheckMessage String?
|
||||
|
||||
/// Besitzer. Nullable, damit Bestandsdaten aus der Zeit vor der
|
||||
/// Benutzerverwaltung erhalten bleiben; beim Einrichten des ersten
|
||||
/// Kontos werden sie diesem zugeordnet.
|
||||
ownerId String?
|
||||
owner User? @relation(fields: [ownerId], references: [id], onDelete: Cascade)
|
||||
|
||||
projects Project[]
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@unique([name])
|
||||
@@unique([ownerId, name])
|
||||
@@index([provider])
|
||||
@@index([ownerId])
|
||||
}
|
||||
|
||||
/// Ein Vibe-Coding-Projekt – von der reinen Idee bis zum fertigen Repo.
|
||||
model Project {
|
||||
id String @id @default(cuid())
|
||||
name String
|
||||
slug String @unique
|
||||
/// Pro Besitzer eindeutig – siehe @@unique unten.
|
||||
slug String
|
||||
/// Einzeiler fuer die Karte im Dashboard.
|
||||
summary String?
|
||||
/// Ausfuehrliche Beschreibung / Ideenskizze (Markdown-freundlich).
|
||||
@@ -93,15 +208,22 @@ model Project {
|
||||
lastSyncedAt DateTime?
|
||||
syncError String?
|
||||
|
||||
/// Besitzer. Nullable wie bei GitConnection, damit Bestandsdaten beim
|
||||
/// Einrichten des ersten Kontos uebernommen werden koennen.
|
||||
ownerId String?
|
||||
owner User? @relation(fields: [ownerId], references: [id], onDelete: Cascade)
|
||||
|
||||
notes Note[]
|
||||
snapshot RepoSnapshot?
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@unique([ownerId, slug])
|
||||
@@index([status])
|
||||
@@index([connectionId])
|
||||
@@index([updatedAt])
|
||||
@@index([ownerId])
|
||||
}
|
||||
|
||||
/// Freie Notizen zu einem Projekt.
|
||||
|
||||
+5
-2
@@ -86,9 +86,12 @@ async function main() {
|
||||
},
|
||||
];
|
||||
|
||||
// Der Slug ist seit der Benutzerverwaltung nur je Besitzer eindeutig.
|
||||
// Die Seed-Projekte entstehen ohne Besitzer und werden beim Einrichten
|
||||
// des ersten Kontos uebernommen.
|
||||
for (const sample of samples) {
|
||||
const existing = await prisma.project.findUnique({
|
||||
where: { slug: sample.slug },
|
||||
const existing = await prisma.project.findFirst({
|
||||
where: { slug: sample.slug, ownerId: null },
|
||||
});
|
||||
if (existing) {
|
||||
console.log(` · uebersprungen (existiert): ${sample.slug}`);
|
||||
|
||||
Reference in New Issue
Block a user