@fuzdev/fuz_app 0.92.0 → 0.94.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 (203) hide show
  1. package/README.md +2 -2
  2. package/dist/actions/action_codegen.d.ts +18 -18
  3. package/dist/actions/action_codegen.js +30 -30
  4. package/dist/actions/action_event.js +1 -1
  5. package/dist/actions/action_event_helpers.d.ts +1 -1
  6. package/dist/actions/action_event_types.d.ts +1 -1
  7. package/dist/actions/action_rpc.d.ts +2 -2
  8. package/dist/actions/broadcast_api.d.ts +1 -1
  9. package/dist/actions/broadcast_api.js +1 -1
  10. package/dist/actions/perform_action.d.ts +2 -2
  11. package/dist/actions/register_action_ws.d.ts +2 -2
  12. package/dist/actions/register_action_ws.js +2 -2
  13. package/dist/actions/register_ws_endpoint.js +1 -1
  14. package/dist/actions/request_tracker.svelte.d.ts +1 -1
  15. package/dist/actions/request_tracker.svelte.js +1 -1
  16. package/dist/actions/rpc_client.d.ts +1 -1
  17. package/dist/actions/socket.svelte.d.ts +2 -2
  18. package/dist/actions/transports_ws.js +1 -1
  19. package/dist/actions/transports_ws_auth_guard.d.ts +1 -1
  20. package/dist/actions/transports_ws_backend.d.ts +1 -1
  21. package/dist/actions/transports_ws_backend.js +2 -2
  22. package/dist/auth/account_action_specs.js +1 -1
  23. package/dist/auth/account_schema.d.ts +1 -1
  24. package/dist/auth/account_schema.js +1 -1
  25. package/dist/auth/actor_lookup_action_specs.js +1 -1
  26. package/dist/auth/actor_lookup_queries.d.ts +1 -1
  27. package/dist/auth/actor_search_action_specs.js +1 -1
  28. package/dist/auth/actor_search_queries.d.ts +1 -1
  29. package/dist/auth/admin_action_specs.js +1 -1
  30. package/dist/auth/api_token.js +1 -1
  31. package/dist/auth/api_token_queries.d.ts +1 -1
  32. package/dist/auth/app_settings_schema.d.ts +1 -1
  33. package/dist/auth/app_settings_schema.js +1 -1
  34. package/dist/auth/audit_emitter.d.ts +2 -2
  35. package/dist/auth/audit_log_routes.d.ts +1 -1
  36. package/dist/auth/audit_log_schema.d.ts +1 -1
  37. package/dist/auth/audit_log_schema.js +2 -2
  38. package/dist/auth/bearer_auth.d.ts +1 -1
  39. package/dist/auth/bootstrap_account.d.ts +1 -1
  40. package/dist/auth/bootstrap_route_schema.js +1 -1
  41. package/dist/auth/bootstrap_routes.d.ts +1 -1
  42. package/dist/auth/bootstrap_routes.js +1 -1
  43. package/dist/auth/cell_action_specs.js +2 -2
  44. package/dist/auth/cell_audit_action_specs.js +1 -1
  45. package/dist/auth/cell_audit_metadata.js +1 -1
  46. package/dist/auth/cell_field_action_specs.js +1 -1
  47. package/dist/auth/cell_field_audit_metadata.js +1 -1
  48. package/dist/auth/cell_grant_action_specs.js +1 -1
  49. package/dist/auth/cell_grant_audit_metadata.js +1 -1
  50. package/dist/auth/cell_item_action_specs.d.ts +1 -1
  51. package/dist/auth/cell_item_action_specs.js +3 -3
  52. package/dist/auth/cell_item_audit_metadata.js +1 -1
  53. package/dist/auth/cell_relation_visibility.d.ts +1 -1
  54. package/dist/auth/cleanup.d.ts +1 -1
  55. package/dist/auth/daemon_token_middleware.d.ts +1 -1
  56. package/dist/auth/deps.d.ts +2 -2
  57. package/dist/auth/invite_schema.d.ts +1 -1
  58. package/dist/auth/invite_schema.js +1 -1
  59. package/dist/auth/request_context.d.ts +1 -1
  60. package/dist/auth/role_grant_offer_action_specs.js +1 -1
  61. package/dist/auth/role_grant_offer_notifications.d.ts +1 -1
  62. package/dist/auth/role_grant_offer_notifications.js +1 -1
  63. package/dist/auth/role_grant_offer_queries.d.ts +1 -1
  64. package/dist/auth/role_grant_offer_schema.d.ts +1 -1
  65. package/dist/auth/role_grant_offer_schema.js +1 -1
  66. package/dist/auth/role_grant_queries.d.ts +1 -1
  67. package/dist/auth/session_queries.d.ts +1 -1
  68. package/dist/auth/session_queries.js +1 -1
  69. package/dist/auth/signup_route_schema.js +1 -1
  70. package/dist/cli/args.d.ts +1 -1
  71. package/dist/cli/args.js +2 -2
  72. package/dist/cli/help.d.ts +1 -1
  73. package/dist/cli/help.js +1 -1
  74. package/dist/cli/logger.d.ts +1 -1
  75. package/dist/cli/logger.js +1 -1
  76. package/dist/db/CLAUDE.md +1 -1
  77. package/dist/db/cell_audit_queries.d.ts +1 -1
  78. package/dist/db/cell_field_queries.d.ts +1 -1
  79. package/dist/db/cell_grant_queries.d.ts +1 -1
  80. package/dist/db/cell_item_queries.d.ts +1 -1
  81. package/dist/db/cell_queries.d.ts +3 -3
  82. package/dist/db/cell_queries.js +1 -1
  83. package/dist/db/fact_disk_storage.d.ts +2 -2
  84. package/dist/db/fact_disk_storage.js +4 -4
  85. package/dist/db/fact_queries.d.ts +1 -1
  86. package/dist/db/fact_store.d.ts +3 -3
  87. package/dist/db/fact_store.js +2 -2
  88. package/dist/db/file_fact_url.d.ts +1 -1
  89. package/dist/db/file_fact_url.js +1 -1
  90. package/dist/db/migrate.js +1 -1
  91. package/dist/db/status.js +1 -1
  92. package/dist/http/auth_shape.js +2 -2
  93. package/dist/http/common_routes.d.ts +1 -1
  94. package/dist/http/db_routes.d.ts +1 -1
  95. package/dist/http/origin.js +1 -1
  96. package/dist/http/pending_effects.d.ts +1 -1
  97. package/dist/http/proxy.d.ts +1 -1
  98. package/dist/http/route_spec.d.ts +1 -1
  99. package/dist/rate_limiter.js +1 -1
  100. package/dist/realtime/sse.d.ts +1 -1
  101. package/dist/realtime/sse_auth_guard.d.ts +1 -1
  102. package/dist/runtime/deno.js +1 -1
  103. package/dist/server/app_backend.d.ts +1 -1
  104. package/dist/server/app_backend.js +1 -1
  105. package/dist/server/env.js +1 -1
  106. package/dist/server/fact_write.d.ts +2 -2
  107. package/dist/server/fact_write.js +1 -1
  108. package/dist/server/serve_fact_route.d.ts +1 -1
  109. package/dist/server/serve_fact_route.js +2 -2
  110. package/dist/server/startup.d.ts +1 -1
  111. package/dist/testing/CLAUDE.md +50 -5
  112. package/dist/testing/adversarial_input.js +1 -1
  113. package/dist/testing/app_server.d.ts +1 -1
  114. package/dist/testing/app_server.js +1 -1
  115. package/dist/testing/auth_apps.js +1 -1
  116. package/dist/testing/cross_backend/bench/bench_report.d.ts +1 -1
  117. package/dist/testing/cross_backend/bench/bench_report.js +2 -2
  118. package/dist/testing/cross_backend/bench/run_cross_impl_bench.d.ts +1 -1
  119. package/dist/testing/cross_backend/bench/run_cross_impl_bench.js +1 -1
  120. package/dist/testing/cross_backend/bootstrap_backend.d.ts +1 -1
  121. package/dist/testing/cross_backend/cell_relations.js +1 -1
  122. package/dist/testing/cross_backend/conformance_case.d.ts +4 -0
  123. package/dist/testing/cross_backend/conformance_case.d.ts.map +1 -1
  124. package/dist/testing/cross_backend/conformance_case.js +26 -0
  125. package/dist/testing/cross_backend/conformance_table.d.ts +21 -0
  126. package/dist/testing/cross_backend/conformance_table.d.ts.map +1 -1
  127. package/dist/testing/cross_backend/conformance_table.js +109 -7
  128. package/dist/testing/cross_backend/create_cross_backend_global_setup.d.ts +1 -1
  129. package/dist/testing/cross_backend/create_dual_spawn_global_setup.d.ts +64 -0
  130. package/dist/testing/cross_backend/create_dual_spawn_global_setup.d.ts.map +1 -0
  131. package/dist/testing/cross_backend/{create_schema_parity_global_setup.js → create_dual_spawn_global_setup.js} +1 -1
  132. package/dist/testing/cross_backend/default_backend_configs.d.ts +12 -0
  133. package/dist/testing/cross_backend/default_backend_configs.d.ts.map +1 -1
  134. package/dist/testing/cross_backend/default_backend_configs.js +12 -0
  135. package/dist/testing/cross_backend/default_spine_surface.d.ts +1 -1
  136. package/dist/testing/cross_backend/default_spine_surface.d.ts.map +1 -1
  137. package/dist/testing/cross_backend/default_spine_surface.js +9 -2
  138. package/dist/testing/cross_backend/fact_serving.js +1 -1
  139. package/dist/testing/cross_backend/login_security.d.ts +11 -0
  140. package/dist/testing/cross_backend/login_security.d.ts.map +1 -0
  141. package/dist/testing/cross_backend/login_security.js +130 -0
  142. package/dist/testing/cross_backend/rust_spine_stub_backend_config.d.ts +17 -0
  143. package/dist/testing/cross_backend/rust_spine_stub_backend_config.d.ts.map +1 -1
  144. package/dist/testing/cross_backend/rust_spine_stub_backend_config.js +7 -2
  145. package/dist/testing/cross_backend/setup.d.ts +1 -1
  146. package/dist/testing/cross_backend/setup.js +1 -1
  147. package/dist/testing/cross_backend/testing_reset_actions.js +2 -2
  148. package/dist/testing/cross_backend/testing_server_core.d.ts +1 -1
  149. package/dist/testing/cross_backend/testing_server_core.js +1 -1
  150. package/dist/testing/cross_backend/ts_spine_backend_config.d.ts +11 -0
  151. package/dist/testing/cross_backend/ts_spine_backend_config.d.ts.map +1 -1
  152. package/dist/testing/cross_backend/ts_spine_backend_config.js +23 -7
  153. package/dist/testing/cross_backend/ws_round_trip.js +1 -1
  154. package/dist/testing/db.js +4 -4
  155. package/dist/testing/middleware.js +1 -1
  156. package/dist/testing/role_grant_helpers.d.ts +1 -1
  157. package/dist/testing/rpc_helpers.d.ts +18 -1
  158. package/dist/testing/rpc_helpers.d.ts.map +1 -1
  159. package/dist/testing/rpc_helpers.js +24 -2
  160. package/dist/testing/schema_generators.d.ts +1 -1
  161. package/dist/testing/schema_generators.js +1 -1
  162. package/dist/testing/stubs.js +1 -1
  163. package/dist/testing/testing_rate_limiter.d.ts +1 -1
  164. package/dist/testing/testing_rate_limiter.js +1 -1
  165. package/dist/testing/transports/bootstrap.d.ts +1 -1
  166. package/dist/testing/transports/bootstrap.js +1 -1
  167. package/dist/testing/ws_round_trip.d.ts +2 -2
  168. package/dist/testing/ws_round_trip.js +2 -2
  169. package/dist/ui/AdminSurface.svelte +1 -1
  170. package/dist/ui/AppShell.svelte +1 -1
  171. package/dist/ui/BootstrapForm.svelte +1 -1
  172. package/dist/ui/CLAUDE.md +1 -1
  173. package/dist/ui/ConfirmButton.svelte +2 -2
  174. package/dist/ui/ConfirmButton.svelte.d.ts +1 -1
  175. package/dist/ui/LoginForm.svelte +1 -1
  176. package/dist/ui/MenuLink.svelte +1 -1
  177. package/dist/ui/MenuLink.svelte.d.ts +1 -1
  178. package/dist/ui/PopoverButton.svelte +1 -1
  179. package/dist/ui/PopoverButton.svelte.d.ts +1 -1
  180. package/dist/ui/SignupForm.svelte +1 -1
  181. package/dist/ui/account_sessions_state.svelte.js +1 -1
  182. package/dist/ui/admin_accounts_state.svelte.d.ts +1 -1
  183. package/dist/ui/admin_accounts_state.svelte.js +1 -1
  184. package/dist/ui/admin_invites_state.svelte.d.ts +1 -1
  185. package/dist/ui/admin_invites_state.svelte.js +1 -1
  186. package/dist/ui/admin_sessions_state.svelte.d.ts +1 -1
  187. package/dist/ui/app_settings_state.svelte.js +1 -1
  188. package/dist/ui/async_slot.svelte.d.ts +2 -2
  189. package/dist/ui/async_slot.svelte.js +2 -2
  190. package/dist/ui/audit_log_state.svelte.js +1 -1
  191. package/dist/ui/auth_state.svelte.d.ts +1 -1
  192. package/dist/ui/auth_state.svelte.js +3 -3
  193. package/dist/ui/format_scope.js +1 -1
  194. package/dist/ui/popover.svelte.d.ts +1 -1
  195. package/dist/ui/popover.svelte.js +2 -2
  196. package/dist/ui/position_helpers.js +1 -1
  197. package/dist/ui/role_grant_offers_state.svelte.js +1 -1
  198. package/dist/ui/sidebar_state.svelte.js +1 -1
  199. package/dist/ui/table_state.svelte.d.ts +1 -1
  200. package/dist/ui/table_state.svelte.js +2 -2
  201. package/package.json +3 -2
  202. package/dist/testing/cross_backend/create_schema_parity_global_setup.d.ts +0 -59
  203. package/dist/testing/cross_backend/create_schema_parity_global_setup.d.ts.map +0 -1
