@fuzdev/fuz_app 0.64.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 +513 -928
- 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 +591 -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/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/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/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 +224 -498
- 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/origin.d.ts +1 -1
- package/dist/http/origin.js +1 -1
- package/dist/runtime/mock.js +1 -1
- 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 +220 -46
- 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 -201
- package/dist/testing/app_server.d.ts +125 -38
- package/dist/testing/app_server.d.ts.map +1 -1
- package/dist/testing/app_server.js +140 -42
- 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 -156
- 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/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 -291
- 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 +21 -16
- package/dist/testing/round_trip.d.ts.map +1 -1
- package/dist/testing/round_trip.js +65 -86
- 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 -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/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 +11 -3
- package/dist/testing/stubs.d.ts.map +1 -1
- package/dist/testing/stubs.js +24 -21
- 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/package.json +4 -4
package/dist/http/CLAUDE.md
CHANGED
|
@@ -9,41 +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
|
-
| `ip_canonical.ts` | RFC 5952 IPv6 canonicalization + IPv4-mapped collapse; `IP_LITERAL_CHARS` regex |
|
|
28
|
-
| `origin.ts` | Origin allowlist middleware with wildcard patterns (Origin-only) |
|
|
29
|
-
| `jsonrpc.ts` | JSON-RPC 2.0 envelope schemas (MCP superset), `JsonrpcErrorCode`, `_meta` |
|
|
30
|
-
| `jsonrpc_errors.ts` | `ThrownJsonrpcError`, `jsonrpc_errors` throwers, HTTP-status mappings |
|
|
31
|
-
| `jsonrpc_helpers.ts` | Message builders, type guards, input/result normalizers |
|
|
32
|
-
| `common_routes.ts` | Health check + authenticated server-status + surface route specs |
|
|
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
|
+
| 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` |
|
|
35
35
|
|
|
36
36
|
## Route Spec System
|
|
37
37
|
|
|
38
|
-
`RouteSpec` (in `route_spec.ts`) is the unit of the attack surface —
|
|
39
|
-
are **data**, registered with Hono by `apply_route_specs`, and
|
|
40
|
-
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.
|
|
41
42
|
|
|
42
43
|
### `RouteSpec` fields
|
|
43
44
|
|
|
44
45
|
- `method` — `'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH'`
|
|
45
46
|
- `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`).
|
|
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`).
|
|
47
48
|
- `handler: RouteHandler` — `(c: Context, route: RouteContext) => Response | Promise<Response>`
|
|
48
49
|
- `description` — free-text, surfaced in `AppSurface`
|
|
49
50
|
- `params?: z.ZodObject` — strict-object schema for URL path params
|
|
@@ -70,25 +71,15 @@ interface RouteContext {
|
|
|
70
71
|
}
|
|
71
72
|
```
|
|
72
73
|
|
|
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`.
|
|
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`.
|
|
86
77
|
|
|
87
78
|
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.
|
|
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.
|
|
92
83
|
|
|
93
84
|
### Declarative transactions
|
|
94
85
|
|
|
@@ -105,74 +96,30 @@ wrapper). See `auth/signup_routes.ts`.
|
|
|
105
96
|
|
|
106
97
|
`apply_route_specs` assembles the following middleware chain per spec:
|
|
107
98
|
|
|
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`
|
|
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`
|
|
147
106
|
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.
|
|
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.
|
|
168
115
|
|
|
169
116
|
Duplicate `method path` pairs throw at registration.
|
|
170
117
|
|
|
171
118
|
Validated values are accessed via `get_route_input(c, schema)`,
|
|
172
119
|
`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
|
-
|
|
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.
|
|
176
123
|
|
|
177
124
|
### DEV-only output + error validation
|
|
178
125
|
|
|
@@ -182,35 +129,26 @@ are the contract with external callers.
|
|
|
182
129
|
**Output and error schemas are validated DEV-only** via `DEV` from
|
|
183
130
|
`esm-env`. `wrap_output_validation`:
|
|
184
131
|
|
|
185
|
-
- Skips streaming responses (non-`application/json` Content-Type) so SSE
|
|
186
|
-
doesn't hang on `.json()`
|
|
132
|
+
- Skips streaming responses (non-`application/json` Content-Type) so SSE doesn't hang on `.json()`
|
|
187
133
|
- 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
|
|
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
|
|
192
136
|
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
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.
|
|
197
140
|
|
|
198
141
|
### Helpers
|
|
199
142
|
|
|
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
|
|
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
|
|
204
145
|
|
|
205
146
|
## Error Schemas
|
|
206
147
|
|
|
207
|
-
`error_schemas.ts` is the **declarative** error surface:
|
|
148
|
+
`http/error_schemas.ts` is the **declarative** error surface:
|
|
208
149
|
|
|
209
|
-
- `ERROR_*` `snake_case` string constants — single source of truth; use
|
|
210
|
-
|
|
211
|
-
- `ApiError`, `ValidationError`, `PermissionError`,
|
|
212
|
-
`CredentialTypeRequiredError`, `RateLimitError`, `PayloadTooLargeError`,
|
|
213
|
-
`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
|
|
214
152
|
- `RouteErrorSchemas = Partial<Record<number, z.ZodType>>`
|
|
215
153
|
- `RateLimitKey = 'ip' | 'account' | 'both'`
|
|
216
154
|
|
|
@@ -220,39 +158,28 @@ same status code. The `error` string literal is the contract; extra keys
|
|
|
220
158
|
(`required_roles`, `required_credential_types`, `retry_after`, `detail`)
|
|
221
159
|
are diagnostic.
|
|
222
160
|
|
|
223
|
-
Pair every schema with the `z.infer` type export
|
|
161
|
+
Pair every schema with the `z.infer` type export.
|
|
224
162
|
|
|
225
163
|
### Three-layer error-schema merge
|
|
226
164
|
|
|
227
|
-
`merge_error_schemas(spec, middleware_errors?)` (in `schema_helpers.ts`)
|
|
165
|
+
`merge_error_schemas(spec, middleware_errors?)` (in `http/schema_helpers.ts`)
|
|
228
166
|
merges three layers, later overrides earlier at the same status code:
|
|
229
167
|
|
|
230
168
|
1. **Derived** — from `derive_error_schemas({auth, has_input?, has_params?, has_query?, rate_limit?})`:
|
|
231
169
|
- `has_input || has_params || has_query` → 400 `ValidationError`
|
|
232
170
|
- `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])`)
|
|
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])`)
|
|
239
173
|
- `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`)
|
|
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`)
|
|
248
176
|
3. **Explicit** — `RouteSpec.errors` — always wins
|
|
249
177
|
|
|
250
178
|
Routes typically only need `errors` for handler-specific codes (404, 409, 422).
|
|
251
179
|
|
|
252
|
-
Actor-failure folding reads `spec.auth.actor !== 'none'` directly —
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
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.
|
|
256
183
|
|
|
257
184
|
**Framework-emitted vs consumer-authored.** The error-schema derivation
|
|
258
185
|
above is sound because the framework authors the errors at fixed
|
|
@@ -262,104 +189,71 @@ middleware sites — 401 from `require_auth`, 400 from
|
|
|
262
189
|
documents the framework's own emissions; consumers tighten via
|
|
263
190
|
`RouteSpec.errors` when their handler narrows the surface.
|
|
264
191
|
|
|
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
|
|
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
|
|
268
195
|
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
|
-
|
|
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.
|
|
276
203
|
|
|
277
204
|
### `ERROR_*` constants by category
|
|
278
205
|
|
|
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`
|
|
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`
|
|
298
214
|
|
|
299
215
|
## Schema Helpers
|
|
300
216
|
|
|
301
|
-
`schema_helpers.ts` is the canonical home for shared Zod introspection
|
|
302
|
-
extracted to break a circular dependency between `route_spec.ts`
|
|
303
|
-
them for input validation) and `surface.ts` (uses them for
|
|
304
|
-
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).
|
|
305
221
|
|
|
306
222
|
**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.
|
|
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.
|
|
310
226
|
|
|
311
227
|
Key helpers:
|
|
312
228
|
|
|
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.
|
|
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
|
|
325
234
|
|
|
326
235
|
## Surface Generation
|
|
327
236
|
|
|
328
|
-
`surface.ts` produces a JSON-serializable attack surface from
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
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.
|
|
333
241
|
|
|
334
242
|
### Types
|
|
335
243
|
|
|
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
|
|
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
|
|
344
247
|
- `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`
|
|
248
|
+
- `RpcEndpointSpec` — `{path, actions: Array<RpcAction>}`; fed into `generate_app_surface` so RPC endpoints appear in the surface without coupling to `create_rpc_endpoint`
|
|
348
249
|
- `GenerateAppSurfaceOptions` — `{route_specs, middleware_specs, env_schema?, event_specs?, rpc_endpoints?}`
|
|
349
250
|
|
|
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.
|
|
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).
|
|
363
257
|
|
|
364
258
|
`create_app_surface_spec(options)` = `generate_app_surface(options)` plus
|
|
365
259
|
the source specs, for tests that need to iterate over raw specs.
|
|
@@ -370,30 +264,25 @@ No side effects, no state — filters and groupings over `AppSurface`:
|
|
|
370
264
|
|
|
371
265
|
- `filter_protected_routes` / `filter_public_routes`
|
|
372
266
|
- `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.
|
|
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
|
|
377
269
|
- `format_route_key(route)` → `'METHOD /path'`
|
|
378
|
-
- `surface_auth_summary(surface)` — counts per auth type, roles broken
|
|
379
|
-
out by name
|
|
270
|
+
- `surface_auth_summary(surface)` — counts per auth type, roles broken out by name
|
|
380
271
|
|
|
381
272
|
The per-route auth predicates these filters compose over (`is_public_auth`,
|
|
382
273
|
`is_role_auth`, `is_credential_gated_auth`, `is_keeper_auth`,
|
|
383
|
-
`is_plain_authenticated_auth`, plus `needs_actor` / `needs_account`)
|
|
384
|
-
|
|
385
|
-
|
|
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
|
|
386
277
|
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.
|
|
278
|
+
`derive_error_schemas`'s actor-failure folding, and the testing harnesses.
|
|
390
279
|
|
|
391
280
|
Consumer code (tests, attack-surface helpers, `SurfaceExplorer.svelte`)
|
|
392
281
|
should reach for these rather than inlining `.filter` chains.
|
|
393
282
|
|
|
394
283
|
## Middleware Infrastructure
|
|
395
284
|
|
|
396
|
-
|
|
285
|
+
`MiddlewareSpec` (in `http/middleware_spec.ts`):
|
|
397
286
|
|
|
398
287
|
```typescript
|
|
399
288
|
interface MiddlewareSpec {
|
|
@@ -404,154 +293,74 @@ interface MiddlewareSpec {
|
|
|
404
293
|
}
|
|
405
294
|
```
|
|
406
295
|
|
|
407
|
-
Declared
|
|
408
|
-
|
|
296
|
+
Declared separately from `http/route_spec.ts` so middleware modules don't
|
|
297
|
+
pull in route types.
|
|
409
298
|
|
|
410
|
-
### Trusted proxy — `proxy.ts`
|
|
299
|
+
### Trusted proxy — `http/proxy.ts`
|
|
411
300
|
|
|
412
301
|
Resolves the real client IP from `X-Forwarded-For` only when the TCP
|
|
413
302
|
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:
|
|
303
|
+
`get_client_ip(c)` returns `'unknown'`. Must run **before** auth and
|
|
304
|
+
rate-limiting middleware (see root ../../CLAUDE.md §Middleware Ordering).
|
|
495
305
|
|
|
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:*`
|
|
306
|
+
Per-symbol semantics on TSDoc; the cross-cutting properties:
|
|
503
307
|
|
|
504
|
-
|
|
505
|
-
`
|
|
506
|
-
(
|
|
507
|
-
(
|
|
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.
|
|
508
312
|
|
|
509
|
-
|
|
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.
|
|
510
317
|
|
|
511
|
-
|
|
318
|
+
### Origin allowlist — `http/origin.ts`
|
|
512
319
|
|
|
513
|
-
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
- `jsonrpc_helpers.ts` — **plumbing**: message builders, type guards, converters
|
|
517
|
-
|
|
518
|
-
Follows the JSON-RPC 2.0 spec and is an **MCP superset** — params and
|
|
519
|
-
result are always object-only (no positional arrays), `_meta` and
|
|
520
|
-
`progressToken` are first-class. The schemas are sourced from the MCP
|
|
521
|
-
TypeScript SDK for compatibility.
|
|
522
|
-
|
|
523
|
-
### `jsonrpc.ts` — envelope + code schemas
|
|
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`).
|
|
524
323
|
|
|
525
|
-
|
|
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)
|
|
526
327
|
|
|
527
|
-
-
|
|
528
|
-
|
|
529
|
-
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
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.
|
|
534
355
|
|
|
535
356
|
`_meta` is intentionally **not** envelope-validated — that lives in
|
|
536
357
|
per-action schemas so mismatches surface as `invalid_params` rather than
|
|
537
358
|
`invalid_request`.
|
|
538
359
|
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
- Standard constants: `JSONRPC_PARSE_ERROR` (-32700), `JSONRPC_INVALID_REQUEST`
|
|
542
|
-
(-32600), `JSONRPC_METHOD_NOT_FOUND` (-32601), `JSONRPC_INVALID_PARAMS`
|
|
543
|
-
(-32602), `JSONRPC_INTERNAL_ERROR` (-32603)
|
|
544
|
-
- Server-defined range: `JSONRPC_SERVER_ERROR_START = -32000`,
|
|
545
|
-
`JSONRPC_SERVER_ERROR_END = -32099`; `JsonrpcServerErrorCode` is a
|
|
546
|
-
branded Zod number in that range
|
|
547
|
-
- `JsonrpcErrorCode` — union of the 5 literals + `JsonrpcServerErrorCode`
|
|
548
|
-
- `JsonrpcErrorObject` — `{code, message, data?}`
|
|
360
|
+
### 15-code error taxonomy
|
|
549
361
|
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
Runtime complement to `error_schemas.ts`. Five standard codes + ten
|
|
553
|
-
general application codes (consumers add their own by casting
|
|
554
|
-
`as JsonrpcErrorCode`):
|
|
362
|
+
Five standard codes + ten general application codes (consumers add their
|
|
363
|
+
own by casting `as JsonrpcErrorCode`):
|
|
555
364
|
|
|
556
365
|
| Name | Code | HTTP | Use |
|
|
557
366
|
| --------------------- | ------ | ---- | ------------------------------------------------------ |
|
|
@@ -571,59 +380,30 @@ general application codes (consumers add their own by casting
|
|
|
571
380
|
| `queue_overflow` | -32009 | 429 | **Client-side** backpressure (WS reconnect queue full) |
|
|
572
381
|
| `request_cancelled` | -32010 | 499 | Caller-initiated cancellation (nginx "client closed") |
|
|
573
382
|
|
|
574
|
-
|
|
383
|
+
**`invalid_params` vs `validation_error`** — use `invalid_params` (standard
|
|
575
384
|
code) for Zod parse failures; reserve `validation_error` (app code) for
|
|
576
|
-
business rules.
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
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.
|
|
580
391
|
|
|
581
|
-
|
|
392
|
+
### API map
|
|
582
393
|
|
|
583
|
-
- `JSONRPC_ERROR_CODES` — `Record<JsonrpcErrorName, JsonrpcErrorCode>`
|
|
584
|
-
with the 15 entries above
|
|
394
|
+
- `JSONRPC_ERROR_CODES` — `Record<JsonrpcErrorName, JsonrpcErrorCode>` (frozen)
|
|
585
395
|
- `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()`
|
|
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()`
|
|
589
397
|
- `ThrownJsonrpcError` — `Error` subclass carrying `code` + optional `data`
|
|
590
|
-
- `JSONRPC_ERROR_CODE_TO_HTTP_STATUS` / `HTTP_STATUS_TO_JSONRPC_ERROR_CODE`
|
|
591
|
-
|
|
592
|
-
|
|
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}`)
|
|
593
403
|
|
|
594
404
|
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}`
|
|
405
|
+
layer converts to `{error: JsonrpcErrorObject}` at the correct HTTP status.
|
|
406
|
+
Generic `Error` maps to 500 `internal_error` (message in DEV only).
|
|
627
407
|
|
|
628
408
|
## Pending Effects
|
|
629
409
|
|
|
@@ -640,40 +420,27 @@ interface EmitAfterCommitContext {
|
|
|
640
420
|
// post_commit_effects: Array<() => void | Promise<void>>
|
|
641
421
|
```
|
|
642
422
|
|
|
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)`.
|
|
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)`.
|
|
657
425
|
|
|
658
426
|
### Why split
|
|
659
427
|
|
|
660
428
|
Both shapes used to coexist on a single `Array<PendingEffect>` discriminated
|
|
661
|
-
union. The shapes encode different contracts — eager pushers say "wait
|
|
662
|
-
|
|
663
|
-
|
|
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
|
|
664
432
|
`c.var.pending_effects.push(x)` ambiguous at the call site. Splitting
|
|
665
433
|
turns the field name into the contract.
|
|
666
434
|
|
|
667
435
|
### Why `emit_after_commit` defers
|
|
668
436
|
|
|
669
437
|
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.
|
|
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.
|
|
677
444
|
|
|
678
445
|
```typescript
|
|
679
446
|
emit_after_commit(ctx, () => notification_sender.send_to_account(account_id, msg));
|
|
@@ -685,29 +452,10 @@ and any side effect that must run only after the transaction commits.
|
|
|
685
452
|
|
|
686
453
|
### Key properties
|
|
687
454
|
|
|
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/`.
|
|
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/`.
|
|
711
459
|
|
|
712
460
|
WS sends are **not** wrapped by `create_validated_broadcaster` (that only
|
|
713
461
|
guards SSE `broadcast(channel, data)`). Zod input schemas on
|
|
@@ -716,40 +464,31 @@ at send time.
|
|
|
716
464
|
|
|
717
465
|
## Common Routes
|
|
718
466
|
|
|
719
|
-
`common_routes.ts` exposes three generic route-spec factories with no
|
|
467
|
+
`http/common_routes.ts` exposes three generic route-spec factories with no
|
|
720
468
|
auth-domain dependencies:
|
|
721
469
|
|
|
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)
|
|
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)
|
|
729
473
|
|
|
730
|
-
Auth-aware variants (account status, bootstrap status) live in
|
|
731
|
-
`
|
|
474
|
+
Auth-aware variants (account status, bootstrap status) live in `auth/` —
|
|
475
|
+
`http/common_routes.ts` stays generic.
|
|
732
476
|
|
|
733
477
|
## DB Routes (Generic Browser)
|
|
734
478
|
|
|
735
|
-
`db_routes.ts` creates keeper-only route specs for administering the
|
|
736
|
-
`public` schema via `information_schema`. Wired by consumers that want
|
|
737
|
-
|
|
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.
|
|
738
482
|
|
|
739
483
|
`create_db_route_specs({db_type, db_name, extra_stats?, log?})`:
|
|
740
484
|
|
|
741
|
-
- `GET /health` — connected probe + table count + optional `extra_stats(db)`.
|
|
742
|
-
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
|
|
743
486
|
- `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
|
|
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
|
|
753
492
|
`assert_valid_sql_identifier()` before string-interpolating into SQL —
|
|
754
493
|
the identifier validation is the only reason the interpolation is safe.
|
|
755
494
|
|
|
@@ -758,20 +497,7 @@ Interfaces exported for consumer use: `TableInfo`, `TableWithCount`,
|
|
|
758
497
|
|
|
759
498
|
## Cross-Module Notes
|
|
760
499
|
|
|
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
|
|
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
|