@happyvertical/smrt-core 0.51.10 → 0.51.14

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.
Files changed (93) hide show
  1. package/agents/change-feed.md +82 -0
  2. package/agents/generators.md +3 -1
  3. package/agents/schema-paths.md +23 -6
  4. package/dist/change-feed-sensitivity.d.ts +155 -0
  5. package/dist/change-feed-sensitivity.d.ts.map +1 -0
  6. package/dist/change-feed-sensitivity.js +139 -0
  7. package/dist/change-feed-sensitivity.js.map +1 -0
  8. package/dist/change-feed.d.ts +37 -10
  9. package/dist/change-feed.d.ts.map +1 -1
  10. package/dist/change-feed.js +128 -12
  11. package/dist/change-feed.js.map +1 -1
  12. package/dist/change-signals.d.ts.map +1 -1
  13. package/dist/change-signals.js +3 -0
  14. package/dist/change-signals.js.map +1 -1
  15. package/dist/class.d.ts +1 -1
  16. package/dist/class.d.ts.map +1 -1
  17. package/dist/class.js +4 -2
  18. package/dist/class.js.map +1 -1
  19. package/dist/decorators/index.d.ts +19 -0
  20. package/dist/decorators/index.d.ts.map +1 -1
  21. package/dist/decorators/index.js.map +1 -1
  22. package/dist/index.d.ts +1 -0
  23. package/dist/index.d.ts.map +1 -1
  24. package/dist/index.js +3 -2
  25. package/dist/manifest/static-manifest.js +1 -1
  26. package/dist/manifest/static-manifest.js.map +1 -1
  27. package/dist/manifest/store.js +1 -1
  28. package/dist/manifest.json +1 -1
  29. package/dist/migrations/differ.d.ts +64 -5
  30. package/dist/migrations/differ.d.ts.map +1 -1
  31. package/dist/migrations/differ.js +274 -37
  32. package/dist/migrations/differ.js.map +1 -1
  33. package/dist/migrations/index.d.ts +1 -1
  34. package/dist/migrations/index.d.ts.map +1 -1
  35. package/dist/migrations/index.js +2 -2
  36. package/dist/migrations/sqlite-rebuild.d.ts +41 -0
  37. package/dist/migrations/sqlite-rebuild.d.ts.map +1 -1
  38. package/dist/migrations/sqlite-rebuild.js +68 -13
  39. package/dist/migrations/sqlite-rebuild.js.map +1 -1
  40. package/dist/migrations.js +2 -2
  41. package/dist/node_modules/.pnpm/{minimatch@10.2.5 → minimatch@10.2.6}/node_modules/minimatch/dist/esm/assert-valid-pattern.js +1 -1
  42. package/dist/node_modules/.pnpm/{minimatch@10.2.5 → minimatch@10.2.6}/node_modules/minimatch/dist/esm/assert-valid-pattern.js.map +1 -1
  43. package/dist/node_modules/.pnpm/{minimatch@10.2.5 → minimatch@10.2.6}/node_modules/minimatch/dist/esm/ast.js +1 -1
  44. package/dist/node_modules/.pnpm/{minimatch@10.2.5 → minimatch@10.2.6}/node_modules/minimatch/dist/esm/ast.js.map +1 -1
  45. package/dist/node_modules/.pnpm/{minimatch@10.2.5 → minimatch@10.2.6}/node_modules/minimatch/dist/esm/brace-expressions.js +1 -1
  46. package/dist/node_modules/.pnpm/{minimatch@10.2.5 → minimatch@10.2.6}/node_modules/minimatch/dist/esm/brace-expressions.js.map +1 -1
  47. package/dist/node_modules/.pnpm/{minimatch@10.2.5 → minimatch@10.2.6}/node_modules/minimatch/dist/esm/escape.js +1 -1
  48. package/dist/node_modules/.pnpm/{minimatch@10.2.5 → minimatch@10.2.6}/node_modules/minimatch/dist/esm/escape.js.map +1 -1
  49. package/dist/node_modules/.pnpm/{minimatch@10.2.5 → minimatch@10.2.6}/node_modules/minimatch/dist/esm/index.js +1 -1
  50. package/dist/node_modules/.pnpm/{minimatch@10.2.5 → minimatch@10.2.6}/node_modules/minimatch/dist/esm/index.js.map +1 -1
  51. package/dist/node_modules/.pnpm/{minimatch@10.2.5 → minimatch@10.2.6}/node_modules/minimatch/dist/esm/unescape.js +1 -1
  52. package/dist/node_modules/.pnpm/{minimatch@10.2.5 → minimatch@10.2.6}/node_modules/minimatch/dist/esm/unescape.js.map +1 -1
  53. package/dist/registry/class-registration.d.ts +1 -1
  54. package/dist/registry/class-registration.d.ts.map +1 -1
  55. package/dist/registry/class-registration.js +50 -4
  56. package/dist/registry/class-registration.js.map +1 -1
  57. package/dist/registry/db-cache-key.d.ts +47 -0
  58. package/dist/registry/db-cache-key.d.ts.map +1 -0
  59. package/dist/registry/db-cache-key.js +201 -0
  60. package/dist/registry/db-cache-key.js.map +1 -0
  61. package/dist/registry/schema-builder.d.ts.map +1 -1
  62. package/dist/registry/schema-builder.js +6 -1
  63. package/dist/registry/schema-builder.js.map +1 -1
  64. package/dist/registry/types.d.ts +43 -0
  65. package/dist/registry/types.d.ts.map +1 -1
  66. package/dist/registry.d.ts +17 -1
  67. package/dist/registry.d.ts.map +1 -1
  68. package/dist/registry.js +20 -8
  69. package/dist/registry.js.map +1 -1
  70. package/dist/scanner/types.d.ts +2 -0
  71. package/dist/scanner/types.d.ts.map +1 -1
  72. package/dist/scanner/types.js.map +1 -1
  73. package/dist/schema/backfill.d.ts +16 -0
  74. package/dist/schema/backfill.d.ts.map +1 -0
  75. package/dist/schema/backfill.js +26 -0
  76. package/dist/schema/backfill.js.map +1 -0
  77. package/dist/schema/generator.d.ts.map +1 -1
  78. package/dist/schema/generator.js +3 -0
  79. package/dist/schema/generator.js.map +1 -1
  80. package/dist/schema/types.d.ts +20 -1
  81. package/dist/schema/types.d.ts.map +1 -1
  82. package/dist/smrt-knowledge.json +21 -21
  83. package/dist/vite-plugin/dev-plane-route.js +1 -1
  84. package/dist/vite-plugin/dev-plane-route.js.map +1 -1
  85. package/dist/vite-plugin/index.js +1 -1
  86. package/dist/vite-plugin/sveltekit-generator.d.ts.map +1 -1
  87. package/dist/vite-plugin/sveltekit-generator.js +54 -7
  88. package/dist/vite-plugin/sveltekit-generator.js.map +1 -1
  89. package/dist/vite-plugin/sync-apply-route.d.ts +7 -0
  90. package/dist/vite-plugin/sync-apply-route.d.ts.map +1 -1
  91. package/dist/vite-plugin/sync-apply-route.js +50 -3
  92. package/dist/vite-plugin/sync-apply-route.js.map +1 -1
  93. package/package.json +23 -23
