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

@@ -6,10 +6,17 @@
"types": "./dist/index.d.ts",
"scripts": {
"build": "tsc",
"typecheck": "tsc --noEmit"
"typecheck": "tsc --noEmit",
"test": "vitest run"
},
"dependencies": {
"@node-rs/argon2": "^2.0.2",
"otplib": "^12.0.1"
},
"devDependencies": {
"@hexahost/typescript-config": "workspace:*",
"typescript": "^5.7.3"
"@types/node": "^22.10.2",
"typescript": "^5.7.3",
"vitest": "^3.0.8"
}
}

View File

@@ -10,6 +10,8 @@ export interface AuthenticatedUser {
id: string;
email: string;
username: string;
displayName: string | null;
emailVerified: boolean;
roles: UserRole[];
}
@@ -27,3 +29,35 @@ export interface AuthTokenPayload {
iat?: number;
exp?: number;
}
export { hashPassword, verifyPassword } from './password';
export {
generateSecureToken,
hashToken,
verifyTokenHash,
} from './tokens';
export {
generateUsernameFromEmail,
sanitizeUsername,
validateUsername,
MIN_USERNAME_LENGTH,
MAX_USERNAME_LENGTH,
type UsernameValidationResult,
} from './username';
export {
generateTotpSecret,
verifyTotpCode,
buildOtpAuthUrl,
} from './totp';
export {
generateRecoveryCodes,
hashRecoveryCode,
verifyRecoveryCode,
} from './recovery-codes';
export {
SESSION_COOKIE_NAME,
SESSION_DURATION_MS,
SESSION_ABSOLUTE_MAX_DURATION_MS,
SESSION_REMEMBER_ME_DURATION_MS,
createSessionExpiry,
} from './session';

View File

@@ -0,0 +1,19 @@
import { describe, expect, it } from "vitest";
import { hashPassword, verifyPassword } from "./password";
describe("password", () => {
it("hashes and verifies a password roundtrip", async () => {
const plain = "super-secure-password-123!";
const hashed = await hashPassword(plain);
expect(hashed).not.toBe(plain);
expect(await verifyPassword(plain, hashed)).toBe(true);
});
it("rejects an incorrect password", async () => {
const hashed = await hashPassword("correct-password");
expect(await verifyPassword("wrong-password", hashed)).toBe(false);
});
});

View File

@@ -0,0 +1,16 @@
import { hash, verify } from '@node-rs/argon2';
export async function hashPassword(password: string): Promise<string> {
return hash(password);
}
export async function verifyPassword(
plain: string,
hashed: string,
): Promise<boolean> {
try {
return await verify(hashed, plain);
} catch {
return false;
}
}

View File

@@ -0,0 +1,28 @@
import { randomBytes } from "node:crypto";
import { hashToken, verifyTokenHash } from "./tokens";
const RECOVERY_CODE_SEGMENT_LENGTH = 4;
function formatRecoveryCode(bytes: Buffer): string {
const hex = bytes.toString("hex").toUpperCase();
return `${hex.slice(0, RECOVERY_CODE_SEGMENT_LENGTH)}-${hex.slice(RECOVERY_CODE_SEGMENT_LENGTH)}`;
}
export function generateRecoveryCodes(count = 10): string[] {
return Array.from({ length: count }, () =>
formatRecoveryCode(randomBytes(RECOVERY_CODE_SEGMENT_LENGTH)),
);
}
export function hashRecoveryCode(code: string): string {
return hashToken(normalizeRecoveryCode(code));
}
export function verifyRecoveryCode(code: string, hashed: string): boolean {
return verifyTokenHash(normalizeRecoveryCode(code), hashed);
}
function normalizeRecoveryCode(code: string): string {
return code.trim().toUpperCase().replace(/\s+/g, "");
}

View File

@@ -0,0 +1,9 @@
export const SESSION_COOKIE_NAME = 'hgc_session';
export const SESSION_DURATION_MS = 7 * 24 * 60 * 60 * 1000;
export const SESSION_ABSOLUTE_MAX_DURATION_MS = 30 * 24 * 60 * 60 * 1000;
export const SESSION_REMEMBER_ME_DURATION_MS = 30 * 24 * 60 * 60 * 1000;
export function createSessionExpiry(from: Date = new Date()): Date {
return new Date(from.getTime() + SESSION_DURATION_MS);
}

View File

@@ -0,0 +1,25 @@
import { describe, expect, it } from "vitest";
import {
generateSecureToken,
hashToken,
verifyTokenHash,
} from "./tokens";
describe("tokens", () => {
it("produces consistent hashes for the same token", () => {
const token = generateSecureToken();
const firstHash = hashToken(token);
const secondHash = hashToken(token);
expect(firstHash).toBe(secondHash);
expect(verifyTokenHash(token, firstHash)).toBe(true);
});
it("rejects tokens that do not match the hash", () => {
const token = generateSecureToken();
const hashed = hashToken(token);
expect(verifyTokenHash("different-token", hashed)).toBe(false);
});
});

