ccdeck 3.44.0 → 3.46.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/README.md +21 -9
  2. package/THIRD_PARTY_NOTICES.md +27 -0
  3. package/bin/cli/startup.js +4 -3
  4. package/dist/web/assets/BrowserWatchModal-CoX9FIUE.js +1 -0
  5. package/dist/web/assets/IntegrationModal-B1ypkK-E.js +5 -0
  6. package/dist/web/assets/RadioPanel-biw_BbZq.js +1 -0
  7. package/dist/web/assets/TrafficRadar-nhtFoYVv.js +8 -0
  8. package/dist/web/assets/{UsageHistoryModal-Du0ZkIJ1.js → UsageHistoryModal-Bd-JRAa4.js} +1 -1
  9. package/dist/web/assets/app-BlRY6EME.css +1 -0
  10. package/dist/web/assets/app-DhGvSH7o.js +319 -0
  11. package/dist/web/assets/{index-BQbOfxbL.js → index-DmENqVdt.js} +1 -1
  12. package/dist/web/index.html +3 -3
  13. package/package.json +1 -1
  14. package/release-notes.json +48 -0
  15. package/src/server/account-notify.mjs +68 -6
  16. package/src/server/account-watch.mjs +61 -2
  17. package/src/server/claude-accounts.mjs +45 -19
  18. package/src/server/claude-login.mjs +111 -0
  19. package/src/server/cswap-admin.mjs +12 -3
  20. package/src/server/deck-prefs.mjs +15 -5
  21. package/src/server/feature-use.mjs +11 -1
  22. package/src/server/forge-http.mjs +514 -0
  23. package/src/server/forge-public-fetch.mjs +228 -0
  24. package/src/server/forge-routes.mjs +154 -0
  25. package/src/server/github-api.mjs +1369 -0
  26. package/src/server/gitlab-api.mjs +1271 -0
  27. package/src/server/index.mjs +6 -2
  28. package/src/server/installer.mjs +24 -7
  29. package/src/server/integrations/cache.mjs +42 -0
  30. package/src/server/integrations/errors.mjs +167 -0
  31. package/src/server/integrations/forge.mjs +76 -0
  32. package/src/server/integrations/jira.mjs +1026 -0
  33. package/src/server/integrations/providers.mjs +11 -0
  34. package/src/server/integrations/proxy.mjs +314 -0
  35. package/src/server/integrations/registry.mjs +142 -0
  36. package/src/server/integrations/routes.mjs +498 -0
  37. package/src/server/integrations/store.mjs +226 -0
  38. package/src/server/lan-deck.mjs +7 -0
  39. package/src/server/lan-elsewhere.d.mts +30 -0
  40. package/src/server/lan-elsewhere.mjs +137 -0
  41. package/src/server/login-service.mjs +2 -2
  42. package/src/server/org-telemetry.mjs +173 -0
  43. package/src/server/pinned-build.mjs +3 -0
  44. package/src/server/request-gates.mjs +19 -0
  45. package/src/server/skill-hook.mjs +28 -22
  46. package/src/server/system-metrics.mjs +4 -0
  47. package/src/server/tool-audit-routes.mjs +4 -0
  48. package/src/server/traffic-radar-config.mjs +33 -0
  49. package/src/server/traffic-radar-platform.mjs +84 -17
  50. package/src/server/traffic-radar-service.mjs +6 -3
  51. package/src/server/traffic-radar.mjs +32 -9
  52. package/src/server/usage-day.mjs +2 -0
  53. package/dist/web/assets/BrowserWatchModal-DMIUygWz.js +0 -1
  54. package/dist/web/assets/RadioPanel-DNB_AHSs.js +0 -1
  55. package/dist/web/assets/TrafficRadar-Ch0LVdSl.js +0 -8
  56. package/dist/web/assets/app-CQIstkMF.js +0 -318
  57. package/dist/web/assets/app-Ch-0CYlK.css +0 -1
