ccdeck 3.29.11 → 3.30.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.
Files changed (176) hide show
  1. package/README.md +1 -1
  2. package/bin/agent-dag.js +67 -118
  3. package/bin/cli/help.js +67 -0
  4. package/bin/cli/login-item.js +182 -0
  5. package/bin/cli/one-shot.js +254 -0
  6. package/bin/cli/package.js +39 -0
  7. package/bin/cli/pulse.js +98 -0
  8. package/bin/cli/restart.js +179 -0
  9. package/bin/cli/screen.js +166 -0
  10. package/bin/cli/second-start.js +120 -0
  11. package/bin/cli/startup.js +459 -0
  12. package/bin/cli/uninstall.js +169 -0
  13. package/bin/deck.js +112 -1559
  14. package/dist/web/assets/BrowserWatchModal-DP3YMtEi.js +1 -0
  15. package/dist/web/assets/UsageHistoryModal-CcdPVXAw.js +1 -0
  16. package/dist/web/assets/index-3vS6A0vt.css +1 -0
  17. package/dist/web/assets/index-BVTWHDta.js +298 -0
  18. package/dist/web/index.html +2 -2
  19. package/hook/hook.js +15 -14
  20. package/package.json +1 -1
  21. package/release-notes.json +22 -0
  22. package/src/server/account-projects.mjs +25 -11
  23. package/src/server/account-routes.mjs +254 -0
  24. package/src/server/activity.mjs +3 -3
  25. package/src/server/append-budget.mjs +185 -0
  26. package/src/server/append-failures.mjs +147 -0
  27. package/src/server/args.mjs +37 -5
  28. package/src/server/atomic-write.mjs +300 -0
  29. package/src/server/block-notify.mjs +0 -5
  30. package/src/server/boot-lock.mjs +1 -1
  31. package/src/server/brand.mjs +7 -6
  32. package/src/server/browser-react.mjs +10 -2
  33. package/src/server/browser-watch-decks.mjs +146 -0
  34. package/src/server/browser-watch-feed.mjs +69 -0
  35. package/src/server/browser-watch-log.mjs +173 -0
  36. package/src/server/browser-watch-mtime.mjs +18 -0
  37. package/src/server/browser-watch-relay.mjs +112 -0
  38. package/src/server/browser-watch-routes.mjs +172 -0
  39. package/src/server/browser-watch-seen.mjs +127 -0
  40. package/src/server/browser-watch-store.mjs +14 -143
  41. package/src/server/browser-watch.mjs +278 -626
  42. package/src/server/canonical-path.mjs +150 -0
  43. package/src/server/ccusage-failure.mjs +64 -0
  44. package/src/server/ccusage-install.mjs +459 -0
  45. package/src/server/ccusage-runner.mjs +271 -0
  46. package/src/server/ccusage.mjs +21 -756
  47. package/src/server/claude-accounts.mjs +220 -464
  48. package/src/server/claude-collector.mjs +189 -0
  49. package/src/server/claude-dir.mjs +2 -2
  50. package/src/server/claude-identity.mjs +5 -4
  51. package/src/server/claude-reset-credits.mjs +130 -0
  52. package/src/server/claude-verdicts.mjs +174 -0
  53. package/src/server/clear-route.mjs +191 -0
  54. package/src/server/codex-auth.mjs +4 -4
  55. package/src/server/codex-dir.mjs +52 -8
  56. package/src/server/codex-enrichment.mjs +310 -0
  57. package/src/server/codex-quota.mjs +5 -3
  58. package/src/server/codex-translate.mjs +407 -0
  59. package/src/server/codex-usage.mjs +8 -5
  60. package/src/server/codex-watch.mjs +496 -0
  61. package/src/server/cswap-auto-loop.mjs +342 -0
  62. package/src/server/cswap-auto-readers.mjs +303 -0
  63. package/src/server/cswap-auto.mjs +51 -609
  64. package/src/server/cswap-install.mjs +69 -265
  65. package/src/server/cswap-layout.mjs +242 -0
  66. package/src/server/deck-home.mjs +1 -1
  67. package/src/server/deck-prefs.mjs +51 -184
  68. package/src/server/deck-scope.mjs +52 -0
  69. package/src/server/desktop-update-routes.mjs +75 -0
  70. package/src/server/detach.mjs +78 -0
  71. package/src/server/discovery.mjs +298 -0
  72. package/src/server/event-log.mjs +328 -0
  73. package/src/server/event-pipeline.mjs +235 -0
  74. package/src/server/event-ring.mjs +214 -0
  75. package/src/server/event-routes.mjs +352 -0
  76. package/src/server/event-sink.mjs +26 -0
  77. package/src/server/exec-children.mjs +128 -0
  78. package/src/server/exec-not-found.mjs +213 -0
  79. package/src/server/exec-output.mjs +23 -0
  80. package/src/server/exec-spec.mjs +341 -0
  81. package/src/server/exec.mjs +74 -661
  82. package/src/server/health-route.mjs +42 -0
  83. package/src/server/hook-challenge.mjs +37 -0
  84. package/src/server/http-io.mjs +104 -0
  85. package/src/server/index.mjs +172 -7620
  86. package/src/server/install-layout.mjs +506 -0
  87. package/src/server/installer.mjs +79 -608
  88. package/src/server/jsonl-chunks.mjs +139 -0
  89. package/src/server/lan-accounts.mjs +27 -0
  90. package/src/server/lan-beacon.mjs +457 -0
  91. package/src/server/lan-call.mjs +239 -0
  92. package/src/server/lan-copies.mjs +270 -0
  93. package/src/server/lan-deck.mjs +583 -0
  94. package/src/server/lan-dials.mjs +161 -0
  95. package/src/server/lan-engine.mjs +475 -1024
  96. package/src/server/lan-hearing.mjs +49 -0
  97. package/src/server/lan-inbound.mjs +100 -0
  98. package/src/server/lan-invite-offer.mjs +77 -0
  99. package/src/server/lan-invite.mjs +207 -0
  100. package/src/server/lan-lines.mjs +73 -0
  101. package/src/server/lan-manifest.mjs +74 -0
  102. package/src/server/lan-reach.mjs +82 -69
  103. package/src/server/lan-requests.mjs +249 -0
  104. package/src/server/lan-round-record.mjs +45 -0
  105. package/src/server/lan-round-timer.mjs +62 -0
  106. package/src/server/lan-routes.mjs +143 -0
  107. package/src/server/lan-serve.mjs +112 -0
  108. package/src/server/lan-socket.mjs +243 -925
  109. package/src/server/lan-sync.mjs +58 -1026
  110. package/src/server/lan-tailnet-poll.mjs +57 -0
  111. package/src/server/lan-turns.mjs +80 -0
  112. package/src/server/lan-wire.mjs +620 -0
  113. package/src/server/lifecycle.mjs +421 -0
  114. package/src/server/listen.mjs +110 -0
  115. package/src/server/live-decks.mjs +138 -0
  116. package/src/server/load-average.mjs +32 -0
  117. package/src/server/log-election.mjs +235 -0
  118. package/src/server/log-replay.mjs +374 -0
  119. package/src/server/log-writer.mjs +72 -557
  120. package/src/server/login-service.mjs +3 -2
  121. package/src/server/macmon.mjs +1 -1
  122. package/src/server/memory-files.mjs +163 -0
  123. package/src/server/memory-metrics.mjs +178 -0
  124. package/src/server/metrics-history.mjs +94 -0
  125. package/src/server/metrics-run.mjs +100 -0
  126. package/src/server/music-routes.mjs +134 -0
  127. package/src/server/network-metrics.mjs +5 -3
  128. package/src/server/network-sampler.mjs +256 -0
  129. package/src/server/npm-latest.mjs +385 -0
  130. package/src/server/npm-upgrade.mjs +352 -0
  131. package/src/server/npx.mjs +41 -7
  132. package/src/server/output-watch.mjs +5 -4
  133. package/src/server/pinned-build.mjs +108 -0
  134. package/src/server/prefs-path.mjs +14 -0
  135. package/src/server/prefs-refusal.mjs +77 -0
  136. package/src/server/prefs-routes.mjs +76 -0
  137. package/src/server/prefs-set-aside.mjs +86 -0
  138. package/src/server/prefs-state.mjs +51 -0
  139. package/src/server/process-command.mjs +104 -0
  140. package/src/server/process-list.mjs +547 -0
  141. package/src/server/pulse-line.mjs +132 -0
  142. package/src/server/purge-key.mjs +7 -7
  143. package/src/server/quota-cli.mjs +174 -0
  144. package/src/server/quota-oauth.mjs +190 -0
  145. package/src/server/quota-shape.mjs +229 -0
  146. package/src/server/quota-store-resets.mjs +206 -0
  147. package/src/server/quota.mjs +123 -509
  148. package/src/server/relay-guard.mjs +3 -3
  149. package/src/server/request-gates.mjs +441 -0
  150. package/src/server/restart-note.mjs +180 -0
  151. package/src/server/retire-sound-hook.mjs +3 -3
  152. package/src/server/ring-bounds.mjs +215 -0
  153. package/src/server/running-deck.mjs +1 -1
  154. package/src/server/running-version.mjs +24 -0
  155. package/src/server/self-update.mjs +39 -1371
  156. package/src/server/session-enrichment.mjs +622 -0
  157. package/src/server/session-read-gate.mjs +49 -0
  158. package/src/server/session-tracking.mjs +242 -0
  159. package/src/server/sse-clients.mjs +222 -0
  160. package/src/server/stale-discovery.mjs +30 -0
  161. package/src/server/static-serve.mjs +81 -0
  162. package/src/server/supervisor.mjs +73 -12
  163. package/src/server/system-metrics.mjs +169 -1935
  164. package/src/server/term.mjs +11 -249
  165. package/src/server/thermal-metrics.mjs +520 -0
  166. package/src/server/thermal-sampler.mjs +204 -0
  167. package/src/server/token-redact.mjs +123 -0
  168. package/src/server/transcript-gate.mjs +107 -0
  169. package/src/server/transcript-scan.mjs +563 -0
  170. package/src/server/usage-routes.mjs +93 -0
  171. package/src/server/uv-bootstrap.mjs +1 -1
  172. package/src/server/wordmark.mjs +127 -0
  173. package/dist/web/assets/BrowserWatchModal-CyAFTvAY.js +0 -1
  174. package/dist/web/assets/UsageHistoryModal-KJfli0oa.js +0 -1
  175. package/dist/web/assets/index-Ck2RAla3.css +0 -1
  176. package/dist/web/assets/index-jmrcEsCj.js +0 -298
