@fuzdev/fuz_app 0.43.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.
@@ -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
@@ -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
+ };
@@ -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`,
@@ -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
 
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Self-service role grant/revoke action specs — schemas, error reasons,
2
+ * Unified self-service role toggle action spec — schemas, error reasons,
3
3
  * and the codegen-ready registry.
4
4
  *
5
5
  * Client-safe: no query-layer or audit-write imports. Handler factory
@@ -11,54 +11,24 @@ import { z } from 'zod';
11
11
  import type { RequestResponseActionSpec } from '../actions/action_spec.js';
12
12
  /** Error reason — caller asked to self-toggle a role outside the configured allowlist. */
13
13
  export declare const ERROR_ROLE_NOT_SELF_SERVICE_ELIGIBLE: "role_not_self_service_eligible";
14
- /** Input for `self_service_role_grant`. */
15
- export declare const SelfServiceRoleGrantInput: z.ZodObject<{
14
+ /** Input for `self_service_role_set`. */
15
+ export declare const SelfServiceRoleSetInput: z.ZodObject<{
16
16
  role: z.ZodString;
17
+ enabled: z.ZodBoolean;
17
18
  }, z.core.$strict>;
18
- export type SelfServiceRoleGrantInput = z.infer<typeof SelfServiceRoleGrantInput>;
19
+ export type SelfServiceRoleSetInput = z.infer<typeof SelfServiceRoleSetInput>;
19
20
  /**
20
- * Output for `self_service_role_grant`. `granted` is `false` on idempotent
21
- * re-grant (caller already held the role globally); `permit_id` is set on
22
- * new grants only.
21
+ * Output for `self_service_role_set`. `enabled` echoes the post-call state
22
+ * (always equals the input `enabled` on success). `changed` is `true` only
23
+ * when the call mutated — re-grants / re-revokes return `false`.
23
24
  */
24
- export declare const SelfServiceRoleGrantOutput: z.ZodObject<{
25
+ export declare const SelfServiceRoleSetOutput: z.ZodObject<{
25
26
  ok: z.ZodLiteral<true>;
26
- granted: z.ZodBoolean;
27
- permit_id: z.ZodOptional<z.core.$ZodBranded<z.ZodUUID, "Uuid", "out">>;
27
+ enabled: z.ZodBoolean;
28
+ changed: z.ZodBoolean;
28
29
  }, z.core.$strict>;
29
- export type SelfServiceRoleGrantOutput = z.infer<typeof SelfServiceRoleGrantOutput>;
30
- /** Input for `self_service_role_revoke`. */
31
- export declare const SelfServiceRoleRevokeInput: z.ZodObject<{
32
- role: z.ZodString;
33
- }, z.core.$strict>;
34
- export type SelfServiceRoleRevokeInput = z.infer<typeof SelfServiceRoleRevokeInput>;
35
- /**
36
- * Output for `self_service_role_revoke`. `revoked` is `false` when the
37
- * caller held no active global permit for the role (idempotent).
38
- */
39
- export declare const SelfServiceRoleRevokeOutput: z.ZodObject<{
40
- ok: z.ZodLiteral<true>;
41
- revoked: z.ZodBoolean;
42
- }, z.core.$strict>;
43
- export type SelfServiceRoleRevokeOutput = z.infer<typeof SelfServiceRoleRevokeOutput>;
44
- export declare const self_service_role_grant_action_spec: {
45
- method: string;
46
- kind: "request_response";
47
- initiator: "frontend";
48
- auth: "authenticated";
49
- side_effects: true;
50
- input: z.ZodObject<{
51
- role: z.ZodString;
52
- }, z.core.$strict>;
53
- output: z.ZodObject<{
54
- ok: z.ZodLiteral<true>;
55
- granted: z.ZodBoolean;
56
- permit_id: z.ZodOptional<z.core.$ZodBranded<z.ZodUUID, "Uuid", "out">>;
57
- }, z.core.$strict>;
58
- async: true;
59
- description: string;
60
- };
61
- export declare const self_service_role_revoke_action_spec: {
30
+ export type SelfServiceRoleSetOutput = z.infer<typeof SelfServiceRoleSetOutput>;
31
+ export declare const self_service_role_set_action_spec: {
62
32
  method: string;
63
33
  kind: "request_response";
64
34
  initiator: "frontend";
@@ -66,18 +36,20 @@ export declare const self_service_role_revoke_action_spec: {
66
36
  side_effects: true;
67
37
  input: z.ZodObject<{
68
38
  role: z.ZodString;
39
+ enabled: z.ZodBoolean;
69
40
  }, z.core.$strict>;
70
41
  output: z.ZodObject<{
71
42
  ok: z.ZodLiteral<true>;
72
- revoked: z.ZodBoolean;
43
+ enabled: z.ZodBoolean;
44
+ changed: z.ZodBoolean;
73
45
  }, z.core.$strict>;
74
46
  async: true;
75
47
  description: string;
76
48
  };
77
49
  /**
78
- * All self-service role action specs — a codegen-ready registry. Method
79
- * names are static, so consumer typed-client codegen picks them up the
80
- * same way it picks up `account_*_action_specs`.
50
+ * All self-service role action specs — a codegen-ready registry. Single-element
51
+ * post-unification, kept for symmetry with the other `all_*_action_specs`
52
+ * exports so codegen and frontend bundles import the same shape.
81
53
  */
82
- export declare const all_self_service_role_action_specs: Array<RequestResponseActionSpec>;
54
+ export declare const all_self_service_role_action_specs: ReadonlyArray<RequestResponseActionSpec>;
83
55
  //# sourceMappingURL=self_service_role_action_specs.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"self_service_role_action_specs.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/auth/self_service_role_action_specs.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH,OAAO,EAAC,CAAC,EAAC,MAAM,KAAK,CAAC;AAGtB,OAAO,KAAK,EAAC,yBAAyB,EAAC,MAAM,2BAA2B,CAAC;AAGzE,0FAA0F;AAC1F,eAAO,MAAM,oCAAoC,EAAG,gCAAyC,CAAC;AAE9F,2CAA2C;AAC3C,eAAO,MAAM,yBAAyB;;kBAEpC,CAAC;AACH,MAAM,MAAM,yBAAyB,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,yBAAyB,CAAC,CAAC;AAElF;;;;GAIG;AACH,eAAO,MAAM,0BAA0B;;;;kBAIrC,CAAC;AACH,MAAM,MAAM,0BAA0B,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,0BAA0B,CAAC,CAAC;AAEpF,4CAA4C;AAC5C,eAAO,MAAM,0BAA0B;;kBAErC,CAAC;AACH,MAAM,MAAM,0BAA0B,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,0BAA0B,CAAC,CAAC;AAEpF;;;GAGG;AACH,eAAO,MAAM,2BAA2B;;;kBAGtC,CAAC;AACH,MAAM,MAAM,2BAA2B,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,2BAA2B,CAAC,CAAC;AAEtF,eAAO,MAAM,mCAAmC;;;;;;;;;;;;;;;;CAWX,CAAC;AAEtC,eAAO,MAAM,oCAAoC;;;;;;;;;;;;;;;CAWZ,CAAC;AAEtC;;;;GAIG;AACH,eAAO,MAAM,kCAAkC,EAAE,KAAK,CAAC,yBAAyB,CAG/E,CAAC"}
1
+ {"version":3,"file":"self_service_role_action_specs.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/auth/self_service_role_action_specs.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH,OAAO,EAAC,CAAC,EAAC,MAAM,KAAK,CAAC;AAEtB,OAAO,KAAK,EAAC,yBAAyB,EAAC,MAAM,2BAA2B,CAAC;AAGzE,0FAA0F;AAC1F,eAAO,MAAM,oCAAoC,EAAG,gCAAyC,CAAC;AAE9F,yCAAyC;AACzC,eAAO,MAAM,uBAAuB;;;kBAMlC,CAAC;AACH,MAAM,MAAM,uBAAuB,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,uBAAuB,CAAC,CAAC;AAE9E;;;;GAIG;AACH,eAAO,MAAM,wBAAwB;;;;kBAInC,CAAC;AACH,MAAM,MAAM,wBAAwB,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,wBAAwB,CAAC,CAAC;AAEhF,eAAO,MAAM,iCAAiC;;;;;;;;;;;;;;;;;CAWT,CAAC;AAEtC;;;;GAIG;AACH,eAAO,MAAM,kCAAkC,EAAE,aAAa,CAAC,yBAAyB,CAEvF,CAAC"}
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Self-service role grant/revoke action specs — schemas, error reasons,
2
+ * Unified self-service role toggle action spec — schemas, error reasons,
3
3
  * and the codegen-ready registry.
4
4
  *
5
5
  * Client-safe: no query-layer or audit-write imports. Handler factory
@@ -8,64 +8,42 @@
8
8
  * @module
9
9
  */
10
10
  import { z } from 'zod';
11
- import { Uuid } from '@fuzdev/fuz_util/id.js';
12
11
  import { RoleName } from './role_schema.js';
13
12
  /** Error reason — caller asked to self-toggle a role outside the configured allowlist. */
14
13
  export const ERROR_ROLE_NOT_SELF_SERVICE_ELIGIBLE = 'role_not_self_service_eligible';
15
- /** Input for `self_service_role_grant`. */
16
- export const SelfServiceRoleGrantInput = z.strictObject({
17
- role: RoleName.meta({ description: 'Role to self-grant. Must be in the configured allowlist.' }),
14
+ /** Input for `self_service_role_set`. */
15
+ export const SelfServiceRoleSetInput = z.strictObject({
16
+ role: RoleName.meta({ description: 'Role to toggle. Must be in the configured allowlist.' }),
17
+ enabled: z.boolean().meta({
18
+ description: 'Desired post-call state. `true` grants if not held; `false` revokes if held. Idempotent in both directions.',
19
+ }),
18
20
  });
19
21
  /**
20
- * Output for `self_service_role_grant`. `granted` is `false` on idempotent
21
- * re-grant (caller already held the role globally); `permit_id` is set on
22
- * new grants only.
22
+ * Output for `self_service_role_set`. `enabled` echoes the post-call state
23
+ * (always equals the input `enabled` on success). `changed` is `true` only
24
+ * when the call mutated — re-grants / re-revokes return `false`.
23
25
  */
24
- export const SelfServiceRoleGrantOutput = z.strictObject({
26
+ export const SelfServiceRoleSetOutput = z.strictObject({
25
27
  ok: z.literal(true),
26
- granted: z.boolean(),
27
- permit_id: Uuid.optional(),
28
+ enabled: z.boolean(),
29
+ changed: z.boolean(),
28
30
  });
29
- /** Input for `self_service_role_revoke`. */
30
- export const SelfServiceRoleRevokeInput = z.strictObject({
31
- role: RoleName.meta({ description: 'Role to self-revoke. Must be in the configured allowlist.' }),
32
- });
33
- /**
34
- * Output for `self_service_role_revoke`. `revoked` is `false` when the
35
- * caller held no active global permit for the role (idempotent).
36
- */
37
- export const SelfServiceRoleRevokeOutput = z.strictObject({
38
- ok: z.literal(true),
39
- revoked: z.boolean(),
40
- });
41
- export const self_service_role_grant_action_spec = {
42
- method: 'self_service_role_grant',
43
- kind: 'request_response',
44
- initiator: 'frontend',
45
- auth: 'authenticated',
46
- side_effects: true,
47
- input: SelfServiceRoleGrantInput,
48
- output: SelfServiceRoleGrantOutput,
49
- async: true,
50
- description: 'Self-grant an active permit for an allowlisted role. Idempotent — already-granted callers receive `granted: false`.',
51
- };
52
- export const self_service_role_revoke_action_spec = {
53
- method: 'self_service_role_revoke',
31
+ export const self_service_role_set_action_spec = {
32
+ method: 'self_service_role_set',
54
33
  kind: 'request_response',
55
34
  initiator: 'frontend',
56
35
  auth: 'authenticated',
57
36
  side_effects: true,
58
- input: SelfServiceRoleRevokeInput,
59
- output: SelfServiceRoleRevokeOutput,
37
+ input: SelfServiceRoleSetInput,
38
+ output: SelfServiceRoleSetOutput,
60
39
  async: true,
61
- description: 'Self-revoke an active global permit for an allowlisted role. Idempotent callers without an active permit receive `revoked: false`.',
40
+ description: 'Toggle a self-service role. Idempotent in both directions `changed: false` when post-call state already matched the request.',
62
41
  };
63
42
  /**
64
- * All self-service role action specs — a codegen-ready registry. Method
65
- * names are static, so consumer typed-client codegen picks them up the
66
- * same way it picks up `account_*_action_specs`.
43
+ * All self-service role action specs — a codegen-ready registry. Single-element
44
+ * post-unification, kept for symmetry with the other `all_*_action_specs`
45
+ * exports so codegen and frontend bundles import the same shape.
67
46
  */
68
47
  export const all_self_service_role_action_specs = [
69
- self_service_role_grant_action_spec,
70
- self_service_role_revoke_action_spec,
48
+ self_service_role_set_action_spec,
71
49
  ];
@@ -1,11 +1,11 @@
1
1
  /**
2
- * Self-service role grant/revoke RPC actions.
2
+ * Unified self-service role toggle RPC action.
3
3
  *
4
- * Two static `request_response` actions — `self_service_role_grant` and
5
- * `self_service_role_revoke` — that take `{role}` as input and toggle a
6
- * permit on the caller for an allowlisted role. Idempotent in both
7
- * directions: re-granting an already-held role returns `granted: false`;
8
- * revoking a role the caller doesn't hold returns `revoked: false`.
4
+ * One static `request_response` action — `self_service_role_set` — that
5
+ * takes `{role, enabled}` and toggles a global permit on the caller for an
6
+ * allowlisted role. Idempotent in both directions: re-enabling an
7
+ * already-held role returns `changed: false`; disabling a role the caller
8
+ * doesn't hold returns `changed: false`.
9
9
  *
10
10
  * The factory takes an `eligible_roles` allowlist (validated against the
11
11
  * supplied `roles.role_options` at factory time so typos surface at startup
@@ -19,8 +19,8 @@
19
19
  * part of the documented schema surface and is round-trip-validated by
20
20
  * `query_audit_log`.
21
21
  *
22
- * Static method names — `role` lives in the input, not the method name —
23
- * so specs are codegen-compatible (`satisfies RequestResponseActionSpec`)
22
+ * Static method name — `role` lives in the input, not the method name —
23
+ * so the spec is codegen-compatible (`satisfies RequestResponseActionSpec`)
24
24
  * and the surface stays constant as consumers add eligible roles. Mirrors
25
25
  * the existing `permit_offer_create({role})` precedent rather than
26
26
  * generating per-role methods.
@@ -57,7 +57,7 @@ export interface SelfServiceRoleActionsOptions {
57
57
  */
58
58
  export type SelfServiceRoleActionDeps = Pick<RouteFactoryDeps, 'log' | 'on_audit_event' | 'audit_log_config'>;
59
59
  /**
60
- * Build the self-service role grant/revoke RPC actions.
60
+ * Build the unified self-service role toggle RPC action.
61
61
  *
62
62
  * @param deps - `SelfServiceRoleActionDeps` slice of `AppDeps` (`log`, `on_audit_event`, optional `audit_log_config`)
63
63
  * @param options - eligible-role allowlist plus optional role schema for typo-checking
@@ -1 +1 @@
1
- {"version":3,"file":"self_service_role_actions.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/auth/self_service_role_actions.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAgCG;AAEH,OAAO,EAAiC,KAAK,SAAS,EAAC,MAAM,0BAA0B,CAAC;AAExF,OAAO,KAAK,EAAC,gBAAgB,EAAC,MAAM,kBAAkB,CAAC;AACvD,OAAO,KAAK,EAAC,gBAAgB,EAAC,MAAM,WAAW,CAAC;AAmBhD,sDAAsD;AACtD,MAAM,WAAW,6BAA6B;IAC7C;;;;OAIG;IACH,cAAc,EAAE,aAAa,CAAC,MAAM,CAAC,CAAC;IACtC;;;;OAIG;IACH,KAAK,CAAC,EAAE,gBAAgB,CAAC;CACzB;AAED;;;;;GAKG;AACH,MAAM,MAAM,yBAAyB,GAAG,IAAI,CAC3C,gBAAgB,EAChB,KAAK,GAAG,gBAAgB,GAAG,kBAAkB,CAC7C,CAAC;AAOF;;;;;;GAMG;AACH,eAAO,MAAM,gCAAgC,GAC5C,MAAM,yBAAyB,EAC/B,SAAS,6BAA6B,KACpC,KAAK,CAAC,SAAS,CAqHjB,CAAC"}
1
+ {"version":3,"file":"self_service_role_actions.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/auth/self_service_role_actions.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAgCG;AAEH,OAAO,EAAiC,KAAK,SAAS,EAAC,MAAM,0BAA0B,CAAC;AAExF,OAAO,KAAK,EAAC,gBAAgB,EAAC,MAAM,kBAAkB,CAAC;AACvD,OAAO,KAAK,EAAC,gBAAgB,EAAC,MAAM,WAAW,CAAC;AAgBhD,sDAAsD;AACtD,MAAM,WAAW,6BAA6B;IAC7C;;;;OAIG;IACH,cAAc,EAAE,aAAa,CAAC,MAAM,CAAC,CAAC;IACtC;;;;OAIG;IACH,KAAK,CAAC,EAAE,gBAAgB,CAAC;CACzB;AAED;;;;;GAKG;AACH,MAAM,MAAM,yBAAyB,GAAG,IAAI,CAC3C,gBAAgB,EAChB,KAAK,GAAG,gBAAgB,GAAG,kBAAkB,CAC7C,CAAC;AAOF;;;;;;GAMG;AACH,eAAO,MAAM,gCAAgC,GAC5C,MAAM,yBAAyB,EAC/B,SAAS,6BAA6B,KACpC,KAAK,CAAC,SAAS,CA4GjB,CAAC"}
@@ -1,11 +1,11 @@
1
1
  /**
2
- * Self-service role grant/revoke RPC actions.
2
+ * Unified self-service role toggle RPC action.
3
3
  *
4
- * Two static `request_response` actions — `self_service_role_grant` and
5
- * `self_service_role_revoke` — that take `{role}` as input and toggle a
6
- * permit on the caller for an allowlisted role. Idempotent in both
7
- * directions: re-granting an already-held role returns `granted: false`;
8
- * revoking a role the caller doesn't hold returns `revoked: false`.
4
+ * One static `request_response` action — `self_service_role_set` — that
5
+ * takes `{role, enabled}` and toggles a global permit on the caller for an
6
+ * allowlisted role. Idempotent in both directions: re-enabling an
7
+ * already-held role returns `changed: false`; disabling a role the caller
8
+ * doesn't hold returns `changed: false`.
9
9
  *
10
10
  * The factory takes an `eligible_roles` allowlist (validated against the
11
11
  * supplied `roles.role_options` at factory time so typos surface at startup
@@ -19,8 +19,8 @@
19
19
  * part of the documented schema surface and is round-trip-validated by
20
20
  * `query_audit_log`.
21
21
  *
22
- * Static method names — `role` lives in the input, not the method name —
23
- * so specs are codegen-compatible (`satisfies RequestResponseActionSpec`)
22
+ * Static method name — `role` lives in the input, not the method name —
23
+ * so the spec is codegen-compatible (`satisfies RequestResponseActionSpec`)
24
24
  * and the surface stays constant as consumers add eligible roles. Mirrors
25
25
  * the existing `permit_offer_create({role})` precedent rather than
26
26
  * generating per-role methods.
@@ -35,14 +35,14 @@ import { rpc_action } from '../actions/action_rpc.js';
35
35
  import { jsonrpc_errors } from '../http/jsonrpc_errors.js';
36
36
  import { query_grant_permit, query_permit_find_active_for_actor, query_permit_has_role, query_revoke_permit, } from './permit_queries.js';
37
37
  import { audit_log_fire_and_forget } from './audit_log_queries.js';
38
- import { ERROR_ROLE_NOT_SELF_SERVICE_ELIGIBLE, self_service_role_grant_action_spec, self_service_role_revoke_action_spec, } from './self_service_role_action_specs.js';
38
+ import { ERROR_ROLE_NOT_SELF_SERVICE_ELIGIBLE, self_service_role_set_action_spec, } from './self_service_role_action_specs.js';
39
39
  const require_request_auth = (auth) => {
40
40
  if (!auth)
41
41
  throw new Error('unreachable: action auth guard did not enforce authentication');
42
42
  return auth;
43
43
  };
44
44
  /**
45
- * Build the self-service role grant/revoke RPC actions.
45
+ * Build the unified self-service role toggle RPC action.
46
46
  *
47
47
  * @param deps - `SelfServiceRoleActionDeps` slice of `AppDeps` (`log`, `on_audit_event`, optional `audit_log_config`)
48
48
  * @param options - eligible-role allowlist plus optional role schema for typo-checking
@@ -65,45 +65,43 @@ export const create_self_service_role_actions = (deps, options) => {
65
65
  });
66
66
  }
67
67
  };
68
- const grant_handler = async (input, ctx) => {
68
+ const handler = async (input, ctx) => {
69
69
  const auth = require_request_auth(ctx.auth);
70
70
  reject_if_ineligible(input.role);
71
- // Pre-check for idempotent re-grant. `query_grant_permit` is itself
72
- // idempotent (returns the existing permit instead of inserting), but
73
- // it doesn't signal "already existed" vs "newly inserted" — so we
74
- // peek first. The TOCTOU window is benign for self-service: two
75
- // concurrent grants both observe "no permit", both call
76
- // `query_grant_permit`, and one collapses onto the other inside the
77
- // query's `ON CONFLICT DO NOTHING`. Worst case both responses report
78
- // `granted: true`; the DB still ends up with exactly one permit.
79
- const already = await query_permit_has_role(ctx, auth.actor.id, input.role);
80
- if (already) {
81
- return { ok: true, granted: false };
71
+ if (input.enabled) {
72
+ // Pre-check for idempotent re-grant. `query_grant_permit` is itself
73
+ // idempotent (returns the existing permit instead of inserting), but
74
+ // it doesn't signal "already existed" vs "newly inserted" so we
75
+ // peek first. The TOCTOU window is benign for self-service: two
76
+ // concurrent grants both observe "no permit", both call
77
+ // `query_grant_permit`, and one collapses onto the other inside the
78
+ // query's `ON CONFLICT DO NOTHING`. Worst case both responses report
79
+ // `changed: true`; the DB still ends up with exactly one permit.
80
+ const already = await query_permit_has_role(ctx, auth.actor.id, input.role);
81
+ if (already) {
82
+ return { ok: true, enabled: true, changed: false };
83
+ }
84
+ const permit = await query_grant_permit(ctx, {
85
+ actor_id: auth.actor.id,
86
+ role: input.role,
87
+ scope_id: null,
88
+ expires_at: null,
89
+ granted_by: auth.actor.id,
90
+ });
91
+ void audit_log_fire_and_forget(ctx, {
92
+ event_type: 'permit_grant',
93
+ actor_id: auth.actor.id,
94
+ account_id: auth.account.id,
95
+ ip: ctx.client_ip,
96
+ metadata: {
97
+ role: permit.role,
98
+ permit_id: permit.id,
99
+ scope_id: permit.scope_id,
100
+ self_service: true,
101
+ },
102
+ }, deps);
103
+ return { ok: true, enabled: true, changed: true };
82
104
  }
83
- const permit = await query_grant_permit(ctx, {
84
- actor_id: auth.actor.id,
85
- role: input.role,
86
- scope_id: null,
87
- expires_at: null,
88
- granted_by: auth.actor.id,
89
- });
90
- void audit_log_fire_and_forget(ctx, {
91
- event_type: 'permit_grant',
92
- actor_id: auth.actor.id,
93
- account_id: auth.account.id,
94
- ip: ctx.client_ip,
95
- metadata: {
96
- role: permit.role,
97
- permit_id: permit.id,
98
- scope_id: permit.scope_id,
99
- self_service: true,
100
- },
101
- }, deps);
102
- return { ok: true, granted: true, permit_id: permit.id };
103
- };
104
- const revoke_handler = async (input, ctx) => {
105
- const auth = require_request_auth(ctx.auth);
106
- reject_if_ineligible(input.role);
107
105
  // Find an active global permit for this (actor, role). No dedicated
108
106
  // query exists, but `query_permit_find_active_for_actor` returns the
109
107
  // short list of every active permit and we filter in JS — fewer
@@ -111,12 +109,12 @@ export const create_self_service_role_actions = (deps, options) => {
111
109
  const active = await query_permit_find_active_for_actor(ctx, auth.actor.id);
112
110
  const target = active.find((p) => p.role === input.role && p.scope_id === null);
113
111
  if (!target) {
114
- return { ok: true, revoked: false };
112
+ return { ok: true, enabled: false, changed: false };
115
113
  }
116
114
  const result = await query_revoke_permit(ctx, target.id, auth.actor.id, auth.actor.id);
117
115
  if (!result) {
118
116
  // Raced with another revoker — treat as already revoked.
119
- return { ok: true, revoked: false };
117
+ return { ok: true, enabled: false, changed: false };
120
118
  }
121
119
  void audit_log_fire_and_forget(ctx, {
122
120
  event_type: 'permit_revoke',
@@ -130,10 +128,7 @@ export const create_self_service_role_actions = (deps, options) => {
130
128
  self_service: true,
131
129
  },
132
130
  }, deps);
133
- return { ok: true, revoked: true };
131
+ return { ok: true, enabled: false, changed: true };
134
132
  };
135
- return [
136
- rpc_action(self_service_role_grant_action_spec, grant_handler),
137
- rpc_action(self_service_role_revoke_action_spec, revoke_handler),
138
- ];
133
+ return [rpc_action(self_service_role_set_action_spec, handler)];
139
134
  };
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Aggregate spec list mirroring `create_standard_rpc_actions` on the backend.
3
+ *
4
+ * `create_standard_rpc_actions` (in `./standard_rpc_actions.js`) bundles three
5
+ * action registries into one mounted RPC surface: admin + permit_offer +
6
+ * account. Frontends mounting that surface need the matching spec list to
7
+ * feed `create_rpc_client` so the typed Proxy knows about every standard
8
+ * method.
9
+ *
10
+ * Without this aggregate, every consumer spreads three (or four with
11
+ * self-service roles) `all_*_action_specs` imports at the typed-client
12
+ * site, the codegen-sources table, and any other registry construction —
13
+ * a triplicate that drifts silently on either side.
14
+ *
15
+ * Self-service role specs are **not** included — they're opt-in (require
16
+ * `eligible_roles` configuration) and not bundled into
17
+ * `create_standard_rpc_actions`. Consumers that mount them spread
18
+ * `all_self_service_role_action_specs` separately.
19
+ *
20
+ * @module
21
+ */
22
+ import type { RequestResponseActionSpec } from '../actions/action_spec.js';
23
+ /**
24
+ * Combined spec registry for the standard RPC surface (admin +
25
+ * permit_offer + account). Symmetric with `create_standard_rpc_actions`.
26
+ *
27
+ * Spec count is the sum of the three sub-registries. Adding a method to
28
+ * any sub-registry surfaces here automatically.
29
+ */
30
+ export declare const all_standard_action_specs: ReadonlyArray<RequestResponseActionSpec>;
31
+ //# sourceMappingURL=standard_action_specs.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"standard_action_specs.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/auth/standard_action_specs.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;GAoBG;AAEH,OAAO,KAAK,EAAC,yBAAyB,EAAC,MAAM,2BAA2B,CAAC;AAKzE;;;;;;GAMG;AACH,eAAO,MAAM,yBAAyB,EAAE,aAAa,CAAC,yBAAyB,CAI9E,CAAC"}
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Aggregate spec list mirroring `create_standard_rpc_actions` on the backend.
3
+ *
4
+ * `create_standard_rpc_actions` (in `./standard_rpc_actions.js`) bundles three
5
+ * action registries into one mounted RPC surface: admin + permit_offer +
6
+ * account. Frontends mounting that surface need the matching spec list to
7
+ * feed `create_rpc_client` so the typed Proxy knows about every standard
8
+ * method.
9
+ *
10
+ * Without this aggregate, every consumer spreads three (or four with
11
+ * self-service roles) `all_*_action_specs` imports at the typed-client
12
+ * site, the codegen-sources table, and any other registry construction —
13
+ * a triplicate that drifts silently on either side.
14
+ *
15
+ * Self-service role specs are **not** included — they're opt-in (require
16
+ * `eligible_roles` configuration) and not bundled into
17
+ * `create_standard_rpc_actions`. Consumers that mount them spread
18
+ * `all_self_service_role_action_specs` separately.
19
+ *
20
+ * @module
21
+ */
22
+ import { all_admin_action_specs } from './admin_action_specs.js';
23
+ import { all_permit_offer_action_specs } from './permit_offer_action_specs.js';
24
+ import { all_account_action_specs } from './account_action_specs.js';
25
+ /**
26
+ * Combined spec registry for the standard RPC surface (admin +
27
+ * permit_offer + account). Symmetric with `create_standard_rpc_actions`.
28
+ *
29
+ * Spec count is the sum of the three sub-registries. Adding a method to
30
+ * any sub-registry surfaces here automatically.
31
+ */
32
+ export const all_standard_action_specs = [
33
+ ...all_admin_action_specs,
34
+ ...all_permit_offer_action_specs,
35
+ ...all_account_action_specs,
36
+ ];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fuzdev/fuz_app",
3
- "version": "0.43.0",
3
+ "version": "0.44.0",
4
4
  "description": "fullstack app library",
5
5
  "glyph": "🗝",
6
6
  "logo": "logo.svg",