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
|
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.
|
/// Eine Verbindung zu einem Git-System inkl. verschluesseltem Token.
|
||||||
model GitConnection {
|
model GitConnection {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
@@ -53,20 +160,28 @@ model GitConnection {
|
|||||||
lastCheckOk Boolean?
|
lastCheckOk Boolean?
|
||||||
lastCheckMessage String?
|
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[]
|
projects Project[]
|
||||||
|
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
@@unique([name])
|
@@unique([ownerId, name])
|
||||||
@@index([provider])
|
@@index([provider])
|
||||||
|
@@index([ownerId])
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Ein Vibe-Coding-Projekt – von der reinen Idee bis zum fertigen Repo.
|
/// Ein Vibe-Coding-Projekt – von der reinen Idee bis zum fertigen Repo.
|
||||||
model Project {
|
model Project {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
name String
|
name String
|
||||||
slug String @unique
|
/// Pro Besitzer eindeutig – siehe @@unique unten.
|
||||||
|
slug String
|
||||||
/// Einzeiler fuer die Karte im Dashboard.
|
/// Einzeiler fuer die Karte im Dashboard.
|
||||||
summary String?
|
summary String?
|
||||||
/// Ausfuehrliche Beschreibung / Ideenskizze (Markdown-freundlich).
|
/// Ausfuehrliche Beschreibung / Ideenskizze (Markdown-freundlich).
|
||||||
@@ -93,15 +208,22 @@ model Project {
|
|||||||
lastSyncedAt DateTime?
|
lastSyncedAt DateTime?
|
||||||
syncError String?
|
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[]
|
notes Note[]
|
||||||
snapshot RepoSnapshot?
|
snapshot RepoSnapshot?
|
||||||
|
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
@@unique([ownerId, slug])
|
||||||
@@index([status])
|
@@index([status])
|
||||||
@@index([connectionId])
|
@@index([connectionId])
|
||||||
@@index([updatedAt])
|
@@index([updatedAt])
|
||||||
|
@@index([ownerId])
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Freie Notizen zu einem Projekt.
|
/// 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) {
|
for (const sample of samples) {
|
||||||
const existing = await prisma.project.findUnique({
|
const existing = await prisma.project.findFirst({
|
||||||
where: { slug: sample.slug },
|
where: { slug: sample.slug, ownerId: null },
|
||||||
});
|
});
|
||||||
if (existing) {
|
if (existing) {
|
||||||
console.log(` · uebersprungen (existiert): ${sample.slug}`);
|
console.log(` · uebersprungen (existiert): ${sample.slug}`);
|
||||||
|
|||||||
Reference in New Issue
Block a user