Phase1
Some checks failed
CI / Node — lint, typecheck, test, build (push) Failing after 9s
CI / Go — node-agent tests (push) Failing after 21s
CI / Go — edge-gateway build (push) Successful in 13s

This commit is contained in:
smueller
2026-06-26 11:31:54 +02:00
parent 4fe25e6ec3
commit 58961000eb
123 changed files with 4231 additions and 136 deletions

View File

@@ -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"
}
}

View File

@@ -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;

View 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"

View File

@@ -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

View 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);
});

View File

@@ -3,6 +3,10 @@ export type {
User,
UserCredential,
UserSession,
EmailVerificationToken,
PasswordResetToken,
TotpCredential,
RecoveryCode,
PlatformRole,
UserPlatformRole,
GameServer,

View File

@@ -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