agent-dag 3.14.0 → 3.16.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.
@@ -0,0 +1,667 @@
1
+ // The thing that actually heals an account: the beacon, the listener and
2
+ // claude-swap, wired together.
3
+ //
4
+ // The two files under this one hold everything that can be reasoned about
5
+ // without a network — lan-sync.mjs decides, lan-socket.mjs carries — and what
6
+ // is left here is the part that has to touch the store. It is deliberately the
7
+ // smallest of the three.
8
+ //
9
+ // WHAT ONE ROUND LOOKS LIKE, from a deck whose copy of an account has died:
10
+ //
11
+ // 1. it hears a beacon from a deck with the same group tag
12
+ // 2. it dials that deck's sync port and both sides prove the passphrase
13
+ // 3. it asks for a manifest: which accounts, and does each one work THERE
14
+ // 4. `plan()` says "heal a@@1" — mine is quarantined, theirs is alive
15
+ // 5. it asks for that one account, with a fresh proof naming it
16
+ // 6. the peer runs `cswap export - --account N`, seals it, sends it
17
+ // 7. it opens the envelope and runs `cswap import -`
18
+ //
19
+ // Steps 6 and 7 are the only ones that touch a credential, and neither of them
20
+ // reads one: claude-swap does the reading and the writing, this passes an
21
+ // opaque blob between two of its commands. That is the same division the manual
22
+ // share already uses, which is why this needed no new credential handling at
23
+ // all.
24
+ //
25
+ // NOTHING HAPPENS ON A SCHEDULE THAT MOVES A CREDENTIAL. The manifest round is
26
+ // periodic and carries no credential; the transfer happens when a plan has
27
+ // something in it, which is when an account is actually broken. A deck whose
28
+ // accounts all work talks to its peers every minute and never asks for
29
+ // anything.
30
+ import { accountKey, manifestFor, open, plan, seal, stillListed, transferChallenge } from "./lan-sync.mjs";
31
+ import { connectToPeer, createBeacon, createSyncServer, sendFrame } from "./lan-socket.mjs";
32
+ import { addTrusted, dropTrusted, identityFrom, mintInvite, pairable, readInvite, trustedPeer } from "./lan-sync.mjs";
33
+ import { randomBytes } from "node:crypto";
34
+ import { hostname, networkInterfaces } from "node:os";
35
+
36
+ /** How often a deck asks its peers what they have. A minute is far more often
37
+ * than a login dies, and it is what makes the panel's list feel live rather
38
+ * than something that updates when you press a button. */
39
+ export const SYNC_MS = 60_000;
40
+
41
+ /** How long one peer round may take before it is abandoned. A manifest is one
42
+ * round trip on a local network; anything past this is a peer that is not
43
+ * going to answer, and holding the attempt open would stall the next round. */
44
+ export const ROUND_MS = 10_000;
45
+
46
+
47
+ /** What this machine calls itself when the user has not said. The hostname,
48
+ * because that is the word they already use for this machine everywhere else. */
49
+ export function defaultName() {
50
+ return hostname().replace(/\.local$/i, "") || "this machine";
51
+ }
52
+
53
+ /**
54
+ * EVERY address another deck might dial, for the panel to print.
55
+ *
56
+ * It returned the first one, and that was wrong the first time somebody
57
+ * checked: on this machine the first is 192.168.1.82 and the deck it needs to
58
+ * reach is on Tailscale at 100.67.32.58, so the panel would have offered an
59
+ * address that peer cannot route to and left them to work out why.
60
+ *
61
+ * WHICH ONE IS RIGHT DEPENDS ON WHERE THE PEER IS, which this side cannot
62
+ * answer — a VPN, a second NIC, a container bridge, all real and all at once.
63
+ * So it offers them all and the person picks: they are the only one who knows
64
+ * how the other machine sees this one, and a list of two is a smaller ask than
65
+ * a wrong answer.
66
+ *
67
+ * `internal` is node's word for loopback, and a link-local 169.254 address is a
68
+ * machine whose DHCP failed — reachable by nobody worth telling about.
69
+ */
70
+ export function localAddresses(faces = networkInterfaces()) {
71
+ const out = [];
72
+ for (const list of Object.values(faces ?? {})) {
73
+ for (const n of list ?? []) {
74
+ if (n.internal) continue;
75
+ if (n.family !== "IPv4" && n.family !== 4) continue;
76
+ if (typeof n.address !== "string" || n.address.startsWith("169.254.")) continue;
77
+ if (!out.includes(n.address)) out.push(n.address);
78
+ }
79
+ }
80
+ return out;
81
+ }
82
+
83
+ /**
84
+ * One deck's LAN sync, from settings to a healed account.
85
+ *
86
+ * `deps` is every side effect: reading accounts, exporting one, importing one.
87
+ * Injected rather than imported so a test can run a whole round — two engines,
88
+ * two fake stores, one real socket pair — without claude-swap on the machine.
89
+ */
90
+ export function createEngine({
91
+ readAccounts, exportAccount, importAccount,
92
+ onChange, onError, onIdentity, onPort, onTrust, onDial, now = Date.now,
93
+ /**
94
+ * The UDP socket the beacon shouts through, injectable for the same reason
95
+ * lan-socket exposes it — and for one more that only showed up in use.
96
+ *
97
+ * The suite runs whole engines over real sockets, which is right: a handshake
98
+ * between two of them is the thing being tested and a mock would only check
99
+ * that the mock agrees with the code it was written from. But `createBeacon`
100
+ * defaulted to a real dgram socket, so `npm test` BROADCAST on whatever
101
+ * network the machine was on — and the fake decks it announces turned up in
102
+ * a real panel, on a real screen, in a list of decks somebody could pair
103
+ * with. Nothing secret leaves, and it is still a test shouting at an office.
104
+ */
105
+ createSocket,
106
+ } = {}) {
107
+ let cfg = { enabled: false, name: defaultName(), secret: "", shared: [], trusted: [], port: 0 };
108
+ let identity = null;
109
+ let beacon = null;
110
+ let server = null;
111
+ let timer = null;
112
+ /**
113
+ * Decks that finished a handshake and that nobody here has accepted yet, and
114
+ * decks merely heard shouting on the network. Two lists because they are two
115
+ * different claims: a pending deck proved it holds the key it announced, a
116
+ * heard one only said so. Both are rows with an accept on them; only the
117
+ * first is evidence.
118
+ *
119
+ * In memory rather than on disk. A request that is a day old is not a request
120
+ * any more, and a list of them that survives restarts is a list nobody reads.
121
+ */
122
+ const pending = new Map();
123
+ const strangers = new Map();
124
+ /** The invite this deck is offering, or null. One at a time: a deck showing
125
+ * two tokens is a deck whose owner cannot say which one they sent. */
126
+ let invite = null;
127
+ /** What the last round did, for the panel. Not a log: one line per peer, most
128
+ * recent only, because "what happened" is a question about now. */
129
+ const lastRound = new Map();
130
+
131
+ /** This deck's accounts in the shape the rules want. Read through the same
132
+ * function the panel uses, so a row can never be alive here and dead there. */
133
+ const localAccounts = async () => {
134
+ const got = await readAccounts();
135
+ return (got?.accounts ?? []).map(a => ({
136
+ key: accountKey(a.email, a.orgUuid),
137
+ email: a.email,
138
+ alive: a.alive === true,
139
+ num: a.num,
140
+ }));
141
+ };
142
+
143
+ /** Frames from a deck that finished the handshake AND that somebody here has
144
+ * accepted. Nothing reaches this before both, which is the whole point of
145
+ * where the two checks sit. `ctx.key` is this connection's key and no other
146
+ * connection's — see sessionKey. */
147
+ const serve = async (msg, ctx) => {
148
+ try {
149
+ if (msg.t === "manifest") {
150
+ const accounts = await localAccounts();
151
+ return ctx.send({ t: "manifest", accounts: manifestFor(accounts, cfg.shared) });
152
+ }
153
+ if (msg.t === "want") {
154
+ // A SECOND PROOF, for the one operation that moves a credential. The
155
+ // session says who connected; this says they are asking for this
156
+ // account, now. A long-lived connection authenticated an hour ago is
157
+ // not a statement about now.
158
+ const want = transferChallenge(ctx.key, {
159
+ nonce: msg.nonce, accountKey: msg.key, fromFp: ctx.peerFp, toFp: identity.fp,
160
+ });
161
+ if (typeof msg.proof !== "string" || msg.proof !== want) {
162
+ return ctx.send({ t: "no", why: "proof" });
163
+ }
164
+ // Only what the user ticked, checked again here rather than trusted
165
+ // from the manifest we sent: the list can change between the two, and
166
+ // the answer that matters is the one at the moment of sending.
167
+ if (!cfg.shared.includes(msg.key)) return ctx.send({ t: "no", why: "not shared" });
168
+ const accounts = await localAccounts();
169
+ const mine = accounts.find(a => a.key === msg.key);
170
+ if (!mine || !mine.alive) return ctx.send({ t: "no", why: "not mine to give" });
171
+ const blob = await exportAccount(mine.num);
172
+ if (!blob) return ctx.send({ t: "no", why: "export failed" });
173
+ const aad = `${identity.fp}->${ctx.peerFp}|${msg.key}`;
174
+ return ctx.send({ t: "have", key: msg.key, sealed: seal(ctx.key, blob, aad) });
175
+ }
176
+ } catch (err) {
177
+ onError?.("serve", err);
178
+ ctx.send({ t: "no", why: "error" });
179
+ }
180
+ };
181
+
182
+ /** Ask one peer what it has, and heal whatever it can heal. */
183
+ const roundWith = async peer => {
184
+ let conn = null;
185
+ try {
186
+ conn = await connectToPeer({
187
+ host: peer.addr, port: peer.port, timeoutMs: ROUND_MS,
188
+ fp: identity.fp, pub: identity.pub, secret: identity.secret, name: cfg.name,
189
+ // Where this deck listens, so the far side can reach back after it
190
+ // accepts rather than only being reachable.
191
+ myPort: server?.port() ?? null,
192
+ // The key pinned when this deck was accepted, so a second machine
193
+ // answering at that address is refused rather than talked to.
194
+ expectPub: trustedPeer(cfg.trusted, peer.fp)?.pub ?? null,
195
+ });
196
+ const ask = frame => new Promise((resolve, reject) => {
197
+ const bell = setTimeout(() => reject(new Error("peer went quiet")), ROUND_MS);
198
+ bell.unref?.();
199
+ let buf = "";
200
+ const onData = chunk => {
201
+ buf += chunk;
202
+ const i = buf.indexOf("\n");
203
+ if (i === -1) return;
204
+ clearTimeout(bell);
205
+ conn.sock.off("data", onData);
206
+ try { resolve(JSON.parse(buf.slice(0, i))); } catch { reject(new Error("bad reply")); }
207
+ };
208
+ conn.sock.on("data", onData);
209
+ sendFrame(conn.sock, frame);
210
+ });
211
+
212
+ // TRUST ON FIRST USE, AND ONLY FOR AN ADDRESS SOMEBODY TYPED. Reaching a
213
+ // deck we have no pin for means the person at this keyboard put its
214
+ // address in the field, which is the same decision the accept button is
215
+ // on the other side. Pinning it here is what makes the two lists agree —
216
+ // without it this deck would dial a peer every minute and still show it
217
+ // as nobody, and its own listener would refuse the same deck calling
218
+ // back.
219
+ //
220
+ // A deck we DO have a pin for was checked before this line: connectToPeer
221
+ // was given expectPub and refuses a different key at that address.
222
+ // WHO IS ACTUALLY THERE. A typed address is a row that says `192.168.1.5:54340`
223
+ // and nothing else until somebody answers it — and once one has, the deck
224
+ // on the other end has told us what it calls itself. The row says that
225
+ // from then on, because "Constantin-PC" is what the person who typed the
226
+ // address was trying to reach.
227
+ learned.set(`${peer.addr}:${peer.port}`, { fp: conn.peerFp, name: conn.peerName || "" });
228
+
229
+ if (!trustedPeer(cfg.trusted, conn.peerFp)) {
230
+ const { list, added } = addTrusted(cfg.trusted, {
231
+ fp: conn.peerFp, pub: conn.peerPub, name: conn.peerName,
232
+ });
233
+ if (added) { cfg = { ...cfg, trusted: list }; onTrust?.(list); }
234
+ }
235
+
236
+ const theirs = await ask({ t: "manifest" });
237
+ if (theirs?.t !== "manifest" || !Array.isArray(theirs.accounts)) throw new Error("no manifest");
238
+ const mine = await localAccounts();
239
+ // Only accounts I have also ticked. Sharing is mutual by construction:
240
+ // a peer cannot push an account at me that I never agreed to hold.
241
+ // A HEAL NEEDS MY TICK; AN ADD DOES NOT, and the asymmetry is deliberate.
242
+ // Healing replaces a slot I already have, so it is only reasonable for an
243
+ // account I said I share. Adding is the case the owner asked for by name:
244
+ // an account that appears among the decks I paired with appears on all of
245
+ // them, which is the whole of "I do not want to paste blobs any more".
246
+ // What can reach this is what a deck somebody here pressed accept on
247
+ // chose to offer.
248
+ const wanted = plan(mine, theirs.accounts)
249
+ .filter(step => step.action === "add" || cfg.shared.includes(step.key));
250
+ const done = [];
251
+ for (const step of wanted) {
252
+ const nonce = randomBytes(12).toString("hex");
253
+ const reply = await ask({
254
+ t: "want", key: step.key, nonce,
255
+ proof: transferChallenge(conn.key, {
256
+ nonce, accountKey: step.key, fromFp: identity.fp, toFp: conn.peerFp,
257
+ }),
258
+ });
259
+ if (reply?.t !== "have" || !reply.sealed) { done.push({ ...step, ok: false, why: reply?.why ?? "refused" }); continue; }
260
+ const blob = open(conn.key, reply.sealed, `${conn.peerFp}->${identity.fp}|${step.key}`);
261
+ if (!blob) { done.push({ ...step, ok: false, why: "could not open" }); continue; }
262
+ const ok = await importAccount(blob);
263
+ done.push({ ...step, ok: !!ok, why: ok ? null : "import failed" });
264
+ }
265
+ lastRound.set(peer.fp, { at: now(), name: peer.name, offered: theirs.accounts.length, done });
266
+ if (done.length) onChange?.();
267
+ return done;
268
+ } catch (err) {
269
+ lastRound.set(peer.fp, { at: now(), name: peer.name, error: err.message });
270
+ return [];
271
+ } finally {
272
+ conn?.sock?.destroy();
273
+ }
274
+ };
275
+
276
+ /** Peers the user typed in, which the beacon will never find.
277
+ *
278
+ * Broadcast dies at the first router and is dropped by a switch that
279
+ * filters it, so a deck across a VPN or on another subnet is unreachable by
280
+ * discovery and perfectly reachable by address. Typing one is a decision to
281
+ * trust whatever answers there the first time, and to pin it: an address is
282
+ * a way to reach a deck, and the accept on the other machine is what lets
283
+ * anything move.
284
+ *
285
+ * Keyed by `host:port` rather than by fingerprint, because a fingerprint is
286
+ * what a deck says about itself after the handshake and this list has to
287
+ * exist before there has been one. */
288
+ const manual = new Map();
289
+ /** What answered at a typed address, once something has. Keyed the same way
290
+ * `manual` is, because until a connection succeeds an address is all there
291
+ * is to key on. */
292
+ const learned = new Map();
293
+
294
+ const round = async () => {
295
+ if (!beacon) return [];
296
+ const all = [];
297
+ // Heard first, typed second, and a typed one is skipped when the beacon
298
+ // already found that address: otherwise a deck that is both would be dialled
299
+ // twice a round and its work counted twice.
300
+ // The same rule the list uses. A deck that has been silent for a day is not
301
+ // dialled once a minute forever on the chance it comes back.
302
+ const heard = [...beacon.peers.values()].filter(p => stillListed(p, now()));
303
+ const seen = new Set(heard.map(p => `${p.addr}:${p.port}`));
304
+ for (const peer of [...heard, ...[...manual.values()].filter(p => !seen.has(`${p.addr}:${p.port}`))]) {
305
+ // Sequential rather than parallel. The store takes one mutation at a
306
+ // time anyway (cswap-admin's lock), and two peers healing the same
307
+ // account at once would race for a slot number claude-swap assigns as
308
+ // max+1 without a lock of its own.
309
+ all.push(...await roundWith(peer));
310
+ }
311
+ return all;
312
+ };
313
+
314
+ return {
315
+ async apply(next) {
316
+ const was = cfg;
317
+ cfg = { ...cfg, ...next };
318
+ const restart = !was.enabled !== !cfg.enabled
319
+ || was.secret !== cfg.secret
320
+ || was.name !== cfg.name;
321
+ if (!restart) return;
322
+ this.stop();
323
+ if (!cfg.enabled) return;
324
+ identity = identityFrom(cfg.secret);
325
+ // Hand the caller a key to keep when there was none, so the next start is
326
+ // the same deck rather than a stranger to everybody who paired with it.
327
+ if (identity.secret !== cfg.secret) {
328
+ cfg = { ...cfg, secret: identity.secret };
329
+ onIdentity?.(identity.secret);
330
+ }
331
+ // The port last used, so an address somebody typed on the other machine
332
+ // still works after this deck restarts. createSyncServer falls through to
333
+ // an OS-chosen one when it is taken, and the caller stores whatever came
334
+ // back — so the pin drifts to a free port rather than failing.
335
+ server = createSyncServer({
336
+ fp: identity.fp, pub: identity.pub, secret: identity.secret,
337
+ name: cfg.name, handlers: serve, onError, prefer: cfg.port,
338
+ trusted: () => cfg.trusted,
339
+ invite: () => (invite && invite.expiresAt > now() ? invite : null),
340
+ // Somebody used the token. They are pinned, and the token is retired —
341
+ // one that pairs twice is one worth stealing twice.
342
+ onInviteUsed: entry => {
343
+ const { list } = addTrusted(cfg.trusted, { fp: entry.fp, pub: entry.pub, name: entry.name });
344
+ cfg = { ...cfg, trusted: list };
345
+ invite = null;
346
+ // AND DIAL IT BACK, KEPT. Accepting made it welcome and left this
347
+ // deck with no way to reach it: an inbound connection puts nothing in
348
+ // the dial list. Without this the pairing is mutual in the trusted
349
+ // list and one-way in fact — and `addPeer` alone lives in memory, so
350
+ // it would be one-way again after the next restart.
351
+ if (entry.addr && entry.port) {
352
+ this.addPeer(entry.addr, entry.port);
353
+ onDial?.(`${entry.addr}:${entry.port}`);
354
+ // AND SAY WHO IS THERE, NOW. `learned` is what joins a dialled row
355
+ // to a heard one, and it was only ever filled by a round that
356
+ // succeeded — so between accepting a deck and the next round, one
357
+ // machine appeared as two rows. We already know the answer here:
358
+ // the handshake that just finished said so.
359
+ learned.set(`${entry.addr}:${entry.port}`, { fp: entry.fp, name: entry.name || "" });
360
+ }
361
+ onTrust?.(list);
362
+ onChange?.();
363
+ },
364
+ onPending: entry => {
365
+ const had = pending.get(entry.fp);
366
+ pending.set(entry.fp, { ...entry, at: had?.at ?? now(), lastAt: now() });
367
+ if (!had) onChange?.();
368
+ },
369
+ });
370
+ const port = await server.start();
371
+ if (port !== cfg.port) onPort?.(port);
372
+ beacon = createBeacon({
373
+ port, name: cfg.name, fp: identity.fp,
374
+ trusted: () => cfg.trusted,
375
+ onPeer: () => onChange?.(),
376
+ onStranger: entry => {
377
+ const had = strangers.get(entry.fp);
378
+ strangers.set(entry.fp, entry);
379
+ // Only a deck that is new to us is news. A beacon every thirty
380
+ // seconds from one already on the list is not a reason to redraw.
381
+ if (!had) onChange?.();
382
+ },
383
+ // Take a new key and keep it. Two decks with one identity are invisible
384
+ // to each other forever otherwise, and the second one to notice moving
385
+ // is enough — whichever notices first, moves.
386
+ onIdClash: () => {
387
+ const fresh = identityFrom("");
388
+ onIdentity?.(fresh.secret);
389
+ onError?.("id-clash", new Error("another deck was using this one's key; taking a new one"));
390
+ },
391
+ onError, now,
392
+ ...(createSocket ? { createSocket } : {}),
393
+ });
394
+ await beacon.start();
395
+ timer = setInterval(() => { void round(); }, SYNC_MS);
396
+ timer.unref?.();
397
+ },
398
+ /**
399
+ * Make an invite: every address this deck has, its port, its name, and a
400
+ * code, in one piece of text somebody sends however they already talk.
401
+ *
402
+ * EVERY ADDRESS, and that is the whole reason this exists. A person cannot
403
+ * know which of their machine's addresses the other machine can route to —
404
+ * a VPN, a second card, another subnet, all real and all at once — and
405
+ * neither can this deck. The one machine that can find out is the one doing
406
+ * the reaching, so it gets the list and tries it.
407
+ */
408
+ invite() {
409
+ if (!server || !beacon) return null;
410
+ const port = server.port();
411
+ if (port == null) return null;
412
+ const addrs = localAddresses().map(a => `${a}:${port}`);
413
+ const made = mintInvite({ addrs, name: cfg.name, now: now() });
414
+ if (!made) return null;
415
+ invite = made;
416
+ onChange?.();
417
+ return { token: made.token, expiresAt: made.expiresAt, addrs };
418
+ },
419
+
420
+ /** What this deck is offering right now, for the panel to draw. Null once
421
+ * it has run out, so a token nobody can use is not shown as if they could. */
422
+ offering() {
423
+ if (!invite || invite.expiresAt <= now()) return null;
424
+ return { token: invite.token, expiresAt: invite.expiresAt };
425
+ },
426
+
427
+ /** Put it away without using it. */
428
+ withdraw() {
429
+ const had = !!invite;
430
+ invite = null;
431
+ if (had) onChange?.();
432
+ return had;
433
+ },
434
+
435
+ /**
436
+ * Join on somebody else's invite: try every address it carries until one
437
+ * answers, and pair with whatever does.
438
+ *
439
+ * IN ORDER, AND STOPPING AT THE FIRST, because the addresses are the same
440
+ * deck seen from different networks — reaching it twice would pair one deck
441
+ * as two. The failures are collected rather than thrown away: when none of
442
+ * them worked, which ones were tried and what each said is the only thing
443
+ * the reader can act on.
444
+ */
445
+ async join(token) {
446
+ const inv = readInvite(token, now());
447
+ if (!inv) return { ok: false, reason: "not_an_invite" };
448
+ if (inv.expired) return { ok: false, reason: "expired" };
449
+ if (!identity || !server) return { ok: false, reason: "not_running" };
450
+ const tried = [];
451
+ for (const at of inv.addrs) {
452
+ let conn = null;
453
+ try {
454
+ conn = await connectToPeer({
455
+ host: at.addr, port: at.port, timeoutMs: ROUND_MS,
456
+ fp: identity.fp, pub: identity.pub, secret: identity.secret,
457
+ name: cfg.name, myPort: server.port(), code: inv.code,
458
+ });
459
+ const { list } = addTrusted(cfg.trusted, {
460
+ fp: conn.peerFp, pub: conn.peerPub, name: conn.peerName || inv.name,
461
+ });
462
+ cfg = { ...cfg, trusted: list };
463
+ this.addPeer(at.addr, at.port);
464
+ onDial?.(`${at.addr}:${at.port}`);
465
+ learned.set(`${at.addr}:${at.port}`, { fp: conn.peerFp, name: conn.peerName || inv.name });
466
+ onTrust?.(list);
467
+ onChange?.();
468
+ return {
469
+ ok: true,
470
+ peer: { fp: conn.peerFp, name: conn.peerName || inv.name, addr: at.addr, port: at.port },
471
+ tried,
472
+ };
473
+ } catch (err) {
474
+ tried.push({ addr: `${at.addr}:${at.port}`, why: err.message });
475
+ } finally {
476
+ conn?.sock?.destroy();
477
+ }
478
+ }
479
+ return { ok: false, reason: "unreachable", tried };
480
+ },
481
+
482
+ /**
483
+ * Accept a deck, which is the only thing that lets anything move.
484
+ *
485
+ * It takes the fingerprint AND the key that was seen with it, from the
486
+ * pending or heard list — never from whatever is at an address now, because
487
+ * the point of pinning is that the thing answering later has to be the same
488
+ * thing. A fingerprint nobody has actually met is refused rather than
489
+ * trusted on a name somebody typed.
490
+ */
491
+ accept(fp) {
492
+ const asked = pending.get(fp) ?? null;
493
+ const heard = strangers.get(fp) ?? null;
494
+ const seen = asked ?? heard;
495
+ if (!seen) return null;
496
+ // TWO KINDS OF ROW, AND THEY ARE NOT THE SAME CLAIM.
497
+ //
498
+ // A deck that ASKED finished a handshake, so it held the private half of
499
+ // the key it announced and that key can be pinned right here. A deck we
500
+ // merely HEARD has only shouted: a beacon carries a fingerprint and no
501
+ // key, and pinning a fingerprint with no key to check it against later is
502
+ // worse than not pinning at all — it looks like a pairing and is not one.
503
+ //
504
+ // So accepting a heard deck starts a conversation rather than ending one:
505
+ // its address goes on the dial list, the next round reaches it and pins
506
+ // whatever answers, and its owner gets the same request to accept. Which
507
+ // is the same two presses, in the other order.
508
+ if (!seen.pub) {
509
+ if (!seen.addr || !seen.port) return null;
510
+ this.addPeer(seen.addr, seen.port);
511
+ strangers.delete(fp);
512
+ onChange?.();
513
+ return { fp, name: seen.name, addr: seen.addr, port: seen.port, dialled: true };
514
+ }
515
+ const { list, added } = addTrusted(cfg.trusted, { fp, pub: seen.pub, name: seen.name });
516
+ cfg = { ...cfg, trusted: list };
517
+ pending.delete(fp);
518
+ strangers.delete(fp);
519
+ onTrust?.(list);
520
+ onChange?.();
521
+ // AND DIAL IT BACK. Accepting a deck that called us made it welcome and
522
+ // left this one with no way to reach it: the peer list is what this deck
523
+ // dials, and an inbound connection puts nothing in it. So the pairing was
524
+ // mutual in the trusted list and one-way in fact — if the other machine
525
+ // stopped calling, nothing here would ever call it. The hello carries the
526
+ // port it listens on for exactly this.
527
+ const back = seen.addr && seen.port && this.addPeer(seen.addr, seen.port)
528
+ ? (learned.set(`${seen.addr}:${seen.port}`, { fp, name: seen.name || "" }),
529
+ { addr: seen.addr, port: seen.port })
530
+ : null;
531
+ return added ? { fp, name: seen.name, addr: seen.addr, port: seen.port ?? null, dialBack: back } : null;
532
+ },
533
+ /** Say no, and stop being asked. The deck is dropped from both lists; if it
534
+ * connects again it is a new request, because refusing is not a block. */
535
+ dismiss(fp) {
536
+ const had = pending.delete(fp) || strangers.delete(fp);
537
+ if (had) onChange?.();
538
+ return had;
539
+ },
540
+ /** Unpair. It stops what has not happened yet and takes back nothing that
541
+ * has — the same sentence the panel says about a shared login. */
542
+ unpair(fp) {
543
+ const list = dropTrusted(cfg.trusted, fp);
544
+ if (list.length === cfg.trusted.length) return false;
545
+ cfg = { ...cfg, trusted: list };
546
+ onTrust?.(list);
547
+ onChange?.();
548
+ return true;
549
+ },
550
+ round,
551
+ /** Dial this address on every round from now on. Returns false for an
552
+ * address that is not one, rather than storing a row that can never
553
+ * connect and reports an error every minute forever. */
554
+ addPeer(addr, port) {
555
+ const p = Number(port);
556
+ if (typeof addr !== "string" || !addr.trim() || !Number.isInteger(p) || p < 1 || p > 65_535) return false;
557
+ const host = addr.trim();
558
+ manual.set(`${host}:${p}`, { fp: `manual:${host}:${p}`, name: host, addr: host, port: p, manual: true });
559
+ return true;
560
+ },
561
+ removePeer(addr, port) { return manual.delete(`${String(addr).trim()}:${Number(port)}`); },
562
+ /** Replace the typed list wholesale, which is what a settings write means.
563
+ * Adding one at a time would leave a removed address still being dialled
564
+ * every minute until the next restart — the row would vanish from the
565
+ * panel while the socket kept opening, which is the worst of both. */
566
+ setPeers(entries) {
567
+ manual.clear();
568
+ for (const entry of Array.isArray(entries) ? entries : []) {
569
+ const at = String(entry).lastIndexOf(":");
570
+ if (at > 0) this.addPeer(String(entry).slice(0, at), Number(String(entry).slice(at + 1)));
571
+ }
572
+ return manual.size;
573
+ },
574
+ status() {
575
+ return {
576
+ enabled: !!cfg.enabled,
577
+ running: !!beacon,
578
+ name: cfg.name,
579
+ fp: identity?.fp ?? null,
580
+ // The address and port a person on another subnet types into the other
581
+ // deck's field. Null when this machine has no ordinary one, which the
582
+ // panel says rather than printing a placeholder.
583
+ port: server?.port() ?? null,
584
+ addrs: beacon ? localAddresses() : [],
585
+ shared: [...cfg.shared],
586
+ // The token this deck is offering, if any. Drawn as the one thing to do
587
+ // when nobody is paired yet, and put away once somebody is.
588
+ invite: invite && invite.expiresAt > now()
589
+ ? { token: invite.token, expiresAt: invite.expiresAt }
590
+ : null,
591
+ // Decks somebody accepted, decks that asked and have not been answered,
592
+ // and decks merely heard. Three lists because they are three different
593
+ // things a person does something different about.
594
+ trusted: cfg.trusted.map(t => ({ fp: t.fp, name: t.name })),
595
+ pending: [...pending.values()].map(p => ({ fp: p.fp, name: p.name, addr: p.addr, at: p.at })),
596
+ // Only the ones somebody could actually pair with right now, one row
597
+ // per machine, newest first — see pairable, which is where the rule
598
+ // that keeps this from becoming a wall of ghosts lives.
599
+ strangers: (() => {
600
+ const { shown, more } = pairable([...strangers.values()], now());
601
+ return shown.map(p => ({ fp: p.fp, name: p.name, addr: p.addr, port: p.port, at: p.at, more }));
602
+ })(),
603
+ peers: beacon ? (() => {
604
+ // ONE DECK, ONE ROW, and it takes work because a deck can arrive here
605
+ // twice by two different routes: heard on the network, and dialled at
606
+ // an address somebody typed or that an invite carried. Both are the
607
+ // same machine and neither knows it — the beacon row is keyed by the
608
+ // fingerprint it announced, the typed row by `host:port`, and until a
609
+ // connection succeeds nothing joins them.
610
+ //
611
+ // What joins them is `learned`: the fingerprint that actually
612
+ // answered at that address. So every row is given the identity it is
613
+ // really about, and rows that turn out to share one are merged — the
614
+ // heard half brings liveness, the dialled half brings the last round.
615
+ const rows = [];
616
+ const byId = new Map();
617
+ const put = row => {
618
+ const had = byId.get(row.id);
619
+ if (!had) { byId.set(row.id, row); rows.push(row); return; }
620
+ // Keep what each half is the authority on.
621
+ had.lastSeen = had.lastSeen ?? row.lastSeen;
622
+ had.last = had.last ?? row.last;
623
+ had.manual = had.manual || row.manual;
624
+ had.met = had.met || row.met;
625
+ if (row.name && !had.name) had.name = row.name;
626
+ };
627
+ for (const p of [...beacon.peers.values(), ...manual.values()]) {
628
+ if (!stillListed(p, now())) continue;
629
+ const met = p.manual ? learned.get(`${p.addr}:${p.port}`) : null;
630
+ const id = met?.fp ?? p.fp;
631
+ put({
632
+ ...p,
633
+ id,
634
+ // The fingerprint an unpair has to name. A typed row's own `fp` is
635
+ // a placeholder built from its address and matches nothing.
636
+ peerFp: p.manual ? met?.fp ?? null : p.fp,
637
+ name: met?.name || p.name,
638
+ met: !!met,
639
+ paired: !!trustedPeer(cfg.trusted, id),
640
+ last: lastRound.get(p.fp) ?? null,
641
+ });
642
+ }
643
+ // A DECK WE ARE PAIRED WITH AND DO NOT DIAL. It called us, we accepted
644
+ // it, and nothing here has its address — which used to mean the panel
645
+ // listed failing addresses under "paired decks" and left out the one
646
+ // deck that actually was.
647
+ for (const t of cfg.trusted) {
648
+ if (byId.has(t.fp)) continue;
649
+ put({
650
+ id: t.fp, fp: t.fp, peerFp: t.fp, name: t.name || t.fp, addr: "", port: 0,
651
+ paired: true, waiting: true, last: lastRound.get(t.fp) ?? null,
652
+ });
653
+ }
654
+ return rows;
655
+ })() : [],
656
+ };
657
+ },
658
+ stop() {
659
+ if (timer) clearInterval(timer);
660
+ timer = null;
661
+ beacon?.stop();
662
+ server?.stop();
663
+ beacon = null;
664
+ server = null;
665
+ },
666
+ };
667
+ }