@happyvertical/smrt-core 0.51.10 → 0.51.12
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/change-feed.md +82 -0
- package/agents/generators.md +3 -1
- package/agents/schema-paths.md +19 -6
- package/dist/change-feed-sensitivity.d.ts +155 -0
- package/dist/change-feed-sensitivity.d.ts.map +1 -0
- package/dist/change-feed-sensitivity.js +139 -0
- package/dist/change-feed-sensitivity.js.map +1 -0
- package/dist/change-feed.d.ts +37 -10
- package/dist/change-feed.d.ts.map +1 -1
- package/dist/change-feed.js +128 -12
- package/dist/change-feed.js.map +1 -1
- package/dist/change-signals.d.ts.map +1 -1
- package/dist/change-signals.js +3 -0
- package/dist/change-signals.js.map +1 -1
- package/dist/class.d.ts +1 -1
- package/dist/class.d.ts.map +1 -1
- package/dist/class.js +4 -2
- package/dist/class.js.map +1 -1
- package/dist/decorators/index.d.ts +19 -0
- package/dist/decorators/index.d.ts.map +1 -1
- package/dist/decorators/index.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -2
- package/dist/manifest/static-manifest.js +1 -1
- package/dist/manifest/static-manifest.js.map +1 -1
- package/dist/manifest/store.js +1 -1
- package/dist/manifest.json +1 -1
- package/dist/migrations/differ.d.ts +64 -5
- package/dist/migrations/differ.d.ts.map +1 -1
- package/dist/migrations/differ.js +270 -37
- package/dist/migrations/differ.js.map +1 -1
- package/dist/migrations/index.d.ts +1 -1
- package/dist/migrations/index.d.ts.map +1 -1
- package/dist/migrations/index.js +2 -2
- package/dist/migrations/sqlite-rebuild.d.ts +41 -0
- package/dist/migrations/sqlite-rebuild.d.ts.map +1 -1
- package/dist/migrations/sqlite-rebuild.js +68 -13
- package/dist/migrations/sqlite-rebuild.js.map +1 -1
- package/dist/migrations.js +2 -2
- package/dist/registry/class-registration.d.ts +1 -1
- package/dist/registry/class-registration.d.ts.map +1 -1
- package/dist/registry/class-registration.js +50 -4
- package/dist/registry/class-registration.js.map +1 -1
- package/dist/registry/db-cache-key.d.ts +47 -0
- package/dist/registry/db-cache-key.d.ts.map +1 -0
- package/dist/registry/db-cache-key.js +201 -0
- package/dist/registry/db-cache-key.js.map +1 -0
- package/dist/registry/schema-builder.d.ts.map +1 -1
- package/dist/registry/schema-builder.js +6 -1
- package/dist/registry/schema-builder.js.map +1 -1
- package/dist/registry/types.d.ts +43 -0
- package/dist/registry/types.d.ts.map +1 -1
- package/dist/registry.d.ts +17 -1
- package/dist/registry.d.ts.map +1 -1
- package/dist/registry.js +20 -8
- package/dist/registry.js.map +1 -1
- package/dist/scanner/types.d.ts +2 -0
- package/dist/scanner/types.d.ts.map +1 -1
- package/dist/scanner/types.js.map +1 -1
- package/dist/schema/backfill.d.ts +16 -0
- package/dist/schema/backfill.d.ts.map +1 -0
- package/dist/schema/backfill.js +26 -0
- package/dist/schema/backfill.js.map +1 -0
- package/dist/schema/generator.d.ts.map +1 -1
- package/dist/schema/generator.js +3 -0
- package/dist/schema/generator.js.map +1 -1
- package/dist/schema/types.d.ts +20 -1
- package/dist/schema/types.d.ts.map +1 -1
- package/dist/smrt-knowledge.json +9 -9
- package/dist/vite-plugin/dev-plane-route.js +1 -1
- package/dist/vite-plugin/dev-plane-route.js.map +1 -1
- package/dist/vite-plugin/sveltekit-generator.d.ts.map +1 -1
- package/dist/vite-plugin/sveltekit-generator.js +54 -7
- package/dist/vite-plugin/sveltekit-generator.js.map +1 -1
- package/package.json +10 -10
package/agents/change-feed.md
CHANGED
|
@@ -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
|
package/agents/generators.md
CHANGED
|
@@ -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 `
|
|
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`.
|
package/agents/schema-paths.md
CHANGED
|
@@ -275,13 +275,26 @@ 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
|
|
279
|
-
|
|
280
|
-
|
|
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
|
+
DuckDB refuses `ALTER COLUMN` while any index depends on the table, so its
|
|
293
|
+
`SET NOT NULL` is bracketed by `DROP INDEX`/recreate from `duckdb_indexes().sql`.
|
|
281
294
|
- **SQLite** has no `ALTER COLUMN`: nullability/default alterations are manual
|
|
282
|
-
(comment SQL → `db:migrate` exit 1)
|
|
283
|
-
|
|
284
|
-
|
|
295
|
+
(comment SQL → `db:migrate` exit 1), except a NOT NULL tightening with a
|
|
296
|
+
declared `backfill`, which the rebuild performs (`COALESCE(col, backfill)`
|
|
297
|
+
in the copy). The rebuild otherwise consumes `type_upgrade` placeholders.
|
|
285
298
|
- Defaults compare through `canonicalizeDefault()`, which folds engine
|
|
286
299
|
renderings (`'x'::text`, `CAST('t' AS BOOLEAN)`, `CURRENT_TIMESTAMP` vs
|
|
287
300
|
`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"}
|
package/dist/change-feed.d.ts
CHANGED
|
@@ -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.
|
|
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;
|
|
1
|
+
{"version":3,"file":"change-feed.d.ts","sourceRoot":"","sources":["../src/change-feed.ts"],"names":[],"mappings":"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"}
|