@fuzdev/fuz_app 0.75.0 → 0.77.0

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.
@@ -1,38 +1,67 @@
1
1
  /**
2
- * `GET /api/facts/:hash`content-addressed fact serving.
2
+ * Content-addressed fact serving cell-scoped, per-reference reads.
3
3
  *
4
- * Resolves a fact hash to the bytes referenced by at least one viewable
5
- * cell. Embedded facts stream from the `facts.bytes` PG column;
6
- * external facts (filesystem-backed `file:<shard>/<rest>` URLs) either
7
- * return an `X-Accel-Redirect` header pointing into nginx's internal
8
- * facts location (production) or stream from disk via the filesystem
9
- * `FactExternalFetcher` (dev / tests). The runtime mode is selected by
10
- * the optional `x_accel_redirect_prefix` factory option — set in prod,
11
- * unset in dev.
4
+ * Two routes serve fact bytes from the PG-backed fact store:
5
+ *
6
+ * - `GET /api/cells/:cell_id/facts/:hash` the **per-reference read**.
7
+ * The request names the referencing cell. Authz is scoped to that one
8
+ * reference: `can_view_cell(caller, cell) AND cell.refs includes hash`.
9
+ * This is the path non-admin callers use, and the only path for
10
+ * confidential content.
11
+ * - `GET /api/facts/:hash` — the **bare-hash read**, restricted to admins.
12
+ *
13
+ * Embedded facts stream from the `fact.bytes` PG column; external facts
14
+ * (filesystem-backed `file:<shard>/<rest>` URLs) either return an
15
+ * `X-Accel-Redirect` header pointing into nginx's internal facts location
16
+ * (production) or stream from disk via the filesystem `FactExternalFetcher`
17
+ * (dev / tests). The runtime mode is selected by the optional
18
+ * `x_accel_redirect_prefix` factory option — set in prod, unset in dev.
12
19
  *
13
20
  * REST, not RPC: binary responses don't fit the JSON-RPC envelope.
14
21
  *
15
- * ## Authorization
16
- *
17
- * Auth is `{account: 'none', actor: 'none'}` — the dispatcher's
18
- * authorization phase is skipped for pure-public routes, so this handler
19
- * builds the `RequestContext` itself from `c.var.account_id` (populated
20
- * by the `/api/*` session middleware) by resolving the caller's single
21
- * actor and loading their role_grants. Unauthed callers pass through
22
- * with `req_ctx: null`. Viewers are admitted via `can_view_cell` over
23
- * **every** active cell that references the hash. Multi-actor accounts
24
- * fall through with `req_ctx: null`there's no `acting?` slot on a
25
- * pure-public route, so multi-actor callers are treated as anonymous
26
- * (admitted only by the public-visibility branch). A fact is viewable iff
27
- * at least one referencing cell admits the caller; unauthenticated callers
28
- * are admitted only via a referencing cell with `cell.visibility ===
29
- * 'public'`. Facts with no referencing active cell are unreachable here
30
- * orphan-fact GC reaps them separately.
31
- *
32
- * 404 is the universal "not viewable" response: missing fact, missing
33
- * referencing cell, all referencing cells private to other actors. We
34
- * deliberately don't distinguish 403 from 404 the existence of a
35
- * private hash should not leak through the public surface.
22
+ * ## Authorization — authz lives on the cell→fact edge, not the hash
23
+ *
24
+ * Facts are global, content-addressed, owner-less bytes: identical bytes
25
+ * from different owners dedup to **one** `fact` row. Keying access control
26
+ * on the bare hash therefore unions visibility across every owner that
27
+ * references it A's private bytes leak the instant B references identical
28
+ * bytes from a public cell. The fix is to scope authz to the
29
+ * `(cell, hash)` edge: a caller reads a fact *through a specific cell it
30
+ * can view that references the hash*. Dedup becomes a pure storage
31
+ * optimization with zero authz consequence whether two owners' bytes
32
+ * share a `fact` row is invisible to the read check.
33
+ *
34
+ * The cell-scoped route resolves the named cell, requires
35
+ * `can_view_cell(caller, cell)`, and requires `cell.refs` to include the
36
+ * hash. B publishing identical bytes from B's public cell makes them
37
+ * readable *via B's cell* — it never touches A's private reference.
38
+ *
39
+ * The bare-hash route is **admin-only**: an admin's reach already spans
40
+ * every cell, so serving by bare hash grants no escalation. Non-admin
41
+ * callers are rejected at the auth phase and never reach the handler.
42
+ * (Explicitly-public facts a producer opting bytes into world-readable
43
+ * status — are a future refinement; there is no such concept today, so
44
+ * the bare-hash route stays strictly admin-gated.)
45
+ *
46
+ * Auth shape on the cell-scoped route is `{account: 'none', actor: 'none'}`
47
+ * — the dispatcher's authorization phase is skipped for pure-public routes,
48
+ * so the handler builds the `RequestContext` itself from `c.var.account_id`
49
+ * (populated by the `/api/*` session middleware) by resolving the caller's
50
+ * single actor and loading their role_grants. Unauthed callers pass through
51
+ * with `req_ctx: null` and are admitted only by a `cell.visibility ===
52
+ * 'public'` cell. Multi-actor accounts fall through with `req_ctx: null`
53
+ * — there's no `acting?` slot on a pure-public route, so multi-actor
54
+ * callers are treated as anonymous.
55
+ *
56
+ * 404 is the universal "not viewable" response: missing fact, missing or
57
+ * unviewable cell, or the cell doesn't reference the hash. We deliberately
58
+ * don't distinguish 403 from 404 — neither the existence of a fact nor the
59
+ * existence of a cell→fact edge should leak through the public surface.
60
+ *
61
+ * Content-addressed serving of inline `blake3:` images (a markdown doc cell
62
+ * with embedded image refs) works through this model: the referencing cell
63
+ * is the doc cell, so serving goes view-doc-cell → doc-cell-refs-include-hash
64
+ * → serve.
36
65
  *
37
66
  * ## Defense-in-depth
38
67
  *
@@ -47,151 +76,223 @@ import { createReadStream } from 'node:fs';
47
76
  import { Readable } from 'node:stream';
48
77
  import { join } from 'node:path';
49
78
  import { FactHashSchema } from '@fuzdev/fuz_util/fact_hash.js';
79
+ import { Uuid } from '@fuzdev/fuz_util/id.js';
50
80
  import { z } from 'zod';
51
- import { build_request_context } from '../auth/request_context.js';
81
+ import { build_request_context, get_request_context, has_role, } from '../auth/request_context.js';
82
+ import { ROLE_ADMIN } from '../auth/role_schema.js';
83
+ import { ActingActor } from '../http/auth_shape.js';
52
84
  import { ACCOUNT_ID_KEY } from '../hono_context.js';
53
85
  import { query_actors_by_account } from '../auth/account_queries.js';
54
86
  import { get_route_params } from '../http/route_spec.js';
55
87
  import { ERROR_INVALID_ROUTE_PARAMS } from '../http/error_schemas.js';
56
88
  import { query_get_fact, query_get_fact_meta } from '../db/fact_queries.js';
57
- import { query_cell_list_by_ref } from '../db/cell_queries.js';
89
+ import { query_cell_get } from '../db/cell_queries.js';
58
90
  import { query_cell_grant_list_for_cell } from '../db/cell_grant_queries.js';
59
91
  import { can_view_cell } from '../auth/cell_authorize.js';
60
92
  import { parse_file_fact_url } from './file_fact_url.js';
61
93
  /** `Cache-Control` for fact responses — 5 min revocation window. */
