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