@fuzdev/fuz_app 0.145.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 (59) 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/package.json +1 -1
@@ -14,7 +14,11 @@ import '../assert_dev_env.js';
14
14
  * though the identical bytes are world-readable via B's cell (one deduped
15
15
  * `fact` row; authz lives on the `(cell, hash)` edge, never unioned);
16
16
  * - **404-mask** — a missing cell and a viewable cell that doesn't reference
17
- * the hash both 404 (never 403, never "exists elsewhere");
17
+ * the hash both 404 (never 403, never "exists elsewhere"), and every masked
18
+ * 404 is bodyless, so its shape names no branch;
19
+ * - **hardening headers** — a served fact carries `X-Content-Type-Options:
20
+ * nosniff`, the `default-src 'none'; sandbox` CSP, and `Cache-Control:
21
+ * private, max-age=300`, whatever its content type;
18
22
  * - **bare-hash admin-only** — `GET /api/facts/:hash` is admin (keeper) only:
19
23
  * non-admin → 403, anonymous → 401;
20
24
  * - **multi-actor fallthrough** — a multi-actor caller resolves to a null
@@ -22,7 +26,10 @@ import '../assert_dev_env.js';
22
26
  * read its own *private* fact there (admitted only by public cells). Opt-in
23
27
  * (needs the multi-actor setup); every spine resolves the acting actor at the
24
28
  * dispatcher's authorization phase from account-grain credentials, so the
25
- * multi-actor account is drivable on TS and Rust alike.
29
+ * multi-actor account is drivable on TS and Rust alike;
30
+ * - **narrowed-token downgrade** — a scope-narrowed API token on the
31
+ * cell-scoped route is anonymous, not refused: its owner's private fact 404s
32
+ * exactly as for an anonymous caller, while a public cell still serves.
26
33
  *
27
34
  * Facts are seeded **embedded** via `_testing_put_fact` (the cross-process
28
35
  * driver has no DB handle); the referencing cell via the `cell_create` RPC
@@ -40,8 +47,10 @@ import { describe, assert } from 'vitest';
40
47
  import { z } from 'zod';
41
48
  import { FactHashSchema } from '../../primitive_schemas.js';
42
49
  import { CellCreateOutput } from '../../auth/cell_action_specs.js';
50
+ import { FACT_CACHE_CONTROL, FACT_CONTENT_SECURITY_POLICY, FACT_X_CONTENT_TYPE_OPTIONS } from '../../server/fact_security_headers.js';
43
51
  import { test_if } from './capabilities.js';
44
52
  import { cross_rpc_call, expect_output } from './cell_cross_helpers.js';
53
+ import { mint_narrowed_api_token } from './narrowed_api_token.js';
45
54
  import { SPINE_RPC_PATH } from './spine_surface_constants.js';
46
55
  /** A blake3 hash that is a valid form but references no seeded fact. */
47
56
  const UNRELATED_HASH = `blake3:${'0'.repeat(64)}`;
@@ -70,15 +79,37 @@ export const cross_put_fact = async (fixture, rpc_path, input) => expect_output(
70
79
  // credential when an `Origin` / `Referer` is present (browser
71
80
  // context), and cross-process `fixture.transport` auto-adds `Origin`.
72
81
  fixture.fresh_transport({ origin: null }), rpc_path, '_testing_put_fact', input, fixture.create_daemon_token_headers()), TestingPutFactOutput).hash;
73
- /** GET a fact over a cross-process `FetchTransport`; returns `{status, content_type, text}`. */
82
+ /** GET a fact over a cross-process `FetchTransport`; returns `{status, content_type, text, headers}`. */
74
83
  const fact_get = async (transport, path, headers) => {
75
84
  const res = await transport(path, { method: 'GET', headers });
76
85
  return {
77
86
  status: res.status,
78
87
  content_type: res.headers.get('content-type'),
79
- text: await res.text()
88
+ text: await res.text(),
89
+ headers: res.headers
80
90
  };
81
91
  };
