@fuzdev/fuz_app 0.106.1 → 0.107.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 (64) 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/audit_log_route_schema.d.ts.map +1 -1
  24. package/dist/auth/audit_log_route_schema.js +11 -1
  25. package/dist/auth/audit_log_routes.d.ts.map +1 -1
  26. package/dist/auth/audit_log_routes.js +4 -9
  27. package/dist/auth/auth_guard_resolver.d.ts +48 -21
  28. package/dist/auth/auth_guard_resolver.d.ts.map +1 -1
  29. package/dist/auth/auth_guard_resolver.js +60 -26
  30. package/dist/auth/bearer_auth.d.ts +1 -1
  31. package/dist/auth/bearer_auth.js +1 -1
  32. package/dist/auth/middleware.js +1 -1
  33. package/dist/auth/request_context.d.ts +54 -24
  34. package/dist/auth/request_context.d.ts.map +1 -1
  35. package/dist/auth/request_context.js +117 -39
  36. package/dist/auth/token_scope.d.ts +109 -21
  37. package/dist/auth/token_scope.d.ts.map +1 -1
  38. package/dist/auth/token_scope.js +106 -21
  39. package/dist/http/CLAUDE.md +23 -11
  40. package/dist/http/auth_shape.d.ts +58 -3
  41. package/dist/http/auth_shape.d.ts.map +1 -1
  42. package/dist/http/auth_shape.js +63 -5
  43. package/dist/http/error_schemas.d.ts +24 -1
  44. package/dist/http/error_schemas.d.ts.map +1 -1
  45. package/dist/http/error_schemas.js +36 -14
  46. package/dist/http/route_spec.d.ts +29 -26
  47. package/dist/http/route_spec.d.ts.map +1 -1
  48. package/dist/http/route_spec.js +12 -16
  49. package/dist/http/surface_query.d.ts +2 -2
  50. package/dist/http/surface_query.js +2 -2
  51. package/dist/server/serve_fact_route.d.ts.map +1 -1
  52. package/dist/server/serve_fact_route.js +12 -10
  53. package/dist/testing/CLAUDE.md +39 -3
  54. package/dist/testing/cross_backend/conformance_case.js +1 -1
  55. package/dist/testing/cross_backend/testing_backdoor.d.ts.map +1 -1
  56. package/dist/testing/cross_backend/testing_backdoor.js +6 -5
  57. package/dist/testing/cross_backend/token_scope_surface.d.ts +9 -0
  58. package/dist/testing/cross_backend/token_scope_surface.d.ts.map +1 -0
  59. package/dist/testing/cross_backend/token_scope_surface.js +170 -0
  60. package/dist/testing/integration.d.ts.map +1 -1
  61. package/dist/testing/integration.js +3 -2
  62. package/dist/testing/rpc_attack_surface.d.ts.map +1 -1
  63. package/dist/testing/rpc_attack_surface.js +4 -2
  64. 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`.
@@ -1 +1 @@
1
- {"version":3,"file":"audit_log_route_schema.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/auth/audit_log_route_schema.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAEH,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAExB,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,uBAAuB,CAAC;AAGvD,2FAA2F;AAC3F,eAAO,MAAM,gBAAgB;;kBAA0C,CAAC;AACxE,MAAM,MAAM,gBAAgB,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,gBAAgB,CAAC,CAAC;AAEhE,+DAA+D;AAC/D,eAAO,MAAM,yBAAyB,UAAU,CAAC;AAEjD;;;;;;;;GAQG;AACH,eAAO,MAAM,4BAA4B,GACxC,gBAAe,MAAkC,KAC/C,IAAI,CAAC,SAAS,EAAE,SAAS,CAQ1B,CAAC"}
1
+ {"version":3,"file":"audit_log_route_schema.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/auth/audit_log_route_schema.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAEH,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAExB,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,uBAAuB,CAAC;AAGvD,2FAA2F;AAC3F,eAAO,MAAM,gBAAgB;;kBAA0C,CAAC;AACxE,MAAM,MAAM,gBAAgB,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,gBAAgB,CAAC,CAAC;AAEhE,+DAA+D;AAC/D,eAAO,MAAM,yBAAyB,UAAU,CAAC;AAEjD;;;;;;;;GAQG;AACH,eAAO,MAAM,4BAA4B,GACxC,gBAAe,MAAkC,KAC/C,IAAI,CAAC,SAAS,EAAE,SAAS,CAkB1B,CAAC"}
@@ -28,7 +28,17 @@ export const DEFAULT_AUDIT_STREAM_ROLE = 'admin';
28
28
  export const create_audit_log_route_shape = (required_role = DEFAULT_AUDIT_STREAM_ROLE) => ({
29
29
  method: 'GET',
30
30
  path: '/audit/stream',
31
- auth: { account: 'required', actor: 'required', roles: [required_role] },
31
+ auth: {
32
+ account: 'required',
33
+ actor: 'required',
34
+ roles: [required_role],
35
+ // Rule 3 — a narrowed token holds no audit feed. Same reasoning as the
36
+ // WS upgrade: this is a long-lived server→client stream whose contents
37
+ // are decided by the account's role, not by any per-message scope
38
+ // check, so there is no point after open at which a narrowing could
39
+ // apply.
40
+ required_scope: 'surface:audit_stream'
41
+ },
32
42
  description: 'Subscribe to realtime audit log events',
33
43
  query: AuditStreamQuery,
34
44
  input: z.null(),
@@ -1 +1 @@
1
- {"version":3,"file":"audit_log_routes.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/auth/audit_log_routes.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAEH,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,yBAAyB,CAAC;AAEtD,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,uBAAuB,CAAC;AAEvD,OAAO,EAAuB,KAAK,SAAS,EAAE,KAAK,eAAe,EAAE,MAAM,oBAAoB,CAAC;AAC/F,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,oCAAoC,CAAC;AAQ3E,yCAAyC;AACzC,MAAM,WAAW,oBAAoB;IACpC,+DAA+D;IAC/D,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB;;;;OAIG;IACH,MAAM,CAAC,EAAE;QACR,SAAS,EAAE,CAAC,MAAM,EAAE,SAAS,CAAC,eAAe,CAAC,EAAE,OAAO,CAAC,EAAE,gBAAgB,KAAK,MAAM,IAAI,CAAC;QAC1F,GAAG,EAAE,MAAM,CAAC;KACZ,CAAC;CACF;AAED;;;;;;;;GAQG;AACH,eAAO,MAAM,4BAA4B,GAAI,UAAU,oBAAoB,KAAG,KAAK,CAAC,SAAS,CAiC5F,CAAC"}
1
+ {"version":3,"file":"audit_log_routes.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/auth/audit_log_routes.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAEH,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,yBAAyB,CAAC;AAEtD,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,uBAAuB,CAAC;AAEvD,OAAO,EAAuB,KAAK,SAAS,EAAE,KAAK,eAAe,EAAE,MAAM,oBAAoB,CAAC;AAC/F,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,oCAAoC,CAAC;AAI3E,yCAAyC;AACzC,MAAM,WAAW,oBAAoB;IACpC,+DAA+D;IAC/D,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB;;;;OAIG;IACH,MAAM,CAAC,EAAE;QACR,SAAS,EAAE,CAAC,MAAM,EAAE,SAAS,CAAC,eAAe,CAAC,EAAE,OAAO,CAAC,EAAE,gBAAgB,KAAK,MAAM,IAAI,CAAC;QAC1F,GAAG,EAAE,MAAM,CAAC;KACZ,CAAC;CACF;AAED;;;;;;;;GAQG;AACH,eAAO,MAAM,4BAA4B,GAAI,UAAU,oBAAoB,KAAG,KAAK,CAAC,SAAS,CA4B5F,CAAC"}
@@ -13,7 +13,7 @@
13
13
  */
14
14
  import { create_audit_log_route_shape } from './audit_log_route_schema.js';
15
15
  import { create_sse_response } from '../realtime/sse.js';
16
- import { AUTH_SESSION_TOKEN_HASH_KEY, require_request_context, token_scope_surface_denial } from './request_context.js';
16
+ import { AUTH_SESSION_TOKEN_HASH_KEY, require_request_context } from './request_context.js';
17
17
  import { AUDIT_LOG_CHANNEL } from '../realtime/sse_auth_guard.js';
18
18
  /**
19
19
  * Create the optional audit-log SSE route spec.
@@ -32,14 +32,9 @@ export const create_audit_log_route_specs = (options) => {
32
32
  {
33
33
  ...create_audit_log_route_shape(options.required_role),
34
34
  handler: (c) => {
35
- // Rule 3 — a narrowed token holds no audit feed. Same reasoning as
36
- // the WS upgrade: this is a long-lived server→client stream whose
37
- // contents are decided by the account's role, not by any
38
- // per-message scope check, so there is no point after open at
39
- // which a narrowing could apply.
40
- const denied = token_scope_surface_denial(c, 'audit_stream');
41
- if (denied)
42
- return denied;
35
+ // Rule 3 is declared on the route shape's `auth.required_scope`,
36
+ // so a narrowed token is refused ahead of the role gate rather
37
+ // than here.
43
38
  const ctx = require_request_context(c);
44
39
  // scope = session hash (capped → tabs-per-session limit and
45
40
  // session-specific `session_revoke` close). groups = [account_id]
@@ -1,25 +1,35 @@
1
1
  /**
2
2
  * Auth guard resolver for the route spec system.
3
3
  *
4
- * Maps the four-axis `RouteAuth` (`account` / `actor` / `roles` /
5
- * `credential_types`) to two-phase middleware sets that
4
+ * Maps the five-axis `RouteAuth` (`account` / `actor` / `roles` /
5
+ * `credential_types` / `required_scope`) to two-phase middleware sets that
6
6
  * `apply_route_specs` weaves into the per-route pipeline:
7
7
  *
8
- * - `pre_validation` runs before input validation. `require_auth` lands
9
- * here whenever `auth.account === 'required'` or `auth.actor ===
10
- * 'required'` (per registry-time invariant 3, `actor: 'required'`
11
- * today implies a credential — accountless actors are out of scope
12
- * for v1). Pre-validation 401 fires before any body parsing so
13
- * unauthenticated callers never see route-shape information from
14
- * parse failures.
15
- * - `post_authorization` runs after the dispatcher's authorization
16
- * phase has populated `RequestContext`. `require_role(roles)` fires
17
- * whenever `auth.roles?.length`. `require_credential_types(types)`
18
- * fires whenever `auth.credential_types?.length`.
8
+ * - `pre_authorization` runs before the authorization phase, and holds every
9
+ * gate that reads only what the auth middleware already set: `require_auth`
10
+ * whenever `auth.account === 'required'` or `auth.actor === 'required'`
11
+ * (per registry-time invariant 3, `actor: 'required'` today implies a
12
+ * credential — accountless actors are out of scope for v1),
13
+ * `require_credential_types(types)` whenever `auth.credential_types?.length`,
14
+ * and the token-scope guard whenever `auth.required_scope` names a capability.
15
+ * All three fire before any body parsing, so a caller they refuse never sees
16
+ * route-shape information — and before the authorization phase, so a wrong
17
+ * channel costs no actor resolution.
18
+ * - `post_authorization` runs after the dispatcher's authorization phase has
19
+ * populated `RequestContext`, and holds the one gate that needs it:
20
+ * `require_role(roles)`, whenever `auth.roles?.length`, reads
21
+ * `c.var.request_context.role_grants`.
22
+ *
23
+ * The resulting order — credential presence, then credential type, then scope,
24
+ * then role — is the spine's canonical coarse-to-fine ordering, the same one
25
+ * `check_action_auth_post_authorization` runs for actions. A route declaring
26
+ * both a credential gate and a surface gate therefore answers with the
27
+ * coarser fact ("this channel may never call me") rather than the finer one
28
+ * ("your token is scoped"), on both dispatch surfaces alike.
19
29
  *
20
30
  * Public routes (`auth.account === 'none' && auth.actor === 'none'`)
21
31
  * yield empty guard arrays. `'optional'` axes contribute no
22
- * pre-validation 401; the authorization phase sets `RequestContext`
32
+ * pre-authorization 401; the authorization phase sets `RequestContext`
23
33
  * to whatever the credential supports and the post-authorization
24
34
  * gates decide whether the actor's role_grants / credential type match.
25
35
  *
@@ -29,16 +39,33 @@ import type { AuthGuardResolver } from '../http/route_spec.js';
29
39
  /**
30
40
  * Standard auth guard resolver for fuz_app.
31
41
  *
32
- * Reads each axis of the four-axis `RouteAuth` shape and emits the
33
- * corresponding middleware:
42
+ * Reads each axis of the `RouteAuth` shape and emits the corresponding
43
+ * middleware:
34
44
  *
35
- * - `account === 'required'` or `actor === 'required'` → pre-validation `require_auth`
45
+ * - `account === 'required'` or `actor === 'required'` → pre-authorization `require_auth`
46
+ * - `credential_types?.length` → pre-authorization `require_credential_types(types)`
47
+ * - `required_scope` → pre-authorization `require_token_scope(capability)`
36
48
  * - `roles?.length` → post-authorization `require_role(roles)` (multi-role any-of)
37
- * - `credential_types?.length` → post-authorization `require_credential_types(types)`
38
49
  *
39
- * Multiple post-authorization guards run in declaration order: credential
40
- * type check first (since failing it implies the request can never
41
- * resolve a usable identity), role check second.
50
+ * Guards run in declaration order, which is the coarse-to-fine order above:
51
+ * presence first (an anonymous caller holds neither a credential type nor a
52
+ * scope to check), then the channel, then the scope, then the role — the last
53
+ * being the only one that needs the resolved `RequestContext`.
54
+ *
55
+ * @throws Error if `auth.required_scope` is not a well-formed capability string
56
+ * (via `require_token_scope`) — `RouteAuth` types it as a plain string to keep
57
+ * `http/` free of an `auth/` import, so this is where the value is checked.
58
+ * The check is on *shape*, not membership: the vocabulary is deliberately open
59
+ * so a consumer can name its own surface, and a `surface:` name decides
60
+ * nothing (rule 3 is all-or-nothing), so the closed set would only have been
61
+ * a spelling check on a diagnostic string. fuz_app's own declarations are
62
+ * pinned to the surfaces it actually mounts by the surface census.
63
+ * @throws Error if `auth.required_scope` sits on an unrestricted route
64
+ * (`account: 'none' && actor: 'none'`). The same holder reaches that route
65
+ * by dropping the credential, so the guard reads as a control and enforces
66
+ * nothing — the shape `compile_action_registry` refuses on an `ActionSpec`
67
+ * for the same reason. Extends `RouteAuth`'s leaf invariant, which the
68
+ * route pipeline never parses.
42
69
  */
43
70
  export declare const fuz_auth_guard_resolver: AuthGuardResolver;
44
71
  //# sourceMappingURL=auth_guard_resolver.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"auth_guard_resolver.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/auth/auth_guard_resolver.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AAGH,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,uBAAuB,CAAC;AAE/D;;;;;;;;;;;;;GAaG;AACH,eAAO,MAAM,uBAAuB,EAAE,iBAerC,CAAC"}
1
+ {"version":3,"file":"auth_guard_resolver.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/auth/auth_guard_resolver.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAoCG;AASH,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,uBAAuB,CAAC;AAE/D;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA8BG;AACH,eAAO,MAAM,uBAAuB,EAAE,iBAuBrC,CAAC"}