admin-manifest.ts184 lines · main
1import { constantTimeEqual } from '@briven/shared';
2import { Hono } from 'hono';
3
4import { env } from '../env.js';
5import { BUILD_AT, BUILD_SHA } from './health.js';
6import { adminStats, listProjects, listUsers } from '../services/admin.js';
7import { listUsageEvents } from '../services/usage-admin.js';
8import { listRecentSnapshotsAcrossProjects } from '../services/snapshots.js';
9
10/**
11 * admin.flndrn.com dashboard API. This is the cross-product operator
12 * console at admin.flndrn.com probing briven as one of its registered
13 * child projects — distinct from the in-product operator dashboard
14 * served by routes/admin.ts (session + step-up MFA at /v1/admin/*).
15 *
16 * Contract (confirmed against the admin.flndrn.com consumer):
17 * GET /api/admin/v1/manifest Bearer → 200 { sections: [...] }
18 * GET /api/admin/v1/ping Bearer → 200 { ok: true }
19 * GET /api/admin/v1/summary Bearer → 200 KPIs
20 * GET /api/admin/v1/users Bearer → 200 { users: [...] }
21 * GET /api/admin/v1/projects Bearer → 200 { projects: [...] }
22 * `/api/admin/*` is mounted as an alias so the consumer's v1-then-bare
23 * fallback resolves to the same handlers.
24 *
25 * Auth: a single shared bearer key in BRIVEN_ADMIN_API_KEY, compared in
26 * constant time (mirrors routes/internal.ts). The value must equal the
27 * key admin.flndrn.com has registered for briven. Fails safe:
28 * - key unset → 503 admin_not_configured (never crashes)
29 * - missing/wrong bearer → 401 unauthorized
30 * No DB or platform state is touched before the auth gate, so an
31 * unauthenticated probe of any route returns 401 (the consumer's
32 * security-gate test) without a query running.
33 */
34export const adminManifestRouter = new Hono();
35
36/**
37 * Sections the manifest advertises. Every entry here is backed by a list
38 * (or summary) endpoint implemented below with a REAL query — no
39 * placeholders.
40 *
41 * `usage` reads the global `usage_events` table (one indexed query) and
42 * `snapshots` composes a bounded cross-project scan of the per-tenant
43 * `_briven_snapshots` registries (two capped round-trips, see
44 * services/snapshots.ts:listRecentSnapshotsAcrossProjects). Both are
45 * served by real queries.
46 */
47const SECTIONS = [
48 {
49 key: 'summary',
50 title: 'Overview',
51 icon: 'dashboard',
52 description: 'Top-line platform counts: users, projects, deployments, open work.',
53 permission: 'dev.briven.read',
54 endpoints: [{ kind: 'detail', method: 'GET', path: '/api/admin/v1/summary' }],
55 },
56 {
57 key: 'users',
58 title: 'Users',
59 icon: 'groups',
60 description: 'Registered briven accounts with project counts and status.',
61 permission: 'dev.briven.read',
62 endpoints: [{ kind: 'list', method: 'GET', path: '/api/admin/v1/users' }],
63 },
64 {
65 key: 'projects',
66 title: 'Projects',
67 icon: 'inventory_2',
68 description: 'Active briven projects with tier and owning org.',
69 permission: 'dev.briven.read',
70 endpoints: [{ kind: 'list', method: 'GET', path: '/api/admin/v1/projects' }],
71 },
72 {
73 key: 'usage',
74 title: 'Usage',
75 icon: 'bar_chart',
76 description: 'Recent hourly usage rollups across projects with Polar push status.',
77 permission: 'dev.briven.read',
78 endpoints: [{ kind: 'list', method: 'GET', path: '/api/admin/v1/usage' }],
79 },
80 {
81 key: 'snapshots',
82 title: 'Snapshots',
83 icon: 'history',
84 description: 'Most recent data snapshots taken across all projects.',
85 permission: 'dev.briven.read',
86 endpoints: [{ kind: 'list', method: 'GET', path: '/api/admin/v1/snapshots' }],
87 },
88] as const;
89
90/**
91 * Bearer gate. Returns 503 when the key is unset (fail-safe: the routes
92 * simply aren't usable yet, the process never crashes) and 401 on a
93 * missing or wrong token. Ordered key-first so an unconfigured deploy and
94 * an unauthenticated probe are both answered before any handler runs.
95 */
96adminManifestRouter.use('/api/admin/*', async (c, next) => {
97 const expected = env.BRIVEN_ADMIN_API_KEY;
98 if (!expected) {
99 return c.json({ code: 'admin_not_configured', message: 'admin API not configured' }, 503);
100 }
101 const auth = c.req.header('authorization');
102 const token = auth?.startsWith('Bearer ') ? auth.slice('Bearer '.length).trim() : null;
103 if (!token || !constantTimeEqual(token, expected)) {
104 return c.json({ code: 'unauthorized', message: 'unauthorized' }, 401);
105 }
106 await next();
107 return;
108});
109
110adminManifestRouter.get('/api/admin/v1/manifest', (c) => c.json({ sections: SECTIONS }));
111adminManifestRouter.get('/api/admin/manifest', (c) => c.json({ sections: SECTIONS }));
112
113adminManifestRouter.get('/api/admin/v1/ping', (c) =>
114 c.json({ ok: true, service: 'briven', ts: new Date().toISOString() }),
115);
116adminManifestRouter.get('/api/admin/ping', (c) =>
117 c.json({ ok: true, service: 'briven', ts: new Date().toISOString() }),
118);
119
120/**
121 * Top-line KPIs. Reuses services/admin.ts adminStats() verbatim — the
122 * same rollups the in-product operator dashboard renders — plus the
123 * running build identity so the dashboard can show what's deployed.
124 */
125adminManifestRouter.get('/api/admin/v1/summary', async (c) => {
126 const stats = await adminStats();
127 return c.json({
128 service: 'briven',
129 buildSha: BUILD_SHA,
130 buildAt: BUILD_AT === 'dev' ? null : BUILD_AT,
131 env: env.BRIVEN_ENV,
132 ...stats,
133 });
134});
135
136/**
137 * Users list — capped at 200, newest first. Reuses listUsers() so the
138 * shape (id, email, name, emailVerified, isAdmin, suspendedAt, createdAt,
139 * projectCount) matches the in-product admin users page exactly.
140 */
141adminManifestRouter.get('/api/admin/v1/users', async (c) => {
142 const rows = await listUsers(200);
143 return c.json({ users: rows });
144});
145
146/**
147 * Projects list — capped at 500, newest first. Reuses listProjects().
148 */
149adminManifestRouter.get('/api/admin/v1/projects', async (c) => {
150 const rows = await listProjects(500);
151 return c.json({ projects: rows });
152});
153
154/**
155 * Usage events — recent hourly rollups across every project, newest
156 * first. Reuses listUsageEvents() (the same query the in-product admin
157 * "Usage" page runs), so the shape (id, projectId, metric, periodStart,
158 * value, polarPushStatus, polarPushedAt, createdAt) matches exactly.
159 * `?limit=` (1-1000, default 200) and `?status=` (pending|pushed|skipped)
160 * are honoured; the unique index covers the order-by + filter.
161 */
162adminManifestRouter.get('/api/admin/v1/usage', async (c) => {
163 const limitRaw = c.req.query('limit');
164 const limit = limitRaw ? Number.parseInt(limitRaw, 10) : 200;
165 const status = c.req.query('status');
166 const rows = await listUsageEvents({ limit: Number.isNaN(limit) ? 200 : limit, status });
167 return c.json({ events: rows });
168});
169
170/**
171 * Snapshots — the most recent data snapshots taken across all projects,
172 * newest first. Snapshots have no global table (each project owns a
173 * `_briven_snapshots` registry inside its own data-plane schema), so this
174 * reuses listRecentSnapshotsAcrossProjects(): a bounded, catalog-driven
175 * UNION over those registries — two capped round-trips, no fan-out loop.
176 * `?limit=` (1-1000, default 200). Each row carries its owning `schema`
177 * (`proj_<id>`) so the operator can see which project it belongs to.
178 */
179adminManifestRouter.get('/api/admin/v1/snapshots', async (c) => {
180 const limitRaw = c.req.query('limit');
181 const limit = limitRaw ? Number.parseInt(limitRaw, 10) : 200;
182 const rows = await listRecentSnapshotsAcrossProjects(Number.isNaN(limit) ? 200 : limit);
183 return c.json({ snapshots: rows });
184});