admin.ts1794 lines · main
1import { Hono, type Context } from 'hono';
2import { z } from 'zod';
3
4import { env } from '../env.js';
5import { requireAdmin } from '../middleware/admin.js';
6import { requireAuth } from '../middleware/session.js';
7import { requireRecentMfa } from '../middleware/step-up.js';
8import type { AppEnv } from '../types/app-env.js';
9import {
10 ABUSE_RESOLUTION,
11 listAbuseReports,
12 resolveAbuseReport,
13 suspendProject,
14 triageAbuseReport,
15 unsuspendProject,
16 type AbuseStatus,
17} from '../services/abuse.js';
18import {
19 adminStats,
20 deleteUserAsAdmin,
21 forceSignOut,
22 getProjectForAdmin,
23 grantAdmin,
24 listProjects,
25 listUsers,
26 revokeAdmin,
27 setProjectsTierForOwner,
28 setProjectTier,
29 suspendUser,
30 unsuspendUser,
31} from '../services/admin.js';
32import { invalidateTierCache } from '../services/tiers.js';
33import { audit, hashIp, listAuditByActionPrefix } from '../services/audit.js';
34import { createImpersonationSession } from '../services/auth-impersonate.js';
35import { listDeploys } from '../services/deploy-history.js';
36import { getHealthSummary } from '../services/platform-health.js';
37import { getAdminOverview } from '../services/admin-overview.js';
38import { getUserDeepDetailForAdmin } from '../services/admin-user-detail.js';
39import {
40 createIncident,
41 listIncidents,
42 resolveIncident,
43 updateIncident,
44} from '../services/incidents.js';
45import {
46 sendContactReply,
47 sendMigrationStatusUpdate,
48 sendTicketReply,
49 sendTicketStatusUpdate,
50} from '../lib/email.js';
51import { translateConvexSchema } from '../services/convex-schema-translator.js';
52import { getMarketingFunnel } from '../services/marketing-events.js';
53import { getSignupGeoSummary } from '../services/signup-geo-admin.js';
54import { getEmailAdminSummary } from '../services/email-admin.js';
55import { log } from '../lib/logger.js';
56import {
57 getMigrationRequest,
58 listMigrationRequestsForAdmin,
59 promoteMigrationRequestToUser,
60 updateMigrationRequest,
61} from '../services/migration-requests.js';
62import { fetchRealtimeStats } from '../services/realtime-stats.js';
63import { listUsageEvents, retrySkippedUsageEvents } from '../services/usage-admin.js';
64import {
65 getProjectEnforcement,
66 getTierStorageCaps,
67 listObjectStorageUsage,
68 listStorageUsage,
69 setProjectEnforcement,
70 setProjectStorageLimit,
71 updateTierStorageCap,
72} from '../services/storage-admin.js';
73import { restoreFile } from '../services/storage.js';
74import { listSuppressions, suppress, unsuppress } from '../services/suppressions.js';
75import { incidentSeverity, ticketStatuses } from '../db/schema.js';
76import {
77 addContactReply,
78 addReply as addTicketReply,
79 getContactMessageForAdmin,
80 getTicketByIdForAdmin,
81 listContactMessages,
82 listTicketsForAdmin,
83 renderTicketNumber,
84 updateContactMessage,
85 updateTicket,
86 type ContactMessageFilter,
87 type UpdateTicketInput,
88} from '../services/support-tickets.js';
89import { NotFoundError, ValidationError } from '@briven/shared';
90import {
91 checkProjectDbHealth,
92 dropProjectDatabase,
93 evictProjectPool,
94 provisionProjectDatabase,
95} from '../db/data-plane.js';
96import { createSnapshot, listSnapshots, restoreSnapshot } from '../services/snapshots.js';
97
98const userActionSchema = z.object({ userId: z.string().min(1) });
99
100function ipHash(c: Context<AppEnv>): string | null {
101 const fwd = c.req.raw.headers.get('x-forwarded-for');
102 const ip = fwd ? fwd.split(',')[0]!.trim() : null;
103 return hashIp(ip);
104}
105
106export const adminRouter = new Hono<AppEnv>();
107
108adminRouter.use('/v1/admin/*', requireAuth());
109adminRouter.use('/v1/admin/*', requireAdmin());
110// Every admin MUTATION requires fresh step-up auth per CLAUDE.md §5.4.
111// Reads pass through so the dashboard can render without re-prompting on
112// every page navigation; writes (POST/PATCH/DELETE/PUT) check
113// users.last_mfa_at and return 403 step_up_required when stale. The
114// dashboard surfaces an inline password prompt to refresh in-place.
115const mfa = requireRecentMfa(10);
116adminRouter.use('/v1/admin/*', async (c, next) => {
117 const method = c.req.method;
118 if (method === 'GET' || method === 'HEAD' || method === 'OPTIONS') {
119 return next();
120 }
121 return mfa(c, next);
122});
123
124adminRouter.get('/v1/admin/stats', async (c) => c.json(await adminStats()));
125
126/**
127 * Admin landing overview — one fan-out of REAL platform signals. Contract with
128 * apps/web/(admin)/admin/overview-client.tsx (the Overview type). Billing
129 * subscribers/mrr/churn are null while Mavi Pay is not connected (honest, not
130 * fabricated); planMix + counts + deploys + health + open incidents are live.
131 */
132adminRouter.get('/v1/admin/overview', async (c) => c.json(await getAdminOverview()));
133
134/**
135 * Platform health — checks + host metrics, literally getHealthSummary(). The
136 * web health page consumes { checks, host }. host is null when Prometheus is
137 * unset/unreachable.
138 */
139adminRouter.get('/v1/admin/health', async (c) => c.json(await getHealthSummary()));
140
141/**
142 * S6 — Auth reliability snapshot (rate-limit pressure, mailer fails, redis).
143 * Admin-only; powers cockpit ops and post-incident review.
144 */
145adminRouter.get('/v1/admin/auth-reliability', async (c) => {
146 const { getAuthReliabilitySnapshot } = await import('../services/auth-reliability.js');
147 return c.json(await getAuthReliabilitySnapshot());
148});
149
150adminRouter.get('/v1/admin/users', async (c) => {
151 const rows = await listUsers(200);
152 return c.json({ users: rows });
153});
154
155adminRouter.get('/v1/admin/users/:id', async (c) => {
156 const userId = c.req.param('id');
157 try {
158 // Deep per-user detail: profile + company + geo-resolved last sign-in +
159 // per-project storage usage + totals + audit activity. Contract mirrors
160 // apps/web/(admin)/admin/users/[id] (Overview `user`/`projects`/`totals`/
161 // `activity` shape). Real numbers or null — never fabricated.
162 const detail = await getUserDeepDetailForAdmin(userId);
163 return c.json(detail);
164 } catch (err) {
165 if (err instanceof Error && /not found/i.test(err.message)) {
166 return c.json({ code: 'not_found' }, 404);
167 }
168 throw err;
169 }
170});
171
172adminRouter.get('/v1/admin/projects', async (c) => {
173 const rows = await listProjects(500);
174 return c.json({ projects: rows });
175});
176
177/**
178 * Single-project admin drill-down. Contract with apps/web/(admin)/admin/
179 * projects/[id]. Returns the control-plane project row incl. tier + suspend/
180 * delete state so the detail page can render the same badges the users page
181 * does. 404s cleanly when no project matches so the page shows a not-found
182 * state rather than an error.
183 */
184adminRouter.get('/v1/admin/projects/:id', async (c) => {
185 const project = await getProjectForAdmin(c.req.param('id'));
186 if (!project) return c.json({ code: 'not_found' }, 404);
187 return c.json({
188 project: {
189 id: project.id,
190 slug: project.slug,
191 name: project.name,
192 ownerId: project.orgId,
193 tier: project.tier,
194 suspendedAt: project.suspendedAt ? project.suspendedAt.toISOString() : null,
195 suspendReason: project.suspendReason,
196 deletedAt: project.deletedAt ? project.deletedAt.toISOString() : null,
197 createdAt: project.createdAt.toISOString(),
198 },
199 });
200});
201
202/**
203 * Storage admin (Sprint 4) — per-project table + row counts. Bytes are
204 * unmeasurable on DoltGres, so usage is reported as tables + rows. Phase 1 is
205 * read-only; limits + flagging land in Phase 2.
206 */
207adminRouter.get('/v1/admin/storage', async (c) => {
208 const usage = await listStorageUsage();
209 return c.json({ usage });
210});
211
212/**
213 * Object-storage (S3/file) usage mirror — read-only. Additive to the DoltGres
214 * row/table view above: per live project, sum of non-deleted file bytes vs its
215 * tier byte cap, recovery window, and active storage-key count.
216 */
217adminRouter.get('/v1/admin/storage/object', async (c) => {
218 const usage = await listObjectStorageUsage();
219 return c.json({ usage });
220});
221
222adminRouter.get('/v1/admin/storage/tier-caps', async (c) => {
223 const caps = await getTierStorageCaps();
224 return c.json({ caps });
225});
226
227const tierParam = z.enum(['free', 'pro', 'team']);
228const capBody = z.object({
229 maxRows: z.number().int().nonnegative(),
230 maxTables: z.number().int().nonnegative(),
231});
232
233/** Edit a tier's storage caps (takes effect immediately, no redeploy). */
234adminRouter.patch('/v1/admin/storage/tier-caps/:tier', async (c) => {
235 const parsedTier = tierParam.safeParse(c.req.param('tier'));
236 if (!parsedTier.success) throw new ValidationError('unknown tier');
237 const body = capBody.safeParse(await c.req.json().catch(() => null));
238 if (!body.success) throw new ValidationError('expected { maxRows, maxTables } as whole numbers');
239 const user = c.get('user');
240 await updateTierStorageCap(parsedTier.data, body.data, user?.id ?? null);
241 await audit({
242 actorId: user?.id ?? null,
243 projectId: null,
244 action: 'admin.storage.tier_cap.update',
245 ipHash: ipHash(c),
246 userAgent: c.req.header('user-agent') ?? null,
247 metadata: { tier: parsedTier.data, ...body.data },
248 });
249 return c.json({ ok: true });
250});
251
252const projectLimitBody = z.object({
253 maxRows: z.number().int().nonnegative().nullable(),
254 maxTables: z.number().int().nonnegative().nullable(),
255});
256
257/** Set or clear a single project's storage override (null = inherit tier cap). */
258adminRouter.patch('/v1/admin/storage/projects/:id', async (c) => {
259 const projectId = c.req.param('id');
260 const body = projectLimitBody.safeParse(await c.req.json().catch(() => null));
261 if (!body.success) {
262 throw new ValidationError('expected { maxRows, maxTables } (whole numbers or null)');
263 }
264 const user = c.get('user');
265 await setProjectStorageLimit(projectId, body.data, user?.id ?? null);
266 await audit({
267 actorId: user?.id ?? null,
268 projectId,
269 action: 'admin.storage.project_limit.set',
270 ipHash: ipHash(c),
271 userAgent: c.req.header('user-agent') ?? null,
272 metadata: { ...body.data },
273 });
274 return c.json({ ok: true });
275});
276
277/**
278 * Flip a single project's storage enforcement mode (flag ⇄ block). 'flag'
279 * (default) only surfaces over-limit; 'block' refuses new rows/tables/uploads
280 * once the project is over its effective cap. Body matches the admin web form
281 * (enforcement-form.tsx): { enforcement: 'flag' | 'block' }. Audited old→new.
282 */
283const enforcementBody = z.object({ enforcement: z.enum(['flag', 'block']) });
284
285adminRouter.patch('/v1/admin/storage/projects/:id/enforcement', async (c) => {
286 const projectId = c.req.param('id');
287 const body = enforcementBody.safeParse(await c.req.json().catch(() => null));
288 if (!body.success) {
289 throw new ValidationError("expected { enforcement: 'flag' | 'block' }");
290 }
291 const user = c.get('user');
292 const from = await getProjectEnforcement(projectId);
293 await setProjectEnforcement(projectId, body.data.enforcement, user?.id ?? null);
294 await audit({
295 actorId: user?.id ?? null,
296 projectId,
297 action: 'admin.storage.enforcement.update',
298 ipHash: ipHash(c),
299 userAgent: c.req.header('user-agent') ?? null,
300 metadata: { from, to: body.data.enforcement },
301 });
302 return c.json({ enforcement: body.data.enforcement });
303});
304
305/** Recover a soft-deleted file on the customer's behalf (support request). */
306adminRouter.post('/v1/admin/storage/projects/:id/files/:fileId/restore', async (c) => {
307 const projectId = c.req.param('id');
308 const fileId = c.req.param('fileId');
309 const user = c.get('user');
310 const result = await restoreFile(fileId, projectId);
311 await audit({
312 actorId: user?.id ?? null,
313 projectId,
314 action: 'admin.storage.file.restore',
315 ipHash: ipHash(c),
316 userAgent: c.req.header('user-agent') ?? null,
317 metadata: { fileId, status: result.status },
318 });
319 return c.json(result);
320});
321
322/**
323 * Mittera email events — pulled from audit_logs filtered to the
324 * `mittera.email.*` action prefix. Returns the most recent 200 with a
325 * compact shape the dashboard renders without further normalisation.
326 */
327adminRouter.get('/v1/admin/email-events', async (c) => {
328 // Match both shapes during the audit-log changeover: the old
329 // double-prefixed `mittera.email.*` rows (from before 2026-05-10
330 // 22:00 UTC) and the new single-prefixed `mittera.*` rows.
331 const rows = await listAuditByActionPrefix('mittera.', 200);
332 const events = rows.map((r) => ({
333 id: r.id,
334 // Strip whichever prefix actually fired.
335 eventType: r.action.replace(/^mittera\.(email\.)?/, ''),
336 messageId:
337 r.metadata && typeof r.metadata.messageId === 'string' ? r.metadata.messageId : null,
338 // Only present on .sent rows; redacted at write time in lib/email.ts.
339 recipientRedacted:
340 r.metadata && typeof r.metadata.recipientRedacted === 'string'
341 ? r.metadata.recipientRedacted
342 : null,
343 bounceCode:
344 r.metadata && typeof r.metadata.bounceCode === 'string' ? r.metadata.bounceCode : null,
345 bounceMessage:
346 r.metadata && typeof r.metadata.bounceMessage === 'string'
347 ? r.metadata.bounceMessage
348 : null,
349 complaintReason:
350 r.metadata && typeof r.metadata.complaintReason === 'string'
351 ? r.metadata.complaintReason
352 : null,
353 deliveredAt:
354 r.metadata && typeof r.metadata.deliveredAt === 'string' ? r.metadata.deliveredAt : null,
355 createdAt: r.createdAt,
356 }));
357 return c.json({ events });
358});
359
360/**
361 * Suppression list — emails we won't send to. Populated by the mittera
362 * webhook on permanent bounces, complaints, and mittera-side
363 * suppressions. Operator can also suppress / unsuppress manually.
364 */
365adminRouter.get('/v1/admin/email-suppressions', async (c) => {
366 const rows = await listSuppressions(500);
367 return c.json({ suppressions: rows });
368});
369
370/**
371 * Email overview — deliverability + volume roll-up for the admin email
372 * events page. Literally getEmailAdminSummary(); shape mirrors what
373 * apps/web/(admin)/admin/email-events consumes.
374 */
375adminRouter.get('/v1/admin/email-overview', async (c) => c.json(await getEmailAdminSummary()));
376
377/**
378 * Deploy history — last N api/realtime/runtime boots. Paired with the
379 * `/info.buildSha` endpoint: /info answers "what's running RIGHT NOW",
380 * this answers "what happened when". Dashboard renders a compact
381 * timeline so an operator can connect "the bug appeared at 14:32" with
382 * "deploy abc1234 went live at 14:30".
383 */
384adminRouter.get('/v1/admin/deploys', async (c) => {
385 const service = c.req.query('service') ?? undefined;
386 const limitRaw = c.req.query('limit');
387 const limit = limitRaw ? Number.parseInt(limitRaw, 10) : 50;
388 const rows = await listDeploys({ service, limit: Number.isFinite(limit) ? limit : 50 });
389 return c.json({
390 deploys: rows.map((r) => ({
391 id: r.id,
392 service: r.service,
393 buildSha: r.buildSha,
394 buildAt: r.buildAt,
395 env: r.env,
396 bootedAt: r.bootedAt,
397 })),
398 });
399});
400
401/**
402 * Usage events — the rows the hourly aggregator writes. Drives the admin
403 * "Usage" page so an operator can verify the cron is running and inspect
404 * what the Polar push worker is about to send.
405 */
406adminRouter.get('/v1/admin/usage-events', async (c) => {
407 const limitRaw = c.req.query('limit');
408 const limit = Math.min(
409 Math.max(limitRaw ? Number.parseInt(limitRaw, 10) : 200, 1),
410 1000,
411 );
412 const status = c.req.query('status');
413 const rows = await listUsageEvents({ limit, status });
414 return c.json({ events: rows });
415});
416
417const retrySkippedSchema = z.object({
418 // Window in days. 1-90. Operator-supplied so the same endpoint
419 // serves both "I just fixed the meter id, retry the last hour" and
420 // "we discovered a month-long config gap during reconciliation".
421 sinceDays: z.coerce.number().int().min(1).max(90).default(7),
422});
423
424adminRouter.post('/v1/admin/usage-events/retry-skipped', async (c) => {
425 const actor = c.get('user')!;
426 const body = await c.req.json().catch(() => ({}));
427 const parsed = retrySkippedSchema.safeParse(body);
428 if (!parsed.success) {
429 return c.json({ code: 'validation_failed', issues: parsed.error.issues }, 400);
430 }
431 const result = await retrySkippedUsageEvents({ sinceDays: parsed.data.sinceDays });
432 await audit({
433 actorId: actor.id,
434 projectId: null,
435 action: 'admin.usage_events.retry_skipped',
436 ipHash: ipHash(c),
437 userAgent: c.req.header('user-agent') ?? null,
438 metadata: { sinceDays: parsed.data.sinceDays, retried: result.retried },
439 });
440 return c.json({ retried: result.retried, sinceDays: parsed.data.sinceDays });
441});
442
443const suppressActionSchema = z.object({
444 email: z.string().email(),
445 reason: z.enum(['permanent_bounce', 'complaint', 'mittera_suppressed', 'manual']).optional(),
446 detail: z.string().max(240).optional(),
447});
448
449adminRouter.post('/v1/admin/email-suppressions', async (c) => {
450 const actor = c.get('user')!;
451 const body = await c.req.json().catch(() => null);
452 const parsed = suppressActionSchema.safeParse(body);
453 if (!parsed.success) {
454 return c.json({ code: 'validation_failed', issues: parsed.error.issues }, 400);
455 }
456 const row = await suppress({
457 email: parsed.data.email,
458 reason: parsed.data.reason ?? 'manual',
459 detail: parsed.data.detail ?? null,
460 });
461 await audit({
462 actorId: actor.id,
463 projectId: null,
464 action: 'admin.email.suppress',
465 ipHash: ipHash(c),
466 userAgent: c.req.header('user-agent') ?? null,
467 metadata: { email: parsed.data.email, reason: parsed.data.reason ?? 'manual' },
468 });
469 return c.json({ suppressed: parsed.data.email, created: Boolean(row) });
470});
471
472adminRouter.delete('/v1/admin/email-suppressions/:email', async (c) => {
473 const actor = c.get('user')!;
474 const email = decodeURIComponent(c.req.param('email'));
475 const removed = await unsuppress(email);
476 await audit({
477 actorId: actor.id,
478 projectId: null,
479 action: 'admin.email.unsuppress',
480 ipHash: ipHash(c),
481 userAgent: c.req.header('user-agent') ?? null,
482 metadata: { email },
483 });
484 return c.json({ unsuppressed: email, removed });
485});
486
487async function parseUserAction(c: Context<AppEnv>) {
488 const body = await c.req.json().catch(() => null);
489 const parsed = userActionSchema.safeParse(body);
490 if (!parsed.success) {
491 return { ok: false as const, error: parsed.error.issues };
492 }
493 return { ok: true as const, userId: parsed.data.userId };
494}
495
496adminRouter.post('/v1/admin/users/suspend', async (c) => {
497 const actor = c.get('user')!;
498 const parsed = await parseUserAction(c);
499 if (!parsed.ok) return c.json({ code: 'validation_failed', issues: parsed.error }, 400);
500 await suspendUser(parsed.userId);
501 await audit({
502 actorId: actor.id,
503 projectId: null,
504 action: 'admin.user.suspend',
505 ipHash: ipHash(c),
506 userAgent: c.req.header('user-agent') ?? null,
507 metadata: { userId: parsed.userId },
508 });
509 return c.json({ suspended: parsed.userId });
510});
511
512adminRouter.post('/v1/admin/users/unsuspend', async (c) => {
513 const actor = c.get('user')!;
514 const parsed = await parseUserAction(c);
515 if (!parsed.ok) return c.json({ code: 'validation_failed', issues: parsed.error }, 400);
516 await unsuspendUser(parsed.userId);
517 await audit({
518 actorId: actor.id,
519 projectId: null,
520 action: 'admin.user.unsuspend',
521 ipHash: ipHash(c),
522 userAgent: c.req.header('user-agent') ?? null,
523 metadata: { userId: parsed.userId },
524 });
525 return c.json({ unsuspended: parsed.userId });
526});
527
528adminRouter.post('/v1/admin/users/delete', async (c) => {
529 const actor = c.get('user')!;
530 const parsed = await parseUserAction(c);
531 if (!parsed.ok) return c.json({ code: 'validation_failed', issues: parsed.error }, 400);
532 try {
533 // Guarded in the service: only a suspended, non-admin account can be
534 // deleted (GDPR soft-delete, 30-day reversible).
535 await deleteUserAsAdmin(parsed.userId);
536 } catch (err) {
537 if (err instanceof ValidationError) {
538 return c.json({ code: 'validation_failed', message: err.message }, 400);
539 }
540 if (err instanceof NotFoundError) return c.json({ code: 'not_found' }, 404);
541 throw err;
542 }
543 await audit({
544 actorId: actor.id,
545 projectId: null,
546 action: 'admin.user.delete',
547 ipHash: ipHash(c),
548 userAgent: c.req.header('user-agent') ?? null,
549 metadata: { userId: parsed.userId },
550 });
551 return c.json({ deleted: parsed.userId });
552});
553
554adminRouter.post('/v1/admin/users/force-sign-out', async (c) => {
555 const actor = c.get('user')!;
556 const parsed = await parseUserAction(c);
557 if (!parsed.ok) return c.json({ code: 'validation_failed', issues: parsed.error }, 400);
558 const n = await forceSignOut(parsed.userId);
559 await audit({
560 actorId: actor.id,
561 projectId: null,
562 action: 'admin.user.force_sign_out',
563 ipHash: ipHash(c),
564 userAgent: c.req.header('user-agent') ?? null,
565 metadata: { userId: parsed.userId, sessions: n },
566 });
567 return c.json({ userId: parsed.userId, sessions: n });
568});
569
570/**
571 * Admin impersonation — create a short-lived session for a target user
572 * in a customer project's tenant. Returns a session token the admin
573 * dashboard can set as a cookie to act on the user's behalf.
574 * Requires superadmin + recent MFA step-up.
575 */
576adminRouter.post('/v1/admin/projects/:projectId/auth/impersonate', async (c) => {
577 const actor = c.get('user')!;
578 const projectId = c.req.param('projectId');
579 const body = await c.req.json().catch(() => ({}));
580 const targetUserId = (body as Record<string, unknown>).userId;
581 if (!projectId || typeof targetUserId !== 'string') {
582 return c.json({ code: 'validation_failed', message: 'missing projectId or userId' }, 400);
583 }
584 const { sessionToken, expiresAt } = await createImpersonationSession(projectId, targetUserId, actor.id);
585 await audit({
586 actorId: actor.id,
587 projectId,
588 action: 'admin.auth.impersonate',
589 ipHash: ipHash(c),
590 userAgent: c.req.header('user-agent') ?? null,
591 metadata: { targetUserId },
592 });
593 return c.json({ sessionToken, expiresAt: expiresAt.toISOString() });
594});
595
596adminRouter.post('/v1/admin/users/grant-admin', async (c) => {
597 const actor = c.get('user')!;
598 const parsed = await parseUserAction(c);
599 if (!parsed.ok) return c.json({ code: 'validation_failed', issues: parsed.error }, 400);
600 await grantAdmin(parsed.userId);
601 await audit({
602 actorId: actor.id,
603 projectId: null,
604 action: 'admin.user.grant_admin',
605 ipHash: ipHash(c),
606 userAgent: c.req.header('user-agent') ?? null,
607 metadata: { userId: parsed.userId },
608 });
609 return c.json({ userId: parsed.userId, isAdmin: true });
610});
611
612adminRouter.post('/v1/admin/users/revoke-admin', async (c) => {
613 const actor = c.get('user')!;
614 const parsed = await parseUserAction(c);
615 if (!parsed.ok) return c.json({ code: 'validation_failed', issues: parsed.error }, 400);
616 await revokeAdmin(parsed.userId);
617 await audit({
618 actorId: actor.id,
619 projectId: null,
620 action: 'admin.user.revoke_admin',
621 ipHash: ipHash(c),
622 userAgent: c.req.header('user-agent') ?? null,
623 metadata: { userId: parsed.userId },
624 });
625 return c.json({ userId: parsed.userId, isAdmin: false });
626});
627
628/* ─── abuse-report triage ───────────────────────────────────────────── */
629
630const abuseStatusQuery = z.enum(['open', 'triaged', 'resolved']);
631const abuseTransitionSchema = z.discriminatedUnion('action', [
632 z.object({
633 action: z.literal('triage'),
634 notes: z.string().max(2000).optional(),
635 }),
636 z.object({
637 action: z.literal('resolve'),
638 resolution: z.enum(ABUSE_RESOLUTION),
639 notes: z.string().max(2000).optional(),
640 // When resolution is 'suspended' or 'banned' AND a projectId is
641 // provided, the resolve path auto-flips projects.suspended_at. For
642 // 'no_action' / 'warned' this field is ignored.
643 projectId: z.string().min(1).optional(),
644 }),
645]);
646
647adminRouter.get('/v1/admin/abuse-reports', async (c) => {
648 const statusParam = c.req.query('status');
649 let status: AbuseStatus | undefined;
650 if (statusParam) {
651 const parsed = abuseStatusQuery.safeParse(statusParam);
652 if (!parsed.success) {
653 return c.json({ code: 'validation_failed', message: 'invalid status' }, 400);
654 }
655 status = parsed.data;
656 }
657 const limit = Number(c.req.query('limit') ?? '100');
658 const reports = await listAbuseReports({ status, limit: Number.isFinite(limit) ? limit : 100 });
659 return c.json({ reports });
660});
661
662adminRouter.patch('/v1/admin/abuse-reports/:reportId', async (c) => {
663 const actor = c.get('user')!;
664 const reportId = c.req.param('reportId');
665 if (!reportId) {
666 return c.json({ code: 'validation_failed', message: 'reportId required' }, 400);
667 }
668 const body = await c.req.json().catch(() => null);
669 const parsed = abuseTransitionSchema.safeParse(body);
670 if (!parsed.success) {
671 return c.json(
672 { code: 'validation_failed', message: 'invalid request body', issues: parsed.error.issues },
673 400,
674 );
675 }
676 if (parsed.data.action === 'triage') {
677 await triageAbuseReport({
678 reportId,
679 triagerId: actor.id,
680 notes: parsed.data.notes,
681 ipHash: ipHash(c),
682 userAgent: c.req.header('user-agent') ?? null,
683 });
684 return c.json({ reportId, status: 'triaged' });
685 }
686 await resolveAbuseReport({
687 reportId,
688 resolverId: actor.id,
689 resolution: parsed.data.resolution,
690 notes: parsed.data.notes,
691 projectId: parsed.data.projectId,
692 ipHash: ipHash(c),
693 userAgent: c.req.header('user-agent') ?? null,
694 });
695 return c.json({
696 reportId,
697 status: 'resolved',
698 resolution: parsed.data.resolution,
699 projectSuspended:
700 (parsed.data.resolution === 'suspended' || parsed.data.resolution === 'banned') &&
701 Boolean(parsed.data.projectId),
702 });
703});
704
705/* ─── admin: manual project suspend / unsuspend ─────────────────────── */
706const projectSuspensionSchema = z.object({
707 projectId: z.string().min(1),
708 reason: z.string().min(1).max(500).optional(),
709});
710
711adminRouter.post('/v1/admin/projects/suspend', async (c) => {
712 const actor = c.get('user')!;
713 const body = await c.req.json().catch(() => null);
714 const parsed = projectSuspensionSchema.safeParse(body);
715 if (!parsed.success) {
716 return c.json({ code: 'validation_failed', issues: parsed.error.issues }, 400);
717 }
718 const ok = await suspendProject({
719 projectId: parsed.data.projectId,
720 actorId: actor.id,
721 reason: parsed.data.reason ?? 'admin_action',
722 ipHash: ipHash(c),
723 userAgent: c.req.header('user-agent') ?? null,
724 });
725 return c.json({ projectId: parsed.data.projectId, suspended: ok });
726});
727
728adminRouter.post('/v1/admin/projects/unsuspend', async (c) => {
729 const actor = c.get('user')!;
730 const body = await c.req.json().catch(() => null);
731 const parsed = z.object({ projectId: z.string().min(1) }).safeParse(body);
732 if (!parsed.success) {
733 return c.json({ code: 'validation_failed', issues: parsed.error.issues }, 400);
734 }
735 const ok = await unsuspendProject({
736 projectId: parsed.data.projectId,
737 actorId: actor.id,
738 ipHash: ipHash(c),
739 userAgent: c.req.header('user-agent') ?? null,
740 });
741 return c.json({ projectId: parsed.data.projectId, unsuspended: ok });
742});
743
744/* ─── admin: project plan-tier override ─────────────────────────────── */
745
746const projectTierBody = z.object({ tier: tierParam });
747
748/**
749 * Superadmin override of a single project's plan tier. Writes
750 * control-plane projects.tier, drops the in-process tier cache so the new
751 * limits apply on the very next request (not after the 60s TTL), and audits
752 * old→new. Used by the admin project-detail tier control.
753 */
754adminRouter.post('/v1/admin/projects/:id/tier', async (c) => {
755 const actor = c.get('user')!;
756 const projectId = c.req.param('id');
757 const parsed = projectTierBody.safeParse(await c.req.json().catch(() => null));
758 if (!parsed.success) {
759 return c.json({ code: 'validation_failed', issues: parsed.error.issues }, 400);
760 }
761 const result = await setProjectTier(projectId, parsed.data.tier);
762 if (!result) return c.json({ code: 'not_found' }, 404);
763 invalidateTierCache(projectId);
764 await audit({
765 actorId: actor.id,
766 projectId,
767 action: 'admin.project.tier.update',
768 ipHash: ipHash(c),
769 userAgent: c.req.header('user-agent') ?? null,
770 metadata: { from: result.previousTier, to: parsed.data.tier },
771 });
772 return c.json({ projectId, tier: parsed.data.tier });
773});
774
775/**
776 * Bulk set the plan tier for EVERY non-deleted project the CALLING admin
777 * owns — powers the "set all my projects to pro" button. Ownership is
778 * scoped strictly in the service to orgs the admin created
779 * (organizations.created_by = actor.id), so it can never touch another
780 * user's projects. Invalidates each changed project's tier cache and audits
781 * once with { tier, count }.
782 */
783adminRouter.post('/v1/admin/projects/set-mine-tier', async (c) => {
784 const actor = c.get('user')!;
785 const parsed = projectTierBody.safeParse(await c.req.json().catch(() => null));
786 if (!parsed.success) {
787 return c.json({ code: 'validation_failed', issues: parsed.error.issues }, 400);
788 }
789 const { changedProjectIds } = await setProjectsTierForOwner(actor.id, parsed.data.tier);
790 for (const id of changedProjectIds) invalidateTierCache(id);
791 await audit({
792 actorId: actor.id,
793 projectId: null,
794 action: 'admin.project.tier.bulk_update',
795 ipHash: ipHash(c),
796 userAgent: c.req.header('user-agent') ?? null,
797 metadata: { tier: parsed.data.tier, count: changedProjectIds.length },
798 });
799 return c.json({ updated: changedProjectIds.length });
800});
801
802/* ─── admin: per-project database controls ──────────────────────────── */
803
804/**
805 * Per-project database health probe — reachability, latency, user-table
806 * count, and the Dolt HEAD commit. Fail-soft in the service (never throws;
807 * failures land in health.error), so this route always answers 200 for a
808 * project that exists. Contract with the admin project-detail database card.
809 */
810adminRouter.get('/v1/admin/projects/:id/database/health', async (c) => {
811 const projectId = c.req.param('id');
812 const project = await getProjectForAdmin(projectId);
813 if (!project) return c.json({ code: 'not_found' }, 404);
814 return c.json({ health: await checkProjectDbHealth(projectId) });
815});
816
817/** List a project's snapshots (Dolt tags), newest first. */
818adminRouter.get('/v1/admin/projects/:id/database/snapshots', async (c) => {
819 const projectId = c.req.param('id');
820 const project = await getProjectForAdmin(projectId);
821 if (!project) return c.json({ code: 'not_found' }, 404);
822 return c.json({ snapshots: await listSnapshots(projectId) });
823});
824
825/**
826 * Restart a project's database connections: evict the cached pool so the
827 * very next query opens a fresh one with a fresh auth handshake. Clears the
828 * stuck-connection / stale-auth class of incidents without touching any
829 * data. Returns the post-restart health so the UI can confirm in one trip.
830 */
831adminRouter.post('/v1/admin/projects/:id/database/restart', async (c) => {
832 const actor = c.get('user')!;
833 const projectId = c.req.param('id');
834 const project = await getProjectForAdmin(projectId);
835 if (!project) return c.json({ code: 'not_found' }, 404);
836 await evictProjectPool(projectId);
837 const health = await checkProjectDbHealth(projectId);
838 await audit({
839 actorId: actor.id,
840 projectId,
841 action: 'admin.project.database.restart',
842 ipHash: ipHash(c),
843 userAgent: c.req.header('user-agent') ?? null,
844 metadata: { reachable: health.reachable },
845 });
846 return c.json({ restarted: true, health });
847});
848
849const databaseRecoverBody = z.object({ snapshotId: z.string().min(1) });
850
851/**
852 * Recover a project's database to a snapshot. Always takes a fresh manual
853 * safety snapshot FIRST (so the recover itself is reversible), then hard-
854 * resets to the target snapshot and evicts the pool so no connection keeps
855 * serving pre-recover state. Audited with both snapshot ids.
856 */
857adminRouter.post('/v1/admin/projects/:id/database/recover', async (c) => {
858 const actor = c.get('user')!;
859 const projectId = c.req.param('id');
860 const parsed = databaseRecoverBody.safeParse(await c.req.json().catch(() => null));
861 if (!parsed.success) {
862 return c.json({ code: 'validation_failed', issues: parsed.error.issues }, 400);
863 }
864 const project = await getProjectForAdmin(projectId);
865 if (!project) return c.json({ code: 'not_found' }, 404);
866 const pre = await createSnapshot(projectId, `pre-recover ${parsed.data.snapshotId}`, {
867 auto: false,
868 });
869 const { restored } = await restoreSnapshot(projectId, parsed.data.snapshotId);
870 await evictProjectPool(projectId);
871 await audit({
872 actorId: actor.id,
873 projectId,
874 action: 'admin.project.database.recover',
875 ipHash: ipHash(c),
876 userAgent: c.req.header('user-agent') ?? null,
877 metadata: { snapshotId: parsed.data.snapshotId, preRecoverySnapshotId: pre.id },
878 });
879 return c.json({
880 recovered: true,
881 preRecoverySnapshotId: pre.id,
882 tablesAfterRecover: restored,
883 });
884});
885
886const databaseReprovisionBody = z.object({ confirmName: z.string().min(1) });
887
888/**
889 * Nuke-and-rebuild a project's database: drop it (data AND snapshots gone
890 * permanently) and provision a fresh empty one. Guarded by a typed
891 * confirmation — the body's confirmName must exactly match the project's
892 * slug or name, otherwise 400 confirm_mismatch and nothing is touched.
893 */
894adminRouter.post('/v1/admin/projects/:id/database/reprovision', async (c) => {
895 const actor = c.get('user')!;
896 const projectId = c.req.param('id');
897 const parsed = databaseReprovisionBody.safeParse(await c.req.json().catch(() => null));
898 if (!parsed.success) {
899 return c.json({ code: 'validation_failed', issues: parsed.error.issues }, 400);
900 }
901 const project = await getProjectForAdmin(projectId);
902 if (!project) return c.json({ code: 'not_found' }, 404);
903 if (parsed.data.confirmName !== project.slug && parsed.data.confirmName !== project.name) {
904 return c.json(
905 { code: 'confirm_mismatch', message: 'confirmation does not match the project slug or name' },
906 400,
907 );
908 }
909 await evictProjectPool(projectId);
910 await dropProjectDatabase(projectId);
911 await provisionProjectDatabase(projectId);
912 await audit({
913 actorId: actor.id,
914 projectId,
915 action: 'admin.project.database.reprovision',
916 ipHash: ipHash(c),
917 userAgent: c.req.header('user-agent') ?? null,
918 metadata: { slug: project.slug },
919 });
920 return c.json({ reprovisioned: true, health: await checkProjectDbHealth(projectId) });
921});
922
923/**
924 * Live realtime snapshot — proxies the secret-gated /v1/realtime/stats
925 * endpoint on the realtime service. Returns 503 when realtime isn't
926 * configured (BRIVEN_REALTIME_URL / BRIVEN_RUNTIME_SHARED_SECRET unset)
927 * so the admin UI can render a clear "not available" state instead of
928 * an empty table.
929 */
930adminRouter.get('/v1/admin/realtime', async (c) => {
931 const stats = await fetchRealtimeStats();
932 if (!stats) return c.json({ code: 'realtime_unavailable' }, 503);
933 return c.json(stats);
934});
935
936/* ─── signup allowlist (invite-only beta gate) ───────────────────── */
937
938adminRouter.get('/v1/admin/signup-allowlist', async (c) => {
939 const { listAllowlist } = await import('../services/signup-allowlist.js');
940 const entries = await listAllowlist();
941 return c.json({ entries });
942});
943
944adminRouter.post('/v1/admin/signup-allowlist', async (c) => {
945 const actor = c.get('user')!;
946 const body = await c.req.json().catch(() => null);
947 const parsed = z
948 .object({
949 email: z.string().email(),
950 notes: z.string().max(500).optional(),
951 })
952 .safeParse(body);
953 if (!parsed.success) {
954 return c.json({ code: 'validation_failed', issues: parsed.error.issues }, 400);
955 }
956 const { addToAllowlist } = await import('../services/signup-allowlist.js');
957 try {
958 const entry = await addToAllowlist({
959 email: parsed.data.email,
960 invitedBy: actor.id,
961 notes: parsed.data.notes ?? null,
962 });
963 await audit({
964 actorId: actor.id,
965 projectId: null,
966 action: 'admin.allowlist.add',
967 ipHash: ipHash(c),
968 userAgent: c.req.header('user-agent') ?? null,
969 metadata: { email: entry.email },
970 });
971 return c.json({ entry }, 201);
972 } catch (err) {
973 if (err instanceof Error && /already on the allowlist/i.test(err.message)) {
974 return c.json({ code: 'duplicate', message: err.message }, 409);
975 }
976 throw err;
977 }
978});
979
980adminRouter.delete('/v1/admin/signup-allowlist/:email', async (c) => {
981 const actor = c.get('user')!;
982 const email = decodeURIComponent(c.req.param('email'));
983 const { removeFromAllowlist } = await import('../services/signup-allowlist.js');
984 const removed = await removeFromAllowlist(email);
985 if (!removed) {
986 return c.json({ code: 'not_found' }, 404);
987 }
988 await audit({
989 actorId: actor.id,
990 projectId: null,
991 action: 'admin.allowlist.remove',
992 ipHash: ipHash(c),
993 userAgent: c.req.header('user-agent') ?? null,
994 metadata: { email },
995 });
996 return c.json({ ok: true });
997});
998
999/**
1000 * Platform-level launch status — surfaces flags the admin needs to see
1001 * at a glance during the invite-only → public-beta transition.
1002 * `openSignups` reads the effective value (DB override → env fallback).
1003 */
1004adminRouter.get('/v1/admin/launch-status', async (c) => {
1005 const actor = c.get('user')!;
1006 const { getOpenSignupsFlag, getPlatformSetting, getMaintenanceState } = await import(
1007 '../services/platform-settings.js'
1008 );
1009 const { getDb } = await import('../db/client.js');
1010 const { users } = await import('../db/schema.js');
1011 const { eq } = await import('drizzle-orm');
1012 const db = getDb();
1013 const [userRow] = await db
1014 .select({ lastMfaAt: users.lastMfaAt })
1015 .from(users)
1016 .where(eq(users.id, actor.id))
1017 .limit(1);
1018 const [openSignups, maintenanceMode, maintenanceState] = await Promise.all([
1019 getOpenSignupsFlag(),
1020 getPlatformSetting<boolean>('maintenanceMode', false),
1021 getMaintenanceState(),
1022 ]);
1023 // Compute the actor's step-up freshness so the dashboard can show a
1024 // banner + re-attest button. Mirrors the 10-min window the
1025 // requireRecentMfa middleware enforces.
1026 const lastMfaAt = userRow?.lastMfaAt ?? null;
1027 const stepUpExpiresAt = lastMfaAt ? new Date(lastMfaAt.getTime() + 10 * 60_000) : null;
1028 const stepUpFresh = stepUpExpiresAt ? stepUpExpiresAt.getTime() > Date.now() : false;
1029 return c.json({
1030 openSignups,
1031 openSignupsEnvDefault: env.BRIVEN_OPEN_SIGNUPS,
1032 maintenanceMode,
1033 discordInviteUrl: env.BRIVEN_DISCORD_INVITE_URL ?? null,
1034 domain: env.BRIVEN_DOMAIN,
1035 polarConfigured: Boolean(env.BRIVEN_POLAR_ACCESS_TOKEN),
1036 mitteraConfigured: Boolean(env.BRIVEN_MITTERA_API_KEY),
1037 minioConfigured: Boolean(
1038 env.BRIVEN_MINIO_ENDPOINT && env.BRIVEN_MINIO_ACCESS_KEY && env.BRIVEN_MINIO_SECRET_KEY,
1039 ),
1040 stepUpFresh,
1041 stepUpExpiresAt: stepUpExpiresAt?.toISOString() ?? null,
1042 // Scheduled-maintenance state. manualOverride is the raw
1043 // maintenanceMode bool (immediate on/off); the rest is the effective
1044 // state derived from maintenanceMode + the maintenanceWindow schedule.
1045 maintenance: {
1046 active: maintenanceState.active,
1047 scheduled: maintenanceState.scheduled,
1048 upcoming: maintenanceState.upcoming,
1049 startsAt: maintenanceState.startsAt,
1050 endsAt: maintenanceState.endsAt,
1051 message: maintenanceState.message,
1052 manualOverride: maintenanceMode,
1053 },
1054 });
1055});
1056
1057/**
1058 * Flip the dashboard-controllable open-signups flag. Writes
1059 * `platform_settings.openSignups = <bool>` + invalidates the in-process
1060 * cache so the change takes effect within ~60s on peer instances and
1061 * immediately on the writer. Audited as admin.signups.toggle.
1062 */
1063adminRouter.post('/v1/admin/launch-status/open-signups', async (c) => {
1064 const actor = c.get('user')!;
1065 const body = await c.req.json().catch(() => null);
1066 const parsed = z.object({ openSignups: z.boolean() }).safeParse(body);
1067 if (!parsed.success) {
1068 return c.json({ code: 'validation_failed', issues: parsed.error.issues }, 400);
1069 }
1070 const { setPlatformSetting } = await import('../services/platform-settings.js');
1071 await setPlatformSetting('openSignups', parsed.data.openSignups, actor.id);
1072 await audit({
1073 actorId: actor.id,
1074 projectId: null,
1075 action: 'admin.signups.toggle',
1076 ipHash: ipHash(c),
1077 userAgent: c.req.header('user-agent') ?? null,
1078 metadata: { openSignups: parsed.data.openSignups },
1079 });
1080 return c.json({ openSignups: parsed.data.openSignups });
1081});
1082
1083/**
1084 * Flip the platform-wide maintenance gate. When true, every non-admin
1085 * route returns 503 until flipped back. Whitelist: /health, /ready,
1086 * /info, /v1/auth/*, /v1/me, /v1/me/*, /v1/admin/* (so the admin can
1087 * sign in + flip it back). Audited as admin.maintenance.toggle.
1088 */
1089adminRouter.post('/v1/admin/launch-status/maintenance-mode', async (c) => {
1090 const actor = c.get('user')!;
1091 const body = await c.req.json().catch(() => null);
1092 // Richer body — every field optional, all back-compat:
1093 // - enabled: immediate manual on/off of the maintenanceMode bool.
1094 // - startsAt/endsAt/message: write the scheduled maintenanceWindow json.
1095 // Passing all three as null clears the schedule.
1096 // The old body { enabled: boolean } alone still works.
1097 const isoDateTime = z.string().datetime();
1098 const parsed = z
1099 .object({
1100 enabled: z.boolean().optional(),
1101 startsAt: isoDateTime.nullable().optional(),
1102 endsAt: isoDateTime.nullable().optional(),
1103 message: z.string().max(2000).nullable().optional(),
1104 })
1105 .safeParse(body);
1106 if (!parsed.success) {
1107 return c.json({ code: 'validation_failed', issues: parsed.error.issues }, 400);
1108 }
1109 const { enabled, startsAt, endsAt, message } = parsed.data;
1110 const windowTouched =
1111 startsAt !== undefined || endsAt !== undefined || message !== undefined;
1112
1113 // When both bounds are set, startsAt must be strictly before endsAt.
1114 if (startsAt != null && endsAt != null && Date.parse(startsAt) >= Date.parse(endsAt)) {
1115 return c.json(
1116 { code: 'validation_failed', message: 'startsAt must be before endsAt' },
1117 400,
1118 );
1119 }
1120
1121 const { setPlatformSetting } = await import('../services/platform-settings.js');
1122 if (enabled !== undefined) {
1123 await setPlatformSetting('maintenanceMode', enabled, actor.id);
1124 }
1125 if (windowTouched) {
1126 await setPlatformSetting(
1127 'maintenanceWindow',
1128 {
1129 startsAt: startsAt ?? null,
1130 endsAt: endsAt ?? null,
1131 message: message ?? null,
1132 },
1133 actor.id,
1134 );
1135 }
1136
1137 await audit({
1138 actorId: actor.id,
1139 projectId: null,
1140 action: 'admin.maintenance.toggle',
1141 ipHash: ipHash(c),
1142 userAgent: c.req.header('user-agent') ?? null,
1143 metadata: {
1144 ...(enabled !== undefined ? { maintenanceMode: enabled } : {}),
1145 ...(windowTouched
1146 ? { window: { startsAt: startsAt ?? null, endsAt: endsAt ?? null } }
1147 : {}),
1148 },
1149 });
1150
1151 const { getMaintenanceState } = await import('../services/platform-settings.js');
1152 return c.json(await getMaintenanceState());
1153});
1154
1155/* ─── incidents (admin write surface; public list on /v1/status/incidents) ─── */
1156
1157const incidentServicesSchema = z.array(z.string().min(1)).min(1);
1158const incidentSeveritySchema = z.enum(incidentSeverity);
1159
1160const createIncidentSchema = z.object({
1161 startedAt: z.string().datetime().optional(),
1162 severity: incidentSeveritySchema,
1163 services: incidentServicesSchema,
1164 summary: z.string().min(1).max(2000),
1165 postmortem: z.string().max(20_000).optional(),
1166});
1167
1168const updateIncidentSchema = z.object({
1169 summary: z.string().min(1).max(2000).optional(),
1170 postmortem: z.string().max(20_000).optional(),
1171 severity: incidentSeveritySchema.optional(),
1172 services: incidentServicesSchema.optional(),
1173});
1174
1175adminRouter.get('/v1/admin/incidents', async (c) => {
1176 const rows = await listIncidents({ limit: 100 });
1177 return c.json({ incidents: rows });
1178});
1179
1180adminRouter.post('/v1/admin/incidents', async (c) => {
1181 const actor = c.get('user')!;
1182 const body = await c.req.json().catch(() => null);
1183 const parsed = createIncidentSchema.safeParse(body);
1184 if (!parsed.success) {
1185 return c.json({ code: 'validation_failed', issues: parsed.error.issues }, 400);
1186 }
1187 try {
1188 const incident = await createIncident({
1189 startedAt: parsed.data.startedAt ? new Date(parsed.data.startedAt) : undefined,
1190 severity: parsed.data.severity,
1191 services: parsed.data.services,
1192 summary: parsed.data.summary,
1193 postmortem: parsed.data.postmortem,
1194 createdBy: actor.id,
1195 });
1196 await audit({
1197 actorId: actor.id,
1198 projectId: null,
1199 action: 'admin.incident.create',
1200 ipHash: ipHash(c),
1201 userAgent: c.req.header('user-agent') ?? null,
1202 metadata: { incidentId: incident.id, severity: incident.severity },
1203 });
1204 return c.json({ incident }, 201);
1205 } catch (err) {
1206 if (err instanceof ValidationError) {
1207 return c.json({ code: 'validation_failed', message: err.message }, 400);
1208 }
1209 throw err;
1210 }
1211});
1212
1213adminRouter.patch('/v1/admin/incidents/:id', async (c) => {
1214 const actor = c.get('user')!;
1215 const id = c.req.param('id');
1216 const body = await c.req.json().catch(() => null);
1217 const parsed = updateIncidentSchema.safeParse(body);
1218 if (!parsed.success) {
1219 return c.json({ code: 'validation_failed', issues: parsed.error.issues }, 400);
1220 }
1221 try {
1222 const incident = await updateIncident(id, parsed.data);
1223 await audit({
1224 actorId: actor.id,
1225 projectId: null,
1226 action: 'admin.incident.update',
1227 ipHash: ipHash(c),
1228 userAgent: c.req.header('user-agent') ?? null,
1229 metadata: { incidentId: id, fields: Object.keys(parsed.data) },
1230 });
1231 return c.json({ incident });
1232 } catch (err) {
1233 if (err instanceof ValidationError) {
1234 return c.json({ code: 'validation_failed', message: err.message }, 400);
1235 }
1236 throw err;
1237 }
1238});
1239
1240adminRouter.post('/v1/admin/incidents/:id/resolve', async (c) => {
1241 const actor = c.get('user')!;
1242 const id = c.req.param('id');
1243 const incident = await resolveIncident(id);
1244 await audit({
1245 actorId: actor.id,
1246 projectId: null,
1247 action: 'admin.incident.resolve',
1248 ipHash: ipHash(c),
1249 userAgent: c.req.header('user-agent') ?? null,
1250 metadata: { incidentId: id },
1251 });
1252 return c.json({ incident });
1253});
1254
1255/* ─── migration requests (customer-initiated platform imports) ───────── */
1256
1257const updateMigrationRequestSchema = z.object({
1258 status: z
1259 .enum(['new', 'contacted', 'scheduled', 'in_progress', 'completed', 'cancelled'])
1260 .optional(),
1261 assignedTo: z.string().nullable().optional(),
1262 operatorNotes: z.string().max(20_000).optional(),
1263 // Optional message included in the customer status-update email when
1264 // the status changes. Persisted-anywhere is intentionally false — once
1265 // the email ships, the message lives in the audit log + the customer's
1266 // inbox, not in operator_notes. Keep it under 5kB so the email body
1267 // stays under most provider limits.
1268 messageToCustomer: z.string().max(5_000).optional(),
1269});
1270
1271adminRouter.get('/v1/admin/migration-requests', async (c) => {
1272 const openOnly = c.req.query('open') === 'true';
1273 const limitParam = c.req.query('limit');
1274 const limit = limitParam ? Number(limitParam) || 100 : 100;
1275 const rows = await listMigrationRequestsForAdmin({ limit, openOnly });
1276 return c.json({
1277 requests: rows.map((r) => ({
1278 ...r,
1279 estimatedRows: r.estimatedRows == null ? null : r.estimatedRows.toString(),
1280 createdAt: r.createdAt.toISOString(),
1281 updatedAt: r.updatedAt.toISOString(),
1282 })),
1283 });
1284});
1285
1286adminRouter.get('/v1/admin/migration-requests/:id', async (c) => {
1287 const id = c.req.param('id');
1288 try {
1289 const row = await getMigrationRequest(id);
1290 return c.json({
1291 request: {
1292 ...row,
1293 estimatedRows: row.estimatedRows == null ? null : row.estimatedRows.toString(),
1294 createdAt: row.createdAt.toISOString(),
1295 updatedAt: row.updatedAt.toISOString(),
1296 },
1297 });
1298 } catch {
1299 return c.json({ code: 'not_found' }, 404);
1300 }
1301});
1302
1303adminRouter.patch('/v1/admin/migration-requests/:id', async (c) => {
1304 const actor = c.get('user')!;
1305 const id = c.req.param('id');
1306 const body = await c.req.json().catch(() => null);
1307 const parsed = updateMigrationRequestSchema.safeParse(body);
1308 if (!parsed.success) {
1309 return c.json({ code: 'validation_failed', issues: parsed.error.issues }, 400);
1310 }
1311 try {
1312 // Snapshot the row pre-update so we can detect a real status change
1313 // and fire the customer notification only when something actually
1314 // changed. messageToCustomer is intentionally pulled out and never
1315 // forwarded to updateMigrationRequest — it's email-only payload.
1316 const { messageToCustomer, ...patch } = parsed.data;
1317 const before = await getMigrationRequest(id);
1318 const request = await updateMigrationRequest(id, patch);
1319 await audit({
1320 actorId: actor.id,
1321 projectId: null,
1322 action: 'admin.migration_request.update',
1323 ipHash: ipHash(c),
1324 userAgent: c.req.header('user-agent') ?? null,
1325 metadata: {
1326 requestId: id,
1327 fields: Object.keys(patch),
1328 statusChanged: before.status !== request.status,
1329 messageIncluded: Boolean(messageToCustomer),
1330 },
1331 });
1332 // Status-change customer notification. Skipped on operator-notes-only
1333 // edits (no status field) and on no-op status writes. Fire-and-forget
1334 // so a slow mittera doesn't block the operator's PATCH response.
1335 if (patch.status && before.status !== request.status) {
1336 void sendMigrationStatusUpdate({
1337 requestId: request.id,
1338 source: request.source,
1339 contactEmail: request.contactEmail,
1340 oldStatus: before.status,
1341 newStatus: request.status,
1342 operatorMessage: messageToCustomer,
1343 }).catch((err) => {
1344 log.error('migration_status_email_failed', {
1345 requestId: request.id,
1346 error: err instanceof Error ? err.message : String(err),
1347 });
1348 });
1349 }
1350 return c.json({
1351 request: {
1352 ...request,
1353 estimatedRows:
1354 request.estimatedRows == null ? null : request.estimatedRows.toString(),
1355 createdAt: request.createdAt.toISOString(),
1356 updatedAt: request.updatedAt.toISOString(),
1357 },
1358 });
1359 } catch (err) {
1360 if (err instanceof ValidationError) {
1361 return c.json({ code: 'validation_failed', message: err.message }, 400);
1362 }
1363 throw err;
1364 }
1365});
1366
1367/* ─── support tickets (tagged /contact submissions) ──────────────────── */
1368
1369interface AdminTicket {
1370 id: string;
1371 ticketNumber: string | null;
1372 status: string;
1373 topic: string;
1374 topicCode: string | null;
1375 name: string;
1376 email: string;
1377 subject: string | null;
1378 message: string;
1379 country: string | null;
1380 assignedTo: string | null;
1381 operatorNotes: string | null;
1382 createdAt: string;
1383 handledAt: string | null;
1384}
1385
1386function serializeAdminTicket(t: {
1387 id: string;
1388 ticketNumber: string | null;
1389 status: string;
1390 topic: string;
1391 topicCode: string | null;
1392 name: string;
1393 email: string;
1394 subject: string | null;
1395 message: string;
1396 country: string | null;
1397 assignedTo: string | null;
1398 operatorNotes: string | null;
1399 createdAt: Date;
1400 handledAt: Date | null;
1401}): AdminTicket {
1402 return {
1403 id: t.id,
1404 ticketNumber: renderTicketNumber(t.ticketNumber),
1405 status: t.status,
1406 topic: t.topic,
1407 topicCode: t.topicCode,
1408 name: t.name,
1409 email: t.email,
1410 subject: t.subject,
1411 message: t.message,
1412 country: t.country,
1413 assignedTo: t.assignedTo,
1414 operatorNotes: t.operatorNotes,
1415 createdAt: t.createdAt.toISOString(),
1416 handledAt: t.handledAt ? t.handledAt.toISOString() : null,
1417 };
1418}
1419
1420function serializeReply(r: {
1421 id: string;
1422 author: string;
1423 body: string;
1424 createdAt: Date;
1425}): { id: string; author: string; body: string; createdAt: string } {
1426 return { id: r.id, author: r.author, body: r.body, createdAt: r.createdAt.toISOString() };
1427}
1428
1429/**
1430 * Serialized shape for the all-messages inbox — the AdminTicket shape plus
1431 * an `isTicket` flag (true when the row carries a ticket number, i.e. it's
1432 * also visible on the tickets page). Contract with the web inbox client.
1433 */
1434type SerializedMessage = AdminTicket & { isTicket: boolean };
1435
1436function serializeAdminMessage(t: {
1437 id: string;
1438 ticketNumber: string | null;
1439 status: string;
1440 topic: string;
1441 topicCode: string | null;
1442 name: string;
1443 email: string;
1444 subject: string | null;
1445 message: string;
1446 country: string | null;
1447 assignedTo: string | null;
1448 operatorNotes: string | null;
1449 createdAt: Date;
1450 handledAt: Date | null;
1451}): SerializedMessage {
1452 return { ...serializeAdminTicket(t), isTicket: t.ticketNumber != null };
1453}
1454
1455const updateTicketSchema = z.object({
1456 status: z.enum(ticketStatuses).optional(),
1457 assignedTo: z.string().max(200).nullable().optional(),
1458 operatorNotes: z.string().max(20_000).nullable().optional(),
1459});
1460
1461const ticketReplySchema = z.object({
1462 body: z.string().trim().min(1).max(8_000),
1463});
1464
1465adminRouter.get('/v1/admin/tickets', async (c) => {
1466 const statusParam = c.req.query('status');
1467 const status =
1468 statusParam && (ticketStatuses as readonly string[]).includes(statusParam)
1469 ? (statusParam as (typeof ticketStatuses)[number])
1470 : undefined;
1471 const limitParam = c.req.query('limit');
1472 const limit = limitParam ? Number(limitParam) || 100 : 100;
1473 const rows = await listTicketsForAdmin({ status, limit });
1474 return c.json({ tickets: rows.map(serializeAdminTicket) });
1475});
1476
1477adminRouter.get('/v1/admin/tickets/:id', async (c) => {
1478 const id = c.req.param('id');
1479 try {
1480 const { ticket, replies } = await getTicketByIdForAdmin(id);
1481 return c.json({
1482 ticket: serializeAdminTicket(ticket),
1483 replies: replies.map(serializeReply),
1484 });
1485 } catch {
1486 return c.json({ code: 'not_found' }, 404);
1487 }
1488});
1489
1490adminRouter.patch('/v1/admin/tickets/:id', async (c) => {
1491 const actor = c.get('user')!;
1492 const id = c.req.param('id');
1493 const body = await c.req.json().catch(() => null);
1494 const parsed = updateTicketSchema.safeParse(body);
1495 if (!parsed.success) {
1496 return c.json({ code: 'validation_failed', issues: parsed.error.issues }, 400);
1497 }
1498 try {
1499 const before = await getTicketByIdForAdmin(id);
1500 const patch: UpdateTicketInput = parsed.data;
1501 const ticket = await updateTicket(id, patch);
1502 const statusChanged = Boolean(patch.status) && before.ticket.status !== ticket.status;
1503 await audit({
1504 actorId: actor.id,
1505 projectId: null,
1506 action: 'contact_ticket.update',
1507 ipHash: ipHash(c),
1508 userAgent: c.req.header('user-agent') ?? null,
1509 metadata: {
1510 ticketId: id,
1511 ticketNumber: renderTicketNumber(ticket.ticketNumber),
1512 fields: Object.keys(patch),
1513 statusChanged,
1514 newStatus: statusChanged ? ticket.status : null,
1515 },
1516 });
1517 // Notify the sender on a real status change to 'replied'/'closed'.
1518 // Fire-and-forget so mittera latency never blocks the operator.
1519 const rendered = renderTicketNumber(ticket.ticketNumber);
1520 if (
1521 statusChanged &&
1522 rendered &&
1523 (ticket.status === 'replied' || ticket.status === 'closed')
1524 ) {
1525 void sendTicketStatusUpdate(ticket.email, rendered, ticket.status).catch((err) => {
1526 log.error('ticket_status_email_failed', {
1527 ticketId: id,
1528 error: err instanceof Error ? err.message : String(err),
1529 });
1530 });
1531 }
1532 return c.json({ ticket: serializeAdminTicket(ticket) });
1533 } catch (err) {
1534 if (err instanceof NotFoundError) return c.json({ code: 'not_found' }, 404);
1535 if (err instanceof ValidationError) {
1536 return c.json({ code: 'validation_failed', message: err.message }, 400);
1537 }
1538 throw err;
1539 }
1540});
1541
1542adminRouter.post('/v1/admin/tickets/:id/reply', async (c) => {
1543 const actor = c.get('user')!;
1544 const id = c.req.param('id');
1545 const body = await c.req.json().catch(() => null);
1546 const parsed = ticketReplySchema.safeParse(body);
1547 if (!parsed.success) {
1548 return c.json({ code: 'validation_failed', issues: parsed.error.issues }, 400);
1549 }
1550 try {
1551 const { reply, ticket } = await addTicketReply(id, 'operator', parsed.data.body);
1552 const rendered = renderTicketNumber(ticket.ticketNumber);
1553 await audit({
1554 actorId: actor.id,
1555 projectId: null,
1556 action: 'contact_ticket.reply',
1557 ipHash: ipHash(c),
1558 userAgent: c.req.header('user-agent') ?? null,
1559 metadata: { ticketId: id, ticketNumber: rendered, replyId: reply.id },
1560 });
1561 // Email the sender the reply. Fire-and-forget.
1562 if (rendered) {
1563 void sendTicketReply(ticket.email, rendered, reply.body).catch((err) => {
1564 log.error('ticket_reply_email_failed', {
1565 ticketId: id,
1566 error: err instanceof Error ? err.message : String(err),
1567 });
1568 });
1569 }
1570 return c.json({ reply: serializeReply(reply) }, 201);
1571 } catch (err) {
1572 if (err instanceof NotFoundError) return c.json({ code: 'not_found' }, 404);
1573 if (err instanceof ValidationError) {
1574 return c.json({ code: 'validation_failed', message: err.message }, 400);
1575 }
1576 throw err;
1577 }
1578});
1579
1580/* ─── all-messages inbox (tagged OR plain /contact submissions) ──────── */
1581
1582const contactMessageFilterSchema = z.enum(['all', 'plain', 'tickets']);
1583
1584const updateContactMessageSchema = z.object({
1585 status: z.enum(ticketStatuses).optional(),
1586 operatorNotes: z.string().max(20_000).nullable().optional(),
1587});
1588
1589const contactReplySchema = z.object({
1590 body: z.string().trim().min(1).max(8_000),
1591});
1592
1593adminRouter.get('/v1/admin/contact-messages', async (c) => {
1594 const filterParam = c.req.query('filter');
1595 const parsed = filterParam ? contactMessageFilterSchema.safeParse(filterParam) : null;
1596 if (filterParam && !parsed!.success) {
1597 return c.json({ code: 'validation_failed', message: 'invalid filter' }, 400);
1598 }
1599 const filter: ContactMessageFilter = parsed?.success ? parsed.data : 'all';
1600 const limitParam = c.req.query('limit');
1601 const limit = limitParam ? Number(limitParam) || 100 : 100;
1602 const rows = await listContactMessages({ filter, limit });
1603 return c.json({ messages: rows.map(serializeAdminMessage) });
1604});
1605
1606adminRouter.get('/v1/admin/contact-messages/:id', async (c) => {
1607 const id = c.req.param('id');
1608 try {
1609 const { message, replies } = await getContactMessageForAdmin(id);
1610 return c.json({
1611 message: serializeAdminMessage(message),
1612 replies: replies.map(serializeReply),
1613 });
1614 } catch {
1615 return c.json({ code: 'not_found' }, 404);
1616 }
1617});
1618
1619adminRouter.post('/v1/admin/contact-messages/:id/reply', async (c) => {
1620 const actor = c.get('user')!;
1621 const id = c.req.param('id');
1622 const body = await c.req.json().catch(() => null);
1623 const parsed = contactReplySchema.safeParse(body);
1624 if (!parsed.success) {
1625 return c.json({ code: 'validation_failed', issues: parsed.error.issues }, 400);
1626 }
1627 try {
1628 const { reply, message } = await addContactReply(id, 'operator', parsed.data.body);
1629 // Store the reply THEN flip status → 'replied' so the inbox reflects
1630 // the operator handled it (works for plain + ticketed alike).
1631 await updateContactMessage(id, { status: 'replied' });
1632 const rendered = renderTicketNumber(message.ticketNumber);
1633 await audit({
1634 actorId: actor.id,
1635 projectId: null,
1636 action: 'contact_message.reply',
1637 ipHash: ipHash(c),
1638 userAgent: c.req.header('user-agent') ?? null,
1639 metadata: {
1640 messageId: id,
1641 ticketNumber: rendered,
1642 isTicket: message.ticketNumber != null,
1643 replyId: reply.id,
1644 },
1645 });
1646 // Email the sender. Ticketed → the ticket-reply email (quotes the
1647 // number); plain → the contact-reply email (no ticket references).
1648 // Fire-and-forget with a logged catch, like the ticket reply route.
1649 if (rendered) {
1650 void sendTicketReply(message.email, rendered, reply.body).catch((err) => {
1651 log.error('contact_reply_email_failed', {
1652 messageId: id,
1653 error: err instanceof Error ? err.message : String(err),
1654 });
1655 });
1656 } else {
1657 void sendContactReply(message.email, reply.body).catch((err) => {
1658 log.error('contact_reply_email_failed', {
1659 messageId: id,
1660 error: err instanceof Error ? err.message : String(err),
1661 });
1662 });
1663 }
1664 return c.json({ reply: serializeReply(reply) }, 201);
1665 } catch (err) {
1666 if (err instanceof NotFoundError) return c.json({ code: 'not_found' }, 404);
1667 if (err instanceof ValidationError) {
1668 return c.json({ code: 'validation_failed', message: err.message }, 400);
1669 }
1670 throw err;
1671 }
1672});
1673
1674adminRouter.patch('/v1/admin/contact-messages/:id', async (c) => {
1675 const actor = c.get('user')!;
1676 const id = c.req.param('id');
1677 const body = await c.req.json().catch(() => null);
1678 const parsed = updateContactMessageSchema.safeParse(body);
1679 if (!parsed.success) {
1680 return c.json({ code: 'validation_failed', issues: parsed.error.issues }, 400);
1681 }
1682 try {
1683 const before = await getContactMessageForAdmin(id);
1684 const message = await updateContactMessage(id, parsed.data);
1685 const statusChanged =
1686 Boolean(parsed.data.status) && before.message.status !== message.status;
1687 await audit({
1688 actorId: actor.id,
1689 projectId: null,
1690 action: 'contact_message.update',
1691 ipHash: ipHash(c),
1692 userAgent: c.req.header('user-agent') ?? null,
1693 metadata: {
1694 messageId: id,
1695 ticketNumber: renderTicketNumber(message.ticketNumber),
1696 isTicket: message.ticketNumber != null,
1697 fields: Object.keys(parsed.data),
1698 statusChanged,
1699 newStatus: statusChanged ? message.status : null,
1700 },
1701 });
1702 return c.json({ message: serializeAdminMessage(message) });
1703 } catch (err) {
1704 if (err instanceof NotFoundError) return c.json({ code: 'not_found' }, 404);
1705 if (err instanceof ValidationError) {
1706 return c.json({ code: 'validation_failed', message: err.message }, 400);
1707 }
1708 throw err;
1709 }
1710});
1711
1712/**
1713 * Promote an unauth lead to a user account. Used when an operator
1714 * verifies (via the contact email match) that the briven user signing
1715 * in / already signed in is the same person who submitted the lead.
1716 * Idempotent. Returns { linkedUserId: null } when no user with the
1717 * lead's email exists yet — operator can wait for sign-up and retry.
1718 */
1719adminRouter.get('/v1/admin/marketing-funnel', async (c) => {
1720 const sinceDays = Math.max(1, Math.min(365, Number(c.req.query('days')) || 30));
1721 return c.json(await getMarketingFunnel({ sinceDays }));
1722});
1723
1724/**
1725 * Platform-wide sign-up geo analytics (SEO). Aggregates auth_signup_geo:
1726 * counts by country + by country/city, a total, and a recent-events list
1727 * (raw IPs included — admin-only, that's the point). Optional `?projectId=`
1728 * filter and `?days=` window (default 30). Admin-guarded like every route
1729 * under /v1/admin/* (see adminRouter.use above). The per-project customer
1730 * users page never reads this data — control-plane analytics only.
1731 */
1732adminRouter.get('/v1/admin/auth/signups/geo', async (c) => {
1733 const sinceDays = Math.max(1, Math.min(365, Number(c.req.query('days')) || 30));
1734 const projectId = c.req.query('projectId') ?? null;
1735 return c.json(await getSignupGeoSummary({ sinceDays, projectId }));
1736});
1737
1738/**
1739 * Offline convex schema → briven schema DSL translator. Stateless;
1740 * accepts pasted convex/schema.ts source and returns the draft briven
1741 * schema for the operator to review and ship. No fetch against the
1742 * customer's convex deployment — schema bytes come from the operator
1743 * (typically pasted from the customer during the migration call).
1744 */
1745adminRouter.post('/v1/admin/translate-convex-schema', async (c) => {
1746 const body = (await c.req.json().catch(() => null)) as { source?: string } | null;
1747 if (!body || typeof body.source !== 'string') {
1748 return c.json({ code: 'validation_failed', message: 'source string required' }, 400);
1749 }
1750 try {
1751 const result = translateConvexSchema(body.source);
1752 return c.json(result);
1753 } catch (err) {
1754 if (err instanceof ValidationError) {
1755 return c.json({ code: 'validation_failed', message: err.message }, 400);
1756 }
1757 throw err;
1758 }
1759});
1760
1761adminRouter.post('/v1/admin/migration-requests/:id/promote-to-user', async (c) => {
1762 const actor = c.get('user')!;
1763 const id = c.req.param('id');
1764 try {
1765 const lead = await getMigrationRequest(id);
1766 const { request, linkedUserId } = await promoteMigrationRequestToUser(
1767 id,
1768 lead.contactEmail,
1769 );
1770 await audit({
1771 actorId: actor.id,
1772 projectId: null,
1773 action: 'admin.migration_request.promote_to_user',
1774 ipHash: ipHash(c),
1775 userAgent: c.req.header('user-agent') ?? null,
1776 metadata: { requestId: id, linkedUserId, matchedEmail: lead.contactEmail },
1777 });
1778 return c.json({
1779 linkedUserId,
1780 request: {
1781 ...request,
1782 estimatedRows:
1783 request.estimatedRows == null ? null : request.estimatedRows.toString(),
1784 createdAt: request.createdAt.toISOString(),
1785 updatedAt: request.updatedAt.toISOString(),
1786 },
1787 });
1788 } catch (err) {
1789 if (err instanceof ValidationError) {
1790 return c.json({ code: 'validation_failed', message: err.message }, 400);
1791 }
1792 throw err;
1793 }
1794});