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.
@@ -0,0 +1,38 @@
1
+ /** A collector's verdict is about the stored copy, not the live CLI session.
2
+ * In particular, an inaccessible Keychain does not make the stored copy dead:
3
+ * treating it as dead starts a futile peer-healing loop on every LAN round. */
4
+ const LOST_COPY = new Set(["no_credentials", "relogin_required", "foreign_credential"]);
5
+
6
+ export function storedCopyAlive(alive, verdict) {
7
+ // An unreadable keychain or a deferred token refresh is a failure to CHECK,
8
+ // not evidence that the stored login has died. Do not repeatedly overwrite
9
+ // that slot with copies from a peer while the check is unavailable.
10
+ if (verdict === "keychain_unavailable" || verdict === "token_expired") return true;
11
+ return alive === true && !LOST_COPY.has(verdict);
12
+ }
13
+
14
+ /** When a collector verdict is present, only `ok` is positive export evidence.
15
+ * Unknown and transient verdict strings are therefore fail-closed. */
16
+ export function exportVerdictOk(verdict) {
17
+ return verdict === "ok";
18
+ }
19
+
20
+ /** Cached preflight for the LAN hot path. A KNOWN bad or unknown verdict is
21
+ * refused before spawning an export. An inactive slot with no verdict may reach
22
+ * the bounded export itself: it exports the stored copy, which carries the
23
+ * slot's own identity. The ACTIVE slot may not, because its export is the live
24
+ * CLI login, which can belong to another account or be expired while the
25
+ * blob's labels still name the slot, so the identity check cannot catch it.
26
+ * Its verdict is refreshed in the background and the next round shares it. */
27
+ export function cachedExportReadable(verdict, { active = false } = {}) {
28
+ if (verdict == null) return !active;
29
+ return exportVerdictOk(verdict);
30
+ }
31
+
32
+ /** Is the live CLI login the account this slot names? The active slot exports
33
+ * the live login, and a verdict can predate a `/login` as somebody else. */
34
+ export function liveLoginIs(identity, email, org) {
35
+ const live = String(identity?.email ?? "").trim().toLowerCase();
36
+ if (!live || live !== String(email ?? "").trim().toLowerCase()) return false;
37
+ return !identity.orgId || !org || identity.orgId === org;
38
+ }
@@ -16,6 +16,7 @@
16
16
  import { readFile, writeFile, mkdir } from "node:fs/promises";
17
17
  import { cswapBin, cswapVersion, installHint } from "./cswap-install.mjs";
18
18
  import { run, runDetached } from "./exec.mjs";
19
+ import { storedCopyAlive } from "./account-health.mjs";
19
20
  // The CLI identity oracle, already written and already trusted by the account
20
21
  // admin routes. #721 needs the same answer, so it reuses the same function
21
22
  // rather than shelling out a second way to ask one question.
