agent-dag 3.15.0 → 3.17.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.
@@ -29,8 +29,8 @@
29
29
  // anything.
30
30
  import { accountKey, manifestFor, open, plan, seal, stillListed, transferChallenge } from "./lan-sync.mjs";
31
31
  import { connectToPeer, createBeacon, createSyncServer, sendFrame } from "./lan-socket.mjs";
32
- import { fingerprint, groupKey } from "./lan-sync.mjs";
33
- import { generateKeyPairSync, randomBytes } from "node:crypto";
32
+ import { addTrusted, dropTrusted, identityFrom, mintInvite, pairable, readInvite, trustedPeer } from "./lan-sync.mjs";
33
+ import { randomBytes } from "node:crypto";
34
34
  import { hostname, networkInterfaces } from "node:os";
35
35
 
36
36
  /** How often a deck asks its peers what they have. A minute is far more often
@@ -44,32 +44,6 @@ export const SYNC_MS = 60_000;
44
44
  export const ROUND_MS = 10_000;
45
45
 
46
46
 
47
- /**
48
- * The name everybody knows this deck by.
49
- *
50
- * PERSISTED, and the first version was not. It regenerated on every start, on
51
- * the argument that a stored key is one more secret to protect — and that
52
- * argument was about the wrong thing, because this is not a key. Nothing is
53
- * signed with it and nothing is decrypted with it; the group passphrase is what
54
- * authenticates, and this is only an identifier, broadcast in the clear in
55
- * every beacon. There is nothing here to steal.
56
- *
57
- * What regenerating it cost was measured rather than guessed: on a machine
58
- * where decks had been restarted a few times, every peer's list held a row per
59
- * restart — dozens of them, each reporting ECONNREFUSED every minute against a
60
- * port nothing has listened on for an hour. Remembered forever and never
61
- * recognised again is the worst of both, and a stable id is the whole fix.
62
- */
63
- export function newIdentity(stored) {
64
- if (typeof stored === "string" && /^[0-9a-f]{12}$/.test(stored)) {
65
- return { fp: stored.replace(/(.{3})(?=.)/g, "$1-"), id: stored };
66
- }
67
- const { publicKey } = generateKeyPairSync("x25519");
68
- const raw = publicKey.export({ type: "spki", format: "der" });
69
- const fp = fingerprint(raw);
70
- return { fp, id: fp.replace(/-/g, "") };
71
- }
72
-
73
47
  /** What this machine calls itself when the user has not said. The hostname,
74
48
  * because that is the word they already use for this machine everywhere else. */
