@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.
- package/dist/actions/CLAUDE.md +7 -6
- package/dist/auth/CLAUDE.md +5 -2
- package/dist/auth/account_action_specs.d.ts.map +1 -1
- package/dist/auth/account_action_specs.js +2 -1
- package/dist/auth/account_route_schema.d.ts.map +1 -1
- package/dist/auth/account_route_schema.js +9 -2
- package/dist/auth/account_routes.d.ts +8 -0
- package/dist/auth/account_routes.d.ts.map +1 -1
- package/dist/auth/account_routes.js +12 -3
- package/dist/auth/request_context.d.ts +23 -0
- package/dist/auth/request_context.d.ts.map +1 -1
- package/dist/auth/request_context.js +32 -1
- package/dist/auth/token_scope.d.ts +22 -14
- package/dist/auth/token_scope.d.ts.map +1 -1
- package/dist/auth/token_scope.js +23 -14
- package/dist/db/CLAUDE.md +7 -3
- package/dist/db/fact_ddl.d.ts +1 -0
- package/dist/db/fact_ddl.d.ts.map +1 -1
- package/dist/db/fact_ddl.js +1 -0
- package/dist/db/fact_holder_queries.d.ts +8 -4
- package/dist/db/fact_holder_queries.d.ts.map +1 -1
- package/dist/db/fact_holder_queries.js +9 -5
- package/dist/db/fact_queries.d.ts +10 -7
- package/dist/db/fact_queries.d.ts.map +1 -1
- package/dist/db/fact_queries.js +31 -10
- package/dist/http/CLAUDE.md +2 -2
- package/dist/http/auth_shape.d.ts.map +1 -1
- package/dist/http/auth_shape.js +11 -6
- package/dist/http/proxy.d.ts +22 -21
- package/dist/http/proxy.d.ts.map +1 -1
- package/dist/http/proxy.js +28 -33
- package/dist/http/route_spec.d.ts +8 -0
- package/dist/http/route_spec.d.ts.map +1 -1
- package/dist/server/env.d.ts.map +1 -1
- package/dist/server/env.js +1 -1
- package/dist/server/fact_security_headers.d.ts +59 -0
- package/dist/server/fact_security_headers.d.ts.map +1 -0
- package/dist/server/fact_security_headers.js +58 -0
- package/dist/server/serve_fact_route.d.ts +21 -10
- package/dist/server/serve_fact_route.d.ts.map +1 -1
- package/dist/server/serve_fact_route.js +41 -50
- package/dist/server/x_accel.d.ts +135 -31
- package/dist/server/x_accel.d.ts.map +1 -1
- package/dist/server/x_accel.js +652 -88
- package/dist/testing/CLAUDE.md +73 -22
- package/dist/testing/cross_backend/capabilities.d.ts +0 -18
- package/dist/testing/cross_backend/capabilities.d.ts.map +1 -1
- package/dist/testing/cross_backend/capabilities.js +1 -4
- package/dist/testing/cross_backend/default_backend_configs.d.ts.map +1 -1
- package/dist/testing/cross_backend/default_backend_configs.js +0 -7
- package/dist/testing/cross_backend/fact_serving.d.ts.map +1 -1
- package/dist/testing/cross_backend/fact_serving.js +85 -12
- package/dist/testing/cross_backend/identity_parity.d.ts +11 -0
- package/dist/testing/cross_backend/identity_parity.d.ts.map +1 -1
- package/dist/testing/cross_backend/identity_parity.js +124 -9
- package/dist/testing/cross_backend/login_security.d.ts.map +1 -1
- package/dist/testing/cross_backend/login_security.js +28 -0
- package/dist/testing/cross_backend/revocation_drivers.d.ts +70 -0
- package/dist/testing/cross_backend/revocation_drivers.d.ts.map +1 -0
- package/dist/testing/cross_backend/revocation_drivers.js +104 -0
- package/dist/testing/cross_backend/sse_round_trip.d.ts +6 -1
- package/dist/testing/cross_backend/sse_round_trip.d.ts.map +1 -1
- package/dist/testing/cross_backend/sse_round_trip.js +52 -16
- package/dist/testing/cross_backend/token_scope_surface.d.ts.map +1 -1
- package/dist/testing/cross_backend/token_scope_surface.js +33 -0
- package/dist/testing/cross_backend/ws_round_trip.d.ts +13 -1
- package/dist/testing/cross_backend/ws_round_trip.d.ts.map +1 -1
- package/dist/testing/cross_backend/ws_round_trip.js +155 -39
- package/package.json +3 -3
package/dist/testing/CLAUDE.md
CHANGED
|
@@ -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` /
|
|
935
|
+
`ws_handshake_pipelining` / `ws_account_actions` /
|
|
936
936
|
`ws_role_gated` / `ws_refusal_body` — each has a `test_if`
|
|
937
|
-
reader (`
|
|
938
|
-
|
|
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
|
|
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
|
|
995
|
-
WS-mutation audit case
|
|
996
|
-
|
|
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
|
|
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
|
|
1139
|
-
`
|
|
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
|
|
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;
|
|
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
|
|
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
|
|
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).
|
|
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)`),
|
|
1923
|
-
|
|
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
|
|
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 — `
|
|
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":"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,
|
|
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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"}
|
|
@@ -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;
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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;
|
|
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"}
|