admin.ts1794 lines · main
| 1 | import { Hono, type Context } from 'hono'; |
| 2 | import { z } from 'zod'; |
| 3 | |
| 4 | import { env } from '../env.js'; |
| 5 | import { requireAdmin } from '../middleware/admin.js'; |
| 6 | import { requireAuth } from '../middleware/session.js'; |
| 7 | import { requireRecentMfa } from '../middleware/step-up.js'; |
| 8 | import type { AppEnv } from '../types/app-env.js'; |
| 9 | import { |
| 10 | ABUSE_RESOLUTION, |
| 11 | listAbuseReports, |
| 12 | resolveAbuseReport, |
| 13 | suspendProject, |
| 14 | triageAbuseReport, |
| 15 | unsuspendProject, |
| 16 | type AbuseStatus, |
| 17 | } from '../services/abuse.js'; |
| 18 | import { |
| 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'; |
| 32 | import { invalidateTierCache } from '../services/tiers.js'; |
| 33 | import { audit, hashIp, listAuditByActionPrefix } from '../services/audit.js'; |
| 34 | import { createImpersonationSession } from '../services/auth-impersonate.js'; |
| 35 | import { listDeploys } from '../services/deploy-history.js'; |
| 36 | import { getHealthSummary } from '../services/platform-health.js'; |
| 37 | import { getAdminOverview } from '../services/admin-overview.js'; |
| 38 | import { getUserDeepDetailForAdmin } from '../services/admin-user-detail.js'; |
| 39 | import { |
| 40 | createIncident, |
| 41 | listIncidents, |
| 42 | resolveIncident, |
| 43 | updateIncident, |
| 44 | } from '../services/incidents.js'; |
| 45 | import { |
| 46 | sendContactReply, |
| 47 | sendMigrationStatusUpdate, |
| 48 | sendTicketReply, |
| 49 | sendTicketStatusUpdate, |
| 50 | } from '../lib/email.js'; |
| 51 | import { translateConvexSchema } from '../services/convex-schema-translator.js'; |
| 52 | import { getMarketingFunnel } from '../services/marketing-events.js'; |
| 53 | import { getSignupGeoSummary } from '../services/signup-geo-admin.js'; |
| 54 | import { getEmailAdminSummary } from '../services/email-admin.js'; |
| 55 | import { log } from '../lib/logger.js'; |
| 56 | import { |
| 57 | getMigrationRequest, |
| 58 | listMigrationRequestsForAdmin, |
| 59 | promoteMigrationRequestToUser, |
| 60 | updateMigrationRequest, |
| 61 | } from '../services/migration-requests.js'; |
| 62 | import { fetchRealtimeStats } from '../services/realtime-stats.js'; |
| 63 | import { listUsageEvents, retrySkippedUsageEvents } from '../services/usage-admin.js'; |
| 64 | import { |
| 65 | getProjectEnforcement, |
| 66 | getTierStorageCaps, |
| 67 | listObjectStorageUsage, |
| 68 | listStorageUsage, |
| 69 | setProjectEnforcement, |
| 70 | setProjectStorageLimit, |
| 71 | updateTierStorageCap, |
| 72 | } from '../services/storage-admin.js'; |
| 73 | import { restoreFile } from '../services/storage.js'; |
| 74 | import { listSuppressions, suppress, unsuppress } from '../services/suppressions.js'; |
| 75 | import { incidentSeverity, ticketStatuses } from '../db/schema.js'; |
| 76 | import { |
| 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'; |
| 89 | import { NotFoundError, ValidationError } from '@briven/shared'; |
| 90 | import { |
| 91 | checkProjectDbHealth, |
| 92 | dropProjectDatabase, |
| 93 | evictProjectPool, |
| 94 | provisionProjectDatabase, |
| 95 | } from '../db/data-plane.js'; |
| 96 | import { createSnapshot, listSnapshots, restoreSnapshot } from '../services/snapshots.js'; |
| 97 | |
| 98 | const userActionSchema = z.object({ userId: z.string().min(1) }); |
| 99 | |
| 100 | function 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 | |
| 106 | export const adminRouter = new Hono<AppEnv>(); |
| 107 | |
| 108 | adminRouter.use('/v1/admin/*', requireAuth()); |
| 109 | adminRouter.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. |
| 115 | const mfa = requireRecentMfa(10); |
| 116 | adminRouter.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 | |
| 124 | adminRouter.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 | */ |
| 132 | adminRouter.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 | */ |
| 139 | adminRouter.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 | */ |
| 145 | adminRouter.get('/v1/admin/auth-reliability', async (c) => { |
| 146 | const { getAuthReliabilitySnapshot } = await import('../services/auth-reliability.js'); |
| 147 | return c.json(await getAuthReliabilitySnapshot()); |
| 148 | }); |
| 149 | |
| 150 | adminRouter.get('/v1/admin/users', async (c) => { |
| 151 | const rows = await listUsers(200); |
| 152 | return c.json({ users: rows }); |
| 153 | }); |
| 154 | |
| 155 | adminRouter.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 | |
| 172 | adminRouter.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 | */ |
| 184 | adminRouter.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 | */ |
| 207 | adminRouter.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 | */ |
| 217 | adminRouter.get('/v1/admin/storage/object', async (c) => { |
| 218 | const usage = await listObjectStorageUsage(); |
| 219 | return c.json({ usage }); |
| 220 | }); |
| 221 | |
| 222 | adminRouter.get('/v1/admin/storage/tier-caps', async (c) => { |
| 223 | const caps = await getTierStorageCaps(); |
| 224 | return c.json({ caps }); |
| 225 | }); |
| 226 | |
| 227 | const tierParam = z.enum(['free', 'pro', 'team']); |
| 228 | const 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). */ |
| 234 | adminRouter.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 | |
| 252 | const 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). */ |
| 258 | adminRouter.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 | */ |
| 283 | const enforcementBody = z.object({ enforcement: z.enum(['flag', 'block']) }); |
| 284 | |
| 285 | adminRouter.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). */ |
| 306 | adminRouter.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 | */ |
| 327 | adminRouter.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 | */ |
| 365 | adminRouter.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 | */ |
| 375 | adminRouter.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 | */ |
| 384 | adminRouter.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 | */ |
| 406 | adminRouter.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 | |
| 417 | const 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 | |
| 424 | adminRouter.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 | |
| 443 | const 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 | |
| 449 | adminRouter.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 | |
| 472 | adminRouter.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 | |
| 487 | async 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 | |
| 496 | adminRouter.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 | |
| 512 | adminRouter.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 | |
| 528 | adminRouter.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 | |
| 554 | adminRouter.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 | */ |
| 576 | adminRouter.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 | |
| 596 | adminRouter.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 | |
| 612 | adminRouter.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 | |
| 630 | const abuseStatusQuery = z.enum(['open', 'triaged', 'resolved']); |
| 631 | const 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 | |
| 647 | adminRouter.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 | |
| 662 | adminRouter.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 ─────────────────────── */ |
| 706 | const projectSuspensionSchema = z.object({ |
| 707 | projectId: z.string().min(1), |
| 708 | reason: z.string().min(1).max(500).optional(), |
| 709 | }); |
| 710 | |
| 711 | adminRouter.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 | |
| 728 | adminRouter.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 | |
| 746 | const 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 | */ |
| 754 | adminRouter.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 | */ |
| 783 | adminRouter.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 | */ |
| 810 | adminRouter.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. */ |
| 818 | adminRouter.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 | */ |
| 831 | adminRouter.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 | |
| 849 | const 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 | */ |
| 857 | adminRouter.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 | |
| 886 | const 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 | */ |
| 894 | adminRouter.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 | */ |
| 930 | adminRouter.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 | |
| 938 | adminRouter.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 | |
| 944 | adminRouter.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 | |
| 980 | adminRouter.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 | */ |
| 1004 | adminRouter.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 | */ |
| 1063 | adminRouter.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 | */ |
| 1089 | adminRouter.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 | |
| 1157 | const incidentServicesSchema = z.array(z.string().min(1)).min(1); |
| 1158 | const incidentSeveritySchema = z.enum(incidentSeverity); |
| 1159 | |
| 1160 | const 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 | |
| 1168 | const 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 | |
| 1175 | adminRouter.get('/v1/admin/incidents', async (c) => { |
| 1176 | const rows = await listIncidents({ limit: 100 }); |
| 1177 | return c.json({ incidents: rows }); |
| 1178 | }); |
| 1179 | |
| 1180 | adminRouter.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 | |
| 1213 | adminRouter.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 | |
| 1240 | adminRouter.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 | |
| 1257 | const 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 | |
| 1271 | adminRouter.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 | |
| 1286 | adminRouter.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 | |
| 1303 | adminRouter.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 | |
| 1369 | interface 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 | |
| 1386 | function 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 | |
| 1420 | function 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 | */ |
| 1434 | type SerializedMessage = AdminTicket & { isTicket: boolean }; |
| 1435 | |
| 1436 | function 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 | |
| 1455 | const 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 | |
| 1461 | const ticketReplySchema = z.object({ |
| 1462 | body: z.string().trim().min(1).max(8_000), |
| 1463 | }); |
| 1464 | |
| 1465 | adminRouter.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 | |
| 1477 | adminRouter.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 | |
| 1490 | adminRouter.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 | |
| 1542 | adminRouter.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 | |
| 1582 | const contactMessageFilterSchema = z.enum(['all', 'plain', 'tickets']); |
| 1583 | |
| 1584 | const updateContactMessageSchema = z.object({ |
| 1585 | status: z.enum(ticketStatuses).optional(), |
| 1586 | operatorNotes: z.string().max(20_000).nullable().optional(), |
| 1587 | }); |
| 1588 | |
| 1589 | const contactReplySchema = z.object({ |
| 1590 | body: z.string().trim().min(1).max(8_000), |
| 1591 | }); |
| 1592 | |
| 1593 | adminRouter.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 | |
| 1606 | adminRouter.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 | |
| 1619 | adminRouter.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 | |
| 1674 | adminRouter.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 | */ |
| 1719 | adminRouter.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 | */ |
| 1732 | adminRouter.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 | */ |
| 1745 | adminRouter.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 | |
| 1761 | adminRouter.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 | }); |