62
94
  const CACHE_CONTROL = 'private, max-age=300';
63
95
  /**
64
- * Path-param schema. Matching the canonical fact-hash form here pushes
65
- * malformed-hash 400s through the framework's standard params-validation
66
- * error shape (`ERROR_INVALID_ROUTE_PARAMS`), which the round-trip
67
- * validator expects.
96
+ * Path-param schema for the bare-hash route. Matching the canonical
97
+ * fact-hash form here pushes malformed-hash 400s through the framework's
98
+ * standard params-validation error shape (`ERROR_INVALID_ROUTE_PARAMS`),
99
+ * which the round-trip validator expects.
100
+ */
101
+ const bare_hash_params_schema = z.strictObject({
102
+ hash: FactHashSchema,
103
+ });
104
+ /**
105
+ * Path-param schema for the cell-scoped route. `cell_id` validates as a
106
+ * `Uuid`; `hash` as the canonical fact-hash form. Malformed values 400 via
107
+ * the standard params-validation shape.
68
108
  */
69
- const params_schema = z.strictObject({
109
+ const cell_fact_params_schema = z.strictObject({
110
+ cell_id: Uuid,
70
111
  hash: FactHashSchema,
71
112
  });
113
+ /** Shared error-schema entry: tighten the auto-derived 400 to the literal emitted by params validation. */
114
+ const params_400_error = {
115
+ 400: z.looseObject({
116
+ error: z.literal(ERROR_INVALID_ROUTE_PARAMS),
117
+ issues: z.array(z.unknown()),
118
+ }),
119
+ };
120
+ /**
121
+ * Serve a fact's bytes by hash, after the caller has been authorized for it.
122
+ *
123
+ * This is the shared tail of both routes — it never re-checks authz, so it
124
+ * MUST only be called once the caller has been admitted (admin on the
125
+ * bare-hash route, viewable referencing cell on the cell-scoped route).
126
+ * Reuses the embedded-stream / `X-Accel-Redirect` / disk-stream logic.
127
+ *
128
+ * Returns 404 when the fact's metadata is missing or its embedded bytes
129
+ * have vanished (a race), so the response shape is identical whether the
130
+ * fact is genuinely absent or merely unviewable.
131
+ */
132
+ const serve_fact_bytes = async (c, route, hash, config) => {
133
+ const { facts_dir, x_accel_redirect_prefix, log } = config;
134
+ const meta = await query_get_fact_meta({ db: route.db }, hash);
135
+ if (!meta) {
136
+ return c.body(null, 404);
137
+ }
138
+ const content_type = meta.content_type ?? 'application/octet-stream';
139
+ const size = String(meta.size);
140
+ if (meta.external_url === null) {
141
+ // Embedded — bytes live in the PG row.
142
+ const row = await query_get_fact({ db: route.db }, hash);
143
+ if (!row || row.bytes === null) {
144
+ // Race: meta said embedded but bytes vanished. Treat as not-found.
145
+ log.warn(`serve_fact: embedded bytes missing for ${hash} (meta said embedded, row=${row ? 'present' : 'null'})`);
146
+ return c.body(null, 404);
147
+ }
148
+ const bytes = to_uint8(row.bytes);
149
+ return c.body(bytes, 200, {
150
+ 'Content-Type': content_type,
151
+ 'Content-Length': size,
152
+ 'Cache-Control': CACHE_CONTROL,
153
+ });
154
+ }
155
+ // External — defense-in-depth re-validate the URL before trusting it
156
+ // to address the filesystem.
157
+ const parsed = parse_file_fact_url(meta.external_url);
158
+ if (!parsed) {
159
+ log.error(`serve_fact: rejecting malformed external_url for ${hash}: ${meta.external_url}`);
160
+ return c.body(null, 404);
161
+ }
162
+ const { shard, rest } = parsed;
163
+ if (x_accel_redirect_prefix !== undefined) {
164
+ // Production: hand off to nginx via the internal facts location.
165
+ return c.body(null, 200, {
166
+ 'Content-Type': content_type,
167
+ 'Content-Length': size,
168
+ 'Cache-Control': CACHE_CONTROL,
169
+ 'X-Accel-Redirect': `${x_accel_redirect_prefix}${shard}/${rest}`,
170
+ });
171
+ }
172
+ // Dev / tests: stream from disk. `createReadStream` errors land on the
173
+ // returned ReadableStream, which Hono surfaces as a 500 to the client.
174
+ const file_path = join(facts_dir, shard, rest);
175
+ const node_stream = createReadStream(file_path);
176
+ const web_stream = Readable.toWeb(node_stream);
177
+ return c.body(web_stream, 200, {
178
+ 'Content-Type': content_type,
179
+ 'Content-Length': size,
180
+ 'Cache-Control': CACHE_CONTROL,
181
+ });
182
+ };
72
183
  /**
73
- * Build the `GET /api/facts/:hash` `RouteSpec`.
184
+ * Resolve the caller's `RequestContext` on a pure-public route from the
185
+ * session-middleware-set account id. Multi-actor accounts return `null`
186
+ * (no `acting?` slot on a public route to disambiguate); single-actor
187
+ * accounts resolve their actor and role_grants for owner / grant / admin
188
+ * admission paths. Unauthenticated callers return `null`.
189
+ */
190
+ const build_public_request_context = async (c, route) => {
191
+ const account_id = c.get(ACCOUNT_ID_KEY);
192
+ if (!account_id)
193
+ return null;
194
+ const actors = await query_actors_by_account({ db: route.db }, account_id);
195
+ if (actors.length !== 1)
196
+ return null;
197
+ return build_request_context({ db: route.db }, account_id, actors[0].id);
198
+ };
199
+ /**
200
+ * Build the cell-scoped `GET /api/cells/:cell_id/facts/:hash` `RouteSpec`
201
+ * — the per-reference read.
202
+ *
203
+ * Resolves the named cell (404 if missing / soft-deleted), requires
204
+ * `can_view_cell(caller, cell)` AND `cell.refs` to include the hash
205
+ * (else 404, masked), then serves the bytes. Authz is scoped to this one
206
+ * `(cell, hash)` edge — never unioned across the fact's other referrers.
74
207
  *
75
208
  * Pure-public auth — the handler builds the per-request `RequestContext`
76
- * from `c.var.account_id` and enforces visibility per-fact via the
77
- * cell-walk above.
209
+ * from `c.var.account_id` and enforces visibility per-reference.
78
210
  */
79
- export const create_serve_fact_route_spec = (options) => {
211
+ export const create_serve_cell_fact_route_spec = (options) => {
80
212
  const { facts_dir, x_accel_redirect_prefix, log } = options;
213
+ const config = { facts_dir, x_accel_redirect_prefix, log };
81
214
  return {
82
215
  method: 'GET',
83
- path: '/api/facts/:hash',
216
+ path: '/api/cells/:cell_id/facts/:hash',
84
217
  auth: { account: 'none', actor: 'none' },
85
- description: 'Serve content-addressed fact bytes. 404 unless at least one referencing cell admits the caller via can_view_cell.',
86
- params: params_schema,
218
+ description: 'Serve content-addressed fact bytes through a named referencing cell. 404 unless the cell admits the caller via can_view_cell AND references the hash (per-reference, never union-of-referrers).',
219
+ params: cell_fact_params_schema,
87
220
  input: z.null(),
88
221
  // The body is a binary stream; no JSON output schema applies.
89
222
  output: z.null(),
90
- errors: {
91
- // Tighten the auto-derived 400 from `ApiError` (`error: string`) to
92
- // the actual literal emitted by `create_params_validation`, so
93
- // `assert_error_schema_tightness` reads the surface as specific
94
- // rather than generic.
95
- 400: z.looseObject({
96
- error: z.literal(ERROR_INVALID_ROUTE_PARAMS),
97
- issues: z.array(z.unknown()),
98
- }),
99
- },
223
+ errors: params_400_error,
100
224
  handler: async (c, route) => {
101
- const { hash } = get_route_params(c);
102
- const meta = await query_get_fact_meta({ db: route.db }, hash);
103
- if (!meta) {
225
+ const { cell_id, hash } = get_route_params(c);
226
+ // Resolve the named cell. Missing / soft-deleted → 404 (masked).
227
+ const cell = await query_cell_get({ db: route.db }, cell_id);
228
+ if (!cell) {
104
229
  return c.body(null, 404);
105
230
  }
106
- // Pure-public route dispatcher skips the authorization phase, so
107
- // build the `RequestContext` here from the session-middleware-set
108
- // account id. Multi-actor accounts fall through with `null` (no
109
- // `acting?` slot on a public route to disambiguate); single-actor
110
- // accounts resolve their actor and role_grants for owner / grant /
111
- // admin admission paths.
112
- const account_id = c.get(ACCOUNT_ID_KEY);
113
- let req_ctx = null;
114
- if (account_id) {
115
- const actors = await query_actors_by_account({ db: route.db }, account_id);
116
- if (actors.length === 1) {
117
- req_ctx = await build_request_context({ db: route.db }, account_id, actors[0].id);
118
- }
119
- }
120
- // `include_grant_count: false` — the authz walk only reads
121
- // `can_view_cell`-relevant fields, so skip the per-row grant
122
- // COUNT subquery. Cheap to begin with; even cheaper now.
123
- const referencing_cells = await query_cell_list_by_ref({ db: route.db }, hash, {
124
- include_grant_count: false,
125
- });
126
- // Sequential walk with early break on first admit — preserves
127
- // the original `.some()` short-circuit. Unauthenticated callers
128
- // skip the grant fetch entirely since no grant can admit a null
129
- // req_ctx (the only admit path is the public-visibility branch
130
- // in `can_view_cell`, which doesn't need grants). Authenticated
131
- // callers eat one `cell_grant` lookup per referencing cell up
132
- // to the first admit; acceptable at MVP fact-serve scale.
133
- // TODO: if profiling shows this hot, batch grants in one query
134
- // across all referencing cells, or push the predicate into SQL.
135
- let viewable = false;
136
- for (const cell of referencing_cells) {
137
- const grants = req_ctx
138
- ? await query_cell_grant_list_for_cell({ db: route.db }, cell.id)
139
- : null;
140
- if (can_view_cell(req_ctx, cell, grants)) {
141
- viewable = true;
142
- break;
143
- }
144
- }
145
- if (!viewable) {
146
- // 404 (not 403) so existence of private hashes doesn't leak
147
- // through the public surface. Same response shape as a
148
- // genuinely missing fact.
231
+ // The cell→fact edge: the cell must actually reference the hash.
232
+ // `cell.refs` is auto-derived from `data` on every write, so this is
233
+ // the authoritative "does this cell reference these bytes" check.
234
+ // Missing edge 404 (masked), never "exists elsewhere".
235
+ if (!cell.refs?.includes(hash)) {
149
236
  return c.body(null, 404);
150
237
  }
151
- const content_type = meta.content_type ?? 'application/octet-stream';
152
- const size = String(meta.size);
153
- if (meta.external_url === null) {
154
- // Embedded bytes live in the PG row.
155
- const row = await query_get_fact({ db: route.db }, hash);
156
- if (!row || row.bytes === null) {
157
- // Race: meta said embedded but bytes vanished. Treat as not-found.
158
- log.warn(`serve_fact: embedded bytes missing for ${hash} (meta said embedded, row=${row ? 'present' : 'null'})`);
159
- return c.body(null, 404);
160
- }
161
- const bytes = to_uint8(row.bytes);
162
- return c.body(bytes, 200, {
163
- 'Content-Type': content_type,
164
- 'Content-Length': size,
165
- 'Cache-Control': CACHE_CONTROL,
166
- });
167
- }
168
- // External — defense-in-depth re-validate the URL before trusting it
169
- // to address the filesystem.
170
- const parsed = parse_file_fact_url(meta.external_url);
171
- if (!parsed) {
172
- log.error(`serve_fact: rejecting malformed external_url for ${hash}: ${meta.external_url}`);
238
+ // Per-reference view check scoped to *this* cell only.
239
+ const req_ctx = await build_public_request_context(c, route);
240
+ // Unauthenticated callers skip the grant fetch entirely — no grant
241
+ // can admit a null req_ctx (the only admit path is the
242
+ // public-visibility branch in `can_view_cell`, which doesn't read
243
+ // grants).
244
+ const grants = req_ctx ? await query_cell_grant_list_for_cell({ db: route.db }, cell.id) : null;
245
+ if (!can_view_cell(req_ctx, cell, grants)) {
246
+ // 404 (not 403) so an unviewable cell→fact edge doesn't leak.
173
247
  return c.body(null, 404);
174
248
  }
175
- const { shard, rest } = parsed;
176
- if (x_accel_redirect_prefix !== undefined) {
177
- // Production: hand off to nginx via the internal facts location.
178
- return c.body(null, 200, {
179
- 'Content-Type': content_type,
180
- 'Content-Length': size,
181
- 'Cache-Control': CACHE_CONTROL,
182
- 'X-Accel-Redirect': `${x_accel_redirect_prefix}${shard}/${rest}`,
183
- });
249
+ return serve_fact_bytes(c, route, hash, config);
250
+ },
251
+ };
252
+ };
253
+ /**
254
+ * Build the admin-only bare-hash `GET /api/facts/:hash` `RouteSpec`.
255
+ *
256
+ * An admin's reach already spans every cell, so serving by bare hash grants
257
+ * no escalation — the union concern that made this route a cross-owner leak
258
+ * for non-admins is vacuous for an admin. Non-admin callers are rejected at
259
+ * the auth phase (403) and never reach the handler. Confidential non-admin
260
+ * reads always go through the cell-scoped route above.
261
+ *
262
+ * Auth is `{account: 'required', actor: 'required', roles: ['admin']}` —
263
+ * the dispatcher's authorization phase resolves the acting actor and the
264
+ * post-authorization guard enforces the admin role before the handler runs.
265
+ * The handler re-checks `has_role(_, admin)` as defense-in-depth so a future
266
+ * mounting/auth-shape regression fails closed rather than serving by bare
267
+ * hash to a non-admin.
268
+ */
269
+ export const create_serve_fact_route_spec = (options) => {
270
+ const { facts_dir, x_accel_redirect_prefix, log } = options;
271
+ const config = { facts_dir, x_accel_redirect_prefix, log };
272
+ return {
273
+ method: 'GET',
274
+ path: '/api/facts/:hash',
275
+ auth: { account: 'required', actor: 'required', roles: [ROLE_ADMIN] },
276
+ description: 'Serve content-addressed fact bytes by bare hash — admin only. Non-admin reads go through GET /api/cells/:cell_id/facts/:hash (per-reference).',
277
+ params: bare_hash_params_schema,
278
+ // `actor: 'required'` (implied by the admin role gate) needs the
279
+ // authorization phase to resolve an acting actor — registry-time
280
+ // invariant 2 requires the `acting?` slot. On a GET it lives on
281
+ // `query` (a multi-actor admin disambiguates via `?acting=<actor>`).
282
+ query: z.strictObject({ acting: ActingActor }),
283
+ input: z.null(),
284
+ // The body is a binary stream; no JSON output schema applies.
285
+ output: z.null(),
286
+ errors: params_400_error,
287
+ handler: async (c, route) => {
288
+ const { hash } = get_route_params(c);
289
+ // Defense-in-depth: the auth phase already gated this on the admin
290
+ // role, but re-check the resolved context so a mounting/auth-shape
291
+ // regression fails closed instead of serving by bare hash.
292
+ if (!has_role(get_request_context(c), ROLE_ADMIN)) {
293
+ return c.body(null, 404);
184
294
  }
185
- // Dev / tests: stream from disk. `createReadStream` errors land on the
186
- // returned ReadableStream, which Hono surfaces as a 500 to the client.
187
- const file_path = join(facts_dir, shard, rest);
188
- const node_stream = createReadStream(file_path);
189
- const web_stream = Readable.toWeb(node_stream);
190
- return c.body(web_stream, 200, {
191
- 'Content-Type': content_type,
192
- 'Content-Length': size,
193
- 'Cache-Control': CACHE_CONTROL,
194
- });
295
+ return serve_fact_bytes(c, route, hash, config);
195
296
  },
196
297
  };
197
298
  };
@@ -574,7 +574,11 @@ auth-related routes (login/logout/verify/sessions/tokens/password/signup)
574
574
  and asserts `DEFAULT_INTEGRATION_ERROR_COVERAGE` (20%). Bootstrap is
575
575
  excluded because no describe block in this suite drives it — its declared
576
576
  codes would always be uncovered. Consumer-specific routes aren't exercised
577
- here either — they don't count against the baseline. Override with
577
+ here either — they don't count against the baseline. 403 authorization
578
+ denials (the credential-channel gate on `/logout` + `/password`, the invite
579
+ gate on `/signup`) are likewise excluded via `ignore_statuses: [403]` — they're
580
+ exercised by the conformance + attack-surface suites, not this lifecycle suite.
581
+ Override with
578
582
  `error_coverage_min?: number` (set to `0` to skip the assertion — useful for
579
583
  minimal route sets whose declared error codes outpace the suite's
580
584
  denial-path drivers).
@@ -960,6 +964,35 @@ a `*.cross.test.ts`, never an in-process setup). Authed cookies come from the
960
964
  fresh-per-test keeper via `fixture.transport.cookies()`, not the stale
961
965
  globalSetup handle. fuz_app's own wiring is `src/test/cross_backend/ws.cross.test.ts`.
962
966
 
967
+ ### `cross_backend/role_grant_offer_notification_ws.ts` — `describe_role_grant_offer_notification_ws_tests`
968
+
969
+ Real-upgrade coverage of the consentful-role-grants WS notification fan-out —
970
+ the seven server-initiated notifications (`received` → recipient,
971
+ `accepted`/`declined` → grantor, `retracted` → recipient, flat
972
+ `role_grant_revoke` → revokee, and `role_grant_offer_supersede` → each
973
+ superseded sibling's grantor on **both** the accept- and revoke-cascade paths).
974
+ `describe_role_grant_offer_notification_ws_tests({setup_test, capabilities,
975
+ base_url, ws_path})` opens the affected counterparty's socket
976
+ (`create_ws_transport`), drives the lifecycle RPC over `fixture.transport`, then
977
+ strict-parses the delivered frame against its canonical params schema from
978
+ `auth/role_grant_offer_notifications.ts` (the guard against serialization drift —
979
+ field / null / datetime / the flat revoke shape / the supersede `reason` +
980
+ `cause_id`). Unlike `describe_cross_process_ws_tests` (consumer-agnostic,
981
+ `heartbeat`-only), this drives a real **domain** notification family — but one
982
+ built from spine primitives only (accounts / role-grants / offers / WS), zero
983
+ consumer domain, so it lives here and runs against any backend that wires the
984
+ standard RPC actions' `notification_sender` and registers a WS socket: fuz_app's
985
+ own spine self-tests (`testing_spine_server`, whose `ws_transport` is threaded
986
+ as the sender via `spine_rpc_endpoints({notification_sender})`; the Rust
987
+ `testing_spine_stub`, which wires it natively) and downstream twin-impl
988
+ consumers (fuz_forge's Deno + Rust backends call it as a thin invocation).
989
+ Sends queue on the post-commit drain, so `WsClient.wait_for` polls + waits
990
+ (method + predicate filter ignores unrelated frames). Accounts are seeded with
991
+ `ROLE_ADMIN` (the only admin-grantable role) so they can open an admin-gated WS
992
+ where one exists (forge) and be offered the role; harmless on the auth-only
993
+ spine. Gated on `capabilities.ws`; cross-process only. fuz_app's own wiring is
994
+ `src/test/cross_backend/role_grant_offer_notification_ws.cross.test.ts`.
995
+
963
996
  ### `cross_backend/sse_round_trip.ts` — `describe_cross_process_sse_tests`
964
997
 
965
998
  Cross-process counterpart to the in-process `sse_round_trip.ts` harness —
@@ -1,4 +1,5 @@
1
1
  import '../assert_dev_env.js';
2
+ import type { NotificationSender } from '../../auth/role_grant_offer_notifications.js';
2
3
  import { type RoleSchemaResult } from '../../auth/role_schema.js';
3
4
  import { type SessionOptions } from '../../auth/session_cookie.js';
4
5
  import { type RouteSpec } from '../../http/route_spec.js';
@@ -35,6 +36,26 @@ export declare const SPINE_RPC_PATH = "/api/rpc";
35
36
  * surface stub leaves `ctx.audit_sse` null so the snapshot stays SSE-free.
36
37
  */
37
38
  export declare const SPINE_SSE_PATH = "/api/admin/audit/stream";
39
+ /** Options for {@link spine_rpc_endpoints}. */
40
+ export interface SpineRpcEndpointsOptions {
41
+ /**
42
+ * WS notification sender threaded into the role-grant-offer sub-factory for
43
+ * server-initiated fan-out (`role_grant_offer_received` / `_accepted` /
44
+ * `_declined` / `_retracted` / `_supersede`, flat `role_grant_revoke`).
45
+ *
46
+ * **Shared-instance trap.** Pass the SAME `BackendWebsocketTransport`
47
+ * instance the WS endpoint registers connections against — the transport
48
+ * *is* the connection registry, so a separate instance would fan out to an
49
+ * empty registry and reach nobody (silently). The TS spine binary
50
+ * constructs one `ws_transport` and threads it both here and into
51
+ * `register_ws_endpoint`.
52
+ *
53
+ * Omitted (the default) for the shared `create_spine_surface_spec` path —
54
+ * surface generation doesn't depend on it, and it must stay absent there so
55
+ * the declared snapshot is unaffected.
56
+ */
57
+ readonly notification_sender?: NotificationSender | null;
58
+ }
38
59
  /**
39
60
  * Factory-form RPC endpoints over the per-test `ctx.deps`. `create_app_server`
40
61
  * (in the binary) owns live dispatch; the surface builder invokes the factory
@@ -45,8 +66,12 @@ export declare const SPINE_SSE_PATH = "/api/admin/audit/stream";
45
66
  * `actions` (see `testing_reset_actions.ts`); it is intentionally excluded
46
67
  * here so it stays off the declared surface (the harness calls it directly
47
68
  * over the daemon-token channel).
69
+ *
70
+ * `options.notification_sender`, when supplied, reaches the role-grant-offer
71
+ * sub-factory so the spine emits the WS notification family — see
72
+ * `SpineRpcEndpointsOptions`.
48
73
  */
49
- export declare const spine_rpc_endpoints: (ctx: AppServerContext) => Array<RpcEndpointSpec>;
74
+ export declare const spine_rpc_endpoints: (ctx: AppServerContext, options?: SpineRpcEndpointsOptions) => Array<RpcEndpointSpec>;
50
75
  /**
51
76
  * Account REST + signup route specs under `/api/account` (bootstrap
52
77
  * auto-mounted by the surface builder / `create_app_server`), plus the
@@ -1 +1 @@
1
- {"version":3,"file":"default_spine_surface.d.ts","sourceRoot":"../src/lib/","sources":["../../../src/lib/testing/cross_backend/default_spine_surface.ts"],"names":[],"mappings":"AAAA,OAAO,sBAAsB,CAAC;AA6B9B,OAAO,EAAqB,KAAK,gBAAgB,EAAC,MAAM,2BAA2B,CAAC;AACpF,OAAO,EAAwB,KAAK,cAAc,EAAC,MAAM,8BAA8B,CAAC;AAGxF,OAAO,EAAqB,KAAK,SAAS,EAAC,MAAM,0BAA0B,CAAC;AAC5E,OAAO,KAAK,EAAC,cAAc,EAAE,eAAe,EAAC,MAAM,uBAAuB,CAAC;AAC3E,OAAO,KAAK,EAAC,gBAAgB,EAAC,MAAM,4BAA4B,CAAC;AAGjE;;;GAGG;AACH,eAAO,MAAM,qBAAqB,EAAE,cAAc,CAAC,MAAM,CAAwC,CAAC;AAElG;;;;;;GAMG;AACH,eAAO,MAAM,sBAAsB,gBAAgB,CAAC;AAEpD;;;;;;GAMG;AACH,eAAO,MAAM,WAAW,EAAE,gBAExB,CAAC;AAEH,iEAAiE;AACjE,eAAO,MAAM,cAAc,aAAa,CAAC;AAEzC;;;;;;GAMG;AACH,eAAO,MAAM,cAAc,4BAA4B,CAAC;AAExD;;;;;;;;;;GAUG;AACH,eAAO,MAAM,mBAAmB,GAAI,KAAK,gBAAgB,KAAG,KAAK,CAAC,eAAe,CAOhF,CAAC;AAEF;;;;;;;;;;GAUG;AACH,eAAO,MAAM,wBAAwB,GAAI,KAAK,gBAAgB,KAAG,KAAK,CAAC,SAAS,CAiB/E,CAAC;AAEF;;;;;;;;GAQG;AACH,eAAO,MAAM,yBAAyB,QAAO,cAM1C,CAAC"}
1
+ {"version":3,"file":"default_spine_surface.d.ts","sourceRoot":"../src/lib/","sources":["../../../src/lib/testing/cross_backend/default_spine_surface.ts"],"names":[],"mappings":"AAAA,OAAO,sBAAsB,CAAC;AA6B9B,OAAO,KAAK,EAAC,kBAAkB,EAAC,MAAM,8CAA8C,CAAC;AACrF,OAAO,EAAqB,KAAK,gBAAgB,EAAC,MAAM,2BAA2B,CAAC;AACpF,OAAO,EAAwB,KAAK,cAAc,EAAC,MAAM,8BAA8B,CAAC;AAGxF,OAAO,EAAqB,KAAK,SAAS,EAAC,MAAM,0BAA0B,CAAC;AAC5E,OAAO,KAAK,EAAC,cAAc,EAAE,eAAe,EAAC,MAAM,uBAAuB,CAAC;AAC3E,OAAO,KAAK,EAAC,gBAAgB,EAAC,MAAM,4BAA4B,CAAC;AAGjE;;;GAGG;AACH,eAAO,MAAM,qBAAqB,EAAE,cAAc,CAAC,MAAM,CAAwC,CAAC;AAElG;;;;;;GAMG;AACH,eAAO,MAAM,sBAAsB,gBAAgB,CAAC;AAEpD;;;;;;GAMG;AACH,eAAO,MAAM,WAAW,EAAE,gBAExB,CAAC;AAEH,iEAAiE;AACjE,eAAO,MAAM,cAAc,aAAa,CAAC;AAEzC;;;;;;GAMG;AACH,eAAO,MAAM,cAAc,4BAA4B,CAAC;AAExD,+CAA+C;AAC/C,MAAM,WAAW,wBAAwB;IACxC;;;;;;;;;;;;;;;OAeG;IACH,QAAQ,CAAC,mBAAmB,CAAC,EAAE,kBAAkB,GAAG,IAAI,CAAC;CACzD;AAED;;;;;;;;;;;;;;GAcG;AACH,eAAO,MAAM,mBAAmB,GAC/B,KAAK,gBAAgB,EACrB,UAAU,wBAAwB,KAChC,KAAK,CAAC,eAAe,CAQvB,CAAC;AAEF;;;;;;;;;;GAUG;AACH,eAAO,MAAM,wBAAwB,GAAI,KAAK,gBAAgB,KAAG,KAAK,CAAC,SAAS,CAiB/E,CAAC;AAEF;;;;;;;;GAQG;AACH,eAAO,MAAM,yBAAyB,QAAO,cAM1C,CAAC"}
@@ -74,13 +74,15 @@ export const SPINE_SSE_PATH = '/api/admin/audit/stream';
74
74
  * `actions` (see `testing_reset_actions.ts`); it is intentionally excluded
75
75
  * here so it stays off the declared surface (the harness calls it directly
76
76
  * over the daemon-token channel).
77
+ *
78
+ * `options.notification_sender`, when supplied, reaches the role-grant-offer
79
+ * sub-factory so the spine emits the WS notification family — see
80
+ * `SpineRpcEndpointsOptions`.
77
81
  */
78
- export const spine_rpc_endpoints = (ctx) => [
82
+ export const spine_rpc_endpoints = (ctx, options) => [
79
83
  {
80
84
  path: SPINE_RPC_PATH,
81
- actions: create_standard_rpc_actions(ctx.deps, {
82
- roles: spine_roles,
83
- }),
85
+ actions: create_standard_rpc_actions({ ...ctx.deps, notification_sender: options?.notification_sender ?? null }, { roles: spine_roles }),
84
86
  },
85
87
  ];
86
88
  /**
@@ -0,0 +1,24 @@
1
+ import '../assert_dev_env.js';
2
+ import { type BackendCapabilities } from './capabilities.js';
3
+ import type { SetupTest } from './setup.js';
4
+ /** Configuration for {@link describe_role_grant_offer_notification_ws_tests}. */
5
+ export interface RoleGrantOfferNotificationWsTestOptions {
6
+ /** Per-test fixture producer (`default_cross_process_setup(handle, ...)`). */
7
+ readonly setup_test: SetupTest;
8
+ /** Backend capability flags; every case gates on `capabilities.ws`. */
9
+ readonly capabilities: BackendCapabilities;
10
+ /** Base URL the backend is reachable at (e.g. `http://localhost:1178`). */
11
+ readonly base_url: string;
12
+ /** WebSocket endpoint path on the backend (e.g. `/api/ws`). */
13
+ readonly ws_path: string;
14
+ }
15
+ /**
16
+ * Register the role-grant-offer WS notification suite — seven cases over a real
17
+ * upgrade, one per server-initiated notification (received / accepted /
18
+ * declined / retracted / revoke + supersede on both the accept and revoke
19
+ * cascades). Each opens the affected counterparty's socket, drives the
20
+ * lifecycle RPC, and strict-parses the delivered frame against its canonical
21
+ * params schema. Gated on `capabilities.ws`.
22
+ */
23
+ export declare const describe_role_grant_offer_notification_ws_tests: (options: RoleGrantOfferNotificationWsTestOptions) => void;
24
+ //# sourceMappingURL=role_grant_offer_notification_ws.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"role_grant_offer_notification_ws.d.ts","sourceRoot":"../src/lib/","sources":["../../../src/lib/testing/cross_backend/role_grant_offer_notification_ws.ts"],"names":[],"mappings":"AAAA,OAAO,sBAAsB,CAAC;AAqE9B,OAAO,EAAC,KAAK,mBAAmB,EAAU,MAAM,mBAAmB,CAAC;AACpE,OAAO,KAAK,EAAC,SAAS,EAAkC,MAAM,YAAY,CAAC;AAK3E,iFAAiF;AACjF,MAAM,WAAW,uCAAuC;IACvD,8EAA8E;IAC9E,QAAQ,CAAC,UAAU,EAAE,SAAS,CAAC;IAC/B,uEAAuE;IACvE,QAAQ,CAAC,YAAY,EAAE,mBAAmB,CAAC;IAC3C,2EAA2E;IAC3E,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B,+DAA+D;IAC/D,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;CACzB;AAED;;;;;;;GAOG;AACH,eAAO,MAAM,+CAA+C,GAC3D,SAAS,uCAAuC,KAC9C,IA8VF,CAAC"}