fix(bootstrap): include missing bootstrap.service helper
The route handlers in 1a65e02 import hasAnySuperAdmin and
createInitialSuperAdmin from this file; was accidentally left
untracked.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
82
src/lib/services/bootstrap.service.ts
Normal file
82
src/lib/services/bootstrap.service.ts
Normal file
@@ -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<boolean> {
|
||||||
|
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<string> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user