internal.ts149 lines · main
1import { constantTimeEqual } from '@briven/shared';
2import { Hono } from 'hono';
3
4import { env } from '../env.js';
5import { resolveApiKey } from '../services/api-keys.js';
6import { getDeployment, getDeploymentBundle } from '../services/deployments.js';
7import { invoke } from '../services/invoke.js';
8import { getPlainEnvForProject } from '../services/project-env.js';
9import { getProjectTier, TIERS } from '../services/tiers.js';
10import { runDueAutoSnapshots } from '../workers/auto-snapshot.js';
11
12/**
13 * Internal endpoints — only the runtime host calls these, authenticated via
14 * BRIVEN_RUNTIME_SHARED_SECRET. Never exposed to the public dashboard or
15 * the customer SDK.
16 */
17export const internalRouter = new Hono();
18
19internalRouter.use('/v1/internal/*', async (c, next) => {
20 const expected = env.BRIVEN_RUNTIME_SHARED_SECRET;
21 if (!expected) return c.json({ code: 'not_configured', message: 'runtime secret missing' }, 503);
22 const auth = c.req.header('authorization');
23 const token = auth?.startsWith('Bearer ') ? auth.slice('Bearer '.length).trim() : null;
24 if (!token || !constantTimeEqual(token, expected)) {
25 return c.json({ code: 'unauthorized' }, 401);
26 }
27 await next();
28 return;
29});
30
31internalRouter.post('/v1/internal/projects/:projectId/verify-key', async (c) => {
32 // Realtime asks whether a project key (brk_…) is valid FOR this project, so a
33 // browser's WebSocket token only ever opens its own project. apps/api owns
34 // the api-key table, so the check lives here (reuses resolveApiKey, the same
35 // resolver requireProjectAuth uses).
36 const projectId = c.req.param('projectId');
37 const body = (await c.req.json().catch(() => null)) as { token?: string } | null;
38 const token = body?.token;
39 if (!token || !token.startsWith('brk_')) return c.json({ valid: false });
40 const resolved = await resolveApiKey(token);
41 return c.json({ valid: !!resolved && resolved.projectId === projectId });
42});
43
44internalRouter.get('/v1/internal/deployments/:projectId/:deploymentId/bundle', async (c) => {
45 // The projectId is included in the path so the runtime can verify the
46 // deployment belongs to the project it thinks it does — defense in depth
47 // against a runtime bug that could otherwise serve cross-project code.
48 const projectId = c.req.param('projectId');
49 const deploymentId = c.req.param('deploymentId');
50
51 const deployment = await getDeployment(projectId, deploymentId);
52 const bundle = await getDeploymentBundle(deploymentId);
53 return c.json({
54 deploymentId: deployment.id,
55 projectId: deployment.projectId,
56 functionNames: (deployment.functionNames as string[] | null) ?? [],
57 bundle: bundle ?? {},
58 });
59});
60
61/**
62 * Decrypted env vars for a project — consumed by the runtime when spawning
63 * an isolate. Body is a flat `{ KEY: "value", ... }` object. The shared
64 * secret middleware above is the only auth; never expose this on a path
65 * the dashboard or SDK can reach.
66 */
67internalRouter.get('/v1/internal/projects/:projectId/env', async (c) => {
68 const projectId = c.req.param('projectId');
69 const values = await getPlainEnvForProject(projectId);
70 return c.json(values);
71});
72
73/**
74 * Internal invoke for system callers (realtime fan-out). The public route
75 * at /v1/projects/:id/functions/:name requires a session/api-key with
76 * developer role; realtime holds neither — it authenticates as the runtime
77 * via BRIVEN_RUNTIME_SHARED_SECRET (already gated above) and invokes on
78 * behalf of an anonymous system identity. The invoke service threads
79 * `auth: null` through to the runtime, which records the call in audit
80 * logs as a system-originated invocation.
81 */
82internalRouter.post('/v1/internal/projects/:projectId/functions/:functionName', async (c) => {
83 const projectId = c.req.param('projectId');
84 const functionName = c.req.param('functionName');
85 const requestId = c.req.header('x-request-id') ?? crypto.randomUUID();
86
87 const raw = await c.req.text();
88 let args: unknown = null;
89 if (raw.length > 0) {
90 try {
91 args = JSON.parse(raw);
92 } catch {
93 return c.json({ code: 'invalid_json', message: 'request body is not valid json' }, 400);
94 }
95 }
96
97 const result = await invoke({
98 projectId,
99 functionName,
100 args,
101 requestId,
102 auth: null,
103 });
104 const status = result.ok ? 200 : 500;
105 return c.json(result, status);
106});
107
108/**
109 * Drive the auto-snapshot worker once on demand. The in-process timer
110 * (workers/auto-snapshot.ts, armed at boot) is the primary trigger; this
111 * endpoint lets an external scheduler (e.g. a system cron hitting the API
112 * over the internal network with BRIVEN_RUNTIME_SHARED_SECRET) run the due
113 * scan in deployments that prefer cron over the in-process timer. Idempotent
114 * and safe to call alongside the timer — the per-project claim guard prevents
115 * any project being snapshotted twice for the same slot.
116 *
117 * curl -XPOST -H "authorization: Bearer $BRIVEN_RUNTIME_SHARED_SECRET" \
118 * https://api.internal/v1/internal/auto-snapshots/run
119 */
120internalRouter.post('/v1/internal/auto-snapshots/run', async (c) => {
121 const summary = await runDueAutoSnapshots(new Date());
122 return c.json({ ok: true, ...summary });
123});
124
125/**
126 * Resolve a project's tier-aware limits. Used by the realtime service on
127 * first subscribe per project to enforce TIERS.concurrentSubscriptions
128 * instead of the platform-wide BRIVEN_REALTIME_MAX_SUBS_PER_PROJECT ceiling.
129 * Returns `tier: null` when the project doesn't exist; callers treat null
130 * as "deny by default" rather than falling back to the platform cap.
131 */
132internalRouter.get('/v1/internal/projects/:projectId/limits', async (c) => {
133 const projectId = c.req.param('projectId');
134 const tier = await getProjectTier(projectId);
135 if (!tier) {
136 return c.json({ projectId, tier: null, limits: null });
137 }
138 const limits = TIERS[tier];
139 return c.json({
140 projectId,
141 tier,
142 limits: {
143 concurrentSubscriptions: limits.concurrentSubscriptions,
144 invokesPerMonth: limits.invokesPerMonth,
145 storageBytes: limits.storageBytes,
146 connectionSecondsPerMonth: limits.connectionSecondsPerMonth,
147 },
148 });
149});