@fuzdev/fuz_app 0.64.0 → 0.66.0

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