@fuzdev/fuz_app 0.106.0 → 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 (66) 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/dist/ui/account_sessions_state.svelte.d.ts +3 -3
  65. package/dist/ui/account_sessions_state.svelte.d.ts.map +1 -1
  66. package/package.json +2 -2
@@ -1,56 +1,90 @@
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
  *
26
36
  * @module
27
37
  */
28
- import { require_auth, require_credential_types, require_role } from './request_context.js';
38
+ import { require_auth, require_credential_types, require_role, require_token_scope } from './request_context.js';
39
+ import { is_public_auth } from '../http/auth_shape.js';
29
40
  /**
30
41
  * Standard auth guard resolver for fuz_app.
31
42
  *
32
- * Reads each axis of the four-axis `RouteAuth` shape and emits the
33
- * corresponding middleware:
43
+ * Reads each axis of the `RouteAuth` shape and emits the corresponding
44
+ * middleware:
34
45
  *
35
- * - `account === 'required'` or `actor === 'required'` → pre-validation `require_auth`
46
+ * - `account === 'required'` or `actor === 'required'` → pre-authorization `require_auth`
47
+ * - `credential_types?.length` → pre-authorization `require_credential_types(types)`
48
+ * - `required_scope` → pre-authorization `require_token_scope(capability)`
36
49
  * - `roles?.length` → post-authorization `require_role(roles)` (multi-role any-of)
37
- * - `credential_types?.length` → post-authorization `require_credential_types(types)`
38
50
  *
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.
51
+ * Guards run in declaration order, which is the coarse-to-fine order above:
52
+ * presence first (an anonymous caller holds neither a credential type nor a
53
+ * scope to check), then the channel, then the scope, then the role — the last
54
+ * being the only one that needs the resolved `RequestContext`.
55
+ *
56
+ * @throws Error if `auth.required_scope` is not a well-formed capability string
57
+ * (via `require_token_scope`) — `RouteAuth` types it as a plain string to keep
58
+ * `http/` free of an `auth/` import, so this is where the value is checked.
59
+ * The check is on *shape*, not membership: the vocabulary is deliberately open
60
+ * so a consumer can name its own surface, and a `surface:` name decides
61
+ * nothing (rule 3 is all-or-nothing), so the closed set would only have been
62
+ * a spelling check on a diagnostic string. fuz_app's own declarations are
63
+ * pinned to the surfaces it actually mounts by the surface census.
64
+ * @throws Error if `auth.required_scope` sits on an unrestricted route
65
+ * (`account: 'none' && actor: 'none'`). The same holder reaches that route
66
+ * by dropping the credential, so the guard reads as a control and enforces
67
+ * nothing — the shape `compile_action_registry` refuses on an `ActionSpec`
68
+ * for the same reason. Extends `RouteAuth`'s leaf invariant, which the
69
+ * route pipeline never parses.
42
70
  */
43
71
  export const fuz_auth_guard_resolver = (auth) => {
44
- const pre_validation = [];
72
+ const pre_authorization = [];
45
73
  const post_authorization = [];
46
74
  if (auth.account === 'required' || auth.actor === 'required') {
47
- pre_validation.push(require_auth);
75
+ pre_authorization.push(require_auth);
48
76
  }
49
77
  if (auth.credential_types?.length) {
50
- post_authorization.push(require_credential_types(auth.credential_types));
78
+ pre_authorization.push(require_credential_types(auth.credential_types));
79
+ }
80
+ if (auth.required_scope !== undefined) {
81
+ if (is_public_auth(auth)) {
82
+ throw new Error(`auth.required_scope "${auth.required_scope}" on an unrestricted route (account: 'none', actor: 'none') — the same holder reaches it by dropping the credential, so the guard would enforce nothing. Gate the route on a credential, or drop the field.`);
83
+ }
84
+ pre_authorization.push(require_token_scope(auth.required_scope));
51
85
  }
52
86
  if (auth.roles?.length) {
53
87
  post_authorization.push(require_role(auth.roles));
54
88
  }
55
- return { pre_validation, post_authorization };
89
+ return { pre_authorization, post_authorization };
56
90
  };
@@ -23,7 +23,7 @@ import { type RateLimiter } from '../rate_limiter.js';
23
23
  *