@@ -0,0 +1,226 @@
1
+ // The trackers this deck is connected to (#2178): what each connection is, kept
2
+ // in one file, and the credential it uses, kept in another.
3
+ //
4
+ // TWO FILES, AND WHY. `integrations.json` holds what the page may see — the
5
+ // name, the address, which account answered, what the provider learned about
6
+ // the server — and is read for every list. `integrations-secrets.json` holds
7
+ // the tokens and nothing else, is read only when a request to the tracker is
8
+ // about to be signed, and is never serialised into an answer. Splitting them
9
+ // means no code path that returns connections can carry a token by accident:
10
+ // the token is not in the object it returns.
11
+ //
12
+ // There is no keychain anywhere in this deck, so both are plain files in a
13
+ // folder of their own, `integrations/` in the deck's home (deck-home.mjs, which
14
+ // honours CCDECK_HOME), kept at 0700 whatever the home itself is — written the way
15
+ // prefs.json and Codex's auth.json are: a fresh temp file created 0600 with
16
+ // O_EXCL, fsynced, chmod'ed, then renamed over the old one with the retrying
17
+ // rename Windows needs. writeFileAtomic is deliberately not used: it creates a
18
+ // new file at 0666 minus the umask, which is world-readable on most machines.
19
+ //
20
+ // THE SECRET IS OPAQUE. A string today (a Jira token), an object tomorrow (an
21
+ // OAuth access/refresh pair). Nothing in this file looks inside it; a provider
22
+ // decides what it is and the proxy hands it back to the provider to sign with.
23
+ //
24
+ // Nothing here is shared with paired decks: the LAN exchange is an explicit
25
+ // allowlist (lan-manifest.mjs, lan-copies.mjs) and prefs never sync, so a
26
+ // connection made on this machine stays on this machine.
27
+ import { randomUUID } from "node:crypto";
28
+ import { chmod, mkdir, readFile, rename, unlink } from "node:fs/promises";
29
+ import { join } from "node:path";
30
+ import { createTemp, renameWithRetry } from "../atomic-write.mjs";
31
+ import { deckDataDir } from "../deck-home.mjs";
32
+ import { PRODUCT } from "../brand.mjs";
33
+
34
+ export const META_FILE = "integrations.json";
35
+ export const SECRETS_FILE = "integrations-secrets.json";
36
+ const FILE_MODE = 0o600;
37
+ const DIR_MODE = 0o700;
38
+ /** More than anybody connects by hand; a bound on what a list answer costs. */
39
+ export const MAX_CONNECTIONS = 50;
40
+ /** The folder both files live in, inside the deck's home. */
41
+ export const STORE_DIR = "integrations";
42
+
43
+ const text = (v, max = 500) => (typeof v === "string" ? v.slice(0, max) : "");
44
+
45
+ /** A connection as it is kept: every field a known type, nothing else. */
46
+ function normaliseConnection(raw) {
47
+ if (!raw || typeof raw !== "object" || typeof raw.id !== "string" || typeof raw.kind !== "string") return null;
48
+ const account = raw.account && typeof raw.account === "object" ? raw.account : {};
49
+ const origins = Array.isArray(raw.allowedOrigins) ? raw.allowedOrigins.filter(o => typeof o === "string").slice(0, 8) : [];
50
+ return {
51
+ id: text(raw.id, 64),
52
+ kind: text(raw.kind, 32),
53
+ name: text(raw.name, 120),
54
+ baseUrl: text(raw.baseUrl, 500),
55
+ account: {
56
+ id: text(account.id, 200),
57
+ login: text(account.login, 200),
58
+ name: text(account.name, 200),
59
+ avatarUrl: text(account.avatarUrl, 1000),
60
+ },
61
+ meta: raw.meta && typeof raw.meta === "object" && !Array.isArray(raw.meta) ? raw.meta : {},
62
+ allowedOrigins: origins,
63
+ createdAt: text(raw.createdAt, 40),
64
+ updatedAt: text(raw.updatedAt, 40),
65
+ };
66
+ }
67
+
68
+ /**
69
+ * @param {object} [opts]
70
+ * @param {() => string} [opts.dir] the directory both files live in; the deck's home by default
71
+ * @param {(msg: string) => void} [opts.warn]
72
+ */
73
+ export function createIntegrationsStore({ dir = () => join(deckDataDir(), STORE_DIR), warn = msg => console.warn(msg) } = {}) {
74
+ /** Every write, one at a time, so two pages saving at once cannot lose one. */
75
+ let chain = Promise.resolve();
76
+ const queued = job => {
77
+ const run = chain.then(job, job);
78
+ chain = run.catch(() => {});
79
+ return run;
80
+ };
81
+
82
+ async function readJson(path, label) {
83
+ let raw;
84
+ try { raw = await readFile(path, "utf8"); }
85
+ catch (err) {
86
+ if (err?.code === "ENOENT") return null;
87
+ throw err;
88
+ }
89
+ try { return JSON.parse(raw.replace(/^/, "")); }
90
+ catch {
91
+ // Kept aside rather than overwritten: a damaged secrets file is still
92
+ // the user's tokens, and the next save would otherwise replace it with
93
+ // an empty one. Its contents are never logged.
94
+ const aside = `${path}.corrupt-${Date.now()}`;
95
+ await rename(path, aside).catch(() => {});
96
+ warn(`${PRODUCT}: ${label} could not be read as JSON and was moved aside to ${aside}.`);
97
+ return null;
98
+ }
99
+ }
100
+
101
+ async function load() {
102
+ const home = dir();
103
+ const meta = await readJson(join(home, META_FILE), META_FILE);
104
+ const secrets = await readJson(join(home, SECRETS_FILE), SECRETS_FILE);
105
+ const connections = (Array.isArray(meta?.connections) ? meta.connections : [])
106
+ .map(normaliseConnection).filter(Boolean).slice(0, MAX_CONNECTIONS);
107
+ const ids = new Set(connections.map(c => c.id));
108
+ const kept = secrets?.secrets && typeof secrets.secrets === "object" ? secrets.secrets : {};
109
+ // A secret whose connection is gone is dropped at the next write: a
110
+ // disconnect that half-failed must not leave a token behind for ever.
111
+ const secretMap = new Map(Object.entries(kept).filter(([id, value]) => ids.has(id) && value != null));
112
+ return { connections, secrets: secretMap };
113
+ }
114
+
115
+ /** The safe write: temp at 0600 with O_EXCL, fsync, chmod, rename. */
116
+ async function writeFile(name, value) {
117
+ const home = dir();
118
+ await mkdir(home, { recursive: true, mode: DIR_MODE });
119
+ // Made 0700 by mkdir only when it did not exist yet; held there on every
120
+ // write, so a folder someone loosened does not stay loose.
121
+ if (process.platform !== "win32") await chmod(home, DIR_MODE).catch(() => {});
122
+ const target = join(home, name);
123
+ const { tmp, handle } = await createTemp(target, { mode: FILE_MODE });
124
+ let landed = false;
125
+ try {
126
+ try {
127
+ await handle.writeFile(JSON.stringify(value, null, 2) + "\n", "utf8");
128
+ await handle.sync();
129
+ } finally {
130
+ await handle.close();
131
+ }
132
+ await chmod(tmp, FILE_MODE);
133
+ await renameWithRetry(tmp, target);
134
+ landed = true;
135
+ } finally {
136
+ // A temp left behind may hold tokens in cleartext.
137
+ if (!landed) await unlink(tmp).catch(() => {});
138
+ }
139
+ }
140
+
141
+ const writeMeta = connections => writeFile(META_FILE, { version: 1, connections });
142
+ const writeSecrets = map => writeFile(SECRETS_FILE, { version: 1, secrets: Object.fromEntries(map) });
143
+
144
+ /** What a page may see of a connection: everything but the secret, and
145
+ * whether one is saved. */
146
+ const publicOf = (conn, secrets) => ({ ...conn, tokenSaved: secrets.has(conn.id) });
147
+
148
+ return {
149
+ /** Every connection, as a page may see it. `own(conn, secret)`, when
150
+ * given, is handed each connection that has a secret, with it, and
151
+ * answers the connection to list: the routes take that secret out of what
152
+ * an earlier build kept (routes.mjs). */
153
+ async list({ own } = {}) {
154
+ const { connections, secrets } = await load();
155
+ return connections.map(c => publicOf(own && secrets.has(c.id) ? own(c, secrets.get(c.id)) : c, secrets));
156
+ },
157
+
158
+ /** One connection, as a page may see it, or null. */
159
+ async get(id) {
160
+ const { connections, secrets } = await load();
161
+ const conn = connections.find(c => c.id === id);
162
+ return conn ? publicOf(conn, secrets) : null;
163
+ },
164
+
165
+ /** The opaque secret for one connection, or null. The ONE read of the
166
+ * secrets file, and its only caller is the proxy that signs requests. */
167
+ async secret(id) {
168
+ const { secrets } = await load();
169
+ return secrets.get(id) ?? null;
170
+ },
171
+
172
+ /** Add a connection with its secret. Answers it as a page may see it. */
173
+ create(fields, secret) {
174
+ return queued(async () => {
175
+ const { connections, secrets } = await load();
176
+ if (connections.length >= MAX_CONNECTIONS) throw new Error("too many connections");
177
+ const now = new Date().toISOString();
178
+ const conn = normaliseConnection({ ...fields, id: randomUUID(), createdAt: now, updatedAt: now });
179
+ // The secret first: if the second write fails the connection does not
180
+ // exist, and the orphaned secret is dropped by the next write.
181
+ if (secret != null) {
182
+ secrets.set(conn.id, secret);
183
+ await writeSecrets(secrets);
184
+ }
185
+ await writeMeta([...connections, conn]);
186
+ return publicOf(conn, secrets);
187
+ });
188
+ },
189
+
190
+ /** Change a connection. `secret` undefined keeps the saved one. */
191
+ update(id, patch, secret) {
192
+ return queued(async () => {
193
+ const { connections, secrets } = await load();
194
+ const at = connections.findIndex(c => c.id === id);
195
+ if (at < 0) return null;
196
+ const prev = connections[at];
197
+ const next = normaliseConnection({ ...prev, ...patch, id: prev.id, kind: prev.kind, createdAt: prev.createdAt, updatedAt: new Date().toISOString() });
198
+ if (secret !== undefined) {
199
+ if (secret === null) secrets.delete(id); else secrets.set(id, secret);
200
+ await writeSecrets(secrets);
201
+ }
202
+ const list = connections.slice();
203
+ list[at] = next;
204
+ await writeMeta(list);
205
+ return publicOf(next, secrets);
206
+ });
207
+ },
208
+
209
+ /** Forget a connection and its secret. True when there was one. */
210
+ remove(id) {
211
+ return queued(async () => {
212
+ const { connections, secrets } = await load();
213
+ const had = connections.some(c => c.id === id);
214
+ // The secret goes first: a disconnect that fails halfway leaves a
215
+ // connection with no token (visible, and Disconnect can be pressed
216
+ // again), never a token with no connection.
217
+ if (secrets.delete(id) || had) await writeSecrets(secrets);
218
+ if (had) await writeMeta(connections.filter(c => c.id !== id));
219
+ return had;
220
+ });
221
+ },
222
+ };
223
+ }
224
+
225
+ /** The deck's own store, in its home. */
226
+ export const integrationsStore = createIntegrationsStore();
@@ -31,6 +31,9 @@ import { run } from "./exec.mjs";
31
31
  // A LAN round checks the imports it lands the way the paste box does.