@@ -16,11 +16,17 @@ import '../assert_dev_env.js';
16
16
  * row runs `as` resolves to a `TestFixture` accessor via
17
17
  * `resolve_principal` — no inline credential minting.
18
18
  *
19
+ * Every response also passes an always-on **no-fingerprint** invariant
20
+ * (`assert_no_fingerprint_headers` over `FINGERPRINT_HEADERS` — `Server` /
21
+ * `X-Powered-By` / `WWW-Authenticate` must stay absent on both spines), and
22
+ * a row may pin further header expectations via `expect.headers`. Headers are
23
+ * deliberately kept out of the equivalence-group `{status, body}` comparison.
24
+ *
19
25
  * @module
20
26
  */
21
27
  import { assert, describe, test } from 'vitest';
22
28
  import { find_auth_route, rest_auth_route_suffixes, } from '../integration_helpers.js';
23
- import { find_rpc_action, rpc_call, resolve_rpc_endpoints_for_setup, } from '../rpc_helpers.js';
29
+ import { find_rpc_action, headers_to_record, rpc_call, resolve_rpc_endpoints_for_setup, } from '../rpc_helpers.js';
24
30
  import {} from './conformance_case.js';
25
31
  import { xfail_until } from './xfail.js';
26
32
  /**
@@ -91,19 +97,70 @@ const assert_fields = (actual, fields, label) => {
91
97
  assert.deepEqual(record[key], expected, `${label}: field '${key}'`);
92
98
  }
93
99
  };
100
+ /**
101
+ * Response headers that fingerprint the backend implementation or framework.
102
+ * Neither spine emits these; the runner asserts they stay absent on EVERY
103
+ * conformance response so a framework upgrade or consumer middleware that adds
104
+ * (say) `Server:` to one spine can't silently become a backend-identifying
105
+ * oracle. Lowercased — matched against the lowercased-key snapshot
106
+ * `headers_to_record` produces.
107
+ */
108
+ export const FINGERPRINT_HEADERS = [
109
+ 'server',
110
+ 'x-powered-by',
111
+ 'www-authenticate',
112
+ ];
113
+ /**
114
+ * Assert a response carries none of the `FINGERPRINT_HEADERS`. Run on every
115
+ * case unconditionally — the always-on no-fingerprint floor.
116
+ */
117
+ export const assert_no_fingerprint_headers = (headers, label) => {
118
+ for (const name of FINGERPRINT_HEADERS) {
119
+ assert.ok(!(name in headers), `${label}: response leaked backend-fingerprinting header '${name}: ${headers[name]}' — ` +
120
+ `Server / X-Powered-By / WWW-Authenticate must stay absent on both spines.`);
121
+ }
122
+ };
123
+ /**
124
+ * Assert each declared header expectation: a string value must be present and
125
+ * equal (header name matched case-insensitively), `null` must be absent. The
126
+ * negative-space twin for headers — `expect.headers` pins a header beyond the
127
+ * always-on no-fingerprint floor.
128
+ */
129
+ export const assert_expected_headers = (headers, expected, label) => {
130
+ for (const [name, value] of Object.entries(expected)) {
131
+ const key = name.toLowerCase();
132
+ if (value === null) {
133
+ assert.ok(!(key in headers), `${label}: header '${name}' expected absent but present as '${headers[key]}'`);
134
+ }
135
+ else {
136
+ assert.strictEqual(headers[key], value, `${label}: header '${name}'`);
137
+ }
138
+ }
139
+ };
140
+ /**
141
+ * Run a case's header invariants: the always-on no-fingerprint floor on every
142
+ * response plus any declared `expect.headers`. Header assertions are kept off
143
+ * the equivalence-group `{status, body}` normalized response — `Set-Cookie` /
144
+ * `Date` legitimately vary, so headers are never compared for byte-identity.
145
+ */
146
+ const assert_response_headers = (headers, c) => {
147
+ assert_no_fingerprint_headers(headers, c.name);
148
+ if (c.expect.headers)
149
+ assert_expected_headers(headers, c.expect.headers, c.name);
150
+ };
94
151
  const is_success_status = (status) => status >= 200 && status < 300;
