@fuzdev/fuz_app 0.144.0 → 0.146.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.
Files changed (69) hide show
  1. package/dist/actions/CLAUDE.md +7 -6
  2. package/dist/auth/CLAUDE.md +5 -2
  3. package/dist/auth/account_action_specs.d.ts.map +1 -1
  4. package/dist/auth/account_action_specs.js +2 -1
  5. package/dist/auth/account_route_schema.d.ts.map +1 -1
  6. package/dist/auth/account_route_schema.js +9 -2
  7. package/dist/auth/account_routes.d.ts +8 -0
  8. package/dist/auth/account_routes.d.ts.map +1 -1
  9. package/dist/auth/account_routes.js +12 -3
  10. package/dist/auth/request_context.d.ts +23 -0
  11. package/dist/auth/request_context.d.ts.map +1 -1
  12. package/dist/auth/request_context.js +32 -1
  13. package/dist/auth/token_scope.d.ts +22 -14
  14. package/dist/auth/token_scope.d.ts.map +1 -1
  15. package/dist/auth/token_scope.js +23 -14
  16. package/dist/db/CLAUDE.md +7 -3
  17. package/dist/db/fact_ddl.d.ts +1 -0
  18. package/dist/db/fact_ddl.d.ts.map +1 -1
  19. package/dist/db/fact_ddl.js +1 -0
  20. package/dist/db/fact_holder_queries.d.ts +8 -4
  21. package/dist/db/fact_holder_queries.d.ts.map +1 -1
  22. package/dist/db/fact_holder_queries.js +9 -5
  23. package/dist/db/fact_queries.d.ts +10 -7
  24. package/dist/db/fact_queries.d.ts.map +1 -1
  25. package/dist/db/fact_queries.js +31 -10
  26. package/dist/http/CLAUDE.md +2 -2
  27. package/dist/http/auth_shape.d.ts.map +1 -1
  28. package/dist/http/auth_shape.js +11 -6
  29. package/dist/http/proxy.d.ts +22 -21
  30. package/dist/http/proxy.d.ts.map +1 -1
  31. package/dist/http/proxy.js +28 -33
  32. package/dist/http/route_spec.d.ts +8 -0
  33. package/dist/http/route_spec.d.ts.map +1 -1
  34. package/dist/server/env.d.ts.map +1 -1
  35. package/dist/server/env.js +1 -1
  36. package/dist/server/fact_security_headers.d.ts +59 -0
  37. package/dist/server/fact_security_headers.d.ts.map +1 -0
  38. package/dist/server/fact_security_headers.js +58 -0
  39. package/dist/server/serve_fact_route.d.ts +21 -10
  40. package/dist/server/serve_fact_route.d.ts.map +1 -1
  41. package/dist/server/serve_fact_route.js +41 -50
  42. package/dist/server/x_accel.d.ts +135 -31
  43. package/dist/server/x_accel.d.ts.map +1 -1
  44. package/dist/server/x_accel.js +652 -88
  45. package/dist/testing/CLAUDE.md +73 -22
  46. package/dist/testing/cross_backend/capabilities.d.ts +0 -18
  47. package/dist/testing/cross_backend/capabilities.d.ts.map +1 -1
  48. package/dist/testing/cross_backend/capabilities.js +1 -4
  49. package/dist/testing/cross_backend/default_backend_configs.d.ts.map +1 -1
  50. package/dist/testing/cross_backend/default_backend_configs.js +0 -7
  51. package/dist/testing/cross_backend/fact_serving.d.ts.map +1 -1
  52. package/dist/testing/cross_backend/fact_serving.js +85 -12
  53. package/dist/testing/cross_backend/identity_parity.d.ts +11 -0
  54. package/dist/testing/cross_backend/identity_parity.d.ts.map +1 -1
  55. package/dist/testing/cross_backend/identity_parity.js +124 -9
  56. package/dist/testing/cross_backend/login_security.d.ts.map +1 -1
  57. package/dist/testing/cross_backend/login_security.js +28 -0
  58. package/dist/testing/cross_backend/revocation_drivers.d.ts +70 -0
  59. package/dist/testing/cross_backend/revocation_drivers.d.ts.map +1 -0
  60. package/dist/testing/cross_backend/revocation_drivers.js +104 -0
  61. package/dist/testing/cross_backend/sse_round_trip.d.ts +6 -1
  62. package/dist/testing/cross_backend/sse_round_trip.d.ts.map +1 -1
  63. package/dist/testing/cross_backend/sse_round_trip.js +52 -16
  64. package/dist/testing/cross_backend/token_scope_surface.d.ts.map +1 -1
  65. package/dist/testing/cross_backend/token_scope_surface.js +33 -0
  66. package/dist/testing/cross_backend/ws_round_trip.d.ts +13 -1
  67. package/dist/testing/cross_backend/ws_round_trip.d.ts.map +1 -1
  68. package/dist/testing/cross_backend/ws_round_trip.js +155 -39
  69. package/package.json +3 -3
@@ -932,10 +932,10 @@ source of truth for wire-shape conformance.
932
932
  (the **gating** flags `ws` / `sse` / `cell_crud` / `cell_relations` /
933
933
  `cell_gated_create` / `account_lifecycle` / `fact_serving` / `ready` /
934
934
  `account_status` / `peer_request` /
935
- `ws_handshake_pipelining` / `ws_account_actions` / `ws_self_revocation_reply` /
935
+ `ws_handshake_pipelining` / `ws_account_actions` /
936
936
  `ws_role_gated` / `ws_refusal_body` — each has a `test_if`
937
- reader (`ws_self_revocation_reply` forks one case's assertion instead, and
938
- `ws_refusal_body` how the front-door table reads a WS refusal); `peer_request` (server-initiated requests — the ActionPeer
937
+ reader (`ws_refusal_body` picks how the front-door table reads a WS
938
+ refusal instead); `peer_request` (server-initiated requests — the ActionPeer
939
939
  `peer/ping` round-trip) is `true` for both the Rust spine and the TS spine
940
940
  (`BackendWebsocketTransport.request_connection`); `ts_default_capabilities`
941
941
  keeps it `false` like sse/ready until a backend wires the `peer/ping` HTTP +
@@ -966,7 +966,8 @@ source of truth for wire-shape conformance.
966
966
  generic round-trip can't drive (it can't delete the subject). `true` on every
967
967
  spine. `fact_serving`
968
968
  gates `describe_fact_serving_cross_tests` (the cell-scoped per-reference +
969
- admin-only bare-hash fact-serving parity suite); like cells it stays off the
969
+ admin-only bare-hash fact-serving parity suite, incl. the served fact's
970
+ hardening headers and bodyless masked 404s); like cells it stays off the
970
971
  declared surface and is `true` on every spine that mounts the serve routes +
971
972
  the `_testing_put_fact` seeder (whose optional `holder` records a
972
973
  `fact_holder` row, so a non-admin can reference what it seeds) — it also
@@ -991,11 +992,9 @@ source of truth for wire-shape conformance.
991
992
  write raw bytes to).
