@fuzdev/fuz_app 0.63.0 → 0.65.0

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