View File

@@ -0,0 +1,21 @@
import { createHash, randomBytes, timingSafeEqual } from 'node:crypto';
export function generateSecureToken(bytes = 32): string {
return randomBytes(bytes).toString('base64url');
}
export function hashToken(token: string): string {
return createHash('sha256').update(token).digest('hex');
}
export function verifyTokenHash(token: string, hashed: string): boolean {
const computed = hashToken(token);
const computedBuffer = Buffer.from(computed, 'hex');
const hashedBuffer = Buffer.from(hashed, 'hex');
if (computedBuffer.length !== hashedBuffer.length) {
return false;
}
return timingSafeEqual(computedBuffer, hashedBuffer);
}

View File

@@ -0,0 +1,27 @@
import { authenticator } from "otplib";
import { describe, expect, it } from "vitest";
import {
buildOtpAuthUrl,
generateTotpSecret,
verifyTotpCode,
} from "./totp";
describe("totp", () => {
it("generates a secret and verifies a valid code", () => {
const secret = generateTotpSecret();
const code = authenticator.generate(secret);
expect(secret.length).toBeGreaterThan(0);
expect(verifyTotpCode(secret, code)).toBe(true);
});
it("builds an otpauth URL", () => {
const secret = generateTotpSecret();
const url = buildOtpAuthUrl("HexaHost", "user@example.com", secret);
expect(url).toContain("otpauth://totp/");
expect(url).toContain("issuer=HexaHost");
expect(url).toContain(encodeURIComponent("user@example.com"));
});
});

21
packages/auth/src/totp.ts Normal file
View File

@@ -0,0 +1,21 @@
import { authenticator } from 'otplib';
export function generateTotpSecret(): string {
return authenticator.generateSecret();
}
export function verifyTotpCode(secret: string, code: string): boolean {
try {
return authenticator.verify({ token: code.replace(/\s+/gu, ''), secret });
} catch {
return false;
}
}
export function buildOtpAuthUrl(
issuer: string,
accountName: string,
secret: string,
): string {
return authenticator.keyuri(accountName, issuer, secret);
}

View File

@@ -0,0 +1,109 @@
export const MIN_USERNAME_LENGTH = 3;
export const MAX_USERNAME_LENGTH = 32;
const USERNAME_PATTERN = /^[a-z][a-z0-9_]*[a-z0-9]$|^[a-z]$/;
const RESERVED_USERNAMES = new Set([
"admin",
"administrator",
"api",
"auth",
"billing",
"dashboard",
"help",
"hexahost",
"login",
"logout",
"moderator",
"null",
"register",
"root",
"settings",
"support",
"system",
"undefined",
"user",
"www",
]);
export interface UsernameValidationResult {
valid: boolean;
error?: string;
}
export function sanitizeUsername(input: string): string {
const normalized = input
.trim()
.toLowerCase()
.replace(/[^a-z0-9_]+/g, "_")
.replace(/_+/g, "_")
.replace(/^_+|_+$/g, "");
if (!normalized) {
return "";
}
if (!/^[a-z]/.test(normalized)) {
return `u_${normalized}`.slice(0, MAX_USERNAME_LENGTH);
}
return normalized.slice(0, MAX_USERNAME_LENGTH);
}
export function generateUsernameFromEmail(email: string): string {
const localPart = email.trim().toLowerCase().split("@")[0] ?? "";
let username = sanitizeUsername(localPart);
if (!username) {
username = "user";
}
if (username.length < MIN_USERNAME_LENGTH) {
username = username.padEnd(MIN_USERNAME_LENGTH, "0");
}
if (username.length > MAX_USERNAME_LENGTH) {
username = username.slice(0, MAX_USERNAME_LENGTH);
}
if (!/^[a-z]/.test(username)) {
username = `u${username}`.slice(0, MAX_USERNAME_LENGTH);
}
return username;
}
export function validateUsername(username: string): UsernameValidationResult {
const normalized = username.trim().toLowerCase();
if (normalized.length < MIN_USERNAME_LENGTH) {
return {
valid: false,
error: `Username must be at least ${MIN_USERNAME_LENGTH} characters.`,
};
}
if (normalized.length > MAX_USERNAME_LENGTH) {
return {
valid: false,
error: `Username must be at most ${MAX_USERNAME_LENGTH} characters.`,
};
}
if (!USERNAME_PATTERN.test(normalized)) {
return {
valid: false,
error:
"Username must start with a letter, contain only lowercase letters, numbers, and underscores, and not end with an underscore.",
};
}
if (RESERVED_USERNAMES.has(normalized)) {
return {
valid: false,
error: "This username is reserved.",
};
}
return { valid: true };
}

View File

