@fuzdev/fuz_app 0.106.1 → 0.108.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 (115) hide show
  1. package/dist/actions/CLAUDE.md +42 -23
  2. package/dist/actions/action_bridge.d.ts +34 -3
  3. package/dist/actions/action_bridge.d.ts.map +1 -1
  4. package/dist/actions/action_bridge.js +32 -1
  5. package/dist/actions/action_rpc.d.ts +5 -4
  6. package/dist/actions/action_rpc.d.ts.map +1 -1
  7. package/dist/actions/action_rpc.js +4 -3
  8. package/dist/actions/action_spec.d.ts +8 -2
  9. package/dist/actions/action_spec.d.ts.map +1 -1
  10. package/dist/actions/action_spec.js +5 -2
  11. package/dist/actions/compile_action_registry.d.ts +26 -14
  12. package/dist/actions/compile_action_registry.d.ts.map +1 -1
  13. package/dist/actions/compile_action_registry.js +29 -14
  14. package/dist/actions/perform_action.d.ts +26 -16
  15. package/dist/actions/perform_action.d.ts.map +1 -1
  16. package/dist/actions/perform_action.js +69 -44
  17. package/dist/actions/register_action_ws.d.ts +4 -4
  18. package/dist/actions/register_action_ws.js +5 -5
  19. package/dist/actions/register_ws_endpoint.d.ts +13 -5
  20. package/dist/actions/register_ws_endpoint.d.ts.map +1 -1
  21. package/dist/actions/register_ws_endpoint.js +15 -6
  22. package/dist/auth/CLAUDE.md +16 -9
  23. package/dist/auth/account_route_schema.d.ts +10 -1
  24. package/dist/auth/account_route_schema.d.ts.map +1 -1
  25. package/dist/auth/account_route_schema.js +10 -1
  26. package/dist/auth/account_routes.d.ts +18 -4
  27. package/dist/auth/account_routes.d.ts.map +1 -1
  28. package/dist/auth/account_routes.js +32 -29
  29. package/dist/auth/api_token_queries.d.ts +4 -0
  30. package/dist/auth/api_token_queries.d.ts.map +1 -1
  31. package/dist/auth/api_token_queries.js +5 -1
  32. package/dist/auth/audit_log_route_schema.d.ts.map +1 -1
  33. package/dist/auth/audit_log_route_schema.js +11 -1
  34. package/dist/auth/audit_log_routes.d.ts.map +1 -1
  35. package/dist/auth/audit_log_routes.js +4 -9
  36. package/dist/auth/auth_guard_resolver.d.ts +48 -21
  37. package/dist/auth/auth_guard_resolver.d.ts.map +1 -1
  38. package/dist/auth/auth_guard_resolver.js +60 -26
  39. package/dist/auth/bearer_auth.d.ts +10 -7
  40. package/dist/auth/bearer_auth.d.ts.map +1 -1
  41. package/dist/auth/bearer_auth.js +13 -22
  42. package/dist/auth/bootstrap_routes.d.ts +12 -2
  43. package/dist/auth/bootstrap_routes.d.ts.map +1 -1
  44. package/dist/auth/bootstrap_routes.js +15 -9
  45. package/dist/auth/middleware.d.ts +0 -3
  46. package/dist/auth/middleware.d.ts.map +1 -1
  47. package/dist/auth/middleware.js +11 -10
  48. package/dist/auth/migrations.d.ts.map +1 -1
  49. package/dist/auth/migrations.js +24 -0
  50. package/dist/auth/request_context.d.ts +54 -24
  51. package/dist/auth/request_context.d.ts.map +1 -1
  52. package/dist/auth/request_context.js +117 -39
  53. package/dist/auth/session_queries.d.ts +13 -2
  54. package/dist/auth/session_queries.d.ts.map +1 -1
  55. package/dist/auth/session_queries.js +14 -3
  56. package/dist/auth/signup_routes.d.ts +10 -0
  57. package/dist/auth/signup_routes.d.ts.map +1 -1
  58. package/dist/auth/signup_routes.js +14 -11
  59. package/dist/auth/token_scope.d.ts +109 -21
  60. package/dist/auth/token_scope.d.ts.map +1 -1
  61. package/dist/auth/token_scope.js +106 -21
  62. package/dist/http/CLAUDE.md +23 -11
  63. package/dist/http/auth_shape.d.ts +58 -3
  64. package/dist/http/auth_shape.d.ts.map +1 -1
  65. package/dist/http/auth_shape.js +63 -5
  66. package/dist/http/error_schemas.d.ts +25 -2
  67. package/dist/http/error_schemas.d.ts.map +1 -1
  68. package/dist/http/error_schemas.js +37 -15
  69. package/dist/http/route_spec.d.ts +29 -26
  70. package/dist/http/route_spec.d.ts.map +1 -1
  71. package/dist/http/route_spec.js +12 -16
  72. package/dist/http/surface_query.d.ts +2 -2
  73. package/dist/http/surface_query.js +2 -2
  74. package/dist/rate_limiter.d.ts +36 -3
  75. package/dist/rate_limiter.d.ts.map +1 -1
  76. package/dist/rate_limiter.js +36 -3
  77. package/dist/server/app_server.d.ts +27 -10
  78. package/dist/server/app_server.d.ts.map +1 -1
  79. package/dist/server/app_server.js +30 -20
  80. package/dist/server/app_server_context.d.ts +10 -2
  81. package/dist/server/app_server_context.d.ts.map +1 -1
  82. package/dist/server/serve_fact_route.d.ts.map +1 -1
  83. package/dist/server/serve_fact_route.js +12 -10
  84. package/dist/testing/CLAUDE.md +60 -11
  85. package/dist/testing/app_server.d.ts.map +1 -1
  86. package/dist/testing/app_server.js +6 -4
  87. package/dist/testing/cross_backend/conformance_case.js +1 -1
  88. package/dist/testing/cross_backend/default_spine_surface.d.ts.map +1 -1
  89. package/dist/testing/cross_backend/default_spine_surface.js +9 -8
  90. package/dist/testing/cross_backend/login_security.d.ts.map +1 -1
  91. package/dist/testing/cross_backend/login_security.js +59 -0
  92. package/dist/testing/cross_backend/session_cap.d.ts +19 -0
  93. package/dist/testing/cross_backend/session_cap.d.ts.map +1 -0
  94. package/dist/testing/cross_backend/session_cap.js +93 -0
  95. package/dist/testing/cross_backend/testing_backdoor.d.ts.map +1 -1
  96. package/dist/testing/cross_backend/testing_backdoor.js +6 -5
  97. package/dist/testing/cross_backend/token_scope_surface.d.ts +9 -0
  98. package/dist/testing/cross_backend/token_scope_surface.d.ts.map +1 -0
  99. package/dist/testing/cross_backend/token_scope_surface.js +170 -0
  100. package/dist/testing/integration.d.ts.map +1 -1
  101. package/dist/testing/integration.js +3 -2
  102. package/dist/testing/middleware.d.ts +2 -5
  103. package/dist/testing/middleware.d.ts.map +1 -1
  104. package/dist/testing/middleware.js +5 -5
  105. package/dist/testing/rate_limiting.d.ts +10 -16
  106. package/dist/testing/rate_limiting.d.ts.map +1 -1
  107. package/dist/testing/rate_limiting.js +11 -95
  108. package/dist/testing/rpc_attack_surface.d.ts.map +1 -1
  109. package/dist/testing/rpc_attack_surface.js +4 -2
  110. package/dist/testing/stubs.d.ts.map +1 -1
  111. package/dist/testing/stubs.js +3 -1
  112. package/dist/testing/testing_rate_limiter.d.ts +7 -1
  113. package/dist/testing/testing_rate_limiter.d.ts.map +1 -1
  114. package/dist/testing/testing_rate_limiter.js +7 -1
  115. package/package.json +1 -1
