@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
@@ -25,7 +25,9 @@ import '../assert_dev_env.js';
25
25
  * all-uppercase submission (proves the *lookup* folds case, not merely that
26
26
  * the stored form was lowercased).
27
27
  * - **whitespace-trim login** — the same shape logs in with surrounding
28
- * whitespace (proves `.trim()` on the lookup path).
28
+ * whitespace (proves `.trim()` on the lookup path). The trimmed set is JS
29
+ * `String.prototype.trim`'s on every spine: U+FEFF padding is trimmed (→
30
+ * 200, and alone → 400 empty), U+0085 is not (→ 401 lookup miss).
29
31
  * - **no Unicode case-fold collision (negative)** — a Turkish-`İ` (U+0130)
30
32
  * variant of an existing ASCII username must NOT match. Both JS
31
33
  * `.toLowerCase()` and Rust `str::to_lowercase()` map `İ` → `i` + U+0307
@@ -67,6 +69,10 @@ import '../assert_dev_env.js';
67
69
  * with mid-string `_`/`-` accepted siblings as the don't-over-reject control.
68
70
  * Signup is also strict-object on both spines (TS `z.strictObject`, Rust
69
71
  * `#[serde(deny_unknown_fields)]`), so an unknown signup body key → 400.
72
+ * Nothing trims a creation username: a padded one is refused 400 at signup and
73
+ * at bootstrap, never trimmed into a legal name. Bootstrap validates its body
74
+ * before its window check, so the padded bootstrap answers 400 even after
75
+ * bootstrap completed.
70
76
  *
71
77
  * **Email format (creation):** the optional signup `email` is validated to a
72
78
  * loose `local@domain.tld` shape on both spines — TS `Email`
@@ -78,6 +84,14 @@ import '../assert_dev_env.js';
78
84
  * share. This closed a real divergence: the Rust spine previously
79
85
  * length-checked the email only, accepting any non-empty string TS rejected.
80
86
  *
87
+ * **Malformed JSON body:** a body typed `application/json` that doesn't parse
88
+ * (`{`) or parses to anything but an object (`[]`) is 400 `{error:
89
+ * "invalid_json_body"}`, with no other field, on login, signup, bootstrap
90
+ * (`bootstrap_path`, parsed ahead of the window check), and the password change
91
+ * (`password_path`, default `/api/account/password`, `null` to skip) — parsed
92
+ * only after its credential and session-only gates, so the case sends the
93
+ * keeper's session, and an anonymous unparseable body is still 401.
94
+ *
81
95
  * Both surfaces are flat REST on every spine, so this is an imperative suite
82
96
  * (not a `conformance_table` row) and ungated. Runs both legs via the shared
83
97
  * `{setup_test}` protocol: the in-process leg
@@ -88,8 +102,9 @@ import '../assert_dev_env.js';
88
102
  * @module
89
103
  */
90
104
  import { describe, test, assert } from 'vitest';
91
- import { ERROR_INVALID_CREDENTIALS, ERROR_INVALID_REQUEST_BODY, ERROR_NO_MATCHING_INVITE } from '../../http/error_schemas.js';
105
+ import { ERROR_ALREADY_BOOTSTRAPPED, ERROR_AUTHENTICATION_REQUIRED, ERROR_INVALID_CREDENTIALS, ERROR_INVALID_JSON_BODY, ERROR_INVALID_REQUEST_BODY, ERROR_NO_MATCHING_INVITE } from '../../http/error_schemas.js';
92
106
  import { DEFAULT_TEST_PASSWORD } from '../test_credentials.js';
107
+ import { test_if } from './capabilities.js';
93
108
  /**
94
109
  * The flat-REST `{error}` reason each terminal login/signup status carries on
95
110
  * every spine — the assertion target that turns a status-only check into a
@@ -245,18 +260,29 @@ export const describe_identity_parity_cross_tests = (options) => {
245
260
  const { setup_test } = options;
246
261
  const login_path = options.login_path ?? '/api/account/login';
247
262
  const signup_path = options.signup_path ?? '/api/account/signup';
263
+ const bootstrap_path = options.bootstrap_path === undefined ? '/api/account/bootstrap' : options.bootstrap_path;
264
+ const password_path = options.password_path === undefined ? '/api/account/password' : options.password_path;
248
265
  /**
249
- * POST JSON on a fresh (cookie-jar-free) transport; return the status and the
250
- * parsed flat-REST `{error}` reason (`undefined` for success / non-JSON
251
- * bodies). Always reads the body so the cross-process socket releases.
266
+ * POST a raw body typed `application/json` on a fresh (cookie-jar-free)
267
+ * transport, with the transport's allowed `Origin`; return the status and
268
+ * the body text. Always reads the body so the cross-process socket releases.
252
269
  */
