@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
@@ -186,6 +186,30 @@ export const auth_migrations = [
186
186
  WHERE scope IS NULL`);
187
187
  await db.query('ALTER TABLE api_token ALTER COLUMN scope SET NOT NULL');
188
188
  }
189
+ },
190
+ // Index the column every bearer request looks up.
191
+ //
192
+ // `full_auth_schema` indexes `api_token(account_id)` and nothing else, so
193
+ // `query_validate_api_token`'s `WHERE token_hash = $1` — the lookup on the
194
+ // hot path of *every* bearer-authenticated request, valid or not — has been
195
+ // a sequential scan since the table shipped. The asymmetry is accidental:
196
+ // `auth_session` gets the same access pattern for free because its session
197
+ // hash *is* the primary key.
198
+ //
199
+ // UNIQUE rather than a plain index, because both spines resolve a token with
200
+ // an at-most-one read (`query_one` / `query_opt`). Two rows sharing a hash
201
+ // would mean the two impls could silently disagree about which credential
202
+ // answered; the constraint makes that unrepresentable instead of merely
203
+ // improbable.
204
+ //
205
+ // The name must match the Rust twin's `api_token_hash_unique_index` byte for
206
+ // byte or the `_testing_migration_tracker` parity gate fails and the
207
+ // swap-freely invariant breaks.
208
+ {
209
+ name: 'api_token_hash_unique_index',
210
+ up: async (db) => {
211
+ await db.query('CREATE UNIQUE INDEX IF NOT EXISTS idx_api_token_hash ON api_token(token_hash)');
212
+ }
189
213
  }
190
214
  ];
191
215
  /** Pre-composed migration namespace for auth tables. */
@@ -38,7 +38,6 @@ import type { Context, MiddlewareHandler } from 'hono';
38
38
  import type { Logger } from '@fuzdev/fuz_util/log.ts';
39
39
  import { type Account, type Actor, type RoleGrant } from './account_schema.js';
40
40
  import type { QueryDeps } from '../db/query_deps.js';
41
- import { type TokenSurface } from './token_scope.js';
42
41
  import type { RouteSpec } from '../http/route_spec.js';
43
42
  import { type RouteAuth } from '../http/auth_shape.js';
44
43
  import { ERROR_ACTOR_REQUIRED, ERROR_ACTOR_NOT_ON_ACCOUNT, ERROR_NO_ACTORS_ON_ACCOUNT, ERROR_ACCOUNT_VANISHED } from '../http/error_schemas.js';
@@ -231,22 +230,48 @@ export declare const resolve_acting_actor: (deps: QueryDeps, account_id: string,
231
230
  */
232
231
  export declare const create_request_context_middleware: (deps: QueryDeps, log: Logger, session_context_key?: string) => MiddlewareHandler;
233
232
  /**
234
- * Refuse a non-RPC spine surface to a narrowed token — **rule 3**, in one place.
235
- *
236
- * Called by every surface rule 3 covers: the bare-hash fact read, the audit SSE
237
- * stream, and the WS upgrade (as pre-upgrade middleware, since
238
- * `upgradeWebSocket`'s callback cannot produce a denial).
239
- *
240
- * An unset `TOKEN_SCOPE_KEY` is the anonymous caller, who holds no credential to
241
- * narrow, so it passes. Sharing that branch is the point — hand-rolled per
242
- * surface it is one `&&` from either refusing every anonymous read or admitting
243
- * an authenticated caller whose middleware forgot to set the key.
233
+ * Refuse a non-RPC surface to a narrowed token — rule 3, for callers that are
234
+ * not route specs. Under the Rust twin's name
235
+ * (`token_scope_surface_denied_response`).
236
+ *
237
+ * The WS upgrade is the spine's one such caller: `upgradeWebSocket`'s callback
238
+ * must return `WSEvents` and so cannot produce a denial, which is why the gate
239
+ * runs as pre-upgrade middleware calling this rather than as a route-spec
240
+ * declaration. A consumer hand-rolling its own upgrade is in the same position
241
+ * and reaches for the same function.
242
+ *
243
+ * Takes the whole capability string, not a bare surface name — one spelling
244
+ * across route-spec declarations and direct calls alike, and the twin of the
245
+ * Rust signature. It needs no parse: the surface arm's identifier is pure label,
246
+ * since rule 3 never asks *which* surface. And it is deliberately not narrowed
247
+ * to the spine's four — the name decides nothing, so there is nothing for a
248
+ * closed set to protect, while a consumer surface would have nothing to pass.
249
+ * fuz_app's own uses are pinned two ways by the surface census: the exact call
250
+ * site, and the scan asserting every `'surface:<name>'` literal in `src/lib`
251
+ * names a surface the spine actually mounts.
244
252
  *
245
253
  * @param c - the request context, after the auth middleware chain
246
- * @param surface - the surface being gated; names itself in the denial
254
+ * @param capability - the `surface:<name>` this route demands; names itself in the denial
247
255
  * @returns the 403 to return, or `null` when the caller may proceed
248
256
  */
249
- export declare const token_scope_surface_denial: (c: Context, surface: TokenSurface) => Response | null;
257
+ export declare const token_scope_surface_denial: (c: Context, capability: string) => Response | null;
258
+ /**
259
+ * Create middleware refusing a route to a credential whose scope does not admit
260
+ * `capability`.
261
+ *
262
+ * The route-spec form, mounted by `fuz_auth_guard_resolver` from
263
+ * `auth.required_scope`. Lands in the `pre_authorization` phase, so a narrowed
264
+ * token is told about its scope rather than about the role it also lacks — and
265
+ * learns nothing about the route's input shape on the way.
266
+ *
267
+ * Parses at construction, so a malformed capability is a registration-time
268
+ * throw rather than a guard that mounts and reports nonsense.
269
+ *
270
+ * @param capability - the capability string this route demands, e.g. `surface:audit_stream`
271
+ * @returns middleware that 403s a credential whose scope lacks it
272
+ * @throws Error if `capability` is not a well-formed `rpc:<method>` / `surface:<name>` string
273
+ */
274
+ export declare const require_token_scope: (capability: string) => MiddlewareHandler;
250
275
  /**
251
276
  * Middleware that requires authentication.
252
277
  *
@@ -284,6 +309,12 @@ export declare const require_role: (roles: ReadonlyArray<string>) => MiddlewareH
284
309
  * Returns 401 if unauthenticated, 403 with
285
310
  * `ERROR_CREDENTIAL_TYPE_REQUIRED` + `required_credential_types` echoing
286
311
  * the spec's allowlist when the wire-side credential isn't in it.
312
+ *
313
+ * Reads only `ACCOUNT_ID_KEY` + `CREDENTIAL_TYPE_KEY`, both set by the auth
314
+ * middleware — never the resolved `RequestContext`. That is why
315
+ * `fuz_auth_guard_resolver` mounts it in the `pre_authorization` phase: a
316
+ * wrong channel is refused before the route resolves an actor for it, so the
317
+ * denial costs no DB work and discloses no account state.
287
318
  * Body shape is symmetric with the role gate (`ERROR_INSUFFICIENT_PERMISSIONS` +
288
319
  * `required_roles`) and matches what the RPC dispatcher's post-auth
289
320
  * gate emits for the same condition. Today's only credential gate is
@@ -403,8 +434,8 @@ export type AuthorizationResult = {
403
434
  * Apply the dispatcher's authorization phase against the flat-record
404
435
  * `RouteAuth` shape. Shared by the route-spec wrapper, the HTTP RPC
405
436
  * dispatcher, and the per-message WS dispatcher. Phase order:
406
- * pre-validation 401 → input validation 400 → authorization phase →
407
- * post-authorization 403.
437
+ * pre-authorization 401 → authorization phase → post-authorization 403 →
438
+ * input validation 400.
408
439
  *
409
440
  * Pure data — the function does not touch a Hono context. Each transport
410
441
  * passes `account_id` (extracted from its own credential surface) and
@@ -419,7 +450,7 @@ export type AuthorizationResult = {
419
450
  * never see a `RequestContext`.
420
451
  * - `account_id == null` on any non-public route → same null
421
452
  * `request_context`. The `'required'` callers were already rejected at
422
- * the pre-validation gate in the dispatcher; only genuine anonymous
453
+ * the pre-authorization gate in the dispatcher; only genuine anonymous
423
454
  * access on an `'optional'` axis lands here.
424
455
  * - `actor === 'none'` → builds account-only context via
425
456
  * `build_account_context`. Null lookup → `account_vanished` 500 failure.
@@ -437,16 +468,15 @@ export declare const apply_authorization_phase: (deps: QueryDeps, account_id: st
437
468
  /**
438
469
  * Create the route-spec authorization handler used by `apply_route_specs`.
439
470
  *
440
- * Reads `acting` off `c.var.validated_input` (or `c.var.validated_query`
441
- * for GET routes) — input validation runs first, so the authorization
442
- * phase consumes the typed Zod field instead of pre-parsing the body.
443
- * Public routes (`auth.account === 'none' && auth.actor === 'none'`)
444
- * skip the phase entirely.
471
+ * Reads the `acting` selector via `read_route_acting` — `c.var.validated_query`
472
+ * on GETs, the raw body on mutations, since input validation now runs after
473
+ * the authority gates. Public routes (`auth.account === 'none' &&
474
+ * auth.actor === 'none'`) skip the phase entirely.
445
475
  *
446
476
  * Per registry-time invariant 2, `auth.actor !== 'none'` ⟺ the input
447
- * (or query) schema declares `acting?: ActingActor` — so reading from
448
- * `c.var.validated_input.acting` / `c.var.validated_query.acting` is
449
- * type-safe.
477
+ * (or query) schema declares `acting?: ActingActor` — so the selector is
478
+ * present on exactly the specs that read it, and input validation is what
479
+ * rejects a malformed one.
450
480
  *
451
481
  * Resolved contexts land on `REQUEST_CONTEXT_KEY` so the post-authorization
452
482
  * REST middleware (`require_role`, `require_credential_types`) reads the
@@ -1 +1 @@
1
- {"version":3,"file":"request_context.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/auth/request_context.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"request_context.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/auth/request_context.ts"],"names":[],"mappings":"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"}
@@ -39,8 +39,9 @@ import { hash_session_token, session_touch_fire_and_forget, query_session_get_va
39
39
  import { query_account_by_id, query_actor_by_id, query_active_actors_by_account } from './account_queries.js';
40
40
  import { query_role_grant_find_active_for_actor } from './role_grant_queries.js';
41
41
  import { ACCOUNT_ID_KEY, AUTH_API_TOKEN_ID_KEY, CREDENTIAL_TYPE_KEY, TOKEN_SCOPE_KEY, TEST_CONTEXT_PRESET_KEY } from '../hono_context.js';
42
- import { token_scope_admits_non_rpc, token_scope_full, token_scope_surface_denied_body } from './token_scope.js';
43
- import { is_public_auth, needs_actor } from '../http/auth_shape.js';
42
+ import { parse_token_scope_capability, token_scope_admits_capability, token_scope_denied_body, token_scope_full } from './token_scope.js';
43
+ import { is_public_auth, needs_actor, parse_acting } from '../http/auth_shape.js';
44
+ import { is_null_schema } from '../http/schema_helpers.js';
44
45
  import { ERROR_AUTHENTICATION_REQUIRED, ERROR_INSUFFICIENT_PERMISSIONS, ERROR_CREDENTIAL_TYPE_REQUIRED, ERROR_ACTOR_REQUIRED, ERROR_ACTOR_NOT_ON_ACCOUNT, ERROR_NO_ACTORS_ON_ACCOUNT, ERROR_ACCOUNT_VANISHED } from '../http/error_schemas.js';
45
46
  /** Hono context variable name for the request context. */
46
47
  export const REQUEST_CONTEXT_KEY = 'request_context';
@@ -240,27 +241,78 @@ export const create_request_context_middleware = (deps, log, session_context_key
240
241
  };
241
242
  };
242
243
  /**
243
- * Refuse a non-RPC spine surface to a narrowed token — **rule 3**, in one place.
244
+ * Refuse a capability the calling credential's scope does not admit.
244
245
  *
245
- * Called by every surface rule 3 covers: the bare-hash fact read, the audit SSE
246
- * stream, and the WS upgrade (as pre-upgrade middleware, since
247
- * `upgradeWebSocket`'s callback cannot produce a denial).
246
+ * The decision every token-scope gate outside the action dispatcher shares.
247
+ * Private because the two exported forms below are the whole surface: a route
248
+ * spec declares `auth.required_scope` and gets `require_token_scope`; anything
249
+ * that is not a route spec calls `token_scope_surface_denial`.
248
250
  *
249
251
  * An unset `TOKEN_SCOPE_KEY` is the anonymous caller, who holds no credential to
250
252
  * narrow, so it passes. Sharing that branch is the point — hand-rolled per
251
253
  * surface it is one `&&` from either refusing every anonymous read or admitting
252
254
  * an authenticated caller whose middleware forgot to set the key.
255
+ */
256
+ const token_scope_denial = (c, capability) => {
257
+ const scope = c.get(TOKEN_SCOPE_KEY);
258
+ if (scope && !token_scope_admits_capability(scope, capability)) {
259
+ return c.json(token_scope_denied_body(capability.capability), 403);
260
+ }
261
+ return null;
262
+ };
263
+ /**
264
+ * Refuse a non-RPC surface to a narrowed token — rule 3, for callers that are
265
+ * not route specs. Under the Rust twin's name
266
+ * (`token_scope_surface_denied_response`).
267
+ *
268
+ * The WS upgrade is the spine's one such caller: `upgradeWebSocket`'s callback
269
+ * must return `WSEvents` and so cannot produce a denial, which is why the gate
270
+ * runs as pre-upgrade middleware calling this rather than as a route-spec
271
+ * declaration. A consumer hand-rolling its own upgrade is in the same position
272
+ * and reaches for the same function.
273
+ *
274
+ * Takes the whole capability string, not a bare surface name — one spelling
275
+ * across route-spec declarations and direct calls alike, and the twin of the
276
+ * Rust signature. It needs no parse: the surface arm's identifier is pure label,
277
+ * since rule 3 never asks *which* surface. And it is deliberately not narrowed
278
+ * to the spine's four — the name decides nothing, so there is nothing for a
279
+ * closed set to protect, while a consumer surface would have nothing to pass.
280
+ * fuz_app's own uses are pinned two ways by the surface census: the exact call
281
+ * site, and the scan asserting every `'surface:<name>'` literal in `src/lib`
282
+ * names a surface the spine actually mounts.
253
283
  *
254
284
  * @param c - the request context, after the auth middleware chain
255
- * @param surface - the surface being gated; names itself in the denial
285
+ * @param capability - the `surface:<name>` this route demands; names itself in the denial
256
286
  * @returns the 403 to return, or `null` when the caller may proceed
257
287
  */
258
- export const token_scope_surface_denial = (c, surface) => {
259
- const scope = c.get(TOKEN_SCOPE_KEY);
260
- if (scope && !token_scope_admits_non_rpc(scope)) {
261
- return c.json(token_scope_surface_denied_body(surface), 403);
288
+ export const token_scope_surface_denial = (c, capability) => token_scope_denial(c, { kind: 'surface', capability });
289
+ /**
290
+ * Create middleware refusing a route to a credential whose scope does not admit
291
+ * `capability`.
292
+ *
293
+ * The route-spec form, mounted by `fuz_auth_guard_resolver` from
294
+ * `auth.required_scope`. Lands in the `pre_authorization` phase, so a narrowed
295
+ * token is told about its scope rather than about the role it also lacks — and
296
+ * learns nothing about the route's input shape on the way.
297
+ *
298
+ * Parses at construction, so a malformed capability is a registration-time
299
+ * throw rather than a guard that mounts and reports nonsense.
300
+ *
301
+ * @param capability - the capability string this route demands, e.g. `surface:audit_stream`
302
+ * @returns middleware that 403s a credential whose scope lacks it
303
+ * @throws Error if `capability` is not a well-formed `rpc:<method>` / `surface:<name>` string
304
+ */
305
+ export const require_token_scope = (capability) => {
306
+ const parsed = parse_token_scope_capability(capability);
307
+ if (parsed === null) {
308
+ throw new Error(`auth.required_scope "${capability}" is not a valid token-scope capability \u2014 expected 'rpc:<method>' or 'surface:<name>', where a surface name is lowercase letters and underscores`);
262
309
  }
263
- return null;
310
+ return async (c, next) => {
311
+ const denied = token_scope_denial(c, parsed);
312
+ if (denied)
313
+ return denied;
314
+ await next();
315
+ };
264
316
  };
265
317
  /**
266
318
  * Middleware that requires authentication.
@@ -315,6 +367,12 @@ export const require_role = (roles) => {
315
367
  * Returns 401 if unauthenticated, 403 with
316
368
  * `ERROR_CREDENTIAL_TYPE_REQUIRED` + `required_credential_types` echoing
317
369
  * the spec's allowlist when the wire-side credential isn't in it.
370
+ *
371
+ * Reads only `ACCOUNT_ID_KEY` + `CREDENTIAL_TYPE_KEY`, both set by the auth
372
+ * middleware — never the resolved `RequestContext`. That is why
373
+ * `fuz_auth_guard_resolver` mounts it in the `pre_authorization` phase: a
374
+ * wrong channel is refused before the route resolves an actor for it, so the
375
+ * denial costs no DB work and discloses no account state.
318
376
  * Body shape is symmetric with the role gate (`ERROR_INSUFFICIENT_PERMISSIONS` +
319
377
  * `required_roles`) and matches what the RPC dispatcher's post-auth
320
378
  * gate emits for the same condition. Today's only credential gate is
@@ -421,8 +479,8 @@ export const build_account_context = async (deps, account_id) => {
421
479
  * Apply the dispatcher's authorization phase against the flat-record
422
480
  * `RouteAuth` shape. Shared by the route-spec wrapper, the HTTP RPC
423
481
  * dispatcher, and the per-message WS dispatcher. Phase order:
424
- * pre-validation 401 → input validation 400 → authorization phase →
425
- * post-authorization 403.
482
+ * pre-authorization 401 → authorization phase → post-authorization 403 →
483
+ * input validation 400.
426
484
  *
427
485
  * Pure data — the function does not touch a Hono context. Each transport
428
486
  * passes `account_id` (extracted from its own credential surface) and
@@ -437,7 +495,7 @@ export const build_account_context = async (deps, account_id) => {
437
495
  * never see a `RequestContext`.
438
496
  * - `account_id == null` on any non-public route → same null
439
497
  * `request_context`. The `'required'` callers were already rejected at
440
- * the pre-validation gate in the dispatcher; only genuine anonymous
498
+ * the pre-authorization gate in the dispatcher; only genuine anonymous
441
499
  * access on an `'optional'` axis lands here.
442
500
  * - `actor === 'none'` → builds account-only context via
443
501
  * `build_account_context`. Null lookup → `account_vanished` 500 failure.
@@ -457,7 +515,7 @@ export const apply_authorization_phase = async (deps, account_id, auth, acting_v
457
515
  if (account_id == null) {
458
516
  // Optional-auth route hit without a credential — leave `RequestContext`
459
517
  // null so the handler can branch on it. `'required'` callers already
460
- // got rejected at the pre-validation gate.
518
+ // got rejected at the pre-authorization gate.
461
519
  return { ok: true, request_context: null };
462
520
  }
463
521
  if (!needs_actor(auth)) {
@@ -496,16 +554,15 @@ export const apply_authorization_phase = async (deps, account_id, auth, acting_v
496
554
  /**
497
555
  * Create the route-spec authorization handler used by `apply_route_specs`.
498
556
  *
499
- * Reads `acting` off `c.var.validated_input` (or `c.var.validated_query`
500
- * for GET routes) — input validation runs first, so the authorization
501
- * phase consumes the typed Zod field instead of pre-parsing the body.
502
- * Public routes (`auth.account === 'none' && auth.actor === 'none'`)
503
- * skip the phase entirely.
557
+ * Reads the `acting` selector via `read_route_acting` — `c.var.validated_query`
558
+ * on GETs, the raw body on mutations, since input validation now runs after
559
+ * the authority gates. Public routes (`auth.account === 'none' &&
560
+ * auth.actor === 'none'`) skip the phase entirely.
504
561
  *
505
562
  * Per registry-time invariant 2, `auth.actor !== 'none'` ⟺ the input
506
- * (or query) schema declares `acting?: ActingActor` — so reading from
507
- * `c.var.validated_input.acting` / `c.var.validated_query.acting` is
508
- * type-safe.
563
+ * (or query) schema declares `acting?: ActingActor` — so the selector is
564
+ * present on exactly the specs that read it, and input validation is what
565
+ * rejects a malformed one.
509
566
  *
510
567
  * Resolved contexts land on `REQUEST_CONTEXT_KEY` so the post-authorization
511
568
  * REST middleware (`require_role`, `require_credential_types`) reads the
@@ -523,7 +580,7 @@ export const create_fuz_authorization_handler = (deps) => {
523
580
  return;
524
581
  if (is_public_auth(spec.auth))
525
582
  return;
526
- const acting_value = needs_actor(spec.auth) ? extract_validated_acting(c) : undefined;
583
+ const acting_value = needs_actor(spec.auth) ? await read_route_acting(c, spec) : undefined;
527
584
  const account_id = c.get(ACCOUNT_ID_KEY) ?? null;
528
585
  const result = await apply_authorization_phase(deps, account_id, spec.auth, acting_value);
529
586
  if (!result.ok)
@@ -538,22 +595,43 @@ export const create_fuz_authorization_handler = (deps) => {
538
595
  };
539
596
  };
540
597
  /**
541
- * Read `acting` off the validated input (or validated query) on the Hono
542
- * context. Input/query validation runs before the authorization phase,
543
- * so this reads a typed Zod field — not the raw body.
544
- *
545
- * Returns `undefined` when `validated_input` / `validated_query` isn't
546
- * set or doesn't carry `acting`. Per registry-time invariant 2, the
547
- * dispatcher only calls this when `auth.actor !== 'none'`, which by
548
- * the biconditional means the input schema declares
549
- * `acting?: ActingActor`.
598
+ * Read the `acting` actor selector for the authorization phase.
599
+ *
600
+ * REST is bi-located: GETs declare `acting` on `query`, mutations on `input`.
601
+ * Query validation still runs ahead of the authorization phase, so the GET
602
+ * half reads a typed Zod field. Input validation now runs *after* the
603
+ * authority gates (nothing about a route's body shape reaches a caller those
604
+ * gates refuse), so the mutation half reads the raw body instead.
605
+ *
606
+ * The raw read mirrors `create_input_validation`'s own skip conditions exactly
607
+ * — GETs and null-input specs — so this never parses a body the route itself
608
+ * would have left alone. That matters for the routes carrying raw bytes rather
609
+ * than JSON (git smart-HTTP, binary uploads): they declare `input: z.null()`,
610
+ * so their stream reaches the handler untouched, and they carry `acting` on
611
+ * `query` regardless.
612
+ *
613
+ * `parse_acting` owns what counts as a selector and why a malformed one reads
614
+ * as omitted.
615
+ *
616
+ * @param c - the request context, after query validation
617
+ * @param spec - the route being dispatched; decides where `acting` can live
618
+ * @returns the actor id, or `undefined` when absent, malformed, or unreadable
550
619
  */
551
- const extract_validated_acting = (c) => {
552
- const validated_input = c.get('validated_input');
553
- if (validated_input && typeof validated_input.acting === 'string')
554
- return validated_input.acting;
620
+ const read_route_acting = async (c, spec) => {
555
621
  const validated_query = c.get('validated_query');
556
622
  if (validated_query && typeof validated_query.acting === 'string')
557
623
  return validated_query.acting;
558
- return undefined;
624
+ if (spec.method === 'GET' || is_null_schema(spec.input))
625
+ return undefined;
626
+ let body;
627
+ try {
628
+ body = await c.req.json();
629
+ }
630
+ catch {
631
+ // Malformed body — input validation answers with the 400.
632
+ return undefined;
633
+ }
634
+ if (typeof body !== 'object' || body === null)
635
+ return undefined;
636
+ return parse_acting(body.acting);
559
637
  };
@@ -113,8 +113,19 @@ export declare const query_session_list_for_account: (deps: QueryDeps, account_i
113
113
  * both commit above `max_sessions`. A transaction is necessary here but not
114
114
  * sufficient. Closing this needs one serialization point per
115
115
  * `(account_id, credential_kind)` — a locked parent row or a transaction-scoped
116
- * advisory lock — taken before count/evict/insert, plus a stable tie-breaker
117
- * alongside `created_at` so the survivors are deterministic.
116
+ * advisory lock — taken before count/evict/insert.
117
+ *
118
+ * Ordering is `created_at DESC, id DESC`. The `id` leg is a **stability**
119
+ * tie-breaker, not a recency one: `created_at` defaults to `NOW()`, the
120
+ * *transaction* timestamp, so two sessions born in one transaction — or in two
121
+ * transactions that started in the same microsecond — tie, and an untied
122
+ * `OFFSET` may keep a different set on two evaluations of the same rows. `id` is
123
+ * the blake3 token hash, so the tie-break is arbitrary but deterministic; it
124
+ * makes the survivors reproducible, it does not make the row just inserted a
125
+ * guaranteed survivor. Only the serialization point above does that.
126
+ *
127
+ * Expired-but-unreaped rows count toward the cap (the predicate is `account_id`
128
+ * alone). Matches the Rust twin `query_session_enforce_limit`.
118
129
  *
119
130
  * @param deps - query dependencies (must be transaction-scoped)
120
131
  * @param account_id - the account to enforce the limit for
@@ -1 +1 @@
1
- {"version":3,"file":"session_queries.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/auth/session_queries.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAGH,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,yBAAyB,CAAC;AAGtD,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,qBAAqB,CAAC;AACrD,OAAO,KAAK,EAAE,WAAW,EAAE,SAAS,EAAE,MAAM,qBAAqB,CAAC;AAElE,kDAAkD;AAClD,eAAO,MAAM,wBAAwB,QAA2B,CAAC;AAEjE,yEAAyE;AACzE,eAAO,MAAM,gCAAgC,QAAsB,CAAC;AAEpE;;;;;;;;GAQG;AACH,eAAO,MAAM,kBAAkB,GAAI,OAAO,MAAM,KAAG,SAElD,CAAC;AAEF;;;;GAIG;AACH,eAAO,MAAM,sBAAsB,QAAO,MAEzC,CAAC;AAEF;;;;;;;;GAQG;AACH,eAAO,MAAM,oBAAoB,GAChC,MAAM,SAAS,EACf,YAAY,MAAM,EAClB,YAAY,MAAM,EAClB,YAAY,IAAI,KACd,OAAO,CAAC,IAAI,CAMd,CAAC;AAEF;;;;;GAKG;AACH,eAAO,MAAM,uBAAuB,GACnC,MAAM,SAAS,EACf,YAAY,MAAM,KAChB,OAAO,CAAC,WAAW,GAAG,SAAS,CAKjC,CAAC;AAEF;;;;;;;;GAQG;AACH,eAAO,MAAM,mBAAmB,GAAU,MAAM,SAAS,EAAE,YAAY,MAAM,KAAG,OAAO,CAAC,IAAI,CAY3F,CAAC;AAEF;;;;;;;;;;;GAWG;AACH,eAAO,MAAM,qCAAqC,GACjD,MAAM,SAAS,EACf,YAAY,MAAM,KAChB,OAAO,CAAC,IAAI,CAEd,CAAC;AAEF;;;;;;;;;;GAUG;AACH,eAAO,MAAM,gCAAgC,GAC5C,MAAM,SAAS,EACf,YAAY,MAAM,EAClB,YAAY,MAAM,KAChB,OAAO,CAAC,OAAO,CAMjB,CAAC;AAEF;;;;;GAKG;AACH,eAAO,MAAM,oCAAoC,GAChD,MAAM,SAAS,EACf,YAAY,MAAM,KAChB,OAAO,CAAC,MAAM,CAMhB,CAAC;AAEF;;GAEG;AACH,eAAO,MAAM,8BAA8B,GAC1C,MAAM,SAAS,EACf,YAAY,MAAM,EAClB,cAAU,KACR,OAAO,CAAC,KAAK,CAAC,WAAW,CAAC,CAK5B,CAAC;AAEF;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA6BG;AACH,eAAO,MAAM,2BAA2B,GACvC,MAAM,SAAS,EACf,YAAY,MAAM,EAClB,cAAc,MAAM,KAClB,OAAO,CAAC,MAAM,CAYhB,CAAC;AAEF;;;;;;GAMG;AACH,eAAO,MAAM,6BAA6B,GACzC,MAAM,SAAS,EACf,cAAW,KACT,OAAO,CAAC,KAAK,CAAC,WAAW,GAAG;IAAE,QAAQ,EAAE,MAAM,CAAA;CAAE,CAAC,CASnD,CAAC;AAEF;;;;;GAKG;AACH,eAAO,MAAM,6BAA6B,GAAU,MAAM,SAAS,KAAG,OAAO,CAAC,MAAM,CAKnF,CAAC;AAEF;;;;;;;;;;;;;GAaG;AACH,eAAO,MAAM,6BAA6B,GACzC,MAAM,SAAS,EACf,YAAY,MAAM,EAClB,iBAAiB,KAAK,CAAC,OAAO,CAAC,IAAI,CAAC,CAAC,GAAG,SAAS,EACjD,KAAK,MAAM,KACT,OAAO,CAAC,IAAI,CAMd,CAAC"}
1
+ {"version":3,"file":"session_queries.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/auth/session_queries.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAGH,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,yBAAyB,CAAC;AAGtD,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,qBAAqB,CAAC;AACrD,OAAO,KAAK,EAAE,WAAW,EAAE,SAAS,EAAE,MAAM,qBAAqB,CAAC;AAElE,kDAAkD;AAClD,eAAO,MAAM,wBAAwB,QAA2B,CAAC;AAEjE,yEAAyE;AACzE,eAAO,MAAM,gCAAgC,QAAsB,CAAC;AAEpE;;;;;;;;GAQG;AACH,eAAO,MAAM,kBAAkB,GAAI,OAAO,MAAM,KAAG,SAElD,CAAC;AAEF;;;;GAIG;AACH,eAAO,MAAM,sBAAsB,QAAO,MAEzC,CAAC;AAEF;;;;;;;;GAQG;AACH,eAAO,MAAM,oBAAoB,GAChC,MAAM,SAAS,EACf,YAAY,MAAM,EAClB,YAAY,MAAM,EAClB,YAAY,IAAI,KACd,OAAO,CAAC,IAAI,CAMd,CAAC;AAEF;;;;;GAKG;AACH,eAAO,MAAM,uBAAuB,GACnC,MAAM,SAAS,EACf,YAAY,MAAM,KAChB,OAAO,CAAC,WAAW,GAAG,SAAS,CAKjC,CAAC;AAEF;;;;;;;;GAQG;AACH,eAAO,MAAM,mBAAmB,GAAU,MAAM,SAAS,EAAE,YAAY,MAAM,KAAG,OAAO,CAAC,IAAI,CAY3F,CAAC;AAEF;;;;;;;;;;;GAWG;AACH,eAAO,MAAM,qCAAqC,GACjD,MAAM,SAAS,EACf,YAAY,MAAM,KAChB,OAAO,CAAC,IAAI,CAEd,CAAC;AAEF;;;;;;;;;;GAUG;AACH,eAAO,MAAM,gCAAgC,GAC5C,MAAM,SAAS,EACf,YAAY,MAAM,EAClB,YAAY,MAAM,KAChB,OAAO,CAAC,OAAO,CAMjB,CAAC;AAEF;;;;;GAKG;AACH,eAAO,MAAM,oCAAoC,GAChD,MAAM,SAAS,EACf,YAAY,MAAM,KAChB,OAAO,CAAC,MAAM,CAMhB,CAAC;AAEF;;GAEG;AACH,eAAO,MAAM,8BAA8B,GAC1C,MAAM,SAAS,EACf,YAAY,MAAM,EAClB,cAAU,KACR,OAAO,CAAC,KAAK,CAAC,WAAW,CAAC,CAK5B,CAAC;AAEF;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAwCG;AACH,eAAO,MAAM,2BAA2B,GACvC,MAAM,SAAS,EACf,YAAY,MAAM,EAClB,cAAc,MAAM,KAClB,OAAO,CAAC,MAAM,CAYhB,CAAC;AAEF;;;;;;GAMG;AACH,eAAO,MAAM,6BAA6B,GACzC,MAAM,SAAS,EACf,cAAW,KACT,OAAO,CAAC,KAAK,CAAC,WAAW,GAAG;IAAE,QAAQ,EAAE,MAAM,CAAA;CAAE,CAAC,CASnD,CAAC;AAEF;;;;;GAKG;AACH,eAAO,MAAM,6BAA6B,GAAU,MAAM,SAAS,KAAG,OAAO,CAAC,MAAM,CAKnF,CAAC;AAEF;;;;;;;;;;;;;GAaG;AACH,eAAO,MAAM,6BAA6B,GACzC,MAAM,SAAS,EACf,YAAY,MAAM,EAClB,iBAAiB,KAAK,CAAC,OAAO,CAAC,IAAI,CAAC,CAAC,GAAG,SAAS,EACjD,KAAK,MAAM,KACT,OAAO,CAAC,IAAI,CAMd,CAAC"}
@@ -143,8 +143,19 @@ export const query_session_list_for_account = async (deps, account_id, limit = 5
143
143
  * both commit above `max_sessions`. A transaction is necessary here but not
144
144
  * sufficient. Closing this needs one serialization point per
145
145
  * `(account_id, credential_kind)` — a locked parent row or a transaction-scoped
146
- * advisory lock — taken before count/evict/insert, plus a stable tie-breaker
147
- * alongside `created_at` so the survivors are deterministic.
146
+ * advisory lock — taken before count/evict/insert.
147
+ *
148
+ * Ordering is `created_at DESC, id DESC`. The `id` leg is a **stability**
149
+ * tie-breaker, not a recency one: `created_at` defaults to `NOW()`, the
150
+ * *transaction* timestamp, so two sessions born in one transaction — or in two
151
+ * transactions that started in the same microsecond — tie, and an untied
152
+ * `OFFSET` may keep a different set on two evaluations of the same rows. `id` is
153
+ * the blake3 token hash, so the tie-break is arbitrary but deterministic; it
154
+ * makes the survivors reproducible, it does not make the row just inserted a
155
+ * guaranteed survivor. Only the serialization point above does that.
156
+ *
157
+ * Expired-but-unreaped rows count toward the cap (the predicate is `account_id`
158
+ * alone). Matches the Rust twin `query_session_enforce_limit`.
148
159
  *
149
160
  * @param deps - query dependencies (must be transaction-scoped)
150
161
  * @param account_id - the account to enforce the limit for
@@ -157,7 +168,7 @@ export const query_session_enforce_limit = async (deps, account_id, max_sessions
157
168
  WHERE id IN (
158
169
  SELECT id FROM auth_session
159
170
  WHERE account_id = $1
160
- ORDER BY created_at DESC
171
+ ORDER BY created_at DESC, id DESC
161
172
  OFFSET $2
162
173
  ) RETURNING id`, [account_id, max_sessions]);
