Public Access
1
0

feat: add first-run admin bootstrap flow and site-admin badge

This commit is contained in:
pguerrerox
2026-05-25 20:20:42 +00:00
parent f5e7e966e3
commit 232342d6a1
14 changed files with 437 additions and 26 deletions
+2
View File
@@ -9,6 +9,7 @@ import { billingRoutes } from './routes/billing.js';
import { healthRoutes } from './routes/health.js';
import { searchJobRoutes } from './routes/search-jobs.js';
import { analyticsRoutes } from './routes/analytics.js';
import { adminBootstrapRoutes } from './routes/admin-bootstrap.js';
function parseAllowedOrigins(rawOrigins: string) {
return rawOrigins
@@ -56,6 +57,7 @@ export async function buildApp() {
await app.register(searchJobRoutes, { prefix: '/api' });
await app.register(deepResearchRoutes, { prefix: '/api' });
await app.register(analyticsRoutes, { prefix: '/api' });
await app.register(adminBootstrapRoutes, { prefix: '/api' });
return app;
}
+36
View File
@@ -64,6 +64,42 @@ export async function isApplicationAdmin(db: DbClient, email: string) {
return isAdminEmailAllowlisted(email);
}
export async function getActiveApplicationAdminCount(db: DbClient): Promise<number> {
const result = await db.query<{ count: string }>(
`
select count(*)::text as count
from public.application_admins
where status = 'active'
`,
);
return Number(result.rows[0]?.count ?? '0');
}
export async function isBootstrapRequired(db: DbClient): Promise<boolean> {
return (await getActiveApplicationAdminCount(db)) === 0;
}
export async function createApplicationAdmin(
db: DbClient,
input: { email: string; createdByUserId?: string | null; permissionsJson?: unknown[] },
): Promise<void> {
const normalizedEmail = normalizeEmail(input.email);
await db.query(
`
insert into public.application_admins (email, email_normalized, status, permissions_json, created_by_user_id)
values ($1, $2, 'active', $3::jsonb, $4)
on conflict (email_normalized)
do update set
email = excluded.email,
status = 'active',
updated_at = now()
`,
[input.email.trim(), normalizedEmail, JSON.stringify(input.permissionsJson ?? []), input.createdByUserId ?? null],
);
}
export async function requireAdmin(request: FastifyRequest, reply: FastifyReply) {
const user = request.authUser;
+14 -1
View File
@@ -15,6 +15,7 @@ type SessionRow = {
avatar_url: string | null;
created_at: string;
updated_at: string;
is_admin: boolean;
};
function hashSessionToken(token: string) {
@@ -37,6 +38,7 @@ function mapSessionRow(row: SessionRow): SessionUser {
avatarUrl: row.avatar_url,
createdAt: row.created_at,
updatedAt: row.updated_at,
isAdmin: row.is_admin,
};
}
@@ -103,9 +105,20 @@ export async function getSessionUserByToken(db: DbClient, token: string) {
const tokenHash = hashSessionToken(token);
const result = await db.query<SessionRow>(
`
select s.id as session_id, u.id, u.email, u.display_name, u.avatar_url, u.created_at, u.updated_at
select
s.id as session_id,
u.id,
u.email,
u.display_name,
u.avatar_url,
u.created_at,
u.updated_at,
(aa.id is not null) as is_admin
from public.sessions s
join public.users u on u.id = s.user_id
left join public.application_admins aa
on aa.email_normalized = lower(trim(u.email))
and aa.status = 'active'
where s.token_hash = $1 and s.expires_at > now()
limit 1
`,
+6
View File
@@ -40,6 +40,8 @@ const envSchema = z.object({
STRIPE_BILLING_PORTAL_CONFIGURATION_ID: z.string().optional(),
ADMIN_EMAILS: z.string().optional(),
BILLING_ADMIN_EMAILS: z.string().optional(),
ALLOW_ADMIN_BOOTSTRAP: z.coerce.boolean().default(false),
ADMIN_BOOTSTRAP_TOKEN: z.string().optional(),
});
export type AppEnv = z.infer<typeof envSchema>;
@@ -80,3 +82,7 @@ export function isBillingAdminEmail(email: string) {
return allowlist.includes(email.trim().toLowerCase());
}
export function isAdminBootstrapEnabled() {
return getEnv().ALLOW_ADMIN_BOOTSTRAP;
}
+131
View File
@@ -0,0 +1,131 @@
import type { FastifyPluginAsync, FastifyRequest } from 'fastify';
import { ZodError, z } from 'zod';
import type { AdminBootstrapClaimResponse, AdminBootstrapStatusResponse } from '../../../shared/types.js';
import { createDefaultWorkspaceForUser } from '../account/repository.js';
import { createApplicationAdmin, isApplicationAdmin, isBootstrapRequired, recordAdminAccessAudit } from '../auth/admin.js';
import { hashPassword } from '../auth/passwords.js';
import { createSession, setSessionCookie } from '../auth/sessions.js';
import { createUser, getUserByEmail, toAppUser } from '../auth/users.js';
import { getEnv, isAdminBootstrapEnabled } from '../config/env.js';
import { getDbPool } from '../db/pool.js';
const claimSchema = z.object({
email: z.string().email(),
password: z.string().min(6),
displayName: z.string().trim().min(1).max(120).optional(),
bootstrapToken: z.string().min(1),
});
class HttpError extends Error {
statusCode: number;
constructor(statusCode: number, message: string) {
super(message);
this.statusCode = statusCode;
}
}
function getRequestMetadata(request: FastifyRequest) {
return {
userAgent: request.headers['user-agent'],
ipAddress: request.ip || null,
};
}
export const adminBootstrapRoutes: FastifyPluginAsync = async (app) => {
app.get('/admin/bootstrap/status', async (_request, reply) => {
const db = getDbPool();
const response: AdminBootstrapStatusResponse = {
bootstrapRequired: await isBootstrapRequired(db),
bootstrapEnabled: isAdminBootstrapEnabled(),
};
return reply.send(response);
});
app.post('/admin/bootstrap/claim', async (request, reply) => {
try {
const payload = claimSchema.parse(request.body);
const env = getEnv();
if (!isAdminBootstrapEnabled()) {
return reply.code(403).send({ error: 'Admin bootstrap is disabled.' });
}
if (!env.ADMIN_BOOTSTRAP_TOKEN || payload.bootstrapToken !== env.ADMIN_BOOTSTRAP_TOKEN) {
return reply.code(403).send({ error: 'Invalid bootstrap token.' });
}
const db = getDbPool();
const client = await db.connect();
try {
await client.query('begin');
await client.query('lock table public.application_admins in share row exclusive mode');
if (!(await isBootstrapRequired(client))) {
throw new HttpError(409, 'Admin bootstrap is no longer required.');
}
const existingUser = await getUserByEmail(client, payload.email);
if (existingUser) {
throw new HttpError(409, 'An account with that email already exists.');
}
const passwordHash = await hashPassword(payload.password);
const user = await createUser(client, {
email: payload.email,
passwordHash,
displayName: payload.displayName,
});
await createDefaultWorkspaceForUser(client, user);
await createApplicationAdmin(client, {
email: user.email,
createdByUserId: user.id,
});
const session = await createSession(client, user.id, getRequestMetadata(request));
await recordAdminAccessAudit(client, {
actorUserId: user.id,
actorEmail: user.email,
route: '/api/admin/bootstrap/claim',
action: 'bootstrap_admin_claimed',
metadataJson: {
bootstrapRequiredAtClaim: true,
},
});
await client.query('commit');
setSessionCookie(reply, session.token, session.expiresAt);
const response: AdminBootstrapClaimResponse = {
user: { ...toAppUser(user), sessionId: session.sessionId, isAdmin: await isApplicationAdmin(client, user.email) },
};
return reply.code(201).send(response);
} catch (error) {
await client.query('rollback');
throw error;
} finally {
client.release();
}
} catch (error) {
if (error instanceof ZodError) {
return reply.code(400).send({ error: error.issues[0]?.message || 'Invalid bootstrap claim payload.' });
}
if (error instanceof HttpError) {
return reply.code(error.statusCode).send({ error: error.message });
}
if ((error as { code?: string }).code === '23505') {
return reply.code(409).send({ error: 'An account with that email already exists.' });
}
request.log.error(error);
return reply.code(500).send({ error: 'Failed to claim admin bootstrap.' });
}
});
};
+14 -4
View File
@@ -2,6 +2,7 @@ import type { FastifyPluginAsync, FastifyRequest } from 'fastify';
import { ZodError, z } from 'zod';
import { hashPassword, verifyPassword } from '../auth/passwords.js';
import { clearSessionCookie, createSession, deleteSessionById, deleteSessionByToken, getSessionTokenFromRequest, getSessionUserByToken, setSessionCookie, } from '../auth/sessions.js';
import { isApplicationAdmin } from '../auth/admin.js';
import { createUser, getUserByEmail, toAppUser } from '../auth/users.js';
import { createDefaultWorkspaceForUser } from '../account/repository.js';
import { getDbPool } from '../db/pool.js';
@@ -36,8 +37,15 @@ export const authRoutes: FastifyPluginAsync = async (app) => {
return { user: null };
}
const user = await getSessionUserByToken(getDbPool(), token);
return { user };
const db = getDbPool();
const user = await getSessionUserByToken(db, token);
if (!user) {
return { user: null };
}
const isAdmin = await isApplicationAdmin(db, user.email);
return { user: { ...user, isAdmin } };
});
app.post('/auth/signup', async (request, reply) => {
@@ -73,8 +81,9 @@ export const authRoutes: FastifyPluginAsync = async (app) => {
}
setSessionCookie(reply, session.token, session.expiresAt);
const isAdmin = await isApplicationAdmin(db, user.email);
return reply.code(201).send({ user: { ...toAppUser(user), sessionId: session.sessionId } });
return reply.code(201).send({ user: { ...toAppUser(user), sessionId: session.sessionId, isAdmin } });
} catch (error) {
if (error instanceof ZodError) {
return reply.code(400).send({ error: error.issues[0]?.message || 'Invalid signup payload.' });
@@ -106,8 +115,9 @@ export const authRoutes: FastifyPluginAsync = async (app) => {
const session = await createSession(db, user.id, getRequestMetadata(request));
setSessionCookie(reply, session.token, session.expiresAt);
const isAdmin = await isApplicationAdmin(db, user.email);
return { user: { ...toAppUser(user), sessionId: session.sessionId } };
return { user: { ...toAppUser(user), sessionId: session.sessionId, isAdmin } };
} catch (error) {
if (error instanceof ZodError) {
return reply.code(400).send({ error: error.issues[0]?.message || 'Invalid login payload.' });