32
32
  import { CHECKS_IMPORTS } from "./account-routes.mjs";
33
33
  import { RUNNING_VERSION } from "./running-version.mjs";
34
+ // Which of this deck's accounts a paired deck is working on, for the account
35
+ // notifier, which reaches the engine's status through it (#2192).
36
+ import { connectLanStatus } from "./lan-elsewhere.mjs";
34
37
 
35
38
  // ── LAN sync ────────────────────────────────────────────────────────────────
36
39
  //
@@ -301,6 +304,10 @@ export const lanEngine = createEngine({
301
304
  },
302
305
  });
303
306
 
307
+ // The status the account notifier reads "which deck is on what" from — see
308
+ // lan-elsewhere.mjs, which says why it does not import this file for it.
309
+ connectLanStatus(() => lanEngine.status());
310
+
304
311
  /**
305
312
  * What prefs is entitled to tell the LAN engine, and what it is not.
306
313
  *
@@ -0,0 +1,30 @@
1
+ // The page's view of lan-elsewhere.mjs: the rule the accounts' rows read, the
2
+ // same one the notifier reads on the server.
3
+
4
+ /** The parts of a status row the rule reads — the engine's and the page's
5
+ * status rows are both this and more. */
6
+ export interface ElsewherePeer {
7
+ fp: string;
8
+ peerFp?: string | null;
9
+ name?: string;
10
+ addr?: string;
11
+ paired?: boolean;
12
+ offers?: { at: number; current?: { key?: string } | { hidden: true } | { other: true } | null } | null;
13
+ }
14
+
15
+ export interface ElsewhereStatus<P extends ElsewherePeer = ElsewherePeer> {
16
+ enabled?: boolean;
17
+ peers?: P[];
18
+ addrs?: string[];
19
+ aliases?: Record<string, string>;
20
+ }
21
+
22
+ export const ELSEWHERE_FRESH_MS: number;
23
+ export function decksByAccount<P extends ElsewherePeer>(
24
+ status: ElsewhereStatus<P> | null | undefined, now: number, freshMs?: number,
25
+ ): Map<string, P[]>;
26
+ export function deckName(p: ElsewherePeer, aliases?: Record<string, string>): string;
27
+ export function namesByAccount(status: ElsewhereStatus | null | undefined, now: number, freshMs?: number): Map<string, string[]>;
28
+ export function deckList(names: readonly string[]): string;
29
+ export function connectLanStatus(status: (() => unknown) | null): void;
30
+ export function elsewhereNow(now?: number): Map<string, string[]>;
@@ -0,0 +1,137 @@
1
+ // Which of this deck's accounts another deck is working on right now (#2192).
2
+ //
3
+ // ONE LOGIN, LIVE IN ONE PLACE. A Claude refresh token is good for one renewal:
4
+ // the copy that renews it first gets a new one, and every other copy of that
5
+ // login is refused with invalid_grant from then on. Claude Code answers that by
6
+ // wiping the stored login, and every session on that machine stops with "Login
7
+ // expired · Please run /login". Two machines that hold one copied login hold one
8
+ // expiry too, so both reach for the renewal within minutes of each other and
9
+ // the slower one loses. Measured on one pair of decks: three of seven
10
+ // interruptions in a month landed on the 8-hour renewal with nothing on that
11
+ // machine renewing in between.
12
+ //
13
+ // Local network already says which account each paired deck is on (`current`
14
+ // in its manifest — see currentFor in lan-copies.mjs), so this needs nothing new
15
+ // on the wire: it reads what the status already holds. It is the one rule the
16
+ // row's warning and the notification both ask, so the two cannot disagree
17
+ // about which deck is on what. The page reads it through lan-elsewhere.d.mts;
18
+ // the server through the door at the bottom, which lan-deck.mjs connects to its
19
+ // engine.
20
+ //
21
+ // IT WARNS AND DOES NOTHING ELSE. The login a deck is on already stays on that
22
+ // deck (manifestFor marks it `inUse` and `serve` refuses it), and Auto-switch
23
+ // may still move to a login a paired deck is on (3.43.3). What is left is a
24
+ // copy a paired deck held before this deck went live on it — which only the
25
+ // two decks saying where they are can show, and only a person can settle. A
26
+ // deck that hides which account it is on, or is on one it does not share with
27
+ // this one, says nothing here, and nothing is guessed in its place.
28
+ //
29
+ // Pure apart from that door. No sockets, no timers, no filesystem.
30
+
31
+ /**
32
+ * How recent a deck's word has to be for "it is on this account right now".
33
+ *
34
+ * A paired deck says which account it is on in every manifest, and a manifest
35
+ * crosses at least once a minute each way — this deck's round asks it, and its
36
+ * round carries one in. Five minutes is five rounds: a dropped round or two does
37
+ * not take the warning down and put it back, which would read as two incidents,
38
+ * and a deck that was switched off stops counting soon after. Past it, what the
39
+ * deck said is its last word rather than its current one.
40
+ */
41
+ export const ELSEWHERE_FRESH_MS = 5 * 60_000;
42
+
43
+ /** An address that is this machine: loopback, or one of its own. A second deck
44
+ * on this machine shares its Claude login rather than copying it, so it is not
45
+ * another place the login is live. */
46
+ function onThisMachine(addr, mine) {
47
+ if (typeof addr !== "string" || !addr) return false;
48
+ if (addr === "::1" || addr === "localhost" || addr.startsWith("127.")) return true;
49
+ return Array.isArray(mine) && mine.includes(addr);
50
+ }
51
+
52
+ /**
53
+ * The paired decks that said, within `freshMs`, that they are working on one of
54
+ * the accounts they share — keyed by account, one row per deck.
55
+ *
56
+ * ONLY WHAT A DECK SAID, AND ONLY RECENTLY. A deck names the account it is on
57
+ * only when it shares that account and its owner left "Show paired decks which
58
+ * of these this deck is using" on; `hidden`, `other`, nothing at all (a deck
59
+ * older than 3.22.15 never says) and a word older than `freshMs` all prove
60
+ * nothing, and none of them is drawn as a deck on an account. A deck that is
61
+ * not paired any more is not counted either: what it said was said to a deck
62
+ * that no longer listens.
63
+ *
64
+ * `status` is the engine's status() — or the page's copy of it, the same shape.
65
+ */
66
+ export function decksByAccount(status, now, freshMs = ELSEWHERE_FRESH_MS) {
67
+ const out = new Map();
68
+ if (!status?.enabled) return out;
69
+ const seen = new Set();
70
+ for (const p of Array.isArray(status.peers) ? status.peers : []) {
71
+ if (!p || p.paired !== true) continue;
72
+ const said = p.offers;
73
+ const key = said?.current && typeof said.current.key === "string" ? said.current.key : "";
74
+ if (!key || typeof said.at !== "number" || !Number.isFinite(said.at)) continue;
75
+ // Within the window, and not from the future: a clock that ran ahead is not
76
+ // a promise about the next five minutes.
77
+ if (now - said.at > freshMs || said.at - now > freshMs) continue;
78
+ if (onThisMachine(p.addr, status.addrs)) continue;
79
+ // One deck reached two ways is merged into one row by the engine already;
80
+ // the same deck under the same key twice would still be one deck.
81
+ const id = `${key}|${p.peerFp ?? p.fp}`;
82
+ if (seen.has(id)) continue;
83
+ seen.add(id);
84
+ out.set(key, [...(out.get(key) ?? []), p]);
85
+ }
86
+ return out;
87
+ }
88
+
89
+ /** A deck as a sentence names it: the name somebody here gave it, else its own. */
90
+ export function deckName(p, aliases) {
91
+ const id = p?.peerFp ?? p?.fp;
92
+ const given = aliases && typeof aliases === "object" && typeof aliases[id] === "string" ? aliases[id].trim() : "";
93
+ const own = typeof p?.name === "string" ? p.name.trim() : "";
94
+ return given || own || "another deck";
95
+ }
96
+
97
+ /** The same answer as names: account key to the decks on it, each named once
98
+ * and in order, so one machine reached under two keys is one name. */
99
+ export function namesByAccount(status, now, freshMs = ELSEWHERE_FRESH_MS) {
100
+ const out = new Map();
101
+ for (const [key, rows] of decksByAccount(status, now, freshMs)) {
102
+ const names = [...new Set(rows.map(p => deckName(p, status?.aliases)))].sort((a, b) => a.localeCompare(b));
103
+ out.set(key, names);
104
+ }
105
+ return out;
106
+ }
107
+
108
+ /** "iMac", "iMac and PC", "iMac, Mac and PC". */
109
+ export function deckList(names) {
110
+ if (names.length <= 1) return names[0] ?? "another deck";
111
+ return `${names.slice(0, -1).join(", ")} and ${names[names.length - 1]}`;
112
+ }
113
+
114
+ // ── the door ────────────────────────────────────────────────────────────────
115
+ //
116
+ // The notifier needs the engine's status, and the engine lives in lan-deck.mjs,
117
+ // which imports far more than the notifier should. So lan-deck.mjs hands its
118
+ // status() in here as it loads, and the notifier asks here. Until it does — a
119
+ // test that runs the notifier on its own — the answer is that no deck is on
120
+ // anything, which is what a deck with Local network off would say too.
121
+
122
+ let _status = null;
123
+
124
+ /** lan-deck.mjs's, once, as it loads. */
125
+ export function connectLanStatus(status) {
126
+ _status = typeof status === "function" ? status : null;
127
+ }
128
+
129
+ /** namesByAccount over the engine's status as it is now. Never throws: a
130
+ * status that could not be read is a deck that knows of nobody. */
131
+ export function elsewhereNow(now = Date.now()) {
132
+ try {
133
+ return _status ? namesByAccount(_status(), now) : new Map();
134
+ } catch {
135
+ return new Map();
136
+ }
137
+ }
@@ -403,8 +403,8 @@ export const SETTING_VARS = Object.freeze([
403
403
  "AGENTS_DECK_NO_INSTALL", "AGENTS_DECK_NO_DOWNLOAD", "AGENTS_DECK_NO_UPDATE_CHECK", "AGENTS_DECK_NO_STATUS",
404
404
  "AGENTS_DECK_NO_FRESHEN", "AGENTS_DECK_NO_NOTIFY", "AGENTS_DECK_NO_LAN", "AGENTS_DECK_NO_REPORTS",
405
405
  "AGENTS_DECK_CSWAP", "AGENTS_DECK_CLAUDE", "AGENTS_DECK_CCUSAGE", "CLAUDE_SWAP_BACKUP", "AGENTS_DECK_LHM_PORT",
406
- // The opt-in for Skill activity's typed-command hook (skill-hook.mjs). A deck
407
- // started at login without it would take the entry back out.
406
+ // The off switch for Skill activity's typed-command hook (skill-hook.mjs). A
407
+ // deck started at login without it would put the entry back in.
408
408
  "AGENTS_DECK_SKILL_HOOK",
409
409
  ]);