@@ -81,16 +81,23 @@ export const create_app_server = async (options) => {
81
81
  const { deps } = backend;
82
82
  const { log } = deps;
83
83
  // Rate limiter defaults (undefined = default, null = disable)
84
- const ip_rate_limiter = options.ip_rate_limiter === undefined ? create_rate_limiter() : options.ip_rate_limiter;
84
+ // One instance per surface — see `AppServerOptions.login_ip_rate_limiter`
85
+ // for why these are not a single shared bucket.
86
+ const login_ip_rate_limiter = options.login_ip_rate_limiter === undefined
87
+ ? create_rate_limiter()
88
+ : options.login_ip_rate_limiter;
89
+ const signup_ip_rate_limiter = options.signup_ip_rate_limiter === undefined
90
+ ? create_rate_limiter()
91
+ : options.signup_ip_rate_limiter;
92
+ const bootstrap_ip_rate_limiter = options.bootstrap_ip_rate_limiter === undefined
93
+ ? create_rate_limiter()
94
+ : options.bootstrap_ip_rate_limiter;
85
95
  const login_account_rate_limiter = options.login_account_rate_limiter === undefined
86
96
  ? create_rate_limiter(default_login_account_rate_limit)
87
97
  : options.login_account_rate_limiter;
88
98
  const signup_account_rate_limiter = options.signup_account_rate_limiter === undefined
89
99
  ? create_rate_limiter(default_login_account_rate_limit)
90
100
  : options.signup_account_rate_limiter;
91
- const bearer_ip_rate_limiter = options.bearer_ip_rate_limiter === undefined
92
- ? create_rate_limiter()
93
- : options.bearer_ip_rate_limiter;
94
101
  const action_ip_rate_limiter = options.action_ip_rate_limiter === undefined
95
102
  ? create_rate_limiter(default_action_ip_rate_limit)
96
103
  : options.action_ip_rate_limiter;
@@ -115,7 +122,6 @@ export const create_app_server = async (options) => {
115
122
  const auth_middleware = await create_auth_middleware_specs(deps, {
116
123
  allowed_origins: options.allowed_origins,
117
124
  session_options: options.session_options,
118
- bearer_ip_rate_limiter,
119
125
  daemon_token_state: options.daemon_token_state
120
126
  });
121
127
  let middleware_specs = [proxy_spec, ...auth_middleware];
@@ -147,7 +153,9 @@ export const create_app_server = async (options) => {
147
153
  backend,
148
154
  bootstrap_status,
149
155
  session_options: options.session_options,
150
- ip_rate_limiter,
156
+ login_ip_rate_limiter,
157
+ signup_ip_rate_limiter,
158
+ bootstrap_ip_rate_limiter,
151
159
  login_account_rate_limiter,
152
160
  signup_account_rate_limiter,
153
161
  action_ip_rate_limiter,
@@ -166,7 +174,7 @@ export const create_app_server = async (options) => {
166
174
  session_options: options.session_options,
167
175
  bootstrap_status,
168
176
  on_bootstrap: options.bootstrap.mode === 'live' ? options.bootstrap.on_bootstrap : undefined,
169
- ip_rate_limiter
177
+ bootstrap_ip_rate_limiter
170
178
  });
171
179
  const prefix = options.bootstrap.route_prefix ?? '/api/account';
172
180
  factory_routes.push(...prefix_route_specs(prefix, bootstrap_routes));
@@ -243,19 +251,21 @@ export const create_app_server = async (options) => {
243
251
  });
244
252
  }
245
253
  }
