pinomad 0.3.0 → 0.4.0

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.
@@ -2,14 +2,14 @@
2
2
  import {
3
3
  restartService,
4
4
  serviceManager
5
- } from "./chunk-t9y2wfk5.js";
5
+ } from "./chunk-akgxwg8f.js";
6
6
  import {
7
7
  globalPrefixOf,
8
8
  packageDir,
9
9
  packagedVersion
10
- } from "./chunk-3tf23vjq.js";
11
- import"./chunk-bggesvnj.js";
12
- import"./chunk-c155p0t0.js";
10
+ } from "./chunk-fgx2sh7d.js";
11
+ import"./chunk-374zqx5h.js";
12
+ import"./chunk-myjp910s.js";
13
13
  import"./chunk-cjfmfyjb.js";
14
14
  import"./chunk-df32avw6.js";
15
15
 
@@ -0,0 +1,50 @@
1
+ // apps/host/src/server-config.ts
2
+ import { randomBytes } from "node:crypto";
3
+ import { readFile, rename, rm, writeFile } from "node:fs/promises";
4
+ import { join } from "node:path";
5
+ var SERVER_CONFIG_FILE = "server.json";
6
+ var normalizeOrigin = (origin) => origin.replace(/\/+$/, "");
7
+ async function readServerConfig(dataDir) {
8
+ let text;
9
+ try {
10
+ text = await readFile(join(dataDir, SERVER_CONFIG_FILE), "utf8");
11
+ } catch (error) {
12
+ if (error.code === "ENOENT")
13
+ return;
14
+ throw error;
15
+ }
16
+ let value;
17
+ try {
18
+ value = JSON.parse(text);
19
+ } catch {
20
+ throw new Error(`${join(dataDir, SERVER_CONFIG_FILE)} is not valid JSON — delete it or run \`pinomad leave\``);
21
+ }
22
+ const origin = value?.origin;
23
+ if (typeof origin !== "string" || origin.trim() === "") {
24
+ throw new Error(`${join(dataDir, SERVER_CONFIG_FILE)} is missing a string "origin"`);
25
+ }
26
+ const binding = value.binding;
27
+ return {
28
+ origin,
29
+ ...typeof binding === "string" && binding !== "" ? { binding } : {}
30
+ };
31
+ }
32
+ async function writeServerConfig(dataDir, config) {
33
+ const path = join(dataDir, SERVER_CONFIG_FILE);
34
+ const tmp = `${path}.tmp-${process.pid}`;
35
+ await writeFile(tmp, `${JSON.stringify({ origin: normalizeOrigin(config.origin), binding: randomBytes(16).toString("base64url") })}
36
+ `, { mode: 384 });
37
+ await rename(tmp, path);
38
+ }
39
+ var removeServerConfig = (dataDir) => rm(join(dataDir, SERVER_CONFIG_FILE), { force: true });
40
+ async function readServerBinding(dataDir) {
41
+ return (await readServerConfig(dataDir))?.binding;
42
+ }
43
+ function resolveRelayOrigin(flag, config) {
44
+ if (flag !== undefined && config !== undefined && normalizeOrigin(flag) !== normalizeOrigin(config.origin)) {
45
+ throw new Error(`--relay ${flag} disagrees with ${SERVER_CONFIG_FILE} (${config.origin}) — remove one`);
46
+ }
47
+ return flag ?? config?.origin;
48
+ }
49
+
50
+ export { normalizeOrigin, readServerConfig, writeServerConfig, removeServerConfig, readServerBinding, resolveRelayOrigin };
@@ -3,105 +3,19 @@ import {
3
3
  INVALID_FRAME_CLOSE_CODE,
4
4
  PROTOCOL,
5
5
  UNAUTHORIZED_CLOSE_CODE,
6
+ checkoutOf,
6
7
  conversationStream,
7
8
  docStream,
9
+ homeOf,
8
10
  hostStreams,
9
11
  hostSupportsCall,
10
- hostSupportsFeature
11
- } from "./chunk-c155p0t0.js";
12
+ hostSupportsFeature,
13
+ organize
14
+ } from "./chunk-myjp910s.js";
12
15
 