410
410
 
@@ -0,0 +1,173 @@
1
+ // What each Claude organization's settings send, remembered per organization.
2
+ //
3
+ // A Team or Enterprise organization can turn Claude Code's OpenTelemetry export
4
+ // on for every member: the admin sets it in claude.ai, and Claude Code fetches
5
+ // it at startup and hourly into <config dir>/remote-settings.json. There is ONE
6
+ // such file per machine, and it describes whichever organization's login last
7
+ // fetched it. claude-swap does not touch it on a switch, so for a while after a
8
+ // switch it still describes the account the user just left, until a Claude
9
+ // session on the new account fetches its own.
10
+ //
11
+ // So the deck cannot read an account's telemetry off the file whenever it
12
+ // likes. It reads it when the file can be attributed, and remembers the answer
13
+ // under the organization's uuid, for the accounts panel to show on every row of
14
+ // that organization — the inactive ones included, which is the point: the
15
+ // panel can say before a switch what the switch will turn on.
16
+ //
17
+ // WHEN THE FILE IS AN ORGANIZATION'S. Both of these, or nothing is recorded:
18
+ // - it was written after claude-swap last changed its store (sequence.json's
19
+ // `lastUpdated`), so it was fetched by the account active now;
20
+ // - the live login (`oauthAccount` in Claude Code's own .claude.json) belongs
21
+ // to the same organization as claude-swap's active account, so a /login
22
+ // made outside claude-swap is not mistaken for the store's account.
23
+ // Anything else is left unknown rather than guessed: telling a user an account
24
+ // is unmonitored because the file was another organization's is the one
25
+ // mistake this must not make. `lastUpdated` also moves on an alias or an add,
26
+ // which can only make an answer wait for the next hourly fetch.
27
+ //
28
+ // Read only when the file has changed since it was last looked at. The roster
29
+ // is polled every fifteen seconds, the file changes at most hourly, and
30
+ // .claude.json can be large; one stat per poll is the whole steady-state cost.
31
+ import { mkdir, readFile, stat } from "node:fs/promises";
32
+ import { homedir } from "node:os";
33
+ import { posix, win32 } from "node:path";
34
+ import { claudeConfigDir } from "./claude-dir.mjs";
35
+ import { deckDataDir } from "./deck-home.mjs";
36
+ import { writeFileAtomic } from "./atomic-write.mjs";
37
+ import { radarVariables } from "./traffic-radar-config.mjs";
38
+
39
+ /** The flags that put content, not only counts, into the export. */
40
+ export const CONTENT_KEYS = Object.freeze([
41
+ "OTEL_LOG_USER_PROMPTS", "OTEL_LOG_ASSISTANT_RESPONSES", "OTEL_LOG_TOOL_DETAILS",
42
+ "OTEL_LOG_TOOL_CONTENT", "OTEL_LOG_RAW_API_BODIES",
43
+ ]);
44
+
45
+ const RECORDS_FILE = "org-telemetry.json";
46
+
47
+ /**
48
+ * What one settings file turns on, from Radar's own reading of it (so endpoint
49
+ * paths and credentials are already stripped): `level` is "content" when a
50
+ * content flag is on beside telemetry, "metrics" for telemetry alone, and "off"
51
+ * when the file does not enable it. Content flags without telemetry send
52
+ * nothing, so they do not count.
53
+ */
54
+ export function telemetrySummary(variables) {
55
+ const on = key => variables.some(v => v.key === key && String(v.value).startsWith("Enabled"));
56
+ const telemetry = on("CLAUDE_CODE_ENABLE_TELEMETRY");
57
+ const content = telemetry ? CONTENT_KEYS.filter(on) : [];
58
+ const destinations = [...new Set(variables
59
+ .filter(v => v.key.endsWith("ENDPOINT") && /^https?:\/\//.test(v.value))
60
+ .map(v => v.value))];
61
+ return { level: !telemetry ? "off" : content.length ? "content" : "metrics", content, destinations };
62
+ }
63
+
64
+ /** Claude Code's own state file: beside the home directory, or inside a
65
+ * moved config directory. */
66
+ function claudeStatePath(env, home, platform) {
67
+ const { join } = platform === "win32" ? win32 : posix;
68
+ return env.CLAUDE_CONFIG_DIR?.trim() ? join(claudeConfigDir(env, home, platform), ".claude.json") : join(home, ".claude.json");
69
+ }
70
+
71
+ async function readJson(path, read) {
72
+ try { return JSON.parse(String(await read(path, "utf8")).replace(/^/, "")); } catch { return null; }
73
+ }
74
+
75
+ let _records = null;
76
+ let _examined = null; // mtime of the remote-settings.json last looked at
77
+
78
+ /** The content flags by the words a person would use for them. The page has
79
+ * its own copy in org-telemetry.ts, for the row. */
80
+ export const CONTENT_WORDS = Object.freeze({
81
+ OTEL_LOG_USER_PROMPTS: "prompts",
82
+ OTEL_LOG_ASSISTANT_RESPONSES: "responses",
83
+ OTEL_LOG_TOOL_DETAILS: "tool details",
84
+ OTEL_LOG_TOOL_CONTENT: "tool output",
85
+ OTEL_LOG_RAW_API_BODIES: "full API bodies",
86
+ });
87
+
88
+ /** "a, b and c". */
89
+ function listed(words) {
90
+ return words.length <= 1 ? words.join("") : `${words.slice(0, -1).join(", ")} and ${words[words.length - 1]}`;
91
+ }
92
+
93
+ /**
94
+ * "Acme's telemetry is on for new sessions: it sends your prompts and tool
95
+ * details to 192.0.2.16:4317" — what a switch to an account of this
96
+ * organization turns on, for the swap notice to carry. Null when nothing is
97
+ * known to be on: settings seen off, or never seen.
98
+ */
99
+ export function telemetryClause(record) {
100
+ if (!record || (record.level !== "metrics" && record.level !== "content")) return null;
101
+ const words = record.level === "content" ? (record.content ?? []).map(k => CONTENT_WORDS[k] ?? k) : [];
102
+ const what = words.length ? `your ${listed(words)}` : "usage data";
103
+ const hosts = (record.destinations ?? []).map(d => String(d).replace(/^https?:\/\//, ""));
104
+ const who = record.orgName ? `${record.orgName}'s telemetry` : "Its organization's telemetry";
105
+ return `${who} is on for new sessions: it sends ${what}${hosts.length ? ` to ${listed(hosts)}` : ""}`;
106
+ }
107
+
108
+ /** Every organization's record as last saved, without looking at the settings
109
+ * file: for a reader that has no store to pin a new reading on. */
110
+ export async function knownOrgTelemetry({
111
+ env = process.env, home = homedir(), platform = process.platform,
112
+ dataDir = deckDataDir(platform, env, home), read = readFile,
113
+ } = {}) {
114
+ if (_records == null) {
115
+ const { join } = platform === "win32" ? win32 : posix;
116
+ const saved = await readJson(join(dataDir, RECORDS_FILE), read);
117
+ _records = saved && typeof saved === "object" && !Array.isArray(saved) ? saved : {};
118
+ }
119
+ return _records;
120
+ }
121
+
122
+ /** Forget everything held in memory. For the suite. */
123
+ export function resetOrgTelemetry() { _records = null; _examined = null; }
124
+
125
+ /**
126
+ * Look at remote-settings.json if it changed, record it under its organization
127
+ * when it can be attributed (see the header), and answer every organization's
128
+ * record: uuid → `{ orgName, level, content, destinations, seenAt, writtenAt }`.
129
+ *
130
+ * `seq` is claude-swap's sequence.json as the roster already read it. Never
131
+ * throws: a record that could not be made is a record not made.
132
+ */
133
+ export async function observeOrgTelemetry({
134
+ seq,
135
+ env = process.env, home = homedir(), platform = process.platform, now = Date.now(),
136
+ dataDir = deckDataDir(platform, env, home),
137
+ statFile = stat, read = readFile, write = writeFileAtomic, makeDir = mkdir,
138
+ } = {}) {
139
+ const { join } = platform === "win32" ? win32 : posix;
140
+ const recordsPath = join(dataDir, RECORDS_FILE);
141
+ if (_records == null) {
142
+ const saved = await readJson(recordsPath, read);
143
+ _records = saved && typeof saved === "object" && !Array.isArray(saved) ? saved : {};
144
+ }
145
+ try {
146
+ const remote = join(claudeConfigDir(env, home, platform), "remote-settings.json");
147
+ const mtime = (await statFile(remote)).mtimeMs;
148
+ if (mtime === _examined) return _records;
149
+ _examined = mtime;
150
+
151
+ const active = seq?.accounts?.[String(seq?.activeAccountNumber)];
152
+ const switchedAt = Date.parse(seq?.lastUpdated ?? "");
153
+ if (!active?.organizationUuid || !Number.isFinite(switchedAt) || mtime <= switchedAt) return _records;
154
+ const live = (await readJson(claudeStatePath(env, home, platform), read))?.oauthAccount;
155
+ if (!live || live.organizationUuid !== active.organizationUuid) return _records;
156
+
157
+ const settings = await readJson(remote, read);
158
+ if (!settings) return _records;
159
+ _records = {
160
+ ..._records,
161
+ [active.organizationUuid]: {
162
+ orgName: live.organizationName ?? active.organizationName ?? null,
163
+ ...telemetrySummary(radarVariables(settings, "Cached organization settings")),
164
+ seenAt: now,
165
+ writtenAt: Math.round(mtime),
166
+ },
167
+ };
168
+ await makeDir(dataDir, { recursive: true })
169
+ .then(() => write(recordsPath, `${JSON.stringify(_records, null, 2)}\n`))
170
+ .catch(() => {});
171
+ } catch { /* no file, or one that could not be read: nothing new to say */ }
172
+ return _records;
173
+ }
@@ -83,6 +83,9 @@ const PINNED_MODULES = [
83
83
  // import() from the report's route.
84
84
  "account-projects.mjs",
85
85
  "swap-log.mjs",
86
+ // account-watch.mjs reads what each organization's telemetry was last seen
87
+ // to turn on, for the swap notice, only once a tick has switched.
88
+ "org-telemetry.mjs",
86
89
  "lan-sync.mjs",
87
90
  // The providers' status pages (#1311), reached only from their route.
88
91
  "provider-status.mjs",
@@ -477,8 +477,27 @@ export const GUARDED_READS = new Set([
477
477
  "/api/fm-station",
478
478
  "/api/radio-stations",
479
479
  "/api/radio-click",
480
+ // The trackers this deck is connected to (#2178): the list of connections
481
+ // with their accounts, and below it every read a provider adds.
482
+ "/api/integrations",
480
483
  ]);
481
484
 
485
+ /** Whole namespaces of guarded reads, for routes whose path carries an id —
486
+ * /api/integrations/<kind>/<connId>/… (#2178). A connected tracker's boards,
487
+ * issues, comments and the images the asset route signs for are the user's
488
+ * work, read with the user's own token; every one of them is fetched by the
489
+ * deck's own page and nothing else. A prefix rather than a list because a
490
+ * provider adds its routes without touching this file, and a read it adds
491
+ * must be covered from the moment it exists. */
492
+ export const GUARDED_READ_PREFIXES = Object.freeze(["/api/integrations/"]);
493
+
494
+ /** Whether a GET to `pathname` must come from the deck's page or carry the
495
+ * token: listed in GUARDED_READS, or under one of GUARDED_READ_PREFIXES. */
496
+ export function isGuardedRead(pathname) {
497
+ const path = String(pathname ?? "");
498
+ return GUARDED_READS.has(path) || GUARDED_READ_PREFIXES.some(prefix => path.startsWith(prefix));
499
+ }
500
+
482
501
  export function isAuthorizedMutation(req) {
483
502
  const headers = req?.headers ?? {};
484
503
  if (presentsDeckToken(headers)) return true;