@fuzdev/fuz_app 0.106.1 → 0.108.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/actions/CLAUDE.md +42 -23
- package/dist/actions/action_bridge.d.ts +34 -3
- package/dist/actions/action_bridge.d.ts.map +1 -1
- package/dist/actions/action_bridge.js +32 -1
- package/dist/actions/action_rpc.d.ts +5 -4
- package/dist/actions/action_rpc.d.ts.map +1 -1
- package/dist/actions/action_rpc.js +4 -3
- package/dist/actions/action_spec.d.ts +8 -2
- package/dist/actions/action_spec.d.ts.map +1 -1
- package/dist/actions/action_spec.js +5 -2
- package/dist/actions/compile_action_registry.d.ts +26 -14
- package/dist/actions/compile_action_registry.d.ts.map +1 -1
- package/dist/actions/compile_action_registry.js +29 -14
- package/dist/actions/perform_action.d.ts +26 -16
- package/dist/actions/perform_action.d.ts.map +1 -1
- package/dist/actions/perform_action.js +69 -44
- package/dist/actions/register_action_ws.d.ts +4 -4
- package/dist/actions/register_action_ws.js +5 -5
- package/dist/actions/register_ws_endpoint.d.ts +13 -5
- package/dist/actions/register_ws_endpoint.d.ts.map +1 -1
- package/dist/actions/register_ws_endpoint.js +15 -6
- package/dist/auth/CLAUDE.md +16 -9
- package/dist/auth/account_route_schema.d.ts +10 -1
- package/dist/auth/account_route_schema.d.ts.map +1 -1
- package/dist/auth/account_route_schema.js +10 -1
- package/dist/auth/account_routes.d.ts +18 -4
- package/dist/auth/account_routes.d.ts.map +1 -1
- package/dist/auth/account_routes.js +32 -29
- package/dist/auth/api_token_queries.d.ts +4 -0
- package/dist/auth/api_token_queries.d.ts.map +1 -1
- package/dist/auth/api_token_queries.js +5 -1
- package/dist/auth/audit_log_route_schema.d.ts.map +1 -1
- package/dist/auth/audit_log_route_schema.js +11 -1
- package/dist/auth/audit_log_routes.d.ts.map +1 -1
- package/dist/auth/audit_log_routes.js +4 -9
- package/dist/auth/auth_guard_resolver.d.ts +48 -21
- package/dist/auth/auth_guard_resolver.d.ts.map +1 -1
- package/dist/auth/auth_guard_resolver.js +60 -26
- package/dist/auth/bearer_auth.d.ts +10 -7
- package/dist/auth/bearer_auth.d.ts.map +1 -1
- package/dist/auth/bearer_auth.js +13 -22
- package/dist/auth/bootstrap_routes.d.ts +12 -2
- package/dist/auth/bootstrap_routes.d.ts.map +1 -1
- package/dist/auth/bootstrap_routes.js +15 -9
- package/dist/auth/middleware.d.ts +0 -3
- package/dist/auth/middleware.d.ts.map +1 -1
- package/dist/auth/middleware.js +11 -10
- package/dist/auth/migrations.d.ts.map +1 -1
- package/dist/auth/migrations.js +24 -0
- package/dist/auth/request_context.d.ts +54 -24
- package/dist/auth/request_context.d.ts.map +1 -1
- package/dist/auth/request_context.js +117 -39
- package/dist/auth/session_queries.d.ts +13 -2
- package/dist/auth/session_queries.d.ts.map +1 -1
- package/dist/auth/session_queries.js +14 -3
- package/dist/auth/signup_routes.d.ts +10 -0
- package/dist/auth/signup_routes.d.ts.map +1 -1
- package/dist/auth/signup_routes.js +14 -11
- package/dist/auth/token_scope.d.ts +109 -21
- package/dist/auth/token_scope.d.ts.map +1 -1
- package/dist/auth/token_scope.js +106 -21
- package/dist/http/CLAUDE.md +23 -11
- package/dist/http/auth_shape.d.ts +58 -3
- package/dist/http/auth_shape.d.ts.map +1 -1
- package/dist/http/auth_shape.js +63 -5
- package/dist/http/error_schemas.d.ts +25 -2
- package/dist/http/error_schemas.d.ts.map +1 -1
- package/dist/http/error_schemas.js +37 -15
- package/dist/http/route_spec.d.ts +29 -26
- package/dist/http/route_spec.d.ts.map +1 -1
- package/dist/http/route_spec.js +12 -16
- package/dist/http/surface_query.d.ts +2 -2
- package/dist/http/surface_query.js +2 -2
- package/dist/rate_limiter.d.ts +36 -3
- package/dist/rate_limiter.d.ts.map +1 -1
- package/dist/rate_limiter.js +36 -3
- package/dist/server/app_server.d.ts +27 -10
- package/dist/server/app_server.d.ts.map +1 -1
- package/dist/server/app_server.js +30 -20
- package/dist/server/app_server_context.d.ts +10 -2
- package/dist/server/app_server_context.d.ts.map +1 -1
- package/dist/server/serve_fact_route.d.ts.map +1 -1
- package/dist/server/serve_fact_route.js +12 -10
- package/dist/testing/CLAUDE.md +60 -11
- package/dist/testing/app_server.d.ts.map +1 -1
- package/dist/testing/app_server.js +6 -4
- package/dist/testing/cross_backend/conformance_case.js +1 -1
- package/dist/testing/cross_backend/default_spine_surface.d.ts.map +1 -1
- package/dist/testing/cross_backend/default_spine_surface.js +9 -8
- package/dist/testing/cross_backend/login_security.d.ts.map +1 -1
- package/dist/testing/cross_backend/login_security.js +59 -0
- package/dist/testing/cross_backend/session_cap.d.ts +19 -0
- package/dist/testing/cross_backend/session_cap.d.ts.map +1 -0
- package/dist/testing/cross_backend/session_cap.js +93 -0
- package/dist/testing/cross_backend/testing_backdoor.d.ts.map +1 -1
- package/dist/testing/cross_backend/testing_backdoor.js +6 -5
- package/dist/testing/cross_backend/token_scope_surface.d.ts +9 -0
- package/dist/testing/cross_backend/token_scope_surface.d.ts.map +1 -0
- package/dist/testing/cross_backend/token_scope_surface.js +170 -0
- package/dist/testing/integration.d.ts.map +1 -1
- package/dist/testing/integration.js +3 -2
- package/dist/testing/middleware.d.ts +2 -5
- package/dist/testing/middleware.d.ts.map +1 -1
- package/dist/testing/middleware.js +5 -5
- package/dist/testing/rate_limiting.d.ts +10 -16
- package/dist/testing/rate_limiting.d.ts.map +1 -1
- package/dist/testing/rate_limiting.js +11 -95
- package/dist/testing/rpc_attack_surface.d.ts.map +1 -1
- package/dist/testing/rpc_attack_surface.js +4 -2
- package/dist/testing/stubs.d.ts.map +1 -1
- package/dist/testing/stubs.js +3 -1
- package/dist/testing/testing_rate_limiter.d.ts +7 -1
- package/dist/testing/testing_rate_limiter.d.ts.map +1 -1
- package/dist/testing/testing_rate_limiter.js +7 -1
- package/package.json +1 -1
|
@@ -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
|
-
* **
|
|
92
|
-
*
|
|
93
|
-
*
|
|
94
|
-
*
|
|
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
|
-
*
|
|
99
|
-
*
|
|
100
|
-
*
|
|
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
|
-
/**
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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
|
-
*
|
|
113
|
-
*
|
|
114
|
-
*
|
|
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
|
|
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
|
|
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
|
|
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"}
|
package/dist/auth/token_scope.js
CHANGED
|
@@ -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
|
-
* **
|
|
98
|
-
*
|
|
99
|
-
*
|
|
100
|
-
*
|
|
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
|
-
*
|
|
105
|
-
*
|
|
106
|
-
*
|
|
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
|
-
/**
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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
|
-
*
|
|
118
|
-
*
|
|
119
|
-
*
|
|
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
|
|
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
|
|
211
|
+
export const token_scope_denied_body = (capability) => ({
|
|
127
212
|
error: 'token_scope_required',
|
|
128
|
-
required_scope:
|
|
213
|
+
required_scope: capability
|
|
129
214
|
});
|
|
130
215
|
/** Stable label for the token list / UI. */
|
|
131
216
|
export const token_scope_label = (scope) => {
|
package/dist/http/CLAUDE.md
CHANGED
|
@@ -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`.
|
|
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-
|
|
101
|
-
4. **
|
|
102
|
-
5. **
|
|
103
|
-
6. **
|
|
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 →
|
|
109
|
-
(`actions/action_rpc.ts`) so HTTP RPC and REST fail with the same priority
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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
|
|
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
|
|
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
|
|
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"}
|
package/dist/http/auth_shape.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Canonical
|
|
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
|
|
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
|
|
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
|