13
16
  // packages/protocol/src/remote-durable.ts
14
17
  import { applyImmutable } from "@earendil-works/chord/delta";
15
18
 
16
- // packages/protocol/src/organization.ts
17
- function organize(index, summaries) {
18
- const summaryById = new Map(summaries.map((summary) => [summary.id, summary]));
19
- const nodes = new Map;
20
- const topLevel = new Map;
21
- for (const entry of index.conversations) {
22
- if (entry.archived === true)
23
- continue;
24
- const home = entry.home;
25
- if (home.kind === "project" && !index.projects.some((project) => project.path === home.path))
26
- continue;
27
- const summary = summaryById.get(entry.id);
28
- if (summary === undefined)
29
- continue;
30
- topLevel.set(entry.id, entry);
31
- nodes.set(entry.id, { summary, children: [] });
32
- }
33
- for (const summary of summaries) {
34
- const chain = [summary];
35
- let current = summary;
36
- while (!topLevel.has(current.id)) {
37
- const parent = current.parent === undefined ? undefined : summaryById.get(current.parent);
38
- if (parent === undefined || chain.includes(parent))
39
- break;
40
- chain.push(parent);
41
- current = parent;
42
- }
43
- if (!topLevel.has(current.id))
44
- continue;
45
- for (let i = chain.length - 1;i >= 0; i--) {
46
- const member = chain[i];
47
- if (!nodes.has(member.id))
48
- nodes.set(member.id, { summary: member, children: [] });
49
- if (i < chain.length - 1) {
50
- const parent = nodes.get(chain[i + 1].id);
51
- const child = nodes.get(member.id);
52
- if (!parent.children.includes(child))
53
- parent.children.push(child);
54
- }
55
- }
56
- }
57
- const byIdAscending = (a, b) => a.summary.id - b.summary.id;
58
- const nodeList = (entry) => sortChildren(nodes.get(entry.id));
59
- const sortChildren = (node) => ({
60
- summary: node.summary,
61
- children: [...node.children].sort(byIdAscending).map(sortChildren)
62
- });
63
- const entries = [...topLevel.values()].sort((a, b) => b.createdAt - a.createdAt);
64
- return {
65
- chats: entries.filter((entry) => entry.home.kind === "chat").map(nodeList),
66
- projects: [...index.projects].sort((a, b) => b.addedAt - a.addedAt).map((project) => ({
67
- project,
68
- conversations: entries.filter((entry) => entry.home.kind === "project" && entry.home.path === project.path).map(nodeList)
69
- }))
70
- };
71
- }
72
- function checkoutOf(index, summaries, id) {
73
- const record = index.checkouts?.find((checkout) => checkout.conversationId === id);
74
- if (record !== undefined)
75
- return record;
76
- const summaryById = new Map(summaries.map((summary) => [summary.id, summary]));
77
- const seen = new Set;
78
- let current = summaryById.get(id);
79
- while (current !== undefined && !seen.has(current.id)) {
80
- seen.add(current.id);
81
- const ancestor = index.checkouts?.find((checkout) => checkout.conversationId === current.id);
82
- if (ancestor !== undefined)
83
- return ancestor;
84
- current = current.parent === undefined ? undefined : summaryById.get(current.parent);
85
- }
86
- return;
87
- }
88
- function homeOf(index, summaries, id) {
89
- const entry = index.conversations.find((candidate) => candidate.id === id);
90
- if (entry !== undefined)
91
- return entry.home;
92
- const summaryById = new Map(summaries.map((summary) => [summary.id, summary]));
93
- const seen = new Set;
94
- let current = summaryById.get(id);
95
- while (current !== undefined && !seen.has(current.id)) {
96
- seen.add(current.id);
97
- const ancestor = index.conversations.find((candidate) => candidate.id === current.id);
98
- if (ancestor !== undefined)
99
- return ancestor.home;
100
- current = current.parent === undefined ? undefined : summaryById.get(current.parent);
101
- }
102
- return;
103
- }
104
-
105
19
  // packages/protocol/src/transport.ts