253
- const post_json = async (fixture, path, body) => {
270
+ const post_raw = async (fixture, path, body, headers) => {
254
271
  const res = await fixture.fresh_transport()(path, {
255
272
  method: 'POST',
256
- headers: { 'content-type': 'application/json' },
257
- body: JSON.stringify(body)
273
+ headers: { 'content-type': 'application/json', ...headers },
274
+ body
258
275
  });
259
- const text = await res.text().catch(() => '');
276
+ return { status: res.status, text: await res.text().catch(() => '') };
277
+ };
278
+ /**
279
+ * POST JSON on a fresh (cookie-jar-free) transport; return the status and the
280
+ * parsed flat-REST `{error}` reason (`undefined` for success / non-JSON
281
+ * bodies).
282
+ */
283
+ const post_json = async (fixture, path, body) => {
284
+ const res = await post_raw(fixture, path, JSON.stringify(body));
285
+ const text = res.text;
260
286
  let error;
261
287
  try {
262
288
  const parsed = text ? JSON.parse(text) : undefined;
@@ -311,6 +337,23 @@ export const describe_identity_parity_cross_tests = (options) => {
311
337
  await fixture.create_account({ username: 'Canon_Trim_User' });
312
338
  assert_attempt(await login(fixture, ' canon_trim_user '), 200, 'whitespace-trim login');
313
339
  });
340
+ // JS `trim` and Rust `str::trim` disagree on two code points: U+FEFF is JS
341
+ // whitespace but not Unicode `White_Space`, U+0085 the reverse. Every
342
+ // spine trims the JS set (TS is the oracle).
343
+ test('U+FEFF padding is trimmed on lookup → 200', async () => {
344
+ const fixture = await setup_test();
345
+ await fixture.create_account({ username: 'canon_bom_user' });
346
+ assert_attempt(await login(fixture, '\ufeffcanon_bom_user\ufeff'), 200, 'U+FEFF-padded login');
347
+ });
348
+ test('U+FEFF alone is empty after the trim → 400', async () => {
349
+ const fixture = await setup_test();
350
+ assert_attempt(await login(fixture, '\ufeff'), 400, 'U+FEFF-only login');
351
+ });
352
+ test('U+0085 padding is not trimmed → 401 lookup miss', async () => {
353
+ const fixture = await setup_test();
354
+ await fixture.create_account({ username: 'canon_nel_user' });
355
+ assert_attempt(await login(fixture, '\u0085canon_nel_user\u0085'), 401, 'U+0085-padded login');
356
+ });
314
357
  test('no Unicode case-fold collision — Turkish İ variant of an ASCII username → 401', async () => {
315
358
  const fixture = await setup_test();
316
359
  // `canon_admin_user` carries an ASCII `i`; the probe replaces it with İ
@@ -463,5 +506,77 @@ export const describe_identity_parity_cross_tests = (options) => {
463
506
  unexpected_field: 'x'
464
507
  }), 400, 'signup unknown body key');
465
508
  });