75
49
  export function defaultName() {
@@ -115,17 +89,78 @@ export function localAddresses(faces = networkInterfaces()) {
115
89
  */
116
90
  export function createEngine({
117
91
  readAccounts, exportAccount, importAccount,
118
- onChange, onError, onIdentity, now = Date.now,
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,
119
106
  } = {}) {
120
- let cfg = { enabled: false, name: defaultName(), passphrase: "", shared: [], deckId: "" };
107
+ let cfg = { enabled: false, name: defaultName(), secret: "", shared: [], trusted: [], port: 0 };
121
108
  let identity = null;
122
109
  let beacon = null;
123
110
  let server = null;
124
111
  let timer = null;
125
- let key = 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
+ /**
125
+ * Decks somebody here said no to.
126
+ *
127
+ * WITHOUT THIS, DECLINING DID NOTHING THAT LASTED. A deck that asks is a deck
128
+ * that keeps asking — it dials on its own timer, and every dial that finds no
129
+ * pin here becomes a fresh request. So `dismiss` took a row off a list that
130
+ * the next minute put back, and on the other machine the refusal was
131
+ * indistinguishable from a deck that had not been answered yet: both are the
132
+ * same `pending` refusal on the wire, and both drew "waiting for the other
133
+ * deck to accept this one" forever.
134
+ *
135
+ * A name kept here is therefore two answers at once. This deck stops asking
136
+ * its owner, and the deck that asked is TOLD — see refuse("declined") in
137
+ * lan-socket.mjs, which is the only way the far end can ever learn that the
138
+ * answer was no rather than not yet.
139
+ *
140
+ * In memory, like the two lists above, and reversible: `allow` takes a name
141
+ * out and the requests come back. Nothing about it is written down, because a
142
+ * refusal that outlives the process is a decision nobody can find to undo.
143
+ */
144
+ const declined = new Map();
145
+ /** The invite this deck is offering, or null. One at a time: a deck showing
146
+ * two tokens is a deck whose owner cannot say which one they sent. */
147
+ let invite = null;
126
148
  /** What the last round did, for the panel. Not a log: one line per peer, most
127
149
  * recent only, because "what happened" is a question about now. */
128
150
  const lastRound = new Map();
151
+ /**
152
+ * Why this deck is not listening, when it is switched on and is not.
153
+ *
154
+ * A second deck on one machine takes the first one's port and the bind fails;
155
+ * the switch stays on, the beacon never starts, and the panel drew
156
+ * `starting…` for as long as the process lived. A state that cannot resolve
157
+ * and does not say why is the worst thing an instrument can show — the reader
158
+ * waits, and waiting is the one thing that never fixes it.
159
+ *
160
+ * Only the failures that stop the service reach this. A round that could not
161
+ * reach one peer is that peer's row, not the deck's.
162
+ */
163
+ let stalled = null;
129
164
 
130
165
  /** This deck's accounts in the shape the rules want. Read through the same
131
166
  * function the panel uses, so a row can never be alive here and dead there. */
@@ -139,8 +174,10 @@ export function createEngine({
139
174
  }));
140
175
  };
141
176
 
142
- /** Frames from a peer that has proved the passphrase. Nothing reaches this
143
- * before that, which is the whole point of where the check sits. */
177
+ /** Frames from a deck that finished the handshake AND that somebody here has
178
+ * accepted. Nothing reaches this before both, which is the whole point of
179
+ * where the two checks sit. `ctx.key` is this connection's key and no other
180
+ * connection's — see sessionKey. */
144
181
  const serve = async (msg, ctx) => {
145
182
  try {
146
183
  if (msg.t === "manifest") {
@@ -152,7 +189,7 @@ export function createEngine({
152
189
  // session says who connected; this says they are asking for this
153
190
  // account, now. A long-lived connection authenticated an hour ago is
154
191
  // not a statement about now.
155
- const want = transferChallenge(key, {
192
+ const want = transferChallenge(ctx.key, {
156
193
  nonce: msg.nonce, accountKey: msg.key, fromFp: ctx.peerFp, toFp: identity.fp,
157
194
  });
158
195
  if (typeof msg.proof !== "string" || msg.proof !== want) {
@@ -168,7 +205,7 @@ export function createEngine({
168
205
  const blob = await exportAccount(mine.num);
169
206
  if (!blob) return ctx.send({ t: "no", why: "export failed" });
170
207
  const aad = `${identity.fp}->${ctx.peerFp}|${msg.key}`;
171
- return ctx.send({ t: "have", key: msg.key, sealed: seal(key, blob, aad) });
208
+ return ctx.send({ t: "have", key: msg.key, sealed: seal(ctx.key, blob, aad) });
172
209
  }
173
210
  } catch (err) {
174
211
  onError?.("serve", err);
@@ -180,7 +217,16 @@ export function createEngine({
180
217
  const roundWith = async peer => {
181
218
  let conn = null;
182
219
  try {
183
- conn = await connectToPeer({ host: peer.addr, port: peer.port, fp: identity.fp, key, timeoutMs: ROUND_MS });
220
+ conn = await connectToPeer({
221
+ host: peer.addr, port: peer.port, timeoutMs: ROUND_MS,
222
+ fp: identity.fp, pub: identity.pub, secret: identity.secret, name: cfg.name,
223
+ // Where this deck listens, so the far side can reach back after it
224
+ // accepts rather than only being reachable.
225
+ myPort: server?.port() ?? null,
226
+ // The key pinned when this deck was accepted, so a second machine
227
+ // answering at that address is refused rather than talked to.
228
+ expectPub: trustedPeer(cfg.trusted, peer.fp)?.pub ?? null,
229
+ });
184
230
  const ask = frame => new Promise((resolve, reject) => {
185
231
  const bell = setTimeout(() => reject(new Error("peer went quiet")), ROUND_MS);
186
232
  bell.unref?.();
@@ -197,6 +243,30 @@ export function createEngine({
197
243
  sendFrame(conn.sock, frame);
198
244
  });
199
245
 
246
+ // TRUST ON FIRST USE, AND ONLY FOR AN ADDRESS SOMEBODY TYPED. Reaching a
247
+ // deck we have no pin for means the person at this keyboard put its
248
+ // address in the field, which is the same decision the accept button is
249
+ // on the other side. Pinning it here is what makes the two lists agree —
250
+ // without it this deck would dial a peer every minute and still show it
251
+ // as nobody, and its own listener would refuse the same deck calling
252
+ // back.
253
+ //
254
+ // A deck we DO have a pin for was checked before this line: connectToPeer
255
+ // was given expectPub and refuses a different key at that address.
256
+ // WHO IS ACTUALLY THERE. A typed address is a row that says `192.168.1.5:54340`
257
+ // and nothing else until somebody answers it — and once one has, the deck
258
+ // on the other end has told us what it calls itself. The row says that
259
+ // from then on, because "Constantin-PC" is what the person who typed the
260
+ // address was trying to reach.
261
+ learned.set(`${peer.addr}:${peer.port}`, { fp: conn.peerFp, name: conn.peerName || "" });
262
+
263
+ if (!trustedPeer(cfg.trusted, conn.peerFp)) {
264
+ const { list, added } = addTrusted(cfg.trusted, {
265
+ fp: conn.peerFp, pub: conn.peerPub, name: conn.peerName,
266
+ });
267
+ if (added) { cfg = { ...cfg, trusted: list }; onTrust?.(list); }
268
+ }
269
+
200
270
  const theirs = await ask({ t: "manifest" });
201
271
  if (theirs?.t !== "manifest" || !Array.isArray(theirs.accounts)) throw new Error("no manifest");
202
272
  const mine = await localAccounts();
@@ -205,10 +275,10 @@ export function createEngine({
205
275
  // A HEAL NEEDS MY TICK; AN ADD DOES NOT, and the asymmetry is deliberate.
206
276
  // Healing replaces a slot I already have, so it is only reasonable for an
207
277
  // account I said I share. Adding is the case the owner asked for by name:
208
- // an account that appears in the group appears everywhere in it, which is
209
- // the whole of "I do not want to paste blobs any more". The group is
210
- // passphrase-gated, so what can reach this is what somebody I trusted
211
- // with that passphrase chose to offer.
278
+ // an account that appears among the decks I paired with appears on all of
279
+ // them, which is the whole of "I do not want to paste blobs any more".
280
+ // What can reach this is what a deck somebody here pressed accept on
281
+ // chose to offer.
212
282
  const wanted = plan(mine, theirs.accounts)
213
283
  .filter(step => step.action === "add" || cfg.shared.includes(step.key));
214
284
  const done = [];
@@ -216,12 +286,12 @@ export function createEngine({
216
286
  const nonce = randomBytes(12).toString("hex");
217
287
  const reply = await ask({
218
288
  t: "want", key: step.key, nonce,
219
- proof: transferChallenge(key, {
289
+ proof: transferChallenge(conn.key, {
220
290
  nonce, accountKey: step.key, fromFp: identity.fp, toFp: conn.peerFp,
221
291
  }),
222
292
  });
223
293
  if (reply?.t !== "have" || !reply.sealed) { done.push({ ...step, ok: false, why: reply?.why ?? "refused" }); continue; }
224
- const blob = open(key, reply.sealed, `${conn.peerFp}->${identity.fp}|${step.key}`);
294
+ const blob = open(conn.key, reply.sealed, `${conn.peerFp}->${identity.fp}|${step.key}`);
225
295
  if (!blob) { done.push({ ...step, ok: false, why: "could not open" }); continue; }
226
296
  const ok = await importAccount(blob);
227
297
  done.push({ ...step, ok: !!ok, why: ok ? null : "import failed" });
@@ -241,13 +311,19 @@ export function createEngine({
241
311
  *
242
312
  * Broadcast dies at the first router and is dropped by a switch that
243
313
  * filters it, so a deck across a VPN or on another subnet is unreachable by
244
- * discovery and perfectly reachable by address. The passphrase remains the
245
- * only gate, so an address is not a way in — it is a way to be dialled.
314
+ * discovery and perfectly reachable by address. Typing one is a decision to
315
+ * trust whatever answers there the first time, and to pin it: an address is
316
+ * a way to reach a deck, and the accept on the other machine is what lets
317
+ * anything move.
246
318
  *
247
319
  * Keyed by `host:port` rather than by fingerprint, because a fingerprint is
248
320
  * what a deck says about itself after the handshake and this list has to
249
321
  * exist before there has been one. */
250
322
  const manual = new Map();
323
+ /** What answered at a typed address, once something has. Keyed the same way
324
+ * `manual` is, because until a connection succeeds an address is all there
325
+ * is to key on. */
326
+ const learned = new Map();
251
327
 
252
328
  const round = async () => {
253
329
  if (!beacon) return [];
@@ -274,36 +350,271 @@ export function createEngine({
274
350
  const was = cfg;
275
351
  cfg = { ...cfg, ...next };
276
352
  const restart = !was.enabled !== !cfg.enabled
277
- || was.passphrase !== cfg.passphrase
278
- || was.name !== cfg.name
279
- || was.deckId !== cfg.deckId;
353
+ || was.secret !== cfg.secret
354
+ || was.name !== cfg.name;
280
355
  if (!restart) return;
281
356
  this.stop();
282
- if (!cfg.enabled || !cfg.passphrase) return;
283
- key = groupKey(cfg.passphrase);
284
- identity = newIdentity(cfg.deckId);
285
- // Hand the caller an id to keep when there was none, so the next start is
286
- // the same deck rather than a new row in everybody's list.
287
- if (identity.id !== cfg.deckId) onIdentity?.(identity.id);
288
- server = createSyncServer({ fp: identity.fp, name: cfg.name, key, handlers: serve, onError });
289
- const port = await server.start();
357
+ if (!cfg.enabled) return;
358
+ identity = identityFrom(cfg.secret);
359
+ // Hand the caller a key to keep when there was none, so the next start is
360
+ // the same deck rather than a stranger to everybody who paired with it.
361
+ if (identity.secret !== cfg.secret) {
362
+ cfg = { ...cfg, secret: identity.secret };
363
+ onIdentity?.(identity.secret);
364
+ }
365
+ // The port last used, so an address somebody typed on the other machine
366
+ // still works after this deck restarts. createSyncServer falls through to
367
+ // an OS-chosen one when it is taken, and the caller stores whatever came
368
+ // back — so the pin drifts to a free port rather than failing.
369
+ server = createSyncServer({
370
+ fp: identity.fp, pub: identity.pub, secret: identity.secret,
371
+ name: cfg.name, handlers: serve, onError, prefer: cfg.port,
372
+ trusted: () => cfg.trusted,
373
+ invite: () => (invite && invite.expiresAt > now() ? invite : null),
374
+ // Somebody used the token. They are pinned, and the token is retired —
375
+ // one that pairs twice is one worth stealing twice.
376
+ onInviteUsed: entry => {
377
+ const { list } = addTrusted(cfg.trusted, { fp: entry.fp, pub: entry.pub, name: entry.name });
378
+ cfg = { ...cfg, trusted: list };
379
+ invite = null;
380
+ // AND DIAL IT BACK, KEPT. Accepting made it welcome and left this
381
+ // deck with no way to reach it: an inbound connection puts nothing in
382
+ // the dial list. Without this the pairing is mutual in the trusted
383
+ // list and one-way in fact — and `addPeer` alone lives in memory, so
384
+ // it would be one-way again after the next restart.
385
+ if (entry.addr && entry.port) {
386
+ this.addPeer(entry.addr, entry.port);
387
+ onDial?.(`${entry.addr}:${entry.port}`);
388
+ // AND SAY WHO IS THERE, NOW. `learned` is what joins a dialled row
389
+ // to a heard one, and it was only ever filled by a round that
390
+ // succeeded — so between accepting a deck and the next round, one
391
+ // machine appeared as two rows. We already know the answer here:
392
+ // the handshake that just finished said so.
393
+ learned.set(`${entry.addr}:${entry.port}`, { fp: entry.fp, name: entry.name || "" });
394
+ }
395
+ onTrust?.(list);
396
+ onChange?.();
397
+ },
398
+ // Asked before the request is drawn, so a deck that was told no is
399
+ // told no again rather than becoming a row somebody has to answer
400
+ // twice. The socket sends the reason; this only knows the name.
401
+ declined: fp => declined.has(fp),
402
+ onPending: entry => {
403
+ const had = pending.get(entry.fp);
404
+ pending.set(entry.fp, { ...entry, at: had?.at ?? now(), lastAt: now() });
405
+ if (!had) onChange?.();
406
+ },
407
+ });
408
+ let port;
409
+ try {
410
+ port = await server.start();
411
+ } catch (err) {
412
+ // Kept, so the panel can say it. Rethrown, because the caller's own
413
+ // catch is what leaves the engine stopped rather than half-started.
414
+ stalled = err?.message ?? String(err);
415
+ throw err;
416
+ }
417
+ stalled = null;
418
+ if (port !== cfg.port) onPort?.(port);
290
419
  beacon = createBeacon({
291
- port, name: cfg.name, fp: identity.fp, key,
420
+ port, name: cfg.name, fp: identity.fp,
421
+ trusted: () => cfg.trusted,
292
422
  onPeer: () => onChange?.(),
293
- // Take a new name and keep it. Two decks with one id are invisible to
294
- // each other forever otherwise, and the second one to notice moving is
295
- // enough — whichever notices first, moves.
423
+ onStranger: entry => {
424
+ const had = strangers.get(entry.fp);
425
+ strangers.set(entry.fp, entry);
426
+ // Only a deck that is new to us is news. A beacon every thirty
427
+ // seconds from one already on the list is not a reason to redraw.
428
+ if (!had) onChange?.();
429
+ },
430
+ // Take a new key and keep it. Two decks with one identity are invisible
431
+ // to each other forever otherwise, and the second one to notice moving
432
+ // is enough — whichever notices first, moves.
296
433
  onIdClash: () => {
297
- const fresh = newIdentity();
298
- onIdentity?.(fresh.id);
299
- onError?.("id-clash", new Error("another deck was using this one's id; taking a new one"));
434
+ const fresh = identityFrom("");
435
+ onIdentity?.(fresh.secret);
436
+ onError?.("id-clash", new Error("another deck was using this one's key; taking a new one"));
300
437
  },
301
438
  onError, now,
439
+ ...(createSocket ? { createSocket } : {}),
302
440
  });
303
441
  await beacon.start();
304
442
  timer = setInterval(() => { void round(); }, SYNC_MS);
305
443
  timer.unref?.();
306
444
  },
445
+ /**
446
+ * Make an invite: every address this deck has, its port, its name, and a
447
+ * code, in one piece of text somebody sends however they already talk.
448
+ *
449
+ * EVERY ADDRESS, and that is the whole reason this exists. A person cannot
450
+ * know which of their machine's addresses the other machine can route to —
451
+ * a VPN, a second card, another subnet, all real and all at once — and
452
+ * neither can this deck. The one machine that can find out is the one doing
453
+ * the reaching, so it gets the list and tries it.
454
+ */
455
+ invite() {
456
+ if (!server || !beacon) return null;
457
+ const port = server.port();
458
+ if (port == null) return null;
459
+ const addrs = localAddresses().map(a => `${a}:${port}`);
460
+ const made = mintInvite({ addrs, name: cfg.name, now: now() });
461
+ if (!made) return null;
462
+ invite = made;
463
+ onChange?.();
464
+ return { token: made.token, expiresAt: made.expiresAt, addrs };
465
+ },
466
+
467
+ /** What this deck is offering right now, for the panel to draw. Null once
468
+ * it has run out, so a token nobody can use is not shown as if they could. */
469
+ offering() {
470
+ if (!invite || invite.expiresAt <= now()) return null;
471
+ return { token: invite.token, expiresAt: invite.expiresAt };
472
+ },
473
+
474
+ /** Put it away without using it. */
475
+ withdraw() {
476
+ const had = !!invite;
477
+ invite = null;
478
+ if (had) onChange?.();
479
+ return had;
480
+ },
481
+
482
+ /**
483
+ * Join on somebody else's invite: try every address it carries until one
484
+ * answers, and pair with whatever does.
485
+ *
486
+ * IN ORDER, AND STOPPING AT THE FIRST, because the addresses are the same
487
+ * deck seen from different networks — reaching it twice would pair one deck
488
+ * as two. The failures are collected rather than thrown away: when none of
489
+ * them worked, which ones were tried and what each said is the only thing
490
+ * the reader can act on.
491
+ */
492
+ async join(token) {
493
+ const inv = readInvite(token, now());
494
+ if (!inv) return { ok: false, reason: "not_an_invite" };
495
+ if (inv.expired) return { ok: false, reason: "expired" };
496
+ if (!identity || !server) return { ok: false, reason: "not_running" };
497
+ const tried = [];
498
+ for (const at of inv.addrs) {
499
+ let conn = null;
500
+ try {
501
+ conn = await connectToPeer({
502
+ host: at.addr, port: at.port, timeoutMs: ROUND_MS,
503
+ fp: identity.fp, pub: identity.pub, secret: identity.secret,
504
+ name: cfg.name, myPort: server.port(), code: inv.code,
505
+ });
506
+ const { list } = addTrusted(cfg.trusted, {
507
+ fp: conn.peerFp, pub: conn.peerPub, name: conn.peerName || inv.name,
508
+ });
509
+ cfg = { ...cfg, trusted: list };
510
+ this.addPeer(at.addr, at.port);
511
+ onDial?.(`${at.addr}:${at.port}`);
512
+ learned.set(`${at.addr}:${at.port}`, { fp: conn.peerFp, name: conn.peerName || inv.name });
513
+ onTrust?.(list);
514
+ onChange?.();
515
+ return {
516
+ ok: true,
517
+ peer: { fp: conn.peerFp, name: conn.peerName || inv.name, addr: at.addr, port: at.port },
518
+ tried,
519
+ };
520
+ } catch (err) {
521
+ tried.push({ addr: `${at.addr}:${at.port}`, why: err.message });
522
+ } finally {
523
+ conn?.sock?.destroy();
524
+ }
525
+ }
526
+ return { ok: false, reason: "unreachable", tried };
527
+ },
528
+
529
+ /**
530
+ * Accept a deck, which is the only thing that lets anything move.
531
+ *
532
+ * It takes the fingerprint AND the key that was seen with it, from the
533
+ * pending or heard list — never from whatever is at an address now, because
534
+ * the point of pinning is that the thing answering later has to be the same
535
+ * thing. A fingerprint nobody has actually met is refused rather than
536
+ * trusted on a name somebody typed.
537
+ */
538
+ accept(fp) {
539
+ const asked = pending.get(fp) ?? null;
540
+ const heard = strangers.get(fp) ?? null;
541
+ const seen = asked ?? heard;
542
+ if (!seen) return null;
543
+ // TWO KINDS OF ROW, AND THEY ARE NOT THE SAME CLAIM.
544
+ //
545
+ // A deck that ASKED finished a handshake, so it held the private half of
546
+ // the key it announced and that key can be pinned right here. A deck we
547
+ // merely HEARD has only shouted: a beacon carries a fingerprint and no
548
+ // key, and pinning a fingerprint with no key to check it against later is
549
+ // worse than not pinning at all — it looks like a pairing and is not one.
550
+ //
551
+ // So accepting a heard deck starts a conversation rather than ending one:
552
+ // its address goes on the dial list, the next round reaches it and pins
553
+ // whatever answers, and its owner gets the same request to accept. Which
554
+ // is the same two presses, in the other order.
555
+ if (!seen.pub) {
556
+ if (!seen.addr || !seen.port) return null;
557
+ this.addPeer(seen.addr, seen.port);
558
+ strangers.delete(fp);
559
+ onChange?.();
560
+ return { fp, name: seen.name, addr: seen.addr, port: seen.port, dialled: true };
561
+ }
562
+ const { list, added } = addTrusted(cfg.trusted, { fp, pub: seen.pub, name: seen.name });
563
+ cfg = { ...cfg, trusted: list };
564
+ pending.delete(fp);
565
+ strangers.delete(fp);
566
+ onTrust?.(list);
567
+ onChange?.();
568
+ // AND DIAL IT BACK. Accepting a deck that called us made it welcome and
569
+ // left this one with no way to reach it: the peer list is what this deck
570
+ // dials, and an inbound connection puts nothing in it. So the pairing was
571
+ // mutual in the trusted list and one-way in fact — if the other machine
572
+ // stopped calling, nothing here would ever call it. The hello carries the
573
+ // port it listens on for exactly this.
574
+ const back = seen.addr && seen.port && this.addPeer(seen.addr, seen.port)
575
+ ? (learned.set(`${seen.addr}:${seen.port}`, { fp, name: seen.name || "" }),
576
+ { addr: seen.addr, port: seen.port })
577
+ : null;
578
+ return added ? { fp, name: seen.name, addr: seen.addr, port: seen.port ?? null, dialBack: back } : null;
579
+ },
580
+ /** Say no, and stop being asked. The deck is dropped from both lists; if it
581
+ * connects again it is a new request, because refusing is not a block. */
582
+ dismiss(fp) {
583
+ // Whatever the row said, kept — the panel draws a declined deck by name
584
+ // and address, and after the delete below there is nowhere else to read
585
+ // them from.
586
+ const was = pending.get(fp) ?? strangers.get(fp) ?? null;
587
+ const had = pending.delete(fp) || strangers.delete(fp);
588
+ if (had) {
589
+ declined.set(fp, {
590
+ fp,
591
+ name: was?.name ?? fp,
592
+ addr: was?.addr ?? "",
593
+ port: was?.port ?? 0,
594
+ at: now(),
595
+ });
596
+ onChange?.();
597
+ }
598
+ return had;
599
+ },
600
+ /** Change your mind. The name comes off the declined list and the next time
601
+ * that deck dials, it is a request again — which it will, on its own, so
602
+ * there is nothing else to press. */
603
+ allow(fp) {
604
+ const had = declined.delete(fp);
605
+ if (had) onChange?.();
606
+ return had;
607
+ },
608
+ /** Unpair. It stops what has not happened yet and takes back nothing that
609
+ * has — the same sentence the panel says about a shared login. */
610
+ unpair(fp) {
611
+ const list = dropTrusted(cfg.trusted, fp);
612
+ if (list.length === cfg.trusted.length) return false;
613
+ cfg = { ...cfg, trusted: list };
614
+ onTrust?.(list);
615
+ onChange?.();
616
+ return true;
617
+ },
307
618
  round,
308
619
  /** Dial this address on every round from now on. Returns false for an
309
620
  * address that is not one, rather than storing a row that can never
@@ -332,6 +643,9 @@ export function createEngine({
332
643
  return {
333
644
  enabled: !!cfg.enabled,
334
645
  running: !!beacon,
646
+ // Said only while it is true, and it is only ever true of a deck that
647
+ // is switched on and has no listener.
648
+ stalled: cfg.enabled && !beacon ? stalled : null,
335
649
  name: cfg.name,
336
650
  fp: identity?.fp ?? null,
337
651
  // The address and port a person on another subnet types into the other
@@ -340,20 +654,94 @@ export function createEngine({
340
654
  port: server?.port() ?? null,
341
655
  addrs: beacon ? localAddresses() : [],
342
656
  shared: [...cfg.shared],
343
- // The rule is stillListed's, in lan-sync.mjs, where it can be tested.
344
- peers: beacon ? [...beacon.peers.values(), ...manual.values()]
345
- .filter(p => stillListed(p, now()))
346
- .map(p => ({ ...p, last: lastRound.get(p.fp) ?? null })) : [],
657
+ // The token this deck is offering, if any. Drawn as the one thing to do
658
+ // when nobody is paired yet, and put away once somebody is.
659
+ invite: invite && invite.expiresAt > now()
660
+ ? { token: invite.token, expiresAt: invite.expiresAt }
661
+ : null,
662
+ // Decks somebody accepted, decks that asked and have not been answered,
663
+ // and decks merely heard. Three lists because they are three different
664
+ // things a person does something different about.
665
+ trusted: cfg.trusted.map(t => ({ fp: t.fp, name: t.name })),
666
+ pending: [...pending.values()].map(p => ({ fp: p.fp, name: p.name, addr: p.addr, at: p.at })),
667
+ // Only the ones somebody could actually pair with right now, one row
668
+ // per machine, newest first — see pairable, which is where the rule
669
+ // that keeps this from becoming a wall of ghosts lives.
670
+ strangers: (() => {
671
+ // A deck that was told no is not somebody to offer pairing with. It
672
+ // has its own row, with the one control that undoes the decision.
673
+ const heard = [...strangers.values()].filter(p => !declined.has(p.fp));
674
+ const { shown, more } = pairable(heard, now(), { mine: localAddresses() });
675
+ return shown.map(p => ({ fp: p.fp, name: p.name, addr: p.addr, port: p.port, at: p.at, more }));
676
+ })(),
677
+ // Said no to, by somebody at this keyboard. Listed rather than merely
678
+ // silenced, because a refusal nobody can see is a refusal nobody can
679
+ // take back.
680
+ declined: [...declined.values()].map(p => ({ fp: p.fp, name: p.name, addr: p.addr, at: p.at })),
681
+ peers: beacon ? (() => {
682
+ // ONE DECK, ONE ROW, and it takes work because a deck can arrive here
683
+ // twice by two different routes: heard on the network, and dialled at
684
+ // an address somebody typed or that an invite carried. Both are the
685
+ // same machine and neither knows it — the beacon row is keyed by the
686
+ // fingerprint it announced, the typed row by `host:port`, and until a
687
+ // connection succeeds nothing joins them.
688
+ //
689
+ // What joins them is `learned`: the fingerprint that actually
690
+ // answered at that address. So every row is given the identity it is
691
+ // really about, and rows that turn out to share one are merged — the
692
+ // heard half brings liveness, the dialled half brings the last round.
693
+ const rows = [];
694
+ const byId = new Map();
695
+ const put = row => {
696
+ const had = byId.get(row.id);
697
+ if (!had) { byId.set(row.id, row); rows.push(row); return; }
698
+ // Keep what each half is the authority on.
699
+ had.lastSeen = had.lastSeen ?? row.lastSeen;
700
+ had.last = had.last ?? row.last;
701
+ had.manual = had.manual || row.manual;
702
+ had.met = had.met || row.met;
703
+ if (row.name && !had.name) had.name = row.name;
704
+ };
705
+ for (const p of [...beacon.peers.values(), ...manual.values()]) {
706
+ if (!stillListed(p, now())) continue;
707
+ const met = p.manual ? learned.get(`${p.addr}:${p.port}`) : null;
708
+ const id = met?.fp ?? p.fp;
709
+ put({
710
+ ...p,
711
+ id,
712
+ // The fingerprint an unpair has to name. A typed row's own `fp` is
713
+ // a placeholder built from its address and matches nothing.
714
+ peerFp: p.manual ? met?.fp ?? null : p.fp,
715
+ name: met?.name || p.name,
716
+ met: !!met,
717
+ paired: !!trustedPeer(cfg.trusted, id),
718
+ last: lastRound.get(p.fp) ?? null,
719
+ });
720
+ }
721
+ // A DECK WE ARE PAIRED WITH AND DO NOT DIAL. It called us, we accepted
722
+ // it, and nothing here has its address — which used to mean the panel
723
+ // listed failing addresses under "paired decks" and left out the one
724
+ // deck that actually was.
725
+ for (const t of cfg.trusted) {
726
+ if (byId.has(t.fp)) continue;
727
+ put({
728
+ id: t.fp, fp: t.fp, peerFp: t.fp, name: t.name || t.fp, addr: "", port: 0,
729
+ paired: true, waiting: true, last: lastRound.get(t.fp) ?? null,
730
+ });
731
+ }
732
+ return rows;
733
+ })() : [],
347
734
  };
348
735
  },
349
736
  stop() {
350
737
  if (timer) clearInterval(timer);
738
+ // A deliberate stop is not a fault, and the next start says its own.
739
+ if (!cfg.enabled) stalled = null;
351
740
  timer = null;
352
741
  beacon?.stop();
353
742
  server?.stop();
354
743
  beacon = null;
355
744
  server = null;
356
- key = null;
357
745
  },
358
746
  };
359
747
  }