@fuzdev/fuz_app 0.42.0 → 0.44.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. package/dist/actions/CLAUDE.md +77 -0
  2. package/dist/actions/action_rpc.d.ts.map +1 -1
  3. package/dist/actions/action_rpc.js +14 -7
  4. package/dist/actions/frontend_rpc_client.d.ts +74 -0
  5. package/dist/actions/frontend_rpc_client.d.ts.map +1 -0
  6. package/dist/actions/frontend_rpc_client.js +61 -0
  7. package/dist/actions/rpc_client.d.ts +64 -0
  8. package/dist/actions/rpc_client.d.ts.map +1 -1
  9. package/dist/actions/rpc_client.js +77 -0
  10. package/dist/auth/CLAUDE.md +32 -21
  11. package/dist/auth/account_action_specs.d.ts +8 -8
  12. package/dist/auth/account_action_specs.js +4 -4
  13. package/dist/auth/admin_action_specs.d.ts +8 -8
  14. package/dist/auth/admin_action_specs.js +4 -4
  15. package/dist/auth/self_service_role_action_specs.d.ts +20 -48
  16. package/dist/auth/self_service_role_action_specs.d.ts.map +1 -1
  17. package/dist/auth/self_service_role_action_specs.js +22 -44
  18. package/dist/auth/self_service_role_actions.d.ts +9 -9
  19. package/dist/auth/self_service_role_actions.d.ts.map +1 -1
  20. package/dist/auth/self_service_role_actions.js +48 -53
  21. package/dist/auth/standard_action_specs.d.ts +31 -0
  22. package/dist/auth/standard_action_specs.d.ts.map +1 -0
  23. package/dist/auth/standard_action_specs.js +36 -0
  24. package/dist/http/schema_helpers.d.ts +9 -0
  25. package/dist/http/schema_helpers.d.ts.map +1 -1
  26. package/dist/http/schema_helpers.js +9 -0
  27. package/dist/testing/admin_integration.js +9 -9
  28. package/dist/testing/audit_completeness.js +3 -3
  29. package/dist/testing/integration.js +36 -36
  30. package/dist/testing/rpc_helpers.d.ts +14 -6
  31. package/dist/testing/rpc_helpers.d.ts.map +1 -1
  32. package/dist/testing/rpc_helpers.js +8 -5
  33. package/dist/ui/admin_rpc_adapters.js +4 -4
  34. package/package.json +1 -1
@@ -591,6 +591,83 @@ Cast the return to a generated `ActionsApi` interface for full typing:
591
591
  codegen via `generate_actions_api_method_signature` keeps the shape
592
592
  consistent. See ../../docs/usage.md §Typed Client Codegen.
593
593
 
594
+ ### Throwing variants — `create_throwing_rpc_call` + `create_throwing_api`
595
+
596
+ Two helpers wrap a typed `create_rpc_client` Proxy so `{ok: false}` results
597
+ throw an `Error` with `{code, message, data?}` (catch blocks read
598
+ `err.data?.reason` — optional chaining required because JSON-RPC `data`
599
+ is spec-level optional). Same hardening on both: only `{code, data}` cross
600
+ onto the Error, leaving `name` / `stack` as the native Error's own so
601
+ attacker-shaped `result.error` payloads cannot overwrite them.
602
+
603
+ | Helper | Shape | Use at |
604
+ | -------------------------- | -------------------------------- | -------------------------------------------------------------------------- |
605
+ | `create_throwing_rpc_call` | `(method, input?) => Promise<T>` | adapter wiring (e.g. `ui/admin_rpc_adapters.ts`) — method comes from a map |
606
+ | `create_throwing_api` | typed Proxy over `ActionsApi` | direct call sites — `await api.foo(input)` keeps full inference |
607
+
608
+ **Recommended consumer convention.** The throwing form is the common
609
+ case at call sites; the Result form is the composable escape hatch for
610
+ sites that want to inspect `error.data.reason` without try/catch. Bind
611
+ them as `api` (throwing wrapper) and `api_raw` (the unwrapped
612
+ underlying, returning Results):
613
+
614
+ ```ts
615
+ const api_raw = create_rpc_client({peer, environment}) as unknown as MyActionsApi;
616
+ const api = create_throwing_api(api_raw);
617
+ // hot path: await api.foo(input)
618
+ // rare branch: const r = await api_raw.foo(input); if (!r.ok) { … }
619
+ ```
620
+
621
+ Composable — feed the same typed Proxy into both: the loose method-keyed
622
+ form for adapter dispatch, the typed Proxy form for hand-written call
623
+ sites. `ThrowingApi<TApi>` mapped type strips
624
+ `Promise<Result<{value: T}, {error: JsonrpcErrorObject}>>` to `Promise<T>`
625
+ on every method that matches the `request_response` / async `local_call`
626
+ return shape; `remote_notification` (`=> void`) and sync `local_call`
627
+ methods pass through. The Proxy implementation inspects each call's
628
+ result shape at runtime and only unwraps when it sees a Result, so
629
+ non-Result returns flow through unchanged.
630
+
631
+ Both helpers throw `"rpc method not found: <name>"` on invocation of an
632
+ unknown method. For `create_throwing_api` the thrower is returned from
633
+ the Proxy get trap so `api.missing()` errors with the same clear
634
+ message rather than the JS default `"api.missing is not a function"`.
635
+ Symbol props and `then` stay `undefined` so the Proxy doesn't get
636
+ probed as a thenable by `await`.
637
+
638
+ ### Frontend factory (`frontend_rpc_client.ts`)
639
+
640
+ `create_frontend_rpc_client<TApi>({specs, path?, transports?})` bundles
641
+ the `ActionRegistry + ActionEventEnvironment + Transports + ActionPeer +
642
+ create_rpc_client` boilerplate every consumer repeats — plus the
643
+ `lookup_action_handler: () => undefined` stub (frontend never registers
644
+ `request_response` handlers; every method dispatches over the wire).
645
+ The `as unknown as TApi` cast happens inside the helper, so call sites
646
+ get a typed return without the cast hostility. Returns
647
+ `{api, peer, environment}` so advanced consumers (zzz-style frontends
648
+ needing extra transports / WS notification handlers / action-history
649
+ wiring) can extend without recreating the bundle.
650
+
651
+ Default transport is `FrontendHttpTransport(path ?? '/api/rpc')`. Pass
652
+ `transports` for WS-first or mixed setups — when supplied, the default
653
+ HTTP transport is **not** registered. `local_call` specs in `specs`
654
+ silently no-op because `lookup_action_handler` always returns
655
+ `undefined`; this factory targets wire-dispatched actions.
656
+
657
+ Pair with `create_throwing_api` to land the recommended `api` /
658
+ `api_raw` convention in two lines:
659
+
660
+ ```ts
661
+ const {api: api_raw} = create_frontend_rpc_client<MyActionsApi>({
662
+ specs: all_standard_action_specs,
663
+ });
664
+ const api = create_throwing_api(api_raw);
665
+ ```
666
+
667
+ `all_standard_action_specs` (in `../auth/standard_action_specs.ts`) is
668
+ the matching aggregate spec list mirroring `create_standard_rpc_actions`
669
+ on the backend — see `../auth/CLAUDE.md` §`standard_rpc_actions.ts`.
670
+
594
671
  ## Broadcast API (`broadcast_api.ts`)