@@ -347,14 +348,39 @@ export function nextReadAt(row, matches, fetchedAtMs, isActive, now) {
347
348
  * for `--json` and keeps the verdicts. Still not awaited by anyone — the nudge
348
349
  * stays synchronous for its callers — and still one child at a time.
349
350
  */
350
- let _verdicts = { at: 0, byNum: {} };
351
+ let _verdicts = { at: 0, byNum: {}, identities: {} };
351
352
  /** Stale after this, because a verdict that outlives its cause is worse than no
352
353
  * verdict: "no credentials" under an account somebody has since signed into is
353
354
  * a sentence that sends them to fix what is already fixed. */
354
355
  const VERDICT_TTL_MS = 10 * 60_000;
355
- /** One `cswap list --json` at a time. A collection can take a while on a cold
356
- * network, and a nudge landing inside one must not start a second. */
357
- let _verdictInFlight = false;
356
+ /** Run one `cswap list --json` at a time. A post-write question must start
357
+ * after any older collection has finished; routine readers can share the
358
+ * latest pending answer without starting another slow collection. */
359
+ export function createVerdictQueue(collect) {
360
+ let pending = null;
361
+ return {
362
+ ask({ fresh = false } = {}) {
363
+ if (pending && !fresh) return pending;
364
+ const previous = pending;
365
+ // Start the first poll immediately: requestCollection promises the UI that
366
+ // its refresh has started before it returns. Only subsequent fresh polls
367
+ // wait for the earlier snapshot to finish.
368
+ let next;
369
+ if (previous) next = previous.then(collect, collect);
370
+ else {
371
+ try { next = Promise.resolve(collect()); }
372
+ catch (error) { next = Promise.reject(error); }
373
+ }
374
+ pending = next;
375
+ void next.then(
376
+ () => { if (pending === next) pending = null; },
377
+ () => { if (pending === next) pending = null; },
378
+ );
379
+ return next;
380
+ },
381
+ busy() { return pending !== null; },
382
+ };
383
+ }
358
384
 
359
385
  /**
360
386
  * Ask claude-swap about one account RIGHT NOW, rather than reading the cache.
@@ -374,6 +400,20 @@ let _verdictInFlight = false;
374
400
  export async function verdictNow(email, org, { runner = run, bin = cswapBin } = {}) {
375
401
  const want = String(email ?? "").trim().toLowerCase();
376
402
  if (!want) return null;
403
+ const all = await verdictsNow({ runner, bin, fresh: true });
404
+ return all?.find(a => a.email === want && a.org === (org ?? ""))?.status ?? null;
405
+ }
406
+
407
+ /**
408
+ * Every account's verdict from ONE `cswap list --json`, as `{ email, org,
409
+ * status, active }` rows (email lower-cased, status null when claude-swap gave none),
410
+ * or null when the question could not be asked at all.
411
+ *
412
+ * For a caller holding several identities at once — a round that imported
413
+ * three logins asks once, not three times, since each ask is a full usage
414
+ * collection that can take a minute on a cold network.
415
+ */
416
+ async function collectVerdicts({ runner, bin }) {
377
417
  try {
378
418
  const out = await runner(await bin(), ["list", "--json"], { timeout: VERDICT_TIMEOUT_MS });
379
419
  if (!out?.ok) return null;
@@ -381,20 +421,47 @@ export async function verdictNow(email, org, { runner = run, bin = cswapBin } =
381
421
  // The same freshly-read answer feeds the cache the panel draws from, since
382
422
  // it cost a subprocess either way.
383
423
  const byNum = readVerdicts(out.stdout);
384
- if (Object.keys(byNum).length) _verdicts = { at: Date.now(), byNum };
424
+ const identities = {};
385
425
  for (const a of Array.isArray(d?.accounts) ? d.accounts : []) {
386
- if (String(a?.email ?? "").trim().toLowerCase() !== want) continue;
387
- if ((a?.organizationUuid ?? "") !== (org ?? "")) continue;
388
- return typeof a?.usageStatus === "string" ? a.usageStatus : null;
426
+ if (Number.isInteger(a?.number) && typeof a?.email === "string" && a.email.trim()) {
427
+ identities[String(a.number)] = `${a.email.trim().toLowerCase()}@@${a.organizationUuid ?? ""}`;
428
+ }
389
429
  }
390
- return null;
430
+ _verdicts = { at: Date.now(), byNum, identities };
431
+ return (Array.isArray(d?.accounts) ? d.accounts : []).map(a => ({
432
+ number: a?.number,
433
+ email: String(a?.email ?? "").trim().toLowerCase(),
434
+ org: a?.organizationUuid ?? "",
435
+ status: typeof a?.usageStatus === "string" ? a.usageStatus : null,
436
+ // For the account Claude Code is signed in as, claude-swap reads the
437
+ // LIVE credential, not the stored copy — so its verdict there is about
438
+ // the live login. See checkImports.
439
+ active: a?.active === true,
440
+ }));
391
441
  } catch { return null; }
392
442
  }
393
443
 
444
+ const verdictQueue = createVerdictQueue(() => collectVerdicts({ runner: run, bin: cswapBin }));
445
+
446
+ /** Routine readers share a collection; post-write callers wait for any older
447
+ * collection and start a new one, so they never inspect the pre-write store. */
448
+ export function verdictsNow({ runner = run, bin = cswapBin, fresh = false } = {}) {
449
+ // Tests and callers supplying their own runner must receive their own answer.
450
+ if (runner !== run || bin !== cswapBin) return collectVerdicts({ runner, bin });
451
+ return verdictQueue.ask({ fresh });
452
+ }
453
+
394
454
  /** claude-swap's verdict for a slot, or null when there is none fresh enough. */
395
- function verdictFor(num, now) {
396
- if (now - _verdicts.at > VERDICT_TTL_MS) return null;
397
- const v = _verdicts.byNum[String(num)];
455
+ function verdictFor(num, now, email, org) {
456
+ return cachedVerdictFor(_verdicts, num, now, email, org);
457
+ }
458
+
459
+ /** Only attach a cached slot verdict to the identity it was collected for. */
460
+ export function cachedVerdictFor(cache, num, now, email, org) {
461
+ if (now - cache.at > VERDICT_TTL_MS) return null;
462
+ const identity = `${String(email ?? "").trim().toLowerCase()}@@${org ?? ""}`;
463
+ if (cache.identities[String(num)] !== identity) return null;
464
+ const v = cache.byNum[String(num)];
398
465
  return typeof v === "string" && v !== "" ? v : null;
399
466
  }
400
467
 
@@ -439,22 +506,12 @@ function nudgeCollector(rows, slots, now, activeNum) {
439
506
  cswapBin().then(bin => runDetached(bin, ["auto", "--once", "--dry-run", "--json"])).catch(() => {});
440
507
  return;
441
508
  }
442
- if (_verdictInFlight) return;
443
- _verdictInFlight = true;
509
+ if (verdictQueue.busy()) return;
444
510
  // Fire-and-forget: this function is deliberately synchronous so callers never
445
511
  // wait on it, and resolving the binary is the only async part. `run` rather
446
512
  // than `runDetached` only so the output can be read; the caller is no more
447
513
  // aware of it than before.
448
- cswapBin()
449
- .then(bin => run(bin, ["list", "--json"], { timeout: VERDICT_TIMEOUT_MS }))
450
- .then(out => {
451
- const byNum = out?.ok ? readVerdicts(out.stdout) : {};
452
- // Replaced whole rather than merged. A slot that has gone away must not
453
- // keep the verdict it had when it was last seen.
454
- if (Object.keys(byNum).length) _verdicts = { at: Date.now(), byNum };
455
- })
456
- .catch(() => {})
457
- .finally(() => { _verdictInFlight = false; });
514
+ void verdictsNow();
458
515
  }
459
516
 
460
517
  /** Long enough for a cold collection over a slow network, short enough that a
@@ -637,6 +694,7 @@ async function readRoster(now, gen) {
637
694
  .filter(Boolean),
638
695
  ].filter(Boolean);
639
696
 
697
+ const collector = verdictFor(num, now, acct.email, acct.organizationUuid);
640
698
  accounts.push({
641
699
  num: Number(num),
642
700
  email: acct.email ?? null,
@@ -654,7 +712,7 @@ async function readRoster(now, gen) {
654
712
  // `stale-copy` row means the live session is fine while the copy in the
655
713
  // store is dead, and the copy is what a share would carry and what a
656
714
  // peer's copy would heal. So both kinds of trouble read as not alive.
657
- alive: trouble == null,
715
+ alive: storedCopyAlive(trouble == null, collector),
658
716
  active: String(seq.activeAccountNumber) === num,
659
717
  disabled: acct.disabled === true,
660
718
  lanes,
@@ -694,7 +752,7 @@ async function readRoster(now, gen) {
694
752
  // "no_credentials", "relogin_required", "keychain_unavailable", … It is
695
753
  // what turns "not collecting" into a sentence with a next step in it, and
696
754
  // it is null on every machine where the collector has not been asked yet.
697
- collector: verdictFor(num, now),
755
+ collector,
698
756
  });
699
757
  }
700
758
 
@@ -24,7 +24,8 @@ import { readFile } from "node:fs/promises";
24
24
  import { homedir } from "node:os";
25
25
  import { join } from "node:path";
26
26
  import { looksMissing, pathLookup, run, runDetached, runInteractive } from "./exec.mjs";
27
- import { backupRoot, invalidateClaudeAccountsCache, verdictNow } from "./claude-accounts.mjs";
27
+ import { backupRoot, invalidateClaudeAccountsCache, verdictNow, verdictsNow } from "./claude-accounts.mjs";
28
+ import { HERE } from "./lan-sync.mjs";
28
29
  import { withStoreLock } from "./store-lock.mjs";
29
30
  import { adminClaudeBin, currentIdentity } from "./claude-identity.mjs";
30
31
  import { claudeCliCandidates } from "./claude-dir.mjs";
@@ -35,6 +36,109 @@ import { PRODUCT } from "./brand.mjs";
35
36
  // open longer than a user would plausibly take to fetch one.
36
37
  const LOGIN_TIMEOUT_MS = 5 * 60_000;
37
38
  const CSWAP_TIMEOUT_MS = 60_000;
39
+ // ── whether this process can read what claude-swap holds ────────────────────
40
+ //
41
+ // ASKED OF CLAUDE-SWAP, NEVER READ OFF ITS WORDS. A Keychain it cannot open is
42
+ // logged to its own log file and nowhere else: `cswap export` then says only
43
+ // "no backup credentials found for account N", which is also what an account
44
+ // with no stored login says, and `cswap import` falls back to its .enc file and
45
+ // exits 0. Nothing on stderr tells a locked Keychain apart, so nothing here
46
+ // looks. `cswap list --json` does tell it apart — a per-account `usageStatus`
47
+ // of `keychain_unavailable` — and that is the one signal these read.
48
+ //
49
+ // MACOS ONLY. claude-swap reports `keychain_unavailable` on Linux and Windows
50
+ // as well, for an .enc file it cannot open, and every sentence these feed talks
51
+ // about a Mac's Keychain and a Terminal window.
52
+
53
+ /** Fresh verdict rows for several identities from one `cswap list --json`, in
54
+ * the order asked; null for any claude-swap would not answer for. */
55
+ async function rowsNow(ids, verdicts) {
56
+ const all = await verdicts({ fresh: true });
57
+ return ids.map(({ email, org }) => {
58
+ const want = String(email ?? "").trim().toLowerCase();
59
+ return all?.find(a => a.email === want && a.org === (org ?? "")) ?? null;
60
+ });
61
+ }
62
+
63
+ /**
64
+ * Why a share came out empty: `keychain_unavailable` when claude-swap says
65
+ * EVERY account that failed is one this Mac's Keychain will not open, and
66
+ * `export_failed` otherwise — the reason has to be true of the whole failure,
67
+ * because the detail printed beside it is.
68
+ */
69
+ export async function exportFailure(failed, store, { platform = process.platform, verdicts = verdictsNow } = {}) {
70
+ if (platform !== "darwin" || !failed.length) return "export_failed";
71
+ const got = await rowsNow(
72
+ failed.map(f => ({ email: f.email, org: store?.orgs?.[f.num] ?? "" })),
73
+ verdicts,
74
+ );
75
+ // The panel's own copy of the verdicts was just replaced; its cached account
76
+ // list must not go on drawing the old ones.
77
+ invalidateClaudeAccountsCache();
78
+ return got.every(r => r?.status === "keychain_unavailable") ? "keychain_unavailable" : "export_failed";
79
+ }
80
+
81
+ /** What claude-swap's verdict on a login that just landed means for it here.
82
+ * Everything else — `ok`, a transient `unavailable`, an api-key account — is a
83
+ * login that arrived and can be read, which is all this is asked. */
84
+ const AFTER_IMPORT = {
85
+ keychain_unavailable: HERE.unreadable,
86
+ no_credentials: HERE.noLogin,
87
+ relogin_required: HERE.expired,
88
+ };
89
+
90
+ /** A Mac's locally unreadable login cannot be offered to a paired deck. */
91
+ export function markUnreadable(accounts, platform = process.platform) {
92
+ if (platform !== "darwin" || !Array.isArray(accounts)) return accounts;
93
+ return accounts.map(a => a.collector === "keychain_unavailable" ? { ...a, readable: false } : a);
94
+ }
95
+
96
+ /**
97
+ * The logins a round just imported, checked once from inside this process.
98
+ *
99
+ * `cswap import` exiting 0 is not proof: on a Mac it falls back quietly when
100
+ * the Keychain will not take the write, and a deck started from SSH or a
101
+ * LaunchAgent may then be unable to read what it holds. One HERE code per
102
+ * identity (see lan-sync.mjs), or null for one that is fine — and
103
+ * `HERE.unverified` when claude-swap could not be asked at all, because an
104
+ * unanswered question is not a yes.
105
+ *
106
+ * One `cswap list --json` for the whole round, after it, rather than one per
107
+ * login inside it: each is a usage collection that can take a minute, and the
108
+ * peer hangs up on a connection left idle for thirty seconds.
109
+ *
110
+ * THE SIGNED-IN ACCOUNT IS READ LIVE. For it, claude-swap's verdict is about the
111
+ * live login rather than the copy just written, so an expired or missing live
112
+ * login says nothing about the import and reads as unverified. A Keychain it
113
+ * cannot open still counts: it is the same Keychain, from the same session.
114
+ */
115
+ export async function checkImports(ids, { platform = process.platform, verdicts = verdictsNow } = {}) {
116
+ if (platform !== "darwin" || !ids.length) return ids.map(() => null);
117
+ const got = await rowsNow(ids, verdicts);
118
+ return got.map(r => {
119
+ if (r?.status == null) return HERE.unverified;
120
+ if (!Object.hasOwn(AFTER_IMPORT, r.status)) return null;
121
+ return r.active && r.status !== "keychain_unavailable" ? HERE.unverified : AFTER_IMPORT[r.status];
122
+ });
123
+ }
124
+
125
+ /**
126
+ * An import's per-account results, each landed one carrying `check` — its
127
+ * checkImports code — when this Mac found something wrong with it. For the
128
+ * Add Account route, which says "imported" per account and was as blind to an
129
+ * unreadable login as the LAN round was.
130
+ */
131
+ export async function checkImportResults(results, opts) {
132
+ const list = Array.isArray(results) ? results : [];
133
+ const came = list.filter(r => landed([r]));
134
+ if (!came.length) return list;
135
+ const codes = await checkImports(came.map(r => ({ email: r.email, org: r.org ?? "" })), opts);
136
+ return list.map(r => {
137
+ const code = codes[came.indexOf(r)];
138
+ return typeof code === "string" ? { ...r, check: code } : r;
139
+ });
140
+ }
141
+
38
142
  // How long to wait for the CLI's verdict on a pasted code before saying so.
39
143
  // Exchanging a code is one HTTPS round trip; a minute is generous.
40
144
  const CODE_VERDICT_MS = 60_000;
@@ -889,8 +993,13 @@ export function mergeExports(texts) {
889
993
  *
890
994
  * The default export shape is used deliberately, never --full, which would
891
995
  * embed the entire ~/.claude.json including every project and MCP server.
996
+ *
997
+ * `explain: false` skips asking claude-swap why an empty export failed. The
998
+ * LAN answers a peer that gives up after ten seconds, and that question is a
999
+ * usage collection that can take a minute; it refreshes the verdict in the
1000
+ * background instead, so the next round's refusal can name the Keychain.
892
1001
  */
893
- export async function shareAccounts(nums) {
1002
+ export async function shareAccounts(nums, { explain = true } = {}) {
894
1003
  const asked = Array.isArray(nums) ? nums : [nums];
895
1004
  // One spawn per account, so the length of this list is a length of time the
896
1005
  // request holds. A store never has fifty accounts; a caller that sends nine
@@ -939,7 +1048,17 @@ export async function shareAccounts(nums) {
939
1048
  // Nothing came out at all. There is no partial bundle to hand over, so this
940
1049
  // is the plain failure the single-account share has always reported.
941
1050
  if (!texts.length) {
942
- return { ok: false, reason: "export_failed", detail: failed[0]?.detail ?? "", failed };
1051
+ if (!explain) {
1052
+ if (process.platform === "darwin") void verdictsNow().then(() => invalidateClaudeAccountsCache(), () => {});
1053
+ return { ok: false, reason: "export_failed", detail: failed[0]?.detail ?? "", failed };
1054
+ }
1055
+ const reason = await exportFailure(failed, store);
1056
+ // failed[0]'s own line would read "no backup credentials found", which
1057
+ // sends somebody to sign in again over a login that is perfectly fine.
1058
+ const detail = reason === "keychain_unavailable"
1059
+ ? `claude-swap could not open this Mac's Keychain — start ${PRODUCT} from a Terminal window on the Mac itself, not over SSH or as a background service`
1060
+ : failed[0]?.detail ?? "";
1061
+ return { ok: false, reason, detail, failed };
943
1062
  }
944
1063
 
945
1064
  const merged = mergeExports(texts);
@@ -1106,7 +1225,7 @@ export function importOutcomes(before, after, wanted, stderr = "") {
1106
1225
  * from here - the fix is a re-login. Requiring the pair is what makes the
1107
1226
  * clobber something a person chose while looking at the address.
1108
1227
  */
1109
- export async function importAccount(blob, { force = false, only = null } = {}) {
1228
+ export async function importAccount(blob, { force = false, only = null, collect = true } = {}) {
1110
1229
  const un = unwrapShare(blob);
1111
1230
  if (!un.ok) return { ok: false, reason: un.reason };
1112
1231
 
@@ -1153,7 +1272,10 @@ export async function importAccount(blob, { force = false, only = null } = {}) {
1153
1272
  })();
1154
1273
 
1155
1274
  const arrived = results.filter(x => x.state === "imported");
1156
- if (arrived.length) runDetached(await cswapBin(), ["list"]);
1275
+ // `collect: false` when a checkImports follows: its `cswap list --json` is
1276
+ // the same collection, and two at once race for one slot's claim, so the
1277
+ // loser reads a fresh slot as `unavailable`.
1278
+ if (arrived.length && collect) runDetached(await cswapBin(), ["list"]);
1157
1279
  return {
1158
1280
  ok: true,
1159
1281
  results,
@@ -1204,14 +1326,20 @@ export async function importAccount(blob, { force = false, only = null } = {}) {
1204
1326
  * would rewrite every matching credential on this machine, and a fresh token
1205
1327
  * replaced by a stale one is not recoverable from here.
1206
1328
  */
1207
- export async function fillEmptySlot(blob, { email, org } = {}) {
1329
+ export async function fillEmptySlot(blob, { email, org, platform = process.platform, collect = true } = {}) {
1208
1330
  return withStoreLock(async () => {
1209
1331
  // FIRST STATEMENT INSIDE THE LOCK. Anything awaited before this re-opens
1210
1332
  // the window it exists to close.
1211
1333
  const now = await verdictNow(email, org ?? "");
1212
- if (now !== "no_credentials") return { ok: false, why: "claude-swap kept the slot it already has" };
1334
+ // THIS LINE IS THE PROTECTION: anything but `no_credentials` — an
1335
+ // unreadable Keychain included, which is an unknown and not an empty slot —
1336
+ // refuses the forced write. The ternary only names which refusal it was,
1337
+ // since "kept the slot it already has" sends nobody to unlock the Mac.
1338
+ if (now !== "no_credentials") {
1339
+ return { ok: false, why: platform === "darwin" && now === "keychain_unavailable" ? HERE.unreadable : "claude-swap kept the slot it already has" };
1340
+ }
1213
1341
 
1214
- const forced = await importAccount(blob, { force: true, only: { email, org: org ?? "" } });
1342
+ const forced = await importAccount(blob, { force: true, only: { email, org: org ?? "" }, collect });
1215
1343
  if (!forced?.ok) return { ok: false, why: forced?.reason ?? "import refused" };
1216
1344
  // `added` counts `imported` alone, and a forced replace is `healed` — see
1217
1345
  // landed, which is the difference between a repair and a repair reported as
@@ -109,6 +109,7 @@ export const DEFAULTS = Object.freeze({
109
109
  // network it does not own turns this off in the dialog, in one press.
110
110
  autoAsk: true,
111
111
  autoAccept: true,
112
+ pairingMode: "automatic",
112
113
  // WHICH ACCOUNT THIS DECK IS ON, told to the decks it is paired with — and
113
114
  // only ever one it shares, so an unticked account is never named. On, so two
114
115
  // of one person's machines show each other where they are working; off, and
@@ -232,6 +233,7 @@ function normaliseLan(raw) {
232
233
  // overrides it, because a truthy string from a hand-edited file is not an
233
234
  // answer.
234
235
  autoAccept: typeof src.autoAccept === "boolean" ? src.autoAccept : DEFAULTS.lan.autoAccept,
236
+ pairingMode: src.pairingMode === "invite" ? "invite" : "automatic",
235
237
  // Whether paired decks are told which shared account this one is on. Absent
236
238
  // is on — the default above — and only a real boolean turns it off.
237
239
  shareActive: typeof src.shareActive === "boolean" ? src.shareActive : DEFAULTS.lan.shareActive,
@@ -0,0 +1,186 @@
1
+ // Custom Claude FM stations that live on YouTube (#1208).
2
+ //
3
+ // A station somebody adds by link is played by the browser, never by this
4
+ // process: a direct stream goes into an <audio> element and a YouTube one into
5
+ // the same embed Claude FM uses. The only thing the deck itself does is the
6
+ // lookup below, which turns a `/@handle/live` or `watch?v=` link into the
7
+ // channel and the video the embed needs, because YouTube only says which video
8
+ // a handle is broadcasting in the page it serves.
9
+ //
10
+ // THAT MAKES THIS THE ONE ROUTE WHERE A CALLER NAMES WHAT THE DECK FETCHES, so
11
+ // the link is never fetched as given. It is parsed down to one of three shapes
12
+ // and the request is rebuilt from the parts: https, www.youtube.com, no port,
13
+ // no login, a path this file wrote and an id that matched a strict pattern.
14
+ // A link with a port or a login is refused rather than cleaned, because nobody
15
+ // copies one of those out of a YouTube tab and both change where the request
16
+ // would really go. index.mjs puts the route behind the guarded-read gate too,
17
+ // so a page on another site cannot use it to make the deck call anything.
18
+ const CHANNEL_ID = /^UC[A-Za-z0-9_-]{22}$/;
19
+ const VIDEO_ID = /^[A-Za-z0-9_-]{11}$/;
20
+ const HANDLE = /^[A-Za-z0-9._-]{1,64}$/;
21
+ const YOUTUBE_HOSTS = new Set(["youtube.com", "www.youtube.com", "m.youtube.com"]);
22
+
23
+ export const FM_STATION_READ_LIMIT = 2_000_000;
24
+ export const FM_STATION_TIMEOUT_MS = 6_000;
25
+ /** How long an answer stands. A station that resolved stays resolved for as
26
+ * long as claude-fm.mjs trusts its own live check; one that did not is asked
27
+ * again sooner, since "not live" is the answer most likely to change. */
28
+ export const FM_STATION_CACHE_MS = 10 * 60_000;
29
+ export const FM_STATION_MISS_CACHE_MS = 60_000;
30
+ /** Answers kept at once. A deck has a handful of stations; the cap only has to
31
+ * stop a caller walking the map up with invented handles. */
32
+ const CACHE_MAX = 32;
33
+ const MAX_REDIRECTS = 5;
34
+
35
+ const BROWSER_UA = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0 Safari/537.36";
36
+ const CANONICAL_VIDEO = /<link\s+rel="canonical"\s+href="https:\/\/www\.youtube\.com\/watch\?v=([A-Za-z0-9_-]{11})"/;
37
+ const EXTERNAL_CHANNEL = /"externalChannelId"\s*:\s*"(UC[A-Za-z0-9_-]{22})"/;
38
+ const CHANNEL_URL = /https:\/\/www\.youtube\.com\/channel\/(UC[A-Za-z0-9_-]{22})/;
39
+
40
+ export function parseYouTubeStationUrl(value) {
41
+ let url;
42
+ try { url = new URL(String(value ?? "").trim()); } catch { return null; }
43
+ if (url.protocol !== "https:") return null;
44
+ if (url.port || url.username || url.password) return null;
45
+ if (!YOUTUBE_HOSTS.has(url.hostname.toLowerCase())) return null;
46
+
47
+ const channel = /^\/channel\/([^/]+)(?:\/live)?\/?$/.exec(url.pathname)?.[1];
48
+ if (channel && CHANNEL_ID.test(channel)) {
49
+ return { kind: "channel", url: `https://www.youtube.com/channel/${channel}/live`, channel };
50
+ }
51
+
52
+ const handle = /^\/@([^/]+)\/live\/?$/.exec(url.pathname)?.[1];
53
+ if (handle && HANDLE.test(handle)) {
54
+ return { kind: "handle", url: `https://www.youtube.com/@${handle}/live`, handle };
55
+ }
56
+
57
+ if (url.pathname === "/watch") {
58
+ const video = url.searchParams.get("v") ?? "";
59
+ if (VIDEO_ID.test(video)) return { kind: "video", url: `https://www.youtube.com/watch?v=${video}`, video };
60
+ }
61
+ return null;
62
+ }
63
+
64
+ export function readYouTubeStationPage(html) {
65
+ const page = typeof html === "string" ? html : "";
66
+ const detailsAt = page.indexOf('"videoDetails"');
67
+ const details = detailsAt < 0 ? "" : page.slice(detailsAt, detailsAt + 24_000);
68
+ const live = /"isLiveContent"\s*:\s*true/.test(details) || /"isLive"\s*:\s*true/.test(details);
69
+ const channel = /"channelId"\s*:\s*"(UC[A-Za-z0-9_-]{22})"/.exec(details)?.[1]
70
+ ?? EXTERNAL_CHANNEL.exec(page)?.[1]
71
+ ?? CHANNEL_URL.exec(page)?.[1]
72
+ ?? null;
73
+ const video = CANONICAL_VIDEO.exec(page)?.[1] ?? null;
74
+ return { live, channel, video };
75
+ }
76
+
77
+ const _cache = new Map();
78
+ const _inflight = new Map();
79
+
80
+ /** For tests: every answer this process remembers, forgotten. */
81
+ export function forgetFmStations() {
82
+ _cache.clear();
83
+ _inflight.clear();
84
+ }
85
+
86
+ /**
87
+ * The channel and video a YouTube station link plays, or why it cannot.
88
+ *
89
+ * CACHED AND SHARED, the way fetchClaudeFm is. Every canvas that mounts with a
90
+ * custom station selected asks this, and four open tabs were four page loads
91
+ * of up to 2MB each from YouTube on every reload; one lookup per link now
92
+ * answers all of them, and a second caller that arrives while the first is
93
+ * still reading waits for that same read.
94
+ */
95
+ export async function resolveYouTubeStation(value, { fetchImpl, now = Date.now } = {}) {
96
+ const parsed = parseYouTubeStationUrl(value);
97
+ if (!parsed) return { ok: false, error: "unsupported_url" };
98
+ if (parsed.kind === "channel") return { ok: true, channel: parsed.channel, video: null };
99
+
100
+ const key = parsed.url;
101
+ const hit = _cache.get(key);
102
+ if (hit && now() - hit.at < (hit.answer.ok ? FM_STATION_CACHE_MS : FM_STATION_MISS_CACHE_MS)) return hit.answer;
103
+ const pending = _inflight.get(key);
104
+ if (pending) return pending;
105
+
106
+ const lookup = lookUp(parsed, fetchImpl ?? fetch)
107
+ .then(answer => {
108
+ _cache.delete(key);
109
+ _cache.set(key, { at: now(), answer });
110
+ // A Map iterates in insertion order, so the first key is the oldest.
111
+ while (_cache.size > CACHE_MAX) _cache.delete(_cache.keys().next().value);
112
+ return answer;
113
+ })
114
+ .finally(() => { _inflight.delete(key); });
115
+ _inflight.set(key, lookup);
116
+ return lookup;
117
+ }
118
+
119
+ async function lookUp(parsed, get) {
120
+ try {
121
+ // Validate every hop before fetching it. Automatic redirects may contact
122
+ // an arbitrary host before response.url can be inspected (SSRF).
123
+ const signal = AbortSignal.timeout(FM_STATION_TIMEOUT_MS);
124
+ let target = parsed.url;
125
+ let response;
126
+ for (let redirects = 0; ; redirects++) {
127
+ if (!isYouTubeHost(target)) return { ok: false, error: "unresolved" };
128
+ response = await get(target, {
129
+ headers: { "User-Agent": BROWSER_UA, "Accept-Language": "en-US,en;q=0.9" },
130
+ signal,
131
+ redirect: "manual",
132
+ });
133
+ // A custom fetch implementation must not silently follow redirects.
134
+ if (response.url && !isYouTubeHost(response.url)) {
135
+ try { await response.body?.cancel(); } catch { /* already closed */ }
136
+ return { ok: false, error: "unresolved" };
137
+ }
138
+ if (![301, 302, 303, 307, 308].includes(response.status)) break;
139
+ const location = response.headers?.get("location");
140
+ try { await response.body?.cancel(); } catch { /* already closed */ }
141
+ if (!location || redirects >= MAX_REDIRECTS) return { ok: false, error: "unresolved" };
142
+ try { target = new URL(location, target).href; }
143
+ catch { return { ok: false, error: "unresolved" }; }
144
+ }
145
+ if (!response.ok) return { ok: false, error: `youtube_${response.status}` };
146
+ const marks = readYouTubeStationPage(await readBounded(response));
147
+ if (parsed.kind === "video" && !marks.live) return { ok: false, error: "not_live" };
148
+ if (parsed.kind === "handle" && !marks.live && !marks.video) return { ok: false, error: "not_live" };
149
+ if (marks.channel) return { ok: true, channel: marks.channel, video: marks.video };
150
+ if (marks.video) return { ok: true, channel: null, video: marks.video };
151
+ if (parsed.kind === "video") return { ok: true, channel: null, video: parsed.video };
152
+ return { ok: false, error: "unresolved" };
153
+ } catch {
154
+ // Offline, DNS, a timeout: one answer, because the canvas does the same
155
+ // thing with all of them, and the error text is the operator's, not the
156
+ // page's — see the note above index.mjs's `guard`.
157
+ return { ok: false, error: "unreachable" };
158
+ }
159
+ }
160
+
161
+ function isYouTubeHost(href) {
162
+ try {
163
+ const url = new URL(href);
164
+ const host = url.hostname.toLowerCase();
165
+ return url.protocol === "https:" && !url.username && !url.password && !url.port
166
+ && (host === "youtube.com" || host.endsWith(".youtube.com"));
167
+ } catch { return false; }
168
+ }
169
+
170
+ async function readBounded(response, limit = FM_STATION_READ_LIMIT) {
171
+ if (!response.body?.getReader) return (await response.text()).slice(0, limit);
172
+ const reader = response.body.getReader();
173
+ const decoder = new TextDecoder();
174
+ let out = "";
175
+ try {
176
+ while (out.length < limit) {
177
+ const { done, value } = await reader.read();
178
+ if (done) break;
179
+ out += decoder.decode(value, { stream: true });
180
+ if (out.includes('"isLive":true') && out.includes('rel="canonical"') && out.includes('"channelId"')) break;
181
+ }
182
+ } finally {
183
+ try { await reader.cancel(); } catch { /* connection already closed */ }
184
+ }
185
+ return out.slice(0, limit);
186
+ }