163
174
  return rows.length;
@@ -36,6 +36,16 @@ export declare const DEFAULT_SIGNUP_FAIL_JITTER_MS = 25;
36
36
  * Per-factory configuration for signup route specs.
37
37
  */
38
38
  export interface SignupRouteOptions extends AuthSessionRouteOptions {
39
+ /**
40
+ * Rate limiter for signup attempts, keyed by client IP. Pass `null` to
41
+ * disable. Its own instance, not login's: signup failures bound username
42
+ * *enumeration* (the 403/409 split), not credential guessing, and an
43
+ * open-signup deployment lets any unauthenticated caller spend this budget
44
+ * — which must not cost anyone their login attempts. Never refunded on
45
+ * success (see `RateLimiter.reset`). Mirrors the Rust spine's
46
+ * `signup_ip_rate_limiter`.
47
+ */
48
+ signup_ip_rate_limiter: RateLimiter | null;
39
49
  /** Rate limiter for signup attempts, keyed by submitted username. Pass `null` to disable. */
40
50
  signup_account_rate_limiter: RateLimiter | null;
41
51
  /**
@@ -1 +1 @@
1
- {"version":3,"file":"signup_routes.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/auth/signup_routes.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAaH,OAAO,EAAmB,KAAK,SAAS,EAAE,MAAM,uBAAuB,CAAC;AAExE,OAAO,EAAgC,KAAK,WAAW,EAAE,MAAM,oBAAoB,CAAC;AACpF,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,WAAW,CAAC;AAGlD,OAAO,KAAK,EAAE,uBAAuB,EAAE,MAAM,qBAAqB,CAAC;AAEnE;;;;;;;;;;GAUG;AACH,eAAO,MAAM,4BAA4B,MAAM,CAAC;AAEhD;;;;;;;GAOG;AACH,eAAO,MAAM,6BAA6B,KAAK,CAAC;AAQhD;;GAEG;AACH,MAAM,WAAW,kBAAmB,SAAQ,uBAAuB;IAClE,6FAA6F;IAC7F,2BAA2B,EAAE,WAAW,GAAG,IAAI,CAAC;IAChD;;;;OAIG;IACH,oBAAoB,CAAC,EAAE,MAAM,CAAC;IAC9B;;;;OAIG;IACH,qBAAqB,CAAC,EAAE,MAAM,CAAC;CAC/B;AAID;;;;;;GAMG;AACH,eAAO,MAAM,yBAAyB,GACrC,MAAM,gBAAgB,EACtB,SAAS,kBAAkB,KACzB,KAAK,CAAC,SAAS,CA4KjB,CAAC"}
1
+ {"version":3,"file":"signup_routes.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/auth/signup_routes.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAaH,OAAO,EAAmB,KAAK,SAAS,EAAE,MAAM,uBAAuB,CAAC;AAExE,OAAO,EAAgC,KAAK,WAAW,EAAE,MAAM,oBAAoB,CAAC;AACpF,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,WAAW,CAAC;AAGlD,OAAO,KAAK,EAAE,uBAAuB,EAAE,MAAM,qBAAqB,CAAC;AAEnE;;;;;;;;;;GAUG;AACH,eAAO,MAAM,4BAA4B,MAAM,CAAC;AAEhD;;;;;;;GAOG;AACH,eAAO,MAAM,6BAA6B,KAAK,CAAC;AAQhD;;GAEG;AACH,MAAM,WAAW,kBAAmB,SAAQ,uBAAuB;IAClE;;;;;;;;OAQG;IACH,sBAAsB,EAAE,WAAW,GAAG,IAAI,CAAC;IAC3C,6FAA6F;IAC7F,2BAA2B,EAAE,WAAW,GAAG,IAAI,CAAC;IAChD;;;;OAIG;IACH,oBAAoB,CAAC,EAAE,MAAM,CAAC;IAC9B;;;;OAIG;IACH,qBAAqB,CAAC,EAAE,MAAM,CAAC;CAC/B;AAID;;;;;;GAMG;AACH,eAAO,MAAM,yBAAyB,GACrC,MAAM,gBAAgB,EACtB,SAAS,kBAAkB,KACzB,KAAK,CAAC,SAAS,CAgLjB,CAAC"}
@@ -55,7 +55,7 @@ const signup_fail_delay = (floor_ms, jitter_ms) => {
55
55
  */
56
56
  export const create_signup_route_specs = (deps, options) => {
57
57
  const { keyring, password } = deps;
58
- const { session_options, ip_rate_limiter, signup_account_rate_limiter, signup_fail_floor_ms = DEFAULT_SIGNUP_FAIL_FLOOR_MS, signup_fail_jitter_ms = DEFAULT_SIGNUP_FAIL_JITTER_MS } = options;
58
+ const { session_options, signup_ip_rate_limiter, signup_account_rate_limiter, signup_fail_floor_ms = DEFAULT_SIGNUP_FAIL_FLOOR_MS, signup_fail_jitter_ms = DEFAULT_SIGNUP_FAIL_JITTER_MS } = options;
59
59
  return [
60
60
  {
61
61
  ...create_signup_route_shape({
@@ -63,9 +63,9 @@ export const create_signup_route_specs = (deps, options) => {
63
63
  }),
64
64
  handler: async (c, route) => {
65
65
  // Per-IP rate limit check (before any work). 429 stays fast.
66
- const ip = ip_rate_limiter ? get_client_ip(c) : null;
67
- if (ip_rate_limiter && ip) {
68
- const check = ip_rate_limiter.check(ip);
66
+ const ip = signup_ip_rate_limiter ? get_client_ip(c) : null;
67
+ if (signup_ip_rate_limiter && ip) {
68
+ const check = signup_ip_rate_limiter.check(ip);
69
69
  if (!check.allowed) {
70
70
  return rate_limit_exceeded_response(c, check.retry_after);
71
71
  }
@@ -157,8 +157,8 @@ export const create_signup_route_specs = (deps, options) => {
157
157
  }
158
158
  catch (e) {
159
159
  if (e instanceof NoMatchingInviteError) {
160
- if (ip_rate_limiter && ip)
161
- ip_rate_limiter.record(ip);
160
+ if (signup_ip_rate_limiter && ip)
161
+ signup_ip_rate_limiter.record(ip);
162
162
  if (signup_account_rate_limiter)
163
163
  signup_account_rate_limiter.record(account_key);
164
164
  emit_failure_audit('no_match');
@@ -167,8 +167,8 @@ export const create_signup_route_specs = (deps, options) => {
167
167
  }
168
168
  // Unique constraint violation: username or email already exists.
169
169
  if (is_pg_unique_violation(e)) {
170
- if (ip_rate_limiter && ip)
171
- ip_rate_limiter.record(ip);
170
+ if (signup_ip_rate_limiter && ip)
171
+ signup_ip_rate_limiter.record(ip);
172
172
  if (signup_account_rate_limiter)
173
173
  signup_account_rate_limiter.record(account_key);
174
174
  emit_failure_audit('signup_conflict');
@@ -185,9 +185,12 @@ export const create_signup_route_specs = (deps, options) => {
185
185
  emit_failure_audit('internal_error');
186
186
  throw e;
187
187
  }
188
- // Reset rate limiters on success
189
- if (ip_rate_limiter && ip)
190
- ip_rate_limiter.reset(ip);
188
+ // Clear the account-grain bucket only — the IP aggregate stands.
189
+ // This is the cheapest version of the spray path `RateLimiter.reset`
190
+ // describes: where login at least costs the attacker a credential,
191
+ // an open-signup deployment let an *unauthenticated* caller zero a
192
+ // shared IP budget by creating throwaway accounts, and the
193
+ // per-account limiter never applies to the attacker's own signups.
191
194
  if (signup_account_rate_limiter)
192
195
  signup_account_rate_limiter.reset(account_key);
193
196
  deps.audit.emit(route, {