auth: single-step super-admin bootstrap (no verification email)

Collapse the two-phase bootstrap (email a verification link → verify screen) into
one direct step: the Bootstrap form collects name/email/username/password and posts
once to /bootstrap, which creates the first user directly as an active Super Admin
(+ provisions DATA_PATH/<email>). Still gated to an empty user table.

The invite flow (/verify, /verify-token) is untouched.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-25 16:11:37 +00:00
co-authored by Claude Opus 4.8
parent d604b1e722
commit 97f6d7fecc
2 changed files with 100 additions and 64 deletions
+9 -29
View File
@@ -1,48 +1,28 @@
import type { Handler } from 'hono';
import { sendMail } from 'emailer';
import { getUserCount, createUser } from 'officerdb';
import { sign, verify } from '@@/jwt';
import argon2 from 'argon2';
import * as errors from '@@/custom-errors';
import { validatePassword } from './validate-password';
import { validateUsername } from './validate-username';
import { provisionUserEnvironment } from '../users/provision';
// Single-step super-admin bootstrap: the first user is created directly as an active Super Admin, with
// no email-verification round-trip. Gated to an empty user table (registration is otherwise closed).
export const bootstrapHandler: Handler = async function (ctx) {
const body = ctx.get('body');
const origin = ctx.get('origin');
const token = body.token as string;
const email = body.email as string;
const userCount = await getUserCount();
if (userCount > 0) throw errors.FORBIDDEN('Registration is closed');
if (!token) {
if (!email || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
throw errors.BAD_REQUEST('Invalid email address');
}
const verificationCode = await sign({ email }, '24h');
const url = `${origin}/auth/verify?verificationCode=${verificationCode}`;
await sendMail({
template: 'VerifyAdmin',
subject: 'Verify your officer.dev account',
to: email,
data: { name: email, url },
});
return ctx.json({ ok: true });
}
const payload = ((await verify(token).catch(() => null)) as { email: string } | null);
if (!payload?.email) throw errors.BAD_REQUEST('Invalid or expired token');
const email = body.email as string;
const name = body.name as string;
const username = body.username as string;
const password = body.password as string;
const confirmPassword = body.confirmPassword as string;
if (!email || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
throw errors.BAD_REQUEST('Invalid email address');
}
if (!name || !name.trim()) throw errors.BAD_REQUEST('Name is required');
const validUsername = validateUsername(username);
validatePassword(password);
@@ -51,7 +31,7 @@ export const bootstrapHandler: Handler = async function (ctx) {
const passwordHash = await argon2.hash(password);
const user = await createUser({
email: payload.email,
email,
password: passwordHash,
name: name.trim(),
username: validUsername,
@@ -60,8 +40,8 @@ export const bootstrapHandler: Handler = async function (ctx) {
});
// Provision the super admin's environment (DATA_PATH/<email> + configs) on creation. This is the only
// account-creation flow for a single-user platform, and — unlike the invite/verify flow — nothing
// else runs provisioning for the first user. Fire-and-forget, mirroring verifyHandler.
// account-creation flow for a single-user platform, and nothing else provisions the first user.
// Fire-and-forget, mirroring verifyHandler.
provisionUserEnvironment(user.email, user.username ?? validUsername).catch((err) => {
console.error('[bootstrap] failed to provision super admin environment:', err);
});