@fuzdev/fuz_app 0.145.0 → 0.146.1

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 (60) 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/http/CLAUDE.md +2 -2
  17. package/dist/http/auth_shape.d.ts.map +1 -1
  18. package/dist/http/auth_shape.js +11 -6
  19. package/dist/http/proxy.d.ts +22 -21
  20. package/dist/http/proxy.d.ts.map +1 -1
  21. package/dist/http/proxy.js +28 -33
  22. package/dist/http/route_spec.d.ts +8 -0
  23. package/dist/http/route_spec.d.ts.map +1 -1
  24. package/dist/server/env.d.ts.map +1 -1
  25. package/dist/server/env.js +1 -1
  26. package/dist/server/fact_security_headers.d.ts +5 -0
  27. package/dist/server/fact_security_headers.d.ts.map +1 -1
  28. package/dist/server/fact_security_headers.js +5 -0
  29. package/dist/server/serve_fact_route.d.ts +12 -7
  30. package/dist/server/serve_fact_route.d.ts.map +1 -1
  31. package/dist/server/serve_fact_route.js +30 -23
  32. package/dist/server/x_accel.d.ts +37 -9
  33. package/dist/server/x_accel.d.ts.map +1 -1
  34. package/dist/server/x_accel.js +136 -19
  35. package/dist/testing/CLAUDE.md +73 -22
  36. package/dist/testing/cross_backend/capabilities.d.ts +0 -18
  37. package/dist/testing/cross_backend/capabilities.d.ts.map +1 -1
  38. package/dist/testing/cross_backend/capabilities.js +1 -4
  39. package/dist/testing/cross_backend/default_backend_configs.d.ts.map +1 -1
  40. package/dist/testing/cross_backend/default_backend_configs.js +0 -7
  41. package/dist/testing/cross_backend/fact_serving.d.ts.map +1 -1
  42. package/dist/testing/cross_backend/fact_serving.js +85 -12
  43. package/dist/testing/cross_backend/identity_parity.d.ts +11 -0
  44. package/dist/testing/cross_backend/identity_parity.d.ts.map +1 -1
  45. package/dist/testing/cross_backend/identity_parity.js +124 -9
  46. package/dist/testing/cross_backend/login_security.d.ts.map +1 -1
  47. package/dist/testing/cross_backend/login_security.js +28 -0
  48. package/dist/testing/cross_backend/revocation_drivers.d.ts +70 -0
  49. package/dist/testing/cross_backend/revocation_drivers.d.ts.map +1 -0
  50. package/dist/testing/cross_backend/revocation_drivers.js +104 -0
  51. package/dist/testing/cross_backend/sse_round_trip.d.ts +6 -1
  52. package/dist/testing/cross_backend/sse_round_trip.d.ts.map +1 -1
  53. package/dist/testing/cross_backend/sse_round_trip.js +52 -16
  54. package/dist/testing/cross_backend/token_scope_surface.d.ts.map +1 -1
  55. package/dist/testing/cross_backend/token_scope_surface.js +33 -0
  56. package/dist/testing/cross_backend/ws_round_trip.d.ts +13 -1
  57. package/dist/testing/cross_backend/ws_round_trip.d.ts.map +1 -1
  58. package/dist/testing/cross_backend/ws_round_trip.js +155 -39
  59. package/dist/ui/Datatable.svelte +3 -3
  60. package/package.json +1 -1
@@ -34,12 +34,17 @@ export interface CrossProcessSseTestOptions {
34
34
  * skips the case.
35
35
  */
36
36
  readonly max_per_scope?: number | null;
37
+ /** REST logout route path, for the logout close case. Default `/api/account/logout`. */
38
+ readonly logout_path?: string;
39
+ /** REST password-change route path, for its close case. Default `/api/account/password`. */
40
+ readonly password_path?: string;
37
41
  }
38
42
  /**
39
43
  * Register the cross-process SSE round-trip suite. Its cases run over a
40
44
  * real streaming `fetch`: connected-comment, audit data frame, account-wide
41
45
  * close-on-revoke, session-scoped close-on-revoke, close on account delete,
42
- * and the per-session cap.
46
+ * close on admin token revoke-all, logout, password change and purge, and the
47
+ * per-session cap.
43
48
  */
44
49
  export declare const describe_cross_process_sse_tests: (options: CrossProcessSseTestOptions) => void;
45
50
  //# sourceMappingURL=sse_round_trip.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"sse_round_trip.d.ts","sourceRoot":"../src/lib/","sources":["../../../src/lib/testing/cross_backend/sse_round_trip.ts"],"names":[],"mappings":"AAAA,OAAO,sBAAsB,CAAC;AA8E9B,OAAO,EAAE,KAAK,mBAAmB,EAAW,MAAM,mBAAmB,CAAC;AACtE,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,YAAY,CAAC;AAK5C,4DAA4D;AAC5D,MAAM,WAAW,0BAA0B;IAC1C;;;;;;OAMG;IACH,QAAQ,CAAC,UAAU,EAAE,SAAS,CAAC;IAC/B,wEAAwE;IACxE,QAAQ,CAAC,YAAY,EAAE,mBAAmB,CAAC;IAC3C,2EAA2E;IAC3E,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B,6EAA6E;IAC7E,QAAQ,CAAC,QAAQ,CAAC,EAAE,MAAM,CAAC;IAC3B;;;;;;OAMG;IACH,QAAQ,CAAC,QAAQ,CAAC,EAAE,MAAM,CAAC;IAC3B,6DAA6D;IAC7D,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,CAAC;IACzB;;;;;OAKG;IACH,QAAQ,CAAC,aAAa,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;CACvC;AA0BD;;;;;GAKG;AACH,eAAO,MAAM,gCAAgC,GAAI,SAAS,0BAA0B,KAAG,IA2PtF,CAAC"}