@@ -17,6 +17,88 @@ Adapter-agnostic change-observation spine (`src/change-feed.ts`) — the server
17
17
  - Retention: `pruneChangeFeed(db, { maxAgeMs?, maxRows?, dryRun? })` — scheduled since #2375 by `runRetentionSweep()` (30-day default), so nothing needs to call it directly; `dryRun` counts the same predicate instead of deleting. Pruning deletes oldest-first and always retains the newest entry (a non-empty feed is never emptied), which is what makes pruned-cursor detection provable. The age bound is a **prefix** bound — everything below the oldest entry still inside the window — because `created_at` and `seq` are not co-monotonic (writer clocks skew, and a staged entry carries its stage-time stamp into a later-assigned sequence); deleting by timestamp alone could punch a hole in the middle of the retained run, where `since < floor - 1` cannot see it and keeps caught-up consumers polling normally. Raw-SQL writes are invisible to the feed (same documented gap as the #1499 cache); `bumpChangeFeed(db, { table, rowId? })` is the manual escape hatch.
18
18
 
19
19
 
20
+ ## Credential-bearing tables are never disclosed (#2937)
21
+
22
+ `src/change-feed-sensitivity.ts` is a leaf module (it imports nothing, so
23
+ `change-feed.ts`, `change-signals.ts` and the registry can all consult it
24
+ without a cycle) holding the tables whose **row id or payload is a secret**. A
25
+ table joins it two ways: by name, via the baseline
26
+ `CHANGE_FEED_CREDENTIAL_TABLES` (`sessions`, `users_cli_auth_requests`,
27
+ `users_magic_link_tokens`, `magic_link_tokens`, `api_keys`,
28
+ `nostr_identities`), or because a class declared `@smrt({ sensitive: true })`
29
+ and registration pushed its resolved table name across
30
+ (`declareChangeFeedSensitiveTable`, called from both the decorator and the
31
+ manifest-stub registration paths). Both are needed: the declaration is the
32
+ package's own contract but only binds in a process where that package
33
+ registered, while the name baseline is all the read path has when serving a
34
+ database another process writes. The set is **monotonic** — `sensitive: false`
35
+ is not an opt-out and nothing removes a name — so a consumer whose domain
36
+ table is named `sessions` loses feed coverage for it and must rename it via
37
+ `@smrt({ tableName })`.
38
+
39
+ `isChangeFeedObservableTable()` ANDs the sensitivity check with
40
+ `CHANGE_FEED_EXCLUDED_TABLES`, and the refusal is enforced at four points, not
41
+ one: the interceptor write path; `appendChange`/`appendChanges`, the lowest
42
+ write API, so `bumpChangeFeed()` and any other escape hatch are covered (a
43
+ refused append returns `null`, indistinguishable from a staged one);
44
+ `deliverLocally` in `change-signals.ts`, which also catches a signal broadcast
45
+ by a peer replica running an older build; and **`getChangesSince()`**. The
46
+ read-side refusal is what makes upgrading sufficient — rows an earlier version
47
+ already wrote stay in the log but are never served. A caller-named sensitive
48
+ table is dropped from the `tables` filter rather than erroring, so nothing
49
+ confirms the table exists, and a request naming *only* sensitive tables gets
50
+ an empty page rather than an unfiltered one. Filtered rows never hold the
51
+ cursor back: an exhaustive page still advances to the served horizon.
52
+
53
+ `pruneChangeFeed()` additionally deletes sensitive rows below the horizon on
54
+ every sweep, ahead of either retention bound, so credentials do not sit at rest
55
+ for the retention window. That deletes from the middle of the retained run,
56
+ which the age bound goes to lengths to avoid — permissible only because these
57
+ rows are unservable on every read path, so no page ever contained them and no
58
+ cursor can fall into the gap. It **does** move `floor`, and usually will: a
59
+ session is created before the first domain write and re-saved on every request,
60
+ so the lowest retained sequences are typically credential rows. `floor` is read
61
+ live (`MIN(seq)`) on every `getChangesSince` and never cached, so the only
62
+ consequence is that a cursor below the new floor is answered `resyncRequired`
63
+ and refetches — including a `since=0` client on a never-pruned feed. Extra
64
+ resyncs, never a missed change. The horizon does not move, because the newest
65
+ entry is retained even when it is sensitive; that preserves "a non-empty feed
66
+ is never emptied" and means one credential row can linger until the next write
67
+ moves the horizon past it.
68
+
69
+ Because a non-observable table appends nothing, it also has no ETag source:
70
+ `getTableVersion()` would pin at whatever an older build last wrote and then
71
+ fall back to the global horizon, which moves only on unrelated traffic — so a
72
+ conditional GET could answer `304` for a revoked API key or a rotated session.
73
+ `getTableVersion()` therefore returns a deliberately **unrepeatable** value for
74
+ such a table — 48 bits of `crypto` randomness per call, not a per-process
75
+ counter, because two replicas' clock-seeded counters drift through each other
76
+ and the ETag carries no per-process entropy — so no *concrete* `If-None-Match`
77
+ can match and every such read is a full 200. It is the single point every ETag
78
+ path goes through (the runtime `APIGenerator`, the generated
79
+ `conditionalVersionedRead`, and route files an older generator already
80
+ emitted), so no call site needs a special case. A wildcard `If-None-Match: *`
81
+ still 304s; the read paths evaluate it only after the payload is built, so it
82
+ distinguishes nothing an unconditional request would not.
83
+
84
+ Registration derives the declared name from config and manifest; the writer uses
85
+ `instance.tableName`, which resolves through the STI base's schema and then the
86
+ class's own. Where those two derivations disagree the declaration lands on a
87
+ name nothing writes under, and the real table keeps appending. STI is *not* such
88
+ a case today — `@smrt()` already resolves an STI child to its base's table, so a
89
+ child declaring `sensitive` declares `<base>` (verified) — but the manifest-stub
90
+ and manifest-merge paths derive the name differently again, so registration
91
+ declares every candidate name. The authoritative check is at the write path:
92
+ `isChangeFeedSensitiveWrite()` asks the registry about the instance's own class
93
+ through a resolver hook on the leaf module (so `change-feed.ts` never imports
94
+ the registry), sees the exact name being recorded with no derivation to keep in
95
+ sync, and declares it — closing the read path and signal bus for that table.
96
+
97
+ Scope note: the generated `_changes`/`_events` routes still gate on an
98
+ authenticated principal only — there is no per-table permission check, and
99
+ tenant scoping (`getTenantScopedChangesSince`, fail-closed) remains the sole
100
+ row-level filter for ordinary tables.
101
+
20
102
  ## Compatible bulk mutations (#2818)
21
103
 
22
104
  `appendChanges(db, entries)` validates all inputs before writing and returns one
@@ -229,7 +229,7 @@ Generated reads (`list`/`get`) on the REST and SvelteKit generators support cond
229
229
  ## Generated dev-plane route (#2782)
230
230
 
231
231
  `src/vite-plugin/dev-plane-route.ts` emits `<routesDir>/_dev/[...tool]/+server.ts`
232
- only when `sveltekit.devPlaneRoute.enabled === true` and
232
+ only when `svelteKit.devPlaneRoute.enabled === true` and
233
233
  `@happyvertical/smrt-dev-mcp/dev-plane` resolves from the consumer (same
234
234
  resolvability guard as `_resources`). The route is dev-only (`$app/environment`
235
235
  `dev`, else 404), refuses to start without `SMRT_DEV_MCP_TOKEN` (503), and
@@ -237,3 +237,5 @@ delegates everything else to `createDevPlane`, passing `getSmrtConfig(...).db`
237
237
  from the generated `$lib/server/smrt` so the plane shares the app's connection.
238
238
  Core never imports smrt-dev-mcp; only the generated file does, at consumer
239
239
  runtime. The route carries the auto-generated header so the sweep owns it.
