@hediet/linkrpc-hub 0.0.1
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/README.md +101 -0
- package/dist/chunks/config-BCvkg7jv.d.ts +566 -0
- package/dist/chunks/configFile-y6Phntqu.d.ts +9 -0
- package/dist/chunks/connectionTokenBinder.interfaces-B-beCg06.js +40 -0
- package/dist/chunks/connectionTokenBinder.interfaces-B-beCg06.js.map +1 -0
- package/dist/chunks/connectionTokenBinder.interfaces-BhzQ1DTS.d.ts +36 -0
- package/dist/chunks/hubConnectionAcceptor-B5-8JsFY.js +2768 -0
- package/dist/chunks/hubConnectionAcceptor-B5-8JsFY.js.map +1 -0
- package/dist/chunks/hubConnectionAcceptor-BwydvFa4.d.ts +1560 -0
- package/dist/chunks/index-CLIUrV88.d.ts +481 -0
- package/dist/chunks/node-CTXsQ6oa.js +460 -0
- package/dist/chunks/node-CTXsQ6oa.js.map +1 -0
- package/dist/chunks/nodeTransit-CWeFnbwt.js +444 -0
- package/dist/chunks/nodeTransit-CWeFnbwt.js.map +1 -0
- package/dist/chunks/nodeTransit-cmtZgdpW.d.ts +226 -0
- package/dist/chunks/runHub-P3YUwwdv.js +1797 -0
- package/dist/chunks/runHub-P3YUwwdv.js.map +1 -0
- package/dist/chunks/server-BAxchQhy.js +1368 -0
- package/dist/chunks/server-BAxchQhy.js.map +1 -0
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +38 -0
- package/dist/cli.js.map +1 -0
- package/dist/config.d.ts +2 -0
- package/dist/config.js +305 -0
- package/dist/config.js.map +1 -0
- package/dist/configFile.d.ts +2 -0
- package/dist/configFile.js +27 -0
- package/dist/configFile.js.map +1 -0
- package/dist/engine/runHub.d.ts +42 -0
- package/dist/engine/runHub.js +2 -0
- package/dist/hub/server/client.d.ts +2 -0
- package/dist/hub/server/client.js +2 -0
- package/dist/hub/server/connectionTokenBinder.d.ts +2 -0
- package/dist/hub/server/connectionTokenBinder.js +2 -0
- package/dist/hub/server/index.d.ts +5 -0
- package/dist/hub/server/index.js +5 -0
- package/dist/hub/server/node/index.d.ts +218 -0
- package/dist/hub/server/node/index.js +2 -0
- package/dist/hub/server/transit.d.ts +2 -0
- package/dist/hub/server/transit.js +2 -0
- package/dist/index.d.ts +512 -0
- package/dist/index.js +309 -0
- package/dist/index.js.map +1 -0
- package/dist/serve.d.ts +14 -0
- package/dist/serve.js +31 -0
- package/dist/serve.js.map +1 -0
- package/dist/spawn.d.ts +12 -0
- package/dist/spawn.js +25 -0
- package/dist/spawn.js.map +1 -0
- package/package.json +83 -0
|
@@ -0,0 +1,1797 @@
|
|
|
1
|
+
import { a as anonymousHandler, b as Hub, d as registerConnectionTokenBinderService, f as createHubServiceInterfaces, l as resolveConnectionHandler, o as boundTokenHandler, t as HubConnectionAcceptor, u as staticTokenHandler } from "./hubConnectionAcceptor-B5-8JsFY.js";
|
|
2
|
+
import { r as createFlowLogger } from "./nodeTransit-CWeFnbwt.js";
|
|
3
|
+
import { a as TokenIdentityStore, c as durationToExp, d as fetchFullDirectory, n as createSqliteIdentityKeystore, s as CapabilityProposalIssuer, u as candidatesForSlot } from "./server-BAxchQhy.js";
|
|
4
|
+
import { spawnCommand } from "../spawn.js";
|
|
5
|
+
import { i as SocketServer, n as WebSocketServer } from "./node-CTXsQ6oa.js";
|
|
6
|
+
import { DEFAULT_RPC_TIMEOUT_MS, HUBRPC_META_KEY, KeypairSigningIdentity, requireObjectParams, signedHash, withRpcTimeout } from "@hediet/linkrpc";
|
|
7
|
+
import * as readline from "node:readline";
|
|
8
|
+
import { hubAccessInterface, hubAccessManifestInterface, hubGrantedServiceIdInterface } from "@hediet/linkrpc/hub/common";
|
|
9
|
+
import { createHash, randomBytes, randomUUID } from "node:crypto";
|
|
10
|
+
import * as path from "node:path";
|
|
11
|
+
import { autorun, observableValue } from "@vscode/observables";
|
|
12
|
+
import { WebSocketTransport, connectNdjson, loadOrCreateIdentity, openWebSocket, parseEndpointUri, runInitializeHandshake } from "@hediet/linkrpc/node";
|
|
13
|
+
import * as net from "node:net";
|
|
14
|
+
import * as fs from "node:fs";
|
|
15
|
+
import * as os from "node:os";
|
|
16
|
+
//#region src/engine/consent.ts
|
|
17
|
+
/**
|
|
18
|
+
* Approver-side consent utilities for the `hubAccessManifest` flow.
|
|
19
|
+
*
|
|
20
|
+
* This module is consumed by *approvers* — {@link runManifestApprover} (the CLI)
|
|
21
|
+
* and any other manifest consumer. It provides:
|
|
22
|
+
*
|
|
23
|
+
* - the prompt contract ({@link ConsentPrompt} / {@link ConsentPromptRequest} /
|
|
24
|
+
* {@link ConsentDecision}) an approver uses to ask a human, plus the default
|
|
25
|
+
* terminal y/N prompt ({@link createTerminalConsentPrompt});
|
|
26
|
+
* - capability-shaping helpers an approver uses when it mints
|
|
27
|
+
* ({@link toSignedPermissions} / {@link computeCallBindHash}, which honor
|
|
28
|
+
* "Allow once" `callBind` byte-binding); and
|
|
29
|
+
* - {@link describePermissions} for rendering a request.
|
|
30
|
+
*
|
|
31
|
+
* The hub-side translation of `hubAccess` requests into parked manifest entries
|
|
32
|
+
* lives on {@link HubAccessManifestHost} (`hubAccessConfig`), not here.
|
|
33
|
+
*/
|
|
34
|
+
/** One-line, human-readable summary of a permission for the consent prompt. */
|
|
35
|
+
function describePermission(perm) {
|
|
36
|
+
return `${patternText(perm.target.serviceId)}::${patternText(perm.target.interfaceId)} {${perm.target.members.map(patternText).join(",")}}`;
|
|
37
|
+
}
|
|
38
|
+
/** One human-readable line per permission, for an approver's UI / prompt. */
|
|
39
|
+
function describePermissions(permissions) {
|
|
40
|
+
return permissions.map(describePermission);
|
|
41
|
+
}
|
|
42
|
+
function patternText(p) {
|
|
43
|
+
if (p === void 0) return "*";
|
|
44
|
+
if ("exact" in p) return p.exact;
|
|
45
|
+
if ("prefix" in p) return p.prefix === "" ? "*" : `${p.prefix}*`;
|
|
46
|
+
return "*";
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Strip the consent-only `callIntent` from each requested permission and, for a
|
|
50
|
+
* one-shot (`duration: 'once'`) grant whose intent declares a concrete call,
|
|
51
|
+
* pin the permission to that exact call via `callBind.payloadHash`. The result
|
|
52
|
+
* is the `Permission[]` to sign into the capability. Shared by the hub-issuer
|
|
53
|
+
* mint path and any inbox approver that wants to honor "Allow once".
|
|
54
|
+
*
|
|
55
|
+
* `callIntent` is never itself signed — only the derived `callBind` is.
|
|
56
|
+
*/
|
|
57
|
+
async function toSignedPermissions(permissions, consumerPrincipalId, duration) {
|
|
58
|
+
return Promise.all(permissions.map(async ({ callIntent, ...perm }) => {
|
|
59
|
+
const signed = { ...perm };
|
|
60
|
+
if (duration === "once" && callIntent !== void 0 && callIntent.params !== void 0 && callIntent.nonce && callIntent.signedAtMs !== void 0) signed.callBind = {
|
|
61
|
+
alg: "sha256",
|
|
62
|
+
payloadHash: await computeCallBindHash(callIntent, consumerPrincipalId)
|
|
63
|
+
};
|
|
64
|
+
return signed;
|
|
65
|
+
}));
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Compute `callBind.payloadHash` for a grant bound to one consumer-declared
|
|
69
|
+
* call. Builds the SAME signed params the consumer commits to at sign time
|
|
70
|
+
* (`signedHash('call', { ...params, $hubrpc: meta })`); the forwarded-call gate
|
|
71
|
+
* re-derives this on the inbound call and checks the hash matches. The consumer
|
|
72
|
+
* owns `nonce`/`signedAtMs`, supplied via `callIntent`.
|
|
73
|
+
*/
|
|
74
|
+
async function computeCallBindHash(intent, consumerPrincipalId) {
|
|
75
|
+
const callMeta = {
|
|
76
|
+
method: intent.method,
|
|
77
|
+
nonce: intent.nonce,
|
|
78
|
+
signedAtMs: intent.signedAtMs,
|
|
79
|
+
principal: consumerPrincipalId,
|
|
80
|
+
...intent.interfaceHash !== void 0 ? { interfaceHash: intent.interfaceHash } : {}
|
|
81
|
+
};
|
|
82
|
+
const signedParams = {
|
|
83
|
+
...requireObjectParams(intent.params),
|
|
84
|
+
[HUBRPC_META_KEY]: callMeta
|
|
85
|
+
};
|
|
86
|
+
return signedHash("call", signedParams);
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* A terminal yes/no consent prompt on stdin.
|
|
90
|
+
*
|
|
91
|
+
* When stdin is not a TTY (no operator to answer) it **abstains** — it never
|
|
92
|
+
* settles, parking until {@link ConsentPromptRequest.signal} aborts. This keeps
|
|
93
|
+
* it safe inside {@link raceConsent}: a non-interactive terminal must not win
|
|
94
|
+
* the race with an instant deny, or a hub-served inbox approval could never
|
|
95
|
+
* land. A headless hub should simply not install this prompt at all (or pair it
|
|
96
|
+
* with an inbox); on its own it grants nothing.
|
|
97
|
+
*
|
|
98
|
+
* When interactive, it resolves on the operator's answer, and abandons the
|
|
99
|
+
* readline prompt if the request is aborted (another prompt decided first).
|
|
100
|
+
*/
|
|
101
|
+
function createTerminalConsentPrompt() {
|
|
102
|
+
return (request) => new Promise((resolve) => {
|
|
103
|
+
if (request.signal.aborted) {
|
|
104
|
+
resolve({ grant: false });
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
if (!process.stdin.isTTY) {
|
|
108
|
+
process.stderr.write("hubAccess: stdin is not a TTY; the terminal prompt is abstaining (waiting for an out-of-band approval or cancellation).\n");
|
|
109
|
+
request.signal.addEventListener("abort", () => resolve({ grant: false }), { once: true });
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
const rl = readline.createInterface({
|
|
113
|
+
input: process.stdin,
|
|
114
|
+
output: process.stderr
|
|
115
|
+
});
|
|
116
|
+
const onAbort = () => {
|
|
117
|
+
rl.close();
|
|
118
|
+
resolve({ grant: false });
|
|
119
|
+
};
|
|
120
|
+
request.signal.addEventListener("abort", onAbort, { once: true });
|
|
121
|
+
const lines = [
|
|
122
|
+
"",
|
|
123
|
+
`Access request from "${request.consumer.name}"`,
|
|
124
|
+
...request.consumer.purpose ? [` purpose: ${request.consumer.purpose}`] : [],
|
|
125
|
+
` node: ${request.consumerPrincipalId}`,
|
|
126
|
+
...request.grants.map((g) => ` grant: ${g}`),
|
|
127
|
+
...request.duration ? [` duration: ${request.duration}`] : [],
|
|
128
|
+
""
|
|
129
|
+
];
|
|
130
|
+
process.stderr.write(lines.join("\n"));
|
|
131
|
+
rl.question("Allow? [y/N] ", (answer) => {
|
|
132
|
+
request.signal.removeEventListener("abort", onAbort);
|
|
133
|
+
rl.close();
|
|
134
|
+
resolve(/^y(es)?$/i.test(answer.trim()) ? { grant: true } : { grant: false });
|
|
135
|
+
});
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* Compose several {@link ConsentPrompt}s into one that presents each request to
|
|
140
|
+
* **all** of them and takes the **first** to settle (first responder wins,
|
|
141
|
+
* including a deny). Once one settles, the rest are aborted via a shared signal
|
|
142
|
+
* so they can tear down (close the readline, drop the inbox entry). The
|
|
143
|
+
* incoming request's own signal is honored too: if the consumer cancels, every
|
|
144
|
+
* child prompt is aborted.
|
|
145
|
+
*
|
|
146
|
+
* This is what lets a single `hubAccess` request reach the hub operator's
|
|
147
|
+
* terminal prompt and a hub-served inbox at the same time.
|
|
148
|
+
*/
|
|
149
|
+
function raceConsent(prompts) {
|
|
150
|
+
return (request) => new Promise((resolve, reject) => {
|
|
151
|
+
if (prompts.length === 0) {
|
|
152
|
+
resolve({
|
|
153
|
+
grant: false,
|
|
154
|
+
reason: "no consent surface configured"
|
|
155
|
+
});
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
const child = new AbortController();
|
|
159
|
+
const onOuterAbort = () => child.abort();
|
|
160
|
+
if (request.signal.aborted) child.abort();
|
|
161
|
+
else request.signal.addEventListener("abort", onOuterAbort, { once: true });
|
|
162
|
+
let settled = false;
|
|
163
|
+
const finish = (run) => {
|
|
164
|
+
if (settled) return;
|
|
165
|
+
settled = true;
|
|
166
|
+
request.signal.removeEventListener("abort", onOuterAbort);
|
|
167
|
+
child.abort();
|
|
168
|
+
run();
|
|
169
|
+
};
|
|
170
|
+
const childRequest = {
|
|
171
|
+
...request,
|
|
172
|
+
signal: child.signal
|
|
173
|
+
};
|
|
174
|
+
for (const prompt of prompts) Promise.resolve().then(() => prompt(childRequest)).then((decision) => finish(() => resolve(decision)), (err) => finish(() => reject(err)));
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
//#endregion
|
|
178
|
+
//#region src/engine/hubAccessConfig.ts
|
|
179
|
+
/**
|
|
180
|
+
* Build the `hubAccess` front-door config from a single {@link AccessDecider}.
|
|
181
|
+
* The returned {@link HubAccessConfig} is what `registerHubAccessService`
|
|
182
|
+
* installs at a participant's overlay root.
|
|
183
|
+
*/
|
|
184
|
+
function createHubAccessConfig(decide, fetchDirectory = async () => []) {
|
|
185
|
+
return {
|
|
186
|
+
handlers: {
|
|
187
|
+
onAccessRequest: async (args) => {
|
|
188
|
+
if (Object.keys(args.dependencies).length === 0) return {
|
|
189
|
+
granted: false,
|
|
190
|
+
reason: "no slots requested"
|
|
191
|
+
};
|
|
192
|
+
const resolvedSlots = {};
|
|
193
|
+
const permissions = [];
|
|
194
|
+
for (const [slotId, slot] of Object.entries(args.dependencies)) {
|
|
195
|
+
const candidate = slot.candidates[0];
|
|
196
|
+
if (!candidate) return {
|
|
197
|
+
granted: false,
|
|
198
|
+
reason: `no candidate for slot '${slotId}'`
|
|
199
|
+
};
|
|
200
|
+
const present = new Set(candidate.satisfiedInterfaces.map((i) => i.id));
|
|
201
|
+
resolvedSlots[slotId] = {
|
|
202
|
+
serviceId: candidate.serviceId,
|
|
203
|
+
interfaces: candidate.satisfiedInterfaces.map((i) => i.id)
|
|
204
|
+
};
|
|
205
|
+
permissions.push(...buildSlotPermissions(candidate.serviceId, slot.request.interfaces, slot.request.members, present));
|
|
206
|
+
}
|
|
207
|
+
const decision = await decide({
|
|
208
|
+
kind: "request",
|
|
209
|
+
consumer: args.consumer,
|
|
210
|
+
consumerPrincipalId: args.consumerPrincipalId,
|
|
211
|
+
permissions,
|
|
212
|
+
duration: args.duration
|
|
213
|
+
});
|
|
214
|
+
if (!decision.grant) return {
|
|
215
|
+
granted: false,
|
|
216
|
+
reason: decision.reason ?? "user denied"
|
|
217
|
+
};
|
|
218
|
+
return {
|
|
219
|
+
granted: true,
|
|
220
|
+
resolvedSlots,
|
|
221
|
+
capabilities: capsFromDecision(decision)
|
|
222
|
+
};
|
|
223
|
+
},
|
|
224
|
+
onAccessExtend: async (args) => {
|
|
225
|
+
const byIface = /* @__PURE__ */ new Map();
|
|
226
|
+
for (const a of args.added) {
|
|
227
|
+
const members = byIface.get(a.interfaceId) ?? [];
|
|
228
|
+
members.push(a.member);
|
|
229
|
+
byIface.set(a.interfaceId, members);
|
|
230
|
+
}
|
|
231
|
+
const permissions = [...byIface.entries()].map(([interfaceId, members]) => ({
|
|
232
|
+
target: {
|
|
233
|
+
serviceId: { exact: args.serviceId },
|
|
234
|
+
interfaceId: { exact: interfaceId },
|
|
235
|
+
members
|
|
236
|
+
},
|
|
237
|
+
canInvoke: true
|
|
238
|
+
}));
|
|
239
|
+
if (permissions.length === 0) return {
|
|
240
|
+
granted: false,
|
|
241
|
+
reason: "no members requested"
|
|
242
|
+
};
|
|
243
|
+
const decision = await decide({
|
|
244
|
+
kind: "extend",
|
|
245
|
+
consumer: args.consumer,
|
|
246
|
+
consumerPrincipalId: args.consumerPrincipalId,
|
|
247
|
+
permissions,
|
|
248
|
+
duration: args.duration
|
|
249
|
+
});
|
|
250
|
+
if (!decision.grant) return {
|
|
251
|
+
granted: false,
|
|
252
|
+
reason: decision.reason ?? "user denied"
|
|
253
|
+
};
|
|
254
|
+
return {
|
|
255
|
+
granted: true,
|
|
256
|
+
serviceId: args.serviceId,
|
|
257
|
+
grantedMembers: args.added.map((a) => ({
|
|
258
|
+
interfaceId: a.interfaceId,
|
|
259
|
+
member: a.member
|
|
260
|
+
})),
|
|
261
|
+
capabilities: capsFromDecision(decision)
|
|
262
|
+
};
|
|
263
|
+
},
|
|
264
|
+
onAccessRequestDirect: async (args) => {
|
|
265
|
+
if (args.permissions.length === 0) return {
|
|
266
|
+
granted: false,
|
|
267
|
+
reason: "no permissions requested"
|
|
268
|
+
};
|
|
269
|
+
const decision = await decide({
|
|
270
|
+
kind: "requestAccess",
|
|
271
|
+
consumer: args.consumer,
|
|
272
|
+
consumerPrincipalId: args.consumerPrincipalId,
|
|
273
|
+
permissions: args.permissions,
|
|
274
|
+
duration: args.duration
|
|
275
|
+
});
|
|
276
|
+
if (!decision.grant) return {
|
|
277
|
+
granted: false,
|
|
278
|
+
reason: decision.reason ?? "user denied"
|
|
279
|
+
};
|
|
280
|
+
return {
|
|
281
|
+
granted: true,
|
|
282
|
+
capabilities: capsFromDecision(decision)
|
|
283
|
+
};
|
|
284
|
+
}
|
|
285
|
+
},
|
|
286
|
+
fetchDirectory: async () => [...await fetchDirectory()]
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
/** A one-line human summary of a request, for a decider's log. */
|
|
290
|
+
function describeRequest(ctx) {
|
|
291
|
+
return `"${ctx.consumer.name}" (node=${ctx.consumerPrincipalId}) requests [${describePermissions(ctx.permissions).join(", ")}]${ctx.duration ? ` for '${ctx.duration}'` : ""}`;
|
|
292
|
+
}
|
|
293
|
+
/**
|
|
294
|
+
* Extract the approver-supplied capabilities from an approved decision. The
|
|
295
|
+
* adapter never mints — an approval that carries no capabilities is a contract
|
|
296
|
+
* violation by the decider.
|
|
297
|
+
*/
|
|
298
|
+
function capsFromDecision(decision) {
|
|
299
|
+
if (decision.capabilities === void 0 || decision.capabilities.length === 0) throw new Error("hubAccess: a grant decision carried no capabilities to relay — the decider must supply approver-signed capabilities (the adapter never mints).");
|
|
300
|
+
return [...decision.capabilities];
|
|
301
|
+
}
|
|
302
|
+
/** Build one permission per requested interface, scoped to `serviceId`. */
|
|
303
|
+
function buildSlotPermissions(serviceId, interfaces, members, present) {
|
|
304
|
+
const hashByIface = new Map(interfaces.map((i) => [i.id, i.hash]));
|
|
305
|
+
const buckets = /* @__PURE__ */ new Map();
|
|
306
|
+
for (const m of members) {
|
|
307
|
+
if (!present.has(m.interfaceId)) continue;
|
|
308
|
+
const hash = hashByIface.get(m.interfaceId);
|
|
309
|
+
const key = `${m.interfaceId}\u0000${hash ?? ""}`;
|
|
310
|
+
let bucket = buckets.get(key);
|
|
311
|
+
if (!bucket) {
|
|
312
|
+
bucket = {
|
|
313
|
+
interfaceId: m.interfaceId,
|
|
314
|
+
hash,
|
|
315
|
+
members: []
|
|
316
|
+
};
|
|
317
|
+
buckets.set(key, bucket);
|
|
318
|
+
}
|
|
319
|
+
bucket.members.push(m.member);
|
|
320
|
+
}
|
|
321
|
+
if (buckets.size === 0) return [...present].map((interfaceId) => {
|
|
322
|
+
const hash = hashByIface.get(interfaceId);
|
|
323
|
+
const target = {
|
|
324
|
+
serviceId: { exact: serviceId },
|
|
325
|
+
interfaceId: { exact: interfaceId },
|
|
326
|
+
members: [{ prefix: "" }]
|
|
327
|
+
};
|
|
328
|
+
if (hash !== void 0) target.interfaceHash = hash;
|
|
329
|
+
return {
|
|
330
|
+
target,
|
|
331
|
+
canInvoke: true
|
|
332
|
+
};
|
|
333
|
+
});
|
|
334
|
+
return [...buckets.values()].map((b) => {
|
|
335
|
+
const target = {
|
|
336
|
+
serviceId: { exact: serviceId },
|
|
337
|
+
interfaceId: { exact: b.interfaceId },
|
|
338
|
+
members: b.members
|
|
339
|
+
};
|
|
340
|
+
if (b.hash !== void 0) target.interfaceHash = b.hash;
|
|
341
|
+
return {
|
|
342
|
+
target,
|
|
343
|
+
canInvoke: true
|
|
344
|
+
};
|
|
345
|
+
});
|
|
346
|
+
}
|
|
347
|
+
//#endregion
|
|
348
|
+
//#region src/engine/hubAccessManifest.ts
|
|
349
|
+
var HubAccessManifestHost = class {
|
|
350
|
+
_pending = /* @__PURE__ */ new Map();
|
|
351
|
+
_listeners = /* @__PURE__ */ new Set();
|
|
352
|
+
_acceptableRootIds;
|
|
353
|
+
_log;
|
|
354
|
+
_revision = 0;
|
|
355
|
+
constructor(options = {}) {
|
|
356
|
+
this._acceptableRootIds = options.acceptableRootIds ?? [];
|
|
357
|
+
this._log = options.log ?? ((line) => process.stderr.write(line + "\n"));
|
|
358
|
+
}
|
|
359
|
+
/**
|
|
360
|
+
* Register `hubAccess::{request,extend,requestAccess}` directly at a
|
|
361
|
+
* participant's overlay root (root form, never forwarded → never gated).
|
|
362
|
+
* Unlike `registerHubAccessService` this consults **no** hub-side directory:
|
|
363
|
+
* `request` forwards raw slots as `discover` entries for the approver to
|
|
364
|
+
* resolve.
|
|
365
|
+
*
|
|
366
|
+
* `options.origin` is a host-authored provenance bag bound to THIS
|
|
367
|
+
* connection and stamped onto every entry parked here. It is bound
|
|
368
|
+
* server-side (not read from the consumer's request params) so a consumer
|
|
369
|
+
* cannot spoof another transport's origin. The host relays it verbatim; it
|
|
370
|
+
* does not interpret it.
|
|
371
|
+
*/
|
|
372
|
+
registerHubAccessAtRoot(connection, options) {
|
|
373
|
+
const origin = options?.origin;
|
|
374
|
+
connection.register(hubAccessInterface, {
|
|
375
|
+
request: async (params, _ctx, stream) => {
|
|
376
|
+
const consumerPrincipalId = params.consumer.principal;
|
|
377
|
+
const consumer = plainConsumer(params.consumer);
|
|
378
|
+
const slotIds = Object.keys(params.dependencies);
|
|
379
|
+
if (slotIds.length === 0) return {
|
|
380
|
+
status: "denied",
|
|
381
|
+
reason: "no slots requested"
|
|
382
|
+
};
|
|
383
|
+
this._log(`hubAccess::request "${consumer.name}" (node=${consumerPrincipalId}) slots=[${slotIds.join(", ")}]${params.duration ? ` for '${params.duration}'` : ""}`);
|
|
384
|
+
const results = await Promise.all(slotIds.map((slotId) => {
|
|
385
|
+
const slot = params.dependencies[slotId];
|
|
386
|
+
return this._park({
|
|
387
|
+
kind: "discover",
|
|
388
|
+
consumer,
|
|
389
|
+
consumerPrincipalId,
|
|
390
|
+
duration: params.duration,
|
|
391
|
+
interfaces: slot.interfaces,
|
|
392
|
+
members: slot.members ?? [],
|
|
393
|
+
origin
|
|
394
|
+
}, stream.signal);
|
|
395
|
+
}));
|
|
396
|
+
const denied = results.find((r) => !r.grant);
|
|
397
|
+
if (denied && !denied.grant) return denied.reason !== void 0 ? {
|
|
398
|
+
status: "denied",
|
|
399
|
+
reason: denied.reason
|
|
400
|
+
} : { status: "denied" };
|
|
401
|
+
const slots = {};
|
|
402
|
+
const capabilities = [];
|
|
403
|
+
slotIds.forEach((slotId, i) => {
|
|
404
|
+
const r = results[i];
|
|
405
|
+
if (!r.grant) return;
|
|
406
|
+
if (r.resolvedSlot) slots[slotId] = {
|
|
407
|
+
serviceId: r.resolvedSlot.serviceId,
|
|
408
|
+
satisfiedInterfaces: [...r.resolvedSlot.satisfiedInterfaces]
|
|
409
|
+
};
|
|
410
|
+
capabilities.push(...r.capabilities);
|
|
411
|
+
});
|
|
412
|
+
return {
|
|
413
|
+
status: "granted",
|
|
414
|
+
slots,
|
|
415
|
+
capabilities
|
|
416
|
+
};
|
|
417
|
+
},
|
|
418
|
+
extend: async (params, _ctx, stream) => {
|
|
419
|
+
const consumerPrincipalId = params.consumer.principal;
|
|
420
|
+
const permissions = extendPermissions(params.serviceId, params.added);
|
|
421
|
+
this._log(`hubAccess::extend "${params.consumer.name}" (node=${consumerPrincipalId}) service=${params.serviceId}`);
|
|
422
|
+
const decision = await this._park({
|
|
423
|
+
kind: "direct",
|
|
424
|
+
consumer: plainConsumer(params.consumer),
|
|
425
|
+
consumerPrincipalId,
|
|
426
|
+
duration: params.duration,
|
|
427
|
+
permissions,
|
|
428
|
+
origin
|
|
429
|
+
}, stream.signal);
|
|
430
|
+
if (!decision.grant) return decision.reason !== void 0 ? {
|
|
431
|
+
status: "denied",
|
|
432
|
+
reason: decision.reason
|
|
433
|
+
} : { status: "denied" };
|
|
434
|
+
return {
|
|
435
|
+
status: "granted",
|
|
436
|
+
serviceId: params.serviceId,
|
|
437
|
+
granted: params.added.map((a) => ({
|
|
438
|
+
interfaceId: a.interfaceId,
|
|
439
|
+
member: a.member
|
|
440
|
+
})),
|
|
441
|
+
capabilities: [...decision.capabilities]
|
|
442
|
+
};
|
|
443
|
+
},
|
|
444
|
+
requestAccess: async (params, _ctx, stream) => {
|
|
445
|
+
const consumerPrincipalId = params.consumer.principal;
|
|
446
|
+
const permissions = params.permissions;
|
|
447
|
+
if (permissions.length === 0) return {
|
|
448
|
+
status: "denied",
|
|
449
|
+
reason: "no permissions requested"
|
|
450
|
+
};
|
|
451
|
+
this._log(`hubAccess::requestAccess "${params.consumer.name}" (node=${consumerPrincipalId}) [${describePermissions(permissions).join(", ")}]${params.duration ? ` for '${params.duration}'` : ""}`);
|
|
452
|
+
const decision = await this._park({
|
|
453
|
+
kind: "direct",
|
|
454
|
+
consumer: plainConsumer(params.consumer),
|
|
455
|
+
consumerPrincipalId,
|
|
456
|
+
duration: params.duration,
|
|
457
|
+
permissions,
|
|
458
|
+
origin
|
|
459
|
+
}, stream.signal);
|
|
460
|
+
if (!decision.grant) return decision.reason !== void 0 ? {
|
|
461
|
+
status: "denied",
|
|
462
|
+
reason: decision.reason
|
|
463
|
+
} : { status: "denied" };
|
|
464
|
+
return {
|
|
465
|
+
status: "granted",
|
|
466
|
+
capabilities: [...decision.capabilities]
|
|
467
|
+
};
|
|
468
|
+
}
|
|
469
|
+
});
|
|
470
|
+
}
|
|
471
|
+
/** Park a desired entry and await the approver's decision (via `setCurrent`). */
|
|
472
|
+
_park(entry, signal) {
|
|
473
|
+
signal?.throwIfAborted();
|
|
474
|
+
return new Promise((resolve, reject) => {
|
|
475
|
+
const requestId = randomUUID();
|
|
476
|
+
const full = {
|
|
477
|
+
...entry,
|
|
478
|
+
requestId
|
|
479
|
+
};
|
|
480
|
+
let done = false;
|
|
481
|
+
const settle = (decision) => {
|
|
482
|
+
if (done) return;
|
|
483
|
+
done = true;
|
|
484
|
+
signal?.removeEventListener("abort", abort);
|
|
485
|
+
this._pending.delete(requestId);
|
|
486
|
+
this._bump();
|
|
487
|
+
resolve(decision);
|
|
488
|
+
};
|
|
489
|
+
const abort = () => {
|
|
490
|
+
if (done) return;
|
|
491
|
+
done = true;
|
|
492
|
+
this._pending.delete(requestId);
|
|
493
|
+
this._bump();
|
|
494
|
+
reject(signal?.reason ?? /* @__PURE__ */ new Error("hubAccess request cancelled"));
|
|
495
|
+
};
|
|
496
|
+
this._pending.set(requestId, {
|
|
497
|
+
entry: full,
|
|
498
|
+
resolve: settle
|
|
499
|
+
});
|
|
500
|
+
this._bump();
|
|
501
|
+
signal?.addEventListener("abort", abort, { once: true });
|
|
502
|
+
if (signal?.aborted) abort();
|
|
503
|
+
});
|
|
504
|
+
}
|
|
505
|
+
/** Snapshot of the desired document (entryId → desired entry) + revision. */
|
|
506
|
+
getDesired() {
|
|
507
|
+
return {
|
|
508
|
+
entries: [...this._pending.values()].map((e) => e.entry),
|
|
509
|
+
acceptableRootIds: this._acceptableRootIds,
|
|
510
|
+
revision: this._revision
|
|
511
|
+
};
|
|
512
|
+
}
|
|
513
|
+
/**
|
|
514
|
+
* Resolve a parked entry from a `setCurrent` decision. Returns `false` when
|
|
515
|
+
* no entry with that id is pending (already decided / cancelled).
|
|
516
|
+
*/
|
|
517
|
+
resolve(requestId, decision) {
|
|
518
|
+
const found = this._pending.get(requestId);
|
|
519
|
+
if (!found) return false;
|
|
520
|
+
found.resolve(decision);
|
|
521
|
+
return true;
|
|
522
|
+
}
|
|
523
|
+
/** Subscribe to coarse "desired set may have changed" notifications. */
|
|
524
|
+
onDidChange(listener) {
|
|
525
|
+
this._listeners.add(listener);
|
|
526
|
+
return () => this._listeners.delete(listener);
|
|
527
|
+
}
|
|
528
|
+
_bump() {
|
|
529
|
+
this._revision += 1;
|
|
530
|
+
for (const l of [...this._listeners]) l();
|
|
531
|
+
}
|
|
532
|
+
};
|
|
533
|
+
function plainConsumer(c) {
|
|
534
|
+
return {
|
|
535
|
+
name: c.name,
|
|
536
|
+
...c.origin !== void 0 ? { origin: c.origin } : {},
|
|
537
|
+
...c.purpose !== void 0 ? { purpose: c.purpose } : {}
|
|
538
|
+
};
|
|
539
|
+
}
|
|
540
|
+
/** Group extend `added` members by interface into permissions scoped to `serviceId`. */
|
|
541
|
+
function extendPermissions(serviceId, added) {
|
|
542
|
+
const byIface = /* @__PURE__ */ new Map();
|
|
543
|
+
for (const a of added) {
|
|
544
|
+
const members = byIface.get(a.interfaceId) ?? [];
|
|
545
|
+
members.push(a.member);
|
|
546
|
+
byIface.set(a.interfaceId, members);
|
|
547
|
+
}
|
|
548
|
+
return [...byIface.entries()].map(([interfaceId, members]) => ({
|
|
549
|
+
target: {
|
|
550
|
+
serviceId: { exact: serviceId },
|
|
551
|
+
interfaceId: { exact: interfaceId },
|
|
552
|
+
members
|
|
553
|
+
},
|
|
554
|
+
canInvoke: true
|
|
555
|
+
}));
|
|
556
|
+
}
|
|
557
|
+
/**
|
|
558
|
+
* Serve `hubAccessManifest::*` on `connection`, mounted under `serviceId` (the
|
|
559
|
+
* hub's own service prefix). Reachable only through the forwarded-call gate, so
|
|
560
|
+
* authorization (an accepted-root capability) is enforced upstream — no gate is
|
|
561
|
+
* applied here.
|
|
562
|
+
*
|
|
563
|
+
* `getCurrent`/`watchCurrent` are vestigial for the hub broker: granted caps are
|
|
564
|
+
* delivered as the blocked `hubAccess` call's result, not read back through
|
|
565
|
+
* `current`. They are served (empty) to satisfy the interface.
|
|
566
|
+
*/
|
|
567
|
+
function registerHubAccessManifest(connection, host, options) {
|
|
568
|
+
connection.register(hubAccessManifestInterface, {
|
|
569
|
+
getDesired: () => {
|
|
570
|
+
const { entries, acceptableRootIds, revision } = host.getDesired();
|
|
571
|
+
const requested = {};
|
|
572
|
+
const roots = acceptableRootIds.length > 0 ? [...acceptableRootIds] : void 0;
|
|
573
|
+
for (const e of entries) {
|
|
574
|
+
const consumer = {
|
|
575
|
+
...e.consumer,
|
|
576
|
+
principal: e.consumerPrincipalId
|
|
577
|
+
};
|
|
578
|
+
const duration = e.duration;
|
|
579
|
+
const originFields = e.origin !== void 0 ? { origin: e.origin } : {};
|
|
580
|
+
if (e.kind === "direct") requested[e.requestId] = {
|
|
581
|
+
kind: "direct",
|
|
582
|
+
consumer,
|
|
583
|
+
permissions: e.permissions,
|
|
584
|
+
...duration !== void 0 ? { duration } : {},
|
|
585
|
+
...roots !== void 0 ? { acceptableRootIds: roots } : {},
|
|
586
|
+
...originFields
|
|
587
|
+
};
|
|
588
|
+
else requested[e.requestId] = {
|
|
589
|
+
kind: "discover",
|
|
590
|
+
consumer,
|
|
591
|
+
interfaces: e.interfaces.map((i) => ({
|
|
592
|
+
id: i.id,
|
|
593
|
+
...i.hash !== void 0 ? { hash: i.hash } : {},
|
|
594
|
+
...i.required !== void 0 ? { required: i.required } : {}
|
|
595
|
+
})),
|
|
596
|
+
members: e.members.map((m) => ({
|
|
597
|
+
interfaceId: m.interfaceId,
|
|
598
|
+
member: m.member,
|
|
599
|
+
...m.required !== void 0 ? { required: m.required } : {}
|
|
600
|
+
})),
|
|
601
|
+
...duration !== void 0 ? { duration } : {},
|
|
602
|
+
...roots !== void 0 ? { acceptableRootIds: roots } : {},
|
|
603
|
+
...originFields
|
|
604
|
+
};
|
|
605
|
+
}
|
|
606
|
+
return {
|
|
607
|
+
requested,
|
|
608
|
+
revision
|
|
609
|
+
};
|
|
610
|
+
},
|
|
611
|
+
watchDesired: (_params, _ctx, stream) => new Promise((resolve) => {
|
|
612
|
+
if (stream.signal.aborted) {
|
|
613
|
+
resolve({});
|
|
614
|
+
return;
|
|
615
|
+
}
|
|
616
|
+
const unsubscribe = host.onDidChange(() => {
|
|
617
|
+
if (stream.signal.aborted) return;
|
|
618
|
+
stream.send({});
|
|
619
|
+
});
|
|
620
|
+
stream.signal.addEventListener("abort", () => {
|
|
621
|
+
unsubscribe();
|
|
622
|
+
resolve({});
|
|
623
|
+
}, { once: true });
|
|
624
|
+
}),
|
|
625
|
+
getCurrent: () => ({
|
|
626
|
+
current: {},
|
|
627
|
+
revision: 0
|
|
628
|
+
}),
|
|
629
|
+
setCurrent: (params) => {
|
|
630
|
+
const unknown = [];
|
|
631
|
+
for (const patch of params.patches) {
|
|
632
|
+
if (patch.op !== "set") continue;
|
|
633
|
+
for (const [requestId, value] of currentEntriesOfPatch(patch.path, patch.value)) if (!host.resolve(requestId, currentEntryToDecision(value))) unknown.push(requestId);
|
|
634
|
+
}
|
|
635
|
+
if (unknown.length > 0) throw new Error(`manifest entries are no longer pending: ${unknown.join(", ")}`);
|
|
636
|
+
return { revision: 0 };
|
|
637
|
+
},
|
|
638
|
+
watchCurrent: (_params, _ctx, stream) => new Promise((resolve) => {
|
|
639
|
+
stream.signal.addEventListener("abort", () => resolve({}), { once: true });
|
|
640
|
+
})
|
|
641
|
+
}, { serviceId: options.serviceId });
|
|
642
|
+
}
|
|
643
|
+
function currentEntryToDecision(value) {
|
|
644
|
+
if (value.status !== "granted") return value.reason !== void 0 ? {
|
|
645
|
+
grant: false,
|
|
646
|
+
reason: value.reason
|
|
647
|
+
} : { grant: false };
|
|
648
|
+
if (value.granted.kind === "discover") return {
|
|
649
|
+
grant: true,
|
|
650
|
+
capabilities: value.granted.capabilities,
|
|
651
|
+
resolvedSlot: {
|
|
652
|
+
serviceId: value.granted.serviceId,
|
|
653
|
+
satisfiedInterfaces: value.granted.satisfiedInterfaces
|
|
654
|
+
}
|
|
655
|
+
};
|
|
656
|
+
return {
|
|
657
|
+
grant: true,
|
|
658
|
+
capabilities: value.granted.capabilities
|
|
659
|
+
};
|
|
660
|
+
}
|
|
661
|
+
/**
|
|
662
|
+
* Yield `[entryId, currentEntry]` pairs a `setCurrent` patch targets. Supports
|
|
663
|
+
* whole-document (`''` → `{ current: {...} }`), the `current` map
|
|
664
|
+
* (`/current` → `{...}`), and a single entry (`/current/<id>`).
|
|
665
|
+
*/
|
|
666
|
+
function* currentEntriesOfPatch(path, value) {
|
|
667
|
+
const segments = path === "" ? [] : path.split("/").slice(1).map(unescapePointer);
|
|
668
|
+
if (segments.length === 0) {
|
|
669
|
+
const current = value.current ?? {};
|
|
670
|
+
yield* Object.entries(current);
|
|
671
|
+
return;
|
|
672
|
+
}
|
|
673
|
+
if (segments[0] !== "current") return;
|
|
674
|
+
if (segments.length === 1) {
|
|
675
|
+
yield* Object.entries(value ?? {});
|
|
676
|
+
return;
|
|
677
|
+
}
|
|
678
|
+
if (segments.length === 2) yield [segments[1], value];
|
|
679
|
+
}
|
|
680
|
+
function unescapePointer(segment) {
|
|
681
|
+
return segment.replace(/~1/g, "/").replace(/~0/g, "~");
|
|
682
|
+
}
|
|
683
|
+
//#endregion
|
|
684
|
+
//#region src/engine/approveClient.ts
|
|
685
|
+
/**
|
|
686
|
+
* Authoritative, typed frontend for a `hubAccessManifest`.
|
|
687
|
+
*
|
|
688
|
+
* Consumers render {@link requests}; coarse watch ticks are reconciled through
|
|
689
|
+
* one serialized full-snapshot loop, so older reads cannot overwrite newer
|
|
690
|
+
* state. A completed watch is re-opened and a failed read keeps the last known
|
|
691
|
+
* snapshot while {@link state} reports `stale`.
|
|
692
|
+
*/
|
|
693
|
+
var ApproveClient = class {
|
|
694
|
+
_requests;
|
|
695
|
+
requests;
|
|
696
|
+
_state;
|
|
697
|
+
state;
|
|
698
|
+
_manifest;
|
|
699
|
+
_ownPrincipalId;
|
|
700
|
+
_log;
|
|
701
|
+
_reconnectDelayMs;
|
|
702
|
+
_timeoutMs;
|
|
703
|
+
_watch;
|
|
704
|
+
_watchGeneration = 0;
|
|
705
|
+
_retryTimer;
|
|
706
|
+
_refreshPending = false;
|
|
707
|
+
_refreshPromise;
|
|
708
|
+
_disposed = false;
|
|
709
|
+
constructor(options) {
|
|
710
|
+
this._manifest = options.manifest;
|
|
711
|
+
this._ownPrincipalId = options.ownPrincipalId;
|
|
712
|
+
this._log = options.log ?? (() => {});
|
|
713
|
+
this._reconnectDelayMs = options.reconnectDelayMs ?? 250;
|
|
714
|
+
this._timeoutMs = options.timeoutMs ?? DEFAULT_RPC_TIMEOUT_MS;
|
|
715
|
+
this._requests = observableValue(this, []);
|
|
716
|
+
this.requests = this._requests;
|
|
717
|
+
this._state = observableValue(this, "connecting");
|
|
718
|
+
this.state = this._state;
|
|
719
|
+
this._openWatch();
|
|
720
|
+
this.refresh();
|
|
721
|
+
}
|
|
722
|
+
/** Reconcile the observable with the latest complete desired snapshot. */
|
|
723
|
+
refresh() {
|
|
724
|
+
if (this._disposed) return Promise.resolve();
|
|
725
|
+
this._refreshPending = true;
|
|
726
|
+
if (!this._refreshPromise) this._refreshPromise = this._drainRefreshes().finally(() => {
|
|
727
|
+
this._refreshPromise = void 0;
|
|
728
|
+
});
|
|
729
|
+
return this._refreshPromise.then(() => {
|
|
730
|
+
if (this._refreshPending && !this._disposed) return this.refresh();
|
|
731
|
+
});
|
|
732
|
+
}
|
|
733
|
+
/**
|
|
734
|
+
* Write a decision only for a request in the current snapshot, then reconcile
|
|
735
|
+
* with the next authoritative snapshot. A successful write can still leave a
|
|
736
|
+
* matching id pending when a federated snapshot lags or the consumer retries;
|
|
737
|
+
* callers receive `still-pending` so they can report that state honestly.
|
|
738
|
+
*/
|
|
739
|
+
async decide(id, decision) {
|
|
740
|
+
if (!this._requests.get().some((request) => request.id === id)) return "gone";
|
|
741
|
+
await withRpcTimeout(this._manifest.setCurrent({ patches: [{
|
|
742
|
+
op: "set",
|
|
743
|
+
path: `/current/${escapePointer(id)}`,
|
|
744
|
+
value: decision
|
|
745
|
+
}] }), "hubAccessManifest::setCurrent", this._timeoutMs);
|
|
746
|
+
await this.refresh();
|
|
747
|
+
if (this._requests.get().some((request) => request.id === id)) {
|
|
748
|
+
this._log(`hubAccess approver: decision accepted for '${safeId(id)}', but a matching request is still pending`);
|
|
749
|
+
return "still-pending";
|
|
750
|
+
}
|
|
751
|
+
return "applied";
|
|
752
|
+
}
|
|
753
|
+
dispose() {
|
|
754
|
+
if (this._disposed) return;
|
|
755
|
+
this._disposed = true;
|
|
756
|
+
this._watchGeneration++;
|
|
757
|
+
if (this._retryTimer !== void 0) {
|
|
758
|
+
clearTimeout(this._retryTimer);
|
|
759
|
+
this._retryTimer = void 0;
|
|
760
|
+
}
|
|
761
|
+
const watch = this._watch;
|
|
762
|
+
this._watch = void 0;
|
|
763
|
+
if (watch) watch.cancel("approve client disposed").catch(() => {});
|
|
764
|
+
}
|
|
765
|
+
async _drainRefreshes() {
|
|
766
|
+
while (this._refreshPending && !this._disposed) {
|
|
767
|
+
this._refreshPending = false;
|
|
768
|
+
try {
|
|
769
|
+
const snapshot = await withRpcTimeout(this._manifest.getDesired({}), "hubAccessManifest::getDesired", this._timeoutMs);
|
|
770
|
+
if (this._disposed) return;
|
|
771
|
+
const requests = Object.entries(snapshot.requested).filter(([, request]) => rootAccepted(request.acceptableRootIds, this._ownPrincipalId)).map(([id, request]) => ({
|
|
772
|
+
id,
|
|
773
|
+
request,
|
|
774
|
+
revision: snapshot.revision
|
|
775
|
+
}));
|
|
776
|
+
this._requests.set(requests, void 0);
|
|
777
|
+
this._state.set("live", void 0);
|
|
778
|
+
} catch (error) {
|
|
779
|
+
if (this._disposed) return;
|
|
780
|
+
this._state.set("stale", void 0);
|
|
781
|
+
this._log(`hubAccess approver: getDesired failed: ${errorMessage(error)}`);
|
|
782
|
+
this._scheduleReconnect();
|
|
783
|
+
}
|
|
784
|
+
}
|
|
785
|
+
}
|
|
786
|
+
_openWatch() {
|
|
787
|
+
if (this._disposed || this._watch !== void 0) return;
|
|
788
|
+
const generation = ++this._watchGeneration;
|
|
789
|
+
try {
|
|
790
|
+
const watch = this._manifest.watchDesired({}, { onMessage: () => {
|
|
791
|
+
this.refresh();
|
|
792
|
+
} });
|
|
793
|
+
this._watch = watch;
|
|
794
|
+
watch.then(() => this._watchEnded(generation, void 0), (error) => this._watchEnded(generation, error));
|
|
795
|
+
} catch (error) {
|
|
796
|
+
this._log(`hubAccess approver: watchDesired failed: ${errorMessage(error)}`);
|
|
797
|
+
this._scheduleReconnect();
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
_watchEnded(generation, error) {
|
|
801
|
+
if (this._disposed || generation !== this._watchGeneration) return;
|
|
802
|
+
this._watch = void 0;
|
|
803
|
+
this._state.set("stale", void 0);
|
|
804
|
+
if (error !== void 0) this._log(`hubAccess approver: watchDesired ended: ${errorMessage(error)}`);
|
|
805
|
+
this._scheduleReconnect();
|
|
806
|
+
}
|
|
807
|
+
_scheduleReconnect() {
|
|
808
|
+
if (this._disposed || this._retryTimer !== void 0) return;
|
|
809
|
+
this._retryTimer = setTimeout(() => {
|
|
810
|
+
this._retryTimer = void 0;
|
|
811
|
+
this._openWatch();
|
|
812
|
+
this.refresh();
|
|
813
|
+
}, this._reconnectDelayMs);
|
|
814
|
+
}
|
|
815
|
+
};
|
|
816
|
+
function rootAccepted(acceptableRootIds, ownPrincipalId) {
|
|
817
|
+
if (acceptableRootIds === void 0) return true;
|
|
818
|
+
if (acceptableRootIds.length === 0 || ownPrincipalId === void 0) return false;
|
|
819
|
+
return acceptableRootIds.includes(ownPrincipalId);
|
|
820
|
+
}
|
|
821
|
+
function escapePointer(segment) {
|
|
822
|
+
return segment.replace(/~/g, "~0").replace(/\//g, "~1");
|
|
823
|
+
}
|
|
824
|
+
function safeId(id) {
|
|
825
|
+
return id.replace(/\u0000/g, "\\0");
|
|
826
|
+
}
|
|
827
|
+
function errorMessage(error) {
|
|
828
|
+
return error instanceof Error ? error.message : String(error);
|
|
829
|
+
}
|
|
830
|
+
//#endregion
|
|
831
|
+
//#region src/engine/manifestApprover.ts
|
|
832
|
+
/**
|
|
833
|
+
* Start consuming the manifest at `options.serviceId` over `options.connection`.
|
|
834
|
+
* Runs until {@link ManifestApprover.dispose}.
|
|
835
|
+
*/
|
|
836
|
+
function runManifestApprover(options) {
|
|
837
|
+
const prompt = options.prompt ?? createTerminalConsentPrompt();
|
|
838
|
+
const rawLog = options.log ?? ((line) => process.stderr.write(line + "\n"));
|
|
839
|
+
const log = (line) => {
|
|
840
|
+
try {
|
|
841
|
+
rawLog(line);
|
|
842
|
+
} catch {}
|
|
843
|
+
};
|
|
844
|
+
const ownPrincipalId = options.ownPrincipalId ?? options.issuer.issuerPrincipalId;
|
|
845
|
+
/** entryIds currently being prompted, with their abort handle. */
|
|
846
|
+
const inFlight = /* @__PURE__ */ new Map();
|
|
847
|
+
let disposed = false;
|
|
848
|
+
let tail = Promise.resolve();
|
|
849
|
+
const client = new ApproveClient({
|
|
850
|
+
manifest: options.manifest,
|
|
851
|
+
ownPrincipalId,
|
|
852
|
+
log
|
|
853
|
+
});
|
|
854
|
+
/** Resolve an entry to permissions (+ chosen service for discover). */
|
|
855
|
+
const resolveEntry = async (entry) => {
|
|
856
|
+
if (entry.kind === "direct") return { permissions: entry.permissions };
|
|
857
|
+
if (options.fetchDirectory === void 0) return { denied: "no directory available to resolve discover candidates" };
|
|
858
|
+
const directory = await options.fetchDirectory();
|
|
859
|
+
const slot = {
|
|
860
|
+
interfaces: entry.interfaces.map((i) => ({
|
|
861
|
+
id: i.id,
|
|
862
|
+
required: i.required !== false,
|
|
863
|
+
...i.hash !== void 0 ? { hash: i.hash } : {}
|
|
864
|
+
})),
|
|
865
|
+
members: entry.members.map((m) => ({
|
|
866
|
+
interfaceId: m.interfaceId,
|
|
867
|
+
member: m.member,
|
|
868
|
+
required: m.required !== false
|
|
869
|
+
}))
|
|
870
|
+
};
|
|
871
|
+
const candidate = candidatesForSlot(slot, directory)[0];
|
|
872
|
+
if (!candidate) return { denied: "no candidate service satisfies the requested interfaces" };
|
|
873
|
+
const present = new Set(candidate.satisfiedInterfaces.map((i) => i.id));
|
|
874
|
+
return {
|
|
875
|
+
permissions: buildSlotPermissions(candidate.serviceId, slot.interfaces, slot.members, present),
|
|
876
|
+
resolvedSlot: {
|
|
877
|
+
serviceId: candidate.serviceId,
|
|
878
|
+
satisfiedInterfaces: candidate.satisfiedInterfaces.map((i) => i.id)
|
|
879
|
+
}
|
|
880
|
+
};
|
|
881
|
+
};
|
|
882
|
+
const mint = async (consumerPrincipalId, permissions, duration) => {
|
|
883
|
+
const signed = await toSignedPermissions(permissions, consumerPrincipalId, duration);
|
|
884
|
+
return options.issuer.mint({
|
|
885
|
+
audience: consumerPrincipalId,
|
|
886
|
+
permissions: signed,
|
|
887
|
+
expiresAtMs: durationToExp(duration)
|
|
888
|
+
});
|
|
889
|
+
};
|
|
890
|
+
const processEntry = (entryId, entry, ac) => async () => {
|
|
891
|
+
if (ac.signal.aborted) {
|
|
892
|
+
inFlight.delete(entryId);
|
|
893
|
+
return;
|
|
894
|
+
}
|
|
895
|
+
try {
|
|
896
|
+
const resolved = await resolveEntry(entry);
|
|
897
|
+
if ("denied" in resolved) {
|
|
898
|
+
await client.decide(entryId, {
|
|
899
|
+
status: "denied",
|
|
900
|
+
reason: resolved.denied
|
|
901
|
+
});
|
|
902
|
+
return;
|
|
903
|
+
}
|
|
904
|
+
if (ac.signal.aborted) return;
|
|
905
|
+
const decision = await prompt({
|
|
906
|
+
requestId: entryId,
|
|
907
|
+
kind: entry.kind === "discover" ? "request" : "requestAccess",
|
|
908
|
+
consumer: entry.consumer,
|
|
909
|
+
consumerPrincipalId: entry.consumer.principal,
|
|
910
|
+
permissions: resolved.permissions,
|
|
911
|
+
grants: describePermissions(resolved.permissions),
|
|
912
|
+
duration: entry.duration,
|
|
913
|
+
signal: ac.signal
|
|
914
|
+
});
|
|
915
|
+
if (ac.signal.aborted) return;
|
|
916
|
+
if (!decision.grant) {
|
|
917
|
+
await client.decide(entryId, decision.reason !== void 0 ? {
|
|
918
|
+
status: "denied",
|
|
919
|
+
reason: decision.reason
|
|
920
|
+
} : { status: "denied" });
|
|
921
|
+
return;
|
|
922
|
+
}
|
|
923
|
+
const capabilities = decision.capabilities !== void 0 && decision.capabilities.length > 0 ? [...decision.capabilities] : [await mint(entry.consumer.principal, resolved.permissions, entry.duration)];
|
|
924
|
+
const granted = resolved.resolvedSlot !== void 0 ? {
|
|
925
|
+
kind: "discover",
|
|
926
|
+
serviceId: resolved.resolvedSlot.serviceId,
|
|
927
|
+
satisfiedInterfaces: [...resolved.resolvedSlot.satisfiedInterfaces],
|
|
928
|
+
capabilities
|
|
929
|
+
} : {
|
|
930
|
+
kind: "direct",
|
|
931
|
+
capabilities
|
|
932
|
+
};
|
|
933
|
+
await client.decide(entryId, {
|
|
934
|
+
status: "granted",
|
|
935
|
+
granted
|
|
936
|
+
});
|
|
937
|
+
} catch (e) {
|
|
938
|
+
log(`hubAccess approver: failed to decide ${entryId}: ${e.message}`);
|
|
939
|
+
} finally {
|
|
940
|
+
inFlight.delete(entryId);
|
|
941
|
+
}
|
|
942
|
+
};
|
|
943
|
+
const subscription = autorun((reader) => {
|
|
944
|
+
const requests = client.requests.read(reader);
|
|
945
|
+
const present = new Set(requests.map((entry) => entry.id));
|
|
946
|
+
for (const [entryId, ac] of inFlight) if (!present.has(entryId)) ac.abort();
|
|
947
|
+
for (const { id: entryId, request: entry } of requests) {
|
|
948
|
+
if (inFlight.has(entryId)) continue;
|
|
949
|
+
const ac = new AbortController();
|
|
950
|
+
inFlight.set(entryId, ac);
|
|
951
|
+
tail = tail.then(processEntry(entryId, entry, ac));
|
|
952
|
+
}
|
|
953
|
+
});
|
|
954
|
+
return { dispose: () => {
|
|
955
|
+
if (disposed) return;
|
|
956
|
+
disposed = true;
|
|
957
|
+
for (const ac of inFlight.values()) ac.abort();
|
|
958
|
+
subscription.dispose();
|
|
959
|
+
client.dispose();
|
|
960
|
+
} };
|
|
961
|
+
}
|
|
962
|
+
//#endregion
|
|
963
|
+
//#region src/engine/cmdEndpoint.ts
|
|
964
|
+
/**
|
|
965
|
+
* Short label for a transport, used to prefix its console log lines.
|
|
966
|
+
* Preference order: an explicit name (the `namedEndpoints` key) → the command
|
|
967
|
+
* basename (`cmd` / `argv[0]`) → the URI host / socket basename → the kind.
|
|
968
|
+
*/
|
|
969
|
+
function endpointLabel(ep, nameKey) {
|
|
970
|
+
if (nameKey !== void 0 && nameKey.length > 0) return nameKey;
|
|
971
|
+
switch (ep.kind) {
|
|
972
|
+
case "cmd-env":
|
|
973
|
+
case "cmd-stdio": {
|
|
974
|
+
const first = ep.cmd ?? ep.argv?.[0];
|
|
975
|
+
if (first) return path.basename(first.split(/\s+/)[0] ?? first);
|
|
976
|
+
return ep.kind;
|
|
977
|
+
}
|
|
978
|
+
case "uri": return _hostish(ep.uri);
|
|
979
|
+
case "ws": return _hostish(ep.url);
|
|
980
|
+
case "socket": return path.basename(ep.path) || "socket";
|
|
981
|
+
}
|
|
982
|
+
}
|
|
983
|
+
function endpointCommand(ep) {
|
|
984
|
+
if (ep.argv !== void 0 && ep.argv.length > 0) return { argv: ep.argv };
|
|
985
|
+
if (ep.cmd !== void 0) return { command: ep.cmd };
|
|
986
|
+
throw new Error("cmd endpoint requires either `cmd` or `argv`");
|
|
987
|
+
}
|
|
988
|
+
function killChild(child) {
|
|
989
|
+
if (!child.killed) child.kill();
|
|
990
|
+
}
|
|
991
|
+
function _hostish(uri) {
|
|
992
|
+
try {
|
|
993
|
+
return new URL(uri).host || uri;
|
|
994
|
+
} catch {
|
|
995
|
+
return uri;
|
|
996
|
+
}
|
|
997
|
+
}
|
|
998
|
+
/**
|
|
999
|
+
* Pipe a child's stdout/stderr to `log`, one prefixed line at a time. Pass
|
|
1000
|
+
* `includeStdout: false` for `cmd-stdio`, whose stdout carries the RPC stream
|
|
1001
|
+
* (only stderr is human output there).
|
|
1002
|
+
*/
|
|
1003
|
+
function pipeChildLogs(child, label, log, opts) {
|
|
1004
|
+
const emit = (chunk) => {
|
|
1005
|
+
for (const line of chunk.split(/\r?\n/)) if (line.length > 0) log(`[${label}] ${line}`);
|
|
1006
|
+
};
|
|
1007
|
+
if (opts.includeStdout && child.stdout) {
|
|
1008
|
+
child.stdout.setEncoding("utf8");
|
|
1009
|
+
child.stdout.on("data", emit);
|
|
1010
|
+
}
|
|
1011
|
+
if (child.stderr) {
|
|
1012
|
+
child.stderr.setEncoding("utf8");
|
|
1013
|
+
child.stderr.on("data", emit);
|
|
1014
|
+
}
|
|
1015
|
+
}
|
|
1016
|
+
/**
|
|
1017
|
+
* Forward this process's stdin to a child's stdin (for `--cmd-interactive`).
|
|
1018
|
+
* Returns a teardown that detaches the listener and restores the tty.
|
|
1019
|
+
*/
|
|
1020
|
+
function forwardStdin(child) {
|
|
1021
|
+
const stdin = process.stdin;
|
|
1022
|
+
if (!child.stdin) return () => {};
|
|
1023
|
+
const onData = (chunk) => {
|
|
1024
|
+
child.stdin?.write(chunk);
|
|
1025
|
+
};
|
|
1026
|
+
stdin.resume();
|
|
1027
|
+
stdin.on("data", onData);
|
|
1028
|
+
return () => {
|
|
1029
|
+
stdin.off("data", onData);
|
|
1030
|
+
stdin.pause();
|
|
1031
|
+
};
|
|
1032
|
+
}
|
|
1033
|
+
//#endregion
|
|
1034
|
+
//#region src/engine/routeTable.ts
|
|
1035
|
+
/**
|
|
1036
|
+
* Static overlap guard for {@link module:./runHub}. The hub's forwarding table
|
|
1037
|
+
* silently overwrites a duplicate prefix; this catches a misconfigured hub at
|
|
1038
|
+
* startup instead — two transports claiming the same service id, or two
|
|
1039
|
+
* default routes — with a message naming both owners.
|
|
1040
|
+
*
|
|
1041
|
+
* Only *statically configured* routes are tracked here. Service ids that peers
|
|
1042
|
+
* claim dynamically through a listener are authorized by the hub's claim
|
|
1043
|
+
* policy, not this table.
|
|
1044
|
+
*/
|
|
1045
|
+
var RouteTable = class {
|
|
1046
|
+
_claimed = /* @__PURE__ */ new Map();
|
|
1047
|
+
_defaultOwner;
|
|
1048
|
+
/** Record that `owner` routes `serviceId`. Throws on a conflicting claim. */
|
|
1049
|
+
claim(serviceId, owner) {
|
|
1050
|
+
const existing = this._claimed.get(serviceId);
|
|
1051
|
+
if (existing !== void 0 && existing !== owner) throw new Error(`route conflict: service id '${serviceId}' is claimed by both '${existing}' and '${owner}'`);
|
|
1052
|
+
this._claimed.set(serviceId, owner);
|
|
1053
|
+
}
|
|
1054
|
+
/** Record `owner` as the single default route. Throws on a second one. */
|
|
1055
|
+
setDefault(owner) {
|
|
1056
|
+
if (this._defaultOwner !== void 0 && this._defaultOwner !== owner) throw new Error(`default-route conflict: both '${this._defaultOwner}' and '${owner}' set defaultRoute; at most one is allowed`);
|
|
1057
|
+
this._defaultOwner = owner;
|
|
1058
|
+
}
|
|
1059
|
+
};
|
|
1060
|
+
//#endregion
|
|
1061
|
+
//#region src/engine/configuredParticipants.ts
|
|
1062
|
+
const REGISTER_METHOD = `${hubGrantedServiceIdInterface.info.id}::register`;
|
|
1063
|
+
const INITIAL_BACKOFF_MS = 200;
|
|
1064
|
+
const MAX_BACKOFF_MS = 5e3;
|
|
1065
|
+
/**
|
|
1066
|
+
* Attach settings/config-defined outbound participants to an existing Hub.
|
|
1067
|
+
*
|
|
1068
|
+
* Each participant is supervised independently: it connects in the background,
|
|
1069
|
+
* claims its configured service ids on the far end, installs its local routes,
|
|
1070
|
+
* and reconnects with exponential backoff after a disconnect.
|
|
1071
|
+
*/
|
|
1072
|
+
function configureParticipants(options) {
|
|
1073
|
+
const log = options.log ?? ((line) => process.stderr.write(line + "\n"));
|
|
1074
|
+
const disposers = [];
|
|
1075
|
+
const interactiveCommands = options.participants.filter((participant) => participant.config.kind === "cmd-stdio").length;
|
|
1076
|
+
if (options.cmdInteractive && interactiveCommands !== 1) throw new Error(`cmdInteractive requires exactly one cmd-stdio participant, found ${interactiveCommands}`);
|
|
1077
|
+
validateConfiguredParticipants(options.participants);
|
|
1078
|
+
for (const participant of options.participants) disposers.push(_startParticipant(participant, options.attachParticipant, log, options.cmdInteractive === true));
|
|
1079
|
+
let disposed = false;
|
|
1080
|
+
return { dispose: () => {
|
|
1081
|
+
if (disposed) return;
|
|
1082
|
+
disposed = true;
|
|
1083
|
+
for (const dispose of disposers.reverse()) dispose();
|
|
1084
|
+
} };
|
|
1085
|
+
}
|
|
1086
|
+
function validateConfiguredParticipants(participants) {
|
|
1087
|
+
const routes = new RouteTable();
|
|
1088
|
+
for (const participant of participants) _validateParticipant(participant, routes);
|
|
1089
|
+
}
|
|
1090
|
+
function _validateParticipant(participant, routes) {
|
|
1091
|
+
const { config, name } = participant;
|
|
1092
|
+
if (config.connectionTokenBinder !== void 0) throw new Error(`participant '${name}': connectionTokenBinder is only supported on cmd-env endpoints`);
|
|
1093
|
+
for (const serviceId of config.routeServiceIds) routes.claim(serviceId, name);
|
|
1094
|
+
if (config.defaultRoute) routes.setDefault(name);
|
|
1095
|
+
}
|
|
1096
|
+
function _startParticipant(participant, attachParticipant, log, interactive) {
|
|
1097
|
+
const { config, name } = participant;
|
|
1098
|
+
const abort = new AbortController();
|
|
1099
|
+
let current;
|
|
1100
|
+
let connectingAttachment;
|
|
1101
|
+
let stopped = false;
|
|
1102
|
+
const dispose = () => {
|
|
1103
|
+
if (stopped) return;
|
|
1104
|
+
stopped = true;
|
|
1105
|
+
abort.abort();
|
|
1106
|
+
connectingAttachment?.dispose();
|
|
1107
|
+
connectingAttachment = void 0;
|
|
1108
|
+
current?.attachment.dispose();
|
|
1109
|
+
current?.transport.dispose();
|
|
1110
|
+
current = void 0;
|
|
1111
|
+
};
|
|
1112
|
+
(async () => {
|
|
1113
|
+
let backoff = INITIAL_BACKOFF_MS;
|
|
1114
|
+
let delayBeforeAttempt = 0;
|
|
1115
|
+
let hasConnected = false;
|
|
1116
|
+
while (!stopped) {
|
|
1117
|
+
if (delayBeforeAttempt > 0 && !await _delayUnlessAborted(delayBeforeAttempt, abort.signal)) return;
|
|
1118
|
+
try {
|
|
1119
|
+
const connected = await _connectParticipant(participant, attachParticipant, log, interactive, abort.signal, (attachment) => {
|
|
1120
|
+
connectingAttachment = attachment;
|
|
1121
|
+
});
|
|
1122
|
+
if (stopped) {
|
|
1123
|
+
connected.attachment.dispose();
|
|
1124
|
+
connected.transport.dispose();
|
|
1125
|
+
return;
|
|
1126
|
+
}
|
|
1127
|
+
connectingAttachment = void 0;
|
|
1128
|
+
current = connected;
|
|
1129
|
+
log(`[${name}] ${hasConnected ? "reconnected" : "connected"}`);
|
|
1130
|
+
hasConnected = true;
|
|
1131
|
+
backoff = INITIAL_BACKOFF_MS;
|
|
1132
|
+
await connected.closed;
|
|
1133
|
+
connected.attachment.dispose();
|
|
1134
|
+
connected.transport.dispose();
|
|
1135
|
+
if (current === connected) current = void 0;
|
|
1136
|
+
if (stopped) return;
|
|
1137
|
+
delayBeforeAttempt = backoff;
|
|
1138
|
+
log(`[${name}] disconnected; reconnecting in ${delayBeforeAttempt} ms`);
|
|
1139
|
+
} catch (error) {
|
|
1140
|
+
connectingAttachment = void 0;
|
|
1141
|
+
if (stopped) return;
|
|
1142
|
+
delayBeforeAttempt = backoff;
|
|
1143
|
+
log(`[${name}] connection failed: ${_errorMessage(error)}; retrying in ${delayBeforeAttempt} ms`);
|
|
1144
|
+
backoff = Math.min(MAX_BACKOFF_MS, backoff * 2);
|
|
1145
|
+
}
|
|
1146
|
+
}
|
|
1147
|
+
})().catch((error) => {
|
|
1148
|
+
if (!stopped) log(`[${name}] reconnect loop failed: ${_errorMessage(error)}`);
|
|
1149
|
+
});
|
|
1150
|
+
return dispose;
|
|
1151
|
+
}
|
|
1152
|
+
async function _connectParticipant(participant, attachParticipant, log, interactive, signal, onAttached) {
|
|
1153
|
+
const { config, name } = participant;
|
|
1154
|
+
const opened = await _openParticipant(config, name, log, interactive);
|
|
1155
|
+
if (signal.aborted) {
|
|
1156
|
+
opened.transport.dispose();
|
|
1157
|
+
throw new Error(`participant '${name}' stopped`);
|
|
1158
|
+
}
|
|
1159
|
+
const onAbort = () => opened.transport.dispose();
|
|
1160
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
1161
|
+
let attachment;
|
|
1162
|
+
try {
|
|
1163
|
+
attachment = attachParticipant(opened.transport);
|
|
1164
|
+
onAttached(attachment);
|
|
1165
|
+
for (const serviceId of config.claimServiceIds) await _registerOnRemote(attachment, serviceId);
|
|
1166
|
+
for (const serviceId of config.routeServiceIds) attachment.claimPrefix(serviceId);
|
|
1167
|
+
if (config.defaultRoute) attachment.setDefaultRoute();
|
|
1168
|
+
return {
|
|
1169
|
+
...opened,
|
|
1170
|
+
attachment
|
|
1171
|
+
};
|
|
1172
|
+
} catch (error) {
|
|
1173
|
+
attachment?.dispose();
|
|
1174
|
+
opened.transport.dispose();
|
|
1175
|
+
throw new Error(`participant '${name}' registration failed: ${_errorMessage(error)}`, { cause: error });
|
|
1176
|
+
} finally {
|
|
1177
|
+
onAttached(void 0);
|
|
1178
|
+
signal.removeEventListener("abort", onAbort);
|
|
1179
|
+
}
|
|
1180
|
+
}
|
|
1181
|
+
async function _openParticipant(config, name, log, interactive) {
|
|
1182
|
+
switch (config.kind) {
|
|
1183
|
+
case "ws": return _openWsTransport(config.url, config.token);
|
|
1184
|
+
case "socket": return _openSocketTransport(config.path, config.token);
|
|
1185
|
+
case "uri": {
|
|
1186
|
+
const resolved = parseEndpointUri(config.uri);
|
|
1187
|
+
if (resolved.kind === "ws") return _openWsTransport(resolved.url, config.token ?? resolved.token);
|
|
1188
|
+
if (resolved.kind === "socket") return _openSocketTransport(resolved.path, config.token ?? resolved.token);
|
|
1189
|
+
throw new Error(`participant '${name}': uri resolves to '${resolved.kind}'; use kind 'cmd-stdio' instead`);
|
|
1190
|
+
}
|
|
1191
|
+
case "cmd-stdio": {
|
|
1192
|
+
const closeSignal = _closeSignal();
|
|
1193
|
+
const child = spawnCommand(endpointCommand(config), {
|
|
1194
|
+
stdio: [
|
|
1195
|
+
"pipe",
|
|
1196
|
+
"pipe",
|
|
1197
|
+
"pipe"
|
|
1198
|
+
],
|
|
1199
|
+
env: {
|
|
1200
|
+
...process.env,
|
|
1201
|
+
...config.env
|
|
1202
|
+
},
|
|
1203
|
+
...config.cwd !== void 0 ? { cwd: config.cwd } : {}
|
|
1204
|
+
});
|
|
1205
|
+
child.once("error", closeSignal.close);
|
|
1206
|
+
child.once("exit", closeSignal.close);
|
|
1207
|
+
if (!child.stdin || !child.stdout) {
|
|
1208
|
+
killChild(child);
|
|
1209
|
+
throw new Error(`participant '${name}': child exposes no stdio`);
|
|
1210
|
+
}
|
|
1211
|
+
pipeChildLogs(child, name, log, { includeStdout: false });
|
|
1212
|
+
const stopForwarding = interactive ? forwardStdin(child) : () => {};
|
|
1213
|
+
const { transport } = await connectNdjson({
|
|
1214
|
+
input: child.stdout,
|
|
1215
|
+
output: child.stdin,
|
|
1216
|
+
onClose: () => {
|
|
1217
|
+
stopForwarding();
|
|
1218
|
+
killChild(child);
|
|
1219
|
+
closeSignal.close();
|
|
1220
|
+
}
|
|
1221
|
+
});
|
|
1222
|
+
return {
|
|
1223
|
+
transport,
|
|
1224
|
+
closed: closeSignal.closed
|
|
1225
|
+
};
|
|
1226
|
+
}
|
|
1227
|
+
}
|
|
1228
|
+
}
|
|
1229
|
+
async function _openSocketTransport(socketPath, token) {
|
|
1230
|
+
const socket = net.createConnection(socketPath);
|
|
1231
|
+
const closeSignal = _closeSignal();
|
|
1232
|
+
socket.on("error", () => {
|
|
1233
|
+
socket.destroy();
|
|
1234
|
+
closeSignal.close();
|
|
1235
|
+
});
|
|
1236
|
+
try {
|
|
1237
|
+
await new Promise((resolve, reject) => {
|
|
1238
|
+
const onError = (error) => reject(error);
|
|
1239
|
+
socket.once("error", onError);
|
|
1240
|
+
socket.once("connect", () => {
|
|
1241
|
+
socket.off("error", onError);
|
|
1242
|
+
resolve();
|
|
1243
|
+
});
|
|
1244
|
+
});
|
|
1245
|
+
} catch (error) {
|
|
1246
|
+
socket.destroy();
|
|
1247
|
+
throw new Error(`connect '${socketPath}' failed: ${_errorMessage(error)}`, { cause: error });
|
|
1248
|
+
}
|
|
1249
|
+
const { transport } = await connectNdjson({
|
|
1250
|
+
input: socket,
|
|
1251
|
+
output: socket,
|
|
1252
|
+
onClose: () => {
|
|
1253
|
+
socket.destroy();
|
|
1254
|
+
closeSignal.close();
|
|
1255
|
+
},
|
|
1256
|
+
initialize: {
|
|
1257
|
+
kind: "client",
|
|
1258
|
+
token: token ?? ""
|
|
1259
|
+
}
|
|
1260
|
+
});
|
|
1261
|
+
return {
|
|
1262
|
+
transport,
|
|
1263
|
+
closed: closeSignal.closed
|
|
1264
|
+
};
|
|
1265
|
+
}
|
|
1266
|
+
async function _openWsTransport(url, token) {
|
|
1267
|
+
const ws = await openWebSocket(url);
|
|
1268
|
+
const closeSignal = _closeSignal();
|
|
1269
|
+
const transport = new WebSocketTransport(ws, () => {
|
|
1270
|
+
try {
|
|
1271
|
+
ws.close();
|
|
1272
|
+
} catch {}
|
|
1273
|
+
closeSignal.close();
|
|
1274
|
+
});
|
|
1275
|
+
try {
|
|
1276
|
+
await runInitializeHandshake(transport, {
|
|
1277
|
+
kind: "client",
|
|
1278
|
+
token: token ?? ""
|
|
1279
|
+
});
|
|
1280
|
+
} catch (error) {
|
|
1281
|
+
transport.dispose();
|
|
1282
|
+
throw error;
|
|
1283
|
+
}
|
|
1284
|
+
return {
|
|
1285
|
+
transport,
|
|
1286
|
+
closed: closeSignal.closed
|
|
1287
|
+
};
|
|
1288
|
+
}
|
|
1289
|
+
function _registerOnRemote(attachment, prefix, timeoutMs = 1e4) {
|
|
1290
|
+
return attachment.request(REGISTER_METHOD, { serviceId: prefix }, timeoutMs);
|
|
1291
|
+
}
|
|
1292
|
+
function _closeSignal() {
|
|
1293
|
+
let close;
|
|
1294
|
+
return {
|
|
1295
|
+
closed: new Promise((resolve) => {
|
|
1296
|
+
let didClose = false;
|
|
1297
|
+
close = () => {
|
|
1298
|
+
if (didClose) return;
|
|
1299
|
+
didClose = true;
|
|
1300
|
+
resolve();
|
|
1301
|
+
};
|
|
1302
|
+
}),
|
|
1303
|
+
close
|
|
1304
|
+
};
|
|
1305
|
+
}
|
|
1306
|
+
function _delayUnlessAborted(ms, signal) {
|
|
1307
|
+
if (signal.aborted) return Promise.resolve(false);
|
|
1308
|
+
return new Promise((resolve) => {
|
|
1309
|
+
const timer = setTimeout(() => {
|
|
1310
|
+
signal.removeEventListener("abort", onAbort);
|
|
1311
|
+
resolve(true);
|
|
1312
|
+
}, ms);
|
|
1313
|
+
const onAbort = () => {
|
|
1314
|
+
clearTimeout(timer);
|
|
1315
|
+
resolve(false);
|
|
1316
|
+
};
|
|
1317
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
1318
|
+
});
|
|
1319
|
+
}
|
|
1320
|
+
function _errorMessage(error) {
|
|
1321
|
+
return error instanceof Error ? error.message : String(error);
|
|
1322
|
+
}
|
|
1323
|
+
//#endregion
|
|
1324
|
+
//#region src/engine/runHub.ts
|
|
1325
|
+
/**
|
|
1326
|
+
* The hub engine: turn one {@link HubConfig} into a running {@link Hub} with all
|
|
1327
|
+
* its listeners and endpoints wired up. `serve`, `connect`, and `tunnel` are
|
|
1328
|
+
* thin config synthesizers over this; the client commands run it in-process
|
|
1329
|
+
* (via the private control socket) when given `-c, --config`.
|
|
1330
|
+
*
|
|
1331
|
+
* Wiring rules, per transport:
|
|
1332
|
+
* - **listener** → a {@link SocketServer}/{@link WebSocketServer} fronted by a
|
|
1333
|
+
* {@link HubConnectionAcceptor}; peers claim their own service ids.
|
|
1334
|
+
* - **endpoint** (`ws`/`socket`/`cmd-stdio`/`uri`) → a far end we dial: open the
|
|
1335
|
+
* raw transport, claim our `claimServiceIds` through its connection-granted
|
|
1336
|
+
* namespace, `hub.attach` it for inbound routing, `claimPrefix` its
|
|
1337
|
+
* `routeServiceIds`, and `setUplink` it when it is the `defaultRoute`. Lost
|
|
1338
|
+
* far-end links reconnect and repeat that registration before routing resumes.
|
|
1339
|
+
* - **endpoint** (`cmd-env`) → a child spawned against a private hub socket; it
|
|
1340
|
+
* joins this hub as a participant and claims its own prefix.
|
|
1341
|
+
*/
|
|
1342
|
+
/** Where persistent managed identities for `managedIdentity` slots are stored. */
|
|
1343
|
+
const PROVISION_SUBDIR = "provisioned-identities";
|
|
1344
|
+
/** Build + start a hub from a config. Resolves once all transports are wired. */
|
|
1345
|
+
async function runHub(opts) {
|
|
1346
|
+
const log = opts.log ?? ((line) => process.stderr.write(line + "\n"));
|
|
1347
|
+
const config = opts.config;
|
|
1348
|
+
const flowLogger = opts.logMessages ? createFlowLogger({ log }) : void 0;
|
|
1349
|
+
const hub = flowLogger ? new Hub({ onTransit: flowLogger.onTransit }) : new Hub();
|
|
1350
|
+
const routes = new RouteTable();
|
|
1351
|
+
const disposers = [];
|
|
1352
|
+
const listeners = [];
|
|
1353
|
+
const tokenStore = new TokenIdentityStore();
|
|
1354
|
+
const identities = new IdentityProvisioner();
|
|
1355
|
+
const forwardChecking = await _setupForwardChecking(hub, config, log, disposers, tokenStore);
|
|
1356
|
+
if (flowLogger) disposers.push(() => flowLogger.dispose());
|
|
1357
|
+
const cmdEndpointCount = config.endpoints.filter((e) => e.kind === "cmd-env" || e.kind === "cmd-stdio").length + Object.values(config.namedEndpoints).filter((e) => e.kind === "cmd-env" || e.kind === "cmd-stdio").length;
|
|
1358
|
+
if (opts.cmdInteractive && cmdEndpointCount > 1) throw new Error(`--cmd-interactive requires exactly one cmd endpoint, found ${cmdEndpointCount}`);
|
|
1359
|
+
const interactive = opts.cmdInteractive === true && cmdEndpointCount === 1;
|
|
1360
|
+
const dispose = () => {
|
|
1361
|
+
for (const d of disposers.reverse()) try {
|
|
1362
|
+
d();
|
|
1363
|
+
} catch {}
|
|
1364
|
+
};
|
|
1365
|
+
try {
|
|
1366
|
+
const controlEndpoint = await _startControlSocket(hub, disposers);
|
|
1367
|
+
for (const listener of config.listeners) {
|
|
1368
|
+
const info = await _wireListener(hub, listener, forwardChecking, config.hubServiceId, disposers, log, tokenStore, identities);
|
|
1369
|
+
listeners.push(info);
|
|
1370
|
+
}
|
|
1371
|
+
const planned = [...config.endpoints.map((ep) => ({
|
|
1372
|
+
label: endpointLabel(ep),
|
|
1373
|
+
ep
|
|
1374
|
+
})), ...Object.entries(config.namedEndpoints).map(([label, ep]) => ({
|
|
1375
|
+
label: endpointLabel(ep, label),
|
|
1376
|
+
ep
|
|
1377
|
+
}))];
|
|
1378
|
+
for (const { label, ep } of planned) {
|
|
1379
|
+
for (const serviceId of ep.routeServiceIds) routes.claim(serviceId, label);
|
|
1380
|
+
if (ep.defaultRoute) routes.setDefault(label);
|
|
1381
|
+
}
|
|
1382
|
+
const connectorParticipants = planned.filter((entry) => entry.ep.kind !== "cmd-env").map(({ label, ep }) => ({
|
|
1383
|
+
name: label,
|
|
1384
|
+
config: ep
|
|
1385
|
+
}));
|
|
1386
|
+
const configuredParticipants = configureParticipants({
|
|
1387
|
+
participants: connectorParticipants,
|
|
1388
|
+
attachParticipant: (transport) => {
|
|
1389
|
+
const link = hub.attach(transport);
|
|
1390
|
+
return {
|
|
1391
|
+
claimPrefix: (prefix) => link.addPrefixRoute(prefix),
|
|
1392
|
+
setDefaultRoute: () => hub.setUplink(transport),
|
|
1393
|
+
request: (method, params, timeoutMs) => link.request(method, params, timeoutMs),
|
|
1394
|
+
dispose: () => link.dispose()
|
|
1395
|
+
};
|
|
1396
|
+
},
|
|
1397
|
+
log,
|
|
1398
|
+
cmdInteractive: interactive && connectorParticipants.some((entry) => entry.config.kind === "cmd-stdio")
|
|
1399
|
+
});
|
|
1400
|
+
disposers.push(() => configuredParticipants.dispose());
|
|
1401
|
+
for (const { label, ep } of planned) {
|
|
1402
|
+
if (ep.kind !== "cmd-env") continue;
|
|
1403
|
+
await _wireCmdEnvEndpoint(hub, label, ep, log, interactive, forwardChecking, config.hubServiceId, tokenStore, identities, disposers);
|
|
1404
|
+
}
|
|
1405
|
+
return {
|
|
1406
|
+
hub,
|
|
1407
|
+
listeners,
|
|
1408
|
+
controlEndpoint,
|
|
1409
|
+
dispose
|
|
1410
|
+
};
|
|
1411
|
+
} catch (e) {
|
|
1412
|
+
dispose();
|
|
1413
|
+
throw e;
|
|
1414
|
+
}
|
|
1415
|
+
}
|
|
1416
|
+
/**
|
|
1417
|
+
* Install the hub's global services and, when {@link HubConfig.forwardChecking}
|
|
1418
|
+
* is enabled, the `hubAccess` consent front door + the hub-served
|
|
1419
|
+
* `hubAccessManifest`.
|
|
1420
|
+
*
|
|
1421
|
+
* The hub itself is **keyless park-and-relay**: a `hubAccess` request is parked
|
|
1422
|
+
* as a desired manifest entry and blocks; an *approver* decides it by relaying a
|
|
1423
|
+
* signed capability via `setCurrent`. Approvers are manifest **clients** (over a
|
|
1424
|
+
* connection), never special cases inside the hub:
|
|
1425
|
+
*
|
|
1426
|
+
* - The built-in **terminal approver** ({@link runManifestApprover}) is started
|
|
1427
|
+
* when stdin is a TTY and {@link HubConfig.consentApprover} is not `false`.
|
|
1428
|
+
* It mints with the hub's persistent admin identity (loaded only then) and
|
|
1429
|
+
* consents on stdin. It reaches the manifest over the in-process services
|
|
1430
|
+
* connection — the same path a remote approver would use.
|
|
1431
|
+
* - A non-interactive hub is **keyless**: it loads no key, and grants come
|
|
1432
|
+
* solely from external approvers (configured `rootPrincipalIds`) deciding via
|
|
1433
|
+
* the manifest with their own self-signed capabilities.
|
|
1434
|
+
*
|
|
1435
|
+
* Returns the acceptor flags to apply to every inbound listener, or `undefined`
|
|
1436
|
+
* for an open (unchecked) hub.
|
|
1437
|
+
*/
|
|
1438
|
+
async function _setupForwardChecking(hub, config, log, disposers, tokenStore) {
|
|
1439
|
+
const fc = config.forwardChecking;
|
|
1440
|
+
const verifySignatures = fc === true ? true : fc === false ? false : fc.verifySignatures;
|
|
1441
|
+
const requireCapability = fc === true ? true : fc === false ? false : fc.requireCapability;
|
|
1442
|
+
if (!verifySignatures && !requireCapability) {
|
|
1443
|
+
_registerCentralConnectionTokenBinder(createHubServiceInterfaces(hub, { hubServiceId: config.hubServiceId }), config, tokenStore, log);
|
|
1444
|
+
return;
|
|
1445
|
+
}
|
|
1446
|
+
const interactive = process.stdin.isTTY === true;
|
|
1447
|
+
const approverEnabled = requireCapability && interactive && config.consentApprover !== false;
|
|
1448
|
+
let admin;
|
|
1449
|
+
let adminId;
|
|
1450
|
+
if (approverEnabled) {
|
|
1451
|
+
const persisted = await loadOrCreateIdentity({
|
|
1452
|
+
id: config.adminIdentitySlot,
|
|
1453
|
+
storeDir: _provisionDir()
|
|
1454
|
+
});
|
|
1455
|
+
admin = KeypairSigningIdentity.fromKeypair(persisted.keypair);
|
|
1456
|
+
adminId = admin.publicSigningIdentity.principal;
|
|
1457
|
+
}
|
|
1458
|
+
const services = createHubServiceInterfaces(hub, { hubServiceId: config.hubServiceId });
|
|
1459
|
+
_registerCentralConnectionTokenBinder(services, config, tokenStore, log);
|
|
1460
|
+
const adminIds = [...adminId !== void 0 ? [adminId] : [], ...config.rootPrincipalIds];
|
|
1461
|
+
let installHubAccess;
|
|
1462
|
+
if (requireCapability) {
|
|
1463
|
+
const manifest = new HubAccessManifestHost({
|
|
1464
|
+
acceptableRootIds: adminIds,
|
|
1465
|
+
log
|
|
1466
|
+
});
|
|
1467
|
+
registerHubAccessManifest(services.connection, manifest, { serviceId: services.hubServiceId });
|
|
1468
|
+
installHubAccess = (root) => manifest.registerHubAccessAtRoot(root);
|
|
1469
|
+
if (approverEnabled && admin !== void 0) {
|
|
1470
|
+
const approverServiceId = config.consentApprover === true ? services.hubServiceId : config.consentApprover.serviceId;
|
|
1471
|
+
const approver = runManifestApprover({
|
|
1472
|
+
manifest: services.connection.service(approverServiceId).get(hubAccessManifestInterface),
|
|
1473
|
+
fetchDirectory: () => fetchFullDirectory(services.connection, approverServiceId),
|
|
1474
|
+
issuer: new CapabilityProposalIssuer(admin),
|
|
1475
|
+
ownPrincipalId: adminId,
|
|
1476
|
+
log
|
|
1477
|
+
});
|
|
1478
|
+
disposers.push(() => approver.dispose());
|
|
1479
|
+
}
|
|
1480
|
+
}
|
|
1481
|
+
if (requireCapability && adminIds.length === 0) log("WARNING: requireCapability is on but this hub has no accepted roots (no local approver — non-interactive or consentApprover:false — and no rootPrincipalIds configured). No capability can be issued or accepted — every access request will hang in the manifest with no one able to approve it. Configure rootPrincipalIds or enable the approver.");
|
|
1482
|
+
log(`forward checking on (verifySignatures=${verifySignatures}, requireCapability=${requireCapability}, admin=${adminId ?? "(keyless)"}${config.rootPrincipalIds.length > 0 ? `, extraRoots=[${config.rootPrincipalIds.join(", ")}]` : ""})`);
|
|
1483
|
+
return {
|
|
1484
|
+
verifyForwardedCalls: verifySignatures,
|
|
1485
|
+
requireForwardedCapability: requireCapability,
|
|
1486
|
+
adminIds,
|
|
1487
|
+
hubServiceId: services.hubServiceId,
|
|
1488
|
+
...installHubAccess !== void 0 ? { installHubAccess } : {}
|
|
1489
|
+
};
|
|
1490
|
+
}
|
|
1491
|
+
/**
|
|
1492
|
+
* Offer the connection-token binder as a **routable hub service** under
|
|
1493
|
+
* `${hubServiceId}::connectionTokenBinder`, in addition to the per-endpoint
|
|
1494
|
+
* root form each `cmd-env` binder installs on its own overlay root.
|
|
1495
|
+
*
|
|
1496
|
+
* The mint policy is the union of every configured `cmd-env` binder's prefixes:
|
|
1497
|
+
* a call may bind an `identitySlot` under any configured `identitySlotPrefix`
|
|
1498
|
+
* and a `serviceIdNamespace` under any configured `serviceIdPrefix`. Skipped
|
|
1499
|
+
* entirely when no endpoint configures a binder.
|
|
1500
|
+
*
|
|
1501
|
+
* Forwarded calls to this serviceId go through the hub's forwarded-call gate, so
|
|
1502
|
+
* on a checked hub a capability is required. On an **open** hub (no gate) it is
|
|
1503
|
+
* reachable unauthenticated — consistent with an open hub's trust-all posture,
|
|
1504
|
+
* but note that this is a privileged minter.
|
|
1505
|
+
*/
|
|
1506
|
+
function _registerCentralConnectionTokenBinder(services, config, tokenStore, log) {
|
|
1507
|
+
const binders = [...config.endpoints, ...Object.values(config.namedEndpoints)].filter((ep) => ep.kind === "cmd-env" && ep.connectionTokenBinder !== void 0).map((ep) => ep.connectionTokenBinder);
|
|
1508
|
+
if (binders.length === 0) return;
|
|
1509
|
+
const identitySlotPrefixes = binders.map((b) => b.identitySlotPrefix).filter((p) => p !== void 0);
|
|
1510
|
+
const serviceIdPrefixes = binders.map((b) => b.serviceIdPrefix).filter((p) => p !== void 0);
|
|
1511
|
+
registerConnectionTokenBinderService(services.connection, {
|
|
1512
|
+
store: tokenStore,
|
|
1513
|
+
identitySlotPrefixes,
|
|
1514
|
+
serviceIdPrefixes,
|
|
1515
|
+
serviceId: services.hubServiceId
|
|
1516
|
+
});
|
|
1517
|
+
log(`connectionTokenBinder served on ${services.hubServiceId}::connectionTokenBinder`);
|
|
1518
|
+
}
|
|
1519
|
+
async function _startControlSocket(hub, disposers) {
|
|
1520
|
+
const socketPath = SocketServer.allocSocketPath();
|
|
1521
|
+
const token = randomBytes(16).toString("hex");
|
|
1522
|
+
const server = await SocketServer.start({ endpoint: socketPath });
|
|
1523
|
+
const acceptor = new HubConnectionAcceptor({
|
|
1524
|
+
server,
|
|
1525
|
+
hub,
|
|
1526
|
+
handlers: [anonymousHandler({})]
|
|
1527
|
+
});
|
|
1528
|
+
disposers.push(() => {
|
|
1529
|
+
acceptor.dispose();
|
|
1530
|
+
server.dispose();
|
|
1531
|
+
_unlinkSocket(socketPath);
|
|
1532
|
+
});
|
|
1533
|
+
return {
|
|
1534
|
+
path: socketPath,
|
|
1535
|
+
token
|
|
1536
|
+
};
|
|
1537
|
+
}
|
|
1538
|
+
/**
|
|
1539
|
+
* Build the forward-checking gate options spread into a {@link
|
|
1540
|
+
* HubConnectionAcceptor}. Shared by inbound listeners and spawned `cmd-env`
|
|
1541
|
+
* children, so a child participant's forwarded calls are gated identically to
|
|
1542
|
+
* an external peer's (otherwise capability enforcement would silently apply
|
|
1543
|
+
* only to listeners, leaving spawned services ungated).
|
|
1544
|
+
*/
|
|
1545
|
+
function _buildGate(forwardChecking, hubServiceId) {
|
|
1546
|
+
const forwardPolicy = !forwardChecking || !forwardChecking.verifyForwardedCalls ? { verifyForwardedCalls: false } : forwardChecking.requireForwardedCapability ? {
|
|
1547
|
+
verifyForwardedCalls: true,
|
|
1548
|
+
requireForwardedCapability: true,
|
|
1549
|
+
adminIds: forwardChecking.adminIds
|
|
1550
|
+
} : {
|
|
1551
|
+
verifyForwardedCalls: true,
|
|
1552
|
+
adminIds: forwardChecking.adminIds
|
|
1553
|
+
};
|
|
1554
|
+
return forwardChecking ? {
|
|
1555
|
+
...forwardPolicy,
|
|
1556
|
+
hubServiceId: forwardChecking.hubServiceId,
|
|
1557
|
+
...forwardChecking.installHubAccess !== void 0 ? { installHubAccess: forwardChecking.installHubAccess } : {}
|
|
1558
|
+
} : {
|
|
1559
|
+
...forwardPolicy,
|
|
1560
|
+
hubServiceId
|
|
1561
|
+
};
|
|
1562
|
+
}
|
|
1563
|
+
async function _wireListener(hub, listener, forwardChecking, hubServiceId, disposers, log, tokenStore, identities) {
|
|
1564
|
+
const gate = _buildGate(forwardChecking, hubServiceId);
|
|
1565
|
+
const handlers = _buildHandlers(listener.handlers, tokenStore, identities);
|
|
1566
|
+
const isTokenAccepted = (token) => Promise.resolve(resolveConnectionHandler(handlers, token) !== void 0);
|
|
1567
|
+
if (listener.type === "websocket") {
|
|
1568
|
+
const server = await WebSocketServer.start({
|
|
1569
|
+
host: listener.host,
|
|
1570
|
+
port: listener.port,
|
|
1571
|
+
path: listener.path,
|
|
1572
|
+
isTokenAccepted,
|
|
1573
|
+
allowedOrigins: listener.allowedOrigins,
|
|
1574
|
+
maxPayload: listener.maxPayload,
|
|
1575
|
+
requestListener: listener.healthPath ? _healthHandler(listener.healthPath) : void 0
|
|
1576
|
+
});
|
|
1577
|
+
const acceptor = new HubConnectionAcceptor({
|
|
1578
|
+
server,
|
|
1579
|
+
hub,
|
|
1580
|
+
...gate,
|
|
1581
|
+
handlers
|
|
1582
|
+
});
|
|
1583
|
+
disposers.push(() => {
|
|
1584
|
+
acceptor.dispose();
|
|
1585
|
+
server.dispose();
|
|
1586
|
+
});
|
|
1587
|
+
const address = `ws://${listener.host}:${server.port}${listener.path}`;
|
|
1588
|
+
log(`listening (websocket) on ${address}`);
|
|
1589
|
+
return {
|
|
1590
|
+
type: "websocket",
|
|
1591
|
+
address
|
|
1592
|
+
};
|
|
1593
|
+
}
|
|
1594
|
+
const server = await SocketServer.start({
|
|
1595
|
+
endpoint: listener.path,
|
|
1596
|
+
isTokenAccepted
|
|
1597
|
+
});
|
|
1598
|
+
const acceptor = new HubConnectionAcceptor({
|
|
1599
|
+
server,
|
|
1600
|
+
hub,
|
|
1601
|
+
...gate,
|
|
1602
|
+
handlers
|
|
1603
|
+
});
|
|
1604
|
+
disposers.push(() => {
|
|
1605
|
+
acceptor.dispose();
|
|
1606
|
+
server.dispose();
|
|
1607
|
+
_unlinkSocket(listener.path);
|
|
1608
|
+
});
|
|
1609
|
+
log(`listening (socket) on ${listener.path}`);
|
|
1610
|
+
return {
|
|
1611
|
+
type: "socket",
|
|
1612
|
+
address: listener.path
|
|
1613
|
+
};
|
|
1614
|
+
}
|
|
1615
|
+
/**
|
|
1616
|
+
* Map a config {@link ProvisionConfig} to a resolved {@link RootProvision}:
|
|
1617
|
+
* turn `managedIdentity.slot` into a lazy resolver, `grantedServiceId` into a
|
|
1618
|
+
* namespace, and `connectionTokenBinder` into registrar options.
|
|
1619
|
+
*/
|
|
1620
|
+
function _provisionFromConfig(provision, tokenStore, identities) {
|
|
1621
|
+
return {
|
|
1622
|
+
...provision.grantedServiceId !== void 0 ? { grantedServiceIdNamespace: provision.grantedServiceId } : {},
|
|
1623
|
+
...provision.managedIdentity !== void 0 ? identities.slotFor(provision.managedIdentity.slot) : {},
|
|
1624
|
+
...provision.connectionTokenBinder !== void 0 ? { connectionTokenBinder: _binderOptions(provision.connectionTokenBinder, tokenStore) } : {}
|
|
1625
|
+
};
|
|
1626
|
+
}
|
|
1627
|
+
/** Build the acceptor's ordered handler factories from a listener's config. */
|
|
1628
|
+
function _buildHandlers(configs, tokenStore, identities) {
|
|
1629
|
+
return configs.map((h) => {
|
|
1630
|
+
switch (h.token) {
|
|
1631
|
+
case "anonymous": return anonymousHandler(_provisionFromConfig(h, tokenStore, identities));
|
|
1632
|
+
case "static": return staticTokenHandler(h.value, _provisionFromConfig(h, tokenStore, identities));
|
|
1633
|
+
case "bound": return boundTokenHandler(tokenStore, (slot) => identities.slotFor(slot));
|
|
1634
|
+
}
|
|
1635
|
+
});
|
|
1636
|
+
}
|
|
1637
|
+
/**
|
|
1638
|
+
* Loads and caches persistent managed identities by slot, backed by a single
|
|
1639
|
+
* SQLite keystore. One per hub; shared between config handlers, bound-token
|
|
1640
|
+
* redemption, and cmd-env endpoints so a slot resolves to the same identity and
|
|
1641
|
+
* per-identity storage everywhere.
|
|
1642
|
+
*
|
|
1643
|
+
* Runs in the keystore's **unencrypted mode**. Identities created by a previous
|
|
1644
|
+
* (file-per-slot plaintext) build are migrated lazily on first resolve, keeping
|
|
1645
|
+
* the same principal — see {@link _migrateLegacy}.
|
|
1646
|
+
*/
|
|
1647
|
+
var IdentityProvisioner = class {
|
|
1648
|
+
_dir = _provisionDir();
|
|
1649
|
+
_keystore;
|
|
1650
|
+
/**
|
|
1651
|
+
* A lazily-resolved managed identity plus its per-identity storage for
|
|
1652
|
+
* `slot`. Spread directly into a {@link RootProvision}.
|
|
1653
|
+
*/
|
|
1654
|
+
slotFor(slot) {
|
|
1655
|
+
const s = this._keystoreFor().slotById(slot);
|
|
1656
|
+
return {
|
|
1657
|
+
resolveIdentity: () => this._resolve(slot, s),
|
|
1658
|
+
storage: s.storage
|
|
1659
|
+
};
|
|
1660
|
+
}
|
|
1661
|
+
/**
|
|
1662
|
+
* Open the keystore on first use — creating the provision dir first, since
|
|
1663
|
+
* `DatabaseSync` cannot create its file in a missing directory. Keyless hubs
|
|
1664
|
+
* (no managed identity) therefore never touch disk.
|
|
1665
|
+
*/
|
|
1666
|
+
_keystoreFor() {
|
|
1667
|
+
if (this._keystore === void 0) {
|
|
1668
|
+
fs.mkdirSync(this._dir, { recursive: true });
|
|
1669
|
+
this._keystore = createSqliteIdentityKeystore({ dbPath: path.join(this._dir, "keystore.db") });
|
|
1670
|
+
}
|
|
1671
|
+
return this._keystore;
|
|
1672
|
+
}
|
|
1673
|
+
async _resolve(slot, s) {
|
|
1674
|
+
const existing = await s.peekIdentity();
|
|
1675
|
+
if (existing) return existing;
|
|
1676
|
+
const migrated = await this._migrateLegacy(slot, s);
|
|
1677
|
+
if (migrated) return migrated;
|
|
1678
|
+
return s.getOrCreateIdentity();
|
|
1679
|
+
}
|
|
1680
|
+
/**
|
|
1681
|
+
* Import a pre-SQLite plaintext identity file for `slot` (if one exists)
|
|
1682
|
+
* into the keystore, preserving its Ed25519 keypair — and therefore its
|
|
1683
|
+
* principal — then rename the file to `.migrated` as an idempotent backup.
|
|
1684
|
+
* Returns the imported identity, or `undefined` when there is nothing to
|
|
1685
|
+
* migrate.
|
|
1686
|
+
*/
|
|
1687
|
+
async _migrateLegacy(slot, s) {
|
|
1688
|
+
const file = path.join(this._dir, `${_legacySlotName(slot)}.json`);
|
|
1689
|
+
try {
|
|
1690
|
+
await fs.promises.access(file);
|
|
1691
|
+
} catch {
|
|
1692
|
+
return;
|
|
1693
|
+
}
|
|
1694
|
+
const persisted = await loadOrCreateIdentity({
|
|
1695
|
+
id: slot,
|
|
1696
|
+
file
|
|
1697
|
+
});
|
|
1698
|
+
const identity = await s.importIdentity(persisted.keypair, persisted.wrapKeypair);
|
|
1699
|
+
await fs.promises.rename(file, `${file}.migrated`).catch(() => {});
|
|
1700
|
+
return identity;
|
|
1701
|
+
}
|
|
1702
|
+
};
|
|
1703
|
+
/**
|
|
1704
|
+
* The legacy on-disk filename for a slot (`sha256(slot)` hex, first 16 bytes),
|
|
1705
|
+
* as written by the pre-SQLite `loadOrCreateIdentity` under {@link _provisionDir}.
|
|
1706
|
+
* Used only to locate files to migrate.
|
|
1707
|
+
*/
|
|
1708
|
+
function _legacySlotName(slot) {
|
|
1709
|
+
return createHash("sha256").update(slot).digest("hex").slice(0, 32);
|
|
1710
|
+
}
|
|
1711
|
+
function _healthHandler(healthPath) {
|
|
1712
|
+
return (req, res) => {
|
|
1713
|
+
if (req.url === healthPath) {
|
|
1714
|
+
res.writeHead(200, { "content-type": "text/plain" });
|
|
1715
|
+
res.end("ok");
|
|
1716
|
+
return;
|
|
1717
|
+
}
|
|
1718
|
+
res.writeHead(426, { "content-type": "text/plain" });
|
|
1719
|
+
res.end("upgrade required");
|
|
1720
|
+
};
|
|
1721
|
+
}
|
|
1722
|
+
async function _wireCmdEnvEndpoint(hub, label, ep, log, interactive, forwardChecking, hubServiceId, tokenStore, identities, disposers) {
|
|
1723
|
+
if (ep.defaultRoute) throw new Error(`endpoint '${label}': defaultRoute is not supported on cmd-env endpoints`);
|
|
1724
|
+
if (ep.claimServiceIds.length > 0) throw new Error(`endpoint '${label}': claimServiceIds is not supported on cmd-env endpoints`);
|
|
1725
|
+
const namespace = ep.grantedServiceId ?? ep.routeServiceIds[0];
|
|
1726
|
+
const socketPath = SocketServer.allocSocketPath();
|
|
1727
|
+
const token = randomBytes(16).toString("hex");
|
|
1728
|
+
const server = await SocketServer.start({ endpoint: socketPath });
|
|
1729
|
+
const gate = _buildGate(forwardChecking, hubServiceId);
|
|
1730
|
+
const provision = {
|
|
1731
|
+
...namespace !== void 0 ? { grantedServiceIdNamespace: namespace } : {},
|
|
1732
|
+
...ep.managedIdentity !== void 0 ? identities.slotFor(ep.managedIdentity.slot) : {},
|
|
1733
|
+
...ep.connectionTokenBinder !== void 0 ? { connectionTokenBinder: _binderOptions(ep.connectionTokenBinder, tokenStore) } : {}
|
|
1734
|
+
};
|
|
1735
|
+
const acceptor = new HubConnectionAcceptor({
|
|
1736
|
+
server,
|
|
1737
|
+
hub,
|
|
1738
|
+
...gate,
|
|
1739
|
+
handlers: [anonymousHandler(provision)]
|
|
1740
|
+
});
|
|
1741
|
+
const child = spawnCommand(endpointCommand(ep), {
|
|
1742
|
+
stdio: interactive ? [
|
|
1743
|
+
"pipe",
|
|
1744
|
+
"pipe",
|
|
1745
|
+
"pipe"
|
|
1746
|
+
] : [
|
|
1747
|
+
"ignore",
|
|
1748
|
+
"pipe",
|
|
1749
|
+
"pipe"
|
|
1750
|
+
],
|
|
1751
|
+
env: {
|
|
1752
|
+
...process.env,
|
|
1753
|
+
...ep.env,
|
|
1754
|
+
LINKRPC_ENDPOINT: socketPath,
|
|
1755
|
+
LINKRPC_TOKEN: token
|
|
1756
|
+
},
|
|
1757
|
+
...ep.cwd !== void 0 ? { cwd: ep.cwd } : {}
|
|
1758
|
+
});
|
|
1759
|
+
pipeChildLogs(child, label, log, { includeStdout: true });
|
|
1760
|
+
if (interactive) disposers.push(forwardStdin(child));
|
|
1761
|
+
disposers.push(() => {
|
|
1762
|
+
killChild(child);
|
|
1763
|
+
acceptor.dispose();
|
|
1764
|
+
server.dispose();
|
|
1765
|
+
_unlinkSocket(socketPath);
|
|
1766
|
+
});
|
|
1767
|
+
}
|
|
1768
|
+
/**
|
|
1769
|
+
* Build the root-form {@link RegisterConnectionTokenBinderOptions} for a
|
|
1770
|
+
* `cmd-env` endpoint's `connectionTokenBinder` config: each optional prefix
|
|
1771
|
+
* becomes a single-element allow-list (absent → empty → that axis disabled).
|
|
1772
|
+
*/
|
|
1773
|
+
function _binderOptions(binder, tokenStore) {
|
|
1774
|
+
return {
|
|
1775
|
+
store: tokenStore,
|
|
1776
|
+
identitySlotPrefixes: binder.identitySlotPrefix !== void 0 ? [binder.identitySlotPrefix] : [],
|
|
1777
|
+
serviceIdPrefixes: binder.serviceIdPrefix !== void 0 ? [binder.serviceIdPrefix] : []
|
|
1778
|
+
};
|
|
1779
|
+
}
|
|
1780
|
+
function _unlinkSocket(socketPath) {
|
|
1781
|
+
if (process.platform === "win32") return;
|
|
1782
|
+
try {
|
|
1783
|
+
fs.unlinkSync(socketPath);
|
|
1784
|
+
} catch {}
|
|
1785
|
+
}
|
|
1786
|
+
function _provisionDir() {
|
|
1787
|
+
const home = os.homedir();
|
|
1788
|
+
let base;
|
|
1789
|
+
if (process.platform === "win32") base = process.env.APPDATA ?? path.join(home, "AppData", "Roaming");
|
|
1790
|
+
else if (process.platform === "darwin") base = path.join(home, "Library", "Application Support");
|
|
1791
|
+
else base = process.env.XDG_CONFIG_HOME ?? path.join(home, ".config");
|
|
1792
|
+
return path.join(base, "linkrpc", PROVISION_SUBDIR);
|
|
1793
|
+
}
|
|
1794
|
+
//#endregion
|
|
1795
|
+
export { ApproveClient as a, buildSlotPermissions as c, computeCallBindHash as d, createTerminalConsentPrompt as f, toSignedPermissions as h, runManifestApprover as i, createHubAccessConfig as l, raceConsent as m, configureParticipants as n, HubAccessManifestHost as o, describePermissions as p, validateConfiguredParticipants as r, registerHubAccessManifest as s, runHub as t, describeRequest as u };
|
|
1796
|
+
|
|
1797
|
+
//# sourceMappingURL=runHub-P3YUwwdv.js.map
|