106
20
  function webSocketTransport(url, token) {
107
21
  return {
@@ -338,7 +252,7 @@ class RemoteClient {
338
252
  Promise.all(hostStreams(hostMinor).map((stream) => this.#snapshot(stream))).then(() => {
339
253
  this.#state = {
340
254
  session: hello.session,
341
- organized: { chats: [], projects: [] },
255
+ organized: { chats: [], projects: [], archived: [] },
342
256
  models: this.#host("models") ?? hello.models,
343
257
  defaults: this.#effectiveDefaults(hello.defaults ?? {}),
344
258
  notices: [],
@@ -608,6 +522,9 @@ class RemoteClient {
608
522
  if (!hostSupportsCall(this.#hostMinor, "updateSettings")) {
609
523
  throw new Error("The host is too old for this (needs protocol 5.2); run `pinomad upgrade` on the host");
610
524
  }
525
+ if (patch.acceptAccountDevices !== undefined && !hostSupportsFeature(this.#hostMinor, "acceptAccountDevices")) {
526
+ throw new Error("The host is too old for this (needs protocol 5.8); run `pinomad upgrade` on the host");
527
+ }
611
528
  return this.#call("updateSettings", { patch });
612
529
  }),
613
530
  createAgent: (role) => this.#request(async () => {
@@ -627,6 +544,12 @@ class RemoteClient {
627
544
  throw new Error("The host is too old for this (needs protocol 5.6); run `pinomad upgrade` on the host");
628
545
  }
629
546
  await this.#call("deleteAgent", { name });
547
+ }),
548
+ setPushToken: (token) => this.#request(async () => {
549
+ if (!hostSupportsCall(this.#hostMinor, "setPushToken")) {
550
+ throw new Error("The host is too old for this (needs protocol 5.7); run `pinomad upgrade` on the host");
551
+ }
552
+ await this.#call("setPushToken", { token });
630
553
  })
631
554
  };
632
555
  }
@@ -0,0 +1,128 @@
1
+ import {
2
+ relayWsBase
3
+ } from "./chunk-knaw1a9g.js";
4
+ import {
5
+ RELAY_CLOSE,
6
+ decodeRelayToHost,
7
+ encodeRelayFrame,
8
+ relayHostId,
9
+ signRelayChallenge,
10
+ signRelayChallengeV2,
11
+ toBase64Url
12
+ } from "./chunk-7gmkgyaz.js";
13
+
14
+ // apps/host/src/cli/server-ops.ts
15
+ import { WebSocket } from "ws";
16
+
17
+ // packages/client/src/account.ts
18
+ var fallbackFetch = (url, init) => fetch(url, init);
19
+ async function probeAccounts(origin, fetchImpl = fallbackFetch) {
20
+ const timeout = new AbortController;
21
+ const timer = setTimeout(() => timeout.abort(), 5000);
22
+ try {
23
+ const response = await fetchImpl(`${origin.replace(/\/+$/, "")}/api/me`, { signal: timeout.signal });
24
+ if (response.status === 401)
25
+ return "enabled";
26
+ if (response.status === 200) {
27
+ const text = await response.text();
28
+ let body;
29
+ try {
30
+ body = JSON.parse(text);
31
+ } catch {
32
+ return "disabled";
33
+ }
34
+ return typeof body?.user === "object" && body.user !== null ? "enabled" : "disabled";
35
+ }
36
+ return response.status === 404 ? "disabled" : "unreachable";
37
+ } catch {
38
+ return "unreachable";
39
+ } finally {
40
+ clearTimeout(timer);
41
+ }
42
+ }
43
+ function isSecureServerOrigin(origin) {
44
+ let url;
45
+ try {
46
+ url = new URL(origin);
47
+ } catch {
48
+ return false;
49
+ }
50
+ const host = url.hostname;
51
+ const loopback = host === "localhost" || host.endsWith(".localhost") || host.startsWith("127.") || host === "::1" || host === "[::1]";
52
+ return url.protocol === "https:" || url.protocol === "http:" && loopback;
53
+ }
54
+
55
+ // apps/host/src/cli/server-ops.ts
56
+ function assertSecureServerOrigin(origin) {
57
+ try {
58
+ new URL(origin);
59
+ } catch {
60
+ throw new Error(`not a URL: ${origin}`);
61
+ }
62
+ if (isSecureServerOrigin(origin))
63
+ return;
64
+ throw new Error(`account operations need an https origin (loopback http allowed for development) — got ${origin}`);
65
+ }
66
+ var probeServerAccounts = (origin, fetchImpl = fetch) => probeAccounts(origin, fetchImpl);
67
+ var closeReason = (code, origin) => {
68
+ switch (code) {
69
+ case RELAY_CLOSE.badSignature:
70
+ return `the server rejected this host's registration signature (${code})`;
71
+ case RELAY_CLOSE.hostNotAllowed:
72
+ return `the server does not know this host (${code}) — a pre-accounts server can't do join/leave; upgrade it first`;
73
+ default:
74
+ return `the connection to ${origin} closed (${code})`;
75
+ }
76
+ };
77
+ async function runServerOperation(options) {
78
+ const origin = options.origin.replace(/\/+$/, "");
79
+ assertSecureServerOrigin(origin);
80
+ const hostId = relayHostId(options.signingKey.publicKey);
81
+ const timeoutMs = options.timeoutMs ?? 1e4;
82
+ const socket = new WebSocket(`${relayWsBase(origin)}/host`, { handshakeTimeout: timeoutMs });
83
+ return await new Promise((resolve, reject) => {
84
+ const finish = (done) => {
85
+ clearTimeout(timer);
86
+ socket.close();
87
+ done();
88
+ };
89
+ const timer = setTimeout(() => finish(() => reject(new Error(`no answer from ${origin} within ${timeoutMs / 1000}s`))), timeoutMs);
90
+ socket.once("error", () => {});
91
+ socket.once("close", (code) => finish(() => reject(new Error(closeReason(code, origin)))));
92
+ socket.on("message", (data, isBinary) => {
93
+ let frame;
94
+ try {
95
+ if (isBinary)
96
+ throw new Error("binary frame");
97
+ frame = decodeRelayToHost(data.toString());
98
+ } catch {
99
+ finish(() => reject(new Error(`${origin} is not speaking the pinomad relay protocol`)));
100
+ return;
101
+ }
102
+ if (frame.t === "challenge") {
103
+ const noisePublicKey = toBase64Url(options.noisePublicKey);
104
+ const challenge = { relayOrigin: origin, hostId, nonce: frame.nonce };
105
+ socket.send(encodeRelayFrame({
106
+ t: "register",
107
+ hostId,
108
+ signature: signRelayChallenge(options.signingKey.secretKey, challenge),
109
+ signatureV2: signRelayChallengeV2(options.signingKey.secretKey, { ...challenge, noisePublicKey }),
110
+ noisePublicKey,
111
+ op: options.op,
112
+ ...options.joinToken === undefined ? {} : { joinToken: options.joinToken },
113
+ ...options.name === undefined ? {} : { name: options.name }
114
+ }));
115
+ return;
116
+ }
117
+ if (frame.t === "registered") {
118
+ finish(() => reject(new Error(`${origin} ignored the operation — the server is too old for accounts; upgrade it first`)));
119
+ return;
120
+ }
121
+ if (frame.t === "incoming" || frame.t === "pairing-request")
122
+ return;
123
+ finish(() => resolve(frame));
124
+ });
125
+ });
126
+ }
127
+
128
+ export { assertSecureServerOrigin, probeServerAccounts, runServerOperation };
@@ -290,6 +290,7 @@ function encodeRelayFrame(frame) {
290
290
  return JSON.stringify(frame);
291
291
  }
292
292
  var isString2 = (value) => typeof value === "string";
293
+ var isBoundedString = (value, max = 64) => isString2(value) && value.length > 0 && value.length <= max;
293
294
  function decodeJson2(text) {
294
295
  try {
295
296
  return JSON.parse(text);
@@ -310,16 +311,71 @@ function decodeRelayToHost(text) {
310
311
  if (!isString2(value.connId))
311
312
  throw new Error("malformed incoming");
312
313
  return { t: "incoming", connId: value.connId };
314
+ case "pairing-request":
315
+ if (!isBoundedString(value.requestId))
316
+ throw new Error("malformed pairing-request");
317
+ return { t: "pairing-request", requestId: value.requestId };
318
+ case "joined":
319
+ if (!isString2(value.username))
320
+ throw new Error("malformed joined");
321
+ return { t: "joined", username: value.username };
322
+ case "left":
323
+ return { t: "left" };
324
+ case "refused":
325
+ if (!isString2(value.reason))
326
+ throw new Error("malformed refused");
327
+ return { t: "refused", reason: value.reason };
313
328
  default:
314
329
  throw new Error("unknown relay frame");
315
330
  }
316
331
  }
317
332
  function decodeHostToRelay(text) {
318
333
  const value = decodeJson2(text);
319
- if (value?.t !== "register" || !isString2(value.hostId) || !isString2(value.signature)) {
334
+ switch (value?.t) {
335
+ case "register":
336
+ return decodeRegister(value);
337
+ case "pairing":
338
+ if (!isBoundedString(value.requestId) || !isBoundedString(value.secret) || typeof value.expiresAt !== "number" || !Number.isFinite(value.expiresAt)) {
339
+ throw new Error("malformed pairing");
340
+ }
341
+ return { t: "pairing", requestId: value.requestId, secret: value.secret, expiresAt: value.expiresAt };
342
+ case "pairing-refused":
343
+ if (!isBoundedString(value.requestId) || !isBoundedString(value.reason)) {
344
+ throw new Error("malformed pairing-refused");
345
+ }
346
+ return { t: "pairing-refused", requestId: value.requestId, reason: value.reason };
347
+ default:
348
+ throw new Error("unknown host frame");
349
+ }
350
+ }
351
+ function decodeRegister(value) {
352
+ if (value.t !== "register" || !isString2(value.hostId) || !isString2(value.signature)) {
353
+ throw new Error("malformed register");
354
+ }
355
+ if (value.op !== undefined && value.op !== "join" && value.op !== "leave")
356
+ throw new Error("malformed register");
357
+ if (value.signatureV2 !== undefined && !isString2(value.signatureV2))
358
+ throw new Error("malformed register");
359
+ if (value.noisePublicKey !== undefined && !isString2(value.noisePublicKey))
360
+ throw new Error("malformed register");
361
+ if (value.joinToken !== undefined && !isString2(value.joinToken))
362
+ throw new Error("malformed register");
363
+ if (value.name !== undefined && !isString2(value.name))
364
+ throw new Error("malformed register");
365
+ if (value.features !== undefined && (!Array.isArray(value.features) || value.features.length > 16 || !value.features.every((feature) => isString2(feature) && feature.length <= 64))) {
320
366
  throw new Error("malformed register");
321
367
  }
322
- return { t: "register", hostId: value.hostId, signature: value.signature };
368
+ return {
369
+ t: "register",
370
+ hostId: value.hostId,
371
+ signature: value.signature,
372
+ ...value.signatureV2 === undefined ? {} : { signatureV2: value.signatureV2 },
373
+ ...value.noisePublicKey === undefined ? {} : { noisePublicKey: value.noisePublicKey },
374
+ ...value.op === undefined ? {} : { op: value.op },
375
+ ...value.joinToken === undefined ? {} : { joinToken: value.joinToken },
376
+ ...value.name === undefined ? {} : { name: value.name },
377
+ ...value.features === undefined ? {} : { features: value.features }
378
+ };
323
379
  }
324
380
  function relayHostId(publicKey) {
325
381
  if (publicKey.length !== 32)
@@ -343,5 +399,22 @@ function verifyRelayChallenge(signature, args) {
343
399
  return false;
344
400
  }
345
401
  }
402
+ function relayChallengeMessageV2(args) {
403
+ return utf82.encode(`pinomad-relay/2
404
+ ${args.relayOrigin}
405
+ ${args.hostId}
406
+ ${args.nonce}
407
+ ${args.noisePublicKey}`);
408
+ }
409
+ function signRelayChallengeV2(secretKey, args) {
410
+ return toBase64Url(ed25519.sign(relayChallengeMessageV2(args), secretKey));
411
+ }
412
+ function verifyRelayChallengeV2(signature, args) {
413
+ try {
414
+ return ed25519.verify(fromBase64Url(signature), relayChallengeMessageV2(args), fromBase64Url(args.hostId));
415
+ } catch {
416
+ return false;
417
+ }
418
+ }
346
419
 
347
- export { generateKeyPair, keyPairFromPrivate, respondIK, SECURE_PROLOGUE, encodeHostHello, decodeClientHello, toBase64Url, fromBase64Url, SecureSession, RELAY_CLOSE, encodeRelayFrame, decodeRelayToHost, decodeHostToRelay, relayHostId, signRelayChallenge, verifyRelayChallenge };
420
+ export { generateKeyPair, keyPairFromPrivate, respondIK, SECURE_PROLOGUE, encodeHostHello, decodeClientHello, toBase64Url, fromBase64Url, SecureSession, RELAY_CLOSE, encodeRelayFrame, decodeRelayToHost, decodeHostToRelay, relayHostId, signRelayChallenge, verifyRelayChallenge, signRelayChallengeV2, verifyRelayChallengeV2 };
@@ -0,0 +1,138 @@
1
+ import {
2
+ fromBase64Url,
3
+ generateKeyPair,
4
+ keyPairFromPrivate,
5
+ toBase64Url
6
+ } from "./chunk-7gmkgyaz.js";
7
+
8
+ // apps/host/src/devices.ts
9
+ import { readFile, writeFile } from "node:fs/promises";
10
+ import { join } from "node:path";
11
+ import { randomBytes, timingSafeEqual } from "node:crypto";
12
+ import { ed25519 } from "@noble/curves/ed25519.js";
13
+ import { defineDoc } from "@earendil-works/pi-durable";
14
+ var DevicesDoc = defineDoc({
15
+ kind: "pinomad.devices",
16
+ version: 1,
17
+ scope: "session",
18
+ initial: () => ({ devices: [] })
19
+ });
20
+ async function ensureDevices(harness, context) {
21
+ await harness.commit(async (tx) => void await tx.doc(DevicesDoc), context);
22
+ }
23
+ var RevocationsDoc = defineDoc({
24
+ kind: "pinomad.revocations",
25
+ version: 1,
26
+ scope: "session",
27
+ initial: () => ({ revoked: [] })
28
+ });
29
+ async function admitDevice(harness, input, context) {
30
+ return await harness.commit(async (tx) => {
31
+ const devices = await tx.doc(DevicesDoc);
32
+ if (devices.devices.some((entry2) => entry2.publicKey === input.publicKey)) {
33
+ return { outcome: "registered" };
34
+ }
35
+ if (input.pair === undefined)
36
+ return { outcome: "refused" };
37
+ const offer = input.offers.consume(input.pair.secret);
38
+ if (offer === undefined)
39
+ return { outcome: "refused" };
40
+ const revocations = await tx.doc(RevocationsDoc);
41
+ if (offer.source === "account") {
42
+ const binding = await input.serverBinding();
43
+ if (revocations.revoked.includes(input.publicKey) || binding === undefined || offer.binding !== binding || !input.acceptAccountDevices()) {
44
+ return { outcome: "refused" };
45
+ }
46
+ }
47
+ const name = input.pair.name.trim().slice(0, 64) || "Unknown device";
48
+ const entry = { publicKey: input.publicKey, name, pairedAt: Date.now(), source: offer.source };
49
+ devices.devices.push(entry);
50
+ if (revocations.revoked.includes(input.publicKey)) {
51
+ revocations.revoked = revocations.revoked.filter((key) => key !== input.publicKey);
52
+ }
53
+ return { outcome: "admitted", entry };
54
+ }, context);
55
+ }
56
+ async function loadHostKey(dataDir) {
57
+ const path = join(dataDir, "host-key");
58
+ try {
59
+ const text = (await readFile(path, "utf8")).trim();
60
+ return keyPairFromPrivate(fromBase64Url(text));
61
+ } catch (error) {
62
+ if (error.code !== "ENOENT")
63
+ throw error;
64
+ const pair = generateKeyPair();
65
+ try {
66
+ await writeFile(path, `${toBase64Url(pair.privateKey)}
67
+ `, { flag: "wx", mode: 384 });
68
+ } catch (race) {
69
+ if (race.code !== "EEXIST")
70
+ throw race;
71
+ return loadHostKey(dataDir);
72
+ }
73
+ return pair;
74
+ }
75
+ }
76
+ async function loadRelayKey(dataDir) {
77
+ const path = join(dataDir, "relay-key");
78
+ try {
79
+ const secretKey = fromBase64Url((await readFile(path, "utf8")).trim());
80
+ if (secretKey.length !== 32)
81
+ throw new Error(`bad relay key in ${path}`);
82
+ return { secretKey, publicKey: ed25519.getPublicKey(secretKey) };
83
+ } catch (error) {
84
+ if (error.code !== "ENOENT")
85
+ throw error;
86
+ const { secretKey, publicKey } = ed25519.keygen();
87
+ try {
88
+ await writeFile(path, `${toBase64Url(secretKey)}
89
+ `, { flag: "wx", mode: 384 });
90
+ } catch (race) {
91
+ if (race.code !== "EEXIST")
92
+ throw race;
93
+ return loadRelayKey(dataDir);
94
+ }
95
+ return { secretKey, publicKey };
96
+ }
97
+ }
98
+ function pairingOffers(ttlMs = 5 * 60 * 1000) {
99
+ const live = new Map;
100
+ const prune = () => {
101
+ const now = Date.now();
102
+ for (const [secret, offer] of live)
103
+ if (offer.expiresAt <= now)
104
+ live.delete(secret);
105
+ };
106
+ const mint = (offer) => {
107
+ prune();
108
+ const secret = toBase64Url(randomBytes(16));
109
+ const expiresAt = Date.now() + ttlMs;
110
+ live.set(secret, { expiresAt, ...offer });
111
+ return { secret, expiresAt };
112
+ };
113
+ return {
114
+ create: (source) => mint({ source }),
115
+ createAccount: (binding) => mint({ source: "account", binding }),
116
+ consume(secret) {
117
+ prune();
118
+ const candidate = Buffer.from(secret);
119
+ for (const [key, offer] of live) {
120
+ const offerBytes = Buffer.from(key);
121
+ if (offerBytes.length !== candidate.length)
122
+ continue;
123
+ if (timingSafeEqual(offerBytes, candidate)) {
124
+ live.delete(key);
125
+ return offer;
126
+ }
127
+ }
128
+ return;
129
+ },
130
+ revokeAccount() {
131
+ for (const [key, offer] of live)
132
+ if (offer.source === "account")
133
+ live.delete(key);
134
+ }
135
+ };
136
+ }
137
+
138
+ export { DevicesDoc, ensureDevices, RevocationsDoc, admitDevice, loadHostKey, loadRelayKey, pairingOffers };