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.
- package/README.md +1 -1
- package/bin/agent-dag.js +67 -118
- package/bin/cli/help.js +67 -0
- package/bin/cli/login-item.js +182 -0
- package/bin/cli/one-shot.js +254 -0
- package/bin/cli/package.js +39 -0
- package/bin/cli/pulse.js +98 -0
- package/bin/cli/restart.js +179 -0
- package/bin/cli/screen.js +166 -0
- package/bin/cli/second-start.js +120 -0
- package/bin/cli/startup.js +459 -0
- package/bin/cli/uninstall.js +169 -0
- package/bin/deck.js +112 -1559
- package/dist/web/assets/BrowserWatchModal-DP3YMtEi.js +1 -0
- package/dist/web/assets/UsageHistoryModal-CcdPVXAw.js +1 -0
- package/dist/web/assets/index-3vS6A0vt.css +1 -0
- package/dist/web/assets/index-BVTWHDta.js +298 -0
- package/dist/web/index.html +2 -2
- package/hook/hook.js +15 -14
- package/package.json +1 -1
- package/release-notes.json +22 -0
- package/src/server/account-projects.mjs +25 -11
- package/src/server/account-routes.mjs +254 -0
- package/src/server/activity.mjs +3 -3
- package/src/server/append-budget.mjs +185 -0
- package/src/server/append-failures.mjs +147 -0
- package/src/server/args.mjs +37 -5
- package/src/server/atomic-write.mjs +300 -0
- package/src/server/block-notify.mjs +0 -5
- package/src/server/boot-lock.mjs +1 -1
- package/src/server/brand.mjs +7 -6
- package/src/server/browser-react.mjs +10 -2
- package/src/server/browser-watch-decks.mjs +146 -0
- package/src/server/browser-watch-feed.mjs +69 -0
- package/src/server/browser-watch-log.mjs +173 -0
- package/src/server/browser-watch-mtime.mjs +18 -0
- package/src/server/browser-watch-relay.mjs +112 -0
- package/src/server/browser-watch-routes.mjs +172 -0
- package/src/server/browser-watch-seen.mjs +127 -0
- package/src/server/browser-watch-store.mjs +14 -143
- package/src/server/browser-watch.mjs +278 -626
- package/src/server/canonical-path.mjs +150 -0
- package/src/server/ccusage-failure.mjs +64 -0
- package/src/server/ccusage-install.mjs +459 -0
- package/src/server/ccusage-runner.mjs +271 -0
- package/src/server/ccusage.mjs +21 -756
- package/src/server/claude-accounts.mjs +220 -464
- package/src/server/claude-collector.mjs +189 -0
- package/src/server/claude-dir.mjs +2 -2
- package/src/server/claude-identity.mjs +5 -4
- package/src/server/claude-reset-credits.mjs +130 -0
- package/src/server/claude-verdicts.mjs +174 -0
- package/src/server/clear-route.mjs +191 -0
- package/src/server/codex-auth.mjs +4 -4
- package/src/server/codex-dir.mjs +52 -8
- package/src/server/codex-enrichment.mjs +310 -0
- package/src/server/codex-quota.mjs +5 -3
- package/src/server/codex-translate.mjs +407 -0
- package/src/server/codex-usage.mjs +8 -5
- package/src/server/codex-watch.mjs +496 -0
- package/src/server/cswap-auto-loop.mjs +342 -0
- package/src/server/cswap-auto-readers.mjs +303 -0
- package/src/server/cswap-auto.mjs +51 -609
- package/src/server/cswap-install.mjs +69 -265
- package/src/server/cswap-layout.mjs +242 -0
- package/src/server/deck-home.mjs +1 -1
- package/src/server/deck-prefs.mjs +51 -184
- package/src/server/deck-scope.mjs +52 -0
- package/src/server/desktop-update-routes.mjs +75 -0
- package/src/server/detach.mjs +78 -0
- package/src/server/discovery.mjs +298 -0
- package/src/server/event-log.mjs +328 -0
- package/src/server/event-pipeline.mjs +235 -0
- package/src/server/event-ring.mjs +214 -0
- package/src/server/event-routes.mjs +352 -0
- package/src/server/event-sink.mjs +26 -0
- package/src/server/exec-children.mjs +128 -0
- package/src/server/exec-not-found.mjs +213 -0
- package/src/server/exec-output.mjs +23 -0
- package/src/server/exec-spec.mjs +341 -0
- package/src/server/exec.mjs +74 -661
- package/src/server/health-route.mjs +42 -0
- package/src/server/hook-challenge.mjs +37 -0
- package/src/server/http-io.mjs +104 -0
- package/src/server/index.mjs +172 -7620
- package/src/server/install-layout.mjs +506 -0
- package/src/server/installer.mjs +79 -608
- package/src/server/jsonl-chunks.mjs +139 -0
- package/src/server/lan-accounts.mjs +27 -0
- package/src/server/lan-beacon.mjs +457 -0
- package/src/server/lan-call.mjs +239 -0
- package/src/server/lan-copies.mjs +270 -0
- package/src/server/lan-deck.mjs +583 -0
- package/src/server/lan-dials.mjs +161 -0
- package/src/server/lan-engine.mjs +475 -1024
- package/src/server/lan-hearing.mjs +49 -0
- package/src/server/lan-inbound.mjs +100 -0
- package/src/server/lan-invite-offer.mjs +77 -0
- package/src/server/lan-invite.mjs +207 -0
- package/src/server/lan-lines.mjs +73 -0
- package/src/server/lan-manifest.mjs +74 -0
- package/src/server/lan-reach.mjs +82 -69
- package/src/server/lan-requests.mjs +249 -0
- package/src/server/lan-round-record.mjs +45 -0
- package/src/server/lan-round-timer.mjs +62 -0
- package/src/server/lan-routes.mjs +143 -0
- package/src/server/lan-serve.mjs +112 -0
- package/src/server/lan-socket.mjs +243 -925
- package/src/server/lan-sync.mjs +58 -1026
- package/src/server/lan-tailnet-poll.mjs +57 -0
- package/src/server/lan-turns.mjs +80 -0
- package/src/server/lan-wire.mjs +620 -0
- package/src/server/lifecycle.mjs +421 -0
- package/src/server/listen.mjs +110 -0
- package/src/server/live-decks.mjs +138 -0
- package/src/server/load-average.mjs +32 -0
- package/src/server/log-election.mjs +235 -0
- package/src/server/log-replay.mjs +374 -0
- package/src/server/log-writer.mjs +72 -557
- package/src/server/login-service.mjs +3 -2
- package/src/server/macmon.mjs +1 -1
- package/src/server/memory-files.mjs +163 -0
- package/src/server/memory-metrics.mjs +178 -0
- package/src/server/metrics-history.mjs +94 -0
- package/src/server/metrics-run.mjs +100 -0
- package/src/server/music-routes.mjs +134 -0
- package/src/server/network-metrics.mjs +5 -3
- package/src/server/network-sampler.mjs +256 -0
- package/src/server/npm-latest.mjs +385 -0
- package/src/server/npm-upgrade.mjs +352 -0
- package/src/server/npx.mjs +41 -7
- package/src/server/output-watch.mjs +5 -4
- package/src/server/pinned-build.mjs +108 -0
- package/src/server/prefs-path.mjs +14 -0
- package/src/server/prefs-refusal.mjs +77 -0
- package/src/server/prefs-routes.mjs +76 -0
- package/src/server/prefs-set-aside.mjs +86 -0
- package/src/server/prefs-state.mjs +51 -0
- package/src/server/process-command.mjs +104 -0
- package/src/server/process-list.mjs +547 -0
- package/src/server/pulse-line.mjs +132 -0
- package/src/server/purge-key.mjs +7 -7
- package/src/server/quota-cli.mjs +174 -0
- package/src/server/quota-oauth.mjs +190 -0
- package/src/server/quota-shape.mjs +229 -0
- package/src/server/quota-store-resets.mjs +206 -0
- package/src/server/quota.mjs +123 -509
- package/src/server/relay-guard.mjs +3 -3
- package/src/server/request-gates.mjs +441 -0
- package/src/server/restart-note.mjs +180 -0
- package/src/server/retire-sound-hook.mjs +3 -3
- package/src/server/ring-bounds.mjs +215 -0
- package/src/server/running-deck.mjs +1 -1
- package/src/server/running-version.mjs +24 -0
- package/src/server/self-update.mjs +39 -1371
- package/src/server/session-enrichment.mjs +622 -0
- package/src/server/session-read-gate.mjs +49 -0
- package/src/server/session-tracking.mjs +242 -0
- package/src/server/sse-clients.mjs +222 -0
- package/src/server/stale-discovery.mjs +30 -0
- package/src/server/static-serve.mjs +81 -0
- package/src/server/supervisor.mjs +73 -12
- package/src/server/system-metrics.mjs +169 -1935
- package/src/server/term.mjs +11 -249
- package/src/server/thermal-metrics.mjs +520 -0
- package/src/server/thermal-sampler.mjs +204 -0
- package/src/server/token-redact.mjs +123 -0
- package/src/server/transcript-gate.mjs +107 -0
- package/src/server/transcript-scan.mjs +563 -0
- package/src/server/usage-routes.mjs +93 -0
- package/src/server/uv-bootstrap.mjs +1 -1
- package/src/server/wordmark.mjs +127 -0
- package/dist/web/assets/BrowserWatchModal-CyAFTvAY.js +0 -1
- package/dist/web/assets/UsageHistoryModal-KJfli0oa.js +0 -1
- package/dist/web/assets/index-Ck2RAla3.css +0 -1
- 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
|
+
}
|