95
152
  /**
96
153
  * Run one conformance case end-to-end: resolve the principal, dispatch the
97
- * request, and assert the expected status / reason / fields.
154
+ * request, and assert the expected status / reason / fields. Returns the
155
+ * normalized response so the caller can feed an equivalence group.
98
156
  */
99
157
  const run_case = async (c, options, resolved_rpc_endpoints) => {
100
158
  const fixture = await options.setup_test();
101
159
  const { transport, headers, suppress_default_origin } = await resolve_principal(fixture, c.request.as, options.principals);
102
160
  if (c.request.method.startsWith('/')) {
103
- await run_rest_case(c, options, transport, headers);
104
- return;
161
+ return run_rest_case(c, options, transport, headers);
105
162
  }
106
- await run_rpc_case(c, transport, headers, suppress_default_origin, resolved_rpc_endpoints);
163
+ return run_rpc_case(c, transport, headers, suppress_default_origin, resolved_rpc_endpoints);
107
164
  };
108
165
  /** Dispatch + assert a case targeting an RPC method. */
109
166
  const run_rpc_case = async (c, transport, headers, suppress_default_origin, resolved_rpc_endpoints) => {
@@ -121,6 +178,7 @@ const run_rpc_case = async (c, transport, headers, suppress_default_origin, reso
121
178
  ...(c.request.verb && { verb: c.request.verb }),
122
179
  ...(suppress_default_origin && { suppress_default_origin: true }),
123
180
  });
181
+ assert_response_headers(res.headers, c);
124
182
  if (is_success_status(c.expect.status)) {
125
183
  assert.ok(res.ok, `${c.name}: expected success (${c.expect.status}) but got error ${JSON.stringify(res.ok ? undefined : res.error)}`);
126
184
  assert.strictEqual(res.status, c.expect.status, `${c.name}: status`);
@@ -128,7 +186,7 @@ const run_rpc_case = async (c, transport, headers, suppress_default_origin, reso
128
186
  assert.ok(parsed.success, `${c.name}: result does not match spec.output: ${JSON.stringify(parsed.success ? undefined : parsed.error.issues)}`);
129
187
  if (c.expect.fields)
130
188
  assert_fields(res.result, c.expect.fields, c.name);
131
- return;
189
+ return { status: res.status, body: res.result };
132
190
  }
133
191
  assert.ok(!res.ok, `${c.name}: expected error status ${c.expect.status} but got success`);
134
192
  assert.strictEqual(res.status, c.expect.status, `${c.name}: error status`);
@@ -145,6 +203,7 @@ const run_rpc_case = async (c, transport, headers, suppress_default_origin, reso
145
203
  }
146
204
  if (c.expect.fields)
147
205
  assert_fields(res.error.data, c.expect.fields, c.name);
206
+ return { status: res.status, body: res.error };
148
207
  };
149
208
  /** Dispatch + assert a case targeting one of the 6 REST auth routes. */
150
209
  const run_rest_case = async (c, options, transport, headers) => {
@@ -165,6 +224,7 @@ const run_rest_case = async (c, options, transport, headers) => {
165
224
  c.request.params !== undefined && { body: JSON.stringify(c.request.params) }),
166
225
  };
167
226
  const response = await transport(route.path, init);
227
+ assert_response_headers(headers_to_record(response.headers), c);
168
228
  assert.strictEqual(response.status, c.expect.status, `${c.name}: status`);
169
229
  const body = await response.json().catch(() => undefined);
170
230
  if (is_success_status(c.expect.status)) {
@@ -177,6 +237,7 @@ const run_rest_case = async (c, options, transport, headers) => {
177
237
  }
178
238
  if (c.expect.fields)
179
239
  assert_fields(body, c.expect.fields, c.name);
240
+ return { status: response.status, body };
180
241
  };
181
242
  /**
182
243
  * Register a `describe` block running every `ConformanceCase` as a
@@ -186,10 +247,28 @@ const run_rest_case = async (c, options, transport, headers) => {
186
247
  */
187
248
  export const describe_conformance_table_tests = (options) => {
188
249
  const resolved_rpc_endpoints = resolve_rpc_endpoints_for_setup(options.rpc_endpoints, options.session_options);
250
+ // Per-group normalized responses, populated as the member case tests run
251
+ // and consulted by the per-group byte-identity assertions registered after
252
+ // the case loop — vitest runs a describe's tests in registration order, so
253
+ // the group assertions execute last, after every member has recorded.
254
+ const equivalence_groups = new Map();
255
+ const group_names = new Set();
256
+ for (const c of options.cases) {
257
+ if (c.expect.equivalence_group)
258
+ group_names.add(c.expect.equivalence_group);
259
+ }
189
260
  describe(options.suite_name ?? 'conformance table', () => {
190
261
  for (const c of options.cases) {
191
262
  const label = c.note ? `${c.name} — ${c.note}` : c.name;
192
- const body = () => run_case(c, options, resolved_rpc_endpoints);
263
+ const group = c.expect.equivalence_group;
264
+ const body = async () => {
265
+ const normalized = await run_case(c, options, resolved_rpc_endpoints);
266
+ if (group) {
267
+ const members = equivalence_groups.get(group) ?? [];
268
+ members.push({ name: c.name, normalized });
269
+ equivalence_groups.set(group, members);
270
+ }
271
+ };
193
272
  if (c.xfail) {
194
273
  xfail_until(c.xfail.tracking_id, c.xfail.reason, label, body);
195
274
  }
@@ -197,5 +276,28 @@ export const describe_conformance_table_tests = (options) => {
197
276
  test(label, body);
198
277
  }
199
278
  }
279
+ // Negative-space gate: every member of an equivalence group must have
280
+ // produced a byte-identical `{status, body}`. This holds the impl under
281
+ // test to "a prober cannot distinguish these masked paths" — the
282
+ // promotion of a masked pair (wrong-password ≡ account-not-found,
283
+ // found-but-unauthorized ≡ not-found) from "same status + reason" to
284
+ // "wire-indistinguishable". Runs per impl, so each spine is held to it.
285
+ for (const group of group_names) {
286
+ test(`equivalence group '${group}' — members are wire-indistinguishable`, () => {
287
+ const members = equivalence_groups.get(group) ?? [];
288
+ assert.ok(members.length >= 2, `equivalence group '${group}': expected >= 2 members to have recorded, got ` +
289
+ `${members.length} — a group pins indistinguishability between paths, so it ` +
290
+ `needs at least a pair (check the member cases ran and weren't all xfail).`);
291
+ const [first, ...rest] = members;
292
+ // `members.length >= 2` guarantees `first` is defined; the explicit
293
+ // assert narrows it for the typechecker (noUncheckedIndexedAccess).
294
+ assert.ok(first);
295
+ for (const member of rest) {
296
+ assert.deepStrictEqual(member.normalized, first.normalized, `equivalence group '${group}': '${member.name}' is distinguishable from ` +
297
+ `'${first.name}' — a prober could tell these masked paths apart. The ` +
298
+ `normalized {status, body} must be byte-identical across all members.`);
299
+ }
300
+ });
301
+ }
200
302
  });
201
303
  };
@@ -13,7 +13,7 @@ import '../assert_dev_env.js';
13
13
  *
