ccdeck 3.28.1 → 3.29.1

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.
@@ -22,7 +22,10 @@ import { challengeDeck, challengeProof, isProcessAlive } from "./deck-probe.mjs"
22
22
  // re-exporting them would be inventing API on the way out of a move.
23
23
  export { challengeDeck, challengeProof, isProcessAlive };
24
24
  import { promisify } from "node:util";
25
- import { claudeConfigDir } from "./claude-dir.mjs";
25
+ import { ccProjectSlug, claudeConfigDir } from "./claude-dir.mjs";
26
+ // Moved to claude-dir.mjs so the Projects rollup can read transcript folders
27
+ // without importing this file; re-exported under the name it always had.
28
+ export { ccProjectSlug };
26
29
  import { CODEX_HOME, CODEX_SESSIONS_DIR, STOP, walkRolloutDays } from "./codex-dir.mjs";
27
30
  import { PRODUCT } from "./brand.mjs";
28
31
  import { createBlockNotifier } from "./block-notify.mjs";
@@ -432,6 +435,67 @@ function notifyTrays(title, body, { chime = null, who = null } = {}) {
432
435
  return true;
433
436
  }
434
437
 
438
+ // The desktop app's updater lives in Electron, while the window is a page
439
+ // served by this deck. Keep only the small piece of state the page needs. A
440
+ // report is accepted only with the deck token; a browser may request an
441
+ // install, but Electron verifies the exact ready version again before acting.
442
+ const DESKTOP_UPDATE_STATUSES = new Set(["idle", "checking", "current", "downloading", "ready", "error"]);
443
+ let desktopUpdateState = { status: "idle", version: null };
444
+
445
+ function cleanDesktopUpdate(value) {
446
+ if (!value || typeof value !== "object" || !DESKTOP_UPDATE_STATUSES.has(value.status)) return null;
447
+ const version = typeof value.version === "string" && value.version.trim()
448
+ ? value.version.trim().slice(0, 80)
449
+ : null;
450
+ if (value.status === "ready" && !version) return null;
451
+ return { status: value.status, version };
452
+ }
453
+
454
+ function broadcastDesktopUpdate() {
455
+ const frame = `event: desktop-update\ndata: ${JSON.stringify(desktopUpdateState)}\n\n`;
456
+ for (const client of sseClients) {
457
+ if (!trayClients.has(client)) writeSse(client, frame);
458
+ }
459
+ }
460
+
461
+ function handleDesktopUpdateRead(_req, res) {
462
+ send(res, 200, desktopUpdateState);
463
+ }
464
+
465
+ async function handleDesktopUpdateReport(req, res) {
466
+ // Same-origin pages pass the generic mutation gate, but only the native app
467
+ // may claim what its updater has verified.
468
+ if (!presentsDeckToken(req.headers ?? {})) {
469
+ return send(res, 401, { ok: false, reason: "app_token_required" });
470
+ }
471
+ const body = await readBody(req, res).catch(() => null);
472
+ let value = null;
473
+ try { value = cleanDesktopUpdate(JSON.parse(body ?? "")); } catch { /* bad JSON */ }
474
+ if (!value) return send(res, 400, { ok: false, reason: "bad_update_state" });
475
+ desktopUpdateState = value;
476
+ broadcastDesktopUpdate();
477
+ send(res, 200, { ok: true });
478
+ }
479
+
480
+ // The window's two messages about that update, relayed to the app as frames
481
+ // on its tray stream: apply it (`desktop-update-restart`), or it has been shown
482
+ // (`desktop-update-seen`, so the app's own ready notice stands down, #1182).
483
+ // Both behind the same gates as /api/restart, and neither decides anything:
484
+ // this refuses only what cannot be current, and Electron checks the exact
485
+ // ready version again before acting on either.
486
+ async function handleDesktopUpdateRequest(req, res, event) {
487
+ const body = await readBody(req, res).catch(() => null);
488
+ let version = "";
489
+ try { version = String(JSON.parse(body ?? "")?.version ?? "").trim(); } catch { /* bad JSON */ }
490
+ if (!version || desktopUpdateState.status !== "ready" || desktopUpdateState.version !== version) {
491
+ return send(res, 409, { ok: false, reason: "update_not_ready" });
492
+ }
493
+ if (trayClients.size === 0) return send(res, 409, { ok: false, reason: "app_disconnected" });
494
+ const frame = `event: ${event}\ndata: ${JSON.stringify({ version })}\n\n`;
495
+ for (const client of trayClients) writeSse(client, frame);
496
+ send(res, 202, { ok: true });
497
+ }
498
+
435
499
  let persistPath = null; // absolute path to events.jsonl, or null