509
+ // `Username` validates the raw value — nothing trims a creation username,
510
+ // so padding is refused, never repaired into a legal name (which would
511
+ // reach the invite gate → 403, or create the account under open signup).
512
+ for (const [username, label] of [
513
+ [' padded_signup_user ', 'space padding'],
514
+ ['padded_signup_user\t', 'trailing tab'],
515
+ ['\u0085padded_signup_user', 'leading U+0085']
516
+ ]) {
517
+ test(`padded username (${label}) → 400, not trimmed`, async () => {
518
+ const fixture = await setup_test();
519
+ assert_attempt(await signup(fixture, username), 400, `signup ${label}`);
520
+ });
521
+ }
522
+ });
523
+ describe('bootstrap input validation parity', () => {
524
+ // The body is validated before the bootstrap window is consulted on every
525
+ // spine (the TS route schema runs ahead of the handler), so a padded
526
+ // username answers 400 `invalid_request_body` even after bootstrap
527
+ // completed — not the closed window's refusal. The unpadded control, the
528
+ // same body otherwise, reaches the closed window, so the 400 can only be
529
+ // the username's.
530
+ test_if(bootstrap_path !== null, 'padded username → 400, not trimmed, ahead of the window check', async () => {
531
+ const fixture = await setup_test();
532
+ const body = {
533
+ token: 'identity-parity-not-the-token',
534
+ username: ' padded_keeper ',
535
+ password: DEFAULT_TEST_PASSWORD
536
+ };
537
+ assert_attempt(await post_json(fixture, bootstrap_path, body), 400, 'bootstrap padded username');
538
+ const control = await post_json(fixture, bootstrap_path, {
539
+ ...body,
540
+ username: 'padded_keeper'
541
+ });
542
+ assert.deepStrictEqual(control, { status: 403, error: ERROR_ALREADY_BOOTSTRAPPED }, 'bootstrap unpadded control');
543
+ });
544
+ });
545
+ describe('malformed JSON body parity', () => {
546
+ // A body typed `application/json` that doesn't parse, or parses to
547
+ // anything but an object, is 400 `invalid_json_body` on every spine —
548
+ // never the schema's `invalid_request_body`, and with no other field.
549
+ // Bootstrap parses ahead of its window check (so the 400 holds after
550
+ // bootstrap completed); the password change parses only after its
551
+ // credential and session-only gates, so it needs a session, and an
552
+ // anonymous caller still hears 401.
553
+ const MALFORMED_BODIES = [
554
+ ['{', 'unparseable'],
555
+ ['[]', 'a non-object array']
556
+ ];
557
+ const assert_invalid_json_body = (got, label) => {
558
+ assert.strictEqual(got.status, 400, `${label} — status: ${got.text}`);
559
+ assert.deepStrictEqual(JSON.parse(got.text), { error: ERROR_INVALID_JSON_BODY }, `${label} — body`);
560
+ };
561
+ const routes = [
562
+ { name: 'login', path: login_path, session: false },
563
+ { name: 'signup', path: signup_path, session: false },
564
+ { name: 'bootstrap', path: bootstrap_path, session: false },
565
+ { name: 'password', path: password_path, session: true }
566
+ ];
567
+ for (const { name, path, session } of routes) {
568
+ for (const [body, label] of MALFORMED_BODIES) {
569
+ test_if(path !== null, `${name}: ${label} body → 400 invalid_json_body`, async () => {
570
+ const fixture = await setup_test();
571
+ assert_invalid_json_body(await post_raw(fixture, path, body, session ? fixture.create_session_headers() : undefined), `${name} ${label} body`);
572
+ });
573
+ }
574
+ }
575
+ test_if(password_path !== null, 'password: an unparseable body without a session → 401, the gates ahead of the parse', async () => {
576
+ const fixture = await setup_test();
577
+ const got = await post_raw(fixture, password_path, '{');
578
+ assert.strictEqual(got.status, 401, `anonymous unparseable password body: ${got.text}`);
579
+ assert.strictEqual(JSON.parse(got.text).error, ERROR_AUTHENTICATION_REQUIRED);
580
+ });
466
581
  });
467
582
  };
