@happyvertical/smrt-core 0.39.4 → 0.39.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/AGENTS.md CHANGED
@@ -125,7 +125,7 @@ The push companion to the change feed (`src/change-signals.ts` + the generated `
125
125
 
126
126
  - **Change-signal bus** (`src/change-signals.ts`): every framework save/delete that appends a durable feed row also publishes a coarse `ChangeSignal` `{ table, operation, rowId, tenantId, seq }` — **never a row payload** (authorization stays on the read path). Structurally mirrors the collection cache's notify/listen path. `subscribeToChangeSignals(db, listener) → unsubscribe`; `publishChangeSignal`/`broadcastChangeSignal`/the listener loop stay internal. `appendChange` now returns the allocated `seq` (was `void`); the signal carries it as a coarse resume cursor (`bumpChangeFeed` ignores the return). The publish runs only after the append SUCCEEDS (no signal without a durable feed row) and in its own log-and-swallow try/catch, so a signal problem never fails the user's write. `_smrt_*` writes never signal (the writer skips them). Delivery is synchronous per-listener with per-listener try/catch (one throwing SSE controller never blocks others); no per-subscriber queue — backpressure rides the platform `ReadableStream`.
127
127
  - **In-process + cross-replica**: locally-published and peer-received signals go through the SAME `deliverLocally` path. Cross-replica fan-out rides the db adapter's optional notification capability (`db.notifications`, a NEW `smrt_change_signals` channel distinct from the cache channel) with echo-avoidance by `PROCESS_ID`. No capability → in-process only, warn-once, **never an error, never blocks the write** (subscribers on other replicas fall back to cursor polling).
128
- - **Generated `_events` SSE route**: REST (`GET {basePath}/_events`, requires `authMiddleware`, otherwise 401 — fail-closed, per-model `api.public` does NOT apply; 405 non-GET; 503 no db or subscriber capacity reached) and SvelteKit (`{routesDir}/_events/+server.ts`, requires an authenticated principal on `locals`; opt out via `sveltekit.eventsRoute.enabled: false`; cap via `sveltekit.eventsRoute.maxSubscribers`, where `0` means unlimited). Tenant scope is captured ONCE at connection open (`resolveDispatchTenantScope`) and filtered server-side per signal via `signalVisibleToTenant` (same rule as `getChangesSince`'s tenant filter) before any byte hits the wire — delivery runs outside any tenant ALS context, so it must use the captured value. The stream lifecycle lives in `buildChangeEventStream(db, { cursor, tenantScope, heartbeatMs?, manifestHash? })` (exported; the SvelteKit route imports it so it stays thin): subscribe-before-catch-up (closes the gap window; overlap is deduped by the SSE `id:`/seq client-side), `retry: 3000`, optional connection-open `event: manifest` carrying `{ manifestHash }` for live contract detection, cursor catch-up via `getChangesSince` filtered by the CAPTURED scope (not re-resolved from ALS, so it matches the live-signal filter exactly and can't replay another tenant's rows) (`Last-Event-ID` header beats `?since=`; default = live-forward only; `resyncRequired` → `event: resync`), heartbeat (`DEFAULT_EVENTS_HEARTBEAT_MS` = 15s), and `cancel()` teardown (clears heartbeat + unsubscribes). SSE change frame: `id: <seq>\nevent: change\ndata: {table,operation,rowId,tenantId}\n\n` — seq is ONLY in the `id:` line, never the data JSON. Subscriber cap default is `DEFAULT_EVENTS_MAX_SUBSCRIBERS` = 1000; over-cap connections return retryable 503 + `Retry-After`, and existing subscribers are unaffected. **Same-origin only** (not CORS-wrapped): `EventSource` can't set headers and credentialed cross-origin needs Allow-Credentials the CORS helper doesn't emit cross-origin SSE is a follow-up. Client disconnect through the Node `createServer` bridge now cancels the response reader (was a teardown leak) so `cancel()` fires and the subscription is released.
128
+ - **Generated `_events` SSE route**: REST (`GET {basePath}/_events`, requires `authMiddleware`, otherwise 401 — fail-closed, per-model `api.public` does NOT apply; 405 non-GET; 503 no db or subscriber capacity reached) and SvelteKit (`{routesDir}/_events/+server.ts`, requires an authenticated principal on `locals`; opt out via `sveltekit.eventsRoute.enabled: false`; cap via `sveltekit.eventsRoute.maxSubscribers`, where `0` means unlimited). Tenant scope is captured ONCE at connection open (`resolveDispatchTenantScope`) and filtered server-side per signal via `signalVisibleToTenant` (same rule as `getChangesSince`'s tenant filter) before any byte hits the wire — delivery runs outside any tenant ALS context, so it must use the captured value. The stream lifecycle lives in `buildChangeEventStream(db, { cursor, tenantScope, heartbeatMs?, manifestHash? })` (exported; the SvelteKit route imports it so it stays thin): subscribe-before-catch-up (closes the gap window; overlap is deduped by the SSE `id:`/seq client-side), `retry: 3000`, optional connection-open `event: manifest` carrying `{ manifestHash }` for live contract detection, cursor catch-up via `getChangesSince` filtered by the CAPTURED scope (not re-resolved from ALS, so it matches the live-signal filter exactly and can't replay another tenant's rows) (`Last-Event-ID` header beats `?since=`; default = live-forward only; `resyncRequired` → `event: resync`), heartbeat (`DEFAULT_EVENTS_HEARTBEAT_MS` = 15s), and `cancel()` teardown (clears heartbeat + unsubscribes). SSE change frame: `id: <seq>\nevent: change\ndata: {table,operation,rowId,tenantId}\n\n` — seq is ONLY in the `id:` line, never the data JSON. Subscriber cap default is `DEFAULT_EVENTS_MAX_SUBSCRIBERS` = 1000; over-cap connections return retryable 503 + `Retry-After`, and existing subscribers are unaffected. **Cross-origin is opt-in and fail-closed (#1861)**: same-origin only by default. REST wraps `_events` in its CORS layer — set `enableCors` + an explicit `allowedOrigins` allowlist + `allowCredentials: true` and an allow-listed browser can subscribe with a credentialed `EventSource` (`withCredentials: true`); the response echoes the specific `Origin` (never `*`) plus `Access-Control-Allow-Credentials: true`. SvelteKit mirrors this via `sveltekit.eventsRoute.allowedOrigins` + `allowCredentials` (the generated route bakes the allowlist into a `Set`, echoes only a member origin, and answers a credentialed `OPTIONS` preflight). CORS never authorizes — the fail-closed auth guard and captured tenant scope are unchanged, so the read posture holds identically across origins; it only lets an allow-listed browser's cookies reach the guard. Client disconnect through the Node `createServer` bridge now cancels the response reader (was a teardown leak) so `cancel()` fires and the subscription is released.
129
129
  - **Known gaps** (documented in the module): raw-SQL writes don't signal (same gap as the feed); live signals for caller-managed-transaction writes are best-effort (the append + signal fire pre-commit, so a rolled-back write may emit a signal and its freed seq is later reused) — the autocommit default path is exact, and clients reconcile via full catch-up/resync (inherits the change feed's transaction caveat).
130
130
 
131
131
  ## Single Table Inheritance (STI)
@@ -1 +1 @@
1
- {"version":3,"file":"events-route.d.ts","sourceRoot":"","sources":["../../src/generators/events-route.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA8BG;AAGH,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,oBAAoB,CAAC;AAE5D,OAAO,EACL,KAAK,YAAY,EAIlB,MAAM,sBAAsB,CAAC;AAC9B,OAAO,EACL,KAAK,mBAAmB,EAEzB,MAAM,gCAAgC,CAAC;AACxC,OAAO,EACL,KAAK,qBAAqB,EAE3B,MAAM,oBAAoB,CAAC;AAI5B,+CAA+C;AAC/C,MAAM,WAAW,kBAAkB;IACjC,0DAA0D;IAC1D,cAAc,CAAC,EAAE,qBAAqB,CAAC;IACvC,gFAAgF;IAChF,EAAE,CAAC,EAAE,OAAO,CAAC;IACb;;;;OAIG;IACH,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB;;;;;OAKG;IACH,cAAc,CAAC,EAAE,MAAM,CAAC;CACzB;AAED;;;GAGG;AACH,eAAO,MAAM,wBAAwB,YAAY,CAAC;AAElD,uEAAuE;AACvE,eAAO,MAAM,2BAA2B,QAAQ,CAAC;AAEjD,4DAA4D;AAC5D,eAAO,MAAM,8BAA8B,OAAO,CAAC;AAEnD,6DAA6D;AAC7D,eAAO,MAAM,kCAAkC,IAAI,CAAC;AAIpD,kDAAkD;AAClD,MAAM,WAAW,wBAAwB;IACvC;;;OAGG;IACH,MAAM,EAAE,MAAM,GAAG,IAAI,CAAC;IACtB;;;;OAIG;IACH,WAAW,EAAE,mBAAmB,CAAC;IACjC,gFAAgF;IAChF,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB;;;OAGG;IACH,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB;;;;OAIG;IACH,qBAAqB,CAAC,EAAE,MAAM,IAAI,CAAC;CACpC;AAED;;;;;GAKG;AACH,wBAAgB,6BAA6B,CAC3C,KAAK,EAAE,MAAM,GAAG,SAAS,GACxB,MAAM,GAAG,IAAI,CAOf;AAED,gFAAgF;AAChF,wBAAgB,gCAAgC,CAC9C,EAAE,EAAE,iBAAiB,EACrB,cAAc,CAAC,EAAE,MAAM,GACtB,OAAO,CAOT;AAED;;;GAGG;AACH,wBAAgB,mCAAmC,CACjD,EAAE,EAAE,iBAAiB,EACrB,cAAc,CAAC,EAAE,MAAM,GACtB,CAAC,MAAM,IAAI,CAAC,GAAG,IAAI,CAKrB;AAED,iFAAiF;AACjF,wBAAgB,mCAAmC,IAAI,QAAQ,CAa9D;AAED;;;;;;;GAOG;AACH,wBAAgB,qBAAqB,CACnC,GAAG,EAAE,YAAY,EACjB,KAAK,EAAE,mBAAmB,GACzB,OAAO,CAIT;AAkCD;;;;;;;;;;;;;;;;;GAiBG;AACH,wBAAgB,sBAAsB,CACpC,EAAE,EAAE,iBAAiB,EACrB,OAAO,EAAE,wBAAwB,GAChC,cAAc,CAAC,UAAU,CAAC,CA6I5B;AAED;;;;;;;GAOG;AACH,wBAAsB,iBAAiB,CACrC,GAAG,EAAE,OAAO,EACZ,OAAO,EAAE,kBAAkB,GAC1B,OAAO,CAAC,QAAQ,CAAC,CAwEnB"}
1
+ {"version":3,"file":"events-route.d.ts","sourceRoot":"","sources":["../../src/generators/events-route.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAoCG;AAGH,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,oBAAoB,CAAC;AAE5D,OAAO,EACL,KAAK,YAAY,EAIlB,MAAM,sBAAsB,CAAC;AAC9B,OAAO,EACL,KAAK,mBAAmB,EAEzB,MAAM,gCAAgC,CAAC;AACxC,OAAO,EACL,KAAK,qBAAqB,EAE3B,MAAM,oBAAoB,CAAC;AAI5B,+CAA+C;AAC/C,MAAM,WAAW,kBAAkB;IACjC,0DAA0D;IAC1D,cAAc,CAAC,EAAE,qBAAqB,CAAC;IACvC,gFAAgF;IAChF,EAAE,CAAC,EAAE,OAAO,CAAC;IACb;;;;OAIG;IACH,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB;;;;;OAKG;IACH,cAAc,CAAC,EAAE,MAAM,CAAC;CACzB;AAED;;;GAGG;AACH,eAAO,MAAM,wBAAwB,YAAY,CAAC;AAElD,uEAAuE;AACvE,eAAO,MAAM,2BAA2B,QAAQ,CAAC;AAEjD,4DAA4D;AAC5D,eAAO,MAAM,8BAA8B,OAAO,CAAC;AAEnD,6DAA6D;AAC7D,eAAO,MAAM,kCAAkC,IAAI,CAAC;AAIpD,kDAAkD;AAClD,MAAM,WAAW,wBAAwB;IACvC;;;OAGG;IACH,MAAM,EAAE,MAAM,GAAG,IAAI,CAAC;IACtB;;;;OAIG;IACH,WAAW,EAAE,mBAAmB,CAAC;IACjC,gFAAgF;IAChF,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB;;;OAGG;IACH,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB;;;;OAIG;IACH,qBAAqB,CAAC,EAAE,MAAM,IAAI,CAAC;CACpC;AAED;;;;;GAKG;AACH,wBAAgB,6BAA6B,CAC3C,KAAK,EAAE,MAAM,GAAG,SAAS,GACxB,MAAM,GAAG,IAAI,CAOf;AAED,gFAAgF;AAChF,wBAAgB,gCAAgC,CAC9C,EAAE,EAAE,iBAAiB,EACrB,cAAc,CAAC,EAAE,MAAM,GACtB,OAAO,CAOT;AAED;;;GAGG;AACH,wBAAgB,mCAAmC,CACjD,EAAE,EAAE,iBAAiB,EACrB,cAAc,CAAC,EAAE,MAAM,GACtB,CAAC,MAAM,IAAI,CAAC,GAAG,IAAI,CAKrB;AAED,iFAAiF;AACjF,wBAAgB,mCAAmC,IAAI,QAAQ,CAa9D;AAED;;;;;;;GAOG;AACH,wBAAgB,qBAAqB,CACnC,GAAG,EAAE,YAAY,EACjB,KAAK,EAAE,mBAAmB,GACzB,OAAO,CAIT;AAkCD;;;;;;;;;;;;;;;;;GAiBG;AACH,wBAAgB,sBAAsB,CACpC,EAAE,EAAE,iBAAiB,EACrB,OAAO,EAAE,wBAAwB,GAChC,cAAc,CAAC,UAAU,CAAC,CA6I5B;AAED;;;;;;;GAOG;AACH,wBAAsB,iBAAiB,CACrC,GAAG,EAAE,OAAO,EACZ,OAAO,EAAE,kBAAkB,GAC1B,OAAO,CAAC,QAAQ,CAAC,CAwEnB"}
@@ -30,10 +30,16 @@ import { createLogger } from "@happyvertical/logger";
30
30
  * happens from a different async context (the writer's afterSave, possibly
31
31
  * another request or replica) with no tenant ALS active, so the filter must
32
32
  * be the value resolved at subscribe time, not re-resolved per signal.
33
- * - **Same-origin only** for this slice: `rest.ts` does NOT wrap `_events` in
34
- * CORS headers. `EventSource` cannot set request headers and credentialed
35
- * cross-origin SSE needs `Access-Control-Allow-Credentials` the CORS helper
36
- * does not emit; cross-origin SSE is a deliberate follow-up.
33
+ * - **Cross-origin is opt-in** (#1861): `rest.ts` now wraps `_events` in its
34
+ * CORS layer. With the fail-closed default it stays same-origin only, but
35
+ * when the generator is configured with `enableCors`, an `allowedOrigins`
36
+ * allowlist, and `allowCredentials: true`, an allow-listed cross-origin
37
+ * browser client can subscribe with a credentialed `EventSource`
38
+ * (`withCredentials: true`) — the response echoes the specific origin (never
39
+ * `*`) plus `Access-Control-Allow-Credentials: true`, so its cookies reach
40
+ * the same fail-closed auth guard. The CORS layer only lets the cookie
41
+ * through; it never authorizes — the auth middleware + captured tenant scope
42
+ * are unchanged, so the read posture holds identically across origins.
37
43
  */
38
44
  var logger = createLogger({ level: "info" });
39
45
  /**
@@ -1 +1 @@
1
- {"version":3,"file":"events-route.js","names":[],"sources":["../../src/generators/events-route.ts"],"sourcesContent":["/**\n * Generated `_events` SSE route — live change signals (issue #1763, SERVER\n * half; parent PRD #1755).\n *\n * Handles `GET {basePath}/_events` in the REST generator: an auth-guarded,\n * tenant-scoped Server-Sent-Events stream of coarse change signals ({table,\n * operation, rowId, tenantId} + a `seq` cursor in the SSE `id:` field). It is\n * the push companion to the pull-based `_changes` route (#1758): a subscriber\n * reacts to a signal by re-reading through the authorized collection routes,\n * so **no row payload ever crosses this channel** — authorization stays\n * entirely on the read path.\n *\n * The stream lifecycle (subscribe, catch-up replay, heartbeat, teardown) lives\n * in {@link buildChangeEventStream} so it is written and tested once; both the\n * REST generator here and the generated SvelteKit route import it. `rest.ts`\n * only registers the path.\n *\n * Contract:\n * - **Fail-closed auth** (identical to `_changes`, #1540 posture): no\n * `authMiddleware` configured → 401; the middleware may return a Response to\n * short-circuit (e.g. 403). The feed spans every table, so per-model\n * `api: { public }` opt-outs deliberately do not apply.\n * - **Tenant scope is captured ONCE at connection open** — signal delivery\n * happens from a different async context (the writer's afterSave, possibly\n * another request or replica) with no tenant ALS active, so the filter must\n * be the value resolved at subscribe time, not re-resolved per signal.\n * - **Same-origin only** for this slice: `rest.ts` does NOT wrap `_events` in\n * CORS headers. `EventSource` cannot set request headers and credentialed\n * cross-origin SSE needs `Access-Control-Allow-Credentials` the CORS helper\n * does not emit; cross-origin SSE is a deliberate follow-up.\n */\n\nimport { createLogger } from '@happyvertical/logger';\nimport type { DatabaseInterface } from '@happyvertical/sql';\nimport { ensureChangeFeedTable, getChangesSince } from '../change-feed.js';\nimport {\n type ChangeSignal,\n changeSignalSubscriberCount,\n subscribeToChangeSignals,\n tryReserveChangeSignalSubscriberSlot,\n} from '../change-signals.js';\nimport {\n type DispatchTenantScope,\n resolveDispatchTenantScope,\n} from '../dispatch/tenant-resolver.js';\nimport {\n type ChangesAuthMiddleware,\n resolveChangesDb,\n} from './changes-route.js';\n\nconst logger = createLogger({ level: 'info' });\n\n/** Options for the `_events` route handler. */\nexport interface EventsRouteOptions {\n /** The generator's configured auth middleware, if any. */\n authMiddleware?: ChangesAuthMiddleware;\n /** The generator's `APIContext.db` (instance, config object, or URL string). */\n db?: unknown;\n /**\n * The build's web-collection shape digest (#1764). When supplied, the stream\n * emits it in a connection-open `manifest` event so long-lived tabs can latch\n * `updateAvailable.contract` on reconnect (#1859).\n */\n manifestHash?: string;\n /**\n * Per-process cap on active `_events` subscribers (#1860). Defaults to\n * {@link DEFAULT_EVENTS_MAX_SUBSCRIBERS}; new over-cap connections receive a\n * retryable 503 and existing subscribers are left untouched. Set to 0 for no\n * cap.\n */\n maxSubscribers?: number;\n}\n\n/**\n * Pseudo object name passed to the auth middleware for the events route, so\n * middlewares can recognize and specially authorize it (mirrors `_changes`).\n */\nexport const EVENTS_ROUTE_OBJECT_NAME = '_events';\n\n/** Default heartbeat interval (ms). Overridable via stream options. */\nexport const DEFAULT_EVENTS_HEARTBEAT_MS = 15000;\n\n/** Default per-process `_events` subscriber cap (#1860). */\nexport const DEFAULT_EVENTS_MAX_SUBSCRIBERS = 1000;\n\n/** Retry hint for over-cap `_events` connections (#1860). */\nexport const DEFAULT_EVENTS_RETRY_AFTER_SECONDS = 5;\n\nconst encoder = new TextEncoder();\n\n/** Options for {@link buildChangeEventStream}. */\nexport interface ChangeEventStreamOptions {\n /**\n * Catch-up cursor. When a non-negative number, changes after it are replayed\n * before going live; `null` means live-forward only (no catch-up).\n */\n cursor: number | null;\n /**\n * Tenant scope captured at connection open. Delivery filters against this\n * fixed value — it must NOT be re-resolved per signal (delivery runs outside\n * any tenant ALS context).\n */\n tenantScope: DispatchTenantScope;\n /** Heartbeat interval (ms). Defaults to {@link DEFAULT_EVENTS_HEARTBEAT_MS}. */\n heartbeatMs?: number;\n /**\n * Optional server manifest hash emitted once at connection open as\n * `event: manifest`. The hash carries no tenant/user data.\n */\n manifestHash?: string;\n /**\n * Reservation claimed at the route boundary before the streaming response was\n * returned. Released when the stream subscribes, or during teardown if the\n * stream never reaches `start()`.\n */\n releaseSubscriberSlot?: () => void;\n}\n\n/**\n * Normalize an `_events` subscriber cap.\n *\n * `0` means unlimited rather than reject-all, matching common limit semantics.\n * Invalid values fall back to the default operational cap.\n */\nexport function normalizeEventsMaxSubscribers(\n value: number | undefined,\n): number | null {\n if (value === undefined) return DEFAULT_EVENTS_MAX_SUBSCRIBERS;\n if (value === 0) return null;\n if (!Number.isFinite(value) || value < 0) {\n return DEFAULT_EVENTS_MAX_SUBSCRIBERS;\n }\n return Math.floor(value);\n}\n\n/** True when opening a new `_events` stream would exceed the configured cap. */\nexport function changeEventSubscribersAtCapacity(\n db: DatabaseInterface,\n maxSubscribers?: number,\n): boolean {\n const normalizedMaxSubscribers =\n normalizeEventsMaxSubscribers(maxSubscribers);\n return (\n normalizedMaxSubscribers !== null &&\n changeSignalSubscriberCount(db) >= normalizedMaxSubscribers\n );\n}\n\n/**\n * Atomically claim one `_events` subscriber slot at the route boundary.\n * Returns null when the configured cap is already reached.\n */\nexport function tryReserveChangeEventSubscriberSlot(\n db: DatabaseInterface,\n maxSubscribers?: number,\n): (() => void) | null {\n return tryReserveChangeSignalSubscriberSlot(\n db,\n normalizeEventsMaxSubscribers(maxSubscribers),\n );\n}\n\n/** Retryable over-cap response shared by REST and generated SvelteKit routes. */\nexport function eventStreamCapacityExceededResponse(): Response {\n return new Response(\n JSON.stringify({\n error: 'Live events unavailable: subscriber capacity reached',\n }),\n {\n status: 503,\n headers: {\n 'Content-Type': 'application/json',\n 'Retry-After': String(DEFAULT_EVENTS_RETRY_AFTER_SECONDS),\n },\n },\n );\n}\n\n/**\n * Whether a signal is visible to a captured tenant scope. Exact same rule as\n * `getChangesSince`'s tenantId filter, run **synchronously server-side** inside\n * the enqueue callback before any byte hits the wire:\n * - not enforced → visible.\n * - enforced, no active tenant (`tenantId === null`) → only global signals.\n * - enforced, tenant `T` → `T`'s signals plus global signals.\n */\nexport function signalVisibleToTenant(\n sig: ChangeSignal,\n scope: DispatchTenantScope,\n): boolean {\n if (!scope.enforced) return true;\n if (scope.tenantId === null) return sig.tenantId === null;\n return sig.tenantId === scope.tenantId || sig.tenantId === null;\n}\n\n/**\n * SSE frame for a change signal. The `data` JSON is EXACTLY\n * `{table, operation, rowId, tenantId}` — the `seq` lives only in the `id:`\n * field (the EventSource `Last-Event-ID` a client echoes to resume).\n */\nfunction encodeSseEvent(sig: ChangeSignal): Uint8Array {\n const data = JSON.stringify({\n table: sig.table,\n operation: sig.operation,\n rowId: sig.rowId,\n tenantId: sig.tenantId,\n });\n return encoder.encode(`id: ${sig.seq}\\nevent: change\\ndata: ${data}\\n\\n`);\n}\n\n/** SSE manifest frame emitted at connection open for live contract detection. */\nfunction encodeSseManifestEvent(manifestHash: string): Uint8Array {\n return encoder.encode(\n `event: manifest\\ndata: ${JSON.stringify({ manifestHash })}\\n\\n`,\n );\n}\n\n/** SSE resync frame. The id advances EventSource past an unservable cursor. */\nfunction encodeSseResyncEvent(cursor: number): Uint8Array {\n return encoder.encode(`id: ${cursor}\\nevent: resync\\ndata: {}\\n\\n`);\n}\n\n/** SSE comment line (used for heartbeats — ignored by EventSource). */\nfunction encodeSseComment(text: string): Uint8Array {\n return encoder.encode(`: ${text}\\n\\n`);\n}\n\n/**\n * Build the SSE body stream for an `_events` connection.\n *\n * `start(controller)`:\n * a. **Subscribe FIRST**, before catch-up. Subscribing before the catch-up\n * read closes the gap window: a write landing between subscribe and the\n * catch-up read is delivered twice (once live, once in the replay) — which\n * is safe, since the client dedupes by the SSE `id:`/seq.\n * b. Write the `retry:` reconnection hint.\n * c. If a cursor was supplied, replay changes after it (paging until\n * exhausted); on `resyncRequired`, emit `event: resync` at the server's\n * fresh horizon.\n * d. Start the heartbeat interval.\n *\n * `cancel()` tears down on disconnect: clears the heartbeat and unsubscribes,\n * so a dropped client never leaks its subscription (which would pin the dead\n * controller and keep the cross-replica listener refcount above 0).\n */\nexport function buildChangeEventStream(\n db: DatabaseInterface,\n options: ChangeEventStreamOptions,\n): ReadableStream<Uint8Array> {\n const { cursor, tenantScope, manifestHash } = options;\n const heartbeatMs = options.heartbeatMs ?? DEFAULT_EVENTS_HEARTBEAT_MS;\n\n let unsubscribe: (() => void) | null = null;\n let releaseSubscriberSlot = options.releaseSubscriberSlot ?? null;\n let heartbeat: ReturnType<typeof setInterval> | null = null;\n let closed = false;\n\n const teardown = () => {\n if (closed) return;\n closed = true;\n if (heartbeat) {\n clearInterval(heartbeat);\n heartbeat = null;\n }\n if (unsubscribe) {\n unsubscribe();\n unsubscribe = null;\n }\n if (releaseSubscriberSlot) {\n releaseSubscriberSlot();\n releaseSubscriberSlot = null;\n }\n };\n\n return new ReadableStream<Uint8Array>({\n async start(controller) {\n // (a) Subscribe FIRST, before catch-up — closes the subscribe/catch-up\n // gap window (a write in between is delivered twice; the client dedupes\n // by seq). The tenant filter uses the scope captured at open, never a\n // per-signal re-resolution.\n unsubscribe = subscribeToChangeSignals(db, (sig) => {\n if (closed) return;\n if (!signalVisibleToTenant(sig, tenantScope)) return;\n try {\n controller.enqueue(encodeSseEvent(sig));\n } catch {\n // Controller already closed (client gone before cancel fired) —\n // tear down so we stop trying to write to a dead controller.\n teardown();\n }\n });\n if (releaseSubscriberSlot) {\n releaseSubscriberSlot();\n releaseSubscriberSlot = null;\n }\n\n // (b) Reconnection hint.\n controller.enqueue(encoder.encode('retry: 3000\\n\\n'));\n // Advertise the server contract at connection open (#1859). A reconnect\n // naturally replays this frame, letting a long-lived tab learn about a\n // shape-only API deploy without a full page load.\n if (manifestHash !== undefined) {\n controller.enqueue(encodeSseManifestEvent(manifestHash));\n }\n\n // (c) Catch-up replay from the cursor, if one was supplied.\n if (cursor != null) {\n try {\n // Catch-up MUST filter by the scope captured at connection open, not\n // re-resolve the tenant via ALS at call time. start() happens to run\n // in-request today, but relying on that is fragile — and it must match\n // the live-signal filter exactly (signalVisibleToTenant): when\n // enforced, `scope.tenantId` (a tenant id → that tenant + global; null\n // → global only); when not enforced, undefined → no tenant filter.\n const catchupTenantId = tenantScope.enforced\n ? tenantScope.tenantId\n : undefined;\n let since = cursor;\n // Page until exhausted (cursor stops advancing / resync).\n for (;;) {\n const page = await getChangesSince(db, {\n since,\n tenantId: catchupTenantId,\n });\n if (page.resyncRequired) {\n const resyncCursor =\n typeof page.resyncCursor === 'number' &&\n Number.isFinite(page.resyncCursor) &&\n page.resyncCursor >= 0\n ? page.resyncCursor\n : since;\n controller.enqueue(encodeSseResyncEvent(resyncCursor));\n break;\n }\n for (const change of page.changes) {\n controller.enqueue(\n encodeSseEvent({\n table: change.table,\n operation: change.operation,\n rowId: change.rowId,\n tenantId: change.tenantId,\n seq: change.seq,\n }),\n );\n }\n if (closed) break;\n if (page.cursor === since || page.changes.length === 0) {\n break;\n }\n since = page.cursor;\n // NOTE: catch-up enqueues per-page without a hard cap. It is\n // bounded — an over-old cursor hits `resyncRequired` and stops — but\n // a large retention window replayed to a slow client could spike\n // memory. Honor the controller's backpressure signal cheaply: when\n // the internal queue is full (`desiredSize <= 0`), yield between\n // pages so the consumer drains first. Bounded by `closed` (set on\n // cancel/disconnect), so it can't spin on a client that never reads.\n while (\n !closed &&\n controller.desiredSize !== null &&\n controller.desiredSize <= 0\n ) {\n await new Promise((resolve) => setTimeout(resolve, 5));\n }\n }\n } catch (error) {\n logger.warn('_events: cursor catch-up failed', {\n error: error instanceof Error ? error.message : String(error),\n });\n }\n }\n\n // (d) Heartbeat keeps intermediaries from idling the connection out.\n heartbeat = setInterval(() => {\n if (closed) return;\n try {\n controller.enqueue(encodeSseComment('heartbeat'));\n } catch {\n teardown();\n }\n }, heartbeatMs);\n // Do not keep the event loop alive solely for heartbeats.\n (heartbeat as { unref?: () => void }).unref?.();\n },\n cancel() {\n // Client disconnected (abort) — release the subscription + heartbeat.\n teardown();\n },\n });\n}\n\n/**\n * Handle a request against the generated `_events` route.\n *\n * Returns 405 for non-GET; 401 when no auth middleware is configured\n * (fail-closed) or the middleware rejects; 503 when the generator has no\n * database; otherwise a 200 `text/event-stream` response whose body is the\n * live signal stream (built by {@link buildChangeEventStream}).\n */\nexport async function handleEventsRoute(\n req: Request,\n options: EventsRouteOptions,\n): Promise<Response> {\n if (req.method !== 'GET') {\n return new Response(JSON.stringify({ error: 'Method not allowed' }), {\n status: 405,\n headers: { 'Content-Type': 'application/json' },\n });\n }\n\n // Fail-closed (#1540): the signal stream spans every table, so it is never\n // public — an auth middleware must be configured and must pass.\n if (!options.authMiddleware) {\n return new Response(JSON.stringify({ error: 'Authentication required' }), {\n status: 401,\n headers: { 'Content-Type': 'application/json' },\n });\n }\n const authCheck = options.authMiddleware(\n EVENTS_ROUTE_OBJECT_NAME,\n req.method.toLowerCase(),\n );\n const authResult = await authCheck(req);\n if (authResult instanceof Response) {\n return authResult;\n }\n\n if (options.db == null) {\n return new Response(\n JSON.stringify({\n error:\n 'Live events unavailable: no database configured for the API generator',\n }),\n { status: 503, headers: { 'Content-Type': 'application/json' } },\n );\n }\n\n const db = await resolveChangesDb(options.db);\n // A raw handle passed straight to the generator may not have gone through\n // framework init; the feed table backs cursor catch-up.\n await ensureChangeFeedTable(db);\n const releaseSubscriberSlot = tryReserveChangeEventSubscriberSlot(\n db,\n options.maxSubscribers,\n );\n if (!releaseSubscriberSlot) {\n return eventStreamCapacityExceededResponse();\n }\n\n // Cursor: Last-Event-ID (reconnection) takes precedence over ?since=.\n // Default = live-forward only (no catch-up).\n const cursor = parseCursor(authResult);\n\n // Capture the tenant scope ONCE at connection open — delivery runs outside\n // any tenant ALS context and must filter against this fixed value.\n const tenantScope = resolveDispatchTenantScope();\n\n return new Response(\n buildChangeEventStream(db, {\n cursor,\n tenantScope,\n manifestHash: options.manifestHash,\n releaseSubscriberSlot,\n }),\n {\n status: 200,\n headers: {\n 'Content-Type': 'text/event-stream',\n 'Cache-Control': 'no-cache, no-transform',\n Connection: 'keep-alive',\n 'X-Accel-Buffering': 'no',\n },\n },\n );\n}\n\n/**\n * Resolve the catch-up cursor for a request: `Last-Event-ID` header first\n * (what an auto-reconnecting EventSource sends), then `?since=`. Returns a\n * non-negative integer, or `null` for live-forward only.\n */\nfunction parseCursor(req: Request): number | null {\n const lastEventId = req.headers.get('Last-Event-ID');\n if (lastEventId !== null && lastEventId.trim() !== '') {\n const n = Number(lastEventId);\n if (Number.isFinite(n) && n >= 0) return Math.floor(n);\n }\n\n const since = new URL(req.url).searchParams.get('since');\n if (since !== null && since.trim() !== '') {\n const n = Number(since);\n if (Number.isFinite(n) && n >= 0) return Math.floor(n);\n }\n\n return null;\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"events-route.js","names":[],"sources":["../../src/generators/events-route.ts"],"sourcesContent":["/**\n * Generated `_events` SSE route — live change signals (issue #1763, SERVER\n * half; parent PRD #1755).\n *\n * Handles `GET {basePath}/_events` in the REST generator: an auth-guarded,\n * tenant-scoped Server-Sent-Events stream of coarse change signals ({table,\n * operation, rowId, tenantId} + a `seq` cursor in the SSE `id:` field). It is\n * the push companion to the pull-based `_changes` route (#1758): a subscriber\n * reacts to a signal by re-reading through the authorized collection routes,\n * so **no row payload ever crosses this channel** — authorization stays\n * entirely on the read path.\n *\n * The stream lifecycle (subscribe, catch-up replay, heartbeat, teardown) lives\n * in {@link buildChangeEventStream} so it is written and tested once; both the\n * REST generator here and the generated SvelteKit route import it. `rest.ts`\n * only registers the path.\n *\n * Contract:\n * - **Fail-closed auth** (identical to `_changes`, #1540 posture): no\n * `authMiddleware` configured → 401; the middleware may return a Response to\n * short-circuit (e.g. 403). The feed spans every table, so per-model\n * `api: { public }` opt-outs deliberately do not apply.\n * - **Tenant scope is captured ONCE at connection open** — signal delivery\n * happens from a different async context (the writer's afterSave, possibly\n * another request or replica) with no tenant ALS active, so the filter must\n * be the value resolved at subscribe time, not re-resolved per signal.\n * - **Cross-origin is opt-in** (#1861): `rest.ts` now wraps `_events` in its\n * CORS layer. With the fail-closed default it stays same-origin only, but\n * when the generator is configured with `enableCors`, an `allowedOrigins`\n * allowlist, and `allowCredentials: true`, an allow-listed cross-origin\n * browser client can subscribe with a credentialed `EventSource`\n * (`withCredentials: true`) — the response echoes the specific origin (never\n * `*`) plus `Access-Control-Allow-Credentials: true`, so its cookies reach\n * the same fail-closed auth guard. The CORS layer only lets the cookie\n * through; it never authorizes — the auth middleware + captured tenant scope\n * are unchanged, so the read posture holds identically across origins.\n */\n\nimport { createLogger } from '@happyvertical/logger';\nimport type { DatabaseInterface } from '@happyvertical/sql';\nimport { ensureChangeFeedTable, getChangesSince } from '../change-feed.js';\nimport {\n type ChangeSignal,\n changeSignalSubscriberCount,\n subscribeToChangeSignals,\n tryReserveChangeSignalSubscriberSlot,\n} from '../change-signals.js';\nimport {\n type DispatchTenantScope,\n resolveDispatchTenantScope,\n} from '../dispatch/tenant-resolver.js';\nimport {\n type ChangesAuthMiddleware,\n resolveChangesDb,\n} from './changes-route.js';\n\nconst logger = createLogger({ level: 'info' });\n\n/** Options for the `_events` route handler. */\nexport interface EventsRouteOptions {\n /** The generator's configured auth middleware, if any. */\n authMiddleware?: ChangesAuthMiddleware;\n /** The generator's `APIContext.db` (instance, config object, or URL string). */\n db?: unknown;\n /**\n * The build's web-collection shape digest (#1764). When supplied, the stream\n * emits it in a connection-open `manifest` event so long-lived tabs can latch\n * `updateAvailable.contract` on reconnect (#1859).\n */\n manifestHash?: string;\n /**\n * Per-process cap on active `_events` subscribers (#1860). Defaults to\n * {@link DEFAULT_EVENTS_MAX_SUBSCRIBERS}; new over-cap connections receive a\n * retryable 503 and existing subscribers are left untouched. Set to 0 for no\n * cap.\n */\n maxSubscribers?: number;\n}\n\n/**\n * Pseudo object name passed to the auth middleware for the events route, so\n * middlewares can recognize and specially authorize it (mirrors `_changes`).\n */\nexport const EVENTS_ROUTE_OBJECT_NAME = '_events';\n\n/** Default heartbeat interval (ms). Overridable via stream options. */\nexport const DEFAULT_EVENTS_HEARTBEAT_MS = 15000;\n\n/** Default per-process `_events` subscriber cap (#1860). */\nexport const DEFAULT_EVENTS_MAX_SUBSCRIBERS = 1000;\n\n/** Retry hint for over-cap `_events` connections (#1860). */\nexport const DEFAULT_EVENTS_RETRY_AFTER_SECONDS = 5;\n\nconst encoder = new TextEncoder();\n\n/** Options for {@link buildChangeEventStream}. */\nexport interface ChangeEventStreamOptions {\n /**\n * Catch-up cursor. When a non-negative number, changes after it are replayed\n * before going live; `null` means live-forward only (no catch-up).\n */\n cursor: number | null;\n /**\n * Tenant scope captured at connection open. Delivery filters against this\n * fixed value — it must NOT be re-resolved per signal (delivery runs outside\n * any tenant ALS context).\n */\n tenantScope: DispatchTenantScope;\n /** Heartbeat interval (ms). Defaults to {@link DEFAULT_EVENTS_HEARTBEAT_MS}. */\n heartbeatMs?: number;\n /**\n * Optional server manifest hash emitted once at connection open as\n * `event: manifest`. The hash carries no tenant/user data.\n */\n manifestHash?: string;\n /**\n * Reservation claimed at the route boundary before the streaming response was\n * returned. Released when the stream subscribes, or during teardown if the\n * stream never reaches `start()`.\n */\n releaseSubscriberSlot?: () => void;\n}\n\n/**\n * Normalize an `_events` subscriber cap.\n *\n * `0` means unlimited rather than reject-all, matching common limit semantics.\n * Invalid values fall back to the default operational cap.\n */\nexport function normalizeEventsMaxSubscribers(\n value: number | undefined,\n): number | null {\n if (value === undefined) return DEFAULT_EVENTS_MAX_SUBSCRIBERS;\n if (value === 0) return null;\n if (!Number.isFinite(value) || value < 0) {\n return DEFAULT_EVENTS_MAX_SUBSCRIBERS;\n }\n return Math.floor(value);\n}\n\n/** True when opening a new `_events` stream would exceed the configured cap. */\nexport function changeEventSubscribersAtCapacity(\n db: DatabaseInterface,\n maxSubscribers?: number,\n): boolean {\n const normalizedMaxSubscribers =\n normalizeEventsMaxSubscribers(maxSubscribers);\n return (\n normalizedMaxSubscribers !== null &&\n changeSignalSubscriberCount(db) >= normalizedMaxSubscribers\n );\n}\n\n/**\n * Atomically claim one `_events` subscriber slot at the route boundary.\n * Returns null when the configured cap is already reached.\n */\nexport function tryReserveChangeEventSubscriberSlot(\n db: DatabaseInterface,\n maxSubscribers?: number,\n): (() => void) | null {\n return tryReserveChangeSignalSubscriberSlot(\n db,\n normalizeEventsMaxSubscribers(maxSubscribers),\n );\n}\n\n/** Retryable over-cap response shared by REST and generated SvelteKit routes. */\nexport function eventStreamCapacityExceededResponse(): Response {\n return new Response(\n JSON.stringify({\n error: 'Live events unavailable: subscriber capacity reached',\n }),\n {\n status: 503,\n headers: {\n 'Content-Type': 'application/json',\n 'Retry-After': String(DEFAULT_EVENTS_RETRY_AFTER_SECONDS),\n },\n },\n );\n}\n\n/**\n * Whether a signal is visible to a captured tenant scope. Exact same rule as\n * `getChangesSince`'s tenantId filter, run **synchronously server-side** inside\n * the enqueue callback before any byte hits the wire:\n * - not enforced → visible.\n * - enforced, no active tenant (`tenantId === null`) → only global signals.\n * - enforced, tenant `T` → `T`'s signals plus global signals.\n */\nexport function signalVisibleToTenant(\n sig: ChangeSignal,\n scope: DispatchTenantScope,\n): boolean {\n if (!scope.enforced) return true;\n if (scope.tenantId === null) return sig.tenantId === null;\n return sig.tenantId === scope.tenantId || sig.tenantId === null;\n}\n\n/**\n * SSE frame for a change signal. The `data` JSON is EXACTLY\n * `{table, operation, rowId, tenantId}` — the `seq` lives only in the `id:`\n * field (the EventSource `Last-Event-ID` a client echoes to resume).\n */\nfunction encodeSseEvent(sig: ChangeSignal): Uint8Array {\n const data = JSON.stringify({\n table: sig.table,\n operation: sig.operation,\n rowId: sig.rowId,\n tenantId: sig.tenantId,\n });\n return encoder.encode(`id: ${sig.seq}\\nevent: change\\ndata: ${data}\\n\\n`);\n}\n\n/** SSE manifest frame emitted at connection open for live contract detection. */\nfunction encodeSseManifestEvent(manifestHash: string): Uint8Array {\n return encoder.encode(\n `event: manifest\\ndata: ${JSON.stringify({ manifestHash })}\\n\\n`,\n );\n}\n\n/** SSE resync frame. The id advances EventSource past an unservable cursor. */\nfunction encodeSseResyncEvent(cursor: number): Uint8Array {\n return encoder.encode(`id: ${cursor}\\nevent: resync\\ndata: {}\\n\\n`);\n}\n\n/** SSE comment line (used for heartbeats — ignored by EventSource). */\nfunction encodeSseComment(text: string): Uint8Array {\n return encoder.encode(`: ${text}\\n\\n`);\n}\n\n/**\n * Build the SSE body stream for an `_events` connection.\n *\n * `start(controller)`:\n * a. **Subscribe FIRST**, before catch-up. Subscribing before the catch-up\n * read closes the gap window: a write landing between subscribe and the\n * catch-up read is delivered twice (once live, once in the replay) — which\n * is safe, since the client dedupes by the SSE `id:`/seq.\n * b. Write the `retry:` reconnection hint.\n * c. If a cursor was supplied, replay changes after it (paging until\n * exhausted); on `resyncRequired`, emit `event: resync` at the server's\n * fresh horizon.\n * d. Start the heartbeat interval.\n *\n * `cancel()` tears down on disconnect: clears the heartbeat and unsubscribes,\n * so a dropped client never leaks its subscription (which would pin the dead\n * controller and keep the cross-replica listener refcount above 0).\n */\nexport function buildChangeEventStream(\n db: DatabaseInterface,\n options: ChangeEventStreamOptions,\n): ReadableStream<Uint8Array> {\n const { cursor, tenantScope, manifestHash } = options;\n const heartbeatMs = options.heartbeatMs ?? DEFAULT_EVENTS_HEARTBEAT_MS;\n\n let unsubscribe: (() => void) | null = null;\n let releaseSubscriberSlot = options.releaseSubscriberSlot ?? null;\n let heartbeat: ReturnType<typeof setInterval> | null = null;\n let closed = false;\n\n const teardown = () => {\n if (closed) return;\n closed = true;\n if (heartbeat) {\n clearInterval(heartbeat);\n heartbeat = null;\n }\n if (unsubscribe) {\n unsubscribe();\n unsubscribe = null;\n }\n if (releaseSubscriberSlot) {\n releaseSubscriberSlot();\n releaseSubscriberSlot = null;\n }\n };\n\n return new ReadableStream<Uint8Array>({\n async start(controller) {\n // (a) Subscribe FIRST, before catch-up — closes the subscribe/catch-up\n // gap window (a write in between is delivered twice; the client dedupes\n // by seq). The tenant filter uses the scope captured at open, never a\n // per-signal re-resolution.\n unsubscribe = subscribeToChangeSignals(db, (sig) => {\n if (closed) return;\n if (!signalVisibleToTenant(sig, tenantScope)) return;\n try {\n controller.enqueue(encodeSseEvent(sig));\n } catch {\n // Controller already closed (client gone before cancel fired) —\n // tear down so we stop trying to write to a dead controller.\n teardown();\n }\n });\n if (releaseSubscriberSlot) {\n releaseSubscriberSlot();\n releaseSubscriberSlot = null;\n }\n\n // (b) Reconnection hint.\n controller.enqueue(encoder.encode('retry: 3000\\n\\n'));\n // Advertise the server contract at connection open (#1859). A reconnect\n // naturally replays this frame, letting a long-lived tab learn about a\n // shape-only API deploy without a full page load.\n if (manifestHash !== undefined) {\n controller.enqueue(encodeSseManifestEvent(manifestHash));\n }\n\n // (c) Catch-up replay from the cursor, if one was supplied.\n if (cursor != null) {\n try {\n // Catch-up MUST filter by the scope captured at connection open, not\n // re-resolve the tenant via ALS at call time. start() happens to run\n // in-request today, but relying on that is fragile — and it must match\n // the live-signal filter exactly (signalVisibleToTenant): when\n // enforced, `scope.tenantId` (a tenant id → that tenant + global; null\n // → global only); when not enforced, undefined → no tenant filter.\n const catchupTenantId = tenantScope.enforced\n ? tenantScope.tenantId\n : undefined;\n let since = cursor;\n // Page until exhausted (cursor stops advancing / resync).\n for (;;) {\n const page = await getChangesSince(db, {\n since,\n tenantId: catchupTenantId,\n });\n if (page.resyncRequired) {\n const resyncCursor =\n typeof page.resyncCursor === 'number' &&\n Number.isFinite(page.resyncCursor) &&\n page.resyncCursor >= 0\n ? page.resyncCursor\n : since;\n controller.enqueue(encodeSseResyncEvent(resyncCursor));\n break;\n }\n for (const change of page.changes) {\n controller.enqueue(\n encodeSseEvent({\n table: change.table,\n operation: change.operation,\n rowId: change.rowId,\n tenantId: change.tenantId,\n seq: change.seq,\n }),\n );\n }\n if (closed) break;\n if (page.cursor === since || page.changes.length === 0) {\n break;\n }\n since = page.cursor;\n // NOTE: catch-up enqueues per-page without a hard cap. It is\n // bounded — an over-old cursor hits `resyncRequired` and stops — but\n // a large retention window replayed to a slow client could spike\n // memory. Honor the controller's backpressure signal cheaply: when\n // the internal queue is full (`desiredSize <= 0`), yield between\n // pages so the consumer drains first. Bounded by `closed` (set on\n // cancel/disconnect), so it can't spin on a client that never reads.\n while (\n !closed &&\n controller.desiredSize !== null &&\n controller.desiredSize <= 0\n ) {\n await new Promise((resolve) => setTimeout(resolve, 5));\n }\n }\n } catch (error) {\n logger.warn('_events: cursor catch-up failed', {\n error: error instanceof Error ? error.message : String(error),\n });\n }\n }\n\n // (d) Heartbeat keeps intermediaries from idling the connection out.\n heartbeat = setInterval(() => {\n if (closed) return;\n try {\n controller.enqueue(encodeSseComment('heartbeat'));\n } catch {\n teardown();\n }\n }, heartbeatMs);\n // Do not keep the event loop alive solely for heartbeats.\n (heartbeat as { unref?: () => void }).unref?.();\n },\n cancel() {\n // Client disconnected (abort) — release the subscription + heartbeat.\n teardown();\n },\n });\n}\n\n/**\n * Handle a request against the generated `_events` route.\n *\n * Returns 405 for non-GET; 401 when no auth middleware is configured\n * (fail-closed) or the middleware rejects; 503 when the generator has no\n * database; otherwise a 200 `text/event-stream` response whose body is the\n * live signal stream (built by {@link buildChangeEventStream}).\n */\nexport async function handleEventsRoute(\n req: Request,\n options: EventsRouteOptions,\n): Promise<Response> {\n if (req.method !== 'GET') {\n return new Response(JSON.stringify({ error: 'Method not allowed' }), {\n status: 405,\n headers: { 'Content-Type': 'application/json' },\n });\n }\n\n // Fail-closed (#1540): the signal stream spans every table, so it is never\n // public — an auth middleware must be configured and must pass.\n if (!options.authMiddleware) {\n return new Response(JSON.stringify({ error: 'Authentication required' }), {\n status: 401,\n headers: { 'Content-Type': 'application/json' },\n });\n }\n const authCheck = options.authMiddleware(\n EVENTS_ROUTE_OBJECT_NAME,\n req.method.toLowerCase(),\n );\n const authResult = await authCheck(req);\n if (authResult instanceof Response) {\n return authResult;\n }\n\n if (options.db == null) {\n return new Response(\n JSON.stringify({\n error:\n 'Live events unavailable: no database configured for the API generator',\n }),\n { status: 503, headers: { 'Content-Type': 'application/json' } },\n );\n }\n\n const db = await resolveChangesDb(options.db);\n // A raw handle passed straight to the generator may not have gone through\n // framework init; the feed table backs cursor catch-up.\n await ensureChangeFeedTable(db);\n const releaseSubscriberSlot = tryReserveChangeEventSubscriberSlot(\n db,\n options.maxSubscribers,\n );\n if (!releaseSubscriberSlot) {\n return eventStreamCapacityExceededResponse();\n }\n\n // Cursor: Last-Event-ID (reconnection) takes precedence over ?since=.\n // Default = live-forward only (no catch-up).\n const cursor = parseCursor(authResult);\n\n // Capture the tenant scope ONCE at connection open — delivery runs outside\n // any tenant ALS context and must filter against this fixed value.\n const tenantScope = resolveDispatchTenantScope();\n\n return new Response(\n buildChangeEventStream(db, {\n cursor,\n tenantScope,\n manifestHash: options.manifestHash,\n releaseSubscriberSlot,\n }),\n {\n status: 200,\n headers: {\n 'Content-Type': 'text/event-stream',\n 'Cache-Control': 'no-cache, no-transform',\n Connection: 'keep-alive',\n 'X-Accel-Buffering': 'no',\n },\n },\n );\n}\n\n/**\n * Resolve the catch-up cursor for a request: `Last-Event-ID` header first\n * (what an auto-reconnecting EventSource sends), then `?since=`. Returns a\n * non-negative integer, or `null` for live-forward only.\n */\nfunction parseCursor(req: Request): number | null {\n const lastEventId = req.headers.get('Last-Event-ID');\n if (lastEventId !== null && lastEventId.trim() !== '') {\n const n = Number(lastEventId);\n if (Number.isFinite(n) && n >= 0) return Math.floor(n);\n }\n\n const since = new URL(req.url).searchParams.get('since');\n if (since !== null && since.trim() !== '') {\n const n = Number(since);\n if (Number.isFinite(n) && n >= 0) return Math.floor(n);\n }\n\n return null;\n}\n"],"mappings":"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"}
@@ -11,6 +11,18 @@ export interface APIConfig {
11
11
  * `Origin` is echoed only when it appears here.
12
12
  */
13
13
  allowedOrigins?: string[];
14
+ /**
15
+ * Opt into credentialed CORS (#1861). When true, CORS responses to an
16
+ * allow-listed origin also carry `Access-Control-Allow-Credentials: true`, so
17
+ * a cross-origin browser client may send cookies (the only auth an
18
+ * `EventSource` can carry — it cannot set an `Authorization` header). Requires
19
+ * `enableCors` + a non-empty `allowedOrigins`: credentials are NEVER paired
20
+ * with a wildcard origin, and the origin is still echoed only when it is
21
+ * explicitly allow-listed. Fail-closed default (`false`): same-origin only,
22
+ * even when CORS is otherwise enabled, so a plain `enableCors` never silently
23
+ * starts flowing cookies cross-origin.
24
+ */
25
+ allowCredentials?: boolean;
14
26
  customRoutes?: Record<string, (req: Request) => Promise<Response>>;
15
27
  authMiddleware?: (objectName: string, action: string) => (req: Request) => Promise<Request | Response>;
16
28
  port?: number;
@@ -253,6 +265,13 @@ export declare class APIGenerator {
253
265
  * never `*`. Returns null when CORS should not be applied.
254
266
  */
255
267
  private resolveAllowedOrigin;
268
+ /**
269
+ * Whether to emit `Access-Control-Allow-Credentials: true` (#1861). Only when
270
+ * explicitly opted in AND an origin actually resolved — credentials are never
271
+ * paired with a wildcard/absent origin (`resolveAllowedOrigin` already never
272
+ * returns `*`).
273
+ */
274
+ private shouldAllowCredentials;
256
275
  /**
257
276
  * Create CORS preflight response
258
277
  */
@@ -1 +1 @@
1
- {"version":3,"file":"rest.d.ts","sourceRoot":"","sources":["../../src/generators/rest.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,OAAO,IAAI,MAAM,WAAW,CAAC;AAE7B,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,eAAe,CAAC;AAKpD,OAAO,KAAK,EAAqB,UAAU,EAAE,MAAM,WAAW,CAAC;AAE/D,OAAO,KAAK,EACV,eAAe,EAGhB,MAAM,mBAAmB,CAAC;AA6B3B,MAAM,WAAW,SAAS;IACxB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,UAAU,CAAC,EAAE,OAAO,CAAC;IACrB;;;;OAIG;IACH,cAAc,CAAC,EAAE,MAAM,EAAE,CAAC;IAC1B,YAAY,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,CAAC,GAAG,EAAE,OAAO,KAAK,OAAO,CAAC,QAAQ,CAAC,CAAC,CAAC;IACnE,cAAc,CAAC,EAAE,CACf,UAAU,EAAE,MAAM,EAClB,MAAM,EAAE,MAAM,KACX,CAAC,GAAG,EAAE,OAAO,KAAK,OAAO,CAAC,OAAO,GAAG,QAAQ,CAAC,CAAC;IACnD,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB;;;;OAIG;IACH,WAAW,CAAC,EAAE;QACZ,cAAc,CAAC,EAAE,MAAM,CAAC;KACzB,CAAC;IACF;;;;;;;;;OASG;IACH,YAAY,CAAC,EAAE,MAAM,CAAC;CACvB;AAED,MAAM,WAAW,UAAU;IACzB,EAAE,CAAC,EAAE,OAAO,CAAC;IACb,EAAE,CAAC,EAAE,OAAO,CAAC;IACb,IAAI,CAAC,EAAE;QACL,EAAE,EAAE,MAAM,CAAC;QACX,QAAQ,CAAC,EAAE,MAAM,CAAC;QAClB,KAAK,CAAC,EAAE,MAAM,EAAE,CAAC;KAClB,CAAC;IACF,oDAAoD;IACpD,WAAW,CAAC,EAAE,QAAQ,CAAC,MAAM,CAAC,CAAC;CAChC;AAiED;;;;GAIG;AACH,wBAAgB,6BAA6B,CAC3C,OAAO,GAAE,QAAQ,CAAC,CAAC,MAAM,EAAE,eAAe,CAAC,CAAkC,GAC5E,MAAM,CAcR;AAED;;GAEG;AACH,qBAAa,YAAY;IACvB,OAAO,CAAC,MAAM,CAAY;IAC1B,OAAO,CAAC,WAAW,CAAiD;IACpE,OAAO,CAAC,OAAO,CAAa;IAC5B,OAAO,CAAC,mBAAmB,CAAqB;gBAEpC,MAAM,GAAE,SAAc,EAAE,OAAO,GAAE,UAAe;IAa5D;;;;;OAKG;IACH,kBAAkB,CAChB,IAAI,EAAE,MAAM,EACZ,UAAU,EAAE,cAAc,CAAC,UAAU,CAAC,GACrC,IAAI;IAKP;;;;;;;;;OASG;IACH,OAAO,CAAC,gBAAgB;IAMxB;;OAEG;IACH,YAAY,IAAI;QAAE,MAAM,EAAE,IAAI,CAAC,MAAM,CAAC;QAAC,GAAG,EAAE,MAAM,CAAA;KAAE;IAoBpD;;OAEG;YACW,cAAc;IAQ5B;;OAEG;YACW,uBAAuB;IAyBrC;;OAEG;YACW,yBAAyB;IAkEvC;;OAEG;IACH,eAAe,IAAI,CAAC,GAAG,EAAE,OAAO,KAAK,OAAO,CAAC,QAAQ,CAAC;IAItD;;OAEG;YACW,aAAa;IAqD3B;;OAEG;YACW,iBAAiB;IAmG/B;;OAEG;YACW,oBAAoB;IA6DlC,OAAO,CAAC,aAAa;IAmBrB;;;;;;OAMG;IACH,OAAO,CAAC,aAAa;IAarB,OAAO,CAAC,kBAAkB;IA4B1B,OAAO,CAAC,uBAAuB;IAuB/B;;;;;;;;;;;;;;;;;;;;OAoBG;IACH,OAAO,CAAC,sBAAsB;IAmB9B;;;;;;OAMG;IACH,OAAO,CAAC,oBAAoB;IAsB5B;;OAEG;YACW,SAAS;IAkEvB;;OAEG;YACW,UAAU;IA0ExB;;OAEG;YACW,WAAW;IA8CzB;;OAEG;YACW,YAAY;IAW1B;;OAEG;YACW,YAAY;IAoB1B;;OAEG;YACW,YAAY;IAc1B;;;;;OAKG;YACW,eAAe;IAqB7B;;;;OAIG;YACW,sBAAsB;IA+DpC;;OAEG;YACW,aAAa;IA6B3B;;;;OAIG;IACH,OAAO,CAAC,mBAAmB;IAiD3B;;;OAGG;IACH,OAAO,CAAC,YAAY;IASpB,OAAO,CAAC,oBAAoB;IAI5B;;;;;;;OAOG;IACH,OAAO,CAAC,sBAAsB;IA2B9B,OAAO,CAAC,uBAAuB;IAe/B,OAAO,CAAC,2BAA2B;IAyBnC;;;;;;;;;;OAUG;YACW,eAAe;IAqB7B,OAAO,CAAC,mBAAmB;IAQ3B;;;;;OAKG;IACH,OAAO,CAAC,uBAAuB;IAY/B;;OAEG;IACH,OAAO,CAAC,kBAAkB;IAS1B;;OAEG;IACH,OAAO,CAAC,mBAAmB;IAS3B;;;;OAIG;IACH,OAAO,CAAC,oBAAoB;IAQ5B;;OAEG;IACH,OAAO,CAAC,kBAAkB;IAc1B;;OAEG;IACH,OAAO,CAAC,cAAc;IAoBtB;;;;;;;;;;;;;;;OAeG;IACH,OAAO,CAAC,4BAA4B;IAsBpC;;OAEG;IACH,OAAO,CAAC,SAAS;CASlB;AAID,MAAM,WAAW,gBAAiB,SAAQ,SAAS;IACjD,WAAW,CAAC,EAAE;QACZ,OAAO,CAAC,EAAE,OAAO,CAAC;QAClB,IAAI,CAAC,EAAE,MAAM,CAAC;QACd,YAAY,CAAC,EAAE,CAAC,MAAM,OAAO,CAAC,OAAO,CAAC,CAAC,EAAE,CAAC;KAC3C,CAAC;CACH;AAED;;GAEG;AACH,wBAAgB,gBAAgB,CAC9B,OAAO,EAAE,CAAC,OAAO,UAAU,CAAC,EAAE,EAC9B,OAAO,GAAE,UAAe,EACxB,MAAM,GAAE,gBAAqB,GAC5B;IAAE,MAAM,EAAE,IAAI,CAAC,MAAM,CAAC;IAAC,GAAG,EAAE,MAAM,CAAA;CAAE,CActC;AAED;;GAEG;AACH,wBAAgB,eAAe,CAC7B,OAAO,EAAE,CAAC,OAAO,UAAU,CAAC,EAAE,EAC9B,OAAO,GAAE,UAAe,EACxB,MAAM,GAAE,gBAAqB,GAC5B,OAAO,CAAC,MAAM,OAAO,CAAC,IAAI,CAAC,CAAC,CAwB9B"}
1
+ {"version":3,"file":"rest.d.ts","sourceRoot":"","sources":["../../src/generators/rest.ts"],"names":[],"mappings":"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"}
@@ -233,12 +233,15 @@ var APIGenerator = class {
233
233
  });
234
234
  return this.addCorsHeaders(response, req);
235
235
  }
236
- if (url.pathname === `${this.config.basePath}/_events`) return handleEventsRoute(req, {
237
- authMiddleware: this.config.authMiddleware,
238
- db: this.resolveContextDb(),
239
- manifestHash: this.resolveManifestHash(),
240
- maxSubscribers: this.config.eventsRoute?.maxSubscribers
241
- });
236
+ if (url.pathname === `${this.config.basePath}/_events`) {
237
+ const response = await handleEventsRoute(req, {
238
+ authMiddleware: this.config.authMiddleware,
239
+ db: this.resolveContextDb(),
240
+ manifestHash: this.resolveManifestHash(),
241
+ maxSubscribers: this.config.eventsRoute?.maxSubscribers
242
+ });
243
+ return this.addCorsHeaders(response, req);
244
+ }
242
245
  if (url.pathname.startsWith(this.config.basePath || "")) {
243
246
  const response = await this.handleObjectRoute(req, url);
244
247
  return this.addCorsHeaders(response, req);
@@ -748,18 +751,28 @@ var APIGenerator = class {
748
751
  return origin && allowed.includes(origin) ? origin : null;
749
752
  }
750
753
  /**
754
+ * Whether to emit `Access-Control-Allow-Credentials: true` (#1861). Only when
755
+ * explicitly opted in AND an origin actually resolved — credentials are never
756
+ * paired with a wildcard/absent origin (`resolveAllowedOrigin` already never
757
+ * returns `*`).
758
+ */
759
+ shouldAllowCredentials() {
760
+ return this.config.allowCredentials === true;
761
+ }
762
+ /**
751
763
  * Create CORS preflight response
752
764
  */
753
765
  createCorsResponse(req) {
754
766
  const headers = {
755
767
  "Access-Control-Allow-Methods": "GET,POST,PUT,PATCH,DELETE,OPTIONS",
756
- "Access-Control-Allow-Headers": "Content-Type,Authorization",
768
+ "Access-Control-Allow-Headers": "Content-Type,Authorization,Last-Event-ID",
757
769
  "Access-Control-Max-Age": "86400"
758
770
  };
759
771
  const origin = this.resolveAllowedOrigin(req);
760
772
  if (origin) {
761
773
  headers["Access-Control-Allow-Origin"] = origin;
762
774
  headers.Vary = "Origin";
775
+ if (this.shouldAllowCredentials()) headers["Access-Control-Allow-Credentials"] = "true";
763
776
  }
764
777
  return new Response(null, {
765
778
  status: 200,
@@ -776,7 +789,8 @@ var APIGenerator = class {
776
789
  headers.set("Access-Control-Allow-Origin", origin);
777
790
  headers.set("Vary", "Origin");
778
791
  headers.set("Access-Control-Allow-Methods", "GET,POST,PUT,PATCH,DELETE,OPTIONS");
779
- headers.set("Access-Control-Allow-Headers", "Content-Type,Authorization");
792
+ headers.set("Access-Control-Allow-Headers", "Content-Type,Authorization,Last-Event-ID");
793
+ if (this.shouldAllowCredentials()) headers.set("Access-Control-Allow-Credentials", "true");
780
794
  return new Response(response.body, {
781
795
  status: response.status,
782
796
  statusText: response.statusText,