ccdeck 3.28.1 → 3.29.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.
@@ -27,7 +27,8 @@
27
27
  // something in it, which is when an account is actually broken. A deck whose
28
28
  // accounts all work talks to its peers every minute and never asks for
29
29
  // anything.
30
- import { accountKey, currentFor, manifestFor, open, plan, seal, stillListed, transferChallenge } from "./lan-sync.mjs";
30
+ import { accountKey, currentFor, manifestFor, onePerKey, open, peerWhy, plan, seal, SENDER_UNREADABLE, slotFor, stillListed, transferChallenge } from "./lan-sync.mjs";
31
+ import { storedCopyAlive, cachedExportReadable, liveLoginIs } from "./account-health.mjs";
31
32
  import { connectToPeer, createBeacon, createSyncServer, DISCOVERY_PORT, MAX_FRAME_BYTES } from "./lan-socket.mjs";
32
33
  import { addTrusted, dropTrusted, identityFrom, mintInvite, pairable, readInvite, trustedPeer } from "./lan-sync.mjs";
33
34
  import { openAbout, sealAbout } from "./lan-about.mjs";
@@ -156,9 +157,8 @@ function flatten(v, max) {
156
157
  }
157
158
 
158
159
  export function offered(list) {
159
- return (Array.isArray(list) ? list : [])
160
+ return onePerKey((Array.isArray(list) ? list : [])
160
161
  .filter(a => a && typeof a.key === "string" && typeof a.email === "string")
161
- .slice(0, 50)
162
162
  // Character-filtered, not merely cut. These two are drawn beside the
163
163
  // fingerprint at the moment the operator picks which of a peer's logins to
164
164
  // import (LanPeerModal.tsx:497), and a bare slice let a format character
@@ -166,9 +166,16 @@ export function offered(list) {
166
166
  .map(a => ({
167
167
  key: flatten(a.key, 320),
168
168
  email: flatten(a.email, 254),
169
- alive: a.alive === true,
169
+ alive: storedCopyAlive(a.alive, a.collector),
170
+ // Additive wire field: an older peer omitted it, which means shareable.
171
+ ...(a.shareable === false ? { shareable: false } : {}),
170
172
  }))
171
- .filter(a => a.key && a.email);
173
+ .filter(a => a.key && a.email))
174
+ // Fifty IDENTITIES, so the cap is spent after onePerKey rather than before
175
+ // it: a peer with duplicate slots could otherwise push a live copy past
176
+ // row fifty and out of the list while offering far fewer than fifty
177
+ // logins. The raw array is already bounded by MAX_FRAME_BYTES.
178
+ .slice(0, 50);
172
179
  }
173
180
 
174
181
  /**
@@ -212,7 +219,7 @@ export function ticksOnArrival(step, via) {
212
219
  }
213
220
 
214
221
  export function createEngine({
215
- readAccounts, exportAccount, importAccount,
222
+ readAccounts, exportAccount, importAccount, checkArrivals, liveLogin,
216
223
  onChange, onError, onIdentity, onPort, onTrust, onUnpaired, onDial, onShared, now = Date.now,
217
224
  /**
218
225
  * The UDP socket the beacon shouts through, injectable for the same reason
@@ -263,7 +270,7 @@ export function createEngine({
263
270
  } = {}) {
264
271
  let cfg = {
265
272
  enabled: false, name: defaultName(), secret: "", shared: [], trusted: [], unpaired: [], port: 0,
266
- autoAsk: true, autoAccept: true, aliases: {},
273
+ autoAsk: true, autoAccept: true, pairingMode: "automatic", aliases: {},
267
274
  // Tell paired decks which shared account this one is on — see currentFor.
268
275
  shareActive: true,
269
276
  // DISCOVERY OVER TAILSCALE, off until somebody turns it on, and its own
@@ -273,6 +280,14 @@ export function createEngine({
273
280
  // person's own Tailscale account — see routeOf.
274
281
  tailscale: false, tailscaleAsk: true, tailscaleAccept: true,
275
282
  };
283
+ // Bumped by every stop, a restart included: anything started before it
284
+ // belongs to a listener that is gone.
285
+ let generation = 0;
286
+ // What a round checks instead, so a round stopped by switching LAN off
287
+ // cannot resume when it is switched back on. A restart for a new name or
288
+ // key revokes nothing — the peer is still paired and LAN still on — so a
289
+ // transfer in flight lands and is shared onward as usual.
290
+ let session = 0;
276
291
  let identity = null;
277
292
  let beacon = null;
278
293
  let server = null;
@@ -405,6 +420,8 @@ export function createEngine({
405
420
 
406
421
  /** The tailnet read's own timer, running only while the switch is on. */
407
422
  let tailTimer = null;
423
+ // An in-flight refresh may finish after discovery is disabled or restarted.
424
+ let tailRefreshGeneration = 0;
408
425
 
409
426
  /** Who held the discovery port the last time it was asked, for as long as
410
427
  * this deck cannot hear: the answer does not change between two tries half a
@@ -443,8 +460,8 @@ export function createEngine({
443
460
  };
444
461
 
445
462
  /** The two permissions that answer for one route. */
446
- const asksOn = via => (via === "tailscale" ? !!cfg.tailscale && cfg.tailscaleAsk !== false : !!cfg.autoAsk);
447
- const saysYesOn = via => (via === "tailscale" ? !!cfg.tailscale && cfg.tailscaleAccept !== false : !!cfg.autoAccept);
463
+ const asksOn = via => cfg.pairingMode !== "invite" && (via === "tailscale" ? !!cfg.tailscale && cfg.tailscaleAsk !== false : !!cfg.autoAsk);
464
+ const saysYesOn = via => cfg.pairingMode !== "invite" && (via === "tailscale" ? !!cfg.tailscale && cfg.tailscaleAccept !== false : !!cfg.autoAccept);
448
465
 
449
466
  /**
450
467
  * Read the tailnet on a timer while the switch is on, and not at all while it
@@ -458,10 +475,17 @@ export function createEngine({
458
475
  const syncTailnet = () => {
459
476
  const want = !!(beacon && tailnet && cfg.enabled && cfg.tailscale);
460
477
  if (want && !tailTimer) {
461
- void tailnet.refresh().then(() => beacon?.announce(), () => {});
478
+ const startedIn = ++tailRefreshGeneration;
479
+ const currentBeacon = beacon;
480
+ void tailnet.refresh().then(() => {
481
+ if (startedIn === tailRefreshGeneration && beacon === currentBeacon && cfg.enabled && cfg.tailscale) {
482
+ currentBeacon.announce();
483
+ }
484
+ }, () => {});
462
485
  tailTimer = setInterval(() => { void tailnet.refresh(); }, TAILNET_MS);
463
486
  tailTimer.unref?.();
464
487
  } else if (!want && tailTimer) {
488
+ tailRefreshGeneration++;
465
489
  clearInterval(tailTimer);
466
490
  tailTimer = null;
467
491
  }
@@ -474,7 +498,14 @@ export function createEngine({
474
498
  return (got?.accounts ?? []).map(a => ({
475
499
  key: accountKey(a.email, a.orgUuid),
476
500
  email: a.email,
477
- alive: a.alive === true,
501
+ org: a.orgUuid,
502
+ alive: storedCopyAlive(a.alive, a.collector),
503
+ // False only for a login the wiring knows this process cannot read (a
504
+ // Mac whose Keychain will not open from here). Absent means readable,
505
+ // which is every deck that does not say.
506
+ readable: a.readable !== false && cachedExportReadable(a.collector, { active: a.active === true }),
507
+ unreadableWhy: a.readable === false || a.collector === "keychain_unavailable"
508
+ ? SENDER_UNREADABLE : "export failed",
478
509
  num: a.num,
479
510
  // The one this deck is on — claude-swap's own answer, one at most.
480
511
  active: a.active === true,
@@ -542,6 +573,10 @@ export function createEngine({
542
573
  };
543
574
 
544
575
  const serve = async (msg, ctx) => {
576
+ // Authentication happened at connection setup; a previously trusted deck
577
+ // may have been unpaired while this socket remained open.
578
+ const mayAnswer = () => cfg.enabled && !!server && !!trustedPeer(cfg.trusted, ctx?.peerFp);
579
+ if (!mayAnswer()) return ctx.send({ t: "no", why: "not paired" });
545
580
  // Before the verbs, and for every one of them: something that proved it
546
581
  // holds a key this deck accepted is talking, now.
547
582
  if (ctx?.peerFp) {
@@ -567,6 +602,10 @@ export function createEngine({
567
602
  offersBy.set(ctx.peerFp, { at: now(), accounts: list, current: heardCurrent(msg.current, list) });
568
603
  }
569
604
  const accounts = await localAccounts();
605
+ // Reading the store can take long enough for the owner to unpair this
606
+ // deck. Do not disclose account identities or the active account from
607
+ // a manifest assembled before that decision.
608
+ if (!mayAnswer()) return ctx.send({ t: "no", why: "not paired" });
570
609
  return ctx.send({
571
610
  t: "manifest", accounts: manifestFor(accounts, cfg.shared),
572
611
  ...currentFor(accounts, cfg.shared, cfg.shareActive),
@@ -574,6 +613,12 @@ export function createEngine({
574
613
  });
575
614
  }
576
615
  if (msg.t === "want") {
616
+ // A listener may have authenticated this socket before its owner
617
+ // unpaired the caller or switched sharing off. Recheck at the moment
618
+ // a credential is requested and again after every asynchronous read.
619
+ const maySend = () => cfg.enabled && !!server && !!trustedPeer(cfg.trusted, ctx.peerFp)
620
+ && cfg.shared.includes(msg.key);
621
+ if (!maySend()) return ctx.send({ t: "no", why: "not shared" });
577
622
  // A SECOND PROOF, for the one operation that moves a credential. The
578
623
  // session says who connected; this says they are asking for this
579
624
  // account, now. A long-lived connection authenticated an hour ago is
@@ -587,11 +632,30 @@ export function createEngine({
587
632
  // Only what the user ticked, checked again here rather than trusted
588
633
  // from the manifest we sent: the list can change between the two, and
589
634
  // the answer that matters is the one at the moment of sending.
590
- if (!cfg.shared.includes(msg.key)) return ctx.send({ t: "no", why: "not shared" });
591
635
  const accounts = await localAccounts();
592
- const mine = accounts.find(a => a.key === msg.key);
593
- if (!mine || !mine.alive) return ctx.send({ t: "no", why: "not mine to give" });
594
- const blob = await exportAccount(mine.num);
636
+ if (!maySend()) return ctx.send({ t: "no", why: "not shared" });
637
+ // The slot manifestFor told the peer about, by the same rule: with two
638
+ // slots for one identity, an expired one listed first must not hide a
639
+ // live one behind it.
640
+ const mine = slotFor(accounts, msg.key);
641
+ if (!mine) return ctx.send({ t: "no", why: "not mine to give" });
642
+ // A LOGIN THIS DECK CANNOT READ IS SAID SO, from state rather than from
643
+ // a failed export: no subprocess, and nothing the CLI printed. The
644
+ // asking deck prints it under the account, so the person learns which
645
+ // machine to unlock instead of reading "export failed".
646
+ if (!mine.readable) return ctx.send({ t: "no", why: mine.unreadableWhy });
647
+ if (!mine.alive) return ctx.send({ t: "no", why: "not mine to give" });
648
+ // The active slot exports the live CLI login, and its verdict can
649
+ // predate a `/login` as somebody else, so ask the CLI who it is now.
650
+ if (mine.active && liveLogin && !liveLoginIs(await liveLogin(), mine.email, mine.org)) {
651
+ return ctx.send({ t: "no", why: "export failed" });
652
+ }
653
+ if (!maySend()) return ctx.send({ t: "no", why: "not shared" });
654
+ const blob = await exportAccount(mine.num, msg.key);
655
+ if (!maySend()) return ctx.send({ t: "no", why: "not shared" });
656
+ // A Mac's failed export refreshes the verdict behind `readable` in the
657
+ // background, so when the Keychain was why, the next ask is answered by
658
+ // the line above.
595
659
  if (!blob) return ctx.send({ t: "no", why: "export failed" });
596
660
  const aad = `${identity.fp}->${ctx.peerFp}|${msg.key}`;
597
661
  return ctx.send({ t: "have", key: msg.key, sealed: seal(ctx.key, blob, aad) });
@@ -623,7 +687,7 @@ export function createEngine({
623
687
  * is nothing before this, so the check lives here.
624
688
  */
625
689
  const askToAccept = entry => {
626
- if (declined.has(entry.fp)) return;
690
+ if (cfg.pairingMode === "invite" || declined.has(entry.fp)) return;
627
691
  const had = pending.get(entry.fp);
628
692
  // WHICH SWITCH ANSWERS depends on where the deck is. A request from the
629
693
  // tailnet is answered by the Tailscale pair, and only for a machine on this
@@ -654,9 +718,23 @@ export function createEngine({
654
718
  * read from the routing table, the way the peer list reads it. */
655
719
  const viaOf = peer => peer.via ?? (routeTo(peer.addr) ? "tailscale" : "lan");
656
720
 
721
+ /** Checks the logins a round brought, once, and records any reason one is
722
+ * unusable here on its row. An unanswered check is not a failure. */
723
+ const markArrivals = async done => {
724
+ const arrived = done.filter(d => d.ok);
725
+ if (!arrived.length || !checkArrivals) return;
726
+ let found = null;
727
+ try { found = await checkArrivals(arrived); } catch { /* unasked is not a failure of the round */ }
728
+ arrived.forEach((d, i) => { if (typeof found?.[i] === "string") d.why = found[i]; });
729
+ };
730
+
657
731
  /** Ask one peer what it has, and heal whatever it can heal. */
658
732
  const roundWith = async peer => {
659
733
  let conn = null;
734
+ const startedIn = session;
735
+ // Out here, so a round that dies after some logins arrived still reports
736
+ // them — see the catch below.
737
+ const done = [];
660
738
  try {
661
739
  conn = await connectToPeer({
662
740
  host: peer.addr, port: peer.port, timeoutMs: ROUND_MS,
@@ -667,6 +745,10 @@ export function createEngine({
667
745
  // The key pinned when this deck was accepted, so a second machine
668
746
  // answering at that address is refused rather than talked to.
669
747
  expectPub: trustedPeer(cfg.trusted, peer.fp)?.pub ?? null,
748
+ // Invite-only still dials the rows it has — an invite-paired deck is one
749
+ // of them — but tells the far end it is not asking, so a row that turns
750
+ // out to be a stranger is refused there instead of becoming a request.
751
+ ask: cfg.pairingMode !== "invite",
670
752
  sealFrames, ephemeral,
671
753
  });
672
754
  // A SECOND READER ON THE SAME SOCKET, AND IT HAS TO KEEP THE SAME CAP.
@@ -683,14 +765,22 @@ export function createEngine({
683
765
  // reject path also never removed the listener; only roundWith's
684
766
  // `finally { conn?.sock?.destroy(); }` stopped it.
685
767
  const ask = frame => new Promise((resolve, reject) => {
686
- const bell = setTimeout(() => reject(new Error("peer went quiet")), ROUND_MS);
687
- bell.unref?.();
688
768
  let buf = "";
769
+ let settled = false;
689
770
  const give = (fn, arg) => {
771
+ if (settled) return;
772
+ settled = true;
690
773
  clearTimeout(bell);
691
774
  conn.sock.off("data", onData);
775
+ conn.sock.off("close", onClose);
776
+ conn.sock.off("error", onError);
692
777
  fn(arg);
693
778
  };
779
+ const onClose = () => give(reject, new Error("peer closed the connection"));
780
+ // The errno rides along in the sentence, because faultText reads it
781
+ // there: ECONNRESET is "it hung up" on the row, and a bare "peer
782
+ // connection failed" was the one fault the panel could only repeat.
783
+ const onError = err => give(reject, new Error(`peer connection failed${err?.code ? ` (${err.code})` : ""}`));
694
784
  const onData = chunk => {
695
785
  buf += chunk;
696
786
  if (buf.length > MAX_FRAME_BYTES) {
@@ -714,9 +804,18 @@ export function createEngine({
714
804
  }
715
805
  give(resolve, got);
716
806
  };
807
+ const bell = setTimeout(() => give(reject, new Error("peer went quiet")), ROUND_MS);
808
+ bell.unref?.();
717
809
  conn.sock.on("data", onData);
810
+ conn.sock.on("close", onClose);
811
+ conn.sock.on("error", onError);
718
812
  // And out through its own writer, which seals whenever the reader opens.
719
- conn.send(frame);
813
+ // A dead socket cannot throw here — sendFrame swallows a failed write,
814
+ // and `close`/`error` above are what report it. What CAN throw is the
815
+ // seal running out of counter, which is this deck's limit and not a
816
+ // network drop, so it is rejected under its own name.
817
+ if (conn.sock.destroyed) return onClose();
818
+ try { conn.send(frame); } catch (err) { give(reject, err); }
720
819
  });
721
820
 
722
821
  // WHO IS ACTUALLY THERE. A typed address is a row that says `192.168.1.5:54340`
@@ -758,6 +857,9 @@ export function createEngine({
758
857
  // A deck we DO have a pin for was checked before this line: connectToPeer
759
858
  // was given expectPub and refuses a different key at that address.
760
859
  if (!trustedPeer(cfg.trusted, conn.peerFp)) {
860
+ // Said as this deck's own setting, not as a fault: the row the panel
861
+ // draws for it is a state the owner chose (see WIRE_ANSWERS).
862
+ if (cfg.pairingMode === "invite") throw new Error("this deck pairs only by invite");
761
863
  if (!peer.typed || wasUnpaired(conn.peerFp)) {
762
864
  // The same row the listener's own `onPending` draws, from the other
763
865
  // direction: this deck dialled rather than being dialled, and the
@@ -782,12 +884,21 @@ export function createEngine({
782
884
  // on — so the deck being asked knows both without dialling back, which a
783
885
  // deck with no address for this one never could. An older deck reads the
784
886
  // question's `t` and card and nothing else, so it answers as it always did.
887
+ const stillPaired = () => session === startedIn && cfg.enabled
888
+ && trustedPeer(cfg.trusted, conn.peerFp)?.pub === conn.peerPub;
889
+ if (!stillPaired()) throw new Error("peer no longer paired");
785
890
  const mine = await localAccounts();
891
+ // The owner can revoke trust or disable sync while the store is read.
892
+ // Never send this deck's account identities on that old connection.
893
+ if (!stillPaired()) throw new Error("peer no longer paired");
786
894
  const theirs = await ask({
787
895
  t: "manifest", accounts: manifestFor(mine, cfg.shared),
788
896
  ...currentFor(mine, cfg.shared, cfg.shareActive),
789
897
  ...cardFor(conn.key, conn.peerFp),
790
898
  });
899
+ // A response from a round that was stopped or unpaired is stale even
900
+ // when the peer had already sent it before the setting changed.
901
+ if (!stillPaired()) throw new Error("peer no longer paired");
791
902
  if (theirs?.t !== "manifest" || !Array.isArray(theirs.accounts)) throw new Error("no manifest");
792
903
  const card = openAbout(conn.key, theirs.about, conn.peerFp, identity.fp);
793
904
  if (card) aboutBy.set(conn.peerFp, { ...card, at: now() });
@@ -813,8 +924,15 @@ export function createEngine({
813
924
  // importAccount untyped, which `offered`'s filter would have caught.
814
925
  const wanted = plan(mine, list)
815
926
  .filter(step => step.action === "add" || cfg.shared.includes(step.key));
816
- const done = [];
927
+ // TWO CHECKS, BECAUSE THEY END DIFFERENT THINGS. Losing the session —
928
+ // LAN switched off, the peer unpaired (`stillPaired`, above) — ends the
929
+ // round. A heal unticked mid-round ends only that heal: the adds behind
930
+ // it need no tick, and the skipped row says why rather than vanishing.
931
+ const stillWanted = step => step.action !== "heal" || cfg.shared.includes(step.key);
932
+ let cut = false;
817
933
  for (const step of wanted) {
934
+ if (!stillPaired()) { cut = true; break; }
935
+ if (!stillWanted(step)) { done.push({ ...step, ok: false, why: "not shared" }); continue; }
818
936
  const nonce = randomBytes(12).toString("hex");
819
937
  const reply = await ask({
820
938
  t: "want", key: step.key, nonce,
@@ -822,17 +940,31 @@ export function createEngine({
822
940
  nonce, accountKey: step.key, fromFp: identity.fp, toFp: conn.peerFp,
823
941
  }),
824
942
  });
825
- if (reply?.t !== "have" || !reply.sealed) { done.push({ ...step, ok: false, why: reply?.why ?? "refused" }); continue; }
943
+ if (reply?.t !== "have" || !reply.sealed) { done.push({ ...step, ok: false, why: peerWhy(reply?.why) }); continue; }
826
944
  const blob = open(conn.key, reply.sealed, `${conn.peerFp}->${identity.fp}|${step.key}`);
827
945
  if (!blob) { done.push({ ...step, ok: false, why: "could not open" }); continue; }
946
+ // Unpairing, disabling LAN, or unticking a heal while export was in
947
+ // progress takes effect before the received credential touches disk.
948
+ if (!stillPaired()) { cut = true; break; }
949
+ if (!stillWanted(step)) { done.push({ ...step, ok: false, why: "not shared" }); continue; }
828
950
  // A verdict rather than a boolean, because "refused" and "kept the
829
951
  // slot it already has" are different things to tell somebody and the
830
952
  // second one used to be reported as success. A bare `true` is still
831
- // accepted: the suite drives this with one.
953
+ // accepted: the suite drives this with one. `ok` here means the login
954
+ // LANDED; whether this deck can then use it is checked after the loop
955
+ // and rides on the same row as a warning.
832
956
  // The step goes down with the blob: the wiring has to know WHICH account
833
957
  // it is placing before it may treat a decline as an empty slot rather
834
958
  // than as a healthy one.
835
- const got = await importAccount(blob, step);
959
+ let got;
960
+ try { got = await importAccount(blob, step); }
961
+ catch {
962
+ // One local store failure must not erase earlier arrivals or stop
963
+ // independent logins from being received. Store diagnostics can
964
+ // contain credential material, so only a fixed verdict leaves here.
965
+ done.push({ ...step, ok: false, why: "import failed" });
966
+ continue;
967
+ }
836
968
  const ok = got === true || got?.ok === true;
837
969
  // AN ACCOUNT THAT ARRIVED HERE IS SHARED ONWARD (#1188). People forget
838
970
  // to tick it, and a group where one machine can heal the others and the
@@ -846,17 +978,44 @@ export function createEngine({
846
978
  // add for one; and a tailnet reaches further than the person's own
847
979
  // machines, which is a decision they make for themselves rather than
848
980
  // one an arrival makes for them.
849
- if (ok && ticksOnArrival(step, viaOf(peer))) {
981
+ // The store can finish an import after the owner disabled LAN or
982
+ // revoked this peer. Keep the imported slot, but do not turn it into
983
+ // a newly shared credential on behalf of an obsolete transfer.
984
+ if (ok && stillPaired() && ticksOnArrival(step, viaOf(peer))) {
850
985
  try { await onShared?.(step.key); }
851
986
  catch { /* the account is here; the tick is retried the next time one arrives */ }
852
987
  }
853
988
  done.push({ ...step, ok, why: ok ? null : (got?.why ?? "import failed") });
854
989
  }
855
- lastRound.set(peer.fp, { at: now(), name: peer.name, offered: theirs.accounts.length, done });
990
+ // A ROUND FROM A SESSION THAT ENDED SAYS NOTHING. LAN was switched off
991
+ // under it, and possibly on again: a cut-short list is not "all logins
992
+ // fine", and it is not this session's to report.
993
+ if (session !== startedIn) return done;
994
+ // Unpaired mid-round: said as such, not as the short list that reads
995
+ // "all logins fine".
996
+ if (cut) throw new Error("peer no longer paired");
997
+ // WHAT ARRIVED, CHECKED ONCE, AFTER THE LAST QUESTION. An import that
998
+ // exited cleanly can still have left a login this process cannot read (a
999
+ // Mac's Keychain, from SSH or a LaunchAgent). Such a row stays `ok` — it
1000
+ // DID arrive, and was ticked onward above — and carries the reason as a
1001
+ // warning. After the loop because the check is a usage collection that
1002
+ // can outlast the peer's thirty-second idle timer, and one ask covers
1003
+ // every login the round brought.
1004
+ await markArrivals(done);
1005
+ lastRound.set(peer.fp, { at: now(), name: peer.name, offered: list.length, done });
856
1006
  if (done.length) onChange?.();
857
1007
  return done;
858
1008
  } catch (err) {
859
- lastRound.set(peer.fp, { at: now(), name: peer.name, error: err.message });
1009
+ // Nor does its failure: "peer no longer paired" from a round that LAN
1010
+ // being switched off ended is about the old session, and would stand on
1011
+ // a row that is paired and fine until the next round replaced it.
1012
+ if (session !== startedIn) return [];
1013
+ // A round cut short still reports what arrived, so the logins it did
1014
+ // bring are checked the same way a finished round's are.
1015
+ await markArrivals(done);
1016
+ if (session !== startedIn) return [];
1017
+ lastRound.set(peer.fp, { at: now(), name: peer.name, error: err.message, done });
1018
+ if (done.length) onChange?.();
860
1019
  // A DIAL-BACK THAT NEVER ANSWERED IS TAKEN AWAY AGAIN. The address came
861
1020
  // from a paired deck's inbound call, and this round was the test of
862
1021
  // whether the call can be returned. It could not — a strict NAT, a
@@ -871,7 +1030,7 @@ export function createEngine({
871
1030
  lastRound.delete(peer.fp);
872
1031
  onChange?.();
873
1032
  }
874
- return [];
1033
+ return done;
875
1034
  } finally {
876
1035
  conn?.sock?.destroy();
877
1036
  }
@@ -904,6 +1063,7 @@ export function createEngine({
904
1063
 
905
1064
  const oneRound = async () => {
906
1065
  if (!beacon) return [];
1066
+ const startedIn = session;
907
1067
  const all = [];
908
1068
  // Heard first, typed second, and a typed one is skipped when the beacon
909
1069
  // already found that address: otherwise a deck that is both would be dialled
@@ -934,8 +1094,13 @@ export function createEngine({
934
1094
  // time anyway (the mutex in store-lock.mjs), and two peers healing the
935
1095
  // same account at once would race for a slot number claude-swap assigns
936
1096
  // as max+1 without a lock of its own.
1097
+ //
1098
+ // And given up when LAN is switched off under it: the rest of the list
1099
+ // belongs to no session, and dialling it only delays the next round.
1100
+ if (session !== startedIn) return all;
937
1101
  all.push(...await roundWith(peer));
938
1102
  }
1103
+ if (session !== startedIn) return all;
939
1104
  roundAt = now();
940
1105
  return all;
941
1106
  };
@@ -965,6 +1130,8 @@ export function createEngine({
965
1130
 
966
1131
  /** The whole round in flight or waiting its turn, or null. See `round` below. */
967
1132
  let _round = null;
1133
+ /** The session `_round` was asked for in. */
1134
+ let _roundIn = -1;
968
1135
 
969
1136
  /**
970
1137
  * One round at a time, and the one already running is the answer (#1040).
@@ -992,7 +1159,18 @@ export function createEngine({
992
1159
  * never a check: joining a check would answer "ask every deck" with one
993
1160
  * deck's work and leave the rest waiting another minute.
994
1161
  */
995
- const round = () => (_round ??= inTurn(oneRound).finally(() => { _round = null; }));
1162
+ //
1163
+ // NEVER A ROUND FROM A SESSION THAT ENDED. LAN switched off and on again
1164
+ // while one ran: that round stops at its next peer and reports nothing, so
1165
+ // joining it would answer the new session's first tick with nothing and
1166
+ // leave every deck unasked for a whole SYNC_MS. The new one queues behind it.
1167
+ const round = () => {
1168
+ if (_round && _roundIn === session) return _round;
1169
+ _roundIn = session;
1170
+ const mine = inTurn(oneRound).finally(() => { if (_round === mine) _round = null; });
1171
+ _round = mine;
1172
+ return mine;
1173
+ };
996
1174
 
997
1175
  return {
998
1176
  async apply(next) {
@@ -1002,6 +1180,13 @@ export function createEngine({
1002
1180
  engine = this;
1003
1181
  const was = cfg;
1004
1182
  cfg = { ...cfg, ...next };
1183
+ // A request made before invite-only was enabled must not survive the
1184
+ // switch and become an automatic approval when automatic mode returns.
1185
+ // A fresh handshake after that switch may request pairing again.
1186
+ if (was.pairingMode !== "invite" && cfg.pairingMode === "invite" && pending.size) {
1187
+ pending.clear();
1188
+ onChange?.();
1189
+ }
1005
1190
  // TURNING IT ON ANSWERS WHAT IS ALREADY WAITING. A person who switches
1006
1191
  // this on with two rows sitting in the panel means those two as much as
1007
1192
  // the next one, and leaving them queued behind a setting called
@@ -1010,8 +1195,8 @@ export function createEngine({
1010
1195
  // PER ROUTE, because each pair of switches answers for its own: turning
1011
1196
  // the local one on does not answer a tailnet request, and turning the
1012
1197
  // Tailscale one on answers only the owner's own machines.
1013
- const yes = c => ({ lan: !!c.autoAccept, tailscale: !!c.tailscale && c.tailscaleAccept !== false });
1014
- const ask = c => ({ lan: !!c.autoAsk, tailscale: !!c.tailscale && c.tailscaleAsk !== false });
1198
+ const yes = c => ({ lan: c.pairingMode !== "invite" && !!c.autoAccept, tailscale: c.pairingMode !== "invite" && !!c.tailscale && c.tailscaleAccept !== false });
1199
+ const ask = c => ({ lan: c.pairingMode !== "invite" && !!c.autoAsk, tailscale: c.pairingMode !== "invite" && !!c.tailscale && c.tailscaleAsk !== false });
1015
1200
  const turnedOn = (f, via) => !f(was)[via] && f(cfg)[via];
1016
1201
  const mayAnswer = p => (p.via === "tailscale" ? turnedOn(yes, "tailscale") && p.own : turnedOn(yes, "lan"));
1017
1202
  for (const [fp, p] of [...pending]) if (!wasUnpaired(fp) && mayAnswer(p)) this.accept(fp, { byHand: false });
@@ -1028,8 +1213,9 @@ export function createEngine({
1028
1213
  || was.secret !== cfg.secret
1029
1214
  || was.name !== cfg.name;
1030
1215
  if (!restart) { syncTailnet(); return; }
1031
- this.stop();
1216
+ this.stop(cfg.enabled);
1032
1217
  if (!cfg.enabled) return;
1218
+ const startedIn = generation;
1033
1219
  identity = identityFrom(cfg.secret);
1034
1220
  // Hand the caller a key to keep when there was none, so the next start is
1035
1221
  // the same deck rather than a stranger to everybody who paired with it.
@@ -1041,7 +1227,7 @@ export function createEngine({
1041
1227
  // still works after this deck restarts. createSyncServer falls through to
1042
1228
  // an OS-chosen one when it is taken, and the caller stores whatever came
1043
1229
  // back — so the pin drifts to a free port rather than failing.
1044
- server = createSyncServer({
1230
+ const startingServer = createSyncServer({
1045
1231
  fp: identity.fp, pub: identity.pub, secret: identity.secret,
1046
1232
  name: cfg.name, handlers: serve, onError, prefer: cfg.port, host, sealFrames, ephemeral,
1047
1233
  // See inboundAt. Every connection passes here, including one that goes
@@ -1079,26 +1265,34 @@ export function createEngine({
1079
1265
  // told no again rather than becoming a row somebody has to answer
1080
1266
  // twice. The socket sends the reason; this only knows the name.
1081
1267
  declined: fp => declined.has(fp),
1268
+ // Read on every handshake rather than captured, so switching the mode
1269
+ // takes effect on the next caller without restarting the listener.
1270
+ inviteOnly: () => cfg.pairingMode === "invite",
1082
1271
  // The same helper the outbound round uses, because a deck that called
1083
1272
  // in and a deck this one called have proved exactly the same thing —
1084
1273
  // see askToAccept.
1085
1274
  onPending: askToAccept,
1086
1275
  });
1276
+ server = startingServer;
1087
1277
  let port;
1088
1278
  try {
1089
- port = await server.start();
1279
+ port = await startingServer.start();
1090
1280
  } catch (err) {
1281
+ if (startedIn !== generation || server !== startingServer || !cfg.enabled) return;
1091
1282
  // Kept, so the panel can say it. Rethrown, because the caller's own
1092
1283
  // catch is what leaves the engine stopped rather than half-started.
1093
1284
  stalled = err?.message ?? String(err);
1094
1285
  throw err;
1095
1286
  }
1287
+ // An immediately cancelled start must not resurrect its listener or
1288
+ // beacon after stop(), or overwrite a newer start's server.
1289
+ if (startedIn !== generation || server !== startingServer || !cfg.enabled || port == null) return;
1096
1290
  stalled = null;
1097
1291
  // From here the socket is accepting, so this is the moment the silence
1098
1292
  // starts being about the network rather than about a deck still starting.
1099
1293
  listeningSince = now();
1100
1294
  if (port !== cfg.port) onPort?.(port);
1101
- beacon = createBeacon({
1295
+ const startingBeacon = createBeacon({
1102
1296
  port, name: cfg.name, fp: identity.fp,
1103
1297
  trusted: () => cfg.trusted,
1104
1298
  // The owner's own machines on the tailnet, while the switch is on.
@@ -1158,7 +1352,9 @@ export function createEngine({
1158
1352
  onError, now,
1159
1353
  ...(createSocket ? { createSocket } : {}),
1160
1354
  });
1161
- await beacon.start();
1355
+ beacon = startingBeacon;
1356
+ await startingBeacon.start();
1357
+ if (startedIn !== generation || beacon !== startingBeacon || !cfg.enabled) return;
1162
1358
  // One read of the tailnet whatever the switch says, so a packet from a
1163
1359
  // tailnet address is told apart from a local one from the first minute.
1164
1360
  void tailnet?.freshen?.(TAILNET_IDLE_MS);
@@ -1167,7 +1363,7 @@ export function createEngine({
1167
1363
  // between rounds is not one number: see ASKING_MS.
1168
1364
  const tick = async () => {
1169
1365
  try { await round(); } catch { /* a round reports itself, per peer */ }
1170
- if (!beacon) return;
1366
+ if (startedIn !== generation || beacon !== startingBeacon || !cfg.enabled) return;
1171
1367
  timer = setTimeout(() => { void tick(); }, waitingOnSomebody() ? ASKING_MS : SYNC_MS);
1172
1368
  timer.unref?.();
1173
1369
  };
@@ -1226,8 +1422,11 @@ export function createEngine({
1226
1422
  if (!inv) return { ok: false, reason: "not_an_invite" };
1227
1423
  if (inv.expired) return { ok: false, reason: "expired" };
1228
1424
  if (!identity || !server) return { ok: false, reason: "not_running" };
1425
+ const startedIn = generation;
1426
+ const stillJoining = () => generation === startedIn && cfg.enabled && !!server;
1229
1427
  const tried = [];
1230
1428
  for (const at of inv.addrs) {
1429
+ if (!stillJoining()) return { ok: false, reason: "not_running", tried };
1231
1430
  let conn = null;
1232
1431
  try {
1233
1432
  conn = await connectToPeer({
@@ -1244,6 +1443,9 @@ export function createEngine({
1244
1443
  inviteProvesBack: inv.provesBack,
1245
1444
  sealFrames, ephemeral,
1246
1445
  });
1446
+ // The handshake can complete after LAN was switched off (or the
1447
+ // identity was restarted). Never persist a pin from that old join.
1448
+ if (!stillJoining()) return { ok: false, reason: "not_running", tried };
1247
1449
  const { list } = addTrusted(cfg.trusted, {
1248
1450
  fp: conn.peerFp, pub: conn.peerPub, name: conn.peerName || inv.name, at: now(),
1249
1451
  });
@@ -1281,7 +1483,7 @@ export function createEngine({
1281
1483
  const asked = pending.get(fp) ?? null;
1282
1484
  const heard = strangers.get(fp) ?? null;
1283
1485
  const seen = asked ?? heard;
1284
- if (!seen) return null;
1486
+ if (!seen || cfg.pairingMode === "invite") return null;
1285
1487
  if (wasUnpaired(fp) && !byHand) return null;
1286
1488
  // TWO KINDS OF ROW, AND THEY ARE NOT THE SAME CLAIM.
1287
1489
  //
@@ -1494,6 +1696,7 @@ export function createEngine({
1494
1696
  // arrives is answered here or answered for you.
1495
1697
  autoAsk: !!cfg.autoAsk,
1496
1698
  autoAccept: !!cfg.autoAccept,
1699
+ pairingMode: cfg.pairingMode === "invite" ? "invite" : "automatic",
1497
1700
  // Whether paired decks are told which shared account this one is on.
1498
1701
  shareActive: cfg.shareActive !== false,
1499
1702
  // Discovery over Tailscale: whether this machine has it at all, which
@@ -1636,7 +1839,10 @@ export function createEngine({
1636
1839
  })() : [],
1637
1840
  };
1638
1841
  },
1639
- stop() {
1842
+ stop(restarting = false) {
1843
+ generation++;
1844
+ if (!restarting) session++;
1845
+ tailRefreshGeneration++;
1640
1846
  if (timer) clearTimeout(timer);
1641
1847
  if (tailTimer) clearInterval(tailTimer);
1642
1848
  tailTimer = null;