@@ -1 +1 @@
1
- {"version":3,"file":"login_security.d.ts","sourceRoot":"../src/lib/","sources":["../../../src/lib/testing/cross_backend/login_security.ts"],"names":[],"mappings":"AAAA,OAAO,sBAAsB,CAAC;AAgF9B,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,YAAY,CAAC;AAK5C,mDAAmD;AACnD,MAAM,WAAW,6BAA6B;IAC7C,2EAA2E;IAC3E,QAAQ,CAAC,UAAU,EAAE,SAAS,CAAC;IAC/B,kFAAkF;IAClF,QAAQ,CAAC,UAAU,CAAC,EAAE,MAAM,CAAC;IAC7B,wEAAwE;IACxE,QAAQ,CAAC,aAAa,CAAC,EAAE,MAAM,CAAC;IAChC;;;;;;OAMG;IACH,QAAQ,CAAC,uBAAuB,CAAC,EAAE,OAAO,CAAC;IAC3C;;;;OAIG;IACH,QAAQ,CAAC,QAAQ,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;CAClC;AAgDD,eAAO,MAAM,mCAAmC,GAC/C,SAAS,6BAA6B,KACpC,IA4UF,CAAC"}
1
+ {"version":3,"file":"login_security.d.ts","sourceRoot":"../src/lib/","sources":["../../../src/lib/testing/cross_backend/login_security.ts"],"names":[],"mappings":"AAAA,OAAO,sBAAsB,CAAC;AAsF9B,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,YAAY,CAAC;AAK5C,mDAAmD;AACnD,MAAM,WAAW,6BAA6B;IAC7C,2EAA2E;IAC3E,QAAQ,CAAC,UAAU,EAAE,SAAS,CAAC;IAC/B,kFAAkF;IAClF,QAAQ,CAAC,UAAU,CAAC,EAAE,MAAM,CAAC;IAC7B,wEAAwE;IACxE,QAAQ,CAAC,aAAa,CAAC,EAAE,MAAM,CAAC;IAChC;;;;;;OAMG;IACH,QAAQ,CAAC,uBAAuB,CAAC,EAAE,OAAO,CAAC;IAC3C;;;;OAIG;IACH,QAAQ,CAAC,QAAQ,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;CAClC;AAmDD,eAAO,MAAM,mCAAmC,GAC/C,SAAS,6BAA6B,KACpC,IA8XF,CAAC"}
@@ -44,6 +44,12 @@ import '../assert_dev_env.js';
44
44
  * after exhausting one forwarded IP, a raw request carrying a spoofed line
45
45
  * first and that IP last still `429`s. Raw socket only (`fetch` merges
46
46
  * repeated headers into one line), so it runs when `base_url` is given.
47
+ * - **a malformed hop stops the walk** — a trusted proxy that appends a
48
+ * non-bare-IP token (`ip:port` here) leaves the client's own entry to its
49
+ * left. After exhausting one forwarded IP, a chain with that IP left of an
50
+ * `ip:port` entry answers `401`, not `429`: the walk stopped at the malformed
51
+ * entry and keyed the request on the connection IP (its audit row says so),
52
+ * never on the client-written entry past it.
47
53
  * - **a success does not refund the per-IP budget** — the distributed-spray
48
54
  * backstop. Failed guesses against *distinct* usernames from one forwarded
49
55
  * IP, interleaved with the attacker's own successful logins, must still 429
@@ -90,6 +96,9 @@ const XFF_SPRAY = '203.0.113.4';
90
96
  const XFF_BURST = '203.0.113.5';
91
97
  const XFF_CORRECT_BURST = '203.0.113.7';
92
98
  const XFF_REPEATED_LINES = '203.0.113.6';
99
+ const XFF_MALFORMED_HOP = '203.0.113.8';
100
+ /** What a trusted hop that appends `ip:port` (an Azure App Gateway, IIS ARR) writes. */
101
+ const XFF_PORT_SUFFIXED_HOP = '198.51.100.7:5555';
93
102
  /** RFC 5737 TEST-NET-2 — the address a client writes into its own first line. */
94
103
  const XFF_SPOOFED_LINE = '198.51.100.77';