92
+ /**
93
+ * Assert a served fact carries the hardening headers — `nosniff`, the
94
+ * sandboxing CSP — and the private cache policy. A spine that dropped either
95
+ * hardening header would serve a user-typed `content_type` (or a sniffable
96
+ * octet-stream) as executable HTML.
97
+ */
98
+ const assert_fact_headers = (headers, label) => {
99
+ assert.strictEqual(headers.get('x-content-type-options'), FACT_X_CONTENT_TYPE_OPTIONS, `${label}: X-Content-Type-Options`);
100
+ assert.strictEqual(headers.get('content-security-policy'), FACT_CONTENT_SECURITY_POLICY, `${label}: Content-Security-Policy`);
101
+ assert.strictEqual(headers.get('cache-control'), FACT_CACHE_CONTROL, `${label}: Cache-Control`);
102
+ };
103
+ /**
104
+ * Assert a 404 is the masked one: no body, so the response says nothing about
105
+ * which check refused it.
106
+ */
107
+ const assert_masked_404 = (got, label) => {
108
+ assert.strictEqual(got.status, 404, `${label} did not 404`);
109
+ assert.strictEqual(got.text, '', `${label}: a masked 404 has no body`);
110
+ // TODO: the content type isn't pinned — the Node TS spine answers the
111
+ // bodyless 404 with `text/plain; charset=UTF-8`, Deno, Bun and Rust with none
112
+ };
82
113
  export const describe_fact_serving_cross_tests = (options) => {
83
114
  const { setup_test, setup_test_multi_actor, capabilities } = options;
84
115
  const rpc_path = options.rpc_path ?? SPINE_RPC_PATH;
@@ -105,6 +136,7 @@ export const describe_fact_serving_cross_tests = (options) => {
105
136
  assert.strictEqual(got.text, 'public-fact-bytes');
106
137
  // The served `Content-Type` echoes the seeded value on both backends.
107
138
  assert.strictEqual(got.content_type, 'text/plain', 'served content-type drifted');
139
+ assert_fact_headers(got.headers, 'cell-scoped text/plain fact');
108
140
  });
