@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.
Files changed (50) hide show
  1. package/README.md +101 -0
  2. package/dist/chunks/config-BCvkg7jv.d.ts +566 -0
  3. package/dist/chunks/configFile-y6Phntqu.d.ts +9 -0
  4. package/dist/chunks/connectionTokenBinder.interfaces-B-beCg06.js +40 -0
  5. package/dist/chunks/connectionTokenBinder.interfaces-B-beCg06.js.map +1 -0
  6. package/dist/chunks/connectionTokenBinder.interfaces-BhzQ1DTS.d.ts +36 -0
  7. package/dist/chunks/hubConnectionAcceptor-B5-8JsFY.js +2768 -0
  8. package/dist/chunks/hubConnectionAcceptor-B5-8JsFY.js.map +1 -0
  9. package/dist/chunks/hubConnectionAcceptor-BwydvFa4.d.ts +1560 -0
  10. package/dist/chunks/index-CLIUrV88.d.ts +481 -0
  11. package/dist/chunks/node-CTXsQ6oa.js +460 -0
  12. package/dist/chunks/node-CTXsQ6oa.js.map +1 -0
  13. package/dist/chunks/nodeTransit-CWeFnbwt.js +444 -0
  14. package/dist/chunks/nodeTransit-CWeFnbwt.js.map +1 -0
  15. package/dist/chunks/nodeTransit-cmtZgdpW.d.ts +226 -0
  16. package/dist/chunks/runHub-P3YUwwdv.js +1797 -0
  17. package/dist/chunks/runHub-P3YUwwdv.js.map +1 -0
  18. package/dist/chunks/server-BAxchQhy.js +1368 -0
  19. package/dist/chunks/server-BAxchQhy.js.map +1 -0
  20. package/dist/cli.d.ts +1 -0
  21. package/dist/cli.js +38 -0
  22. package/dist/cli.js.map +1 -0
  23. package/dist/config.d.ts +2 -0
  24. package/dist/config.js +305 -0
  25. package/dist/config.js.map +1 -0
  26. package/dist/configFile.d.ts +2 -0
  27. package/dist/configFile.js +27 -0
  28. package/dist/configFile.js.map +1 -0
  29. package/dist/engine/runHub.d.ts +42 -0
  30. package/dist/engine/runHub.js +2 -0
  31. package/dist/hub/server/client.d.ts +2 -0
  32. package/dist/hub/server/client.js +2 -0
  33. package/dist/hub/server/connectionTokenBinder.d.ts +2 -0
  34. package/dist/hub/server/connectionTokenBinder.js +2 -0
  35. package/dist/hub/server/index.d.ts +5 -0
  36. package/dist/hub/server/index.js +5 -0
  37. package/dist/hub/server/node/index.d.ts +218 -0
  38. package/dist/hub/server/node/index.js +2 -0
  39. package/dist/hub/server/transit.d.ts +2 -0
  40. package/dist/hub/server/transit.js +2 -0
  41. package/dist/index.d.ts +512 -0
  42. package/dist/index.js +309 -0
  43. package/dist/index.js.map +1 -0
  44. package/dist/serve.d.ts +14 -0
  45. package/dist/serve.js +31 -0
  46. package/dist/serve.js.map +1 -0
  47. package/dist/spawn.d.ts +12 -0
  48. package/dist/spawn.js +25 -0
  49. package/dist/spawn.js.map +1 -0
  50. 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