index.ts615 lines · main
| 1 | import { resolve } from 'node:path'; |
| 2 | |
| 3 | import { constantTimeEqual, resolveBuildIdentity } from '@briven/shared'; |
| 4 | import { createLogger } from '@briven/shared/observability'; |
| 5 | import { z } from 'zod'; |
| 6 | |
| 7 | import { env } from './env.js'; |
| 8 | import { incCounter, registerGauge, renderPrometheus } from './metrics.js'; |
| 9 | import { PollManager } from './poll-manager.js'; |
| 10 | import { SubscriptionRegistry } from './subscription-registry.js'; |
| 11 | |
| 12 | const BOOT_TIME = new Date().toISOString(); |
| 13 | // apps/realtime runs from /app/apps/realtime; the repo root is two |
| 14 | // levels up. |
| 15 | const { buildSha: BUILD_SHA, buildAt: BUILD_AT } = resolveBuildIdentity( |
| 16 | resolve(process.cwd(), '../../.git'), |
| 17 | ); |
| 18 | |
| 19 | const log = createLogger({ |
| 20 | service: 'realtime', |
| 21 | env: env.BRIVEN_ENV, |
| 22 | level: env.BRIVEN_LOG_LEVEL, |
| 23 | }); |
| 24 | |
| 25 | /** |
| 26 | * Reactive WebSocket service. |
| 27 | * |
| 28 | * Wire protocol (JSON frames over a single WS connection): |
| 29 | * client → server: |
| 30 | * {type:'subscribe', subscriptionId, projectId, functionName, args} |
| 31 | * {type:'unsubscribe', subscriptionId} |
| 32 | * server → client: |
| 33 | * {type:'hello', protocol:1} |
| 34 | * {type:'data', subscriptionId, ok, value | code/message, durationMs} |
| 35 | * {type:'unsubscribed', subscriptionId} |
| 36 | * {type:'error', code} |
| 37 | * |
| 38 | * Subscription lifecycle: |
| 39 | * 1. Client subscribes → realtime calls apps/api invoke endpoint |
| 40 | * 2. Response includes `touchedTables`; realtime watches those tables |
| 41 | * for changes (Phase 2: Dolt commit-diff polling) |
| 42 | * 3. Change detected → realtime re-invokes every subscription that |
| 43 | * touched that table, sends a fresh `data` frame |
| 44 | * 4. Unsubscribe / disconnect → drop the subscription, decrement channel |
| 45 | * refcounts, stop polling the project when its last channel is removed |
| 46 | * |
| 47 | * @README-BRIVEN Phase 2: Postgres LISTEN/NOTIFY replaced with DoltGres |
| 48 | * commit-diff polling. The PollManager queries `DOLT_HASHOF('HEAD')` |
| 49 | * for each active project at the configured interval; when the hash |
| 50 | * changes it fires every channel belonging to that project. |
| 51 | */ |
| 52 | |
| 53 | const subscribeSchema = z.object({ |
| 54 | type: z.literal('subscribe'), |
| 55 | subscriptionId: z.string().min(1), |
| 56 | projectId: z.string().min(1), |
| 57 | functionName: z.string().min(1), |
| 58 | args: z.unknown(), |
| 59 | }); |
| 60 | |
| 61 | const unsubscribeSchema = z.object({ |
| 62 | type: z.literal('unsubscribe'), |
| 63 | subscriptionId: z.string().min(1), |
| 64 | }); |
| 65 | |
| 66 | const clientMessage = z.discriminatedUnion('type', [subscribeSchema, unsubscribeSchema]); |
| 67 | |
| 68 | interface Subscription { |
| 69 | subscriptionId: string; |
| 70 | projectId: string; |
| 71 | functionName: string; |
| 72 | args: unknown; |
| 73 | channels: Set<string>; |
| 74 | send: (frame: Record<string, unknown>) => void; |
| 75 | // Wall-clock ms at subscription open. Closed-out subs flush their |
| 76 | // duration into closedSubSecondsByProject (cumulative billable |
| 77 | // seconds-of-connection per project). Active subs contribute to the |
| 78 | // pull-based gauge below — at scrape time we add (now - startedAt) |
| 79 | // for every still-live sub so the metric never under-counts. |
| 80 | startedAt: number; |
| 81 | } |
| 82 | |
| 83 | const subscriptions = new Map<string, Subscription>(); // subscriptionId → sub |
| 84 | const registry = new SubscriptionRegistry(); // channel ↔ subId, ref-counted |
| 85 | const sockets = new WeakMap<object, Set<string>>(); // ws → set of subscriptionIds it owns |
| 86 | // projectId → live sub count. Maintained inline with the subscriptions |
| 87 | // map so the per-project cap can be enforced in O(1) without scanning. |
| 88 | const subsByProject = new Map<string, number>(); |
| 89 | |
| 90 | // Cumulative connection-seconds, per project, from subs that have |
| 91 | // already closed. Phase 3 usage-metering pillar #3 — Polar metering |
| 92 | // push reads this via /metrics. Kept in memory (resets on process |
| 93 | // restart); a durable rollup is the natural follow-up. |
| 94 | const closedSubSecondsByProject = new Map<string, number>(); |
| 95 | |
| 96 | /** |
| 97 | * Tier-aware per-project subscription cap. Resolved from the api's internal |
| 98 | * /v1/internal/projects/:id/limits endpoint on first subscribe per project, |
| 99 | * cached for 5 min so tier changes propagate within one window. Returns |
| 100 | * null when the project doesn't exist OR the api is unreachable — caller |
| 101 | * falls back to the env ceiling so a metadata outage doesn't lock out |
| 102 | * legitimate subscriptions. |
| 103 | */ |
| 104 | const TIER_CAP_CACHE_TTL_MS = 5 * 60_000; |
| 105 | interface CapCacheEntry { |
| 106 | cap: number | null; |
| 107 | expiresAt: number; |
| 108 | } |
| 109 | const capByProject = new Map<string, CapCacheEntry>(); |
| 110 | |
| 111 | async function resolveProjectCap(projectId: string): Promise<number | null> { |
| 112 | const now = Date.now(); |
| 113 | const hit = capByProject.get(projectId); |
| 114 | if (hit && hit.expiresAt > now) return hit.cap; |
| 115 | if (!env.BRIVEN_RUNTIME_SHARED_SECRET) return null; |
| 116 | try { |
| 117 | const url = `${env.BRIVEN_API_INTERNAL_URL}/v1/internal/projects/${encodeURIComponent( |
| 118 | projectId, |
| 119 | )}/limits`; |
| 120 | const res = await fetch(url, { |
| 121 | headers: { authorization: `Bearer ${env.BRIVEN_RUNTIME_SHARED_SECRET}` }, |
| 122 | }); |
| 123 | if (!res.ok) { |
| 124 | capByProject.set(projectId, { cap: null, expiresAt: now + TIER_CAP_CACHE_TTL_MS }); |
| 125 | return null; |
| 126 | } |
| 127 | const body = (await res.json()) as { |
| 128 | limits: { concurrentSubscriptions?: number } | null; |
| 129 | }; |
| 130 | const cap = body.limits?.concurrentSubscriptions ?? null; |
| 131 | capByProject.set(projectId, { cap, expiresAt: now + TIER_CAP_CACHE_TTL_MS }); |
| 132 | return cap; |
| 133 | } catch (err) { |
| 134 | log.warn('realtime_resolve_cap_failed', { |
| 135 | projectId, |
| 136 | message: err instanceof Error ? err.message : String(err), |
| 137 | }); |
| 138 | return null; |
| 139 | } |
| 140 | } |
| 141 | |
| 142 | function dbNameFor(projectId: string): string { |
| 143 | return `proj_${projectId.replace(/[^a-zA-Z0-9]/g, '_').toLowerCase()}`; |
| 144 | } |
| 145 | |
| 146 | function channelFor(projectId: string, table: string): string { |
| 147 | return `briven_${dbNameFor(projectId)}_${table}`; |
| 148 | } |
| 149 | |
| 150 | // ── PollManager — Dolt commit-diff polling (Phase 2) ────────────── |
| 151 | // Replaces Postgres LISTEN/NOTIFY. Each project is polled for HEAD |
| 152 | // changes; when a change is detected every channel for that project |
| 153 | // fires via the existing `fireChannel` path. |
| 154 | |
| 155 | const pollManager = new PollManager(registry, fireChannel, env.BRIVEN_REALTIME_POLL_MS); |
| 156 | |
| 157 | // Per-project refcount — when the first channel is attached for a |
| 158 | // project we call pollManager.addProject; when the last is detached |
| 159 | // we call pollManager.removeProject. Keeps the poll set tight. |
| 160 | const projectRefCount = new Map<string, number>(); |
| 161 | |
| 162 | function projectIdFromChannel(channel: string): string | null { |
| 163 | // channels are `briven_<dbName>_<table>` where dbName = `proj_<sanitizedId>`. |
| 164 | // Strip the `briven_` prefix and the trailing `_<table>` suffix to recover |
| 165 | // the database name, then strip `proj_` to recover the raw project id. |
| 166 | const withoutPrefix = channel.slice('briven_'.length); |
| 167 | const lastUnderscore = withoutPrefix.lastIndexOf('_'); |
| 168 | if (lastUnderscore === -1) return null; |
| 169 | const dbName = withoutPrefix.slice(0, lastUnderscore); |
| 170 | // dbName is `proj_<sanitized>`. The project id is everything after `proj_`. |
| 171 | // Since project ids are sanitised (alphanumeric + underscores only), this is |
| 172 | // a reversible mapping (the sanitised id is what's stored in the db name). |
| 173 | return dbName.slice('proj_'.length); |
| 174 | } |
| 175 | |
| 176 | async function startListen(channel: string): Promise<void> { |
| 177 | const pid = projectIdFromChannel(channel); |
| 178 | if (!pid) return; |
| 179 | const prev = projectRefCount.get(pid) ?? 0; |
| 180 | projectRefCount.set(pid, prev + 1); |
| 181 | if (prev === 0) { |
| 182 | // First channel for this project — start polling it. |
| 183 | pollManager.addProject(pid); |
| 184 | log.info('realtime_project_watch_started', { projectId: pid, channel }); |
| 185 | } |
| 186 | } |
| 187 | |
| 188 | async function stopListen(channel: string): Promise<void> { |
| 189 | const pid = projectIdFromChannel(channel); |
| 190 | if (!pid) return; |
| 191 | const prev = projectRefCount.get(pid) ?? 0; |
| 192 | if (prev <= 1) { |
| 193 | projectRefCount.delete(pid); |
| 194 | pollManager.removeProject(pid); |
| 195 | log.info('realtime_project_watch_stopped', { projectId: pid, channel }); |
| 196 | } else { |
| 197 | projectRefCount.set(pid, prev - 1); |
| 198 | } |
| 199 | } |
| 200 | |
| 201 | async function fireChannel(channel: string): Promise<void> { |
| 202 | // SubscriptionRegistry.subsForChannel returns a snapshot — safe to iterate |
| 203 | // while attach/detach inside invokeOnce mutates the channel's live set. |
| 204 | const snapshot = registry.subsForChannel(channel); |
| 205 | if (snapshot.length === 0) return; |
| 206 | incCounter('briven_realtime_notifies_total'); |
| 207 | for (const subId of snapshot) { |
| 208 | const sub = subscriptions.get(subId); |
| 209 | if (!sub) continue; |
| 210 | const result = await invokeOnce(sub); |
| 211 | incCounter('briven_realtime_reinvoke_total', { |
| 212 | outcome: (result as { ok?: boolean }).ok ? 'ok' : 'err', |
| 213 | }); |
| 214 | sub.send({ type: 'data', subscriptionId: sub.subscriptionId, ...result }); |
| 215 | } |
| 216 | } |
| 217 | |
| 218 | async function invokeOnce(sub: Subscription): Promise<Record<string, unknown>> { |
| 219 | // why: the public /v1/projects/:id/functions/:name route requires a |
| 220 | // session or api-key with developer role. Realtime is a system caller, |
| 221 | // not a user — it authenticates as the runtime via the shared secret |
| 222 | // and uses the internal invoke endpoint, which threads `auth: null`. |
| 223 | const url = `${env.BRIVEN_API_INTERNAL_URL}/v1/internal/projects/${sub.projectId}/functions/${sub.functionName}`; |
| 224 | const headers: Record<string, string> = { 'content-type': 'application/json' }; |
| 225 | if (env.BRIVEN_RUNTIME_SHARED_SECRET) { |
| 226 | headers['authorization'] = `Bearer ${env.BRIVEN_RUNTIME_SHARED_SECRET}`; |
| 227 | } |
| 228 | try { |
| 229 | const res = await fetch(url, { |
| 230 | method: 'POST', |
| 231 | headers, |
| 232 | body: JSON.stringify(sub.args ?? {}), |
| 233 | }); |
| 234 | if (!res.ok) { |
| 235 | const text = await res.text().catch(() => ''); |
| 236 | return { ok: false, code: 'invoke_failed', message: text || `http ${res.status}` }; |
| 237 | } |
| 238 | const body = (await res.json()) as { |
| 239 | ok: boolean; |
| 240 | value?: unknown; |
| 241 | code?: string; |
| 242 | message?: string; |
| 243 | durationMs?: number; |
| 244 | touchedTables?: string[]; |
| 245 | }; |
| 246 | // Update channel subscriptions to match what the executor actually |
| 247 | // touched. Adding new ones is idempotent; removing dropped ones keeps |
| 248 | // the LISTEN set tight. |
| 249 | const next = new Set((body.touchedTables ?? []).map((t) => channelFor(sub.projectId, t))); |
| 250 | for (const ch of sub.channels) { |
| 251 | if (!next.has(ch)) await detachSubFromChannel(sub.subscriptionId, ch); |
| 252 | } |
| 253 | for (const ch of next) { |
| 254 | if (!sub.channels.has(ch)) await attachSubToChannel(sub.subscriptionId, ch); |
| 255 | } |
| 256 | sub.channels = next; |
| 257 | return body as unknown as Record<string, unknown>; |
| 258 | } catch (err) { |
| 259 | return { |
| 260 | ok: false, |
| 261 | code: 'invoke_error', |
| 262 | message: err instanceof Error ? err.message : 'unknown', |
| 263 | }; |
| 264 | } |
| 265 | } |
| 266 | |
| 267 | async function attachSubToChannel(subId: string, channel: string): Promise<void> { |
| 268 | if (registry.attach(subId, channel)) await startListen(channel); |
| 269 | } |
| 270 | |
| 271 | async function detachSubFromChannel(subId: string, channel: string): Promise<void> { |
| 272 | if (registry.detach(subId, channel)) await stopListen(channel); |
| 273 | } |
| 274 | |
| 275 | async function dropSubscription(subId: string): Promise<void> { |
| 276 | const sub = subscriptions.get(subId); |
| 277 | if (!sub) return; |
| 278 | for (const ch of sub.channels) await detachSubFromChannel(subId, ch); |
| 279 | // Flush this sub's connection seconds into the per-project tally so |
| 280 | // the metric survives the sub teardown. (Math.max guards against |
| 281 | // a clock skew producing a negative — postgres NTP is solid here |
| 282 | // but the realtime container's clock isn't authoritative.) |
| 283 | const seconds = Math.max(0, (Date.now() - sub.startedAt) / 1000); |
| 284 | closedSubSecondsByProject.set( |
| 285 | sub.projectId, |
| 286 | (closedSubSecondsByProject.get(sub.projectId) ?? 0) + seconds, |
| 287 | ); |
| 288 | // Decrement the per-project counter. Map entry deleted at 0 so the |
| 289 | // map doesn't grow unbounded with dormant projects. |
| 290 | const next = (subsByProject.get(sub.projectId) ?? 0) - 1; |
| 291 | if (next <= 0) subsByProject.delete(sub.projectId); |
| 292 | else subsByProject.set(sub.projectId, next); |
| 293 | subscriptions.delete(subId); |
| 294 | } |
| 295 | |
| 296 | /** |
| 297 | * Authorise an incoming WS upgrade. Refuses ALL requests when the shared |
| 298 | * secret is unset — prior behaviour returned `true` (fail-open), which |
| 299 | * combined with deployments that forgot to set the env var produced an |
| 300 | * unauthenticated WebSocket service. apps/api/src/routes/internal.ts |
| 301 | * already does the right thing (503 when secret unset); this matches. |
| 302 | */ |
| 303 | /** |
| 304 | * Extract the bearer token from a WS upgrade request. Accepts the Authorization |
| 305 | * header (server-to-server) OR a `?token=` / `?bearer=` query param — browsers |
| 306 | * CANNOT set headers on a WebSocket, so the SDK passes the token in the URL. |
| 307 | */ |
| 308 | function extractToken(req: Request): string | null { |
| 309 | const auth = req.headers.get('authorization'); |
| 310 | const headerToken = auth?.startsWith('Bearer ') ? auth.slice('Bearer '.length).trim() : null; |
| 311 | if (headerToken) return headerToken; |
| 312 | try { |
| 313 | const p = new URL(req.url).searchParams; |
| 314 | return p.get('token') ?? p.get('bearer'); |
| 315 | } catch { |
| 316 | return null; |
| 317 | } |
| 318 | } |
| 319 | |
| 320 | /** |
| 321 | * Authorise an incoming WS upgrade. Accepts either the global shared secret |
| 322 | * (trusted server-to-server callers) or a structurally-valid project key |
| 323 | * (`brk_…`). A project key's validity FOR A SPECIFIC PROJECT is verified at |
| 324 | * SUBSCRIBE time (see verifyProjectKey) — the projectId isn't known until the |
| 325 | * subscribe frame arrives. Refuses ALL requests when the shared secret is unset |
| 326 | * (fail-closed), matching apps/api/src/routes/internal.ts. |
| 327 | */ |
| 328 | function authorise(req: Request): boolean { |
| 329 | if (!env.BRIVEN_RUNTIME_SHARED_SECRET) return false; |
| 330 | const token = extractToken(req); |
| 331 | if (!token) return false; |
| 332 | if (constantTimeEqual(token, env.BRIVEN_RUNTIME_SHARED_SECRET)) return true; |
| 333 | return token.startsWith('brk_'); |
| 334 | } |
| 335 | |
| 336 | /** |
| 337 | * Verify a project key (`brk_…`) is valid FOR `projectId` by asking apps/api |
| 338 | * (which owns the api-key table) over the shared-secret internal channel. |
| 339 | * Cached briefly so a reconnect storm doesn't hammer the control plane. |
| 340 | */ |
| 341 | const keyVerifyCache = new Map<string, { ok: boolean; exp: number }>(); |
| 342 | async function verifyProjectKey(projectId: string, token: string): Promise<boolean> { |
| 343 | if (!token.startsWith('brk_')) return false; |
| 344 | const cacheKey = `${projectId}:${token}`; |
| 345 | const now = Date.now(); |
| 346 | const hit = keyVerifyCache.get(cacheKey); |
| 347 | if (hit && hit.exp > now) return hit.ok; |
| 348 | let ok = false; |
| 349 | try { |
| 350 | const res = await fetch( |
| 351 | `${env.BRIVEN_API_INTERNAL_URL}/v1/internal/projects/${projectId}/verify-key`, |
| 352 | { |
| 353 | method: 'POST', |
| 354 | headers: { |
| 355 | 'content-type': 'application/json', |
| 356 | ...(env.BRIVEN_RUNTIME_SHARED_SECRET |
| 357 | ? { authorization: `Bearer ${env.BRIVEN_RUNTIME_SHARED_SECRET}` } |
| 358 | : {}), |
| 359 | }, |
| 360 | body: JSON.stringify({ token }), |
| 361 | }, |
| 362 | ); |
| 363 | if (res.ok) ok = ((await res.json()) as { valid?: boolean }).valid === true; |
| 364 | } catch { |
| 365 | ok = false; |
| 366 | } |
| 367 | // Short positive TTL (keys can be revoked); shorter negative TTL. |
| 368 | keyVerifyCache.set(cacheKey, { ok, exp: now + (ok ? 60_000 : 5_000) }); |
| 369 | return ok; |
| 370 | } |
| 371 | |
| 372 | if (!env.BRIVEN_RUNTIME_SHARED_SECRET) { |
| 373 | log.warn( |
| 374 | 'realtime_boot_warning: BRIVEN_RUNTIME_SHARED_SECRET is unset — every WS upgrade will be rejected with 401 until configured', |
| 375 | ); |
| 376 | } |
| 377 | |
| 378 | // Pull-based gauges — snapshot at scrape time. Keeps the subscribe / |
| 379 | // unsubscribe hot paths free of gauge bookkeeping. |
| 380 | registerGauge('briven_realtime_subscriptions_active', () => [ |
| 381 | { labels: {}, value: subscriptions.size }, |
| 382 | ]); |
| 383 | registerGauge('briven_realtime_channels_active', () => [ |
| 384 | { labels: {}, value: registry.channelCount }, |
| 385 | ]); |
| 386 | |
| 387 | // Per-project cumulative connection-seconds. The closed-sub tally is the |
| 388 | // authoritative base; we add (now - startedAt) for every still-live sub |
| 389 | // so a scrape during a long-running subscription doesn't show 0. Polar |
| 390 | // metering reads this via /metrics on the aggregation cron in apps/api. |
| 391 | registerGauge('briven_realtime_connection_seconds_total', () => { |
| 392 | const out: { labels: { project: string }; value: number }[] = []; |
| 393 | const now = Date.now(); |
| 394 | const liveByProject = new Map<string, number>(); |
| 395 | for (const sub of subscriptions.values()) { |
| 396 | liveByProject.set( |
| 397 | sub.projectId, |
| 398 | (liveByProject.get(sub.projectId) ?? 0) + (now - sub.startedAt) / 1000, |
| 399 | ); |
| 400 | } |
| 401 | const projects = new Set<string>([ |
| 402 | ...closedSubSecondsByProject.keys(), |
| 403 | ...liveByProject.keys(), |
| 404 | ]); |
| 405 | for (const projectId of projects) { |
| 406 | const value = |
| 407 | (closedSubSecondsByProject.get(projectId) ?? 0) + (liveByProject.get(projectId) ?? 0); |
| 408 | out.push({ labels: { project: projectId }, value }); |
| 409 | } |
| 410 | return out; |
| 411 | }); |
| 412 | |
| 413 | // Record the data-plane DSN so the first subscription doesn't block on |
| 414 | // setup. When BRIVEN_DATA_PLANE_URL is unset the init is a no-op; |
| 415 | // PollManager opens per-project clients lazily and handles the absent |
| 416 | // DSN gracefully (polling stays disabled). |
| 417 | if (env.BRIVEN_DATA_PLANE_URL) { |
| 418 | pollManager.init(env.BRIVEN_DATA_PLANE_URL).catch((err) => { |
| 419 | log.error('realtime_poll_manager_init_failed', { |
| 420 | message: err instanceof Error ? err.message : String(err), |
| 421 | }); |
| 422 | }); |
| 423 | } |
| 424 | |
| 425 | log.info('realtime_boot', { |
| 426 | port: env.BRIVEN_REALTIME_PORT, |
| 427 | apiUrl: env.BRIVEN_API_INTERNAL_URL, |
| 428 | auth: env.BRIVEN_RUNTIME_SHARED_SECRET ? 'shared_secret' : 'rejecting_all', |
| 429 | poll: env.BRIVEN_DATA_PLANE_URL ? 'enabled' : 'disabled', |
| 430 | pollIntervalMs: env.BRIVEN_REALTIME_POLL_MS, |
| 431 | phase: 2, // @README-BRIVEN Phase 2: commit-diff polling live |
| 432 | }); |
| 433 | |
| 434 | interface SocketHandle { |
| 435 | send: (data: string) => void; |
| 436 | } |
| 437 | |
| 438 | export default { |
| 439 | port: env.BRIVEN_REALTIME_PORT, |
| 440 | fetch(req: Request, server: { upgrade: (req: Request, options?: { data?: unknown }) => boolean }) { |
| 441 | const url = new URL(req.url); |
| 442 | if (url.pathname === '/health') return Response.json({ status: 'ok', service: 'realtime' }); |
| 443 | if (url.pathname === '/ready') { |
| 444 | return Response.json({ |
| 445 | status: env.BRIVEN_DATA_PLANE_URL ? 'ready' : 'degraded', |
| 446 | poll: env.BRIVEN_DATA_PLANE_URL ? 'enabled' : 'disabled', |
| 447 | pollIntervalMs: env.BRIVEN_REALTIME_POLL_MS, |
| 448 | activeProjects: pollManager.projectCount, |
| 449 | }); |
| 450 | } |
| 451 | if (url.pathname === '/info') { |
| 452 | return Response.json({ |
| 453 | service: 'realtime', |
| 454 | env: env.BRIVEN_ENV, |
| 455 | buildSha: BUILD_SHA, |
| 456 | buildAt: BUILD_AT, |
| 457 | bootedAt: BOOT_TIME, |
| 458 | uptimeSec: Math.floor(process.uptime()), |
| 459 | }); |
| 460 | } |
| 461 | if (url.pathname === '/metrics') { |
| 462 | return new Response(renderPrometheus(), { |
| 463 | status: 200, |
| 464 | headers: { 'content-type': 'text/plain; version=0.0.4; charset=utf-8' }, |
| 465 | }); |
| 466 | } |
| 467 | if (url.pathname === '/v1/realtime/stats') { |
| 468 | // Operator-facing live snapshot. Shared-secret gated — same gate |
| 469 | // as /v1/subscribe so an unauth scrape can't enumerate project |
| 470 | // ids. Numbers are O(subscriptions.size), fine for the year-one |
| 471 | // 10k target; if it ever needs to scale further the natural |
| 472 | // aggregation is a periodic write to a stats endpoint on the api. |
| 473 | if (!authorise(req)) return Response.json({ code: 'unauthorized' }, { status: 401 }); |
| 474 | const byProject: { projectId: string; subscriptions: number }[] = []; |
| 475 | for (const [projectId, count] of subsByProject) { |
| 476 | byProject.push({ projectId, subscriptions: count }); |
| 477 | } |
| 478 | byProject.sort((a, b) => b.subscriptions - a.subscriptions); |
| 479 | const byChannel = registry.channelCounts(); |
| 480 | byChannel.sort((a, b) => b.subscriptions - a.subscriptions); |
| 481 | return Response.json({ |
| 482 | totalSubscriptions: subscriptions.size, |
| 483 | totalChannels: registry.channelCount, |
| 484 | limits: { |
| 485 | perWs: env.BRIVEN_REALTIME_MAX_SUBS_PER_WS, |
| 486 | perProject: env.BRIVEN_REALTIME_MAX_SUBS_PER_PROJECT, |
| 487 | }, |
| 488 | // byProject returns every active project — at year-one scale |
| 489 | // (~25 projects) the response stays under a kilobyte; we rely on |
| 490 | // sort-descending so dashboards can render top-N client-side. |
| 491 | // byChannel keeps its top-50 clamp since channel cardinality can |
| 492 | // explode with many tables × many subscribers. |
| 493 | byProject, |
| 494 | byChannel: byChannel.slice(0, 50), |
| 495 | }); |
| 496 | } |
| 497 | if (url.pathname === '/v1/subscribe') { |
| 498 | if (!authorise(req)) return Response.json({ code: 'unauthorized' }, { status: 401 }); |
| 499 | // Carry the upgrade token onto the socket so the subscribe handler can do |
| 500 | // the per-project authorization check once the projectId is known. |
| 501 | if (server.upgrade(req, { data: { token: extractToken(req) } })) return undefined; |
| 502 | return new Response('upgrade required', { status: 426 }); |
| 503 | } |
| 504 | return Response.json({ code: 'not_found' }, { status: 404 }); |
| 505 | }, |
| 506 | websocket: { |
| 507 | open(ws: SocketHandle) { |
| 508 | sockets.set(ws as unknown as object, new Set<string>()); |
| 509 | ws.send(JSON.stringify({ type: 'hello', protocol: 1 })); |
| 510 | }, |
| 511 | async message(ws: SocketHandle, raw: string | Buffer) { |
| 512 | const text = typeof raw === 'string' ? raw : raw.toString('utf8'); |
| 513 | let parsed: z.infer<typeof clientMessage>; |
| 514 | try { |
| 515 | parsed = clientMessage.parse(JSON.parse(text)); |
| 516 | } catch { |
| 517 | ws.send(JSON.stringify({ type: 'error', code: 'malformed_message' })); |
| 518 | return; |
| 519 | } |
| 520 | |
| 521 | const owned = sockets.get(ws as unknown as object); |
| 522 | if (!owned) return; |
| 523 | |
| 524 | if (parsed.type === 'unsubscribe') { |
| 525 | owned.delete(parsed.subscriptionId); |
| 526 | await dropSubscription(parsed.subscriptionId); |
| 527 | ws.send(JSON.stringify({ type: 'unsubscribed', subscriptionId: parsed.subscriptionId })); |
| 528 | return; |
| 529 | } |
| 530 | |
| 531 | // Per-project authorization. A connection authed with the global shared |
| 532 | // secret is a trusted server-to-server caller and may subscribe to any |
| 533 | // project. A connection authed with a project key (brk_…) may ONLY |
| 534 | // subscribe to the project that key belongs to — verified against |
| 535 | // apps/api. Without this, one browser token could read another tenant's |
| 536 | // data (the subscribe frame's projectId is otherwise unchecked). |
| 537 | const connToken = (ws as unknown as { data?: { token?: string } }).data?.token ?? null; |
| 538 | const trusted = |
| 539 | !!connToken && |
| 540 | !!env.BRIVEN_RUNTIME_SHARED_SECRET && |
| 541 | constantTimeEqual(connToken, env.BRIVEN_RUNTIME_SHARED_SECRET); |
| 542 | if (!trusted) { |
| 543 | const allowed = connToken ? await verifyProjectKey(parsed.projectId, connToken) : false; |
| 544 | if (!allowed) { |
| 545 | incCounter('briven_realtime_subscribe_rejected_total', { reason: 'forbidden_project' }); |
| 546 | ws.send( |
| 547 | JSON.stringify({ |
| 548 | type: 'error', |
| 549 | code: 'forbidden', |
| 550 | subscriptionId: parsed.subscriptionId, |
| 551 | }), |
| 552 | ); |
| 553 | return; |
| 554 | } |
| 555 | } |
| 556 | |
| 557 | if (owned.size >= env.BRIVEN_REALTIME_MAX_SUBS_PER_WS) { |
| 558 | // Defensive cap so a single client (bug or malicious) can't open |
| 559 | // unbounded subscriptions and degrade the service for everyone. |
| 560 | // The client sees an error frame referencing the offending sub |
| 561 | // id so it can correlate; the server-side counter has already |
| 562 | // ignored this sub, so unsubscribe isn't required. |
| 563 | incCounter('briven_realtime_subscribe_rejected_total', { reason: 'ws_limit' }); |
| 564 | ws.send( |
| 565 | JSON.stringify({ |
| 566 | type: 'error', |
| 567 | code: 'subscription_limit_ws', |
| 568 | subscriptionId: parsed.subscriptionId, |
| 569 | limit: env.BRIVEN_REALTIME_MAX_SUBS_PER_WS, |
| 570 | }), |
| 571 | ); |
| 572 | return; |
| 573 | } |
| 574 | const projectCount = subsByProject.get(parsed.projectId) ?? 0; |
| 575 | // Tier-aware cap when the api answers; env ceiling as the floor so |
| 576 | // a metadata outage doesn't lock out legitimate Team customers. |
| 577 | const tierCap = await resolveProjectCap(parsed.projectId); |
| 578 | const projectLimit = tierCap ?? env.BRIVEN_REALTIME_MAX_SUBS_PER_PROJECT; |
| 579 | if (projectCount >= projectLimit) { |
| 580 | incCounter('briven_realtime_subscribe_rejected_total', { reason: 'project_limit' }); |
| 581 | ws.send( |
| 582 | JSON.stringify({ |
| 583 | type: 'error', |
| 584 | code: 'subscription_limit_project', |
| 585 | subscriptionId: parsed.subscriptionId, |
| 586 | limit: projectLimit, |
| 587 | source: tierCap !== null ? 'tier' : 'env', |
| 588 | }), |
| 589 | ); |
| 590 | return; |
| 591 | } |
| 592 | |
| 593 | const sub: Subscription = { |
| 594 | subscriptionId: parsed.subscriptionId, |
| 595 | projectId: parsed.projectId, |
| 596 | functionName: parsed.functionName, |
| 597 | args: parsed.args, |
| 598 | channels: new Set<string>(), |
| 599 | send: (frame) => ws.send(JSON.stringify(frame)), |
| 600 | startedAt: Date.now(), |
| 601 | }; |
| 602 | subscriptions.set(sub.subscriptionId, sub); |
| 603 | owned.add(sub.subscriptionId); |
| 604 | subsByProject.set(sub.projectId, projectCount + 1); |
| 605 | const result = await invokeOnce(sub); |
| 606 | ws.send(JSON.stringify({ type: 'data', subscriptionId: sub.subscriptionId, ...result })); |
| 607 | }, |
| 608 | async close(ws: object) { |
| 609 | const owned = sockets.get(ws); |
| 610 | if (!owned) return; |
| 611 | for (const subId of owned) await dropSubscription(subId); |
| 612 | sockets.delete(ws); |
| 613 | }, |
| 614 | }, |
| 615 | }; |