436
500
 
437
501
  // ─── Which deck records which session ─────────────────────────────────────
@@ -1782,40 +1846,6 @@ export async function readContextFromTranscript(path) {
1782
1846
  return { ...state.ctx };
1783
1847
  }
1784
1848
 
1785
- /** Longest slug CC will store verbatim. Anything longer is truncated here and
1786
- * given a hash suffix, so two deep paths sharing a 200-character prefix still
1787
- * get separate directories. */
1788
- const CC_SLUG_MAX = 200;
1789
-
1790
- /** CC's hash of the *unencoded* path, used only for the truncation suffix:
1791
- * the classic h*31 + c string hash, kept in a signed 32-bit int. */
1792
- function ccPathHash(s) {
1793
- let h = 0;
1794
- for (let i = 0; i < s.length; i++) h = ((h << 5) - h + s.charCodeAt(i)) | 0;
1795
- return h;
1796
- }
1797
-
1798
- /** Encode an absolute path the way CC stores it under
1799
- * ~/.claude/projects/<slug>/, so the auto-memory scan below looks in the
1800
- * directory CC actually wrote.
1801
- *
1802
- * CC flattens *every* non-alphanumeric character to "-", not just the path
1803
- * separators and the Windows drive colon this used to replace. Dots are the
1804
- * ones that bite: a worktree under .claude/worktrees/, or any folder named
1805
- * like my.app, produced a slug with a literal dot in it, the readdir below
1806
- * missed, and the project's auto-memory files were quietly left out of the
1807
- * context panel. Underscores and spaces were wrong the same way. */
1808
- export function ccProjectSlug(cwd) {
1809
- if (!cwd) return "";
1810
- // resolve() gives the platform's own absolute form — "/Users/…" on
1811
- // macOS/Linux, "C:\Users\…" on Windows — and this class covers both, so the
1812
- // drive colon and the backslashes fall out of the general rule.
1813
- const abs = resolve(cwd);
1814
- const slug = abs.replace(/[^a-zA-Z0-9]/g, "-");
1815
- if (slug.length <= CC_SLUG_MAX) return slug;
1816
- return `${slug.slice(0, CC_SLUG_MAX)}-${Math.abs(ccPathHash(abs)).toString(36)}`;
1817
- }
1818
-
1819
1849
  /**
1820
1850
  * Candidate memory-file paths on the walk from `cwd` up to the filesystem root.
1821
1851
  *
@@ -3889,6 +3919,31 @@ async function handlePresence(req, res) {
3889
3919
  * Tailscale is here at all before anybody can turn discovery on. */
3890
3920
  const tailnet = createTailnet();
3891
3921
 