1
+ {"version":3,"file":"sse_round_trip.d.ts","sourceRoot":"../src/lib/","sources":["../../../src/lib/testing/cross_backend/sse_round_trip.ts"],"names":[],"mappings":"AAAA,OAAO,sBAAsB,CAAC;AAkF9B,OAAO,EAAE,KAAK,mBAAmB,EAAW,MAAM,mBAAmB,CAAC;AAEtE,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,YAAY,CAAC;AAK5C,4DAA4D;AAC5D,MAAM,WAAW,0BAA0B;IAC1C;;;;;;OAMG;IACH,QAAQ,CAAC,UAAU,EAAE,SAAS,CAAC;IAC/B,wEAAwE;IACxE,QAAQ,CAAC,YAAY,EAAE,mBAAmB,CAAC;IAC3C,2EAA2E;IAC3E,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B,6EAA6E;IAC7E,QAAQ,CAAC,QAAQ,CAAC,EAAE,MAAM,CAAC;IAC3B;;;;;;OAMG;IACH,QAAQ,CAAC,QAAQ,CAAC,EAAE,MAAM,CAAC;IAC3B,6DAA6D;IAC7D,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,CAAC;IACzB;;;;;OAKG;IACH,QAAQ,CAAC,aAAa,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IACvC,wFAAwF;IACxF,QAAQ,CAAC,WAAW,CAAC,EAAE,MAAM,CAAC;IAC9B,4FAA4F;IAC5F,QAAQ,CAAC,aAAa,CAAC,EAAE,MAAM,CAAC;CAChC;AA0BD;;;;;;GAMG;AACH,eAAO,MAAM,gCAAgC,GAAI,SAAS,0BAA0B,KAAG,IAyStF,CAAC"}
@@ -35,18 +35,21 @@ import '../assert_dev_env.js';
35
35
  * `capabilities.account_lifecycle`) — the keeper soft-deletes a second
36
36
  * admin whose stream is open; the delete revokes its credentials and
37
37
  * closes its stream.
38
- * 6. **per-session cap** — one stream past `max_per_scope` on one session
38
+ * 6. **close on the other account-wide revocations** (gated on `rpc_path`;
39
+ * purge also on `capabilities.account_lifecycle`) — `admin_token_revoke_all`,
40
+ * `POST /logout`, `POST /password`, and `account_purge` of a second admin
41
+ * each close its stream — the rows `create_account_revocations` shares with
42
+ * the WS suite.
43
+ * 7. **per-session cap** — one stream past `max_per_scope` on one session
39
44
  * closes the oldest (evict-oldest, no final frame) and leaves every other
40
45
  * stream open. Needs no RPC: the cap runs inside the subscribe itself.
41
46
  *
42
- * The close-on-revoke matrix is layered: cases 3–5 exercise the account-wide
43
- * and session-scoped paths cross-process; the remaining revocation events
44
- * (`token_revoke_all` / `logout` / `password_change` / `account_purge`, all
45
- * account-wide; `token_revoke`, token-scoped; and `role_grant_revoke`,
46
- * role-matched) are covered by the spine's `fuz_realtime` SSE-registry unit
47
- * tests and the in-process guard self-test, so a cross-process
48
- * `token_revoke_all`-with-zero-tokens case (which may emit no audit row) stays
49
- * out to keep the spawned-backend suite non-flaky.
47
+ * The close-on-revoke matrix is layered: cases 3–6 exercise the account-wide
48
+ * and session-scoped paths cross-process; `token_revoke` (token-scoped — the
49
+ * stock stream admits sessions only, so its bearer close is covered on the
50
+ * WebSocket by `describe_cross_process_ws_tests`) and `role_grant_revoke`
51
+ * (role-matched) are covered by the spine's `fuz_realtime` SSE-registry unit
52
+ * tests and the in-process guard self-test.
50
53
  *
51
54
  * Gated on `capabilities.sse` — backends without an end-to-end SSE stream
52
55
  * skip (the cases still surface as `.skip` in the report). Cross-process
@@ -63,7 +66,9 @@ import { AUDIT_LOG_SSE_MAX_PER_SCOPE, audit_log_event_specs } from '../../realti
63
66
  import { SSE_CONNECTED_COMMENT } from '../../realtime/sse_constants.js';
64
67
  import { create_sse_transport } from '../transports/sse_transport.js';
65
68
  import { create_rpc_post_init } from '../rpc_helpers.js';
69
+ import { DEFAULT_TEST_PASSWORD } from '../test_credentials.js';
66
70
  import { test_if } from './capabilities.js';
71
+ import { create_account_revocations } from './revocation_drivers.js';
67
72
  /** Default audit-log SSE stream path — the standard fuz_app `/api/admin/audit/stream`. */
68
73
  const DEFAULT_SSE_PATH = '/api/admin/audit/stream';