246
- if (ip_rate_limiter === null) {
247
- config_diagnostics.push({
248
- level: 'warning',
249
- category: 'config',
250
- message: 'IP rate limiter explicitly disabled (null)'
251
- });
252
- }
253
- if (bearer_ip_rate_limiter === null) {
254
- config_diagnostics.push({
255
- level: 'warning',
256
- category: 'config',
257
- message: 'Bearer IP rate limiter explicitly disabled (null)'
258
- });
254
+ // One diagnostic per surface — a deployment that disables login's limiter
255
+ // but keeps signup's is a different posture than one that disables both,
256
+ // and a single collapsed warning hid which surface was open.
257
+ for (const [name, limiter] of [
258
+ ['login', login_ip_rate_limiter],
259
+ ['signup', signup_ip_rate_limiter],
260
+ ['bootstrap', bootstrap_ip_rate_limiter]
261
+ ]) {
262
+ if (limiter === null) {
263
+ config_diagnostics.push({
264
+ level: 'warning',
265
+ category: 'config',
266
+ message: `${name} IP rate limiter explicitly disabled (null)`
267
+ });
268
+ }
259
269
  }
260
270
  if (config_diagnostics.length) {
261
271
  surface_spec.surface.diagnostics = [...surface_spec.surface.diagnostics, ...config_diagnostics];
@@ -25,8 +25,16 @@ export interface AppServerContext {
25
25
  backend: AppBackend;
26
26
  bootstrap_status: BootstrapStatus;
27
27
  session_options: SessionOptions<string>;
28
- /** Shared IP rate limiter (from options). `null` when not configured. */
29
- ip_rate_limiter: RateLimiter | null;
28
+ /**
29
+ * Per-IP login + password-change rate limiter (from options). `null` when
30
+ * not configured. One instance per auth surface — see
31
+ * `AppServerOptions.login_ip_rate_limiter` for why these aren't shared.
32
+ */
33
+ login_ip_rate_limiter: RateLimiter | null;
34
+ /** Per-IP signup rate limiter (from options). `null` when not configured. */
35
+ signup_ip_rate_limiter: RateLimiter | null;
36
+ /** Per-IP bootstrap rate limiter (from options). `null` when not configured. */
37
+ bootstrap_ip_rate_limiter: RateLimiter | null;
30
38
  /** Per-account login rate limiter (from options). `null` when not configured. */
31
39
  login_account_rate_limiter: RateLimiter | null;
32
40
  /** Per-account signup rate limiter (from options). `null` when not configured. */
@@ -1 +1 @@
1
- {"version":3,"file":"app_server_context.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/server/app_server_context.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AAEH,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,iBAAiB,CAAC;AAC/C,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,kBAAkB,CAAC;AACnD,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,6BAA6B,CAAC;AACnE,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,2BAA2B,CAAC;AAChE,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,oBAAoB,CAAC;AACtD,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,+BAA+B,CAAC;AAEjE,8CAA8C;AAC9C,MAAM,WAAW,gBAAgB;IAChC,IAAI,EAAE,OAAO,CAAC;IACd,OAAO,EAAE,UAAU,CAAC;IACpB,gBAAgB,EAAE,eAAe,CAAC;IAClC,eAAe,EAAE,cAAc,CAAC,MAAM,CAAC,CAAC;IACxC,yEAAyE;IACzE,eAAe,EAAE,WAAW,GAAG,IAAI,CAAC;IACpC,iFAAiF;IACjF,0BAA0B,EAAE,WAAW,GAAG,IAAI,CAAC;IAC/C,kFAAkF;IAClF,2BAA2B,EAAE,WAAW,GAAG,IAAI,CAAC;IAChD,uGAAuG;IACvG,sBAAsB,EAAE,WAAW,GAAG,IAAI,CAAC;IAC3C,0GAA0G;IAC1G,2BAA2B,EAAE,WAAW,GAAG,IAAI,CAAC;IAChD;;;;OAIG;IACH,SAAS,EAAE,WAAW,GAAG,IAAI,CAAC;CAC9B"}
1
+ {"version":3,"file":"app_server_context.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/server/app_server_context.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AAEH,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,iBAAiB,CAAC;AAC/C,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,kBAAkB,CAAC;AACnD,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,6BAA6B,CAAC;AACnE,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,2BAA2B,CAAC;AAChE,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,oBAAoB,CAAC;AACtD,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,+BAA+B,CAAC;AAEjE,8CAA8C;AAC9C,MAAM,WAAW,gBAAgB;IAChC,IAAI,EAAE,OAAO,CAAC;IACd,OAAO,EAAE,UAAU,CAAC;IACpB,gBAAgB,EAAE,eAAe,CAAC;IAClC,eAAe,EAAE,cAAc,CAAC,MAAM,CAAC,CAAC;IACxC;;;;OAIG;IACH,qBAAqB,EAAE,WAAW,GAAG,IAAI,CAAC;IAC1C,6EAA6E;IAC7E,sBAAsB,EAAE,WAAW,GAAG,IAAI,CAAC;IAC3C,gFAAgF;IAChF,yBAAyB,EAAE,WAAW,GAAG,IAAI,CAAC;IAC9C,iFAAiF;IACjF,0BAA0B,EAAE,WAAW,GAAG,IAAI,CAAC;IAC/C,kFAAkF;IAClF,2BAA2B,EAAE,WAAW,GAAG,IAAI,CAAC;IAChD,uGAAuG;IACvG,sBAAsB,EAAE,WAAW,GAAG,IAAI,CAAC;IAC3C,0GAA0G;IAC1G,2BAA2B,EAAE,WAAW,GAAG,IAAI,CAAC;IAChD;;;;OAIG;IACH,SAAS,EAAE,WAAW,GAAG,IAAI,CAAC;CAC9B"}
@@ -1 +1 @@
1
- {"version":3,"file":"serve_fact_route.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/server/serve_fact_route.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAwFG;AAMH,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,yBAAyB,CAAC;AAgBtD,OAAO,EAAuC,KAAK,SAAS,EAAE,MAAM,uBAAuB,CAAC;AAE5F,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,iBAAiB,CAAC;AAM/C,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,cAAc,CAAC;AA6DjD,MAAM,WAAW,+BAA+B;IAC/C;;;;;;OAMG;IACH,IAAI,EAAE,OAAO,CAAC;IACd,kFAAkF;IAClF,SAAS,EAAE,MAAM,CAAC;IAClB;;;;;;;OAOG;IACH,OAAO,CAAC,EAAE,YAAY,CAAC;IACvB,GAAG,EAAE,MAAM,CAAC;CACZ;AAgHD;;;;;;;;;;;GAWG;AACH,eAAO,MAAM,iCAAiC,GAC7C,SAAS,+BAA+B,KACtC,SAgDF,CAAC;AAEF;;;;;;;;;;;;;;;GAeG;AACH,eAAO,MAAM,4BAA4B,GACxC,SAAS,+BAA+B,KACtC,SAwCF,CAAC"}
1
+ {"version":3,"file":"serve_fact_route.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/server/serve_fact_route.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAwFG;AAMH,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,yBAAyB,CAAC;AAetD,OAAO,EAAuC,KAAK,SAAS,EAAE,MAAM,uBAAuB,CAAC;AAE5F,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,iBAAiB,CAAC;AAM/C,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,cAAc,CAAC;AA6DjD,MAAM,WAAW,+BAA+B;IAC/C;;;;;;OAMG;IACH,IAAI,EAAE,OAAO,CAAC;IACd,kFAAkF;IAClF,SAAS,EAAE,MAAM,CAAC;IAClB;;;;;;;OAOG;IACH,OAAO,CAAC,EAAE,YAAY,CAAC;IACvB,GAAG,EAAE,MAAM,CAAC;CACZ;AAgHD;;;;;;;;;;;GAWG;AACH,eAAO,MAAM,iCAAiC,GAC7C,SAAS,+BAA+B,KACtC,SAgDF,CAAC;AAEF;;;;;;;;;;;;;;;GAeG;AACH,eAAO,MAAM,4BAA4B,GACxC,SAAS,+BAA+B,KACtC,SA0CF,CAAC"}
@@ -93,7 +93,7 @@ import { join } from 'node:path';
93
93
  import { FactHashSchema } from '@fuzdev/fuz_util/hash_schemas.ts';
94
94
  import { Uuid } from '@fuzdev/fuz_util/id.ts';
95
95
  import { z } from 'zod';
96
- import { build_request_context, get_request_context, has_role, token_scope_surface_denial } from '../auth/request_context.js';
96
+ import { build_request_context, get_request_context, has_role } from '../auth/request_context.js';
97
97
  import { ROLE_ADMIN } from '../auth/role_schema.js';
98
98
  import { ActingActor } from '../http/auth_shape.js';
99
99
  import { ACCOUNT_ID_KEY } from '../hono_context.js';
@@ -320,7 +320,17 @@ export const create_serve_fact_route_spec = (options) => {
320
320
  return {
321
321
  method: 'GET',
322
322
  path: '/api/facts/:hash',
323
- auth: { account: 'required', actor: 'required', roles: [ROLE_ADMIN] },
323
+ auth: {
324
+ account: 'required',
325
+ actor: 'required',
326
+ roles: [ROLE_ADMIN],
327
+ // Rule 3 — a narrowed token gets no bare-hash read. This route
328
+ // serves *any* stored fact by hash with no per-reference cell check
329
+ // (that is the per-cell route's job), so it is the broad capability
330
+ // of the pair; gating it keeps a narrowed token to the cell-scoped
331
+ // route its RPC methods can justify.
332
+ required_scope: 'surface:fact_bare'
333
+ },
324
334
  description: 'Serve content-addressed fact bytes by bare hash — admin only. Non-admin reads go through GET /api/cells/:cell_id/facts/:hash (per-reference).',
325
335
  params: bare_hash_params_schema,
326
336
  // `actor: 'required'` (implied by the admin role gate) needs the
@@ -334,14 +344,6 @@ export const create_serve_fact_route_spec = (options) => {
334
344
  errors: params_400_error,
335
345
  handler: async (c, route) => {
336
346
  const { hash } = get_route_params(c);
337
- // Rule 3 — a narrowed token gets no bare-hash read. This route serves
338
- // *any* stored fact by hash with no per-reference cell check (that is
339
- // the per-cell route's job), so it is the broad capability of the
340
- // pair; gating it keeps a narrowed token to the cell-scoped route its
341
- // RPC methods can justify.
342
- const denied = token_scope_surface_denial(c, 'fact_bare');
343
- if (denied)
344
- return denied;
345
347
  // Defense-in-depth: the auth phase already gated this on the admin
346
348
  // role, but re-check the resolved context so a mounting/auth-shape
347
349
  // regression fails closed instead of serving by bare hash.
@@ -457,8 +457,8 @@ file that imports from `middleware.ts` gets these mocks globally. Pair with
457
457
  - `BearerAuthTestOptions`, `BearerAuthTestCase` — test-case table shape for the bearer auth runner.
458
458
  - `create_bearer_auth_mocks(tc)` — configures the module-level mocks per test case; returns spy references.
459
459
  - `TEST_CLIENT_IP = '127.0.0.1'` — IP set by the proxy stub in `create_bearer_auth_test_app`.
460
- - `create_bearer_auth_test_app(tc, ip_rate_limiter?)` — Hono app with bearer middleware + echo route at `/api/test` returning `{ok, account_id, credential_type, api_token_id, request_context_set}` — the account-grain identity bearer auth writes, plus a flag for tests that pre-populate `REQUEST_CONTEXT_KEY` via `pre_context`.
461
- - `describe_bearer_auth_cases(suite_name, cases, ip_rate_limiter?)` — table-driven runner; one `test()` per case; asserts status, error, body fields, `api_token_id`, context preservation.
460
+ - `create_bearer_auth_test_app(tc)` — Hono app with bearer middleware + echo route at `/api/test` returning `{ok, account_id, credential_type, api_token_id, request_context_set}` — the account-grain identity bearer auth writes, plus a flag for tests that pre-populate `REQUEST_CONTEXT_KEY` via `pre_context`.
461
+ - `describe_bearer_auth_cases(suite_name, cases)` — table-driven runner; one `test()` per case; asserts status, error, body fields, `api_token_id`, context preservation.
462
462
  - `TEST_MIDDLEWARE_PATH = '/api/test'` — path used by the echo route in the stack factory.
463
463
  - `create_test_middleware_stack_app(options?)` — real proxy + origin + bearer middleware for integration-shape testing. Echo route returns `{ok, client_ip, has_context}`.
464
464
 
@@ -571,17 +571,19 @@ Options: `{setup_test, surface_source, capabilities, sensitive_fields?, admin_on
571
571
 
572
572
  ### `rate_limiting.ts` — `describe_rate_limiting_tests`
573
573
 
574
- Three test groups:
574
+ Two test groups:
575
575
 
576
576
  1. IP rate limiting on login — fires `max_attempts + 1` requests; last should be 429 with `RateLimitError` body + valid `Retry-After` header.
577
577
  2. Per-account rate limiting on login — same username exhausts the bucket; a different username is not blocked.
578
- 3. Bearer auth IP rate limiting — invalid bearer tokens exhaust the IP bucket via the `account_verify` RPC method.
578
+
579
+ No bearer group — the bearer path carries no rate limiter on either spine
580
+ (`auth/bearer_auth.ts`), which is also why `rpc_endpoints` is optional here.
579
581
 
580
582
  Each group asserts its required route exists with a descriptive message.
581
583
  Creates a tight rate limiter (default `max_attempts: 2`, `window_ms: 60_000`)
582
584
  per test and disposes it in `finally`.
583
585
 
584
- Options: `{session_options, create_route_specs, rpc_endpoints, app_options?, db_factories?, max_attempts?}`.
586
+ Options: `{session_options, create_route_specs, rpc_endpoints?, app_options?, db_factories?, max_attempts?}`.
585
587
  Reads inputs directly from the options bag instead of going through the
586
588
  `setup_test` fixture protocol — the per-test rate-limiter overrides need a
587
589
  fresh `TestApp` per test that the single-fixture model can't carry.
@@ -1346,6 +1348,42 @@ are `src/test/auth/cell_crud_parity.db.test.ts`
1346
1348
  complete action list plus its own spec. Daemon-token-gated like its siblings;
1347
1349
  the Rust mirror is `fuz_testing::create_testing_action_manifest_action_spec`.
1348
1350
 
1351
+ ### Token-scope surface parity — `cross_backend/token_scope_surface.ts`
1352
+
1353
+ `describe_token_scope_surface_cross_tests({setup_test, capabilities, rpc_path?,
1354
+ sse_path?})` — the `surface:<name>` twin of the conformance slate's
1355
+ `token_scope_cases`, which cover only the `rpc:<method>` arm. Token scoping's
1356
+ RPC-only rule (_a narrowed api token reaches no non-RPC surface_) is the
1357
+ load-bearing half of the design and had **no** cross-impl gate: the schema gate
1358
+ can't see it (no column), the action-manifest gate can't see it (no method), and
1359
+ the spec-derived suites can't see it (REST routes, off the declared RPC surface).
1360
+
1361
+ Mints a narrowed bearer through the production `account_token_create` path and
1362
+ fires it at the two surfaces whose denial **body** is observable over plain
1363
+ HTTP — the audit SSE stream (`capabilities.sse`) and the admin-only bare-hash
1364
+ fact read (`capabilities.fact_serving`) — asserting the exact flat
1365
+ `{error: 'token_scope_required', required_scope: 'surface:<name>'}` both spines
1366
+ contract on (`token_scope_denied_body` ↔
1367
+ `fuz_auth::token_scope_surface_denied_response`).
1368
+
1369
+ The fifth case is the one with no prior pin anywhere: **scope outranks role**. A
1370
+ narrowed token on an account that _also_ lacks the gating role must hear
1371
+ `token_scope_required`, not `insufficient_permissions` — the observable the
1372
+ TS↔Rust ordering divergence produced before both spines converged on running the
1373
+ surface gate ahead of the role gate. Every other scoped-token probe uses the
1374
+ keeper's token, whose account holds the role, so both orderings look identical
1375
+ there; that is why the divergence survived as long as it did. Verified to
1376
+ discriminate: dropping the route's `required_scope` turns it into a 403
1377
+ `insufficient_permissions` + `required_roles`, exactly the pre-convergence shape.
1378
+
1379
+ Two non-vacuity controls: the narrowed token must still reach the RPC method it
1380
+ _does_ name, and a full bearer must get past the scope gate on the same route.
1381
+ The WS upgrade is deliberately omitted — `create_ws_transport` threads cookies,
1382
+ not bearer headers, and a refused upgrade's body isn't readable, so it can't
1383
+ serve a body-parity gate (its per-spine coverage stays in the two surface
1384
+ censuses). Cross-process only; fuz_app's own wiring is
1385
+ `src/test/cross_backend/token_scope_surface.cross.test.ts`.
1386
+
1349
1387
  ### Origin verification parity — `cross_backend/origin.ts`
1350
1388
 
1351
1389
  `describe_origin_cross_tests({setup_test, capabilities, rpc_path?})` — the
@@ -1396,9 +1434,9 @@ read) it fires three principals over real HTTP: **anonymous** → 401, **session
1396
1434
  proving the daemon-token gate that fences each backdoor action holds end-to-end
1397
1435
  on the real dispatcher (the spec-derived `describe_rpc_attack_surface_tests`
1398
1436
  never enumerates them because they're off the declared surface). Each method is
1399
- sent with **valid** params so the session/bearer cases clear the 400
1400
- input-validation phase and reach the 403 credential gate (order is
1401
- 401 → 400 → 403); the handler never runs. Cited property: `docs/security.md`
1437
+ sent with **valid** params so each case varies only the credential (validation
1438
+ runs after the gates — order is 401 → authz → 403 → 400); the handler never
1439
+ runs. Cited property: `docs/security.md`
1402
1440
  §Test Backdoor Actions. Cross-process only (the `_testing_*` actions are
1403
1441
  mounted on the spawned binary, not the in-process app — like the ws/sse
1404
1442
  suites); ungated, since every cross backend that uses
@@ -1459,7 +1497,7 @@ pay the 250ms denial floor.
1459
1497
 
1460
1498
  `describe_login_security_cross_tests({setup_test, login_path?})` — the imperative
1461
1499
  suite pinning **login rate limiting + `X-Forwarded-For` client-IP resolution**
1462
- over real HTTP on both spines. Two cases:
1500
+ over real HTTP on both spines:
1463
1501
 
1464
1502
  - **per-IP limit fires** — `default_login_ip_rate_limit.max_attempts` (5, the
1465
1503
  shared cap on both impls) failed logins from one forwarded IP → 401, the next →
@@ -1469,15 +1507,26 @@ over real HTTP on both spines. Two cases:
1469
1507
  - **XFF keying** — exhaust one forwarded IP to 429, then a _distinct_ forwarded IP
1470
1508
  → 401 (not 429), proving the limiter keys on the resolved `X-Forwarded-For` IP,
1471
1509
  not the loopback TCP peer (a backend ignoring XFF would 429 the fresh IP too).
1510
+ - **a success does not refund the per-IP budget** — the distributed-spray
1511
+ backstop. Failed guesses against distinct usernames from one forwarded IP,
1512
+ interleaved with the attacker's own successful logins, must still 429 within a
1513
+ bounded total-request budget. Both spines used to clear the IP bucket on
1514
+ success; only the account-grain bucket is forgiven now. The **budget bound** is
1515
+ what makes this adversarial — a decrement-instead-of-zero implementation loops
1516
+ to the iteration cap and fails here. Either leg may trip: the aggregate governs
1517
+ the address, so once it fills the attacker's own login is refused too.
1472
1518
 
1473
1519
  **Cross-process only**, on a **dedicated dual-spawn project** (`cross_backend_security`
1474
1520
  via `global_setup_login_security.ts`) — the limiters can't be enabled on a backend
1475
1521
  the standard suites share (they fire many loopback logins a live limiter would
1476
1522
  429), so the standard backends keep every limiter null. Determinism without a
1477
1523
  limiter reset: each case uses its own forwarded IP (a fresh bucket) plus its own
1478
- non-existent username. Zero Rust code change — the stub's `FUZ_LOGIN_RATE_LIMIT_ENABLED`
1524
+ non-existent username; the spray case additionally mints its attacker account
1525
+ over the setup path (loopback, no XFF), so minting can't touch the bucket under
1526
+ test. Zero Rust code change — the stub's `FUZ_LOGIN_RATE_LIMIT_ENABLED`
1479
1527
  and `FUZ_TRUSTED_PROXIES` env-gates were already wired; the TS side env-gates the
1480
- limiters in the binary, and `create_spine_route_specs` honors `ctx.ip_rate_limiter`.
1528
+ limiters in the binary, and `create_spine_route_specs` honors
1529
+ `ctx.login_ip_rate_limiter`.
1481
1530
  The in-process counterparts already exist (`describe_rate_limiting_tests` plus the
1482
1531
  proxy middleware tests), so there's no in-process leg. `npm run test:cross:security`.
1483
1532
  Cited property: `docs/security.md` §"Rate Limiting" + §"Trusted Proxy / Client IP".
@@ -1 +1 @@
1
- {"version":3,"file":"app_server.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/testing/app_server.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"app_server.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/testing/app_server.ts"],"names":[],"mappings":"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"}
@@ -276,10 +276,11 @@ export const create_test_app = async (options) => {
276
276
  allowed_origins: [/^http:\/\/localhost/],
277
277
  proxy: { trusted_proxies: ['127.0.0.1'], get_connection_ip: () => '127.0.0.1' },
278
278
  env_schema: z.object({}),
279
- ip_rate_limiter: null,
279
+ login_ip_rate_limiter: null,
280
+ signup_ip_rate_limiter: null,
281
+ bootstrap_ip_rate_limiter: null,
280
282
  login_account_rate_limiter: null,
281
283
  signup_account_rate_limiter: null,
282
- bearer_ip_rate_limiter: null,
283
284
  await_pending_effects: true,
284
285
  daemon_token_state,
285
286
  rpc_endpoints: options.rpc_endpoints,
@@ -402,10 +403,11 @@ export const create_test_app_for_bootstrap = async (options) => {
402
403
  allowed_origins: [/^http:\/\/localhost/],
403
404
  proxy: { trusted_proxies: ['127.0.0.1'], get_connection_ip: () => '127.0.0.1' },
404
405
  env_schema: z.object({}),
405
- ip_rate_limiter: null,
406
+ login_ip_rate_limiter: null,
407
+ signup_ip_rate_limiter: null,
408
+ bootstrap_ip_rate_limiter: null,
406
409
  login_account_rate_limiter: null,
407
410
  signup_account_rate_limiter: null,
408
- bearer_ip_rate_limiter: null,
409
411
  await_pending_effects: true,
410
412
  daemon_token_state,
411
413
  rpc_endpoints: options.rpc_endpoints,
@@ -141,7 +141,7 @@ export const ConformanceCaseExpectation = z.strictObject({
141
141
  description: 'Expected error reason — pass the IMPORTED `ERROR_*` constant from ' +
142
142
  '`http/error_schemas.ts`, never a string literal. Asserted against the RPC ' +
143
143
  '`error.data.reason` (when the denial carries one) or the REST flat-body ' +
144
- '`error` field. The pre-validation 401 carries `data.reason` too; a denial ' +
144
+ '`error` field. The pre-authorization 401 carries `data.reason` too; a denial ' +
145
145
  'that genuinely omits it falls back to the `status` assertion to pin the class.'
146
146
  }),
147
147
  fields: z
@@ -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,EAAE,MAAM,EAAE,MAAM,yBAAyB,CAAC;AAItD,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,8CAA8C,CAAC;AACvF,OAAO,EAAsB,KAAK,gBAAgB,EAAE,MAAM,2BAA2B,CAAC;AAEtF,OAAO,EAAyB,KAAK,cAAc,EAAE,MAAM,8BAA8B,CAAC;AAI1F,OAAO,EAAsB,KAAK,SAAS,EAAE,MAAM,0BAA0B,CAAC;AAC9E,OAAO,KAAK,EAAE,cAAc,EAAE,eAAe,EAAE,MAAM,uBAAuB,CAAC;AAC7E,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,oCAAoC,CAAC;AAe3E;;;GAGG;AACH,eAAO,MAAM,qBAAqB,EAAE,cAAc,CAAC,MAAM,CAAwC,CAAC;AAElG;;;;;;;;;;GAUG;AACH,eAAO,MAAM,WAAW,EAAE,gBAGxB,CAAC;AAEH,+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;;;;;;;;;GASG;AACH,eAAO,MAAM,6BAA6B,GAAI,MAAM,MAAM,KAAG,SAC+B,CAAC;AAE7F;;;;;;;;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,EAAE,MAAM,EAAE,MAAM,yBAAyB,CAAC;AAItD,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,8CAA8C,CAAC;AACvF,OAAO,EAAsB,KAAK,gBAAgB,EAAE,MAAM,2BAA2B,CAAC;AAEtF,OAAO,EAAyB,KAAK,cAAc,EAAE,MAAM,8BAA8B,CAAC;AAI1F,OAAO,EAAsB,KAAK,SAAS,EAAE,MAAM,0BAA0B,CAAC;AAC9E,OAAO,KAAK,EAAE,cAAc,EAAE,eAAe,EAAE,MAAM,uBAAuB,CAAC;AAC7E,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,oCAAoC,CAAC;AAe3E;;;GAGG;AACH,eAAO,MAAM,qBAAqB,EAAE,cAAc,CAAC,MAAM,CAAwC,CAAC;AAElG;;;;;;;;;;GAUG;AACH,eAAO,MAAM,WAAW,EAAE,gBAGxB,CAAC;AAEH,+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,CAgC/E,CAAC;AAEF;;;;;;;;;GASG;AACH,eAAO,MAAM,6BAA6B,GAAI,MAAM,MAAM,KAAG,SAC+B,CAAC;AAE7F;;;;;;;;GAQG;AACH,eAAO,MAAM,yBAAyB,QAAO,cAM1C,CAAC"}
@@ -72,20 +72,21 @@ export const create_spine_route_specs = (ctx) => [
72
72
  ...create_account_route_specs(ctx.deps, {
73
73
  session_options: spine_session_options,
74
74
  // Honor the limiters the server was assembled with (on `AppServerContext`
75
- // per the `ip_rate_limiter` / `login_account_rate_limiter` TSDoc) rather
76
- // than hardcoding `null`. The standard spine binary passes `null` (the
77
- // cross suites fire many loopback logins and must not trip a limiter), so
78
- // this is behavior-preserving there; the dedicated login-security cross
79
- // backend passes real limiters to exercise the 429 + `Retry-After` path
80
- // and XFF-keyed bucketing over the wire (see `login_security.ts`).
81
- ip_rate_limiter: ctx.ip_rate_limiter,
75
+ // per the `login_ip_rate_limiter` / `login_account_rate_limiter` TSDoc)
76
+ // rather than hardcoding `null`. The standard spine binary passes `null`
77
+ // (the cross suites fire many loopback logins and must not trip a
78
+ // limiter), so this is behavior-preserving there; the dedicated
79
+ // login-security cross backend passes real limiters to exercise the 429 +
80
+ // `Retry-After` path and XFF-keyed bucketing over the wire (see
81
+ // `login_security.ts`).
82
+ login_ip_rate_limiter: ctx.login_ip_rate_limiter,
82
83
  login_account_rate_limiter: ctx.login_account_rate_limiter,
83
84
  login_fail_floor_ms: 0,
84
85
  bootstrap_status: ctx.bootstrap_status
85
86
  }),
86
87
  ...create_signup_route_specs(ctx.deps, {
87
88
  session_options: spine_session_options,
88
- ip_rate_limiter: null,
89
+ signup_ip_rate_limiter: null,
89
90
  signup_account_rate_limiter: null,
90
91
  // Disable the denial floor so the identity-parity suite's accepted
91
92
  // cases (valid username/email, no invite → 403 no-matching-invite)
@@ -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;AAuD9B,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,YAAY,CAAC;AAE5C,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,IAqGF,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;AA8D9B,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,YAAY,CAAC;AAE5C,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;AA0BD,eAAO,MAAM,mCAAmC,GAC/C,SAAS,6BAA6B,KACpC,IA2KF,CAAC"}
@@ -24,6 +24,13 @@ import '../assert_dev_env.js';
24
24
  * not `429`). A backend that ignored XFF and keyed on the (loopback) TCP
25
25
  * peer would 429 the fresh-IP request too — so the `401` proves the limiter
26
26
  * keys on the resolved `X-Forwarded-For` client IP.
27
+ * - **a success does not refund the per-IP budget** — the distributed-spray
28
+ * backstop. Failed guesses against *distinct* usernames from one forwarded
29
+ * IP, interleaved with the attacker's own successful logins, must still 429
30
+ * within a bounded total-request budget. Both spines used to clear the IP
31
+ * bucket on success, which made that budget unbounded: one credential (or,
32
+ * under open signup, none) bought unlimited guesses against arbitrary victims
33
+ * from a single address. Only the account-grain bucket is forgiven now.
27
34
  *
28
35
  * **Determinism without a limiter reset.** Limiter state is in-memory and the
29
36
  * per-test `_testing_reset` wipes only the DB, never the buckets — so each
@@ -58,6 +65,7 @@ import { default_login_account_rate_limit, default_login_ip_rate_limit } from '.
58
65
  const XFF_IP_LIMIT = '203.0.113.1';
59
66
  const XFF_SEGREGATION_EXHAUST = '203.0.113.2';
60
67
  const XFF_SEGREGATION_FRESH = '203.0.113.3';
68
+ const XFF_SPRAY = '203.0.113.4';
61
69
  /** A non-routable, non-existent identifier — login resolves to the not-found path (records the bucket, returns 401). */
62
70
  const PROBE_PASSWORD = 'login_security_wrong_password';
63
71
  export const describe_login_security_cross_tests = (options) => {
@@ -93,6 +101,20 @@ export const describe_login_security_cross_tests = (options) => {
93
101
  retry_after_header: res.headers.get('retry-after')
94
102
  };
95
103
  };
104
+ /**
105
+ * POST a login expected to *succeed*, on a fresh transport with the given
106
+ * `X-Forwarded-For`. The mirror of `attempt` — used to prove that a success
107
+ * does not refund the resolved IP's bucket.
108
+ */
109
+ const succeed = async (fixture, forwarded_for, username, password) => {
110
+ const res = await fixture.fresh_transport()(login_path, {
111
+ method: 'POST',
112
+ headers: { 'content-type': 'application/json', 'x-forwarded-for': forwarded_for },
113
+ body: JSON.stringify({ username, password })
114
+ });
115
+ await res.text();
116
+ return res.status;
117
+ };
96
118
  describe('login rate limiting + trusted-proxy parity', () => {
97
119
  test(`per-IP login limit fires 429 + Retry-After after ${ip_limit} attempts from one forwarded IP`, async () => {
98
120
  const fixture = await setup_test();
@@ -126,5 +148,42 @@ export const describe_login_security_cross_tests = (options) => {
126
148
  const fresh = await attempt(fixture, XFF_SEGREGATION_FRESH, 'probe_xff');
127
149
  assert.strictEqual(fresh.status, 401, "a fresh forwarded IP must not inherit another IP's limit");
128
150
  });
151
+ test('a success does not refund the per-IP budget (distributed-spray backstop)', async () => {
152
+ const fixture = await setup_test();
153
+ // The attacker's own account. Minting rides the setup path from
154
+ // loopback with no XFF, so it lands on a different bucket than
155
+ // XFF_SPRAY and can't confound the count.
156
+ const attacker = await fixture.create_account({
157
+ username: 'spray_attacker',
158
+ password_value: 'spray_attacker_password'
159
+ });
160
+ let budget = 0;
161
+ let limited = false;
162
+ for (let i = 0; i < ip_limit * 3; i++) {
163
+ // Their own successful login — the move that used to zero the
164
+ // whole IP window — then one guess against a fresh victim
165
+ // username, so no per-account bucket accumulates and only the
166
+ // IP aggregate can stop this.
167
+ const own = await succeed(fixture, XFF_SPRAY, attacker.account.username, 'spray_attacker_password');
168
+ budget++;
169
+ // Either leg may trip: the aggregate governs the *address*, so
170
+ // once it fills even the attacker's own login is refused. That
171
+ // is the property — no move from this IP buys more attempts.
172
+ if (own === 429) {
173
+ limited = true;
174
+ break;
175
+ }
176
+ assert.strictEqual(own, 200, "the attacker's own login must succeed");
177
+ const guess = await attempt(fixture, XFF_SPRAY, `spray_victim_${i}`);
178
+ budget++;
179
+ if (guess.status === 429) {
180
+ limited = true;
181
+ break;
182
+ }
183
+ assert.strictEqual(guess.status, 401, 'a guess against an unknown user must 401');
184
+ }
185
+ assert(limited, 'the per-IP aggregate must eventually 429 the spray');
186
+ assert(budget <= ip_limit * 2 + 2, `interleaved successes must not extend the per-IP budget (spent ${budget})`);
187
+ });
129
188
  });
130
189
  };
@@ -0,0 +1,19 @@
1
+ import '../assert_dev_env.js';
2
+ import type { SetupTest } from './setup.js';
3
+ /** Options for the session-cap parity suite. */
4
+ export interface SessionCapCrossTestOptions {
5
+ /** Per-test fixture producer (cross-process only — see the module doc). */
6
+ readonly setup_test: SetupTest;
7
+ /**
8
+ * The cap both spines enforce. Defaults to `DEFAULT_MAX_SESSIONS` — the TS
9
+ * route default and the Rust `const`. A consumer that overrides
10
+ * `max_sessions` on the TS side passes its value here.
11
+ */
12
+ readonly max_sessions?: number;
13
+ /** REST login route path. Default `/api/account/login`. */
14
+ readonly login_path?: string;
15
+ /** REST account-status route path. Default `/api/account/status`. */
16
+ readonly status_path?: string;
17
+ }
18
+ export declare const describe_session_cap_cross_tests: (options: SessionCapCrossTestOptions) => void;
19
+ //# sourceMappingURL=session_cap.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"session_cap.d.ts","sourceRoot":"../src/lib/","sources":["../../../src/lib/testing/cross_backend/session_cap.ts"],"names":[],"mappings":"AAAA,OAAO,sBAAsB,CAAC;AAwD9B,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,YAAY,CAAC;AAE5C,gDAAgD;AAChD,MAAM,WAAW,0BAA0B;IAC1C,2EAA2E;IAC3E,QAAQ,CAAC,UAAU,EAAE,SAAS,CAAC;IAC/B;;;;OAIG;IACH,QAAQ,CAAC,YAAY,CAAC,EAAE,MAAM,CAAC;IAC/B,2DAA2D;IAC3D,QAAQ,CAAC,UAAU,CAAC,EAAE,MAAM,CAAC;IAC7B,qEAAqE;IACrE,QAAQ,CAAC,WAAW,CAAC,EAAE,MAAM,CAAC;CAC9B;AAED,eAAO,MAAM,gCAAgC,GAAI,SAAS,0BAA0B,KAAG,IA6DtF,CAAC"}
@@ -0,0 +1,93 @@
1
+ import '../assert_dev_env.js';
2
+ /**
3
+ * Cross-backend parity suite for the **per-account concurrent-session cap**
4
+ * over real HTTP.
5
+ *
6
+ * The cap is a shipped control on the TS spine (`query_session_enforce_limit`,
7
+ * `DEFAULT_MAX_SESSIONS`) that the Rust spine went without — its session INSERT
8
+ * carried no eviction, so every Rust-backed deployment served unlimited
9
+ * concurrent sessions per account. That divergence survived because nothing crossed the
10
+ * wire on it — the TS half is pinned by in-process tests
11
+ * (`session_middleware.lifecycle.db.test.ts`, `session_token_limits.integration.db.test.ts`)
12
+ * whose Rust counterparts simply didn't exist to fail. This suite is the pin
13
+ * that runs on *both*, so the next spine to lose the cap fails here rather than
14
+ * in production. Two properties:
15
+ *
16
+ * - **the cap bounds concurrent sessions, by eviction not refusal** — logging in
17
+ * `max_sessions + 1` times succeeds every time (a login is never denied for
18
+ * being the one over the line), and the newest cookie resolves afterward.
19
+ * - **the evicted session is the oldest** — the first login's cookie no longer
20
+ * authenticates once the cap is exceeded. A spine with no cap authenticates it
21
+ * fine, which is exactly the defect this catches.
22
+ *
23
+ * **Why `max_sessions + 1` logins and not `max_sessions`.** The starting session
24
+ * count isn't zero and isn't guaranteed equal across impls — `create_account`
25
+ * seeds its own session on the Rust cradle. Overshooting by one guarantees at
26
+ * least one eviction whatever the baseline is, and the *oldest* login is the
27
+ * first to go either way. Asserting on the first and last sessions rather than
28
+ * on a row count keeps the case wire-observable (no DB channel) and
29
+ * baseline-independent.
30
+ *
31
+ * Both surfaces are flat REST (`POST /api/account/login`, `GET /api/account/status`)
32
+ * on every spine, so this is an imperative suite (not a `conformance_table` row)
33
+ * — the sibling of `cookie_attributes.ts` / `origin.ts` / `login_security.ts`.
34
+ * **Cross-process only**, and load-bearingly so: each login is held on its own
35
+ * `fresh_transport` and identified by that transport's cookie jar, which
36
+ * in-process is a jar-less passthrough. That also keeps the suite free of a
37
+ * fourth `Set-Cookie` parser — reading the raw header is `cookie_attributes.ts`'s
38
+ * job, and this case only needs "does this login still authenticate".
39
+ *
40
+ * What it does **not** pin: concurrent creators. Two simultaneous logins can
41
+ * each evict against a stale count under Read Committed and both commit above
42
+ * the cap — see `query_session_enforce_limit`'s race note. That needs a
43
+ * barrier-based test against one backend, not a parity case.
44
+ *
45
+ * `$lib`-free by contract (relative specifiers only), like the sibling
46
+ * cross-backend suites.
47
+ *
48
+ * @module
49
+ */
50
+ import { describe, test, assert } from 'vitest';
51
+ import { DEFAULT_MAX_SESSIONS } from '../../auth/account_route_schema.js';
52
+ import { DEFAULT_TEST_PASSWORD } from '../test_credentials.js';
53
+ export const describe_session_cap_cross_tests = (options) => {
54
+ const { setup_test } = options;
55
+ const max_sessions = options.max_sessions ?? DEFAULT_MAX_SESSIONS;
56
+ const login_path = options.login_path ?? '/api/account/login';
57
+ const status_path = options.status_path ?? '/api/account/status';
58
+ // Fresh-keeper-per-test wipes the DB between tests, so a literal username
59
+ // never collides (see `setup.ts`).
60
+ const username = 'session_cap_user';
61
+ describe('per-account session cap parity', () => {
62
+ test(`logging in ${max_sessions + 1} times evicts the oldest session (newest cookie resolves, first does not)`, async () => {
63
+ const fixture = await setup_test();
64
+ await fixture.create_account({ username, password_value: DEFAULT_TEST_PASSWORD });
65
+ // One transport per login, each with its own empty jar, so each ends up
66
+ // holding exactly that login's session cookie and re-sends it on any
67
+ // later request. That's why this suite is cross-process only: in-process
68
+ // `fresh_transport` is a jar-less passthrough and every session would
69
+ // look alike. Held oldest-first.
70
+ const sessions = [];
71
+ for (let i = 0; i < max_sessions + 1; i++) {
72
+ const transport = fixture.fresh_transport();
73
+ const res = await transport(login_path, {
74
+ method: 'POST',
75
+ headers: { 'content-type': 'application/json' },
76
+ body: JSON.stringify({ username, password: DEFAULT_TEST_PASSWORD })
77
+ });
78
+ assert.strictEqual(res.status, 200, `login ${i + 1}/${max_sessions + 1} must succeed — the cap evicts, it never refuses`);
79
+ assert.ok(transport.cookies().length > 0, `login ${i + 1} must set a session cookie on its jar`);
80
+ sessions.push(transport);
81
+ }
82
+ /** `GET /status` on one login's transport: 200 authenticated, 401 not. */
83
+ const status_on = async (transport) => {
84
+ const res = await transport(status_path, { method: 'GET' });
85
+ await res.text();
86
+ return res.status;
87
+ };
88
+ assert.strictEqual(await status_on(sessions[sessions.length - 1]), 200, 'the newest session must still resolve — the cap must evict, not refuse');
89
+ assert.strictEqual(await status_on(sessions[0]), 401, `the oldest session must be evicted once the account exceeds ${max_sessions} sessions ` +
90
+ '(a spine with no cap authenticates this cookie)');
91
+ });
92
+ });
93
+ };
@@ -1 +1 @@
1
- {"version":3,"file":"testing_backdoor.d.ts","sourceRoot":"../src/lib/","sources":["../../../src/lib/testing/cross_backend/testing_backdoor.ts"],"names":[],"mappings":"AAAA,OAAO,sBAAsB,CAAC;AA2D9B,OAAO,KAAK,EAAE,wBAAwB,EAAe,MAAM,YAAY,CAAC;AAqExE,kEAAkE;AAClE,MAAM,MAAM,+BAA+B,GAAG,wBAAwB,CAAC;AAEvE,eAAO,MAAM,qCAAqC,GACjD,SAAS,+BAA+B,KACtC,IAiCF,CAAC"}
1
+ {"version":3,"file":"testing_backdoor.d.ts","sourceRoot":"../src/lib/","sources":["../../../src/lib/testing/cross_backend/testing_backdoor.ts"],"names":[],"mappings":"AAAA,OAAO,sBAAsB,CAAC;AA4D9B,OAAO,KAAK,EAAE,wBAAwB,EAAe,MAAM,YAAY,CAAC;AAqExE,kEAAkE;AAClE,MAAM,MAAM,+BAA+B,GAAG,wBAAwB,CAAC;AAEvE,eAAO,MAAM,qCAAqC,GACjD,SAAS,+BAA+B,KACtC,IAiCF,CAAC"}