@fuzdev/fuz_app 0.64.0 → 0.65.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/actions/CLAUDE.md +513 -928
- package/dist/actions/broadcast_api.d.ts +1 -1
- package/dist/actions/broadcast_api.js +1 -1
- package/dist/actions/cancel.d.ts +2 -2
- package/dist/actions/cancel.js +3 -3
- package/dist/actions/connection_closer.d.ts +1 -4
- package/dist/actions/connection_closer.d.ts.map +1 -1
- package/dist/actions/connection_closer.js +1 -4
- package/dist/actions/register_action_ws.d.ts +2 -2
- package/dist/actions/register_ws_endpoint.d.ts +1 -1
- package/dist/actions/transports_ws_auth_guard.d.ts +1 -2
- package/dist/actions/transports_ws_auth_guard.d.ts.map +1 -1
- package/dist/actions/transports_ws_auth_guard.js +1 -2
- package/dist/auth/CLAUDE.md +591 -1871
- package/dist/auth/account_schema.d.ts +1 -1
- package/dist/auth/account_schema.d.ts.map +1 -1
- package/dist/auth/api_token_queries.js +1 -1
- package/dist/auth/audit_log_ddl.d.ts +1 -1
- package/dist/auth/audit_log_ddl.d.ts.map +1 -1
- package/dist/auth/audit_log_ddl.js +1 -1
- package/dist/auth/bootstrap_account.d.ts.map +1 -1
- package/dist/auth/bootstrap_account.js +1 -5
- package/dist/auth/bootstrap_routes.d.ts +7 -1
- package/dist/auth/bootstrap_routes.d.ts.map +1 -1
- package/dist/auth/bootstrap_routes.js +15 -11
- package/dist/auth/keyring.d.ts +6 -6
- package/dist/auth/keyring.js +8 -8
- package/dist/auth/role_grant_offer_actions.d.ts.map +1 -1
- package/dist/auth/role_grant_offer_actions.js +4 -2
- package/dist/db/create_db.d.ts.map +1 -1
- package/dist/db/create_db.js +13 -0
- package/dist/dev/setup.d.ts +2 -2
- package/dist/dev/setup.js +3 -3
- package/dist/http/CLAUDE.md +224 -498
- package/dist/http/error_schemas.d.ts +0 -4
- package/dist/http/error_schemas.d.ts.map +1 -1
- package/dist/http/error_schemas.js +0 -4
- package/dist/http/ip_canonical.d.ts +5 -4
- package/dist/http/ip_canonical.d.ts.map +1 -1
- package/dist/http/ip_canonical.js +8 -4
- package/dist/http/origin.d.ts +1 -1
- package/dist/http/origin.js +1 -1
- package/dist/runtime/mock.js +1 -1
- package/dist/server/app_server.d.ts +41 -10
- package/dist/server/app_server.d.ts.map +1 -1
- package/dist/server/app_server.js +10 -4
- package/dist/server/env.d.ts +7 -7
- package/dist/server/env.d.ts.map +1 -1
- package/dist/server/env.js +14 -14
- package/dist/server/static.d.ts +4 -4
- package/dist/server/static.js +7 -7
- package/dist/testing/CLAUDE.md +220 -46
- package/dist/testing/admin_integration.d.ts +18 -23
- package/dist/testing/admin_integration.d.ts.map +1 -1
- package/dist/testing/admin_integration.js +159 -201
- package/dist/testing/app_server.d.ts +125 -38
- package/dist/testing/app_server.d.ts.map +1 -1
- package/dist/testing/app_server.js +140 -42
- package/dist/testing/audit_completeness.d.ts +23 -22
- package/dist/testing/audit_completeness.d.ts.map +1 -1
- package/dist/testing/audit_completeness.js +199 -156
- package/dist/testing/bootstrap_success.d.ts +28 -0
- package/dist/testing/bootstrap_success.d.ts.map +1 -0
- package/dist/testing/bootstrap_success.js +144 -0
- package/dist/testing/cross_backend/capabilities.d.ts +64 -0
- package/dist/testing/cross_backend/capabilities.d.ts.map +1 -0
- package/dist/testing/cross_backend/capabilities.js +47 -0
- package/dist/testing/cross_backend/setup.d.ts +215 -0
- package/dist/testing/cross_backend/setup.d.ts.map +1 -0
- package/dist/testing/cross_backend/setup.js +101 -0
- package/dist/testing/data_exposure.d.ts +14 -15
- package/dist/testing/data_exposure.d.ts.map +1 -1
- package/dist/testing/data_exposure.js +127 -146
- package/dist/testing/db_entities.d.ts +11 -1
- package/dist/testing/db_entities.d.ts.map +1 -1
- package/dist/testing/db_entities.js +13 -1
- package/dist/testing/integration.d.ts +35 -21
- package/dist/testing/integration.d.ts.map +1 -1
- package/dist/testing/integration.js +231 -291
- package/dist/testing/integration_helpers.d.ts +16 -6
- package/dist/testing/integration_helpers.d.ts.map +1 -1
- package/dist/testing/integration_helpers.js +7 -7
- package/dist/testing/mock_fs.d.ts.map +1 -1
- package/dist/testing/mock_fs.js +0 -2
- package/dist/testing/rate_limiting.d.ts.map +1 -1
- package/dist/testing/rate_limiting.js +9 -0
- package/dist/testing/role_grant_helpers.d.ts +31 -0
- package/dist/testing/role_grant_helpers.d.ts.map +1 -0
- package/dist/testing/role_grant_helpers.js +46 -0
- package/dist/testing/round_trip.d.ts +21 -16
- package/dist/testing/round_trip.d.ts.map +1 -1
- package/dist/testing/round_trip.js +65 -86
- package/dist/testing/rpc_round_trip.d.ts +24 -21
- package/dist/testing/rpc_round_trip.d.ts.map +1 -1
- package/dist/testing/rpc_round_trip.js +91 -104
- package/dist/testing/schema_introspect.d.ts +106 -0
- package/dist/testing/schema_introspect.d.ts.map +1 -0
- package/dist/testing/schema_introspect.js +123 -0
- package/dist/testing/schema_parity.d.ts +144 -0
- package/dist/testing/schema_parity.d.ts.map +1 -0
- package/dist/testing/schema_parity.js +233 -0
- package/dist/testing/standard.d.ts +57 -25
- package/dist/testing/standard.d.ts.map +1 -1
- package/dist/testing/standard.js +62 -5
- package/dist/testing/stubs.d.ts +11 -3
- package/dist/testing/stubs.d.ts.map +1 -1
- package/dist/testing/stubs.js +24 -21
- package/dist/testing/transports/surface_source.d.ts +51 -0
- package/dist/testing/transports/surface_source.d.ts.map +1 -0
- package/dist/testing/transports/surface_source.js +19 -0
- package/package.json +4 -4
package/dist/actions/CLAUDE.md
CHANGED
|
@@ -1,31 +1,27 @@
|
|
|
1
1
|
# actions/ — SAES (Symmetric Action Event System)
|
|
2
2
|
|
|
3
|
-
One declarative `ActionSpec`
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
../../
|
|
13
|
-
§
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
Skill(fuz-stack)
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
low-cost for future filtering. Bridge, RPC endpoint, and per-derivation
|
|
26
|
-
codegen helpers are post-SAES-RPC-closeout stable.
|
|
27
|
-
|
|
28
|
-
## Action specs (`action_spec.ts`)
|
|
3
|
+
> One declarative `ActionSpec` binds to three transport surfaces (REST,
|
|
4
|
+
> JSON-RPC over HTTP, WebSocket) with uniform DEV-only output validation and
|
|
5
|
+
> symmetric send/receive.
|
|
6
|
+
|
|
7
|
+
For consumer wiring (client-authoritative vs server-authoritative dispatch,
|
|
8
|
+
role-grant-offer UI integration), see ../../docs/usage.md §Deriving
|
|
9
|
+
Route/Event Specs, §Single JSON-RPC 2.0 Endpoint, §WebSocket Endpoint. For
|
|
10
|
+
DEV-only output validation semantics see ../../docs/architecture.md
|
|
11
|
+
§DEV-only Output Validation. For the SAES binding matrix and middleware
|
|
12
|
+
ordering see the root ../../CLAUDE.md §Action Spec System (SAES) and
|
|
13
|
+
§Middleware Ordering.
|
|
14
|
+
|
|
15
|
+
**CLAUDE.md is a map; TSDoc is the detail.** Per-symbol semantics
|
|
16
|
+
(parameters, options, lifecycle methods, narrowing rules) live on TSDoc next
|
|
17
|
+
to the code. This file documents the cross-cutting invariants and the
|
|
18
|
+
shapes that span multiple files.
|
|
19
|
+
|
|
20
|
+
Every exported Zod schema is paired with a same-named `z.infer` type export
|
|
21
|
+
— ecosystem-wide rule (Skill(fuz-stack) §Zod schemas). New schemas keep
|
|
22
|
+
the pair invariant.
|
|
23
|
+
|
|
24
|
+
## Action specs (`actions/action_spec.ts`)
|
|
29
25
|
|
|
30
26
|
Canonical source of truth. Three concrete kinds discriminate on `kind`:
|
|
31
27
|
|
|
@@ -35,64 +31,38 @@ Canonical source of truth. Three concrete kinds discriminate on `kind`:
|
|
|
35
31
|
| `remote_notification` | `null` | `true` | `z.ZodVoid` | `true` |
|
|
36
32
|
| `local_call` | `null` | arbitrary | arbitrary | boolean |
|
|
37
33
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
Optional
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
`
|
|
60
|
-
`
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
the dispatcher's per-action rate-limit hook. Same hook fires on the HTTP
|
|
66
|
-
RPC dispatcher (`create_rpc_endpoint`) and the WebSocket dispatcher
|
|
67
|
-
(`register_action_ws`) — one budget per action, not per transport.
|
|
68
|
-
`'ip'` keys on the resolved client IP; `'account'` keys on
|
|
69
|
-
`request_context.account.id` (post-auth, account-grain — every
|
|
70
|
-
authenticated action has an account regardless of whether an actor was
|
|
71
|
-
resolved) and is rejected at registration when paired with
|
|
72
|
-
`auth.account !== 'required'` (no account to key on); `'both'` runs
|
|
73
|
-
both checks. **Throttle-requests semantics** — every invocation records,
|
|
74
|
-
regardless of outcome (different from REST login's throttle-failures
|
|
75
|
-
that resets on success). The originally motivating threat is admin
|
|
76
|
-
mutation oracles (`invite_create` account-existence probe) where the
|
|
77
|
-
_successful_ invocation is the threat; the same shape extends to
|
|
78
|
-
authed-spam oracles (`role_grant_offer_create` iterating
|
|
79
|
-
`to_account_id` to probe `ERROR_ACCOUNT_NOT_FOUND`) and to paginated
|
|
80
|
-
cross-account reads (`admin_account_list`, `audit_log_list`,
|
|
81
|
-
`audit_log_role_grant_history`) where every successful page is an
|
|
82
|
-
enumeration step. Limiters are configured at server-assembly
|
|
83
|
-
time via `AppServerOptions.action_ip_rate_limiter` /
|
|
84
|
-
`action_account_rate_limiter` and threaded into both dispatchers
|
|
85
|
-
automatically; consumers wiring `register_action_ws` directly forward
|
|
86
|
-
the same limiters from `AppServerContext`.
|
|
87
|
-
|
|
88
|
-
Canonical spec shape: module-scope declaration with `satisfies` +
|
|
34
|
+
`RouteAuth` is the flat record `{account, actor, roles?, credential_types?}`
|
|
35
|
+
from `http/auth_shape.ts` — same shape governs `RouteSpec.auth` so the four
|
|
36
|
+
axes drive one auth surface across REST and SAES. Cross-axis invariants:
|
|
37
|
+
roles imply `actor: 'required'`; `account: 'none'` implies `actor: 'none'`
|
|
38
|
+
(no accountless actors in v1); the unrestricted leaf
|
|
39
|
+
(`account: 'none', actor: 'none'`) cannot declare roles or credential
|
|
40
|
+
gates. The biconditional `actor !== 'none' ⟺ input declares acting?: ActingActor`
|
|
41
|
+
is enforced at registration time via `assert_route_auth_acting_biconditional`.
|
|
42
|
+
|
|
43
|
+
Optional fields:
|
|
44
|
+
|
|
45
|
+
- `streams?: string` — names a companion `remote_notification` method
|
|
46
|
+
emitted as request-scoped progress.
|
|
47
|
+
- `error_reasons?: ReadonlyArray<string>` — reason codes the handler may
|
|
48
|
+
surface via `error.data.reason`. Declarative metadata for consumers
|
|
49
|
+
(codegen, UI form-state matching, docs); no runtime enforcement, drift
|
|
50
|
+
caught per-module by source-scanning unit tests (e.g.
|
|
51
|
+
../../test/auth/role_grant_offer_actions.error_reasons.test.ts).
|
|
52
|
+
- `rate_limit?: 'ip' | 'account' | 'both'` — opts the action into the
|
|
53
|
+
dispatcher's per-action rate-limit hook. **Throttle-requests semantics**
|
|
54
|
+
— every invocation records regardless of outcome (different from REST
|
|
55
|
+
login's throttle-failures). `'account'` rejected at registration when
|
|
56
|
+
paired with `auth.account !== 'required'`. Limiters configured via
|
|
57
|
+
`AppServerOptions.action_ip_rate_limiter` / `action_account_rate_limiter`
|
|
58
|
+
and threaded into both dispatchers automatically.
|
|
59
|
+
|
|
60
|
+
Canonical spec shape: module-scope `satisfies` declaration with
|
|
89
61
|
`{method}_action_spec` naming, preserving the literal `method` type and
|
|
90
|
-
dropping per-spec `*_METHOD` constants (readers dereference `.method`
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
## Kind → binding constraints
|
|
62
|
+
dropping per-spec `*_METHOD` constants (readers dereference `.method`). See
|
|
63
|
+
../../docs/usage.md §Canonical action-spec shape.
|
|
94
64
|
|
|
95
|
-
|
|
65
|
+
## Kind → binding matrix
|
|
96
66
|
|
|
97
67
|
| Kind | REST `RouteSpec` | RPC `RouteSpec` (via dispatcher) | WS dispatch | SSE `EventSpec` |
|
|
98
68
|
| --------------------- | ---------------- | -------------------------------- | ----------- | --------------- |
|
|
@@ -100,160 +70,125 @@ The three action kinds map to bindings with hard constraints:
|
|
|
100
70
|
| `remote_notification` | no | no | server push | yes (bridge) |
|
|
101
71
|
| `local_call` | no | no | no | no |
|
|
102
72
|
|
|
103
|
-
`create_action_route_spec` throws if `spec.auth` is null
|
|
104
|
-
|
|
105
|
-
|
|
73
|
+
`create_action_route_spec` throws if `spec.auth` is null (notifications and
|
|
74
|
+
local calls cannot become routes). `create_action_event_spec` throws on any
|
|
75
|
+
non-`remote_notification` kind.
|
|
76
|
+
|
|
77
|
+
## Registry compile (`actions/compile_action_registry.ts`)
|
|
78
|
+
|
|
79
|
+
`compile_action_registry` is the shared registration loop called by both
|
|
80
|
+
`create_rpc_endpoint` and `register_action_ws`. Validates four
|
|
81
|
+
registry-time invariants and returns the `Map<method, RpcAction>` the
|
|
82
|
+
dispatchers use:
|
|
83
|
+
|
|
84
|
+
1. **Auth-shape biconditional** — `actor !== 'none' ⟺ input declares acting?: ActingActor` (via `assert_route_auth_acting_biconditional`).
|
|
85
|
+
2. **Rate-limit account axis** — `rate_limit: 'account' | 'both'` requires `auth.account === 'required'`.
|
|
86
|
+
3. **JSON-RPC §4.2 wire validity** — `request_response` specs with a handler may not use `z.null()` for input (use `z.void()` for nullary).
|
|
87
|
+
4. **Unique method names** across the array.
|
|
88
|
+
|
|
89
|
+
Only `request_response` specs with a handler reach the dispatch map;
|
|
90
|
+
`remote_notification` / handler-less specs (e.g. WS `cancel`) stay
|
|
91
|
+
registry-only.
|
|
106
92
|
|
|
107
|
-
## Registry + codegen (`action_registry.ts`, `action_codegen.ts`)
|
|
93
|
+
## Registry + codegen (`actions/action_registry.ts`, `actions/action_codegen.ts`)
|
|
108
94
|
|
|
109
95
|
**Symmetric design — universal calling abstraction.** SAES is one spec
|
|
110
96
|
shape that drives dispatch across (a) network boundaries (frontend ⇄
|
|
111
97
|
backend over HTTP / WS) and (b) within the same runtime (`local_call`
|
|
112
98
|
actions). `ActionPeer` is symmetric on both sides (`send` + `receive`).
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
`Promise<Result<{value}, {error}>>` shape `FrontendActionsApi` methods
|
|
121
|
-
return. Closing those gaps is on the deferred follow-up set in the
|
|
99
|
+
Typed surfaces are paired: `FrontendActionsApi` is "what the frontend can
|
|
100
|
+
call" (typed Proxy from `create_rpc_client`); `BackendActionsApi` is "what
|
|
101
|
+
the backend can call" (typed object from `create_broadcast_api` today;
|
|
102
|
+
broader runtime constructors will join). Remaining asymmetry today:
|
|
103
|
+
`create_broadcast_api` returns `Promise<void>` while `FrontendActionsApi`
|
|
104
|
+
methods return `Promise<Result<...>>`. Closing those gaps is on the
|
|
105
|
+
deferred follow-up set in
|
|
122
106
|
[SAES RPC closeout](https://github.com/ryanatkn/grimoire/blob/main/quests/HISTORY.md#saes-rpc-direction-2026-04)
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
`
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
list + banner + imports into the final file body in one call.
|
|
177
|
-
|
|
178
|
-
**Protocol actions are filtered by default.** Every spec-iterating helper
|
|
107
|
+
— wait for a second backend runtime case.
|
|
108
|
+
|
|
109
|
+
### `ActionRegistry`
|
|
110
|
+
|
|
111
|
+
Query/filter wrapper over `ActionSpecUnion[]`. Codegen-relevant getter
|
|
112
|
+
groups (each pairs `_specs` with matching `_methods`):
|
|
113
|
+
|
|
114
|
+
| Getter family | Filter | Drives |
|
|
115
|
+
| --------------------- | ------------------------------------------------------------------- | ----------------------------- |
|
|
116
|
+
| Kind-narrow | by `kind` | `*ActionMethod` enums |
|
|
117
|
+
| `*_handled` | `request_response` + handler-side initiator | `BackendActionHandlers` map |
|
|
118
|
+
| `specs_relevant_to_*` | everything the side might encounter | typed-Proxy method enums |
|
|
119
|
+
| `broadcast` | `remote_notification`, `initiator !== 'frontend'`, excludes streams | `BackendActionsApi` interface |
|
|
120
|
+
| `backend_initiated` | forward-looking kind-agnostic broadcast | same content today |
|
|
121
|
+
|
|
122
|
+
Other getters (auth filters, initiator-direction filters) are pre-built API
|
|
123
|
+
surface unused by codegen today.
|
|
124
|
+
|
|
125
|
+
### Codegen helpers (`actions/action_codegen.ts`)
|
|
126
|
+
|
|
127
|
+
Used by consumer `*.gen.ts` producers, not the runtime. Detailed signatures
|
|
128
|
+
|
|
129
|
+
- options on each function's TSDoc.
|
|
130
|
+
|
|
131
|
+
| Helper | Output |
|
|
132
|
+
| ---------------------------------------------- | ------------------------------------------------------------------------ |
|
|
133
|
+
| `ImportBuilder` | Class managing value/type/namespace imports; auto-tree-shakes type-only |
|
|
134
|
+
| `get_executor_phases(spec, executor)` | Phases an executor participates in for the spec |
|
|
135
|
+
| `get_handler_return_type` | TS type a phase handler must return; side-effect imports `ActionOutputs` |
|
|
136
|
+
| `generate_phase_handlers` | Per-action typed handler-map fragment |
|
|
137
|
+
| `generate_actions_api_method_signature` | Single source of truth for the typed `FrontendActionsApi` method shape |
|
|
138
|
+
| `generate_action_method_enums` | Up to nine `z.enum` + `z.infer` pairs |
|
|
139
|
+
| `generate_action_method_enum_block` | Lower-level escape hatch for cross-product enums |
|
|
140
|
+
| `generate_typed_action_event_alias` | Fixed-shape `TypedActionEvent<TMethod, TPhase, TStep>` alias |
|
|
141
|
+
| `generate_action_specs_record` | `ActionSpecs` runtime const + interface + `action_specs` array |
|
|
142
|
+
| `generate_action_inputs_outputs` | `ActionInputs` + `ActionOutputs` runtime consts + interfaces |
|
|
143
|
+
| `generate_action_event_datas` | `ActionEventDatas` interface; per-spec variants |
|
|
144
|
+
| `generate_frontend_actions_api` | Typed `FrontendActionsApi` interface |
|
|
145
|
+
| `generate_frontend_action_handlers` | `FrontendActionHandlers` interface (Tier 2 only) |
|
|
146
|
+
| `generate_backend_actions_api` | `BackendActionsApi` interface + `broadcast_action_specs` array |
|
|
147
|
+
| `generate_backend_action_handlers_map` | `BackendActionHandlers` mapped type |
|
|
148
|
+
| `compose_gen_file` | Wrapper: banner + `imports.build()` + blocks join |
|
|
149
|
+
| `create_namespace_qualifier(sources, imports)` | Multi-source consumer helper; registers `import * as ns` per source |
|
|
150
|
+
|
|
151
|
+
Shared defaults: `DEFAULT_COLLECTIONS_PATH = './action_collections.js'`,
|
|
152
|
+
`DEFAULT_SPECS_MODULE = './action_specs.js'`,
|
|
153
|
+
`DEFAULT_METATYPES_PATH = './action_metatypes.js'`,
|
|
154
|
+
`resolve_spec_qualifier` (the default-vs-callback resolver every
|
|
155
|
+
multi-source-aware helper uses).
|
|
156
|
+
|
|
157
|
+
### Codegen invariants
|
|
158
|
+
|
|
159
|
+
**Protocol actions filtered by default.** Every spec-iterating helper
|
|
179
160
|
accepts `{include_protocol_actions?: boolean}` (default `false`) and drops
|
|
180
|
-
`heartbeat` / `cancel
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
`
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
`generate_backend_actions_api`). When `qualify_spec` is set, the helper
|
|
199
|
-
emits the callback's return value (e.g.
|
|
200
|
-
`admin_specs.account_list_action_spec`) and skips the default `* as specs`
|
|
201
|
-
import — the consumer (or the namespace-qualifier helper) owns the
|
|
202
|
-
multi-namespace imports. The helper appends `.input` / `.output` to the
|
|
203
|
-
qualified identifier in `generate_action_inputs_outputs` automatically;
|
|
204
|
-
the callback returns the bare spec identifier.
|
|
205
|
-
|
|
206
|
-
Tier 1 (HTTP-only, e.g. zap/visiones) emits a smaller surface — typically just
|
|
207
|
-
`ActionMethod` + `FrontendActionsApi` + `ActionInputs` / `ActionOutputs`
|
|
208
|
-
interfaces — and never calls `generate_typed_action_event_alias` or
|
|
209
|
-
`generate_frontend_action_handlers`. Tier 2 (`TypedActionEvent`-aware, e.g.
|
|
161
|
+
`heartbeat` / `cancel`. Protocol actions ship from fuz_app and are spread
|
|
162
|
+
into each consumer's `actions` array at registration time (via
|
|
163
|
+
`protocol_actions` from `actions/protocol.ts`); they should not appear in
|
|
164
|
+
consumer-owned typed surfaces. Pass `include_protocol_actions: true` only
|
|
165
|
+
if a consumer genuinely owns protocol actions in their typed API.
|
|
166
|
+
|
|
167
|
+
**Consumer tiers.** Single-source consumers (zzz) drop into the
|
|
168
|
+
helpers and accept the default `* as specs` namespace import. Multi-source
|
|
169
|
+
consumers (zap, visiones — stitching local specs with
|
|
170
|
+
`all_admin_action_specs` / `all_role_grant_offer_action_specs` /
|
|
171
|
+
`all_account_action_specs` / `all_self_service_role_action_specs` from
|
|
172
|
+
fuz_app) call `create_namespace_qualifier` once, then pass the returned
|
|
173
|
+
`qualify_spec` callback to multi-source helpers.
|
|
174
|
+
|
|
175
|
+
**Tier 1** (HTTP-only, zap/visiones) emits a smaller surface — typically
|
|
176
|
+
`ActionMethod` + `FrontendActionsApi` + `ActionInputs` / `ActionOutputs`.
|
|
177
|
+
Never calls `generate_typed_action_event_alias` or
|
|
178
|
+
`generate_frontend_action_handlers`. **Tier 2** (`TypedActionEvent`-aware,
|
|
210
179
|
zzz) emits the full set including `ActionEventDatas`, `TypedActionEvent`,
|
|
211
180
|
and `FrontendActionHandlers`.
|
|
212
181
|
|
|
213
|
-
|
|
214
|
-
- `generate_action_method_enum_block(specs, imports, {name, jsdoc, predicate, include_protocol_actions?})` — lower-level escape hatch for genuinely cross-product enums the discriminator doesn't cover. Caller owns the predicate, name, and jsdoc.
|
|
215
|
-
- `generate_typed_action_event_alias(imports, {collections_path?, metatypes_path?})` — fixed-shape `TypedActionEvent<TMethod, TPhase, TStep>` alias narrowing `ActionEvent.data` against `ActionEventDatas`. Adds the three fuz_app type imports + `ActionEventDatas` (from `collections_path`) + `ActionMethod` (from `metatypes_path`).
|
|
216
|
-
- `generate_action_specs_record(specs, imports, {specs_module?, qualify_spec?, include_protocol_actions?})` — `ActionSpecs` runtime const + interface + `action_specs: Array<ActionSpecUnion>` value. Adds `* as specs` from `specs_module` unless `qualify_spec` is set (then `specs_module` is ignored and the consumer owns namespace imports).
|
|
217
|
-
- `generate_action_inputs_outputs(specs, imports, {specs_module?, qualify_spec?, include_protocol_actions?})` — `ActionInputs` and `ActionOutputs` runtime consts + interfaces. Same `qualify_spec` semantics as `generate_action_specs_record`; the helper appends `.input` / `.output` to the qualified identifier.
|
|
218
|
-
- `generate_action_event_datas(specs, imports, {same_file?, collections_path?, include_protocol_actions?})` — `ActionEventDatas` interface; per-spec variant by kind (`ActionEventRequestResponseData` / `ActionEventRemoteNotificationData` / `ActionEventLocalCallData`). `same_file` (default `true`) is the file-layout switch: when `true`, assumes `ActionInputs` / `ActionOutputs` are in the same module and adds no import (the zzz pattern); when `false`, adds the type imports from `collections_path` (default `'./action_collections.js'`). `collections_path` alone is a no-op — the surprising omit-vs-default behavior of earlier versions has been replaced.
|
|
219
|
-
- `generate_frontend_actions_api(specs, imports, {interface_name?, method_filter?, collections_path?, sync_returns_value?, include_protocol_actions?})` — emits the typed `FrontendActionsApi` interface (configurable via `interface_name`, default `'FrontendActionsApi'`). One method signature per spec via `generate_actions_api_method_signature`. Protocol actions filtered by default; `method_filter: (spec) => boolean` runs after the protocol-action filter. Renamed from `generate_actions_api` in API review III to make the side-of-the-wire intent visible at every consumer site.
|
|
220
|
-
- `generate_frontend_action_handlers(specs, imports, {collections_path?, include_protocol_actions?})` — `FrontendActionHandlers` interface (Tier 2 only — wraps `generate_phase_handlers` with `action_event_type: 'TypedActionEvent'`). Pair with `generate_typed_action_event_alias`.
|
|
221
|
-
- `generate_backend_actions_api(specs, imports, {interface_name?, spec_array_name?, specs_module?, collections_path?, qualify_spec?, include_protocol_actions?})` — `BackendActionsApi` interface AND `broadcast_action_specs: ReadonlyArray<ActionSpecUnion>` array (both names configurable). Filter: `kind === 'remote_notification' && initiator !== 'frontend'`, with `streams`-target methods (request-scoped progress notifications invoked via `ctx.notify`) excluded — the discriminator is `ActionSpec.streams`, not a manual list. Adds `ActionInputs` (from `collections_path`) + `ActionSpecUnion`, plus `* as specs` from `specs_module` unless `qualify_spec` is set. Method shape today is `(input) => Promise<void>` (matches `create_broadcast_api`'s fire-and-forget runtime); generalizing to per-kind shapes via `generate_actions_api_method_signature` is deferred until a second backend runtime constructor lands (tracked in grimoire `lore/fuz_app/TODO.md` §Future Directions, _Symmetric backend signature shape_).
|
|
222
|
-
- `generate_backend_action_handlers_map(imports, options?)` — emits the `BackendActionHandlers` mapped type (`{[K in BackendRequestResponseMethod]: (input: ActionInputs[K], ctx: BackendHandlerContext) => ActionOutputs[K] | Promise<ActionOutputs[K]>}`). Replaces the hand-maintained `Exclude<>` + parallel mapped-type pattern (zzz had this at `zzz/src/lib/server/zzz_action_handlers.ts:42-66`). Configurable type name, method enum name, and context type name; configurable `collections_path` / `metatypes_path` for the type imports.
|
|
223
|
-
|
|
224
|
-
### Wrapper + multi-source helper
|
|
225
|
-
|
|
226
|
-
- `compose_gen_file({origin_path, imports, blocks})` — encapsulates the per-`*.gen.ts` boilerplate (banner + `imports.build()` + blocks join + template literal). Returns the full file body. Each consumer producer collapses to one `compose_gen_file` call wrapping the helper invocations.
|
|
227
|
-
- `create_namespace_qualifier(sources, imports)` — multi-source consumer helper. Takes `ReadonlyArray<{ns, module, specs}>`, registers `import * as ns from module` for each on `imports`, builds the `method_to_ns` lookup with duplicate-method detection, returns `{qualify_spec, all_specs}` ready to thread through the high-level helpers. Closes the per-file boilerplate gap that kept zap + visiones on hand-rolled template strings even after the `qualify_spec?` callback landed (the per-call callback wasn't enough — the import dance + dup-check was the real boilerplate).
|
|
228
|
-
|
|
229
|
-
## Registry compile (`compile_action_registry.ts`)
|
|
230
|
-
|
|
231
|
-
Shared registration loop called by both `create_rpc_endpoint` and
|
|
232
|
-
`register_action_ws`. Validates four invariants and returns the
|
|
233
|
-
`Map<method, RpcAction>` the dispatchers use:
|
|
234
|
-
|
|
235
|
-
1. Auth-shape biconditional (`assert_route_auth_acting_biconditional` —
|
|
236
|
-
`auth.actor !== 'none' ⟺ input declares acting?: ActingActor`).
|
|
237
|
-
2. Rate-limit / account axis — `rate_limit: 'account' | 'both'` requires
|
|
238
|
-
`auth.account === 'required'`.
|
|
239
|
-
3. JSON-RPC §4.2 wire validity — `request_response` specs with a handler
|
|
240
|
-
may not use `z.null()` for input (use `z.void()` for nullary).
|
|
241
|
-
4. Unique `method` across the array.
|
|
242
|
-
|
|
243
|
-
Only `request_response` specs with a handler reach the dispatch map;
|
|
244
|
-
`remote_notification` / handler-less specs (e.g. WS `cancel`) stay
|
|
245
|
-
registry-only.
|
|
246
|
-
|
|
247
|
-
## HTTP bridge (`action_bridge.ts`)
|
|
182
|
+
## HTTP bridge (`actions/action_bridge.ts`)
|
|
248
183
|
|
|
249
184
|
Derives transport-specific specs from action specs. HTTP-specific concerns
|
|
250
185
|
(path, handler, errors) come from options, not the action spec.
|
|
251
186
|
|
|
252
|
-
- `create_action_route_spec(spec, options)` — one action → one `RouteSpec`. HTTP method defaults by `side_effects` (`true` → POST, `false` → GET; override via `options.http_method`). `route.auth` is `spec.auth` verbatim
|
|
187
|
+
- `create_action_route_spec(spec, options)` — one action → one `RouteSpec`. HTTP method defaults by `side_effects` (`true` → POST, `false` → GET; override via `options.http_method`). `route.auth` is `spec.auth` verbatim. `transaction: spec.side_effects`. Throws if `spec.auth` is null.
|
|
253
188
|
- `create_action_event_spec(spec, {channel?})` — one notification action → one `EventSpec` for SSE surface + `create_validated_broadcaster`. Throws on non-`remote_notification` kind.
|
|
254
|
-
- `derive_http_method(side_effects)` — exported for
|
|
189
|
+
- `derive_http_method(side_effects)` — exported for custom bridges.
|
|
255
190
|
|
|
256
|
-
## Single JSON-RPC 2.0 endpoint (`action_rpc.ts`)
|
|
191
|
+
## Single JSON-RPC 2.0 endpoint (`actions/action_rpc.ts`)
|
|
257
192
|
|
|
258
193
|
`create_rpc_endpoint({path, actions, log}): RouteSpec[]` produces **two**
|
|
259
194
|
route specs on the same path (GET + POST) that share one internal
|
|
@@ -261,68 +196,47 @@ dispatcher. Per-action auth lives inside the dispatcher; the outer routes
|
|
|
261
196
|
use `auth: {account: 'none', actor: 'none'}` and `transaction: false`.
|
|
262
197
|
|
|
263
198
|
The HTTP RPC dispatcher is a thin shim around `perform_action`
|
|
264
|
-
(`actions/perform_action.ts`). The shim owns
|
|
265
|
-
|
|
266
|
-
auth/validation/dispatch pipeline is shared with the WebSocket
|
|
267
|
-
dispatcher.
|
|
199
|
+
(`actions/perform_action.ts`). The shim owns wire-shape concerns (envelope
|
|
200
|
+
parsing, GET vs POST split, `c.json` binding); the
|
|
201
|
+
auth/validation/dispatch pipeline is shared with the WebSocket dispatcher.
|
|
268
202
|
|
|
269
|
-
Phase order:
|
|
203
|
+
**Phase order: 401 → 400 → 403 → handler.** Validate first, authorize
|
|
270
204
|
after. The trade-off is that an unauthorized caller sees the validation
|
|
271
205
|
step; the alternative ordering (403-before-400) was rejected because
|
|
272
|
-
defense-in-depth via attack-surface obscurity is illusory when the
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
Shim responsibilities:
|
|
276
|
-
|
|
277
|
-
1. **Parse envelope** — POST body as `JsonrpcRequest` (parse errors → JSON-RPC `parse_error` 400). GET reads `method`, `id`, `params` from query string; missing `method`/`id` → 400 `invalid_request`. Integer `id` normalization: `?id=42` matches `{id: 42}`.
|
|
278
|
-
2. **Lookup method** — `Map<method, RpcAction>` built via `compile_action_registry` (which runs the registry-time invariants — see §Registry compile above). Unknown method → `method_not_found`.
|
|
279
|
-
3. **GET read restriction** — GET is rejected for `side_effects: true` actions (`invalid_request` with "must use POST"). HTTP-only.
|
|
280
|
-
4. **Build PerformActionInput** — read `account_id` / `credential_type` from `c.var`, resolve `client_ip` via `get_client_ip`, pass `c.req.raw.signal` as `signal`, build a DEV-warn-and-drop `notify`. Test-preset escape hatch reads `TEST_CONTEXT_PRESET_KEY` + `REQUEST_CONTEXT_KEY` and forwards as `preset.request_context`.
|
|
281
|
-
5. **Call `perform_action`** — runs steps 1–6 of the shared pipeline (see §Shared dispatch core below).
|
|
282
|
-
6. **Bind result** — `perform_action_result_to_envelope(id, result)` builds the JSON-RPC wire envelope; `c.json(envelope, result.status)` returns it.
|
|
283
|
-
|
|
284
|
-
The shared core inside `perform_action` runs:
|
|
285
|
-
|
|
286
|
-
- Pre-validation auth (401), input validation (400), authorization phase (with `apply_authorization_phase` resolving the actor from `validated_input.acting`), post-authorization auth (403 — credential gate first, role gate second), rate limit (429), transactional dispatch + DEV output validation, error normalization.
|
|
287
|
-
|
|
288
|
-
Resolution failures from the authorization phase come back as
|
|
289
|
-
`AuthorizationResult.ok === false` carrying `{status, body}` —
|
|
290
|
-
`perform_action` folds this into a JSON-RPC envelope where `error.code`
|
|
291
|
-
maps from `http_status_to_jsonrpc_error_code(result.status)`,
|
|
292
|
-
`error.message` is the reason string, and `error.data: {reason, ...rest}`
|
|
293
|
-
flattens any diagnostic fields (e.g. `available[]` for `actor_required`).
|
|
294
|
-
The two 500 reasons stay distinct: `no_actors_on_account` (signup
|
|
295
|
-
invariant violation — the actor enumeration came back empty);
|
|
296
|
-
`account_vanished` (torn read after resolve). REST emits the same `body`
|
|
297
|
-
directly via `c.json(body, status)` for surface consistency.
|
|
298
|
-
|
|
299
|
-
Error paths: `ThrownJsonrpcError` (duck-typed via `err instanceof Error
|
|
300
|
-
&& typeof err.code === 'number'`) preserves code + data verbatim. Duck-
|
|
301
|
-
typing avoids cross-copy `instanceof` misses when consumers throw their
|
|
302
|
-
own `ThrownJsonrpcError` (e.g. zzz). Generic thrown errors become
|
|
303
|
-
`internal_error` 500; message is the raw error under `DEV`, "internal
|
|
304
|
-
server error" otherwise. The HTTP shim's outer `c.json` then binds the
|
|
305
|
-
status.
|
|
306
|
-
|
|
307
|
-
Per-request handler shape (uniform across HTTP RPC + WS):
|
|
206
|
+
defense-in-depth via attack-surface obscurity is illusory when the surface
|
|
207
|
+
is published in `library.json` codegen anyway.
|
|
308
208
|
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
209
|
+
Shim responsibilities (per-request):
|
|
210
|
+
|
|
211
|
+
1. Parse envelope (POST body / GET query string); parse errors → `parse_error` 400.
|
|
212
|
+
2. Lookup method in the `compile_action_registry`-built map; unknown → `method_not_found`.
|
|
213
|
+
3. GET read restriction — GET rejected for `side_effects: true` actions.
|
|
214
|
+
4. Build `PerformActionInput` from `c.var` + `get_client_ip` + `c.req.raw.signal`. Test-preset escape hatch reads `TEST_CONTEXT_PRESET_KEY` + `REQUEST_CONTEXT_KEY`.
|
|
215
|
+
5. Call `perform_action` (shared core).
|
|
216
|
+
6. Bind result via `perform_action_result_to_envelope(id, result)`; `c.json(envelope, result.status)`.
|
|
217
|
+
|
|
218
|
+
Error paths: `ThrownJsonrpcError` (duck-typed via `err instanceof Error &&
|
|
219
|
+
typeof err.code === 'number'` to handle cross-copy `instanceof` misses,
|
|
220
|
+
e.g. when consumers like zzz throw their own `ThrownJsonrpcError`)
|
|
221
|
+
preserves code + data verbatim. Generic throws become `internal_error` 500;
|
|
222
|
+
message is the raw error under `DEV`, "internal server error" otherwise.
|
|
223
|
+
|
|
224
|
+
### Per-request handler shape
|
|
225
|
+
|
|
226
|
+
Unified across HTTP RPC + WS via `ActionContext`:
|
|
314
227
|
|
|
228
|
+
```ts
|
|
315
229
|
interface ActionContext {
|
|
316
230
|
auth: RequestContext | null; // null for public actions
|
|
317
231
|
request_id: JsonrpcRequestId;
|
|
318
232
|
connection_id?: Uuid; // populated on WS, undefined on HTTP
|
|
319
233
|
db: Db; // transaction for mutations, pool for reads
|
|
320
|
-
pending_effects: Array<Promise<void>>; // eager
|
|
234
|
+
pending_effects: Array<Promise<void>>; // eager — see http/CLAUDE.md §Pending Effects
|
|
321
235
|
post_commit_effects: Array<() => void | Promise<void>>; // deferred — push via `emit_after_commit`
|
|
322
236
|
client_ip: string;
|
|
323
|
-
credential_type: CredentialType | null; //
|
|
237
|
+
credential_type: CredentialType | null; // same value the credential_types gate consumed
|
|
324
238
|
log: Logger;
|
|
325
|
-
notify: (method, params) => void; // HTTP: DEV-mode warn + drop
|
|
239
|
+
notify: (method, params) => void; // HTTP: DEV-mode warn + drop; WS: socket-scoped
|
|
326
240
|
signal: AbortSignal; // HTTP: client-disconnect; WS: AbortSignal.any([socket_close, request_cancel])
|
|
327
241
|
}
|
|
328
242
|
|
|
@@ -332,198 +246,180 @@ interface RpcAction {
|
|
|
332
246
|
}
|
|
333
247
|
```
|
|
334
248
|
|
|
335
|
-
### `rpc_action(spec, handler)` — typed binder
|
|
336
|
-
|
|
337
|
-
`rpc_action<TSpec extends RequestResponseActionSpec>(spec, handler)`
|
|
338
|
-
returns a `RpcAction` with the handler's input / output types pinned
|
|
339
|
-
to `z.infer<TSpec['input']>` / `z.infer<TSpec['output']>` and the
|
|
340
|
-
handler's `ctx.auth` slot tightened to the narrowest shape the
|
|
341
|
-
dispatcher's runtime guarantee allows. The conditional `HandlerForSpec<TSpec>`
|
|
342
|
-
discriminates on the spec literal:
|
|
343
|
-
|
|
344
|
-
| Spec auth axes | Selected handler type | `ctx.auth` |
|
|
345
|
-
| ------------------------------------------------------ | --------------------- | ------------------------ |
|
|
346
|
-
| `auth.actor === 'required'` | `ActorActionHandler` | `RequestActorContext` |
|
|
347
|
-
| `auth.account === 'required' && auth.actor === 'none'` | `AuthActionHandler` | `RequestContext` |
|
|
348
|
-
| else (public, optional axes) | `ActionHandler` | `RequestContext \| null` |
|
|
249
|
+
### `rpc_action(spec, handler)` — typed binder
|
|
349
250
|
|
|
350
|
-
|
|
351
|
-
|
|
251
|
+
`rpc_action<TSpec extends RequestResponseActionSpec>(spec, handler)` pins
|
|
252
|
+
the handler's input / output types to `z.infer<TSpec['input']>` /
|
|
253
|
+
`z.infer<TSpec['output']>` and tightens `ctx.auth` per the conditional
|
|
254
|
+
`HandlerForSpec<TSpec>`:
|
|
352
255
|
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
});
|
|
359
|
-
|
|
360
|
-
// account-grain spec → ctx.auth: RequestContext (actor: null)
|
|
361
|
-
rpc_action(account_verify_action_spec, (_input, ctx) => {
|
|
362
|
-
return to_session_account(ctx.auth.account);
|
|
363
|
-
});
|
|
364
|
-
```
|
|
256
|
+
| Spec auth axes | `ctx.auth` |
|
|
257
|
+
| ------------------------------------------------------ | ------------------------ |
|
|
258
|
+
| `auth.actor === 'required'` | `RequestActorContext` |
|
|
259
|
+
| `auth.account === 'required' && auth.actor === 'none'` | `RequestContext` |
|
|
260
|
+
| else (public, optional axes) | `RequestContext \| null` |
|
|
365
261
|
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
widens the axes and silently drops the ergonomic narrowing (the
|
|
373
|
-
binder still compiles; consumers just lose the auto-narrow on
|
|
374
|
-
`ctx.auth`).
|
|
262
|
+
Use at every spec → handler binding site so handler-type errors surface at
|
|
263
|
+
the factory call instead of at runtime. The bracketed form
|
|
264
|
+
`[T] extends ['required']` defeats distributive conditionals so a degraded
|
|
265
|
+
`AuthAxisState` union (when the spec was typed without preserving its
|
|
266
|
+
literal) falls through to the loosest tier instead of collapsing to the
|
|
267
|
+
narrowest.
|
|
375
268
|
|
|
376
269
|
zzz uses a codegen-driven `Record<Method, Handler>` map for the same
|
|
377
270
|
narrowing — ideal when handlers are stateless free functions. fuz_app's
|
|
378
271
|
handlers close over factory-captured deps (`log`, `audit`,
|
|
379
272
|
`options.app_settings`, `options.max_tokens`), so per-pair typing via
|
|
380
|
-
`rpc_action()` is the right shape here
|
|
381
|
-
construction time and the handler keeps its closure. The conditional
|
|
382
|
-
auto-selects the right tier per spec; consumers don't pick a binder.
|
|
273
|
+
`rpc_action()` is the right shape here.
|
|
383
274
|
|
|
384
|
-
##
|
|
275
|
+
## Shared dispatch core (`actions/perform_action.ts`)
|
|
385
276
|
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
`
|
|
389
|
-
|
|
277
|
+
The transport-agnostic post-parse pipeline. Each transport assembles a
|
|
278
|
+
`PerformActionInput` from its wire envelope + connection identity, calls
|
|
279
|
+
`perform_action(input, deps)`, and binds the discriminated
|
|
280
|
+
`PerformActionResult` to its wire shape.
|
|
281
|
+
|
|
282
|
+
Pipeline (401 → 400 → 403 → handler):
|
|
390
283
|
|
|
391
|
-
|
|
392
|
-
|
|
284
|
+
1. Pre-validation auth (401)
|
|
285
|
+
2. Validate params (400) — `spec.input.safeParse` with `z.void()` / `?? {}` rules
|
|
286
|
+
3. Authorization phase — `apply_authorization_phase` against `account_id` + `validated_input.acting`. Test escape hatch via `preset.request_context`
|
|
287
|
+
4. Post-authorization auth (403) — credential-type gate first, role gate second
|
|
288
|
+
5. Rate limit (429) — throttle-requests semantics
|
|
289
|
+
6. Dispatch + DEV output validation + error normalization — `spec.side_effects` picks transaction vs pool. `ThrownJsonrpcError` preserves code + data; generic throws become `internal_error`
|
|
393
290
|
|
|
394
|
-
`
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
291
|
+
`PerformActionInput` carries `account_id`, `credential_type`, `client_ip`,
|
|
292
|
+
`signal`, `notify`, optional `connection_id`, optional `preset`.
|
|
293
|
+
`PerformActionDeps` carries `db` (pool-level), `pending_effects`, `log`,
|
|
294
|
+
the two rate limiters. Audit writes are out-of-band: factories close over
|
|
295
|
+
`AppDeps.audit` independently.
|
|
399
296
|
|
|
400
|
-
|
|
297
|
+
Authorization-phase resolution failures from the auth domain come back as
|
|
298
|
+
`AuthorizationResult.ok === false` carrying `{status, body}` — folded into
|
|
299
|
+
a JSON-RPC envelope where `error.code` maps from
|
|
300
|
+
`http_status_to_jsonrpc_error_code(result.status)`, `error.message` is the
|
|
301
|
+
reason string, and `error.data: {reason, ...rest}` flattens diagnostic
|
|
302
|
+
fields. REST emits the same `body` directly via `c.json(body, status)` for
|
|
303
|
+
surface consistency.
|
|
401
304
|
|
|
402
|
-
-
|
|
403
|
-
- `WS_CLOSE_CLIENT_HEARTBEAT_TIMEOUT = 4002` — client observed receive-silence past `DEFAULT_HEARTBEAT_RECEIVE_TIMEOUT`.
|
|
404
|
-
- `WS_CLOSE_SERVER_HEARTBEAT_TIMEOUT = 4003` — server observed receive-silence past `DEFAULT_SERVER_HEARTBEAT_TIMEOUT` (60s).
|
|
305
|
+
## DEV-only output validation — uniform across surfaces
|
|
405
306
|
|
|
406
|
-
|
|
307
|
+
Critical invariant: every action-handler surface applies DEV-only output
|
|
308
|
+
validation and produces the **same failure mode** — log an error, return
|
|
309
|
+
the response unchanged, do not throw, do not mutate status.
|
|
407
310
|
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
`
|
|
412
|
-
envelope via `http_status_to_jsonrpc_error_code`. DEV-mode checks that
|
|
413
|
-
JSON-RPC error codes match the declared HTTP status and warns on drift.
|
|
414
|
-
`is_ready(): true` always.
|
|
311
|
+
| Surface | Code location | Hot path under production |
|
|
312
|
+
| ----------------------------- | -------------------------------------------------------------------------------------------------------------------------- | ------------------------- |
|
|
313
|
+
| REST bridge | `http/route_spec.ts` — `wrap_output_validation` (applied via `apply_route_specs`; inherited by `create_action_route_spec`) | short-circuit (no parse) |
|
|
314
|
+
| HTTP RPC + WebSocket dispatch | `actions/perform_action.ts` — `if (DEV) spec.output.safeParse(output)` inside the shared dispatch core | short-circuit (no parse) |
|
|
415
315
|
|
|
416
|
-
|
|
316
|
+
Caller-facing `input` schemas are validated **always** (DEV + production)
|
|
317
|
+
— they're the contract with external callers. Server-authored `output`
|
|
318
|
+
schemas are internal data. See ../../docs/architecture.md §DEV-only Output
|
|
319
|
+
Validation for full rationale.
|
|
320
|
+
|
|
321
|
+
## Transports
|
|
322
|
+
|
|
323
|
+
`Transport` is the unifying interface — overloaded `send(message, options?)`
|
|
324
|
+
returning `Promise<JsonrpcResponseOrError>` for requests and
|
|
325
|
+
`Promise<JsonrpcErrorResponse | null>` for notifications, plus `is_ready()`
|
|
326
|
+
and optional `dispose()`. All transports share `TransportSendOptions`:
|
|
417
327
|
|
|
418
|
-
|
|
419
|
-
(
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
ignores them.
|
|
328
|
+
- `signal?: AbortSignal` — per-call cancel. Bottoms out at
|
|
329
|
+
`FrontendWebsocketClient.request({signal})` on WS (sends `cancel`
|
|
330
|
+
notification on abort) and at `fetch({signal})` on HTTP.
|
|
331
|
+
- `queue?: boolean` — per-call durable-queue opt-in. Honored only by
|
|
332
|
+
`FrontendWebsocketTransport` on the `request_response` path (default
|
|
333
|
+
`false`). HTTP, backend, and WS notifications all ignore it.
|
|
425
334
|
|
|
426
|
-
|
|
335
|
+
`Transports` registry holds multiple transports with a `current` selection
|
|
336
|
+
and `allow_fallback: boolean` (default `true`). Explicit
|
|
337
|
+
`transport_for_method` (on `rpc_client`) or
|
|
338
|
+
`default_send_options.transport_name` (on `ActionPeer`) takes precedence.
|
|
427
339
|
|
|
428
|
-
|
|
429
|
-
- `WebsocketRpcConnection extends WebsocketConnection` — adds `request(method, params, {signal?, queue?, id?})` that throws `ThrownJsonrpcError` with the right code (`service_unavailable`, `queue_overflow`, `request_cancelled`, wire code from peer).
|
|
340
|
+
### WS close codes (`actions/transports.ts`)
|
|
430
341
|
|
|
431
|
-
|
|
432
|
-
`
|
|
433
|
-
|
|
342
|
+
- `WS_CLOSE_SESSION_REVOKED = 4001` — server revoked auth; client enters permanent `revoked` state, no reconnect.
|
|
343
|
+
- `WS_CLOSE_CLIENT_HEARTBEAT_TIMEOUT = 4002` — client observed receive-silence past `DEFAULT_HEARTBEAT_RECEIVE_TIMEOUT`.
|
|
344
|
+
- `WS_CLOSE_SERVER_HEARTBEAT_TIMEOUT = 4003` — server observed receive-silence past `DEFAULT_SERVER_HEARTBEAT_TIMEOUT` (60s).
|
|
434
345
|
|
|
435
|
-
###
|
|
346
|
+
### Transport modules
|
|
436
347
|
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
`
|
|
348
|
+
| Module | Name | Role |
|
|
349
|
+
| ---------------------------------- | ------------------------ | ------------------------------------------------------------------------------------ |
|
|
350
|
+
| `actions/transports_http.ts` | `frontend_http_rpc` | Thin `fetch` adapter; POST default, GET on `has_side_effects(method) === false` |
|
|
351
|
+
| `actions/transports_ws.ts` | `frontend_websocket_rpc` | Thin adapter over `WebsocketRpcConnection` (default impl: `FrontendWebsocketClient`) |
|
|
352
|
+
| `actions/transports_ws_backend.ts` | `backend_websocket_rpc` | Server-side WS with session tracking; satisfies `FilterableBroadcastTransport` |
|
|
441
353
|
|
|
442
|
-
|
|
354
|
+
`FrontendHttpTransport` synthesizes a JSON-RPC error envelope via
|
|
355
|
+
`http_status_to_jsonrpc_error_code` on non-OK HTTP; DEV warns on drift
|
|
356
|
+
between JSON-RPC error code and declared HTTP status.
|
|
443
357
|
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
358
|
+
`FrontendWebsocketTransport` notification sends fail-fast when disconnected
|
|
359
|
+
regardless of `queue` — `connection.send()` has no queue semantic, so
|
|
360
|
+
buffering would masquerade as success at the rpc_client layer. Requests
|
|
361
|
+
route via `queue`.
|
|
447
362
|
|
|
448
|
-
|
|
363
|
+
### `BackendWebsocketTransport` — server-side WS state
|
|
449
364
|
|
|
450
|
-
|
|
451
|
-
- `remove_connection(ws)` — idempotent; safe after revocation.
|
|
365
|
+
Three aligned maps keyed by `connection_id` (branded `Uuid`):
|
|
452
366
|
|
|
453
|
-
|
|
367
|
+
- `#connections: Map<Uuid, WSContext>` — id → socket
|
|
368
|
+
- `#connection_ids: WeakMap<WSContext, Uuid>` — socket → id (reverse)
|
|
369
|
+
- `#connection_identities: Map<Uuid, ConnectionIdentity>` — id → `{token_hash, account_id, api_token_id}` (session sets `token_hash`, bearer sets `api_token_id`, daemon-token sets both null)
|
|
454
370
|
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
- `close_sockets_for_account(account_id)` — coarse; covers session + bearer + daemon-token.
|
|
371
|
+
Targeted closure (all return socket count closed, use
|
|
372
|
+
`WS_CLOSE_SESSION_REVOKED`):
|
|
458
373
|
|
|
459
|
-
|
|
374
|
+
- `close_sockets_for_session(token_hash)`
|
|
375
|
+
- `close_sockets_for_token(api_token_id)`
|
|
376
|
+
- `close_sockets_for_account(account_id)` — coarse, covers session + bearer + daemon-token
|
|
460
377
|
|
|
461
|
-
- `send(notification)`
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
378
|
+
Fan-out: `send(notification)` broadcasts to every connection;
|
|
379
|
+
`broadcast_filtered(message, predicate)` runs per-connection ACL predicate
|
|
380
|
+
over `ConnectionIdentity`; `send_to_account` wraps `broadcast_filtered` and
|
|
381
|
+
structurally satisfies `NotificationSender` (see `auth/CLAUDE.md` §WS
|
|
382
|
+
notifications).
|
|
465
383
|
|
|
466
384
|
Return values are bookkeeping, not delivery receipts — `0` means no live
|
|
467
385
|
sockets, non-zero means `ws.send` did not throw. Durable delivery requires
|
|
468
386
|
persistence + rehydration by the consumer.
|
|
469
387
|
|
|
470
|
-
## WS auth guard (`transports_ws_auth_guard.ts`)
|
|
388
|
+
## WS auth guard (`actions/transports_ws_auth_guard.ts`)
|
|
471
389
|
|
|
472
390
|
Closes WS sockets on audit revoke events — per-message dispatch doesn't
|
|
473
|
-
re-check session/token validity, so this guard is the revocation seam
|
|
474
|
-
|
|
391
|
+
re-check session/token validity, so this guard is the revocation seam for
|
|
392
|
+
open connections.
|
|
475
393
|
|
|
476
394
|
`create_ws_auth_guard(transport, log)` returns an `on_audit_event` callback.
|
|
477
395
|
For standard WS endpoints mounted via `AppServerOptions.ws_endpoints`,
|
|
478
|
-
`create_app_server` composes this guard onto
|
|
479
|
-
`
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
`ws_disconnect_event_types` (ReadonlySet): `session_revoke`,
|
|
486
|
-
`token_revoke`, `session_revoke_all`, `token_revoke_all`, `password_change`.
|
|
487
|
-
`role_grant_revoke` is intentionally **omitted** — the WS transport does not
|
|
396
|
+
`create_app_server` composes this guard onto `backend.deps.audit.on_event_chain`
|
|
397
|
+
automatically (per `WsEndpointSpec.auth_guard`). For custom wiring, append
|
|
398
|
+
inside the consumer's `audit_factory` body.
|
|
399
|
+
|
|
400
|
+
`ws_disconnect_event_types` (ReadonlySet): `session_revoke`, `token_revoke`,
|
|
401
|
+
`session_revoke_all`, `token_revoke_all`, `password_change`.
|
|
402
|
+
`role_grant_revoke` is intentionally **omitted** — the WS transport doesn't
|
|
488
403
|
track per-connection role requirements, so role-scoped disconnection would
|
|
489
404
|
require either closing all sockets (too aggressive) or new per-connection
|
|
490
405
|
role tracking (out of scope). Consumers that need it compose their own
|
|
491
406
|
callback.
|
|
492
407
|
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
- `token_revoke` → `close_sockets_for_token(metadata.token_id)`
|
|
497
|
-
- `session_revoke_all` / `token_revoke_all` / `password_change` → `close_sockets_for_account(target_account_id ?? account_id)` (admin actions set `target_account_id`; self-service only `account_id`).
|
|
498
|
-
|
|
499
|
-
`outcome === 'failure'` events are ignored — they carry
|
|
500
|
-
attacker-controlled identifiers. Reacting to them would let an authenticated
|
|
501
|
-
caller close another user's socket by guessing a session hash or token id.
|
|
408
|
+
`outcome === 'failure'` events are ignored — they carry attacker-controlled
|
|
409
|
+
identifiers. Reacting to them would let an authenticated caller close
|
|
410
|
+
another user's socket by guessing a session hash or token id.
|
|
502
411
|
|
|
503
412
|
`create_ws_logout_closer(transport, log)` is the sibling helper for
|
|
504
413
|
user-initiated `logout` events — kept separate because
|
|
505
414
|
`ws_disconnect_event_types` deliberately omits `logout` (admin-initiated
|
|
506
415
|
revocations use `session_revoke`, while `logout` is the user-initiated
|
|
507
|
-
case).
|
|
416
|
+
case). Closes via `close_sockets_for_account(event.account_id)`.
|
|
508
417
|
|
|
509
|
-
|
|
510
|
-
const ws_guard = create_ws_auth_guard(transport, log);
|
|
511
|
-
const ws_logout_closer = create_ws_logout_closer(transport, log);
|
|
512
|
-
const on_audit_event = (event: AuditLogEvent): void => {
|
|
513
|
-
ws_guard(event);
|
|
514
|
-
ws_logout_closer(event);
|
|
515
|
-
};
|
|
516
|
-
```
|
|
517
|
-
|
|
518
|
-
Same `outcome === 'failure'` guard as `create_ws_auth_guard`. Closes via
|
|
519
|
-
`close_sockets_for_account(event.account_id)` — `logout` is always
|
|
520
|
-
self-service, so there is no `target_account_id` to fall back on.
|
|
418
|
+
## Connection closer (`actions/connection_closer.ts`)
|
|
521
419
|
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
on revocation — the belt+suspenders layer that complements the audit-
|
|
526
|
-
listener guards above.
|
|
420
|
+
Narrow structural capability for handler-side eager WS socket closure on
|
|
421
|
+
revocation — belt+suspenders layer that complements the audit-listener
|
|
422
|
+
guards above.
|
|
527
423
|
|
|
528
424
|
```ts
|
|
529
425
|
interface ConnectionCloser {
|
|
@@ -533,543 +429,281 @@ interface ConnectionCloser {
|
|
|
533
429
|
}
|
|
534
430
|
```
|
|
535
431
|
|
|
536
|
-
`BackendWebsocketTransport` satisfies this structurally — consumers
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
(
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
- `path` — Hono mount path
|
|
579
|
-
- `allowed_origins` — origin allowlist regexes (parsed via `parse_allowed_origins`)
|
|
580
|
-
- `actions` — the `ReadonlyArray<Action>` to dispatch (spread `protocol_actions` first)
|
|
581
|
-
- `required_roles?: ReadonlyArray<RoleName>` — any-of upgrade-time role gate; omit or `[]` to skip
|
|
582
|
-
- `transport?: BackendWebsocketTransport` — supplied or auto-created; returned on `AppServer.ws_endpoints[path]` either way
|
|
583
|
-
- `heartbeat?`, `artificial_delay?`, `on_socket_open?`, `on_socket_close?` — passed through to `register_ws_endpoint`
|
|
584
|
-
- `auth_guard?: boolean` — default `true`; auto-composes `create_ws_auth_guard` + `create_ws_logout_closer` against the endpoint's transport and appends them to `deps.audit.on_event_chain`. The wiring is deduped by **reference identity** (`WeakSet<BackendWebsocketTransport>`), so two `WsEndpointSpec`s sharing one `BackendWebsocketTransport` instance still get a single pair of listeners. Wrapped / DI-proxied transports dedupe as separate entries — set `auth_guard: false` on duplicates and compose `create_ws_auth_guard` / `create_ws_logout_closer` against the underlying transport once
|
|
585
|
-
- `extra_audit_handlers?: ReadonlyArray<AuditEventHandler>` — appended after the standard guards; consumer-owned, never deduped
|
|
432
|
+
`BackendWebsocketTransport` satisfies this structurally — consumers pass
|
|
433
|
+
the transport instance directly (same shape as `NotificationSender`). Wired
|
|
434
|
+
into `AccountRouteOptions.connection_closer` (logout / password),
|
|
435
|
+
`AccountActionOptions.connection_closer` (session/token revoke), and
|
|
436
|
+
`AdminActionOptions.connection_closer` (admin revoke-all). Each handler
|
|
437
|
+
calls the appropriate `close_sockets_for_*` synchronously **before** the
|
|
438
|
+
audit emit so revocation lands even on audit INSERT failure. Failure
|
|
439
|
+
outcomes (`revoked: false`, 404 not-found) skip the eager close — mirrors
|
|
440
|
+
the listener's `outcome === 'failure'` guard so attacker-guessable ids can
|
|
441
|
+
never target arbitrary sockets.
|
|
442
|
+
|
|
443
|
+
## WebSocket dispatch — three layered entry points
|
|
444
|
+
|
|
445
|
+
In decreasing abstraction.
|
|
446
|
+
|
|
447
|
+
### `create_app_server.ws_endpoints` — canonical mount surface
|
|
448
|
+
|
|
449
|
+
Mirror of `rpc_endpoints` for WebSocket endpoints. Accepts either an array
|
|
450
|
+
of `WsEndpointSpec` or a factory
|
|
451
|
+
`(ctx: AppServerContext) => ReadonlyArray<WsEndpointSpec>`; factory form
|
|
452
|
+
runs after server context is assembled so action lists can depend on
|
|
453
|
+
`ctx.deps` / `ctx.action_*_rate_limiter`. Each entry is auto-mounted via
|
|
454
|
+
`register_ws_endpoint` against the assembled Hono app.
|
|
455
|
+
|
|
456
|
+
`upgradeWebSocket` (the Hono adapter helper) is supplied once at the top
|
|
457
|
+
level — `create_app_server` throws when `ws_endpoints` resolves non-empty
|
|
458
|
+
but `upgradeWebSocket` is missing. A factory returning `[]` does NOT trip
|
|
459
|
+
the check, so feature-flag gated WS surfaces stay safe.
|
|
460
|
+
|
|
461
|
+
`WsEndpointSpec` fields: `path`, `allowed_origins`, `actions`,
|
|
462
|
+
`required_roles?`, `transport?`, `heartbeat?`, `artificial_delay?`,
|
|
463
|
+
`on_socket_open?`, `on_socket_close?`, `auth_guard?` (default `true`,
|
|
464
|
+
deduped by reference identity via `WeakSet<BackendWebsocketTransport>`),
|
|
465
|
+
`extra_audit_handlers?`.
|
|
466
|
+
|
|
467
|
+
Mounted transport reachable at `app_server.ws_endpoints[path]`
|
|
468
|
+
(`Readonly<Record<string, BackendWebsocketTransport>>`). Duplicate paths
|
|
469
|
+
across `WsEndpointSpec`s throw at mount time. Cross-surface collisions
|
|
470
|
+
(same `GET <path>` on both `RouteSpec` and `WsEndpointSpec`) throw with
|
|
471
|
+
exact-string match. Pattern overlap (e.g. `GET /api/:resource` vs
|
|
472
|
+
`/api/ws`) is not detected — Hono's specific-before-wildcard routing keeps
|
|
473
|
+
those working but avoid the overlap.
|
|
586
474
|
|
|
587
475
|
`auth_guard: true` does NOT close sockets on `role_grant_revoke`
|
|
588
|
-
(deliberate — per-connection role tracking
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
Cross-surface collisions are also detected — registering `GET <path>`
|
|
599
|
-
as both a `RouteSpec` and a `WsEndpointSpec` throws
|
|
600
|
-
`'create_app_server: ws_endpoints path collides with a GET RouteSpec: <path>'`
|
|
601
|
-
at mount time. Exact-string match only; pattern overlap (e.g. a
|
|
602
|
-
`RouteSpec` at `GET /api/:resource` vs `WsEndpointSpec` at `/api/ws`)
|
|
603
|
-
is not detected — Hono's specific-before-wildcard routing keeps those
|
|
604
|
-
working, but if you need certainty avoid the overlap.
|
|
605
|
-
|
|
606
|
-
`auth_guard` semantics when multiple specs share a transport: **any**
|
|
607
|
-
spec with `auth_guard !== false` wires the guard for that transport
|
|
608
|
-
(OR-semantics, order-independent). To opt out for a shared transport,
|
|
609
|
-
every sibling spec must pass `auth_guard: false`. Documented on the
|
|
610
|
-
`WsEndpointSpec.auth_guard` TSDoc.
|
|
611
|
-
|
|
612
|
-
`AppSurfaceWsEndpoint.methods` surfaces `request_response` + `remote_notification`
|
|
613
|
-
specs only — `local_call` specs are filtered out because they don't
|
|
614
|
-
dispatch over WS (`compile_action_registry` routes only
|
|
615
|
-
`request_response` with a handler into `action_map`; `local_call` is
|
|
616
|
-
frontend-side registry metadata).
|
|
617
|
-
|
|
618
|
-
### `register_ws_endpoint` (`register_ws_endpoint.ts`) — middle tier
|
|
476
|
+
(deliberate — per-connection role tracking out of scope). Compose via
|
|
477
|
+
`extra_audit_handlers` when needed. When multiple specs share a transport,
|
|
478
|
+
**any** spec with `auth_guard !== false` wires the guard for that
|
|
479
|
+
transport (OR-semantics).
|
|
480
|
+
|
|
481
|
+
`AppSurfaceWsEndpoint.methods` surfaces `request_response` +
|
|
482
|
+
`remote_notification` specs only — `local_call` specs are filtered out
|
|
483
|
+
because they don't dispatch over WS.
|
|
484
|
+
|
|
485
|
+
### `register_ws_endpoint` — middle tier
|
|
619
486
|
|
|
620
487
|
Composes the standard upgrade stack:
|
|
621
488
|
|
|
622
489
|
1. `verify_request_source(allowed_origins)`
|
|
623
490
|
2. `require_auth`
|
|
624
|
-
3.
|
|
625
|
-
4.
|
|
626
|
-
5.
|
|
627
|
-
|
|
628
|
-
Extends `RegisterActionWsOptions` with `allowed_origins: ReadonlyArray<RegExp>`
|
|
629
|
-
and optional `required_roles: ReadonlyArray<RoleName>`. Returns
|
|
630
|
-
`{transport}`. Note: `required_roles` is a **coarse upgrade-time gate**
|
|
631
|
-
— per-action `auth` in each spec still applies at dispatch time via
|
|
632
|
-
`perform_action`. Pass `[]` (or omit) to skip the role gate.
|
|
633
|
-
(`verify_request_source` and `require_auth` / `require_role` are from
|
|
634
|
-
`auth/`; see `auth/CLAUDE.md` §Middleware for their semantics.)
|
|
491
|
+
3. Upgrade-time authorization phase — resolves the acting actor, seeds `REQUEST_CONTEXT_KEY` for the inner `register_action_ws`
|
|
492
|
+
4. Optional `require_role(required_roles)` — any-of disjunction (coarse upgrade-time gate; per-action `auth` in each spec still applies at dispatch time)
|
|
493
|
+
5. Delegates to `register_action_ws`
|
|
635
494
|
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
495
|
+
Extends `RegisterActionWsOptions` with `allowed_origins` and optional
|
|
496
|
+
`required_roles`. Returns `{transport}`. Most consumers reach for
|
|
497
|
+
`ws_endpoints` above; this is the entry test harnesses use when they need
|
|
498
|
+
the upgrade stack without `create_app_server`'s full assembly.
|
|
639
499
|
|
|
640
|
-
### `register_action_ws`
|
|
500
|
+
### `register_action_ws` — lower-level dispatcher
|
|
641
501
|
|
|
642
502
|
Exposed for tests (`create_ws_test_harness`) that need to drive the
|
|
643
503
|
dispatcher without the origin/auth front-stack.
|
|
644
504
|
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
`spec.side_effects: true`). Audit fan-out and other rollback-resilient
|
|
656
|
-
fire-and-forget writes run through `AppDeps.audit` from each action
|
|
657
|
-
factory's closure — the dispatcher never holds a separate pool reference.
|
|
658
|
-
|
|
659
|
-
Per-message dispatch delegates to `perform_action` (`actions/perform_action.ts`)
|
|
660
|
-
— the shared core that HTTP RPC also calls. `register_action_ws` only owns
|
|
661
|
-
WS-specific concerns:
|
|
662
|
-
|
|
663
|
-
- **Wire envelope parsing** — JSON.parse → batch rejection → notification interception (cancel, silent drop) → per-message dispatch.
|
|
664
|
-
- **Cancel-notification interception** — `{request_id → AbortController}` map; aborts the matching pending controller before the cancel bubbles past the dispatcher.
|
|
665
|
-
- **Socket-scoped notify** — `(method, params) => ws.send(notification)`, threaded into `perform_action` as `notify`.
|
|
666
|
-
- **Composed abort signal** — `AbortSignal.any([socket_close, per_request_cancel])`, threaded into `perform_action` as `signal`.
|
|
667
|
-
- **Connection lifecycle** — `transport.add_connection` / `remove_connection`, `on_socket_open` / `_close` hooks, server heartbeat.
|
|
668
|
-
|
|
669
|
-
Per-message authorization phase: `perform_action` calls
|
|
505
|
+
Per-message dispatch delegates to `perform_action` — the shared core that
|
|
506
|
+
HTTP RPC also calls. `register_action_ws` owns only WS-specific concerns:
|
|
507
|
+
|
|
508
|
+
- **Wire envelope parsing** — JSON.parse → batch rejection → notification interception (cancel, silent drop) → per-message dispatch
|
|
509
|
+
- **Cancel-notification interception** — `{request_id → AbortController}` map; aborts the matching pending controller before the cancel bubbles past the dispatcher
|
|
510
|
+
- **Socket-scoped notify** — `(method, params) => ws.send(notification)`, threaded into `perform_action` as `notify`
|
|
511
|
+
- **Composed abort signal** — `AbortSignal.any([socket_close, per_request_cancel])`, threaded as `signal`
|
|
512
|
+
- **Connection lifecycle** — `transport.add_connection` / `remove_connection`, `on_socket_open` / `_close` hooks, server heartbeat
|
|
513
|
+
|
|
514
|
+
**Per-message authorization phase.** `perform_action` calls
|
|
670
515
|
`apply_authorization_phase` per-message (HTTP and WS uniformly). Role grant
|
|
671
516
|
changes during a connection lifetime are picked up on the next message —
|
|
672
517
|
no in-place refresh, no socket-close on `role_grant_revoke`. Authentication
|
|
673
518
|
invalidation (`session_revoke`, `password_change`, `token_revoke_all`)
|
|
674
519
|
still closes the socket via `create_ws_auth_guard`.
|
|
675
520
|
|
|
676
|
-
Per-message
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
`flush_post_commit_effects`. Both flush in the same `try/finally` that
|
|
693
|
-
releases the request controller, so fire-and-forget audit / notification
|
|
694
|
-
effects pushed by the handler complete (or reject visibly) before the
|
|
695
|
-
next message dispatches. See `http/CLAUDE.md` §Pending Effects.
|
|
696
|
-
|
|
697
|
-
Lifecycle hooks on `RegisterActionWsOptions`:
|
|
698
|
-
|
|
699
|
-
- `on_socket_open({ws, connection_id, identity, notify, signal})` — fires after `transport.add_connection` but before the first message. Awaited. Throws log + close with `1011 'socket bootstrap failed'` + send an `internal_error` frame.
|
|
700
|
-
- `on_socket_close({ws, connection_id, identity})` — fires before `transport.remove_connection`, so `identity` is still readable even when the audit guard already tore the transport record down. Errors are logged and swallowed.
|
|
701
|
-
|
|
702
|
-
Server-side heartbeat (`heartbeat?: boolean | ServerHeartbeatOptions`):
|
|
521
|
+
Per-message side-effect queues: `pending_effects` (eager) drains via
|
|
522
|
+
`flush_pending_effects`; `post_commit_effects` (deferred — pushed by
|
|
523
|
+
handlers via `emit_after_commit`) drains via `flush_post_commit_effects`.
|
|
524
|
+
Both flush in the same `try/finally` that releases the request controller,
|
|
525
|
+
so fire-and-forget audit / notification effects pushed by the handler
|
|
526
|
+
complete (or reject visibly) before the next message dispatches. See
|
|
527
|
+
`http/CLAUDE.md` §Pending Effects.
|
|
528
|
+
|
|
529
|
+
**Lifecycle hooks.** `on_socket_open({ws, connection_id, identity, notify, signal})`
|
|
530
|
+
fires after `transport.add_connection` but before the first message;
|
|
531
|
+
awaited; throws log + close with `1011 'socket bootstrap failed'`.
|
|
532
|
+
`on_socket_close({ws, connection_id, identity})` fires before
|
|
533
|
+
`transport.remove_connection` so `identity` is still readable. Errors
|
|
534
|
+
logged and swallowed.
|
|
535
|
+
|
|
536
|
+
**Server-side heartbeat** (`heartbeat?: boolean | ServerHeartbeatOptions`):
|
|
703
537
|
default-on, 60s silence timeout. Any inbound message resets
|
|
704
538
|
`last_receive_time` — chatty clients never trip it. First timeout window
|
|
705
|
-
after open is exempt (cold-start grace). Tick interval is
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
## Event state machine
|
|
709
|
-
|
|
710
|
-
Five modules make up a discriminated-union-based state machine used by the
|
|
711
|
-
reactive client (`rpc_client.ts` + consumer ActionEvent-aware UIs) to track
|
|
712
|
-
an action through its lifecycle.
|
|
713
|
-
|
|
714
|
-
### `action_event_types.ts`
|
|
715
|
-
|
|
716
|
-
- `ActionExecutor` — `'frontend' | 'backend'`
|
|
717
|
-
- `ActionEventStep` — `'initial' | 'parsed' | 'handling' | 'handled' | 'failed'`
|
|
718
|
-
- `action_event_step_transitions` — valid next-steps: `initial → parsed | failed`, `parsed → handling | failed`, `handling → handled | failed`, `handled`/`failed` terminal.
|
|
719
|
-
- `action_event_phase_by_kind` — valid phases per kind (`request_response` has 6, `remote_notification` has 2, `local_call` has 1).
|
|
720
|
-
- `action_event_phase_transitions` — chained phases: `send_request → receive_response`; `receive_request → send_response`; everything else terminal.
|
|
721
|
-
- `ActionEventEnvironment` — `{executor, lookup_action_handler, lookup_action_spec, log?}`. The ambient registry + handler resolver for an `ActionEvent`.
|
|
722
|
-
|
|
723
|
-
### `action_event_data.ts`
|
|
724
|
-
|
|
725
|
-
`ActionEventData` is the base Zod schema — a strict object with all 10
|
|
726
|
-
possible fields always present (nullable where not applicable for the
|
|
727
|
-
current phase/step). The exported union `ActionEventDataUnion<TMethod,
|
|
728
|
-
TInput, TOutput>` is a **39-variant discriminated union** across `kind` +
|
|
729
|
-
`phase` + `step`: 28 variants for `request_response`, 6 for
|
|
730
|
-
`remote_notification`, 5 for `local_call`. Narrows the shape of
|
|
731
|
-
`input` / `output` / `error` / `request` / `response` / `notification` /
|
|
732
|
-
`progress` at each point in the lifecycle.
|
|
733
|
-
|
|
734
|
-
### `action_event_helpers.ts`
|
|
735
|
-
|
|
736
|
-
Type guards (discriminate on `kind` + `phase` + `step`):
|
|
737
|
-
|
|
738
|
-
- By kind: `is_request_response`, `is_remote_notification`, `is_local_call`
|
|
739
|
-
- By phase: `is_send_request`, `is_receive_request`, `is_send_response`, `is_receive_response`, `is_notification_send`, `is_notification_receive`, `is_execute`
|
|
740
|
-
- By step: `is_initial`, `is_parsed`, `is_handling`, `is_handled`, `is_failed`
|
|
741
|
-
- Combined: `is_send_request_with_parsed_input`, `is_notification_send_with_parsed_input`
|
|
539
|
+
after open is exempt (cold-start grace). Tick interval is `timeout / 2`,
|
|
540
|
+
so event-loop blockage pauses the timer itself.
|
|
742
541
|
|
|
743
|
-
|
|
542
|
+
Two abort signals composed via `AbortSignal.any`:
|
|
744
543
|
|
|
745
|
-
- `
|
|
746
|
-
- `
|
|
747
|
-
- `validate_phase_transition(from, to)` — throws on illegal phase chain.
|
|
748
|
-
- `get_initial_phase(kind, initiator, executor)` — the phase an executor starts an action from, or `null` if this executor can't initiate.
|
|
749
|
-
- `should_validate_output(kind, phase)` — true for `receive_request`/`receive_response` on `request_response` and `execute` on `local_call`.
|
|
750
|
-
- `is_action_complete(data)` — `failed`, or `handled` at a terminal phase.
|
|
751
|
-
|
|
752
|
-
Constructors / extractors:
|
|
753
|
-
|
|
754
|
-
- `create_initial_data(kind, phase, method, executor, input)` — produces a well-formed initial-step `ActionEventData` with every nullable field null.
|
|
755
|
-
- `extract_action_result(event): Result<{value}, {error}>` — pulls the terminal outcome. Throws on non-terminal events (programming error).
|
|
756
|
-
|
|
757
|
-
### `action_event.ts`
|
|
758
|
-
|
|
759
|
-
`ActionEvent<TMethod, TPhase, TStep>` — the mutable state-machine class.
|
|
760
|
-
Holds `#data` (current `ActionEventDataUnion`), notifies observers on
|
|
761
|
-
every transition via `observe(listener): () => unsubscribe`. Keeps the
|
|
762
|
-
spec + environment references.
|
|
763
|
-
|
|
764
|
-
Lifecycle methods:
|
|
765
|
-
|
|
766
|
-
- `parse()` — transitions `initial → parsed` by running `spec.input.safeParse(data.input)`. Input validation failures **fail immediately** without routing through an error phase — they're client-side programming errors, not runtime conditions with handlers. Handler errors DO route through `send_error` / `receive_error`. On `receive_response` with an error response, transitions to `receive_error` instead of failing.
|
|
767
|
-
- `handle_async()` / `handle_sync()` — `parsed → handling → handled`. Looks up the registered handler via `environment.lookup_action_handler(method, phase)`. Missing handler skips to `handled` (terminal with no output). Throws routed via `#get_error_phase_for_current_phase`: `send_request`/`receive_request` → `send_error`; `receive_response` → `receive_error`; other phases → `failed`. `ThrownJsonrpcError` preserves code + message + data; other throws become `internal_error`.
|
|
768
|
-
- `transition(phase)` — `handled` at a chainable phase → next phase's `initial`. Uses `#create_phase_data` to carry forward `request` / `response` / `error` / `output` as appropriate.
|
|
769
|
-
- `is_complete()`, `update_progress(progress)`, `set_request(request)`, `set_response(response)`, `set_notification(notification)`.
|
|
770
|
-
|
|
771
|
-
Constructors:
|
|
544
|
+
- `socket_abort_controller` — per-socket, fires on close. Drives every handler's `ctx.signal`.
|
|
545
|
+
- `pending_controllers: Map<JsonrpcRequestId, AbortController>` — per-request. Registered before dispatch, cleared in `finally` so late cancels for a completed id (or a reused id) can't null-abort the wrong handler. Unknown cancels no-op.
|
|
772
546
|
|
|
773
|
-
|
|
774
|
-
- `create_action_event_from_json(json, environment)` — rehydrate after wire transfer.
|
|
775
|
-
- `parse_action_event(raw_json, environment)` — `ActionEventData.parse` + `create_action_event_from_json`.
|
|
547
|
+
## Protocol actions (`actions/protocol.ts`)
|
|
776
548
|
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
it materializes the `JsonrpcNotification`.
|
|
549
|
+
Two shared `{spec, handler}` tuples that every consumer spreads into both
|
|
550
|
+
sides' `actions` arrays — disconnect detection and per-request cancel work
|
|
551
|
+
identically across every repo without per-consumer ping plumbing.
|
|
781
552
|
|
|
782
|
-
|
|
553
|
+
The category is wire-protocol concerns shipped by fuz_app, not consumer
|
|
554
|
+
domain logic. Contrast that matters: protocol vs domain. A future
|
|
555
|
+
clock-skew probe or reconnect-resume token belongs here; a `payment_charge`
|
|
556
|
+
action does not.
|
|
783
557
|
|
|
784
|
-
|
|
785
|
-
registry and `ActionEventEnvironment`. Construct with
|
|
786
|
-
`{environment, transports?, default_send_options?}`.
|
|
558
|
+
Two const arrays:
|
|
787
559
|
|
|
788
|
-
`
|
|
789
|
-
(a
|
|
790
|
-
`transport_name` and `queue` can be defaulted here once to flip the peer
|
|
791
|
-
into client-authoritative mode: `new ActionPeer({..., default_send_options:
|
|
792
|
-
{queue: true}})` durably queues every request_response call by default.
|
|
560
|
+
- `protocol_actions: ReadonlyArray<Action>` — for the server's `register_action_ws` `actions`. Spread before consumer-owned actions.
|
|
561
|
+
- `protocol_action_specs: ReadonlyArray<ActionSpecUnion>` — derived via `.map(a => a.spec)` so the two arrays cannot drift. For the frontend `ActionRegistry`.
|
|
793
562
|
|
|
794
|
-
|
|
563
|
+
Asymmetry intentional — server runs handlers (heartbeat echo + cancel
|
|
564
|
+
stub), frontend registry only stores specs. Both bundles plus the codegen
|
|
565
|
+
`include_protocol_actions: false` default form a three-leg contract.
|
|
795
566
|
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
```
|
|
567
|
+
**Not auto-spread by `create_frontend_rpc_client` or `register_ws_endpoint`** —
|
|
568
|
+
bundled helpers stay pure factories so the dispatch surface stays
|
|
569
|
+
grep-traceable at every consumer registration site and consumers can
|
|
570
|
+
override individual protocol actions without an opt-out flag.
|
|
801
571
|
|
|
802
|
-
|
|
572
|
+
### Individual actions
|
|
803
573
|
|
|
804
|
-
-
|
|
805
|
-
-
|
|
806
|
-
- Delegates to `transport.send(message, {signal, queue: options?.queue ?? default.queue})`.
|
|
807
|
-
- Unexpected throws become `create_jsonrpc_error_response_from_thrown`.
|
|
574
|
+
- **`heartbeat_action`** — `request_response`, `initiator: 'frontend'`, `auth: 'authenticated'`, `side_effects: false`, nullary input/output (`z.strictObject({})`). Handler is a stateless no-op echo. Client's activity-aware heartbeat timer fires this whenever idle past `DEFAULT_HEARTBEAT_INTERVAL`; server's `register_action_ws` heartbeat tracker counts the incoming message as activity.
|
|
575
|
+
- **`cancel_action`** — `remote_notification`, `initiator: 'frontend'`, `auth: null`, `side_effects: true`. Params: `CancelNotificationParams = z.strictObject({request_id: JsonrpcRequestId})`. **Handler is an empty stub** — cancel semantics are dispatcher-owned (`register_action_ws` has the `{request_id → AbortController}` map). Wire format is snake_case `cancel` + `{request_id}`, not MCP's `$/cancelRequest` + `{requestId}` — MCP adoption would happen at an MCP adapter's translation layer, not in the base transport.
|
|
808
576
|
|
|
809
|
-
|
|
577
|
+
## Event state machine
|
|
810
578
|
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
-
|
|
579
|
+
Five modules (`action_event_types.ts`, `action_event_data.ts`,
|
|
580
|
+
`action_event_helpers.ts`, `action_event.ts`, `action_peer.ts`) define a
|
|
581
|
+
discriminated-union-based state machine used by the reactive client to
|
|
582
|
+
track an action through its lifecycle. Per-symbol semantics on TSDoc;
|
|
583
|
+
high-level shapes that span modules:
|
|
814
584
|
|
|
815
|
-
|
|
816
|
-
|
|
585
|
+
- **39-variant discriminated union** — `ActionEventDataUnion<TMethod, TInput, TOutput>` across `kind` + `phase` + `step` (28 for `request_response`, 6 for `remote_notification`, 5 for `local_call`). Narrows `input` / `output` / `error` / `request` / `response` / `notification` / `progress` at each lifecycle point.
|
|
586
|
+
- **Step transitions** — `initial → parsed | failed`, `parsed → handling | failed`, `handling → handled | failed`, `handled`/`failed` terminal. `validate_step_transition(from, to)` throws on illegal moves.
|
|
587
|
+
- **Phase transitions** — chained: `send_request → receive_response`, `receive_request → send_response`; everything else terminal. `validate_phase_for_kind` + `validate_phase_transition` enforce.
|
|
588
|
+
- **`ActionEvent.parse()`** — `initial → parsed` via `spec.input.safeParse`. Input validation failures **fail immediately** without routing through an error phase (client-side programming errors, not runtime conditions with handlers). Handler errors DO route through `send_error` / `receive_error`. On `receive_response` with error response, transitions to `receive_error` instead of failing.
|
|
589
|
+
- **Protocol message creation is automatic** — transitioning `parsed → handling` on `send_request` materializes the outgoing `JsonrpcRequest` with a fresh `create_uuid()` id; on `send` (notification) it materializes the `JsonrpcNotification`.
|
|
817
590
|
|
|
818
|
-
|
|
591
|
+
`ActionPeer` is symmetric send + receive over a `Transports` registry and
|
|
592
|
+
`ActionEventEnvironment`. `default_send_options` excludes `signal`
|
|
593
|
+
deliberately — a shared signal would abort every subsequent call after the
|
|
594
|
+
first trip. `transport_name` and `queue` can be defaulted here once to
|
|
595
|
+
flip the peer into client-authoritative mode.
|
|
819
596
|
|
|
820
|
-
|
|
821
|
-
sides' `actions` arrays — disconnect detection and per-request cancel work
|
|
822
|
-
identically across every repo without per-consumer ping plumbing.
|
|
597
|
+
## Reactive frontend client
|
|
823
598
|
|
|
824
|
-
|
|
825
|
-
domain logic. The contrast that matters is protocol vs domain: a future
|
|
826
|
-
clock-skew probe or reconnect-resume token belongs in this bundle; a
|
|
827
|
-
`payment_charge` action does not. Avoid the framing "composable vs
|
|
828
|
-
non-composable" — every `Action` is composable by the same mechanism
|
|
829
|
-
(spread into the `actions` array), so the distinction would not carve
|
|
830
|
-
nature at the joints.
|
|
831
|
-
|
|
832
|
-
### Canonical bundles (`protocol.ts`)
|
|
833
|
-
|
|
834
|
-
Two const arrays declare the canonical protocol-action set so consumers
|
|
835
|
-
spread one symbol per side instead of importing each primitive
|
|
836
|
-
individually:
|
|
837
|
-
|
|
838
|
-
- `protocol_actions: ReadonlyArray<Action>` — for the server's
|
|
839
|
-
`register_action_ws` `actions` array. Spread before consumer-owned
|
|
840
|
-
actions: `actions: [...protocol_actions, ...consumer_actions]`.
|
|
841
|
-
- `protocol_action_specs: ReadonlyArray<ActionSpecUnion>` — derived via
|
|
842
|
-
`.map(a => a.spec)` so the two arrays cannot drift. For the frontend
|
|
843
|
-
`ActionRegistry`. Spread before consumer-owned specs:
|
|
844
|
-
`new ActionRegistry([...protocol_action_specs, ...action_specs])`.
|
|
845
|
-
|
|
846
|
-
The asymmetry is intentional — the server runs handlers (heartbeat echo +
|
|
847
|
-
cancel stub), the frontend registry only stores specs. Both bundles plus
|
|
848
|
-
the codegen `include_protocol_actions: false` default form a three-leg
|
|
849
|
-
contract: codegen excludes protocol actions from generated typed surfaces
|
|
850
|
-
because consumers spread these bundles in at registration time.
|
|
851
|
-
|
|
852
|
-
The bundles are **not** auto-spread by `create_frontend_rpc_client` or
|
|
853
|
-
`register_ws_endpoint` — bundled helpers stay pure factories so the
|
|
854
|
-
dispatch surface stays grep-traceable at every consumer registration site
|
|
855
|
-
and consumers can override individual protocol actions (custom heartbeat,
|
|
856
|
-
etc.) without an opt-out flag.
|
|
857
|
-
|
|
858
|
-
### `heartbeat_action`
|
|
859
|
-
|
|
860
|
-
Method `'heartbeat'`, `request_response`, `initiator: 'frontend'`, `auth:
|
|
861
|
-
'authenticated'`, `side_effects: false`, nullary input/output
|
|
862
|
-
(`z.strictObject({})`). Handler is a stateless no-op echo. The client's
|
|
863
|
-
activity-aware heartbeat timer (`FrontendWebsocketClient.#heartbeat_tick`)
|
|
864
|
-
fires this whenever idle past `DEFAULT_HEARTBEAT_INTERVAL`; the server's
|
|
865
|
-
`register_action_ws` heartbeat tracker counts the incoming message as
|
|
866
|
-
activity and resets `last_receive_time`.
|
|
867
|
-
|
|
868
|
-
### `cancel_action`
|
|
869
|
-
|
|
870
|
-
Method `'cancel'`, `remote_notification`, `initiator: 'frontend'`, `auth:
|
|
871
|
-
null`, `side_effects: true`. Params: `CancelNotificationParams =
|
|
872
|
-
z.strictObject({request_id: JsonrpcRequestId})`. The **handler is an empty
|
|
873
|
-
stub** — cancel semantics are dispatcher-owned
|
|
874
|
-
(`register_action_ws` has the `{request_id → AbortController}` map, not the
|
|
875
|
-
handler). The tuple exists for symmetry + so `spec_by_method` knows about
|
|
876
|
-
it (enables input validation on incoming cancels) + so `create_rpc_client`
|
|
877
|
-
sees the method.
|
|
878
|
-
|
|
879
|
-
Wire format is snake_case `cancel` + `{request_id}`, not MCP's
|
|
880
|
-
`$/cancelRequest` + `{requestId}`. MCP adoption would happen at an MCP
|
|
881
|
-
adapter's translation layer, not in the base transport.
|
|
882
|
-
|
|
883
|
-
## Reactive frontend client (`socket.svelte.ts`, `request_tracker.svelte.ts`)
|
|
884
|
-
|
|
885
|
-
### `FrontendWebsocketClient`
|
|
599
|
+
### `FrontendWebsocketClient` (`actions/socket.svelte.ts`)
|
|
886
600
|
|
|
887
601
|
Portable, Svelte-reactive (`$state.raw` for `ws`, `status`, `reconnect_count`,
|
|
888
|
-
|
|
889
|
-
`
|
|
890
|
-
Cell inheritance, no app coupling. Implements `WebsocketConnection` +
|
|
891
|
-
`WebsocketRpcConnection`, and is `Disposable`.
|
|
602
|
+
etc.). Plain class — no Cell inheritance, no app coupling. Implements
|
|
603
|
+
`WebsocketConnection` + `WebsocketRpcConnection`, and is `Disposable`.
|
|
892
604
|
|
|
893
605
|
Ships three correctness primitives default-on:
|
|
894
606
|
|
|
895
|
-
1. **Promise-based `request`** — auto-assigned monotonic id
|
|
607
|
+
1. **Promise-based `request`** — auto-assigned monotonic id; pending map keyed by id; resolved via intercept on the message path. Rejects `ThrownJsonrpcError` with specific codes (`unauthenticated`, `request_cancelled`, `queue_overflow`, `service_unavailable`, `internal_error`, or the server's wire code verbatim). The transport catch block preserves `.code` exactly so `FrontendWebsocketTransport` never collapses to `internal_error`.
|
|
896
608
|
2. **Durable queue** — `request()` calls while disconnected buffer up to `DEFAULT_QUEUE_MAX_SIZE = 100` and flush on reopen. Overflow rejects `queue_overflow`. Pass `{queue: false}` to reject immediately (used internally by the heartbeat — it must not fight the queue for the disconnect-detection slot). Raw `send(data)` is **drop-on-disconnect** by design (fire-and-forget notifications want that).
|
|
897
|
-
3. **Activity-aware heartbeat** — idles past `DEFAULT_HEARTBEAT_INTERVAL = 30_000` fire the shared `heartbeat` request. Receive-silence past `DEFAULT_HEARTBEAT_RECEIVE_TIMEOUT = 60_000` closes with `WS_CLOSE_CLIENT_HEARTBEAT_TIMEOUT`. Tick runs at `interval / 2` so event-loop blockage pauses the timer itself
|
|
609
|
+
3. **Activity-aware heartbeat** — idles past `DEFAULT_HEARTBEAT_INTERVAL = 30_000` fire the shared `heartbeat` request. Receive-silence past `DEFAULT_HEARTBEAT_RECEIVE_TIMEOUT = 60_000` closes with `WS_CLOSE_CLIENT_HEARTBEAT_TIMEOUT`. Tick runs at `interval / 2` so event-loop blockage pauses the timer itself.
|
|
898
610
|
|
|
899
|
-
Reconnect policy (exponential backoff): `delay = DEFAULT_RECONNECT_DELAY * DEFAULT_BACKOFF_FACTOR ** (attempts-1)`,
|
|
611
|
+
Reconnect policy (exponential backoff): `delay = DEFAULT_RECONNECT_DELAY * DEFAULT_BACKOFF_FACTOR ** (attempts-1)`,
|
|
612
|
+
capped at `DEFAULT_RECONNECT_DELAY_MAX`. `WS_CLOSE_SESSION_REVOKED` is
|
|
613
|
+
**terminal** — sets `#revoked = true`, no reconnect loop on 401.
|
|
900
614
|
|
|
901
|
-
Live policy swaps (behave like constructor — whole policy atomic, missing
|
|
615
|
+
Live policy swaps (behave like constructor — whole policy atomic, missing
|
|
616
|
+
fields fall back to defaults, not "keep current"): `set_reconnect`,
|
|
617
|
+
`set_heartbeat`, `cancel_reconnect`.
|
|
902
618
|
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
- `
|
|
619
|
+
`SocketStatus = 'initial' | 'connecting' | 'connected' | 'reconnecting' | 'closed'`.
|
|
620
|
+
`socket_status_to_async_status(status, revoked)` collapses to fuz_util's
|
|
621
|
+
4-way `AsyncStatus`.
|
|
906
622
|
|
|
907
|
-
|
|
623
|
+
### `RequestTracker` (`actions/request_tracker.svelte.ts`)
|
|
908
624
|
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
625
|
+
Public utility — reactive pending-request state with timeouts.
|
|
626
|
+
`SvelteMap` keyed by `JsonrpcRequestId`, default `request_timeout_ms = 120_000`.
|
|
627
|
+
Used by transports that don't delegate pending correlation to a
|
|
628
|
+
`WebsocketRpcConnection` (`FrontendWebsocketTransport` delegates to
|
|
629
|
+
`FrontendWebsocketClient`'s own `#pending` map).
|
|
914
630
|
|
|
915
|
-
|
|
631
|
+
## RPC client (`actions/rpc_client.ts`)
|
|
916
632
|
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
`deferred`, `created` (ISO datetime), reactive `status: AsyncStatus`, and
|
|
920
|
-
`timeout`. Default `request_timeout_ms = 120_000`.
|
|
633
|
+
`create_rpc_client({peer, environment, actions?, transport_for_method?})` —
|
|
634
|
+
returns a Proxy-based typed API. Per-kind dispatch:
|
|
921
635
|
|
|
922
|
-
|
|
923
|
-
`
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
(rejects all with `internal_error`).
|
|
636
|
+
- **`local_call` sync** — `parse().handle_sync()`, return value directly. Throws on error (sync can't return `Result`). Ignores `signal`.
|
|
637
|
+
- **`local_call` async** — `parse().handle_async()`, return `Result<{value}, {error}>`. Pre-flight `signal.aborted` check short-circuits.
|
|
638
|
+
- **`request_response`** — builds `ActionEvent`, runs `parse().handle_async()` to produce the request, calls `peer.send(request, {transport_name, signal, queue})`, transitions to `receive_response`, wires the response, parses (may transition to `receive_error`), runs handler, extracts `Result`.
|
|
639
|
+
- **`remote_notification`** — builds event, creates notification, `peer.send(notification, {transport_name, signal, queue})`. Returns `Result<{value: void}, {error}>`.
|
|
927
640
|
|
|
928
|
-
|
|
929
|
-
`
|
|
930
|
-
|
|
641
|
+
`RpcClientCallOptions extends ActionPeerSendOptions` — `{signal?, queue?, transport_name?}`.
|
|
642
|
+
`transport_for_method: (method) => TransportName | undefined` for per-method
|
|
643
|
+
selection. `on_action_event(event)` fires once per dispatched action with
|
|
644
|
+
the live `ActionEvent` (zzz wires reactive history here).
|
|
931
645
|
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
`create_rpc_client({peer, environment, actions?, transport_for_method?})` —
|
|
935
|
-
returns a Proxy-based typed API. Method name → action method via
|
|
936
|
-
`environment.lookup_action_spec`. Dispatches based on the spec's `kind`:
|
|
937
|
-
|
|
938
|
-
- `local_call` (sync) — execute `parse().handle_sync()`, return value directly. Throws on error (sync methods can't return `Result`). Ignores `signal` (no cooperative interrupt mid-handler).
|
|
939
|
-
- `local_call` (async) — `parse().handle_async()`, return `Result<{value}, {error}>`. Pre-flight `signal.aborted` check short-circuits with `internal_error`.
|
|
940
|
-
- `request_response` — builds `ActionEvent`, runs `parse().handle_async()` to produce the outgoing `request`, calls `peer.send(request, {transport_name, signal, queue})`, transitions to `receive_response`, wires the response via `set_response`, parses (may transition to `receive_error`), runs handler, extracts `Result`.
|
|
941
|
-
- `remote_notification` — builds event, creates notification, `peer.send(notification, {transport_name, signal, queue})`. Returns `Result<{value: void}, {error}>`.
|
|
942
|
-
|
|
943
|
-
Per-call options: `RpcClientCallOptions extends ActionPeerSendOptions` —
|
|
944
|
-
`{signal?, queue?, transport_name?}`. `transport_name` overrides
|
|
945
|
-
per-method `transport_for_method` selector for this call.
|
|
946
|
-
|
|
947
|
-
`transport_for_method: (method) => TransportName | undefined` — optional
|
|
948
|
-
per-method transport selector. Useful when methods are registered on
|
|
949
|
-
different backend dispatchers (e.g. streaming action on WS, rest on HTTP).
|
|
950
|
-
Returning `undefined` falls through to the peer's default selection.
|
|
951
|
-
|
|
952
|
-
`on_action_event: (event: ActionEvent<keyof TApi & string>) => void` —
|
|
953
|
-
optional callback fired once per dispatched action with the live
|
|
954
|
-
`ActionEvent`. Consumers wire reactive state inside the callback — e.g.
|
|
955
|
-
zzz's `Actions` cell calls its own `add_from_json` +
|
|
956
|
-
`listen_to_action_event` here so the history plumbing stays inside zzz
|
|
957
|
-
instead of leaking onto the rpc_client surface. `event.spec.method` and
|
|
958
|
-
`event.data.method` narrow to `keyof TApi & string` so consumers passing
|
|
959
|
-
a generated `FrontendActionsApi` get the literal method-name union without
|
|
960
|
-
an `as ActionMethod` cast at the call site.
|
|
961
|
-
|
|
962
|
-
Cast the return to a generated `FrontendActionsApi` interface for full
|
|
963
|
-
typing: codegen via `generate_actions_api_method_signature` keeps the
|
|
964
|
-
shape consistent. See ../../docs/usage.md §Typed Client Codegen.
|
|
965
|
-
|
|
966
|
-
### Throwing variants — `create_throwing_rpc_call` + `create_throwing_api`
|
|
967
|
-
|
|
968
|
-
Two helpers wrap a typed `create_rpc_client` Proxy so `{ok: false}` results
|
|
969
|
-
throw an `Error` with `{code, message, data?}` (catch blocks read
|
|
970
|
-
`err.data?.reason` — optional chaining required because JSON-RPC `data`
|
|
971
|
-
is spec-level optional). Same hardening on both: only `{code, data}` cross
|
|
972
|
-
onto the Error, leaving `name` / `stack` as the native Error's own so
|
|
973
|
-
attacker-shaped `result.error` payloads cannot overwrite them.
|
|
646
|
+
### Throwing variants
|
|
974
647
|
|
|
975
648
|
| Helper | Shape | Use at |
|
|
976
649
|
| -------------------------- | ------------------------------------- | -------------------------------------------------------------------------- |
|
|
977
|
-
| `create_throwing_rpc_call` | `(method, input?) => Promise<T>` |
|
|
978
|
-
| `create_throwing_api` |
|
|
979
|
-
|
|
980
|
-
**Layered design.** Result is the protocol primitive —
|
|
981
|
-
returns `Result<{value}, {error}>`
|
|
982
|
-
throwing wrappers sit _above_ it as ergonomic adapters;
|
|
983
|
-
the same underlying transport and call sites pick
|
|
984
|
-
preferable when the call site inspects
|
|
985
|
-
allocation, no try/catch
|
|
986
|
-
storms, hot paths). Throwing is preferable when the call site
|
|
987
|
-
inspect — `await api.foo()` reads cleaner than the `if (!r.ok) throw …`
|
|
650
|
+
| `create_throwing_rpc_call` | `(method, input?) => Promise<T>` | Adapter wiring (e.g. `ui/admin_rpc_adapters.ts`) — method comes from a map |
|
|
651
|
+
| `create_throwing_api` | Typed Proxy over `FrontendActionsApi` | Direct call sites — `await api.foo(input)` keeps full inference |
|
|
652
|
+
|
|
653
|
+
**Layered design.** `Result` is the protocol primitive —
|
|
654
|
+
`create_rpc_client` returns `Result<{value}, {error}>` with no Error
|
|
655
|
+
allocation. The throwing wrappers sit _above_ it as ergonomic adapters;
|
|
656
|
+
both shapes share the same underlying transport and call sites pick
|
|
657
|
+
per-site. `Result` is preferable when the call site inspects
|
|
658
|
+
`error.data.reason` (no allocation, no try/catch) or when overhead matters
|
|
659
|
+
(reconnect storms, hot paths). Throwing is preferable when the call site
|
|
660
|
+
doesn't inspect — `await api.foo()` reads cleaner than the `if (!r.ok) throw …`
|
|
988
661
|
ritual.
|
|
989
662
|
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
as the generic to get the typed Result-shaped Proxy without casts, then
|
|
994
|
-
build the throwing form on top:
|
|
995
|
-
|
|
996
|
-
```ts
|
|
997
|
-
const api_result = create_rpc_client<FrontendActionsApi>({peer, environment});
|
|
998
|
-
const api = create_throwing_api(api_result);
|
|
999
|
-
// hot path: await api.foo(input)
|
|
1000
|
-
// rare branch: const r = await api_result.foo(input); if (!r.ok) { … }
|
|
1001
|
-
```
|
|
1002
|
-
|
|
1003
|
-
`create_throwing_rpc_call` is **not** a peer choice for direct call sites —
|
|
1004
|
-
it's a niche primitive for method-name-mapping adapter factories
|
|
1005
|
-
(`ui/admin_rpc_adapters.ts`) where the method string comes from a domain
|
|
1006
|
-
mapping rather than a typed call site. Use it only at adapter boundaries.
|
|
663
|
+
Hardening on both: only `{code, data}` cross onto the Error, leaving
|
|
664
|
+
`name` / `stack` as the native Error's own so attacker-shaped
|
|
665
|
+
`result.error` payloads cannot overwrite them.
|
|
1007
666
|
|
|
1008
667
|
`ThrowingApi<TApi>` (the mapped type returned by `create_throwing_api`)
|
|
1009
668
|
strips `Promise<Result<{value: T}, {error: JsonrpcErrorObject}>>` to
|
|
1010
|
-
`Promise<T>` on every method
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
non-Result returns flow through unchanged.
|
|
669
|
+
`Promise<T>` on every method matching the `request_response` / async
|
|
670
|
+
`local_call` return shape; `remote_notification` and sync `local_call`
|
|
671
|
+
methods pass through. The Proxy inspects each call's result shape at
|
|
672
|
+
runtime and only unwraps when it sees a Result.
|
|
1015
673
|
|
|
1016
674
|
Both helpers throw `"rpc method not found: <name>"` on invocation of an
|
|
1017
|
-
unknown method.
|
|
1018
|
-
|
|
1019
|
-
message rather than the JS default `"api.missing is not a function"`.
|
|
1020
|
-
Symbol props and `then` stay `undefined` so the Proxy doesn't get
|
|
1021
|
-
probed as a thenable by `await`.
|
|
675
|
+
unknown method. Symbol props and `then` stay `undefined` so the Proxy
|
|
676
|
+
doesn't get probed as a thenable by `await`.
|
|
1022
677
|
|
|
1023
|
-
### Frontend factory (`frontend_rpc_client.ts`)
|
|
678
|
+
### Frontend factory (`actions/frontend_rpc_client.ts`)
|
|
1024
679
|
|
|
1025
680
|
`create_frontend_rpc_client<TApi>({specs, path?, transports?, transport_for_method?, on_action_event?})`
|
|
1026
|
-
bundles
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
inside the helper, so call sites get a typed return without the cast
|
|
1032
|
-
hostility.
|
|
681
|
+
bundles `ActionRegistry + ActionEventEnvironment + Transports + ActionPeer +
|
|
682
|
+
create_rpc_client + create_throwing_api` boilerplate every consumer
|
|
683
|
+
repeats — plus the `lookup_action_handler: () => undefined` stub (frontend
|
|
684
|
+
never registers `request_response` handlers; every method dispatches over
|
|
685
|
+
the wire).
|
|
1033
686
|
|
|
1034
687
|
Returns both Proxy shapes from one factory call:
|
|
1035
688
|
|
|
1036
689
|
- `api: ThrowingApi<TApi>` — typed throwing Proxy. Default for hot-path call sites.
|
|
1037
690
|
- `api_result: TApi` — typed Result-shaped Proxy. For sites that inspect `error.data.reason` without try/catch.
|
|
1038
|
-
- `peer`, `environment` — exposed for advanced consumers
|
|
1039
|
-
|
|
1040
|
-
```ts
|
|
1041
|
-
const {api, api_result} = create_frontend_rpc_client<FrontendActionsApi>({
|
|
1042
|
-
specs: all_standard_action_specs,
|
|
1043
|
-
});
|
|
1044
|
-
// hot path: await api.account_verify()
|
|
1045
|
-
// rare branch: const r = await api_result.account_verify(); if (!r.ok) { … }
|
|
1046
|
-
```
|
|
691
|
+
- `peer`, `environment` — exposed for advanced consumers.
|
|
1047
692
|
|
|
1048
693
|
Default transport is `FrontendHttpTransport(path ?? '/api/rpc')`. Pass
|
|
1049
|
-
`transports` for WS-first or mixed setups
|
|
1050
|
-
|
|
1051
|
-
silently no-op because `lookup_action_handler` always returns
|
|
1052
|
-
`undefined
|
|
1053
|
-
|
|
1054
|
-
`all_standard_action_specs` is transport-agnostic — when a consumer
|
|
1055
|
-
spreads `create_standard_rpc_actions(ctx.deps, opts)` into both
|
|
1056
|
-
`rpc_endpoints` AND `ws_endpoints` on `create_app_server`, every method
|
|
1057
|
-
is reachable on both transports and `transport_for_method` can route
|
|
1058
|
-
per-call (e.g. return `'frontend_websocket_rpc'` for `account_*` /
|
|
1059
|
-
`admin_*` methods to bind them to the live WS connection).
|
|
1060
|
-
|
|
1061
|
-
`transport_for_method` and `on_action_event` are pure pass-throughs to
|
|
1062
|
-
`create_rpc_client` — exposed so consumers needing per-method routing
|
|
1063
|
-
(zap-style WS-for-actions / HTTP-for-rest split) or per-dispatch event
|
|
1064
|
-
wiring (zzz-style reactive Cells observing `ActionEvent` lifecycle)
|
|
1065
|
-
don't have to drop down to manual `create_rpc_client` construction
|
|
1066
|
-
(which forfeits the bundled `api` / `api_result` pair).
|
|
694
|
+
`transports` for WS-first or mixed setups (the default HTTP transport is
|
|
695
|
+
**not** registered when `transports` is supplied). `local_call` specs in
|
|
696
|
+
`specs` silently no-op because `lookup_action_handler` always returns
|
|
697
|
+
`undefined`.
|
|
1067
698
|
|
|
1068
699
|
`all_standard_action_specs` (in `auth/standard_action_specs.ts`) is
|
|
1069
|
-
|
|
1070
|
-
|
|
700
|
+
transport-agnostic — when a consumer spreads `create_standard_rpc_actions`
|
|
701
|
+
into both `rpc_endpoints` AND `ws_endpoints`, `transport_for_method` can
|
|
702
|
+
route per-call (e.g. return `'frontend_websocket_rpc'` for `account_*` /
|
|
703
|
+
`admin_*` methods to bind them to the live WS connection). See
|
|
704
|
+
`auth/CLAUDE.md` §Standard RPC bundle.
|
|
1071
705
|
|
|
1072
|
-
## Broadcast API (`broadcast_api.ts`)
|
|
706
|
+
## Broadcast API (`actions/broadcast_api.ts`)
|
|
1073
707
|
|
|
1074
708
|
`create_broadcast_api({peer, specs, log?, should_deliver?})` — builds a
|
|
1075
709
|
typed `{method: (input) => Promise<void>}` object from a list of action
|
|
@@ -1078,7 +712,7 @@ request-scoped dispatch, this handles backend-initiated broadcast.
|
|
|
1078
712
|
Request-scoped streaming stays on `ctx.notify` inside a handler.
|
|
1079
713
|
|
|
1080
714
|
Per-method call: validates input against `spec.input` (logs + returns on
|
|
1081
|
-
failure), wraps in
|
|
715
|
+
failure), wraps in `JsonrpcNotification`, sends via the peer's resolved
|
|
1082
716
|
transport. `transport_name` on `peer.default_send_options` pins the target
|
|
1083
717
|
deterministically — no fallback, because broadcast is 1→N over a specific
|
|
1084
718
|
primary transport and "any ready transport" could reach an unexpected
|
|
@@ -1087,68 +721,19 @@ audience. Silently skips when no ready transport.
|
|
|
1087
721
|
`should_deliver: (identity, method, input) => boolean` — optional
|
|
1088
722
|
per-connection ACL predicate. When set, fans out via
|
|
1089
723
|
`transport.broadcast_filtered` (feature-detected via
|
|
1090
|
-
`is_filterable_broadcast_transport`). Errors
|
|
1091
|
-
|
|
724
|
+
`is_filterable_broadcast_transport`). Errors logged but never thrown —
|
|
725
|
+
broadcasts are fire-and-forget.
|
|
1092
726
|
|
|
1093
727
|
Typed surface: consumers declare an explicit `interface BackendActionsApi`
|
|
1094
|
-
and pin
|
|
1095
|
-
cast, so
|
|
1096
|
-
natural fit
|
|
1097
|
-
|
|
1098
|
-
## Shared type surface (`action_types.ts`)
|
|
1099
|
-
|
|
1100
|
-
Sits above `action_spec.ts` (pure Zod) and below the dispatchers
|
|
1101
|
-
(`register_action_ws.ts`, `action_rpc.ts`, `perform_action.ts`).
|
|
1102
|
-
Extracted so composable primitives (e.g. `heartbeat_action`) can name the
|
|
1103
|
-
types without pulling in server-only modules.
|
|
1104
|
-
|
|
1105
|
-
This is the polymorphic `Action` shape only. The unified `ActionContext`
|
|
1106
|
-
from `action_rpc.ts` is the single handler context across every
|
|
1107
|
-
transport; `ActionHandler` is the single handler signature.
|
|
1108
|
-
|
|
1109
|
-
- `Action<TSpec>` — `{spec: TSpec, handler?: ActionHandler}`. The composable unit passed to both sides' `actions` arrays. Polymorphic on `kind`: `request_response` specs require a handler for dispatch; `remote_notification` specs may declare a stub for symmetry but are dispatcher-handled (e.g. `cancel`); `local_call` specs never reach a network dispatcher. The WS dispatcher only invokes handlers on `request_response` actions; everything else is registry-only.
|
|
728
|
+
and pin via `create_broadcast_api<BackendActionsApi>({...})` — unchecked
|
|
729
|
+
cast, so interface and `specs` array must stay in sync (codegen is a
|
|
730
|
+
natural fit).
|
|
1110
731
|
|
|
1111
|
-
|
|
1112
|
-
is the narrowing the HTTP RPC dispatcher accepts (`create_rpc_endpoint`)
|
|
1113
|
-
and the `rpc_action` binder produces (the actor-axis narrowing lives
|
|
1114
|
-
in `HandlerForSpec<TSpec>`).
|
|
732
|
+
## Shared type surface (`actions/action_types.ts`)
|
|
1115
733
|
|
|
1116
|
-
|
|
734
|
+
Sits above `action_spec.ts` (pure Zod) and below the dispatchers. Extracted
|
|
735
|
+
so composable primitives (e.g. `heartbeat_action`) can name the types
|
|
736
|
+
without pulling in server-only modules.
|
|
1117
737
|
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
envelope + connection identity, calls `perform_action(input, deps)`,
|
|
1121
|
-
and binds the discriminated `PerformActionResult` to its wire shape.
|
|
1122
|
-
|
|
1123
|
-
Pipeline (401 → 400 → 403 → handler):
|
|
1124
|
-
|
|
1125
|
-
1. Pre-validation auth (401) — short-circuits unauthenticated callers on `'required'` axes before input validation.
|
|
1126
|
-
2. Validate params (400) — `spec.input.safeParse` with `z.void()` / `?? {}` rules.
|
|
1127
|
-
3. Authorization phase — `apply_authorization_phase` against `account_id` + `validated_input.acting`. Test escape hatch lives in the caller — pass `preset.request_context` to skip the live phase.
|
|
1128
|
-
4. Post-authorization auth (403) — credential-type gate first, role gate second.
|
|
1129
|
-
5. Rate limit (429) — per-action IP / account throttling, throttle-requests semantics (every invocation records).
|
|
1130
|
-
6. Dispatch + DEV output validation + error normalization — `spec.side_effects` picks transaction (`deps.db.transaction`) vs pool. Handler throws roll back the transaction; `ThrownJsonrpcError` preserves code + data, generic throws become `internal_error`.
|
|
1131
|
-
|
|
1132
|
-
`PerformActionInput` carries `account_id`, `credential_type`, `client_ip`,
|
|
1133
|
-
`signal`, `notify`, optional `connection_id`, optional `preset`.
|
|
1134
|
-
`PerformActionDeps` carries `db` (pool-level), `pending_effects`, `log`,
|
|
1135
|
-
the two rate limiters. Audit writes are out-of-band: factories close over
|
|
1136
|
-
`AppDeps.audit` independently. `PerformActionResult` is `{kind: 'ok',
|
|
1137
|
-
result} | {kind: 'error', error, status}`; `perform_action_result_to_envelope(id, result)`
|
|
1138
|
-
builds the JSON-RPC wire shape both transports send.
|
|
1139
|
-
|
|
1140
|
-
## DEV-only output validation — uniform across surfaces
|
|
1141
|
-
|
|
1142
|
-
The critical invariant: every action-handler surface applies DEV-only
|
|
1143
|
-
output validation and produces the **same failure mode** — log an error,
|
|
1144
|
-
return the response unchanged, do not throw, do not mutate status.
|
|
1145
|
-
|
|
1146
|
-
| Surface | Code location | Hot path under production |
|
|
1147
|
-
| ----------------------------- | -------------------------------------------------------------------------------------------------------------------------- | ------------------------- |
|
|
1148
|
-
| REST bridge | `http/route_spec.ts` — `wrap_output_validation` (applied via `apply_route_specs`; inherited by `create_action_route_spec`) | short-circuit (no parse) |
|
|
1149
|
-
| HTTP RPC + WebSocket dispatch | `actions/perform_action.ts` — `if (DEV) spec.output.safeParse(output)` inside the shared dispatch core | short-circuit (no parse) |
|
|
1150
|
-
|
|
1151
|
-
Caller-facing `input` schemas are validated **always** (DEV + production) —
|
|
1152
|
-
they're the contract with external callers. Server-authored `output`
|
|
1153
|
-
schemas are internal data. See ../../docs/architecture.md §DEV-only Output
|
|
1154
|
-
Validation for the full rationale.
|
|
738
|
+
- `Action<TSpec>` — `{spec: TSpec, handler?: ActionHandler}`. Polymorphic on `kind`: `request_response` specs require a handler for dispatch; `remote_notification` specs may declare a stub for symmetry but are dispatcher-handled (e.g. `cancel`); `local_call` specs never reach a network dispatcher.
|
|
739
|
+
- `RpcAction = Action<RequestResponseActionSpec> & {handler: ActionHandler}` — narrowing the HTTP RPC dispatcher accepts (`create_rpc_endpoint`) and the `rpc_action` binder produces.
|