@fuzdev/fuz_app 0.64.0 → 0.66.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 +510 -946
- package/dist/actions/action_codegen.d.ts +1 -1
- package/dist/actions/action_codegen.js +1 -1
- package/dist/actions/action_event_data.d.ts +1 -1
- package/dist/actions/broadcast_api.d.ts +1 -1
- package/dist/actions/broadcast_api.js +1 -1
- package/dist/actions/cancel.d.ts +2 -2
- package/dist/actions/cancel.js +3 -3
- package/dist/actions/connection_closer.d.ts +1 -4
- package/dist/actions/connection_closer.d.ts.map +1 -1
- package/dist/actions/connection_closer.js +1 -4
- package/dist/actions/register_action_ws.d.ts +2 -2
- package/dist/actions/register_ws_endpoint.d.ts +1 -1
- package/dist/actions/transports_ws_auth_guard.d.ts +1 -2
- package/dist/actions/transports_ws_auth_guard.d.ts.map +1 -1
- package/dist/actions/transports_ws_auth_guard.js +1 -2
- package/dist/auth/CLAUDE.md +570 -1871
- package/dist/auth/account_schema.d.ts +1 -1
- package/dist/auth/account_schema.d.ts.map +1 -1
- package/dist/auth/api_token_queries.js +1 -1
- package/dist/auth/audit_log_ddl.d.ts +1 -1
- package/dist/auth/audit_log_ddl.d.ts.map +1 -1
- package/dist/auth/audit_log_ddl.js +1 -1
- package/dist/auth/audit_log_schema.js +2 -2
- package/dist/auth/bootstrap_account.d.ts.map +1 -1
- package/dist/auth/bootstrap_account.js +1 -5
- package/dist/auth/bootstrap_routes.d.ts +7 -1
- package/dist/auth/bootstrap_routes.d.ts.map +1 -1
- package/dist/auth/bootstrap_routes.js +15 -11
- package/dist/auth/daemon_token_middleware.d.ts +15 -5
- package/dist/auth/daemon_token_middleware.d.ts.map +1 -1
- package/dist/auth/daemon_token_middleware.js +24 -15
- package/dist/auth/invite_queries.d.ts +17 -7
- package/dist/auth/invite_queries.d.ts.map +1 -1
- package/dist/auth/invite_queries.js +19 -8
- package/dist/auth/keyring.d.ts +6 -6
- package/dist/auth/keyring.js +8 -8
- package/dist/auth/role_grant_offer_actions.d.ts.map +1 -1
- package/dist/auth/role_grant_offer_actions.js +4 -2
- package/dist/auth/signup_routes.d.ts +47 -1
- package/dist/auth/signup_routes.d.ts.map +1 -1
- package/dist/auth/signup_routes.js +103 -52
- package/dist/db/create_db.d.ts.map +1 -1
- package/dist/db/create_db.js +13 -0
- package/dist/dev/setup.d.ts +2 -2
- package/dist/dev/setup.js +3 -3
- package/dist/env/resolve.d.ts +44 -7
- package/dist/env/resolve.d.ts.map +1 -1
- package/dist/env/resolve.js +94 -27
- package/dist/http/CLAUDE.md +243 -522
- package/dist/http/error_schemas.d.ts +0 -4
- package/dist/http/error_schemas.d.ts.map +1 -1
- package/dist/http/error_schemas.js +0 -4
- package/dist/http/ip_canonical.d.ts +5 -4
- package/dist/http/ip_canonical.d.ts.map +1 -1
- package/dist/http/ip_canonical.js +8 -4
- package/dist/http/jsonrpc.d.ts +23 -7
- package/dist/http/jsonrpc.d.ts.map +1 -1
- package/dist/http/jsonrpc.js +19 -3
- package/dist/http/origin.d.ts +1 -1
- package/dist/http/origin.js +1 -1
- package/dist/http/surface.d.ts +9 -2
- package/dist/http/surface.d.ts.map +1 -1
- package/dist/runtime/mock.d.ts +1 -1
- package/dist/runtime/mock.js +2 -2
- package/dist/server/app_server.d.ts +41 -10
- package/dist/server/app_server.d.ts.map +1 -1
- package/dist/server/app_server.js +10 -4
- package/dist/server/env.d.ts +7 -7
- package/dist/server/env.d.ts.map +1 -1
- package/dist/server/env.js +14 -14
- package/dist/server/static.d.ts +4 -4
- package/dist/server/static.js +7 -7
- package/dist/testing/CLAUDE.md +740 -418
- package/dist/testing/admin_integration.d.ts +18 -23
- package/dist/testing/admin_integration.d.ts.map +1 -1
- package/dist/testing/admin_integration.js +230 -216
- package/dist/testing/app_server.d.ts +141 -39
- package/dist/testing/app_server.d.ts.map +1 -1
- package/dist/testing/app_server.js +157 -44
- package/dist/testing/audit_completeness.d.ts +25 -22
- package/dist/testing/audit_completeness.d.ts.map +1 -1
- package/dist/testing/audit_completeness.js +198 -159
- package/dist/testing/bootstrap_success.d.ts +28 -0
- package/dist/testing/bootstrap_success.d.ts.map +1 -0
- package/dist/testing/bootstrap_success.js +144 -0
- package/dist/testing/cross_backend/backend_config.d.ts +113 -0
- package/dist/testing/cross_backend/backend_config.d.ts.map +1 -0
- package/dist/testing/cross_backend/backend_config.js +1 -0
- package/dist/testing/cross_backend/bench/bench_report.d.ts +46 -0
- package/dist/testing/cross_backend/bench/bench_report.d.ts.map +1 -0
- package/dist/testing/cross_backend/bench/bench_report.js +83 -0
- package/dist/testing/cross_backend/bench/run_cross_impl_bench.d.ts +44 -0
- package/dist/testing/cross_backend/bench/run_cross_impl_bench.d.ts.map +1 -0
- package/dist/testing/cross_backend/bench/run_cross_impl_bench.js +38 -0
- package/dist/testing/cross_backend/bench/scenario.d.ts +57 -0
- package/dist/testing/cross_backend/bench/scenario.d.ts.map +1 -0
- package/dist/testing/cross_backend/bench/scenario.js +28 -0
- package/dist/testing/cross_backend/bootstrap_backend.d.ts +41 -0
- package/dist/testing/cross_backend/bootstrap_backend.d.ts.map +1 -0
- package/dist/testing/cross_backend/bootstrap_backend.js +34 -0
- package/dist/testing/cross_backend/build_test_backend_paths.d.ts +24 -0
- package/dist/testing/cross_backend/build_test_backend_paths.d.ts.map +1 -0
- package/dist/testing/cross_backend/build_test_backend_paths.js +33 -0
- package/dist/testing/cross_backend/capabilities.d.ts +65 -0
- package/dist/testing/cross_backend/capabilities.d.ts.map +1 -0
- package/dist/testing/cross_backend/capabilities.js +47 -0
- package/dist/testing/cross_backend/default_backend_configs.d.ts +122 -0
- package/dist/testing/cross_backend/default_backend_configs.d.ts.map +1 -0
- package/dist/testing/cross_backend/default_backend_configs.js +111 -0
- package/dist/testing/cross_backend/default_secrets.d.ts +40 -0
- package/dist/testing/cross_backend/default_secrets.d.ts.map +1 -0
- package/dist/testing/cross_backend/default_secrets.js +39 -0
- package/dist/testing/cross_backend/default_spine_surface.d.ts +64 -0
- package/dist/testing/cross_backend/default_spine_surface.d.ts.map +1 -0
- package/dist/testing/cross_backend/default_spine_surface.js +121 -0
- package/dist/testing/cross_backend/setup.d.ts +451 -0
- package/dist/testing/cross_backend/setup.d.ts.map +1 -0
- package/dist/testing/cross_backend/setup.js +581 -0
- package/dist/testing/cross_backend/spawn_backend.d.ts +58 -0
- package/dist/testing/cross_backend/spawn_backend.d.ts.map +1 -0
- package/dist/testing/cross_backend/spawn_backend.js +229 -0
- package/dist/testing/cross_backend/spine_stub_backend_config.d.ts +66 -0
- package/dist/testing/cross_backend/spine_stub_backend_config.d.ts.map +1 -0
- package/dist/testing/cross_backend/spine_stub_backend_config.js +49 -0
- package/dist/testing/cross_backend/sse_round_trip.d.ts +37 -0
- package/dist/testing/cross_backend/sse_round_trip.d.ts.map +1 -0
- package/dist/testing/cross_backend/sse_round_trip.js +137 -0
- package/dist/testing/cross_backend/standard.d.ts +96 -0
- package/dist/testing/cross_backend/standard.d.ts.map +1 -0
- package/dist/testing/cross_backend/standard.js +49 -0
- package/dist/testing/cross_backend/testing_reset_actions.d.ts +171 -0
- package/dist/testing/cross_backend/testing_reset_actions.d.ts.map +1 -0
- package/dist/testing/cross_backend/testing_reset_actions.js +213 -0
- package/dist/testing/cross_backend/testing_server_bun.d.ts +5 -0
- package/dist/testing/cross_backend/testing_server_bun.d.ts.map +1 -0
- package/dist/testing/cross_backend/testing_server_bun.js +59 -0
- package/dist/testing/cross_backend/testing_server_core.d.ts +140 -0
- package/dist/testing/cross_backend/testing_server_core.d.ts.map +1 -0
- package/dist/testing/cross_backend/testing_server_core.js +68 -0
- package/dist/testing/cross_backend/testing_server_deno.d.ts +5 -0
- package/dist/testing/cross_backend/testing_server_deno.d.ts.map +1 -0
- package/dist/testing/cross_backend/testing_server_deno.js +37 -0
- package/dist/testing/cross_backend/testing_server_node.d.ts +5 -0
- package/dist/testing/cross_backend/testing_server_node.d.ts.map +1 -0
- package/dist/testing/cross_backend/testing_server_node.js +50 -0
- package/dist/testing/cross_backend/ts_spine_backend_config.d.ts +72 -0
- package/dist/testing/cross_backend/ts_spine_backend_config.d.ts.map +1 -0
- package/dist/testing/cross_backend/ts_spine_backend_config.js +112 -0
- package/dist/testing/cross_backend/ws_round_trip.d.ts +35 -0
- package/dist/testing/cross_backend/ws_round_trip.d.ts.map +1 -0
- package/dist/testing/cross_backend/ws_round_trip.js +113 -0
- package/dist/testing/data_exposure.d.ts +11 -14
- package/dist/testing/data_exposure.d.ts.map +1 -1
- package/dist/testing/data_exposure.js +123 -146
- package/dist/testing/db_entities.d.ts +22 -1
- package/dist/testing/db_entities.d.ts.map +1 -1
- package/dist/testing/db_entities.js +24 -1
- package/dist/testing/integration.d.ts +56 -21
- package/dist/testing/integration.d.ts.map +1 -1
- package/dist/testing/integration.js +294 -319
- package/dist/testing/integration_helpers.d.ts +16 -6
- package/dist/testing/integration_helpers.d.ts.map +1 -1
- package/dist/testing/integration_helpers.js +7 -7
- package/dist/testing/mock_fs.d.ts.map +1 -1
- package/dist/testing/mock_fs.js +0 -2
- package/dist/testing/rate_limiting.d.ts.map +1 -1
- package/dist/testing/rate_limiting.js +9 -0
- package/dist/testing/role_grant_helpers.d.ts +31 -0
- package/dist/testing/role_grant_helpers.d.ts.map +1 -0
- package/dist/testing/role_grant_helpers.js +46 -0
- package/dist/testing/round_trip.d.ts +20 -16
- package/dist/testing/round_trip.d.ts.map +1 -1
- package/dist/testing/round_trip.js +61 -86
- package/dist/testing/rpc_helpers.d.ts +10 -4
- package/dist/testing/rpc_helpers.d.ts.map +1 -1
- package/dist/testing/rpc_helpers.js +1 -1
- package/dist/testing/rpc_round_trip.d.ts +24 -21
- package/dist/testing/rpc_round_trip.d.ts.map +1 -1
- package/dist/testing/rpc_round_trip.js +87 -104
- package/dist/testing/schema_introspect.d.ts +106 -0
- package/dist/testing/schema_introspect.d.ts.map +1 -0
- package/dist/testing/schema_introspect.js +123 -0
- package/dist/testing/schema_parity.d.ts +144 -0
- package/dist/testing/schema_parity.d.ts.map +1 -0
- package/dist/testing/schema_parity.js +233 -0
- package/dist/testing/sse_round_trip.d.ts.map +1 -1
- package/dist/testing/sse_round_trip.js +1 -68
- package/dist/testing/standard.d.ts +56 -25
- package/dist/testing/standard.d.ts.map +1 -1
- package/dist/testing/standard.js +62 -5
- package/dist/testing/stubs.d.ts +21 -6
- package/dist/testing/stubs.d.ts.map +1 -1
- package/dist/testing/stubs.js +33 -23
- package/dist/testing/testing_rate_limiter.d.ts +59 -0
- package/dist/testing/testing_rate_limiter.d.ts.map +1 -0
- package/dist/testing/testing_rate_limiter.js +74 -0
- package/dist/testing/transports/bootstrap.d.ts +52 -0
- package/dist/testing/transports/bootstrap.d.ts.map +1 -0
- package/dist/testing/transports/bootstrap.js +70 -0
- package/dist/testing/transports/fetch_transport.d.ts +81 -0
- package/dist/testing/transports/fetch_transport.d.ts.map +1 -0
- package/dist/testing/transports/fetch_transport.js +74 -0
- package/dist/testing/transports/sse_frame_reader.d.ts +41 -0
- package/dist/testing/transports/sse_frame_reader.d.ts.map +1 -0
- package/dist/testing/transports/sse_frame_reader.js +84 -0
- package/dist/testing/transports/sse_transport.d.ts +54 -0
- package/dist/testing/transports/sse_transport.d.ts.map +1 -0
- package/dist/testing/transports/sse_transport.js +51 -0
- package/dist/testing/transports/ws_client.d.ts +108 -0
- package/dist/testing/transports/ws_client.d.ts.map +1 -0
- package/dist/testing/transports/ws_client.js +56 -0
- package/dist/testing/transports/ws_transport.d.ts +43 -0
- package/dist/testing/transports/ws_transport.d.ts.map +1 -0
- package/dist/testing/transports/ws_transport.js +169 -0
- package/dist/testing/ws_round_trip.d.ts +21 -103
- package/dist/testing/ws_round_trip.d.ts.map +1 -1
- package/dist/testing/ws_round_trip.js +42 -40
- package/dist/ui/CLAUDE.md +5 -3
- package/dist/ui/MenuLink.svelte +16 -16
- package/dist/ui/MenuLink.svelte.d.ts +13 -4
- package/dist/ui/MenuLink.svelte.d.ts.map +1 -1
- package/package.json +10 -4
package/dist/http/CLAUDE.md
CHANGED
|
@@ -9,41 +9,40 @@ guards live in `auth/` and consume these primitives. Routes and actions in
|
|
|
9
9
|
other domains should do the same — extend, don't special-case.
|
|
10
10
|
|
|
11
11
|
For the design rationale behind declarative routes, DEV-only output
|
|
12
|
-
validation, the three-layer error-schema merge, and fire-and-forget
|
|
13
|
-
see ../../docs/architecture.md.
|
|
12
|
+
validation, the three-layer error-schema merge, and fire-and-forget
|
|
13
|
+
effects, see ../../docs/architecture.md.
|
|
14
14
|
|
|
15
15
|
## Module Map
|
|
16
16
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
| `db_routes.ts` | Generic keeper-only table browser route specs (public schema) |
|
|
34
|
-
| `pending_effects.ts` | `emit_after_commit` + `flush_pending_effects` + `flush_post_commit_effects` + `EmitAfterCommitContext` |
|
|
17
|
+
- `http/route_spec.ts` — `RouteSpec` + `apply_route_specs`, validation pipeline, transactions.
|
|
18
|
+
- `http/auth_shape.ts` — canonical `RouteAuth` Zod schema + cross-axis invariants + predicates.
|
|
19
|
+
- `http/error_schemas.ts` — `ERROR_*` constants, standard error shapes, `derive_error_schemas`.
|
|
20
|
+
- `http/schema_helpers.ts` — shared Zod introspection (null/strict/surface/merge/middleware-applies).
|
|
21
|
+
- `http/middleware_spec.ts` — `MiddlewareSpec` interface.
|
|
22
|
+
- `http/surface.ts` — `AppSurface`, `AppSurfaceSpec`, `generate_app_surface`, diagnostics.
|
|
23
|
+
- `http/surface_query.ts` — pure filters/groupings over `AppSurface`.
|
|
24
|
+
- `http/proxy.ts` — trusted-proxy middleware, CIDR parsing, rightmost-first XFF resolution.
|
|
25
|
+
- `http/ip_canonical.ts` — RFC 5952 IPv6 canonicalization + IPv4-mapped collapse; `IP_LITERAL_CHARS` regex.
|
|
26
|
+
- `http/origin.ts` — origin allowlist middleware with wildcard patterns (Origin-only).
|
|
27
|
+
- `http/jsonrpc.ts` — JSON-RPC 2.0 envelope schemas (MCP superset), `JsonrpcErrorCode`, `_meta`.
|
|
28
|
+
- `http/jsonrpc_errors.ts` — `ThrownJsonrpcError`, `jsonrpc_errors` throwers, HTTP-status mappings.
|
|
29
|
+
- `http/jsonrpc_helpers.ts` — message builders, type guards, input/result normalizers.
|
|
30
|
+
- `http/common_routes.ts` — health check + authenticated server-status + surface route specs.
|
|
31
|
+
- `http/db_routes.ts` — generic keeper-only table browser route specs (public schema).
|
|
32
|
+
- `http/pending_effects.ts` — `emit_after_commit` + `flush_pending_effects` + `flush_post_commit_effects` + `EmitAfterCommitContext`.
|
|
35
33
|
|
|
36
34
|
## Route Spec System
|
|
37
35
|
|
|
38
|
-
`RouteSpec` (in `route_spec.ts`) is the unit of the attack surface —
|
|
39
|
-
are **data**, registered with Hono by `apply_route_specs
|
|
40
|
-
by `generate_app_surface`. Same-shaped data, different
|
|
36
|
+
`RouteSpec` (in `http/route_spec.ts`) is the unit of the attack surface —
|
|
37
|
+
routes are **data**, registered with Hono by `apply_route_specs` and
|
|
38
|
+
introspected by `generate_app_surface`. Same-shaped data, different
|
|
39
|
+
consumers.
|
|
41
40
|
|
|
42
41
|
### `RouteSpec` fields
|
|
43
42
|
|
|
44
43
|
- `method` — `'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH'`
|
|
45
44
|
- `path` — Hono path (supports `:param` segments)
|
|
46
|
-
- `auth: RouteAuth` — flat record `{account, actor, roles?, credential_types?}` from `auth_shape.ts`. Each axis is `'none' | 'optional' | 'required'`. Same shape governs `ActionSpec.auth` (see `actions/CLAUDE.md`).
|
|
45
|
+
- `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`).
|
|
47
46
|
- `handler: RouteHandler` — `(c: Context, route: RouteContext) => Response | Promise<Response>`
|
|
48
47
|
- `description` — free-text, surfaced in `AppSurface`
|
|
49
48
|
- `params?: z.ZodObject` — strict-object schema for URL path params
|
|
@@ -70,25 +69,15 @@ interface RouteContext {
|
|
|
70
69
|
}
|
|
71
70
|
```
|
|
72
71
|
|
|
73
|
-
- **`route.db`** —
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
and may call `route.db.transaction(...)` for their own scope.
|
|
77
|
-
- **`route.pending_effects`** — direct push for eager fire-and-forget pool
|
|
78
|
-
writes (audit, session touch, api-token usage tracking). Push the in-flight
|
|
79
|
-
`Promise<void>` to register it for test-mode flushing.
|
|
80
|
-
- **`route.post_commit_effects`** — do not push directly; reach for
|
|
81
|
-
`emit_after_commit` from `pending_effects.ts`. The helper pushes a
|
|
82
|
-
thunk that the flush middleware invokes after the handler returns,
|
|
83
|
-
closing the microtask-ordering window that an eager
|
|
84
|
-
`Promise.resolve().then(fn)` leaves open inside the wrapping
|
|
85
|
-
`db.transaction`.
|
|
72
|
+
- **`route.db`** — handler's main DB work. Wrapped in a transaction when `transaction: true` (default for non-GET); routes that opt out (`transaction: false`, e.g. signup / bootstrap) get the pool here directly and may call `route.db.transaction(...)` for their own scope.
|
|
73
|
+
- **`route.pending_effects`** — direct push for eager fire-and-forget pool writes (audit, session touch, api-token usage tracking).
|
|
74
|
+
- **`route.post_commit_effects`** — do not push directly; reach for `emit_after_commit` from `http/pending_effects.ts`. The helper pushes a thunk that the flush middleware invokes after the handler returns, closing the microtask-ordering window that an eager `Promise.resolve().then(fn)` leaves open inside the wrapping `db.transaction`.
|
|
86
75
|
|
|
87
76
|
Pool-level fire-and-forget writes (audit logs, etc.) run through the bound
|
|
88
|
-
`AppDeps.audit` capability — see `auth/CLAUDE.md` §
|
|
89
|
-
need rollback-resilient writes call `deps.audit.emit(route, input)`,
|
|
90
|
-
captures the pool inside the bound emitter so the row lands even
|
|
91
|
-
the handler's transaction rolls back.
|
|
77
|
+
`AppDeps.audit` capability — see `auth/CLAUDE.md` §AppDeps split. Handlers
|
|
78
|
+
that need rollback-resilient writes call `deps.audit.emit(route, input)`,
|
|
79
|
+
which captures the pool inside the bound emitter so the row lands even
|
|
80
|
+
when the handler's transaction rolls back.
|
|
92
81
|
|
|
93
82
|
### Declarative transactions
|
|
94
83
|
|
|
@@ -105,74 +94,30 @@ wrapper). See `auth/signup_routes.ts`.
|
|
|
105
94
|
|
|
106
95
|
`apply_route_specs` assembles the following middleware chain per spec:
|
|
107
96
|
|
|
108
|
-
1. **Params validation** — `spec.params` → `validated_params` context
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
or `auth.actor === 'required'`. Fires before any body parsing so
|
|
116
|
-
unauthenticated callers never see route-shape information from
|
|
117
|
-
input parse failures. The `AuthGuardResolver` (e.g.
|
|
118
|
-
`fuz_auth_guard_resolver` from `auth/auth_guard_resolver.ts`) returns
|
|
119
|
-
this set as `pre_validation: Array<MiddlewareHandler>`.
|
|
120
|
-
4. **Input validation** — JSON body parsed + validated; mismatch returns
|
|
121
|
-
400 `ERROR_INVALID_JSON_BODY` (not JSON) or `ERROR_INVALID_REQUEST_BODY`
|
|
122
|
-
(schema failure with `issues`). Skipped on GET and `z.null()` inputs.
|
|
123
|
-
The validated input lands on `c.var.validated_input` so the
|
|
124
|
-
authorization phase reads `acting` as a typed Zod field.
|
|
125
|
-
5. **Authorization phase** — when `spec.auth.actor !== 'none'`,
|
|
126
|
-
resolves the acting actor against `c.var.account_id` (set by the
|
|
127
|
-
auth middleware) plus `validated_input.acting` (or
|
|
128
|
-
`validated_query.acting` for GET routes), builds `RequestContext`
|
|
129
|
-
via `build_request_context`, and sets `REQUEST_CONTEXT_KEY`. When
|
|
130
|
-
`auth.account !== 'none' && auth.actor === 'none'`, an account-only
|
|
131
|
-
context is built. Resolution failures return 400
|
|
132
|
-
`ERROR_ACTOR_REQUIRED` (with `available[]`) or
|
|
133
|
-
`ERROR_ACTOR_NOT_ON_ACCOUNT` (or 500 `ERROR_NO_ACTORS_ON_ACCOUNT`
|
|
134
|
-
on signup-invariant violation, 500 `ERROR_ACCOUNT_VANISHED` on
|
|
135
|
-
torn account/actor reads after a successful resolve). Public
|
|
136
|
-
routes (`account: 'none' && actor: 'none'`) skip this phase
|
|
137
|
-
entirely.
|
|
138
|
-
6. **Post-authorization auth guards** — `require_credential_types(types)`
|
|
139
|
-
(403 `ERROR_CREDENTIAL_TYPE_REQUIRED` with `required_credential_types: ReadonlyArray<string>`)
|
|
140
|
-
fires first when `auth.credential_types?.length`; `require_role(roles)` (403
|
|
141
|
-
`ERROR_INSUFFICIENT_PERMISSIONS` with `required_roles: ReadonlyArray<string>`)
|
|
142
|
-
fires next when `auth.roles?.length`. Both read
|
|
143
|
-
`REQUEST_CONTEXT_KEY` populated by step 5. Multi-role specs admit
|
|
144
|
-
any-of via `has_any_scoped_role(ctx, roles, null)`.
|
|
145
|
-
7. **Handler** — wrapped in transaction when `use_transaction` (see
|
|
146
|
-
above), receives `RouteContext`
|
|
97
|
+
1. **Params validation** — `spec.params` → `validated_params` context var; mismatch returns 400 `ERROR_INVALID_ROUTE_PARAMS` with Zod `issues`
|
|
98
|
+
2. **Query validation** — `spec.query` → `validated_query`; mismatch returns 400 `ERROR_INVALID_QUERY_PARAMS`
|
|
99
|
+
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>`.
|
|
100
|
+
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.
|
|
101
|
+
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.
|
|
102
|
+
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)`.
|
|
103
|
+
7. **Handler** — wrapped in transaction when `use_transaction` (see above), receives `RouteContext`
|
|
147
104
|
8. **DEV-only output + error validation** — wraps the handler (see below)
|
|
148
|
-
9. **Error catch** — catches `ThrownJsonrpcError` → maps to HTTP status +
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
`c.json(failure.body, status)` from the dispatcher's authorization
|
|
157
|
-
phase) — REST callers see one envelope across every emit site, while
|
|
158
|
-
the JSON-RPC dispatcher keeps its own `{jsonrpc, id, error: {code,
|
|
159
|
-
message, data}}` envelope on the RPC mount
|
|
160
|
-
|
|
161
|
-
Ordering: **401 → 400 → 403 → handler**. Mirrors the RPC dispatcher
|
|
162
|
-
(`actions/action_rpc.ts`) so HTTP RPC and REST fail with the same
|
|
163
|
-
priority. The alternative (403-before-400) was rejected because
|
|
164
|
-
defense-in-depth via attack-surface obscurity is illusory when the
|
|
165
|
-
surface is published in `library.json` codegen anyway. The trade-off
|
|
166
|
-
is that an authenticated-but-unauthorized caller can distinguish 400
|
|
167
|
-
from 403.
|
|
105
|
+
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.
|
|
106
|
+
|
|
107
|
+
**Ordering: 401 → 400 → 403 → handler.** Mirrors the RPC dispatcher
|
|
108
|
+
(`actions/action_rpc.ts`) so HTTP RPC and REST fail with the same priority.
|
|
109
|
+
The alternative (403-before-400) was rejected because defense-in-depth via
|
|
110
|
+
attack-surface obscurity is illusory when the surface is published in
|
|
111
|
+
`library.json` codegen anyway. The trade-off is that an
|
|
112
|
+
authenticated-but-unauthorized caller can distinguish 400 from 403.
|
|
168
113
|
|
|
169
114
|
Duplicate `method path` pairs throw at registration.
|
|
170
115
|
|
|
171
116
|
Validated values are accessed via `get_route_input(c, schema)`,
|
|
172
117
|
`get_route_params(c, schema)`, `get_route_query(c, schema)` — pass the
|
|
173
|
-
matching Zod schema and the return type infers as `z.infer<typeof
|
|
174
|
-
|
|
175
|
-
|
|
118
|
+
matching Zod schema and the return type infers as `z.infer<typeof schema>`.
|
|
119
|
+
Each helper has a `<T>(c)` overload (no schema arg) for callers without the
|
|
120
|
+
schema in scope.
|
|
176
121
|
|
|
177
122
|
### DEV-only output + error validation
|
|
178
123
|
|
|
@@ -182,77 +127,56 @@ are the contract with external callers.
|
|
|
182
127
|
**Output and error schemas are validated DEV-only** via `DEV` from
|
|
183
128
|
`esm-env`. `wrap_output_validation`:
|
|
184
129
|
|
|
185
|
-
- Skips streaming responses (non-`application/json` Content-Type) so SSE
|
|
186
|
-
doesn't hang on `.json()`
|
|
130
|
+
- Skips streaming responses (non-`application/json` Content-Type) so SSE doesn't hang on `.json()`
|
|
187
131
|
- On 2xx JSON: validates body against `spec.output`
|
|
188
|
-
- On non-2xx JSON: validates body against the merged error schema for
|
|
189
|
-
|
|
190
|
-
- **Logs on mismatch, returns the response unchanged** — never throws,
|
|
191
|
-
never mutates the body
|
|
132
|
+
- On non-2xx JSON: validates body against the merged error schema for that HTTP status
|
|
133
|
+
- **Logs on mismatch, returns the response unchanged** — never throws, never mutates the body
|
|
192
134
|
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
Output Validation.
|
|
135
|
+
Production short-circuits to the unwrapped handler — no parse work on the
|
|
136
|
+
hot path. Uniform across all three action-handler surfaces (REST, RPC,
|
|
137
|
+
WS); see ../../docs/architecture.md §DEV-only Output Validation.
|
|
197
138
|
|
|
198
139
|
### Helpers
|
|
199
140
|
|
|
200
|
-
- `apply_middleware_specs(app, specs)` — registers middleware specs on
|
|
201
|
-
|
|
202
|
-
- `prefix_route_specs(prefix, specs)` — prepends a path prefix to every
|
|
203
|
-
spec; `/` collapses to the bare prefix
|
|
141
|
+
- `apply_middleware_specs(app, specs)` — registers middleware specs on Hono by `{name, path, handler}`
|
|
142
|
+
- `prefix_route_specs(prefix, specs)` — prepends a path prefix to every spec; `/` collapses to the bare prefix
|
|
204
143
|
|
|
205
144
|
## Error Schemas
|
|
206
145
|
|
|
207
|
-
`error_schemas.ts` is the **declarative** error surface:
|
|
146
|
+
`http/error_schemas.ts` is the **declarative** error surface:
|
|
208
147
|
|
|
209
|
-
- `ERROR_*` `snake_case` string constants — single source of truth; use
|
|
210
|
-
|
|
211
|
-
- `ApiError`, `ValidationError`, `PermissionError`,
|
|
212
|
-
`CredentialTypeRequiredError`, `RateLimitError`, `PayloadTooLargeError`,
|
|
213
|
-
`ForeignKeyError` — standard shapes
|
|
148
|
+
- `ERROR_*` `snake_case` string constants — single source of truth; use `.literal(ERROR_*)` in Zod schemas and inline checks in handlers
|
|
149
|
+
- `ApiError`, `ValidationError`, `PermissionError`, `CredentialTypeRequiredError`, `RateLimitError`, `PayloadTooLargeError`, `ForeignKeyError` — standard shapes
|
|
214
150
|
- `RouteErrorSchemas = Partial<Record<number, z.ZodType>>`
|
|
215
151
|
- `RateLimitKey = 'ip' | 'account' | 'both'`
|
|
216
152
|
|
|
217
153
|
All standard shapes use `z.looseObject` — intentional because multiple
|
|
218
154
|
producers (middleware + handler) can emit different extra fields at the
|
|
219
155
|
same status code. The `error` string literal is the contract; extra keys
|
|
220
|
-
(`required_roles`, `required_credential_types`, `retry_after`, `detail`)
|
|
221
|
-
are diagnostic.
|
|
156
|
+
(`required_roles`, `required_credential_types`, `retry_after`, `detail`) are diagnostic.
|
|
222
157
|
|
|
223
|
-
Pair every schema with the `z.infer` type export
|
|
158
|
+
Pair every schema with the `z.infer` type export.
|
|
224
159
|
|
|
225
160
|
### Three-layer error-schema merge
|
|
226
161
|
|
|
227
|
-
`merge_error_schemas(spec, middleware_errors?)` (in `schema_helpers.ts`)
|
|
162
|
+
`merge_error_schemas(spec, middleware_errors?)` (in `http/schema_helpers.ts`)
|
|
228
163
|
merges three layers, later overrides earlier at the same status code:
|
|
229
164
|
|
|
230
165
|
1. **Derived** — from `derive_error_schemas({auth, has_input?, has_params?, has_query?, rate_limit?})`:
|
|
231
166
|
- `has_input || has_params || has_query` → 400 `ValidationError`
|
|
232
167
|
- `auth.account === 'required'` or `auth.actor === 'required'` → 401 `ApiError`
|
|
233
|
-
- `auth.roles?.length` → 403 `PermissionError` (carries `required_roles
|
|
234
|
-
- `auth.credential_types?.length` → 403 `CredentialTypeRequiredError`
|
|
235
|
-
(carries `required_credential_types: ReadonlyArray<string>` echoing
|
|
236
|
-
the spec's allowlist — symmetric with `PermissionError`'s
|
|
237
|
-
`required_roles`; literal is `ERROR_CREDENTIAL_TYPE_REQUIRED`; both
|
|
238
|
-
gates set yields a `z.union([PermissionError, CredentialTypeRequiredError])`)
|
|
168
|
+
- `auth.roles?.length` → 403 `PermissionError` (carries `required_roles`)
|
|
169
|
+
- `auth.credential_types?.length` → 403 `CredentialTypeRequiredError` (carries `required_credential_types`; both gates set yields `z.union([PermissionError, CredentialTypeRequiredError])`)
|
|
239
170
|
- `rate_limit` → 429 `RateLimitError`
|
|
240
|
-
- `auth.actor !== 'none'` → widens 400 to a union with `ActorRequiredError` /
|
|
241
|
-
|
|
242
|
-
/ `AccountVanishedError`. Mirrors what the dispatcher's authorization
|
|
243
|
-
phase actually emits on routes whose input declares `acting?: ActingActor`
|
|
244
|
-
(per registry-time invariant 2) — so DEV-mode error-schema validation in
|
|
245
|
-
`wrap_output_validation` doesn't reject the auth phase's body.
|
|
246
|
-
2. **Middleware** — from `MiddlewareSpec.errors` that apply to the route's
|
|
247
|
-
path (via `middleware_applies`)
|
|
171
|
+
- `auth.actor !== 'none'` → widens 400 to a union with `ActorRequiredError` / `ActorNotOnAccountError` and adds 500 union of `NoActorsOnAccountError` / `AccountVanishedError`. Mirrors what the dispatcher's authorization phase actually emits on routes whose input declares `acting?: ActingActor` (per registry-time invariant 2) — so DEV-mode error-schema validation doesn't reject the auth phase's body.
|
|
172
|
+
2. **Middleware** — from `MiddlewareSpec.errors` that apply to the route's path (via `middleware_applies`)
|
|
248
173
|
3. **Explicit** — `RouteSpec.errors` — always wins
|
|
249
174
|
|
|
250
175
|
Routes typically only need `errors` for handler-specific codes (404, 409, 422).
|
|
251
176
|
|
|
252
|
-
Actor-failure folding reads `spec.auth.actor !== 'none'` directly —
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
truth.
|
|
177
|
+
Actor-failure folding reads `spec.auth.actor !== 'none'` directly — per
|
|
178
|
+
registry-time invariant 2 (`actor !== 'none' ⟺ input declares acting?: ActingActor`),
|
|
179
|
+
the auth-shape axis is the single source of truth.
|
|
256
180
|
|
|
257
181
|
**Framework-emitted vs consumer-authored.** The error-schema derivation
|
|
258
182
|
above is sound because the framework authors the errors at fixed
|
|
@@ -262,104 +186,70 @@ middleware sites — 401 from `require_auth`, 400 from
|
|
|
262
186
|
documents the framework's own emissions; consumers tighten via
|
|
263
187
|
`RouteSpec.errors` when their handler narrows the surface.
|
|
264
188
|
|
|
265
|
-
The same auto-derivation pattern is **not** appropriate for
|
|
266
|
-
authored inputs (or handler outputs). A consumer's spec declares
|
|
267
|
-
exact `acting?: ActingActor` slot, and the framework reads it back via
|
|
189
|
+
The same auto-derivation pattern is **not** appropriate for
|
|
190
|
+
consumer-authored inputs (or handler outputs). A consumer's spec declares
|
|
191
|
+
the exact `acting?: ActingActor` slot, and the framework reads it back via
|
|
268
192
|
reference-equality to drive the authorization phase — auto-extending
|
|
269
|
-
schemas at registration time would obscure the source of truth ("did
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
193
|
+
schemas at registration time would obscure the source of truth ("did the
|
|
194
|
+
spec declare this, or did the framework graft it on?") and quietly shadow
|
|
195
|
+
consumer fields named `acting` that aren't the canonical `ActingActor`.
|
|
196
|
+
The asymmetry is the design rule: derive what the framework emits, never
|
|
197
|
+
what the consumer authors. The keeper `db_routes` bug (an early consumer
|
|
198
|
+
registration failure caught by invariant 2's throw) was the empirical
|
|
199
|
+
confirmation.
|
|
276
200
|
|
|
277
201
|
### `ERROR_*` constants by category
|
|
278
202
|
|
|
279
|
-
- **Validation**: `ERROR_INVALID_REQUEST_BODY`, `ERROR_INVALID_JSON_BODY`,
|
|
280
|
-
|
|
281
|
-
- **
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
- **
|
|
285
|
-
|
|
286
|
-
- **
|
|
287
|
-
`ERROR_KEEPER_ACCOUNT_NOT_CONFIGURED`, `ERROR_KEEPER_ACCOUNT_NOT_FOUND`
|
|
288
|
-
- **Bootstrap**: `ERROR_ALREADY_BOOTSTRAPPED`, `ERROR_TOKEN_FILE_MISSING`,
|
|
289
|
-
`ERROR_BOOTSTRAP_NOT_CONFIGURED`
|
|
290
|
-
- **Signup/invites**: `ERROR_NO_MATCHING_INVITE`, `ERROR_SIGNUP_CONFLICT`,
|
|
291
|
-
`ERROR_INVITE_NOT_FOUND`,
|
|
292
|
-
`ERROR_INVITE_DUPLICATE`, `ERROR_INVITE_ACCOUNT_EXISTS_USERNAME`,
|
|
293
|
-
`ERROR_INVITE_ACCOUNT_EXISTS_EMAIL`
|
|
294
|
-
- **Admin**: `ERROR_ROLE_NOT_WEB_GRANTABLE`, `ERROR_ROLE_GRANT_NOT_FOUND`,
|
|
295
|
-
`ERROR_INVALID_EVENT_TYPE`
|
|
296
|
-
- **DB browser**: `ERROR_FOREIGN_KEY_VIOLATION`, `ERROR_TABLE_NOT_FOUND`,
|
|
297
|
-
`ERROR_TABLE_NO_PRIMARY_KEY`, `ERROR_ROW_NOT_FOUND`
|
|
203
|
+
- **Validation**: `ERROR_INVALID_REQUEST_BODY`, `ERROR_INVALID_JSON_BODY`, `ERROR_INVALID_ROUTE_PARAMS`, `ERROR_INVALID_QUERY_PARAMS`
|
|
204
|
+
- **Auth**: `ERROR_AUTHENTICATION_REQUIRED`, `ERROR_INSUFFICIENT_PERMISSIONS`, `ERROR_CREDENTIAL_TYPE_REQUIRED`, `ERROR_RATE_LIMIT_EXCEEDED`, `ERROR_INVALID_CREDENTIALS`, `ERROR_PAYLOAD_TOO_LARGE`
|
|
205
|
+
- **Origin + bearer**: `ERROR_FORBIDDEN_ORIGIN`, `ERROR_BEARER_REJECTED_BROWSER`, `ERROR_INVALID_TOKEN`, `ERROR_ACCOUNT_NOT_FOUND`
|
|
206
|
+
- **Keeper/daemon**: `ERROR_INVALID_DAEMON_TOKEN`, `ERROR_KEEPER_ACCOUNT_NOT_CONFIGURED`, `ERROR_KEEPER_ACCOUNT_NOT_FOUND`
|
|
207
|
+
- **Bootstrap**: `ERROR_ALREADY_BOOTSTRAPPED`, `ERROR_TOKEN_FILE_MISSING`
|
|
208
|
+
- **Signup/invites**: `ERROR_NO_MATCHING_INVITE`, `ERROR_SIGNUP_CONFLICT`, `ERROR_INVITE_NOT_FOUND`, `ERROR_INVITE_DUPLICATE`, `ERROR_INVITE_ACCOUNT_EXISTS_USERNAME`, `ERROR_INVITE_ACCOUNT_EXISTS_EMAIL`
|
|
209
|
+
- **Admin**: `ERROR_ROLE_NOT_WEB_GRANTABLE`, `ERROR_ROLE_GRANT_NOT_FOUND`, `ERROR_INVALID_EVENT_TYPE`
|
|
210
|
+
- **DB browser**: `ERROR_FOREIGN_KEY_VIOLATION`, `ERROR_TABLE_NOT_FOUND`, `ERROR_TABLE_NO_PRIMARY_KEY`, `ERROR_ROW_NOT_FOUND`
|
|
298
211
|
|
|
299
212
|
## Schema Helpers
|
|
300
213
|
|
|
301
|
-
`schema_helpers.ts` is the canonical home for shared Zod introspection
|
|
302
|
-
extracted to break a circular dependency between `route_spec.ts`
|
|
303
|
-
|
|
304
|
-
generation).
|
|
214
|
+
`http/schema_helpers.ts` is the canonical home for shared Zod introspection
|
|
215
|
+
— extracted to break a circular dependency between `http/route_spec.ts`
|
|
216
|
+
(input validation) and `http/surface.ts` (surface generation).
|
|
305
217
|
|
|
306
218
|
**Import `is_null_schema`, `is_strict_object_schema`, `schema_to_surface`,
|
|
307
|
-
`middleware_applies`, and `merge_error_schemas` from `schema_helpers.ts`,
|
|
308
|
-
not from `surface.ts`.** The helpers were moved; `surface.ts`
|
|
309
|
-
and re-uses them for generation logic.
|
|
219
|
+
`middleware_applies`, and `merge_error_schemas` from `http/schema_helpers.ts`,
|
|
220
|
+
not from `http/surface.ts`.** The helpers were moved; `http/surface.ts`
|
|
221
|
+
only imports and re-uses them for generation logic.
|
|
310
222
|
|
|
311
223
|
Key helpers:
|
|
312
224
|
|
|
313
|
-
- `is_null_schema(schema)` — `instanceof z.ZodNull
|
|
314
|
-
|
|
315
|
-
- `
|
|
316
|
-
|
|
317
|
-
- `
|
|
318
|
-
`default` keys stripped recursively (defaults may be non-deterministic
|
|
319
|
-
and `$schema` is snapshot noise)
|
|
320
|
-
- `middleware_applies(mw_path, route_path)` — Hono pattern matching:
|
|
321
|
-
`'*'`, exact, `'/api/*'` prefix (handles `prefix.slice(0, -1)` so
|
|
322
|
-
`/api/*` also matches the bare `/api`)
|
|
323
|
-
- `merge_error_schemas(spec, middleware_errors?)` — three-layer merge
|
|
324
|
-
described above.
|
|
225
|
+
- `is_null_schema(schema)` — `instanceof z.ZodNull` (not parse-null, to avoid false positives from `z.nullable(z.string())`)
|
|
226
|
+
- `is_strict_object_schema(schema)` — detects `z.strictObject()` by checking `schema.def.catchall instanceof z.ZodNever`
|
|
227
|
+
- `schema_to_surface(schema)` — Zod → JSON Schema, with `$schema` and `default` keys stripped recursively (defaults may be non-deterministic; `$schema` is snapshot noise)
|
|
228
|
+
- `middleware_applies(mw_path, route_path)` — Hono pattern matching: `'*'`, exact, `'/api/*'` prefix (handles `prefix.slice(0, -1)` so `/api/*` also matches the bare `/api`)
|
|
229
|
+
- `merge_error_schemas(spec, middleware_errors?)` — the three-layer merge described above
|
|
325
230
|
|
|
326
231
|
## Surface Generation
|
|
327
232
|
|
|
328
|
-
`surface.ts` produces a JSON-serializable attack surface from
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
invariants.
|
|
233
|
+
`http/surface.ts` produces a JSON-serializable attack surface from
|
|
234
|
+
middleware + route + RPC + env + event specs. Used for startup logging,
|
|
235
|
+
snapshot testing, surface explorer UI, adversarial test generation, and
|
|
236
|
+
policy invariants.
|
|
333
237
|
|
|
334
238
|
### Types
|
|
335
239
|
|
|
336
|
-
- `AppSurface` — JSON-serializable output (`middleware`, `routes`,
|
|
337
|
-
|
|
338
|
-
- `
|
|
339
|
-
produced it (`surface`, `route_specs`, `middleware_specs`, `rpc_endpoints`).
|
|
340
|
-
Runtime-only — use for tests and introspection
|
|
341
|
-
- `AppSurfaceRoute`, `AppSurfaceMiddleware`, `AppSurfaceEnv`,
|
|
342
|
-
`AppSurfaceEvent`, `AppSurfaceRpcEndpoint`, `AppSurfaceRpcMethod` —
|
|
343
|
-
per-entity entries
|
|
240
|
+
- `AppSurface` — JSON-serializable output (`middleware`, `routes`, `rpc_endpoints`, `env`, `events`, `diagnostics`)
|
|
241
|
+
- `AppSurfaceSpec` — the surface bundled with the **source specs** (`surface`, `route_specs`, `middleware_specs`, `rpc_endpoints`). Runtime-only — use for tests and introspection
|
|
242
|
+
- `AppSurfaceRoute`, `AppSurfaceMiddleware`, `AppSurfaceEnv`, `AppSurfaceEvent`, `AppSurfaceRpcEndpoint`, `AppSurfaceRpcMethod` — per-entity entries
|
|
344
243
|
- `AppSurfaceDiagnostic` — `{level: 'warning' | 'info'; category; message; source?}`
|
|
345
|
-
- `RpcEndpointSpec` — `{path, actions: Array<RpcAction>}`; fed into
|
|
346
|
-
`generate_app_surface` so RPC endpoints appear in the surface without
|
|
347
|
-
coupling to `create_rpc_endpoint`
|
|
244
|
+
- `RpcEndpointSpec` — `{path, actions: Array<RpcAction>}`; fed into `generate_app_surface` so RPC endpoints appear in the surface without coupling to `create_rpc_endpoint`
|
|
348
245
|
- `GenerateAppSurfaceOptions` — `{route_specs, middleware_specs, env_schema?, event_specs?, rpc_endpoints?}`
|
|
349
246
|
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
- Per-route `is_mutation` = `method !== 'GET'`
|
|
357
|
-
- Per-route `transaction` mirrors the handler default (`spec.transaction ?? method !== 'GET'`)
|
|
358
|
-
- `env_schema_to_surface(schema)` reads `SchemaFieldMeta` from `.meta()`
|
|
359
|
-
— `description`, `sensitivity`, and probes `safeParse(undefined)` to
|
|
360
|
-
detect `optional` + `has_default`
|
|
361
|
-
- `events_to_surface(event_specs)` — SSE events surface as `{method, description, channel, params_schema}`
|
|
362
|
-
- RPC methods surface their `RouteAuth` directly — same shape on both `ActionSpec.auth` and `RouteSpec.auth`, no translation step.
|
|
247
|
+
`generate_app_surface(options)` emits a `warning` diagnostic for every
|
|
248
|
+
input schema that's not strict (unknown keys silently strip under
|
|
249
|
+
`z.object()`), runs the three-layer merge per route, derives `is_mutation`
|
|
250
|
+
and `transaction` from method/spec, and surfaces RPC methods with their
|
|
251
|
+
`RouteAuth` directly (same shape on both `ActionSpec.auth` and
|
|
252
|
+
`RouteSpec.auth`, no translation step).
|
|
363
253
|
|
|
364
254
|
`create_app_surface_spec(options)` = `generate_app_surface(options)` plus
|
|
365
255
|
the source specs, for tests that need to iterate over raw specs.
|
|
@@ -370,30 +260,25 @@ No side effects, no state — filters and groupings over `AppSurface`:
|
|
|
370
260
|
|
|
371
261
|
- `filter_protected_routes` / `filter_public_routes`
|
|
372
262
|
- `filter_role_routes` / `filter_authenticated_routes` / `filter_keeper_routes` / `filter_routes_for_role(role)`
|
|
373
|
-
- `filter_routes_by_prefix(prefix)` / `filter_routes_with_input` /
|
|
374
|
-
|
|
375
|
-
`filter_mutation_routes` / `filter_rate_limited_routes`
|
|
376
|
-
- `routes_by_auth_type(surface)` — `Map<RouteAuthCategory, Array<AppSurfaceRoute>>` where `RouteAuthCategory = 'none' | 'authenticated' | 'optional' | 'keeper' | 'role:<name>' | 'other'`. Multi-role specs appear under each of their role buckets; the `'optional'` and `'other'` buckets exist for shapes that don't fit the four-axis categorical view.
|
|
263
|
+
- `filter_routes_by_prefix(prefix)` / `filter_routes_with_input` / `filter_routes_with_params` / `filter_routes_with_query` / `filter_mutation_routes` / `filter_rate_limited_routes`
|
|
264
|
+
- `routes_by_auth_type(surface)` — `Map<RouteAuthCategory, Array<AppSurfaceRoute>>` where `RouteAuthCategory = 'none' | 'authenticated' | 'optional' | 'keeper' | 'role:<name>' | 'other'`. Multi-role specs appear under each role bucket
|
|
377
265
|
- `format_route_key(route)` → `'METHOD /path'`
|
|
378
|
-
- `surface_auth_summary(surface)` — counts per auth type, roles broken
|
|
379
|
-
out by name
|
|
266
|
+
- `surface_auth_summary(surface)` — counts per auth type, roles broken out by name
|
|
380
267
|
|
|
381
268
|
The per-route auth predicates these filters compose over (`is_public_auth`,
|
|
382
269
|
`is_role_auth`, `is_credential_gated_auth`, `is_keeper_auth`,
|
|
383
|
-
`is_plain_authenticated_auth`, plus `needs_actor` / `needs_account`)
|
|
384
|
-
|
|
385
|
-
|
|
270
|
+
`is_plain_authenticated_auth`, plus `needs_actor` / `needs_account`) live
|
|
271
|
+
in `http/auth_shape.ts` next to the canonical `RouteAuth` schema — import
|
|
272
|
+
them from there, not from this module. Same predicates back the
|
|
386
273
|
dispatcher's authorization phase, the route-spec auth-guard resolver,
|
|
387
|
-
`derive_error_schemas`'s actor-failure folding, and the testing
|
|
388
|
-
harnesses, so every consumer that branches on the four-axis shape
|
|
389
|
-
shares one source of truth.
|
|
274
|
+
`derive_error_schemas`'s actor-failure folding, and the testing harnesses.
|
|
390
275
|
|
|
391
276
|
Consumer code (tests, attack-surface helpers, `SurfaceExplorer.svelte`)
|
|
392
277
|
should reach for these rather than inlining `.filter` chains.
|
|
393
278
|
|
|
394
279
|
## Middleware Infrastructure
|
|
395
280
|
|
|
396
|
-
|
|
281
|
+
`MiddlewareSpec` (in `http/middleware_spec.ts`):
|
|
397
282
|
|
|
398
283
|
```typescript
|
|
399
284
|
interface MiddlewareSpec {
|
|
@@ -404,226 +289,117 @@ interface MiddlewareSpec {
|
|
|
404
289
|
}
|
|
405
290
|
```
|
|
406
291
|
|
|
407
|
-
Declared
|
|
408
|
-
|
|
292
|
+
Declared separately from `http/route_spec.ts` so middleware modules don't
|
|
293
|
+
pull in route types.
|
|
409
294
|
|
|
410
|
-
### Trusted proxy — `proxy.ts`
|
|
295
|
+
### Trusted proxy — `http/proxy.ts`
|
|
411
296
|
|
|
412
297
|
Resolves the real client IP from `X-Forwarded-For` only when the TCP
|
|
413
298
|
connection is from a configured trusted proxy. Without this middleware,
|
|
414
|
-
`get_client_ip(c)` returns `'unknown'`.
|
|
415
|
-
|
|
416
|
-
Must run **before** auth and rate-limiting middleware. See the root
|
|
417
|
-
../../CLAUDE.md §Middleware Ordering.
|
|
418
|
-
|
|
419
|
-
- `normalize_ip(ip)` — delegates to `canonicalize_ip` from `ip_canonical.ts`:
|
|
420
|
-
RFC 5952 IPv6 canonicalization (lowercase hex, longest-zero-run
|
|
421
|
-
compression), IPv4-mapped IPv6 emitted in dotted form and stripped of
|
|
422
|
-
the `::ffff:` prefix so the bucket collapses to plain IPv4. Idempotent;
|
|
423
|
-
safe on non-IP strings (`'unknown'` → `'unknown'`); strict char-set
|
|
424
|
-
filter (`IP_LITERAL_CHARS`) preserves malformed forms unchanged so
|
|
425
|
-
downstream `validate_ip_strict` can still reject them. Pure IPv6 like
|
|
426
|
-
`::ffff:1` (group[5]=0, not 0xffff — NOT IPv4-mapped) stays preserved.
|
|
427
|
-
Mirrors `zzz_server::proxy::normalize_ip`
|
|
428
|
-
- `ProxyOptions` — `{trusted_proxies, get_connection_ip, log?}`
|
|
429
|
-
- `ParsedProxy` — `{type: 'ip'; address}` or `{type: 'cidr'; network; prefix; address_type}`
|
|
430
|
-
- `parse_proxy_entry(entry)` — accepts `'127.0.0.1'`, `'::1'`,
|
|
431
|
-
`'10.0.0.0/8'`, `'fe80::/10'`. Throws on invalid IPs, NaN/negative/
|
|
432
|
-
over-range prefix, non-network-aligned CIDRs, or bad input
|
|
433
|
-
- **`validate_ip_strict(ip)`** — defensive validator for any IP string
|
|
434
|
-
read from an untrusted source. Hono's `distinctRemoteAddr` is lax —
|
|
435
|
-
classifies anything-with-colons as `'IPv6'`, and
|
|
436
|
-
`convertIPv6ToBinary` silently accepts `'[::1]:8080'`, `'::1\n'`,
|
|
437
|
-
etc. as binary-valid IPv6. The two-layer check here (character-set
|
|
438
|
-
pre-filter + round-trip through `convertIPv*ToBinary`) closes both
|
|
439
|
-
holes: returns `'IPv4' | 'IPv6'` on a strictly-valid bare literal,
|
|
440
|
-
`undefined` on anything else.
|
|
441
|
-
- `is_trusted_ip(ip, proxies)` — normalizes before matching; uses
|
|
442
|
-
`validate_ip_strict` to reject malformed input up front (without it,
|
|
443
|
-
CIDR proxies would surface a 500 from a thrown
|
|
444
|
-
`convertIPv6ToBinary` on entries like `'203.0.113.1:8080'`); skips
|
|
445
|
-
mismatched address families for CIDR matches
|
|
446
|
-
- `resolve_client_ip(forwarded_for, proxies)` — walks **right-to-left**,
|
|
447
|
-
skipping trusted entries AND any entry that fails strict validation
|
|
448
|
-
(closes the rate-limit-key poisoning surface where an attacker who
|
|
449
|
-
controls XFF and transits through a trusted proxy could rotate
|
|
450
|
-
garbage strings to evade per-IP limits). First untrusted +
|
|
451
|
-
strictly-valid wins. If everything is trusted-or-malformed, returns
|
|
452
|
-
the leftmost strictly-valid entry, or `undefined` to let the
|
|
453
|
-
middleware fall back to the connection IP
|
|
454
|
-
- `create_proxy_middleware(options)` + `create_proxy_middleware_spec(options)` —
|
|
455
|
-
three-branch logic:
|
|
456
|
-
1. No XFF → use connection IP directly
|
|
457
|
-
2. XFF present + connection untrusted → ignore XFF (spoof-proof), use
|
|
458
|
-
connection IP, log debug
|
|
459
|
-
3. XFF present + connection trusted → resolve from header, log warn if
|
|
460
|
-
all XFF entries turn out to be trusted
|
|
461
|
-
- `get_client_ip(c)` — returns `'unknown'` when the proxy middleware
|
|
462
|
-
hasn't run
|
|
463
|
-
|
|
464
|
-
Tradeoff for the strict validation: legitimate non-standard proxies
|
|
465
|
-
that include ports in XFF entries (`203.0.113.1:8080`) lose per-client
|
|
466
|
-
distinction in rate limiting and collapse to the proxy's connection
|
|
467
|
-
IP (one bucket for everyone behind that proxy). nginx + cloud LBs
|
|
468
|
-
don't include ports — bounded by operator configuration in practice.
|
|
469
|
-
|
|
470
|
-
### Origin allowlist — `origin.ts`
|
|
471
|
-
|
|
472
|
-
Origin allowlisting for locally-running services — **not** the CSRF
|
|
473
|
-
layer. CSRF is handled by `SameSite: strict` on session cookies (see
|
|
474
|
-
`auth/session_middleware.ts`).
|
|
475
|
-
|
|
476
|
-
- `parse_allowed_origins(env_value)` — comma-separated patterns → `Array<RegExp>`
|
|
477
|
-
- `should_allow_origin(origin, patterns)` — case-insensitive match
|
|
478
|
-
- `verify_request_source(allowed_patterns)` — Hono handler:
|
|
479
|
-
1. `Origin` header present → must match allowlist or 403 `ERROR_FORBIDDEN_ORIGIN`
|
|
480
|
-
2. No `Origin` → allow through (curl, CLI, token auth is primary control)
|
|
481
|
-
|
|
482
|
-
**Origin-only by design.** Fetch spec mandates `Origin` on every unsafe
|
|
483
|
-
method, so a real browser request on any state-changing surface always
|
|
484
|
-
carries it. Non-browser clients (curl, server-to-server, CLI) don't
|
|
485
|
-
ship auto-attached session cookies, so CSRF isn't the relevant threat
|
|
486
|
-
there — auth (bearer / daemon token) is the actual control. A `Referer`
|
|
487
|
-
fallback would only widen the accepted-shape envelope without closing
|
|
488
|
-
a real CSRF hole. Mirrors `zzz_server::auth::is_request_origin_allowed`.
|
|
489
|
-
|
|
490
|
-
`ERROR_FORBIDDEN_REFERER` stays exported from `error_schemas.ts` for
|
|
491
|
-
consumers whose error-schema unions or test assertions still reference
|
|
492
|
-
it — the emit site is gone, the constant is not.
|
|
493
|
-
|
|
494
|
-
Pattern syntax:
|
|
299
|
+
`get_client_ip(c)` returns `'unknown'`. Must run **before** auth and
|
|
300
|
+
rate-limiting middleware (see root ../../CLAUDE.md §Middleware Ordering).
|
|
495
301
|
|
|
496
|
-
-
|
|
497
|
-
- Wildcard subdomain (complete label only): `https://*.fuz.dev` —
|
|
498
|
-
matches `api.fuz.dev`, NOT `fuz.dev`
|
|
499
|
-
- Multiple wildcards: `https://*.*.corp.fuz.dev` matches `api.staging.corp.fuz.dev`
|
|
500
|
-
- Port wildcard: `http://localhost:*` (optional port, matches with or without)
|
|
501
|
-
- IPv6: `http://[::1]:3000`, `https://[2001:db8::1]` (no wildcards inside brackets)
|
|
502
|
-
- Combined: `https://*.fuz.dev:*`
|
|
302
|
+
Per-symbol semantics on TSDoc; the cross-cutting properties:
|
|
503
303
|
|
|
504
|
-
|
|
505
|
-
`
|
|
506
|
-
(
|
|
507
|
-
(
|
|
304
|
+
- **Rightmost-first XFF walk**, skipping trusted entries AND entries that fail strict validation. Closes a rate-limit-key-poisoning surface where an attacker who controls XFF and transits through a trusted proxy could rotate garbage strings to evade per-IP limits.
|
|
305
|
+
- **`validate_ip_strict(ip)`** is defensive against Hono's lax `distinctRemoteAddr` (which classifies anything-with-colons as IPv6 and accepts `'[::1]:8080'`, `'::1\n'` as binary-valid). Two-layer check: character-set pre-filter + round-trip through `convertIPv*ToBinary`.
|
|
306
|
+
- **`normalize_ip(ip)`** delegates to `canonicalize_ip` from `http/ip_canonical.ts` — RFC 5952 lowercase + longest-zero-run compression, IPv4-mapped IPv6 stripped to plain IPv4 so buckets collapse. Idempotent, safe on non-IP strings, strict char-set filter preserves malformed forms unchanged.
|
|
307
|
+
- **Three-branch middleware logic**: no XFF → use connection IP; XFF + connection untrusted → ignore XFF, use connection IP (spoof-proof, debug log); XFF + connection trusted → resolve from header, warn if all entries turn out trusted.
|
|
508
308
|
|
|
509
|
-
|
|
309
|
+
Tradeoff: legitimate non-standard proxies that include ports in XFF entries
|
|
310
|
+
(`203.0.113.1:8080`) lose per-client distinction and collapse to the
|
|
311
|
+
proxy's connection IP. nginx + cloud LBs don't include ports — bounded by
|
|
312
|
+
operator configuration in practice.
|
|
510
313
|
|
|
511
|
-
|
|
314
|
+
### Origin allowlist — `http/origin.ts`
|
|
512
315
|
|
|
513
|
-
-
|
|
514
|
-
|
|
515
|
-
HTTP-status mapping
|
|
516
|
-
- `jsonrpc_helpers.ts` — **plumbing**: message builders, type guards, converters
|
|
316
|
+
Origin allowlisting for locally-running services — **not** the CSRF layer.
|
|
317
|
+
CSRF is handled by `SameSite: strict` on session cookies (`auth/session_middleware.ts`).
|
|
517
318
|
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
`
|
|
521
|
-
TypeScript SDK for compatibility.
|
|
522
|
-
|
|
523
|
-
### `jsonrpc.ts` — envelope + code schemas
|
|
524
|
-
|
|
525
|
-
`JSONRPC_VERSION = '2.0'` plus Zod schemas paired with inferred types:
|
|
319
|
+
- `parse_allowed_origins(env_value)` — comma-separated patterns → `Array<RegExp>`
|
|
320
|
+
- `should_allow_origin(origin, patterns)` — case-insensitive match
|
|
321
|
+
- `verify_request_source(allowed_patterns)` — Hono handler: `Origin` present → must match allowlist or 403 `ERROR_FORBIDDEN_ORIGIN`; no `Origin` → allow through (curl, CLI, token auth is primary control)
|
|
526
322
|
|
|
527
|
-
-
|
|
528
|
-
|
|
529
|
-
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
323
|
+
**Origin-only by design.** Fetch spec mandates `Origin` on every unsafe
|
|
324
|
+
method, so a real browser request on any state-changing surface always
|
|
325
|
+
carries it. Non-browser clients don't ship auto-attached session cookies,
|
|
326
|
+
so CSRF isn't the relevant threat there — auth (bearer / daemon token) is
|
|
327
|
+
the actual control. A `Referer` fallback would only widen the
|
|
328
|
+
accepted-shape envelope without closing a real CSRF hole. Mirrors
|
|
329
|
+
`zzz_server::auth::is_request_origin_allowed`.
|
|
330
|
+
|
|
331
|
+
Pattern syntax: exact `https://api.fuz.dev`; wildcard subdomain
|
|
332
|
+
`https://*.fuz.dev` (matches `api.fuz.dev`, NOT `fuz.dev`); multiple
|
|
333
|
+
wildcards `https://*.*.corp.fuz.dev`; port wildcard `http://localhost:*`
|
|
334
|
+
(optional port); IPv6 `http://[::1]:3000`, `https://[2001:db8::1]` (no
|
|
335
|
+
wildcards inside brackets). Patterns normalize through `URL` constructor.
|
|
336
|
+
IPv6 zone identifiers (`%eth0`) not supported. Throws on paths, partial
|
|
337
|
+
wildcards (`*fuz.dev`), wildcards inside IPv6 brackets, or missing
|
|
338
|
+
protocol.
|
|
339
|
+
|
|
340
|
+
## JSON-RPC (`http/jsonrpc.ts`, `http/jsonrpc_errors.ts`, `http/jsonrpc_helpers.ts`)
|
|
341
|
+
|
|
342
|
+
Three files split by concern: `http/jsonrpc.ts` declarative (envelope
|
|
343
|
+
schemas), `http/jsonrpc_errors.ts` runtime (throwable + map),
|
|
344
|
+
`http/jsonrpc_helpers.ts` plumbing (builders, guards, converters).
|
|
345
|
+
|
|
346
|
+
Follows JSON-RPC 2.0 spec with a partial **MCP superset** posture — params
|
|
347
|
+
are object-only (no positional arrays) and `_meta` / `progressToken` are
|
|
348
|
+
first-class; result is the full JSON-RPC §5 value space (object, array,
|
|
349
|
+
string, number, boolean, null) since the per-action `spec.output` is the
|
|
350
|
+
actual contract and the MCP object-only result constraint would reject any
|
|
351
|
+
spec declaring `output: z.null()` / primitives on the wire. Schemas sourced
|
|
352
|
+
from the MCP TypeScript SDK for compatibility on the params / `_meta` axis.
|
|
534
353
|
|
|
535
354
|
`_meta` is intentionally **not** envelope-validated — that lives in
|
|
536
355
|
per-action schemas so mismatches surface as `invalid_params` rather than
|
|
537
356
|
`invalid_request`.
|
|
538
357
|
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
- `
|
|
548
|
-
- `
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
`
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
| `invalid_params` | -32602 | 400 | Params schema failure |
|
|
562
|
-
| `internal_error` | -32603 | 500 | Unhandled exception |
|
|
563
|
-
| `unauthenticated` | -32001 | 401 | HTTP 401 renamed ("unauthorized" is wrong for 401) |
|
|
564
|
-
| `forbidden` | -32002 | 403 | Authorized but denied |
|
|
565
|
-
| `not_found` | -32003 | 404 | Resource not found |
|
|
566
|
-
| `conflict` | -32004 | 409 | Uniqueness/state conflict |
|
|
567
|
-
| `validation_error` | -32005 | 422 | **Application-level** validation (business logic) |
|
|
568
|
-
| `rate_limited` | -32006 | 429 | Server-side policy |
|
|
569
|
-
| `service_unavailable` | -32007 | 503 | Upstream down / maintenance |
|
|
570
|
-
| `timeout` | -32008 | 504 | Handler exceeded time budget |
|
|
571
|
-
| `queue_overflow` | -32009 | 429 | **Client-side** backpressure (WS reconnect queue full) |
|
|
572
|
-
| `request_cancelled` | -32010 | 499 | Caller-initiated cancellation (nginx "client closed") |
|
|
573
|
-
|
|
574
|
-
`invalid_params` vs `validation_error`: use `invalid_params` (standard
|
|
358
|
+
### 15-code error taxonomy
|
|
359
|
+
|
|
360
|
+
Five standard codes + ten general application codes (consumers add their
|
|
361
|
+
own by casting `as JsonrpcErrorCode`):
|
|
362
|
+
|
|
363
|
+
- `parse_error` (-32700, HTTP 400) — JSON parse failure.
|
|
364
|
+
- `invalid_request` (-32600, HTTP 400) — envelope malformed.
|
|
365
|
+
- `method_not_found` (-32601, HTTP 404) — unknown RPC method.
|
|
366
|
+
- `invalid_params` (-32602, HTTP 400) — params schema failure.
|
|
367
|
+
- `internal_error` (-32603, HTTP 500) — unhandled exception.
|
|
368
|
+
- `unauthenticated` (-32001, HTTP 401) — HTTP 401 renamed ("unauthorized" is wrong for 401).
|
|
369
|
+
- `forbidden` (-32002, HTTP 403) — authorized but denied.
|
|
370
|
+
- `not_found` (-32003, HTTP 404) — resource not found.
|
|
371
|
+
- `conflict` (-32004, HTTP 409) — uniqueness/state conflict.
|
|
372
|
+
- `validation_error` (-32005, HTTP 422) — **application-level** validation (business logic).
|
|
373
|
+
- `rate_limited` (-32006, HTTP 429) — server-side policy.
|
|
374
|
+
- `service_unavailable` (-32007, HTTP 503) — upstream down / maintenance.
|
|
375
|
+
- `timeout` (-32008, HTTP 504) — handler exceeded time budget.
|
|
376
|
+
- `queue_overflow` (-32009, HTTP 429) — **client-side** backpressure (WS reconnect queue full).
|
|
377
|
+
- `request_cancelled` (-32010, HTTP 499) — caller-initiated cancellation (nginx "client closed").
|
|
378
|
+
|
|
379
|
+
**`invalid_params` vs `validation_error`** — use `invalid_params` (standard
|
|
575
380
|
code) for Zod parse failures; reserve `validation_error` (app code) for
|
|
576
|
-
business rules.
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
381
|
+
business rules.
|
|
382
|
+
|
|
383
|
+
**`rate_limited` vs `queue_overflow`** — both 429; reverse map
|
|
384
|
+
`HTTP_STATUS_TO_JSONRPC_ERROR_CODE[429] = rate_limited` because rate
|
|
385
|
+
limiting is the default interpretation when translating generic HTTP back
|
|
386
|
+
to a JSON-RPC code.
|
|
580
387
|
|
|
581
|
-
|
|
388
|
+
### API map
|
|
582
389
|
|
|
583
|
-
- `JSONRPC_ERROR_CODES` — `Record<JsonrpcErrorName, JsonrpcErrorCode>`
|
|
584
|
-
with the 15 entries above
|
|
390
|
+
- `JSONRPC_ERROR_CODES` — `Record<JsonrpcErrorName, JsonrpcErrorCode>` (frozen)
|
|
585
391
|
- `jsonrpc_error_messages` — named constructors returning `JsonrpcErrorObject`
|
|
586
|
-
- `jsonrpc_errors` — named constructors returning `ThrownJsonrpcError`
|
|
587
|
-
(derived from `jsonrpc_error_messages` via `create_error_thrower`).
|
|
588
|
-
Usage: `throw jsonrpc_errors.not_found('user')`, `throw jsonrpc_errors.forbidden()`
|
|
392
|
+
- `jsonrpc_errors` — named constructors returning `ThrownJsonrpcError` (derived from `jsonrpc_error_messages` via `create_error_thrower`). Usage: `throw jsonrpc_errors.not_found('user')`, `throw jsonrpc_errors.forbidden()`
|
|
589
393
|
- `ThrownJsonrpcError` — `Error` subclass carrying `code` + optional `data`
|
|
590
|
-
- `JSONRPC_ERROR_CODE_TO_HTTP_STATUS` / `HTTP_STATUS_TO_JSONRPC_ERROR_CODE`
|
|
591
|
-
|
|
592
|
-
|
|
394
|
+
- `JSONRPC_ERROR_CODE_TO_HTTP_STATUS` / `HTTP_STATUS_TO_JSONRPC_ERROR_CODE` + `jsonrpc_error_code_to_http_status` / `http_status_to_jsonrpc_error_code` accessors (fall back to 500 / `internal_error`)
|
|
395
|
+
- Envelope schemas in `http/jsonrpc.ts`: `JsonrpcRequest`, `JsonrpcNotification`, `JsonrpcResponse`, `JsonrpcErrorResponse`, `JsonrpcResponseOrError`, `JsonrpcMessage`, `JsonrpcMessageFromClientToServer`, `JsonrpcMessageFromServerToClient`. Also `JsonrpcRequestId`, `JsonrpcMethod`, `JsonrpcProgressToken`, `JsonrpcMcpMeta`, `JsonrpcRequestParamsMeta`
|
|
396
|
+
- Builders in `http/jsonrpc_helpers.ts`: `create_jsonrpc_request`, `create_jsonrpc_notification`, `create_jsonrpc_response`, `create_jsonrpc_error_response`, `create_jsonrpc_error_response_from_thrown` (preserves code/message/data on `ThrownJsonrpcError`; plain `Error` → `internal_error` with `{stack}` in DEV)
|
|
397
|
+
- Type guards: `is_jsonrpc_request_id` (rejects NaN/Infinity), `is_jsonrpc_object`, `is_jsonrpc_message`, `is_jsonrpc_request` / `_notification` / `_response` / `_error_response`
|
|
398
|
+
- Converters: `to_jsonrpc_message_id`, `to_jsonrpc_params` (normalizes primitives to `{value}`), `to_jsonrpc_result` (null/undefined → `{}`, primitives → `{value}`)
|
|
593
399
|
|
|
594
400
|
Handlers can `throw jsonrpc_errors.*` — `apply_route_specs`' error-catch
|
|
595
|
-
layer converts
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
### `jsonrpc_helpers.ts` — builders, guards, converters
|
|
599
|
-
|
|
600
|
-
Used by the SAES runtime (`ActionEvent`, `ActionPeer`, transports) and
|
|
601
|
-
the RPC endpoint dispatcher.
|
|
602
|
-
|
|
603
|
-
Builders (all emit correctly-shaped messages with `jsonrpc: '2.0'`):
|
|
604
|
-
|
|
605
|
-
- `create_jsonrpc_request(method, params, id)`
|
|
606
|
-
- `create_jsonrpc_notification(method, params)`
|
|
607
|
-
- `create_jsonrpc_response(id, result)`
|
|
608
|
-
- `create_jsonrpc_error_response(id, error)`
|
|
609
|
-
- `create_jsonrpc_error_response_from_thrown(id, error)` — `ThrownJsonrpcError`
|
|
610
|
-
→ preserves code/message/data; plain `Error` → `internal_error`, includes
|
|
611
|
-
`{stack}` in `data` under DEV only
|
|
612
|
-
|
|
613
|
-
Type guards:
|
|
614
|
-
|
|
615
|
-
- `is_jsonrpc_request_id` — string or finite number (rejects NaN/Infinity)
|
|
616
|
-
- `is_jsonrpc_object` — object with `jsonrpc: '2.0'` (not array)
|
|
617
|
-
- `is_jsonrpc_message` — single message or non-empty batch array
|
|
618
|
-
- `is_jsonrpc_request` / `is_jsonrpc_notification` / `is_jsonrpc_response` / `is_jsonrpc_error_response`
|
|
619
|
-
|
|
620
|
-
Converters:
|
|
621
|
-
|
|
622
|
-
- `to_jsonrpc_message_id(message_or_id)` — extracts a valid id or returns `null`
|
|
623
|
-
- `to_jsonrpc_params(input)` — normalizes to `Record<string, any>` or
|
|
624
|
-
`undefined`; primitives wrap as `{value}`
|
|
625
|
-
- `to_jsonrpc_result(output)` — normalizes for a response; null/undefined
|
|
626
|
-
becomes `{}`, primitives wrap as `{value}`
|
|
401
|
+
layer converts to `{error: JsonrpcErrorObject}` at the correct HTTP status.
|
|
402
|
+
Generic `Error` maps to 500 `internal_error` (message in DEV only).
|
|
627
403
|
|
|
628
404
|
## Pending Effects
|
|
629
405
|
|
|
@@ -640,40 +416,26 @@ interface EmitAfterCommitContext {
|
|
|
640
416
|
// post_commit_effects: Array<() => void | Promise<void>>
|
|
641
417
|
```
|
|
642
418
|
|
|
643
|
-
- **`pending_effects: Array<Promise<void>>`** — eager. Producers push the
|
|
644
|
-
|
|
645
|
-
running: audit emits via `AppDeps.audit`, session-touch UPDATE,
|
|
646
|
-
api-token usage tracking. The pool write is rollback-resilient by
|
|
647
|
-
virtue of running outside the request transaction; pushing the
|
|
648
|
-
in-flight handle lets test mode (`await_pending_effects: true`) await
|
|
649
|
-
it. Drain rule: `flush_pending_effects(effects, log, on_rejection?)`.
|
|
650
|
-
- **`post_commit_effects: Array<() => void | Promise<void>>`** —
|
|
651
|
-
deferred. Producers go through `emit_after_commit(ctx, fn)` exclusively;
|
|
652
|
-
raw thunks should not be pushed directly. The flush middleware (in
|
|
653
|
-
`server/app_server.ts` and the per-message WS dispatcher in
|
|
654
|
-
`actions/register_action_ws.ts`) is the only site that invokes each
|
|
655
|
-
thunk, after the wrapping `db.transaction` (and the rest of the
|
|
656
|
-
handler chain) has resolved. Drain rule: `flush_post_commit_effects(effects, log)`.
|
|
419
|
+
- **`pending_effects: Array<Promise<void>>`** — eager. Producers push the in-flight `Promise<void>` for fire-and-forget pool writes already running: audit emits via `AppDeps.audit`, session-touch UPDATE, api-token usage tracking. The pool write is rollback-resilient by virtue of running outside the request transaction; pushing the in-flight handle lets test mode (`await_pending_effects: true`) await it. Drain: `flush_pending_effects(effects, log, on_rejection?)`.
|
|
420
|
+
- **`post_commit_effects: Array<() => void | Promise<void>>`** — deferred. Producers go through `emit_after_commit(ctx, fn)` exclusively; raw thunks should not be pushed directly. The flush middleware (in `server/app_server.ts` and the per-message WS dispatcher in `actions/register_action_ws.ts`) is the only site that invokes each thunk, after the wrapping `db.transaction` resolves. Drain: `flush_post_commit_effects(effects, log)`.
|
|
657
421
|
|
|
658
422
|
### Why split
|
|
659
423
|
|
|
660
424
|
Both shapes used to coexist on a single `Array<PendingEffect>` discriminated
|
|
661
|
-
union. The shapes encode different contracts — eager pushers say "wait
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
`c.var.pending_effects.push(x)` ambiguous at the call site. Splitting
|
|
665
|
-
turns the field name into the contract.
|
|
425
|
+
union. The shapes encode different contracts — eager pushers say "wait for
|
|
426
|
+
this work that's already started"; thunk pushers say "run this after the
|
|
427
|
+
handler returns" — and burying both behind one field made
|
|
428
|
+
`c.var.pending_effects.push(x)` ambiguous at the call site. Splitting turns the field name into the contract.
|
|
666
429
|
|
|
667
430
|
### Why `emit_after_commit` defers
|
|
668
431
|
|
|
669
432
|
The thunk shape is **load-bearing for correctness**. Pushing
|
|
670
|
-
`Promise.resolve().then(fn)` onto an eager queue — what
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
returns.
|
|
433
|
+
`Promise.resolve().then(fn)` onto an eager queue — what `emit_after_commit`
|
|
434
|
+
used to do — schedules `fn` as a microtask that drains _before_ the
|
|
435
|
+
wrapping `await db.query('COMMIT')` resumes, so a rolled-back transaction
|
|
436
|
+
would leak a notification for state that never landed. The thunk defers
|
|
437
|
+
the work to flush time; the `try/finally` in the flush middleware runs
|
|
438
|
+
after the handler (and any wrapping transaction) returns.
|
|
677
439
|
|
|
678
440
|
```typescript
|
|
679
441
|
emit_after_commit(ctx, () => notification_sender.send_to_account(account_id, msg));
|
|
@@ -685,29 +447,10 @@ and any side effect that must run only after the transaction commits.
|
|
|
685
447
|
|
|
686
448
|
### Key properties
|
|
687
449
|
|
|
688
|
-
- **The flush owns the safety net.** `flush_post_commit_effects` wraps
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
inside `emit_after_commit` would skip directly-pushed thunks (e.g.
|
|
693
|
-
tests); centralizing the wrap in the flush closes that gap.
|
|
694
|
-
- **Test mode (`await_pending_effects: true`) flushes both queues.**
|
|
695
|
-
Eager: `await flush_pending_effects(pending_effects, log)`. Deferred:
|
|
696
|
-
`await flush_post_commit_effects(post_commit_effects, log)`. Both
|
|
697
|
-
complete before the response returns. Production mode wraps the same
|
|
698
|
-
helpers in `void ...` and threads `on_effect_error` into
|
|
699
|
-
`flush_pending_effects`'s `on_rejection` callback for fan-out.
|
|
700
|
-
- **Same drain location for both.** The outer flush middleware
|
|
701
|
-
(`server/app_server.ts`) and the per-message WS flush handle the two
|
|
702
|
-
queues adjacent to each other. The deferred queue does not drain inside
|
|
703
|
-
the route-spec wrapper / `perform_action` — that would tighten the
|
|
704
|
-
"post-commit" timing further but would force three drain sites (REST
|
|
705
|
-
wrapper, RPC dispatcher, WS dispatcher) to gain timing no current
|
|
706
|
-
consumer needs (the WS-fan-out use case is happy with post-handler).
|
|
707
|
-
- Structurally satisfied by both `RouteContext` (HTTP) and
|
|
708
|
-
`ActionContext` (RPC + WS) — they share the `{log, post_commit_effects}`
|
|
709
|
-
shape, which is why this helper lives in `http/` rather than
|
|
710
|
-
`actions/` or `auth/`.
|
|
450
|
+
- **The flush owns the safety net.** `flush_post_commit_effects` wraps every thunk in `try/catch` and routes errors through `ctx.log.error`, so one failing send cannot starve sibling effects in the same batch nor corrupt the already-committed response. Per-thunk `try/catch` inside `emit_after_commit` would skip directly-pushed thunks (e.g. tests); centralizing the wrap in the flush closes that gap.
|
|
451
|
+
- **Test mode (`await_pending_effects: true`) flushes both queues.** Eager: `await flush_pending_effects(pending_effects, log)`. Deferred: `await flush_post_commit_effects(post_commit_effects, log)`. Both complete before the response returns. Production mode wraps the same helpers in `void ...` and threads `on_effect_error` into `flush_pending_effects`'s `on_rejection` callback for fan-out.
|
|
452
|
+
- **Same drain location for both.** The outer flush middleware (`server/app_server.ts`) and the per-message WS flush handle the two queues adjacent to each other. The deferred queue does not drain inside the route-spec wrapper / `perform_action` — that would tighten the "post-commit" timing further but would force three drain sites (REST wrapper, RPC dispatcher, WS dispatcher) to gain timing no current consumer needs.
|
|
453
|
+
- Structurally satisfied by both `RouteContext` (HTTP) and `ActionContext` (RPC + WS) — they share the `{log, post_commit_effects}` shape, which is why this helper lives in `http/` rather than `actions/` or `auth/`.
|
|
711
454
|
|
|
712
455
|
WS sends are **not** wrapped by `create_validated_broadcaster` (that only
|
|
713
456
|
guards SSE `broadcast(channel, data)`). Zod input schemas on
|
|
@@ -716,40 +459,31 @@ at send time.
|
|
|
716
459
|
|
|
717
460
|
## Common Routes
|
|
718
461
|
|
|
719
|
-
`common_routes.ts` exposes three generic route-spec factories with no
|
|
462
|
+
`http/common_routes.ts` exposes three generic route-spec factories with no
|
|
720
463
|
auth-domain dependencies:
|
|
721
464
|
|
|
722
|
-
- `create_health_route_spec()` — `GET /health`, public, returns
|
|
723
|
-
|
|
724
|
-
- `
|
|
725
|
-
authenticated, returns `{version, uptime_ms}`
|
|
726
|
-
- `create_surface_route_spec({surface})` — `GET /api/surface`,
|
|
727
|
-
authenticated, serves the `AppSurface` JSON. Authenticated because
|
|
728
|
-
surface data reveals API structure (schemas, auth, routes)
|
|
465
|
+
- `create_health_route_spec()` — `GET /health`, public, returns `{status: 'ok'}`. Infrastructure endpoint for uptime monitors
|
|
466
|
+
- `create_server_status_route_spec({version, get_uptime_ms})` — `GET /api/server/status`, authenticated, returns `{version, uptime_ms}`
|
|
467
|
+
- `create_surface_route_spec({surface})` — `GET /api/surface`, authenticated, serves the `AppSurface` JSON. Authenticated because surface data reveals API structure (schemas, auth, routes)
|
|
729
468
|
|
|
730
|
-
Auth-aware variants (account status, bootstrap status) live in
|
|
731
|
-
`
|
|
469
|
+
Auth-aware variants (account status, bootstrap status) live in `auth/` —
|
|
470
|
+
`http/common_routes.ts` stays generic.
|
|
732
471
|
|
|
733
472
|
## DB Routes (Generic Browser)
|
|
734
473
|
|
|
735
|
-
`db_routes.ts` creates keeper-only route specs for administering the
|
|
736
|
-
`public` schema via `information_schema`. Wired by consumers that want
|
|
737
|
-
|
|
474
|
+
`http/db_routes.ts` creates keeper-only route specs for administering the
|
|
475
|
+
`public` schema via `information_schema`. Wired by consumers that want a
|
|
476
|
+
generic table browser; the factory is domain-agnostic.
|
|
738
477
|
|
|
739
478
|
`create_db_route_specs({db_type, db_name, extra_stats?, log?})`:
|
|
740
479
|
|
|
741
|
-
- `GET /health` — connected probe + table count + optional `extra_stats(db)`.
|
|
742
|
-
Returns `{connected: false}` at 503 on failure
|
|
480
|
+
- `GET /health` — connected probe + table count + optional `extra_stats(db)`. Returns `{connected: false}` at 503 on failure
|
|
743
481
|
- `GET /tables` — list public tables with row counts
|
|
744
|
-
- `GET /tables/:name` — columns + rows (paginated via `?offset`/`?limit`,
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
error code `23503`)
|
|
750
|
-
|
|
751
|
-
All four routes use the keeper auth shape (`{account: 'required', actor: 'required', roles: ['keeper'], credential_types: ['daemon_token']}`). Param schemas use
|
|
752
|
-
`VALID_SQL_IDENTIFIER` regex, and every table name gets
|
|
482
|
+
- `GET /tables/:name` — columns + rows (paginated via `?offset`/`?limit`, limit clamped to `[1, 1000]` with default 100) + total count + primary key
|
|
483
|
+
- `DELETE /tables/:name/rows/:id` — delete by PK. Returns 400 if table has no PK (`ERROR_TABLE_NO_PRIMARY_KEY`), 404 if row missing (`ERROR_ROW_NOT_FOUND`) or table missing (`ERROR_TABLE_NOT_FOUND`), 409 on FK violation (pg error code `23503`)
|
|
484
|
+
|
|
485
|
+
All four routes use the keeper auth shape (`{account: 'required', actor: 'required', roles: ['keeper'], credential_types: ['daemon_token']}`).
|
|
486
|
+
Param schemas use `VALID_SQL_IDENTIFIER` regex, and every table name gets
|
|
753
487
|
`assert_valid_sql_identifier()` before string-interpolating into SQL —
|
|
754
488
|
the identifier validation is the only reason the interpolation is safe.
|
|
755
489
|
|
|
@@ -758,20 +492,7 @@ Interfaces exported for consumer use: `TableInfo`, `TableWithCount`,
|
|
|
758
492
|
|
|
759
493
|
## Cross-Module Notes
|
|
760
494
|
|
|
761
|
-
- **Middleware ordering** is assembled by `create_app_server` — see the
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
set before any handler or rate limiter reads it
|
|
766
|
-
- **No re-exports.** Import every symbol from its canonical source
|
|
767
|
-
module. `surface.ts` no longer re-exports schema helpers — go through
|
|
768
|
-
`schema_helpers.ts`
|
|
769
|
-
- **Input/output schemas align with SAES.** When wiring RPC via
|
|
770
|
-
`actions/action_rpc.ts` or bridging to `RouteSpec` via
|
|
771
|
-
`actions/action_bridge.ts`, the same Zod types flow through unchanged
|
|
772
|
-
(see `actions/CLAUDE.md` §Single JSON-RPC 2.0 endpoint and §HTTP bridge)
|
|
773
|
-
- **Error modules are complementary, not redundant.** `error_schemas.ts`
|
|
774
|
-
is Zod-first (for routes and surface); `jsonrpc_errors.ts` is
|
|
775
|
-
throw-first (for handlers and the catch layer). A single `ERROR_*`
|
|
776
|
-
code can be raised either way depending on whether the handler needs
|
|
777
|
-
to also attach diagnostic fields
|
|
495
|
+
- **Middleware ordering** is assembled by `create_app_server` — see the root ../../CLAUDE.md §Middleware Ordering. The invariants `http/` needs consumers to uphold: trusted-proxy runs before auth/rate-limit; origin verification runs before session parsing; `client_ip` must be set before any handler or rate limiter reads it
|
|
496
|
+
- **No re-exports.** Import every symbol from its canonical source module. `http/surface.ts` no longer re-exports schema helpers — go through `http/schema_helpers.ts`
|
|
497
|
+
- **Input/output schemas align with SAES.** When wiring RPC via `actions/action_rpc.ts` or bridging to `RouteSpec` via `actions/action_bridge.ts`, the same Zod types flow through unchanged (see `actions/CLAUDE.md` §Single JSON-RPC 2.0 endpoint and §HTTP bridge)
|
|
498
|
+
- **Error modules are complementary, not redundant.** `http/error_schemas.ts` is Zod-first (for routes and surface); `http/jsonrpc_errors.ts` is throw-first (for handlers and the catch layer). A single `ERROR_*` code can be raised either way depending on whether the handler needs to also attach diagnostic fields
|