@fuzdev/fuz_app 0.64.0 → 0.66.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/actions/CLAUDE.md +510 -946
- package/dist/actions/action_codegen.d.ts +1 -1
- package/dist/actions/action_codegen.js +1 -1
- package/dist/actions/action_event_data.d.ts +1 -1
- 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 +570 -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/audit_log_schema.js +2 -2
- 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/daemon_token_middleware.d.ts +15 -5
- package/dist/auth/daemon_token_middleware.d.ts.map +1 -1
- package/dist/auth/daemon_token_middleware.js +24 -15
- package/dist/auth/invite_queries.d.ts +17 -7
- package/dist/auth/invite_queries.d.ts.map +1 -1
- package/dist/auth/invite_queries.js +19 -8
- package/dist/auth/keyring.d.ts +6 -6
- package/dist/auth/keyring.js +8 -8
- package/dist/auth/role_grant_offer_actions.d.ts.map +1 -1
- package/dist/auth/role_grant_offer_actions.js +4 -2
- package/dist/auth/signup_routes.d.ts +47 -1
- package/dist/auth/signup_routes.d.ts.map +1 -1
- package/dist/auth/signup_routes.js +103 -52
- 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/env/resolve.d.ts +44 -7
- package/dist/env/resolve.d.ts.map +1 -1
- package/dist/env/resolve.js +94 -27
- package/dist/http/CLAUDE.md +243 -522
- 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/jsonrpc.d.ts +23 -7
- package/dist/http/jsonrpc.d.ts.map +1 -1
- package/dist/http/jsonrpc.js +19 -3
- package/dist/http/origin.d.ts +1 -1
- package/dist/http/origin.js +1 -1
- package/dist/http/surface.d.ts +9 -2
- package/dist/http/surface.d.ts.map +1 -1
- package/dist/runtime/mock.d.ts +1 -1
- package/dist/runtime/mock.js +2 -2
- 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 +740 -418
- 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 +230 -216
- package/dist/testing/app_server.d.ts +141 -39
- package/dist/testing/app_server.d.ts.map +1 -1
- package/dist/testing/app_server.js +157 -44
- package/dist/testing/audit_completeness.d.ts +25 -22
- package/dist/testing/audit_completeness.d.ts.map +1 -1
- package/dist/testing/audit_completeness.js +198 -159
- 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/backend_config.d.ts +113 -0
- package/dist/testing/cross_backend/backend_config.d.ts.map +1 -0
- package/dist/testing/cross_backend/backend_config.js +1 -0
- package/dist/testing/cross_backend/bench/bench_report.d.ts +46 -0
- package/dist/testing/cross_backend/bench/bench_report.d.ts.map +1 -0
- package/dist/testing/cross_backend/bench/bench_report.js +83 -0
- package/dist/testing/cross_backend/bench/run_cross_impl_bench.d.ts +44 -0
- package/dist/testing/cross_backend/bench/run_cross_impl_bench.d.ts.map +1 -0
- package/dist/testing/cross_backend/bench/run_cross_impl_bench.js +38 -0
- package/dist/testing/cross_backend/bench/scenario.d.ts +57 -0
- package/dist/testing/cross_backend/bench/scenario.d.ts.map +1 -0
- package/dist/testing/cross_backend/bench/scenario.js +28 -0
- package/dist/testing/cross_backend/bootstrap_backend.d.ts +41 -0
- package/dist/testing/cross_backend/bootstrap_backend.d.ts.map +1 -0
- package/dist/testing/cross_backend/bootstrap_backend.js +34 -0
- package/dist/testing/cross_backend/build_test_backend_paths.d.ts +24 -0
- package/dist/testing/cross_backend/build_test_backend_paths.d.ts.map +1 -0
- package/dist/testing/cross_backend/build_test_backend_paths.js +33 -0
- package/dist/testing/cross_backend/capabilities.d.ts +65 -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/default_backend_configs.d.ts +122 -0
- package/dist/testing/cross_backend/default_backend_configs.d.ts.map +1 -0
- package/dist/testing/cross_backend/default_backend_configs.js +111 -0
- package/dist/testing/cross_backend/default_secrets.d.ts +40 -0
- package/dist/testing/cross_backend/default_secrets.d.ts.map +1 -0
- package/dist/testing/cross_backend/default_secrets.js +39 -0
- package/dist/testing/cross_backend/default_spine_surface.d.ts +64 -0
- package/dist/testing/cross_backend/default_spine_surface.d.ts.map +1 -0
- package/dist/testing/cross_backend/default_spine_surface.js +121 -0
- package/dist/testing/cross_backend/setup.d.ts +451 -0
- package/dist/testing/cross_backend/setup.d.ts.map +1 -0
- package/dist/testing/cross_backend/setup.js +581 -0
- package/dist/testing/cross_backend/spawn_backend.d.ts +58 -0
- package/dist/testing/cross_backend/spawn_backend.d.ts.map +1 -0
- package/dist/testing/cross_backend/spawn_backend.js +229 -0
- package/dist/testing/cross_backend/spine_stub_backend_config.d.ts +66 -0
- package/dist/testing/cross_backend/spine_stub_backend_config.d.ts.map +1 -0
- package/dist/testing/cross_backend/spine_stub_backend_config.js +49 -0
- package/dist/testing/cross_backend/sse_round_trip.d.ts +37 -0
- package/dist/testing/cross_backend/sse_round_trip.d.ts.map +1 -0
- package/dist/testing/cross_backend/sse_round_trip.js +137 -0
- package/dist/testing/cross_backend/standard.d.ts +96 -0
- package/dist/testing/cross_backend/standard.d.ts.map +1 -0
- package/dist/testing/cross_backend/standard.js +49 -0
- package/dist/testing/cross_backend/testing_reset_actions.d.ts +171 -0
- package/dist/testing/cross_backend/testing_reset_actions.d.ts.map +1 -0
- package/dist/testing/cross_backend/testing_reset_actions.js +213 -0
- package/dist/testing/cross_backend/testing_server_bun.d.ts +5 -0
- package/dist/testing/cross_backend/testing_server_bun.d.ts.map +1 -0
- package/dist/testing/cross_backend/testing_server_bun.js +59 -0
- package/dist/testing/cross_backend/testing_server_core.d.ts +140 -0
- package/dist/testing/cross_backend/testing_server_core.d.ts.map +1 -0
- package/dist/testing/cross_backend/testing_server_core.js +68 -0
- package/dist/testing/cross_backend/testing_server_deno.d.ts +5 -0
- package/dist/testing/cross_backend/testing_server_deno.d.ts.map +1 -0
- package/dist/testing/cross_backend/testing_server_deno.js +37 -0
- package/dist/testing/cross_backend/testing_server_node.d.ts +5 -0
- package/dist/testing/cross_backend/testing_server_node.d.ts.map +1 -0
- package/dist/testing/cross_backend/testing_server_node.js +50 -0
- package/dist/testing/cross_backend/ts_spine_backend_config.d.ts +72 -0
- package/dist/testing/cross_backend/ts_spine_backend_config.d.ts.map +1 -0
- package/dist/testing/cross_backend/ts_spine_backend_config.js +112 -0
- package/dist/testing/cross_backend/ws_round_trip.d.ts +35 -0
- package/dist/testing/cross_backend/ws_round_trip.d.ts.map +1 -0
- package/dist/testing/cross_backend/ws_round_trip.js +113 -0
- package/dist/testing/data_exposure.d.ts +11 -14
- package/dist/testing/data_exposure.d.ts.map +1 -1
- package/dist/testing/data_exposure.js +123 -146
- package/dist/testing/db_entities.d.ts +22 -1
- package/dist/testing/db_entities.d.ts.map +1 -1
- package/dist/testing/db_entities.js +24 -1
- package/dist/testing/integration.d.ts +56 -21
- package/dist/testing/integration.d.ts.map +1 -1
- package/dist/testing/integration.js +294 -319
- 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 +20 -16
- package/dist/testing/round_trip.d.ts.map +1 -1
- package/dist/testing/round_trip.js +61 -86
- package/dist/testing/rpc_helpers.d.ts +10 -4
- package/dist/testing/rpc_helpers.d.ts.map +1 -1
- package/dist/testing/rpc_helpers.js +1 -1
- package/dist/testing/rpc_round_trip.d.ts +24 -21
- package/dist/testing/rpc_round_trip.d.ts.map +1 -1
- package/dist/testing/rpc_round_trip.js +87 -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/sse_round_trip.d.ts.map +1 -1
- package/dist/testing/sse_round_trip.js +1 -68
- package/dist/testing/standard.d.ts +56 -25
- package/dist/testing/standard.d.ts.map +1 -1
- package/dist/testing/standard.js +62 -5
- package/dist/testing/stubs.d.ts +21 -6
- package/dist/testing/stubs.d.ts.map +1 -1
- package/dist/testing/stubs.js +33 -23
- package/dist/testing/testing_rate_limiter.d.ts +59 -0
- package/dist/testing/testing_rate_limiter.d.ts.map +1 -0
- package/dist/testing/testing_rate_limiter.js +74 -0
- package/dist/testing/transports/bootstrap.d.ts +52 -0
- package/dist/testing/transports/bootstrap.d.ts.map +1 -0
- package/dist/testing/transports/bootstrap.js +70 -0
- package/dist/testing/transports/fetch_transport.d.ts +81 -0
- package/dist/testing/transports/fetch_transport.d.ts.map +1 -0
- package/dist/testing/transports/fetch_transport.js +74 -0
- package/dist/testing/transports/sse_frame_reader.d.ts +41 -0
- package/dist/testing/transports/sse_frame_reader.d.ts.map +1 -0
- package/dist/testing/transports/sse_frame_reader.js +84 -0
- package/dist/testing/transports/sse_transport.d.ts +54 -0
- package/dist/testing/transports/sse_transport.d.ts.map +1 -0
- package/dist/testing/transports/sse_transport.js +51 -0
- package/dist/testing/transports/ws_client.d.ts +108 -0
- package/dist/testing/transports/ws_client.d.ts.map +1 -0
- package/dist/testing/transports/ws_client.js +56 -0
- package/dist/testing/transports/ws_transport.d.ts +43 -0
- package/dist/testing/transports/ws_transport.d.ts.map +1 -0
- package/dist/testing/transports/ws_transport.js +169 -0
- package/dist/testing/ws_round_trip.d.ts +21 -103
- package/dist/testing/ws_round_trip.d.ts.map +1 -1
- package/dist/testing/ws_round_trip.js +42 -40
- package/dist/ui/CLAUDE.md +5 -3
- package/dist/ui/MenuLink.svelte +16 -16
- package/dist/ui/MenuLink.svelte.d.ts +13 -4
- package/dist/ui/MenuLink.svelte.d.ts.map +1 -1
- package/package.json +10 -4
package/dist/actions/CLAUDE.md
CHANGED
|
@@ -1,259 +1,184 @@
|
|
|
1
1
|
# actions/ — SAES (Symmetric Action Event System)
|
|
2
2
|
|
|
3
|
-
One declarative `ActionSpec`
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
registry, codegen helpers, both transport bridges, the single-endpoint RPC
|
|
7
|
-
dispatcher, every transport adapter, the event state machine, and the
|
|
8
|
-
reactive frontend client.
|
|
9
|
-
|
|
10
|
-
For narrative context (consumer wiring examples, client-authoritative vs
|
|
11
|
-
server-authoritative dispatch, role-grant-offer UI integration) see
|
|
12
|
-
../../docs/usage.md §Deriving Route/Event Specs, §Single JSON-RPC 2.0 Endpoint,
|
|
13
|
-
§WebSocket Endpoint. For DEV-only output validation semantics see
|
|
14
|
-
../../docs/architecture.md §DEV-only Output Validation. For the SAES
|
|
15
|
-
binding matrix and middleware ordering see the root ../../CLAUDE.md
|
|
16
|
-
§Action Spec System (SAES) and §Middleware Ordering.
|
|
17
|
-
|
|
18
|
-
IMPORTANT: Every exported Zod schema is paired with a same-named `z.infer`
|
|
19
|
-
type export — the convention callers rely on for type imports. When adding
|
|
20
|
-
new schemas, keep the pair invariant (ecosystem-wide rule; see
|
|
21
|
-
Skill(fuz-stack) zod-schemas).
|
|
22
|
-
|
|
23
|
-
NOTE: `ActionRegistry` keeps a few pre-built getters (auth filters,
|
|
24
|
-
initiator-direction filters) that codegen doesn't consume today — kept
|
|
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.
|
|
29
6
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
Enums + unions:
|
|
39
|
-
|
|
40
|
-
- `ActionKind` — `'request_response' | 'remote_notification' | 'local_call'`
|
|
41
|
-
- `ActionInitiator` — `'frontend' | 'backend' | 'both'`
|
|
42
|
-
- `RouteAuth` — flat record `{account, actor, roles?, credential_types?}` from `http/auth_shape.ts`. Each axis (`account`, `actor`) is `'none' | 'optional' | 'required'`; `roles` and `credential_types` are optional any-of arrays. Cross-axis invariants: roles imply `actor: 'required'`; `account: 'none'` implies `actor: 'none'` (no accountless actors in v1); the unrestricted leaf (`account: 'none', actor: 'none'`) cannot declare roles or credential gates. The biconditional `actor !== 'none' ⟺ input declares acting?: ActingActor` is enforced at registration time via `assert_route_auth_acting_biconditional`.
|
|
43
|
-
- `ActionSpecUnion` — discriminated union of the three variants
|
|
44
|
-
- `ActionEventPhase` — `'send_request' | 'receive_request' | 'send_response' | 'receive_response' | 'send_error' | 'receive_error' | 'send' | 'receive' | 'execute'`
|
|
45
|
-
- `is_action_spec(value)` — structural type guard
|
|
46
|
-
|
|
47
|
-
Optional `streams?: string` names a companion `remote_notification` method
|
|
48
|
-
emitted as request-scoped progress. Transport-agnostic handshake —
|
|
49
|
-
registry-time validation that the named method exists is a consumer concern.
|
|
50
|
-
|
|
51
|
-
Optional `error_reasons?: ReadonlyArray<string>` declares the reason codes a
|
|
52
|
-
handler may surface via `error.data.reason`. Same precedent as `streams`:
|
|
53
|
-
declarative metadata for consumers (codegen, UI form-state matching, docs)
|
|
54
|
-
to read off the spec instead of scanning handler code. No runtime
|
|
55
|
-
enforcement — drift between declared reasons and what handlers actually
|
|
56
|
-
throw is caught per-module by source-scanning unit tests (see
|
|
57
|
-
../../test/auth/role*grant_offer_actions.error_reasons.test.ts). Reuses
|
|
58
|
-
the same `as const` string constants the handler throws (e.g.
|
|
59
|
-
`ERROR_ROLE_GRANT_OFFER*\*`from`auth/role_grant_offer_action_specs.ts`,
|
|
60
|
-
`ERROR_ROLE_GRANT_NOT_FOUND`from`http/error_schemas.ts`) so call
|
|
61
|
-
sites can import either side. Standard transport errors (validation,
|
|
62
|
-
auth, rate-limit) stay implicit.
|
|
63
|
-
|
|
64
|
-
Optional `rate_limit?: 'ip' | 'account' | 'both'` opts the action into
|
|
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` +
|
|
89
|
-
`{method}_action_spec` naming, preserving the literal `method` type and
|
|
90
|
-
dropping per-spec `*_METHOD` constants (readers dereference `.method` at
|
|
91
|
-
call sites). See ../../docs/usage.md §Canonical action-spec shape.
|
|
92
|
-
|
|
93
|
-
## Kind → binding constraints
|
|
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.
|
|
94
14
|
|
|
95
|
-
|
|
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.
|
|
96
19
|
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
| `remote_notification` | no | no | server push | yes (bridge) |
|
|
101
|
-
| `local_call` | no | no | no | no |
|
|
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.
|
|
102
23
|
|
|
103
|
-
|
|
104
|
-
notifications and local calls cannot become routes. `create_action_event_spec`
|
|
105
|
-
throws on any non-`remote_notification` kind.
|
|
24
|
+
## Action specs (`actions/action_spec.ts`)
|
|
106
25
|
|
|
107
|
-
|
|
26
|
+
Canonical source of truth. Three concrete kinds discriminate on `kind`:
|
|
108
27
|
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
`
|
|
117
|
-
|
|
118
|
-
`
|
|
119
|
-
`
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
`
|
|
136
|
-
`
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
excludes `streams` targets): `broadcast_specs`, `broadcast_methods`.
|
|
144
|
-
- Backend-initiated (forward-looking kind-agnostic version of broadcast;
|
|
145
|
-
same content today, will widen when local_calls or backend
|
|
146
|
-
`request_response` join): `backend_initiated_specs`,
|
|
147
|
-
`backend_initiated_methods`.
|
|
148
|
-
|
|
149
|
-
Other getters (auth filters, initiator-direction filters) are pre-built
|
|
150
|
-
API surface unused by codegen today.
|
|
151
|
-
|
|
152
|
-
`action_codegen.ts` provides gen helpers (used by consumer `*.gen.ts` files,
|
|
153
|
-
not the runtime):
|
|
154
|
-
|
|
155
|
-
### Primitives
|
|
156
|
-
|
|
157
|
-
- `ImportBuilder` — tracks value / type / namespace imports; emits `import type` when every entry on a module is a type (tree-shaking). Namespace (`* as specs`) entries are emitted verbatim. Public surface: `add`, `add_type`, `add_many`, `add_types`, `build`, `preview`, `has_imports`, `import_count`, `clear`.
|
|
158
|
-
- `get_executor_phases(spec, executor)` — phases a given executor (`'frontend' | 'backend'`) participates in for the spec. Branch-aware: the backend `can_receive` branch only pushes `send_error` when `!can_send`, so `initiator: 'both'` doesn't double-count and no `Set` dedup is needed.
|
|
159
|
-
- `get_handler_return_type(spec, phase, imports, collections_path?)` — the TS type a phase handler must return; triggers the `ActionOutputs` import (sourced from `collections_path`, default `'./action_collections.js'`) as a side effect.
|
|
160
|
-
- `generate_phase_handlers(spec, executor, imports, {action_event_type?, collections_path?})` — emits the typed handler-map fragment for one action; consumers compose these into `ActionHandlers` types. Returns `''` when the spec contributes no phases on the given executor (e.g. a backend-only `local_call` asked for `'frontend'`) so wrappers' `.filter(Boolean)` drops the row entirely instead of emitting a useless `${method}?: never` for a method that doesn't belong on this side.
|
|
161
|
-
- `generate_actions_api_method_signature(spec, imports, {sync_returns_value?, collections_path?})` — single source of truth for the typed `FrontendActionsApi` method shape. Threads `options?: RpcClientCallOptions` (`{signal?, transport_name?, queue?}`) onto every async method — `request_response`, `remote_notification`, and async `local_call` — and wraps the return in `Promise<Result<...>>`. Registers exactly the imports the emitted line references on `imports` — `ActionInputs` only when the spec has input, `RpcClientCallOptions` only when async, `Result` / `JsonrpcErrorObject` only when the return wraps in `Result`. Mirrors the leaf-level pattern `get_handler_return_type` already follows so wrappers no longer pre-register imports a per-spec emit might not actually use.
|
|
162
|
-
- `create_banner(origin_path)` — gen banner comment.
|
|
163
|
-
- `to_action_spec_identifier(method)` / `to_action_spec_input_identifier` / `to_action_spec_output_identifier` — naming convention helpers (emit `foo_action_spec` / `foo_action_spec.input` / `foo_action_spec.output`).
|
|
164
|
-
- `PROTOCOL_ACTION_METHODS` (+ `ProtocolActionMethod` type) — readonly tuple `['heartbeat', 'cancel']`. Pairs with `protocol_actions` / `protocol_action_specs` in `actions/protocol.ts` (the runtime bundles). Consumers spread when filtering backend `request_response` methods so dispatcher-owned protocol actions don't leak into `BackendRequestResponseMethod` / handler maps.
|
|
165
|
-
- `is_protocol_action_method(method)` — type predicate paired with `PROTOCOL_ACTION_METHODS`; use this in `method_filter` callbacks instead of `PROTOCOL_ACTION_METHODS.includes(s.method as never)`.
|
|
166
|
-
- `DEFAULT_COLLECTIONS_PATH = './action_collections.js'` — shared default for every helper that takes a `collections_path?`.
|
|
167
|
-
- `DEFAULT_SPECS_MODULE = './action_specs.js'` — shared default for helpers that emit `specs.{method}_action_spec` and need a `* as specs` namespace import.
|
|
168
|
-
- `DEFAULT_METATYPES_PATH = './action_metatypes.js'` — shared default for the sibling module carrying the generated `ActionMethod` enum.
|
|
169
|
-
- `resolve_spec_qualifier(imports, {specs_module?, qualify_spec?})` — the standard default-vs-callback resolver every multi-source-aware helper in this module uses. With `qualify_spec` set, returns the callback verbatim (consumer owns its namespace setup); otherwise registers `* as specs from specs_module` (default `DEFAULT_SPECS_MODULE`) on `imports` and returns `(s) => 'specs.' + to_action_spec_identifier(s.method)`. Reuse from custom codegen helpers instead of reimplementing the defaulting + import-registration dance.
|
|
170
|
-
|
|
171
|
-
### High-level helpers
|
|
172
|
-
|
|
173
|
-
Each accepts `(specs, imports, options?)` and returns one block of declarations.
|
|
174
|
-
Composed by consumer `*.gen.ts` producers; outputs do not include the banner or
|
|
175
|
-
surrounding `imports.build()`. Use `compose_gen_file` to assemble the block
|
|
176
|
-
list + banner + imports into the final file body in one call.
|
|
177
|
-
|
|
178
|
-
**Protocol actions are filtered by default.** Every spec-iterating helper
|
|
179
|
-
accepts `{include_protocol_actions?: boolean}` (default `false`) and drops
|
|
180
|
-
`heartbeat` / `cancel` from the emitted output. Protocol actions ship from
|
|
181
|
-
fuz_app and are spread into each consumer's `actions` array at
|
|
182
|
-
registration time (via `protocol_actions` from `actions/protocol.ts`) —
|
|
183
|
-
they should not appear in consumer-owned typed surfaces (`ActionMethod`,
|
|
184
|
-
`FrontendActionsApi`, `ActionInputs`, `FrontendActionHandlers`, etc.).
|
|
185
|
-
Pass `include_protocol_actions: true` only if a consumer genuinely owns
|
|
186
|
-
protocol actions in their typed API.
|
|
187
|
-
|
|
188
|
-
**Consumer tiers and namespace handling.** Single-source consumers (zzz,
|
|
189
|
-
undying — every spec lives in one local `action_specs.ts`) drop straight
|
|
190
|
-
into the helpers and accept the default `* as specs from specs_module`
|
|
191
|
-
namespace import. Multi-source consumers (zap, visiones — which stitch
|
|
192
|
-
local specs together with `all_admin_action_specs` /
|
|
193
|
-
`all_role_grant_offer_action_specs` / `all_account_action_specs` /
|
|
194
|
-
`all_self_service_role_action_specs` from fuz_app) call
|
|
195
|
-
`create_namespace_qualifier(sources, imports)` once, then pass the
|
|
196
|
-
returned `qualify_spec` callback to the multi-source helpers
|
|
197
|
-
(`generate_action_specs_record`, `generate_action_inputs_outputs`,
|
|
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.
|
|
210
|
-
zzz) emits the full set including `ActionEventDatas`, `TypedActionEvent`,
|
|
211
|
-
and `FrontendActionHandlers`.
|
|
28
|
+
- `request_response` — `auth: RouteAuth` (non-null), `side_effects` arbitrary, `output` arbitrary, `async: true`.
|
|
29
|
+
- `remote_notification` — `auth: null`, `side_effects: true`, `output: z.ZodVoid`, `async: true`.
|
|
30
|
+
- `local_call` — `auth: null`, `side_effects` arbitrary, `output` arbitrary, `async` boolean.
|
|
31
|
+
|
|
32
|
+
`RouteAuth` is the flat record `{account, actor, roles?, credential_types?}`
|
|
33
|
+
from `http/auth_shape.ts` — same shape governs `RouteSpec.auth` so the four
|
|
34
|
+
axes drive one auth surface across REST and SAES. Cross-axis invariants:
|
|
35
|
+
roles imply `actor: 'required'`; `account: 'none'` implies `actor: 'none'`
|
|
36
|
+
(no accountless actors in v1); the unrestricted leaf
|
|
37
|
+
(`account: 'none', actor: 'none'`) cannot declare roles or credential
|
|
38
|
+
gates. The biconditional `actor !== 'none' ⟺ input declares acting?: ActingActor`
|
|
39
|
+
is enforced at registration time via `assert_route_auth_acting_biconditional`.
|
|
40
|
+
|
|
41
|
+
Optional fields:
|
|
42
|
+
|
|
43
|
+
- `streams?: string` — names a companion `remote_notification` method
|
|
44
|
+
emitted as request-scoped progress.
|
|
45
|
+
- `error_reasons?: ReadonlyArray<string>` — reason codes the handler may
|
|
46
|
+
surface via `error.data.reason`. Declarative metadata for consumers
|
|
47
|
+
(codegen, UI form-state matching, docs); no runtime enforcement, drift
|
|
48
|
+
caught per-module by source-scanning unit tests (e.g.
|
|
49
|
+
../../test/auth/role_grant_offer_actions.error_reasons.test.ts).
|
|
50
|
+
- `rate_limit?: 'ip' | 'account' | 'both'` — opts the action into the
|
|
51
|
+
dispatcher's per-action rate-limit hook. **Throttle-requests semantics**
|
|
52
|
+
— every invocation records regardless of outcome (different from REST
|
|
53
|
+
login's throttle-failures). `'account'` rejected at registration when
|
|
54
|
+
paired with `auth.account !== 'required'`. Limiters configured via
|
|
55
|
+
`AppServerOptions.action_ip_rate_limiter` / `action_account_rate_limiter`
|
|
56
|
+
and threaded into both dispatchers automatically.
|
|
57
|
+
|
|
58
|
+
Canonical spec shape: module-scope `satisfies` declaration with
|
|
59
|
+
`{method}_action_spec` naming, preserving the literal `method` type and
|
|
60
|
+
dropping per-spec `*_METHOD` constants (readers dereference `.method`). See
|
|
61
|
+
../../docs/usage.md §Canonical action-spec shape.
|
|
212
62
|
|
|
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.
|
|
63
|
+
## Kind → binding matrix
|
|
223
64
|
|
|
224
|
-
|
|
65
|
+
- `request_response` — REST `RouteSpec` via bridge; RPC `RouteSpec` via `create_rpc_endpoint`; WS dispatch yes; no SSE.
|
|
66
|
+
- `remote_notification` — no REST/RPC routes; WS server push; SSE `EventSpec` via bridge.
|
|
67
|
+
- `local_call` — none (no REST, no RPC, no WS, no SSE).
|
|
225
68
|
|
|
226
|
-
|
|
227
|
-
|
|
69
|
+
`create_action_route_spec` throws if `spec.auth` is null (notifications and
|
|
70
|
+
local calls cannot become routes). `create_action_event_spec` throws on any
|
|
71
|
+
non-`remote_notification` kind.
|
|
228
72
|
|
|
229
|
-
## Registry compile (`compile_action_registry.ts`)
|
|
73
|
+
## Registry compile (`actions/compile_action_registry.ts`)
|
|
230
74
|
|
|
231
|
-
|
|
232
|
-
`register_action_ws`. Validates four
|
|
233
|
-
`Map<method, RpcAction>` the
|
|
75
|
+
`compile_action_registry` is the shared registration loop called by both
|
|
76
|
+
`create_rpc_endpoint` and `register_action_ws`. Validates four
|
|
77
|
+
registry-time invariants and returns the `Map<method, RpcAction>` the
|
|
78
|
+
dispatchers use:
|
|
234
79
|
|
|
235
|
-
1. Auth-shape biconditional (`assert_route_auth_acting_biconditional`
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
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.
|
|
80
|
+
1. **Auth-shape biconditional** — `actor !== 'none' ⟺ input declares acting?: ActingActor` (via `assert_route_auth_acting_biconditional`).
|
|
81
|
+
2. **Rate-limit account axis** — `rate_limit: 'account' | 'both'` requires `auth.account === 'required'`.
|
|
82
|
+
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).
|
|
83
|
+
4. **Unique method names** across the array.
|
|
242
84
|
|
|
243
85
|
Only `request_response` specs with a handler reach the dispatch map;
|
|
244
86
|
`remote_notification` / handler-less specs (e.g. WS `cancel`) stay
|
|
245
87
|
registry-only.
|
|
246
88
|
|
|
247
|
-
##
|
|
89
|
+
## Registry + codegen (`actions/action_registry.ts`, `actions/action_codegen.ts`)
|
|
90
|
+
|
|
91
|
+
**Symmetric design — universal calling abstraction.** SAES is one spec
|
|
92
|
+
shape driving dispatch across (a) network boundaries (frontend ⇄ backend
|
|
93
|
+
over HTTP / WS) and (b) within the same runtime (`local_call` actions).
|
|
94
|
+
`ActionPeer` is symmetric on both sides (`send` + `receive`). Typed
|
|
95
|
+
surfaces are paired: `FrontendActionsApi` is "what the frontend can call"
|
|
96
|
+
(typed Proxy from `create_rpc_client`); `BackendActionsApi` is "what the
|
|
97
|
+
backend can call" (typed object from `create_broadcast_api` today;
|
|
98
|
+
broader runtime constructors will join). Remaining asymmetry:
|
|
99
|
+
`create_broadcast_api` returns `Promise<void>` while `FrontendActionsApi`
|
|
100
|
+
methods return `Promise<Result<...>>`. Closing those gaps is on the
|
|
101
|
+
deferred follow-up set from the SAES RPC closeout work
|
|
102
|
+
— wait for a second backend runtime case.
|
|
103
|
+
|
|
104
|
+
### `ActionRegistry`
|
|
105
|
+
|
|
106
|
+
Query/filter wrapper over `ActionSpecUnion[]`. Codegen-relevant getter
|
|
107
|
+
groups (each pairs `_specs` with matching `_methods`):
|
|
108
|
+
|
|
109
|
+
- Kind-narrow — filter by `kind`; drives `*ActionMethod` enums.
|
|
110
|
+
- `*_handled` — `request_response` + handler-side initiator; drives `BackendActionHandlers` map.
|
|
111
|
+
- `specs_relevant_to_*` — everything the side might encounter; drives typed-Proxy method enums.
|
|
112
|
+
- `broadcast` — `remote_notification`, `initiator !== 'frontend'`, excludes streams; drives `BackendActionsApi` interface.
|
|
113
|
+
- `backend_initiated` — forward-looking kind-agnostic broadcast; same content today.
|
|
114
|
+
|
|
115
|
+
Other getters (auth filters, initiator-direction filters) are pre-built API
|
|
116
|
+
surface unused by codegen today.
|
|
117
|
+
|
|
118
|
+
### Codegen helpers (`actions/action_codegen.ts`)
|
|
119
|
+
|
|
120
|
+
Used by consumer `*.gen.ts` producers, not the runtime. Detailed signatures
|
|
121
|
+
and options on each function's TSDoc.
|
|
122
|
+
|
|
123
|
+
- `ImportBuilder` — class managing value/type/namespace imports; auto-tree-shakes type-only.
|
|
124
|
+
- `get_executor_phases(spec, executor)` — phases an executor participates in for the spec.
|
|
125
|
+
- `get_handler_return_type` — TS type a phase handler must return; side-effect imports `ActionOutputs`.
|
|
126
|
+
- `generate_phase_handlers` — per-action typed handler-map fragment.
|
|
127
|
+
- `generate_actions_api_method_signature` — single source of truth for the typed `FrontendActionsApi` method shape.
|
|
128
|
+
- `generate_action_method_enums` — up to nine `z.enum` + `z.infer` pairs.
|
|
129
|
+
- `generate_action_method_enum_block` — lower-level escape hatch for cross-product enums.
|
|
130
|
+
- `generate_typed_action_event_alias` — fixed-shape `TypedActionEvent<TMethod, TPhase, TStep>` alias.
|
|
131
|
+
- `generate_action_specs_record` — `ActionSpecs` runtime const + interface + `action_specs` array.
|
|
132
|
+
- `generate_action_inputs_outputs` — `ActionInputs` + `ActionOutputs` runtime consts + interfaces.
|
|
133
|
+
- `generate_action_event_datas` — `ActionEventDatas` interface; per-spec variants.
|
|
134
|
+
- `generate_frontend_actions_api` — typed `FrontendActionsApi` interface.
|
|
135
|
+
- `generate_frontend_action_handlers` — `FrontendActionHandlers` interface (Tier 2 only).
|
|
136
|
+
- `generate_backend_actions_api` — `BackendActionsApi` interface + `broadcast_action_specs` array.
|
|
137
|
+
- `generate_backend_action_handlers_map` — `BackendActionHandlers` mapped type.
|
|
138
|
+
- `compose_gen_file` — wrapper: banner + `imports.build()` + blocks join.
|
|
139
|
+
- `create_namespace_qualifier(sources, imports)` — multi-source consumer helper; registers `import * as ns` per source.
|
|
140
|
+
|
|
141
|
+
Shared defaults: `DEFAULT_COLLECTIONS_PATH = './action_collections.js'`,
|
|
142
|
+
`DEFAULT_SPECS_MODULE = './action_specs.js'`,
|
|
143
|
+
`DEFAULT_METATYPES_PATH = './action_metatypes.js'`,
|
|
144
|
+
`resolve_spec_qualifier` (the default-vs-callback resolver every
|
|
145
|
+
multi-source-aware helper uses).
|
|
146
|
+
|
|
147
|
+
### Codegen invariants
|
|
148
|
+
|
|
149
|
+
**Protocol actions filtered by default.** Every spec-iterating helper
|
|
150
|
+
accepts `{include_protocol_actions?: boolean}` (default `false`) and drops
|
|
151
|
+
`heartbeat` / `cancel`. Protocol actions ship from fuz_app and spread into
|
|
152
|
+
each consumer's `actions` array at registration time (via
|
|
153
|
+
`protocol_actions` from `actions/protocol.ts`); they should not appear in
|
|
154
|
+
consumer-owned typed surfaces. Pass `include_protocol_actions: true` only
|
|
155
|
+
if a consumer genuinely owns protocol actions in their typed API.
|
|
156
|
+
|
|
157
|
+
**Consumer tiers.** Single-source consumers (zzz) drop into the helpers
|
|
158
|
+
and accept the default `* as specs` namespace import. Multi-source
|
|
159
|
+
consumers (zap, visiones — stitching local specs with
|
|
160
|
+
`all_admin_action_specs` / `all_role_grant_offer_action_specs` /
|
|
161
|
+
`all_account_action_specs` / `all_self_service_role_action_specs` from
|
|
162
|
+
fuz_app) call `create_namespace_qualifier` once, then pass the returned
|
|
163
|
+
`qualify_spec` callback to multi-source helpers.
|
|
164
|
+
|
|
165
|
+
**Tier 1** (HTTP-only, zap/visiones) emits a smaller surface — typically
|
|
166
|
+
`ActionMethod` + `FrontendActionsApi` + `ActionInputs` / `ActionOutputs`.
|
|
167
|
+
Never calls `generate_typed_action_event_alias` or
|
|
168
|
+
`generate_frontend_action_handlers`. **Tier 2** (`TypedActionEvent`-aware,
|
|
169
|
+
zzz) emits the full set including `ActionEventDatas`, `TypedActionEvent`,
|
|
170
|
+
and `FrontendActionHandlers`.
|
|
171
|
+
|
|
172
|
+
## HTTP bridge (`actions/action_bridge.ts`)
|
|
248
173
|
|
|
249
174
|
Derives transport-specific specs from action specs. HTTP-specific concerns
|
|
250
175
|
(path, handler, errors) come from options, not the action spec.
|
|
251
176
|
|
|
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
|
|
177
|
+
- `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
178
|
- `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
|
|
179
|
+
- `derive_http_method(side_effects)` — exported for custom bridges.
|
|
255
180
|
|
|
256
|
-
## Single JSON-RPC 2.0 endpoint (`action_rpc.ts`)
|
|
181
|
+
## Single JSON-RPC 2.0 endpoint (`actions/action_rpc.ts`)
|
|
257
182
|
|
|
258
183
|
`create_rpc_endpoint({path, actions, log}): RouteSpec[]` produces **two**
|
|
259
184
|
route specs on the same path (GET + POST) that share one internal
|
|
@@ -261,68 +186,47 @@ dispatcher. Per-action auth lives inside the dispatcher; the outer routes
|
|
|
261
186
|
use `auth: {account: 'none', actor: 'none'}` and `transaction: false`.
|
|
262
187
|
|
|
263
188
|
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.
|
|
268
|
-
|
|
269
|
-
Phase order: **401 → 400 → 403 → handler** — validate first, authorize
|
|
270
|
-
after. The trade-off is that an unauthorized caller sees the validation
|
|
271
|
-
step; the alternative ordering (403-before-400) was rejected because
|
|
272
|
-
defense-in-depth via attack-surface obscurity is illusory when the
|
|
273
|
-
surface is published in `library.json` codegen anyway.
|
|
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):
|
|
189
|
+
(`actions/perform_action.ts`). The shim owns wire-shape concerns (envelope
|
|
190
|
+
parsing, GET vs POST split, `c.json` binding); the
|
|
191
|
+
auth/validation/dispatch pipeline is shared with the WebSocket dispatcher.
|
|
308
192
|
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
193
|
+
**Phase order: 401 → 400 → 403 → handler.** Validate first, authorize
|
|
194
|
+
after. Trade-off: an unauthorized caller sees the validation step. The
|
|
195
|
+
alternative ordering (403-before-400) was rejected because
|
|
196
|
+
defense-in-depth via attack-surface obscurity is illusory when the surface
|
|
197
|
+
is published in `library.json` codegen anyway.
|
|
198
|
+
|
|
199
|
+
Shim responsibilities (per-request):
|
|
200
|
+
|
|
201
|
+
1. Parse envelope (POST body / GET query string); parse errors → `parse_error` 400.
|
|
202
|
+
2. Lookup method in the `compile_action_registry`-built map; unknown → `method_not_found`.
|
|
203
|
+
3. GET read restriction — GET rejected for `side_effects: true` actions.
|
|
204
|
+
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`.
|
|
205
|
+
5. Call `perform_action` (shared core).
|
|
206
|
+
6. Bind result via `perform_action_result_to_envelope(id, result)`; `c.json(envelope, result.status)`.
|
|
207
|
+
|
|
208
|
+
Error paths: `ThrownJsonrpcError` (duck-typed via `err instanceof Error &&
|
|
209
|
+
typeof err.code === 'number'` to handle cross-copy `instanceof` misses,
|
|
210
|
+
e.g. when consumers like zzz throw their own `ThrownJsonrpcError`)
|
|
211
|
+
preserves code + data verbatim. Generic throws become `internal_error` 500;
|
|
212
|
+
message is the raw error under `DEV`, "internal server error" otherwise.
|
|
314
213
|
|
|
214
|
+
### Per-request handler shape
|
|
215
|
+
|
|
216
|
+
Unified across HTTP RPC + WS via `ActionContext`:
|
|
217
|
+
|
|
218
|
+
```ts
|
|
315
219
|
interface ActionContext {
|
|
316
220
|
auth: RequestContext | null; // null for public actions
|
|
317
221
|
request_id: JsonrpcRequestId;
|
|
318
222
|
connection_id?: Uuid; // populated on WS, undefined on HTTP
|
|
319
223
|
db: Db; // transaction for mutations, pool for reads
|
|
320
|
-
pending_effects: Array<Promise<void>>; // eager
|
|
224
|
+
pending_effects: Array<Promise<void>>; // eager — see http/CLAUDE.md §Pending Effects
|
|
321
225
|
post_commit_effects: Array<() => void | Promise<void>>; // deferred — push via `emit_after_commit`
|
|
322
226
|
client_ip: string;
|
|
323
|
-
credential_type: CredentialType | null; //
|
|
227
|
+
credential_type: CredentialType | null; // same value the credential_types gate consumed
|
|
324
228
|
log: Logger;
|
|
325
|
-
notify: (method, params) => void; // HTTP: DEV-mode warn + drop
|
|
229
|
+
notify: (method, params) => void; // HTTP: DEV-mode warn + drop; WS: socket-scoped
|
|
326
230
|
signal: AbortSignal; // HTTP: client-disconnect; WS: AbortSignal.any([socket_close, request_cancel])
|
|
327
231
|
}
|
|
328
232
|
|
|
@@ -332,198 +236,173 @@ interface RpcAction {
|
|
|
332
236
|
}
|
|
333
237
|
```
|
|
334
238
|
|
|
335
|
-
### `rpc_action(spec, handler)` — typed binder
|
|
239
|
+
### `rpc_action(spec, handler)` — typed binder
|
|
336
240
|
|
|
337
|
-
`rpc_action<TSpec extends RequestResponseActionSpec>(spec, handler)`
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
dispatcher's runtime guarantee allows. The conditional `HandlerForSpec<TSpec>`
|
|
342
|
-
discriminates on the spec literal:
|
|
241
|
+
`rpc_action<TSpec extends RequestResponseActionSpec>(spec, handler)` pins
|
|
242
|
+
the handler's input / output types to `z.infer<TSpec['input']>` /
|
|
243
|
+
`z.infer<TSpec['output']>` and tightens `ctx.auth` per the conditional
|
|
244
|
+
`HandlerForSpec<TSpec>`:
|
|
343
245
|
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
| `auth.account === 'required' && auth.actor === 'none'` | `AuthActionHandler` | `RequestContext` |
|
|
348
|
-
| else (public, optional axes) | `ActionHandler` | `RequestContext \| null` |
|
|
246
|
+
- `auth.actor === 'required'` → `ctx.auth: RequestActorContext`.
|
|
247
|
+
- `auth.account === 'required' && auth.actor === 'none'` → `ctx.auth: RequestContext`.
|
|
248
|
+
- else (public, optional axes) → `ctx.auth: RequestContext | null`.
|
|
349
249
|
|
|
350
|
-
Use
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
rpc_action(role_grant_revoke_action_spec, async (input, ctx) => {
|
|
356
|
-
const revoker_id = ctx.auth.actor.id;
|
|
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
|
-
```
|
|
365
|
-
|
|
366
|
-
The bracketed form `[T] extends ['required']` defeats distributive
|
|
367
|
-
conditionals so a degraded `AuthAxisState` union (when the spec was
|
|
368
|
-
typed without preserving its literal) falls through to the loosest
|
|
369
|
-
tier instead of collapsing to the narrowest. Specs declared with
|
|
370
|
-
`satisfies RequestResponseActionSpec` (canonical) preserve the
|
|
371
|
-
literals — typing a spec directly as `RequestResponseActionSpec`
|
|
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`).
|
|
250
|
+
Use at every spec → handler binding site so handler-type errors surface
|
|
251
|
+
at the factory call instead of at runtime. The bracketed form
|
|
252
|
+
`[T] extends ['required']` defeats distributive conditionals so a degraded
|
|
253
|
+
`AuthAxisState` union (when the spec was typed without preserving its
|
|
254
|
+
literal) falls through to the loosest tier instead of the narrowest.
|
|
375
255
|
|
|
376
256
|
zzz uses a codegen-driven `Record<Method, Handler>` map for the same
|
|
377
257
|
narrowing — ideal when handlers are stateless free functions. fuz_app's
|
|
378
258
|
handlers close over factory-captured deps (`log`, `audit`,
|
|
379
259
|
`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.
|
|
260
|
+
`rpc_action()` is the right shape here.
|
|
383
261
|
|
|
384
|
-
##
|
|
262
|
+
## Shared dispatch core (`actions/perform_action.ts`)
|
|
385
263
|
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
`
|
|
389
|
-
|
|
264
|
+
The transport-agnostic post-parse pipeline. Each transport assembles a
|
|
265
|
+
`PerformActionInput` from its wire envelope + connection identity, calls
|
|
266
|
+
`perform_action(input, deps)`, and binds the discriminated
|
|
267
|
+
`PerformActionResult` to its wire shape.
|
|
268
|
+
|
|
269
|
+
Pipeline (401 → 400 → 403 → handler):
|
|
390
270
|
|
|
391
|
-
|
|
392
|
-
|
|
271
|
+
1. Pre-validation auth (401)
|
|
272
|
+
2. Validate params (400) — `spec.input.safeParse` with `z.void()` / `?? {}` rules
|
|
273
|
+
3. Authorization phase — `apply_authorization_phase` against `account_id` + `validated_input.acting`. Test escape hatch via `preset.request_context`
|
|
274
|
+
4. Post-authorization auth (403) — credential-type gate first, role gate second
|
|
275
|
+
5. Rate limit (429) — throttle-requests semantics
|
|
276
|
+
6. Dispatch + DEV output validation + error normalization — `spec.side_effects` picks transaction vs pool. `ThrownJsonrpcError` preserves code + data; generic throws become `internal_error`
|
|
393
277
|
|
|
394
|
-
`
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
278
|
+
`PerformActionInput` carries `account_id`, `credential_type`, `client_ip`,
|
|
279
|
+
`signal`, `notify`, optional `connection_id`, optional `preset`.
|
|
280
|
+
`PerformActionDeps` carries `db` (pool-level), `pending_effects`, `log`,
|
|
281
|
+
the two rate limiters. Audit writes are out-of-band: factories close over
|
|
282
|
+
`AppDeps.audit` independently.
|
|
399
283
|
|
|
400
|
-
|
|
284
|
+
Authorization-phase resolution failures from the auth domain come back as
|
|
285
|
+
`AuthorizationResult.ok === false` carrying `{status, body}` — folded into
|
|
286
|
+
a JSON-RPC envelope where `error.code` maps from
|
|
287
|
+
`http_status_to_jsonrpc_error_code(result.status)`, `error.message` is the
|
|
288
|
+
reason string, and `error.data: {reason, ...rest}` flattens diagnostic
|
|
289
|
+
fields. REST emits the same `body` directly via `c.json(body, status)` for
|
|
290
|
+
surface consistency.
|
|
401
291
|
|
|
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).
|
|
292
|
+
## DEV-only output validation — uniform across surfaces
|
|
405
293
|
|
|
406
|
-
|
|
294
|
+
Critical invariant: every action-handler surface applies DEV-only output
|
|
295
|
+
validation and produces the **same failure mode** — log an error, return
|
|
296
|
+
the response unchanged, do not throw, do not mutate status.
|
|
407
297
|
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
returning `false` (matches `create_rpc_endpoint`'s GET convention). Forwards
|
|
411
|
-
`signal` to `fetch`. On non-OK HTTP response, synthesizes a JSON-RPC error
|
|
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.
|
|
298
|
+
- REST bridge — `http/route_spec.ts` `wrap_output_validation` (applied via `apply_route_specs`; inherited by `create_action_route_spec`). Production hot path short-circuits (no parse).
|
|
299
|
+
- HTTP RPC + WebSocket dispatch — `actions/perform_action.ts` `if (DEV) spec.output.safeParse(output)` inside the shared dispatch core. Production hot path short-circuits (no parse).
|
|
415
300
|
|
|
416
|
-
|
|
301
|
+
Caller-facing `input` schemas are validated **always** (DEV + production)
|
|
302
|
+
— they're the contract with external callers. Server-authored `output`
|
|
303
|
+
schemas are internal data. See ../../docs/architecture.md §DEV-only Output
|
|
304
|
+
Validation for full rationale.
|
|
417
305
|
|
|
418
|
-
|
|
419
|
-
(the canonical implementation is `FrontendWebsocketClient`). No parallel
|
|
420
|
-
pending-request map — delegates request/response correlation, durable queue,
|
|
421
|
-
heartbeat, and abort-signal cancel to the underlying connection. Routes
|
|
422
|
-
inbound server-pushed messages (requests + notifications) into a `receive`
|
|
423
|
-
callback; responses are owned by `connection.request()` so the transport
|
|
424
|
-
ignores them.
|
|
306
|
+
## Transports
|
|
425
307
|
|
|
426
|
-
|
|
308
|
+
`Transport` is the unifying interface — overloaded `send(message, options?)`
|
|
309
|
+
returning `Promise<JsonrpcResponseOrError>` for requests and
|
|
310
|
+
`Promise<JsonrpcErrorResponse | null>` for notifications, plus `is_ready()`
|
|
311
|
+
and optional `dispose()`. All transports share `TransportSendOptions`:
|
|
427
312
|
|
|
428
|
-
- `
|
|
429
|
-
|
|
313
|
+
- `signal?: AbortSignal` — per-call cancel. Bottoms out at
|
|
314
|
+
`FrontendWebsocketClient.request({signal})` on WS (sends `cancel`
|
|
315
|
+
notification on abort) and at `fetch({signal})` on HTTP.
|
|
316
|
+
- `queue?: boolean` — per-call durable-queue opt-in. Honored only by
|
|
317
|
+
`FrontendWebsocketTransport` on the `request_response` path (default
|
|
318
|
+
`false`). HTTP, backend, and WS notifications all ignore it.
|
|
430
319
|
|
|
431
|
-
|
|
432
|
-
`
|
|
433
|
-
|
|
320
|
+
`Transports` registry holds multiple transports with a `current` selection
|
|
321
|
+
and `allow_fallback: boolean` (default `true`). Explicit
|
|
322
|
+
`transport_for_method` (on `rpc_client`) or
|
|
323
|
+
`default_send_options.transport_name` (on `ActionPeer`) takes precedence.
|
|
434
324
|
|
|
435
|
-
###
|
|
325
|
+
### WS close codes (`actions/transports.ts`)
|
|
436
326
|
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
327
|
+
- `WS_CLOSE_SESSION_REVOKED = 4001` — server revoked auth; client enters permanent `revoked` state, no reconnect.
|
|
328
|
+
- `WS_CLOSE_CLIENT_HEARTBEAT_TIMEOUT = 4002` — client observed receive-silence past `DEFAULT_HEARTBEAT_RECEIVE_TIMEOUT`.
|
|
329
|
+
- `WS_CLOSE_SERVER_HEARTBEAT_TIMEOUT = 4003` — server observed receive-silence past `DEFAULT_SERVER_HEARTBEAT_TIMEOUT` (60s).
|
|
330
|
+
|
|
331
|
+
### Transport modules
|
|
332
|
+
|
|
333
|
+
- `actions/transports_http.ts` — `frontend_http_rpc`; thin `fetch` adapter, POST default, GET on `has_side_effects(method) === false`.
|
|
334
|
+
- `actions/transports_ws.ts` — `frontend_websocket_rpc`; thin adapter over `WebsocketRpcConnection` (default impl: `FrontendWebsocketClient`).
|
|
335
|
+
- `actions/transports_ws_backend.ts` — `backend_websocket_rpc`; server-side WS with session tracking; satisfies `FilterableBroadcastTransport`.
|
|
441
336
|
|
|
442
|
-
|
|
337
|
+
`FrontendHttpTransport` synthesizes a JSON-RPC error envelope via
|
|
338
|
+
`http_status_to_jsonrpc_error_code` on non-OK HTTP; DEV warns on drift
|
|
339
|
+
between JSON-RPC error code and declared HTTP status.
|
|
443
340
|
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
341
|
+
`FrontendWebsocketTransport` notification sends fail-fast when disconnected
|
|
342
|
+
regardless of `queue` — `connection.send()` has no queue semantic, so
|
|
343
|
+
buffering would masquerade as success at the rpc_client layer. Requests
|
|
344
|
+
route via `queue`.
|
|
447
345
|
|
|
448
|
-
|
|
346
|
+
### `BackendWebsocketTransport` — server-side WS state
|
|
449
347
|
|
|
450
|
-
|
|
451
|
-
- `remove_connection(ws)` — idempotent; safe after revocation.
|
|
348
|
+
Three aligned maps keyed by `connection_id` (branded `Uuid`):
|
|
452
349
|
|
|
453
|
-
|
|
350
|
+
- `#connections: Map<Uuid, WSContext>` — id → socket
|
|
351
|
+
- `#connection_ids: WeakMap<WSContext, Uuid>` — socket → id (reverse)
|
|
352
|
+
- `#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
353
|
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
- `close_sockets_for_account(account_id)` — coarse; covers session + bearer + daemon-token.
|
|
354
|
+
Targeted closure (all return socket count closed, use
|
|
355
|
+
`WS_CLOSE_SESSION_REVOKED`):
|
|
458
356
|
|
|
459
|
-
|
|
357
|
+
- `close_sockets_for_session(token_hash)`
|
|
358
|
+
- `close_sockets_for_token(api_token_id)`
|
|
359
|
+
- `close_sockets_for_account(account_id)` — coarse, covers session + bearer + daemon-token
|
|
460
360
|
|
|
461
|
-
- `send(notification)`
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
361
|
+
Fan-out: `send(notification)` broadcasts to every connection;
|
|
362
|
+
`broadcast_filtered(message, predicate)` runs per-connection ACL predicate
|
|
363
|
+
over `ConnectionIdentity`; `send_to_account` wraps `broadcast_filtered` and
|
|
364
|
+
structurally satisfies `NotificationSender` (see `auth/CLAUDE.md` §WS
|
|
365
|
+
notifications).
|
|
465
366
|
|
|
466
367
|
Return values are bookkeeping, not delivery receipts — `0` means no live
|
|
467
368
|
sockets, non-zero means `ws.send` did not throw. Durable delivery requires
|
|
468
369
|
persistence + rehydration by the consumer.
|
|
469
370
|
|
|
470
|
-
## WS auth guard (`transports_ws_auth_guard.ts`)
|
|
371
|
+
## WS auth guard (`actions/transports_ws_auth_guard.ts`)
|
|
471
372
|
|
|
472
373
|
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
|
-
|
|
374
|
+
re-check session/token validity, so this guard is the revocation seam for
|
|
375
|
+
open connections.
|
|
475
376
|
|
|
476
377
|
`create_ws_auth_guard(transport, log)` returns an `on_audit_event` callback.
|
|
477
378
|
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
|
|
379
|
+
`create_app_server` composes this guard onto `backend.deps.audit.on_event_chain`
|
|
380
|
+
automatically (per `WsEndpointSpec.auth_guard`). For custom wiring, append
|
|
381
|
+
inside the consumer's `audit_factory` body.
|
|
382
|
+
|
|
383
|
+
`ws_disconnect_event_types` (ReadonlySet): `session_revoke`, `token_revoke`,
|
|
384
|
+
`session_revoke_all`, `token_revoke_all`, `password_change`.
|
|
385
|
+
`role_grant_revoke` is intentionally **omitted** — the WS transport doesn't
|
|
488
386
|
track per-connection role requirements, so role-scoped disconnection would
|
|
489
387
|
require either closing all sockets (too aggressive) or new per-connection
|
|
490
388
|
role tracking (out of scope). Consumers that need it compose their own
|
|
491
389
|
callback.
|
|
492
390
|
|
|
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.
|
|
391
|
+
`outcome === 'failure'` events are ignored — they carry attacker-controlled
|
|
392
|
+
identifiers. Reacting to them would let an authenticated caller close
|
|
393
|
+
another user's socket by guessing a session hash or token id.
|
|
502
394
|
|
|
503
395
|
`create_ws_logout_closer(transport, log)` is the sibling helper for
|
|
504
396
|
user-initiated `logout` events — kept separate because
|
|
505
397
|
`ws_disconnect_event_types` deliberately omits `logout` (admin-initiated
|
|
506
398
|
revocations use `session_revoke`, while `logout` is the user-initiated
|
|
507
|
-
case).
|
|
399
|
+
case). Closes via `close_sockets_for_account(event.account_id)`.
|
|
508
400
|
|
|
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.
|
|
401
|
+
## Connection closer (`actions/connection_closer.ts`)
|
|
521
402
|
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
on revocation — the belt+suspenders layer that complements the audit-
|
|
526
|
-
listener guards above.
|
|
403
|
+
Narrow structural capability for handler-side eager WS socket closure on
|
|
404
|
+
revocation — belt+suspenders layer that complements the audit-listener
|
|
405
|
+
guards above.
|
|
527
406
|
|
|
528
407
|
```ts
|
|
529
408
|
interface ConnectionCloser {
|
|
@@ -533,543 +412,277 @@ interface ConnectionCloser {
|
|
|
533
412
|
}
|
|
534
413
|
```
|
|
535
414
|
|
|
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
|
|
415
|
+
`BackendWebsocketTransport` satisfies this structurally — consumers pass
|
|
416
|
+
the transport instance directly (same shape as `NotificationSender`). Wired
|
|
417
|
+
into `AccountRouteOptions.connection_closer` (logout / password),
|
|
418
|
+
`AccountActionOptions.connection_closer` (session/token revoke), and
|
|
419
|
+
`AdminActionOptions.connection_closer` (admin revoke-all). Each handler
|
|
420
|
+
calls the appropriate `close_sockets_for_*` synchronously **before** the
|
|
421
|
+
audit emit so revocation lands even on audit INSERT failure. Failure
|
|
422
|
+
outcomes (`revoked: false`, 404 not-found) skip the eager close — mirrors
|
|
423
|
+
the listener's `outcome === 'failure'` guard so attacker-guessable ids can
|
|
424
|
+
never target arbitrary sockets.
|
|
425
|
+
|
|
426
|
+
## WebSocket dispatch — three layered entry points
|
|
427
|
+
|
|
428
|
+
In decreasing abstraction.
|
|
429
|
+
|
|
430
|
+
### `create_app_server.ws_endpoints` — canonical mount surface
|
|
431
|
+
|
|
432
|
+
Mirror of `rpc_endpoints` for WebSocket endpoints. Accepts either an array
|
|
433
|
+
of `WsEndpointSpec` or a factory
|
|
434
|
+
`(ctx: AppServerContext) => ReadonlyArray<WsEndpointSpec>`; factory form
|
|
435
|
+
runs after server context is assembled so action lists can depend on
|
|
436
|
+
`ctx.deps` / `ctx.action_*_rate_limiter`. Each entry is auto-mounted via
|
|
437
|
+
`register_ws_endpoint` against the assembled Hono app.
|
|
438
|
+
|
|
439
|
+
`upgradeWebSocket` (the Hono adapter helper) is supplied once at the top
|
|
440
|
+
level — `create_app_server` throws when `ws_endpoints` resolves non-empty
|
|
441
|
+
but `upgradeWebSocket` is missing. A factory returning `[]` does NOT trip
|
|
442
|
+
the check, so feature-flag gated WS surfaces stay safe.
|
|
443
|
+
|
|
444
|
+
`WsEndpointSpec` fields: `path`, `allowed_origins`, `actions`,
|
|
445
|
+
`required_roles?`, `transport?`, `heartbeat?`, `artificial_delay?`,
|
|
446
|
+
`on_socket_open?`, `on_socket_close?`, `auth_guard?` (default `true`,
|
|
447
|
+
deduped by reference identity via `WeakSet<BackendWebsocketTransport>`),
|
|
448
|
+
`extra_audit_handlers?`.
|
|
449
|
+
|
|
450
|
+
Mounted transport reachable at `app_server.ws_endpoints[path]`
|
|
451
|
+
(`Readonly<Record<string, BackendWebsocketTransport>>`). Duplicate paths
|
|
452
|
+
across `WsEndpointSpec`s throw at mount time. Cross-surface collisions
|
|
453
|
+
(same `GET <path>` on both `RouteSpec` and `WsEndpointSpec`) throw with
|
|
454
|
+
exact-string match. Pattern overlap (e.g. `GET /api/:resource` vs
|
|
455
|
+
`/api/ws`) is not detected — Hono's specific-before-wildcard routing keeps
|
|
456
|
+
those working but avoid the overlap.
|
|
586
457
|
|
|
587
458
|
`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
|
|
459
|
+
(deliberate — per-connection role tracking out of scope). Compose via
|
|
460
|
+
`extra_audit_handlers` when needed. When multiple specs share a transport,
|
|
461
|
+
**any** spec with `auth_guard !== false` wires the guard for that
|
|
462
|
+
transport (OR-semantics).
|
|
463
|
+
|
|
464
|
+
`AppSurfaceWsEndpoint.methods` surfaces `request_response` +
|
|
465
|
+
`remote_notification` specs only — `local_call` specs are filtered out
|
|
466
|
+
because they don't dispatch over WS.
|
|
467
|
+
|
|
468
|
+
### `register_ws_endpoint` — middle tier
|
|
619
469
|
|
|
620
470
|
Composes the standard upgrade stack:
|
|
621
471
|
|
|
622
472
|
1. `verify_request_source(allowed_origins)`
|
|
623
473
|
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.)
|
|
474
|
+
3. Upgrade-time authorization phase — resolves the acting actor, seeds `REQUEST_CONTEXT_KEY` for the inner `register_action_ws`
|
|
475
|
+
4. Optional `require_role(required_roles)` — any-of disjunction (coarse upgrade-time gate; per-action `auth` in each spec still applies at dispatch time)
|
|
476
|
+
5. Delegates to `register_action_ws`
|
|
635
477
|
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
478
|
+
Extends `RegisterActionWsOptions` with `allowed_origins` and optional
|
|
479
|
+
`required_roles`. Returns `{transport}`. Most consumers reach for
|
|
480
|
+
`ws_endpoints` above; this is the entry test harnesses use when they need
|
|
481
|
+
the upgrade stack without `create_app_server`'s full assembly.
|
|
639
482
|
|
|
640
|
-
### `register_action_ws`
|
|
483
|
+
### `register_action_ws` — lower-level dispatcher
|
|
641
484
|
|
|
642
485
|
Exposed for tests (`create_ws_test_harness`) that need to drive the
|
|
643
486
|
dispatcher without the origin/auth front-stack.
|
|
644
487
|
|
|
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
|
|
488
|
+
Per-message dispatch delegates to `perform_action` — the shared core that
|
|
489
|
+
HTTP RPC also calls. `register_action_ws` owns only WS-specific concerns:
|
|
490
|
+
|
|
491
|
+
- **Wire envelope parsing** — JSON.parse → batch rejection → notification interception (cancel, silent drop) → per-message dispatch
|
|
492
|
+
- **Cancel-notification interception** — `{request_id → AbortController}` map; aborts the matching pending controller before the cancel bubbles past the dispatcher
|
|
493
|
+
- **Socket-scoped notify** — `(method, params) => ws.send(notification)`, threaded into `perform_action` as `notify`
|
|
494
|
+
- **Composed abort signal** — `AbortSignal.any([socket_close, per_request_cancel])`, threaded as `signal`
|
|
495
|
+
- **Connection lifecycle** — `transport.add_connection` / `remove_connection`, `on_socket_open` / `_close` hooks, server heartbeat
|
|
496
|
+
|
|
497
|
+
**Per-message authorization phase.** `perform_action` calls
|
|
670
498
|
`apply_authorization_phase` per-message (HTTP and WS uniformly). Role grant
|
|
671
499
|
changes during a connection lifetime are picked up on the next message —
|
|
672
500
|
no in-place refresh, no socket-close on `role_grant_revoke`. Authentication
|
|
673
501
|
invalidation (`session_revoke`, `password_change`, `token_revoke_all`)
|
|
674
502
|
still closes the socket via `create_ws_auth_guard`.
|
|
675
503
|
|
|
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`):
|
|
504
|
+
Per-message side-effect queues: `pending_effects` (eager) drains via
|
|
505
|
+
`flush_pending_effects`; `post_commit_effects` (deferred — pushed by
|
|
506
|
+
handlers via `emit_after_commit`) drains via `flush_post_commit_effects`.
|
|
507
|
+
Both flush in the same `try/finally` that releases the request controller,
|
|
508
|
+
so fire-and-forget audit / notification effects pushed by the handler
|
|
509
|
+
complete (or reject visibly) before the next message dispatches. See
|
|
510
|
+
`http/CLAUDE.md` §Pending Effects.
|
|
511
|
+
|
|
512
|
+
**Lifecycle hooks.** `on_socket_open({ws, connection_id, identity, notify, signal})`
|
|
513
|
+
fires after `transport.add_connection` but before the first message;
|
|
514
|
+
awaited; throws log + close with `1011 'socket bootstrap failed'`.
|
|
515
|
+
`on_socket_close({ws, connection_id, identity})` fires before
|
|
516
|
+
`transport.remove_connection` so `identity` is still readable. Errors
|
|
517
|
+
logged and swallowed.
|
|
518
|
+
|
|
519
|
+
**Server-side heartbeat** (`heartbeat?: boolean | ServerHeartbeatOptions`):
|
|
703
520
|
default-on, 60s silence timeout. Any inbound message resets
|
|
704
521
|
`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.
|
|
522
|
+
after open is exempt (cold-start grace). Tick interval is `timeout / 2`,
|
|
523
|
+
so event-loop blockage pauses the timer itself.
|
|
733
524
|
|
|
734
|
-
|
|
525
|
+
Two abort signals composed via `AbortSignal.any`:
|
|
735
526
|
|
|
736
|
-
|
|
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`
|
|
742
|
-
|
|
743
|
-
Validators:
|
|
744
|
-
|
|
745
|
-
- `validate_step_transition(from, to)` — throws on illegal step moves.
|
|
746
|
-
- `validate_phase_for_kind(kind, phase)` — throws if the phase isn't valid for the kind.
|
|
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:
|
|
527
|
+
- `socket_abort_controller` — per-socket, fires on close. Drives every handler's `ctx.signal`.
|
|
528
|
+
- `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
529
|
|
|
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`.
|
|
530
|
+
## Protocol actions (`actions/protocol.ts`)
|
|
776
531
|
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
it materializes the `JsonrpcNotification`.
|
|
532
|
+
Two shared `{spec, handler}` tuples that every consumer spreads into both
|
|
533
|
+
sides' `actions` arrays — disconnect detection and per-request cancel
|
|
534
|
+
work identically across every repo without per-consumer ping plumbing.
|
|
781
535
|
|
|
782
|
-
|
|
536
|
+
The category is wire-protocol concerns shipped by fuz_app, not consumer
|
|
537
|
+
domain logic. Protocol vs domain: a future clock-skew probe or
|
|
538
|
+
reconnect-resume token belongs here; a `payment_charge` action does not.
|
|
783
539
|
|
|
784
|
-
|
|
785
|
-
registry and `ActionEventEnvironment`. Construct with
|
|
786
|
-
`{environment, transports?, default_send_options?}`.
|
|
540
|
+
Two const arrays:
|
|
787
541
|
|
|
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.
|
|
542
|
+
- `protocol_actions: ReadonlyArray<Action>` — for the server's `register_action_ws` `actions`. Spread before consumer-owned actions.
|
|
543
|
+
- `protocol_action_specs: ReadonlyArray<ActionSpecUnion>` — derived via `.map(a => a.spec)` so the two arrays cannot drift. For the frontend `ActionRegistry`.
|
|
793
544
|
|
|
794
|
-
|
|
545
|
+
Asymmetry intentional — server runs handlers (heartbeat echo + cancel
|
|
546
|
+
stub), frontend registry only stores specs. Both bundles plus the codegen
|
|
547
|
+
`include_protocol_actions: false` default form a three-leg contract.
|
|
795
548
|
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
```
|
|
549
|
+
**Not auto-spread by `create_frontend_rpc_client` or `register_ws_endpoint`** —
|
|
550
|
+
bundled helpers stay pure factories so the dispatch surface stays
|
|
551
|
+
grep-traceable at every registration site and consumers can override
|
|
552
|
+
individual protocol actions without an opt-out flag.
|
|
801
553
|
|
|
802
|
-
|
|
554
|
+
### Individual actions
|
|
803
555
|
|
|
804
|
-
-
|
|
805
|
-
-
|
|
806
|
-
- Delegates to `transport.send(message, {signal, queue: options?.queue ?? default.queue})`.
|
|
807
|
-
- Unexpected throws become `create_jsonrpc_error_response_from_thrown`.
|
|
556
|
+
- **`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.
|
|
557
|
+
- **`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
558
|
|
|
809
|
-
|
|
559
|
+
## Event state machine
|
|
810
560
|
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
-
|
|
561
|
+
Five modules (`action_event_types.ts`, `action_event_data.ts`,
|
|
562
|
+
`action_event_helpers.ts`, `action_event.ts`, `action_peer.ts`) define a
|
|
563
|
+
discriminated-union-based state machine used by the reactive client to
|
|
564
|
+
track an action through its lifecycle. Per-symbol semantics on TSDoc;
|
|
565
|
+
high-level shapes that span modules:
|
|
814
566
|
|
|
815
|
-
|
|
816
|
-
|
|
567
|
+
- **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.
|
|
568
|
+
- **Step transitions** — `initial → parsed | failed`, `parsed → handling | failed`, `handling → handled | failed`, `handled`/`failed` terminal. `validate_step_transition(from, to)` throws on illegal moves.
|
|
569
|
+
- **Phase transitions** — chained: `send_request → receive_response`, `receive_request → send_response`; everything else terminal. `validate_phase_for_kind` + `validate_phase_transition` enforce.
|
|
570
|
+
- **`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.
|
|
571
|
+
- **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
572
|
|
|
818
|
-
|
|
573
|
+
`ActionPeer` is symmetric send + receive over a `Transports` registry and
|
|
574
|
+
`ActionEventEnvironment`. `default_send_options` excludes `signal`
|
|
575
|
+
deliberately — a shared signal would abort every subsequent call after the
|
|
576
|
+
first trip. `transport_name` and `queue` can be defaulted here once to
|
|
577
|
+
flip the peer into client-authoritative mode.
|
|
819
578
|
|
|
820
|
-
|
|
821
|
-
sides' `actions` arrays — disconnect detection and per-request cancel work
|
|
822
|
-
identically across every repo without per-consumer ping plumbing.
|
|
579
|
+
## Reactive frontend client
|
|
823
580
|
|
|
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`
|
|
581
|
+
### `FrontendWebsocketClient` (`actions/socket.svelte.ts`)
|
|
886
582
|
|
|
887
583
|
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`.
|
|
584
|
+
etc.). Plain class — no Cell inheritance, no app coupling. Implements
|
|
585
|
+
`WebsocketConnection` + `WebsocketRpcConnection`, and is `Disposable`.
|
|
892
586
|
|
|
893
587
|
Ships three correctness primitives default-on:
|
|
894
588
|
|
|
895
|
-
1. **Promise-based `request`** — auto-assigned monotonic id
|
|
589
|
+
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
590
|
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
|
|
591
|
+
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
592
|
|
|
899
|
-
Reconnect policy (exponential backoff): `delay = DEFAULT_RECONNECT_DELAY * DEFAULT_BACKOFF_FACTOR ** (attempts-1)`,
|
|
593
|
+
Reconnect policy (exponential backoff): `delay = DEFAULT_RECONNECT_DELAY * DEFAULT_BACKOFF_FACTOR ** (attempts-1)`,
|
|
594
|
+
capped at `DEFAULT_RECONNECT_DELAY_MAX`. `WS_CLOSE_SESSION_REVOKED` is
|
|
595
|
+
**terminal** — sets `#revoked = true`, no reconnect loop on 401.
|
|
900
596
|
|
|
901
|
-
Live policy swaps (behave like constructor — whole policy atomic, missing
|
|
597
|
+
Live policy swaps (behave like constructor — whole policy atomic, missing
|
|
598
|
+
fields fall back to defaults, not "keep current"): `set_reconnect`,
|
|
599
|
+
`set_heartbeat`, `cancel_reconnect`.
|
|
902
600
|
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
- `
|
|
601
|
+
`SocketStatus = 'initial' | 'connecting' | 'connected' | 'reconnecting' | 'closed'`.
|
|
602
|
+
`socket_status_to_async_status(status, revoked)` collapses to fuz_util's
|
|
603
|
+
4-way `AsyncStatus`.
|
|
906
604
|
|
|
907
|
-
|
|
605
|
+
### `RequestTracker` (`actions/request_tracker.svelte.ts`)
|
|
908
606
|
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
607
|
+
Public utility — reactive pending-request state with timeouts.
|
|
608
|
+
`SvelteMap` keyed by `JsonrpcRequestId`, default `request_timeout_ms = 120_000`.
|
|
609
|
+
Used by transports that don't delegate pending correlation to a
|
|
610
|
+
`WebsocketRpcConnection` (`FrontendWebsocketTransport` delegates to
|
|
611
|
+
`FrontendWebsocketClient`'s own `#pending` map).
|
|
914
612
|
|
|
915
|
-
|
|
613
|
+
## RPC client (`actions/rpc_client.ts`)
|
|
916
614
|
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
`deferred`, `created` (ISO datetime), reactive `status: AsyncStatus`, and
|
|
920
|
-
`timeout`. Default `request_timeout_ms = 120_000`.
|
|
615
|
+
`create_rpc_client({peer, environment, actions?, transport_for_method?})` —
|
|
616
|
+
returns a Proxy-based typed API. Per-kind dispatch:
|
|
921
617
|
|
|
922
|
-
|
|
923
|
-
`
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
(rejects all with `internal_error`).
|
|
618
|
+
- **`local_call` sync** — `parse().handle_sync()`, return value directly. Throws on error (sync can't return `Result`). Ignores `signal`.
|
|
619
|
+
- **`local_call` async** — `parse().handle_async()`, return `Result<{value}, {error}>`. Pre-flight `signal.aborted` check short-circuits.
|
|
620
|
+
- **`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`.
|
|
621
|
+
- **`remote_notification`** — builds event, creates notification, `peer.send(notification, {transport_name, signal, queue})`. Returns `Result<{value: void}, {error}>`.
|
|
927
622
|
|
|
928
|
-
|
|
929
|
-
`
|
|
930
|
-
|
|
623
|
+
`RpcClientCallOptions extends ActionPeerSendOptions` — `{signal?, queue?, transport_name?}`.
|
|
624
|
+
`transport_for_method: (method) => TransportName | undefined` for per-method
|
|
625
|
+
selection. `on_action_event(event)` fires once per dispatched action with
|
|
626
|
+
the live `ActionEvent` (zzz wires reactive history here).
|
|
931
627
|
|
|
932
|
-
|
|
628
|
+
### Throwing variants
|
|
933
629
|
|
|
934
|
-
`
|
|
935
|
-
|
|
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.
|
|
974
|
-
|
|
975
|
-
| Helper | Shape | Use at |
|
|
976
|
-
| -------------------------- | ------------------------------------- | -------------------------------------------------------------------------- |
|
|
977
|
-
| `create_throwing_rpc_call` | `(method, input?) => Promise<T>` | adapter wiring (e.g. `ui/admin_rpc_adapters.ts`) — method comes from a map |
|
|
978
|
-
| `create_throwing_api` | typed Proxy over `FrontendActionsApi` | direct call sites — `await api.foo(input)` keeps full inference |
|
|
979
|
-
|
|
980
|
-
**Layered design.** Result is the protocol primitive — `create_rpc_client`
|
|
981
|
-
returns `Result<{value}, {error}>` per call with no Error allocation. The
|
|
982
|
-
throwing wrappers sit _above_ it as ergonomic adapters; both shapes share
|
|
983
|
-
the same underlying transport and call sites pick per-site. `Result` is
|
|
984
|
-
preferable when the call site inspects `error.data.reason` (no Error
|
|
985
|
-
allocation, no try/catch nesting) or when overhead matters (reconnect
|
|
986
|
-
storms, hot paths). Throwing is preferable when the call site doesn't
|
|
987
|
-
inspect — `await api.foo()` reads cleaner than the `if (!r.ok) throw …`
|
|
988
|
-
ritual.
|
|
989
|
-
|
|
990
|
-
`create_frontend_rpc_client` ships both shapes by default — see
|
|
991
|
-
[Frontend factory](#frontend-factory-frontend_rpc_clientts) below. Direct
|
|
992
|
-
consumers of `create_rpc_client` pass their typed `FrontendActionsApi`
|
|
993
|
-
as the generic to get the typed Result-shaped Proxy without casts, then
|
|
994
|
-
build the throwing form on top:
|
|
630
|
+
- `create_throwing_rpc_call` — shape `(method, input?) => Promise<T>`. Use at adapter wiring (e.g. `ui/admin_rpc_adapters.ts`) — method comes from a map.
|
|
631
|
+
- `create_throwing_api` — typed Proxy over `FrontendActionsApi`. Use at direct call sites — `await api.foo(input)` keeps full inference.
|
|
995
632
|
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
633
|
+
**Layered design.** `Result` is the protocol primitive —
|
|
634
|
+
`create_rpc_client` returns `Result<{value}, {error}>` with no Error
|
|
635
|
+
allocation. The throwing wrappers sit _above_ it as ergonomic adapters;
|
|
636
|
+
both shapes share the same underlying transport and call sites pick
|
|
637
|
+
per-site. `Result` is preferable when the call site inspects
|
|
638
|
+
`error.data.reason` (no allocation, no try/catch) or when overhead matters
|
|
639
|
+
(reconnect storms, hot paths). Throwing is preferable when the call site
|
|
640
|
+
doesn't inspect — `await api.foo()` reads cleaner than `if (!r.ok) throw …`.
|
|
1002
641
|
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
mapping rather than a typed call site. Use it only at adapter boundaries.
|
|
642
|
+
Hardening on both: only `{code, data}` cross onto the Error, leaving
|
|
643
|
+
`name` / `stack` as the native Error's own so attacker-shaped
|
|
644
|
+
`result.error` payloads cannot overwrite them.
|
|
1007
645
|
|
|
1008
646
|
`ThrowingApi<TApi>` (the mapped type returned by `create_throwing_api`)
|
|
1009
647
|
strips `Promise<Result<{value: T}, {error: JsonrpcErrorObject}>>` to
|
|
1010
|
-
`Promise<T>` on every method
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
non-Result returns flow through unchanged.
|
|
648
|
+
`Promise<T>` on every method matching the `request_response` / async
|
|
649
|
+
`local_call` return shape; `remote_notification` and sync `local_call`
|
|
650
|
+
methods pass through. The Proxy inspects each call's result shape at
|
|
651
|
+
runtime and only unwraps when it sees a Result.
|
|
1015
652
|
|
|
1016
653
|
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`.
|
|
654
|
+
unknown method. Symbol props and `then` stay `undefined` so the Proxy
|
|
655
|
+
doesn't get probed as a thenable by `await`.
|
|
1022
656
|
|
|
1023
|
-
### Frontend factory (`frontend_rpc_client.ts`)
|
|
657
|
+
### Frontend factory (`actions/frontend_rpc_client.ts`)
|
|
1024
658
|
|
|
1025
659
|
`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.
|
|
660
|
+
bundles `ActionRegistry + ActionEventEnvironment + Transports + ActionPeer +
|
|
661
|
+
create_rpc_client + create_throwing_api` boilerplate every consumer
|
|
662
|
+
repeats — plus the `lookup_action_handler: () => undefined` stub (frontend
|
|
663
|
+
never registers `request_response` handlers; every method dispatches over
|
|
664
|
+
the wire).
|
|
1033
665
|
|
|
1034
666
|
Returns both Proxy shapes from one factory call:
|
|
1035
667
|
|
|
1036
668
|
- `api: ThrowingApi<TApi>` — typed throwing Proxy. Default for hot-path call sites.
|
|
1037
669
|
- `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
|
-
```
|
|
670
|
+
- `peer`, `environment` — exposed for advanced consumers.
|
|
1047
671
|
|
|
1048
672
|
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).
|
|
673
|
+
`transports` for WS-first or mixed setups (the default HTTP transport is
|
|
674
|
+
**not** registered when `transports` is supplied). `local_call` specs in
|
|
675
|
+
`specs` silently no-op because `lookup_action_handler` always returns
|
|
676
|
+
`undefined`.
|
|
1067
677
|
|
|
1068
678
|
`all_standard_action_specs` (in `auth/standard_action_specs.ts`) is
|
|
1069
|
-
|
|
1070
|
-
|
|
679
|
+
transport-agnostic — when a consumer spreads `create_standard_rpc_actions`
|
|
680
|
+
into both `rpc_endpoints` AND `ws_endpoints`, `transport_for_method` can
|
|
681
|
+
route per-call (e.g. return `'frontend_websocket_rpc'` for `account_*` /
|
|
682
|
+
`admin_*` methods to bind them to the live WS connection). See
|
|
683
|
+
`auth/CLAUDE.md` §Standard RPC bundle.
|
|
1071
684
|
|
|
1072
|
-
## Broadcast API (`broadcast_api.ts`)
|
|
685
|
+
## Broadcast API (`actions/broadcast_api.ts`)
|
|
1073
686
|
|
|
1074
687
|
`create_broadcast_api({peer, specs, log?, should_deliver?})` — builds a
|
|
1075
688
|
typed `{method: (input) => Promise<void>}` object from a list of action
|
|
@@ -1078,77 +691,28 @@ request-scoped dispatch, this handles backend-initiated broadcast.
|
|
|
1078
691
|
Request-scoped streaming stays on `ctx.notify` inside a handler.
|
|
1079
692
|
|
|
1080
693
|
Per-method call: validates input against `spec.input` (logs + returns on
|
|
1081
|
-
failure), wraps in
|
|
694
|
+
failure), wraps in `JsonrpcNotification`, sends via the peer's resolved
|
|
1082
695
|
transport. `transport_name` on `peer.default_send_options` pins the target
|
|
1083
696
|
deterministically — no fallback, because broadcast is 1→N over a specific
|
|
1084
697
|
primary transport and "any ready transport" could reach an unexpected
|
|
1085
|
-
audience. Silently skips when
|
|
698
|
+
audience. Silently skips when none ready.
|
|
1086
699
|
|
|
1087
700
|
`should_deliver: (identity, method, input) => boolean` — optional
|
|
1088
701
|
per-connection ACL predicate. When set, fans out via
|
|
1089
702
|
`transport.broadcast_filtered` (feature-detected via
|
|
1090
|
-
`is_filterable_broadcast_transport`). Errors
|
|
1091
|
-
|
|
703
|
+
`is_filterable_broadcast_transport`). Errors logged but never thrown —
|
|
704
|
+
broadcasts are fire-and-forget.
|
|
1092
705
|
|
|
1093
706
|
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.
|
|
707
|
+
and pin via `create_broadcast_api<BackendActionsApi>({...})` — unchecked
|
|
708
|
+
cast, so interface and `specs` array must stay in sync (codegen is a
|
|
709
|
+
natural fit).
|
|
1108
710
|
|
|
1109
|
-
|
|
711
|
+
## Shared type surface (`actions/action_types.ts`)
|
|
1110
712
|
|
|
1111
|
-
`
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
in `HandlerForSpec<TSpec>`).
|
|
713
|
+
Sits above `action_spec.ts` (pure Zod) and below the dispatchers. Extracted
|
|
714
|
+
so composable primitives (e.g. `heartbeat_action`) can name the types
|
|
715
|
+
without pulling in server-only modules.
|
|
1115
716
|
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
The transport-agnostic post-parse pipeline shared by HTTP RPC and
|
|
1119
|
-
WebSocket. Each transport assembles a `PerformActionInput` from its wire
|
|
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.
|
|
717
|
+
- `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.
|
|
718
|
+
- `RpcAction = Action<RequestResponseActionSpec> & {handler: ActionHandler}` — narrowing the HTTP RPC dispatcher accepts (`create_rpc_endpoint`) and the `rpc_action` binder produces.
|