@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.
- package/LICENSE +21 -0
- package/README.i18n.yaml +34 -0
- package/README.md +96 -2
- package/README.zh.md +97 -0
- package/lib/client.js +733 -0
- package/lib/index.js +441 -0
- package/lib/typert.host.d.ts +3 -0
- package/lib/typert.host.js +233 -0
- package/lib/typert.remote-client.d.ts +26 -0
- package/lib/typert.remote-client.js +75 -0
- package/lib/types/client/index.d.ts +22 -0
- package/lib/types/client/index.js +25 -0
- package/lib/types/client/mount.d.ts +50 -0
- package/lib/types/client/mount.js +81 -0
- package/lib/types/client/state.d.ts +63 -0
- package/lib/types/client/state.js +182 -0
- package/lib/types/index.d.ts +121 -0
- package/lib/types/index.js +405 -0
- package/lib/types/snapshot.d.ts +8 -0
- package/lib/types/snapshot.js +8 -0
- package/lib/types/types.d.ts +130 -0
- package/lib/types/types.js +7 -0
- package/package.json +81 -4
|
@@ -0,0 +1,405 @@
|
|
|
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
|
+
var __runInitializers = (this && this.__runInitializers) || function (thisArg, initializers, value) {
|
|
10
|
+
var useValue = arguments.length > 2;
|
|
11
|
+
for (var i = 0; i < initializers.length; i++) {
|
|
12
|
+
value = useValue ? initializers[i].call(thisArg, value) : initializers[i].call(thisArg);
|
|
13
|
+
}
|
|
14
|
+
return useValue ? value : void 0;
|
|
15
|
+
};
|
|
16
|
+
var __esDecorate = (this && this.__esDecorate) || function (ctor, descriptorIn, decorators, contextIn, initializers, extraInitializers) {
|
|
17
|
+
function accept(f) { if (f !== void 0 && typeof f !== "function") throw new TypeError("Function expected"); return f; }
|
|
18
|
+
var kind = contextIn.kind, key = kind === "getter" ? "get" : kind === "setter" ? "set" : "value";
|
|
19
|
+
var target = !descriptorIn && ctor ? contextIn["static"] ? ctor : ctor.prototype : null;
|
|
20
|
+
var descriptor = descriptorIn || (target ? Object.getOwnPropertyDescriptor(target, contextIn.name) : {});
|
|
21
|
+
var _, done = false;
|
|
22
|
+
for (var i = decorators.length - 1; i >= 0; i--) {
|
|
23
|
+
var context = {};
|
|
24
|
+
for (var p in contextIn) context[p] = p === "access" ? {} : contextIn[p];
|
|
25
|
+
for (var p in contextIn.access) context.access[p] = contextIn.access[p];
|
|
26
|
+
context.addInitializer = function (f) { if (done) throw new TypeError("Cannot add initializers after decoration has completed"); extraInitializers.push(accept(f || null)); };
|
|
27
|
+
var result = (0, decorators[i])(kind === "accessor" ? { get: descriptor.get, set: descriptor.set } : descriptor[key], context);
|
|
28
|
+
if (kind === "accessor") {
|
|
29
|
+
if (result === void 0) continue;
|
|
30
|
+
if (result === null || typeof result !== "object") throw new TypeError("Object expected");
|
|
31
|
+
if (_ = accept(result.get)) descriptor.get = _;
|
|
32
|
+
if (_ = accept(result.set)) descriptor.set = _;
|
|
33
|
+
if (_ = accept(result.init)) initializers.unshift(_);
|
|
34
|
+
}
|
|
35
|
+
else if (_ = accept(result)) {
|
|
36
|
+
if (kind === "field") initializers.unshift(_);
|
|
37
|
+
else descriptor[key] = _;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
if (target) Object.defineProperty(target, contextIn.name, descriptor);
|
|
41
|
+
done = true;
|
|
42
|
+
};
|
|
43
|
+
import { Remote, TypertRemoteService, principalOf, } from '@qlzt/dsh-typert-protocol';
|
|
44
|
+
import { REMOTE_ACCESS_SNAPSHOT_KEY } from "./snapshot.js";
|
|
45
|
+
export { REMOTE_ACCESS_SNAPSHOT_KEY } from "./snapshot.js";
|
|
46
|
+
/** The snapshot endpoint every Peer may read for itself. */
|
|
47
|
+
const FOLLOW_ACTION = 'remoteAccess/follow';
|
|
48
|
+
const ALLOW = Object.freeze({ kind: 'allow' });
|
|
49
|
+
const NO_ALLOW = Object.freeze({ kind: 'deny', reason: 'no-allow' });
|
|
50
|
+
const POLICY_FAILED = Object.freeze({ kind: 'deny', reason: 'policy-failed' });
|
|
51
|
+
const OWNER = Object.freeze({ kind: 'owner' });
|
|
52
|
+
const OWNER_BASELINE = Object.freeze({ type: 'baseline', owner: true });
|
|
53
|
+
const ALLOWED = Object.freeze({ kind: 'allow' });
|
|
54
|
+
const FAILED = Object.freeze({ kind: 'failed' });
|
|
55
|
+
/** One open non-owner snapshot generation; changes coalesce until its reader wakes. */
|
|
56
|
+
class AccessFollower {
|
|
57
|
+
principal;
|
|
58
|
+
dirty = false;
|
|
59
|
+
wake;
|
|
60
|
+
/** @param principal - the generation's caller identity; never the owner. */
|
|
61
|
+
constructor(principal) {
|
|
62
|
+
this.principal = principal;
|
|
63
|
+
}
|
|
64
|
+
/** Mark the generation stale and wake its reader. */
|
|
65
|
+
touch() {
|
|
66
|
+
this.dirty = true;
|
|
67
|
+
this.wake?.();
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Wait until the generation is stale or cancelled.
|
|
71
|
+
* @param signal - generation cancellation.
|
|
72
|
+
* @returns true when stale; false once cancelled.
|
|
73
|
+
*/
|
|
74
|
+
async changed(signal) {
|
|
75
|
+
const abort = () => { this.wake?.(); };
|
|
76
|
+
signal.addEventListener('abort', abort, { once: true });
|
|
77
|
+
try {
|
|
78
|
+
while (!this.dirty && !signal.aborted) {
|
|
79
|
+
await new Promise((resolve) => { this.wake = resolve; });
|
|
80
|
+
this.wake = undefined;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
finally {
|
|
84
|
+
signal.removeEventListener('abort', abort);
|
|
85
|
+
}
|
|
86
|
+
this.dirty = false;
|
|
87
|
+
return !signal.aborted;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Registry of access policies, the Gateway's access decisions, list
|
|
92
|
+
* filtering, and the per-Peer access snapshot. Every composition that mounts
|
|
93
|
+
* the Gateway mounts this service too.
|
|
94
|
+
*/
|
|
95
|
+
let RemoteAccessService = (() => {
|
|
96
|
+
let _classSuper = TypertRemoteService;
|
|
97
|
+
let _instanceExtraInitializers = [];
|
|
98
|
+
let _follow_decorators;
|
|
99
|
+
return class RemoteAccessService extends _classSuper {
|
|
100
|
+
static {
|
|
101
|
+
const _metadata = typeof Symbol === "function" && Symbol.metadata ? Object.create(_classSuper[Symbol.metadata] ?? null) : void 0;
|
|
102
|
+
_follow_decorators = [Remote({ mode: 'stream' })];
|
|
103
|
+
__esDecorate(this, null, _follow_decorators, { kind: "method", name: "follow", static: false, private: false, access: { has: obj => "follow" in obj, get: obj => obj.follow }, metadata: _metadata }, null, _instanceExtraInitializers);
|
|
104
|
+
if (_metadata) Object.defineProperty(this, Symbol.metadata, { enumerable: true, configurable: true, writable: true, value: _metadata });
|
|
105
|
+
}
|
|
106
|
+
policies = (__runInitializers(this, _instanceExtraInitializers), new Map());
|
|
107
|
+
/** Policies whose failure was logged since the last `invalidate()`; their later failures stay silent. */
|
|
108
|
+
reported = new Set();
|
|
109
|
+
followers = new Set();
|
|
110
|
+
/** Aborted when this instance unloads; every open snapshot generation ends with it. */
|
|
111
|
+
lifetime = new AbortController();
|
|
112
|
+
/** @param ctx - owning Host plugin Context. */
|
|
113
|
+
constructor(ctx) {
|
|
114
|
+
super(ctx, 'remoteAccess');
|
|
115
|
+
ctx.effect(() => () => { this.lifetime.abort(); }, 'remoteAccess: snapshot generations');
|
|
116
|
+
// Every index the webServer renders belongs to the owner; snapshotFor documents non-owner pages.
|
|
117
|
+
ctx.inject(['webServer'], (webCtx) => {
|
|
118
|
+
webCtx.on('webserver/index-inject', (table) => {
|
|
119
|
+
table.push({ kind: 'global', name: REMOTE_ACCESS_SNAPSHOT_KEY, value: OWNER_BASELINE });
|
|
120
|
+
});
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* Register the policy of one non-owner principal kind in the caller's
|
|
125
|
+
* fiber; open snapshots are recomputed on registration and removal.
|
|
126
|
+
* @param kind - the principal kind the policy serves; never `owner`.
|
|
127
|
+
* @param policy - business-owned bindings and refusal reasons for principals of `kind`.
|
|
128
|
+
* @returns disposer removing this policy.
|
|
129
|
+
* @throws Error when `kind` already has a policy.
|
|
130
|
+
*/
|
|
131
|
+
registerPolicy(kind, policy) {
|
|
132
|
+
const policies = this.policies;
|
|
133
|
+
if (policies.has(kind))
|
|
134
|
+
throw new Error(`remote access: principal kind ${JSON.stringify(kind)} already has a policy`);
|
|
135
|
+
const entry = { kind, policy };
|
|
136
|
+
const dispose = this.ctx.effect(() => {
|
|
137
|
+
policies.set(kind, entry);
|
|
138
|
+
this.invalidate();
|
|
139
|
+
return () => {
|
|
140
|
+
policies.delete(kind);
|
|
141
|
+
this.invalidate();
|
|
142
|
+
};
|
|
143
|
+
}, `remoteAccess.registerPolicy(${JSON.stringify(kind)})`);
|
|
144
|
+
return () => { void dispose(); };
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* Report that the bindings or condition answers for `principal` may have
|
|
148
|
+
* changed. Business packages call this on every grant, revocation, mode
|
|
149
|
+
* change, and expiry; open snapshots re-push, and the next failure of each
|
|
150
|
+
* policy is logged again.
|
|
151
|
+
* @param principal - affected principal; omitted for every principal.
|
|
152
|
+
*/
|
|
153
|
+
invalidate(principal) {
|
|
154
|
+
this.reported.clear();
|
|
155
|
+
const key = principal === undefined ? undefined : canonicalJson(principal);
|
|
156
|
+
for (const follower of this.followers) {
|
|
157
|
+
if (key === undefined || canonicalJson(follower.principal) === key)
|
|
158
|
+
follower.touch();
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* Gateway-only: decide one request whose resources the Gateway resolved.
|
|
163
|
+
* The owner is allowed, and so is `remoteAccess/follow`: every Peer reads
|
|
164
|
+
* its own snapshot. Otherwise the action is allowed only when, on every
|
|
165
|
+
* listed resource, some binding of the principal's policy names that
|
|
166
|
+
* resource, has a role listing the action, and has no condition or a
|
|
167
|
+
* condition that allows. `bindings()` is called once per decision.
|
|
168
|
+
* @param request - the caller or recipient, the action, the resources it
|
|
169
|
+
* touches, and a call's arguments.
|
|
170
|
+
* @returns `allow`; or `deny` with `no-allow` when the principal kind has
|
|
171
|
+
* no policy, the first refusing condition's reason on the first resource
|
|
172
|
+
* no binding allows, else `explain()`'s reason, else `no-allow`;
|
|
173
|
+
* `policy-failed` when `bindings()`, a condition, or `explain()` throws,
|
|
174
|
+
* logged once per policy until the next `invalidate()`. The Gateway
|
|
175
|
+
* throws `remote-access/denied` for a denied call and drops a denied event.
|
|
176
|
+
*/
|
|
177
|
+
decide(request) {
|
|
178
|
+
const { principal, action } = request;
|
|
179
|
+
// oxlint-disable-next-line typescript/no-unnecessary-condition -- other packages merge non-owner kinds into PrincipalMap.
|
|
180
|
+
if (principal.kind === 'owner' || action === FOLLOW_ACTION)
|
|
181
|
+
return ALLOW;
|
|
182
|
+
const entry = this.policies.get(principal.kind);
|
|
183
|
+
if (entry === undefined)
|
|
184
|
+
return NO_ALLOW;
|
|
185
|
+
const bindings = this.bindingsOf(entry, principal, action);
|
|
186
|
+
if (bindings === undefined)
|
|
187
|
+
return POLICY_FAILED;
|
|
188
|
+
for (const resource of request.resources) {
|
|
189
|
+
const evaluation = this.evaluate(entry, bindings, action, resource, request.args);
|
|
190
|
+
switch (evaluation.kind) {
|
|
191
|
+
case 'allow':
|
|
192
|
+
continue;
|
|
193
|
+
case 'failed':
|
|
194
|
+
return POLICY_FAILED;
|
|
195
|
+
case 'refuse':
|
|
196
|
+
return evaluation.refusal ?? this.explain(entry, request);
|
|
197
|
+
/* v8 ignore next -- closed union. */
|
|
198
|
+
default:
|
|
199
|
+
return assertNever(evaluation);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
return ALLOW;
|
|
203
|
+
}
|
|
204
|
+
/**
|
|
205
|
+
* Filter items inside a Remote method. The invocation is read from the
|
|
206
|
+
* Context that accessed this service, so call it as
|
|
207
|
+
* `this.ctx.remoteAccess.allows(resource)` from the Remote method. Each
|
|
208
|
+
* check reads the policy's bindings again, so a long-running stream sees
|
|
209
|
+
* binding changes from the next check on.
|
|
210
|
+
* @param resource - resource of one result item.
|
|
211
|
+
* @returns true for the owner and outside a Remote call; otherwise whether
|
|
212
|
+
* the invocation's endpoint action is allowed on `resource` without call
|
|
213
|
+
* arguments, false when the policy is absent or fails.
|
|
214
|
+
*/
|
|
215
|
+
allows(resource) {
|
|
216
|
+
const invocation = this.ctx.invocation;
|
|
217
|
+
const principal = principalOf(invocation);
|
|
218
|
+
if (invocation === undefined || principal === undefined)
|
|
219
|
+
return true;
|
|
220
|
+
const action = `${invocation.request.namespace}/${invocation.request.method}`;
|
|
221
|
+
const entry = this.policies.get(principal.kind);
|
|
222
|
+
if (entry === undefined)
|
|
223
|
+
return false;
|
|
224
|
+
const bindings = this.bindingsOf(entry, principal, action);
|
|
225
|
+
return bindings !== undefined && this.evaluate(entry, bindings, action, resource, undefined).kind === 'allow';
|
|
226
|
+
}
|
|
227
|
+
/**
|
|
228
|
+
* The baseline a page opens with before its snapshot stream connects. A
|
|
229
|
+
* business package rendering a non-owner page assigns this value to
|
|
230
|
+
* {@link REMOTE_ACCESS_SNAPSHOT_KEY} after every index injection row,
|
|
231
|
+
* replacing the owner baseline the webServer index carries.
|
|
232
|
+
* @param principal - the page's caller identity.
|
|
233
|
+
* @returns the owner baseline for the owner; otherwise the principal's current grants.
|
|
234
|
+
*/
|
|
235
|
+
snapshotFor(principal) {
|
|
236
|
+
// oxlint-disable-next-line typescript/no-unnecessary-condition -- other packages merge non-owner kinds into PrincipalMap.
|
|
237
|
+
if (principal.kind === 'owner')
|
|
238
|
+
return OWNER_BASELINE;
|
|
239
|
+
return { type: 'baseline', owner: false, grants: this.grantsFor(principal) };
|
|
240
|
+
}
|
|
241
|
+
/**
|
|
242
|
+
* Stream the calling Peer's access snapshot: one baseline, then `upsert`
|
|
243
|
+
* and `remove` frames after each `invalidate()` and each policy
|
|
244
|
+
* registration or removal that changes a grant. The owner receives only
|
|
245
|
+
* `{ owner: true }`. The generation ends when `signal` aborts or this
|
|
246
|
+
* service unloads, so a reloaded service starts every reader from a fresh
|
|
247
|
+
* baseline.
|
|
248
|
+
* @param signal - generation cancellation.
|
|
249
|
+
* @returns the access frames of one generation.
|
|
250
|
+
*/
|
|
251
|
+
async *follow(signal) {
|
|
252
|
+
signal.throwIfAborted();
|
|
253
|
+
const generation = AbortSignal.any([signal, this.lifetime.signal]);
|
|
254
|
+
const principal = principalOf(this.ctx.invocation);
|
|
255
|
+
if (principal === undefined) {
|
|
256
|
+
yield this.snapshotFor(OWNER);
|
|
257
|
+
await abortion(generation);
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
260
|
+
const follower = new AccessFollower(principal);
|
|
261
|
+
this.followers.add(follower);
|
|
262
|
+
try {
|
|
263
|
+
let sent = this.grantsFor(principal);
|
|
264
|
+
yield { type: 'baseline', owner: false, grants: sent };
|
|
265
|
+
while (await follower.changed(generation)) {
|
|
266
|
+
const next = this.grantsFor(principal);
|
|
267
|
+
const previous = sent;
|
|
268
|
+
sent = next;
|
|
269
|
+
for (const grant of next) {
|
|
270
|
+
const before = previous.find(other => sameResource(other.resource, grant.resource));
|
|
271
|
+
if (before === undefined || !sameActions(before.actions, grant.actions))
|
|
272
|
+
yield { type: 'upsert', grant };
|
|
273
|
+
}
|
|
274
|
+
for (const grant of previous) {
|
|
275
|
+
if (!next.some(other => sameResource(other.resource, grant.resource)))
|
|
276
|
+
yield { type: 'remove', resource: grant.resource };
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
finally {
|
|
281
|
+
this.followers.delete(follower);
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
/** One `bindings()` call; `undefined` after a logged failure. */
|
|
285
|
+
bindingsOf(entry, principal, purpose) {
|
|
286
|
+
try {
|
|
287
|
+
return entry.policy.bindings(principal);
|
|
288
|
+
}
|
|
289
|
+
catch (error) {
|
|
290
|
+
// A policy failure is contained to this question, so one faulty rule cannot break a shared event stream.
|
|
291
|
+
this.report(entry, `list bindings for ${purpose}`, error);
|
|
292
|
+
return undefined;
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
/** Find a binding on `resource` whose role lists `action` and whose condition, if any, allows. */
|
|
296
|
+
evaluate(entry, bindings, action, resource, args) {
|
|
297
|
+
let refusal;
|
|
298
|
+
for (const binding of bindings) {
|
|
299
|
+
if (!sameResource(binding.resource, resource) || !binding.role.actions.includes(action))
|
|
300
|
+
continue;
|
|
301
|
+
if (binding.condition === undefined)
|
|
302
|
+
return ALLOWED;
|
|
303
|
+
let decision;
|
|
304
|
+
try {
|
|
305
|
+
decision = binding.condition(args === undefined ? { action, resource } : { action, resource, args });
|
|
306
|
+
}
|
|
307
|
+
catch (error) {
|
|
308
|
+
this.report(entry, `evaluate a condition for ${action}`, error);
|
|
309
|
+
return FAILED;
|
|
310
|
+
}
|
|
311
|
+
if (decision.kind === 'allow')
|
|
312
|
+
return ALLOWED;
|
|
313
|
+
refusal ??= decision;
|
|
314
|
+
}
|
|
315
|
+
return { kind: 'refuse', refusal };
|
|
316
|
+
}
|
|
317
|
+
/** The refusal reason `explain()` names, else `no-allow`; `policy-failed` when it throws. */
|
|
318
|
+
explain(entry, request) {
|
|
319
|
+
let reason;
|
|
320
|
+
try {
|
|
321
|
+
reason = entry.policy.explain?.(request);
|
|
322
|
+
}
|
|
323
|
+
catch (error) {
|
|
324
|
+
this.report(entry, `explain a refusal of ${request.action}`, error);
|
|
325
|
+
return POLICY_FAILED;
|
|
326
|
+
}
|
|
327
|
+
return reason === undefined ? NO_ALLOW : { kind: 'deny', reason };
|
|
328
|
+
}
|
|
329
|
+
/**
|
|
330
|
+
* One grant per bound resource, in first-binding order: the endpoint
|
|
331
|
+
* actions of the resource's bindings that `decide()` would allow there
|
|
332
|
+
* without call arguments. A resource with no such action gets no grant;
|
|
333
|
+
* event actions stay out of the snapshot.
|
|
334
|
+
*/
|
|
335
|
+
grantsFor(principal) {
|
|
336
|
+
const entry = this.policies.get(principal.kind);
|
|
337
|
+
if (entry === undefined)
|
|
338
|
+
return [];
|
|
339
|
+
const bindings = this.bindingsOf(entry, principal, 'a snapshot');
|
|
340
|
+
if (bindings === undefined)
|
|
341
|
+
return [];
|
|
342
|
+
const grants = [];
|
|
343
|
+
const seen = [];
|
|
344
|
+
for (const { resource } of bindings) {
|
|
345
|
+
if (seen.some(other => sameResource(other, resource)))
|
|
346
|
+
continue;
|
|
347
|
+
seen.push(resource);
|
|
348
|
+
const candidates = new Set();
|
|
349
|
+
for (const binding of bindings) {
|
|
350
|
+
if (!sameResource(binding.resource, resource))
|
|
351
|
+
continue;
|
|
352
|
+
for (const action of binding.role.actions) {
|
|
353
|
+
if (!action.startsWith('event:'))
|
|
354
|
+
candidates.add(action);
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
const actions = [...candidates].filter(action => this.evaluate(entry, bindings, action, resource, undefined).kind === 'allow').sort();
|
|
358
|
+
if (actions.length > 0)
|
|
359
|
+
grants.push(Object.freeze({ resource, actions: Object.freeze(actions) }));
|
|
360
|
+
}
|
|
361
|
+
return grants;
|
|
362
|
+
}
|
|
363
|
+
/** Log a policy failure once per policy until the next `invalidate()`; snapshots and lists ask many times per change. */
|
|
364
|
+
report(entry, action, error) {
|
|
365
|
+
if (this.reported.has(entry))
|
|
366
|
+
return;
|
|
367
|
+
this.reported.add(entry);
|
|
368
|
+
this.ctx.logger.warn(`remote access: the ${JSON.stringify(entry.kind)} policy failed to ${action}; its further failures are not logged until access state changes`);
|
|
369
|
+
this.ctx.logger.warn(error);
|
|
370
|
+
}
|
|
371
|
+
};
|
|
372
|
+
})();
|
|
373
|
+
export { RemoteAccessService };
|
|
374
|
+
/* v8 ignore next 3 -- closed-union backstop. */
|
|
375
|
+
function assertNever(value) {
|
|
376
|
+
throw new Error(`remote access: unreachable value ${JSON.stringify(value)}`);
|
|
377
|
+
}
|
|
378
|
+
/** Key-order-independent JSON text identifying one principal. */
|
|
379
|
+
function canonicalJson(value) {
|
|
380
|
+
if (Array.isArray(value))
|
|
381
|
+
return `[${value.map(canonicalJson).join(',')}]`;
|
|
382
|
+
if (typeof value === 'object' && value !== null) {
|
|
383
|
+
// Own keys are unique, so no two entries compare equal.
|
|
384
|
+
const entries = Object.entries(value).sort(([left], [right]) => (left < right ? -1 : 1));
|
|
385
|
+
return `{${entries.map(([key, item]) => `${JSON.stringify(key)}:${canonicalJson(item)}`).join(',')}}`;
|
|
386
|
+
}
|
|
387
|
+
return JSON.stringify(value);
|
|
388
|
+
}
|
|
389
|
+
/** Resources are equal when their kinds and ids are; `global` carries no id. */
|
|
390
|
+
function sameResource(left, right) {
|
|
391
|
+
return left.kind === right.kind && left.id === right.id;
|
|
392
|
+
}
|
|
393
|
+
function sameActions(left, right) {
|
|
394
|
+
return left.length === right.length && left.every((action, index) => action === right[index]);
|
|
395
|
+
}
|
|
396
|
+
function abortion(signal) {
|
|
397
|
+
return new Promise((resolve) => {
|
|
398
|
+
if (signal.aborted)
|
|
399
|
+
resolve();
|
|
400
|
+
else
|
|
401
|
+
signal.addEventListener('abort', () => { resolve(); }, { once: true });
|
|
402
|
+
});
|
|
403
|
+
}
|
|
404
|
+
export default RemoteAccessService;
|
|
405
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The index global carrying a page's first access snapshot, shared by the
|
|
3
|
+
* Host row that writes it and the Client mount that reads it.
|
|
4
|
+
* @module @qlzt/dsh-api-remote-access/src/snapshot
|
|
5
|
+
*/
|
|
6
|
+
/** `globalThis` property an index injection row assigns to the page's opening baseline. */
|
|
7
|
+
export declare const REMOTE_ACCESS_SNAPSHOT_KEY = "__DSH_REMOTE_ACCESS__";
|
|
8
|
+
//# sourceMappingURL=snapshot.d.ts.map
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The index global carrying a page's first access snapshot, shared by the
|
|
3
|
+
* Host row that writes it and the Client mount that reads it.
|
|
4
|
+
* @module @qlzt/dsh-api-remote-access/src/snapshot
|
|
5
|
+
*/
|
|
6
|
+
/** `globalThis` property an index injection row assigns to the page's opening baseline. */
|
|
7
|
+
export const REMOTE_ACCESS_SNAPSHOT_KEY = '__DSH_REMOTE_ACCESS__';
|
|
8
|
+
//# sourceMappingURL=snapshot.js.map
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Method-level access control contracts: actions, roles, bindings, policies,
|
|
3
|
+
* decision requests, and the access snapshot stream.
|
|
4
|
+
* @module @qlzt/dsh-api-remote-access/types
|
|
5
|
+
*/
|
|
6
|
+
import type { Principal, RemoteAccessResource, RemoteAccessResourceKind, RemoteAccessResourceOf } from '@qlzt/dsh-typert-protocol';
|
|
7
|
+
export type { RemoteAccessResource, RemoteAccessResourceKind, RemoteAccessResourceOf, TypertResourceKind, TypertResourceMap, } from '@qlzt/dsh-typert-protocol';
|
|
8
|
+
/** Canonical `<namespace>/<method>` Remote endpoint; its segments never contain `:`. */
|
|
9
|
+
export type RemoteAccessEndpoint = `${string}/${string}`;
|
|
10
|
+
/** A Remote endpoint, or `event:<name>` for one forwarded event. */
|
|
11
|
+
export type RemoteAccessAction = RemoteAccessEndpoint | `event:${string}`;
|
|
12
|
+
/** A named action set over one resource kind; a plain value the business package owns. */
|
|
13
|
+
export interface RemoteAccessRole<K extends RemoteAccessResourceKind> {
|
|
14
|
+
/** Name for diagnostics; decisions read only `resource` and `actions`. */
|
|
15
|
+
readonly name: string;
|
|
16
|
+
/** The resource kind every binding of this role names. */
|
|
17
|
+
readonly resource: K;
|
|
18
|
+
/** Actions the role allows on its binding's resource. */
|
|
19
|
+
readonly actions: readonly RemoteAccessAction[];
|
|
20
|
+
}
|
|
21
|
+
/** Answer of `decide()` and of a condition. */
|
|
22
|
+
export type RemoteAccessDecision = {
|
|
23
|
+
readonly kind: 'allow';
|
|
24
|
+
} | {
|
|
25
|
+
readonly kind: 'deny';
|
|
26
|
+
readonly reason: string;
|
|
27
|
+
};
|
|
28
|
+
/**
|
|
29
|
+
* Refines one binding for one action on its resource. Called synchronously,
|
|
30
|
+
* only for a binding whose resource matches and whose role lists the action.
|
|
31
|
+
* @param request - the action, the binding's resource, and the named
|
|
32
|
+
* arguments of a concrete call, validated against their Typert-compiled
|
|
33
|
+
* types with lookup parameters as wire identities; `args` is absent for
|
|
34
|
+
* events, snapshots, and list filtering, where the condition answers for
|
|
35
|
+
* the action alone.
|
|
36
|
+
* @returns allow, or deny with the reason a refused call reports.
|
|
37
|
+
*/
|
|
38
|
+
export type RemoteAccessCondition<K extends RemoteAccessResourceKind> = (request: {
|
|
39
|
+
readonly action: RemoteAccessAction;
|
|
40
|
+
readonly resource: RemoteAccessResourceOf<K>;
|
|
41
|
+
readonly args?: Readonly<Record<string, unknown>>;
|
|
42
|
+
}) => RemoteAccessDecision;
|
|
43
|
+
/** One role on one resource of the role's kind; the resource type follows from the role. */
|
|
44
|
+
export type RemoteAccessBinding = {
|
|
45
|
+
[K in RemoteAccessResourceKind]: {
|
|
46
|
+
readonly role: RemoteAccessRole<K>;
|
|
47
|
+
readonly resource: RemoteAccessResourceOf<K>;
|
|
48
|
+
/** Without a condition, the binding allows every action its role lists. */
|
|
49
|
+
readonly condition?: RemoteAccessCondition<K>;
|
|
50
|
+
};
|
|
51
|
+
}[RemoteAccessResourceKind];
|
|
52
|
+
/** One access question: may `principal` take `action` on `resources`. */
|
|
53
|
+
export interface RemoteAccessRequest<P extends Principal> {
|
|
54
|
+
readonly principal: P;
|
|
55
|
+
readonly action: RemoteAccessAction;
|
|
56
|
+
/**
|
|
57
|
+
* Every resource the action touches, the global resource alone when it
|
|
58
|
+
* names none; allowed only when the action is allowed on every listed resource.
|
|
59
|
+
*/
|
|
60
|
+
readonly resources: readonly RemoteAccessResource[];
|
|
61
|
+
/**
|
|
62
|
+
* Named arguments of a concrete call, validated against their Typert-compiled types
|
|
63
|
+
* with lookup parameters as wire identities; absent for events, snapshots, and list filtering.
|
|
64
|
+
*/
|
|
65
|
+
readonly args?: Readonly<Record<string, unknown>>;
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Business rules for one non-owner principal kind. Every member answers
|
|
69
|
+
* synchronously from state the business package holds in memory.
|
|
70
|
+
*/
|
|
71
|
+
export interface RemoteAccessPolicy<P extends Principal> {
|
|
72
|
+
/**
|
|
73
|
+
* List the principal's bindings. Called once per `decide()`, once per
|
|
74
|
+
* `allows()`, and once per snapshot computation; never cached.
|
|
75
|
+
* @param principal - non-owner caller of this policy's kind.
|
|
76
|
+
* @returns every binding the principal holds now.
|
|
77
|
+
*/
|
|
78
|
+
bindings(principal: P): readonly RemoteAccessBinding[];
|
|
79
|
+
/**
|
|
80
|
+
* Name the reason for a request no binding allows; called only after the
|
|
81
|
+
* decision is already a deny, so it never changes the decision.
|
|
82
|
+
* @param request - the refused request.
|
|
83
|
+
* @returns the reason a refused call reports, or `undefined` for `no-allow`.
|
|
84
|
+
*/
|
|
85
|
+
explain?(request: RemoteAccessRequest<P>): string | undefined;
|
|
86
|
+
}
|
|
87
|
+
/** Details of a refused non-owner call. */
|
|
88
|
+
export interface RemoteAccessDeniedDetails {
|
|
89
|
+
/** Canonical `<namespace>/<method>` endpoint of the refused call. */
|
|
90
|
+
readonly action: string;
|
|
91
|
+
/**
|
|
92
|
+
* A condition's or `explain()`'s reason, `no-allow`, or `policy-failed`;
|
|
93
|
+
* the Gateway refuses a call whose resources are invalid with
|
|
94
|
+
* `resource-invalid` before `decide()` is asked.
|
|
95
|
+
*/
|
|
96
|
+
readonly reason: string;
|
|
97
|
+
}
|
|
98
|
+
declare module '@qlzt/dsh-typert-protocol' {
|
|
99
|
+
interface RemoteErrorDetailsMap {
|
|
100
|
+
'remote-access/denied': RemoteAccessDeniedDetails;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
/** The endpoint actions one principal may call on one resource. */
|
|
104
|
+
export interface RemoteAccessGrant {
|
|
105
|
+
readonly resource: RemoteAccessResource;
|
|
106
|
+
/**
|
|
107
|
+
* Endpoint actions allowed on this resource without call arguments, sorted;
|
|
108
|
+
* plain strings because Typert generates no codec for template literal types.
|
|
109
|
+
*/
|
|
110
|
+
readonly actions: readonly string[];
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* One frame of `remoteAccess/follow`. A generation opens with one baseline;
|
|
114
|
+
* an owner baseline is the only frame of its generation.
|
|
115
|
+
*/
|
|
116
|
+
export type RemoteAccessFrame = {
|
|
117
|
+
readonly type: 'baseline';
|
|
118
|
+
readonly owner: true;
|
|
119
|
+
} | {
|
|
120
|
+
readonly type: 'baseline';
|
|
121
|
+
readonly owner: false;
|
|
122
|
+
readonly grants: readonly RemoteAccessGrant[];
|
|
123
|
+
} | {
|
|
124
|
+
readonly type: 'upsert';
|
|
125
|
+
readonly grant: RemoteAccessGrant;
|
|
126
|
+
} | {
|
|
127
|
+
readonly type: 'remove';
|
|
128
|
+
readonly resource: RemoteAccessResource;
|
|
129
|
+
};
|
|
130
|
+
//# sourceMappingURL=types.d.ts.map
|
package/package.json
CHANGED
|
@@ -1,6 +1,83 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@qlzt/dsh-api-remote-access",
|
|
3
|
-
"
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"description": "Method-level Remote access control for non-owner Peers and the per-Peer access snapshot",
|
|
4
|
+
"version": "0.2.1-rc.2",
|
|
5
|
+
"publishConfig": {
|
|
6
|
+
"access": "public"
|
|
7
|
+
},
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "https://gitee.com/zsping1989/deepseek-harness.git",
|
|
11
|
+
"directory": "packages/api/remote-access"
|
|
12
|
+
},
|
|
13
|
+
"type": "module",
|
|
14
|
+
"main": "lib/index.js",
|
|
15
|
+
"types": "lib/types/index.d.ts",
|
|
16
|
+
"exports": {
|
|
17
|
+
".": {
|
|
18
|
+
"types": "./lib/types/index.d.ts",
|
|
19
|
+
"default": "./lib/index.js"
|
|
20
|
+
},
|
|
21
|
+
"./types": {
|
|
22
|
+
"types": "./lib/types/types.d.ts",
|
|
23
|
+
"default": "./lib/types/types.js"
|
|
24
|
+
},
|
|
25
|
+
"./client": {
|
|
26
|
+
"types": "./lib/types/client/index.d.ts",
|
|
27
|
+
"default": "./lib/client.js"
|
|
28
|
+
},
|
|
29
|
+
"./typert": {
|
|
30
|
+
"types": "./lib/typert.host.d.ts",
|
|
31
|
+
"default": "./lib/typert.host.js"
|
|
32
|
+
},
|
|
33
|
+
"./remote": {
|
|
34
|
+
"types": "./lib/typert.remote-client.d.ts",
|
|
35
|
+
"default": "./lib/typert.remote-client.js"
|
|
36
|
+
},
|
|
37
|
+
"./src/*": "./src/*",
|
|
38
|
+
"./package.json": "./package.json"
|
|
39
|
+
},
|
|
40
|
+
"dsh": {
|
|
41
|
+
"client": {
|
|
42
|
+
"external": [
|
|
43
|
+
"@qlzt/dsh-api-gateway/client"
|
|
44
|
+
],
|
|
45
|
+
"inject": [
|
|
46
|
+
"@qlzt/dsh-api-gateway",
|
|
47
|
+
"@qlzt/dsh-client-ui-renderer"
|
|
48
|
+
],
|
|
49
|
+
"platform": "web"
|
|
50
|
+
}
|
|
51
|
+
},
|
|
52
|
+
"files": [
|
|
53
|
+
"lib/index.js",
|
|
54
|
+
"lib/client.js",
|
|
55
|
+
"lib/types/**/*.js",
|
|
56
|
+
"lib/types/**/*.d.ts",
|
|
57
|
+
"lib/typert.host.js",
|
|
58
|
+
"lib/typert.host.d.ts",
|
|
59
|
+
"lib/typert.remote-client.js",
|
|
60
|
+
"lib/typert.remote-client.d.ts"
|
|
61
|
+
],
|
|
62
|
+
"license": "MIT",
|
|
63
|
+
"dependencies": {
|
|
64
|
+
"@qlzt/dsh-typert-protocol": "0.2.1-rc.2",
|
|
65
|
+
"zod": "4.4.3"
|
|
66
|
+
},
|
|
67
|
+
"peerDependencies": {
|
|
68
|
+
"@deepseek-ai/cordis": "~4.0.5"
|
|
69
|
+
},
|
|
70
|
+
"devDependencies": {
|
|
71
|
+
"@deepseek-ai/cordis": "~4.0.5",
|
|
72
|
+
"@deepseek-ai/dsh-api-gateway": "0.2.1-rc.2",
|
|
73
|
+
"@deepseek-ai/dsh-client-connection": "0.2.1-rc.2",
|
|
74
|
+
"@deepseek-ai/dsh-client-ui-renderer": "0.2.1-rc.2",
|
|
75
|
+
"@deepseek-ai/dsh-client-ui-slots": "0.2.1-rc.2",
|
|
76
|
+
"@deepseek-ai/dsh-host-webserver": "0.2.1-rc.2",
|
|
77
|
+
"@deepseek-ai/dsh-typert-registry": "0.2.1-rc.2"
|
|
78
|
+
},
|
|
79
|
+
"scripts": {
|
|
80
|
+
"bundle": "tsdown",
|
|
81
|
+
"watch": "tsdown --watch"
|
|
82
|
+
}
|
|
83
|
+
}
|