240
+
241
+ Generated SvelteKit write routes (#2977) call `requireRoutePermission(locals, action)` after `requireRouteAuth`: CRUD writes need `<collection>.create|update|delete` and mutating custom actions `<collection>.<method>` in the session permission snapshot on `locals`, else 403 (fail-closed; `api.public: true` stays open, `tenantContext.superAdminBypass` skips). See `packages/users/agents/permissions.md`.
@@ -275,13 +275,30 @@ and always reports what it will not touch:
275
275
  way to add uniqueness there; the bundled DuckDB 1.4.x resolves
276
276
  `ON CONFLICT (col)` through that index (the old #12684 limitation the DuckDB
277
277
  strategy's `requiresInlineUnique()` note describes no longer reproduces —
278
- the #2369 DuckDB test pins the upsert), older DuckDB builds may not. A required column with no default is enforced only on an empty table;
279
- on a populated one it is added nullable and the `NOT NULL` is reported as a
280
- manual follow-up on every engine.
278
+ the #2369 DuckDB test pins the upsert), older DuckDB builds may not. A required column with no default is enforced inline only on an empty
279
+ table. On a populated one it needs a per-row `backfill` (#3008,
280
+ `ColumnDefinition.backfill` from `@field({ backfill })`): PostgreSQL/DuckDB
281
+ run `ADD COLUMN` (nullable) → `UPDATE … SET col = (backfill) WHERE col IS
282
+ NULL` → `SET DEFAULT` (deferred, if declared) → `SET NOT NULL` → the unique
283
+ index; SQLite adds it through the table rebuild (`sqliteBackfillPlaceholderSql`
284
+ → `planSqliteTableRebuilds` appends `"col" TYPE NOT NULL` and copies
285
+ `(backfill)`), one rebuild per table for every such column. The differ first
286
+ probes `WHERE (backfill) IS NULL` on the live rows. With no default and no
287
+ backfill, or with a backfill that yields NULL or will not evaluate, the
288
+ column is **not added**: a manual `alter_column` (`mismatch.actual ===
289
+ REQUIRED_COLUMN_NOT_ADDED`) names table and column, and `compareTable`
290
+ withholds `add_index`/`add_foreign_key` changes on it. The same backfill
291
+ repairs nullability drift (an existing nullable column holding NULLs).
292
+ A backfill for a single-column unique target is also probed for duplicate
293
+ values and refused by name. Partial unique indexes are exempt from that
294
+ probe only where `supportsPartialIndexes()` holds (SQLite/PostgreSQL);
295
+ DuckDB/JSON build them as full indexes, so they are probed there (#3015).
296
+ DuckDB refuses `ALTER COLUMN` while any index depends on the table, so its
297
+ `SET NOT NULL` is bracketed by `DROP INDEX`/recreate from `duckdb_indexes().sql`.
281
298
  - **SQLite** has no `ALTER COLUMN`: nullability/default alterations are manual
282
- (comment SQL → `db:migrate` exit 1). The SQLite rebuild consumes
283
- only `type_upgrade` placeholders today; extending it to rewrite constraints
284
- would lift this.
299
+ (comment SQL → `db:migrate` exit 1), except a NOT NULL tightening with a
300
+ declared `backfill`, which the rebuild performs (`COALESCE(col, backfill)`
301
+ in the copy). The rebuild otherwise consumes `type_upgrade` placeholders.
285
302
  - Defaults compare through `canonicalizeDefault()`, which folds engine
286
303
  renderings (`'x'::text`, `CAST('t' AS BOOLEAN)`, `CURRENT_TIMESTAMP` vs
287
304
  `now()`) by manifest type; an unclassifiable rendering skips the comparison
@@ -0,0 +1,155 @@
1
+ /**
2
+ * Credential-bearing tables the change feed must never disclose (issue #2937).
3
+ *
4
+ * The feed records `{table, rowId, operation, tenantId}` for every observable
5
+ * table, and the generated `/_changes` and `/_events` routes gate on nothing
6
+ * but the presence of an authenticated principal — there is no per-table
7
+ * permission check. That is fine for domain data, which is what the routes are
8
+ * for, and a disclosure for any table whose **row id is itself a credential**.
9
+ *
10
+ * `@happyvertical/smrt-users`' `Session` is exactly that: `createSession()`
11
+ * returns the row id, that value is the `sid` cookie and the `accessToken`
12
+ * `TerminalAuthService` hands a device, and `loadSessionContext()` re-saves the
13
+ * row on every authenticated request — so every live session id was being
14
+ * rewritten into the feed continuously, where the lowest-privileged
15
+ * authenticated caller could read it and replay it.
16
+ *
17
+ * ## The rule
18
+ *
19
+ * A table is *sensitive* when it is named by {@link CHANGE_FEED_CREDENTIAL_TABLES}
20
+ * or when some registered class declared `@smrt({ sensitive: true })` for it.
21
+ * Sensitive tables are not observable: they are refused on the write path, on
22
+ * the live-signal path, and — because an earlier build already wrote their ids
23
+ * into existing databases — on the **read** path, so upgrading is sufficient
24
+ * and no operator has to purge anything by hand.
25
+ *
26
+ * ## Why both a declared marker and a baseline list
27
+ *
28
+ * The declared marker is the contract a package uses to make its own claim, and
29
+ * it travels with the class through the scanner, the manifest and the registry.
30
+ * It is not sufficient on its own, because it only takes effect in a process
31
+ * where the owning package actually registered: a worker that loaded only core,
32
+ * a replica still running yesterday's `smrt-users`, or a read-only handle
33
+ * serving `/_changes` for a database some other process writes. The baseline
34
+ * list is what makes the rule hold in those processes too — it is a property of
35
+ * the *name*, which is all the read path has.
36
+ *
37
+ * The set is therefore **monotonic**: names are added and never removed, and
38
+ * nothing here can be turned off by configuration. A consumer whose own domain
39
+ * table is named `sessions` or `api_keys` loses change-feed coverage for it;
40
+ * that is the deliberate direction to fail in, and the fix is to name the table
41
+ * something else (`@smrt({ tableName })`), not to weaken the rule.
42
+ *
43
+ * This module is a leaf on purpose — it imports nothing — so both
44
+ * `change-feed.ts` and `change-signals.ts` can consult it, and the registry can
45
+ * populate it at registration time, without an import cycle.
46
+ *
47
+ * ## Why the set lives on `globalThis`
48
+ *
49
+ * For the same reason `ObjectRegistry` does (see `packages/core/AGENTS.md`):
50
+ * this is a process-wide append-only registry written by one module (class
51
+ * registration) and read by others (the feed writer, the feed reader, the
52
+ * signal bus). A dev server's HMR reload, or a build that resolves two copies
53
+ * of `@happyvertical/smrt-core` (hoisting mismatch, dual ESM/CJS entry,
54
+ * bundler duplication), gives this file two module instances — and a
55
+ * module-scope `Set` would then take a class's declaration in one instance
56
+ * while the guards read the other's. The baseline names survive that, because
57
+ * every instance re-seeds them from the same static array; a
58
+ * **consumer-declared** table would not, and it would fail open silently, with
59
+ * nothing logged. One shared `globalThis` slot removes the divergence.
60
+ */
61
+ declare global {
62
+ var __smrtChangeFeedSensitiveTables: Set<string> | undefined;
63
+ }
64
+ /**
65
+ * Framework tables whose row id or stored payload is secret, by name.
66
+ *
67
+ * Audited for #2937 across `@happyvertical/smrt-users` and
68
+ * `@happyvertical/smrt-profiles`:
69
+ *
70
+ * - `sessions` — the row **id is the bearer credential** (`sid` cookie value,
71
+ * terminal-auth `accessToken`).
72
+ * - `users_cli_auth_requests` — device-code state: a plaintext `userCode` an
73
+ * approver can exchange, a `deviceCodeHash`, and the `sessionId` minted on
74
+ * approval.
75
+ * - `users_magic_link_tokens` / `magic_link_tokens` — the replay-protection
76
+ * nonce and the login-token hash a `verify()` looks up by.
77
+ * - `api_keys` — the API-key store (`keyHash`, `keyPrefix`, scopes).
78
+ * - `nostr_identities` — custodial private-key material (AES-256-GCM
79
+ * ciphertext, IV and auth tag).
80
+ * - `secrets` / `tenant_keys` (`@happyvertical/smrt-secrets`) — tenant secret
81
+ * ciphertext and the wrapped per-tenant data-encryption keys.
82
+ *
83
+ * The feed never carries column values, so for the hash-bearing tables what
84
+ * this withholds is the row id and the write timing — an enumeration handle
85
+ * onto a credential row, and a side channel that says when a credential was
86
+ * issued, used or rotated. Neither belongs on a route every authenticated
87
+ * principal can call.
88
+ */
89
+ export declare const CHANGE_FEED_CREDENTIAL_TABLES: readonly string[];
90
+ /**
91
+ * Mark `tableName` credential-bearing for the rest of the process.
92
+ *
93
+ * Called from class registration for every class declaring
94
+ * `@smrt({ sensitive: true })`. Idempotent, and deliberately one-way: a later
95
+ * registration of the same table under a different config cannot un-mark it,
96
+ * because a re-registration racing a read must not open the table back up.
97
+ */
98
+ export declare function declareChangeFeedSensitiveTable(tableName: string): void;
99
+ /** Whether `tableName` is credential-bearing and must never reach the feed. */
100
+ export declare function isChangeFeedSensitiveTable(tableName: string): boolean;
101
+ /**
102
+ * Every sensitive table name, sorted.
103
+ *
104
+ * Used by the read path to build its exclusion predicate, and by diagnostics
105
+ * that need to explain why a table reports no changes.
106
+ */
107
+ export declare function getChangeFeedSensitiveTables(): string[];
108
+ /**
109
+ * Resolves whether the class that produced an instance declared itself
110
+ * sensitive. Supplied by the registry; see
111
+ * {@link setChangeFeedSensitiveClassResolver}.
112
+ */
113
+ export type ChangeFeedSensitiveClassResolver = (ctor: unknown) => boolean;
114
+ /**
115
+ * Register the registry's class-level `sensitive` lookup (dependency
116
+ * inversion, exactly like the DispatchBus tenant resolver).
117
+ *
118
+ * ## Why the write path needs this and not just the name set
119
+ *
120
+ * Declaring by name at registration time assumes the name a class *declares*
121
+ * is the name its rows are *recorded* under. Registration derives that name
122
+ * from config and manifest, while the writer uses `instance.tableName`, which
123
+ * resolves through the STI base's schema and then the class's own. Those
124
+ * derivations are separate code, and where they disagree the declaration lands
125
+ * on a name nothing writes under while the real table keeps appending — a
126
+ * silent fail-open for exactly the consumer-declared tables the marker exists
127
+ * to protect.
128
+ *
129
+ * Measured, for the record: STI is **not** such a case today. `@smrt()`
130
+ * resolves an STI child to its base's table at registration, so a child
131
+ * declaring `sensitive: true` already declares `<base>` — verified against a
132
+ * real STI pair. The manifest-stub and manifest-merge paths derive the name
133
+ * differently again (`decoratorConfig` versus `objectDef.schema.tableName`),
134
+ * and registration now declares every candidate name to cover them.
135
+ *
136
+ * That list of candidates is still a list of guesses about someone else's
137
+ * derivation. This resolver is the authoritative check: the write path holds
138
+ * the instance, so it knows both the class and the exact `tableName` about to
139
+ * be recorded, with no derivation to keep in sync. It declares that name on the
140
+ * way past, which closes the read path and the signal bus for the same table.
141
+ *
142
+ * Kept in this leaf module so `change-feed.ts` never imports the registry —
143
+ * `class.ts` already imports `change-feed.ts`, so the reverse edge would be a
144
+ * cycle.
145
+ */
146
+ export declare function setChangeFeedSensitiveClassResolver(resolver: ChangeFeedSensitiveClassResolver | undefined): void;
147
+ /**
148
+ * Whether `tableName`, about to be recorded for an instance of `constructor`,
149
+ * is credential-bearing — by name, or because the class declared it.
150
+ *
151
+ * A class-declared hit also adds `tableName` to the set, so the read path and
152
+ * the signal bus refuse the same table from then on.
153
+ */
154
+ export declare function isChangeFeedSensitiveWrite(tableName: string, ctor: unknown): boolean;
155
+ //# sourceMappingURL=change-feed-sensitivity.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"change-feed-sensitivity.d.ts","sourceRoot":"","sources":["../src/change-feed-sensitivity.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2DG;AAEH,OAAO,CAAC,MAAM,CAAC;IAEb,IAAI,+BAA+B,EAAE,GAAG,CAAC,MAAM,CAAC,GAAG,SAAS,CAAC;CAC9D;AAED;;;;;;;;;;;;;;;;;;;;;;;;GAwBG;AACH,eAAO,MAAM,6BAA6B,EAAE,SAAS,MAAM,EAS1D,CAAC;AAoBF;;;;;;;GAOG;AACH,wBAAgB,+BAA+B,CAAC,SAAS,EAAE,MAAM,GAAG,IAAI,CAIvE;AAED,+EAA+E;AAC/E,wBAAgB,0BAA0B,CAAC,SAAS,EAAE,MAAM,GAAG,OAAO,CAErE;AAED;;;;;GAKG;AACH,wBAAgB,4BAA4B,IAAI,MAAM,EAAE,CAEvD;AAED;;;;GAIG;AACH,MAAM,MAAM,gCAAgC,GAAG,CAAC,IAAI,EAAE,OAAO,KAAK,OAAO,CAAC;AAI1E;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA+BG;AACH,wBAAgB,mCAAmC,CACjD,QAAQ,EAAE,gCAAgC,GAAG,SAAS,GACrD,IAAI,CAEN;AAED;;;;;;GAMG;AACH,wBAAgB,0BAA0B,CACxC,SAAS,EAAE,MAAM,EACjB,IAAI,EAAE,OAAO,GACZ,OAAO,CAcT"}
@@ -0,0 +1,139 @@
1
+ //#region src/change-feed-sensitivity.ts
2
+ /**
3
+ * Framework tables whose row id or stored payload is secret, by name.
4
+ *
5
+ * Audited for #2937 across `@happyvertical/smrt-users` and
6
+ * `@happyvertical/smrt-profiles`:
7
+ *
8
+ * - `sessions` — the row **id is the bearer credential** (`sid` cookie value,
9
+ * terminal-auth `accessToken`).
10
+ * - `users_cli_auth_requests` — device-code state: a plaintext `userCode` an
11
+ * approver can exchange, a `deviceCodeHash`, and the `sessionId` minted on
12
+ * approval.
13
+ * - `users_magic_link_tokens` / `magic_link_tokens` — the replay-protection
14
+ * nonce and the login-token hash a `verify()` looks up by.
15
+ * - `api_keys` — the API-key store (`keyHash`, `keyPrefix`, scopes).
16
+ * - `nostr_identities` — custodial private-key material (AES-256-GCM
17
+ * ciphertext, IV and auth tag).
18
+ * - `secrets` / `tenant_keys` (`@happyvertical/smrt-secrets`) — tenant secret
19
+ * ciphertext and the wrapped per-tenant data-encryption keys.
20
+ *
21
+ * The feed never carries column values, so for the hash-bearing tables what
22
+ * this withholds is the row id and the write timing — an enumeration handle
23
+ * onto a credential row, and a side channel that says when a credential was
24
+ * issued, used or rotated. Neither belongs on a route every authenticated
25
+ * principal can call.
26
+ */
27
+ var CHANGE_FEED_CREDENTIAL_TABLES = [
28
+ "sessions",
29
+ "users_cli_auth_requests",
30
+ "users_magic_link_tokens",
31
+ "magic_link_tokens",
32
+ "api_keys",
33
+ "nostr_identities",
34
+ "secrets",
35
+ "tenant_keys"
36
+ ];
37
+ /**
38
+ * The process-wide sensitive-table set, shared across every module instance.
39
+ *
40
+ * Seeded with the baseline on first access. A second module instance reaching
41
+ * the already-created set re-adds the baseline names, which is a no-op for a
42
+ * `Set` and keeps the invariant that the baseline is always present even if a
43
+ * future edit changes the array.
44
+ */
45
+ function sensitiveTableSet() {
46
+ let set = globalThis.__smrtChangeFeedSensitiveTables;
47
+ if (!set) {
48
+ set = /* @__PURE__ */ new Set();
49
+ globalThis.__smrtChangeFeedSensitiveTables = set;
50
+ }
51
+ for (const table of CHANGE_FEED_CREDENTIAL_TABLES) set.add(table);
52
+ return set;
53
+ }
54
+ /**
55
+ * Mark `tableName` credential-bearing for the rest of the process.
56
+ *
57
+ * Called from class registration for every class declaring
58
+ * `@smrt({ sensitive: true })`. Idempotent, and deliberately one-way: a later
59
+ * registration of the same table under a different config cannot un-mark it,
60
+ * because a re-registration racing a read must not open the table back up.
61
+ */
62
+ function declareChangeFeedSensitiveTable(tableName) {
63
+ const name = tableName?.trim();
64
+ if (!name) return;
65
+ sensitiveTableSet().add(name);
66
+ }
67
+ /** Whether `tableName` is credential-bearing and must never reach the feed. */
68
+ function isChangeFeedSensitiveTable(tableName) {
69
+ return Boolean(tableName) && sensitiveTableSet().has(tableName);
70
+ }
71
+ /**
72
+ * Every sensitive table name, sorted.
73
+ *
74
+ * Used by the read path to build its exclusion predicate, and by diagnostics
75
+ * that need to explain why a table reports no changes.
76
+ */
77
+ function getChangeFeedSensitiveTables() {
78
+ return [...sensitiveTableSet()].sort();
79
+ }
80
+ var sensitiveClassResolver;
81
+ /**
82
+ * Register the registry's class-level `sensitive` lookup (dependency
83
+ * inversion, exactly like the DispatchBus tenant resolver).
84
+ *
85
+ * ## Why the write path needs this and not just the name set
86
+ *
87
+ * Declaring by name at registration time assumes the name a class *declares*
88
+ * is the name its rows are *recorded* under. Registration derives that name
89
+ * from config and manifest, while the writer uses `instance.tableName`, which
90
+ * resolves through the STI base's schema and then the class's own. Those
91
+ * derivations are separate code, and where they disagree the declaration lands
92
+ * on a name nothing writes under while the real table keeps appending — a
93
+ * silent fail-open for exactly the consumer-declared tables the marker exists
94
+ * to protect.
95
+ *
96
+ * Measured, for the record: STI is **not** such a case today. `@smrt()`
97
+ * resolves an STI child to its base's table at registration, so a child
98
+ * declaring `sensitive: true` already declares `<base>` — verified against a
99
+ * real STI pair. The manifest-stub and manifest-merge paths derive the name
100
+ * differently again (`decoratorConfig` versus `objectDef.schema.tableName`),
101
+ * and registration now declares every candidate name to cover them.
102
+ *
103
+ * That list of candidates is still a list of guesses about someone else's
104
+ * derivation. This resolver is the authoritative check: the write path holds
105
+ * the instance, so it knows both the class and the exact `tableName` about to
106
+ * be recorded, with no derivation to keep in sync. It declares that name on the
107
+ * way past, which closes the read path and the signal bus for the same table.
108
+ *
109
+ * Kept in this leaf module so `change-feed.ts` never imports the registry —
110
+ * `class.ts` already imports `change-feed.ts`, so the reverse edge would be a
111
+ * cycle.
112
+ */
113
+ function setChangeFeedSensitiveClassResolver(resolver) {
114
+ sensitiveClassResolver = resolver;
115
+ }
116
+ /**
117
+ * Whether `tableName`, about to be recorded for an instance of `constructor`,
118
+ * is credential-bearing — by name, or because the class declared it.
119
+ *
120
+ * A class-declared hit also adds `tableName` to the set, so the read path and
121
+ * the signal bus refuse the same table from then on.
122
+ */
123
+ function isChangeFeedSensitiveWrite(tableName, ctor) {
124
+ if (isChangeFeedSensitiveTable(tableName)) return true;
125
+ if (!ctor || !sensitiveClassResolver) return false;
126
+ let declared = false;
127
+ try {
128
+ declared = sensitiveClassResolver(ctor) === true;
129
+ } catch {
130
+ return false;
131
+ }
132
+ if (!declared) return false;
133
+ declareChangeFeedSensitiveTable(tableName);
134
+ return true;
135
+ }
136
+ //#endregion
137
+ export { CHANGE_FEED_CREDENTIAL_TABLES, declareChangeFeedSensitiveTable, getChangeFeedSensitiveTables, isChangeFeedSensitiveTable, isChangeFeedSensitiveWrite, setChangeFeedSensitiveClassResolver };
138
+
139
+ //# sourceMappingURL=change-feed-sensitivity.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"change-feed-sensitivity.js","names":[],"sources":["../src/change-feed-sensitivity.ts"],"sourcesContent":["/**\n * Credential-bearing tables the change feed must never disclose (issue #2937).\n *\n * The feed records `{table, rowId, operation, tenantId}` for every observable\n * table, and the generated `/_changes` and `/_events` routes gate on nothing\n * but the presence of an authenticated principal — there is no per-table\n * permission check. That is fine for domain data, which is what the routes are\n * for, and a disclosure for any table whose **row id is itself a credential**.\n *\n * `@happyvertical/smrt-users`' `Session` is exactly that: `createSession()`\n * returns the row id, that value is the `sid` cookie and the `accessToken`\n * `TerminalAuthService` hands a device, and `loadSessionContext()` re-saves the\n * row on every authenticated request — so every live session id was being\n * rewritten into the feed continuously, where the lowest-privileged\n * authenticated caller could read it and replay it.\n *\n * ## The rule\n *\n * A table is *sensitive* when it is named by {@link CHANGE_FEED_CREDENTIAL_TABLES}\n * or when some registered class declared `@smrt({ sensitive: true })` for it.\n * Sensitive tables are not observable: they are refused on the write path, on\n * the live-signal path, and — because an earlier build already wrote their ids\n * into existing databases — on the **read** path, so upgrading is sufficient\n * and no operator has to purge anything by hand.\n *\n * ## Why both a declared marker and a baseline list\n *\n * The declared marker is the contract a package uses to make its own claim, and\n * it travels with the class through the scanner, the manifest and the registry.\n * It is not sufficient on its own, because it only takes effect in a process\n * where the owning package actually registered: a worker that loaded only core,\n * a replica still running yesterday's `smrt-users`, or a read-only handle\n * serving `/_changes` for a database some other process writes. The baseline\n * list is what makes the rule hold in those processes too — it is a property of\n * the *name*, which is all the read path has.\n *\n * The set is therefore **monotonic**: names are added and never removed, and\n * nothing here can be turned off by configuration. A consumer whose own domain\n * table is named `sessions` or `api_keys` loses change-feed coverage for it;\n * that is the deliberate direction to fail in, and the fix is to name the table\n * something else (`@smrt({ tableName })`), not to weaken the rule.\n *\n * This module is a leaf on purpose — it imports nothing — so both\n * `change-feed.ts` and `change-signals.ts` can consult it, and the registry can\n * populate it at registration time, without an import cycle.\n *\n * ## Why the set lives on `globalThis`\n *\n * For the same reason `ObjectRegistry` does (see `packages/core/AGENTS.md`):\n * this is a process-wide append-only registry written by one module (class\n * registration) and read by others (the feed writer, the feed reader, the\n * signal bus). A dev server's HMR reload, or a build that resolves two copies\n * of `@happyvertical/smrt-core` (hoisting mismatch, dual ESM/CJS entry,\n * bundler duplication), gives this file two module instances — and a\n * module-scope `Set` would then take a class's declaration in one instance\n * while the guards read the other's. The baseline names survive that, because\n * every instance re-seeds them from the same static array; a\n * **consumer-declared** table would not, and it would fail open silently, with\n * nothing logged. One shared `globalThis` slot removes the divergence.\n */\n\ndeclare global {\n // eslint-disable-next-line no-var\n var __smrtChangeFeedSensitiveTables: Set<string> | undefined;\n}\n\n/**\n * Framework tables whose row id or stored payload is secret, by name.\n *\n * Audited for #2937 across `@happyvertical/smrt-users` and\n * `@happyvertical/smrt-profiles`:\n *\n * - `sessions` — the row **id is the bearer credential** (`sid` cookie value,\n * terminal-auth `accessToken`).\n * - `users_cli_auth_requests` — device-code state: a plaintext `userCode` an\n * approver can exchange, a `deviceCodeHash`, and the `sessionId` minted on\n * approval.\n * - `users_magic_link_tokens` / `magic_link_tokens` — the replay-protection\n * nonce and the login-token hash a `verify()` looks up by.\n * - `api_keys` — the API-key store (`keyHash`, `keyPrefix`, scopes).\n * - `nostr_identities` — custodial private-key material (AES-256-GCM\n * ciphertext, IV and auth tag).\n * - `secrets` / `tenant_keys` (`@happyvertical/smrt-secrets`) — tenant secret\n * ciphertext and the wrapped per-tenant data-encryption keys.\n *\n * The feed never carries column values, so for the hash-bearing tables what\n * this withholds is the row id and the write timing — an enumeration handle\n * onto a credential row, and a side channel that says when a credential was\n * issued, used or rotated. Neither belongs on a route every authenticated\n * principal can call.\n */\nexport const CHANGE_FEED_CREDENTIAL_TABLES: readonly string[] = [\n 'sessions',\n 'users_cli_auth_requests',\n 'users_magic_link_tokens',\n 'magic_link_tokens',\n 'api_keys',\n 'nostr_identities',\n 'secrets',\n 'tenant_keys',\n];\n\n/**\n * The process-wide sensitive-table set, shared across every module instance.\n *\n * Seeded with the baseline on first access. A second module instance reaching\n * the already-created set re-adds the baseline names, which is a no-op for a\n * `Set` and keeps the invariant that the baseline is always present even if a\n * future edit changes the array.\n */\nfunction sensitiveTableSet(): Set<string> {\n let set = globalThis.__smrtChangeFeedSensitiveTables;\n if (!set) {\n set = new Set<string>();\n globalThis.__smrtChangeFeedSensitiveTables = set;\n }\n for (const table of CHANGE_FEED_CREDENTIAL_TABLES) set.add(table);\n return set;\n}\n\n/**\n * Mark `tableName` credential-bearing for the rest of the process.\n *\n * Called from class registration for every class declaring\n * `@smrt({ sensitive: true })`. Idempotent, and deliberately one-way: a later\n * registration of the same table under a different config cannot un-mark it,\n * because a re-registration racing a read must not open the table back up.\n */\nexport function declareChangeFeedSensitiveTable(tableName: string): void {\n const name = tableName?.trim();\n if (!name) return;\n sensitiveTableSet().add(name);\n}\n\n/** Whether `tableName` is credential-bearing and must never reach the feed. */\nexport function isChangeFeedSensitiveTable(tableName: string): boolean {\n return Boolean(tableName) && sensitiveTableSet().has(tableName);\n}\n\n/**\n * Every sensitive table name, sorted.\n *\n * Used by the read path to build its exclusion predicate, and by diagnostics\n * that need to explain why a table reports no changes.\n */\nexport function getChangeFeedSensitiveTables(): string[] {\n return [...sensitiveTableSet()].sort();\n}\n\n/**\n * Resolves whether the class that produced an instance declared itself\n * sensitive. Supplied by the registry; see\n * {@link setChangeFeedSensitiveClassResolver}.\n */\nexport type ChangeFeedSensitiveClassResolver = (ctor: unknown) => boolean;\n\nlet sensitiveClassResolver: ChangeFeedSensitiveClassResolver | undefined;\n\n/**\n * Register the registry's class-level `sensitive` lookup (dependency\n * inversion, exactly like the DispatchBus tenant resolver).\n *\n * ## Why the write path needs this and not just the name set\n *\n * Declaring by name at registration time assumes the name a class *declares*\n * is the name its rows are *recorded* under. Registration derives that name\n * from config and manifest, while the writer uses `instance.tableName`, which\n * resolves through the STI base's schema and then the class's own. Those\n * derivations are separate code, and where they disagree the declaration lands\n * on a name nothing writes under while the real table keeps appending — a\n * silent fail-open for exactly the consumer-declared tables the marker exists\n * to protect.\n *\n * Measured, for the record: STI is **not** such a case today. `@smrt()`\n * resolves an STI child to its base's table at registration, so a child\n * declaring `sensitive: true` already declares `<base>` — verified against a\n * real STI pair. The manifest-stub and manifest-merge paths derive the name\n * differently again (`decoratorConfig` versus `objectDef.schema.tableName`),\n * and registration now declares every candidate name to cover them.\n *\n * That list of candidates is still a list of guesses about someone else's\n * derivation. This resolver is the authoritative check: the write path holds\n * the instance, so it knows both the class and the exact `tableName` about to\n * be recorded, with no derivation to keep in sync. It declares that name on the\n * way past, which closes the read path and the signal bus for the same table.\n *\n * Kept in this leaf module so `change-feed.ts` never imports the registry —\n * `class.ts` already imports `change-feed.ts`, so the reverse edge would be a\n * cycle.\n */\nexport function setChangeFeedSensitiveClassResolver(\n resolver: ChangeFeedSensitiveClassResolver | undefined,\n): void {\n sensitiveClassResolver = resolver;\n}\n\n/**\n * Whether `tableName`, about to be recorded for an instance of `constructor`,\n * is credential-bearing — by name, or because the class declared it.\n *\n * A class-declared hit also adds `tableName` to the set, so the read path and\n * the signal bus refuse the same table from then on.\n */\nexport function isChangeFeedSensitiveWrite(\n tableName: string,\n ctor: unknown,\n): boolean {\n if (isChangeFeedSensitiveTable(tableName)) return true;\n if (!ctor || !sensitiveClassResolver) return false;\n let declared = false;\n try {\n declared = sensitiveClassResolver(ctor) === true;\n } catch {\n // A resolver failure must never fail the user's write; the name set still\n // covers every framework credential table.\n return false;\n }\n if (!declared) return false;\n declareChangeFeedSensitiveTable(tableName);\n return true;\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;AA2FA,IAAa,gCAAmD;CAC9D;CACA;CACA;CACA;CACA;CACA;CACA;CACA;AACF;;;;;;;;;AAUA,SAAS,oBAAiC;CACxC,IAAI,MAAM,WAAW;CACrB,IAAI,CAAC,KAAK;EACR,sBAAM,IAAI,IAAY;EACtB,WAAW,kCAAkC;CAC/C;CACA,KAAK,MAAM,SAAS,+BAA+B,IAAI,IAAI,KAAK;CAChE,OAAO;AACT;;;;;;;;;AAUA,SAAgB,gCAAgC,WAAyB;CACvE,MAAM,OAAO,WAAW,KAAK;CAC7B,IAAI,CAAC,MAAM;CACX,kBAAkB,CAAC,CAAC,IAAI,IAAI;AAC9B;;AAGA,SAAgB,2BAA2B,WAA4B;CACrE,OAAO,QAAQ,SAAS,KAAK,kBAAkB,CAAC,CAAC,IAAI,SAAS;AAChE;;;;;;;AAQA,SAAgB,+BAAyC;CACvD,OAAO,CAAC,GAAG,kBAAkB,CAAC,CAAC,CAAC,KAAK;AACvC;AASA,IAAI;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAkCJ,SAAgB,oCACd,UACM;CACN,yBAAyB;AAC3B;;;;;;;;AASA,SAAgB,2BACd,WACA,MACS;CACT,IAAI,2BAA2B,SAAS,GAAG,OAAO;CAClD,IAAI,CAAC,QAAQ,CAAC,wBAAwB,OAAO;CAC7C,IAAI,WAAW;CACf,IAAI;EACF,WAAW,uBAAuB,IAAI,MAAM;CAC9C,QAAQ;EAGN,OAAO;CACT;CACA,IAAI,CAAC,UAAU,OAAO;CACtB,gCAAgC,SAAS;CACzC,OAAO;AACT"}
@@ -16,13 +16,24 @@ export declare const CHANGE_FEED_TABLE = "_smrt_changes";
16
16
  * the feed's own table so it can never observe itself), the model-backed
17
17
  * operational plumbing ({@link FRAMEWORK_OPERATIONAL_TABLES}), and the retired
18
18
  * system tables that may still exist on older databases.
19
+ *
20
+ * Credential-bearing *application* tables are excluded separately, because
21
+ * that set grows at registration time rather than being fixed at module load
22
+ * — see {@link isChangeFeedSensitiveTable} and `change-feed-sensitivity.ts`
23
+ * (#2937).
19
24
  */
20
25
  export declare const CHANGE_FEED_EXCLUDED_TABLES: ReadonlySet<string>;
21
26
  /**
22
27
  * Whether framework writes to `tableName` are recorded in the change feed.
23
28
  *
29
+ * Two reasons a table is not observable: it is framework bookkeeping
30
+ * ({@link CHANGE_FEED_EXCLUDED_TABLES}), or it is credential-bearing
31
+ * ({@link isChangeFeedSensitiveTable}, #2937). The second is evaluated live
32
+ * rather than folded into the frozen set above, because a class declaring
33
+ * `@smrt({ sensitive: true })` registers after this module loads.
34
+ *
24
35
  * Exported so tooling and tests can reason about feed coverage without
25
- * re-deriving the rule. See {@link CHANGE_FEED_EXCLUDED_TABLES}.
36
+ * re-deriving the rule.
26
37
  */
27
38
  export declare function isChangeFeedObservableTable(tableName: string): boolean;
28
39
  /** Interceptor name of the framework's change-feed writer. */
@@ -227,15 +238,6 @@ export declare function ensureChangeFeedTable(db: DatabaseInterface): Promise<vo
227
238
  * handle from interleaving inside a manual SAVEPOINT scope.
228
239
  */
229
240
  export declare function appendChange(db: DatabaseInterface, input: AppendChangeInput): Promise<number | null>;
230
- /**
231
- * Append a mutation batch with one feed row per input entry.
232
- *
233
- * The batch is deliberately one statement per supported dialect. In
234
- * particular, PostgreSQL enters the existing append helper from one
235
- * framework-owned function call, so a caller transaction still stages every
236
- * entry, preserves rollback/failure isolation, and never waits on the feed
237
- * head. Direct batches allocate contiguous sequences in input order.
238
- */
239
241
  export declare function appendChanges(db: DatabaseInterface, inputs: AppendChangeBatch): Promise<Array<number | null>>;
240
242
  /**
241
243
  * Sequence change-feed entries staged inside caller-managed transactions
@@ -374,6 +376,31 @@ export declare function getTenantScopedChangesSince(db: DatabaseInterface, optio
374
376
  * high-water mark that survives pruning would remove even that cost; it is a
375
377
  * deliberate follow-up, out of scope for this slice.
376
378
  *
379
+ * ## Tables the feed does not observe (#2937)
380
+ *
381
+ * The whole contract above — "any write to the table appends a new sequence
382
+ * strictly greater than every previously-observed value" — rests on the table
383
+ * being recorded at all. For a credential-bearing table it is not, so there is
384
+ * no version to compute: the value would pin at whatever an older build last
385
+ * wrote, then fall back to the global horizon, which only moves when some
386
+ * *other* table writes. A client revalidating with `If-None-Match` would be
387
+ * answered `304` for a revoked API key, a rotated session or a changed
388
+ * identity, and stay stale until unrelated traffic happened to bump the
389
+ * horizon — a silent stale read of security state.
390
+ *
391
+ * Such a table therefore gets a deliberately **unrepeatable** value, so every
392
+ * ETag derived from it is fresh and no *concrete* `If-None-Match` can match.
393
+ * Those reads cost a full response every time, which is the correct trade for
394
+ * a credential store, and the caller needs no special case: this is the one
395
+ * place every ETag path (the runtime `APIGenerator`, the generated SvelteKit
396
+ * `conditionalVersionedRead`, and route files already emitted by an older
397
+ * generator) goes through.
398
+ *
399
+ * A wildcard `If-None-Match: *` still yields `304`, and deliberately so: the
400
+ * read paths evaluate it only *after* the payload is built, so it distinguishes
401
+ * nothing beyond 200-versus-404 — which an unconditional request reveals too —
402
+ * and it carries no stale representation.
403
+ *
377
404
  * Idempotently ensures the feed table exists first, so it is safe to call from
378
405
  * a read route on a raw handle that has never been written to.
379
406
  */
@@ -1 +1 @@
1
- {"version":3,"file":"change-feed.d.ts","sourceRoot":"","sources":["../src/change-feed.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAkIG;AAGH,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,oBAAoB,CAAC;AAS5D,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,aAAa,CAAC;AAuB9C,wDAAwD;AACxD,eAAO,MAAM,iBAAiB,kBAAkB,CAAC;AAEjD;;;;;;;;;;;;;;GAcG;AACH,eAAO,MAAM,2BAA2B,EAAE,WAAW,CAAC,MAAM,CAQ1D,CAAC;AAEH;;;;;GAKG;AACH,wBAAgB,2BAA2B,CAAC,SAAS,EAAE,MAAM,GAAG,OAAO,CAEtE;AAED,8DAA8D;AAC9D,eAAO,MAAM,4BAA4B,qBAAqB,CAAC;AAE/D;;;;GAIG;AACH,MAAM,MAAM,eAAe,GAAG,QAAQ,GAAG,QAAQ,GAAG,QAAQ,CAAC;AAE7D,oCAAoC;AACpC,MAAM,WAAW,eAAe;IAC9B,uEAAuE;IACvE,GAAG,EAAE,MAAM,CAAC;IACZ,yFAAyF;IACzF,KAAK,EAAE,MAAM,CAAC;IACd;;;OAGG;IACH,KAAK,EAAE,MAAM,GAAG,IAAI,CAAC;IACrB,8DAA8D;IAC9D,SAAS,EAAE,eAAe,CAAC;IAC3B,+EAA+E;IAC/E,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAC;IACxB,gEAAgE;IAChE,SAAS,EAAE,MAAM,CAAC;CACnB;AAED,2CAA2C;AAC3C,MAAM,WAAW,iBAAiB;IAChC;;;;;;;;;;;OAWG;IACH,KAAK,EAAE,MAAM,CAAC;IACd;;;;;;OAMG;IACH,KAAK,CAAC,EAAE,OAAO,CAAC;IAChB,0EAA0E;IAC1E,MAAM,CAAC,EAAE,MAAM,EAAE,CAAC;IAClB;;;;;;;OAOG;IACH,QAAQ,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IACzB;;;;OAIG;IACH,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB;AAED,8CAA8C;AAC9C,MAAM,WAAW,cAAc;IAC7B,mDAAmD;IACnD,OAAO,EAAE,eAAe,EAAE,CAAC;IAC3B;;;;;OAKG;IACH,MAAM,EAAE,MAAM,CAAC;IACf;;;;;;;;;;;;;;;;OAgBG;IACH,cAAc,CAAC,EAAE,OAAO,CAAC;IACzB;;;;;OAKG;IACH,YAAY,CAAC,EAAE,MAAM,CAAC;CACvB;AAED,+DAA+D;AAC/D,MAAM,WAAW,iBAAiB;IAChC,gDAAgD;IAChD,KAAK,EAAE,MAAM,CAAC;IACd,8EAA8E;IAC9E,KAAK,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IACtB,gDAAgD;IAChD,SAAS,CAAC,EAAE,eAAe,CAAC;IAC5B,8DAA8D;IAC9D,QAAQ,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;CAC1B;AAED,0EAA0E;AAC1E,MAAM,MAAM,iBAAiB,GAAG,SAAS,iBAAiB,EAAE,CAAC;AAE7D,8EAA8E;AAC9E,MAAM,WAAW,mBAAmB;IAClC,uDAAuD;IACvD,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,2DAA2D;IAC3D,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB;;;;;OAKG;IACH,MAAM,CAAC,EAAE,OAAO,CAAC;CAClB;AAED,qDAAqD;AACrD,eAAO,MAAM,qBAAqB,MAAM,CAAC;AAEzC,6DAA6D;AAC7D,eAAO,MAAM,iBAAiB,OAAQ,CAAC;AAiUvC;;;;;;;;;;;GAWG;AACH,wBAAsB,sCAAsC,CAC1D,EAAE,EAAE,iBAAiB,EACrB,OAAO,GAAE;IACP,eAAe,CAAC,EAAE,OAAO,CAAC;IAC1B,QAAQ,CAAC,EAAE,MAAM,CAAC;CACd,GACL,OAAO,CAAC,IAAI,CAAC,CAcf;AAED;;;;;;;;;;;;;;;;;;GAkBG;AACH,wBAAsB,+BAA+B,CACnD,EAAE,EAAE,iBAAiB,EACrB,QAAQ,CAAC,EAAE,MAAM,GAChB,OAAO,CAAC,IAAI,CAAC,CAcf;AAED,wBAAsB,qBAAqB,CACzC,EAAE,EAAE,iBAAiB,GACpB,OAAO,CAAC,IAAI,CAAC,CA0Bf;AAMD;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4BG;AACH,wBAAsB,YAAY,CAChC,EAAE,EAAE,iBAAiB,EACrB,KAAK,EAAE,iBAAiB,GACvB,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAiIxB;AAED;;;;;;;;GAQG;AACH,wBAAsB,aAAa,CACjC,EAAE,EAAE,iBAAiB,EACrB,MAAM,EAAE,iBAAiB,GACxB,OAAO,CAAC,KAAK,CAAC,MAAM,GAAG,IAAI,CAAC,CAAC,CAsG/B;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA6BG;AACH,wBAAsB,eAAe,CAAC,EAAE,EAAE,iBAAiB,GAAG,OAAO,CAAC,MAAM,CAAC,CAE5E;AAyRD;;;;;;;;;;;;;;;GAeG;AACH,wBAAsB,cAAc,CAClC,EAAE,EAAE,iBAAiB,EACrB,KAAK,EAAE,iBAAiB,GACvB,OAAO,CAAC,IAAI,CAAC,CAGf;AAMD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAkCG;AACH,wBAAsB,eAAe,CACnC,EAAE,EAAE,iBAAiB,EACrB,OAAO,EAAE,iBAAiB,GACzB,OAAO,CAAC,cAAc,CAAC,CAiJzB;AAED;;;;;;;;;;;;;;;GAeG;AACH,wBAAsB,2BAA2B,CAC/C,EAAE,EAAE,iBAAiB,EACrB,OAAO,EAAE,IAAI,CAAC,iBAAiB,EAAE,UAAU,CAAC,GAC3C,OAAO,CAAC,cAAc,CAAC,CAMzB;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAsCG;AACH,wBAAsB,eAAe,CACnC,EAAE,EAAE,iBAAiB,EACrB,KAAK,EAAE,MAAM,GACZ,OAAO,CAAC,MAAM,CAAC,CA8CjB;AA4BD;;;;;;;;;;;;;;;;;;;;;;GAsBG;AACH,wBAAsB,eAAe,CACnC,EAAE,EAAE,iBAAiB,EACrB,SAAS,EAAE,mBAAmB,GAC7B,OAAO,CAAC;IAAE,MAAM,EAAE,MAAM,CAAA;CAAE,CAAC,CAyF7B;AA4BD,eAAO,MAAM,6BAA6B,gCAAgC,CAAC;AAW3E;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,wBAAgB,wBAAwB,IAAI,IAAI,CA4D/C;AAED,uDAAuD;AACvD,wBAAgB,0BAA0B,IAAI,OAAO,CAEpD;AAiED;;;;;GAKG;AACH,wBAAsB,qBAAqB,CACzC,OAAO,EAAE,SAAS;IAAE,QAAQ,EAAE,UAAU,CAAC;IAAC,SAAS,EAAE,eAAe,CAAA;CAAE,EAAE,GACvE,OAAO,CAAC,IAAI,CAAC,CA0Df;AAED,gFAAgF;AAChF,wBAAsB,oBAAoB,CACxC,QAAQ,EAAE,UAAU,EACpB,SAAS,GAAE,eAA0B,GACpC,OAAO,CAAC,IAAI,CAAC,CAEf;AA8DD;;GAEG;AACH,wBAAgB,uBAAuB,IAAI,IAAI,CAS9C"}
1
+ {"version":3,"file":"change-feed.d.ts","sourceRoot":"","sources":["../src/change-feed.ts"],"names":[],"mappings":"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"}