Phase1
This commit is contained in:
@@ -9,15 +9,18 @@
|
||||
"typecheck": "prisma generate && tsc --noEmit",
|
||||
"db:generate": "prisma generate",
|
||||
"db:migrate": "prisma migrate dev",
|
||||
"db:push": "prisma db push"
|
||||
"db:push": "prisma db push",
|
||||
"bootstrap:admin": "tsx src/cli/bootstrap-admin.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@prisma/client": "^6.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@hexahost/auth": "workspace:*",
|
||||
"@hexahost/typescript-config": "workspace:*",
|
||||
"@types/node": "^22.10.2",
|
||||
"prisma": "^6.3.1",
|
||||
"tsx": "^4.19.2",
|
||||
"typescript": "^5.7.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
-- Phase 1: Authentication models and user extensions
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "users" ADD COLUMN IF NOT EXISTS "displayName" TEXT;
|
||||
ALTER TABLE "users" ADD COLUMN IF NOT EXISTS "emailVerifiedAt" TIMESTAMPTZ(3);
|
||||
ALTER TABLE "users" ADD COLUMN IF NOT EXISTS "failedLoginAttempts" INTEGER NOT NULL DEFAULT 0;
|
||||
ALTER TABLE "users" ADD COLUMN IF NOT EXISTS "lockedUntil" TIMESTAMPTZ(3);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE IF NOT EXISTS "email_verification_tokens" (
|
||||
"id" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"tokenHash" TEXT NOT NULL,
|
||||
"expiresAt" TIMESTAMPTZ(3) NOT NULL,
|
||||
"usedAt" TIMESTAMPTZ(3),
|
||||
"createdAt" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "email_verification_tokens_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS "password_reset_tokens" (
|
||||
"id" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"tokenHash" TEXT NOT NULL,
|
||||
"expiresAt" TIMESTAMPTZ(3) NOT NULL,
|
||||
"usedAt" TIMESTAMPTZ(3),
|
||||
"createdAt" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "password_reset_tokens_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS "totp_credentials" (
|
||||
"id" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"secret" TEXT NOT NULL,
|
||||
"enabledAt" TIMESTAMPTZ(3),
|
||||
"createdAt" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMPTZ(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "totp_credentials_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS "recovery_codes" (
|
||||
"id" TEXT NOT NULL,
|
||||
"totpCredentialId" TEXT NOT NULL,
|
||||
"codeHash" TEXT NOT NULL,
|
||||
"usedAt" TIMESTAMPTZ(3),
|
||||
"createdAt" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "recovery_codes_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "email_verification_tokens_tokenHash_key" ON "email_verification_tokens"("tokenHash");
|
||||
CREATE INDEX IF NOT EXISTS "email_verification_tokens_userId_idx" ON "email_verification_tokens"("userId");
|
||||
CREATE INDEX IF NOT EXISTS "email_verification_tokens_expiresAt_idx" ON "email_verification_tokens"("expiresAt");
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "password_reset_tokens_tokenHash_key" ON "password_reset_tokens"("tokenHash");
|
||||
CREATE INDEX IF NOT EXISTS "password_reset_tokens_userId_idx" ON "password_reset_tokens"("userId");
|
||||
CREATE INDEX IF NOT EXISTS "password_reset_tokens_expiresAt_idx" ON "password_reset_tokens"("expiresAt");
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "totp_credentials_userId_key" ON "totp_credentials"("userId");
|
||||
|
||||
CREATE INDEX IF NOT EXISTS "recovery_codes_totpCredentialId_idx" ON "recovery_codes"("totpCredentialId");
|
||||
|
||||
-- AddForeignKey
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "email_verification_tokens" ADD CONSTRAINT "email_verification_tokens_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
EXCEPTION WHEN duplicate_object THEN NULL;
|
||||
END $$;
|
||||
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "password_reset_tokens" ADD CONSTRAINT "password_reset_tokens_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
EXCEPTION WHEN duplicate_object THEN NULL;
|
||||
END $$;
|
||||
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "totp_credentials" ADD CONSTRAINT "totp_credentials_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
EXCEPTION WHEN duplicate_object THEN NULL;
|
||||
END $$;
|
||||
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "recovery_codes" ADD CONSTRAINT "recovery_codes_totpCredentialId_fkey" FOREIGN KEY ("totpCredentialId") REFERENCES "totp_credentials"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
EXCEPTION WHEN duplicate_object THEN NULL;
|
||||
END $$;
|
||||
|
||||
-- Seed platform roles
|
||||
INSERT INTO "platform_roles" ("id", "name", "description", "createdAt", "updatedAt")
|
||||
VALUES
|
||||
(gen_random_uuid()::text, 'USER', 'Standard platform user', NOW(), NOW()),
|
||||
(gen_random_uuid()::text, 'SUPPORT', 'Support staff', NOW(), NOW()),
|
||||
(gen_random_uuid()::text, 'MODERATOR', 'Content moderator', NOW(), NOW()),
|
||||
(gen_random_uuid()::text, 'ADMIN', 'Platform administrator', NOW(), NOW()),
|
||||
(gen_random_uuid()::text, 'SUPER_ADMIN', 'Super administrator', NOW(), NOW())
|
||||
ON CONFLICT ("name") DO NOTHING;
|
||||
3
packages/database/prisma/migrations/migration_lock.toml
Normal file
3
packages/database/prisma/migrations/migration_lock.toml
Normal file
@@ -0,0 +1,3 @@
|
||||
# Please do not edit this file manually
|
||||
# It should be added in your version-control system (i.e. Git)
|
||||
provider = "postgresql"
|
||||
@@ -40,18 +40,25 @@ enum JobStatus {
|
||||
}
|
||||
|
||||
model User {
|
||||
id String @id @default(uuid())
|
||||
email String @unique
|
||||
username String @unique
|
||||
status UserStatus @default(ACTIVE)
|
||||
createdAt DateTime @default(now()) @db.Timestamptz(3)
|
||||
updatedAt DateTime @updatedAt @db.Timestamptz(3)
|
||||
id String @id @default(uuid())
|
||||
email String @unique
|
||||
username String @unique
|
||||
displayName String?
|
||||
status UserStatus @default(ACTIVE)
|
||||
emailVerifiedAt DateTime? @db.Timestamptz(3)
|
||||
failedLoginAttempts Int @default(0)
|
||||
lockedUntil DateTime? @db.Timestamptz(3)
|
||||
createdAt DateTime @default(now()) @db.Timestamptz(3)
|
||||
updatedAt DateTime @updatedAt @db.Timestamptz(3)
|
||||
|
||||
credentials UserCredential[]
|
||||
sessions UserSession[]
|
||||
platformRoles UserPlatformRole[]
|
||||
gameServers GameServer[]
|
||||
auditEvents AuditEvent[]
|
||||
credentials UserCredential[]
|
||||
sessions UserSession[]
|
||||
platformRoles UserPlatformRole[]
|
||||
gameServers GameServer[]
|
||||
auditEvents AuditEvent[]
|
||||
emailVerificationTokens EmailVerificationToken[]
|
||||
passwordResetTokens PasswordResetToken[]
|
||||
totpCredential TotpCredential?
|
||||
|
||||
@@map("users")
|
||||
}
|
||||
@@ -87,6 +94,63 @@ model UserSession {
|
||||
@@map("user_sessions")
|
||||
}
|
||||
|
||||
model EmailVerificationToken {
|
||||
id String @id @default(uuid())
|
||||
userId String
|
||||
tokenHash String @unique
|
||||
expiresAt DateTime @db.Timestamptz(3)
|
||||
usedAt DateTime? @db.Timestamptz(3)
|
||||
createdAt DateTime @default(now()) @db.Timestamptz(3)
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([userId])
|
||||
@@index([expiresAt])
|
||||
@@map("email_verification_tokens")
|
||||
}
|
||||
|
||||
model PasswordResetToken {
|
||||
id String @id @default(uuid())
|
||||
userId String
|
||||
tokenHash String @unique
|
||||
expiresAt DateTime @db.Timestamptz(3)
|
||||
usedAt DateTime? @db.Timestamptz(3)
|
||||
createdAt DateTime @default(now()) @db.Timestamptz(3)
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([userId])
|
||||
@@index([expiresAt])
|
||||
@@map("password_reset_tokens")
|
||||
}
|
||||
|
||||
model TotpCredential {
|
||||
id String @id @default(uuid())
|
||||
userId String @unique
|
||||
secret String
|
||||
enabledAt DateTime? @db.Timestamptz(3)
|
||||
createdAt DateTime @default(now()) @db.Timestamptz(3)
|
||||
updatedAt DateTime @updatedAt @db.Timestamptz(3)
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
recoveryCodes RecoveryCode[]
|
||||
|
||||
@@map("totp_credentials")
|
||||
}
|
||||
|
||||
model RecoveryCode {
|
||||
id String @id @default(uuid())
|
||||
totpCredentialId String
|
||||
codeHash String
|
||||
usedAt DateTime? @db.Timestamptz(3)
|
||||
createdAt DateTime @default(now()) @db.Timestamptz(3)
|
||||
|
||||
totpCredential TotpCredential @relation(fields: [totpCredentialId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([totpCredentialId])
|
||||
@@map("recovery_codes")
|
||||
}
|
||||
|
||||
model PlatformRole {
|
||||
id String @id @default(uuid())
|
||||
name String @unique
|
||||
|
||||
158
packages/database/src/cli/bootstrap-admin.ts
Normal file
158
packages/database/src/cli/bootstrap-admin.ts
Normal file
@@ -0,0 +1,158 @@
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
import {
|
||||
generateUsernameFromEmail,
|
||||
hashPassword,
|
||||
validateUsername,
|
||||
} from '@hexahost/auth';
|
||||
|
||||
const PLATFORM_ROLES = [
|
||||
'USER',
|
||||
'SUPPORT',
|
||||
'MODERATOR',
|
||||
'ADMIN',
|
||||
'SUPER_ADMIN',
|
||||
] as const;
|
||||
|
||||
interface CliArgs {
|
||||
email: string;
|
||||
password: string;
|
||||
username?: string;
|
||||
displayName?: string;
|
||||
}
|
||||
|
||||
function parseArgs(argv: string[]): CliArgs {
|
||||
const args: Record<string, string> = {};
|
||||
|
||||
for (let i = 0; i < argv.length; i += 1) {
|
||||
const key = argv[i];
|
||||
const value = argv[i + 1];
|
||||
if (key?.startsWith('--') && value) {
|
||||
args[key.slice(2)] = value;
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
|
||||
if (!args['email'] || !args['password']) {
|
||||
console.error(
|
||||
'Usage: bootstrap-admin --email <email> --password <password> [--username <name>] [--display-name <name>]',
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (args['password'].length < 12) {
|
||||
console.error('Password must be at least 12 characters.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
return {
|
||||
email: args['email'].trim().toLowerCase(),
|
||||
password: args['password'],
|
||||
username: args['username'],
|
||||
displayName: args['display-name'],
|
||||
};
|
||||
}
|
||||
|
||||
async function ensurePlatformRoles(prisma: PrismaClient): Promise<void> {
|
||||
for (const name of PLATFORM_ROLES) {
|
||||
await prisma.platformRole.upsert({
|
||||
where: { name },
|
||||
create: { name, description: `${name} platform role` },
|
||||
update: {},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const input = parseArgs(process.argv.slice(2));
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
try {
|
||||
await ensurePlatformRoles(prisma);
|
||||
|
||||
const existing = await prisma.user.findUnique({
|
||||
where: { email: input.email },
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
const superAdminRole = await prisma.platformRole.findUnique({
|
||||
where: { name: 'SUPER_ADMIN' },
|
||||
});
|
||||
|
||||
if (!superAdminRole) {
|
||||
throw new Error('SUPER_ADMIN role missing after seed');
|
||||
}
|
||||
|
||||
await prisma.userPlatformRole.upsert({
|
||||
where: {
|
||||
userId_roleId: {
|
||||
userId: existing.id,
|
||||
roleId: superAdminRole.id,
|
||||
},
|
||||
},
|
||||
create: {
|
||||
userId: existing.id,
|
||||
roleId: superAdminRole.id,
|
||||
},
|
||||
update: {},
|
||||
});
|
||||
|
||||
console.log(`User ${input.email} already exists — SUPER_ADMIN role ensured.`);
|
||||
return;
|
||||
}
|
||||
|
||||
let username = input.username?.trim().toLowerCase();
|
||||
if (username) {
|
||||
const validation = validateUsername(username);
|
||||
if (!validation.valid) {
|
||||
throw new Error(validation.error ?? 'Invalid username');
|
||||
}
|
||||
} else {
|
||||
username = generateUsernameFromEmail(input.email);
|
||||
}
|
||||
|
||||
const passwordHash = await hashPassword(input.password);
|
||||
|
||||
const superAdminRole = await prisma.platformRole.findUniqueOrThrow({
|
||||
where: { name: 'SUPER_ADMIN' },
|
||||
});
|
||||
|
||||
const user = await prisma.$transaction(async (tx) => {
|
||||
const created = await tx.user.create({
|
||||
data: {
|
||||
email: input.email,
|
||||
username,
|
||||
displayName: input.displayName ?? 'Administrator',
|
||||
emailVerifiedAt: new Date(),
|
||||
credentials: {
|
||||
create: { passwordHash },
|
||||
},
|
||||
platformRoles: {
|
||||
create: { roleId: superAdminRole.id },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await tx.auditEvent.create({
|
||||
data: {
|
||||
userId: created.id,
|
||||
action: 'admin.bootstrap',
|
||||
entityType: 'user',
|
||||
entityId: created.id,
|
||||
metadata: { source: 'bootstrap-cli' },
|
||||
},
|
||||
});
|
||||
|
||||
return created;
|
||||
});
|
||||
|
||||
console.log(`Admin user created: ${user.email} (${user.id})`);
|
||||
} finally {
|
||||
await prisma.$disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
void main().catch((error: unknown) => {
|
||||
console.error(error instanceof Error ? error.message : error);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -3,6 +3,10 @@ export type {
|
||||
User,
|
||||
UserCredential,
|
||||
UserSession,
|
||||
EmailVerificationToken,
|
||||
PasswordResetToken,
|
||||
TotpCredential,
|
||||
RecoveryCode,
|
||||
PlatformRole,
|
||||
UserPlatformRole,
|
||||
GameServer,
|
||||
|
||||
@@ -5,5 +5,5 @@
|
||||
"rootDir": "./src"
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
"exclude": ["node_modules", "dist", "src/cli/**/*"]
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user