3922
+ // Whether every import is followed by checkImports, which only asks on a Mac.
3923
+ // When it does, the import skips its own detached collection — see importAccount.
3924
+ const CHECKS_IMPORTS = process.platform === "darwin";
3925
+
3926
+ const ACTIVE_VERDICT_FIRST_RETRY_MS = 60_000;
3927
+ const ACTIVE_VERDICT_MAX_RETRY_MS = 30 * 60_000;
3928
+ const LIVE_LOGIN_TIMEOUT_MS = 5_000;
3929
+ let activeVerdictRetryMs = ACTIVE_VERDICT_FIRST_RETRY_MS;
3930
+ let activeVerdictNextAt = 0;
3931
+ // A claude-swap that never reports a verdict for the active slot would
3932
+ // otherwise cost a usage collection every minute for as long as LAN is on.
3933
+ function refreshActiveVerdict() {
3934
+ if (Date.now() < activeVerdictNextAt) return;
3935
+ activeVerdictNextAt = Date.now() + activeVerdictRetryMs;
3936
+ activeVerdictRetryMs = Math.min(activeVerdictRetryMs * 2, ACTIVE_VERDICT_MAX_RETRY_MS);
3937
+ void import("./claude-accounts.mjs")
3938
+ .then(({ verdictsNow, invalidateClaudeAccountsCache }) =>
3939
+ verdictsNow().then(got => { if (got) invalidateClaudeAccountsCache(); }))
3940
+ .catch(() => {});
3941
+ }
3942
+ function activeVerdictArrived() {
3943
+ activeVerdictRetryMs = ACTIVE_VERDICT_FIRST_RETRY_MS;
3944
+ activeVerdictNextAt = 0;
3945
+ }
3946
+
3892
3947
  const lanEngine = createEngine({
3893
3948
  tailnet,
3894
3949
  // Who holds the discovery port when it is taken, so the panel can say.
@@ -3901,17 +3956,63 @@ const lanEngine = createEngine({
3901
3956
  about: aboutThisDeck({ version: RUNNING_VERSION }),
3902
3957
  readAccounts: async () => {
3903
3958
  const { fetchClaudeAccounts } = await import("./claude-accounts.mjs");
3904
- return fetchClaudeAccounts();
3959
+ const got = await fetchClaudeAccounts();
3960
+ // A login this Mac's Keychain will not open from the deck's session. The
3961
+ // engine refuses to export it with a fixed code rather than spawning an
3962
+ // export that can only fail. Mac only: claude-swap's `keychain_unavailable`
3963
+ // elsewhere is an unreadable .enc file, and the sentence the peer prints
3964
+ // talks about a Keychain.
3965
+ if (!got || !Array.isArray(got.accounts)) return got;
3966
+ // The active slot is withheld from peers until it has a fresh verdict — see
3967
+ // cachedExportReadable — so ask for one now rather than waiting on the
3968
+ // collector's own schedule.
3969
+ if (got.accounts.some(a => a.active === true && a.collector == null)) refreshActiveVerdict();
3970
+ else activeVerdictArrived();
3971
+ const { markUnreadable } = await import("./cswap-admin.mjs");
3972
+ return { ...got, accounts: markUnreadable(got.accounts) };
3973
+ },
3974
+ // Bounded well inside a round, since a want waits on it.
3975
+ liveLogin: async () => {
3976
+ const { currentIdentity } = await import("./cswap-admin.mjs");
3977
+ const late = new Promise(resolve => setTimeout(resolve, LIVE_LOGIN_TIMEOUT_MS, null).unref?.());
3978
+ return Promise.race([currentIdentity().catch(() => null), late]);
3979
+ },
3980
+ exportAccount: async (num, expectedKey) => {
3981
+ const { accountKey } = await import("./lan-sync.mjs");
3982
+ const { shareAccounts, unwrapShare } = await import("./cswap-admin.mjs");
3983
+ // Do not collect live verdicts here. A LAN want has a ten-second round
3984
+ // budget, while a verdict collection may wait up to ninety seconds (or
3985
+ // queue behind another one). The want handler already re-reads this deck's
3986
+ // cached account state immediately before export and refuses known
3987
+ // unreadable/dead copies. The share itself is therefore the only bounded
3988
+ // operation left on the hot path.
3989
+ //
3990
+ // Without the explanation, which would outlast the asking peer's patience;
3991
+ // see shareAccounts.
3992
+ const out = await shareAccounts([String(num)], { explain: false });
3993
+ // The blob or nothing. WHY it failed is never carried out of here: the
3994
+ // engine decides what a peer is told from this deck's state — see
3995
+ // `readable` in readAccounts below.
3996
+ if (!out?.ok) return null;
3997
+ const opened = unwrapShare(out.blob);
3998
+ if (!opened.ok) return null;
3999
+ let accounts;
4000
+ try { accounts = JSON.parse(opened.payload)?.accounts; } catch { return null; }
4001
+ if (!Array.isArray(accounts) || accounts.length !== 1) return null;
4002
+ // Slot numbers are local. Verify the payload identity after export so a
4003
+ // moved/reused slot can never satisfy a want for another account.
4004
+ return accountKey(accounts[0]?.email, accounts[0]?.organizationUuid) === expectedKey ? out.blob : null;
3905
4005
  },
3906
- exportAccount: async num => {
3907
- const { shareAccounts } = await import("./cswap-admin.mjs");
3908
- const out = await shareAccounts([String(num)]);
3909
- return out?.ok ? out.blob : null;
4006
+ // The logins a round just imported, checked once after it — see checkImports.
4007
+ checkArrivals: async steps => {
4008
+ const { checkImports } = await import("./cswap-admin.mjs");
4009
+ return checkImports(steps.map(step => {
4010
+ const [email, org] = String(step?.key ?? "").split("@@");
4011
+ return { email, org: org ?? "" };
4012
+ }));
3910
4013
  },
3911
4014
  importAccount: async (blob, step) => {
3912
- // `landed` is not needed out here any more: the only call that read it was
3913
- // the forced import, which fillEmptySlot now owns along with it.
3914
- const { importAccount, fillEmptySlot } = await import("./cswap-admin.mjs");
4015
+ const { importAccount, fillEmptySlot, landed } = await import("./cswap-admin.mjs");
3915
4016
  const [want, wantOrg] = String(step?.key ?? "").split("@@");
3916
4017
  // NO `force`, ever, and this is the line where that promise is kept. A
3917
4018
  // plain import adds an account that is missing and replaces exactly one
@@ -3932,22 +4033,27 @@ const lanEngine = createEngine({
3932
4033
  // `force === true && narrowing` — so the promise above survives word for
3933
4034
  // word, and a bundle that does not carry what was asked for is refused
3934
4035
  // rather than unpacked.
3935
- const out = await importAccount(blob, { only: { email: want, org: wantOrg ?? "" } });
4036
+ const out = await importAccount(blob, { only: { email: want, org: wantOrg ?? "" }, collect: !CHECKS_IMPORTS });
3936
4037
  if (!out?.ok) return { ok: false, why: out?.reason ?? "import refused" };
3937
4038
  // A SKIP IS NOT A HEAL, and reading `ok` alone said it was. `cswap import`
3938
- // exits ZERO when it declines an account it already holds — importAccount's
3939
- // own comment says so and reports it as `added: false` — so a round that
4039
+ // exits ZERO when it declines an account it already holds, so a round that
3940
4040
  // changed nothing was counted as a successful repair and the panel said the
3941
4041
  // account had been fixed. Whoever read that then waited for numbers that
3942
4042
  // were never going to move.
3943
4043
  //
4044
+ // `landed`, NOT `added`: `added` counts new slots only, and a plain import
4045
+ // that replaces a quarantined slot is `healed` — the one repair this path
4046
+ // exists for. Reading `added` reported every such heal as a failure and
4047
+ // then sent it on to fillEmptySlot, which declined it. A slot that stayed
4048
+ // `present` is the decline.
4049
+ //
3944
4050
  // The decline is narrow and documented on claude-swap's side: a plain
3945
4051
  // import replaces a slot only when its usage row is quarantined as
3946
4052
  // refresh-token-dead, and is "never triggered by the live store's
3947
4053
  // `no credentials` state". So an account whose login expired heals over the
3948
4054
  // network, and one that has NO stored login does not — which is a true
3949
4055
  // sentence the panel can now print instead of a false one.
3950
- if (out.added === true) return { ok: true };
4056
+ if (landed(out.results)) return { ok: true };
3951
4057
 
3952
4058
  // THE DECLINE, AND WHY IT MATTERS WHICH ONE IT IS.
3953
4059
  //
@@ -3986,7 +4092,7 @@ const lanEngine = createEngine({
3986
4092
  // verdict taken before any of it began. fillEmptySlot re-reads the verdict
3987
4093
  // as its first statement INSIDE the lock, so the promise holds by
3988
4094
  // construction rather than by how long the queue happened to be.
3989
- return fillEmptySlot(blob, { email: want, org: wantOrg ?? "" });
4095
+ return fillEmptySlot(blob, { email: want, org: wantOrg ?? "", collect: !CHECKS_IMPORTS });
3990
4096
  },
3991
4097
  // The deck's own long-term key, kept so a restart is the same deck rather
3992
4098
  // than a stranger to everybody who has paired with it. Written once, on the
@@ -4117,6 +4223,7 @@ export function lanApplyFields(prefs, { load = false, env = process.env } = {})
4117
4223
  shared: Array.isArray(lan.shared) ? lan.shared : [],
4118
4224
  autoAsk: lan.autoAsk !== false,
4119
4225
  autoAccept: lan.autoAccept !== false,
4226
+ pairingMode: lan.pairingMode === "invite" ? "invite" : "automatic",
4120
4227
  // Whether paired decks are told which shared account this one is on.
4121
4228
  shareActive: lan.shareActive !== false,
4122
4229
  // Discovery over Tailscale, off unless somebody turned it on, and the two
@@ -5875,6 +5982,31 @@ async function handleClaudeFm(req, res) {
5875
5982
  send(res, 200, answer);
5876
5983
  }
5877
5984
 
5985
+ /**
5986
+ * A custom station's YouTube link, resolved to the channel and video the
5987
+ * embed plays (#1208). fm-station.mjs rebuilds the request from the parsed
5988
+ * link rather than fetching it as given, and caches the answer.
5989
+ *
5990
+ * AGENTS_DECK_NO_MUSIC is the promise that this deck never contacts YouTube,
5991
+ * and a station somebody added is not an exception to it: the answer is the
5992
+ * same plain no /api/claude-fm gives, and the canvas draws nothing for it.
5993
+ */
5994
+ async function handleFmStation(req, res) {
5995
+ if (process.env.AGENTS_DECK_NO_MUSIC === "1") {
5996
+ return send(res, 200, { ok: false, off: true });
5997
+ }
5998
+ const url = new URL(req.url, "http://localhost");
5999
+ const stationUrl = url.searchParams.get("url") ?? "";
6000
+ const { parseYouTubeStationUrl, resolveYouTubeStation } = await import(
6001
+ pathToFileURL(join(PKG_ROOT, "src/server/fm-station.mjs")).href
6002
+ );
6003
+ if (!parseYouTubeStationUrl(stationUrl)) {
6004
+ return send(res, 400, { ok: false, error: "unsupported_url" });
6005
+ }
6006
+ const answer = await resolveYouTubeStation(stationUrl);
6007
+ send(res, answer.ok ? 200 : 404, answer);
6008
+ }
6009
+
5878
6010
  async function handleLofiGirl(req, res) {
5879
6011
  if (process.env.AGENTS_DECK_NO_MUSIC === "1") {
5880
6012
  return send(res, 200, { ok: true, live: false, off: true });
@@ -6154,14 +6286,14 @@ function getProjectRollup() {
6154
6286
  * The "Projects" report for one account: how many tokens it spent per project
6155
6287
  * over a window. The server tallies only tokens (per model); the web side
6156
6288
  * prices them with its own table, so cost never lives in two places. `num` is
6157
- * the account's slot, resolved to its `(email, org)` key here; `days` is 7, 30,
6158
- * or 0 for all tracked.
6289
+ * the account's slot, resolved to its `(email, org)` key here; `days` is 1, 7,
6290
+ * 30, or 0 for all tracked.
6159
6291
  */
6160
6292
  async function handleAccountProjects(req, res) {
6161
6293
  const url = new URL(req.url, "http://localhost");
6162
6294
  const num = Number(url.searchParams.get("num"));
6163
6295
  const d = Number(url.searchParams.get("days"));
6164
- const days = d === 30 ? 30 : d === 0 ? 0 : 7;
6296
+ const days = d === 1 ? 1 : d === 30 ? 30 : d === 0 ? 0 : 7;
6165
6297
  if (!Number.isInteger(num) || num <= 0) return send(res, 400, { ok: false, reason: "bad_account" });
6166
6298
  const { identityForSlot } = await import(pathToFileURL(join(PKG_ROOT, "src/server/swap-log.mjs")).href);
6167
6299
  const id = await identityForSlot(num);
@@ -6211,10 +6343,17 @@ async function handleClaudeAccountAdmin(req, res) {
6211
6343
  // `only` names one account inside the pasted bundle, and is the only way
6212
6344
  // `force` is honoured at all - see importAccount for why the pair is
6213
6345
  // required rather than the flag alone.
6214
- case "import": result = await admin.importAccount(parsed.blob, {
6215
- force: parsed.force === true,
6216
- only: parsed.only ?? null,
6217
- }); break;
6346
+ case "import": {
6347
+ const out = await admin.importAccount(parsed.blob, {
6348
+ force: parsed.force === true,
6349
+ only: parsed.only ?? null,
6350
+ collect: !CHECKS_IMPORTS,
6351
+ });
6352
+ // Checked the way a LAN round's arrivals are (#1244): `cswap import`
6353
+ // exiting 0 on a Mac is not proof this process can read what it wrote.
6354
+ result = out?.ok ? { ...out, results: await admin.checkImportResults(out.results) } : out;
6355
+ break;
6356
+ }
6218
6357
  case "remove": result = await admin.removeAccount(parsed.account); break;
6219
6358
  // #721. Re-captures the active slot's credentials in place; see
6220
6359
  // recaptureActive for why this is not a login and cannot become one.
@@ -6270,6 +6409,7 @@ function cswapAutoModule() {
6270
6409
  const PINNED_MODULES = [
6271
6410
  "self-update.mjs",
6272
6411
  "claude-accounts.mjs",
6412
+ "account-health.mjs",
6273
6413
  "cswap-admin.mjs",
6274
6414
  "cswap-auto.mjs",
6275
6415
  "quota.mjs",
@@ -6279,6 +6419,7 @@ const PINNED_MODULES = [
6279
6419
  "browser-watch.mjs",
6280
6420
  "browser-watch-store.mjs",
6281
6421
  "claude-fm.mjs",
6422
+ "fm-station.mjs",
6282
6423
  "lofi-girl.mjs",
6283
6424
  "live-radio-mix.mjs",
6284
6425
  "best-of-nostalgia.mjs",
@@ -7244,6 +7385,13 @@ const GUARDED_READS = new Set([
7244
7385
  // Per-account, per-project token spend — the user's own work, the same class
7245
7386
  // of secret as the accounts list it hangs off.
7246
7387
  "/api/account-projects",
7388
+ // Not a secret, and here for the other reason a read can be dangerous: it is
7389
+ // the one route where the caller names what the deck goes and fetches
7390
+ // (#1208). fm-station.mjs holds that to YouTube, but a page on another site
7391
+ // still had a way to make this process download pages on demand, and the
7392
+ // only caller that needs it is the deck's own canvas, which sends
7393
+ // Sec-Fetch-Site: same-origin on every fetch.
7394
+ "/api/fm-station",
7247
7395
  ]);
7248
7396
 
7249
7397
  function isAuthorizedMutation(req) {
@@ -7554,6 +7702,10 @@ export async function startServer({ port = 4317, host = "127.0.0.1", persist = n
7554
7702
  if (req.method === "GET" && url.pathname === "/api/hook-challenge") return handleHookChallenge(req, res, url);
7555
7703
  if (req.method === "GET" && url.pathname === "/events") return handleSse(req, res);
7556
7704
  if (req.method === "GET" && url.pathname === "/api/version") return guard(handleVersion(req, res), res);
7705
+ if (req.method === "GET" && url.pathname === "/api/desktop-update") return handleDesktopUpdateRead(req, res);
7706
+ if (req.method === "POST" && url.pathname === "/api/desktop-update") return guard(handleDesktopUpdateReport(req, res), res);
7707
+ if (req.method === "POST" && url.pathname === "/api/desktop-update/restart") return guard(handleDesktopUpdateRequest(req, res, "desktop-update-restart"), res);
7708
+ if (req.method === "POST" && url.pathname === "/api/desktop-update/seen") return guard(handleDesktopUpdateRequest(req, res, "desktop-update-seen"), res);
7557
7709
  if (req.method === "POST" && url.pathname === "/api/upgrade") return guard(handleUpgrade(req, res), res);
7558
7710
  if (req.method === "POST" && url.pathname === "/api/restart") return guard(handleRestart(req, res), res);
7559
7711
  if (req.method === "POST" && url.pathname === "/api/presence") return guard(handlePresence(req, res), res);
@@ -7613,6 +7765,7 @@ export async function startServer({ port = 4317, host = "127.0.0.1", persist = n
7613
7765
  if (req.method === "GET" && url.pathname === "/api/cswap-auto") return guard(handleCswapAuto(req, res), res);
7614
7766
  if (req.method === "POST" && url.pathname === "/api/cswap-auto") return guard(handleCswapAutoAction(req, res), res);
7615
7767
  if (req.method === "GET" && url.pathname === "/api/claude-fm") return guard(handleClaudeFm(req, res), res);
7768
+ if (req.method === "GET" && url.pathname === "/api/fm-station") return guard(handleFmStation(req, res), res);
7616
7769
  if (req.method === "GET" && url.pathname === "/api/lofi-girl") return guard(handleLofiGirl(req, res), res);
7617
7770
  if (req.method === "GET" && url.pathname === "/api/live-radio-mix") return guard(handleLiveRadioMix(req, res), res);
7618
7771
  if (req.method === "GET" && url.pathname === "/api/best-of-nostalgia") return guard(handleBestOfNostalgia(req, res), res);