109
141
  test_if(capabilities.fact_serving, 'cross-owner dedup does not leak: A’s private reference stays 404 while B publishes the same bytes', async () => {
110
142
  const fixture = await setup_test();
@@ -129,12 +161,13 @@ export const describe_fact_serving_cross_tests = (options) => {
129
161
  assert.strictEqual(via_b.status, 200, `B's public cell should serve the fact: ${via_b.text}`);
130
162
  // Seeded without a content_type → both backends fall back to octet-stream.
131
163
  assert.strictEqual(via_b.content_type, 'application/octet-stream', 'no-content-type fact should serve as octet-stream');
164
+ assert_fact_headers(via_b.headers, 'octet-stream fact');
132
165
  // A's PRIVATE reference must NOT leak — to anon or to B — even though
133
166
  // the identical bytes are world-readable via B's public cell.
134
167
  const a_via_anon = await fact_get(anon, cell_fact_path(a_cell, hash), {});
135
- assert.strictEqual(a_via_anon.status, 404, "A's private reference leaked to anon");
168
+ assert_masked_404(a_via_anon, "A's private reference read by anon");
136
169
  const a_via_b = await fact_get(t, cell_fact_path(a_cell, hash), b.create_session_headers());
137
- assert.strictEqual(a_via_b.status, 404, "A's private reference leaked to B");
170
+ assert_masked_404(a_via_b, "A's private reference read by B");
138
171
  // A can read its own private cell's fact.
139
172
  const a_via_a = await fact_get(t, cell_fact_path(a_cell, hash), a.create_session_headers());
140
173
  assert.strictEqual(a_via_a.status, 200, 'A could not read its own private fact');
@@ -146,29 +179,33 @@ export const describe_fact_serving_cross_tests = (options) => {
146
179
  const admin_headers = fixture.create_session_headers();
147
180
  // Missing cell → 404.
148
181
  const missing = await fact_get(t, cell_fact_path(NIL_UUID, hash), admin_headers);
149
- assert.strictEqual(missing.status, 404, 'missing cell did not 404');
182
+ assert_masked_404(missing, 'missing cell');
150
183
  // A viewable (keeper-owned, public) cell that references a DIFFERENT
151
184
  // hash → 404 (missing cell→fact edge), even though the caller can view
152
185
  // the cell. `path` is admin-only and the keeper is admin.
153
186
  const unrelated_cell = await create_cell_with_ref(t, admin_headers, UNRELATED_HASH, 'public');
154
187
  const no_edge = await fact_get(t, cell_fact_path(unrelated_cell, hash), admin_headers);
155
- assert.strictEqual(no_edge.status, 404, 'cell without the edge did not 404');
188
+ assert_masked_404(no_edge, 'cell without the edge');
156
189
  // Edge present but no `fact` row: the same cell DOES reference
157
190
  // `UNRELATED_HASH`, which was never seeded. Cell + edge + view all
158
191
  // pass, so this exercises the distinct serve-time "metadata missing"
159
192
  // 404 branch — masked identically to the authz 404s above.
160
193
  const edge_no_bytes = await fact_get(t, cell_fact_path(unrelated_cell, UNRELATED_HASH), admin_headers);
161
- assert.strictEqual(edge_no_bytes.status, 404, 'edge to an absent fact did not 404');
194
+ assert_masked_404(edge_no_bytes, 'edge to an absent fact');
162
195
  });
163
196
  test_if(capabilities.fact_serving, 'bare-hash route is admin-only', async () => {
164
197
  const fixture = await setup_test();
165
198
  const non_admin = await fixture.create_account({ username: 'fact_non_admin' });
166
- const hash = await put_fact(fixture, 'bare-hash-bytes');
199
+ // stored as `text/html` — the type a dropped hardening header would let
200
+ // execute in the admin's session
201
+ const hash = await put_fact(fixture, 'bare-hash-bytes', 'text/html');
167
202
  const t = fixture.fresh_transport();
168
- // keeper holds ROLE_ADMIN → 200 + the actual bytes.
203
+ // keeper holds ROLE_ADMIN → 200 + the actual bytes, hardened.
169
204
  const as_admin = await fact_get(t, `/api/facts/${hash}`, fixture.create_session_headers());
170
205
  assert.strictEqual(as_admin.status, 200, `admin bare-hash read failed: ${as_admin.text}`);
171
206
  assert.strictEqual(as_admin.text, 'bare-hash-bytes', 'bare-hash route served wrong bytes');
207
+ assert.strictEqual(as_admin.content_type, 'text/html', 'served content-type drifted');
208
+ assert_fact_headers(as_admin.headers, 'bare-hash text/html fact');
172
209
  // non-admin → 403.
173
210
  const as_non_admin = await fact_get(t, `/api/facts/${hash}`, non_admin.create_session_headers());
174
211
  assert.strictEqual(as_non_admin.status, 403, 'non-admin reached the bare-hash route');
@@ -177,6 +214,42 @@ export const describe_fact_serving_cross_tests = (options) => {
177
214
  const as_anon = await fact_get(anon, `/api/facts/${hash}`, {});
178
215
  assert.strictEqual(as_anon.status, 401, 'anon reached the bare-hash route');
179
216
  });
217
+ // A narrowed token reaches no non-RPC surface; on this anonymous-admitting
218
+ // route it is downgraded to anonymous rather than refused (a 403 would
219
+ // enforce nothing — the holder drops the header). So the owner's narrowed
220
+ // token reads exactly what an anonymous caller reads: its private fact
221
+ // 404s, a public cell still serves. The owner's full-scope bearer reading
222
+ // the private fact pins that the 404 is the scope, not the bearer.
223
+ test_if(capabilities.fact_serving, 'narrowed api token is treated as anonymous on the cell-scoped route', async () => {
224
+ const fixture = await setup_test();
225
+ const owner = await fixture.create_account({ username: 'fact_narrowed_owner' });
226
+ const hash = await cross_put_fact(fixture, rpc_path, {
227
+ content: 'narrowed-token-bytes',
228
+ holder: owner.actor.id
229
+ });
230
+ const t = fixture.fresh_transport();
231
+ const session = owner.create_session_headers();
232
+ const private_cell = await create_cell_with_ref(t, session, hash, 'private');
233
+ const public_cell = await create_cell_with_ref(t, session, hash, 'public');
234
+ const token = await mint_narrowed_api_token({
235
+ app: fixture.transport,
236
+ rpc_path,
237
+ session_headers: session,
238
+ methods: ['cell_get']
239
+ });
240
+ // no `Origin` — a bearer in a browser context is discarded
241
+ const bare = fixture.fresh_transport({ origin: null });
242
+ const narrowed = { authorization: `Bearer ${token}` };
243
+ const full = await fact_get(bare, cell_fact_path(private_cell, hash), owner.create_bearer_headers());
244
+ assert.strictEqual(full.status, 200, `full-scope bearer read failed: ${full.text}`);
245
+ const anon_private = await fact_get(bare, cell_fact_path(private_cell, hash), {});
246
+ const narrowed_private = await fact_get(bare, cell_fact_path(private_cell, hash), narrowed);
247
+ assert_masked_404(anon_private, 'anon read of a private fact');
248
+ assert_masked_404(narrowed_private, 'narrowed-token read of a private fact');
249
+ const narrowed_public = await fact_get(bare, cell_fact_path(public_cell, hash), narrowed);
250
+ assert.strictEqual(narrowed_public.status, 200, 'narrowed token refused a public cell');
251
+ assert.strictEqual(narrowed_public.text, 'narrowed-token-bytes');
252
+ });
180
253
  // The cell-scoped route is pure-public — no `acting?` slot — so the handler
181
254
  // can't disambiguate a multi-actor account and resolves it to a null
182
255
  // (anonymous) context. This pins that fallthrough: a multi-actor caller is
@@ -201,7 +274,7 @@ export const describe_fact_serving_cross_tests = (options) => {
201
274
  // Owns a PRIVATE cell, yet reading its own fact back resolves to a null
202
275
  // (anonymous) context → 404. The owner path never runs.
203
276
  const own_private = await fact_get(t, cell_fact_path(private_cell, hash), keeper);
204
- assert.strictEqual(own_private.status, 404, "multi-actor owner's private read was admitted");
277
+ assert_masked_404(own_private, "multi-actor owner's private read");
205
278
  // A PUBLIC cell still admits the anonymous-treated caller → 200, proving
206
279
  // the 404 above is the multi-actor fallthrough, not a blanket block.
207
280
  const via_public = await fact_get(t, cell_fact_path(public_cell, hash), keeper);
@@ -8,6 +8,17 @@ export interface IdentityParityCrossTestOptions {
8
8
  readonly login_path?: string;
9
9
  /** REST signup route path. Default `/api/account/signup` (the spine convention). */
10
10
  readonly signup_path?: string;
11
+ /**
12
+ * REST bootstrap route path. Default `/api/account/bootstrap` (the spine
13
+ * convention); `null` skips the bootstrap case, for a surface that mounts no
14
+ * bootstrap route.
15
+ */
16
+ readonly bootstrap_path?: string | null;
17
+ /**
18
+ * REST password-change route path, for the malformed-body case. Default
19
+ * `/api/account/password` (the spine convention); `null` skips that case.
20
+ */
21
+ readonly password_path?: string | null;
11
22
  }
12
23
  export declare const describe_identity_parity_cross_tests: (options: IdentityParityCrossTestOptions) => void;
13
24
  //# sourceMappingURL=identity_parity.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"identity_parity.d.ts","sourceRoot":"../src/lib/","sources":["../../../src/lib/testing/cross_backend/identity_parity.ts"],"names":[],"mappings":"AAAA,OAAO,sBAAsB,CAAC;AAmG9B,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,YAAY,CAAC;AAkB5C,uDAAuD;AACvD,MAAM,WAAW,8BAA8B;IAC9C,+DAA+D;IAC/D,QAAQ,CAAC,UAAU,EAAE,SAAS,CAAC;IAC/B,kFAAkF;IAClF,QAAQ,CAAC,UAAU,CAAC,EAAE,MAAM,CAAC;IAC7B,oFAAoF;IACpF,QAAQ,CAAC,WAAW,CAAC,EAAE,MAAM,CAAC;CAC9B;AA8JD,eAAO,MAAM,oCAAoC,GAChD,SAAS,8BAA8B,KACrC,IA6TF,CAAC"}
1
+ {"version":3,"file":"identity_parity.d.ts","sourceRoot":"../src/lib/","sources":["../../../src/lib/testing/cross_backend/identity_parity.ts"],"names":[],"mappings":"AAAA,OAAO,sBAAsB,CAAC;AAoH9B,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,YAAY,CAAC;AAmB5C,uDAAuD;AACvD,MAAM,WAAW,8BAA8B;IAC9C,+DAA+D;IAC/D,QAAQ,CAAC,UAAU,EAAE,SAAS,CAAC;IAC/B,kFAAkF;IAClF,QAAQ,CAAC,UAAU,CAAC,EAAE,MAAM,CAAC;IAC7B,oFAAoF;IACpF,QAAQ,CAAC,WAAW,CAAC,EAAE,MAAM,CAAC;IAC9B;;;;OAIG;IACH,QAAQ,CAAC,cAAc,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IACxC;;;OAGG;IACH,QAAQ,CAAC,aAAa,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;CACvC;AA8JD,eAAO,MAAM,oCAAoC,GAChD,SAAS,8BAA8B,KACrC,IAieF,CAAC"}
@@ -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
+ };