992
993
  `ws_account_actions` (the WS endpoint mounts the account actions — `true` on
993
994
  fuz_app's own spine presets, `false` in the family defaults) gates the WS
994
- round-trip suite's self-revocation case and, with `rpc_path`, its
995
- WS-mutation audit case (which calls `_testing_drain_effects`);
996
- `ws_self_revocation_reply` says what the self-revocation case requires: the reply ahead of the close (`true`, the TS
997
- family) or the close alone (`false`, the Rust family, whose socket loop
998
- drops the response once the connection is closed — a recorded divergence).
995
+ round-trip suite's self-revocation case (which requires the reply ahead of
996
+ the close on every backend) and, with `rpc_path`, its WS-mutation audit case
997
+ (which calls `_testing_drain_effects`).
999
998
 
1000
999
  ### `cross_backend/standard.ts` — `describe_standard_cross_process_tests`
1001
1000
 
@@ -1117,7 +1116,8 @@ call (not folded into `describe_standard_cross_process_tests`) because it
1117
1116
  needs raw `base_url` / `ws_path` the standard bundle doesn't carry, mirroring
1118
1117
  how `describe_ws_round_trip_tests` sits beside `describe_standard_tests`
1119
1118
  in-process. `describe_cross_process_ws_tests({setup_test, capabilities,
1120
- base_url, ws_path, origin?, rpc_path?, max_message_bytes?})` opens a live
1119
+ base_url, ws_path, origin?, rpc_path?, max_message_bytes?, logout_path?,
1120
+ password_path?, max_tokens?})` opens a live
1121
1121
  `WebSocket` via `create_ws_transport` (the `ws` npm package) and asserts
1122
1122
  cases against the upgrade stack `register_ws_endpoint` wires (origin →
1123
1123
  `require_auth` → dispatch): authed upgrade round-trips `heartbeat`,
@@ -1135,8 +1135,19 @@ anonymous upgrade refused, disallowed-origin upgrade refused, and — gated on
1135
1135
  mid-connection (`account_session_revoke_all` over the keeper session channel;
1136
1136
  asserted via `WsClient.wait_for_close`) and when its account is deleted
1137
1137
  (`account_delete` by the keeper, also gated on
1138
- `capabilities.account_lifecycle`; the close code is
1139
- `WS_CLOSE_SESSION_REVOKED`). Per-connection auth is enforced **at upgrade
1138
+ `capabilities.account_lifecycle`), each with `WS_CLOSE_SESSION_REVOKED` and
1139
+ `WS_CLOSE_SESSION_REVOKED_REASON`. The rest of the revocation table closes a second
1140
+ account's socket with `WS_CLOSE_SESSION_REVOKED` and
1141
+ `WS_CLOSE_SESSION_REVOKED_REASON`: `account_token_revoke` its revoked token's
1142
+ bearer socket (a socket on its other token stays open);
1143
+ `admin_token_revoke_all`, `POST /logout` (`logout_path`), `POST /password`
1144
+ (`password_path`) and `account_purge` (also gated on
1145
+ `capabilities.account_lifecycle`) its bearer and session sockets; and
1146
+ `max_tokens` further mints (default `DEFAULT_MAX_TOKENS`; `null` skips) the
1147
+ bearer socket of the token the cap evicts. The account-wide rows come from
1148
+ `cross_backend/revocation_drivers.ts` (`create_account_revocations`, shared
1149
+ with the SSE suite), and a case that opens several sockets closes whichever
1150
+ opened when a later open fails. Per-connection auth is enforced **at upgrade
1140
1151
  time**, so the negative upgrade cases assert the upgrade itself rejects, not
1141
1152
  a per-message error; the close cases prove a revocation closes an
1142
1153
  already-open socket, since per-message dispatch never re-checks credential
@@ -1144,8 +1155,7 @@ validity. Omit `rpc_path` to skip the close cases (consumers without the
1144
1155
  standard account and admin actions on their RPC endpoint). Two cases are gated
1145
1156
  on `capabilities.ws_account_actions` (the WS endpoint mounts the account
1146
1157
  actions): a socket that revokes its own sessions over that socket is closed
1147
- with `WS_CLOSE_SESSION_REVOKED`, and — where the backend declares
1148
- `capabilities.ws_self_revocation_reply` — reads the reply first; and (with
1158
+ with `WS_CLOSE_SESSION_REVOKED` and its reason after reading the reply to its request; and (with
1149
1159
  `rpc_path`) a socket mints an API token over WS, and its `token_create` audit
1150
1160
  row is read back after `_testing_drain_effects` — a WS reply can precede its
1151
1161
  audit write, so the drain is the barrier (the binary mounts it via
@@ -1349,7 +1359,11 @@ account-wide close) and then just the one (`account_session_revoke`, the
1349
1359
  session-scoped close), each dropping the live stream (asserted via
1350
1360
  `SseTransport.wait_for_close`); the keeper soft-deletes a second admin whose
1351
1361
  stream is open (`account_delete`, also gated on
1352
- `capabilities.account_lifecycle`), dropping it; and one stream past the per-session cap
1362
+ `capabilities.account_lifecycle`), dropping it; `admin_token_revoke_all`,
1363
+ `POST /logout` (`logout_path`), `POST /password` (`password_path`) and
1364
+ `account_purge` (also gated on `capabilities.account_lifecycle`) of a second
1365
+ admin each drop its stream — the rows `create_account_revocations`
1366
+ (`cross_backend/revocation_drivers.ts`) shares with the WS suite; and one stream past the per-session cap
1353
1367
  (`max_per_scope`, default `AUDIT_LOG_SSE_MAX_PER_SCOPE`; `null` skips) on one
1354
1368
  session closes the oldest and leaves the rest open. The data-frame + close
1355
1369
  cases gate on `rpc_path` (they drive the standard account/admin actions); all
@@ -1359,6 +1373,20 @@ cases gate on `capabilities.sse`. Cross-process only — wire from a
1359
1373
  TS spines wire `audit_log_sse` and the Rust `spine_stub` serves the same
1360
1374
  stream.
1361
1375
 
1376
+ ### `cross_backend/revocation_drivers.ts` — shared revocation drivers
1377
+
1378
+ The account-wide revocations both transport suites close a live connection on,
1379
+ as one table: `create_account_revocations({capabilities, rpc_path,
1380
+ logout_path, password_path})` → `AccountRevocation` rows (`{label, slug,
1381
+ gate, revoke(fixture, target)}`) for `admin_token_revoke_all`, `POST /logout`,
1382
+ `POST /password`, and `account_purge` (`gate` reads
1383
+ `capabilities.account_lifecycle`). `revoke` asserts the revocation succeeded;
1384
+ the target must hold `DEFAULT_TEST_PASSWORD`. A revocation added here is
1385
+ covered on WS and SSE at once. The drivers stand alone too: `cross_post_as`
1386
+ (a JSON `POST` on an account's session, asserting 200),
1387
+ `cross_admin_token_revoke_all`, and `cross_account_purge` (over the daemon
1388
+ token, no `Origin`).
1389
+
1362
1390
  ### `cross_backend/cell_crud.ts` + `cell_relations.ts` + `cell_gated_create.ts` — cell parity suites
1363
1391
 
1364
1392
  The cell-layer parity coverage is split across two sibling suites. Cells
@@ -1669,7 +1697,8 @@ cases are `src/test/auth/cell_ref_admission{,.fact_less}.db.test.ts`.
1669
1697
  `describe_token_scope_surface_cross_tests({setup_test, capabilities, rpc_path?,
1670
1698
  sse_path?})` — the `surface:<name>` twin of the conformance slate's
1671
1699
  `token_scope_cases`, which cover only the `rpc:<method>` arm. Token scoping's
1672
- RPC-only rule (_a narrowed api token reaches no non-RPC surface_) is the
1700
+ RPC-only rule (_a narrowed api token reaches no non-RPC surface that reads
1701
+ account data or carries authority_) is the
1673
1702
  load-bearing half of the design and had **no** cross-impl gate: the schema gate
1674
1703
  can't see it (no column), the action-manifest gate can't see it (no method), and
1675
1704
  the spec-derived suites can't see it (REST routes, off the declared RPC surface).
@@ -1682,7 +1711,12 @@ fact read (`capabilities.fact_serving`) — asserting the exact flat
1682
1711
  contract on (`token_scope_denied_body` ↔
1683
1712
  `fuz_auth::token_scope_surface_denied_response`).
1684
1713
 
1685
- The fifth case is the one with no prior pin anywhere: **scope outranks role**. A
1714
+ The account status read (`capabilities.account_status`) answers anonymous
1715
+ callers, so rule 3 there is the anonymous answer: a narrowed token on
1716
+ `GET /api/account/status` gets the anonymous `401`, status and body byte for
1717
+ byte, while the same account's full-scope bearer reads the account.
1718
+
1719
+ One case has no prior pin anywhere: **scope outranks role**. A
1686
1720
  narrowed token on an account that _also_ lacks the gating role must hear
1687
1721
  `token_scope_required`, not `insufficient_permissions` — the observable the
1688
1722
  TS↔Rust ordering divergence produced before both spines converged on running the
@@ -1911,16 +1945,18 @@ asserting each spec declares `credential_types: ['daemon_token']`) + the
1911
1945
 
1912
1946
  ### Identity-primitive parity — `cross_backend/identity_parity.ts`
1913
1947
 
1914
- `describe_identity_parity_cross_tests({setup_test, login_path?, signup_path?})` —
1948
+ `describe_identity_parity_cross_tests({setup_test, login_path?, signup_path?, bootstrap_path?, password_path?})` —
1915
1949
  the imperative suite pinning the `primitive_schemas` twins (`Username`,
1916
1950
  `UsernameProvided`, `Email`) + login/signup input handling end-to-end over real
1917
1951
  HTTP (flat REST `/login` + `/signup`, so imperative, not a `conformance_table`
1918
- row). Six facets, each a TS↔Rust reimplementation pair that the spec-derived
1952
+ row). The facets, each a TS↔Rust reimplementation pair that the spec-derived
1919
1953
  round-trip / conformance suites never vary:
1920
1954
 
1921
1955
  - **Login canonicalization** — case-insensitive + whitespace-trim lookup
1922
- (`UsernameProvided` `.trim().toLowerCase()` + `LOWER(username)`), plus a
1923
- Turkish-`İ` homograph **no-Unicode-fold-collision** negative.
1956
+ (`UsernameProvided` `.trim().toLowerCase()` + `LOWER(username)`), the trimmed
1957
+ set being JS `String.prototype.trim`'s on every spine (U+FEFF padding trimmed,
1958
+ U+0085 not), plus a Turkish-`İ` homograph **no-Unicode-fold-collision**
1959
+ negative.
1924
1960
  - **Username-or-email login** — an account created with an email logs in via
1925
1961
  that email, case-insensitively both directions; username login still works;
1926
1962
  non-existent email → 401. This is the corner that surfaced and closed the Rust
@@ -1933,7 +1969,11 @@ round-trip / conformance suites never vary:
1933
1969
  `account_login` (closed a cluster where three of these fell through to a
1934
1970
  lookup-miss 401 on Rust and a whitespace-only identifier 401'd on TS). Asserted
1935
1971
  by error reason, not just status.
1936
- - **ASCII-only creation invariant** — non-ASCII username → 400 at signup.
1972
+ - **ASCII-only creation invariant** — non-ASCII username → 400 at signup, and a
1973
+ padded creation name (space, tab, U+0085) → 400, never trimmed into a legal
1974
+ one — at signup, and at bootstrap (`bootstrap_path`, default
1975
+ `/api/account/bootstrap`, `null` to skip), where the body is validated ahead
1976
+ of the window check, so the 400 holds after bootstrap completed.
1937
1977
  - **Length + format regex parity** — both `[USERNAME_LENGTH_MIN,
1938
1978
  USERNAME_LENGTH_MAX]` = `[3, 39]` boundary edges (just-outside → 400,
1939
1979
  just-inside → 403 no-matching-invite) and the creation regex shape (leading
@@ -1948,6 +1988,13 @@ USERNAME_LENGTH_MAX]` = `[3, 39]` boundary edges (just-outside → 400,
1948
1988
  Zod's `z.email()` rejects — the deliberately-looser shared rule. **Closed a
1949
1989
  real divergence**: the Rust spine previously length-checked the email only,
1950
1990
  accepting any non-empty string TS rejected.
1991
+ - **Malformed JSON body** — an unparseable (`{`) or non-object (`[]`) body typed
1992
+ `application/json` → 400 `{error: "invalid_json_body"}` and nothing else, on
1993
+ login, signup, bootstrap (ahead of its window check), and the password change
1994
+ (`password_path`, default `/api/account/password`, `null` skips; parsed after
1995
+ its credential and session-only gates, so the case sends a session and an
1996
+ anonymous one still answers 401). A Rust backend needs the spine's
1997
+ `parse_json_body`.
1951
1998
 
1952
1999
  Ungated (login + signup are on every spine). Runs both legs via the shared
1953
2000
  `{setup_test}` protocol: in-process (`identity_parity.db.test.ts`, plain
@@ -1970,6 +2017,10 @@ over real HTTP on both spines:
1970
2017
  - **XFF keying** — exhaust one forwarded IP to 429, then a _distinct_ forwarded IP
1971
2018
  → 401 (not 429), proving the limiter keys on the resolved `X-Forwarded-For` IP,
1972
2019
  not the loopback TCP peer (a backend ignoring XFF would 429 the fresh IP too).
2020
+ - **a malformed hop stops the walk** — after exhausting one forwarded IP, a
2021
+ chain with that IP left of an `ip:port` entry → 401, not 429: the walk stops at
2022
+ the malformed entry and keys on the connection IP (its audit row says so),
2023
+ never on the client-written entry past it.
1973
2024
  - **a success does not refund the per-IP budget** — the distributed-spray
1974
2025
  backstop. Failed guesses against distinct usernames from one forwarded IP,
1975
2026
  interleaved with the attacker's own successful logins, must still 429 within a
@@ -176,24 +176,6 @@ export interface BackendCapabilities {
176
176
  * once the account actions are there.
177
177
  */
178
178
  readonly ws_account_actions: boolean;
179
- /**
180
- * A socket that revokes the credential it is running on reads the reply to
181
- * that request **before** the revocation's close. Read by the
182
- * self-revocation case in `describe_cross_process_ws_tests` (which
183
- * `ws_account_actions` gates): with it `true` the case requires the reply
184
- * frame ahead of the `WS_CLOSE_SESSION_REVOKED` close; with it `false` the
185
- * case requires the close alone.
186
- *
187
- * `true` for the TS family — `register_action_ws` sends the response and
188
- * only then flushes the request's post-commit queue, where the close is.
189
- * `false` for the Rust family: its dispatch runs the post-commit queue
190
- * before handing the response to the socket loop, and the loop, finding the
191
- * connection already closed, writes the close frame and drops the response.
192
- * The caller's revocation still took effect; it is not told so. The flag
193
- * records that divergence so the suite stays green on both spines without
194
- * hiding it — flip it once the Rust spine answers first.
195
- */
196
- readonly ws_self_revocation_reply: boolean;
197
179
  /**
198
180
  * A test `CellCreateAuthorize` policy is live-mounted on the backend's cell
199
181
  * layer — creating a `kind: 'gated'` cell requires the `participant` role
@@ -1 +1 @@
1
- {"version":3,"file":"capabilities.d.ts","sourceRoot":"../src/lib/","sources":["../../../src/lib/testing/cross_backend/capabilities.ts"],"names":[],"mappings":"AAAA,OAAO,sBAAsB,CAAC;AA2C9B;;;;;;;;;;;GAWG;AACH,MAAM,WAAW,iBAAiB;IACjC;;;;;OAKG;IACH,QAAQ,CAAC,WAAW,EAAE,OAAO,CAAC;IAC9B;;;;;OAKG;IACH,QAAQ,CAAC,aAAa,EAAE,OAAO,CAAC;IAChC;;;;OAIG;IACH,QAAQ,CAAC,gBAAgB,EAAE,OAAO,CAAC;CACnC;AAED;;;;;GAKG;AACH,MAAM,WAAW,mBAAmB;IACnC;;;;;;OAMG;IACH,QAAQ,CAAC,EAAE,EAAE,OAAO,CAAC;IACrB;;;;;OAKG;IACH,QAAQ,CAAC,GAAG,EAAE,OAAO,CAAC;IACtB;;;;;;;OAOG;IACH,QAAQ,CAAC,SAAS,EAAE,OAAO,CAAC;IAC5B;;;;;;;;;;OAUG;IACH,QAAQ,CAAC,cAAc,EAAE,OAAO,CAAC;IACjC;;;;;;;;;;;;;;OAcG;IACH,QAAQ,CAAC,iBAAiB,EAAE,OAAO,CAAC;IACpC;;;;;;;;OAQG;IACH,QAAQ,CAAC,YAAY,EAAE,OAAO,CAAC;IAC/B;;;;;;;;OAQG;IACH,QAAQ,CAAC,KAAK,EAAE,OAAO,CAAC;IACxB;;;;;;;;OAQG;IACH,QAAQ,CAAC,cAAc,EAAE,OAAO,CAAC;IACjC;;;;;;;;;;;;;;;;;;OAkBG;IACH,QAAQ,CAAC,YAAY,EAAE,OAAO,CAAC;IAC/B;;;;;;;;;;;;;;;;OAgBG;IACH,QAAQ,CAAC,uBAAuB,EAAE,OAAO,CAAC;IAC1C;;;;;;;;;;;;;;OAcG;IACH,QAAQ,CAAC,kBAAkB,EAAE,OAAO,CAAC;IACrC;;;;;;;;;;;;;;;;OAgBG;IACH,QAAQ,CAAC,wBAAwB,EAAE,OAAO,CAAC;IAC3C;;;;;;;;;;;;OAYG;IACH,QAAQ,CAAC,iBAAiB,EAAE,OAAO,CAAC;IACpC;;;;;;;;;OASG;IACH,QAAQ,CAAC,aAAa,EAAE,OAAO,CAAC;IAChC;;;;;;;;;;;OAWG;IACH,QAAQ,CAAC,eAAe,EAAE,OAAO,CAAC;CAClC;AAED;;;;;;;;;;;;GAYG;AACH,eAAO,MAAM,uBAAuB,EAAE,mBA2BpC,CAAC;AAEH;;;;GAIG;AACH,eAAO,MAAM,sBAAsB,EAAE,iBAInC,CAAC;AAEH;;;;;;;;GAQG;AACH,eAAO,MAAM,OAAO,GAAI,MAAM,OAAO,EAAE,MAAM,MAAM,EAAE,IAAI,MAAM,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,KAAG,IAMrF,CAAC"}
1
+ {"version":3,"file":"capabilities.d.ts","sourceRoot":"../src/lib/","sources":["../../../src/lib/testing/cross_backend/capabilities.ts"],"names":[],"mappings":"AAAA,OAAO,sBAAsB,CAAC;AA2C9B;;;;;;;;;;;GAWG;AACH,MAAM,WAAW,iBAAiB;IACjC;;;;;OAKG;IACH,QAAQ,CAAC,WAAW,EAAE,OAAO,CAAC;IAC9B;;;;;OAKG;IACH,QAAQ,CAAC,aAAa,EAAE,OAAO,CAAC;IAChC;;;;OAIG;IACH,QAAQ,CAAC,gBAAgB,EAAE,OAAO,CAAC;CACnC;AAED;;;;;GAKG;AACH,MAAM,WAAW,mBAAmB;IACnC;;;;;;OAMG;IACH,QAAQ,CAAC,EAAE,EAAE,OAAO,CAAC;IACrB;;;;;OAKG;IACH,QAAQ,CAAC,GAAG,EAAE,OAAO,CAAC;IACtB;;;;;;;OAOG;IACH,QAAQ,CAAC,SAAS,EAAE,OAAO,CAAC;IAC5B;;;;;;;;;;OAUG;IACH,QAAQ,CAAC,cAAc,EAAE,OAAO,CAAC;IACjC;;;;;;;;;;;;;;OAcG;IACH,QAAQ,CAAC,iBAAiB,EAAE,OAAO,CAAC;IACpC;;;;;;;;OAQG;IACH,QAAQ,CAAC,YAAY,EAAE,OAAO,CAAC;IAC/B;;;;;;;;OAQG;IACH,QAAQ,CAAC,KAAK,EAAE,OAAO,CAAC;IACxB;;;;;;;;OAQG;IACH,QAAQ,CAAC,cAAc,EAAE,OAAO,CAAC;IACjC;;;;;;;;;;;;;;;;;;OAkBG;IACH,QAAQ,CAAC,YAAY,EAAE,OAAO,CAAC;IAC/B;;;;;;;;;;;;;;;;OAgBG;IACH,QAAQ,CAAC,uBAAuB,EAAE,OAAO,CAAC;IAC1C;;;;;;;;;;;;;;OAcG;IACH,QAAQ,CAAC,kBAAkB,EAAE,OAAO,CAAC;IACrC;;;;;;;;;;;;OAYG;IACH,QAAQ,CAAC,iBAAiB,EAAE,OAAO,CAAC;IACpC;;;;;;;;;OASG;IACH,QAAQ,CAAC,aAAa,EAAE,OAAO,CAAC;IAChC;;;;;;;;;;;OAWG;IACH,QAAQ,CAAC,eAAe,EAAE,OAAO,CAAC;CAClC;AAED;;;;;;;;;;;;GAYG;AACH,eAAO,MAAM,uBAAuB,EAAE,mBAwBpC,CAAC;AAEH;;;;GAIG;AACH,eAAO,MAAM,sBAAsB,EAAE,iBAInC,CAAC;AAEH;;;;;;;;GAQG;AACH,eAAO,MAAM,OAAO,GAAI,MAAM,OAAO,EAAE,MAAM,MAAM,EAAE,IAAI,MAAM,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,KAAG,IAMrF,CAAC"}
@@ -28,7 +28,7 @@ import '../assert_dev_env.js';
28
28
  *
29
29
  * **Gating flags vs shape notes.** `BackendCapabilities` holds only flags a
30
30
  * suite actually gates on (each has a `test_if(capabilities.X, ...)` reader,
31
- * or — `ws_self_revocation_reply` — forks one case's assertion).
31
+ * or — `ws_refusal_body` — picks how a case reads a refusal).
32
32
  * Wiring facts that gate nothing — `bearer_auth` / `trusted_proxy` /
33
33
  * `login_rate_limit` — live in the parallel `BackendShapeNotes` record
34
34
  * (`in_process_shape_notes` here, `ts_default_shape_notes` /
@@ -66,9 +66,6 @@ export const in_process_capabilities = Object.freeze({
66
66
  ws_handshake_pipelining: false,
67
67
  // Cross-process-only: the case reads a real socket's frames in wire order.
68
68
  ws_account_actions: false,
69
- // Read only by the case `ws_account_actions` gates; the in-process
70
- // dispatcher does answer first.
71
- ws_self_revocation_reply: true,
72
69
  // Cross-process-only: the test `CellCreateAuthorize` policy is mounted on the
73
70
  // spine binaries' full mount + the Rust stub, not the in-process default app.
74
71
  cell_gated_create: false,
@@ -1 +1 @@
1
- {"version":3,"file":"default_backend_configs.d.ts","sourceRoot":"../src/lib/","sources":["../../../src/lib/testing/cross_backend/default_backend_configs.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"default_backend_configs.d.ts","sourceRoot":"../src/lib/","sources":["../../../src/lib/testing/cross_backend/default_backend_configs.ts"],"names":[],"mappings":"AAAA,OAAO,sBAAsB,CAAC;AAE9B;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAgCG;AAEH,OAAO,KAAK,EAAE,sBAAsB,EAAE,aAAa,EAAE,MAAM,qBAAqB,CAAC;AACjF,OAAO,KAAK,EAAE,mBAAmB,EAAE,iBAAiB,EAAE,MAAM,mBAAmB,CAAC;AAChF,OAAO,EAA4B,KAAK,gBAAgB,EAAE,MAAM,+BAA+B,CAAC;AAYhG,2CAA2C;AAC3C,MAAM,WAAW,yBAAyB;IACzC,QAAQ,CAAC,wBAAwB,CAAC,EAAE,OAAO,CAAC;IAC5C,QAAQ,CAAC,8BAA8B,CAAC,EAAE,MAAM,CAAC;CACjD;AAED;;;;;GAKG;AACH,eAAO,MAAM,qBAAqB,GACjC,SAAS,yBAAyB,KAChC,MAAM,CAAC,MAAM,EAAE,MAAM,CAKtB,CAAC;AAEH;;;;GAIG;AACH,eAAO,MAAM,uBAAuB,EAAE,mBAsCpC,CAAC;AAEH;;;;GAIG;AACH,eAAO,MAAM,yBAAyB,EAAE,mBA8BtC,CAAC;AAEH;;;;;;GAMG;AACH,eAAO,MAAM,sBAAsB,EAAE,iBAInC,CAAC;AAEH;;;;;;GAMG;AACH,eAAO,MAAM,wBAAwB,EAAE,iBAIrC,CAAC;AAeH,MAAM,WAAW,iCAAiC;IACjD,gFAAgF;IAChF,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,sCAAsC;IACtC,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,4DAA4D;IAC5D,QAAQ,CAAC,aAAa,EAAE,aAAa,CAAC,MAAM,CAAC,CAAC;IAC9C,mDAAmD;IACnD,QAAQ,CAAC,YAAY,CAAC,EAAE,MAAM,CAAC;IAC/B,iEAAiE;IACjE,QAAQ,CAAC,SAAS,CAAC,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAAC;IACtD,6CAA6C;IAC7C,QAAQ,CAAC,YAAY,CAAC,EAAE,mBAAmB,CAAC;IAC5C,wEAAwE;IACxE,QAAQ,CAAC,KAAK,CAAC,EAAE,gBAAgB,CAAC;IAClC,sEAAsE;IACtE,QAAQ,CAAC,mBAAmB,CAAC,EAAE,OAAO,CAAC,sBAAsB,CAAC,CAAC;IAC/D;;;;OAIG;IACH,QAAQ,CAAC,YAAY,CAAC,EAAE,MAAM,CAAC;IAC/B;;;;;;OAMG;IACH,QAAQ,CAAC,WAAW,CAAC,EAAE,MAAM,CAAC;CAC9B;AAED;;;;;GAKG;AACH,eAAO,MAAM,8BAA8B,GAC1C,MAAM,iCAAiC,KACrC,aAoCF,CAAC;AAEF,MAAM,WAAW,mCAAmC;IACnD,gFAAgF;IAChF,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,sCAAsC;IACtC,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,4DAA4D;IAC5D,QAAQ,CAAC,aAAa,EAAE,aAAa,CAAC,MAAM,CAAC,CAAC;IAC9C;;;;OAIG;IACH,QAAQ,CAAC,YAAY,EAAE,MAAM,CAAC;IAC9B,iEAAiE;IACjE,QAAQ,CAAC,SAAS,CAAC,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAAC;IACtD,+CAA+C;IAC/C,QAAQ,CAAC,YAAY,CAAC,EAAE,mBAAmB,CAAC;IAC5C,wEAAwE;IACxE,QAAQ,CAAC,KAAK,CAAC,EAAE,gBAAgB,CAAC;IAClC,sEAAsE;IACtE,QAAQ,CAAC,mBAAmB,CAAC,EAAE,OAAO,CAAC,sBAAsB,CAAC,CAAC;IAC/D;;;;OAIG;IACH,QAAQ,CAAC,YAAY,CAAC,EAAE,MAAM,CAAC;IAC/B;;;;OAIG;IACH,QAAQ,CAAC,QAAQ,CAAC,EAAE,MAAM,CAAC;IAC3B;;;OAGG;IACH,QAAQ,CAAC,WAAW,CAAC,EAAE,MAAM,CAAC;CAC9B;AAED;;;;;GAKG;AACH,eAAO,MAAM,gCAAgC,GAC5C,MAAM,mCAAmC,KACvC,aA4CF,CAAC"}
@@ -47,9 +47,6 @@ export const ts_default_capabilities = Object.freeze({
47
47
  // fuz_app's spine configs opt in (the spine binary mounts the account
48
48
  // actions on `/api/ws`); a consumer does once its endpoint carries them.
49
49
  ws_account_actions: false,
50
- // `register_action_ws` sends a request's response before it flushes the
51
- // post-commit queue a self-revocation's close is on.
52
- ws_self_revocation_reply: true,
53
50
  // Off by default — the `cell_gated_create` test policy is a fuz_app test
54
51
  // fixture, mounted only on the spine binaries (`ts_spine_*`), not generic
55
52
  // consumers.
@@ -88,10 +85,6 @@ export const rust_default_capabilities = Object.freeze({
88
85
  // Off by default, as on the TS family: a consumer opts in once its WS
89
86
  // endpoint mounts the account actions. The `testing_spine_stub` preset does.
90
87
  ws_account_actions: false,
91
- // The Rust dispatch runs the post-commit queue — the self-revocation's
92
- // close — before the socket loop writes the response, and the loop then
93
- // drops it. A divergence from the TS spine; see the flag's doc.
94
- ws_self_revocation_reply: false,
95
88
  // Off by default — the `testing_spine_stub` preset opts in (it mounts the
96
89
  // `TestCellGatedCreateAuthorize` policy); a generic Rust consumer doesn't.
97
90
  cell_gated_create: false,
@@ -1 +1 @@
1
- {"version":3,"file":"fact_serving.d.ts","sourceRoot":"../src/lib/","sources":["../../../src/lib/testing/cross_backend/fact_serving.ts"],"names":[],"mappings":"AAAA,OAAO,sBAAsB,CAAC;AAiD9B,OAAO,KAAK,EAAE,uCAAuC,EAAE,SAAS,EAAE,WAAW,EAAE,MAAM,YAAY,CAAC;AAElG;;;;;GAKG;AACH,MAAM,WAAW,2BAA4B,SAAQ,uCAAuC;IAC3F,QAAQ,CAAC,sBAAsB,CAAC,EAAE,SAAS,CAAC;CAC5C;AAgBD,iEAAiE;AACjE,MAAM,WAAW,iBAAiB;IACjC,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;IACzB,QAAQ,CAAC,YAAY,CAAC,EAAE,MAAM,CAAC;IAC/B,uFAAuF;IACvF,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,CAAC;CACzB;AAED;;;;;;;;;GASG;AACH,eAAO,MAAM,cAAc,GAC1B,SAAS,IAAI,CAAC,WAAW,EAAE,iBAAiB,GAAG,6BAA6B,CAAC,EAC7E,UAAU,MAAM,EAChB,OAAO,iBAAiB,KACtB,OAAO,CAAC,MAAM,CAaV,CAAC;AAgBR,eAAO,MAAM,iCAAiC,GAAI,SAAS,2BAA2B,KAAG,IAgOxF,CAAC"}
1
+ {"version":3,"file":"fact_serving.d.ts","sourceRoot":"../src/lib/","sources":["../../../src/lib/testing/cross_backend/fact_serving.ts"],"names":[],"mappings":"AAAA,OAAO,sBAAsB,CAAC;AA8D9B,OAAO,KAAK,EAAE,uCAAuC,EAAE,SAAS,EAAE,WAAW,EAAE,MAAM,YAAY,CAAC;AAElG;;;;;GAKG;AACH,MAAM,WAAW,2BAA4B,SAAQ,uCAAuC;IAC3F,QAAQ,CAAC,sBAAsB,CAAC,EAAE,SAAS,CAAC;CAC5C;AAgBD,iEAAiE;AACjE,MAAM,WAAW,iBAAiB;IACjC,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;IACzB,QAAQ,CAAC,YAAY,CAAC,EAAE,MAAM,CAAC;IAC/B,uFAAuF;IACvF,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,CAAC;CACzB;AAED;;;;;;;;;GASG;AACH,eAAO,MAAM,cAAc,GAC1B,SAAS,IAAI,CAAC,WAAW,EAAE,iBAAiB,GAAG,6BAA6B,CAAC,EAC7E,UAAU,MAAM,EAChB,OAAO,iBAAiB,KACtB,OAAO,CAAC,MAAM,CAaV,CAAC;AAgDR,eAAO,MAAM,iCAAiC,GAAI,SAAS,2BAA2B,KAAG,IAkRxF,CAAC"}
@@ -14,7 +14,11 @@ import '../assert_dev_env.js';
14
14
  * though the identical bytes are world-readable via B's cell (one deduped
15
15
  * `fact` row; authz lives on the `(cell, hash)` edge, never unioned);
16
16
  * - **404-mask** — a missing cell and a viewable cell that doesn't reference
17
- * the hash both 404 (never 403, never "exists elsewhere");
17
+ * the hash both 404 (never 403, never "exists elsewhere"), and every masked
18
+ * 404 is bodyless, so its shape names no branch;
19
+ * - **hardening headers** — a served fact carries `X-Content-Type-Options:
20
+ * nosniff`, the `default-src 'none'; sandbox` CSP, and `Cache-Control:
21
+ * private, max-age=300`, whatever its content type;
18
22
  * - **bare-hash admin-only** — `GET /api/facts/:hash` is admin (keeper) only:
19
23
  * non-admin → 403, anonymous → 401;
20
24
  * - **multi-actor fallthrough** — a multi-actor caller resolves to a null
@@ -22,7 +26,10 @@ import '../assert_dev_env.js';
22
26
  * read its own *private* fact there (admitted only by public cells). Opt-in
23
27
  * (needs the multi-actor setup); every spine resolves the acting actor at the
24
28
  * dispatcher's authorization phase from account-grain credentials, so the
25
- * multi-actor account is drivable on TS and Rust alike.
29
+ * multi-actor account is drivable on TS and Rust alike;
30
+ * - **narrowed-token downgrade** — a scope-narrowed API token on the
31
+ * cell-scoped route is anonymous, not refused: its owner's private fact 404s
32
+ * exactly as for an anonymous caller, while a public cell still serves.
26
33
  *
27
34
  * Facts are seeded **embedded** via `_testing_put_fact` (the cross-process
28
35
  * driver has no DB handle); the referencing cell via the `cell_create` RPC
@@ -40,8 +47,10 @@ import { describe, assert } from 'vitest';
40
47
  import { z } from 'zod';
41
48
  import { FactHashSchema } from '../../primitive_schemas.js';
42
49
  import { CellCreateOutput } from '../../auth/cell_action_specs.js';
50
+ import { FACT_CACHE_CONTROL, FACT_CONTENT_SECURITY_POLICY, FACT_X_CONTENT_TYPE_OPTIONS } from '../../server/fact_security_headers.js';
43
51
  import { test_if } from './capabilities.js';
44
52
  import { cross_rpc_call, expect_output } from './cell_cross_helpers.js';
53
+ import { mint_narrowed_api_token } from './narrowed_api_token.js';
45
54
  import { SPINE_RPC_PATH } from './spine_surface_constants.js';
46
55
  /** A blake3 hash that is a valid form but references no seeded fact. */
47
56
  const UNRELATED_HASH = `blake3:${'0'.repeat(64)}`;
@@ -70,15 +79,37 @@ export const cross_put_fact = async (fixture, rpc_path, input) => expect_output(
70
79
  // credential when an `Origin` / `Referer` is present (browser
71
80
  // context), and cross-process `fixture.transport` auto-adds `Origin`.
72
81
  fixture.fresh_transport({ origin: null }), rpc_path, '_testing_put_fact', input, fixture.create_daemon_token_headers()), TestingPutFactOutput).hash;
73
- /** GET a fact over a cross-process `FetchTransport`; returns `{status, content_type, text}`. */
82
+ /** GET a fact over a cross-process `FetchTransport`; returns `{status, content_type, text, headers}`. */
74
83
  const fact_get = async (transport, path, headers) => {
75
84
  const res = await transport(path, { method: 'GET', headers });
76
85
  return {
77
86
  status: res.status,
78
87
  content_type: res.headers.get('content-type'),
79
- text: await res.text()
88
+ text: await res.text(),
89
+ headers: res.headers
80
90
  };
81
91
  };
92
+ /**
93
+ * Assert a served fact carries the hardening headers — `nosniff`, the
94
+ * sandboxing CSP — and the private cache policy. A spine that dropped either
95
+ * hardening header would serve a user-typed `content_type` (or a sniffable
96
+ * octet-stream) as executable HTML.
97
+ */
98
+ const assert_fact_headers = (headers, label) => {
99
+ assert.strictEqual(headers.get('x-content-type-options'), FACT_X_CONTENT_TYPE_OPTIONS, `${label}: X-Content-Type-Options`);
100
+ assert.strictEqual(headers.get('content-security-policy'), FACT_CONTENT_SECURITY_POLICY, `${label}: Content-Security-Policy`);
101
+ assert.strictEqual(headers.get('cache-control'), FACT_CACHE_CONTROL, `${label}: Cache-Control`);
102
+ };
103
+ /**
104
+ * Assert a 404 is the masked one: no body, so the response says nothing about
105
+ * which check refused it.
106
+ */
107
+ const assert_masked_404 = (got, label) => {
108
+ assert.strictEqual(got.status, 404, `${label} did not 404`);
109
+ assert.strictEqual(got.text, '', `${label}: a masked 404 has no body`);
110
+ // TODO: the content type isn't pinned — the Node TS spine answers the
111
+ // bodyless 404 with `text/plain; charset=UTF-8`, Deno, Bun and Rust with none
112
+ };
82
113
  export const describe_fact_serving_cross_tests = (options) => {
83
114
  const { setup_test, setup_test_multi_actor, capabilities } = options;
84
115
  const rpc_path = options.rpc_path ?? SPINE_RPC_PATH;
@@ -105,6 +136,7 @@ export const describe_fact_serving_cross_tests = (options) => {
105
136
  assert.strictEqual(got.text, 'public-fact-bytes');
106
137
  // The served `Content-Type` echoes the seeded value on both backends.
107
138
  assert.strictEqual(got.content_type, 'text/plain', 'served content-type drifted');
139
+ assert_fact_headers(got.headers, 'cell-scoped text/plain fact');
108
140
  });
109
141
  test_if(capabilities.fact_serving, 'cross-owner dedup does not leak: A’s private reference stays 404 while B publishes the same bytes', async () => {
110
142
  const fixture = await setup_test();
@@ -129,12 +161,13 @@ export const describe_fact_serving_cross_tests = (options) => {
129
161
  assert.strictEqual(via_b.status, 200, `B's public cell should serve the fact: ${via_b.text}`);
130
162
  // Seeded without a content_type → both backends fall back to octet-stream.
131
163
  assert.strictEqual(via_b.content_type, 'application/octet-stream', 'no-content-type fact should serve as octet-stream');
164
+ assert_fact_headers(via_b.headers, 'octet-stream fact');
132
165
  // A's PRIVATE reference must NOT leak — to anon or to B — even though
133
166
  // the identical bytes are world-readable via B's public cell.
134
167
  const a_via_anon = await fact_get(anon, cell_fact_path(a_cell, hash), {});
135
- assert.strictEqual(a_via_anon.status, 404, "A's private reference leaked to anon");
168
+ assert_masked_404(a_via_anon, "A's private reference read by anon");
136
169
  const a_via_b = await fact_get(t, cell_fact_path(a_cell, hash), b.create_session_headers());
137
- assert.strictEqual(a_via_b.status, 404, "A's private reference leaked to B");
170
+ assert_masked_404(a_via_b, "A's private reference read by B");
138
171
  // A can read its own private cell's fact.
139
172
  const a_via_a = await fact_get(t, cell_fact_path(a_cell, hash), a.create_session_headers());
140
173
  assert.strictEqual(a_via_a.status, 200, 'A could not read its own private fact');
@@ -146,29 +179,33 @@ export const describe_fact_serving_cross_tests = (options) => {
146
179
  const admin_headers = fixture.create_session_headers();
147
180
  // Missing cell → 404.
148
181
  const missing = await fact_get(t, cell_fact_path(NIL_UUID, hash), admin_headers);
149
- assert.strictEqual(missing.status, 404, 'missing cell did not 404');
182
+ assert_masked_404(missing, 'missing cell');
150
183
  // A viewable (keeper-owned, public) cell that references a DIFFERENT
151
184
  // hash → 404 (missing cell→fact edge), even though the caller can view
152
185
  // the cell. `path` is admin-only and the keeper is admin.
153
186
  const unrelated_cell = await create_cell_with_ref(t, admin_headers, UNRELATED_HASH, 'public');
154
187
  const no_edge = await fact_get(t, cell_fact_path(unrelated_cell, hash), admin_headers);
155
- assert.strictEqual(no_edge.status, 404, 'cell without the edge did not 404');
188
+ assert_masked_404(no_edge, 'cell without the edge');
156
189
  // Edge present but no `fact` row: the same cell DOES reference
157
190
  // `UNRELATED_HASH`, which was never seeded. Cell + edge + view all
158
191
  // pass, so this exercises the distinct serve-time "metadata missing"
159
192
  // 404 branch — masked identically to the authz 404s above.
160
193
  const edge_no_bytes = await fact_get(t, cell_fact_path(unrelated_cell, UNRELATED_HASH), admin_headers);
161
- assert.strictEqual(edge_no_bytes.status, 404, 'edge to an absent fact did not 404');
194
+ assert_masked_404(edge_no_bytes, 'edge to an absent fact');
162
195
  });
163
196
  test_if(capabilities.fact_serving, 'bare-hash route is admin-only', async () => {
164
197
  const fixture = await setup_test();
165
198
  const non_admin = await fixture.create_account({ username: 'fact_non_admin' });
166
- const hash = await put_fact(fixture, 'bare-hash-bytes');
199
+ // stored as `text/html` — the type a dropped hardening header would let
200
+ // execute in the admin's session
201
+ const hash = await put_fact(fixture, 'bare-hash-bytes', 'text/html');
167
202
  const t = fixture.fresh_transport();
168
- // keeper holds ROLE_ADMIN → 200 + the actual bytes.
203
+ // keeper holds ROLE_ADMIN → 200 + the actual bytes, hardened.
169
204
  const as_admin = await fact_get(t, `/api/facts/${hash}`, fixture.create_session_headers());
170
205
  assert.strictEqual(as_admin.status, 200, `admin bare-hash read failed: ${as_admin.text}`);
171
206
  assert.strictEqual(as_admin.text, 'bare-hash-bytes', 'bare-hash route served wrong bytes');
207
+ assert.strictEqual(as_admin.content_type, 'text/html', 'served content-type drifted');
208
+ assert_fact_headers(as_admin.headers, 'bare-hash text/html fact');
172
209
  // non-admin → 403.
173
210
  const as_non_admin = await fact_get(t, `/api/facts/${hash}`, non_admin.create_session_headers());
174
211
  assert.strictEqual(as_non_admin.status, 403, 'non-admin reached the bare-hash route');
@@ -177,6 +214,42 @@ export const describe_fact_serving_cross_tests = (options) => {
177
214
  const as_anon = await fact_get(anon, `/api/facts/${hash}`, {});
178
215
  assert.strictEqual(as_anon.status, 401, 'anon reached the bare-hash route');
179
216
  });
217
+ // A narrowed token reaches no non-RPC surface; on this anonymous-admitting
218
+ // route it is downgraded to anonymous rather than refused (a 403 would
219
+ // enforce nothing — the holder drops the header). So the owner's narrowed
220
+ // token reads exactly what an anonymous caller reads: its private fact
221
+ // 404s, a public cell still serves. The owner's full-scope bearer reading
222
+ // the private fact pins that the 404 is the scope, not the bearer.
223
+ test_if(capabilities.fact_serving, 'narrowed api token is treated as anonymous on the cell-scoped route', async () => {
224
+ const fixture = await setup_test();
225
+ const owner = await fixture.create_account({ username: 'fact_narrowed_owner' });
226
+ const hash = await cross_put_fact(fixture, rpc_path, {
227
+ content: 'narrowed-token-bytes',
228
+ holder: owner.actor.id
229
+ });
230
+ const t = fixture.fresh_transport();
231
+ const session = owner.create_session_headers();
232
+ const private_cell = await create_cell_with_ref(t, session, hash, 'private');
233
+ const public_cell = await create_cell_with_ref(t, session, hash, 'public');
234
+ const token = await mint_narrowed_api_token({
235
+ app: fixture.transport,
236
+ rpc_path,
237
+ session_headers: session,
238
+ methods: ['cell_get']
239
+ });
240
+ // no `Origin` — a bearer in a browser context is discarded
241
+ const bare = fixture.fresh_transport({ origin: null });
242
+ const narrowed = { authorization: `Bearer ${token}` };
243
+ const full = await fact_get(bare, cell_fact_path(private_cell, hash), owner.create_bearer_headers());
244
+ assert.strictEqual(full.status, 200, `full-scope bearer read failed: ${full.text}`);
245
+ const anon_private = await fact_get(bare, cell_fact_path(private_cell, hash), {});
246
+ const narrowed_private = await fact_get(bare, cell_fact_path(private_cell, hash), narrowed);
247
+ assert_masked_404(anon_private, 'anon read of a private fact');
248
+ assert_masked_404(narrowed_private, 'narrowed-token read of a private fact');
249
+ const narrowed_public = await fact_get(bare, cell_fact_path(public_cell, hash), narrowed);
250
+ assert.strictEqual(narrowed_public.status, 200, 'narrowed token refused a public cell');
251
+ assert.strictEqual(narrowed_public.text, 'narrowed-token-bytes');
252
+ });
180
253
  // The cell-scoped route is pure-public — no `acting?` slot — so the handler
181
254
  // can't disambiguate a multi-actor account and resolves it to a null
182
255
  // (anonymous) context. This pins that fallthrough: a multi-actor caller is
@@ -201,7 +274,7 @@ export const describe_fact_serving_cross_tests = (options) => {
201
274
  // Owns a PRIVATE cell, yet reading its own fact back resolves to a null
202
275
  // (anonymous) context → 404. The owner path never runs.
203
276
  const own_private = await fact_get(t, cell_fact_path(private_cell, hash), keeper);
204
- assert.strictEqual(own_private.status, 404, "multi-actor owner's private read was admitted");
277
+ assert_masked_404(own_private, "multi-actor owner's private read");
205
278
  // A PUBLIC cell still admits the anonymous-treated caller → 200, proving
206
279
  // the 404 above is the multi-actor fallthrough, not a blanket block.
207
280
  const via_public = await fact_get(t, cell_fact_path(public_cell, hash), keeper);
@@ -8,6 +8,17 @@ export interface IdentityParityCrossTestOptions {
8
8
  readonly login_path?: string;
9
9
  /** REST signup route path. Default `/api/account/signup` (the spine convention). */
10
10
  readonly signup_path?: string;
11
+ /**
12
+ * REST bootstrap route path. Default `/api/account/bootstrap` (the spine
13
+ * convention); `null` skips the bootstrap case, for a surface that mounts no
14
+ * bootstrap route.
15
+ */
16
+ readonly bootstrap_path?: string | null;
17
+ /**
18
+ * REST password-change route path, for the malformed-body case. Default
19
+ * `/api/account/password` (the spine convention); `null` skips that case.
20
+ */
21
+ readonly password_path?: string | null;
11
22
  }
12
23
  export declare const describe_identity_parity_cross_tests: (options: IdentityParityCrossTestOptions) => void;
13
24
  //# sourceMappingURL=identity_parity.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"identity_parity.d.ts","sourceRoot":"../src/lib/","sources":["../../../src/lib/testing/cross_backend/identity_parity.ts"],"names":[],"mappings":"AAAA,OAAO,sBAAsB,CAAC;AAmG9B,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,YAAY,CAAC;AAkB5C,uDAAuD;AACvD,MAAM,WAAW,8BAA8B;IAC9C,+DAA+D;IAC/D,QAAQ,CAAC,UAAU,EAAE,SAAS,CAAC;IAC/B,kFAAkF;IAClF,QAAQ,CAAC,UAAU,CAAC,EAAE,MAAM,CAAC;IAC7B,oFAAoF;IACpF,QAAQ,CAAC,WAAW,CAAC,EAAE,MAAM,CAAC;CAC9B;AA8JD,eAAO,MAAM,oCAAoC,GAChD,SAAS,8BAA8B,KACrC,IA6TF,CAAC"}
1
+ {"version":3,"file":"identity_parity.d.ts","sourceRoot":"../src/lib/","sources":["../../../src/lib/testing/cross_backend/identity_parity.ts"],"names":[],"mappings":"AAAA,OAAO,sBAAsB,CAAC;AAoH9B,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,YAAY,CAAC;AAmB5C,uDAAuD;AACvD,MAAM,WAAW,8BAA8B;IAC9C,+DAA+D;IAC/D,QAAQ,CAAC,UAAU,EAAE,SAAS,CAAC;IAC/B,kFAAkF;IAClF,QAAQ,CAAC,UAAU,CAAC,EAAE,MAAM,CAAC;IAC7B,oFAAoF;IACpF,QAAQ,CAAC,WAAW,CAAC,EAAE,MAAM,CAAC;IAC9B;;;;OAIG;IACH,QAAQ,CAAC,cAAc,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IACxC;;;OAGG;IACH,QAAQ,CAAC,aAAa,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;CACvC;AA8JD,eAAO,MAAM,oCAAoC,GAChD,SAAS,8BAA8B,KACrC,IAieF,CAAC"}