24
24
  * Soft-fails for invalid, expired, or empty tokens — calls `next()` without
25
25
  * setting account identity, letting downstream auth enforcement (the RPC
26
- * dispatcher's pre-validation / post-authorization auth gates or
26
+ * dispatcher's pre-authorization / post-authorization auth gates or
27
27
  * `require_auth`) return a consistent JSON-RPC or route-level error. This
28
28
  * avoids leaking token-specific diagnostics
29
29
  * (`invalid_token`, `account_not_found`) that could aid enumeration attacks,
@@ -26,7 +26,7 @@ import { rate_limit_exceeded_response } from '../rate_limiter.js';
26
26
  *
27
27
  * Soft-fails for invalid, expired, or empty tokens — calls `next()` without
28
28
  * setting account identity, letting downstream auth enforcement (the RPC
29
- * dispatcher's pre-validation / post-authorization auth gates or
29
+ * dispatcher's pre-authorization / post-authorization auth gates or
30
30
  * `require_auth`) return a consistent JSON-RPC or route-level error. This
31
31
  * avoids leaking token-specific diagnostics
32
32
  * (`invalid_token`, `account_not_found`) that could aid enumeration attacks,
@@ -53,7 +53,7 @@ export const create_auth_middleware_specs = async (deps, options) => {
53
53
  // Bearer middleware soft-fails for invalid/expired tokens (calls next()
54
54
  // without setting context). Only 429 is a hard-fail from this layer.
55
55
  // Auth enforcement (401/403) happens downstream — the RPC dispatcher's
56
- // pre-validation / post-authorization auth gates, or `require_auth` /
56
+ // pre-authorization / post-authorization auth gates, or `require_auth` /
57
57
  // `require_role` on REST — producing consistent JSON-RPC or
58
58
  // route-level errors.
59
59
  errors: { 429: RateLimitError }
@@ -38,7 +38,6 @@ import type { Context, MiddlewareHandler } from 'hono';
38
38
  import type { Logger } from '@fuzdev/fuz_util/log.ts';
39
39
  import { type Account, type Actor, type RoleGrant } from './account_schema.js';
40
40
  import type { QueryDeps } from '../db/query_deps.js';
41
- import { type TokenSurface } from './token_scope.js';
42
41
  import type { RouteSpec } from '../http/route_spec.js';
43
42
  import { type RouteAuth } from '../http/auth_shape.js';
44
43
  import { ERROR_ACTOR_REQUIRED, ERROR_ACTOR_NOT_ON_ACCOUNT, ERROR_NO_ACTORS_ON_ACCOUNT, ERROR_ACCOUNT_VANISHED } from '../http/error_schemas.js';
@@ -231,22 +230,48 @@ export declare const resolve_acting_actor: (deps: QueryDeps, account_id: string,
231
230
  */
232
231
  export declare const create_request_context_middleware: (deps: QueryDeps, log: Logger, session_context_key?: string) => MiddlewareHandler;
233
232
  /**
234
- * Refuse a non-RPC spine surface to a narrowed token — **rule 3**, in one place.
235
- *
236
- * Called by every surface rule 3 covers: the bare-hash fact read, the audit SSE
237
- * stream, and the WS upgrade (as pre-upgrade middleware, since
238
- * `upgradeWebSocket`'s callback cannot produce a denial).
239
- *
240
- * An unset `TOKEN_SCOPE_KEY` is the anonymous caller, who holds no credential to
241
- * narrow, so it passes. Sharing that branch is the point — hand-rolled per
242
- * surface it is one `&&` from either refusing every anonymous read or admitting
243
- * an authenticated caller whose middleware forgot to set the key.
233
+ * Refuse a non-RPC surface to a narrowed token — rule 3, for callers that are
234
+ * not route specs. Under the Rust twin's name
235
+ * (`token_scope_surface_denied_response`).
236
+ *
237
+ * The WS upgrade is the spine's one such caller: `upgradeWebSocket`'s callback
238
+ * must return `WSEvents` and so cannot produce a denial, which is why the gate
239
+ * runs as pre-upgrade middleware calling this rather than as a route-spec
240
+ * declaration. A consumer hand-rolling its own upgrade is in the same position
241
+ * and reaches for the same function.
242
+ *
243
+ * Takes the whole capability string, not a bare surface name — one spelling
244
+ * across route-spec declarations and direct calls alike, and the twin of the
245
+ * Rust signature. It needs no parse: the surface arm's identifier is pure label,
246
+ * since rule 3 never asks *which* surface. And it is deliberately not narrowed
247
+ * to the spine's four — the name decides nothing, so there is nothing for a
248
+ * closed set to protect, while a consumer surface would have nothing to pass.
249
+ * fuz_app's own uses are pinned two ways by the surface census: the exact call
250
+ * site, and the scan asserting every `'surface:<name>'` literal in `src/lib`
251
+ * names a surface the spine actually mounts.
244
252
  *
245
253
  * @param c - the request context, after the auth middleware chain
246
- * @param surface - the surface being gated; names itself in the denial
254
+ * @param capability - the `surface:<name>` this route demands; names itself in the denial
247
255
  * @returns the 403 to return, or `null` when the caller may proceed
248
256
  */
249
- export declare const token_scope_surface_denial: (c: Context, surface: TokenSurface) => Response | null;
257
+ export declare const token_scope_surface_denial: (c: Context, capability: string) => Response | null;
258
+ /**
259
+ * Create middleware refusing a route to a credential whose scope does not admit
260
+ * `capability`.
261
+ *
262
+ * The route-spec form, mounted by `fuz_auth_guard_resolver` from
263
+ * `auth.required_scope`. Lands in the `pre_authorization` phase, so a narrowed
264
+ * token is told about its scope rather than about the role it also lacks — and
265
+ * learns nothing about the route's input shape on the way.
266
+ *
267
+ * Parses at construction, so a malformed capability is a registration-time
268
+ * throw rather than a guard that mounts and reports nonsense.
269
+ *
270
+ * @param capability - the capability string this route demands, e.g. `surface:audit_stream`
271
+ * @returns middleware that 403s a credential whose scope lacks it
272
+ * @throws Error if `capability` is not a well-formed `rpc:<method>` / `surface:<name>` string
273
+ */
274
+ export declare const require_token_scope: (capability: string) => MiddlewareHandler;
250
275
  /**
251
276
  * Middleware that requires authentication.
252
277
  *
@@ -284,6 +309,12 @@ export declare const require_role: (roles: ReadonlyArray<string>) => MiddlewareH
284
309
  * Returns 401 if unauthenticated, 403 with
285
310
  * `ERROR_CREDENTIAL_TYPE_REQUIRED` + `required_credential_types` echoing
286
311
  * the spec's allowlist when the wire-side credential isn't in it.
312
+ *
313
+ * Reads only `ACCOUNT_ID_KEY` + `CREDENTIAL_TYPE_KEY`, both set by the auth
314
+ * middleware — never the resolved `RequestContext`. That is why
315
+ * `fuz_auth_guard_resolver` mounts it in the `pre_authorization` phase: a
316
+ * wrong channel is refused before the route resolves an actor for it, so the
317
+ * denial costs no DB work and discloses no account state.
287
318
  * Body shape is symmetric with the role gate (`ERROR_INSUFFICIENT_PERMISSIONS` +
288
319
  * `required_roles`) and matches what the RPC dispatcher's post-auth
289
320
  * gate emits for the same condition. Today's only credential gate is
@@ -403,8 +434,8 @@ export type AuthorizationResult = {
403
434
  * Apply the dispatcher's authorization phase against the flat-record
404
435
  * `RouteAuth` shape. Shared by the route-spec wrapper, the HTTP RPC
405
436
  * dispatcher, and the per-message WS dispatcher. Phase order:
406
- * pre-validation 401 → input validation 400 → authorization phase →
407
- * post-authorization 403.
437
+ * pre-authorization 401 → authorization phase → post-authorization 403 →
438
+ * input validation 400.
408
439
  *
409
440
  * Pure data — the function does not touch a Hono context. Each transport
410
441
  * passes `account_id` (extracted from its own credential surface) and
@@ -419,7 +450,7 @@ export type AuthorizationResult = {
419
450
  * never see a `RequestContext`.
420
451
  * - `account_id == null` on any non-public route → same null
421
452
  * `request_context`. The `'required'` callers were already rejected at
422
- * the pre-validation gate in the dispatcher; only genuine anonymous
453
+ * the pre-authorization gate in the dispatcher; only genuine anonymous
423
454
  * access on an `'optional'` axis lands here.
424
455
  * - `actor === 'none'` → builds account-only context via
425
456
  * `build_account_context`. Null lookup → `account_vanished` 500 failure.
@@ -437,16 +468,15 @@ export declare const apply_authorization_phase: (deps: QueryDeps, account_id: st
437
468
  /**
438
469
  * Create the route-spec authorization handler used by `apply_route_specs`.
439
470
  *
440
- * Reads `acting` off `c.var.validated_input` (or `c.var.validated_query`
441
- * for GET routes) — input validation runs first, so the authorization
442
- * phase consumes the typed Zod field instead of pre-parsing the body.
443
- * Public routes (`auth.account === 'none' && auth.actor === 'none'`)
444
- * skip the phase entirely.
471
+ * Reads the `acting` selector via `read_route_acting` — `c.var.validated_query`
472
+ * on GETs, the raw body on mutations, since input validation now runs after
473
+ * the authority gates. Public routes (`auth.account === 'none' &&
474
+ * auth.actor === 'none'`) skip the phase entirely.
445
475
  *
446
476
  * Per registry-time invariant 2, `auth.actor !== 'none'` ⟺ the input
447
- * (or query) schema declares `acting?: ActingActor` — so reading from
448
- * `c.var.validated_input.acting` / `c.var.validated_query.acting` is
449
- * type-safe.
477
+ * (or query) schema declares `acting?: ActingActor` — so the selector is
478
+ * present on exactly the specs that read it, and input validation is what
479
+ * rejects a malformed one.
450
480
  *
451
481
  * Resolved contexts land on `REQUEST_CONTEXT_KEY` so the post-authorization
452
482
  * REST middleware (`require_role`, `require_credential_types`) reads the
@@ -1 +1 @@
1
- {"version":3,"file":"request_context.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/auth/request_context.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"request_context.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/auth/request_context.ts"],"names":[],"mappings":"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"}
@@ -39,8 +39,9 @@ import { hash_session_token, session_touch_fire_and_forget, query_session_get_va
39
39
  import { query_account_by_id, query_actor_by_id, query_active_actors_by_account } from './account_queries.js';
40
40
  import { query_role_grant_find_active_for_actor } from './role_grant_queries.js';
41
41
  import { ACCOUNT_ID_KEY, AUTH_API_TOKEN_ID_KEY, CREDENTIAL_TYPE_KEY, TOKEN_SCOPE_KEY, TEST_CONTEXT_PRESET_KEY } from '../hono_context.js';
42
- import { token_scope_admits_non_rpc, token_scope_full, token_scope_surface_denied_body } from './token_scope.js';
43
- import { is_public_auth, needs_actor } from '../http/auth_shape.js';
42
+ import { parse_token_scope_capability, token_scope_admits_capability, token_scope_denied_body, token_scope_full } from './token_scope.js';
43
+ import { is_public_auth, needs_actor, parse_acting } from '../http/auth_shape.js';
44
+ import { is_null_schema } from '../http/schema_helpers.js';
44
45
  import { ERROR_AUTHENTICATION_REQUIRED, ERROR_INSUFFICIENT_PERMISSIONS, ERROR_CREDENTIAL_TYPE_REQUIRED, ERROR_ACTOR_REQUIRED, ERROR_ACTOR_NOT_ON_ACCOUNT, ERROR_NO_ACTORS_ON_ACCOUNT, ERROR_ACCOUNT_VANISHED } from '../http/error_schemas.js';
45
46
  /** Hono context variable name for the request context. */
46
47
  export const REQUEST_CONTEXT_KEY = 'request_context';
@@ -240,27 +241,78 @@ export const create_request_context_middleware = (deps, log, session_context_key
240
241
  };
241
242
  };
242
243
  /**
243
- * Refuse a non-RPC spine surface to a narrowed token — **rule 3**, in one place.
244
+ * Refuse a capability the calling credential's scope does not admit.
244
245
  *
245
- * Called by every surface rule 3 covers: the bare-hash fact read, the audit SSE
246
- * stream, and the WS upgrade (as pre-upgrade middleware, since
247
- * `upgradeWebSocket`'s callback cannot produce a denial).
246
+ * The decision every token-scope gate outside the action dispatcher shares.
247
+ * Private because the two exported forms below are the whole surface: a route
248
+ * spec declares `auth.required_scope` and gets `require_token_scope`; anything
249
+ * that is not a route spec calls `token_scope_surface_denial`.
248
250
  *
249
251
  * An unset `TOKEN_SCOPE_KEY` is the anonymous caller, who holds no credential to
250
252
  * narrow, so it passes. Sharing that branch is the point — hand-rolled per
251
253
  * surface it is one `&&` from either refusing every anonymous read or admitting
252
254
  * an authenticated caller whose middleware forgot to set the key.
255
+ */
256
+ const token_scope_denial = (c, capability) => {
257
+ const scope = c.get(TOKEN_SCOPE_KEY);
258
+ if (scope && !token_scope_admits_capability(scope, capability)) {
259
+ return c.json(token_scope_denied_body(capability.capability), 403);
260
+ }
261
+ return null;
262
+ };
263
+ /**
264
+ * Refuse a non-RPC surface to a narrowed token — rule 3, for callers that are
265
+ * not route specs. Under the Rust twin's name
266
+ * (`token_scope_surface_denied_response`).
267
+ *
268
+ * The WS upgrade is the spine's one such caller: `upgradeWebSocket`'s callback
269
+ * must return `WSEvents` and so cannot produce a denial, which is why the gate
270
+ * runs as pre-upgrade middleware calling this rather than as a route-spec
271
+ * declaration. A consumer hand-rolling its own upgrade is in the same position
272
+ * and reaches for the same function.
273
+ *
274
+ * Takes the whole capability string, not a bare surface name — one spelling
275
+ * across route-spec declarations and direct calls alike, and the twin of the
276
+ * Rust signature. It needs no parse: the surface arm's identifier is pure label,
277
+ * since rule 3 never asks *which* surface. And it is deliberately not narrowed
278
+ * to the spine's four — the name decides nothing, so there is nothing for a
279
+ * closed set to protect, while a consumer surface would have nothing to pass.
280
+ * fuz_app's own uses are pinned two ways by the surface census: the exact call
281
+ * site, and the scan asserting every `'surface:<name>'` literal in `src/lib`
282
+ * names a surface the spine actually mounts.
253
283
  *
254
284
  * @param c - the request context, after the auth middleware chain
255
- * @param surface - the surface being gated; names itself in the denial
285
+ * @param capability - the `surface:<name>` this route demands; names itself in the denial
256
286
  * @returns the 403 to return, or `null` when the caller may proceed
257
287
  */
258
- export const token_scope_surface_denial = (c, surface) => {
259
- const scope = c.get(TOKEN_SCOPE_KEY);
260
- if (scope && !token_scope_admits_non_rpc(scope)) {
261
- return c.json(token_scope_surface_denied_body(surface), 403);
288
+ export const token_scope_surface_denial = (c, capability) => token_scope_denial(c, { kind: 'surface', capability });
289
+ /**
290
+ * Create middleware refusing a route to a credential whose scope does not admit
291
+ * `capability`.
292
+ *
293
+ * The route-spec form, mounted by `fuz_auth_guard_resolver` from
294
+ * `auth.required_scope`. Lands in the `pre_authorization` phase, so a narrowed
295
+ * token is told about its scope rather than about the role it also lacks — and
296
+ * learns nothing about the route's input shape on the way.
297
+ *
298
+ * Parses at construction, so a malformed capability is a registration-time
299
+ * throw rather than a guard that mounts and reports nonsense.
300
+ *
301
+ * @param capability - the capability string this route demands, e.g. `surface:audit_stream`
302
+ * @returns middleware that 403s a credential whose scope lacks it
303
+ * @throws Error if `capability` is not a well-formed `rpc:<method>` / `surface:<name>` string
304
+ */
305
+ export const require_token_scope = (capability) => {
306
+ const parsed = parse_token_scope_capability(capability);
307
+ if (parsed === null) {
308
+ throw new Error(`auth.required_scope "${capability}" is not a valid token-scope capability \u2014 expected 'rpc:<method>' or 'surface:<name>', where a surface name is lowercase letters and underscores`);
262
309
  }
263
- return null;
310
+ return async (c, next) => {
311
+ const denied = token_scope_denial(c, parsed);
312
+ if (denied)
313
+ return denied;
314
+ await next();
315
+ };
264
316
  };
265
317
  /**
266
318
  * Middleware that requires authentication.
@@ -315,6 +367,12 @@ export const require_role = (roles) => {
315
367
  * Returns 401 if unauthenticated, 403 with
316
368
  * `ERROR_CREDENTIAL_TYPE_REQUIRED` + `required_credential_types` echoing
317
369
  * the spec's allowlist when the wire-side credential isn't in it.
370
+ *
371
+ * Reads only `ACCOUNT_ID_KEY` + `CREDENTIAL_TYPE_KEY`, both set by the auth
372
+ * middleware — never the resolved `RequestContext`. That is why
373
+ * `fuz_auth_guard_resolver` mounts it in the `pre_authorization` phase: a
374
+ * wrong channel is refused before the route resolves an actor for it, so the
375
+ * denial costs no DB work and discloses no account state.
318
376
  * Body shape is symmetric with the role gate (`ERROR_INSUFFICIENT_PERMISSIONS` +
319
377
  * `required_roles`) and matches what the RPC dispatcher's post-auth
320
378
  * gate emits for the same condition. Today's only credential gate is
@@ -421,8 +479,8 @@ export const build_account_context = async (deps, account_id) => {
421
479
  * Apply the dispatcher's authorization phase against the flat-record
422
480
  * `RouteAuth` shape. Shared by the route-spec wrapper, the HTTP RPC
423
481
  * dispatcher, and the per-message WS dispatcher. Phase order:
424
- * pre-validation 401 → input validation 400 → authorization phase →
425
- * post-authorization 403.
482
+ * pre-authorization 401 → authorization phase → post-authorization 403 →
483
+ * input validation 400.
426
484
  *
427
485
  * Pure data — the function does not touch a Hono context. Each transport
428
486
  * passes `account_id` (extracted from its own credential surface) and
@@ -437,7 +495,7 @@ export const build_account_context = async (deps, account_id) => {
437
495
  * never see a `RequestContext`.
438
496
  * - `account_id == null` on any non-public route → same null
439
497
  * `request_context`. The `'required'` callers were already rejected at
440
- * the pre-validation gate in the dispatcher; only genuine anonymous
498
+ * the pre-authorization gate in the dispatcher; only genuine anonymous
441
499
  * access on an `'optional'` axis lands here.
442
500
  * - `actor === 'none'` → builds account-only context via
443
501
  * `build_account_context`. Null lookup → `account_vanished` 500 failure.
@@ -457,7 +515,7 @@ export const apply_authorization_phase = async (deps, account_id, auth, acting_v
457
515
  if (account_id == null) {
458
516
  // Optional-auth route hit without a credential — leave `RequestContext`
459
517
  // null so the handler can branch on it. `'required'` callers already
460
- // got rejected at the pre-validation gate.
518
+ // got rejected at the pre-authorization gate.
461
519
  return { ok: true, request_context: null };
462
520
  }
463
521
  if (!needs_actor(auth)) {
@@ -496,16 +554,15 @@ export const apply_authorization_phase = async (deps, account_id, auth, acting_v
496
554
  /**
497
555
  * Create the route-spec authorization handler used by `apply_route_specs`.
498
556
  *
499
- * Reads `acting` off `c.var.validated_input` (or `c.var.validated_query`
500
- * for GET routes) — input validation runs first, so the authorization
501
- * phase consumes the typed Zod field instead of pre-parsing the body.
502
- * Public routes (`auth.account === 'none' && auth.actor === 'none'`)
503
- * skip the phase entirely.
557
+ * Reads the `acting` selector via `read_route_acting` — `c.var.validated_query`
558
+ * on GETs, the raw body on mutations, since input validation now runs after
559
+ * the authority gates. Public routes (`auth.account === 'none' &&
560
+ * auth.actor === 'none'`) skip the phase entirely.
504
561
  *
505
562
  * Per registry-time invariant 2, `auth.actor !== 'none'` ⟺ the input
506
- * (or query) schema declares `acting?: ActingActor` — so reading from
507
- * `c.var.validated_input.acting` / `c.var.validated_query.acting` is
508
- * type-safe.
563
+ * (or query) schema declares `acting?: ActingActor` — so the selector is
564
+ * present on exactly the specs that read it, and input validation is what
565
+ * rejects a malformed one.
509
566
  *
510
567
  * Resolved contexts land on `REQUEST_CONTEXT_KEY` so the post-authorization
511
568
  * REST middleware (`require_role`, `require_credential_types`) reads the
@@ -523,7 +580,7 @@ export const create_fuz_authorization_handler = (deps) => {
523
580
  return;
524
581
  if (is_public_auth(spec.auth))
525
582
  return;
526
- const acting_value = needs_actor(spec.auth) ? extract_validated_acting(c) : undefined;
583
+ const acting_value = needs_actor(spec.auth) ? await read_route_acting(c, spec) : undefined;
527
584
  const account_id = c.get(ACCOUNT_ID_KEY) ?? null;
528
585
  const result = await apply_authorization_phase(deps, account_id, spec.auth, acting_value);
529
586
  if (!result.ok)
@@ -538,22 +595,43 @@ export const create_fuz_authorization_handler = (deps) => {
538
595
  };
539
596
  };
540
597
  /**
541
- * Read `acting` off the validated input (or validated query) on the Hono
542
- * context. Input/query validation runs before the authorization phase,
543
- * so this reads a typed Zod field — not the raw body.
544
- *
545
- * Returns `undefined` when `validated_input` / `validated_query` isn't
546
- * set or doesn't carry `acting`. Per registry-time invariant 2, the
547
- * dispatcher only calls this when `auth.actor !== 'none'`, which by
548
- * the biconditional means the input schema declares
549
- * `acting?: ActingActor`.
598
+ * Read the `acting` actor selector for the authorization phase.
599
+ *
600
+ * REST is bi-located: GETs declare `acting` on `query`, mutations on `input`.
601
+ * Query validation still runs ahead of the authorization phase, so the GET
602
+ * half reads a typed Zod field. Input validation now runs *after* the
603
+ * authority gates (nothing about a route's body shape reaches a caller those
604
+ * gates refuse), so the mutation half reads the raw body instead.
605
+ *
606
+ * The raw read mirrors `create_input_validation`'s own skip conditions exactly
607
+ * — GETs and null-input specs — so this never parses a body the route itself
608
+ * would have left alone. That matters for the routes carrying raw bytes rather
609
+ * than JSON (git smart-HTTP, binary uploads): they declare `input: z.null()`,
610
+ * so their stream reaches the handler untouched, and they carry `acting` on
611
+ * `query` regardless.
612
+ *
613
+ * `parse_acting` owns what counts as a selector and why a malformed one reads
614
+ * as omitted.
615
+ *
616
+ * @param c - the request context, after query validation
617
+ * @param spec - the route being dispatched; decides where `acting` can live
618
+ * @returns the actor id, or `undefined` when absent, malformed, or unreadable
550
619
  */
551
- const extract_validated_acting = (c) => {
552
- const validated_input = c.get('validated_input');
553
- if (validated_input && typeof validated_input.acting === 'string')
554
- return validated_input.acting;
620
+ const read_route_acting = async (c, spec) => {
555
621
  const validated_query = c.get('validated_query');
556
622
  if (validated_query && typeof validated_query.acting === 'string')
557
623
  return validated_query.acting;
558
- return undefined;
624
+ if (spec.method === 'GET' || is_null_schema(spec.input))
625
+ return undefined;
626
+ let body;
627
+ try {
628
+ body = await c.req.json();
629
+ }
630
+ catch {
631
+ // Malformed body — input validation answers with the 400.
632
+ return undefined;
633
+ }
634
+ if (typeof body !== 'object' || body === null)
635
+ return undefined;
636
+ return parse_acting(body.acting);
559
637
  };