@qlzt/dsh-api-remote-access 0.0.0-stage → 0.2.1-rc.2

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.
@@ -0,0 +1,182 @@
1
+ /**
2
+ * React-free Client access state: the latest `remoteAccess/follow` snapshot
3
+ * and identity-stable boolean sources per resource and action.
4
+ * @module @qlzt/dsh-api-remote-access/src/client/state
5
+ */
6
+ const CLOSED = Object.freeze({ kind: 'closed' });
7
+ const OWNER_SNAPSHOT = Object.freeze({ kind: 'owner' });
8
+ const GLOBAL_KEY = '\u0000global';
9
+ /**
10
+ * Validate the opening baseline an index injection row assigned to the page.
11
+ * @param value - the injected global; `undefined` when the page carries none.
12
+ * @returns the baseline, or `undefined` when none was injected.
13
+ * @throws TypeError when the injected value is not a baseline frame.
14
+ */
15
+ export function parseInjectedBaseline(value) {
16
+ if (value === undefined)
17
+ return undefined;
18
+ if (!isRecord(value) || value['type'] !== 'baseline')
19
+ throw new TypeError('remote access: the injected access snapshot is not a baseline frame');
20
+ if (value['owner'] === true)
21
+ return { type: 'baseline', owner: true };
22
+ const grants = value['grants'];
23
+ if (value['owner'] !== false || !Array.isArray(grants) || !grants.every(isGrant)) {
24
+ throw new TypeError('remote access: the injected access snapshot carries malformed grants');
25
+ }
26
+ return { type: 'baseline', owner: false, grants };
27
+ }
28
+ function isGrant(value) {
29
+ if (!isRecord(value) || !isResource(value['resource']))
30
+ return false;
31
+ const actions = value['actions'];
32
+ return Array.isArray(actions) && actions.every(action => typeof action === 'string');
33
+ }
34
+ function isResource(value) {
35
+ if (!isRecord(value) || typeof value['kind'] !== 'string')
36
+ return false;
37
+ return value['kind'] === 'global' || typeof value['id'] === 'string';
38
+ }
39
+ function isRecord(value) {
40
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
41
+ }
42
+ /**
43
+ * Snapshot owner for one Client generation. Sources are cached per
44
+ * `(kind, id, action)` so Hook bindings keep their identity; each source
45
+ * computes its boolean from the current snapshot.
46
+ */
47
+ export class ClientAccessState {
48
+ snapshot = CLOSED;
49
+ /** @param initial - the page's injected opening baseline; without one every answer is false until a baseline arrives. */
50
+ constructor(initial) {
51
+ if (initial !== undefined)
52
+ this.replace(initial);
53
+ }
54
+ listeners = new Set();
55
+ sources = new Map();
56
+ /**
57
+ * Answer for one action on the global resource.
58
+ * @param action - endpoint action the caller may invoke.
59
+ * @returns identity-stable boolean source.
60
+ */
61
+ global(action) {
62
+ return this.cached(GLOBAL_KEY, '', action);
63
+ }
64
+ /**
65
+ * Answer for one action on one resource of a declared kind.
66
+ * @param kind - declared resource kind.
67
+ * @param id - resource identity.
68
+ * @param action - endpoint action the caller may invoke.
69
+ * @returns identity-stable boolean source.
70
+ */
71
+ resource(kind, id, action) {
72
+ return this.cached(kind, id, action);
73
+ }
74
+ /**
75
+ * Replace the snapshot from one generation's opening frame.
76
+ * @param frame - owner or non-owner baseline.
77
+ */
78
+ replace(frame) {
79
+ if (frame.owner) {
80
+ this.publish(OWNER_SNAPSHOT);
81
+ return;
82
+ }
83
+ let global = new Set();
84
+ const resources = new Map();
85
+ // The baseline is unpublished, so its maps are filled in place.
86
+ for (const { resource, actions } of frame.grants) {
87
+ if (resource.kind === 'global') {
88
+ global = new Set(actions);
89
+ continue;
90
+ }
91
+ let grants = resources.get(resource.kind);
92
+ if (grants === undefined) {
93
+ grants = new Map();
94
+ resources.set(resource.kind, grants);
95
+ }
96
+ grants.set(resource.id, new Set(actions));
97
+ }
98
+ this.publish({ kind: 'granted', global, resources });
99
+ }
100
+ /**
101
+ * Apply one increment of the current generation.
102
+ * @param frame - `upsert` or `remove`.
103
+ */
104
+ update(frame) {
105
+ const current = this.snapshot;
106
+ if (current.kind !== 'granted')
107
+ return;
108
+ switch (frame.type) {
109
+ case 'upsert':
110
+ this.publish(withGrant(current, frame.grant));
111
+ return;
112
+ case 'remove':
113
+ this.publish(withGrant(current, { resource: frame.resource, actions: [] }, true));
114
+ return;
115
+ /* v8 ignore next -- the generated Remote codec validates this closed union. */
116
+ default:
117
+ return assertNever(frame);
118
+ }
119
+ }
120
+ /** Whether the current snapshot is the owner's, whose answers never depend on the stream. */
121
+ get owner() {
122
+ return this.snapshot.kind === 'owner';
123
+ }
124
+ /** The stream failed terminally: the owner keeps every answer; a non-owner's answers become false. */
125
+ unavailable() {
126
+ if (this.snapshot.kind !== 'owner')
127
+ this.publish(CLOSED);
128
+ }
129
+ cached(kind, id, action) {
130
+ const key = `${kind}\u0000${id}\u0000${action}`;
131
+ let source = this.sources.get(key);
132
+ if (source === undefined) {
133
+ source = {
134
+ getSnapshot: () => answer(this.snapshot, kind, id, action),
135
+ subscribe: (listener) => {
136
+ this.listeners.add(listener);
137
+ return () => { this.listeners.delete(listener); };
138
+ },
139
+ };
140
+ this.sources.set(key, source);
141
+ }
142
+ return source;
143
+ }
144
+ publish(next) {
145
+ this.snapshot = next;
146
+ for (const listener of [...this.listeners])
147
+ listener();
148
+ }
149
+ }
150
+ function answer(snapshot, kind, id, action) {
151
+ switch (snapshot.kind) {
152
+ case 'closed':
153
+ return false;
154
+ case 'owner':
155
+ return true;
156
+ case 'granted': {
157
+ const allowed = kind === GLOBAL_KEY ? snapshot.global : snapshot.resources.get(kind)?.get(id);
158
+ return allowed?.has(action) ?? false;
159
+ }
160
+ /* v8 ignore next -- closed union. */
161
+ default:
162
+ return assertNever(snapshot);
163
+ }
164
+ }
165
+ function withGrant(snapshot, grant, remove = false) {
166
+ const resource = grant.resource;
167
+ if (resource.kind === 'global')
168
+ return { ...snapshot, global: new Set(grant.actions) };
169
+ const resources = new Map(snapshot.resources);
170
+ const grants = new Map(resources.get(resource.kind));
171
+ if (remove)
172
+ grants.delete(resource.id);
173
+ else
174
+ grants.set(resource.id, new Set(grant.actions));
175
+ resources.set(resource.kind, grants);
176
+ return { ...snapshot, resources };
177
+ }
178
+ /* v8 ignore next 3 -- closed-union backstop. */
179
+ function assertNever(value) {
180
+ throw new Error(`unreachable access frame: ${JSON.stringify(value)}`);
181
+ }
182
+ //# sourceMappingURL=state.js.map
@@ -0,0 +1,121 @@
1
+ /**
2
+ * Host method-level access control. The Gateway asks `decide()` before it
3
+ * dispatches a non-owner call or forwards an event to a non-owner Peer; list
4
+ * methods filter items through `allows()`; every Peer reads its own access
5
+ * snapshot, first from the index and then from `follow()`. Business packages
6
+ * register one policy per non-owner principal kind.
7
+ * @module @qlzt/dsh-api-remote-access
8
+ */
9
+ import type { Context } from '@deepseek-ai/cordis';
10
+ import { TypertRemoteService, type Principal, type PrincipalMap, type RemoteAccessResource } from '@qlzt/dsh-typert-protocol';
11
+ import type { RemoteAccessDecision, RemoteAccessFrame, RemoteAccessPolicy, RemoteAccessRequest } from './types.ts';
12
+ export type * from './types.ts';
13
+ export { REMOTE_ACCESS_SNAPSHOT_KEY } from './snapshot.ts';
14
+ /** The opening frame of a snapshot generation. */
15
+ export type RemoteAccessBaseline = Extract<RemoteAccessFrame, {
16
+ type: 'baseline';
17
+ }>;
18
+ declare module '@deepseek-ai/cordis' {
19
+ interface Context {
20
+ /** Host method-level access control for non-owner Peers. */
21
+ remoteAccess: RemoteAccessService;
22
+ }
23
+ }
24
+ /**
25
+ * Registry of access policies, the Gateway's access decisions, list
26
+ * filtering, and the per-Peer access snapshot. Every composition that mounts
27
+ * the Gateway mounts this service too.
28
+ */
29
+ export declare class RemoteAccessService extends TypertRemoteService {
30
+ private readonly policies;
31
+ /** Policies whose failure was logged since the last `invalidate()`; their later failures stay silent. */
32
+ private readonly reported;
33
+ private readonly followers;
34
+ /** Aborted when this instance unloads; every open snapshot generation ends with it. */
35
+ private readonly lifetime;
36
+ /** @param ctx - owning Host plugin Context. */
37
+ constructor(ctx: Context);
38
+ /**
39
+ * Register the policy of one non-owner principal kind in the caller's
40
+ * fiber; open snapshots are recomputed on registration and removal.
41
+ * @param kind - the principal kind the policy serves; never `owner`.
42
+ * @param policy - business-owned bindings and refusal reasons for principals of `kind`.
43
+ * @returns disposer removing this policy.
44
+ * @throws Error when `kind` already has a policy.
45
+ */
46
+ registerPolicy<K extends Exclude<keyof PrincipalMap, 'owner'>>(kind: K, policy: RemoteAccessPolicy<PrincipalMap[K]>): () => void;
47
+ /**
48
+ * Report that the bindings or condition answers for `principal` may have
49
+ * changed. Business packages call this on every grant, revocation, mode
50
+ * change, and expiry; open snapshots re-push, and the next failure of each
51
+ * policy is logged again.
52
+ * @param principal - affected principal; omitted for every principal.
53
+ */
54
+ invalidate(principal?: Principal): void;
55
+ /**
56
+ * Gateway-only: decide one request whose resources the Gateway resolved.
57
+ * The owner is allowed, and so is `remoteAccess/follow`: every Peer reads
58
+ * its own snapshot. Otherwise the action is allowed only when, on every
59
+ * listed resource, some binding of the principal's policy names that
60
+ * resource, has a role listing the action, and has no condition or a
61
+ * condition that allows. `bindings()` is called once per decision.
62
+ * @param request - the caller or recipient, the action, the resources it
63
+ * touches, and a call's arguments.
64
+ * @returns `allow`; or `deny` with `no-allow` when the principal kind has
65
+ * no policy, the first refusing condition's reason on the first resource
66
+ * no binding allows, else `explain()`'s reason, else `no-allow`;
67
+ * `policy-failed` when `bindings()`, a condition, or `explain()` throws,
68
+ * logged once per policy until the next `invalidate()`. The Gateway
69
+ * throws `remote-access/denied` for a denied call and drops a denied event.
70
+ */
71
+ decide(request: RemoteAccessRequest<Principal>): RemoteAccessDecision;
72
+ /**
73
+ * Filter items inside a Remote method. The invocation is read from the
74
+ * Context that accessed this service, so call it as
75
+ * `this.ctx.remoteAccess.allows(resource)` from the Remote method. Each
76
+ * check reads the policy's bindings again, so a long-running stream sees
77
+ * binding changes from the next check on.
78
+ * @param resource - resource of one result item.
79
+ * @returns true for the owner and outside a Remote call; otherwise whether
80
+ * the invocation's endpoint action is allowed on `resource` without call
81
+ * arguments, false when the policy is absent or fails.
82
+ */
83
+ allows(resource: RemoteAccessResource): boolean;
84
+ /**
85
+ * The baseline a page opens with before its snapshot stream connects. A
86
+ * business package rendering a non-owner page assigns this value to
87
+ * {@link REMOTE_ACCESS_SNAPSHOT_KEY} after every index injection row,
88
+ * replacing the owner baseline the webServer index carries.
89
+ * @param principal - the page's caller identity.
90
+ * @returns the owner baseline for the owner; otherwise the principal's current grants.
91
+ */
92
+ snapshotFor(principal: Principal): RemoteAccessBaseline;
93
+ /**
94
+ * Stream the calling Peer's access snapshot: one baseline, then `upsert`
95
+ * and `remove` frames after each `invalidate()` and each policy
96
+ * registration or removal that changes a grant. The owner receives only
97
+ * `{ owner: true }`. The generation ends when `signal` aborts or this
98
+ * service unloads, so a reloaded service starts every reader from a fresh
99
+ * baseline.
100
+ * @param signal - generation cancellation.
101
+ * @returns the access frames of one generation.
102
+ */
103
+ follow(signal: AbortSignal): AsyncIterable<RemoteAccessFrame>;
104
+ /** One `bindings()` call; `undefined` after a logged failure. */
105
+ private bindingsOf;
106
+ /** Find a binding on `resource` whose role lists `action` and whose condition, if any, allows. */
107
+ private evaluate;
108
+ /** The refusal reason `explain()` names, else `no-allow`; `policy-failed` when it throws. */
109
+ private explain;
110
+ /**
111
+ * One grant per bound resource, in first-binding order: the endpoint
112
+ * actions of the resource's bindings that `decide()` would allow there
113
+ * without call arguments. A resource with no such action gets no grant;
114
+ * event actions stay out of the snapshot.
115
+ */
116
+ private grantsFor;
117
+ /** Log a policy failure once per policy until the next `invalidate()`; snapshots and lists ask many times per change. */
118
+ private report;
119
+ }
120
+ export default RemoteAccessService;
121
+ //# sourceMappingURL=index.d.ts.map