@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
@@ -88,37 +88,125 @@ export declare const token_scope_admits_method: (scope: TokenScope, method: stri
88
88
  /**
89
89
  * Does `scope` admit a non-RPC spine surface?
90
90
  *
91
- * **Rule 3, and it is the load-bearing one.** A method-name allowlist alone
92
- * would have been a false promise: the db-admin browser serves paginated rows of
93
- * any `public` table (including `account.password_hash`, `auth_session`, and
94
- * `api_token`) plus row `DELETE`, gated on a global role a bearer credential
95
- * satisfies. A token whose UI badge read "scoped to `cell_get`" could still
96
- * delete an account row.
91
+ * **A narrowed token reaches no non-RPC surface at all** — the load-bearing
92
+ * half of token scoping, and what "rule 3" names wherever this module's
93
+ * callers and the Rust twin use that shorthand. See `docs/security.md`
94
+ * §Token scoping.
97
95
  *
98
- * So a narrowed token reaches no non-RPC surface at all. Strictly more
99
- * restrictive than naming each surface, no vocabulary to maintain, and one
100
- * sentence a user can hold: *a scoped token is RPC-only*.
96
+ * A method-name allowlist alone would have been a false promise: the db-admin
97
+ * browser serves paginated rows of any `public` table (including
98
+ * `account.password_hash`, `auth_session`, and `api_token`) plus row `DELETE`,
99
+ * gated on a global role a bearer credential satisfies. A token whose UI badge
100
+ * read "scoped to `cell_get`" could still delete an account row.
101
+ *
102
+ * Denying every non-RPC surface outright is strictly more restrictive than
103
+ * naming each one, keeps no vocabulary to maintain, and fits in one sentence a
104
+ * user can hold: *a scoped token is RPC-only*.
101
105
  */
102
106
  export declare const token_scope_admits_non_rpc: (scope: TokenScope) => boolean;
103
- /** Surfaces gated by rule 3. Capability strings match the Rust `TokenSurface`. */
104
- export declare const TOKEN_SURFACE_CAPABILITIES: {
105
- readonly db_admin: "surface:db_admin";
106
- readonly fact_bare: "surface:fact_bare";
107
- readonly audit_stream: "surface:audit_stream";
108
- readonly ws_upgrade: "surface:ws_upgrade";
107
+ /**
108
+ * The non-RPC surfaces the **spine itself** mounts (rule 3). Twin of the Rust
109
+ * `TokenSurface` enum, name for name.
110
+ *
111
+ * Not a registry a consumer registers into. `RouteAuth.required_scope` accepts
112
+ * any well-formed `surface:<name>` (see `parse_token_scope_capability`), so a
113
+ * consumer names its own non-RPC surface without an upstream release. This
114
+ * tuple is what the *spine* may declare, and what its surface census iterates
115
+ * — the closure is a check on fuz_app's own routes, not a vocabulary consumers
116
+ * have to ask permission to extend.
117
+ *
118
+ * A surface name never decides anything: rule 3 is all-or-nothing
119
+ * (`token_scope_admits_non_rpc`), so the name only labels *which* surface
120
+ * refused, in the `required_scope` a denial reports. That is the same field
121
+ * whose `rpc:<method>` arm has always carried arbitrary consumer method names.
122
+ */
123
+ export declare const TOKEN_SURFACES: readonly ["db_admin", "fact_bare", "audit_stream", "ws_upgrade"];
124
+ export type TokenSurface = (typeof TOKEN_SURFACES)[number];
125
+ /**
126
+ * Is `value` one of the surfaces the spine mounts?
127
+ *
128
+ * Membership, not well-formedness — `parse_token_scope_capability` is the format
129
+ * check the guard resolver runs on a declaration, and it is deliberately looser.
130
+ * Used where the *spine's* own surfaces are the question: the direct-call denial
131
+ * helpers, and the census assertion that fuz_app declares no surface it doesn't
132
+ * mount.
133
+ */
134
+ export declare const is_token_surface: (value: string) => value is TokenSurface;
135
+ /**
136
+ * Name format for the identifier half of a capability string. Mirrors
137
+ * `RoleName` / `CredentialTypeName` / `ScopeKindName`.
138
+ *
139
+ * Applied to the `surface:` arm only. The `rpc:` arm's identifier is an action
140
+ * method name, whose vocabulary belongs to `ActionSpec` (and includes shapes
141
+ * like `peer/ping` this regex would refuse) — the capability parser checks it
142
+ * is non-empty and leaves the rest to the registry that owns it.
143
+ */
144
+ export declare const TOKEN_SURFACE_NAME_REGEX: RegExp;
145
+ /**
146
+ * The capability string an action method is refused under: `rpc:<method>`.
147
+ *
148
+ * Exported because two sites *compute* one — the dispatcher's per-method gate
149
+ * and the REST bridge, which must agree — where a `surface:` capability is
150
+ * always written as a literal on a route spec, so nothing computes one.
151
+ */
152
+ export declare const token_scope_method_capability: (method: string) => string;
153
+ /**
154
+ * A parsed `required_scope` capability — what a route demands of the calling
155
+ * credential's `TokenScope`.
156
+ *
157
+ * `capability` is the wire string verbatim, carried rather than re-derived so a
158
+ * denial reports exactly what the route declared. The arms are asymmetric on
159
+ * purpose, and the asymmetry is the whole design: the `rpc:` identifier is
160
+ * **consumed by a predicate** (`token_scope_admits_method`), so it is kept; the
161
+ * `surface:` identifier is **pure label**, because rule 3 is all-or-nothing
162
+ * (`token_scope_admits_non_rpc` never looks at which surface). That is why one
163
+ * half of the vocabulary needs no registry.
164
+ */
165
+ export type TokenScopeCapability = {
166
+ kind: 'rpc';
167
+ capability: string;
168
+ method: string;
169
+ } | {
170
+ kind: 'surface';
171
+ capability: string;
109
172
  };
110
- export type TokenSurface = keyof typeof TOKEN_SURFACE_CAPABILITIES;
111
173
  /**
112
- * Build the flat denial body a non-RPC surface returns when a narrowed token
113
- * reaches it: `{error: 'token_scope_required', required_scope: 'surface:<name>'}`
114
- * at 403.
174
+ * Parse a capability string into the question it asks, or `null` when it is
175
+ * not a capability at all.
176
+ *
177
+ * Callers turn `null` into a registration-time throw: `RouteAuth.required_scope`
178
+ * is typed as a plain string so `http/` needs no `auth/` import, and this is the
179
+ * single point where that string becomes a mounted guard, so it is where the
180
+ * value gets checked. Refusing an unknown prefix is what keeps the `surface:`
181
+ * namespace fuz_app's own — a route cannot mint an `rpc:`-looking capability out
182
+ * of a surface gate, which would put a method name in front of a whole-surface
183
+ * refusal.
184
+ *
185
+ * @param value - the declared capability string, e.g. `surface:audit_stream`
186
+ * @returns the parsed capability, or `null` on an unknown prefix / empty or malformed identifier
187
+ */
188
+ export declare const parse_token_scope_capability: (value: string) => TokenScopeCapability | null;
189
+ /**
190
+ * Does `scope` admit `capability`?
191
+ *
192
+ * The one place the two arms of the vocabulary meet their two predicates, so a
193
+ * `surface:` capability can never be answered by a method-list membership test
194
+ * (which would let a narrowed token onto a surface by listing a method named
195
+ * after it) and an `rpc:` capability can never be answered by rule 3 (which
196
+ * would refuse a token the method it was minted for).
197
+ */
198
+ export declare const token_scope_admits_capability: (scope: TokenScope, capability: TokenScopeCapability) => boolean;
199
+ /**
200
+ * Build the flat denial body a non-RPC route returns when a narrowed token
201
+ * reaches a capability it lacks:
202
+ * `{error: 'token_scope_required', required_scope: '<section>:<id>'}` at 403.
115
203
  *
116
- * One shape for every surface, matching the Rust
204
+ * One shape for every capability, matching the Rust
117
205
  * `token_scope_surface_denied_response`. These are REST-ish routes whose
118
206
  * sibling denials are already flat, so the JSON-RPC envelope stays with the
119
207
  * dispatcher's per-method gate and stops there.
120
208
  */
121
- export declare const token_scope_surface_denied_body: (surface: TokenSurface) => {
209
+ export declare const token_scope_denied_body: (capability: string) => {
122
210
  error: "token_scope_required";
123
211
  required_scope: string;
124
212
  };
@@ -1 +1 @@
1
- {"version":3,"file":"token_scope.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/auth/token_scope.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AAEH,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAExB,wFAAwF;AACxF,eAAO,MAAM,mBAAmB,IAAI,CAAC;AAErC;;;GAGG;AACH,eAAO,MAAM,qBAAqB,QAAW,CAAC;AAE9C;;;;;;;;;;;;GAYG;AACH,eAAO,MAAM,UAAU;;;;;;;;;4BAYrB,CAAC;AACH,MAAM,MAAM,UAAU,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,UAAU,CAAC,CAAC;AAEpD;;;;;;;;GAQG;AACH,eAAO,MAAM,eAAe;;;;;4BAG1B,CAAC;AACH,MAAM,MAAM,eAAe,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,eAAe,CAAC,CAAC;AAE9D,kDAAkD;AAClD,eAAO,MAAM,gBAAgB,QAAO,UAGlC,CAAC;AAEH,uCAAuC;AACvC,eAAO,MAAM,mBAAmB,GAAI,SAAS,KAAK,CAAC,MAAM,CAAC,KAAG,UAI3D,CAAC;AAEH,qDAAqD;AACrD,eAAO,MAAM,sBAAsB,GAAI,OAAO,eAAe,KAAG,UACgB,CAAC;AAEjF;;;;GAIG;AACH,eAAO,MAAM,yBAAyB,GAAI,OAAO,UAAU,EAAE,QAAQ,MAAM,KAAG,OACtB,CAAC;AAEzD;;;;;;;;;;;;;GAaG;AACH,eAAO,MAAM,0BAA0B,GAAI,OAAO,UAAU,KAAG,OAAgC,CAAC;AAEhG,kFAAkF;AAClF,eAAO,MAAM,0BAA0B;;;;;CAK7B,CAAC;AACX,MAAM,MAAM,YAAY,GAAG,MAAM,OAAO,0BAA0B,CAAC;AAEnE;;;;;;;;;GASG;AACH,eAAO,MAAM,+BAA+B,GAC3C,SAAS,YAAY,KACnB;IAAE,KAAK,EAAE,sBAAsB,CAAC;IAAC,cAAc,EAAE,MAAM,CAAA;CAGxD,CAAC;AAEH,4CAA4C;AAC5C,eAAO,MAAM,iBAAiB,GAAI,OAAO,UAAU,KAAG,MAGrD,CAAC;AAEF;;;;;;;;;GASG;AACH,eAAO,MAAM,iBAAiB,GAAI,KAAK,MAAM,KAAG,UAAU,GAAG,IAU5D,CAAC;AAEF;;;;;GAKG;AACH,eAAO,MAAM,qBAAqB,GAAI,OAAO,UAAU,KAAG,MAQzD,CAAC;AAEF;;;;;;;;GAQG;AACH,eAAO,MAAM,wBAAwB,GAAI,OAAO,OAAO,KAAG,UAAU,GAAG,IAQtE,CAAC"}
1
+ {"version":3,"file":"token_scope.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/auth/token_scope.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AAEH,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAExB,wFAAwF;AACxF,eAAO,MAAM,mBAAmB,IAAI,CAAC;AAErC;;;GAGG;AACH,eAAO,MAAM,qBAAqB,QAAW,CAAC;AAE9C;;;;;;;;;;;;GAYG;AACH,eAAO,MAAM,UAAU;;;;;;;;;4BAYrB,CAAC;AACH,MAAM,MAAM,UAAU,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,UAAU,CAAC,CAAC;AAEpD;;;;;;;;GAQG;AACH,eAAO,MAAM,eAAe;;;;;4BAG1B,CAAC;AACH,MAAM,MAAM,eAAe,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,eAAe,CAAC,CAAC;AAE9D,kDAAkD;AAClD,eAAO,MAAM,gBAAgB,QAAO,UAGlC,CAAC;AAEH,uCAAuC;AACvC,eAAO,MAAM,mBAAmB,GAAI,SAAS,KAAK,CAAC,MAAM,CAAC,KAAG,UAI3D,CAAC;AAEH,qDAAqD;AACrD,eAAO,MAAM,sBAAsB,GAAI,OAAO,eAAe,KAAG,UACgB,CAAC;AAEjF;;;;GAIG;AACH,eAAO,MAAM,yBAAyB,GAAI,OAAO,UAAU,EAAE,QAAQ,MAAM,KAAG,OACtB,CAAC;AAEzD;;;;;;;;;;;;;;;;;GAiBG;AACH,eAAO,MAAM,0BAA0B,GAAI,OAAO,UAAU,KAAG,OAAgC,CAAC;AAEhG;;;;;;;;;;;;;;;GAeG;AACH,eAAO,MAAM,cAAc,kEAAmE,CAAC;AAC/F,MAAM,MAAM,YAAY,GAAG,CAAC,OAAO,cAAc,CAAC,CAAC,MAAM,CAAC,CAAC;AAE3D;;;;;;;;GAQG;AACH,eAAO,MAAM,gBAAgB,GAAI,OAAO,MAAM,KAAG,KAAK,IAAI,YACA,CAAC;AAE3D;;;;;;;;GAQG;AACH,eAAO,MAAM,wBAAwB,QAAgC,CAAC;AAQtE;;;;;;GAMG;AACH,eAAO,MAAM,6BAA6B,GAAI,QAAQ,MAAM,KAAG,MAC3B,CAAC;AAErC;;;;;;;;;;;GAWG;AACH,MAAM,MAAM,oBAAoB,GAC/B;IAAE,IAAI,EAAE,KAAK,CAAC;IAAC,UAAU,EAAE,MAAM,CAAC;IAAC,MAAM,EAAE,MAAM,CAAA;CAAE,GAAG;IAAE,IAAI,EAAE,SAAS,CAAC;IAAC,UAAU,EAAE,MAAM,CAAA;CAAE,CAAC;AAE/F;;;;;;;;;;;;;;GAcG;AACH,eAAO,MAAM,4BAA4B,GAAI,OAAO,MAAM,KAAG,oBAAoB,GAAG,IAUnF,CAAC;AAEF;;;;;;;;GAQG;AACH,eAAO,MAAM,6BAA6B,GACzC,OAAO,UAAU,EACjB,YAAY,oBAAoB,KAC9B,OAGkC,CAAC;AAEtC;;;;;;;;;GASG;AACH,eAAO,MAAM,uBAAuB,GACnC,YAAY,MAAM,KAChB;IAAE,KAAK,EAAE,sBAAsB,CAAC;IAAC,cAAc,EAAE,MAAM,CAAA;CAGxD,CAAC;AAEH,4CAA4C;AAC5C,eAAO,MAAM,iBAAiB,GAAI,OAAO,UAAU,KAAG,MAGrD,CAAC;AAEF;;;;;;;;;GASG;AACH,eAAO,MAAM,iBAAiB,GAAI,KAAK,MAAM,KAAG,UAAU,GAAG,IAU5D,CAAC;AAEF;;;;;GAKG;AACH,eAAO,MAAM,qBAAqB,GAAI,OAAO,UAAU,KAAG,MAQzD,CAAC;AAEF;;;;;;;;GAQG;AACH,eAAO,MAAM,wBAAwB,GAAI,OAAO,OAAO,KAAG,UAAU,GAAG,IAQtE,CAAC"}
@@ -94,38 +94,123 @@ export const token_scope_admits_method = (scope, method) => scope.kind === 'full
94
94
  /**
95
95
  * Does `scope` admit a non-RPC spine surface?
96
96
  *
97
- * **Rule 3, and it is the load-bearing one.** A method-name allowlist alone
98
- * would have been a false promise: the db-admin browser serves paginated rows of
99
- * any `public` table (including `account.password_hash`, `auth_session`, and
100
- * `api_token`) plus row `DELETE`, gated on a global role a bearer credential
101
- * satisfies. A token whose UI badge read "scoped to `cell_get`" could still
102
- * delete an account row.
97
+ * **A narrowed token reaches no non-RPC surface at all** — the load-bearing
98
+ * half of token scoping, and what "rule 3" names wherever this module's
99
+ * callers and the Rust twin use that shorthand. See `docs/security.md`
100
+ * §Token scoping.
103
101
  *
104
- * So a narrowed token reaches no non-RPC surface at all. Strictly more
105
- * restrictive than naming each surface, no vocabulary to maintain, and one
106
- * sentence a user can hold: *a scoped token is RPC-only*.
102
+ * A method-name allowlist alone would have been a false promise: the db-admin
103
+ * browser serves paginated rows of any `public` table (including
104
+ * `account.password_hash`, `auth_session`, and `api_token`) plus row `DELETE`,
105
+ * gated on a global role a bearer credential satisfies. A token whose UI badge
106
+ * read "scoped to `cell_get`" could still delete an account row.
107
+ *
108
+ * Denying every non-RPC surface outright is strictly more restrictive than
109
+ * naming each one, keeps no vocabulary to maintain, and fits in one sentence a
110
+ * user can hold: *a scoped token is RPC-only*.
107
111
  */
108
112
  export const token_scope_admits_non_rpc = (scope) => scope.kind === 'full';
109
- /** Surfaces gated by rule 3. Capability strings match the Rust `TokenSurface`. */
110
- export const TOKEN_SURFACE_CAPABILITIES = {
111
- db_admin: 'surface:db_admin',
112
- fact_bare: 'surface:fact_bare',
113
- audit_stream: 'surface:audit_stream',
114
- ws_upgrade: 'surface:ws_upgrade'
113
+ /**
114
+ * The non-RPC surfaces the **spine itself** mounts (rule 3). Twin of the Rust
115
+ * `TokenSurface` enum, name for name.
116
+ *
117
+ * Not a registry a consumer registers into. `RouteAuth.required_scope` accepts
118
+ * any well-formed `surface:<name>` (see `parse_token_scope_capability`), so a
119
+ * consumer names its own non-RPC surface without an upstream release. This
120
+ * tuple is what the *spine* may declare, and what its surface census iterates
121
+ * — the closure is a check on fuz_app's own routes, not a vocabulary consumers
122
+ * have to ask permission to extend.
123
+ *
124
+ * A surface name never decides anything: rule 3 is all-or-nothing
125
+ * (`token_scope_admits_non_rpc`), so the name only labels *which* surface
126
+ * refused, in the `required_scope` a denial reports. That is the same field
127
+ * whose `rpc:<method>` arm has always carried arbitrary consumer method names.
128
+ */
129
+ export const TOKEN_SURFACES = ['db_admin', 'fact_bare', 'audit_stream', 'ws_upgrade'];
130
+ /**
131
+ * Is `value` one of the surfaces the spine mounts?
132
+ *
133
+ * Membership, not well-formedness — `parse_token_scope_capability` is the format
134
+ * check the guard resolver runs on a declaration, and it is deliberately looser.
135
+ * Used where the *spine's* own surfaces are the question: the direct-call denial
136
+ * helpers, and the census assertion that fuz_app declares no surface it doesn't
137
+ * mount.
138
+ */
139
+ export const is_token_surface = (value) => TOKEN_SURFACES.includes(value);
140
+ /**
141
+ * Name format for the identifier half of a capability string. Mirrors
142
+ * `RoleName` / `CredentialTypeName` / `ScopeKindName`.
143
+ *
144
+ * Applied to the `surface:` arm only. The `rpc:` arm's identifier is an action
145
+ * method name, whose vocabulary belongs to `ActionSpec` (and includes shapes
146
+ * like `peer/ping` this regex would refuse) — the capability parser checks it
147
+ * is non-empty and leaves the rest to the registry that owns it.
148
+ */
149
+ export const TOKEN_SURFACE_NAME_REGEX = /^[a-z][a-z_]*[a-z]$|^[a-z]$/;
150
+ // Capability-string prefixes. Private: `token_scope_method_capability` builds
151
+ // the one capability anything computes, and `parse_token_scope_capability` reads
152
+ // one back, so nothing outside this module concatenates these itself.
153
+ const CAPABILITY_RPC_PREFIX = 'rpc:';
154
+ const CAPABILITY_SURFACE_PREFIX = 'surface:';
155
+ /**
156
+ * The capability string an action method is refused under: `rpc:<method>`.
157
+ *
158
+ * Exported because two sites *compute* one — the dispatcher's per-method gate
159
+ * and the REST bridge, which must agree — where a `surface:` capability is
160
+ * always written as a literal on a route spec, so nothing computes one.
161
+ */
162
+ export const token_scope_method_capability = (method) => `${CAPABILITY_RPC_PREFIX}${method}`;
163
+ /**
164
+ * Parse a capability string into the question it asks, or `null` when it is
165
+ * not a capability at all.
166
+ *
167
+ * Callers turn `null` into a registration-time throw: `RouteAuth.required_scope`
168
+ * is typed as a plain string so `http/` needs no `auth/` import, and this is the
169
+ * single point where that string becomes a mounted guard, so it is where the
170
+ * value gets checked. Refusing an unknown prefix is what keeps the `surface:`
171
+ * namespace fuz_app's own — a route cannot mint an `rpc:`-looking capability out
172
+ * of a surface gate, which would put a method name in front of a whole-surface
173
+ * refusal.
174
+ *
175
+ * @param value - the declared capability string, e.g. `surface:audit_stream`
176
+ * @returns the parsed capability, or `null` on an unknown prefix / empty or malformed identifier
177
+ */
178
+ export const parse_token_scope_capability = (value) => {
179
+ if (value.startsWith(CAPABILITY_RPC_PREFIX)) {
180
+ const method = value.slice(CAPABILITY_RPC_PREFIX.length);
181
+ return method.length > 0 ? { kind: 'rpc', capability: value, method } : null;
182
+ }
183
+ if (value.startsWith(CAPABILITY_SURFACE_PREFIX)) {
184
+ const surface = value.slice(CAPABILITY_SURFACE_PREFIX.length);
185
+ return TOKEN_SURFACE_NAME_REGEX.test(surface) ? { kind: 'surface', capability: value } : null;
186
+ }
187
+ return null;
115
188
  };
116
189
  /**
117
- * Build the flat denial body a non-RPC surface returns when a narrowed token
118
- * reaches it: `{error: 'token_scope_required', required_scope: 'surface:<name>'}`
119
- * at 403.
190
+ * Does `scope` admit `capability`?
191
+ *
192
+ * The one place the two arms of the vocabulary meet their two predicates, so a
193
+ * `surface:` capability can never be answered by a method-list membership test
194
+ * (which would let a narrowed token onto a surface by listing a method named
195
+ * after it) and an `rpc:` capability can never be answered by rule 3 (which
196
+ * would refuse a token the method it was minted for).
197
+ */
198
+ export const token_scope_admits_capability = (scope, capability) => capability.kind === 'rpc'
199
+ ? token_scope_admits_method(scope, capability.method)
200
+ : token_scope_admits_non_rpc(scope);
201
+ /**
202
+ * Build the flat denial body a non-RPC route returns when a narrowed token
203
+ * reaches a capability it lacks:
204
+ * `{error: 'token_scope_required', required_scope: '<section>:<id>'}` at 403.
120
205
  *
121
- * One shape for every surface, matching the Rust
206
+ * One shape for every capability, matching the Rust
122
207
  * `token_scope_surface_denied_response`. These are REST-ish routes whose
123
208
  * sibling denials are already flat, so the JSON-RPC envelope stays with the
124
209
  * dispatcher's per-method gate and stops there.
125
210
  */
126
- export const token_scope_surface_denied_body = (surface) => ({
211
+ export const token_scope_denied_body = (capability) => ({
127
212
  error: 'token_scope_required',
128
- required_scope: TOKEN_SURFACE_CAPABILITIES[surface]
213
+ required_scope: capability
129
214
  });
130
215
  /** Stable label for the token list / UI. */
131
216
  export const token_scope_label = (scope) => {
@@ -43,7 +43,7 @@ consumers.
43
43
 
44
44
  - `method` — `'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH'`
45
45
  - `path` — Hono path (supports `:param` segments)
46
- - `auth: RouteAuth` — flat record `{account, actor, roles?, credential_types?}` from `http/auth_shape.ts`. Each axis is `'none' | 'optional' | 'required'`. Same shape governs `ActionSpec.auth` (see `actions/CLAUDE.md`).
46
+ - `auth: RouteAuth` — flat record `{account, actor, roles?, credential_types?, required_scope?}` from `http/auth_shape.ts`. `account` / `actor` are `'none' | 'optional' | 'required'`. `required_scope` names the capability a narrowed api token's scope must admit — `surface:<name>` (a non-RPC surface, refused to every narrowed token) or `rpc:<method>` (one action method) — and is route-spec-only: `compile_action_registry` rejects it on an `ActionSpec`, where the dispatcher derives the same capability from `spec.method`. Otherwise the same shape governs `ActionSpec.auth` (see `actions/CLAUDE.md`).
47
47
  - `handler: RouteHandler` — `(c: Context, route: RouteContext) => Response | Promise<Response>`
48
48
  - `description` — free-text, surfaced in `AppSurface`
49
49
  - `params?: z.ZodObject` — strict-object schema for URL path params
@@ -97,20 +97,22 @@ wrapper). See `auth/signup_routes.ts`.
97
97
 
98
98
  1. **Params validation** — `spec.params` → `validated_params` context var; mismatch returns 400 `ERROR_INVALID_ROUTE_PARAMS` with Zod `issues`
99
99
  2. **Query validation** — `spec.query` → `validated_query`; mismatch returns 400 `ERROR_INVALID_QUERY_PARAMS`
100
- 3. **Pre-validation auth guards** — `require_auth` (401 `ERROR_AUTHENTICATION_REQUIRED`) when `auth.account === 'required'` or `auth.actor === 'required'`. Fires before any body parsing so unauthenticated callers never see route-shape information from input parse failures. The `AuthGuardResolver` (e.g. `fuz_auth_guard_resolver` from `auth/auth_guard_resolver.ts`) returns this set as `pre_validation: Array<MiddlewareHandler>`.
101
- 4. **Input validation** — JSON body parsed + validated; mismatch returns 400 `ERROR_INVALID_JSON_BODY` (not JSON) or `ERROR_INVALID_REQUEST_BODY` (schema failure with `issues`). Skipped on GET and `z.null()` inputs. The validated input lands on `c.var.validated_input` so the authorization phase reads `acting` as a typed Zod field.
102
- 5. **Authorization phase** — when `spec.auth.actor !== 'none'`, resolves the acting actor against `c.var.account_id` (set by the auth middleware) plus `validated_input.acting` (or `validated_query.acting` for GET routes), builds `RequestContext` via `build_request_context`, and sets `REQUEST_CONTEXT_KEY`. When `auth.account !== 'none' && auth.actor === 'none'`, an account-only context is built. Resolution failures return 400 `ERROR_ACTOR_REQUIRED` (with `available[]`) or `ERROR_ACTOR_NOT_ON_ACCOUNT` (or 500 `ERROR_NO_ACTORS_ON_ACCOUNT` on signup-invariant violation, 500 `ERROR_ACCOUNT_VANISHED` on torn account/actor reads after a successful resolve). Public routes (`account: 'none' && actor: 'none'`) skip this phase entirely.
103
- 6. **Post-authorization auth guards** — `require_credential_types(types)` (403 `ERROR_CREDENTIAL_TYPE_REQUIRED` with `required_credential_types: ReadonlyArray<string>`) fires first when `auth.credential_types?.length`; `require_role(roles)` (403 `ERROR_INSUFFICIENT_PERMISSIONS` with `required_roles: ReadonlyArray<string>`) fires next when `auth.roles?.length`. Both read `REQUEST_CONTEXT_KEY` populated by step 5. Multi-role specs admit any-of via `has_any_scoped_role(ctx, roles, null)`.
100
+ 3. **Pre-authorization auth guards** — every gate that reads only what the auth middleware set, in coarse-to-fine order: `require_auth` (401 `ERROR_AUTHENTICATION_REQUIRED`) when `auth.account === 'required'` or `auth.actor === 'required'`; `require_credential_types(types)` (403 `ERROR_CREDENTIAL_TYPE_REQUIRED` with `required_credential_types: ReadonlyArray<string>`) when `auth.credential_types?.length`; `require_token_scope(capability)` (403 `ERROR_TOKEN_SCOPE_REQUIRED` with `required_scope`, the token-scope refusal) when `auth.required_scope` names a capability. All fire before any body parsing — so a caller they refuse never sees route-shape information from input parse failures — and before actor resolution, so a wrong channel costs no DB work and never learns account state (e.g. that the account is multi-actor). The `AuthGuardResolver` (e.g. `fuz_auth_guard_resolver` from `auth/auth_guard_resolver.ts`) returns this set as `pre_authorization: Array<MiddlewareHandler>`.
101
+ 4. **Authorization phase** — when `spec.auth.actor !== 'none'`, resolves the acting actor against `c.var.account_id` (set by the auth middleware) plus the `acting` selector (`validated_query.acting` on GETs; read off the raw body on mutations, since body validation now runs after the gates), builds `RequestContext` via `build_request_context`, and sets `REQUEST_CONTEXT_KEY`. When `auth.account !== 'none' && auth.actor === 'none'`, an account-only context is built. Resolution failures return 400 `ERROR_ACTOR_REQUIRED` (with `available[]`) or `ERROR_ACTOR_NOT_ON_ACCOUNT` (or 500 `ERROR_NO_ACTORS_ON_ACCOUNT` on signup-invariant violation, 500 `ERROR_ACCOUNT_VANISHED` on torn account/actor reads after a successful resolve). Public routes (`account: 'none' && actor: 'none'`) skip this phase entirely.
102
+ 5. **Post-authorization auth guards** — `require_role(roles)` (403 `ERROR_INSUFFICIENT_PERMISSIONS` with `required_roles: ReadonlyArray<string>`) when `auth.roles?.length`. The only gate in this phase, because it is the only one that reads `REQUEST_CONTEXT_KEY` (populated by step 4) rather than the auth middleware's own context vars. Multi-role specs admit any-of via `has_any_scoped_role(ctx, roles, null)`.
103
+ 6. **Input validation** — JSON body parsed + validated; mismatch returns 400 `ERROR_INVALID_JSON_BODY` (not JSON) or `ERROR_INVALID_REQUEST_BODY` (schema failure with `issues`). Skipped on GET and `z.null()` inputs. The validated input lands on `c.var.validated_input`.
104
104
  7. **Handler** — wrapped in transaction when `use_transaction` (see above), receives `RouteContext`
105
105
  8. **DEV-only output + error validation** — wraps the handler (see below)
106
106
  9. **Error catch** — catches `ThrownJsonrpcError` → maps to HTTP status + the flat REST `ApiError` body (`{error: <reason>, message?, ...rest_data}`); catches generic `Error` → 500 `{error: 'internal_error', message?}` (message only in DEV). The reason string comes from `err.data.reason` when set (consumer-supplied canonical reason override) or from `jsonrpc_error_code_to_name(err.code)` (e.g. `-32003 → 'not_found'`). The flat shape matches what middleware and direct handlers emit — REST callers see one envelope across every emit site, while the JSON-RPC dispatcher keeps its own `{jsonrpc, id, error: {code, message, data}}` envelope on the RPC mount.
107
107
 
108
- **Ordering: 401 → 400 → 403 → handler.** Mirrors the RPC dispatcher
109
- (`actions/action_rpc.ts`) so HTTP RPC and REST fail with the same priority.
110
- The alternative (403-before-400) was rejected because defense-in-depth via
111
- attack-surface obscurity is illusory when the surface is published in
112
- `library.json` codegen anyway. The trade-off is that an
113
- authenticated-but-unauthorized caller can distinguish 400 from 403.
108
+ **Ordering: 401 → authz → 403 → 400 → handler.** Mirrors the RPC dispatcher
109
+ (`actions/action_rpc.ts`) so HTTP RPC and REST fail with the same priority, and
110
+ matches the Rust spine, which validates handler-side. Body validation runs last
111
+ so a caller the authority gates refuse learns nothing about the route's input
112
+ shape — a 400 would confirm the route exists and describe how to call it.
113
+ Params and query still validate first: they are part of addressing the route,
114
+ not of its payload, and the authorization phase reads `acting` off the query on
115
+ GETs.
114
116
 
115
117
  Duplicate `method path` pairs throw at registration.
116
118
 
@@ -501,6 +503,16 @@ Param schemas use `VALID_SQL_IDENTIFIER` regex, and every table name gets
501
503
  `assert_valid_sql_identifier()` before string-interpolating into SQL —
502
504
  the identifier validation is the only reason the interpolation is safe.
503
505
 
506
+ **Do not widen the auth on these specs.** They serve paginated rows of any
507
+ `public` table — `account.password_hash`, `auth_session`, `api_token` — plus
508
+ row `DELETE`. `credential_types: ['daemon_token']` is what keeps every bearer
509
+ credential out, and it's why the specs need no `auth.required_scope`: a daemon
510
+ token resolves to `full` by construction, so no narrowed token can reach them
511
+ (see ../../../docs/security.md §Token scoping). Re-auth them to a role a bearer
512
+ satisfies and both controls are gone at once — the token's scope stops
513
+ applying and the credential gate stops firing. A consumer that does this owns
514
+ the surface census for it.
515
+
504
516
  Interfaces exported for consumer use: `TableInfo`, `TableWithCount`,
505
517
  `PrimaryKeyInfo`, `ColumnInfo`, `DbRouteOptions`.
506
518
 
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Canonical four-axis auth shape for action specs and route specs.
2
+ * Canonical auth shape for action specs and route specs.
3
3
  *
4
4
  * Replaces the discriminated `'public' | 'authenticated' | 'keeper' | {role}`
5
5
  * literal that conflated authentication, account resolution, actor resolution,
@@ -11,6 +11,9 @@
11
11
  * - `roles` — disjunction of permitted roles (any-of); absent = no role check.
12
12
  * - `credential_types` — restricts the credential channel (e.g. daemon_token);
13
13
  * absent = any authenticated credential.
14
+ * - `required_scope` — the capability an api token's scope must admit to reach
15
+ * this route (`rpc:<method>` or `surface:<name>`); route specs only,
16
+ * absent = no scope gate.
14
17
  *
15
18
  * The same shape governs both `ActionSpec.auth` (in `actions/action_spec.ts`)
16
19
  * and `RouteSpec.auth` (in `http/route_spec.ts`). The canonical schema
@@ -52,6 +55,27 @@ import { z } from 'zod';
52
55
  */
53
56
  export declare const ActingActor: z.ZodOptional<z.core.$ZodBranded<z.ZodUUID, "Uuid", "out">>;
54
57
  export type ActingActor = z.infer<typeof ActingActor>;
58
+ /**
59
+ * Read an `acting` selector out of an unvalidated value.
60
+ *
61
+ * The authorization phase runs **before** input validation on both dispatchers
62
+ * (see `http/CLAUDE.md` §Validation pipeline), so it reads the selector raw.
63
+ *
64
+ * **A malformed selector reads as omitted, not as an error.** Safe because
65
+ * `acting` only ever picks among actors the resolved account already owns — a
66
+ * value that names nothing grants nothing — and because the typed input schema
67
+ * rejects it a step later, so the caller still gets a 400. Refusing here
68
+ * instead would put an input-shape complaint ahead of the authority gates,
69
+ * which is the ordering this pipeline exists to avoid.
70
+ *
71
+ * Both callers pass through here so that rule has one home: the action
72
+ * dispatcher's `read_acting` and the REST authorization handler's
73
+ * `read_route_acting`. Twin of the Rust dispatcher's `read_acting`.
74
+ *
75
+ * @param value - the raw `acting` field, from params / query / body
76
+ * @returns the actor id, or `undefined` when absent or malformed
77
+ */
78
+ export declare const parse_acting: (value: unknown) => string | undefined;
55
79
  /**
56
80
  * Per-axis auth state — names the dispatcher's behavior on `account` and
57
81
  * `actor` independently:
@@ -73,7 +97,7 @@ export declare const AuthAxisState: z.ZodEnum<{
73
97
  }>;
74
98
  export type AuthAxisState = z.infer<typeof AuthAxisState>;
75
99
  /**
76
- * The canonical four-axis auth shape used by both `ActionSpec.auth` and
100
+ * The canonical auth shape used by both `ActionSpec.auth` and
77
101
  * `RouteSpec.auth`.
78
102
  *
79
103
  * Cross-axis registry invariants enforced via `.superRefine`:
@@ -85,7 +109,11 @@ export type AuthAxisState = z.infer<typeof AuthAxisState>;
85
109
  * is invalid in v1. The credential resolver always binds account before
86
110
  * actor today; agent-token / group-actor credentials will lift this.
87
111
  * 4. **Unrestricted is leaf.** `account === 'none' && actor === 'none'`
88
- * ⟹ no `roles`, no `credential_types` (nothing left to gate).
112
+ * ⟹ no `roles`, no `credential_types` (nothing left to gate). The same
113
+ * rule covers `required_scope`, but enforcing it needs the capability
114
+ * vocabulary from `auth/`, so it lives with the other `required_scope`
115
+ * check in `fuz_auth_guard_resolver` — which is also the only site every
116
+ * route spec traverses, since the route pipeline never parses this schema.
89
117
  *
90
118
  * Invariant 2 — the `actor !== 'none' ⟺ input or query declares
91
119
  * acting?: ActingActor` biconditional — needs introspection of the
@@ -106,6 +134,33 @@ export declare const RouteAuth: z.ZodObject<{
106
134
  }>;
107
135
  roles: z.ZodOptional<z.ZodReadonly<z.ZodArray<z.ZodString>>>;
108
136
  credential_types: z.ZodOptional<z.ZodReadonly<z.ZodArray<z.ZodString>>>;
137
+ /**
138
+ * The capability a **narrowed** api token's scope must admit to reach this
139
+ * route, in the `<section>:<id>` vocabulary a denial reports back as
140
+ * `required_scope`:
141
+ *
142
+ * - `surface:<name>` — a non-RPC surface, refused to every narrowed token
143
+ * whatever its method list says (token scoping's rule that a narrowed
144
+ * token is RPC-only). `surface:audit_stream` and `surface:fact_bare` are
145
+ * the spine's; a consumer names its own without registering it.
146
+ * - `rpc:<method>` — one action method, refused unless the token lists it.
147
+ * What a route bridged from an `ActionSpec` declares: it carries the
148
+ * method's identity but never reaches the dispatcher's per-method gate.
149
+ *
150
+ * Declaring it makes the guard resolver mount the refusal ahead of the role
151
+ * gate, instead of the route hand-rolling the check in its handler (which
152
+ * the auth phase has already role-gated by then, so a narrowed token whose
153
+ * account lacks the role would be told about the role rather than about the
154
+ * scope).
155
+ *
156
+ * Opt-in rather than blanket: the account REST routes cross no boundary, and
157
+ * the RPC endpoint runs its own per-method check inside the dispatcher — and
158
+ * both are route specs too.
159
+ *
160
+ * Typed as a plain string here to keep `http/` from importing `auth/`; the
161
+ * resolver parses it at registration and throws on a malformed one.
162
+ */
163
+ required_scope: z.ZodOptional<z.ZodString>;
109
164
  }, z.core.$strict>;
110
165
  export type RouteAuth = z.infer<typeof RouteAuth>;
111
166
  /**
@@ -1 +1 @@
1
- {"version":3,"file":"auth_shape.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/http/auth_shape.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA8BG;AAEH,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAIxB;;;;;;;;;;;;;;;;;;;GAmBG;AACH,eAAO,MAAM,WAAW,6DAGtB,CAAC;AACH,MAAM,MAAM,WAAW,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,WAAW,CAAC,CAAC;AAEtD;;;;;;;;;;;;;GAaG;AACH,eAAO,MAAM,aAAa;;;;EAA2C,CAAC;AACtE,MAAM,MAAM,aAAa,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,aAAa,CAAC,CAAC;AAE1D;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,eAAO,MAAM,SAAS;;;;;;;;;;;;;kBA6CnB,CAAC;AACJ,MAAM,MAAM,SAAS,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,SAAS,CAAC,CAAC;AAUlD;;;;;GAKG;AACH,eAAO,MAAM,cAAc,GAAI,MAAM,SAAS,KAAG,OACA,CAAC;AAElD;;;;;;GAMG;AACH,eAAO,MAAM,WAAW,GAAI,MAAM,SAAS,KAAG,OAAgC,CAAC;AAE/E;;;;GAIG;AACH,eAAO,MAAM,aAAa,GAAI,MAAM,SAAS,KAAG,OAAkC,CAAC;AAEnF,wEAAwE;AACxE,eAAO,MAAM,YAAY,GAAI,MAAM,SAAS,KAAG,OAA+B,CAAC;AAE/E,8FAA8F;AAC9F,eAAO,MAAM,wBAAwB,GAAI,MAAM,SAAS,KAAG,OAC3B,CAAC;AAEjC;;;;;GAKG;AACH,eAAO,MAAM,cAAc,GAAI,MAAM,SAAS,KAAG,OACQ,CAAC;AAE1D;;;;GAIG;AACH,eAAO,MAAM,2BAA2B,GAAI,MAAM,SAAS,KAAG,OACwB,CAAC;AAYvF;;;;;;;;;;GAUG;AACH,eAAO,MAAM,4BAA4B,GAAI,QAAQ,CAAC,CAAC,OAAO,KAAG,OAIhE,CAAC;AAEF;;;;;GAKG;AACH,MAAM,WAAW,WAAW;IAC3B,KAAK,EAAE,CAAC,CAAC,OAAO,CAAC;IACjB,KAAK,CAAC,EAAE,CAAC,CAAC,OAAO,CAAC;CAClB;AAED;;;;;;;;;;;;;;;;;;GAkBG;AACH,eAAO,MAAM,sCAAsC,GAClD,MAAM,SAAS,EACf,OAAO,WAAW,EAClB,SAAS,MAAM,KACb,IAmBF,CAAC"}
1
+ {"version":3,"file":"auth_shape.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/http/auth_shape.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAiCG;AAEH,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAIxB;;;;;;;;;;;;;;;;;;;GAmBG;AACH,eAAO,MAAM,WAAW,6DAGtB,CAAC;AACH,MAAM,MAAM,WAAW,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,WAAW,CAAC,CAAC;AAEtD;;;;;;;;;;;;;;;;;;;GAmBG;AACH,eAAO,MAAM,YAAY,GAAI,OAAO,OAAO,KAAG,MAAM,GAAG,SAGtD,CAAC;AAEF;;;;;;;;;;;;;GAaG;AACH,eAAO,MAAM,aAAa;;;;EAA2C,CAAC;AACtE,MAAM,MAAM,aAAa,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,aAAa,CAAC,CAAC;AAE1D;;;;;;;;;;;;;;;;;;;;;;;;GAwBG;AACH,eAAO,MAAM,SAAS;;;;;;;;;;;;;IAMpB;;;;;;;;;;;;;;;;;;;;;;;;;OAyBG;;kBAyCF,CAAC;AACJ,MAAM,MAAM,SAAS,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,SAAS,CAAC,CAAC;AAUlD;;;;;GAKG;AACH,eAAO,MAAM,cAAc,GAAI,MAAM,SAAS,KAAG,OACA,CAAC;AAElD;;;;;;GAMG;AACH,eAAO,MAAM,WAAW,GAAI,MAAM,SAAS,KAAG,OAAgC,CAAC;AAE/E;;;;GAIG;AACH,eAAO,MAAM,aAAa,GAAI,MAAM,SAAS,KAAG,OAAkC,CAAC;AAEnF,wEAAwE;AACxE,eAAO,MAAM,YAAY,GAAI,MAAM,SAAS,KAAG,OAA+B,CAAC;AAE/E,8FAA8F;AAC9F,eAAO,MAAM,wBAAwB,GAAI,MAAM,SAAS,KAAG,OAC3B,CAAC;AAEjC;;;;;GAKG;AACH,eAAO,MAAM,cAAc,GAAI,MAAM,SAAS,KAAG,OACQ,CAAC;AAE1D;;;;GAIG;AACH,eAAO,MAAM,2BAA2B,GAAI,MAAM,SAAS,KAAG,OACwB,CAAC;AAYvF;;;;;;;;;;GAUG;AACH,eAAO,MAAM,4BAA4B,GAAI,QAAQ,CAAC,CAAC,OAAO,KAAG,OAIhE,CAAC;AAEF;;;;;GAKG;AACH,MAAM,WAAW,WAAW;IAC3B,KAAK,EAAE,CAAC,CAAC,OAAO,CAAC;IACjB,KAAK,CAAC,EAAE,CAAC,CAAC,OAAO,CAAC;CAClB;AAED;;;;;;;;;;;;;;;;;;GAkBG;AACH,eAAO,MAAM,sCAAsC,GAClD,MAAM,SAAS,EACf,OAAO,WAAW,EAClB,SAAS,MAAM,KACb,IAmBF,CAAC"}
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Canonical four-axis auth shape for action specs and route specs.
2
+ * Canonical auth shape for action specs and route specs.
3
3
  *
4
4
  * Replaces the discriminated `'public' | 'authenticated' | 'keeper' | {role}`
5
5
  * literal that conflated authentication, account resolution, actor resolution,
@@ -11,6 +11,9 @@
11
11
  * - `roles` — disjunction of permitted roles (any-of); absent = no role check.
12
12
  * - `credential_types` — restricts the credential channel (e.g. daemon_token);
13
13
  * absent = any authenticated credential.
14
+ * - `required_scope` — the capability an api token's scope must admit to reach
15
+ * this route (`rpc:<method>` or `surface:<name>`); route specs only,
16
+ * absent = no scope gate.
14
17
  *
15
18
  * The same shape governs both `ActionSpec.auth` (in `actions/action_spec.ts`)
16
19
  * and `RouteSpec.auth` (in `http/route_spec.ts`). The canonical schema
@@ -55,6 +58,30 @@ import { zod_unwrap_to_object } from '@fuzdev/fuz_util/zod.ts';
55
58
  export const ActingActor = Uuid.optional().meta({
56
59
  description: 'Actor on the authenticated account that this request acts as. Omit on single-actor accounts; required on multi-actor.'
57
60
  });
61
+ /**
62
+ * Read an `acting` selector out of an unvalidated value.
63
+ *
64
+ * The authorization phase runs **before** input validation on both dispatchers
65
+ * (see `http/CLAUDE.md` §Validation pipeline), so it reads the selector raw.
66
+ *
67
+ * **A malformed selector reads as omitted, not as an error.** Safe because
68
+ * `acting` only ever picks among actors the resolved account already owns — a
69
+ * value that names nothing grants nothing — and because the typed input schema
70
+ * rejects it a step later, so the caller still gets a 400. Refusing here
71
+ * instead would put an input-shape complaint ahead of the authority gates,
72
+ * which is the ordering this pipeline exists to avoid.
73
+ *
74
+ * Both callers pass through here so that rule has one home: the action
75
+ * dispatcher's `read_acting` and the REST authorization handler's
76
+ * `read_route_acting`. Twin of the Rust dispatcher's `read_acting`.
77
+ *
78
+ * @param value - the raw `acting` field, from params / query / body
79
+ * @returns the actor id, or `undefined` when absent or malformed
80
+ */
81
+ export const parse_acting = (value) => {
82
+ const parsed = ActingActor.safeParse(value);
83
+ return parsed.success ? parsed.data : undefined;
84
+ };
58
85
  /**
59
86
  * Per-axis auth state — names the dispatcher's behavior on `account` and
60
87
  * `actor` independently:
@@ -71,7 +98,7 @@ export const ActingActor = Uuid.optional().meta({
71
98
  */
72
99
  export const AuthAxisState = z.enum(['none', 'optional', 'required']);
73
100
  /**
74
- * The canonical four-axis auth shape used by both `ActionSpec.auth` and
101
+ * The canonical auth shape used by both `ActionSpec.auth` and
75
102
  * `RouteSpec.auth`.
76
103
  *
77
104
  * Cross-axis registry invariants enforced via `.superRefine`:
@@ -83,7 +110,11 @@ export const AuthAxisState = z.enum(['none', 'optional', 'required']);
83
110
  * is invalid in v1. The credential resolver always binds account before
84
111
  * actor today; agent-token / group-actor credentials will lift this.
85
112
  * 4. **Unrestricted is leaf.** `account === 'none' && actor === 'none'`
86
- * ⟹ no `roles`, no `credential_types` (nothing left to gate).
113
+ * ⟹ no `roles`, no `credential_types` (nothing left to gate). The same
114
+ * rule covers `required_scope`, but enforcing it needs the capability
115
+ * vocabulary from `auth/`, so it lives with the other `required_scope`
116
+ * check in `fuz_auth_guard_resolver` — which is also the only site every
117
+ * route spec traverses, since the route pipeline never parses this schema.
87
118
  *
88
119
  * Invariant 2 — the `actor !== 'none' ⟺ input or query declares
89
120
  * acting?: ActingActor` biconditional — needs introspection of the
@@ -96,7 +127,34 @@ export const RouteAuth = z
96
127
  account: AuthAxisState,
97
128
  actor: AuthAxisState,
98
129
  roles: z.array(z.string()).readonly().optional(),
99
- credential_types: z.array(z.string()).readonly().optional()
130
+ credential_types: z.array(z.string()).readonly().optional(),
131
+ /**
132
+ * The capability a **narrowed** api token's scope must admit to reach this
133
+ * route, in the `<section>:<id>` vocabulary a denial reports back as
134
+ * `required_scope`:
135
+ *
136
+ * - `surface:<name>` — a non-RPC surface, refused to every narrowed token
137
+ * whatever its method list says (token scoping's rule that a narrowed
138
+ * token is RPC-only). `surface:audit_stream` and `surface:fact_bare` are
139
+ * the spine's; a consumer names its own without registering it.
140
+ * - `rpc:<method>` — one action method, refused unless the token lists it.
141
+ * What a route bridged from an `ActionSpec` declares: it carries the
142
+ * method's identity but never reaches the dispatcher's per-method gate.
143
+ *
144
+ * Declaring it makes the guard resolver mount the refusal ahead of the role
145
+ * gate, instead of the route hand-rolling the check in its handler (which
146
+ * the auth phase has already role-gated by then, so a narrowed token whose
147
+ * account lacks the role would be told about the role rather than about the
148
+ * scope).
149
+ *
150
+ * Opt-in rather than blanket: the account REST routes cross no boundary, and
151
+ * the RPC endpoint runs its own per-method check inside the dispatcher — and
152
+ * both are route specs too.
153
+ *
154
+ * Typed as a plain string here to keep `http/` from importing `auth/`; the
155
+ * resolver parses it at registration and throws on a malformed one.
156
+ */
157
+ required_scope: z.string().optional()
100
158
  })
101
159
  .superRefine((value, ctx) => {
102
160
  // invariant 1: roles imply actor
@@ -133,7 +191,7 @@ export const RouteAuth = z
133
191
  }
134
192
  }
135
193
  });
136
- // --- Predicates over the four-axis shape ---
194
+ // --- Predicates over the flat-record shape ---
137
195
  //
138
196
  // Pure derived reads of `RouteAuth`. Live here so every consumer that
139
197
  // branches on the shape (the dispatcher's authorization phase, route