@@ -0,0 +1,49 @@
1
+ // Whether this deck can hear other decks announce themselves, and what the
2
+ // panel says while it cannot: lifted out of createEngine in lan-engine.mjs
3
+ // with the one variable only it used — who is holding the discovery port.
4
+ // The beacon knows WHETHER it hears (see createBeacon's `hearing`); this keeps
5
+ // the sentence, and asks port-holder.mjs once per spell whose fault it is.
6
+ import { DISCOVERY_PORT } from "./lan-beacon.mjs";
7
+
8
+ /**
9
+ * `beaconNow` answers the engine's beacon as it is at the moment of asking,
10
+ * null while it is down. `portHolder` names the program on the port — see
11
+ * port-holder.mjs — and without one nothing is asked. `onChange` is the
12
+ * engine's own, so the panel redraws when the answer arrives.
13
+ */
14
+ export function createHearing({ beaconNow, portHolder, onChange }) {
15
+ /** Who held the discovery port the last time it was asked, for as long as
16
+ * this deck cannot hear: the answer does not change between two tries half a
17
+ * minute apart, and on Windows asking costs a PowerShell start. Undefined
18
+ * until asked, null when the machine would not say. */
19
+ let holder;
20
+
21
+ /** What the panel says while this deck cannot hear — see createBeacon's
22
+ * `hearing`. Null whenever it can. */
23
+ const deafLine = () => {
24
+ const beacon = beaconNow();
25
+ if (!beacon || beacon.hearing()) return null;
26
+ const err = beacon.deafError?.();
27
+ if (err && err.code !== "EADDRINUSE") {
28
+ return `This deck cannot listen on UDP ${DISCOVERY_PORT} (${err.code ?? err.message}), so it hears no other deck announce itself. Other decks still find it and pair with it.`;
29
+ }
30
+ return `${holder ?? "Another program"} is holding UDP ${DISCOVERY_PORT}, so this deck hears no new decks. Others still find it and pair with it, and it takes the port back as soon as it is free.`;
31
+ };
32
+
33
+ /** The beacon took the discovery port, or lost it: say so, and while it
34
+ * cannot hear, ask once which program is holding the port. See deafLine. */
35
+ const hearingChanged = now => {
36
+ if (now) { holder = undefined; onChange?.(); return; }
37
+ if (holder === undefined && portHolder && beaconNow()?.deafError?.()?.code === "EADDRINUSE") {
38
+ holder = null;
39
+ void Promise.resolve().then(() => portHolder()).then(who => { holder = who ?? null; onChange?.(); }, () => {});
40
+ }
41
+ onChange?.();
42
+ };
43
+
44
+ /** Forget who held the port, so the next spell of deafness asks again —
45
+ * what a stop does, because the next start may find somebody else there. */
46
+ const forget = () => { holder = undefined; };
47
+
48
+ return { deafLine, hearingChanged, forget };
49
+ }
@@ -0,0 +1,100 @@
1
+ // Who has called this deck, and for how long anybody could have: lifted out of
2
+ // createEngine in lan-engine.mjs with the four records only these write — when
3
+ // each paired deck last spoke and from where, when another machine last got a
4
+ // connection through, and when the listener came up. Nothing here listens:
5
+ // serve (lan-serve.mjs) and the listener's onInbound report each call, and
6
+ // status() and the peer list read what they left.
7
+
8
+ /**
9
+ * Did this connection come from a DIFFERENT computer?
10
+ *
11
+ * The question behind "can other decks reach this one": a socket accepted from
12
+ * loopback, or from one of this machine's own addresses, is the second deck on
13
+ * this computer talking to the first — which happens on every developer machine
14
+ * and proves nothing at all about the network. Both spellings of loopback are
15
+ * named because both arrive: `127.0.0.1` from a deck that dialled an address
16
+ * and `::1` from one that dialled a name.
17
+ *
18
+ * Pure and separate from the engine so the case that matters — a connection
19
+ * from somewhere else — can be tested without a second machine.
20
+ */
21
+ export const anotherMachine = (from, mine = []) => {
22
+ const at = String(from ?? "").replace(/^::ffff:/, "").trim();
23
+ return !!at && at !== "127.0.0.1" && at !== "::1" && !mine.includes(at);
24
+ };
25
+
26
+ /**
27
+ * `localAddresses` answers this machine's own addresses at the moment of
28
+ * asking — see localAddresses in lan-engine.mjs — and `now` is the engine's
29
+ * clock.
30
+ */
31
+ export function createInbound({ now, localAddresses }) {
32
+ /**
33
+ * When each paired deck last SPOKE TO THIS ONE, keyed by fingerprint.
34
+ *
35
+ * The panel had no evidence at all about a deck it does not dial. A deck that
36
+ * calls in has no beacon row here (if it had one it would be dialled), never
37
+ * appears in `lastRound`, and its `lastSeen` was therefore undefined forever
38
+ * — so the row was drawn as live on the strength of being paired, and a
39
+ * Windows deck that had been closed for an hour still read `ready`. Reported
40
+ * from a screenshot of exactly that.
41
+ *
42
+ * Every authenticated frame lands in `serve`, which is the one place that
43
+ * knows a paired deck is on the other end of an open socket right now. That
44
+ * is the evidence, and it is the same kind the beacon gives: a timestamp.
45
+ */
46
+ const spokeAt = new Map();
47
+ /** And the address it spoke FROM, so a deck that only ever calls in can
48
+ * still be said to come over the tailnet or the local network — it has no
49
+ * address of its own here, and without this its row could not say which. */
50
+ const spokeFrom = new Map();
51
+ /**
52
+ * When a connection from ANOTHER MACHINE last arrived on the sync listener.
53
+ *
54
+ * The one fact that settles "can other decks reach this one", and the only
55
+ * one on the whole question that is measured rather than reasoned about: a
56
+ * firewall's configuration is read through three different tools on three
57
+ * platforms, one of which (`ufw`) refuses to show its rules to a process
58
+ * that is not root. An accepted socket needs none of that — the packets got
59
+ * in, whatever any rule file says.
60
+ *
61
+ * ANOTHER MACHINE, checked here and not in the socket: a connection from
62
+ * loopback or from one of this machine's own addresses is the second deck on
63
+ * this computer, which proves nothing about the network. The socket does not
64
+ * hold that list; this does.
65
+ */
66
+ let inboundAt = null;
67
+
68
+ /** When this deck's listener came up, or null while it is down.
69
+ *
70
+ * THE OTHER HALF OF `inboundAt`. On its own, "nothing has ever connected in"
71
+ * says nothing: a deck that started four seconds ago has the same null as one
72
+ * that has been listening all afternoon while the network talked around it.
73
+ * What makes the silence evidence is how long it has gone on for, and that is
74
+ * a number only the engine holds. See silentInbound in lan-reach.mjs, which
75
+ * is the one verdict in this feature that works on a platform nothing can be
76
+ * asked about. */
77
+ let listeningSince = null;
78
+
79
+ return {
80
+ /** A paired deck is talking to this one, now, over the socket `ctx` is
81
+ * serve's for — see spokeAt. */
82
+ spoke(ctx) {
83
+ spokeAt.set(ctx.peerFp, now());
84
+ const from = ctx.peerAddr || String(ctx.sock?.remoteAddress ?? "").replace(/^::ffff:/, "");
85
+ if (from) spokeFrom.set(ctx.peerFp, from);
86
+ },
87
+ /** When that deck last spoke, and from which address: undefined until it
88
+ * has. */
89
+ spokeAt: fp => spokeAt.get(fp),
90
+ spokeFrom: fp => spokeFrom.get(fp),
91
+ /** A connection arrived on the listener from `from`, handshake or not —
92
+ * see inboundAt. */
93
+ arrived(from) { if (anotherMachine(from, localAddresses())) inboundAt = now(); },
94
+ inboundAt: () => inboundAt,
95
+ /** The listener came up, or went down — see listeningSince. */
96
+ listening() { listeningSince = now(); },
97
+ closed() { listeningSince = null; },
98
+ listeningSince: () => listeningSince,
99
+ };
100
+ }
@@ -0,0 +1,77 @@
1
+ // The invite this deck is offering right now, and what has happened to it:
2
+ // lifted out of createEngine in lan-engine.mjs, where it was one variable that
3
+ // seven places wrote and read. lan-invite.mjs says what an invite IS — minting
4
+ // one, reading one, the proofs that ride the handshake — and stays pure; this
5
+ // is the one this deck is holding out, from the press that makes it to the
6
+ // proof that spends it or the wrong ones that put it away.
7
+ import { MAX_WRONG_PROOFS } from "./lan-invite.mjs";
8
+
9
+ /**
10
+ * `now` is the engine's clock; `onChange` and `onError` are the engine's own,
11
+ * so the panel redraws and the log reads exactly as it did when this lived
12
+ * inside it.
13
+ */
14
+ export function createInviteOffer({ now, onChange, onError }) {
15
+ /** The invite this deck is offering, or null. One at a time: a deck showing
16
+ * two tokens is a deck whose owner cannot say which one they sent. */
17
+ let invite = null;
18
+
19
+ return {
20
+ /** Offer a freshly minted invite in place of whatever was on offer — see
21
+ * the engine's invite(), which mints it — with no wrong proofs yet. */
22
+ put(made) {
23
+ invite = { ...made, refused: 0 };
24
+ onChange?.();
25
+ },
26
+
27
+ /** The invite a proof is checked against, or null once it has run out —
28
+ * what the listener asks on every handshake. */
29
+ live: () => (invite && invite.expiresAt > now() ? invite : null),
30
+
31
+ /** What this deck is offering right now, for the panel to draw. Null once
32
+ * it has run out, so a token nobody can use is not shown as if they could. */
33
+ offering() {
34
+ if (!invite || invite.expiresAt <= now()) return null;
35
+ return { token: invite.token, expiresAt: invite.expiresAt };
36
+ },
37
+
38
+ /** Put it away without using it. */
39
+ withdraw() {
40
+ const had = !!invite;
41
+ invite = null;
42
+ if (had) onChange?.();
43
+ return had;
44
+ },
45
+
46
+ /** Somebody used it: it is spent. Nothing is redrawn here, because the
47
+ * pairing that spent it redraws once it is done. */
48
+ retire() {
49
+ invite = null;
50
+ },
51
+
52
+ /** Somebody presented a proof of the token that did not hold. Counted on
53
+ * the invite, written to the log with where it came from, and at
54
+ * MAX_WRONG_PROOFS the invite is put away (#1137) — the owner makes a new
55
+ * one, which is one press, and the old one stops being something anybody
56
+ * can keep working at. */
57
+ wrongInvite(from) {
58
+ if (!invite) return;
59
+ const refused = (invite.refused ?? 0) + 1;
60
+ invite = { ...invite, refused };
61
+ const where = from?.addr || "an unknown address";
62
+ onError?.("invite", new Error(`a proof of this deck's invite from ${where} did not hold (${refused} of ${MAX_WRONG_PROOFS})`));
63
+ if (refused >= MAX_WRONG_PROOFS) {
64
+ invite = null;
65
+ onError?.("invite", new Error(`put the invite away after ${MAX_WRONG_PROOFS} proofs that did not hold; make a new one`));
66
+ }
67
+ onChange?.();
68
+ },
69
+
70
+ /** The token on offer for status(), with how many proofs of it have failed
71
+ * so far — the record beside the log's. Null when there is none, or it has
72
+ * run out. */
73
+ row: () => (invite && invite.expiresAt > now()
74
+ ? { token: invite.token, expiresAt: invite.expiresAt, refused: invite.refused ?? 0 }
75
+ : null),
76
+ };
77
+ }
@@ -0,0 +1,207 @@
1
+ // The invite: one piece of text that lets a deck pair with this one without
2
+ // anybody pressing accept, and the two proofs that ride the handshake for it.
3
+ // Moved out of lan-sync.mjs, whose protocol version and name rule it uses;
4
+ // like everything there, it is pure — no sockets, no timers, no filesystem.
5
+ //
6
+ // ONE PIECE OF TEXT THAT CARRIES EVERYTHING, and it exists because of a real
7
+ // afternoon. Two people, two machines, a typed address, and `handshake timed
8
+ // out` — because the address on screen was the LAN one and the only route
9
+ // between them was a VPN. Neither of them could have known which of the two to
10
+ // use; the deck could not know either, and printing both made it their problem.
11
+ //
12
+ // So the invite carries EVERY address this deck has, and the deck that receives
13
+ // it tries them in turn. Nobody has to know which one is routable, because the
14
+ // only machine that can find out is the one doing the reaching.
15
+ //
16
+ // AND IT CARRIES A CODE, which is what turns two presses into one. A deck that
17
+ // proves it holds the invite is not a stranger asking to be let in — it is
18
+ // somebody the owner of this machine handed a token to. So it is paired on
19
+ // arrival and nobody presses accept. That also closes the gap trust-on-first-use
20
+ // left open: the first contact is verified rather than believed — for as long
21
+ // as the code cannot be guessed, which is why it is 128 random bits and not
22
+ // something a person would type (#1137; see inviteCode).
23
+ //
24
+ // THE TOKEN IS THE SECRET, said plainly rather than implied. Whoever holds it
25
+ // can pair with this deck until it expires. It is a door key with a timer, not
26
+ // an identifier — which is why it is short-lived, single-purpose, and replaced
27
+ // the moment it is used.
28
+ import { createHmac, randomBytes } from "node:crypto";
29
+ import { cleanName, PROTOCOL } from "./lan-sync.mjs";
30
+
31
+ /** How long an invite is good for. Long enough to paste into a chat and have
32
+ * somebody read it, short enough that one left in a channel is not a way in
33
+ * tomorrow. */
34
+ export const INVITE_MS = 10 * 60 * 1000;
35
+
36
+ /** The most addresses an invite may carry. A machine with a VPN, a second card
37
+ * and a container bridge has three or four; ten is far past honest and keeps a
38
+ * hand-built token from being a way to make this deck dial a list. */
39
+ export const MAX_INVITE_ADDRS = 10;
40
+
41
+ /** How many proofs of one invite may fail before it is put away (#1137).
42
+ *
43
+ * Not a guard on the code — 128 bits is not something a few more tries get
44
+ * any nearer — but a bound on how long a live invite can be worked at, and a
45
+ * signal: a token presented wrong this often is circulating in some form
46
+ * nobody here handed out, and the owner is better off making another. Five
47
+ * rather than one, because one honest paste can reach the deck at several of
48
+ * its addresses and an old token pasted by mistake should not cost the new
49
+ * one. */
50
+ export const MAX_WRONG_PROOFS = 5;
51
+
52
+ /** What the token starts with, so a reader can tell at a glance what they have
53
+ * been sent and a wrong paste is refused before it is parsed. */
54
+ export const INVITE_PREFIX = "ccdeck1.";
55
+
56
+ const b64url = buf => buf.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
57
+ const unb64url = str => Buffer.from(str.replace(/-/g, "+").replace(/_/g, "/"), "base64");
58
+
59
+ /** How many random bytes an invite's code is: 128 bits, which is past anything
60
+ * that can be counted through. See inviteCode. */
61
+ export const INVITE_CODE_BYTES = 16;
62
+
63
+ /** The code in a token this version mints: INVITE_CODE_BYTES in base64url,
64
+ * which is 22 characters and no padding. */
65
+ const CODE = /^[A-Za-z0-9_-]{22}$/;
66
+
67
+ /** The code in a token an OLDER deck minted: six digits. Read, so a deck of
68
+ * this version can still join one — never minted, so this deck never holds
69
+ * one. See readInvite. */
70
+ const OLDER_CODE = /^[0-9]{6}$/;
71
+
72
+ /**
73
+ * The invite's secret: 128 bits from the system's random source, spelled in
74
+ * base64url so it rides in the token as 22 characters.
75
+ *
76
+ * IT WAS SIX DIGITS, "a code somebody could read out loud if they had to", and
77
+ * nobody ever had to: the panel shows the whole token to copy, and `join` reads
78
+ * the code out of the pasted token and nowhere else. Six digits is about twenty
79
+ * bits, which a laptop counts through in a second, and both handshake proofs
80
+ * are made over the code and a transcript that crosses the wire — so the
81
+ * proofs kept the code only as well as it could not be guessed (#1137). A
82
+ * secret nobody types can be as long as it needs to be.
83
+ *
84
+ * The bytes go into the code as they came, with nothing reduced or sampled
85
+ * away, so every bit of it is the random source's.
86
+ */
87
+ export function inviteCode(rand = randomBytes) {
88
+ return b64url(rand(INVITE_CODE_BYTES));
89
+ }
90
+
91
+ /** Mint one. `addrs` are already `host:port` strings, because which addresses
92
+ * this machine has is not a question this file can answer.
93
+ *
94
+ * Never with an older deck's six digits, even when handed one: a code that
95
+ * can be counted through, held by this deck, is exactly what #1137 retired.
96
+ * A code that is not this version's kind is refused rather than used. */
97
+ export function mintInvite({ addrs, name, now = Date.now(), code = inviteCode() } = {}) {
98
+ if (typeof code !== "string" || !CODE.test(code)) return null;
99
+ const list = (Array.isArray(addrs) ? addrs : []).filter(a => typeof a === "string" && a).slice(0, MAX_INVITE_ADDRS);
100
+ if (!list.length) return null;
101
+ const expiresAt = now + INVITE_MS;
102
+ // `pb` IS THE MINTER SAYING IT WILL PROVE THE CODE BACK, and it is here
103
+ // rather than in PROTOCOL because PROTOCOL is the whole wire. A deck of this
104
+ // version answers the handshake with `inviteProofBack`; every deck before it
105
+ // speaks the same PROTOCOL and does not. The joiner has no other way to tell
106
+ // the two apart, and a joiner that demanded the proof from both would break
107
+ // invites exactly across a version boundary — which is when people use them.
108
+ //
109
+ // It is not a security decision the far end gets to make. The token is text
110
+ // the minter handed to the joiner out of band, in the same breath as the
111
+ // code; whoever can rewrite it already holds the code and has no use for
112
+ // this. What the flag can do is say "the deck that minted me is old", and the
113
+ // worst that buys is the behaviour shipped today.
114
+ //
115
+ // SINCE #1137 THE CODE SAYS IT TOO. A token with this version's code was
116
+ // minted by a deck that proves back, so readInvite asks for the proof from
117
+ // one whether or not `pb` survived the trip. The flag stays for what it
118
+ // always meant; a token without it can only ever lower the bar for a token
119
+ // an older deck minted.
120
+ const body = { v: PROTOCOL, a: list, n: cleanName(name), c: code, x: expiresAt, pb: 1 };
121
+ return { token: INVITE_PREFIX + b64url(Buffer.from(JSON.stringify(body), "utf8")), code, expiresAt };
122
+ }
123
+
124
+ /**
125
+ * Read one somebody pasted, or null.
126
+ *
127
+ * REFUSES BEFORE IT UNDERSTANDS, in the order that costs least: the prefix,
128
+ * then the length, then the decode, then the shape, then the clock. A token is
129
+ * text a person pasted out of a chat window, so it is as untrusted as anything
130
+ * that arrives on a socket — and every failure here is the same answer to the
131
+ * reader, which is "that is not an invite".
132
+ */
133
+ export function readInvite(raw, now = Date.now()) {
134
+ if (typeof raw !== "string") return null;
135
+ const text = raw.trim();
136
+ if (!text.startsWith(INVITE_PREFIX)) return null;
137
+ if (text.length > 2048) return null;
138
+ let body = null;
139
+ try { body = JSON.parse(unb64url(text.slice(INVITE_PREFIX.length)).toString("utf8")); }
140
+ catch { return null; }
141
+ if (!body || typeof body !== "object" || Array.isArray(body)) return null;
142
+ if (body.v !== PROTOCOL) return null;
143
+ // THIS VERSION'S CODE, OR AN OLDER DECK'S SIX DIGITS. Every deck before
144
+ // #1137 mints six digits and refuses any other code, so a token from one of
145
+ // those still joins here, while a token from this version is refused THERE
146
+ // as not an invite, before that deck dials anything. This deck never mints
147
+ // six digits — see mintInvite — so the older kind only ever names a deck
148
+ // that is older.
149
+ const ours = typeof body.c === "string" && CODE.test(body.c);
150
+ if (!ours && !(typeof body.c === "string" && OLDER_CODE.test(body.c))) return null;
151
+ if (typeof body.x !== "number" || !Number.isFinite(body.x)) return null;
152
+ // Expired is its own answer and the caller says so — "that invite has run
153
+ // out, ask for a new one" is a different instruction from "that is not an
154
+ // invite", and a reader who cannot tell them apart retypes the same thing.
155
+ const expired = body.x <= now;
156
+ const addrs = (Array.isArray(body.a) ? body.a : [])
157
+ .filter(a => typeof a === "string")
158
+ .slice(0, MAX_INVITE_ADDRS)
159
+ .map(a => ({ raw: a, at: a.lastIndexOf(":") }))
160
+ .filter(p => p.at > 0)
161
+ .map(p => ({ addr: p.raw.slice(0, p.at), port: Number(p.raw.slice(p.at + 1)) }))
162
+ .filter(p => p.addr && Number.isInteger(p.port) && p.port > 0 && p.port < 65_536);
163
+ if (!addrs.length) return null;
164
+ return {
165
+ addrs, name: cleanName(body.n), code: body.c, expiresAt: body.x, expired,
166
+ // Whether the deck that minted this will prove it holds the code — see
167
+ // mintInvite. Absent means a deck older than that, and the caller degrades
168
+ // to what it did before rather than refusing the token. Always so for this
169
+ // version's code, which only a deck that proves back mints.
170
+ provesBack: ours || body.pb === 1,
171
+ };
172
+ }
173
+
174
+ /**
175
+ * The proof that a caller is holding the invite, over the same transcript the
176
+ * session key is bound to.
177
+ *
178
+ * Not the code itself on the wire. A code that travelled in the clear would be
179
+ * replayable by anybody who watched one exchange, and this is the value that
180
+ * decides whether a deck is paired without anybody pressing anything.
181
+ */
182
+ export function inviteProof(code, transcript) {
183
+ return createHmac("sha256", `ccdeck-invite-v${PROTOCOL}`).update(`${code}|${transcript}`).digest("hex");
184
+ }
185
+
186
+ /**
187
+ * The same proof, the other way round: the deck that MINTED the invite showing
188
+ * the caller it holds the code too.
189
+ *
190
+ * WHY THE CALLER'S PROOF WAS NOT ENOUGH, and it is the whole of #971. The code
191
+ * travelled in one message, caller to listener, and the reply carried a session
192
+ * proof — an HMAC over an ECDH against whatever public key the responder had
193
+ * just presented. That proves the responder holds the private half of the key
194
+ * it just chose. Anything with a socket holds the private half of a key it just
195
+ * chose. `join` passes no pin, by definition, so the impostor check in
196
+ * connectToPeer is inert on this path and whatever answered first was written
197
+ * into the trusted list.
198
+ *
199
+ * A SEPARATE KEY STRING RATHER THAN A DIRECTION FIELD, because the two
200
+ * transcripts are byte-identical — handshakeTranscript takes the caller and the
201
+ * listener in a fixed order, so both sides compute the same string. Reusing
202
+ * `inviteProof` here would let the listener's reply be the caller's own `auth`
203
+ * frame echoed back, which is precisely the party this is meant to exclude.
204
+ */
205
+ export function inviteProofBack(code, transcript) {
206
+ return createHmac("sha256", `ccdeck-invite-back-v${PROTOCOL}`).update(`${code}|${transcript}`).digest("hex");
207
+ }
@@ -0,0 +1,73 @@
1
+ // The line both ends of a connection speak, whichever dialled: newline-
2
+ // delimited JSON, read without being made to allocate and written with the
3
+ // newline the reader waits for — and the two bounds on it, the most one line
4
+ // may be and how long the handshake over it may take. Lifted out of
5
+ // lan-socket.mjs, which re-exports all of it: it is the one part of that file
6
+ // the answering half and the calling half both use.
7
+ import { MAX_MANIFEST_BYTES } from "./lan-sync.mjs";
8
+
9
+ /** How long a connection has to finish the handshake before it is dropped. A
10
+ * handshake is two round trips on a local network — single-digit
11
+ * milliseconds — so five seconds is generous for a slow machine and short
12
+ * enough that holding sockets open costs an attacker something. */
13
+ export const HANDSHAKE_MS = 5_000;
14
+
15
+ /** The most one frame may be, and the most a peer may hold open.
16
+ *
17
+ * Frames are JSON lines. The largest legitimate one is a manifest; the cap is
18
+ * an order of magnitude over the biggest real store, and the buffer is
19
+ * ABANDONED rather than grown past it — a socket that keeps sending without a
20
+ * newline is trying to make this allocate, and the answer is to stop reading
21
+ * rather than to read faster. */
22
+ export const MAX_FRAME_BYTES = MAX_MANIFEST_BYTES;
23
+
24
+ /**
25
+ * Read newline-delimited JSON off a socket, refusing to be made to allocate.
26
+ *
27
+ * The cap is on the UNTERMINATED buffer rather than on a frame that arrived,
28
+ * which is the distinction that matters: a peer that sends a megabyte with no
29
+ * newline in it is not sending a large frame, it is sending nothing at all,
30
+ * expensively. Past the cap this stops reading and hands the caller a refusal
31
+ * — it does not keep buffering in the hope a newline turns up.
32
+ */
33
+ export function frameReader(onFrame, onRefuse, max = MAX_FRAME_BYTES) {
34
+ let buf = "";
35
+ let dead = false;
36
+ return chunk => {
37
+ if (dead) return;
38
+ buf += chunk;
39
+ if (buf.length > max) { dead = true; buf = ""; onRefuse("frame too large"); return; }
40
+ let i;
41
+ while ((i = buf.indexOf("\n")) !== -1) {
42
+ const line = buf.slice(0, i);
43
+ buf = buf.slice(i + 1);
44
+ if (!line.trim()) continue;
45
+ let msg;
46
+ try { msg = JSON.parse(line); }
47
+ catch { dead = true; buf = ""; onRefuse("not json"); return; }
48
+ // `typeof [] === "object"`, so an array walks straight past the obvious
49
+ // check and reaches a handler that reads `msg.t` off it — undefined, and
50
+ // then whatever that handler does with a frame that has no type. A frame
51
+ // is a record; anything else is refused.
52
+ if (!msg || typeof msg !== "object" || Array.isArray(msg)) {
53
+ dead = true; buf = ""; onRefuse("not an object"); return;
54
+ }
55
+ // A HANDLER THAT THROWS ENDS ITS CONNECTION, NOT THE PROCESS. This runs
56
+ // inside the socket's `data` event, where nothing above catches, so a
57
+ // throw from a frame handler took the whole deck down — and every frame
58
+ // read here has come off the network before anybody is trusted. The
59
+ // handlers are meant never to throw; this is what holds when one does.
60
+ // The error goes on with the refusal, so the next one that hides here is
61
+ // logged with its stack rather than as two words.
62
+ try { onFrame(msg); }
63
+ catch (err) { dead = true; buf = ""; onRefuse("bad frame", err); return; }
64
+ if (dead) return;
65
+ }
66
+ };
67
+ }
68
+
69
+ /** One line out. Kept in one place so nothing forgets the newline the reader
70
+ * above is waiting for. */
71
+ export function sendFrame(sock, obj) {
72
+ try { sock.write(`${JSON.stringify(obj)}\n`); } catch { /* peer went away */ }
73
+ }
@@ -0,0 +1,74 @@
1
+ // This deck's side of the manifest exchange: the frame it sends — the accounts
2
+ // it shares, which of them it is on, and its card — and what it keeps of the
3
+ // frame it hears back. Lifted out of createEngine in lan-engine.mjs with the
4
+ // two maps only these fill, so saying and hearing sit in one file as they sat
5
+ // side by side in the engine. The rules for the lists themselves are
6
+ // lan-copies.mjs's, and the card's are lan-about.mjs's.
7
+ import { currentFor, heardCurrent, manifestFor, offered } from "./lan-sync.mjs";
8
+ import { openAbout, sealAbout } from "./lan-about.mjs";
9
+
10
+ /**
11
+ * `about` is this deck's own card, or null for a deck built without one. `now`
12
+ * is the engine's clock. `myFp` answers this deck's fingerprint as it is at
13
+ * the moment of asking — a restart for a new key changes it — and `settings`
14
+ * the settings in force, of which a manifest reads `shared` and `shareActive`.
15
+ */
16
+ export function createManifests({ about, now, myFp, settings }) {
17
+ /** What each paired deck said about itself — version, operating system,
18
+ * architecture — keyed by fingerprint, most recent only. Filled from both
19
+ * directions: the manifest a deck answers with, and the question a deck that
20
+ * calls in asks. See lan-about.mjs. */
21
+ const aboutBy = new Map();
22
+ /** The accounts each paired deck offered in its last manifest, keyed by
23
+ * fingerprint. Kept APART from the engine's lastRound on purpose: a round
24
+ * that fails replaces that line, and the list a deck offered a minute ago is
25
+ * still the best answer to "what does it share" while it is unreachable. */
26
+ const offersBy = new Map();
27
+
28
+ /** This deck's card for one connection, as a frame field — or nothing, for a
29
+ * deck built without one. Spread into the frame, so a deck from before this
30
+ * existed receives exactly the frame it always did plus one key it never
31
+ * reads. */
32
+ const cardFor = (key, toFp) => {
33
+ const sealed = sealAbout(key, about, myFp(), toFp);
34
+ return sealed ? { about: sealed } : {};
35
+ };
36
+
37
+ /** This deck's manifest for the deck on the other end of `key`: the accounts
38
+ * it shares, which of them it is on, and its card. One frame whichever way
39
+ * it travels — the question a round asks, and the answer `serve` gives. */
40
+ const manifestFrame = (accounts, key, toFp) => {
41
+ const cfg = settings();
42
+ return {
43
+ t: "manifest", accounts: manifestFor(accounts, cfg.shared),
44
+ ...currentFor(accounts, cfg.shared, cfg.shareActive),
45
+ ...cardFor(key, toFp),
46
+ };
47
+ };
48
+
49
+ /**
50
+ * What a paired deck's manifest said about it, kept for the panel — the
51
+ * question a deck that calls in asks as much as the answer a dialled one
52
+ * gives. Its card, when the seal opens; and the accounts it offers with the
53
+ * one it is on, kept only when a list came — an older caller asks with its
54
+ * card alone, and a missing list is not an empty one. Returns that list, or
55
+ * null for a frame that carried none.
56
+ */
57
+ const keepManifest = (key, frame, fromFp) => {
58
+ const card = openAbout(key, frame.about, fromFp, myFp());
59
+ if (card) aboutBy.set(fromFp, { ...card, at: now() });
60
+ if (!Array.isArray(frame.accounts)) return null;
61
+ const list = offered(frame.accounts);
62
+ offersBy.set(fromFp, { at: now(), accounts: list, current: heardCurrent(frame.current, list) });
63
+ return list;
64
+ };
65
+
66
+ /** What the deck proven as `fp` last said, for its row: its card and the
67
+ * logins it offered, each null until it has said one. */
68
+ const heardOf = fp => ({
69
+ about: aboutBy.get(fp) ?? null,
70
+ offers: offersBy.get(fp) ?? null,
71
+ });
72
+
73
+ return { manifestFrame, keepManifest, heardOf };
74
+ }