14
14
  * ```ts
15
15
  * import {create_cross_backend_global_setup} from
16
- * '@fuzdev/fuz_app/testing/cross_backend/create_cross_backend_global_setup.js';
16
+ * '@fuzdev/fuz_app/testing/cross_backend/create_cross_backend_global_setup.ts';
17
17
  * import {deno_backend_config, rust_backend_config} from './my_backend_config.js';
18
18
  * import './cross_test_types.js'; // augments inject('backend_handle')
19
19
  *
@@ -0,0 +1,64 @@
1
+ import '../assert_dev_env.js';
2
+ /**
3
+ * Generic **dual-spawn** vitest `globalSetup` factory — spawns + bootstraps
4
+ * *two* backends at once and `provide`s both serialized handles, for any
5
+ * cross-impl gate that needs both alive together. (The per-backend
6
+ * `create_cross_backend_global_setup` derives one backend from the project
7
+ * name; this brings up a pair.)
8
+ *
9
+ * Its primary use is the **schema- / action-manifest-parity** gates (capture
10
+ * each backend over a `_testing_*` introspection RPC and diff with
11
+ * `assert_schema_snapshots_equal` / `assert_action_manifests_equal`) — which is
12
+ * why the default `provide_keys` are `parity_handle_*`. The login-security gate
13
+ * (`global_setup_login_security.ts`) reuses it with its own keys; any future
14
+ * two-backend gate can too.
15
+ *
16
+ * A consumer's dual-spawn `global_setup.ts` collapses to:
17
+ *
18
+ * ```ts
19
+ * import {create_dual_spawn_global_setup} from
20
+ * '@fuzdev/fuz_app/testing/cross_backend/create_dual_spawn_global_setup.ts';
21
+ * import {deno_backend_config, rust_backend_config} from './my_backend_config.js';
22
+ * import './cross_test_types.js'; // augments the two provide keys
23
+ *
24
+ * export default create_dual_spawn_global_setup({
25
+ * configs: {a: deno_backend_config, b: rust_backend_config},
26
+ * });
27
+ * ```
28
+ *
29
+ * The `.cross.test.ts` `inject`s both keys, rebuilds each with
30
+ * `reconstruct_bootstrapped_handle`, and asserts. Run this project in a
31
+ * later `groupOrder` than the single-backend projects (or with distinct
32
+ * ports) — it reuses both configs' ports, so it must not run concurrently
33
+ * with the per-backend projects.
34
+ *
35
+ * @module
36
+ */
37
+ import type { TestProject } from 'vitest/node';
38
+ import type { BackendConfig } from './backend_config.js';
39
+ export interface DualSpawnGlobalSetupOptions {
40
+ /**
41
+ * The two backend config factories to spawn. `a` is spawned first; on
42
+ * its success `b` is spawned (with `a` torn down if `b` throws). Label
43
+ * them in the consuming test (e.g. via `assert_schema_snapshots_equal`'s labels).
44
+ */
45
+ readonly configs: {
46
+ readonly a: () => BackendConfig;
47
+ readonly b: () => BackendConfig;
48
+ };
49
+ /**
50
+ * `project.provide` keys for the two serialized handles (read by
51
+ * `inject` in the test). Default `parity_handle_a` / `parity_handle_b`
52
+ * (the primary parity use); override for other dual-spawn gates. Augment
53
+ * vitest's `ProvidedContext` for whichever keys you use.
54
+ */
55
+ readonly provide_keys?: {
56
+ readonly a: string;
57
+ readonly b: string;
58
+ };
59
+ }
60
+ /**
61
+ * Build a vitest `globalSetup` default export that spawns both backends.
62
+ */
63
+ export declare const create_dual_spawn_global_setup: ({ configs, provide_keys, }: DualSpawnGlobalSetupOptions) => ((project: TestProject) => Promise<() => Promise<void>>);
64
+ //# sourceMappingURL=create_dual_spawn_global_setup.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"create_dual_spawn_global_setup.d.ts","sourceRoot":"../src/lib/","sources":["../../../src/lib/testing/cross_backend/create_dual_spawn_global_setup.ts"],"names":[],"mappings":"AAAA,OAAO,sBAAsB,CAAC;AAE9B;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAkCG;AAEH,OAAO,KAAK,EAAC,WAAW,EAAC,MAAM,aAAa,CAAC;AAE7C,OAAO,KAAK,EAAC,aAAa,EAAC,MAAM,qBAAqB,CAAC;AAIvD,MAAM,WAAW,2BAA2B;IAC3C;;;;OAIG;IACH,QAAQ,CAAC,OAAO,EAAE;QAAC,QAAQ,CAAC,CAAC,EAAE,MAAM,aAAa,CAAC;QAAC,QAAQ,CAAC,CAAC,EAAE,MAAM,aAAa,CAAA;KAAC,CAAC;IACrF;;;;;OAKG;IACH,QAAQ,CAAC,YAAY,CAAC,EAAE;QAAC,QAAQ,CAAC,CAAC,EAAE,MAAM,CAAC;QAAC,QAAQ,CAAC,CAAC,EAAE,MAAM,CAAA;KAAC,CAAC;CACjE;AAED;;GAEG;AACH,eAAO,MAAM,8BAA8B,GAAI,4BAG5C,2BAA2B,KAAG,CAAC,CAAC,OAAO,EAAE,WAAW,KAAK,OAAO,CAAC,MAAM,OAAO,CAAC,IAAI,CAAC,CAAC,CAmBvF,CAAC"}
@@ -4,7 +4,7 @@ import { serialize_bootstrapped_handle } from './setup.js';
4
4
  /**
5
5
  * Build a vitest `globalSetup` default export that spawns both backends.
6
6
  */
7
- export const create_schema_parity_global_setup = ({ configs, provide_keys = { a: 'parity_handle_a', b: 'parity_handle_b' }, }) => {
7
+ export const create_dual_spawn_global_setup = ({ configs, provide_keys = { a: 'parity_handle_a', b: 'parity_handle_b' }, }) => {
8
8
  return async (project) => {
9
9
  const a = await bootstrap_backend(configs.a());
10
10
  // Tear `a` down if `b` fails to spawn — no orphaned child process.
@@ -35,6 +35,18 @@ import '../assert_dev_env.js';
35
35
  import type { BackendBootstrapConfig, BackendConfig } from './backend_config.js';
36
36
  import type { BackendCapabilities, BackendShapeNotes } from './capabilities.js';
37
37
  import { type TestBackendPaths } from './build_test_backend_paths.js';
38
+ /**
39
+ * Env var both spine binaries read to enable their login rate limiters
40
+ * (`'true'` on / unset off). The cross-language contract for the login-security
41
+ * cross project: the TS binary reads it via `runtime.env_get` (a test-only flag,
42
+ * not in `BaseServerEnv`); the Rust `testing_spine_stub` reads it via
43
+ * `std::env::var` — so one backend-config option drives both impls. Shared home
44
+ * here because both `ts_spine_backend_config` and `rust_spine_stub_backend_config`
45
+ * already import this module. (The spawned TS binary re-declares the literal
46
+ * locally — it can't import this module, which transitively pulls `vitest` —
47
+ * mirroring how `testing_spine_server_node.ts` re-declares `TS_SPINE_DIR_ENV`.)
48
+ */
49
+ export declare const LOGIN_RATE_LIMIT_ENABLED_ENV = "FUZ_LOGIN_RATE_LIMIT_ENABLED";
38
50
  /**
39
51
  * Capabilities shared by TS-family backends — same canonical implementation,
40
52
  * same feature set. The non-gating wiring facts (`bearer_auth` /
@@ -1 +1 @@
1
- {"version":3,"file":"default_backend_configs.d.ts","sourceRoot":"../src/lib/","sources":["../../../src/lib/testing/cross_backend/default_backend_configs.ts"],"names":[],"mappings":"AAAA,OAAO,sBAAsB,CAAC;AAE9B;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAgCG;AAEH,OAAO,KAAK,EAAC,sBAAsB,EAAE,aAAa,EAAC,MAAM,qBAAqB,CAAC;AAC/E,OAAO,KAAK,EAAC,mBAAmB,EAAE,iBAAiB,EAAC,MAAM,mBAAmB,CAAC;AAC9E,OAAO,EAA2B,KAAK,gBAAgB,EAAC,MAAM,+BAA+B,CAAC;AAQ9F;;;;GAIG;AACH,eAAO,MAAM,uBAAuB,EAAE,mBAiBpC,CAAC;AAEH;;;;GAIG;AACH,eAAO,MAAM,yBAAyB,EAAE,mBAgBtC,CAAC;AAEH;;;;;;GAMG;AACH,eAAO,MAAM,sBAAsB,EAAE,iBAInC,CAAC;AAEH;;;;;;GAMG;AACH,eAAO,MAAM,wBAAwB,EAAE,iBAIrC,CAAC;AAeH,MAAM,WAAW,iCAAiC;IACjD,gFAAgF;IAChF,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,sCAAsC;IACtC,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,4DAA4D;IAC5D,QAAQ,CAAC,aAAa,EAAE,aAAa,CAAC,MAAM,CAAC,CAAC;IAC9C,oDAAoD;IACpD,QAAQ,CAAC,YAAY,CAAC,EAAE,MAAM,CAAC;IAC/B,iEAAiE;IACjE,QAAQ,CAAC,SAAS,CAAC,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAAC;IACtD,6CAA6C;IAC7C,QAAQ,CAAC,YAAY,CAAC,EAAE,mBAAmB,CAAC;IAC5C,wEAAwE;IACxE,QAAQ,CAAC,KAAK,CAAC,EAAE,gBAAgB,CAAC;IAClC,sEAAsE;IACtE,QAAQ,CAAC,mBAAmB,CAAC,EAAE,OAAO,CAAC,sBAAsB,CAAC,CAAC;IAC/D;;;;OAIG;IACH,QAAQ,CAAC,YAAY,CAAC,EAAE,MAAM,CAAC;IAC/B;;;;;;OAMG;IACH,QAAQ,CAAC,WAAW,CAAC,EAAE,MAAM,CAAC;CAC9B;AAED;;;;;GAKG;AACH,eAAO,MAAM,8BAA8B,GAC1C,MAAM,iCAAiC,KACrC,aAoCF,CAAC;AAEF,MAAM,WAAW,mCAAmC;IACnD,gFAAgF;IAChF,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,sCAAsC;IACtC,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,4DAA4D;IAC5D,QAAQ,CAAC,aAAa,EAAE,aAAa,CAAC,MAAM,CAAC,CAAC;IAC9C;;;;OAIG;IACH,QAAQ,CAAC,YAAY,EAAE,MAAM,CAAC;IAC9B,iEAAiE;IACjE,QAAQ,CAAC,SAAS,CAAC,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAAC;IACtD,+CAA+C;IAC/C,QAAQ,CAAC,YAAY,CAAC,EAAE,mBAAmB,CAAC;IAC5C,wEAAwE;IACxE,QAAQ,CAAC,KAAK,CAAC,EAAE,gBAAgB,CAAC;IAClC,sEAAsE;IACtE,QAAQ,CAAC,mBAAmB,CAAC,EAAE,OAAO,CAAC,sBAAsB,CAAC,CAAC;IAC/D;;;;OAIG;IACH,QAAQ,CAAC,YAAY,CAAC,EAAE,MAAM,CAAC;IAC/B;;;;OAIG;IACH,QAAQ,CAAC,QAAQ,CAAC,EAAE,MAAM,CAAC;IAC3B;;;OAGG;IACH,QAAQ,CAAC,WAAW,CAAC,EAAE,MAAM,CAAC;CAC9B;AAED;;;;;GAKG;AACH,eAAO,MAAM,gCAAgC,GAC5C,MAAM,mCAAmC,KACvC,aA4CF,CAAC"}
1
+ {"version":3,"file":"default_backend_configs.d.ts","sourceRoot":"../src/lib/","sources":["../../../src/lib/testing/cross_backend/default_backend_configs.ts"],"names":[],"mappings":"AAAA,OAAO,sBAAsB,CAAC;AAE9B;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAgCG;AAEH,OAAO,KAAK,EAAC,sBAAsB,EAAE,aAAa,EAAC,MAAM,qBAAqB,CAAC;AAC/E,OAAO,KAAK,EAAC,mBAAmB,EAAE,iBAAiB,EAAC,MAAM,mBAAmB,CAAC;AAC9E,OAAO,EAA2B,KAAK,gBAAgB,EAAC,MAAM,+BAA+B,CAAC;AAQ9F;;;;;;;;;;GAUG;AACH,eAAO,MAAM,4BAA4B,iCAAiC,CAAC;AAE3E;;;;GAIG;AACH,eAAO,MAAM,uBAAuB,EAAE,mBAiBpC,CAAC;AAEH;;;;GAIG;AACH,eAAO,MAAM,yBAAyB,EAAE,mBAgBtC,CAAC;AAEH;;;;;;GAMG;AACH,eAAO,MAAM,sBAAsB,EAAE,iBAInC,CAAC;AAEH;;;;;;GAMG;AACH,eAAO,MAAM,wBAAwB,EAAE,iBAIrC,CAAC;AAeH,MAAM,WAAW,iCAAiC;IACjD,gFAAgF;IAChF,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,sCAAsC;IACtC,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,4DAA4D;IAC5D,QAAQ,CAAC,aAAa,EAAE,aAAa,CAAC,MAAM,CAAC,CAAC;IAC9C,oDAAoD;IACpD,QAAQ,CAAC,YAAY,CAAC,EAAE,MAAM,CAAC;IAC/B,iEAAiE;IACjE,QAAQ,CAAC,SAAS,CAAC,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAAC;IACtD,6CAA6C;IAC7C,QAAQ,CAAC,YAAY,CAAC,EAAE,mBAAmB,CAAC;IAC5C,wEAAwE;IACxE,QAAQ,CAAC,KAAK,CAAC,EAAE,gBAAgB,CAAC;IAClC,sEAAsE;IACtE,QAAQ,CAAC,mBAAmB,CAAC,EAAE,OAAO,CAAC,sBAAsB,CAAC,CAAC;IAC/D;;;;OAIG;IACH,QAAQ,CAAC,YAAY,CAAC,EAAE,MAAM,CAAC;IAC/B;;;;;;OAMG;IACH,QAAQ,CAAC,WAAW,CAAC,EAAE,MAAM,CAAC;CAC9B;AAED;;;;;GAKG;AACH,eAAO,MAAM,8BAA8B,GAC1C,MAAM,iCAAiC,KACrC,aAoCF,CAAC;AAEF,MAAM,WAAW,mCAAmC;IACnD,gFAAgF;IAChF,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,sCAAsC;IACtC,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,4DAA4D;IAC5D,QAAQ,CAAC,aAAa,EAAE,aAAa,CAAC,MAAM,CAAC,CAAC;IAC9C;;;;OAIG;IACH,QAAQ,CAAC,YAAY,EAAE,MAAM,CAAC;IAC9B,iEAAiE;IACjE,QAAQ,CAAC,SAAS,CAAC,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAAC;IACtD,+CAA+C;IAC/C,QAAQ,CAAC,YAAY,CAAC,EAAE,mBAAmB,CAAC;IAC5C,wEAAwE;IACxE,QAAQ,CAAC,KAAK,CAAC,EAAE,gBAAgB,CAAC;IAClC,sEAAsE;IACtE,QAAQ,CAAC,mBAAmB,CAAC,EAAE,OAAO,CAAC,sBAAsB,CAAC,CAAC;IAC/D;;;;OAIG;IACH,QAAQ,CAAC,YAAY,CAAC,EAAE,MAAM,CAAC;IAC/B;;;;OAIG;IACH,QAAQ,CAAC,QAAQ,CAAC,EAAE,MAAM,CAAC;IAC3B;;;OAGG;IACH,QAAQ,CAAC,WAAW,CAAC,EAAE,MAAM,CAAC;CAC9B;AAED;;;;;GAKG;AACH,eAAO,MAAM,gCAAgC,GAC5C,MAAM,mCAAmC,KACvC,aA4CF,CAAC"}
@@ -1,6 +1,18 @@
1
1
  import '../assert_dev_env.js';
2
2
  import { build_test_backend_paths } from './build_test_backend_paths.js';
3
3
  import { default_test_bootstrap_token, default_test_cookie_keys, default_test_keeper_password, default_test_keeper_username, } from './default_secrets.js';
4
+ /**
5
+ * Env var both spine binaries read to enable their login rate limiters
6
+ * (`'true'` on / unset off). The cross-language contract for the login-security
7
+ * cross project: the TS binary reads it via `runtime.env_get` (a test-only flag,
8
+ * not in `BaseServerEnv`); the Rust `testing_spine_stub` reads it via
9
+ * `std::env::var` — so one backend-config option drives both impls. Shared home
10
+ * here because both `ts_spine_backend_config` and `rust_spine_stub_backend_config`
11
+ * already import this module. (The spawned TS binary re-declares the literal
12
+ * locally — it can't import this module, which transitively pulls `vitest` —
13
+ * mirroring how `testing_spine_server_node.ts` re-declares `TS_SPINE_DIR_ENV`.)
14
+ */
15
+ export const LOGIN_RATE_LIMIT_ENABLED_ENV = 'FUZ_LOGIN_RATE_LIMIT_ENABLED';
4
16
  /**
5
17
  * Capabilities shared by TS-family backends — same canonical implementation,
6
18
  * same feature set. The non-gating wiring facts (`bearer_auth` /
@@ -23,7 +23,7 @@ import '../assert_dev_env.js';
23
23
  *
24
24
  * @module
25
25
  */
26
- import type { Logger } from '@fuzdev/fuz_util/log.js';
26
+ import type { Logger } from '@fuzdev/fuz_util/log.ts';
27
27
  import type { NotificationSender } from '../../auth/role_grant_offer_notifications.js';
28
28
  import { type RoleSchemaResult } from '../../auth/role_schema.js';
29
29
  import { type SessionOptions } from '../../auth/session_cookie.js';
@@ -1 +1 @@
1
- {"version":3,"file":"default_spine_surface.d.ts","sourceRoot":"../src/lib/","sources":["../../../src/lib/testing/cross_backend/default_spine_surface.ts"],"names":[],"mappings":"AAAA,OAAO,sBAAsB,CAAC;AAE9B;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AAEH,OAAO,KAAK,EAAC,MAAM,EAAC,MAAM,yBAAyB,CAAC;AAIpD,OAAO,KAAK,EAAC,kBAAkB,EAAC,MAAM,8CAA8C,CAAC;AACrF,OAAO,EAAqB,KAAK,gBAAgB,EAAC,MAAM,2BAA2B,CAAC;AACpF,OAAO,EAAwB,KAAK,cAAc,EAAC,MAAM,8BAA8B,CAAC;AAIxF,OAAO,EAAqB,KAAK,SAAS,EAAC,MAAM,0BAA0B,CAAC;AAC5E,OAAO,KAAK,EAAC,cAAc,EAAE,eAAe,EAAC,MAAM,uBAAuB,CAAC;AAC3E,OAAO,KAAK,EAAC,gBAAgB,EAAC,MAAM,oCAAoC,CAAC;AAGzE;;;GAGG;AACH,eAAO,MAAM,qBAAqB,EAAE,cAAc,CAAC,MAAM,CAAwC,CAAC;AAElG;;;;;;GAMG;AACH,eAAO,MAAM,sBAAsB,gBAAgB,CAAC;AAEpD;;;;;;GAMG;AACH,eAAO,MAAM,WAAW,EAAE,gBAExB,CAAC;AAEH,iEAAiE;AACjE,eAAO,MAAM,cAAc,aAAa,CAAC;AAEzC;;;;;;GAMG;AACH,eAAO,MAAM,cAAc,4BAA4B,CAAC;AAExD,+CAA+C;AAC/C,MAAM,WAAW,wBAAwB;IACxC;;;;;;;;;;;;;;;OAeG;IACH,QAAQ,CAAC,mBAAmB,CAAC,EAAE,kBAAkB,GAAG,IAAI,CAAC;CACzD;AAED;;;;;;;;;;;;;;GAcG;AACH,eAAO,MAAM,mBAAmB,GAC/B,KAAK,gBAAgB,EACrB,UAAU,wBAAwB,KAChC,KAAK,CAAC,eAAe,CAQvB,CAAC;AAEF;;;;;;;;;;GAUG;AACH,eAAO,MAAM,wBAAwB,GAAI,KAAK,gBAAgB,KAAG,KAAK,CAAC,SAAS,CAwB/E,CAAC;AAEF;;;;;;;;;;GAUG;AACH,eAAO,MAAM,yBAAyB,EAAE,GAAwD,CAAC;AAEjG;;;;;;;;;GASG;AACH,eAAO,MAAM,6BAA6B,GAAI,MAAM,MAAM,KAAG,SAC6B,CAAC;AAE3F;;;;;;;;GAQG;AACH,eAAO,MAAM,yBAAyB,QAAO,cAM1C,CAAC"}
1
+ {"version":3,"file":"default_spine_surface.d.ts","sourceRoot":"../src/lib/","sources":["../../../src/lib/testing/cross_backend/default_spine_surface.ts"],"names":[],"mappings":"AAAA,OAAO,sBAAsB,CAAC;AAE9B;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AAEH,OAAO,KAAK,EAAC,MAAM,EAAC,MAAM,yBAAyB,CAAC;AAIpD,OAAO,KAAK,EAAC,kBAAkB,EAAC,MAAM,8CAA8C,CAAC;AACrF,OAAO,EAAqB,KAAK,gBAAgB,EAAC,MAAM,2BAA2B,CAAC;AACpF,OAAO,EAAwB,KAAK,cAAc,EAAC,MAAM,8BAA8B,CAAC;AAIxF,OAAO,EAAqB,KAAK,SAAS,EAAC,MAAM,0BAA0B,CAAC;AAC5E,OAAO,KAAK,EAAC,cAAc,EAAE,eAAe,EAAC,MAAM,uBAAuB,CAAC;AAC3E,OAAO,KAAK,EAAC,gBAAgB,EAAC,MAAM,oCAAoC,CAAC;AAGzE;;;GAGG;AACH,eAAO,MAAM,qBAAqB,EAAE,cAAc,CAAC,MAAM,CAAwC,CAAC;AAElG;;;;;;GAMG;AACH,eAAO,MAAM,sBAAsB,gBAAgB,CAAC;AAEpD;;;;;;GAMG;AACH,eAAO,MAAM,WAAW,EAAE,gBAExB,CAAC;AAEH,iEAAiE;AACjE,eAAO,MAAM,cAAc,aAAa,CAAC;AAEzC;;;;;;GAMG;AACH,eAAO,MAAM,cAAc,4BAA4B,CAAC;AAExD,+CAA+C;AAC/C,MAAM,WAAW,wBAAwB;IACxC;;;;;;;;;;;;;;;OAeG;IACH,QAAQ,CAAC,mBAAmB,CAAC,EAAE,kBAAkB,GAAG,IAAI,CAAC;CACzD;AAED;;;;;;;;;;;;;;GAcG;AACH,eAAO,MAAM,mBAAmB,GAC/B,KAAK,gBAAgB,EACrB,UAAU,wBAAwB,KAChC,KAAK,CAAC,eAAe,CAQvB,CAAC;AAEF;;;;;;;;;;GAUG;AACH,eAAO,MAAM,wBAAwB,GAAI,KAAK,gBAAgB,KAAG,KAAK,CAAC,SAAS,CA+B/E,CAAC;AAEF;;;;;;;;;;GAUG;AACH,eAAO,MAAM,yBAAyB,EAAE,GAAwD,CAAC;AAEjG;;;;;;;;;GASG;AACH,eAAO,MAAM,6BAA6B,GAAI,MAAM,MAAM,KAAG,SAC6B,CAAC;AAE3F;;;;;;;;GAQG;AACH,eAAO,MAAM,yBAAyB,QAAO,cAM1C,CAAC"}
@@ -77,8 +77,15 @@ export const create_spine_route_specs = (ctx) => [
77
77
  ...prefix_route_specs('/api/account', [
78
78
  ...create_account_route_specs(ctx.deps, {
79
79
  session_options: spine_session_options,
80
- ip_rate_limiter: null,
81
- login_account_rate_limiter: null,
80
+ // Honor the limiters the server was assembled with (on `AppServerContext`
81
+ // per the `ip_rate_limiter` / `login_account_rate_limiter` TSDoc) rather
82
+ // than hardcoding `null`. The standard spine binary passes `null` (the
83
+ // cross suites fire many loopback logins and must not trip a limiter), so
84
+ // this is behavior-preserving there; the dedicated login-security cross
85
+ // backend passes real limiters to exercise the 429 + `Retry-After` path
86
+ // and XFF-keyed bucketing over the wire (see `login_security.ts`).
87
+ ip_rate_limiter: ctx.ip_rate_limiter,
88
+ login_account_rate_limiter: ctx.login_account_rate_limiter,
82
89
  login_fail_floor_ms: 0,
83
90
  bootstrap_status: ctx.bootstrap_status,
84
91
  }),
@@ -38,7 +38,7 @@ import '../assert_dev_env.js';
38
38
  */
39
39
  import { describe, assert } from 'vitest';
40
40
  import { z } from 'zod';
41
- import { FactHashSchema } from '@fuzdev/fuz_util/fact_hash.js';
41
+ import { FactHashSchema } from '@fuzdev/fuz_util/fact_hash.ts';
42
42
  import { CellCreateOutput } from '../../auth/cell_action_specs.js';
43
43
  import { test_if } from './capabilities.js';
44
44
  import { cross_rpc_call, expect_output } from './cell_cross_helpers.js';
@@ -0,0 +1,11 @@
1
+ import '../assert_dev_env.js';
2
+ import type { SetupTest } from './setup.js';
3
+ /** Options for the login-security parity suite. */
4
+ export interface LoginSecurityCrossTestOptions {
5
+ /** Per-test fixture producer (cross-process only — see the module doc). */
6
+ readonly setup_test: SetupTest;
7
+ /** REST login route path. Default `/api/account/login` (the spine convention). */
8
+ readonly login_path?: string;
9
+ }
10
+ export declare const describe_login_security_cross_tests: (options: LoginSecurityCrossTestOptions) => void;
11
+ //# sourceMappingURL=login_security.d.ts.map
@@ -0,0 +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;AAoD9B,OAAO,KAAK,EAAC,SAAS,EAAC,MAAM,YAAY,CAAC;AAE1C,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;CAC7B;AAyBD,eAAO,MAAM,mCAAmC,GAC/C,SAAS,6BAA6B,KACpC,IAoGF,CAAC"}
@@ -0,0 +1,130 @@
1
+ import '../assert_dev_env.js';
2
+ /**
3
+ * Cross-backend parity suite for **login rate limiting + trusted-proxy
4
+ * (X-Forwarded-For) resolution** over real HTTP.
5
+ *
6
+ * Login throttling and client-IP resolution are wired-but-never-crossed: the
7
+ * in-process `describe_rate_limiting_tests` covers the limiter, and the
8
+ * `fuz_http` / proxy middleware unit tests cover XFF resolution, but no case
9
+ * exercised either over a real socket on both impls — the limiter is nulled
10
+ * on every standard cross backend and the resolved client IP has no
11
+ * wire-observable downstream there. This dedicated suite spawns a backend
12
+ * with the login limiters enabled + the loopback proxy trusted (see
13
+ * `global_setup_login_security.ts`), then pins two properties end-to-end:
14
+ *
15
+ * - **per-IP login limit fires** — the first `default_login_ip_rate_limit`
16
+ * failed logins from one forwarded IP each return `401`, and the next
17
+ * returns `429` with the canonical `{error: "rate_limit_exceeded",
18
+ * retry_after}` body **and** a `Retry-After: ceil(retry_after)` header. The
19
+ * 429 wire shape is the cross-impl contract — TS `rate_limit_exceeded_response`
20
+ * and Rust `route_response::rate_limit_exceeded` must agree.
21
+ * - **trusted-proxy / XFF resolution is honored** — distinct
22
+ * `X-Forwarded-For` IPs get independent buckets: after exhausting one
23
+ * forwarded IP to `429`, a *different* forwarded IP is unaffected (`401`,
24
+ * not `429`). A backend that ignored XFF and keyed on the (loopback) TCP
25
+ * peer would 429 the fresh-IP request too — so the `401` proves the limiter
26
+ * keys on the resolved `X-Forwarded-For` client IP.
27
+ *
28
+ * **Determinism without a limiter reset.** Limiter state is in-memory and the
29
+ * per-test `_testing_reset` wipes only the DB, never the buckets — so each
30
+ * case uses its own forwarded IP and its own (non-existent) username, keeping
31
+ * every bucket independent across cases and across the two impls (separate
32
+ * processes). The login floor is zeroed on both spines, so the failed-login
33
+ * loop stays fast.
34
+ *
35
+ * Both surfaces are flat REST (`POST /api/account/login`) on every spine, so
36
+ * this is an imperative suite (not a `conformance_table` row) — the sibling of
37
+ * `origin.ts` / `identity_parity.ts`. Cross-process only: the limiter+proxy
38
+ * wiring is the point, and the in-process counterparts already exist
39
+ * (`describe_rate_limiting_tests` + the proxy middleware tests). Cited
40
+ * property: `docs/security.md` §"Rate Limiting" + §"Trusted Proxy / Client IP".
41
+ *
42
+ * `$lib`-free by contract (relative specifiers only), like the sibling
43
+ * cross-backend suites.
44
+ *
45
+ * @module
46
+ */
47
+ import { describe, test, assert } from 'vitest';
48
+ import { ERROR_RATE_LIMIT_EXCEEDED } from '../../http/error_schemas.js';
49
+ import { default_login_account_rate_limit, default_login_ip_rate_limit } from '../../rate_limiter.js';
50
+ /**
51
+ * RFC 5737 TEST-NET-3 (`203.0.113.0/24`) documentation addresses — guaranteed
52
+ * non-routable, so they never collide with a real client IP. One per bucket the
53
+ * suite needs; distinct across cases so no case inherits another's count (the
54
+ * limiter is not reset between cases). Spoofed via `X-Forwarded-For`; the
55
+ * loopback TCP peer is trusted, so the rightmost untrusted hop (these) is the
56
+ * resolved client IP.
57
+ */
58
+ const XFF_IP_LIMIT = '203.0.113.1';
59
+ const XFF_SEGREGATION_EXHAUST = '203.0.113.2';
60
+ const XFF_SEGREGATION_FRESH = '203.0.113.3';
61
+ /** A non-routable, non-existent identifier — login resolves to the not-found path (records the bucket, returns 401). */
62
+ const PROBE_PASSWORD = 'login_security_wrong_password';
63
+ export const describe_login_security_cross_tests = (options) => {
64
+ const { setup_test } = options;
65
+ const login_path = options.login_path ?? '/api/account/login';
66
+ /** Per-IP cap shared by both impls (TS `default_login_ip_rate_limit`, Rust `DEFAULT_LOGIN_IP_RATE_LIMIT`). */
67
+ const ip_limit = default_login_ip_rate_limit.max_attempts;
68
+ // The XFF-segregation case enables both limiters (one flag drives both), so
69
+ // the per-account bucket is live too. After exhausting an IP, the shared
70
+ // username holds exactly `ip_limit` account records, so the fresh-IP probe
71
+ // only stays a 401 (not 429) while the account cap sits above the IP cap.
72
+ // Assert the ordering so a future cap change fails loud here instead of as a
73
+ // confusing 429-instead-of-401 mid-case.
74
+ assert(default_login_account_rate_limit.max_attempts > ip_limit, 'login-security XFF segregation needs the per-account cap above the per-IP cap');
75
+ /**
76
+ * POST a login with the given `X-Forwarded-For` on a fresh (cookie-jar-free)
77
+ * transport — wrong password against a non-existent username, so the handler
78
+ * clears origin + input validation, records the resolved-IP bucket, and
79
+ * returns `401` (or `429` once the bucket trips). Always reads the body so
80
+ * the cross-process socket releases.
81
+ */
82
+ const attempt = async (fixture, forwarded_for, username) => {
83
+ const res = await fixture.fresh_transport()(login_path, {
84
+ method: 'POST',
85
+ headers: { 'content-type': 'application/json', 'x-forwarded-for': forwarded_for },
86
+ body: JSON.stringify({ username, password: PROBE_PASSWORD }),
87
+ });
88
+ const body = (await res.json().catch(() => undefined));
89
+ return {
90
+ status: res.status,
91
+ error: typeof body?.error === 'string' ? body.error : undefined,
92
+ retry_after: typeof body?.retry_after === 'number' ? body.retry_after : undefined,
93
+ retry_after_header: res.headers.get('retry-after'),
94
+ };
95
+ };
96
+ describe('login rate limiting + trusted-proxy parity', () => {
97
+ test(`per-IP login limit fires 429 + Retry-After after ${ip_limit} attempts from one forwarded IP`, async () => {
98
+ const fixture = await setup_test();
99
+ // The first `ip_limit` failed logins from one forwarded IP each clear the
100
+ // limiter (a normal 401), recording against the resolved client IP.
101
+ for (let i = 0; i < ip_limit; i++) {
102
+ const r = await attempt(fixture, XFF_IP_LIMIT, 'probe_ip_limit');
103
+ assert.strictEqual(r.status, 401, `attempt ${i + 1}/${ip_limit} must be a normal 401, not rate-limited`);
104
+ }
105
+ // The next request from the SAME forwarded IP trips the per-IP bucket.
106
+ const limited = await attempt(fixture, XFF_IP_LIMIT, 'probe_ip_limit');
107
+ assert.strictEqual(limited.status, 429, `attempt ${ip_limit + 1} must be 429`);
108
+ assert.strictEqual(limited.error, ERROR_RATE_LIMIT_EXCEEDED, '429 body must carry the canonical rate_limit_exceeded reason');
109
+ assert(limited.retry_after !== undefined && limited.retry_after > 0, '429 body must carry a positive numeric retry_after');
110
+ assert(limited.retry_after_header !== null, 'Retry-After header must be present on the 429');
111
+ assert.strictEqual(Number(limited.retry_after_header), Math.ceil(limited.retry_after), 'Retry-After header must equal ceil(retry_after)');
112
+ });
113
+ test('distinct X-Forwarded-For IPs get independent buckets (XFF resolution honored)', async () => {
114
+ const fixture = await setup_test();
115
+ // Exhaust one forwarded IP to its cap (own username, so the per-account
116
+ // bucket never confounds — these stay well under the account cap).
117
+ for (let i = 0; i < ip_limit; i++) {
118
+ await attempt(fixture, XFF_SEGREGATION_EXHAUST, 'probe_xff');
119
+ }
120
+ const exhausted = await attempt(fixture, XFF_SEGREGATION_EXHAUST, 'probe_xff');
121
+ assert.strictEqual(exhausted.status, 429, 'the exhausted forwarded IP must be rate-limited');
122
+ // A DIFFERENT forwarded IP is a fresh bucket → not limited. If the backend
123
+ // ignored XFF and keyed on the loopback TCP peer, this would land in the
124
+ // already-exhausted peer bucket and 429 — so a 401 proves the limiter keys
125
+ // on the resolved X-Forwarded-For client IP.
126
+ const fresh = await attempt(fixture, XFF_SEGREGATION_FRESH, 'probe_xff');
127
+ assert.strictEqual(fresh.status, 401, "a fresh forwarded IP must not inherit another IP's limit");
128
+ });
129
+ });
130
+ };
@@ -23,6 +23,23 @@ export interface SpineStubBackendConfigOptions {
23
23
  * var. When neither is set the preset throws.
24
24
  */
25
25
  readonly binary_path?: string;
26
+ /**
27
+ * Enable the per-IP + per-account login rate limiters on the stub
28
+ * (`FUZ_LOGIN_RATE_LIMIT_ENABLED=true`). Off by default — the standard cross
29
+ * suites fire many loopback logins a live limiter would 429. Set `true` only
30
+ * for the dedicated login-security cross project (`global_setup_login_security.ts`).
31
+ * Pair with `trusted_proxies` so the limiter keys on the resolved
32
+ * `X-Forwarded-For` client IP. Mirrors `TsSpineBackendConfigOptions.enable_login_rate_limit`.
33
+ */
34
+ readonly enable_login_rate_limit?: boolean;
35
+ /**
36
+ * Comma-separated trusted-proxy allowlist passed as `FUZ_TRUSTED_PROXIES`
37
+ * (e.g. `'127.0.0.1,::1'`). Unset by default (the stub leaves XFF parsing
38
+ * off, keying on the raw TCP peer). The login-security project sets the
39
+ * loopback set so the limiter keys on the `X-Forwarded-For` client IP — the
40
+ * TS spine binary wires the equivalent set unconditionally.
41
+ */
42
+ readonly trusted_proxies?: string;
26
43
  }
27
44
  /**
28
45
  * Build the `BackendConfig` for `testing_spine_stub`. Resolves the binary
@@ -1 +1 @@
1
- {"version":3,"file":"rust_spine_stub_backend_config.d.ts","sourceRoot":"../src/lib/","sources":["../../../src/lib/testing/cross_backend/rust_spine_stub_backend_config.ts"],"names":[],"mappings":"AAAA,OAAO,sBAAsB,CAAC;AAuC9B,OAAO,KAAK,EAAC,aAAa,EAAC,MAAM,qBAAqB,CAAC;AAQvD,uGAAuG;AACvG,eAAO,MAAM,uBAAuB,oCAAoC,CAAC;AAEzE;;;;;GAKG;AACH,eAAO,MAAM,wCAAwC,6CAA6C,CAAC;AAenG,kGAAkG;AAClG,eAAO,MAAM,4BAA4B,OAAO,CAAC;AAEjD,0FAA0F;AAC1F,eAAO,MAAM,oCAAoC,sDACG,CAAC;AAErD,MAAM,WAAW,6BAA6B;IAC7C,8DAA8D;IAC9D,QAAQ,CAAC,IAAI,CAAC,EAAE,MAAM,CAAC;IACvB,+EAA+E;IAC/E,QAAQ,CAAC,YAAY,CAAC,EAAE,MAAM,CAAC;IAC/B;;;OAGG;IACH,QAAQ,CAAC,WAAW,CAAC,EAAE,MAAM,CAAC;CAC9B;AAED;;;;;;;;;;GAUG;AACH,eAAO,MAAM,8BAA8B,GAC1C,UAAS,6BAAkC,KACzC,aAwCF,CAAC"}
1
+ {"version":3,"file":"rust_spine_stub_backend_config.d.ts","sourceRoot":"../src/lib/","sources":["../../../src/lib/testing/cross_backend/rust_spine_stub_backend_config.ts"],"names":[],"mappings":"AAAA,OAAO,sBAAsB,CAAC;AAuC9B,OAAO,KAAK,EAAC,aAAa,EAAC,MAAM,qBAAqB,CAAC;AASvD,uGAAuG;AACvG,eAAO,MAAM,uBAAuB,oCAAoC,CAAC;AAEzE;;;;;GAKG;AACH,eAAO,MAAM,wCAAwC,6CAA6C,CAAC;AAenG,kGAAkG;AAClG,eAAO,MAAM,4BAA4B,OAAO,CAAC;AAEjD,0FAA0F;AAC1F,eAAO,MAAM,oCAAoC,sDACG,CAAC;AAErD,MAAM,WAAW,6BAA6B;IAC7C,8DAA8D;IAC9D,QAAQ,CAAC,IAAI,CAAC,EAAE,MAAM,CAAC;IACvB,+EAA+E;IAC/E,QAAQ,CAAC,YAAY,CAAC,EAAE,MAAM,CAAC;IAC/B;;;OAGG;IACH,QAAQ,CAAC,WAAW,CAAC,EAAE,MAAM,CAAC;IAC9B;;;;;;;OAOG;IACH,QAAQ,CAAC,uBAAuB,CAAC,EAAE,OAAO,CAAC;IAC3C;;;;;;OAMG;IACH,QAAQ,CAAC,eAAe,CAAC,EAAE,MAAM,CAAC;CAClC;AAED;;;;;;;;;;GAUG;AACH,eAAO,MAAM,8BAA8B,GAC1C,UAAS,6BAAkC,KACzC,aA+CF,CAAC"}
@@ -36,7 +36,7 @@ import '../assert_dev_env.js';
36
36
  import { fileURLToPath } from 'node:url';
37
37
  import { build_test_backend_paths } from './build_test_backend_paths.js';
38
38
  import { SPINE_EXPECTED_SCHEMA_URL } from './default_spine_surface.js';
39
- import { make_default_rust_backend_config, rust_default_capabilities, } from './default_backend_configs.js';
39
+ import { LOGIN_RATE_LIMIT_ENABLED_ENV, make_default_rust_backend_config, rust_default_capabilities, } from './default_backend_configs.js';
40
40
  /** Env var naming the prebuilt `testing_spine_stub` binary. Required when `binary_path` is omitted. */
41
41
  export const RUST_SPINE_STUB_BIN_ENV = 'FUZ_TESTING_RUST_SPINE_STUB_BIN';
42
42
  /**
@@ -74,7 +74,7 @@ export const RUST_SPINE_STUB_DEFAULT_DATABASE_URL = 'postgres://localhost/fuz_ap
74
74
  * @throws Error when no binary path is available.
75
75
  */
76
76
  export const rust_spine_stub_backend_config = (options = {}) => {
77
- const { port = RUST_SPINE_STUB_DEFAULT_PORT, database_url = RUST_SPINE_STUB_DEFAULT_DATABASE_URL, binary_path = process.env[RUST_SPINE_STUB_BIN_ENV], } = options;
77
+ const { port = RUST_SPINE_STUB_DEFAULT_PORT, database_url = RUST_SPINE_STUB_DEFAULT_DATABASE_URL, binary_path = process.env[RUST_SPINE_STUB_BIN_ENV], enable_login_rate_limit, trusted_proxies, } = options;
78
78
  if (!binary_path) {
79
79
  throw new Error(`rust_spine_stub_backend_config: no binary path — set ${RUST_SPINE_STUB_BIN_ENV} to a prebuilt ` +
80
80
  '`testing_spine_stub` binary (build it with `cargo build -p testing_spine_stub --release`) ' +
@@ -105,6 +105,11 @@ export const rust_spine_stub_backend_config = (options = {}) => {
105
105
  // file the TS spine reads, so the two backends share one cross-impl
106
106
  // contract.
107
107
  [RUST_SPINE_STUB_EXPECTED_SCHEMA_PATH_ENV]: fileURLToPath(SPINE_EXPECTED_SCHEMA_URL),
108
+ // Login-security project opt-ins (off by default): enable the login
109
+ // limiters + trust the loopback proxy so the limiter keys on the
110
+ // resolved `X-Forwarded-For` IP. The stub reads both directly.
111
+ ...(enable_login_rate_limit ? { [LOGIN_RATE_LIMIT_ENABLED_ENV]: 'true' } : {}),
112
+ ...(trusted_proxies !== undefined ? { FUZ_TRUSTED_PROXIES: trusted_proxies } : {}),
108
113
  },
109
114
  });
110
115
  };
@@ -1,5 +1,5 @@
1
1
  import '../assert_dev_env.js';
2
- import { Uuid } from '@fuzdev/fuz_util/id.js';
2
+ import { Uuid } from '@fuzdev/fuz_util/id.ts';
3
3
  import type { TestAccount } from '../app_server.js';
4
4
  import type { BackendCapabilities } from './capabilities.js';
5
5
  import { type FetchTransport } from '../transports/fetch_transport.js';
@@ -22,7 +22,7 @@ import '../assert_dev_env.js';
22
22
  * @module
23
23
  */
24
24
  import { z } from 'zod';
25
- import { Uuid } from '@fuzdev/fuz_util/id.js';
25
+ import { Uuid } from '@fuzdev/fuz_util/id.ts';
26
26
  import { DAEMON_TOKEN_HEADER } from '../../auth/daemon_token.js';
27
27
  import { USERNAME_LENGTH_MAX } from '../../primitive_schemas.js';
28
28
  import { DEFAULT_TEST_PASSWORD } from '../test_credentials.js';
@@ -66,8 +66,8 @@ import '../assert_dev_env.js';
66
66
  * @module
67
67
  */
68
68
  import { z } from 'zod';
69
- import { Uuid } from '@fuzdev/fuz_util/id.js';
70
- import { fact_hash_bytes, FactHashSchema } from '@fuzdev/fuz_util/fact_hash.js';
69
+ import { Uuid } from '@fuzdev/fuz_util/id.ts';
70
+ import { fact_hash_bytes, FactHashSchema } from '@fuzdev/fuz_util/fact_hash.ts';
71
71
  import { rpc_action } from '../../actions/action_rpc.js';
72
72
  import { query_put_fact } from '../../db/fact_queries.js';
73
73
  import { ROLE_ADMIN, ROLE_KEEPER } from '../../auth/role_schema.js';
@@ -28,7 +28,7 @@ import '../assert_dev_env.js';
28
28
  */
29
29
  import type { Context, Hono } from 'hono';
30
30
  import type { UpgradeWebSocket } from 'hono/ws';
31
- import { type Logger as LoggerType } from '@fuzdev/fuz_util/log.js';
31
+ import { type Logger as LoggerType } from '@fuzdev/fuz_util/log.ts';
32
32
  import type { RuntimeDeps } from '../../runtime/deps.js';
33
33
  /**
34
34
  * Adapter-built handle to a bound HTTP server.