@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,1368 @@
1
+ import "./hubConnectionAcceptor-B5-8JsFY.js";
2
+ import "./nodeTransit-CWeFnbwt.js";
3
+ import "./connectionTokenBinder.interfaces-B-beCg06.js";
4
+ import { createRequire } from "node:module";
5
+ import { InMemoryManagedIdentity, LinkRpcConnection, base64UrlToBytes, bytesToBase64Url, crypto, jcsCanonicalize, jcsCanonicalizeBytes, keyIdForPrincipal, publicKeyForKeyId, signCapability } from "@hediet/linkrpc";
6
+ import { ROOT_SERVICE_ID, hubAccessInterface, isValidServiceId, mapTransport, walkHubDetailed } from "@hediet/linkrpc/hub/common";
7
+ import { createHash, randomBytes } from "node:crypto";
8
+ import * as fs from "node:fs/promises";
9
+ import * as path from "node:path";
10
+ //#region src/hub/server/hubRegisterServiceId.ts
11
+ /**
12
+ * Register an **in-process** service on `hub` under `serviceId`, returning a
13
+ * {@link RegisteredServiceId} whose `connection` serves its interfaces.
14
+ *
15
+ * This is the hubv2 replacement for the v1 `hub.attachParticipant(pair.a)` +
16
+ * `LinkRpcConnection.fromTransport(pair.b)` + `enableReflection({ serviceId })`
17
+ * dance that every built-in extension service performed. It:
18
+ *
19
+ * 1. attaches an in-memory link to the hub and claims `serviceId` on it, so
20
+ * the hub routes every fully-qualified `serviceId::…` call to this service;
21
+ * 2. exposes a {@link LinkRpcConnection} whose registered interfaces answer
22
+ * those calls (register them with `{ serviceId }`); and
23
+ * 3. enables reflection under `serviceId` so the hub's aggregating
24
+ * `directory::list` surfaces this service.
25
+ *
26
+ * Disposing the returned handle (`svc.dispose()`) closes the connection and
27
+ * detaches the link, releasing the claimed prefix.
28
+ *
29
+ * Unlike an accepted *participant* (which gets a {@link RootOverlay} and only
30
+ * reaches the hub through its uplink), an in-process service is attached
31
+ * directly to the hub as a prefix owner. It is fully trusted — no provenance,
32
+ * identity, or forwarded-call gating applies to traffic it receives.
33
+ */
34
+ function hubRegisterServiceId(hub, serviceId) {
35
+ const link = hub.attachOut();
36
+ link.addPrefixRoute(serviceId);
37
+ const connection = LinkRpcConnection.fromTransport(link.transport);
38
+ connection.enableReflection({ serviceId });
39
+ return new RegisteredServiceId(connection, link);
40
+ }
41
+ /**
42
+ * Handle to an in-process service registered on a {@link Hub} via
43
+ * {@link hubRegisterServiceId}. Owns both the {@link LinkRpcConnection} the
44
+ * service serves its interfaces on and the hub {@link AttachedLink} that routes
45
+ * traffic to it. {@link dispose} closes the connection and detaches the link
46
+ * (releasing the claimed prefix) — wire it into the owning feature's disposal.
47
+ */
48
+ var RegisteredServiceId = class {
49
+ connection;
50
+ _link;
51
+ constructor(connection, _link) {
52
+ this.connection = connection;
53
+ this._link = _link;
54
+ }
55
+ dispose() {
56
+ this.connection.close();
57
+ this._link.dispose();
58
+ }
59
+ };
60
+ //#endregion
61
+ //#region src/hub/server/accessCandidates.ts
62
+ /**
63
+ * Fetch the full interface directory by recursively walking the hub's
64
+ * **referral** directory graph. {@link walkHubDetailed} follows explicit
65
+ * `hubrpc.directory` listings to collect leaf interfaces (folding transitive
66
+ * root-node-id requirements onto descendants along the way). Routing claims do
67
+ * not create referrals. This is the v2 replacement for the hub-internal
68
+ * `_fullDirectory()`.
69
+ */
70
+ async function fetchFullDirectory(connection, hubServiceId = "hub") {
71
+ const { listings } = await walkHubDetailed(connection.channel, { rootTarget: hubServiceId });
72
+ return listings.map((it) => ({
73
+ serviceId: it.serviceId,
74
+ interfaceId: it.interfaceId,
75
+ hash: it.hash,
76
+ ...it.serviceDescription !== void 0 ? { serviceDescription: it.serviceDescription } : {},
77
+ ...it.rootPrincipalSets !== void 0 ? { rootPrincipalSets: it.rootPrincipalSets } : {}
78
+ }));
79
+ }
80
+ /**
81
+ * Resolve every dependency slot against a directory snapshot. Pure port of
82
+ * the v1 hub's per-request candidate resolution loop + `_candidatesForSlot`.
83
+ */
84
+ function resolveAccessCandidates(slots, directory) {
85
+ const dependencies = {};
86
+ const noCandidateSlots = [];
87
+ for (const [slotId, slot] of Object.entries(slots)) {
88
+ const candidates = candidatesForSlot(slot, directory);
89
+ dependencies[slotId] = {
90
+ request: slot,
91
+ candidates
92
+ };
93
+ if (candidates.length === 0) noCandidateSlots.push(slotId);
94
+ }
95
+ return {
96
+ dependencies,
97
+ noCandidateSlots
98
+ };
99
+ }
100
+ /**
101
+ * Match one slot against the directory snapshot. A service is a candidate iff
102
+ * it satisfies every `required` interface (by id, and by hash when pinned).
103
+ */
104
+ function candidatesForSlot(slot, directory) {
105
+ const byPrefix = /* @__PURE__ */ new Map();
106
+ for (const it of directory) {
107
+ let entry = byPrefix.get(it.serviceId);
108
+ if (!entry) {
109
+ entry = { interfaces: [] };
110
+ byPrefix.set(it.serviceId, entry);
111
+ }
112
+ entry.interfaces.push({
113
+ id: it.interfaceId,
114
+ hash: it.hash
115
+ });
116
+ if (entry.serviceDescription === void 0 && it.serviceDescription !== void 0) entry.serviceDescription = it.serviceDescription;
117
+ }
118
+ const out = [];
119
+ for (const [prefix, entry] of byPrefix) {
120
+ const ifaces = entry.interfaces;
121
+ const satisfied = [];
122
+ const unsatisfied = [];
123
+ let missingRequired = false;
124
+ for (const req of slot.interfaces) if (ifaces.some((i) => i.id === req.id && (req.hash === void 0 || i.hash === req.hash))) satisfied.push(req);
125
+ else {
126
+ unsatisfied.push(req);
127
+ if (req.required) missingRequired = true;
128
+ }
129
+ if (missingRequired) continue;
130
+ out.push({
131
+ serviceId: prefix,
132
+ ...entry.serviceDescription !== void 0 ? { serviceDescription: entry.serviceDescription } : {},
133
+ satisfiedInterfaces: satisfied,
134
+ unsatisfiedInterfaces: unsatisfied
135
+ });
136
+ }
137
+ return out;
138
+ }
139
+ //#endregion
140
+ //#region src/hub/server/mintCapability.ts
141
+ /**
142
+ * Admin-issued capability minting for the v2 consent engine.
143
+ *
144
+ * v1 issued capabilities through the hub (`proposeCapability` +
145
+ * `signProposedCapability`). In v2 the hub no longer signs anything — the
146
+ * consent engine owns an admin {@link SigningIdentity} and mints capabilities
147
+ * directly. A {@link SigningIdentity} signs over arbitrary bytes with its
148
+ * Ed25519 key, which is exactly what {@link signCapability} needs, so this is
149
+ * a thin wrapper that assembles a {@link Capability} and signs it.
150
+ */
151
+ /**
152
+ * Assemble and sign a {@link Capability} with the admin identity.
153
+ *
154
+ * This replaces the v1 `hub.proposeCapability` / `signProposedCapability`
155
+ * pair: the consent engine decides the permissions (e.g. from
156
+ * {@link resolveAccessCandidates} + the user's consent selection) and mints
157
+ * the capability bound to the consumer's PrincipalId as `audience`.
158
+ */
159
+ async function mintCapability(options) {
160
+ const capability = {
161
+ issuer: options.issuer.publicSigningIdentity.principal,
162
+ audience: options.audience,
163
+ permissions: [...options.permissions],
164
+ nonce: options.nonce ?? randomNonce(),
165
+ ...options.expiresAtMs !== void 0 ? { expiresAtMs: options.expiresAtMs } : {},
166
+ ...options.parentHash !== void 0 ? { parentHash: options.parentHash } : {}
167
+ };
168
+ return signCapability(capability, options.issuer);
169
+ }
170
+ function randomNonce() {
171
+ const bytes = /* @__PURE__ */ new Uint8Array(16);
172
+ globalThis.crypto.getRandomValues(bytes);
173
+ return bytesToBase64Url(bytes);
174
+ }
175
+ //#endregion
176
+ //#region src/hub/server/capabilityProposal.ts
177
+ /**
178
+ * Capability proposal issuer — the byte-equality preview mechanism for the v2
179
+ * consent engine, backed by an admin {@link Identity}.
180
+ *
181
+ * In v1 these were hub-core methods (`Hub.proposeCapability` / `signProposal` /
182
+ * `redeemProposal`). In v2 the hub signs nothing, so the consent engine owns an
183
+ * admin identity and runs the proposal protocol itself. The mechanism lets a
184
+ * separate consent UI (a webview shell, reached over RPC) render the *exact*
185
+ * `Capability` that will be signed, then return it verbatim for redemption —
186
+ * with cryptographic guarantees that the displayed bytes equal the signed
187
+ * bytes and that a proposal cannot be replayed or substituted across prompts.
188
+ *
189
+ * Protocol (all host-side, using the admin key):
190
+ * 1. `propose({ audience, permissions, expiresAtMs? })` → an unsigned
191
+ * `Capability` tagged with an internal marker.
192
+ * 2. `signProposal(cap, { bind? })` → a `CapabilityProposal`: the readable
193
+ * `capability` plus an opaque `$hubData` blob (a domain-separated
194
+ * signature over `canonicalJson({ preview: cap, bind? })`). The UI renders
195
+ * `capability` and, on approval, returns the whole proposal verbatim.
196
+ * 3. `redeemProposal(proposal, { expectedBind? })` → a real
197
+ * `SignedCapability`. Verifies the `$hubData` signature, that the preview
198
+ * deep-equals `proposal.capability`, the optional `bind`, and a one-shot
199
+ * nonce; then signs the bare canonical cap so the result is an ordinary
200
+ * bearer token (verifiable by {@link verifyChain}).
201
+ */
202
+ function isHubProposalData(v) {
203
+ return v !== null && typeof v === "object" && typeof v.payload === "string" && typeof v.signature === "string";
204
+ }
205
+ const _DURATION_TO_MS = {
206
+ once: 3e5,
207
+ shortLived: 3e5,
208
+ longLived: 864e5
209
+ };
210
+ /**
211
+ * Convert an access duration into a Unix-milliseconds expiration timestamp, so
212
+ * handlers compute the same `expiresAtMs` the issuer mints with. Returns
213
+ * `undefined` for `persistent` (and any future never-expiring duration), which
214
+ * callers pass straight to {@link CapabilityProposalIssuer.propose}/`mint` —
215
+ * both omit `expiresAtMs` when it is `undefined`, yielding a non-expiring cap.
216
+ * Defaults to the safe short TTL (`shortLived`) when no duration is supplied.
217
+ */
218
+ function durationToExp(duration) {
219
+ const d = duration ?? "shortLived";
220
+ if (d === "persistent") return;
221
+ return Date.now() + _DURATION_TO_MS[d];
222
+ }
223
+ function canonicalEqual(a, b) {
224
+ try {
225
+ return jcsCanonicalize(a) === jcsCanonicalize(b);
226
+ } catch {
227
+ return false;
228
+ }
229
+ }
230
+ /** Marker proving a `Capability` came from {@link CapabilityProposalIssuer.propose}. */
231
+ const _PROPOSED = Symbol("linkrpc.proposedCapability");
232
+ /**
233
+ * Issues capabilities and capability proposals signed by an admin
234
+ * {@link SigningIdentity}. One instance per hub host; tracks redeemed nonces
235
+ * for one-shot proposal redemption.
236
+ */
237
+ var CapabilityProposalIssuer = class {
238
+ _issuer;
239
+ _redeemedNonces = /* @__PURE__ */ new Set();
240
+ _nonceCounter = 0;
241
+ constructor(_issuer) {
242
+ this._issuer = _issuer;
243
+ }
244
+ get issuerPrincipalId() {
245
+ return this._issuer.publicSigningIdentity.principal;
246
+ }
247
+ /**
248
+ * Build an unsigned `Capability` the issuer is willing to sign, tagged with
249
+ * an internal marker. {@link signProposal} refuses to sign anything lacking
250
+ * the marker — a guard against a handler fabricating arbitrary caps. The
251
+ * marker is non-enumerable, so it never affects serialization or signing.
252
+ */
253
+ propose(args) {
254
+ const cap = {
255
+ issuer: this._issuer.publicSigningIdentity.principal,
256
+ audience: args.audience,
257
+ permissions: [...args.permissions],
258
+ nonce: this._nextNonce(),
259
+ ...args.expiresAtMs !== void 0 ? { expiresAtMs: args.expiresAtMs } : {}
260
+ };
261
+ Object.defineProperty(cap, _PROPOSED, {
262
+ value: true,
263
+ enumerable: false,
264
+ configurable: false,
265
+ writable: false
266
+ });
267
+ return cap;
268
+ }
269
+ /**
270
+ * Mint a final `SignedCapability` directly from a proposed cap, bypassing
271
+ * the preview round-trip. Use when no UI needs to render the unsigned form.
272
+ */
273
+ async mint(args) {
274
+ const cap = this.propose(args);
275
+ return this._sign(cap);
276
+ }
277
+ /**
278
+ * Pair a proposed `Capability` with a domain-separated signature over
279
+ * `canonicalJson({ preview: cap, bind? })`. The result is **not** a bearer
280
+ * token — only this issuer can redeem it.
281
+ */
282
+ async signProposal(cap, opts) {
283
+ if (!cap[_PROPOSED]) throw new Error("signProposal: capability was not produced by propose()");
284
+ const payload = { preview: cap };
285
+ if (opts?.bind !== void 0) payload.bind = opts.bind;
286
+ const previewBytes = jcsCanonicalizeBytes(payload);
287
+ const sig = await this._issuer.sign(previewBytes);
288
+ return {
289
+ capability: cap,
290
+ $hubData: {
291
+ payload: bytesToBase64Url(previewBytes),
292
+ signature: bytesToBase64Url(sig)
293
+ }
294
+ };
295
+ }
296
+ /**
297
+ * Verify and redeem a {@link CapabilityProposal}, returning a real
298
+ * {@link SignedCapability}. Checks: `$hubData` signature, preview deep-equal
299
+ * to `proposal.capability`, optional `bind` match, and one-shot nonce.
300
+ */
301
+ async redeemProposal(proposal, opts) {
302
+ if (!isHubProposalData(proposal.$hubData)) throw new Error("redeemProposal: malformed $hubData");
303
+ const payloadBytes = base64UrlToBytes(proposal.$hubData.payload);
304
+ const sigBytes = base64UrlToBytes(proposal.$hubData.signature);
305
+ const pubKey = publicKeyForKeyId(keyIdForPrincipal(this._issuer.publicSigningIdentity.principal));
306
+ if (!await crypto.verify(pubKey, payloadBytes, sigBytes)) throw new Error("redeemProposal: $hubData.signature does not verify");
307
+ let parsed;
308
+ try {
309
+ parsed = JSON.parse(new TextDecoder().decode(payloadBytes));
310
+ } catch (e) {
311
+ throw new Error(`redeemProposal: malformed payload (${e.message})`);
312
+ }
313
+ if (!canonicalEqual(parsed.preview, proposal.capability)) throw new Error("redeemProposal: proposal.capability does not match the signed preview");
314
+ const hasBind = parsed.bind !== void 0;
315
+ if (hasBind !== (opts?.expectedBind !== void 0)) throw new Error(hasBind ? "redeemProposal: proposal is bound but no expectedBind supplied" : "redeemProposal: expectedBind supplied but proposal has no bind");
316
+ if (hasBind && !canonicalEqual(parsed.bind, opts.expectedBind)) throw new Error("redeemProposal: bind does not match expectedBind");
317
+ const cap = parsed.preview;
318
+ if (this._redeemedNonces.has(cap.nonce)) throw new Error("redeemProposal: proposal already redeemed");
319
+ this._redeemedNonces.add(cap.nonce);
320
+ return this._sign(cap);
321
+ }
322
+ _sign(cap) {
323
+ return signCapability(cap, this._issuer);
324
+ }
325
+ _nextNonce() {
326
+ const bytes = /* @__PURE__ */ new Uint8Array(16);
327
+ globalThis.crypto.getRandomValues(bytes);
328
+ this._nonceCounter += 1;
329
+ return `${bytesToBase64Url(bytes)}-${this._nonceCounter}`;
330
+ }
331
+ };
332
+ //#endregion
333
+ //#region src/hub/server/hubAccessService.ts
334
+ /**
335
+ * Install `hubAccess::{request,extend,requestAccess}` at the **connection
336
+ * root** of a participant's overlay (root form, no serviceId prefix). The root
337
+ * is never forwarded, so the consent front door is reached directly and needs
338
+ * no capability of its own.
339
+ *
340
+ * The capability `audience` is taken **directly from `params.consumer.principal`**
341
+ * — no longer derived from a verified call signer. A cap minted for a nodeId is
342
+ * only usable by the holder of that node's key (enforced at call time by
343
+ * `permits`, which checks `audience === call.signer`), so a self-asserted
344
+ * audience grants no usable authority to a caller who does not hold the key.
345
+ */
346
+ function registerHubAccessService(connection, options) {
347
+ const { handlers } = options;
348
+ connection.register(hubAccessInterface, {
349
+ request: async (params) => {
350
+ const consumerPrincipalId = params.consumer.principal;
351
+ const slots = {};
352
+ for (const [slotId, raw] of Object.entries(params.dependencies)) slots[slotId] = normalizeSlot(raw);
353
+ const { dependencies, noCandidateSlots } = resolveAccessCandidates(slots, [...await options.fetchDirectory()]);
354
+ if (noCandidateSlots.length > 0) return {
355
+ status: "noCandidates",
356
+ slots: [...noCandidateSlots]
357
+ };
358
+ const decision = await handlers.onAccessRequest({
359
+ consumer: params.consumer,
360
+ consumerPrincipalId,
361
+ dependencies,
362
+ duration: params.duration
363
+ });
364
+ if (!decision.granted) return decision.reason !== void 0 ? {
365
+ status: "denied",
366
+ reason: decision.reason
367
+ } : { status: "denied" };
368
+ const responseSlots = {};
369
+ for (const [slotId, binding] of Object.entries(decision.resolvedSlots)) {
370
+ const resolved = dependencies[slotId];
371
+ if (!resolved) continue;
372
+ if (!resolved.candidates.find((c) => c.serviceId === binding.serviceId)) continue;
373
+ responseSlots[slotId] = {
374
+ serviceId: binding.serviceId,
375
+ satisfiedInterfaces: [...binding.interfaces]
376
+ };
377
+ }
378
+ return {
379
+ status: "granted",
380
+ slots: responseSlots,
381
+ capabilities: [...decision.capabilities]
382
+ };
383
+ },
384
+ extend: async (params) => {
385
+ const consumerPrincipalId = params.consumer.principal;
386
+ const added = params.added.map((a) => ({
387
+ interfaceId: a.interfaceId,
388
+ member: a.member,
389
+ required: a.required !== false
390
+ }));
391
+ const decision = await handlers.onAccessExtend({
392
+ consumer: params.consumer,
393
+ consumerPrincipalId,
394
+ serviceId: params.serviceId,
395
+ added,
396
+ duration: params.duration
397
+ });
398
+ if (!decision.granted) return decision.reason !== void 0 ? {
399
+ status: "denied",
400
+ reason: decision.reason
401
+ } : { status: "denied" };
402
+ const grantedMembers = decision.grantedMembers.map((g) => ({
403
+ interfaceId: g.interfaceId,
404
+ member: g.member
405
+ }));
406
+ return decision.capabilities !== void 0 ? {
407
+ status: "granted",
408
+ serviceId: decision.serviceId,
409
+ granted: grantedMembers,
410
+ capabilities: [...decision.capabilities]
411
+ } : {
412
+ status: "granted",
413
+ serviceId: decision.serviceId,
414
+ granted: grantedMembers
415
+ };
416
+ },
417
+ requestAccess: async (params) => {
418
+ const consumerPrincipalId = params.consumer.principal;
419
+ const decision = await handlers.onAccessRequestDirect({
420
+ consumer: params.consumer,
421
+ consumerPrincipalId,
422
+ permissions: params.permissions,
423
+ duration: params.duration
424
+ });
425
+ if (!decision.granted) return decision.reason !== void 0 ? {
426
+ status: "denied",
427
+ reason: decision.reason
428
+ } : { status: "denied" };
429
+ return {
430
+ status: "granted",
431
+ capabilities: [...decision.capabilities]
432
+ };
433
+ }
434
+ });
435
+ }
436
+ function normalizeSlot(raw) {
437
+ return {
438
+ interfaces: raw.interfaces.map((i) => ({
439
+ id: i.id,
440
+ required: i.required !== false,
441
+ ...i.hash !== void 0 ? { hash: i.hash } : {}
442
+ })),
443
+ members: (raw.members ?? []).map((m) => ({
444
+ interfaceId: m.interfaceId,
445
+ member: m.member,
446
+ required: m.required !== false
447
+ }))
448
+ };
449
+ }
450
+ //#endregion
451
+ //#region src/hub/server/tokenIdentityStore.ts
452
+ /**
453
+ * The hub's connection-token mint + redemption store, shared between the
454
+ * `connectionTokenBinder::bindConnectionToken` front door (mint) and a
455
+ * `managedIdentity: { mode: "fromToken" }` / `grantedServiceId: { mode:
456
+ * "fromToken" }` listener (redeem).
457
+ *
458
+ * Tokens are random, time-boxed and **single-use**: {@link redeem} consumes the
459
+ * entry, so a token can bind exactly one connection and cannot be replayed once
460
+ * used or expired. Expired entries are pruned lazily on access.
461
+ */
462
+ var TokenIdentityStore = class {
463
+ _byToken = /* @__PURE__ */ new Map();
464
+ _defaultTtlMs;
465
+ _now;
466
+ _generate;
467
+ constructor(options = {}) {
468
+ this._defaultTtlMs = options.defaultTtlMs ?? 3e4;
469
+ this._now = options.now ?? (() => Date.now());
470
+ this._generate = options.generateToken ?? (() => randomBytes(32).toString("base64url"));
471
+ }
472
+ /** Mint a fresh single-use token bound to `binding`, valid for `ttlMs`. */
473
+ mint(binding = {}, ttlMs) {
474
+ this._prune();
475
+ const token = this._generate();
476
+ const expiresAt = this._now() + (ttlMs ?? this._defaultTtlMs);
477
+ this._byToken.set(token, {
478
+ identitySlot: binding.identitySlot,
479
+ grantedServiceIdNamespace: binding.grantedServiceIdNamespace,
480
+ expiresAt
481
+ });
482
+ return {
483
+ token,
484
+ expiresAt
485
+ };
486
+ }
487
+ /**
488
+ * Non-consuming existence check for the `hubrpc::initialize` token gate.
489
+ * Returns `true` iff the token is live (known and unexpired). Single-use is
490
+ * enforced separately by {@link redeem}.
491
+ */
492
+ peek(token) {
493
+ if (token === void 0) return false;
494
+ const entry = this._byToken.get(token);
495
+ if (entry === void 0) return false;
496
+ if (entry.expiresAt <= this._now()) {
497
+ this._byToken.delete(token);
498
+ return false;
499
+ }
500
+ return true;
501
+ }
502
+ /**
503
+ * Consume `token`, returning its binding exactly once. Returns `undefined`
504
+ * for an unknown, expired or already-redeemed token (the caller should drop
505
+ * the connection in that case).
506
+ */
507
+ redeem(token) {
508
+ if (token === void 0) return void 0;
509
+ const entry = this._byToken.get(token);
510
+ if (entry === void 0) return void 0;
511
+ this._byToken.delete(token);
512
+ if (entry.expiresAt <= this._now()) return void 0;
513
+ const binding = {};
514
+ if (entry.identitySlot !== void 0) binding.identitySlot = entry.identitySlot;
515
+ if (entry.grantedServiceIdNamespace !== void 0) binding.grantedServiceIdNamespace = entry.grantedServiceIdNamespace;
516
+ return binding;
517
+ }
518
+ /** Live (unexpired, unredeemed) token count — primarily for tests/metrics. */
519
+ get size() {
520
+ this._prune();
521
+ return this._byToken.size;
522
+ }
523
+ _prune() {
524
+ const now = this._now();
525
+ for (const [token, entry] of this._byToken) if (entry.expiresAt <= now) this._byToken.delete(token);
526
+ }
527
+ };
528
+ //#endregion
529
+ //#region src/hub/server/provenance.ts
530
+ /**
531
+ * Annotate every transport from `source` with the provenance `provider`
532
+ * resolves for it. On provider error/timeout — or when the returned
533
+ * `identityKey` does not start with `` `${provider.identityNamespace}/` `` —
534
+ * the transport is forwarded with `provenance: undefined`, unless
535
+ * {@link WithProvenanceOptions.requireProvenance} is set, in which case it is
536
+ * disposed and dropped.
537
+ *
538
+ * This is the one concrete {@link mapTransport} the hub ships: it is where
539
+ * `net.Socket` (or any backend handle) is read for attestation and nowhere
540
+ * else.
541
+ */
542
+ function withProvenance(source, provider, options = {}) {
543
+ const namespacePrefix = `${provider.identityNamespace}/`;
544
+ return mapTransport(source, async (t) => {
545
+ const controller = new AbortController();
546
+ t.onDidClose(() => controller.abort());
547
+ let timer;
548
+ if (options.resolveTimeoutMs !== void 0) timer = setTimeout(() => controller.abort(), options.resolveTimeoutMs);
549
+ let provenance;
550
+ try {
551
+ const result = await provider.resolve(t, controller.signal);
552
+ if (!("error" in result) && result.identityKey.startsWith(namespacePrefix)) provenance = result;
553
+ } catch {
554
+ provenance = void 0;
555
+ } finally {
556
+ if (timer !== void 0) clearTimeout(timer);
557
+ }
558
+ if (provenance === void 0 && options.requireProvenance) {
559
+ t.dispose();
560
+ return;
561
+ }
562
+ return Object.assign(t, { provenance });
563
+ });
564
+ }
565
+ //#endregion
566
+ //#region src/hub/server/prefixPolicy.ts
567
+ /**
568
+ * Default {@link PrefixPolicy}: a participant may claim only the prefix(es)
569
+ * {@link PrincipalIdPrefixPolicyOptions.derivePrefixes} grants it. The out-of-box
570
+ * derivation ties the claimable prefix to the peer's attested provenance, so
571
+ * `docker/echo-provider` can serve `docker/echo-provider/*` and nothing can
572
+ * impersonate it.
573
+ */
574
+ var PrincipalIdPrefixPolicy = class {
575
+ _derive;
576
+ constructor(options = {}) {
577
+ this._derive = options.derivePrefixes ?? defaultDerivePrefixes;
578
+ }
579
+ authorizeClaim(ctx) {
580
+ if (this._derive(ctx).includes(ctx.requestedPrefix)) return { ok: true };
581
+ return {
582
+ ok: false,
583
+ reason: `not authorized to claim '${ctx.requestedPrefix}'`
584
+ };
585
+ }
586
+ };
587
+ function defaultDerivePrefixes(ctx) {
588
+ const provenance = ctx.transport.provenance;
589
+ if (!provenance) return [];
590
+ const prefix = provenance.identityKey;
591
+ if (prefix === ROOT_SERVICE_ID || !isValidServiceId(prefix)) return [];
592
+ return [prefix];
593
+ }
594
+ //#endregion
595
+ //#region src/hub/server/sqliteIdentityKeystore.ts
596
+ let _DatabaseSync;
597
+ function _getDatabaseSync() {
598
+ if (_DatabaseSync === void 0) _DatabaseSync = createRequire(import.meta.url)("node:sqlite").DatabaseSync;
599
+ return _DatabaseSync;
600
+ }
601
+ const _codec = {
602
+ encode: (_slotId, plaintext) => plaintext,
603
+ decode: (_slotId, stored) => stored
604
+ };
605
+ const _SCHEMA = `
606
+ CREATE TABLE IF NOT EXISTS identity_slots (
607
+ slot_id TEXT PRIMARY KEY,
608
+ ed25519_priv TEXT,
609
+ ed25519_pub TEXT,
610
+ x25519_priv TEXT,
611
+ x25519_pub TEXT,
612
+ identity_created_at INTEGER,
613
+ files_dir TEXT,
614
+ snooze_scope TEXT,
615
+ snooze_expires_at INTEGER,
616
+ created_at INTEGER NOT NULL
617
+ );
618
+ CREATE TABLE IF NOT EXISTS identity_storage (
619
+ slot_id TEXT NOT NULL,
620
+ key TEXT NOT NULL,
621
+ value TEXT NOT NULL,
622
+ PRIMARY KEY (slot_id, key)
623
+ );
624
+ CREATE TABLE IF NOT EXISTS identity_keys (
625
+ slot_id TEXT NOT NULL,
626
+ key_json TEXT NOT NULL,
627
+ PRIMARY KEY (slot_id, key_json)
628
+ );
629
+ `;
630
+ /** Open (creating if needed) a SQLite-backed {@link IdentityKeystore}. */
631
+ function createSqliteIdentityKeystore(opts) {
632
+ const db = opts.db ?? new (_getDatabaseSync())(_requireDbPath(opts));
633
+ db.exec(_SCHEMA);
634
+ return new _SqliteKeystore(db);
635
+ }
636
+ function _requireDbPath(opts) {
637
+ if (opts.dbPath === void 0) throw new Error("createSqliteIdentityKeystore: pass either `dbPath` or `db`");
638
+ return opts.dbPath;
639
+ }
640
+ /** Prepared statements shared by every slot handle on one connection. */
641
+ var _Stmts = class {
642
+ selIdentity;
643
+ upsertIdentity;
644
+ ensureSlot;
645
+ slotExists;
646
+ setFilesDir;
647
+ getFilesDir;
648
+ setSnooze;
649
+ getSnooze;
650
+ clearSnooze;
651
+ listKeys;
652
+ hasKey;
653
+ addKey;
654
+ delKey;
655
+ storGet;
656
+ storSet;
657
+ storDel;
658
+ storKeys;
659
+ storCount;
660
+ delSlot;
661
+ delStorage;
662
+ delKeys;
663
+ constructor(db) {
664
+ this.selIdentity = db.prepare(`SELECT ed25519_priv, ed25519_pub, x25519_priv, x25519_pub
665
+ FROM identity_slots WHERE slot_id = ?`);
666
+ this.upsertIdentity = db.prepare(`INSERT INTO identity_slots
667
+ (slot_id, ed25519_priv, ed25519_pub, x25519_priv, x25519_pub,
668
+ identity_created_at, created_at)
669
+ VALUES (?, ?, ?, ?, ?, ?, ?)
670
+ ON CONFLICT(slot_id) DO UPDATE SET
671
+ ed25519_priv = excluded.ed25519_priv,
672
+ ed25519_pub = excluded.ed25519_pub,
673
+ x25519_priv = excluded.x25519_priv,
674
+ x25519_pub = excluded.x25519_pub,
675
+ identity_created_at = excluded.identity_created_at`);
676
+ this.ensureSlot = db.prepare(`INSERT OR IGNORE INTO identity_slots (slot_id, created_at) VALUES (?, ?)`);
677
+ this.slotExists = db.prepare(`SELECT 1 FROM identity_slots WHERE slot_id = ?`);
678
+ this.setFilesDir = db.prepare(`UPDATE identity_slots SET files_dir = ? WHERE slot_id = ?`);
679
+ this.getFilesDir = db.prepare(`SELECT files_dir FROM identity_slots WHERE slot_id = ?`);
680
+ this.setSnooze = db.prepare(`UPDATE identity_slots SET snooze_scope = ?, snooze_expires_at = ? WHERE slot_id = ?`);
681
+ this.getSnooze = db.prepare(`SELECT snooze_scope, snooze_expires_at FROM identity_slots WHERE slot_id = ?`);
682
+ this.clearSnooze = db.prepare(`UPDATE identity_slots SET snooze_scope = NULL, snooze_expires_at = NULL WHERE slot_id = ?`);
683
+ this.listKeys = db.prepare(`SELECT key_json FROM identity_keys WHERE slot_id = ?`);
684
+ this.hasKey = db.prepare(`SELECT 1 FROM identity_keys WHERE slot_id = ? AND key_json = ?`);
685
+ this.addKey = db.prepare(`INSERT OR IGNORE INTO identity_keys (slot_id, key_json) VALUES (?, ?)`);
686
+ this.delKey = db.prepare(`DELETE FROM identity_keys WHERE slot_id = ? AND key_json = ?`);
687
+ this.storGet = db.prepare(`SELECT value FROM identity_storage WHERE slot_id = ? AND key = ?`);
688
+ this.storSet = db.prepare(`INSERT OR REPLACE INTO identity_storage (slot_id, key, value) VALUES (?, ?, ?)`);
689
+ this.storDel = db.prepare(`DELETE FROM identity_storage WHERE slot_id = ? AND key = ?`);
690
+ this.storKeys = db.prepare(`SELECT key FROM identity_storage WHERE slot_id = ?`);
691
+ this.storCount = db.prepare(`SELECT COUNT(*) AS n FROM identity_storage WHERE slot_id = ?`);
692
+ this.delSlot = db.prepare(`DELETE FROM identity_slots WHERE slot_id = ?`);
693
+ this.delStorage = db.prepare(`DELETE FROM identity_storage WHERE slot_id = ?`);
694
+ this.delKeys = db.prepare(`DELETE FROM identity_keys WHERE slot_id = ?`);
695
+ }
696
+ };
697
+ var _SqliteKeystore = class {
698
+ _stmts;
699
+ _slots = /* @__PURE__ */ new Map();
700
+ constructor(db) {
701
+ this._stmts = new _Stmts(db);
702
+ }
703
+ slotById(id) {
704
+ return this._slot(id);
705
+ }
706
+ _slot(id) {
707
+ let existing = this._slots.get(id);
708
+ if (existing === void 0) {
709
+ existing = new _SqliteSlot(id, this._stmts);
710
+ this._slots.set(id, existing);
711
+ }
712
+ return existing;
713
+ }
714
+ async slotByIdAndKey(id, key) {
715
+ const slot = this._slot(id);
716
+ if (!await slot._exists()) return { error: "slotDoesNotExist" };
717
+ if (!await slot.hasKey(key)) return { error: "unknownKey" };
718
+ return slot;
719
+ }
720
+ async slotByIdAndTime(id, nowMs) {
721
+ const slot = this._slot(id);
722
+ if (!await slot.hasState()) return slot;
723
+ const snooze = await slot.getSnooze();
724
+ if (snooze !== void 0 && snooze.expiresAt > nowMs) return slot;
725
+ return { error: "consentRequired" };
726
+ }
727
+ async createSlotWithKey(id, key) {
728
+ const slot = this.slotById(id);
729
+ await slot.addKeys(key);
730
+ return slot;
731
+ }
732
+ };
733
+ var _SqliteSlot = class {
734
+ id;
735
+ _s;
736
+ _storage;
737
+ constructor(id, _s) {
738
+ this.id = id;
739
+ this._s = _s;
740
+ this._storage = new _SqliteSlotStorage(id, _s);
741
+ }
742
+ get storage() {
743
+ return this._storage;
744
+ }
745
+ async getOrCreateIdentity() {
746
+ const existing = await this.peekIdentity();
747
+ if (existing) return existing;
748
+ const ed = await crypto.generateKeypair();
749
+ const wrap = await crypto.generateX25519Keypair();
750
+ this._writeIdentity(ed, wrap);
751
+ return new InMemoryManagedIdentity(ed, wrap);
752
+ }
753
+ async importIdentity(ed, wrap) {
754
+ const existing = await this.peekIdentity();
755
+ if (existing) return existing;
756
+ this._writeIdentity(ed, wrap);
757
+ return new InMemoryManagedIdentity(ed, wrap);
758
+ }
759
+ async peekIdentity() {
760
+ const row = this._s.selIdentity.get(this.id);
761
+ if (row === void 0 || row.ed25519_priv === null || row.ed25519_pub === null || row.x25519_priv === null || row.x25519_pub === null) return;
762
+ const ed = {
763
+ privateKey: base64UrlToBytes(_codec.decode(this.id, row.ed25519_priv)),
764
+ publicKey: base64UrlToBytes(row.ed25519_pub)
765
+ };
766
+ const wrap = {
767
+ privateKey: base64UrlToBytes(_codec.decode(this.id, row.x25519_priv)),
768
+ publicKey: base64UrlToBytes(row.x25519_pub)
769
+ };
770
+ return new InMemoryManagedIdentity(ed, wrap);
771
+ }
772
+ _writeIdentity(ed, wrap) {
773
+ this._s.upsertIdentity.run(this.id, _codec.encode(this.id, bytesToBase64Url(ed.privateKey)), bytesToBase64Url(ed.publicKey), _codec.encode(this.id, bytesToBase64Url(wrap.privateKey)), bytesToBase64Url(wrap.publicKey), Date.now(), Date.now());
774
+ }
775
+ async listKeys() {
776
+ return this._s.listKeys.all(this.id).map((r) => _fromCanonicalKey(r.key_json));
777
+ }
778
+ async hasKey(key) {
779
+ return this._s.hasKey.get(this.id, _canonicalKey$1(key)) !== void 0;
780
+ }
781
+ async addKeys(...keys) {
782
+ if (keys.length === 0) return;
783
+ this._ensureRow();
784
+ for (const key of keys) this._s.addKey.run(this.id, _canonicalKey$1(key));
785
+ }
786
+ async deleteKey(key) {
787
+ return this._s.delKey.run(this.id, _canonicalKey$1(key)).changes > 0;
788
+ }
789
+ /** Internal: has this slot ever been created? */
790
+ async _exists() {
791
+ return this._s.slotExists.get(this.id) !== void 0;
792
+ }
793
+ async getSnooze() {
794
+ const row = this._s.getSnooze.get(this.id);
795
+ if (row === void 0 || row.snooze_scope === null || row.snooze_expires_at === null) return;
796
+ if (Date.now() >= row.snooze_expires_at) return void 0;
797
+ return {
798
+ scope: row.snooze_scope,
799
+ expiresAt: row.snooze_expires_at
800
+ };
801
+ }
802
+ async setSnooze(snooze) {
803
+ this._ensureRow();
804
+ this._s.setSnooze.run(snooze.scope, snooze.expiresAt, this.id);
805
+ }
806
+ async clearSnooze() {
807
+ this._s.clearSnooze.run(this.id);
808
+ }
809
+ async getFilesDir() {
810
+ const row = this._s.getFilesDir.get(this.id);
811
+ if (row === void 0 || row.files_dir === null) return void 0;
812
+ return row.files_dir;
813
+ }
814
+ async setFilesDir(absPath) {
815
+ this._ensureRow();
816
+ this._s.setFilesDir.run(absPath, this.id);
817
+ }
818
+ async hasFiles() {
819
+ const dir = await this.getFilesDir();
820
+ if (dir === void 0) return false;
821
+ try {
822
+ return (await fs.readdir(dir)).length > 0;
823
+ } catch (e) {
824
+ if (e.code === "ENOENT") return false;
825
+ throw e;
826
+ }
827
+ }
828
+ async wipeFiles() {
829
+ const dir = await this.getFilesDir();
830
+ if (dir === void 0) return;
831
+ let entries;
832
+ try {
833
+ entries = await fs.readdir(dir);
834
+ } catch (e) {
835
+ if (e.code === "ENOENT") return;
836
+ throw e;
837
+ }
838
+ await Promise.all(entries.map((name) => fs.rm(path.join(dir, name), {
839
+ recursive: true,
840
+ force: true
841
+ })));
842
+ }
843
+ async hasState() {
844
+ if (await this.peekIdentity()) return true;
845
+ const { n } = this._s.storCount.get(this.id);
846
+ if (n > 0) return true;
847
+ return this.hasFiles();
848
+ }
849
+ async delete() {
850
+ const filesDir = await this.getFilesDir().catch(() => void 0);
851
+ if (filesDir !== void 0) await fs.rm(filesDir, {
852
+ recursive: true,
853
+ force: true
854
+ });
855
+ this._s.delStorage.run(this.id);
856
+ this._s.delKeys.run(this.id);
857
+ this._s.delSlot.run(this.id);
858
+ }
859
+ _ensureRow() {
860
+ this._s.ensureSlot.run(this.id, Date.now());
861
+ }
862
+ };
863
+ /** Per-slot KV backing `identity.storage::*`, one row per key in `identity_storage`. */
864
+ var _SqliteSlotStorage = class {
865
+ _slotId;
866
+ _s;
867
+ constructor(_slotId, _s) {
868
+ this._slotId = _slotId;
869
+ this._s = _s;
870
+ }
871
+ async get(key) {
872
+ const row = this._s.storGet.get(this._slotId, key);
873
+ if (row === void 0) return void 0;
874
+ return JSON.parse(_codec.decode(this._slotId, row.value));
875
+ }
876
+ async set(key, value) {
877
+ this._s.ensureSlot.run(this._slotId, Date.now());
878
+ this._s.storSet.run(this._slotId, key, _codec.encode(this._slotId, JSON.stringify(value)));
879
+ }
880
+ async delete(key) {
881
+ return this._s.storDel.run(this._slotId, key).changes > 0;
882
+ }
883
+ async list(prefix) {
884
+ const keys = this._s.storKeys.all(this._slotId).map((r) => r.key);
885
+ return prefix === void 0 ? keys : keys.filter((k) => k.startsWith(prefix));
886
+ }
887
+ };
888
+ /** Canonical (sorted-field) JSON for exact access-key equality. */
889
+ function _canonicalKey$1(key) {
890
+ const entries = Object.keys(key).sort().map((k) => [k, key[k]]);
891
+ return JSON.stringify(entries);
892
+ }
893
+ /** Rebuild an access key from its canonical (sorted `[key, value]` pairs) JSON. */
894
+ function _fromCanonicalKey(canonical) {
895
+ const entries = JSON.parse(canonical);
896
+ return Object.fromEntries(entries);
897
+ }
898
+ //#endregion
899
+ //#region src/hub/server/identityKeystore.ts
900
+ const _FILE_SCHEMA_VERSION = 1;
901
+ const _DOMAIN = "hubrpc.identity.keystore.v1";
902
+ const _STORAGE_DOMAIN = "hubrpc.identity.storage.v1";
903
+ const _KEYS_DOMAIN = "hubrpc.identity.keys.v1";
904
+ const _SNOOZE_DOMAIN = "hubrpc.identity.snooze.v1";
905
+ const _FILESDIR_DOMAIN = "hubrpc.identity.filesdir.v1";
906
+ function createIdentityKeystore(opts) {
907
+ if (opts.keystoreSecret.length < 32) throw new Error("createIdentityKeystore: keystoreSecret must be at least 32 bytes");
908
+ return new _IdentityKeystoreImpl(opts);
909
+ }
910
+ var _IdentityKeystoreImpl = class {
911
+ _slots = /* @__PURE__ */ new Map();
912
+ _opts;
913
+ _dirReady = false;
914
+ constructor(opts) {
915
+ this._opts = opts;
916
+ }
917
+ slotById(id) {
918
+ return this._slot(id);
919
+ }
920
+ async slotByIdAndKey(id, key) {
921
+ const slot = this._slot(id);
922
+ if (!await slot._exists()) return { error: "slotDoesNotExist" };
923
+ if (!await slot.hasKey(key)) return { error: "unknownKey" };
924
+ return slot;
925
+ }
926
+ async slotByIdAndTime(id, nowMs) {
927
+ const slot = this._slot(id);
928
+ if (!await slot.hasState()) return slot;
929
+ const snooze = await slot.getSnooze();
930
+ if (snooze !== void 0 && snooze.expiresAt > nowMs) return slot;
931
+ return { error: "consentRequired" };
932
+ }
933
+ async createSlotWithKey(id, key) {
934
+ const slot = this._slot(id);
935
+ await slot.addKeys(key);
936
+ return slot;
937
+ }
938
+ _slot(id) {
939
+ let existing = this._slots.get(id);
940
+ if (existing) return existing;
941
+ const hash = createHash("sha256").update(id).digest("hex").slice(0, 16);
942
+ existing = new _IdentitySlotImpl({
943
+ id,
944
+ identityFile: path.join(this._opts.storageDir, `${hash}.bin`),
945
+ storageFile: path.join(this._opts.storageDir, `${hash}.storage.bin`),
946
+ keysFile: path.join(this._opts.storageDir, `${hash}.keys.bin`),
947
+ snoozeFile: path.join(this._opts.storageDir, `${hash}.snooze.bin`),
948
+ filesDirFile: path.join(this._opts.storageDir, `${hash}.filesdir.bin`),
949
+ keystoreSecret: this._opts.keystoreSecret,
950
+ ensureDir: () => this._ensureDir()
951
+ });
952
+ this._slots.set(id, existing);
953
+ return existing;
954
+ }
955
+ async _ensureDir() {
956
+ if (this._dirReady) return;
957
+ await fs.mkdir(this._opts.storageDir, {
958
+ recursive: true,
959
+ mode: 448
960
+ });
961
+ this._dirReady = true;
962
+ }
963
+ };
964
+ var _IdentitySlotImpl = class {
965
+ _deps;
966
+ _identity;
967
+ _identityLoaded = false;
968
+ _storage;
969
+ _keys;
970
+ _keysFileExists = false;
971
+ _keysLoaded = false;
972
+ _keysWriteChain = Promise.resolve();
973
+ constructor(_deps) {
974
+ this._deps = _deps;
975
+ this._storage = new _FileBackedStorage({
976
+ slot: _deps.id,
977
+ file: _deps.storageFile,
978
+ keystoreSecret: _deps.keystoreSecret,
979
+ ensureDir: _deps.ensureDir
980
+ });
981
+ }
982
+ get id() {
983
+ return this._deps.id;
984
+ }
985
+ get storage() {
986
+ return this._storage;
987
+ }
988
+ async getOrCreateIdentity() {
989
+ const existing = await this.peekIdentity();
990
+ if (existing) return existing;
991
+ const ed = await crypto.generateKeypair();
992
+ const wrap = await crypto.generateX25519Keypair();
993
+ await this._writeIdentity(ed, wrap);
994
+ const identity = new InMemoryManagedIdentity(ed, wrap);
995
+ this._identity = identity;
996
+ this._identityLoaded = true;
997
+ return identity;
998
+ }
999
+ async peekIdentity() {
1000
+ if (this._identityLoaded) return this._identity;
1001
+ let raw;
1002
+ try {
1003
+ raw = await fs.readFile(this._deps.identityFile);
1004
+ } catch (e) {
1005
+ if (e.code === "ENOENT") {
1006
+ this._identityLoaded = true;
1007
+ this._identity = void 0;
1008
+ return;
1009
+ }
1010
+ throw e;
1011
+ }
1012
+ const plain = await this._decryptIdentityPayload(raw);
1013
+ const ed = {
1014
+ privateKey: base64UrlToBytes(plain.ed25519.privateKey),
1015
+ publicKey: base64UrlToBytes(plain.ed25519.publicKey)
1016
+ };
1017
+ const wrap = {
1018
+ privateKey: base64UrlToBytes(plain.x25519.privateKey),
1019
+ publicKey: base64UrlToBytes(plain.x25519.publicKey)
1020
+ };
1021
+ const identity = new InMemoryManagedIdentity(ed, wrap);
1022
+ this._identity = identity;
1023
+ this._identityLoaded = true;
1024
+ return identity;
1025
+ }
1026
+ async hasState() {
1027
+ if (await this.peekIdentity()) return true;
1028
+ if ((await this._storage.list()).length > 0) return true;
1029
+ return this.hasFiles();
1030
+ }
1031
+ async listKeys() {
1032
+ return (await this._loadKeys()).map((k) => ({ ...k }));
1033
+ }
1034
+ async hasKey(key) {
1035
+ const keys = await this._loadKeys();
1036
+ const canon = _canonicalKey(key);
1037
+ return keys.some((k) => _canonicalKey(k) === canon);
1038
+ }
1039
+ async addKeys(...keys) {
1040
+ if (keys.length === 0) return;
1041
+ await this._mutateKeys((current) => {
1042
+ const seen = new Set(current.map((k) => _canonicalKey(k)));
1043
+ for (const key of keys) {
1044
+ const canon = _canonicalKey(key);
1045
+ if (!seen.has(canon)) {
1046
+ seen.add(canon);
1047
+ current.push({ ...key });
1048
+ }
1049
+ }
1050
+ });
1051
+ }
1052
+ async deleteKey(key) {
1053
+ const canon = _canonicalKey(key);
1054
+ let existed = false;
1055
+ await this._mutateKeys((current) => {
1056
+ const idx = current.findIndex((k) => _canonicalKey(k) === canon);
1057
+ if (idx >= 0) {
1058
+ existed = true;
1059
+ current.splice(idx, 1);
1060
+ }
1061
+ });
1062
+ return existed;
1063
+ }
1064
+ /** Internal: does this slot exist (i.e. has it ever been created)? */
1065
+ async _exists() {
1066
+ await this._loadKeys();
1067
+ return this._keysFileExists;
1068
+ }
1069
+ async getSnooze() {
1070
+ let raw;
1071
+ try {
1072
+ raw = await fs.readFile(this._deps.snoozeFile);
1073
+ } catch (e) {
1074
+ if (e.code === "ENOENT") return void 0;
1075
+ throw e;
1076
+ }
1077
+ const obj = await _aesDecrypt(this._deps.keystoreSecret, _SNOOZE_DOMAIN, this._deps.id, raw);
1078
+ if (obj.schemaVersion !== _FILE_SCHEMA_VERSION) throw new Error(`identity snooze: unsupported schemaVersion ${obj.schemaVersion}`);
1079
+ if (obj.slot !== this._deps.id) throw new Error("identity snooze: slot mismatch");
1080
+ if (Date.now() >= obj.expiresAt) return void 0;
1081
+ return {
1082
+ scope: obj.scope,
1083
+ expiresAt: obj.expiresAt
1084
+ };
1085
+ }
1086
+ async setSnooze(snooze) {
1087
+ await this._deps.ensureDir();
1088
+ const payload = {
1089
+ schemaVersion: _FILE_SCHEMA_VERSION,
1090
+ slot: this._deps.id,
1091
+ scope: snooze.scope,
1092
+ expiresAt: snooze.expiresAt
1093
+ };
1094
+ const ct = await _aesEncrypt(this._deps.keystoreSecret, _SNOOZE_DOMAIN, this._deps.id, payload);
1095
+ await fs.writeFile(this._deps.snoozeFile, ct, { mode: 384 });
1096
+ }
1097
+ async clearSnooze() {
1098
+ await _safeUnlink(this._deps.snoozeFile);
1099
+ }
1100
+ async getFilesDir() {
1101
+ let raw;
1102
+ try {
1103
+ raw = await fs.readFile(this._deps.filesDirFile);
1104
+ } catch (e) {
1105
+ if (e.code === "ENOENT") return void 0;
1106
+ throw e;
1107
+ }
1108
+ const obj = await _aesDecrypt(this._deps.keystoreSecret, _FILESDIR_DOMAIN, this._deps.id, raw);
1109
+ if (obj.schemaVersion !== _FILE_SCHEMA_VERSION) throw new Error(`identity filesdir: unsupported schemaVersion ${obj.schemaVersion}`);
1110
+ if (obj.slot !== this._deps.id) throw new Error("identity filesdir: slot mismatch");
1111
+ return obj.dir;
1112
+ }
1113
+ async setFilesDir(absPath) {
1114
+ await this._deps.ensureDir();
1115
+ const payload = {
1116
+ schemaVersion: _FILE_SCHEMA_VERSION,
1117
+ slot: this._deps.id,
1118
+ dir: absPath,
1119
+ updatedAt: Date.now()
1120
+ };
1121
+ const ct = await _aesEncrypt(this._deps.keystoreSecret, _FILESDIR_DOMAIN, this._deps.id, payload);
1122
+ await fs.writeFile(this._deps.filesDirFile, ct, { mode: 384 });
1123
+ }
1124
+ async hasFiles() {
1125
+ const dir = await this.getFilesDir();
1126
+ if (dir === void 0) return false;
1127
+ try {
1128
+ return (await fs.readdir(dir)).length > 0;
1129
+ } catch (e) {
1130
+ if (e.code === "ENOENT") return false;
1131
+ throw e;
1132
+ }
1133
+ }
1134
+ async wipeFiles() {
1135
+ const dir = await this.getFilesDir();
1136
+ if (dir === void 0) return;
1137
+ let entries;
1138
+ try {
1139
+ entries = await fs.readdir(dir);
1140
+ } catch (e) {
1141
+ if (e.code === "ENOENT") return;
1142
+ throw e;
1143
+ }
1144
+ await Promise.all(entries.map((name) => fs.rm(path.join(dir, name), {
1145
+ recursive: true,
1146
+ force: true
1147
+ })));
1148
+ }
1149
+ async delete() {
1150
+ this._identity = void 0;
1151
+ this._identityLoaded = false;
1152
+ this._keys = void 0;
1153
+ this._keysLoaded = false;
1154
+ this._keysFileExists = false;
1155
+ this._storage._invalidate();
1156
+ const filesDir = await this.getFilesDir().catch(() => void 0);
1157
+ if (filesDir !== void 0) await fs.rm(filesDir, {
1158
+ recursive: true,
1159
+ force: true
1160
+ });
1161
+ await _safeUnlink(this._deps.identityFile);
1162
+ await _safeUnlink(this._deps.storageFile);
1163
+ await _safeUnlink(this._deps.keysFile);
1164
+ await _safeUnlink(this._deps.snoozeFile);
1165
+ await _safeUnlink(this._deps.filesDirFile);
1166
+ }
1167
+ async _writeIdentity(ed, wrap) {
1168
+ await this._deps.ensureDir();
1169
+ const payload = {
1170
+ schemaVersion: _FILE_SCHEMA_VERSION,
1171
+ slot: this._deps.id,
1172
+ ed25519: {
1173
+ privateKey: bytesToBase64Url(ed.privateKey),
1174
+ publicKey: bytesToBase64Url(ed.publicKey)
1175
+ },
1176
+ x25519: {
1177
+ privateKey: bytesToBase64Url(wrap.privateKey),
1178
+ publicKey: bytesToBase64Url(wrap.publicKey)
1179
+ },
1180
+ createdAt: Date.now()
1181
+ };
1182
+ const ct = await _aesEncrypt(this._deps.keystoreSecret, _DOMAIN, this._deps.id, payload);
1183
+ await fs.writeFile(this._deps.identityFile, ct, { mode: 384 });
1184
+ }
1185
+ async _decryptIdentityPayload(raw) {
1186
+ const obj = await _aesDecrypt(this._deps.keystoreSecret, _DOMAIN, this._deps.id, raw);
1187
+ if (obj.schemaVersion !== _FILE_SCHEMA_VERSION) throw new Error(`identity keystore: unsupported schemaVersion ${obj.schemaVersion}`);
1188
+ if (obj.slot !== this._deps.id) throw new Error("identity keystore: slot mismatch");
1189
+ return obj;
1190
+ }
1191
+ async _loadKeys() {
1192
+ if (this._keysLoaded) return this._keys;
1193
+ let raw;
1194
+ try {
1195
+ raw = await fs.readFile(this._deps.keysFile);
1196
+ } catch (e) {
1197
+ if (e.code === "ENOENT") {
1198
+ this._keys = [];
1199
+ this._keysFileExists = false;
1200
+ this._keysLoaded = true;
1201
+ return this._keys;
1202
+ }
1203
+ throw e;
1204
+ }
1205
+ const obj = await _aesDecrypt(this._deps.keystoreSecret, _KEYS_DOMAIN, this._deps.id, raw);
1206
+ if (obj.schemaVersion !== _FILE_SCHEMA_VERSION) throw new Error(`identity keys: unsupported schemaVersion ${obj.schemaVersion}`);
1207
+ if (obj.slot !== this._deps.id) throw new Error("identity keys: slot mismatch");
1208
+ this._keys = obj.keys.map((k) => ({ ...k }));
1209
+ this._keysFileExists = true;
1210
+ this._keysLoaded = true;
1211
+ return this._keys;
1212
+ }
1213
+ async _mutateKeys(fn) {
1214
+ const next = this._keysWriteChain.then(async () => {
1215
+ const keys = await this._loadKeys();
1216
+ fn(keys);
1217
+ await this._persistKeys(keys);
1218
+ });
1219
+ this._keysWriteChain = next.catch(() => {});
1220
+ return next;
1221
+ }
1222
+ async _persistKeys(keys) {
1223
+ await this._deps.ensureDir();
1224
+ const payload = {
1225
+ schemaVersion: _FILE_SCHEMA_VERSION,
1226
+ slot: this._deps.id,
1227
+ keys: keys.map((k) => ({ ...k })),
1228
+ updatedAt: Date.now()
1229
+ };
1230
+ const ct = await _aesEncrypt(this._deps.keystoreSecret, _KEYS_DOMAIN, this._deps.id, payload);
1231
+ await fs.writeFile(this._deps.keysFile, ct, { mode: 384 });
1232
+ this._keysFileExists = true;
1233
+ }
1234
+ };
1235
+ /** Canonical (sorted-field) JSON for exact access-key equality. */
1236
+ function _canonicalKey(key) {
1237
+ const entries = Object.keys(key).sort().map((k) => [k, key[k]]);
1238
+ return JSON.stringify(entries);
1239
+ }
1240
+ /**
1241
+ * File-backed per-slot storage. Lazily reads the encrypted file on first
1242
+ * access and caches the decrypted map in memory. Writes serialize
1243
+ * through `_writeChain` so concurrent `set`/`delete` calls don't
1244
+ * overwrite each other.
1245
+ */
1246
+ var _FileBackedStorage = class {
1247
+ _opts;
1248
+ _data;
1249
+ _loaded = false;
1250
+ _writeChain = Promise.resolve();
1251
+ constructor(_opts) {
1252
+ this._opts = _opts;
1253
+ }
1254
+ /**
1255
+ * Drop the cached map (called by the slot's `delete()` so the next
1256
+ * `get` re-reads from disk, where the file is now gone).
1257
+ */
1258
+ _invalidate() {
1259
+ this._data = void 0;
1260
+ this._loaded = false;
1261
+ }
1262
+ async get(key) {
1263
+ const data = await this._load();
1264
+ return Object.prototype.hasOwnProperty.call(data, key) ? data[key] : void 0;
1265
+ }
1266
+ async set(key, value) {
1267
+ await this._mutate((data) => {
1268
+ data[key] = value;
1269
+ });
1270
+ }
1271
+ async delete(key) {
1272
+ let existed = false;
1273
+ await this._mutate((data) => {
1274
+ existed = Object.prototype.hasOwnProperty.call(data, key);
1275
+ if (existed) delete data[key];
1276
+ });
1277
+ return existed;
1278
+ }
1279
+ async list(prefix) {
1280
+ const data = await this._load();
1281
+ const keys = Object.keys(data);
1282
+ return prefix === void 0 ? keys : keys.filter((k) => k.startsWith(prefix));
1283
+ }
1284
+ async _load() {
1285
+ if (this._loaded) return this._data;
1286
+ let raw;
1287
+ try {
1288
+ raw = await fs.readFile(this._opts.file);
1289
+ } catch (e) {
1290
+ if (e.code === "ENOENT") {
1291
+ this._data = {};
1292
+ this._loaded = true;
1293
+ return this._data;
1294
+ }
1295
+ throw e;
1296
+ }
1297
+ const obj = await _aesDecrypt(this._opts.keystoreSecret, _STORAGE_DOMAIN, this._opts.slot, raw);
1298
+ if (obj.schemaVersion !== _FILE_SCHEMA_VERSION) throw new Error(`identity storage: unsupported schemaVersion ${obj.schemaVersion}`);
1299
+ if (obj.slot !== this._opts.slot) throw new Error("identity storage: slot mismatch");
1300
+ this._data = { ...obj.entries };
1301
+ this._loaded = true;
1302
+ return this._data;
1303
+ }
1304
+ async _mutate(fn) {
1305
+ const next = this._writeChain.then(async () => {
1306
+ const data = await this._load();
1307
+ fn(data);
1308
+ await this._persist(data);
1309
+ });
1310
+ this._writeChain = next.catch(() => {});
1311
+ return next;
1312
+ }
1313
+ async _persist(data) {
1314
+ await this._opts.ensureDir();
1315
+ const payload = {
1316
+ schemaVersion: _FILE_SCHEMA_VERSION,
1317
+ slot: this._opts.slot,
1318
+ entries: data,
1319
+ updatedAt: Date.now()
1320
+ };
1321
+ const ct = await _aesEncrypt(this._opts.keystoreSecret, _STORAGE_DOMAIN, this._opts.slot, payload);
1322
+ await fs.writeFile(this._opts.file, ct, { mode: 384 });
1323
+ }
1324
+ };
1325
+ async function _aesEncrypt(secret, domain, slot, payload) {
1326
+ const pt = new TextEncoder().encode(JSON.stringify(payload));
1327
+ const { createCipheriv, randomBytes } = await import("node:crypto");
1328
+ const key = await _deriveKey(secret, domain);
1329
+ const iv = randomBytes(12);
1330
+ const cipher = createCipheriv("aes-256-gcm", key, iv);
1331
+ cipher.setAAD(new TextEncoder().encode(`${domain}:${slot}`));
1332
+ const enc = Buffer.concat([cipher.update(pt), cipher.final()]);
1333
+ const tag = cipher.getAuthTag();
1334
+ const out = new Uint8Array(28 + enc.length);
1335
+ out.set(iv, 0);
1336
+ out.set(tag, 12);
1337
+ out.set(enc, 28);
1338
+ return out;
1339
+ }
1340
+ async function _aesDecrypt(secret, domain, slot, raw) {
1341
+ if (raw.length < 28) throw new Error(`${domain}: file too short`);
1342
+ const iv = raw.subarray(0, 12);
1343
+ const tag = raw.subarray(12, 28);
1344
+ const ct = raw.subarray(28);
1345
+ const { createDecipheriv } = await import("node:crypto");
1346
+ const decipher = createDecipheriv("aes-256-gcm", await _deriveKey(secret, domain), iv);
1347
+ decipher.setAAD(new TextEncoder().encode(`${domain}:${slot}`));
1348
+ decipher.setAuthTag(tag);
1349
+ const pt = Buffer.concat([decipher.update(ct), decipher.final()]);
1350
+ return JSON.parse(pt.toString("utf8"));
1351
+ }
1352
+ async function _deriveKey(secret, domain) {
1353
+ const { createHmac } = await import("node:crypto");
1354
+ const prk = createHmac("sha256", Buffer.alloc(32)).update(secret).digest();
1355
+ const info = Buffer.concat([Buffer.from(domain), Buffer.from([1])]);
1356
+ return createHmac("sha256", prk).update(info).digest();
1357
+ }
1358
+ async function _safeUnlink(file) {
1359
+ try {
1360
+ await fs.unlink(file);
1361
+ } catch (e) {
1362
+ if (e.code !== "ENOENT") throw e;
1363
+ }
1364
+ }
1365
+ //#endregion
1366
+ export { TokenIdentityStore as a, durationToExp as c, fetchFullDirectory as d, resolveAccessCandidates as f, withProvenance as i, mintCapability as l, hubRegisterServiceId as m, createSqliteIdentityKeystore as n, registerHubAccessService as o, RegisteredServiceId as p, PrincipalIdPrefixPolicy as r, CapabilityProposalIssuer as s, createIdentityKeystore as t, candidatesForSlot as u };
1367
+
1368
+ //# sourceMappingURL=server-BAxchQhy.js.map