595
672
 
596
673
  `create_broadcast_api({peer, specs, log?, should_deliver?})` — builds a
@@ -1 +1 @@
1
- {"version":3,"file":"action_rpc.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/actions/action_rpc.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAGH,OAAO,EAAC,CAAC,EAAC,MAAM,KAAK,CAAC;AAEtB,OAAO,KAAK,EAAC,MAAM,EAAC,MAAM,yBAAyB,CAAC;AAEpD,OAAO,KAAK,EAAC,yBAAyB,EAAC,MAAM,kBAAkB,CAAC;AAChE,OAAO,EAAoB,KAAK,SAAS,EAAC,MAAM,uBAAuB,CAAC;AAExE,OAAO,EAAgC,KAAK,cAAc,EAAC,MAAM,4BAA4B,CAAC;AAE9F,OAAO,KAAK,EAAC,EAAE,EAAC,MAAM,aAAa,CAAC;AAEpC,OAAO,EAGN,KAAK,gBAAgB,EAGrB,MAAM,oBAAoB,CAAC;AAW5B;;;;;;GAMG;AACH,MAAM,WAAW,aAAa;IAC7B,+DAA+D;IAC/D,IAAI,EAAE,cAAc,GAAG,IAAI,CAAC;IAC5B,iDAAiD;IACjD,UAAU,EAAE,gBAAgB,CAAC;IAC7B,8DAA8D;IAC9D,EAAE,EAAE,EAAE,CAAC;IACP,oFAAoF;IACpF,aAAa,EAAE,EAAE,CAAC;IAClB,2EAA2E;IAC3E,eAAe,EAAE,KAAK,CAAC,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC;IACtC;;;;;;;OAOG;IACH,SAAS,EAAE,MAAM,CAAC;IAClB,uBAAuB;IACvB,GAAG,EAAE,MAAM,CAAC;IACZ;;;;;;;;OAQG;IACH,MAAM,EAAE,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,OAAO,KAAK,IAAI,CAAC;IAClD;;;;OAIG;IACH,MAAM,EAAE,WAAW,CAAC;CACpB;AAED;;;;;GAKG;AACH,MAAM,MAAM,aAAa,CAAC,MAAM,GAAG,GAAG,EAAE,OAAO,GAAG,GAAG,IAAI,CACxD,KAAK,EAAE,MAAM,EACb,GAAG,EAAE,aAAa,KACd,OAAO,GAAG,OAAO,CAAC,OAAO,CAAC,CAAC;AAEhC;;;;;GAKG;AACH,MAAM,WAAW,SAAS;IACzB,IAAI,EAAE,yBAAyB,CAAC;IAChC,OAAO,EAAE,aAAa,CAAC;CACvB;AAED;;;;;;;;;;;;;;;;;GAiBG;AACH,eAAO,MAAM,UAAU,GAAI,KAAK,SAAS,yBAAyB,EACjE,MAAM,KAAK,EACX,SAAS,aAAa,CAAC,CAAC,CAAC,KAAK,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC,EAAE,CAAC,CAAC,KAAK,CAAC,KAAK,CAAC,QAAQ,CAAC,CAAC,CAAC,KACvE,SAGD,CAAC;AAEH,yCAAyC;AACzC,MAAM,WAAW,wBAAwB;IACxC,sDAAsD;IACtD,IAAI,EAAE,MAAM,CAAC;IACb,4BAA4B;IAC5B,OAAO,EAAE,KAAK,CAAC,SAAS,CAAC,CAAC;IAC1B,2CAA2C;IAC3C,GAAG,EAAE,MAAM,CAAC;CACZ;AA4DD;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AACH,eAAO,MAAM,mBAAmB,GAAI,SAAS,wBAAwB,KAAG,KAAK,CAAC,SAAS,CA6PtF,CAAC"}
1
+ {"version":3,"file":"action_rpc.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/actions/action_rpc.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAGH,OAAO,EAAC,CAAC,EAAC,MAAM,KAAK,CAAC;AAEtB,OAAO,KAAK,EAAC,MAAM,EAAC,MAAM,yBAAyB,CAAC;AAEpD,OAAO,KAAK,EAAC,yBAAyB,EAAC,MAAM,kBAAkB,CAAC;AAChE,OAAO,EAAoB,KAAK,SAAS,EAAC,MAAM,uBAAuB,CAAC;AAExE,OAAO,EAAgC,KAAK,cAAc,EAAC,MAAM,4BAA4B,CAAC;AAE9F,OAAO,KAAK,EAAC,EAAE,EAAC,MAAM,aAAa,CAAC;AAEpC,OAAO,EAGN,KAAK,gBAAgB,EAGrB,MAAM,oBAAoB,CAAC;AAW5B;;;;;;GAMG;AACH,MAAM,WAAW,aAAa;IAC7B,+DAA+D;IAC/D,IAAI,EAAE,cAAc,GAAG,IAAI,CAAC;IAC5B,iDAAiD;IACjD,UAAU,EAAE,gBAAgB,CAAC;IAC7B,8DAA8D;IAC9D,EAAE,EAAE,EAAE,CAAC;IACP,oFAAoF;IACpF,aAAa,EAAE,EAAE,CAAC;IAClB,2EAA2E;IAC3E,eAAe,EAAE,KAAK,CAAC,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC;IACtC;;;;;;;OAOG;IACH,SAAS,EAAE,MAAM,CAAC;IAClB,uBAAuB;IACvB,GAAG,EAAE,MAAM,CAAC;IACZ;;;;;;;;OAQG;IACH,MAAM,EAAE,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,OAAO,KAAK,IAAI,CAAC;IAClD;;;;OAIG;IACH,MAAM,EAAE,WAAW,CAAC;CACpB;AAED;;;;;GAKG;AACH,MAAM,MAAM,aAAa,CAAC,MAAM,GAAG,GAAG,EAAE,OAAO,GAAG,GAAG,IAAI,CACxD,KAAK,EAAE,MAAM,EACb,GAAG,EAAE,aAAa,KACd,OAAO,GAAG,OAAO,CAAC,OAAO,CAAC,CAAC;AAEhC;;;;;GAKG;AACH,MAAM,WAAW,SAAS;IACzB,IAAI,EAAE,yBAAyB,CAAC;IAChC,OAAO,EAAE,aAAa,CAAC;CACvB;AAED;;;;;;;;;;;;;;;;;GAiBG;AACH,eAAO,MAAM,UAAU,GAAI,KAAK,SAAS,yBAAyB,EACjE,MAAM,KAAK,EACX,SAAS,aAAa,CAAC,CAAC,CAAC,KAAK,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC,EAAE,CAAC,CAAC,KAAK,CAAC,KAAK,CAAC,QAAQ,CAAC,CAAC,CAAC,KACvE,SAGD,CAAC;AAEH,yCAAyC;AACzC,MAAM,WAAW,wBAAwB;IACxC,sDAAsD;IACtD,IAAI,EAAE,MAAM,CAAC;IACb,4BAA4B;IAC5B,OAAO,EAAE,KAAK,CAAC,SAAS,CAAC,CAAC;IAC1B,2CAA2C;IAC3C,GAAG,EAAE,MAAM,CAAC;CACZ;AA4DD;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AACH,eAAO,MAAM,mBAAmB,GAAI,SAAS,wBAAwB,KAAG,KAAK,CAAC,SAAS,CAsQtF,CAAC"}
@@ -17,7 +17,7 @@ import {} from '../http/route_spec.js';
17
17
  import { get_client_ip } from '../http/proxy.js';