95
104
  /**
@@ -193,6 +202,25 @@ export const describe_login_security_cross_tests = (options) => {
193
202
  const fresh = await attempt(fixture, XFF_SEGREGATION_FRESH, 'probe_xff');
194
203
  assert.strictEqual(fresh.status, 401, "a fresh forwarded IP must not inherit another IP's limit");
195
204
  });
205
+ test('a malformed hop stops the walk: the request keys on the connection IP', async () => {
206
+ const fixture = await setup_test();
207
+ for (let i = 0; i < ip_limit; i++) {
208
+ await attempt(fixture, XFF_MALFORMED_HOP, 'probe_malformed_hop');
209
+ }
210
+ const exhausted = await attempt(fixture, XFF_MALFORMED_HOP, 'probe_malformed_hop');
211
+ assert.strictEqual(exhausted.status, 429, 'the exhausted forwarded IP must be rate-limited');
212
+ // The client-written entry sits left of the hop's malformed one. A walk
213
+ // that skipped the malformed entry would key on it again → 429; one
214
+ // that stops keys on the (loopback) connection IP → 401.
215
+ const stopped = await attempt(fixture, `${XFF_MALFORMED_HOP}, ${XFF_PORT_SUFFIXED_HOP}`, 'probe_malformed_hop');
216
+ assert.strictEqual(stopped.status, 401, 'the walk must stop at the malformed hop, not reach the entry left of it');
217
+ const drained = await cross_rpc_call(fixture.fresh_transport({ origin: null }), SPINE_RPC_PATH, '_testing_drain_effects', undefined, fixture.create_daemon_token_headers());
218
+ assert.ok(drained.ok, `_testing_drain_effects failed: ${JSON.stringify(drained.error)}`);
219
+ const listed = expect_output(await cross_rpc_call(fixture.transport, SPINE_RPC_PATH, 'audit_log_list', { event_type: 'login', outcome: 'failure' }, fixture.create_session_headers()), AuditLogListOutput);
220
+ const ips = listed.events.map((e) => e.ip);
221
+ assert.ok(ips.some((ip) => ip === '127.0.0.1' || ip === '::1'), `the stopped walk must key on the loopback connection IP: ${JSON.stringify(ips)}`);
222
+ assert.ok(!ips.includes(XFF_PORT_SUFFIXED_HOP), `a malformed entry is never a client IP: ${JSON.stringify(ips)}`);
223
+ });
196
224
  test('IPv6 clients share a bucket per /64, and the audit row keeps the full address', async () => {
197
225
  const fixture = await setup_test();
198
226
  for (let i = 0; i < ip_limit; i++) {
@@ -0,0 +1,70 @@
1
+ import '../assert_dev_env.js';
2
+ import type { BackendCapabilities } from './capabilities.js';
3
+ import type { TestAccountFixture, TestFixture } from './setup.js';
4
+ /**
5
+ * POST a JSON body to a REST route on `account`'s session, asserting `200`.
6
+ *
7
+ * @param fixture - the per-test fixture whose fresh transport carries the request
8
+ * @param account - the account whose session cookie authenticates it
9
+ * @param path - the REST route path
10
+ * @param body - the JSON request body
11
+ */
12
+ export declare const cross_post_as: (fixture: TestFixture, account: TestAccountFixture, path: string, body: unknown) => Promise<void>;
13
+ /**
14
+ * Revoke every API token of `account_id` over the keeper's admin session
15
+ * (`admin_token_revoke_all`), asserting success.
16
+ *
17
+ * @param fixture - the per-test fixture; its keeper holds the admin role
18
+ * @param rpc_path - the RPC endpoint path
19
+ * @param account_id - the account whose tokens to revoke
20
+ */
21
+ export declare const cross_admin_token_revoke_all: (fixture: TestFixture, rpc_path: string, account_id: string) => Promise<void>;
22
+ /**
23
+ * Purge `account_id` over the keeper's daemon token (`account_purge`),
24
+ * asserting success. The request carries no `Origin`, as a daemon-token
25
+ * caller's wouldn't.
26
+ *
27
+ * @param fixture - the per-test fixture
28
+ * @param rpc_path - the RPC endpoint path
29
+ * @param account_id - the account to purge
30
+ */
31
+ export declare const cross_account_purge: (fixture: TestFixture, rpc_path: string, account_id: string) => Promise<void>;
32
+ /** One account-wide revocation, as a row both transport suites iterate. */
33
+ export interface AccountRevocation {
34
+ /** Names the revocation in a case name and a failure message. */
35
+ readonly label: string;
36
+ /**
37
+ * Distinguishes the target account's username — unique in the table, and
38
+ * short enough that a suite's prefix keeps the username within `Username`.
39
+ */
40
+ readonly slug: string;
41
+ /** Whether the backend serves the revocation (beyond `rpc_path`, which every row needs). */
42
+ readonly gate: boolean;
43
+ /**
44
+ * Revoke `target`'s credentials and assert the revocation succeeded.
45
+ * `target` must have been created with `DEFAULT_TEST_PASSWORD`, which the
46
+ * password-change row presents.
47
+ */
48
+ readonly revoke: (fixture: TestFixture, target: TestAccountFixture) => Promise<void>;
49
+ }
50
+ /** Configuration for `create_account_revocations`. */
51
+ export interface AccountRevocationsOptions {
52
+ /** Backend capability flags; `account_purge` gates on `account_lifecycle`. */
53
+ readonly capabilities: BackendCapabilities;
54
+ /** RPC endpoint path the admin and keeper revocations are sent to. */
55
+ readonly rpc_path: string;
56
+ /** REST logout route path. */
57
+ readonly logout_path: string;
58
+ /** REST password-change route path. */
59
+ readonly password_path: string;
60
+ }
61
+ /**
62
+ * The account-wide revocations that close every connection of the target
63
+ * account: `admin_token_revoke_all`, `POST /logout`, `POST /password`, and
64
+ * `account_purge` (gated on `capabilities.account_lifecycle`).
65
+ *
66
+ * @param options - the backend's capabilities and route paths
67
+ * @returns the revocation rows, in a stable order
68
+ */
69
+ export declare const create_account_revocations: (options: AccountRevocationsOptions) => ReadonlyArray<AccountRevocation>;
70
+ //# sourceMappingURL=revocation_drivers.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"revocation_drivers.d.ts","sourceRoot":"../src/lib/","sources":["../../../src/lib/testing/cross_backend/revocation_drivers.ts"],"names":[],"mappings":"AAAA,OAAO,sBAAsB,CAAC;AA4B9B,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,mBAAmB,CAAC;AAE7D,OAAO,KAAK,EAAE,kBAAkB,EAAE,WAAW,EAAE,MAAM,YAAY,CAAC;AAElE;;;;;;;GAOG;AACH,eAAO,MAAM,aAAa,GACzB,SAAS,WAAW,EACpB,SAAS,kBAAkB,EAC3B,MAAM,MAAM,EACZ,MAAM,OAAO,KACX,OAAO,CAAC,IAAI,CAOd,CAAC;AAEF;;;;;;;GAOG;AACH,eAAO,MAAM,4BAA4B,GACxC,SAAS,WAAW,EACpB,UAAU,MAAM,EAChB,YAAY,MAAM,KAChB,OAAO,CAAC,IAAI,CASd,CAAC;AAEF;;;;;;;;GAQG;AACH,eAAO,MAAM,mBAAmB,GAC/B,SAAS,WAAW,EACpB,UAAU,MAAM,EAChB,YAAY,MAAM,KAChB,OAAO,CAAC,IAAI,CASd,CAAC;AAEF,2EAA2E;AAC3E,MAAM,WAAW,iBAAiB;IACjC,iEAAiE;IACjE,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IACvB;;;OAGG;IACH,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,4FAA4F;IAC5F,QAAQ,CAAC,IAAI,EAAE,OAAO,CAAC;IACvB;;;;OAIG;IACH,QAAQ,CAAC,MAAM,EAAE,CAAC,OAAO,EAAE,WAAW,EAAE,MAAM,EAAE,kBAAkB,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;CACrF;AAED,sDAAsD;AACtD,MAAM,WAAW,yBAAyB;IACzC,8EAA8E;IAC9E,QAAQ,CAAC,YAAY,EAAE,mBAAmB,CAAC;IAC3C,sEAAsE;IACtE,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B,8BAA8B;IAC9B,QAAQ,CAAC,WAAW,EAAE,MAAM,CAAC;IAC7B,uCAAuC;IACvC,QAAQ,CAAC,aAAa,EAAE,MAAM,CAAC;CAC/B;AAED;;;;;;;GAOG;AACH,eAAO,MAAM,0BAA0B,GACtC,SAAS,yBAAyB,KAChC,aAAa,CAAC,iBAAiB,CAiCjC,CAAC"}
@@ -0,0 +1,104 @@
1
+ import '../assert_dev_env.js';
2
+ /**
3
+ * The account-wide revocations the cross-process WS and SSE round-trip suites
4
+ * both close a live connection on — one table, so a revocation added here is
5
+ * covered on both transports.
6
+ *
7
+ * Each row drives one revocation of a target account's credentials and asserts
8
+ * it succeeded; the suite opens the target's connections first and asserts
9
+ * their close after. `describe_cross_process_ws_tests` and
10
+ * `describe_cross_process_sse_tests` iterate `create_account_revocations`. The
11
+ * revocations whose close is narrower than the account (one session, one
12
+ * token, a cap eviction) or that a suite drives differently (session
13
+ * revoke-all, account delete) stay in their suites.
14
+ *
15
+ * The drivers are exported on their own for a consumer suite that closes a
16
+ * transport of its own on the same revocations.
17
+ *
18
+ * @module
19
+ */
20
+ import { assert } from 'vitest';
21
+ import { account_purge_action_spec, admin_token_revoke_all_action_spec } from '../../auth/admin_action_specs.js';
22
+ import { DEFAULT_TEST_PASSWORD } from '../test_credentials.js';
23
+ import { cross_rpc_call } from './cell_cross_helpers.js';
24
+ /**
25
+ * POST a JSON body to a REST route on `account`'s session, asserting `200`.
26
+ *
27
+ * @param fixture - the per-test fixture whose fresh transport carries the request
28
+ * @param account - the account whose session cookie authenticates it
29
+ * @param path - the REST route path
30
+ * @param body - the JSON request body
31
+ */
32
+ export const cross_post_as = async (fixture, account, path, body) => {
33
+ const res = await fixture.fresh_transport()(path, {
34
+ method: 'POST',
35
+ headers: { 'content-type': 'application/json', ...account.create_session_headers() },
36
+ body: JSON.stringify(body)
37
+ });
38
+ assert.strictEqual(res.status, 200, `POST ${path}: ${await res.text()}`);
39
+ };
40
+ /**
41
+ * Revoke every API token of `account_id` over the keeper's admin session
42
+ * (`admin_token_revoke_all`), asserting success.
43
+ *
44
+ * @param fixture - the per-test fixture; its keeper holds the admin role
45
+ * @param rpc_path - the RPC endpoint path
46
+ * @param account_id - the account whose tokens to revoke
47
+ */
48
+ export const cross_admin_token_revoke_all = async (fixture, rpc_path, account_id) => {
49
+ const res = await cross_rpc_call(fixture.transport, rpc_path, admin_token_revoke_all_action_spec.method, { account_id }, fixture.create_session_headers());
50
+ assert.ok(res.ok, `admin_token_revoke_all failed: ${JSON.stringify(res.error)}`);
51
+ };
52
+ /**
53
+ * Purge `account_id` over the keeper's daemon token (`account_purge`),
54
+ * asserting success. The request carries no `Origin`, as a daemon-token
55
+ * caller's wouldn't.
56
+ *
57
+ * @param fixture - the per-test fixture
58
+ * @param rpc_path - the RPC endpoint path
59
+ * @param account_id - the account to purge
60
+ */
61
+ export const cross_account_purge = async (fixture, rpc_path, account_id) => {
62
+ const res = await cross_rpc_call(fixture.fresh_transport({ origin: null }), rpc_path, account_purge_action_spec.method, { account_id, confirm: true }, fixture.create_daemon_token_headers());
63
+ assert.ok(res.ok, `account_purge failed: ${JSON.stringify(res.error)}`);
64
+ };
65
+ /**
66
+ * The account-wide revocations that close every connection of the target
67
+ * account: `admin_token_revoke_all`, `POST /logout`, `POST /password`, and
68
+ * `account_purge` (gated on `capabilities.account_lifecycle`).
69
+ *
70
+ * @param options - the backend's capabilities and route paths
71
+ * @returns the revocation rows, in a stable order
72
+ */
73
+ export const create_account_revocations = (options) => {
74
+ const { capabilities, rpc_path, logout_path, password_path } = options;
75
+ return [
76
+ {
77
+ label: 'admin_token_revoke_all',
78
+ slug: 'token_revoke_all',
79
+ gate: true,
80
+ revoke: (fixture, target) => cross_admin_token_revoke_all(fixture, rpc_path, target.account.id)
81
+ },
82
+ {
83
+ label: 'POST /logout',
84
+ slug: 'logout',
85
+ gate: true,
86
+ revoke: (fixture, target) => cross_post_as(fixture, target, logout_path, {})
87
+ },
88
+ {
89
+ label: 'POST /password',
90
+ slug: 'password',
91
+ gate: true,
92
+ revoke: (fixture, target) => cross_post_as(fixture, target, password_path, {
93
+ current_password: DEFAULT_TEST_PASSWORD,
94
+ new_password: 'revoked-new-password-456'
95
+ })
96
+ },
97
+ {
98
+ label: 'account_purge',
99
+ slug: 'purge',
100
+ gate: capabilities.account_lifecycle,
101
+ revoke: (fixture, target) => cross_account_purge(fixture, rpc_path, target.account.id)
102
+ }
103
+ ];
104
+ };
@@ -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.