69
74
  /**
@@ -85,10 +90,11 @@ const assert_audit_data_frame = (frame) => {
85
90
  * Register the cross-process SSE round-trip suite. Its cases run over a
86
91
  * real streaming `fetch`: connected-comment, audit data frame, account-wide
87
92
  * close-on-revoke, session-scoped close-on-revoke, close on account delete,
88
- * and the per-session cap.
93
+ * close on admin token revoke-all, logout, password change and purge, and the
94
+ * per-session cap.
89
95
  */
90
96
  export const describe_cross_process_sse_tests = (options) => {
91
- const { setup_test, capabilities, base_url, rpc_path, origin } = options;
97
+ const { setup_test, capabilities, base_url, rpc_path, origin, logout_path = '/api/account/logout', password_path = '/api/account/password' } = options;
92
98
  const sse_path = options.sse_path ?? DEFAULT_SSE_PATH;
93
99
  const max_per_scope = options.max_per_scope === undefined ? AUDIT_LOG_SSE_MAX_PER_SCOPE : options.max_per_scope;
94
100
  describe('cross-process sse', () => {
@@ -166,11 +172,8 @@ export const describe_cross_process_sse_tests = (options) => {
166
172
  // TS registry's `close_by_identity(session_hash)`). The keeper holds
167
173
  // exactly one session, so revoking it by its blake3 hash drops the stream
168
174
  // opened under it. This is the close-on-revoke path the account-wide case
169
- // above doesn't exercise; the remaining account-wide events
170
- // (`token_revoke_all` / `logout` / `password_change` / `account_purge`)
171
- // share the close the `session_revoke_all` and account-delete cases cover,
172
- // and `token_revoke`'s token-scoped and `role_grant_revoke`'s role-matched
173
- // closes are covered by `fuz_realtime`'s SSE registry unit tests.
175
+ // above doesn't exercise; `role_grant_revoke`'s role-matched close is
176
+ // covered by `fuz_realtime`'s SSE registry unit tests.
174
177
  test_if(capabilities.sse && rpc_path !== undefined, 'stream closes on a single session_revoke of the subscriber session', async () => {
175
178
  const fixture = await setup_test();
176
179
  const sse = await create_sse_transport({
@@ -223,6 +226,39 @@ export const describe_cross_process_sse_tests = (options) => {
223
226
  await sse.close();
224
227
  }
225
228
  });
229
+ // The account-wide revocations past session revoke-all and delete
230
+ // (`revocation_drivers.ts`, shared with the WS suite) each close the
231
+ // target admin's stream. Without `rpc_path` the rows still register, as
232
+ // skipped cases, and never run — so the empty path is never sent.
233
+ const revocations = create_account_revocations({
234
+ capabilities,
235
+ rpc_path: rpc_path ?? '',
236
+ logout_path,
237
+ password_path
238
+ });
239
+ for (const { label, slug, gate, revoke } of revocations) {
240
+ test_if(capabilities.sse && gate && rpc_path !== undefined, `stream closes on ${label} of the subscriber account`, async () => {
241
+ const fixture = await setup_test();
242
+ const target = await fixture.create_account({
243
+ username: `sse_revoke_${slug}`,
244
+ password_value: DEFAULT_TEST_PASSWORD,
245
+ roles: [ROLE_ADMIN]
246
+ });
247
+ const cookie = target.create_session_headers().cookie;
248
+ assert.ok(cookie, 'expected a session cookie for the target account');
249
+ const sse = await create_sse_transport({ base_url, sse_path, cookies: [cookie], origin });
250
+ try {
251
+ const first = await sse.read_frame();
252
+ assert.strictEqual(first + '\n\n', SSE_CONNECTED_COMMENT, 'first frame must be the connected comment');
253
+ await revoke(fixture, target);
254
+ const closed = await sse.wait_for_close(2000);
255
+ assert.ok(closed, `stream did not close within 2s after ${label}`);
256
+ }
257
+ finally {
258
+ await sse.close();
259
+ }
260
+ });
261
+ }
226
262
  // One stream past the cap on one session → the oldest closes, the newest
227
263
  // stays open. Opened in sequence, each awaited to its connect comment, so
228
264
  // the server registered them in order and "oldest" is stream 0 on both
@@ -1 +1 @@
1
- {"version":3,"file":"token_scope_surface.d.ts","sourceRoot":"../src/lib/","sources":["../../../src/lib/testing/cross_backend/token_scope_surface.ts"],"names":[],"mappings":"AAAA,OAAO,sBAAsB,CAAC;AA2E9B,OAAO,KAAK,EAAE,uCAAuC,EAAE,MAAM,YAAY,CAAC;AAkB1E,wDAAwD;AACxD,MAAM,WAAW,iCAAkC,SAAQ,uCAAuC;IACjG,6DAA6D;IAC7D,QAAQ,CAAC,QAAQ,CAAC,EAAE,MAAM,CAAC;CAC3B;AA6DD,eAAO,MAAM,wCAAwC,GACpD,SAAS,iCAAiC,KACxC,IA4HF,CAAC"}
1
+ {"version":3,"file":"token_scope_surface.d.ts","sourceRoot":"../src/lib/","sources":["../../../src/lib/testing/cross_backend/token_scope_surface.ts"],"names":[],"mappings":"AAAA,OAAO,sBAAsB,CAAC;AAgF9B,OAAO,KAAK,EAAE,uCAAuC,EAAE,MAAM,YAAY,CAAC;AAqB1E,wDAAwD;AACxD,MAAM,WAAW,iCAAkC,SAAQ,uCAAuC;IACjG,6DAA6D;IAC7D,QAAQ,CAAC,QAAQ,CAAC,EAAE,MAAM,CAAC;CAC3B;AA6DD,eAAO,MAAM,wCAAwC,GACpD,SAAS,iCAAiC,KACxC,IA+JF,CAAC"}
@@ -35,6 +35,11 @@ import '../assert_dev_env.js';
35
35
  * `surface:audit_stream`'s own capability string is consequently unreachable
36
36
  * on the default mount and has no cross-impl pin — each spine's census holds
37
37
  * its declaration, the way the WS upgrade's is held.
38
+ * - **The account status read answers a narrowed token as anonymous** —
39
+ * `GET /api/account/status` answers anonymous callers, so rule 3 there is the
40
+ * anonymous answer (`401` plus the bootstrap flag), byte for byte, never the
41
+ * account's email, its verification, or its role grants. A full-scope bearer
42
+ * reading the account is the control.
38
43
  * - **Scope outranks role** — a narrowed token whose account *also* lacks the
39
44
  * gating role must hear `token_scope_required`, not
40
45
  * `insufficient_permissions`. This is the observable the TS↔Rust ordering
@@ -70,6 +75,8 @@ import { mint_narrowed_api_token } from './narrowed_api_token.js';
70
75
  import { SPINE_RPC_PATH } from './spine_surface_constants.js';
71
76
  /** Default audit-log SSE path — the `audit_stream` surface. */
72
77
  const DEFAULT_SSE_PATH = '/api/admin/audit/stream';
78
+ /** The account status read, which answers a narrowed token as anonymous. */
79
+ const STATUS_PATH = '/api/account/status';
73
80
  /**
74
81
  * A well-formed hash that no fact has. The scope gate is a pre-authorization
75
82
  * guard, so it fires before the handler ever looks the hash up — but *params*
@@ -172,6 +179,32 @@ export const describe_token_scope_surface_cross_tests = (options) => {
172
179
  const body = (await res.json().catch(() => undefined));
173
180
  assert.notStrictEqual(body?.error, ERROR_TOKEN_SCOPE_REQUIRED, 'a full-authority bearer must not be refused on scope grounds');
174
181
  });
182
+ /**
183
+ * The status read answers anonymous callers, so a narrowed token is
184
+ * answered as one — byte for byte, status and body — rather than refused.
185
+ * The full-scope bearer of the same account reads the account, so the
186
+ * 401 is the scope, not the bearer.
187
+ */
188
+ test_if(capabilities.account_status, 'a narrowed token reads the account status as the anonymous caller', async () => {
189
+ const fixture = await setup_test();
190
+ const token = await mint_narrowed_api_token({
191
+ app: fixture.transport,
192
+ rpc_path,
193
+ session_headers: fixture.create_session_headers(),
194
+ methods: [ADMITTED_METHOD]
195
+ });
196
+ const anonymous = await fixture.fresh_transport({ origin: null })(STATUS_PATH, {});
197
+ const narrowed = await fixture.fresh_transport({ origin: null })(STATUS_PATH, {
198
+ headers: { authorization: `Bearer ${token}` }
199
+ });
200
+ assert.strictEqual(anonymous.status, 401, 'anonymous status must be 401');
201
+ assert.strictEqual(narrowed.status, 401, 'a narrowed token must read status as anonymous');
202
+ assert.strictEqual(await narrowed.text(), await anonymous.text(), 'the narrowed answer must be the anonymous one, byte for byte');
203
+ const full = await fixture.fresh_transport({ origin: null })(STATUS_PATH, {
204
+ headers: fixture.create_bearer_headers()
205
+ });
206
+ assert.strictEqual(full.status, 200, 'a full-scope bearer must read the account');
207
+ });
175
208
  /**
176
209
  * **Scope outranks role**, and this is the case that had no cross-impl
177
210
  * pin while the two spines disagreed about it.
@@ -31,13 +31,25 @@ export interface CrossProcessWsTestOptions {
31
31
  * the server cap.
32
32
  */
33
33
  readonly max_message_bytes?: number;
34
+ /** REST logout route path, for the logout close case. Default `/api/account/logout`. */
35
+ readonly logout_path?: string;
36
+ /** REST password-change route path, for its close case. Default `/api/account/password`. */
37
+ readonly password_path?: string;
38
+ /**
39
+ * The backend's per-account API token cap, which the token-cap close case
40
+ * mints past. Defaults to `DEFAULT_MAX_TOKENS`; `null` (a backend with the
41
+ * cap disabled) skips the case.
42
+ */
43
+ readonly max_tokens?: number | null;
34
44
  }
35
45
  /**
36
46
  * Register the cross-process WS round-trip suite over a real upgrade: authed
37
47
  * `heartbeat` round-trip, `heartbeat`'s parameterless-shape contract, an
38
48
  * oversized message closing with `WS_CLOSE_MESSAGE_TOO_BIG`, anonymous-upgrade
39
49
  * refusal, disallowed-origin refusal, and — when `rpc_path` is supplied —
40
- * session revocation and account deletion closing the live socket, plus a
50
+ * each listed revocation (session and token revokes, admin revoke-all, logout,
51
+ * password change, token-cap eviction, account delete and purge) closing the
52
+ * live socket, plus a
41
53
  * self-revoking socket reading its reply before its close, and a WS mutation's
42
54
  * audit row read back after `_testing_drain_effects`.
43
55
  */
@@ -1 +1 @@
1
- {"version":3,"file":"ws_round_trip.d.ts","sourceRoot":"../src/lib/","sources":["../../../src/lib/testing/cross_backend/ws_round_trip.ts"],"names":[],"mappings":"AAAA,OAAO,sBAAsB,CAAC;AA0G9B,OAAO,EAAE,KAAK,mBAAmB,EAAW,MAAM,mBAAmB,CAAC;AAEtE,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,YAAY,CAAC;AAK5C,2DAA2D;AAC3D,MAAM,WAAW,yBAAyB;IACzC;;;;OAIG;IACH,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;IACzB,oEAAoE;IACpE,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,CAAC;IACzB;;;;;OAKG;IACH,QAAQ,CAAC,QAAQ,CAAC,EAAE,MAAM,CAAC;IAC3B;;;;;OAKG;IACH,QAAQ,CAAC,iBAAiB,CAAC,EAAE,MAAM,CAAC;CACpC;AAED;;;;;;;;GAQG;AACH,eAAO,MAAM,+BAA+B,GAAI,SAAS,yBAAyB,KAAG,IAkVpF,CAAC"}
1
+ {"version":3,"file":"ws_round_trip.d.ts","sourceRoot":"../src/lib/","sources":["../../../src/lib/testing/cross_backend/ws_round_trip.ts"],"names":[],"mappings":"AAAA,OAAO,sBAAsB,CAAC;AAyH9B,OAAO,EAAE,KAAK,mBAAmB,EAAW,MAAM,mBAAmB,CAAC;AAGtE,OAAO,KAAK,EAAE,SAAS,EAAmC,MAAM,YAAY,CAAC;AAK7E,2DAA2D;AAC3D,MAAM,WAAW,yBAAyB;IACzC;;;;OAIG;IACH,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;IACzB,oEAAoE;IACpE,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,CAAC;IACzB;;;;;OAKG;IACH,QAAQ,CAAC,QAAQ,CAAC,EAAE,MAAM,CAAC;IAC3B;;;;;OAKG;IACH,QAAQ,CAAC,iBAAiB,CAAC,EAAE,MAAM,CAAC;IACpC,wFAAwF;IACxF,QAAQ,CAAC,WAAW,CAAC,EAAE,MAAM,CAAC;IAC9B,4FAA4F;IAC5F,QAAQ,CAAC,aAAa,CAAC,EAAE,MAAM,CAAC;IAChC;;;;OAIG;IACH,QAAQ,CAAC,UAAU,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;CACpC;AAED;;;;;;;;;;GAUG;AACH,eAAO,MAAM,+BAA+B,GAAI,SAAS,yBAAyB,KAAG,IA4epF,CAAC"}
@@ -43,21 +43,30 @@ import '../assert_dev_env.js';
43
43
  * server-initiated close.
44
44
  * Per-message dispatch never re-checks credential validity, so an open socket
45
45
  * ends only when a revocation closes it — which every revocation does, once
46
- * its transaction commits. An authenticated socket is dropped when the
47
- * account's sessions are revoked over the keeper's session channel
46
+ * its transaction commits, and every close here is asserted with
47
+ * `WS_CLOSE_SESSION_REVOKED` and `WS_CLOSE_SESSION_REVOKED_REASON`, so a
48
+ * dropped connection can't pass for one. An authenticated socket is dropped
49
+ * when the account's sessions are revoked over the keeper's session channel
48
50
  * (`account_session_revoke_all`), and when its account is deleted
49
- * (`account_delete`, also gated on `capabilities.account_lifecycle`). Omit
50
- * `rpc_path` to skip them (consumers without the standard account and admin
51
- * actions on their RPC endpoint).
51
+ * (`account_delete`, also gated on `capabilities.account_lifecycle`). The
52
+ * rest of the revocation table follows, each closing a second account's
53
+ * socket:
54
+ * `account_token_revoke` closes the revoked token's bearer socket and leaves
55
+ * a socket on the account's other token open; `admin_token_revoke_all`,
56
+ * `POST /logout`, `POST /password`, and `account_purge` (also gated on
57
+ * `capabilities.account_lifecycle`) close the account's bearer and session
58
+ * sockets — the rows `create_account_revocations` shares with the SSE suite;
59
+ * and a token the per-account cap evicts (`max_tokens` further mints) has its
60
+ * bearer socket closed. Omit `rpc_path` to skip them
61
+ * (consumers without the standard account and admin actions on their RPC
62
+ * endpoint).
52
63
  *
53
64
  * The last cases are gated on `capabilities.ws_account_actions` — the backend's
54
65
  * WS endpoint mounts the self-service account actions: a socket that revokes
55
66
  * its own sessions *over that socket* is closed with
56
- * `WS_CLOSE_SESSION_REVOKED`, and — on a backend declaring
57
- * `capabilities.ws_self_revocation_reply` — reads the reply to its request
58
- * first. The TS spine queues the close behind the commit and sends the reply
59
- * ahead of it, so the caller is never left without an answer; the Rust spine
60
- * does not answer yet, which that flag records. Under the same gate (and
67
+ * `WS_CLOSE_SESSION_REVOKED` after reading the reply to its request — both
68
+ * spines answer the revoking request ahead of the close, so the caller is
69
+ * never left without an answer. Under the same gate (and
61
70
  * `rpc_path`), a socket mints an API token over WS and the token's
62
71
  * `token_create` audit row is read back after `_testing_drain_effects` — the
63
72
  * barrier a WS mutation needs, since a WS reply can precede its audit write.
@@ -73,16 +82,19 @@ import '../assert_dev_env.js';
73
82
  import { assert, describe } from 'vitest';
74
83
  import { assert_rejects } from '@fuzdev/fuz_util/testing.ts';
75
84
  import { heartbeat_action_spec } from '../../actions/heartbeat.js';
76
- import { DEFAULT_WS_MAX_MESSAGE_BYTES, WS_CLOSE_MESSAGE_TOO_BIG, WS_CLOSE_SESSION_REVOKED } from '../../actions/transports.js';
77
- import { account_session_revoke_all_action_spec, account_token_create_action_spec } from '../../auth/account_action_specs.js';
85
+ import { DEFAULT_WS_MAX_MESSAGE_BYTES, WS_CLOSE_MESSAGE_TOO_BIG, WS_CLOSE_SESSION_REVOKED, WS_CLOSE_SESSION_REVOKED_REASON } from '../../actions/transports.js';
86
+ import { account_session_revoke_all_action_spec, account_token_create_action_spec, account_token_revoke_action_spec, TokenCreateOutput, TokenRevokeOutput } from '../../auth/account_action_specs.js';
87
+ import { DEFAULT_MAX_TOKENS } from '../../auth/account_route_schema.js';
78
88
  import { account_delete_action_spec, audit_log_list_action_spec, AuditLogListOutput } from '../../auth/admin_action_specs.js';
79
89
  import { JSONRPC_ERROR_CODES } from '../../http/jsonrpc_errors.js';
80
90
  import { is_response_for } from '../transports/ws_client.js';
81
- import { create_ws_transport } from '../transports/ws_transport.js';
91
+ import { create_admitted_ws_transport, create_ws_transport } from '../transports/ws_transport.js';
82
92
  import { connect_raw_ws, is_raw_ws_close, RAW_WS_OPCODE_TEXT } from '../transports/ws_raw_client.js';
83
93
  import { create_rpc_post_init } from '../rpc_helpers.js';
94
+ import { DEFAULT_TEST_PASSWORD } from '../test_credentials.js';
84
95
  import { test_if } from './capabilities.js';
85
96
  import { cross_rpc_call, expect_output } from './cell_cross_helpers.js';
97
+ import { create_account_revocations } from './revocation_drivers.js';
86
98
  /** Origin guaranteed to fail the `http://localhost:*` allowlist the test backends run with. */
87
99
  const DISALLOWED_ORIGIN = 'http://disallowed.example';
88
100
  /**
@@ -90,12 +102,15 @@ const DISALLOWED_ORIGIN = 'http://disallowed.example';
90
102
  * `heartbeat` round-trip, `heartbeat`'s parameterless-shape contract, an
91
103
  * oversized message closing with `WS_CLOSE_MESSAGE_TOO_BIG`, anonymous-upgrade
92
104
  * refusal, disallowed-origin refusal, and — when `rpc_path` is supplied —
93
- * session revocation and account deletion closing the live socket, plus a
105
+ * each listed revocation (session and token revokes, admin revoke-all, logout,
106
+ * password change, token-cap eviction, account delete and purge) closing the
107
+ * live socket, plus a
94
108
  * self-revoking socket reading its reply before its close, and a WS mutation's
95
109
  * audit row read back after `_testing_drain_effects`.
96
110
  */
97
111
  export const describe_cross_process_ws_tests = (options) => {
98
- const { setup_test, capabilities, base_url, ws_path, origin, rpc_path, max_message_bytes = DEFAULT_WS_MAX_MESSAGE_BYTES } = options;
112
+ const { setup_test, capabilities, base_url, ws_path, origin, rpc_path, max_message_bytes = DEFAULT_WS_MAX_MESSAGE_BYTES, logout_path = '/api/account/logout', password_path = '/api/account/password' } = options;
113
+ const max_tokens = options.max_tokens === undefined ? DEFAULT_MAX_TOKENS : options.max_tokens;
99
114
  const open_authed = async () => {
100
115
  const fixture = await setup_test();
101
116
  return create_ws_transport({ base_url, ws_path, cookies: fixture.transport.cookies(), origin });
@@ -229,6 +244,54 @@ export const describe_cross_process_ws_tests = (options) => {
229
244
  test_if(capabilities.ws, 'disallowed-origin upgrade is refused', async () => {
230
245
  await assert_rejects(() => create_ws_transport({ base_url, ws_path, cookies: [], origin: DISALLOWED_ORIGIN }));
231
246
  });
247
+ // Every revocation closes with 4001 "Session revoked" — the code and its
248
+ // reason, so a dropped connection (1006) or a crash can't pass for one.
249
+ const assert_revoked_close = async (client, label) => {
250
+ assert.ok(await client.wait_for_close(2000), `socket did not close within 2s after ${label}`);
251
+ assert.strictEqual(client.close_code, WS_CLOSE_SESSION_REVOKED, label);
252
+ assert.strictEqual(client.close_reason, WS_CLOSE_SESSION_REVOKED_REASON, label);
253
+ };
254
+ // A bearer socket opens outside a browser context (no Origin, no cookie),
255
+ // as a CLI client's would.
256
+ const open_bearer = (token) => create_admitted_ws_transport({
257
+ base_url,
258
+ ws_path,
259
+ cookies: [],
260
+ origin: null,
261
+ headers: { Authorization: `Bearer ${token}` }
262
+ });
263
+ const open_session = (account) => {
264
+ const { cookie } = account.create_session_headers();
265
+ assert.ok(cookie, 'expected a session cookie');
266
+ return create_admitted_ws_transport({ base_url, ws_path, cookies: [cookie], origin });
267
+ };
268
+ /**
269
+ * Open each socket in turn, run `fn` over them, and close every socket
270
+ * that opened — also when a later open fails, so a refused upgrade leaves
271
+ * no socket registered on the shared backend for the rest of the run.
272
+ *
273
+ * Every close is awaited whatever the others do. When the open or `fn`
274
+ * failed, that error propagates and a close error is dropped, so it never
275
+ * hides the case's own failure; when they succeeded, the first close error
276
+ * is thrown, so a transport error surfacing at close still fails the case.
277
+ */
278
+ const with_sockets = async (opens, fn) => {
279
+ const clients = [];
280
+ try {
281
+ for (const open of opens)
282
+ clients.push(await open());
283
+ await fn(...clients);
284
+ }
285
+ catch (error) {
286
+ await Promise.allSettled(clients.map((c) => c.close()));
287
+ throw error;
288
+ }
289
+ const closes = await Promise.allSettled(clients.map((c) => c.close()));
290
+ for (const close of closes) {
291
+ if (close.status === 'rejected')
292
+ throw close.reason;
293
+ }
294
+ };
232
295
  // Per-message dispatch never re-checks credential validity, so a live
233
296
  // socket drops only when a revocation closes it. Revoke the keeper's
234
297
  // sessions over its own session channel → the handler's post-commit
@@ -248,8 +311,7 @@ export const describe_cross_process_ws_tests = (options) => {
248
311
  await client.request(1, heartbeat_action_spec.method, {});
249
312
  const res = await fixture.transport(rpc_path, create_rpc_post_init(account_session_revoke_all_action_spec.method));
250
313
  assert.strictEqual(res.status, 200, `account_session_revoke_all RPC failed (status=${res.status})`);
251
- const closed = await client.wait_for_close(2000);
252
- assert.ok(closed, 'socket did not close within 2s after session_revoke_all');
314
+ await assert_revoked_close(client, 'account_session_revoke_all');
253
315
  }
254
316
  finally {
255
317
  await client.close();
@@ -273,22 +335,80 @@ export const describe_cross_process_ws_tests = (options) => {
273
335
  account_id: target.account.id
274
336
  }));
275
337
  assert.strictEqual(res.status, 200, `account_delete RPC failed (status=${res.status})`);
276
- const closed = await client.wait_for_close(2000);
277
- assert.ok(closed, 'socket did not close within 2s after account_delete');
278
- assert.strictEqual(client.close_code, WS_CLOSE_SESSION_REVOKED);
338
+ await assert_revoked_close(client, 'account_delete');
279
339
  }
280
340
  finally {
281
341
  await client.close();
282
342
  }
283
343
  });
284
- // A socket that revokes the session it is running on. It must be closed;
285
- // and where the backend declares `ws_self_revocation_reply`, the request
286
- // is answered first, the close following from behind the revocation's
344
+ // The rest of the revocation table (docs/security.md §Closing on
345
+ // Revocation), one case per revocation, each closing a socket of a second
346
+ // account with 4001 "Session revoked".
347
+ /** Mint a token for `account` over its own session. */
348
+ const mint_token = async (fixture, account, name) => expect_output(await cross_rpc_call(fixture.fresh_transport(), rpc_path, account_token_create_action_spec.method, { name, scope: { kind: 'full' }, lifetime: { kind: 'eternal' } }, account.create_session_headers()), TokenCreateOutput);
349
+ // `account_token_revoke` closes the revoked token's connections and no
350
+ // others: a socket on the account's other token stays open.
351
+ test_if(capabilities.ws && rpc_path !== undefined, "account_token_revoke closes the revoked token's bearer socket only", async () => {
352
+ const fixture = await setup_test();
353
+ const target = await fixture.create_account({ username: 'ws_token_revoke' });
354
+ const minted = await mint_token(fixture, target, 'ws revoke probe');
355
+ await with_sockets([() => open_bearer(minted.token), () => open_bearer(target.api_token)], async (revoked, other) => {
356
+ const res = expect_output(await cross_rpc_call(fixture.fresh_transport(), rpc_path, account_token_revoke_action_spec.method, { token_id: minted.id }, target.create_session_headers()), TokenRevokeOutput);
357
+ assert.strictEqual(res.revoked, true);
358
+ await assert_revoked_close(revoked, 'account_token_revoke');
359
+ // the account's other token keeps its socket
360
+ assert.deepStrictEqual(await other.request(2, heartbeat_action_spec.method, {}), {});
361
+ assert.strictEqual(other.close_code, null);
362
+ });
363
+ });
364
+ // The account-wide revocations (`revocation_drivers.ts`, shared with the
365
+ // SSE suite) close the account's connections — its bearer socket, and its
366
+ // session socket too.
367
+ // Without `rpc_path` the rows still register, as skipped cases, and never
368
+ // run — so the empty path is never sent.
369
+ const revocations = create_account_revocations({
370
+ capabilities,
371
+ rpc_path: rpc_path ?? '',
372
+ logout_path,
373
+ password_path
374
+ });
375
+ for (const { label, slug, gate, revoke } of revocations) {
376
+ test_if(capabilities.ws && gate && rpc_path !== undefined, `${label} closes the account's bearer and session sockets`, async () => {
377
+ const fixture = await setup_test();
378
+ const target = await fixture.create_account({
379
+ username: `ws_revoke_${slug}`,
380
+ password_value: DEFAULT_TEST_PASSWORD
381
+ });
382
+ await with_sockets([() => open_bearer(target.api_token), () => open_session(target)], async (bearer, session) => {
383
+ await revoke(fixture, target);
384
+ await assert_revoked_close(bearer, `${label} (bearer socket)`);
385
+ await assert_revoked_close(session, `${label} (session socket)`);
386
+ });
387
+ });
388
+ }
389
+ // `max_tokens` further mints leave the watched token outside the newest
390
+ // `max_tokens`, so the cap evicts it whatever tokens the account held
391
+ // before it, and its socket closes once the evicting mint commits.
392
+ test_if(capabilities.ws && rpc_path !== undefined && max_tokens !== null, 'a token the cap evicts has its bearer socket closed', async () => {
393
+ const fixture = await setup_test();
394
+ const target = await fixture.create_account({ username: 'ws_token_cap' });
395
+ const watched = await mint_token(fixture, target, 'ws cap victim');
396
+ const client = await open_bearer(watched.token);
397
+ try {
398
+ for (let i = 0; i < max_tokens; i++) {
399
+ await mint_token(fixture, target, `ws cap filler ${i}`);
400
+ }
401
+ await assert_revoked_close(client, `${max_tokens} further token mints`);
402
+ }
403
+ finally {
404
+ await client.close();
405
+ }
406
+ });
407
+ // A socket that revokes the session it is running on. Its request is
408
+ // answered first, the close following from behind the revocation's
287
409
  // commit. A caller left with only the close cannot tell a completed
288
410
  // revocation from a dropped connection.
289
- test_if(capabilities.ws && capabilities.ws_account_actions, capabilities.ws_self_revocation_reply
290
- ? 'a socket that revokes its own sessions reads the reply, then the revocation close'
291
- : 'a socket that revokes its own sessions is closed with the revocation code', async () => {
411
+ test_if(capabilities.ws && capabilities.ws_account_actions, 'a socket that revokes its own sessions reads the reply, then the revocation close', async () => {
292
412
  const client = await open_authed();
293
413
  try {
294
414
  await client.request(1, heartbeat_action_spec.method, {});
@@ -298,19 +418,15 @@ export const describe_cross_process_ws_tests = (options) => {
298
418
  id: revoke_id,
299
419
  method: account_session_revoke_all_action_spec.method
300
420
  });
301
- const closed = await client.wait_for_close(2000);
302
- assert.ok(closed, 'socket did not close within 2s of revoking its own sessions');
303
- assert.strictEqual(client.close_code, WS_CLOSE_SESSION_REVOKED);
304
- if (capabilities.ws_self_revocation_reply) {
305
- // frames arrive in wire order and nothing arrives after the
306
- // close, so a reply in hand was written ahead of it
307
- const reply = client.messages.find(is_response_for(revoke_id));
308
- assert.ok(reply, 'the reply must reach the caller before the close');
309
- assert.ok('result' in reply, JSON.stringify(reply));
310
- const result = reply.result;
311
- assert.strictEqual(result.ok, true);
312
- assert.ok((result.count ?? 0) >= 1, "at least the socket's own session was revoked");
313
- }
421
+ await assert_revoked_close(client, 'revoking its own sessions');
422
+ // frames arrive in wire order and nothing arrives after the
423
+ // close, so a reply in hand was written ahead of it
424
+ const reply = client.messages.find(is_response_for(revoke_id));
425
+ assert.ok(reply, 'the reply must reach the caller before the close');
426
+ assert.ok('result' in reply, JSON.stringify(reply));
427
+ const result = reply.result;
428
+ assert.strictEqual(result.ok, true);
429
+ assert.ok((result.count ?? 0) >= 1, "at least the socket's own session was revoked");
314
430
  }
315
431
  finally {
316
432
  await client.close();
@@ -156,8 +156,8 @@
156
156
  position: sticky;
157
157
  top: 0;
158
158
  z-index: 1;
159
- background: var(--shade_00, Canvas);
160
- border-bottom: var(--border_width, 1px) solid var(--border_color);
159
+ background: var(--shade_00);
160
+ border-bottom: var(--border_width) solid var(--border_color);
161
161
  }
162
162
 
163
163
  .datatable-header-cell {
@@ -187,7 +187,7 @@
187
187
  grid-template-columns: subgrid;
188
188
  align-items: start;
189
189
  min-width: 0; /* override grid auto minimum to respect column widths */
190
- border-bottom: var(--border_width, 1px) solid var(--border_color);
190
+ border-bottom: var(--border_width) solid var(--border_color);
191
191
  }
192
192
 
193
193
  .datatable-cell {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fuzdev/fuz_app",
3
- "version": "0.145.0",
3
+ "version": "0.146.1",
4
4
  "description": "fullstack app library",
5
5
  "glyph": "🗝",
6
6
  "logo": "logo.svg",