@fuzdev/fuz_app 0.30.0 → 0.32.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 +630 -0
- package/dist/actions/action_rpc.d.ts +29 -0
- package/dist/actions/action_rpc.d.ts.map +1 -1
- package/dist/actions/action_rpc.js +42 -6
- package/dist/actions/action_types.d.ts +2 -2
- package/dist/actions/cancel.d.ts +12 -13
- package/dist/actions/cancel.d.ts.map +1 -1
- package/dist/actions/cancel.js +10 -13
- package/dist/actions/heartbeat.d.ts +8 -13
- package/dist/actions/heartbeat.d.ts.map +1 -1
- package/dist/actions/heartbeat.js +5 -8
- package/dist/actions/register_action_ws.d.ts +3 -3
- package/dist/actions/register_action_ws.js +2 -2
- package/dist/actions/register_ws_endpoint.d.ts +4 -4
- package/dist/actions/register_ws_endpoint.d.ts.map +1 -1
- package/dist/actions/register_ws_endpoint.js +3 -3
- package/dist/actions/rpc_client.d.ts +29 -0
- package/dist/actions/rpc_client.d.ts.map +1 -1
- package/dist/actions/rpc_client.js +31 -0
- package/dist/actions/socket.svelte.d.ts +16 -16
- package/dist/actions/socket.svelte.d.ts.map +1 -1
- package/dist/actions/socket.svelte.js +15 -15
- package/dist/actions/transports_ws_auth_guard.d.ts.map +1 -1
- package/dist/auth/CLAUDE.md +945 -0
- package/dist/auth/account_action_specs.d.ts +216 -0
- package/dist/auth/account_action_specs.d.ts.map +1 -0
- package/dist/auth/account_action_specs.js +159 -0
- package/dist/auth/account_actions.d.ts +51 -0
- package/dist/auth/account_actions.d.ts.map +1 -0
- package/dist/auth/account_actions.js +119 -0
- package/dist/auth/account_queries.d.ts +6 -2
- package/dist/auth/account_queries.d.ts.map +1 -1
- package/dist/auth/account_queries.js +40 -4
- package/dist/auth/account_routes.d.ts +94 -16
- package/dist/auth/account_routes.d.ts.map +1 -1
- package/dist/auth/account_routes.js +108 -180
- package/dist/auth/account_schema.d.ts +85 -30
- package/dist/auth/account_schema.d.ts.map +1 -1
- package/dist/auth/account_schema.js +40 -8
- package/dist/auth/admin_action_specs.d.ts +674 -0
- package/dist/auth/admin_action_specs.d.ts.map +1 -0
- package/dist/auth/admin_action_specs.js +287 -0
- package/dist/auth/admin_actions.d.ts +69 -0
- package/dist/auth/admin_actions.d.ts.map +1 -0
- package/dist/auth/admin_actions.js +256 -0
- package/dist/auth/admin_rpc_actions.d.ts +49 -0
- package/dist/auth/admin_rpc_actions.d.ts.map +1 -0
- package/dist/auth/admin_rpc_actions.js +32 -0
- package/dist/auth/api_token.d.ts +10 -0
- package/dist/auth/api_token.d.ts.map +1 -1
- package/dist/auth/api_token.js +9 -0
- package/dist/auth/api_token_queries.d.ts +3 -3
- package/dist/auth/api_token_queries.js +3 -3
- package/dist/auth/app_settings_schema.d.ts +4 -3
- package/dist/auth/app_settings_schema.d.ts.map +1 -1
- package/dist/auth/app_settings_schema.js +2 -1
- package/dist/auth/audit_log_routes.d.ts +14 -6
- package/dist/auth/audit_log_routes.d.ts.map +1 -1
- package/dist/auth/audit_log_routes.js +22 -79
- package/dist/auth/audit_log_schema.d.ts +100 -29
- package/dist/auth/audit_log_schema.d.ts.map +1 -1
- package/dist/auth/audit_log_schema.js +83 -11
- package/dist/auth/bootstrap_routes.d.ts +14 -0
- package/dist/auth/bootstrap_routes.d.ts.map +1 -1
- package/dist/auth/bootstrap_routes.js +10 -3
- package/dist/auth/cleanup.d.ts +63 -0
- package/dist/auth/cleanup.d.ts.map +1 -0
- package/dist/auth/cleanup.js +80 -0
- package/dist/auth/invite_schema.d.ts +11 -10
- package/dist/auth/invite_schema.d.ts.map +1 -1
- package/dist/auth/invite_schema.js +4 -3
- package/dist/auth/migrations.d.ts +6 -0
- package/dist/auth/migrations.d.ts.map +1 -1
- package/dist/auth/migrations.js +28 -0
- package/dist/auth/permit_offer_action_specs.d.ts +364 -0
- package/dist/auth/permit_offer_action_specs.d.ts.map +1 -0
- package/dist/auth/permit_offer_action_specs.js +216 -0
- package/dist/auth/permit_offer_actions.d.ts +96 -0
- package/dist/auth/permit_offer_actions.d.ts.map +1 -0
- package/dist/auth/permit_offer_actions.js +428 -0
- package/dist/auth/permit_offer_notifications.d.ts +361 -0
- package/dist/auth/permit_offer_notifications.d.ts.map +1 -0
- package/dist/auth/permit_offer_notifications.js +179 -0
- package/dist/auth/permit_offer_queries.d.ts +165 -0
- package/dist/auth/permit_offer_queries.d.ts.map +1 -0
- package/dist/auth/permit_offer_queries.js +390 -0
- package/dist/auth/permit_offer_schema.d.ts +103 -0
- package/dist/auth/permit_offer_schema.d.ts.map +1 -0
- package/dist/auth/permit_offer_schema.js +142 -0
- package/dist/auth/permit_queries.d.ts +77 -14
- package/dist/auth/permit_queries.d.ts.map +1 -1
- package/dist/auth/permit_queries.js +119 -24
- package/dist/auth/session_queries.d.ts +4 -2
- package/dist/auth/session_queries.d.ts.map +1 -1
- package/dist/auth/session_queries.js +4 -2
- package/dist/auth/signup_routes.d.ts +13 -0
- package/dist/auth/signup_routes.d.ts.map +1 -1
- package/dist/auth/signup_routes.js +14 -7
- package/dist/http/CLAUDE.md +584 -0
- package/dist/http/pending_effects.d.ts +29 -0
- package/dist/http/pending_effects.d.ts.map +1 -0
- package/dist/http/pending_effects.js +31 -0
- package/dist/http/route_spec.d.ts.map +1 -1
- package/dist/http/route_spec.js +4 -3
- package/dist/rate_limiter.d.ts +30 -0
- package/dist/rate_limiter.d.ts.map +1 -1
- package/dist/rate_limiter.js +25 -2
- package/dist/realtime/sse_auth_guard.d.ts +2 -0
- package/dist/realtime/sse_auth_guard.d.ts.map +1 -1
- package/dist/realtime/sse_auth_guard.js +5 -3
- package/dist/server/app_server.d.ts +13 -2
- package/dist/server/app_server.d.ts.map +1 -1
- package/dist/server/app_server.js +12 -1
- package/dist/testing/CLAUDE.md +668 -1
- package/dist/testing/admin_integration.d.ts +10 -7
- package/dist/testing/admin_integration.d.ts.map +1 -1
- package/dist/testing/admin_integration.js +382 -482
- package/dist/testing/app_server.d.ts +7 -6
- package/dist/testing/app_server.d.ts.map +1 -1
- package/dist/testing/attack_surface.d.ts +9 -3
- package/dist/testing/attack_surface.d.ts.map +1 -1
- package/dist/testing/attack_surface.js +4 -4
- package/dist/testing/audit_completeness.d.ts +11 -0
- package/dist/testing/audit_completeness.d.ts.map +1 -1
- package/dist/testing/audit_completeness.js +169 -134
- package/dist/testing/auth_apps.d.ts.map +1 -1
- package/dist/testing/auth_apps.js +4 -33
- package/dist/testing/db.d.ts +1 -1
- package/dist/testing/db.d.ts.map +1 -1
- package/dist/testing/db.js +2 -0
- package/dist/testing/entities.d.ts +35 -13
- package/dist/testing/entities.d.ts.map +1 -1
- package/dist/testing/entities.js +17 -0
- package/dist/testing/integration.d.ts +10 -0
- package/dist/testing/integration.d.ts.map +1 -1
- package/dist/testing/integration.js +352 -340
- package/dist/testing/integration_helpers.d.ts +16 -5
- package/dist/testing/integration_helpers.d.ts.map +1 -1
- package/dist/testing/integration_helpers.js +24 -4
- package/dist/testing/rate_limiting.d.ts +7 -0
- package/dist/testing/rate_limiting.d.ts.map +1 -1
- package/dist/testing/rate_limiting.js +41 -10
- package/dist/testing/rpc_helpers.d.ts +153 -1
- package/dist/testing/rpc_helpers.d.ts.map +1 -1
- package/dist/testing/rpc_helpers.js +184 -8
- package/dist/testing/sse_round_trip.d.ts +8 -0
- package/dist/testing/sse_round_trip.d.ts.map +1 -1
- package/dist/testing/sse_round_trip.js +10 -3
- package/dist/testing/standard.d.ts +9 -1
- package/dist/testing/standard.d.ts.map +1 -1
- package/dist/testing/standard.js +6 -2
- package/dist/testing/stubs.d.ts +10 -2
- package/dist/testing/stubs.d.ts.map +1 -1
- package/dist/testing/stubs.js +17 -2
- package/dist/testing/surface_invariants.d.ts +7 -3
- package/dist/testing/surface_invariants.d.ts.map +1 -1
- package/dist/testing/surface_invariants.js +5 -4
- package/dist/testing/ws_round_trip.d.ts.map +1 -1
- package/dist/testing/ws_round_trip.js +9 -38
- package/dist/ui/AccountSessions.svelte +8 -4
- package/dist/ui/AccountSessions.svelte.d.ts.map +1 -1
- package/dist/ui/AdminAccounts.svelte +61 -33
- package/dist/ui/AdminAccounts.svelte.d.ts.map +1 -1
- package/dist/ui/AdminAuditLog.svelte +3 -2
- package/dist/ui/AdminAuditLog.svelte.d.ts.map +1 -1
- package/dist/ui/AdminInvites.svelte +3 -2
- package/dist/ui/AdminInvites.svelte.d.ts.map +1 -1
- package/dist/ui/AdminOverview.svelte +14 -9
- package/dist/ui/AdminOverview.svelte.d.ts.map +1 -1
- package/dist/ui/AdminPermitHistory.svelte +3 -2
- package/dist/ui/AdminPermitHistory.svelte.d.ts.map +1 -1
- package/dist/ui/AdminSessions.svelte +29 -25
- package/dist/ui/AdminSessions.svelte.d.ts.map +1 -1
- package/dist/ui/CLAUDE.md +363 -0
- package/dist/ui/OpenSignupToggle.svelte +6 -3
- package/dist/ui/OpenSignupToggle.svelte.d.ts.map +1 -1
- package/dist/ui/PermitOfferForm.svelte +141 -0
- package/dist/ui/PermitOfferForm.svelte.d.ts +14 -0
- package/dist/ui/PermitOfferForm.svelte.d.ts.map +1 -0
- package/dist/ui/PermitOfferHistory.svelte +109 -0
- package/dist/ui/PermitOfferHistory.svelte.d.ts +11 -0
- package/dist/ui/PermitOfferHistory.svelte.d.ts.map +1 -0
- package/dist/ui/PermitOfferInbox.svelte +121 -0
- package/dist/ui/PermitOfferInbox.svelte.d.ts +12 -0
- package/dist/ui/PermitOfferInbox.svelte.d.ts.map +1 -0
- package/dist/ui/account_sessions_state.svelte.d.ts +53 -3
- package/dist/ui/account_sessions_state.svelte.d.ts.map +1 -1
- package/dist/ui/account_sessions_state.svelte.js +39 -16
- package/dist/ui/admin_accounts_state.svelte.d.ts +118 -2
- package/dist/ui/admin_accounts_state.svelte.d.ts.map +1 -1
- package/dist/ui/admin_accounts_state.svelte.js +99 -23
- package/dist/ui/admin_invites_state.svelte.d.ts +47 -1
- package/dist/ui/admin_invites_state.svelte.d.ts.map +1 -1
- package/dist/ui/admin_invites_state.svelte.js +38 -26
- package/dist/ui/admin_rpc_adapters.d.ts +94 -0
- package/dist/ui/admin_rpc_adapters.d.ts.map +1 -0
- package/dist/ui/admin_rpc_adapters.js +100 -0
- package/dist/ui/admin_sessions_state.svelte.d.ts +26 -0
- package/dist/ui/admin_sessions_state.svelte.d.ts.map +1 -1
- package/dist/ui/admin_sessions_state.svelte.js +35 -21
- package/dist/ui/app_settings_state.svelte.d.ts +39 -0
- package/dist/ui/app_settings_state.svelte.d.ts.map +1 -1
- package/dist/ui/app_settings_state.svelte.js +34 -18
- package/dist/ui/audit_log_state.svelte.d.ts +40 -3
- package/dist/ui/audit_log_state.svelte.d.ts.map +1 -1
- package/dist/ui/audit_log_state.svelte.js +36 -42
- package/dist/ui/auth_state.svelte.d.ts +4 -3
- package/dist/ui/auth_state.svelte.d.ts.map +1 -1
- package/dist/ui/auth_state.svelte.js +4 -1
- package/dist/ui/permit_offers_state.svelte.d.ts +125 -0
- package/dist/ui/permit_offers_state.svelte.d.ts.map +1 -0
- package/dist/ui/permit_offers_state.svelte.js +197 -0
- package/package.json +3 -3
- package/dist/auth/admin_routes.d.ts +0 -29
- package/dist/auth/admin_routes.d.ts.map +0 -1
- package/dist/auth/admin_routes.js +0 -226
- package/dist/auth/app_settings_routes.d.ts +0 -27
- package/dist/auth/app_settings_routes.d.ts.map +0 -1
- package/dist/auth/app_settings_routes.js +0 -66
- package/dist/auth/invite_routes.d.ts +0 -18
- package/dist/auth/invite_routes.d.ts.map +0 -1
- package/dist/auth/invite_routes.js +0 -129
|
@@ -0,0 +1,630 @@
|
|
|
1
|
+
# actions/ — SAES (Symmetric Action Event System)
|
|
2
|
+
|
|
3
|
+
One declarative `ActionSpec` shape — `{method, kind, initiator, auth, side_effects, input, output, async, description}` — binds to three
|
|
4
|
+
transport surfaces (REST, JSON-RPC, WebSocket) with uniform DEV-only output
|
|
5
|
+
validation and symmetric send/receive. This directory holds the spec types,
|
|
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, permit-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. When adding new schemas, keep the pair invariant — it is the
|
|
20
|
+
convention callers rely on for type imports.
|
|
21
|
+
|
|
22
|
+
NOTE: The system is marked `@action-system-review` — `action_registry`,
|
|
23
|
+
`action_codegen`, and `action_bridge` still hold partial/stub API. Non-obvious
|
|
24
|
+
future-churn sites are flagged in source. Treat the core bridge + RPC endpoint
|
|
25
|
+
as stable; registry getters and codegen shapes may move.
|
|
26
|
+
|
|
27
|
+
## Action specs (`action_spec.ts`)
|
|
28
|
+
|
|
29
|
+
Canonical source of truth. Three concrete kinds discriminate on `kind`:
|
|
30
|
+
|
|
31
|
+
| Kind | `auth` | `side_effects` | `output` | `async` |
|
|
32
|
+
| --------------------- | ----------------------- | -------------- | ----------- | ------- |
|
|
33
|
+
| `request_response` | `ActionAuth` (non-null) | arbitrary | arbitrary | `true` |
|
|
34
|
+
| `remote_notification` | `null` | `true` | `z.ZodVoid` | `true` |
|
|
35
|
+
| `local_call` | `null` | arbitrary | arbitrary | boolean |
|
|
36
|
+
|
|
37
|
+
Enums + unions:
|
|
38
|
+
|
|
39
|
+
- `ActionKind` — `'request_response' | 'remote_notification' | 'local_call'`
|
|
40
|
+
- `ActionInitiator` — `'frontend' | 'backend' | 'both'`
|
|
41
|
+
- `ActionAuth` — `'public' | 'authenticated' | 'keeper' | {role: string}`
|
|
42
|
+
- `ActionSpecUnion` — discriminated union of the three variants
|
|
43
|
+
- `ActionEventPhase` — `'send_request' | 'receive_request' | 'send_response' | 'receive_response' | 'send_error' | 'receive_error' | 'send' | 'receive' | 'execute'`
|
|
44
|
+
- `is_action_spec(value)` — structural type guard
|
|
45
|
+
|
|
46
|
+
Optional `streams?: string` names a companion `remote_notification` method
|
|
47
|
+
emitted as request-scoped progress. Transport-agnostic handshake —
|
|
48
|
+
registry-time validation that the named method exists is a consumer concern.
|
|
49
|
+
|
|
50
|
+
Canonical spec shape: module-scope declaration with `satisfies` +
|
|
51
|
+
`{method}_action_spec` naming, preserving the literal `method` type and
|
|
52
|
+
dropping per-spec `*_METHOD` constants (readers dereference `.method` at
|
|
53
|
+
call sites). See ../../docs/usage.md §Canonical action-spec shape.
|
|
54
|
+
|
|
55
|
+
## Kind → binding constraints
|
|
56
|
+
|
|
57
|
+
The three action kinds map to bindings with hard constraints:
|
|
58
|
+
|
|
59
|
+
| Kind | REST `RouteSpec` | RPC `RouteSpec` (via dispatcher) | WS dispatch | SSE `EventSpec` |
|
|
60
|
+
| --------------------- | ---------------- | -------------------------------- | ----------- | --------------- |
|
|
61
|
+
| `request_response` | yes (bridge) | yes (`create_rpc_endpoint`) | yes | no |
|
|
62
|
+
| `remote_notification` | no | no | server push | yes (bridge) |
|
|
63
|
+
| `local_call` | no | no | no | no |
|
|
64
|
+
|
|
65
|
+
`create_action_route_spec` throws if `spec.auth` is null — enforces that
|
|
66
|
+
notifications and local calls cannot become routes. `create_action_event_spec`
|
|
67
|
+
throws on any non-`remote_notification` kind.
|
|
68
|
+
|
|
69
|
+
## Registry + codegen (`action_registry.ts`, `action_codegen.ts`)
|
|
70
|
+
|
|
71
|
+
`ActionRegistry(specs)` is a query/filter wrapper over `ActionSpecUnion[]`.
|
|
72
|
+
Used getters today are `spec_by_method`, `request_response_specs`,
|
|
73
|
+
`remote_notification_specs`, `local_call_specs`, `frontend_methods`,
|
|
74
|
+
`backend_methods`, `methods`. The rest are pre-built stubs — revisit when
|
|
75
|
+
the system matures.
|
|
76
|
+
|
|
77
|
+
`action_codegen.ts` provides gen helpers (used by consumer `*.gen.ts` files,
|
|
78
|
+
not the runtime):
|
|
79
|
+
|
|
80
|
+
- `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`.
|
|
81
|
+
- `get_executor_phases(spec, executor)` — phases a given executor (`'frontend' | 'backend'`) participates in for the spec. Deduplicates via `Set` (handles `initiator: 'both'` overlap).
|
|
82
|
+
- `get_handler_return_type(spec, phase, imports, path_prefix)` — the TS type a phase handler must return; triggers the `ActionOutputs` import as a side effect.
|
|
83
|
+
- `generate_phase_handlers(spec, executor, imports, {action_event_type?})` — emits the typed handler-map fragment for one action; consumers compose these into `ActionHandlers` types.
|
|
84
|
+
- `generate_actions_api_method_signature(spec, {sync_returns_value?})` — single source of truth for the typed `ActionsApi` method shape. Threads `options?: RpcClientCallOptions` (`{signal?, transport_name?, queue?}`) onto every async method. Consumers must regenerate onto this helper — older inline templates using `get_innermost_type_name` directly drop the options arg.
|
|
85
|
+
- `create_banner(origin_path)` — gen banner comment.
|
|
86
|
+
- `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`).
|
|
87
|
+
- `get_innermost_type(schema)` / `get_innermost_type_name(schema)` — unwrap Zod `ZodOptional` / `ZodNullable` / `ZodDefault` / transforms / pipes / prefaults to reach the base schema.
|
|
88
|
+
|
|
89
|
+
## HTTP bridge (`action_bridge.ts`)
|
|
90
|
+
|
|
91
|
+
Derives transport-specific specs from action specs. HTTP-specific concerns
|
|
92
|
+
(path, handler, errors) come from options, not the action spec.
|
|
93
|
+
|
|
94
|
+
- `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`). Auth maps via `map_action_auth` (`'public'` → `{type: 'none'}`, `'authenticated'` → `{type: 'authenticated'}`, `'keeper'` → `{type: 'keeper'}`, `{role}` → `{type: 'role', role}`). `options.errors: RouteErrorSchemas` attaches transport-specific (HTTP status–keyed) error shapes. `transaction: spec.side_effects`. Throws if `spec.auth` is null.
|
|
95
|
+
- `create_action_event_spec(spec, {channel?})` — one notification action → one `EventSpec` for SSE surface + `create_validated_broadcaster`. Throws on non-`remote_notification` kind.
|
|
96
|
+
- `map_action_auth(auth)` / `derive_http_method(side_effects)` — exported for consumers that build custom bridges.
|
|
97
|
+
|
|
98
|
+
## Single JSON-RPC 2.0 endpoint (`action_rpc.ts`)
|
|
99
|
+
|
|
100
|
+
`create_rpc_endpoint({path, actions, log}): RouteSpec[]` produces **two**
|
|
101
|
+
route specs on the same path (GET + POST) that share one internal
|
|
102
|
+
dispatcher. Per-action auth lives inside the dispatcher; the outer routes
|
|
103
|
+
use `auth: {type: 'none'}` and `transaction: false`.
|
|
104
|
+
|
|
105
|
+
Dispatcher phase order (POST; GET differs only at step 1):
|
|
106
|
+
|
|
107
|
+
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}`.
|
|
108
|
+
2. **Lookup method** — `Map<method, RpcAction>`. Unknown method → `method_not_found`. Duplicate methods throw at construction.
|
|
109
|
+
3. **GET read restriction** — GET is rejected for `side_effects: true` actions (`invalid_request` with "must use POST").
|
|
110
|
+
4. **Auth check** — via `check_action_auth(spec.auth, request_context, credential_type)`. `keeper` requires `credential_type === 'daemon_token'` AND `has_role(request_context, 'keeper')` — the `has_role` alone is insufficient, session/bearer cannot elevate. `{role}` uses `has_role`. Failure → `unauthenticated` / `forbidden`.
|
|
111
|
+
5. **Validate params** — `spec.input.safeParse(raw_params ?? null-if-null-schema)`. Failure → `invalid_params` with `{issues}`.
|
|
112
|
+
6. **Dispatch** — `spec.side_effects` picks transaction (`route.db.transaction(tx => execute(tx))`) vs pool (`route.db`). Handler throws roll back the transaction — the catch sits outside the transaction boundary.
|
|
113
|
+
7. **DEV-only output validation** — `spec.output.safeParse(output)` runs only under `DEV` (from `esm-env`). On mismatch: `log.error(...)`, return response unchanged; never throws, never mutates status.
|
|
114
|
+
|
|
115
|
+
Error paths: `ThrownJsonrpcError` (duck-typed via `err instanceof Error &&
|
|
116
|
+
typeof err.code === 'number'`) preserves code + data verbatim, status via
|
|
117
|
+
`jsonrpc_error_code_to_http_status`. Duck-typing avoids cross-copy
|
|
118
|
+
`instanceof` misses when consumers throw their own `ThrownJsonrpcError`
|
|
119
|
+
(e.g. zzz). Generic thrown errors become `internal_error` 500; message is
|
|
120
|
+
the raw error under `DEV`, "internal server error" otherwise.
|
|
121
|
+
|
|
122
|
+
Per-request handler shape:
|
|
123
|
+
|
|
124
|
+
```ts
|
|
125
|
+
type ActionHandler<TInput, TOutput> = (
|
|
126
|
+
input: TInput,
|
|
127
|
+
ctx: ActionContext,
|
|
128
|
+
) => TOutput | Promise<TOutput>;
|
|
129
|
+
|
|
130
|
+
interface ActionContext {
|
|
131
|
+
auth: RequestContext | null; // null for public actions
|
|
132
|
+
request_id: JsonrpcRequestId;
|
|
133
|
+
db: Db; // transaction for mutations, pool for reads
|
|
134
|
+
background_db: Db; // always pool — for fire-and-forget outlive
|
|
135
|
+
pending_effects: Array<Promise<void>>;
|
|
136
|
+
log: Logger;
|
|
137
|
+
notify: (method, params) => void; // HTTP: DEV-mode warn + drop (no streaming channel)
|
|
138
|
+
signal: AbortSignal; // c.req.raw.signal — fires on client disconnect
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
interface RpcAction {
|
|
142
|
+
spec: RequestResponseActionSpec;
|
|
143
|
+
handler: ActionHandler;
|
|
144
|
+
}
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
### `rpc_action(spec, handler)` — typed binder
|
|
148
|
+
|
|
149
|
+
`rpc_action<TSpec extends RequestResponseActionSpec>(spec, handler)`
|
|
150
|
+
returns a `RpcAction` with the handler's input / output types pinned to
|
|
151
|
+
`z.infer<TSpec['input']>` and `z.infer<TSpec['output']>` via the generic.
|
|
152
|
+
Use this at every spec → handler binding site so handler-type errors
|
|
153
|
+
surface at the factory call instead of at runtime:
|
|
154
|
+
|
|
155
|
+
```ts
|
|
156
|
+
export const create_admin_actions = (deps, options) => [
|
|
157
|
+
rpc_action(admin_account_list_action_spec, account_list_handler),
|
|
158
|
+
rpc_action(admin_session_revoke_all_action_spec, session_revoke_all_handler),
|
|
159
|
+
// …
|
|
160
|
+
];
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
zzz uses a codegen-driven `Record<Method, Handler>` map for the same
|
|
164
|
+
narrowing — ideal when handlers are stateless free functions. fuz_app's
|
|
165
|
+
handlers close over factory-captured deps (`log`, `on_audit_event`,
|
|
166
|
+
`options.app_settings`, `options.max_tokens`), so per-pair typing via
|
|
167
|
+
`rpc_action()` is the right shape here: the binding happens at
|
|
168
|
+
construction time and the handler keeps its closure. Applied across
|
|
169
|
+
`admin_actions.ts` + `permit_offer_actions.ts` + `account_actions.ts`
|
|
170
|
+
— each pairs a spec imported from its `*_action_specs.ts` sibling with
|
|
171
|
+
a closure-bound handler.
|
|
172
|
+
|
|
173
|
+
## Transports (`transports.ts`, `transports_http.ts`, `transports_ws.ts`, `transports_ws_backend.ts`)
|
|
174
|
+
|
|
175
|
+
`Transport` is the unifying interface: overloaded `send(message, options?)`
|
|
176
|
+
returning `Promise<JsonrpcResponseOrError>` for requests and
|
|
177
|
+
`Promise<JsonrpcErrorResponse | null>` for notifications, plus `is_ready()`
|
|
178
|
+
and optional `dispose()`. All transports share `TransportSendOptions`:
|
|
179
|
+
|
|
180
|
+
- `signal?: AbortSignal` — per-call cancel. Bottoms out at `FrontendWebsocketClient.request({signal})` on WS (sends `cancel` notification on abort) and at `fetch({signal})` on HTTP.
|
|
181
|
+
- `queue?: boolean` — per-call durable-queue opt-in. Honored only by `FrontendWebsocketTransport` on the `request_response` path (default `false`). HTTP, backend, and WS notifications all ignore it.
|
|
182
|
+
|
|
183
|
+
`Transports` registry holds multiple transports with a `current` selection
|
|
184
|
+
and `allow_fallback: boolean` (default `true`). `get_transport(name?)`
|
|
185
|
+
returns first-ready: specified → current → any. No fallback when
|
|
186
|
+
`allow_fallback: false`. Explicit `transport_for_method` (on `rpc_client`)
|
|
187
|
+
or `default_send_options.transport_name` (on `ActionPeer`) takes precedence.
|
|
188
|
+
|
|
189
|
+
WS close codes live here:
|
|
190
|
+
|
|
191
|
+
- `WS_CLOSE_SESSION_REVOKED = 4001` — server revoked auth; client enters permanent `revoked` state, no reconnect.
|
|
192
|
+
- `WS_CLOSE_CLIENT_HEARTBEAT_TIMEOUT = 4002` — client observed receive-silence past `DEFAULT_HEARTBEAT_RECEIVE_TIMEOUT`.
|
|
193
|
+
- `WS_CLOSE_SERVER_HEARTBEAT_TIMEOUT = 4003` — server observed receive-silence past `DEFAULT_SERVER_HEARTBEAT_TIMEOUT` (60s).
|
|
194
|
+
|
|
195
|
+
### `FrontendHttpTransport` (`transports_http.ts`)
|
|
196
|
+
|
|
197
|
+
Thin `fetch` adapter. Name `'frontend_http_rpc'`. POST by default; GET with
|
|
198
|
+
`?method=&id=¶ms=` when the caller supplies `has_side_effects(method)`
|
|
199
|
+
returning `false` (matches `create_rpc_endpoint`'s GET convention). Forwards
|
|
200
|
+
`signal` to `fetch`. On non-OK HTTP response, synthesizes a JSON-RPC error
|
|
201
|
+
envelope via `http_status_to_jsonrpc_error_code`. DEV-mode checks that
|
|
202
|
+
JSON-RPC error codes match the declared HTTP status and warns on drift.
|
|
203
|
+
`is_ready(): true` always.
|
|
204
|
+
|
|
205
|
+
### `FrontendWebsocketTransport` (`transports_ws.ts`)
|
|
206
|
+
|
|
207
|
+
Name `'frontend_websocket_rpc'`. A **thin adapter** over `WebsocketRpcConnection`
|
|
208
|
+
(the canonical implementation is `FrontendWebsocketClient`). No parallel
|
|
209
|
+
pending-request map — delegates request/response correlation, durable queue,
|
|
210
|
+
heartbeat, and abort-signal cancel to the underlying connection. Routes
|
|
211
|
+
inbound server-pushed messages (requests + notifications) into a `receive`
|
|
212
|
+
callback; responses are owned by `connection.request()` so the transport
|
|
213
|
+
ignores them.
|
|
214
|
+
|
|
215
|
+
Two connection interfaces it consumes:
|
|
216
|
+
|
|
217
|
+
- `WebsocketConnection` — minimal fire-and-forget (`send(data)`, `connected`, `add_message_handler`, `add_error_handler`).
|
|
218
|
+
- `WebsocketRpcConnection extends WebsocketConnection` — adds `request(method, params, {signal?, queue?, id?})` that throws `ThrownJsonrpcError` with the right code (`service_unavailable`, `queue_overflow`, `request_cancelled`, wire code from peer).
|
|
219
|
+
|
|
220
|
+
Notification sends fail-fast when disconnected regardless of `queue` —
|
|
221
|
+
`connection.send()` has no queue semantic, so buffering would masquerade
|
|
222
|
+
as success at the rpc_client layer. Requests are routed via `queue`.
|
|
223
|
+
|
|
224
|
+
### `BackendWebsocketTransport` (`transports_ws_backend.ts`)
|
|
225
|
+
|
|
226
|
+
Name `'backend_websocket_rpc'`. Server-side WS transport with session
|
|
227
|
+
tracking. Implements `FilterableBroadcastTransport` (the structural
|
|
228
|
+
capability for per-connection ACL'd fan-out; feature-detected via
|
|
229
|
+
`is_filterable_broadcast_transport`).
|
|
230
|
+
|
|
231
|
+
State is three aligned maps keyed by `connection_id` (branded `Uuid`):
|
|
232
|
+
|
|
233
|
+
- `#connections: Map<Uuid, WSContext>` — id → socket.
|
|
234
|
+
- `#connection_ids: WeakMap<WSContext, Uuid>` — socket → id (reverse).
|
|
235
|
+
- `#connection_identities: Map<Uuid, ConnectionIdentity>` — id → auth identity. `ConnectionIdentity` is `{token_hash: string | null, account_id: Uuid, api_token_id: string | null}` — session connections set `token_hash`, bearer set `api_token_id`, daemon-token sets both null.
|
|
236
|
+
|
|
237
|
+
Lifecycle:
|
|
238
|
+
|
|
239
|
+
- `add_connection(ws, token_hash, account_id, api_token_id) → Uuid` — assigns a fresh `connection_id`.
|
|
240
|
+
- `remove_connection(ws)` — idempotent; safe after revocation.
|
|
241
|
+
|
|
242
|
+
Targeted closure (all return `number` of sockets closed; use `WS_CLOSE_SESSION_REVOKED`):
|
|
243
|
+
|
|
244
|
+
- `close_sockets_for_session(token_hash)` — single session revocation.
|
|
245
|
+
- `close_sockets_for_token(api_token_id)` — one bearer token, leaves account's other sockets intact.
|
|
246
|
+
- `close_sockets_for_account(account_id)` — coarse; covers session + bearer + daemon-token.
|
|
247
|
+
|
|
248
|
+
Fan-out:
|
|
249
|
+
|
|
250
|
+
- `send(notification)` — broadcasts to every connection (current `send(request)` returns an internal_error "not yet implemented" — backend cannot initiate request-response).
|
|
251
|
+
- `broadcast_filtered(message, predicate)` — per-connection predicate over `ConnectionIdentity`; skips non-matching. Returns count.
|
|
252
|
+
- `send_to_account(account_id, message)` — targeted wrapper over `broadcast_filtered`. Mirrors `close_sockets_for_account` on the send side (every connection for the account). Structurally satisfies the `NotificationSender` interface from `auth/permit_offer_notifications.ts` (see `../auth/CLAUDE.md` §WS notifications).
|
|
253
|
+
- `get_connection_count()` — telemetry counter over the connection map.
|
|
254
|
+
|
|
255
|
+
Return values are bookkeeping, not delivery receipts — `0` means no live
|
|
256
|
+
sockets, non-zero means `ws.send` did not throw. Durable delivery requires
|
|
257
|
+
persistence + rehydration by the consumer.
|
|
258
|
+
|
|
259
|
+
## WS auth guard (`transports_ws_auth_guard.ts`)
|
|
260
|
+
|
|
261
|
+
`create_ws_auth_guard(transport, log)` returns an `on_audit_event` callback
|
|
262
|
+
wireable via `CreateAppBackendOptions.on_audit_event`. Mirrors the SSE
|
|
263
|
+
guard in `realtime/sse_auth_guard.ts` but targets the WS transport.
|
|
264
|
+
|
|
265
|
+
`WS_DISCONNECT_EVENT_TYPES` (ReadonlySet): `session_revoke`,
|
|
266
|
+
`token_revoke`, `session_revoke_all`, `token_revoke_all`, `password_change`.
|
|
267
|
+
`permit_revoke` is intentionally **omitted** — the WS transport does not
|
|
268
|
+
track per-connection role requirements, so role-scoped disconnection would
|
|
269
|
+
require either closing all sockets (too aggressive) or new per-connection
|
|
270
|
+
role tracking (out of scope). Consumers that need it compose their own
|
|
271
|
+
callback.
|
|
272
|
+
|
|
273
|
+
Event dispatch:
|
|
274
|
+
|
|
275
|
+
- `session_revoke` → `close_sockets_for_session(metadata.session_id)`
|
|
276
|
+
- `token_revoke` → `close_sockets_for_token(metadata.token_id)`
|
|
277
|
+
- `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`).
|
|
278
|
+
|
|
279
|
+
`outcome === 'failure'` events are ignored — they carry
|
|
280
|
+
attacker-controlled identifiers. Reacting to them would let an authenticated
|
|
281
|
+
caller close another user's socket by guessing a session hash or token id.
|
|
282
|
+
|
|
283
|
+
## WebSocket dispatch
|
|
284
|
+
|
|
285
|
+
Two layered entry points:
|
|
286
|
+
|
|
287
|
+
### `register_ws_endpoint` (`register_ws_endpoint.ts`) — idiomatic
|
|
288
|
+
|
|
289
|
+
Composes the standard upgrade stack:
|
|
290
|
+
|
|
291
|
+
1. `verify_request_source(allowed_origins)`
|
|
292
|
+
2. `require_auth`
|
|
293
|
+
3. optional `require_role(required_role)`
|
|
294
|
+
4. delegates to `register_action_ws`
|
|
295
|
+
|
|
296
|
+
Extends `RegisterActionWsOptions<TCtx>` with `allowed_origins: Array<RegExp>`
|
|
297
|
+
and optional `required_role: RoleName`. Returns `{transport}`. Note:
|
|
298
|
+
`required_role` is a **coarse upgrade-time gate** — per-action `auth` in
|
|
299
|
+
each spec still applies at dispatch time. (`verify_request_source` and
|
|
300
|
+
`require_auth` / `require_role` are from `../auth/`; see
|
|
301
|
+
`../auth/CLAUDE.md` §Middleware for their semantics.)
|
|
302
|
+
|
|
303
|
+
### `register_action_ws` (`register_action_ws.ts`) — lower-level
|
|
304
|
+
|
|
305
|
+
Exposed for tests (`create_ws_test_harness`) that need to drive the
|
|
306
|
+
dispatcher without the origin/auth front-stack.
|
|
307
|
+
|
|
308
|
+
Actions are passed as `ReadonlyArray<Action<TCtx>>` — the composable
|
|
309
|
+
`{spec, handler?}` tuple shared with `create_rpc_client`. The dispatcher
|
|
310
|
+
fans the array into a `spec_by_method` map (drives auth + validation) and
|
|
311
|
+
a `handlers` record (drives invocation). Spec without handler is fine for
|
|
312
|
+
client-only specs (incoming notification specs); spec without handler that
|
|
313
|
+
the dispatcher is asked to invoke returns `method_not_found`.
|
|
314
|
+
|
|
315
|
+
`extend_context(base, c)` builds the per-request context on every message.
|
|
316
|
+
`BaseHandlerContext` (the non-extended minimum, exported from `action_types.ts`):
|
|
317
|
+
|
|
318
|
+
```ts
|
|
319
|
+
interface BaseHandlerContext {
|
|
320
|
+
request_id: JsonrpcRequestId;
|
|
321
|
+
connection_id: Uuid; // stable across messages on this socket
|
|
322
|
+
notify: (method, params) => void; // socket-scoped, not broadcast
|
|
323
|
+
signal: AbortSignal; // AbortSignal.any([socket_close, per_request_cancel])
|
|
324
|
+
}
|
|
325
|
+
```
|
|
326
|
+
|
|
327
|
+
`WsActionHandler<TCtx>` is the WS-side handler type (single-context-slot,
|
|
328
|
+
returns `unknown` — disambiguated from `action_rpc.ts`'s `ActionHandler`).
|
|
329
|
+
|
|
330
|
+
Per-message wire behavior:
|
|
331
|
+
|
|
332
|
+
- **Batch JSON-RPC rejected** — arrays get `invalid_request`.
|
|
333
|
+
- **Notifications** — method + no id. Intercepted: `cancel` aborts the matching per-request controller; other notifications are silenced per JSON-RPC spec (no consumer notification handlers yet).
|
|
334
|
+
- **Per-action auth** — `public` / `authenticated` pass through (upgrade already verified); `keeper` requires `credential_type === 'daemon_token'` AND `has_role(ROLE_KEEPER)`; `{role}` requires `has_role(role)`. Same shape as `action_rpc.ts`.
|
|
335
|
+
- **Input validation** — `spec.input.safeParse(params)`; failure → `invalid_params` with `{issues}`.
|
|
336
|
+
- **DEV-only output validation** — `spec.output.safeParse(output)` under `DEV`; logs error on mismatch, never throws, sends result unchanged. Uniform with RPC + REST surfaces.
|
|
337
|
+
- **Error handling** — `ThrownJsonrpcError` preserves code + data; generic throws are wrapped via `create_jsonrpc_error_response_from_thrown`. `ThrownJsonrpcError` is logged at `debug` (expected protocol outcome); generic errors at `error`.
|
|
338
|
+
|
|
339
|
+
Two abort signals, composed via `AbortSignal.any`:
|
|
340
|
+
|
|
341
|
+
- `socket_abort_controller` — per-socket, fires on close. Drives every handler's `ctx.signal` on that socket.
|
|
342
|
+
- `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.
|
|
343
|
+
|
|
344
|
+
Lifecycle hooks on `RegisterActionWsOptions`:
|
|
345
|
+
|
|
346
|
+
- `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.
|
|
347
|
+
- `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.
|
|
348
|
+
|
|
349
|
+
Server-side heartbeat (`heartbeat?: boolean | ServerHeartbeatOptions`):
|
|
350
|
+
default-on, 60s silence timeout. Any inbound message resets
|
|
351
|
+
`last_receive_time` — chatty clients never trip it. First timeout window
|
|
352
|
+
after open is exempt (cold-start grace). Tick interval is
|
|
353
|
+
`timeout / 2`, so event-loop blockage pauses the timer itself.
|
|
354
|
+
|
|
355
|
+
## Event state machine
|
|
356
|
+
|
|
357
|
+
Five modules make up a discriminated-union-based state machine used by the
|
|
358
|
+
reactive client (`rpc_client.ts` + consumer ActionEvent-aware UIs) to track
|
|
359
|
+
an action through its lifecycle.
|
|
360
|
+
|
|
361
|
+
### `action_event_types.ts`
|
|
362
|
+
|
|
363
|
+
- `ActionExecutor` — `'frontend' | 'backend'`
|
|
364
|
+
- `ActionEventStep` — `'initial' | 'parsed' | 'handling' | 'handled' | 'failed'`
|
|
365
|
+
- `ACTION_EVENT_STEP_TRANSITIONS` — valid next-steps: `initial → parsed | failed`, `parsed → handling | failed`, `handling → handled | failed`, `handled`/`failed` terminal.
|
|
366
|
+
- `ACTION_EVENT_PHASE_BY_KIND` — valid phases per kind (`request_response` has 6, `remote_notification` has 2, `local_call` has 1).
|
|
367
|
+
- `ACTION_EVENT_PHASE_TRANSITIONS` — chained phases: `send_request → receive_response`; `receive_request → send_response`; everything else terminal.
|
|
368
|
+
- `ActionEventEnvironment` — `{executor, lookup_action_handler, lookup_action_spec, log?}`. The ambient registry + handler resolver for an `ActionEvent`.
|
|
369
|
+
|
|
370
|
+
### `action_event_data.ts`
|
|
371
|
+
|
|
372
|
+
`ActionEventData` is the base Zod schema — a strict object with all 10
|
|
373
|
+
possible fields always present (nullable where not applicable for the
|
|
374
|
+
current phase/step). The exported union `ActionEventDataUnion<TMethod,
|
|
375
|
+
TInput, TOutput>` is a **39-variant discriminated union** across `kind` +
|
|
376
|
+
`phase` + `step`: 28 variants for `request_response`, 6 for
|
|
377
|
+
`remote_notification`, 5 for `local_call`. Narrows the shape of
|
|
378
|
+
`input` / `output` / `error` / `request` / `response` / `notification` /
|
|
379
|
+
`progress` at each point in the lifecycle.
|
|
380
|
+
|
|
381
|
+
### `action_event_helpers.ts`
|
|
382
|
+
|
|
383
|
+
Type guards (discriminate on `kind` + `phase` + `step`):
|
|
384
|
+
|
|
385
|
+
- By kind: `is_request_response`, `is_remote_notification`, `is_local_call`
|
|
386
|
+
- By phase: `is_send_request`, `is_receive_request`, `is_send_response`, `is_receive_response`, `is_notification_send`, `is_notification_receive`, `is_execute`
|
|
387
|
+
- By step: `is_initial`, `is_parsed`, `is_handling`, `is_handled`, `is_failed`
|
|
388
|
+
- Combined: `is_send_request_with_parsed_input`, `is_notification_send_with_parsed_input`
|
|
389
|
+
|
|
390
|
+
Validators:
|
|
391
|
+
|
|
392
|
+
- `validate_step_transition(from, to)` — throws on illegal step moves.
|
|
393
|
+
- `validate_phase_for_kind(kind, phase)` — throws if the phase isn't valid for the kind.
|
|
394
|
+
- `validate_phase_transition(from, to)` — throws on illegal phase chain.
|
|
395
|
+
- `get_initial_phase(kind, initiator, executor)` — the phase an executor starts an action from, or `null` if this executor can't initiate.
|
|
396
|
+
- `should_validate_output(kind, phase)` — true for `receive_request`/`receive_response` on `request_response` and `execute` on `local_call`.
|
|
397
|
+
- `is_action_complete(data)` — `failed`, or `handled` at a terminal phase.
|
|
398
|
+
|
|
399
|
+
Constructors / extractors:
|
|
400
|
+
|
|
401
|
+
- `create_initial_data(kind, phase, method, executor, input)` — produces a well-formed initial-step `ActionEventData` with every nullable field null.
|
|
402
|
+
- `extract_action_result(event): Result<{value}, {error}>` — pulls the terminal outcome. Throws on non-terminal events (programming error).
|
|
403
|
+
|
|
404
|
+
### `action_event.ts`
|
|
405
|
+
|
|
406
|
+
`ActionEvent<TMethod, TPhase, TStep>` — the mutable state-machine class.
|
|
407
|
+
Holds `#data` (current `ActionEventDataUnion`), notifies observers on
|
|
408
|
+
every transition via `observe(listener): () => unsubscribe`. Keeps the
|
|
409
|
+
spec + environment references.
|
|
410
|
+
|
|
411
|
+
Lifecycle methods:
|
|
412
|
+
|
|
413
|
+
- `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.
|
|
414
|
+
- `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`.
|
|
415
|
+
- `transition(phase)` — `handled` at a chainable phase → next phase's `initial`. Uses `#create_phase_data` to carry forward `request` / `response` / `error` / `output` as appropriate.
|
|
416
|
+
- `is_complete()`, `update_progress(progress)`, `set_request(request)`, `set_response(response)`, `set_notification(notification)`.
|
|
417
|
+
|
|
418
|
+
Constructors:
|
|
419
|
+
|
|
420
|
+
- `create_action_event(environment, spec, input, initial_phase?)` — default phase via `get_initial_phase`; throws if the executor can't initiate.
|
|
421
|
+
- `create_action_event_from_json(json, environment)` — rehydrate after wire transfer.
|
|
422
|
+
- `parse_action_event(raw_json, environment)` — `ActionEventData.parse` + `create_action_event_from_json`.
|
|
423
|
+
|
|
424
|
+
Protocol message creation is automatic: when transitioning `parsed → handling`
|
|
425
|
+
on a `send_request` phase, `ActionEvent` materializes the outgoing
|
|
426
|
+
`JsonrpcRequest` with a fresh `create_uuid()` id; on `send` (notification)
|
|
427
|
+
it materializes the `JsonrpcNotification`.
|
|
428
|
+
|
|
429
|
+
## Action peer (`action_peer.ts`)
|
|
430
|
+
|
|
431
|
+
`ActionPeer` — symmetric JSON-RPC send + receive over a `Transports`
|
|
432
|
+
registry and `ActionEventEnvironment`. Construct with
|
|
433
|
+
`{environment, transports?, default_send_options?}`.
|
|
434
|
+
|
|
435
|
+
`default_send_options` excludes `signal` — signals are inherently per-call
|
|
436
|
+
(a shared signal would abort every subsequent call after the first trip).
|
|
437
|
+
`transport_name` and `queue` can be defaulted here once to flip the peer
|
|
438
|
+
into client-authoritative mode: `new ActionPeer({..., default_send_options:
|
|
439
|
+
{queue: true}})` durably queues every request_response call by default.
|
|
440
|
+
|
|
441
|
+
Per-call options:
|
|
442
|
+
|
|
443
|
+
```ts
|
|
444
|
+
interface ActionPeerSendOptions extends TransportSendOptions {
|
|
445
|
+
transport_name?: TransportName;
|
|
446
|
+
}
|
|
447
|
+
```
|
|
448
|
+
|
|
449
|
+
`send(message, options?)`:
|
|
450
|
+
|
|
451
|
+
- Resolves the transport via `transports.get_transport(options?.transport_name ?? default.transport_name)`.
|
|
452
|
+
- No transport → `service_unavailable` JSON-RPC error (does not throw).
|
|
453
|
+
- Delegates to `transport.send(message, {signal, queue: options?.queue ?? default.queue})`.
|
|
454
|
+
- Unexpected throws become `create_jsonrpc_error_response_from_thrown`.
|
|
455
|
+
|
|
456
|
+
`receive(message)` — dispatch for inbound messages:
|
|
457
|
+
|
|
458
|
+
- **Requests** — look up spec via `environment.lookup_action_spec`; unknown → `method_not_found`. Otherwise `create_action_event(environment, spec, params, 'receive_request')`, wire the request via `set_request`, run `parse().handle_async()`. On `handled`, transition to `send_response` + re-run `parse().handle_async()`. On `failed` or `send_error` phase, returns a `JsonrpcErrorResponse`.
|
|
459
|
+
- **Notifications** — same flow for `'receive'` phase; returns `null` (no response).
|
|
460
|
+
- **Anything else** — `invalid_request` JSON-RPC error.
|
|
461
|
+
|
|
462
|
+
Currently partial: `#receive_request`'s `send_response` transition step has
|
|
463
|
+
a known sharp edge ("shouldn't need the guard" TODO).
|
|
464
|
+
|
|
465
|
+
## Composable actions (`heartbeat.ts`, `cancel.ts`)
|
|
466
|
+
|
|
467
|
+
Two shared `{spec, handler}` tuples that every consumer spreads into both
|
|
468
|
+
sides' `actions` arrays — disconnect detection and per-request cancel work
|
|
469
|
+
identically across every repo without per-consumer ping plumbing.
|
|
470
|
+
|
|
471
|
+
### `heartbeat_action`
|
|
472
|
+
|
|
473
|
+
Method `'heartbeat'`, `request_response`, `initiator: 'frontend'`, `auth:
|
|
474
|
+
'authenticated'`, `side_effects: false`, nullary input/output
|
|
475
|
+
(`z.strictObject({})`). Handler is a stateless no-op echo. The client's
|
|
476
|
+
activity-aware heartbeat timer (`FrontendWebsocketClient.#heartbeat_tick`)
|
|
477
|
+
fires this whenever idle past `DEFAULT_HEARTBEAT_INTERVAL`; the server's
|
|
478
|
+
`register_action_ws` heartbeat tracker counts the incoming message as
|
|
479
|
+
activity and resets `last_receive_time`.
|
|
480
|
+
|
|
481
|
+
### `cancel_action`
|
|
482
|
+
|
|
483
|
+
Method `'cancel'`, `remote_notification`, `initiator: 'frontend'`, `auth:
|
|
484
|
+
null`, `side_effects: true`. Params: `CancelNotificationParams =
|
|
485
|
+
z.strictObject({request_id: JsonrpcRequestId})`. The **handler is an empty
|
|
486
|
+
stub** — cancel semantics are dispatcher-owned
|
|
487
|
+
(`register_action_ws` has the `{request_id → AbortController}` map, not the
|
|
488
|
+
handler). The tuple exists for symmetry + so `spec_by_method` knows about
|
|
489
|
+
it (enables input validation on incoming cancels) + so `create_rpc_client`
|
|
490
|
+
sees the method.
|
|
491
|
+
|
|
492
|
+
Wire format is snake_case `cancel` + `{request_id}`, not MCP's
|
|
493
|
+
`$/cancelRequest` + `{requestId}`. MCP adoption would happen at an MCP
|
|
494
|
+
adapter's translation layer, not in the base transport.
|
|
495
|
+
|
|
496
|
+
## Reactive frontend client (`socket.svelte.ts`, `request_tracker.svelte.ts`)
|
|
497
|
+
|
|
498
|
+
### `FrontendWebsocketClient`
|
|
499
|
+
|
|
500
|
+
Portable, Svelte-reactive (`$state.raw` for `ws`, `status`, `reconnect_count`,
|
|
501
|
+
`current_reconnect_delay`, `last_connect_time`, `last_close_time`,
|
|
502
|
+
`last_close_code`, `last_close_reason`, `last_send_error`). Plain class — no
|
|
503
|
+
Cell inheritance, no app coupling. Implements `WebsocketConnection` +
|
|
504
|
+
`WebsocketRpcConnection`, and is `Disposable`.
|
|
505
|
+
|
|
506
|
+
Ships three correctness primitives default-on:
|
|
507
|
+
|
|
508
|
+
1. **Promise-based `request`** — auto-assigned monotonic id (override via `options.id` for transport-minted UUIDs). Pending map keyed by id, resolved via intercept on the message path. Rejects `ThrownJsonrpcError` with specific codes — `unauthenticated` (revoked), `request_cancelled` (abort), `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`.
|
|
509
|
+
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).
|
|
510
|
+
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 — dead-because-blocked and dead-because-unresponsive close arguably the same way.
|
|
511
|
+
|
|
512
|
+
Reconnect policy (exponential backoff): `delay = DEFAULT_RECONNECT_DELAY * DEFAULT_BACKOFF_FACTOR ** (attempts-1)`, capped at `DEFAULT_RECONNECT_DELAY_MAX`. `WS_CLOSE_SESSION_REVOKED` is **terminal** — sets `#revoked = true`, no reconnect loop on 401.
|
|
513
|
+
|
|
514
|
+
Live policy swaps (behave like constructor — whole policy atomic, missing fields fall back to defaults, not "keep current"):
|
|
515
|
+
|
|
516
|
+
- `set_reconnect(reconnect?)` — monotonically **shortens** pending reconnects (never extends). Turning off while a reconnect is pending cancels it + transitions to `closed`.
|
|
517
|
+
- `set_heartbeat(heartbeat?)` — restarts the live timer when connected.
|
|
518
|
+
- `cancel_reconnect()` — `reconnecting → closed` + resets backoff without disabling future reconnects. Queue stays intact; next `connect()` flushes.
|
|
519
|
+
|
|
520
|
+
`SocketStatus` is `'initial' | 'connecting' | 'connected' | 'reconnecting' | 'closed'`. Terminal only when `revoked: true` or auto-reconnect is disabled.
|
|
521
|
+
|
|
522
|
+
`socket_status_to_async_status(status, revoked): AsyncStatus` — collapses
|
|
523
|
+
the 5-way `SocketStatus` to fuz_util's 4-way `AsyncStatus` for UI
|
|
524
|
+
indicators: `reconnecting → 'failure'`, `closed` splits by `revoked`
|
|
525
|
+
(`failure` if revoked, else `initial` — the "not connected, not trying"
|
|
526
|
+
state).
|
|
527
|
+
|
|
528
|
+
### `RequestTracker` (`request_tracker.svelte.ts`)
|
|
529
|
+
|
|
530
|
+
Public utility — reactive pending-request state with timeouts. `SvelteMap`
|
|
531
|
+
keyed by `JsonrpcRequestId`, each entry a `RequestTrackerItem` with `id`,
|
|
532
|
+
`deferred`, `created` (ISO datetime), reactive `status: AsyncStatus`, and
|
|
533
|
+
`timeout`. Default `request_timeout_ms = 120_000`.
|
|
534
|
+
|
|
535
|
+
Methods: `track_request(id): Deferred`, `resolve_request(id, response)`,
|
|
536
|
+
`reject_request(id, error_message)`, `handle_message(message)` (id-keyed
|
|
537
|
+
dispatch of JSON-RPC responses, ignores notifications / id-less frames),
|
|
538
|
+
`cancel_request(id)` (cleanup only, does not reject), `cancel_all_requests(reason?)`
|
|
539
|
+
(rejects all with `internal_error`).
|
|
540
|
+
|
|
541
|
+
Used by transports that don't delegate pending correlation to a
|
|
542
|
+
`WebsocketRpcConnection`. `FrontendWebsocketTransport` does not use it
|
|
543
|
+
(delegates to `FrontendWebsocketClient`'s own `#pending` map).
|
|
544
|
+
|
|
545
|
+
## RPC client (`rpc_client.ts`)
|
|
546
|
+
|
|
547
|
+
`create_rpc_client({peer, environment, actions?, transport_for_method?})` —
|
|
548
|
+
returns a Proxy-based typed API. Method name → action method via
|
|
549
|
+
`environment.lookup_action_spec`. Dispatches based on the spec's `kind`:
|
|
550
|
+
|
|
551
|
+
- `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).
|
|
552
|
+
- `local_call` (async) — `parse().handle_async()`, return `Result<{value}, {error}>`. Pre-flight `signal.aborted` check short-circuits with `internal_error`.
|
|
553
|
+
- `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`.
|
|
554
|
+
- `remote_notification` — builds event, creates notification, `peer.send(notification, {transport_name, signal, queue})`. Returns `Result<{value: void}, {error}>`.
|
|
555
|
+
|
|
556
|
+
Per-call options: `RpcClientCallOptions extends ActionPeerSendOptions` —
|
|
557
|
+
`{signal?, queue?, transport_name?}`. `transport_name` overrides
|
|
558
|
+
per-method `transport_for_method` selector for this call.
|
|
559
|
+
|
|
560
|
+
`transport_for_method: (method) => TransportName | undefined` — optional
|
|
561
|
+
per-method transport selector. Useful when methods are registered on
|
|
562
|
+
different backend dispatchers (e.g. streaming action on WS, rest on HTTP).
|
|
563
|
+
Returning `undefined` falls through to the peer's default selection.
|
|
564
|
+
|
|
565
|
+
`RpcClientActionHistory` — duck-typed integration point for consumers
|
|
566
|
+
(e.g. zzz's Actions cell) that want to record every dispatched event.
|
|
567
|
+
`add_from_json({method, action_event_data})` returns an object with
|
|
568
|
+
`listen_to_action_event(event)` — the Proxy wires each new event into the
|
|
569
|
+
history if supplied.
|
|
570
|
+
|
|
571
|
+
Cast the return to a generated `ActionsApi` interface for full typing:
|
|
572
|
+
codegen via `generate_actions_api_method_signature` keeps the shape
|
|
573
|
+
consistent. See ../../docs/usage.md §Typed Client Codegen.
|
|
574
|
+
|
|
575
|
+
## Broadcast API (`broadcast_api.ts`)
|
|
576
|
+
|
|
577
|
+
`create_broadcast_api({peer, specs, log?, should_deliver?})` — builds a
|
|
578
|
+
typed `{method: (input) => Promise<void>}` object from a list of action
|
|
579
|
+
specs. Counterpart to `register_action_ws`: that handles frontend-initiated
|
|
580
|
+
request-scoped dispatch, this handles backend-initiated broadcast.
|
|
581
|
+
Request-scoped streaming stays on `ctx.notify` inside a handler.
|
|
582
|
+
|
|
583
|
+
Per-method call: validates input against `spec.input` (logs + returns on
|
|
584
|
+
failure), wraps in a `JsonrpcNotification`, sends via the peer's resolved
|
|
585
|
+
transport. `transport_name` on `peer.default_send_options` pins the target
|
|
586
|
+
deterministically — no fallback, because broadcast is 1→N over a specific
|
|
587
|
+
primary transport and "any ready transport" could reach an unexpected
|
|
588
|
+
audience. Silently skips when no ready transport.
|
|
589
|
+
|
|
590
|
+
`should_deliver: (identity, method, input) => boolean` — optional
|
|
591
|
+
per-connection ACL predicate. When set, fans out via
|
|
592
|
+
`transport.broadcast_filtered` (feature-detected via
|
|
593
|
+
`is_filterable_broadcast_transport`). Errors during send are logged but
|
|
594
|
+
never thrown — broadcasts are fire-and-forget.
|
|
595
|
+
|
|
596
|
+
Typed surface: consumers declare an explicit `interface BackendActionsApi`
|
|
597
|
+
and pin it via `create_broadcast_api<BackendActionsApi>({...})` — unchecked
|
|
598
|
+
cast, so the interface and `specs` array must stay in sync (codegen is a
|
|
599
|
+
natural fit when consumers already generate per-method type maps).
|
|
600
|
+
|
|
601
|
+
## Shared type surface (`action_types.ts`)
|
|
602
|
+
|
|
603
|
+
Sits above `action_spec.ts` (pure Zod) and below the dispatchers
|
|
604
|
+
(`register_action_ws.ts`, `action_rpc.ts`). Extracted so composable
|
|
605
|
+
primitives (e.g. `heartbeat_action`) can name the types without pulling
|
|
606
|
+
in server-only modules.
|
|
607
|
+
|
|
608
|
+
- `BaseHandlerContext` — `{request_id, connection_id, notify, signal}` (see §WebSocket dispatch for field semantics).
|
|
609
|
+
- `WsActionHandler<TCtx>` — `(input, ctx) => unknown`. Disambiguated from HTTP's `ActionHandler`.
|
|
610
|
+
- `Action<TCtx>` — `{spec: ActionSpecUnion, handler?: WsActionHandler<TCtx>}`. The composable unit passed to both sides' `actions` arrays. Left open for future fields (rate_limit, ACL, middleware hooks) so additions attach to the action itself instead of scattering parallel arrays.
|
|
611
|
+
|
|
612
|
+
Re-exported from `register_action_ws.ts` as `Action`, `BaseHandlerContext`,
|
|
613
|
+
`WsActionHandler` for ergonomics.
|
|
614
|
+
|
|
615
|
+
## DEV-only output validation — uniform across surfaces
|
|
616
|
+
|
|
617
|
+
The critical invariant: all three action-handler surfaces apply DEV-only
|
|
618
|
+
output validation and produce the **same failure mode** — log an error,
|
|
619
|
+
return the response unchanged, do not throw, do not mutate status.
|
|
620
|
+
|
|
621
|
+
| Surface | Code location | Hot path under production |
|
|
622
|
+
| ----------------- | -------------------------------------------------------------------------------------------------------------------------- | ------------------------- |
|
|
623
|
+
| REST bridge | `http/route_spec.ts` — `wrap_output_validation` (applied via `apply_route_specs`; inherited by `create_action_route_spec`) | short-circuit (no parse) |
|
|
624
|
+
| JSON-RPC endpoint | `action_rpc.ts` — `if (DEV) action.spec.output.safeParse(output)` | short-circuit (no parse) |
|
|
625
|
+
| WebSocket | `register_action_ws.ts` — `if (DEV) spec.output.safeParse(output)` | short-circuit (no parse) |
|
|
626
|
+
|
|
627
|
+
Caller-facing `input` schemas are validated **always** (DEV + production) —
|
|
628
|
+
they're the contract with external callers. Server-authored `output`
|
|
629
|
+
schemas are internal data. See ../../docs/architecture.md §DEV-only Output
|
|
630
|
+
Validation for the full rationale.
|