18
18
  import { get_request_context, has_role } from '../auth/request_context.js';
19
19
  import { CREDENTIAL_TYPE_KEY } from '../hono_context.js';
20
- import { is_null_schema } from '../http/schema_helpers.js';
20
+ import { is_null_schema, is_void_schema } from '../http/schema_helpers.js';
21
21
  import { JSONRPC_VERSION, JsonrpcRequest, } from '../http/jsonrpc.js';
22
22
  import { jsonrpc_error_messages, jsonrpc_error_code_to_http_status, JSONRPC_ERROR_CODES, } from '../http/jsonrpc_errors.js';
23
23
  import { ERROR_INSUFFICIENT_PERMISSIONS, ERROR_KEEPER_REQUIRES_DAEMON_TOKEN, } from '../http/error_schemas.js';
@@ -127,6 +127,9 @@ export const create_rpc_endpoint = (options) => {
127
127
  if (action_map.has(action.spec.method)) {
128
128
  throw new Error(`Duplicate RPC action method: ${action.spec.method}`);
129
129
  }
130
+ if (is_null_schema(action.spec.input)) {
131
+ throw new Error(`RPC action "${action.spec.method}" uses z.null() for input — JSON-RPC 2.0 §4.2 forbids "params": null on the wire (must be omitted or be a Structured value). Use z.void() for parameterless methods.`);
132
+ }
130
133
  action_map.set(action.spec.method, action);
131
134
  }
