internal.ts149 lines · main
| 1 | import { constantTimeEqual } from '@briven/shared'; |
| 2 | import { Hono } from 'hono'; |
| 3 | |
| 4 | import { env } from '../env.js'; |
| 5 | import { resolveApiKey } from '../services/api-keys.js'; |
| 6 | import { getDeployment, getDeploymentBundle } from '../services/deployments.js'; |
| 7 | import { invoke } from '../services/invoke.js'; |
| 8 | import { getPlainEnvForProject } from '../services/project-env.js'; |
| 9 | import { getProjectTier, TIERS } from '../services/tiers.js'; |
| 10 | import { 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 | */ |
| 17 | export const internalRouter = new Hono(); |
| 18 | |
| 19 | internalRouter.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 | |
| 31 | internalRouter.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 | |
| 44 | internalRouter.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 | */ |
| 67 | internalRouter.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 | */ |
| 82 | internalRouter.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 | */ |
| 120 | internalRouter.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 | */ |
| 132 | internalRouter.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 | }); |