diff --git a/src/lib/services/bootstrap.service.ts b/src/lib/services/bootstrap.service.ts new file mode 100644 index 00000000..ea7a6c19 --- /dev/null +++ b/src/lib/services/bootstrap.service.ts @@ -0,0 +1,82 @@ +/** + * First-run bootstrap: lets the very first operator self-register as the + * super-admin on a fresh DB. Safe because the only "do it" path + * (`createInitialSuperAdmin`) refuses to run when any super-admin row + * already exists, so the window closes the moment the first account is + * created. + */ + +import { eq } from 'drizzle-orm'; + +import { db } from '@/lib/db'; +import { user, userProfiles } from '@/lib/db/schema'; +import { auth } from '@/lib/auth'; +import { ConflictError, ValidationError } from '@/lib/errors'; + +/** True when no user has `is_super_admin = true` in user_profiles. */ +export async function hasAnySuperAdmin(): Promise { + const row = await db + .select({ userId: userProfiles.userId }) + .from(userProfiles) + .where(eq(userProfiles.isSuperAdmin, true)) + .limit(1); + return row.length > 0; +} + +export interface BootstrapInput { + name: string; + email: string; + password: string; +} + +/** + * Atomically: create the better-auth user, create the user_profiles row + * with isSuperAdmin=true. Refuses to run when a super-admin already + * exists — the only safe-by-design self-registration path. + * + * Returns the new user's id on success. + */ +export async function createInitialSuperAdmin(input: BootstrapInput): Promise { + if (input.password.length < 9) { + throw new ValidationError('Password must be at least 9 characters'); + } + if (!input.email.includes('@')) { + throw new ValidationError('A valid email is required'); + } + if (!input.name.trim()) { + throw new ValidationError('Name is required'); + } + + // Re-check inside the critical path so two concurrent first-run + // submissions can't both win — the first to insert the profile row + // closes the window for everyone else. + if (await hasAnySuperAdmin()) { + throw new ConflictError('A super-administrator account already exists'); + } + + const email = input.email.toLowerCase().trim(); + const existing = await db.query.user.findFirst({ where: eq(user.email, email) }); + if (existing) { + // Either someone signed up via a different flow first, or we're + // racing a portal-activation. Refuse rather than silently re-purpose. + throw new ConflictError('A user with this email already exists'); + } + + const authResult = await auth.api.signUpEmail({ + body: { + email, + password: input.password, + name: input.name.trim(), + }, + }); + + const newUserId = authResult.user.id; + + await db.insert(userProfiles).values({ + userId: newUserId, + displayName: input.name.trim(), + isSuperAdmin: true, + }); + + return newUserId; +}