@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
@@ -5,22 +5,32 @@
5
5
  * `perform_action` runs the post-parse pipeline that every action-spec
6
6
  * handler must traverse:
7
7
  *
8
- * 1. **Pre-validation auth (401)** — short-circuits unauthenticated callers
9
- * on `'required'` axes before input validation runs, so callers never
10
- * see `invalid_params` for methods with required input.
11
- * 2. **Validate params (400)** — `spec.input.safeParse(raw_params)` with
12
- * `z.void()` / `?? {}` rules. The validated input lands inside the
13
- * function so the authorization phase reads `acting` as a typed Zod
14
- * field.
15
- * 3. **Authorization phase** — when `auth.actor !== 'none'` (or
8
+ * 1. **Pre-authorization auth (401)** — short-circuits unauthenticated callers
9
+ * on `'required'` axes.
10
+ * 2. **Authorization phase** — when `auth.actor !== 'none'` (or
16
11
  * `auth.account !== 'none' && actor === 'none'`), resolves the actor
17
12
  * via `apply_authorization_phase` against the supplied `account_id`
18
- * plus `validated_input.acting`. Failures fold into a JSON-RPC error
19
- * envelope. The test-harness escape hatch lives in the caller — pass
13
+ * plus the request's `acting` selector, read off the raw params by
14
+ * `read_acting`. Failures fold into a JSON-RPC error envelope. The
15
+ * test-harness escape hatch lives in the caller — pass
20
16
  * `preset.request_context` to skip the live phase and use a pre-baked
21
17
  * context instead.
22
- * 4. **Post-authorization auth (403)** — gates `auth.credential_types` and
23
- * `auth.roles` against the resolved context.
18
+ * 3. **Post-authorization auth (403)** — gates `auth.credential_types`,
19
+ * the credential's `token_scope`, and `auth.roles` against the resolved
20
+ * context.
21
+ * 4. **Validate params (400)** — `spec.input.safeParse(raw_params)` with
22
+ * `z.void()` / `?? {}` rules. Runs **after** the authority gates: a
23
+ * 400 describing the action's input shape to a caller those gates
24
+ * refuse would confirm the method exists and describe how to call it,
25
+ * to a channel that should have learned nothing. Coarse authority
26
+ * facts before fine ones, and no shape disclosure before authority is
27
+ * settled — the same argument that puts the credential gate ahead of
28
+ * the scope gate. The cost: a malformed request from an *authenticated*
29
+ * caller now pays for the authorization phase's actor + role_grant reads
30
+ * before being rejected, where validation used to turn it away first.
31
+ * Not a new DoS lever — the same caller reaches that work anyway by
32
+ * sending well-formed params — but it does remove a cheap-rejection path
33
+ * that buggy clients used to hit.
24
34
  * 5. **Rate limit (429)** — per-action IP / account throttling, throttle-
25
35
  * requests semantics (every invocation records, regardless of outcome).
26
36
  * 6. **Dispatch + DEV-only output validation + error normalization** —
@@ -45,50 +55,39 @@ import { dispatch_with_post_commit_rollback } from '../http/pending_effects.js';
45
55
  import { JSONRPC_VERSION } from '../http/jsonrpc.js';
46
56
  import { jsonrpc_error_messages, jsonrpc_error_code_to_http_status, http_status_to_jsonrpc_error_code, JSONRPC_ERROR_CODES, dev_only } from '../http/jsonrpc_errors.js';
47
57
  import { ERROR_AUTHENTICATION_REQUIRED, ERROR_INSUFFICIENT_PERMISSIONS, ERROR_CREDENTIAL_TYPE_REQUIRED, ERROR_TOKEN_SCOPE_REQUIRED } from '../http/error_schemas.js';
48
- import { is_public_auth } from '../http/auth_shape.js';
49
- import { token_scope_admits_method } from '../auth/token_scope.js';
58
+ import { is_public_auth, needs_actor, parse_acting } from '../http/auth_shape.js';
59
+ import { token_scope_admits_method, token_scope_method_capability } from '../auth/token_scope.js';
50
60
  /**
51
61
  * The shared dispatch core. Pure data — no Hono context, no socket. Each
52
62
  * transport calls into this with pre-parsed inputs and binds the result
53
63
  * to its wire shape.
54
64
  *
55
- * Phase order: 401 → 400 → 403 → handler. On the test-preset path the
56
- * dispatcher skips the live authorization phase and uses the supplied
57
- * pre-baked context for post-authorization checks; pre-validation 401
58
- * still fires when the harness omits `account_id`.
65
+ * Phase order: 401 → authz → 403 → 400 → handler. On the test-preset path
66
+ * the dispatcher skips the live authorization phase and uses the supplied
67
+ * pre-baked context for post-authorization checks; the 401 still fires when
68
+ * the harness omits `account_id`.
59
69
  */
60
70
  export const perform_action = async (input, deps) => {
61
71
  const { action, raw_params, request_id: id, account_id, credential_type, token_scope, client_ip, signal, notify, connection_id, request_client, preset } = input;
62
72
  const { db, pending_effects, post_commit_effects, log, action_ip_rate_limiter, action_account_rate_limiter } = deps;
63
73
  const { spec, handler } = action;
64
74
  const action_auth = spec.auth;
65
- // step 1: pre-validation auth — 401 short-circuit before input validation.
66
- const pre = check_action_auth_pre_validation(action_auth, account_id);
75
+ // step 1: pre-authorization auth — 401 short-circuit.
76
+ const pre = check_action_auth_pre_authorization(action_auth, account_id);
67
77
  if (pre)
68
78
  return error_result(pre);
69
- // step 2: validate params. JSON-RPC 2.0 §4.2 forbids `params: null`;
70
- // registration sites reject `z.null()` inputs. Empty-body convention
71
- // (`raw_params ?? {}`) lets all-optional-object methods omit `params`.
72
- const params = is_void_schema(spec.input) ? raw_params : (raw_params ?? {});
73
- const parse_result = spec.input.safeParse(params);
74
- if (!parse_result.success) {
75
- return error_result(jsonrpc_error_messages.invalid_params('invalid params', dev_only({ issues: parse_result.error.issues })));
76
- }
77
- const validated_input = parse_result.data;
78
- // step 3: authorization phase. `acting` reads off the typed Zod field
79
- // validated in step 2. Per registry-time invariant 2,
80
- // `auth.actor !== 'none' ⟺ input declares acting?: ActingActor` — so
81
- // the typed read is safe whenever the dispatcher needs it.
79
+ // step 2: authorization phase. `acting` reads off the raw params rather
80
+ // than a validated field, because validation now runs after the authority
81
+ // gates. Per registry-time invariant 2,
82
+ // `auth.actor !== 'none' ⟺ input declares acting?: ActingActor` — so the
83
+ // selector is present on exactly the specs that need it, and step 4 is
84
+ // what rejects a malformed one.
82
85
  let request_context = null;
83
86
  if (preset !== undefined) {
84
87
  request_context = preset.request_context;
85
88
  }
86
89
  else if (!is_public_auth(action_auth)) {
87
- const validated_with_acting = validated_input;
88
- const acting_value = validated_with_acting && typeof validated_with_acting.acting === 'string'
89
- ? validated_with_acting.acting
90
- : undefined;
91
- const result = await apply_authorization_phase({ db }, account_id, action_auth, acting_value);
90
+ const result = await apply_authorization_phase({ db }, account_id, action_auth, read_acting(action_auth, raw_params));
92
91
  if (!result.ok) {
93
92
  const { error: reason, ...rest } = result.body;
94
93
  const code = http_status_to_jsonrpc_error_code(result.status);
@@ -102,10 +101,19 @@ export const perform_action = async (input, deps) => {
102
101
  // unauthenticated-optional axis; the handler sees null in either case.
103
102
  request_context = result.request_context;
104
103
  }
105
- // step 4: post-authorization auth — credential gate first, role gate second.
104
+ // step 3: post-authorization auth — credential gate, then scope, then role.
106
105
  const post = check_action_auth_post_authorization(action_auth, request_context, credential_type, action.spec.method, token_scope);
107
106
  if (post)
108
107
  return error_result(post);
108
+ // step 4: validate params. JSON-RPC 2.0 §4.2 forbids `params: null`;
109
+ // registration sites reject `z.null()` inputs. Empty-body convention
110
+ // (`raw_params ?? {}`) lets all-optional-object methods omit `params`.
111
+ const params = is_void_schema(spec.input) ? raw_params : (raw_params ?? {});
112
+ const parse_result = spec.input.safeParse(params);
113
+ if (!parse_result.success) {
114
+ return error_result(jsonrpc_error_messages.invalid_params('invalid params', dev_only({ issues: parse_result.error.issues })));
115
+ }
116
+ const validated_input = parse_result.data;
109
117
  // step 5: rate limit — throttle-requests semantics (record on every
110
118
  // invocation, no success-reset). Same limiters shared with the WS
111
119
  // dispatcher so an attacker can't switch transports to bypass the budget.
@@ -203,16 +211,33 @@ const rate_limited_result = (retry_after) => {
203
211
  };
204
212
  };
205
213
  /**
206
- * Pre-validation auth gate — fires before input validation so missing
207
- * credentials short-circuit with `unauthenticated` instead of leaking
208
- * a `invalid_params` error for methods with required input.
214
+ * Read the `acting` actor selector off the raw params for the authorization
215
+ * phase.
216
+ *
217
+ * Consulted only when the spec declares an actor axis; `parse_acting` owns
218
+ * what counts as a selector and why a malformed one reads as omitted.
219
+ *
220
+ * @param auth - the spec's auth shape; no actor axis means no selector
221
+ * @param raw_params - the request's unvalidated params
222
+ * @returns the actor id, or `undefined` when absent, malformed, or unwanted
223
+ */
224
+ const read_acting = (auth, raw_params) => {
225
+ if (!needs_actor(auth))
226
+ return undefined;
227
+ if (typeof raw_params !== 'object' || raw_params === null)
228
+ return undefined;
229
+ return parse_acting(raw_params.acting);
230
+ };
231
+ /**
232
+ * Pre-authorization auth gate — fires before the authorization phase so
233
+ * missing credentials short-circuit with `unauthenticated`.
209
234
  *
210
235
  * 401 fires when `auth.account === 'required'` (or `auth.actor === 'required'`,
211
236
  * since registry-time invariant 3 forbids accountless actors in v1) and
212
237
  * no account is on the request. `'optional'` axes pass through — the
213
238
  * authorization phase decides based on whatever the credential supports.
214
239
  */
215
- const check_action_auth_pre_validation = (auth, account_id) => {
240
+ const check_action_auth_pre_authorization = (auth, account_id) => {
216
241
  if (auth.account === 'required' || auth.actor === 'required') {
217
242
  if (account_id == null) {
218
243
  // Carry the reason on `error.data.reason` (symmetric with the 403
@@ -264,7 +289,7 @@ const check_action_auth_post_authorization = (auth, request_context, credential_
264
289
  if (token_scope && !token_scope_admits_method(token_scope, method)) {
265
290
  return jsonrpc_error_messages.forbidden('forbidden', {
266
291
  reason: ERROR_TOKEN_SCOPE_REQUIRED,
267
- required_scope: `rpc:${method}`
292
+ required_scope: token_scope_method_capability(method)
268
293
  });
269
294
  }
270
295
  if (auth.roles?.length) {
@@ -174,10 +174,10 @@ export interface RegisterActionWsResult {
174
174
  * - Notifications (method + no id) are silently dropped per JSON-RPC spec.
175
175
  * Exception: `cancel` notifications abort the matching pending request's
176
176
  * `ctx.signal` before bubbling out.
177
- * - Per-message dispatch goes through `perform_action`: pre-validation
178
- * auth (401) → input validation (400) → authorization phase →
179
- * post-authorization auth (403) → rate limit (429) → handler (with
180
- * transaction wrap iff `spec.side_effects: true`) → DEV output validation.
177
+ * - Per-message dispatch goes through `perform_action`: pre-authorization
178
+ * auth (401) → authorization phase → post-authorization auth (403) →
179
+ * input validation (400) → rate limit (429) → handler (with transaction
180
+ * wrap iff `spec.side_effects: true`) → DEV output validation.
181
181
  * - Authorization phase runs **per message** — role_grant changes during a
182
182
  * connection lifetime are picked up on the next message without any
183
183
  * in-place refresh. Authentication invalidation closes the socket via
@@ -53,10 +53,10 @@ export const DEFAULT_SERVER_HEARTBEAT_TIMEOUT = 60_000;
53
53
  * - Notifications (method + no id) are silently dropped per JSON-RPC spec.
54
54
  * Exception: `cancel` notifications abort the matching pending request's
55
55
  * `ctx.signal` before bubbling out.
56
- * - Per-message dispatch goes through `perform_action`: pre-validation
57
- * auth (401) → input validation (400) → authorization phase →
58
- * post-authorization auth (403) → rate limit (429) → handler (with
59
- * transaction wrap iff `spec.side_effects: true`) → DEV output validation.
56
+ * - Per-message dispatch goes through `perform_action`: pre-authorization
57
+ * auth (401) → authorization phase → post-authorization auth (403) →
58
+ * input validation (400) → rate limit (429) → handler (with transaction
59
+ * wrap iff `spec.side_effects: true`) → DEV output validation.
60
60
  * - Authorization phase runs **per message** — role_grant changes during a
61
61
  * connection lifetime are picked up on the next message without any
62
62
  * in-place refresh. Authentication invalidation closes the socket via
@@ -98,7 +98,7 @@ export const register_action_ws = (options) => {
98
98
  // scoped token that could upgrade would observe far more than it was
99
99
  // granted.
100
100
  async (c, next) => {
101
- const denied = token_scope_surface_denial(c, 'ws_upgrade');
101
+ const denied = token_scope_surface_denial(c, 'surface:ws_upgrade');
102
102
  if (denied)
103
103
  return denied;
104
104
  return next();
@@ -7,12 +7,18 @@
7
7
  * 1. `verify_request_source(allowed_origins)` — reject disallowed origins
8
8
  * before the upgrade handshake runs.
9
9
  * 2. `require_auth` — reject unauthenticated upgrades.
10
- * 3. **Authorization phase** — resolve the acting actor against the
10
+ * 3. `require_token_scope('surface:ws_upgrade')` — rule 3: a narrowed token
11
+ * cannot hold a socket. Mounted here, ahead of the role guard, so a
12
+ * narrowed token is told about its scope rather than about a role it
13
+ * also lacks. `register_action_ws` repeats the check on its own route
14
+ * chain, which is what covers consumers mounting it directly; the
15
+ * earlier answer wins when both are wired.
16
+ * 4. **Authorization phase** — resolve the acting actor against the
11
17
  * authenticated account plus an optional `?acting=<uuid>` query string,
12
18
  * and build the `RequestContext` that per-message dispatch reads.
13
19
  * Multi-actor accounts must supply `?acting` to pick a persona;
14
20
  * single-actor accounts work without it.
15
- * 4. Optional `require_role(required_roles)` — for endpoints gated to a
21
+ * 5. Optional `require_role(required_roles)` — for endpoints gated to a
16
22
  * non-empty any-of set of roles.
17
23
  *
18
24
  * Then delegates to `register_action_ws` for per-message JSON-RPC
@@ -43,14 +49,16 @@ export interface RegisterWsEndpointOptions extends RegisterActionWsOptions {
43
49
  }
44
50
  /**
45
51
  * Mount a WebSocket endpoint with the standard upgrade stack (origin check
46
- * + auth + actor resolution + optional role) and JSON-RPC dispatch.
52
+ * + auth + token scope + actor resolution + optional role) and JSON-RPC
53
+ * dispatch.
47
54
  *
48
55
  * Returns the `BackendWebsocketTransport` (supplied or freshly
49
56
  * created), same as `register_action_ws` — retain it to wire
50
57
  * `create_ws_auth_guard` on `on_audit_event` or to broadcast.
51
58
  *
52
- * @mutates options.app - applies origin/auth/authorization/role middleware via `app.use`,
53
- * then registers the `GET path` route via the inner `register_action_ws`
59
+ * @mutates options.app - applies origin/auth/scope/authorization/role middleware
60
+ * via `app.use`, then registers the `GET path` route via the inner
61
+ * `register_action_ws`
54
62
  */
55
63
  export declare const register_ws_endpoint: (options: RegisterWsEndpointOptions) => RegisterActionWsResult;
56
64
  //# sourceMappingURL=register_ws_endpoint.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"register_ws_endpoint.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/actions/register_ws_endpoint.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;GAqBG;AAYH,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,wBAAwB,CAAC;AAGvD,OAAO,EAEN,KAAK,uBAAuB,EAC5B,KAAK,sBAAsB,EAC3B,MAAM,yBAAyB,CAAC;AAEjC,0CAA0C;AAC1C,MAAM,WAAW,yBAA0B,SAAQ,uBAAuB;IACzE;;;;OAIG;IACH,eAAe,EAAE,aAAa,CAAC,MAAM,CAAC,CAAC;IACvC;;;;;;;;OAQG;IACH,cAAc,CAAC,EAAE,aAAa,CAAC,QAAQ,CAAC,CAAC;CACzC;AAgDD;;;;;;;;;;GAUG;AACH,eAAO,MAAM,oBAAoB,GAChC,SAAS,yBAAyB,KAChC,sBAmBF,CAAC"}
1
+ {"version":3,"file":"register_ws_endpoint.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/actions/register_ws_endpoint.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2BG;AAaH,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,wBAAwB,CAAC;AAGvD,OAAO,EAEN,KAAK,uBAAuB,EAC5B,KAAK,sBAAsB,EAC3B,MAAM,yBAAyB,CAAC;AAEjC,0CAA0C;AAC1C,MAAM,WAAW,yBAA0B,SAAQ,uBAAuB;IACzE;;;;OAIG;IACH,eAAe,EAAE,aAAa,CAAC,MAAM,CAAC,CAAC;IACvC;;;;;;;;OAQG;IACH,cAAc,CAAC,EAAE,aAAa,CAAC,QAAQ,CAAC,CAAC;CACzC;AAgDD;;;;;;;;;;;;GAYG;AACH,eAAO,MAAM,oBAAoB,GAChC,SAAS,yBAAyB,KAChC,sBAoBF,CAAC"}
@@ -7,12 +7,18 @@
7
7
  * 1. `verify_request_source(allowed_origins)` — reject disallowed origins
8
8
  * before the upgrade handshake runs.
9
9
  * 2. `require_auth` — reject unauthenticated upgrades.
10
- * 3. **Authorization phase** — resolve the acting actor against the
10
+ * 3. `require_token_scope('surface:ws_upgrade')` — rule 3: a narrowed token
11
+ * cannot hold a socket. Mounted here, ahead of the role guard, so a
12
+ * narrowed token is told about its scope rather than about a role it
13
+ * also lacks. `register_action_ws` repeats the check on its own route
14
+ * chain, which is what covers consumers mounting it directly; the
15
+ * earlier answer wins when both are wired.
16
+ * 4. **Authorization phase** — resolve the acting actor against the
11
17
  * authenticated account plus an optional `?acting=<uuid>` query string,
12
18
  * and build the `RequestContext` that per-message dispatch reads.
13
19
  * Multi-actor accounts must supply `?acting` to pick a persona;
14
20
  * single-actor accounts work without it.
15
- * 4. Optional `require_role(required_roles)` — for endpoints gated to a
21
+ * 5. Optional `require_role(required_roles)` — for endpoints gated to a
16
22
  * non-empty any-of set of roles.
17
23
  *
18
24
  * Then delegates to `register_action_ws` for per-message JSON-RPC
@@ -21,7 +27,7 @@
21
27
  * @module
22
28
  */
23
29
  import { Logger } from '@fuzdev/fuz_util/log.ts';
24
- import { apply_authorization_phase, REQUEST_CONTEXT_KEY, require_auth, require_role } from '../auth/request_context.js';
30
+ import { apply_authorization_phase, REQUEST_CONTEXT_KEY, require_auth, require_role, require_token_scope } from '../auth/request_context.js';
25
31
  import { verify_request_source } from '../http/origin.js';
26
32
  import { ACCOUNT_ID_KEY, TEST_CONTEXT_PRESET_KEY } from '../hono_context.js';
27
33
  import { register_action_ws } from './register_action_ws.js';
@@ -67,19 +73,22 @@ const create_ws_authorization_middleware = (db) => {
67
73
  };
68
74
  /**
69
75
  * Mount a WebSocket endpoint with the standard upgrade stack (origin check
70
- * + auth + actor resolution + optional role) and JSON-RPC dispatch.
76
+ * + auth + token scope + actor resolution + optional role) and JSON-RPC
77
+ * dispatch.
71
78
  *
72
79
  * Returns the `BackendWebsocketTransport` (supplied or freshly
73
80
  * created), same as `register_action_ws` — retain it to wire
74
81
  * `create_ws_auth_guard` on `on_audit_event` or to broadcast.
75
82
  *
76
- * @mutates options.app - applies origin/auth/authorization/role middleware via `app.use`,
77
- * then registers the `GET path` route via the inner `register_action_ws`
83
+ * @mutates options.app - applies origin/auth/scope/authorization/role middleware
84
+ * via `app.use`, then registers the `GET path` route via the inner
85
+ * `register_action_ws`
78
86
  */
79
87
  export const register_ws_endpoint = (options) => {
80
88
  const { app, path, allowed_origins, db, required_roles, log = new Logger('[ws]'), ...rest } = options;
81
89
  app.use(path, verify_request_source(allowed_origins));
82
90
  app.use(path, require_auth);
91
+ app.use(path, require_token_scope('surface:ws_upgrade'));
83
92
  app.use(path, create_ws_authorization_middleware(db));
84
93
  if (required_roles?.length) {
85
94
  app.use(path, require_role(required_roles));
@@ -61,6 +61,7 @@ Convention — `*_schema.ts` is Zod-only; `*_ddl.ts` holds DDL strings.
61
61
  - `auth/scope_kind_schema.ts` — `ScopeKindName`, `create_scope_kind_schema` (open registry, no builtins).
62
62
  - `auth/credential_type_schema.ts` — `CredentialTypeName`, `CREDENTIAL_TYPE_SESSION` / `_API_TOKEN` / `_DAEMON_TOKEN`, `create_credential_type_schema`.
63
63
  - `auth/grant_path_schema.ts` — `GrantPathName`, `GRANT_PATH_ADMIN` / `_SELF_SERVICE` / `_SYSTEM` / `_BOOTSTRAP`, `create_grant_path_schema`.
64
+ - `auth/token_scope.ts` — per-credential authority narrowing stored on `api_token.scope`: `TokenScope` / `TokenScopeInput` + constructors, `parse_stored_token_scope` (fail-closed), `token_scope_admits_method` / `_non_rpc`, and the `<section>:<id>` capability vocabulary `RouteAuth.required_scope` is written in (`parse_token_scope_capability`, `token_scope_admits_capability`, `token_scope_denied_body`, `TOKEN_SURFACES`). Not an open registry — see ../../../docs/security.md §Token scoping for why the identifier half is deliberately unregistered.
64
65
  - `auth/auth_ddl.ts` — `CREATE TABLE` / index / seed strings for the core identity tables.
65
66
  - `auth/audit_log_schema.ts` — `AUDIT_EVENT_TYPES` (27 builtins), `AuditEventType` / `AuditEventTypeName`, `audit_metadata_schemas`, `AuditLogEvent`, `AuditLogInput`, `AuditLogConfig`, `create_audit_log_config`.
66
67
  - `auth/audit_log_ddl.ts` — `audit_log` table DDL with `seq BIGSERIAL` for cursor-based gap fill (BIGSERIAL converges with the Rust spine; `create_db` registers a `pg.types` int8 parser so `seq` still reads as a JS number).
@@ -123,7 +124,7 @@ they track the same config. Sample via `get_*`; `reset_*` are test-only.
123
124
  - `auth/bootstrap_routes.ts` — `POST /bootstrap` + `check_bootstrap_status`; `BootstrapStatus` runtime ref.
124
125
  - `auth/signup_routes.ts` — `POST /signup` (open or invite-gated).
125
126
  - `auth/audit_log_routes.ts` — optional `GET /audit/stream` (SSE); list/history are on the RPC surface.
126
- - `auth/auth_guard_resolver.ts` — `fuz_auth_guard_resolver` injected into `apply_route_specs` so the framework stays auth-agnostic.
127
+ - `auth/auth_guard_resolver.ts` — `fuz_auth_guard_resolver` injected into `apply_route_specs` so the framework stays auth-agnostic. Maps each `RouteAuth` axis to a guard and is the single site every route spec traverses, so it also carries the two invariants the schema can't (a `required_scope` that isn't a well-formed capability, and one on an unrestricted route).
127
128
 
128
129
  **Hono-free route shapes.** Each cookie/SSE-coupled route module has a sibling
129
130
  `*_route_schema.ts` (`account_route_schema.ts`, `signup_route_schema.ts`,
@@ -158,7 +159,7 @@ Everything else listed under §RPC action surfaces.
158
159
  ### Middleware
159
160
 
160
161
  - `auth/middleware.ts` — `create_auth_middleware_specs(deps, options)` assembles `[origin, session, request_context, bearer_auth]` + optional `daemon_token`.
161
- - `auth/request_context.ts` — `RequestContext`, `resolve_acting_actor`, `build_request_context`, predicates (`has_role`, `has_scoped_role`, `has_any_scoped_role`), guards (`require_auth`, `require_role`, `require_credential_types`), `refresh_role_grants`.
162
+ - `auth/request_context.ts` — `RequestContext`, `resolve_acting_actor`, `build_request_context`, predicates (`has_role`, `has_scoped_role`, `has_any_scoped_role`), guards (`require_auth`, `require_role`, `require_credential_types`, `require_token_scope`), `token_scope_surface_denial` (the direct-call form, for surfaces that aren't route specs), `refresh_role_grants`.
162
163
  - `auth/session_middleware.ts` — `process_session_cookie` integration, `create_session_and_set_cookie` (shared by login / signup / bootstrap).
163
164
  - `auth/bearer_auth.ts` — soft-fail bearer middleware; rejects when `Origin` or `Referer` present (browser context).
164
165
  - `auth/daemon_token_middleware.ts` — `start_daemon_token_rotation` + `create_daemon_token_middleware(state, deps, log)` (atomic file write, soft-fail validation, keeper account resolution). Soft-fails — discards the credential (pass-through, no own 401/503) on **every** non-success path: browser context (`Origin`/`Referer` present), malformed/invalid token, and valid-token-but-no-keeper all `next()` through to the dispatcher's `credential_type_required` (403) gate, mirroring the bearer guard and the Rust spine's `resolve.rs`. Daemon tokens are loopback-only, so browser context never arises in practice — the discard is defense-in-depth.
@@ -274,12 +275,15 @@ Sets `c.var.account_id` + `CREDENTIAL_TYPE_KEY` on a valid credential.
274
275
  Account-only — never loads actor or role_grants, never populates
275
276
  `REQUEST_CONTEXT_KEY`.
276
277
 
277
- **Authorization runs after input validation**, matching the dispatcher's
278
- 401 → 400 → 403 phase order (see `http/CLAUDE.md` §Validation pipeline).
279
- When the route's input declares `acting?: ActingActor` or its auth requires
280
- role_grants, the authorization phase calls `resolve_acting_actor` over the
281
- validated `acting` value and builds an actor-bound `RequestContext`.
282
- Account-grain routes run with `RequestContext.actor: null`.
278
+ **Authorization runs before input validation**, matching the dispatcher's
279
+ 401 → authz → 403 → 400 phase order (see `http/CLAUDE.md` §Validation
280
+ pipeline). When the route's input declares `acting?: ActingActor` or its auth
281
+ requires role_grants, the authorization phase calls `resolve_acting_actor` over
282
+ the `acting` selector — the validated query value on GETs, read off the raw
283
+ body on mutations — and builds an actor-bound `RequestContext`. A malformed
284
+ selector reads as omitted (it only ever picks among actors the account already
285
+ owns, and input validation rejects it a step later). Account-grain routes run
286
+ with `RequestContext.actor: null`.
283
287
 
284
288
  `apply_authorization_phase` is pure data — returns `AuthorizationResult`
285
289
  (`{ok: true, request_context: RequestContext | null} | {ok: false, status, body}`)
@@ -399,7 +403,10 @@ Keeper is not a dedicated guard — it's a composable `RouteAuth` shape:
399
403
  `{account: 'required', actor: 'required', roles: ['keeper'], credential_types: ['daemon_token']}`.
400
404
  The two-part check is `require_credential_types(['daemon_token'])` (403
401
405
  `ERROR_CREDENTIAL_TYPE_REQUIRED`) followed by `require_role(['keeper'])`
402
- (403 `ERROR_INSUFFICIENT_PERMISSIONS`). Same scope-aware semantics mirrored
406
+ (403 `ERROR_INSUFFICIENT_PERMISSIONS`) — the channel gate lands
407
+ pre-authorization and the role gate post-authorization, so a non-daemon
408
+ credential is refused before the route resolves an actor for it. Same
409
+ scope-aware semantics mirrored
403
410
  in the HTTP RPC dispatcher (`actions/action_rpc.ts`), the WS dispatcher
404
411
  (`actions/register_action_ws.ts`), and the admin bypasses inside
405
412
  `auth/role_grant_offer_actions.ts`.
@@ -79,7 +79,16 @@ export declare const PasswordChangeOutput: z.ZodObject<{
79
79
  tokens_revoked: z.ZodNumber;
80
80
  }, z.core.$strict>;
81
81
  export type PasswordChangeOutput = z.infer<typeof PasswordChangeOutput>;
82
- /** Default maximum sessions per account. */
82
+ /**
83
+ * Default maximum sessions per account.
84
+ *
85
+ * The Rust twin pins the same 5 as a `const` (`fuz_auth`'s
86
+ * `DEFAULT_MAX_SESSIONS`) rather than a route-state field, because its
87
+ * `AccountRouteState` has no `Default` and a new field would break every
88
+ * exhaustive literal in every consumer workspace. So this stays the
89
+ * configurable side of the pair (`null` disables), and the two ship the same
90
+ * number — retuning means editing both.
91
+ */
83
92
  export declare const DEFAULT_MAX_SESSIONS = 5;
84
93
  /** Default maximum API tokens per account. */
85
94
  export declare const DEFAULT_MAX_TOKENS = 10;
@@ -1 +1 @@
1
- {"version":3,"file":"account_route_schema.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/auth/account_route_schema.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAEH,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAKxB,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,uBAAuB,CAAC;AAQvD,kFAAkF;AAClF,eAAO,MAAM,kBAAkB,WAAW,CAAC;AAC3C,MAAM,MAAM,kBAAkB,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,kBAAkB,CAAC,CAAC;AAEpE,4CAA4C;AAC5C,eAAO,MAAM,mBAAmB;;;;;;;;;;;;;;;;;;;;;kBAI9B,CAAC;AACH,MAAM,MAAM,mBAAmB,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,mBAAmB,CAAC,CAAC;AAEtE,4EAA4E;AAC5E,eAAO,MAAM,iCAAiC;;;iBAG5C,CAAC;AACH,MAAM,MAAM,iCAAiC,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,iCAAiC,CAAC,CAAC;AAElG,oFAAoF;AACpF,eAAO,MAAM,UAAU;;;kBAGrB,CAAC;AACH,MAAM,MAAM,UAAU,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,UAAU,CAAC,CAAC;AAEpD,6EAA6E;AAC7E,eAAO,MAAM,WAAW;;kBAEtB,CAAC;AACH,MAAM,MAAM,WAAW,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,WAAW,CAAC,CAAC;AAEtD,2EAA2E;AAC3E,eAAO,MAAM,WAAW,WAAW,CAAC;AACpC,MAAM,MAAM,WAAW,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,WAAW,CAAC,CAAC;AAEtD,wFAAwF;AACxF,eAAO,MAAM,YAAY;;;kBAGvB,CAAC;AACH,MAAM,MAAM,YAAY,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,YAAY,CAAC,CAAC;AAExD,sHAAsH;AACtH,eAAO,MAAM,mBAAmB;;;kBAG9B,CAAC;AACH,MAAM,MAAM,mBAAmB,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,mBAAmB,CAAC,CAAC;AAEtE,uGAAuG;AACvG,eAAO,MAAM,oBAAoB;;;;kBAI/B,CAAC;AACH,MAAM,MAAM,oBAAoB,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,oBAAoB,CAAC,CAAC;AAExE,4CAA4C;AAC5C,eAAO,MAAM,oBAAoB,IAAI,CAAC;AAEtC,8CAA8C;AAC9C,eAAO,MAAM,kBAAkB,KAAK,CAAC;AAErC;;;;;;;;;;;GAWG;AACH,eAAO,MAAM,0BAA0B;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAUD,CAAC;AAEvC,8EAA8E;AAC9E,MAAM,WAAW,wBAAwB;IACxC,8FAA8F;IAC9F,0BAA0B,EAAE,OAAO,CAAC;CACpC;AAED;;;;;;;;;GASG;AACH,eAAO,MAAM,2BAA2B,GACvC,SAAS,wBAAwB,KAC/B,CACF,IAAI,CAAC,SAAS,EAAE,SAAS,CAAC,EAC1B,IAAI,CAAC,SAAS,EAAE,SAAS,CAAC,EAC1B,IAAI,CAAC,SAAS,EAAE,SAAS,CAAC,EAC1B,IAAI,CAAC,SAAS,EAAE,SAAS,CAAC,CAoD1B,CAAC"}
1
+ {"version":3,"file":"account_route_schema.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/auth/account_route_schema.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAEH,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAKxB,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,uBAAuB,CAAC;AAQvD,kFAAkF;AAClF,eAAO,MAAM,kBAAkB,WAAW,CAAC;AAC3C,MAAM,MAAM,kBAAkB,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,kBAAkB,CAAC,CAAC;AAEpE,4CAA4C;AAC5C,eAAO,MAAM,mBAAmB;;;;;;;;;;;;;;;;;;;;;kBAI9B,CAAC;AACH,MAAM,MAAM,mBAAmB,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,mBAAmB,CAAC,CAAC;AAEtE,4EAA4E;AAC5E,eAAO,MAAM,iCAAiC;;;iBAG5C,CAAC;AACH,MAAM,MAAM,iCAAiC,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,iCAAiC,CAAC,CAAC;AAElG,oFAAoF;AACpF,eAAO,MAAM,UAAU;;;kBAGrB,CAAC;AACH,MAAM,MAAM,UAAU,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,UAAU,CAAC,CAAC;AAEpD,6EAA6E;AAC7E,eAAO,MAAM,WAAW;;kBAEtB,CAAC;AACH,MAAM,MAAM,WAAW,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,WAAW,CAAC,CAAC;AAEtD,2EAA2E;AAC3E,eAAO,MAAM,WAAW,WAAW,CAAC;AACpC,MAAM,MAAM,WAAW,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,WAAW,CAAC,CAAC;AAEtD,wFAAwF;AACxF,eAAO,MAAM,YAAY;;;kBAGvB,CAAC;AACH,MAAM,MAAM,YAAY,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,YAAY,CAAC,CAAC;AAExD,sHAAsH;AACtH,eAAO,MAAM,mBAAmB;;;kBAG9B,CAAC;AACH,MAAM,MAAM,mBAAmB,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,mBAAmB,CAAC,CAAC;AAEtE,uGAAuG;AACvG,eAAO,MAAM,oBAAoB;;;;kBAI/B,CAAC;AACH,MAAM,MAAM,oBAAoB,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,oBAAoB,CAAC,CAAC;AAExE;;;;;;;;;GASG;AACH,eAAO,MAAM,oBAAoB,IAAI,CAAC;AAEtC,8CAA8C;AAC9C,eAAO,MAAM,kBAAkB,KAAK,CAAC;AAErC;;;;;;;;;;;GAWG;AACH,eAAO,MAAM,0BAA0B;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAUD,CAAC;AAEvC,8EAA8E;AAC9E,MAAM,WAAW,wBAAwB;IACxC,8FAA8F;IAC9F,0BAA0B,EAAE,OAAO,CAAC;CACpC;AAED;;;;;;;;;GASG;AACH,eAAO,MAAM,2BAA2B,GACvC,SAAS,wBAAwB,KAC/B,CACF,IAAI,CAAC,SAAS,EAAE,SAAS,CAAC,EAC1B,IAAI,CAAC,SAAS,EAAE,SAAS,CAAC,EAC1B,IAAI,CAAC,SAAS,EAAE,SAAS,CAAC,EAC1B,IAAI,CAAC,SAAS,EAAE,SAAS,CAAC,CAoD1B,CAAC"}
@@ -56,7 +56,16 @@ export const PasswordChangeOutput = z.strictObject({
56
56
  sessions_revoked: z.number(),
57
57
  tokens_revoked: z.number()
58
58
  });
59
- /** Default maximum sessions per account. */
59
+ /**
60
+ * Default maximum sessions per account.
61
+ *
62
+ * The Rust twin pins the same 5 as a `const` (`fuz_auth`'s
63
+ * `DEFAULT_MAX_SESSIONS`) rather than a route-state field, because its
64
+ * `AccountRouteState` has no `Default` and a new field would break every
65
+ * exhaustive literal in every consumer workspace. So this stays the
66
+ * configurable side of the pair (`null` disables), and the two ship the same
67
+ * number — retuning means editing both.
68
+ */
60
69
  export const DEFAULT_MAX_SESSIONS = 5;
61
70
  /** Default maximum API tokens per account. */
62
71
  export const DEFAULT_MAX_TOKENS = 10;
@@ -69,20 +69,34 @@ export declare const DEFAULT_LOGIN_FAIL_FLOOR_MS = 250;
69
69
  */
70
70
  export declare const DEFAULT_LOGIN_FAIL_JITTER_MS = 25;
71
71
  /**
72
- * Shared options for route factories that create sessions and rate-limit by IP.
72
+ * Shared options for route factories that create sessions.
73
73
  *
74
74
  * Extended by `AccountRouteOptions` and `SignupRouteOptions`.
75
75
  * Consumers can destructure these from `AppServerContext` once and spread into multiple factories.
76
+ *
77
+ * The per-IP limiter is deliberately *not* here. Each auth surface names its
78
+ * own (`login_ip_rate_limiter`, `signup_ip_rate_limiter`,
79
+ * `bootstrap_ip_rate_limiter`) because the buckets are monotone within their
80
+ * window — see `RateLimiter.reset`. A shared base field made one instance the
81
+ * path of least resistance, which let a failure on any surface spend the
82
+ * budget that bounds guessing on every other one.
76
83
  */
77
84
  export interface AuthSessionRouteOptions {
78
85
  session_options: SessionOptions<string>;
79
- /** Rate limiter for auth attempts, keyed by client IP. Pass `null` to disable. */
80
- ip_rate_limiter: RateLimiter | null;
81
86
  }
82
87
  /**
83
88
  * Per-factory configuration for account route specs.
84
89
  */
85
90
  export interface AccountRouteOptions extends AuthSessionRouteOptions {
91
+ /**
92
+ * Rate limiter for login + password-change attempts, keyed by client IP.
93
+ * Pass `null` to disable. The distributed-spray backstop: login is the one
94
+ * surface that guesses *other* accounts' credentials, so this bucket is
95
+ * never refunded on success (see `RateLimiter.reset`). Password change
96
+ * shares it — it is password-bearing on the same account grain, and the
97
+ * Rust spine shares the same instance across both.
98
+ */
99
+ login_ip_rate_limiter: RateLimiter | null;
86
100
  /** Rate limiter for login attempts, keyed by submitted username. Pass `null` to disable. */
87
101
  login_account_rate_limiter: RateLimiter | null;
88
102
  /** Max active sessions per account. Evicts oldest on login. Default 5, `null` disables. */
@@ -130,7 +144,7 @@ export interface AccountRouteOptions extends AuthSessionRouteOptions {
130
144
  * Self-service session/token management is on `auth/account_actions.ts`.
131
145
  *
132
146
  * @param deps - stateless capabilities (keyring, password, log)
133
- * @param options - per-factory configuration (session_options, ip_rate_limiter, login_account_rate_limiter, bootstrap_status)
147
+ * @param options - per-factory configuration (session_options, login_ip_rate_limiter, login_account_rate_limiter, bootstrap_status)
134
148
  * @returns route specs (not yet applied to Hono)
135
149
  */
136
150
  export declare const create_account_route_specs: (deps: RouteFactoryDeps, options: AccountRouteOptions) => Array<RouteSpec>;
@@ -1 +1 @@
1
- {"version":3,"file":"account_routes.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/auth/account_routes.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;GAsBG;AAEH,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,qBAAqB,CAAC;AA4B1D,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;AAClD,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,iCAAiC,CAAC;AAGxE;;;;;;;;;;;;GAYG;AACH,eAAO,MAAM,gCAAgC,GAAI,UAAU,oBAAoB,KAAG,SA4EhF,CAAC;AAEH,iDAAiD;AACjD,MAAM,WAAW,oBAAoB;IACpC,yDAAyD;IACzD,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,8FAA8F;IAC9F,gBAAgB,CAAC,EAAE;QAAE,SAAS,EAAE,OAAO,CAAA;KAAE,CAAC;CAC1C;AAED;;;;;;;;;GASG;AACH,eAAO,MAAM,2BAA2B,MAAM,CAAC;AAE/C;;;;;;GAMG;AACH,eAAO,MAAM,4BAA4B,KAAK,CAAC;AAQ/C;;;;;GAKG;AACH,MAAM,WAAW,uBAAuB;IACvC,eAAe,EAAE,cAAc,CAAC,MAAM,CAAC,CAAC;IACxC,kFAAkF;IAClF,eAAe,EAAE,WAAW,GAAG,IAAI,CAAC;CACpC;AAED;;GAEG;AACH,MAAM,WAAW,mBAAoB,SAAQ,uBAAuB;IACnE,4FAA4F;IAC5F,0BAA0B,EAAE,WAAW,GAAG,IAAI,CAAC;IAC/C,2FAA2F;IAC3F,YAAY,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAC7B;;;;OAIG;IACH,mBAAmB,CAAC,EAAE,MAAM,CAAC;IAC7B;;;OAGG;IACH,oBAAoB,CAAC,EAAE,MAAM,CAAC;IAC9B;;;;;;;OAOG;IACH,iBAAiB,CAAC,EAAE,gBAAgB,GAAG,IAAI,CAAC;IAC5C;;;;;;;OAOG;IACH,gBAAgB,CAAC,EAAE;QAAE,SAAS,EAAE,OAAO,CAAA;KAAE,CAAC;CAC1C;AAID;;;;;;;;;;;;;GAaG;AACH,eAAO,MAAM,0BAA0B,GACtC,MAAM,gBAAgB,EACtB,SAAS,mBAAmB,KAC1B,KAAK,CAAC,SAAS,CAwRjB,CAAC"}
1
+ {"version":3,"file":"account_routes.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/auth/account_routes.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;GAsBG;AAEH,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,qBAAqB,CAAC;AA4B1D,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;AAClD,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,iCAAiC,CAAC;AAGxE;;;;;;;;;;;;GAYG;AACH,eAAO,MAAM,gCAAgC,GAAI,UAAU,oBAAoB,KAAG,SA4EhF,CAAC;AAEH,iDAAiD;AACjD,MAAM,WAAW,oBAAoB;IACpC,yDAAyD;IACzD,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,8FAA8F;IAC9F,gBAAgB,CAAC,EAAE;QAAE,SAAS,EAAE,OAAO,CAAA;KAAE,CAAC;CAC1C;AAED;;;;;;;;;GASG;AACH,eAAO,MAAM,2BAA2B,MAAM,CAAC;AAE/C;;;;;;GAMG;AACH,eAAO,MAAM,4BAA4B,KAAK,CAAC;AAQ/C;;;;;;;;;;;;GAYG;AACH,MAAM,WAAW,uBAAuB;IACvC,eAAe,EAAE,cAAc,CAAC,MAAM,CAAC,CAAC;CACxC;AAED;;GAEG;AACH,MAAM,WAAW,mBAAoB,SAAQ,uBAAuB;IACnE;;;;;;;OAOG;IACH,qBAAqB,EAAE,WAAW,GAAG,IAAI,CAAC;IAC1C,4FAA4F;IAC5F,0BAA0B,EAAE,WAAW,GAAG,IAAI,CAAC;IAC/C,2FAA2F;IAC3F,YAAY,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAC7B;;;;OAIG;IACH,mBAAmB,CAAC,EAAE,MAAM,CAAC;IAC7B;;;OAGG;IACH,oBAAoB,CAAC,EAAE,MAAM,CAAC;IAC9B;;;;;;;OAOG;IACH,iBAAiB,CAAC,EAAE,gBAAgB,GAAG,IAAI,CAAC;IAC5C;;;;;;;OAOG;IACH,gBAAgB,CAAC,EAAE;QAAE,SAAS,EAAE,OAAO,CAAA;KAAE,CAAC;CAC1C;AAID;;;;;;;;;;;;;GAaG;AACH,eAAO,MAAM,0BAA0B,GACtC,MAAM,gBAAgB,EACtB,SAAS,mBAAmB,KAC1B,KAAK,CAAC,SAAS,CA6RjB,CAAC"}
@@ -155,12 +155,12 @@ const login_fail_delay = (floor_ms, jitter_ms) => {
155
155
  * Self-service session/token management is on `auth/account_actions.ts`.
156
156
  *
157
157
  * @param deps - stateless capabilities (keyring, password, log)
158
- * @param options - per-factory configuration (session_options, ip_rate_limiter, login_account_rate_limiter, bootstrap_status)
158
+ * @param options - per-factory configuration (session_options, login_ip_rate_limiter, login_account_rate_limiter, bootstrap_status)
159
159
  * @returns route specs (not yet applied to Hono)
160
160
  */
161
161
  export const create_account_route_specs = (deps, options) => {
162
162
  const { keyring, password } = deps;
163
- const { session_options, ip_rate_limiter, login_account_rate_limiter, max_sessions = DEFAULT_MAX_SESSIONS, login_fail_floor_ms = DEFAULT_LOGIN_FAIL_FLOOR_MS, login_fail_jitter_ms = DEFAULT_LOGIN_FAIL_JITTER_MS, connection_closer = null, bootstrap_status } = options;
163
+ const { session_options, login_ip_rate_limiter, login_account_rate_limiter, max_sessions = DEFAULT_MAX_SESSIONS, login_fail_floor_ms = DEFAULT_LOGIN_FAIL_FLOOR_MS, login_fail_jitter_ms = DEFAULT_LOGIN_FAIL_JITTER_MS, connection_closer = null, bootstrap_status } = options;
164
164
  const [verify_shape, login_shape, logout_shape, password_shape] = create_account_route_shapes({
165
165
  login_account_rate_limited: login_account_rate_limiter !== null
166
166
  });
@@ -181,9 +181,9 @@ export const create_account_route_specs = (deps, options) => {
181
181
  ...login_shape,
182
182
  handler: async (c, route) => {
183
183
  // Per-IP rate limit check (before any DB/password work)
184
- const ip = ip_rate_limiter ? get_client_ip(c) : null;
185
- if (ip_rate_limiter && ip) {
186
- const check = ip_rate_limiter.check(ip);
184
+ const ip = login_ip_rate_limiter ? get_client_ip(c) : null;
185
+ if (login_ip_rate_limiter && ip) {
186
+ const check = login_ip_rate_limiter.check(ip);
187
187
  if (!check.allowed) {
188
188
  return rate_limit_exceeded_response(c, check.retry_after);
189
189
  }
@@ -214,8 +214,8 @@ export const create_account_route_specs = (deps, options) => {
214
214
  // enumeration prevention: verify_dummy equalizes Argon2id timing;
215
215
  // login_fail_delay equalizes every other path difference.
216
216
  await password.verify_dummy(pw);
217
- if (ip_rate_limiter && ip)
218
- ip_rate_limiter.record(ip);
217
+ if (login_ip_rate_limiter && ip)
218
+ login_ip_rate_limiter.record(ip);
219
219
  if (login_account_rate_limiter)
220
220
  login_account_rate_limiter.record(account_rate_key);
221
221
  deps.audit.emit(route, {
@@ -229,8 +229,8 @@ export const create_account_route_specs = (deps, options) => {
229
229
  }
230
230
  const valid = await password.verify_password(pw, account.password_hash);
231
231
  if (!valid) {
232
- if (ip_rate_limiter && ip)
233
- ip_rate_limiter.record(ip);
232
+ if (login_ip_rate_limiter && ip)
233
+ login_ip_rate_limiter.record(ip);
234
234
  if (login_account_rate_limiter)
235
235
  login_account_rate_limiter.record(account_rate_key);
236
236
  deps.audit.emit(route, {
@@ -243,9 +243,11 @@ export const create_account_route_specs = (deps, options) => {
243
243
  await delay;
244
244
  return c.json({ error: ERROR_INVALID_CREDENTIALS }, 401);
245
245
  }
246
- // Successful login — reset rate limits
247
- if (ip_rate_limiter && ip)
248
- ip_rate_limiter.reset(ip);
246
+ // Successful login — clear the account-grain failure history only.
247
+ // The IP aggregate is deliberately left standing: it is the
248
+ // distributed-spray backstop, so a success on one account must not
249
+ // refund the budget shared by every other account reachable from
250
+ // that address. See `RateLimiter.reset`.
249
251
  if (login_account_rate_limiter)
250
252
  login_account_rate_limiter.reset(account_rate_key);
251
253
  await create_session_and_set_cookie({
@@ -305,9 +307,9 @@ export const create_account_route_specs = (deps, options) => {
305
307
  ...password_shape,
306
308
  handler: async (c, route) => {
307
309
  // per-IP rate limit check (before argon2 work)
308
- const ip = ip_rate_limiter ? get_client_ip(c) : null;
309
- if (ip_rate_limiter && ip) {
310
- const check = ip_rate_limiter.check(ip);
310
+ const ip = login_ip_rate_limiter ? get_client_ip(c) : null;
311
+ if (login_ip_rate_limiter && ip) {
312
+ const check = login_ip_rate_limiter.check(ip);
311
313
  if (!check.allowed) {
312
314
  return rate_limit_exceeded_response(c, check.retry_after);
313
315
  }
@@ -325,8 +327,8 @@ export const create_account_route_specs = (deps, options) => {
325
327
  }
326
328
  const valid = await password.verify_password(current_password, ctx.account.password_hash);
327
329
  if (!valid) {
328
- if (ip_rate_limiter && ip)
329
- ip_rate_limiter.record(ip);
330
+ if (login_ip_rate_limiter && ip)
331
+ login_ip_rate_limiter.record(ip);
330
332
  if (login_account_rate_limiter)
331
333
  login_account_rate_limiter.record(ctx.account.id);
332
334
  deps.audit.emit(route, {
@@ -350,16 +352,17 @@ export const create_account_route_specs = (deps, options) => {
350
352
  // incidental; password is account-level state).
351
353
  const updated = await query_update_account_password(route, ctx.account.id, new_hash, null, ctx.account.password_hash);
352
354
  // Verify-success contract — the caller proved knowledge, so wipe
353
- // their failure history. The race-loser branch below re-records
354
- // one on top of the wiped slate so net cost stays 1 (mirrors the
355
- // verify-fail branch above's `record`-from-prior+1 outcome when
356
- // prior was 0; for prior > 0 the race-loser pays exactly 1,
357
- // matching the OLD pre-S1 behavior). Deferring from "after
358
- // verify" to "after UPDATE settled" is what closes the S1
359
- // bypass — a throw between reset and the UPDATE could have
360
- // wiped an attacker's budget.
361
- if (ip_rate_limiter && ip)
362
- ip_rate_limiter.reset(ip);
355
+ // their account-grain failure history. The race-loser branch below
356
+ // re-records one on top of the wiped slate so net cost stays 1 on
357
+ // that bucket. Deferring from "after verify" to "after UPDATE
358
+ // settled" is what closes the S1 bypass — a throw between reset
359
+ // and the UPDATE could have wiped an attacker's budget.
360
+ //
361
+ // The IP aggregate is deliberately not cleared: it is the same
362
+ // instance login uses, so refunding it here reopens the spray path
363
+ // through a route that only needs the caller's *own* password. Its
364
+ // record on the race-loser branch below therefore accumulates
365
+ // rather than netting out, which is the intent.
363
366
  if (login_account_rate_limiter)
364
367
  login_account_rate_limiter.reset(ctx.account.id);
365
368
  if (!updated) {
@@ -370,8 +373,8 @@ export const create_account_route_specs = (deps, options) => {
370
373
  // audit log can distinguish "user typoed" from "two clients
371
374
  // raced." Sessions/tokens were already revoked by the winner;
372
375
  // no cookie clear here either.
373
- if (ip_rate_limiter && ip)
374
- ip_rate_limiter.record(ip);
376
+ if (login_ip_rate_limiter && ip)
377
+ login_ip_rate_limiter.record(ip);
375
378
  if (login_account_rate_limiter)
376
379
  login_account_rate_limiter.record(ctx.account.id);
377
380
  deps.audit.emit(route, {