@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.
- package/dist/actions/CLAUDE.md +77 -0
- package/dist/actions/frontend_rpc_client.d.ts +74 -0
- package/dist/actions/frontend_rpc_client.d.ts.map +1 -0
- package/dist/actions/frontend_rpc_client.js +61 -0
- package/dist/actions/rpc_client.d.ts +64 -0
- package/dist/actions/rpc_client.d.ts.map +1 -1
- package/dist/actions/rpc_client.js +77 -0
- package/dist/auth/CLAUDE.md +24 -13
- package/dist/auth/self_service_role_action_specs.d.ts +20 -48
- package/dist/auth/self_service_role_action_specs.d.ts.map +1 -1
- package/dist/auth/self_service_role_action_specs.js +22 -44
- package/dist/auth/self_service_role_actions.d.ts +9 -9
- package/dist/auth/self_service_role_actions.d.ts.map +1 -1
- package/dist/auth/self_service_role_actions.js +48 -53
- package/dist/auth/standard_action_specs.d.ts +31 -0
- package/dist/auth/standard_action_specs.d.ts.map +1 -0
- package/dist/auth/standard_action_specs.js +36 -0
- package/package.json +1 -1
package/dist/actions/CLAUDE.md
CHANGED
|
@@ -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;
|
|
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
|
+
};
|
package/dist/auth/CLAUDE.md
CHANGED
|
@@ -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
|
|
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
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
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
|
|
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
|
|
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
|
|
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:
|
|
1130
|
-
codegen-ready registry of
|
|
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
|
-
*
|
|
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 `
|
|
15
|
-
export declare const
|
|
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
|
|
19
|
+
export type SelfServiceRoleSetInput = z.infer<typeof SelfServiceRoleSetInput>;
|
|
19
20
|
/**
|
|
20
|
-
* Output for `
|
|
21
|
-
*
|
|
22
|
-
*
|
|
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
|
|
25
|
+
export declare const SelfServiceRoleSetOutput: z.ZodObject<{
|
|
25
26
|
ok: z.ZodLiteral<true>;
|
|
26
|
-
|
|
27
|
-
|
|
27
|
+
enabled: z.ZodBoolean;
|
|
28
|
+
changed: z.ZodBoolean;
|
|
28
29
|
}, z.core.$strict>;
|
|
29
|
-
export type
|
|
30
|
-
|
|
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
|
-
|
|
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.
|
|
79
|
-
*
|
|
80
|
-
*
|
|
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:
|
|
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;
|
|
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
|
-
*
|
|
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 `
|
|
16
|
-
export const
|
|
17
|
-
role: RoleName.meta({ description: 'Role to
|
|
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 `
|
|
21
|
-
*
|
|
22
|
-
*
|
|
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
|
|
26
|
+
export const SelfServiceRoleSetOutput = z.strictObject({
|
|
25
27
|
ok: z.literal(true),
|
|
26
|
-
|
|
27
|
-
|
|
28
|
+
enabled: z.boolean(),
|
|
29
|
+
changed: z.boolean(),
|
|
28
30
|
});
|
|
29
|
-
|
|
30
|
-
|
|
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:
|
|
59
|
-
output:
|
|
37
|
+
input: SelfServiceRoleSetInput,
|
|
38
|
+
output: SelfServiceRoleSetOutput,
|
|
60
39
|
async: true,
|
|
61
|
-
description: '
|
|
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.
|
|
65
|
-
*
|
|
66
|
-
*
|
|
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
|
-
|
|
70
|
-
self_service_role_revoke_action_spec,
|
|
48
|
+
self_service_role_set_action_spec,
|
|
71
49
|
];
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* Unified self-service role toggle RPC action.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
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
|
|
23
|
-
* so
|
|
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
|
|
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;
|
|
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
|
-
*
|
|
2
|
+
* Unified self-service role toggle RPC action.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
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
|
|
23
|
-
* so
|
|
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,
|
|
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
|
|
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
|
|
68
|
+
const handler = async (input, ctx) => {
|
|
69
69
|
const auth = require_request_auth(ctx.auth);
|
|
70
70
|
reject_if_ineligible(input.role);
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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,
|
|
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,
|
|
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,
|
|
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
|
+
];
|