feat: add first-run admin bootstrap flow and site-admin badge
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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
|
||||
`,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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.' });
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -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.' });
|
||||
|
||||
Reference in New Issue
Block a user