132
135
  /**
@@ -163,12 +166,16 @@ export const create_rpc_endpoint = (options) => {
163
166
  return c.json(error, jsonrpc_error_code_to_http_status(auth_error.code));
164
167
  }
165
168
  // step 4: validate params
166
- // Missing `params` on the envelope maps to `null` for `z.null()` input
167
- // schemas and `{}` for object inputs matches HTTP's "empty body = empty
168
- // object" convention so callers of all-optional-object RPC methods can
169
- // omit `params` on the wire (JSON-RPC envelope still serializes without
170
- // a `params` field; no protocol-level change).
171
- const params = raw_params ?? (is_null_schema(action.spec.input) ? null : {});
169
+ // Missing `params` on the envelope maps to `undefined` for `z.void()`
170
+ // input schemas and `{}` for object inputs (matches HTTP's "empty
171
+ // body = empty object" convention so callers of all-optional-object
172
+ // RPC methods can omit `params` on the wire). JSON-RPC 2.0 §4.2
173
+ // forbids `params: null`, so `z.void()` is the spec-correct schema
174
+ // for parameterless methods registration above rejects `z.null()`
175
+ // inputs to keep this branch from having to consider that legacy
176
+ // shape. When `raw_params` is present it flows through unchanged so
177
+ // contract-violating shapes still fail validation.
178
+ const params = is_void_schema(action.spec.input) ? raw_params : (raw_params ?? {});
172
179
  const parse_result = action.spec.input.safeParse(params);
173
180
  if (!parse_result.success) {
174
181
  const error = jsonrpc_error_response(id, jsonrpc_error_messages.invalid_params('invalid params', {
@@ -0,0 +1,74 @@
1
+ /**
2
+ * Frontend-only typed RPC client factory.
3
+ *
4
+ * Bundles the `ActionRegistry + ActionEventEnvironment + Transports +
5
+ * ActionPeer + create_rpc_client` boilerplate every consumer repeats — plus
6
+ * the `lookup_action_handler: () => undefined` stub (frontend never registers
7
+ * `request_response` handlers; every method dispatches over the wire).
8
+ *
9
+ * Generic `TApi` is the consumer's typed Proxy interface. The `as unknown
10
+ * as TApi` double cast happens inside the helper so call sites get a typed
11
+ * return value without the cast hostility.
12
+ *
13
+ * Companion to `create_throwing_api` — typical wiring is two lines:
14
+ *
15
+ * ```ts
16
+ * const {api: api_raw} = create_frontend_rpc_client<MyActionsApi>({specs: all_specs});
17
+ * const api = create_throwing_api(api_raw);
18
+ * ```
19
+ *
20
+ * Returns the underlying `peer` and `environment` alongside `api` so
21
+ * advanced consumers (zzz-style frontends needing extra transports / WS
22
+ * notification handlers / action-history wiring) can extend without
23
+ * recreating the bundle.
24
+ *
25
+ * Note: `local_call` specs in `specs` will silently no-op because
26
+ * `lookup_action_handler` always returns `undefined` — the frontend
27
+ * factory is for wire-dispatched actions. Frontend-side `local_call`
28
+ * needs a different wiring shape (custom `environment.lookup_action_handler`).
29
+ *
30
+ * @module
31
+ */
32
+ import { ActionPeer } from './action_peer.js';
33
+ import { type Transport } from './transports.js';
34
+ import type { ActionEventEnvironment } from './action_event_types.js';
35
+ import type { ActionSpecUnion } from './action_spec.js';
36
+ /** Options for `create_frontend_rpc_client`. */
37
+ export interface CreateFrontendRpcClientOptions {
38
+ /**
39
+ * Action specs the typed Proxy can dispatch. Methods absent from this
40
+ * list silently return `undefined` from the Proxy — the generic `TApi`
41
+ * cannot constrain runtime membership, so consumers must keep this list
42
+ * in sync with the typed surface (codegen recommended).
43
+ */
44
+ specs: ReadonlyArray<ActionSpecUnion>;
45
+ /**
46
+ * HTTP RPC endpoint path for the default `FrontendHttpTransport`.
47
+ * Defaults to `/api/rpc`. Ignored when `transports` is provided.
48
+ */
49
+ path?: string;
50
+ /**
51
+ * Optional explicit transport list. When provided, the default
52
+ * `FrontendHttpTransport(path)` is **not** registered — the caller is
53
+ * responsible for at least one ready transport. Use for WS-first or
54
+ * WS+HTTP mixed setups.
55
+ */
56
+ transports?: ReadonlyArray<Transport>;
57
+ }
58
+ /** Bundle returned by `create_frontend_rpc_client`. */
59
+ export interface FrontendRpcClient<TApi> {
60
+ /** Typed Proxy — call `api.method(input)` for `Promise<Result<...>>`. */
61
+ api: TApi;
62
+ /** Underlying peer — exposed for consumers that need to register more transports or send raw messages. */
63
+ peer: ActionPeer;
64
+ /** Action environment — exposed for consumers that need to share it (e.g. attach a notification handler registry). */
65
+ environment: ActionEventEnvironment;
66
+ }
67
+ /**
68
+ * Build a frontend-only typed RPC client.
69
+ *
70
+ * @param options - `specs` (required), optional `path` / `transports`
71
+ * @returns `{api, peer, environment}` — typed Proxy plus the underlying primitives
72
+ */
73
+ export declare const create_frontend_rpc_client: <TApi>(options: CreateFrontendRpcClientOptions) => FrontendRpcClient<TApi>;
74
+ //# sourceMappingURL=frontend_rpc_client.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"frontend_rpc_client.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/actions/frontend_rpc_client.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA8BG;AAGH,OAAO,EAAC,UAAU,EAAC,MAAM,kBAAkB,CAAC;AAC5C,OAAO,EAAa,KAAK,SAAS,EAAC,MAAM,iBAAiB,CAAC;AAG3D,OAAO,KAAK,EAAC,sBAAsB,EAAC,MAAM,yBAAyB,CAAC;AACpE,OAAO,KAAK,EAAC,eAAe,EAAC,MAAM,kBAAkB,CAAC;AAEtD,gDAAgD;AAChD,MAAM,WAAW,8BAA8B;IAC9C;;;;;OAKG;IACH,KAAK,EAAE,aAAa,CAAC,eAAe,CAAC,CAAC;IACtC;;;OAGG;IACH,IAAI,CAAC,EAAE,MAAM,CAAC;IACd;;;;;OAKG;IACH,UAAU,CAAC,EAAE,aAAa,CAAC,SAAS,CAAC,CAAC;CACtC;AAED,uDAAuD;AACvD,MAAM,WAAW,iBAAiB,CAAC,IAAI;IACtC,yEAAyE;IACzE,GAAG,EAAE,IAAI,CAAC;IACV,0GAA0G;IAC1G,IAAI,EAAE,UAAU,CAAC;IACjB,sHAAsH;IACtH,WAAW,EAAE,sBAAsB,CAAC;CACpC;AAED;;;;;GAKG;AACH,eAAO,MAAM,0BAA0B,GAAI,IAAI,EAC9C,SAAS,8BAA8B,KACrC,iBAAiB,CAAC,IAAI,CAgBxB,CAAC"}
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Frontend-only typed RPC client factory.
3
+ *
4
+ * Bundles the `ActionRegistry + ActionEventEnvironment + Transports +
5
+ * ActionPeer + create_rpc_client` boilerplate every consumer repeats — plus
6
+ * the `lookup_action_handler: () => undefined` stub (frontend never registers
7
+ * `request_response` handlers; every method dispatches over the wire).
8
+ *
9
+ * Generic `TApi` is the consumer's typed Proxy interface. The `as unknown
10
+ * as TApi` double cast happens inside the helper so call sites get a typed
11
+ * return value without the cast hostility.
12
+ *
13
+ * Companion to `create_throwing_api` — typical wiring is two lines:
14
+ *
15
+ * ```ts
16
+ * const {api: api_raw} = create_frontend_rpc_client<MyActionsApi>({specs: all_specs});
17
+ * const api = create_throwing_api(api_raw);
18
+ * ```
19
+ *
20
+ * Returns the underlying `peer` and `environment` alongside `api` so
21
+ * advanced consumers (zzz-style frontends needing extra transports / WS
22
+ * notification handlers / action-history wiring) can extend without
23
+ * recreating the bundle.
24
+ *
25
+ * Note: `local_call` specs in `specs` will silently no-op because
26
+ * `lookup_action_handler` always returns `undefined` — the frontend
27
+ * factory is for wire-dispatched actions. Frontend-side `local_call`
28
+ * needs a different wiring shape (custom `environment.lookup_action_handler`).
29
+ *
30
+ * @module
31
+ */
32
+ import { ActionRegistry } from './action_registry.js';
33
+ import { ActionPeer } from './action_peer.js';
34
+ import { Transports } from './transports.js';
35
+ import { FrontendHttpTransport } from './transports_http.js';
36
+ import { create_rpc_client } from './rpc_client.js';
37
+ /**
38
+ * Build a frontend-only typed RPC client.
39
+ *
40
+ * @param options - `specs` (required), optional `path` / `transports`
41
+ * @returns `{api, peer, environment}` — typed Proxy plus the underlying primitives
42
+ */
43
+ export const create_frontend_rpc_client = (options) => {
44
+ const registry = new ActionRegistry([...options.specs]);
45
+ const environment = {
46
+ executor: 'frontend',
47
+ lookup_action_spec: (method) => registry.spec_by_method.get(method),
48
+ lookup_action_handler: () => undefined,
49
+ };
50
+ const transports = new Transports();
51
+ if (options.transports) {
52
+ for (const t of options.transports)
53
+ transports.register_transport(t);
54
+ }
55
+ else {
56
+ transports.register_transport(new FrontendHttpTransport(options.path ?? '/api/rpc'));
57
+ }
58
+ const peer = new ActionPeer({ environment, transports });
59
+ const api = create_rpc_client({ peer, environment });
60
+ return { api, peer, environment };
61
+ };
@@ -9,10 +9,12 @@
9
9
  *
10
10
  * @module
11
11
  */
12
+ import type { Result } from '@fuzdev/fuz_util/result.js';
12
13
  import type { ActionEventEnvironment } from './action_event_types.js';
13
14
  import type { ActionPeer, ActionPeerSendOptions } from './action_peer.js';
14
15
  import type { ActionEventDataUnion } from './action_event_data.js';
15
16
  import type { TransportName } from './transports.js';
17
+ import type { JsonrpcErrorObject } from '../http/jsonrpc.js';
16
18
  /**
17
19
  * Optional per-method transport selector. Return the transport to use for a
18
20
  * given method, or `undefined` to let the peer pick via its fallback rules.
@@ -113,4 +115,66 @@ export type ThrowingRpcCall = <TOutput = unknown>(method: string, input?: unknow
113
115
  * notably the consumer's generated `ActionsApi` interface)
114
116
  */
115
117
  export declare const create_throwing_rpc_call: <TApi extends Record<keyof TApi, (input?: any) => Promise<any> | void>>(api: TApi) => ThrowingRpcCall;
118
+ /**
119
+ * Maps a typed `ActionsApi` to a throwing variant.
120
+ *
121
+ * For each method whose return type matches the `create_rpc_client` shape
122
+ * (`Promise<Result<{value: T}, {error: JsonrpcErrorObject}>>`), the wrapped
123
+ * method returns `Promise<T>` directly. Other shapes (notifications typed
124
+ * as `=> void`, sync `local_call` methods) pass through unchanged — there
125
+ * is nothing to unwrap.
126
+ *
127
+ * Input + options parameters are preserved verbatim so generics, branded
128
+ * Uuids, and per-call `RpcClientCallOptions` keep working.
129
+ */
130
+ export type ThrowingApi<TApi> = {
131
+ [K in keyof TApi]: TApi[K] extends (input?: infer TInput, options?: infer TOptions) => Promise<Result<{
132
+ value: infer TValue;
133
+ }, {
134
+ error: JsonrpcErrorObject;
135
+ }>> ? (input?: TInput, options?: TOptions) => Promise<TValue> : TApi[K];
136
+ };
137
+ /**
138
+ * Wrap a typed RPC client so every call resolves to its unwrapped value or
139
+ * throws an `Error` carrying the JSON-RPC `{code, message, data?}` shape.
140
+ *
141
+ * Implementation is a Proxy because the underlying `create_rpc_client`
142
+ * return is itself a Proxy with no concrete keys — a key-by-key wrap would
143
+ * need to enumerate the typed surface, which only the consumer's generated
144
+ * `ActionsApi` interface knows.
145
+ *
146
+ * Pass-through on non-Result returns is deliberate: sync `local_call`
147
+ * Proxy methods return values directly (see `create_sync_local_call_method`
148
+ * above). The Proxy can't distinguish those at get-time, so the wrapper
149
+ * inspects `result` shape at call-time and only unwraps when it sees a
150
+ * Result. Non-object returns pass through unchanged.
151
+ *
152
+ * Only `{code, data}` cross onto the thrown Error — `name` / `stack` are
153
+ * left as the Error's own properties so attacker-shaped `result.error`
154
+ * payloads cannot overwrite them. Same hardening as
155
+ * `create_throwing_rpc_call`.
156
+ *
157
+ * Composable with `create_throwing_rpc_call` — same typed underlying
158
+ * client feeds both: the Proxy form for direct call sites, the loose
159
+ * method-keyed form for adapter wiring (`ui/admin_rpc_adapters.ts`).
160
+ *
161
+ * Recommended consumer convention: bind the throwing wrapper to `api`
162
+ * (the common case at call sites) and the underlying Result-returning
163
+ * Proxy to `api_raw` (the composable escape hatch for callers that
164
+ * want to inspect `error.data.reason` without try/catch).
165
+ *
166
+ * Catch blocks read `err.data?.reason` — optional chaining required
167
+ * because JSON-RPC `data` is spec-level optional.
168
+ *
169
+ * On unknown string-keyed methods, the get trap returns a function that
170
+ * throws `"rpc method not found: <prop>"` on invocation — symmetric with
171
+ * `create_throwing_rpc_call` and clearer than the JS default
172
+ * `"api.foo is not a function"`. Symbol props and `then` stay
173
+ * undefined so the Proxy isn't accidentally treated as a thenable
174
+ * (`await api` would otherwise probe `then` and trip the thrower).
175
+ *
176
+ * @param api_raw - typed RPC client from `create_rpc_client`, cast
177
+ * to a consumer-generated `ActionsApi` interface
178
+ */
179
+ export declare const create_throwing_api: <TApi extends object>(api_raw: TApi) => ThrowingApi<TApi>;
116
180
  //# sourceMappingURL=rpc_client.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"rpc_client.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/actions/rpc_client.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AAQH,OAAO,KAAK,EAAC,sBAAsB,EAAC,MAAM,yBAAyB,CAAC;AAOpE,OAAO,KAAK,EAAC,UAAU,EAAE,qBAAqB,EAAC,MAAM,kBAAkB,CAAC;AACxE,OAAO,KAAK,EAAC,oBAAoB,EAAC,MAAM,wBAAwB,CAAC;AACjE,OAAO,KAAK,EAAC,aAAa,EAAC,MAAM,iBAAiB,CAAC;AAGnD;;;;;;;GAOG;AACH,MAAM,MAAM,kBAAkB,GAAG,CAAC,MAAM,EAAE,MAAM,KAAK,aAAa,GAAG,SAAS,CAAC;AAM/E,8EAA8E;AAC9E,MAAM,WAAW,sBAAsB;IACtC,aAAa,EAAE,CAAC,IAAI,EAAE;QAAC,MAAM,EAAE,MAAM,CAAC;QAAC,iBAAiB,EAAE,oBAAoB,CAAA;KAAC,KAC5E;QACA,sBAAsB,EAAE,CAAC,KAAK,EAAE,GAAG,KAAK,IAAI,CAAC;KAC5C,GACD,SAAS,CAAC;CACb;AAED,uCAAuC;AACvC,MAAM,WAAW,sBAAsB;IACtC,IAAI,EAAE,UAAU,CAAC;IACjB,WAAW,EAAE,sBAAsB,CAAC;IACpC,kEAAkE;IAClE,OAAO,CAAC,EAAE,sBAAsB,CAAC;IACjC;;;;;OAKG;IACH,oBAAoB,CAAC,EAAE,kBAAkB,CAAC;CAC1C;AAED;;;;;;;;;;GAUG;AACH,eAAO,MAAM,iBAAiB,GAC7B,SAAS,sBAAsB,KAC7B,MAAM,CAAC,MAAM,EAAE,CAAC,GAAG,IAAI,EAAE,KAAK,CAAC,GAAG,CAAC,KAAK,GAAG,CAgB7C,CAAC;AA2DF;;;;;GAKG;AACH,MAAM,WAAW,oBAAqB,SAAQ,qBAAqB;CAAG;AAgItE;;;;;;;GAOG;AACH,MAAM,MAAM,eAAe,GAAG,CAAC,OAAO,GAAG,OAAO,EAC/C,MAAM,EAAE,MAAM,EACd,KAAK,CAAC,EAAE,OAAO,KACX,OAAO,CAAC,OAAO,CAAC,CAAC;AAEtB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAqCG;AACH,eAAO,MAAM,wBAAwB,GACpC,IAAI,SAAS,MAAM,CAAC,MAAM,IAAI,EAAE,CAAC,KAAK,CAAC,EAAE,GAAG,KAAK,OAAO,CAAC,GAAG,CAAC,GAAG,IAAI,CAAC,EAErE,KAAK,IAAI,KACP,eAcF,CAAC"}
1
+ {"version":3,"file":"rpc_client.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/actions/rpc_client.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AAEH,OAAO,KAAK,EAAC,MAAM,EAAC,MAAM,4BAA4B,CAAC;AAQvD,OAAO,KAAK,EAAC,sBAAsB,EAAC,MAAM,yBAAyB,CAAC;AAOpE,OAAO,KAAK,EAAC,UAAU,EAAE,qBAAqB,EAAC,MAAM,kBAAkB,CAAC;AACxE,OAAO,KAAK,EAAC,oBAAoB,EAAC,MAAM,wBAAwB,CAAC;AACjE,OAAO,KAAK,EAAC,aAAa,EAAC,MAAM,iBAAiB,CAAC;AAEnD,OAAO,KAAK,EAAC,kBAAkB,EAAC,MAAM,oBAAoB,CAAC;AAE3D;;;;;;;GAOG;AACH,MAAM,MAAM,kBAAkB,GAAG,CAAC,MAAM,EAAE,MAAM,KAAK,aAAa,GAAG,SAAS,CAAC;AAM/E,8EAA8E;AAC9E,MAAM,WAAW,sBAAsB;IACtC,aAAa,EAAE,CAAC,IAAI,EAAE;QAAC,MAAM,EAAE,MAAM,CAAC;QAAC,iBAAiB,EAAE,oBAAoB,CAAA;KAAC,KAC5E;QACA,sBAAsB,EAAE,CAAC,KAAK,EAAE,GAAG,KAAK,IAAI,CAAC;KAC5C,GACD,SAAS,CAAC;CACb;AAED,uCAAuC;AACvC,MAAM,WAAW,sBAAsB;IACtC,IAAI,EAAE,UAAU,CAAC;IACjB,WAAW,EAAE,sBAAsB,CAAC;IACpC,kEAAkE;IAClE,OAAO,CAAC,EAAE,sBAAsB,CAAC;IACjC;;;;;OAKG;IACH,oBAAoB,CAAC,EAAE,kBAAkB,CAAC;CAC1C;AAED;;;;;;;;;;GAUG;AACH,eAAO,MAAM,iBAAiB,GAC7B,SAAS,sBAAsB,KAC7B,MAAM,CAAC,MAAM,EAAE,CAAC,GAAG,IAAI,EAAE,KAAK,CAAC,GAAG,CAAC,KAAK,GAAG,CAgB7C,CAAC;AA2DF;;;;;GAKG;AACH,MAAM,WAAW,oBAAqB,SAAQ,qBAAqB;CAAG;AAgItE;;;;;;;GAOG;AACH,MAAM,MAAM,eAAe,GAAG,CAAC,OAAO,GAAG,OAAO,EAC/C,MAAM,EAAE,MAAM,EACd,KAAK,CAAC,EAAE,OAAO,KACX,OAAO,CAAC,OAAO,CAAC,CAAC;AAEtB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAqCG;AACH,eAAO,MAAM,wBAAwB,GACpC,IAAI,SAAS,MAAM,CAAC,MAAM,IAAI,EAAE,CAAC,KAAK,CAAC,EAAE,GAAG,KAAK,OAAO,CAAC,GAAG,CAAC,GAAG,IAAI,CAAC,EAErE,KAAK,IAAI,KACP,eAcF,CAAC;AAEF;;;;;;;;;;;GAWG;AACH,MAAM,MAAM,WAAW,CAAC,IAAI,IAAI;KAC9B,CAAC,IAAI,MAAM,IAAI,GAAG,IAAI,CAAC,CAAC,CAAC,SAAS,CAClC,KAAK,CAAC,EAAE,MAAM,MAAM,EACpB,OAAO,CAAC,EAAE,MAAM,QAAQ,KACpB,OAAO,CAAC,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,MAAM,CAAA;KAAC,EAAE;QAAC,KAAK,EAAE,kBAAkB,CAAA;KAAC,CAAC,CAAC,GACrE,CAAC,KAAK,CAAC,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,QAAQ,KAAK,OAAO,CAAC,MAAM,CAAC,GACvD,IAAI,CAAC,CAAC,CAAC;CACV,CAAC;AAEF;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAyCG;AACH,eAAO,MAAM,mBAAmB,GAAI,IAAI,SAAS,MAAM,EAAE,SAAS,IAAI,KAAG,WAAW,CAAC,IAAI,CA8BxF,CAAC"}
@@ -222,3 +222,80 @@ export const create_throwing_rpc_call = (api) => {
222
222
  return result.value;
223
223
  };
224
224
  };
225
+ /**
226
+ * Wrap a typed RPC client so every call resolves to its unwrapped value or
227
+ * throws an `Error` carrying the JSON-RPC `{code, message, data?}` shape.
228
+ *
229
+ * Implementation is a Proxy because the underlying `create_rpc_client`
230
+ * return is itself a Proxy with no concrete keys — a key-by-key wrap would
231
+ * need to enumerate the typed surface, which only the consumer's generated
232
+ * `ActionsApi` interface knows.
233
+ *
234
+ * Pass-through on non-Result returns is deliberate: sync `local_call`
235
+ * Proxy methods return values directly (see `create_sync_local_call_method`
236
+ * above). The Proxy can't distinguish those at get-time, so the wrapper
237
+ * inspects `result` shape at call-time and only unwraps when it sees a
238
+ * Result. Non-object returns pass through unchanged.
239
+ *
240
+ * Only `{code, data}` cross onto the thrown Error — `name` / `stack` are
241
+ * left as the Error's own properties so attacker-shaped `result.error`
242
+ * payloads cannot overwrite them. Same hardening as
243
+ * `create_throwing_rpc_call`.
244
+ *
245
+ * Composable with `create_throwing_rpc_call` — same typed underlying
246
+ * client feeds both: the Proxy form for direct call sites, the loose
247
+ * method-keyed form for adapter wiring (`ui/admin_rpc_adapters.ts`).
248
+ *
249
+ * Recommended consumer convention: bind the throwing wrapper to `api`
250
+ * (the common case at call sites) and the underlying Result-returning
251
+ * Proxy to `api_raw` (the composable escape hatch for callers that
252
+ * want to inspect `error.data.reason` without try/catch).
253
+ *
254
+ * Catch blocks read `err.data?.reason` — optional chaining required
255
+ * because JSON-RPC `data` is spec-level optional.
256
+ *
257
+ * On unknown string-keyed methods, the get trap returns a function that
258
+ * throws `"rpc method not found: <prop>"` on invocation — symmetric with
259
+ * `create_throwing_rpc_call` and clearer than the JS default
260
+ * `"api.foo is not a function"`. Symbol props and `then` stay
261
+ * undefined so the Proxy isn't accidentally treated as a thenable
262
+ * (`await api` would otherwise probe `then` and trip the thrower).
263
+ *
264
+ * @param api_raw - typed RPC client from `create_rpc_client`, cast
265
+ * to a consumer-generated `ActionsApi` interface
266
+ */
267
+ export const create_throwing_api = (api_raw) => {
268
+ return new Proxy(api_raw, {
269
+ get(target, prop) {
270
+ const fn = target[prop];
271
+ if (typeof fn === 'function') {
272
+ return async (...args) => {
273
+ const result = await fn.apply(target, args);
274
+ if (result === null || typeof result !== 'object')
275
+ return result;
276
+ const r = result;
277
+ if (r.ok === true)
278
+ return r.value;
279
+ if (r.ok === false && r.error && typeof r.error === 'object') {
280
+ const e = r.error;
281
+ throw Object.assign(new Error(e.message), {
282
+ code: e.code,
283
+ data: e.data,
284
+ });
285
+ }
286
+ return result;
287
+ };
288
+ }
289
+ if (fn !== undefined)
290
+ return fn;
291
+ // Underlying api has no member by this name. Symbol props and
292
+ // `then` must stay undefined — `await tapi` reads `then` and
293
+ // would otherwise trip the thrower.
294
+ if (typeof prop !== 'string' || prop === 'then')
295
+ return undefined;
296
+ return () => {
297
+ throw new Error(`rpc method not found: ${prop}`);
298
+ };
299
+ },
300
+ });
301
+ };
@@ -845,16 +845,16 @@ auth per-spec, so mixed-auth endpoints compose cleanly.
845
845
 
846
846
  | Spec | Side effects | Input | Output |
847
847
  | -------------------------------------- | ------------ | --------------------------------------------------------- | ----------------------------- |
848
- | `admin_account_list_action_spec` | false | `z.null()` | `{accounts, grantable_roles}` |
849
- | `admin_session_list_action_spec` | false | `z.null()` | `{sessions}` |
848
+ | `admin_account_list_action_spec` | false | `z.void()` | `{accounts, grantable_roles}` |
849
+ | `admin_session_list_action_spec` | false | `z.void()` | `{sessions}` |
850
850
  | `admin_session_revoke_all_action_spec` | true | `{account_id}` | `{ok, count}` |
851
851
  | `admin_token_revoke_all_action_spec` | true | `{account_id}` | `{ok, count}` |
852
852
  | `audit_log_list_action_spec` | false | `{event_type?, account_id?, limit?, offset?, since_seq?}` | `{events}` |
853
853
  | `audit_log_permit_history_action_spec` | false | `{limit?, offset?}` | `{events}` |
854
854
  | `invite_create_action_spec` | true | `{email?, username?}` | `{ok, invite}` |
855
- | `invite_list_action_spec` | false | `z.null()` | `{invites}` |
855
+ | `invite_list_action_spec` | false | `z.void()` | `{invites}` |
856
856
  | `invite_delete_action_spec` | true | `{invite_id}` | `{ok}` |
857
- | `app_settings_get_action_spec` | false | `z.null()` | `{settings}` |
857
+ | `app_settings_get_action_spec` | false | `z.void()` | `{settings}` |
858
858
  | `app_settings_update_action_spec` | true | `{open_signup}` | `{ok, settings}` |
859
859
 
860
860
  `AUDIT_LOG_LIST_LIMIT_MAX = 200` — page size clamp (mirrors the former REST
@@ -1039,6 +1039,14 @@ the admin integration suite exercises `account_token_create` /
1039
1039
  consumer wiring the admin surface without account actions will hit
1040
1040
  `method not found` on first admin-suite run.
1041
1041
 
1042
+ Frontend mirror: `all_standard_action_specs` (in
1043
+ `./standard_action_specs.ts`) bundles `all_admin_action_specs +
1044
+ all_permit_offer_action_specs + all_account_action_specs` into one
1045
+ `ReadonlyArray<RequestResponseActionSpec>` for typed-client codegen
1046
+ and `create_frontend_rpc_client({specs})` wiring. Self-service role
1047
+ specs are not included (opt-in, app-specific `eligible_roles`) —
1048
+ spread `all_self_service_role_action_specs` separately when needed.
1049
+
1042
1050
  ### `account_action_specs.ts` + `account_actions.ts` — seven self-service RPC actions
1043
1051
 
1044
1052
  Counterpart to `account_routes.ts`. Cookie-lifecycle flows (`login`,
@@ -1058,12 +1066,12 @@ exists.
1058
1066
 
1059
1067
  | Spec | Side effects | Input | Output |
1060
1068
  | ---------------------------------------- | ------------ | -------------- | ----------------------- |
1061
- | `account_verify_action_spec` | false | `z.null()` | `SessionAccountJson` |
1062
- | `account_session_list_action_spec` | false | `z.null()` | `{sessions}` |
1069
+ | `account_verify_action_spec` | false | `z.void()` | `SessionAccountJson` |
1070
+ | `account_session_list_action_spec` | false | `z.void()` | `{sessions}` |
1063
1071
  | `account_session_revoke_action_spec` | true | `{session_id}` | `{ok, revoked}` |
1064
- | `account_session_revoke_all_action_spec` | true | `z.null()` | `{ok, count}` |
1072
+ | `account_session_revoke_all_action_spec` | true | `z.void()` | `{ok, count}` |
1065
1073
  | `account_token_create_action_spec` | true | `{name?}` | `{ok, token, id, name}` |
1066
- | `account_token_list_action_spec` | false | `z.null()` | `{tokens}` |
1074
+ | `account_token_list_action_spec` | false | `z.void()` | `{tokens}` |
1067
1075
  | `account_token_revoke_action_spec` | true | `{token_id}` | `{ok, revoked}` |
1068
1076
 
1069
1077
  `session_id` validates as `Blake3Hash`; `token_id` validates as
@@ -1084,15 +1092,17 @@ registry of all seven specs.
1084
1092
  ### `self_service_role_action_specs.ts` + `self_service_role_actions.ts` — opt-in self-service role toggle
1085
1093
 
1086
1094
  Same split as the other registries: `*_action_specs.ts` holds the input/output
1087
- Zod schemas, the two `satisfies RequestResponseActionSpec` literals, the
1095
+ Zod schemas, the `satisfies RequestResponseActionSpec` literal, the
1088
1096
  `ERROR_ROLE_NOT_SELF_SERVICE_ELIGIBLE` reason constant, and the
1089
1097
  `all_self_service_role_action_specs` registry — all client-safe. The
1090
- `*_actions.ts` factory imports the specs and pairs them with handlers.
1091
-
1092
- Two static `request_response` actions — `self_service_role_grant` and
1093
- `self_service_role_revoke` — that take `{role}` as input and toggle a
1094
- global permit on the caller. Both are idempotent: `granted: false` when
1095
- the caller already holds the role, `revoked: false` when they don't.
1098
+ `*_actions.ts` factory imports the spec and pairs it with the handler.
1099
+
1100
+ One static `request_response` action — `self_service_role_set` — that
1101
+ takes `{role, enabled: boolean}` and toggles a global permit on the
1102
+ caller. Idempotent in both directions: `changed: false` when the
1103
+ post-call state already matched the request (already-held when
1104
+ enabling; not-held when disabling). Output is `{ok, enabled, changed}` —
1105
+ `enabled` echoes the post-call state for self-describing responses.
1096
1106
  Audit metadata carries `self_service: true` so admin reviewers can
1097
1107
  distinguish self-toggled permits from admin grants/offers. The
1098
1108
  `permit_grant` / `permit_revoke` metadata schemas declare
@@ -1100,7 +1110,7 @@ distinguish self-toggled permits from admin grants/offers. The
1100
1110
  part of the documented surface rather than riding on `z.looseObject`
1101
1111
  permissiveness.
1102
1112
 
1103
- Method names are static — `role` lives in the input, not the method
1113
+ Method name is static — `role` lives in the input, not the method
1104
1114
  name. Mirrors the `permit_offer_create({role})` precedent. Per-role
1105
1115
  parameterized methods would break the `satisfies RequestResponseActionSpec`
1106
1116
  codegen invariant and grow the surface linearly per role.
@@ -1110,14 +1120,15 @@ codegen invariant and grow the surface linearly per role.
1110
1120
  - `eligible_roles: ReadonlyArray<string>` — required allowlist. Roles
1111
1121
  outside the list are rejected with `forbidden` + reason
1112
1122
  `role_not_self_service_eligible` (exported as
1113
- `ERROR_ROLE_NOT_SELF_SERVICE_ELIGIBLE`).
1123
+ `ERROR_ROLE_NOT_SELF_SERVICE_ELIGIBLE`). The eligibility check fires
1124
+ before the `enabled` branch — same rejection regardless of direction.
1114
1125
  - `roles?: RoleSchemaResult` — optional. When supplied, every entry in
1115
1126
  `eligible_roles` is checked against `roles.role_options` at factory
1116
1127
  time so typos throw at startup instead of at first call.
1117
1128
 
1118
- Grant path uses `query_permit_has_role` for a benign-TOCTOU pre-check
1129
+ Grant branch uses `query_permit_has_role` for a benign-TOCTOU pre-check
1119
1130
  (distinguishes new grant from idempotent re-grant), then
1120
- `query_grant_permit` for the actual insert. Revoke path filters
1131
+ `query_grant_permit` for the actual insert. Revoke branch filters
1121
1132
  `query_permit_find_active_for_actor` in JS for the matching
1122
1133
  `(actor, role, scope_id IS NULL)` row before calling
1123
1134
  `query_revoke_permit`. Bundle is **not** included in
@@ -1126,8 +1137,8 @@ spread alongside the standard bundle when needed.
1126
1137
 
1127
1138
  Deps: `SelfServiceRoleActionDeps = Pick<RouteFactoryDeps, 'log' | 'on_audit_event' | 'audit_log_config'>`.
1128
1139
 
1129
- `all_self_service_role_action_specs: Array<RequestResponseActionSpec>` —
1130
- codegen-ready registry of both specs.
1140
+ `all_self_service_role_action_specs: ReadonlyArray<RequestResponseActionSpec>` —
1141
+ codegen-ready registry of the single unified spec.
1131
1142
 
1132
1143
  ## Cleanup
1133
1144
 
@@ -10,10 +10,10 @@
10
10
  import { z } from 'zod';
11
11
  import type { RequestResponseActionSpec } from '../actions/action_spec.js';
12
12
  /** Input for `account_verify`. No parameters — the caller is the subject. */
13
- export declare const VerifyInput: z.ZodNull;
13
+ export declare const VerifyInput: z.ZodVoid;
14
14
  export type VerifyInput = z.infer<typeof VerifyInput>;
15
15
  /** Input for `account_session_list`. No parameters. */
16
- export declare const SessionListInput: z.ZodNull;
16
+ export declare const SessionListInput: z.ZodVoid;
17
17
  export type SessionListInput = z.infer<typeof SessionListInput>;
18
18
  /** Output for `account_session_list`. */
19
19
  export declare const SessionListOutput: z.ZodObject<{
@@ -38,7 +38,7 @@ export declare const SessionRevokeOutput: z.ZodObject<{
38
38
  }, z.core.$strict>;
39
39
  export type SessionRevokeOutput = z.infer<typeof SessionRevokeOutput>;
40
40
  /** Input for `account_session_revoke_all`. No parameters. */
41
- export declare const SessionRevokeAllInput: z.ZodNull;
41
+ export declare const SessionRevokeAllInput: z.ZodVoid;
42
42
  export type SessionRevokeAllInput = z.infer<typeof SessionRevokeAllInput>;
43
43
  /** Output for `account_session_revoke_all`. */
44
44
  export declare const SessionRevokeAllOutput: z.ZodObject<{
@@ -60,7 +60,7 @@ export declare const TokenCreateOutput: z.ZodObject<{
60
60
  }, z.core.$strict>;
61
61
  export type TokenCreateOutput = z.infer<typeof TokenCreateOutput>;
62
62
  /** Input for `account_token_list`. No parameters. */
63
- export declare const TokenListInput: z.ZodNull;
63
+ export declare const TokenListInput: z.ZodVoid;
64
64
  export type TokenListInput = z.infer<typeof TokenListInput>;
65
65
  /** Output for `account_token_list`. Hashes are excluded. */
66
66
  export declare const TokenListOutput: z.ZodObject<{
@@ -92,7 +92,7 @@ export declare const account_verify_action_spec: {
92
92
  initiator: "frontend";
93
93
  auth: "authenticated";
94
94
  side_effects: false;
95
- input: z.ZodNull;
95
+ input: z.ZodVoid;
96
96
  output: z.ZodObject<{
97
97
  id: z.core.$ZodBranded<z.ZodUUID, "Uuid", "out">;
98
98
  username: z.ZodString;
@@ -109,7 +109,7 @@ export declare const account_session_list_action_spec: {
109
109
  initiator: "frontend";
110
110
  auth: "authenticated";
111
111
  side_effects: false;
112
- input: z.ZodNull;
112
+ input: z.ZodVoid;
113
113
  output: z.ZodObject<{
114
114
  sessions: z.ZodArray<z.ZodObject<{
115
115
  id: z.ZodString;
@@ -144,7 +144,7 @@ export declare const account_session_revoke_all_action_spec: {
144
144
  initiator: "frontend";
145
145
  auth: "authenticated";
146
146
  side_effects: true;
147
- input: z.ZodNull;
147
+ input: z.ZodVoid;
148
148
  output: z.ZodObject<{
149
149
  ok: z.ZodLiteral<true>;
150
150
  count: z.ZodNumber;
@@ -176,7 +176,7 @@ export declare const account_token_list_action_spec: {
176
176
  initiator: "frontend";
177
177
  auth: "authenticated";
178
178
  side_effects: false;
179
- input: z.ZodNull;
179
+ input: z.ZodVoid;
180
180
  output: z.ZodObject<{
181
181
  tokens: z.ZodArray<z.ZodObject<{
182
182
  id: z.ZodString;