@@ -5,5 +5,5 @@
"rootDir": "./src"
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
"exclude": ["node_modules", "dist", "src/**/*.test.ts"]
}

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,17 @@
import path from "node:path";
import { fileURLToPath } from "node:url";
import { defineConfig } from "vitest/config";
const dirname = path.dirname(fileURLToPath(import.meta.url));
export default defineConfig({
test: {
environment: "node",
include: ["src/**/*.test.ts"],
},
resolve: {
alias: {
"@": path.resolve(dirname, "./src"),
},
},
});

View File

@@ -0,0 +1,103 @@
import { z } from 'zod';
const emailSchema = z.string().email().max(320);
const passwordSchema = z.string().min(12).max(128);
export const registerRequestSchema = z.object({
email: emailSchema,
password: passwordSchema,
username: z.string().min(3).max(32).optional(),
displayName: z.string().min(1).max(64).optional(),
});
export const loginRequestSchema = z.object({
email: emailSchema,
password: z.string().min(1).max(128),
});
export const verifyEmailRequestSchema = z.object({
token: z.string().min(1),
});
export const forgotPasswordRequestSchema = z.object({
email: emailSchema,
});
export const resetPasswordRequestSchema = z.object({
token: z.string().min(1),
newPassword: passwordSchema,
});
export const twoFactorConfirmRequestSchema = z.object({
code: z.string().min(6).max(16),
});
export const twoFactorVerifyRequestSchema = z.object({
challengeId: z.string().uuid(),
code: z.string().min(6).max(16),
});
export const twoFactorDisableRequestSchema = z.object({
password: z.string().min(1).max(128),
code: z.string().min(6).max(16),
});
export const authUserSchema = z.object({
id: z.string().uuid(),
email: z.string().email(),
username: z.string(),
displayName: z.string().nullable(),
emailVerified: z.boolean(),
roles: z.array(z.string()),
});
export const loginSuccessResponseSchema = z.object({
user: authUserSchema,
});
export const loginTwoFactorRequiredResponseSchema = z.object({
twoFactorRequired: z.literal(true),
challengeId: z.string().uuid(),
});
export const loginResponseSchema = z.union([
loginSuccessResponseSchema,
loginTwoFactorRequiredResponseSchema,
]);
export const twoFactorSetupResponseSchema = z.object({
secret: z.string(),
qrUrl: z.string(),
});
export const sessionSummarySchema = z.object({
id: z.string().uuid(),
ipAddress: z.string().nullable(),
userAgent: z.string().nullable(),
createdAt: z.string().datetime(),
expiresAt: z.string().datetime(),
current: z.boolean(),
});
export const sessionListResponseSchema = z.object({
sessions: z.array(sessionSummarySchema),
});
export const messageResponseSchema = z.object({
message: z.string(),
});
export type RegisterRequest = z.infer<typeof registerRequestSchema>;
export type LoginRequest = z.infer<typeof loginRequestSchema>;
export type VerifyEmailRequest = z.infer<typeof verifyEmailRequestSchema>;
export type ForgotPasswordRequest = z.infer<typeof forgotPasswordRequestSchema>;
export type ResetPasswordRequest = z.infer<typeof resetPasswordRequestSchema>;
export type TwoFactorConfirmRequest = z.infer<typeof twoFactorConfirmRequestSchema>;
export type TwoFactorVerifyRequest = z.infer<typeof twoFactorVerifyRequestSchema>;
export type TwoFactorDisableRequest = z.infer<typeof twoFactorDisableRequestSchema>;
export type AuthUser = z.infer<typeof authUserSchema>;
export type LoginResponse = z.infer<typeof loginResponseSchema>;
export type TwoFactorSetupResponse = z.infer<typeof twoFactorSetupResponseSchema>;
export type SessionSummary = z.infer<typeof sessionSummarySchema>;
export type SessionListResponse = z.infer<typeof sessionListResponseSchema>;
export type MessageResponse = z.infer<typeof messageResponseSchema>;

View File

@@ -21,3 +21,36 @@ export {
type PaginationQuery,
type PaginationMeta,
} from './pagination';
export {
registerRequestSchema,
loginRequestSchema,
verifyEmailRequestSchema,
forgotPasswordRequestSchema,
resetPasswordRequestSchema,
twoFactorConfirmRequestSchema,
twoFactorVerifyRequestSchema,
twoFactorDisableRequestSchema,
authUserSchema,
loginSuccessResponseSchema,
loginTwoFactorRequiredResponseSchema,
loginResponseSchema,
twoFactorSetupResponseSchema,
sessionSummarySchema,
sessionListResponseSchema,
messageResponseSchema,
type RegisterRequest,
type LoginRequest,
type VerifyEmailRequest,
type ForgotPasswordRequest,
type ResetPasswordRequest,
type TwoFactorConfirmRequest,
type TwoFactorVerifyRequest,
type TwoFactorDisableRequest,
type AuthUser,
type LoginResponse,
type TwoFactorSetupResponse,
type SessionSummary,
type SessionListResponse,
type MessageResponse,
} from './auth';

File diff suppressed because one or more lines are too long

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