ccdeck 3.43.4 → 3.45.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 (97) hide show
  1. package/README.md +79 -18
  2. package/THIRD_PARTY_NOTICES.md +70 -7
  3. package/bin/cli/commands.js +613 -0
  4. package/bin/cli/help.js +26 -3
  5. package/bin/cli/one-shot.js +2 -2
  6. package/bin/cli/picker.js +86 -0
  7. package/bin/cli/startup.js +13 -0
  8. package/bin/deck.js +16 -1
  9. package/dist/web/assets/BrowserWatchModal-Cbzf3TWa.js +1 -0
  10. package/dist/web/assets/IntegrationModal-BnnBr0a3.js +5 -0
  11. package/dist/web/assets/RadioPanel-BIqrMLQN.js +1 -0
  12. package/dist/web/assets/TrafficRadar-D1gZCIwX.js +8 -0
  13. package/dist/web/assets/UsageHistoryModal-DAhplVju.js +1 -0
  14. package/dist/web/assets/app-BW3NYUYB.js +318 -0
  15. package/dist/web/assets/app-C5RY_wZ4.css +1 -0
  16. package/dist/web/assets/{index-Ch4ks76J.js → index-r8Ymr47Q.js} +1 -1
  17. package/dist/web/index.html +3 -3
  18. package/package.json +2 -1
  19. package/release-notes.json +52 -0
  20. package/src/server/account-notify.mjs +268 -9
  21. package/src/server/account-pick.mjs +160 -0
  22. package/src/server/account-projects.mjs +55 -15
  23. package/src/server/account-routes.mjs +47 -4
  24. package/src/server/account-watch.mjs +107 -4
  25. package/src/server/activity.mjs +3 -2
  26. package/src/server/args.mjs +112 -2
  27. package/src/server/autoswitch-failover.mjs +138 -0
  28. package/src/server/claude-accounts.mjs +127 -16
  29. package/src/server/claude-dir.mjs +142 -5
  30. package/src/server/claude-identity.mjs +23 -0
  31. package/src/server/cli-lines.mjs +224 -0
  32. package/src/server/codex-cost-usage.mjs +67 -0
  33. package/src/server/codex-enrichment.mjs +53 -9
  34. package/src/server/codex-review-policy.d.mts +4 -0
  35. package/src/server/codex-review-policy.mjs +11 -0
  36. package/src/server/codex-skills.mjs +146 -0
  37. package/src/server/codex-watch.mjs +5 -2
  38. package/src/server/cswap-auto-choice.mjs +53 -0
  39. package/src/server/cswap-auto-loop.mjs +100 -9
  40. package/src/server/deck-client.mjs +112 -0
  41. package/src/server/deck-prefs.mjs +26 -5
  42. package/src/server/event-pipeline.mjs +19 -3
  43. package/src/server/event-routes.mjs +15 -0
  44. package/src/server/exec.mjs +6 -2
  45. package/src/server/feature-use.mjs +11 -1
  46. package/src/server/forge-http.mjs +514 -0
  47. package/src/server/forge-public-fetch.mjs +228 -0
  48. package/src/server/forge-routes.mjs +154 -0
  49. package/src/server/github-api.mjs +1369 -0
  50. package/src/server/gitlab-api.mjs +1271 -0
  51. package/src/server/index.mjs +23 -3
  52. package/src/server/installer.mjs +149 -8
  53. package/src/server/integrations/cache.mjs +42 -0
  54. package/src/server/integrations/errors.mjs +167 -0
  55. package/src/server/integrations/forge.mjs +76 -0
  56. package/src/server/integrations/jira.mjs +1026 -0
  57. package/src/server/integrations/providers.mjs +11 -0
  58. package/src/server/integrations/proxy.mjs +314 -0
  59. package/src/server/integrations/registry.mjs +142 -0
  60. package/src/server/integrations/routes.mjs +498 -0
  61. package/src/server/integrations/store.mjs +226 -0
  62. package/src/server/lan-deck.mjs +7 -0
  63. package/src/server/lan-elsewhere.d.mts +30 -0
  64. package/src/server/lan-elsewhere.mjs +137 -0
  65. package/src/server/lan-engine.mjs +2 -2
  66. package/src/server/lan-sync.mjs +3 -3
  67. package/src/server/lan-wire.mjs +3 -3
  68. package/src/server/login-guard.mjs +162 -0
  69. package/src/server/login-service.mjs +3 -0
  70. package/src/server/org-telemetry.mjs +173 -0
  71. package/src/server/pinned-build.mjs +8 -0
  72. package/src/server/prefs-routes.mjs +4 -0
  73. package/src/server/request-gates.mjs +26 -0
  74. package/src/server/session-read-gate.mjs +5 -3
  75. package/src/server/session-summary.mjs +281 -0
  76. package/src/server/skill-hook.mjs +162 -0
  77. package/src/server/stop-continue-gate.d.mts +8 -0
  78. package/src/server/stop-continue-gate.mjs +77 -0
  79. package/src/server/stop-continue.mjs +544 -0
  80. package/src/server/stop-failure.d.mts +10 -0
  81. package/src/server/stop-failure.mjs +97 -0
  82. package/src/server/swap-log.mjs +19 -10
  83. package/src/server/tool-audit-report.mjs +5 -3
  84. package/src/server/tool-audit-routes.mjs +5 -3
  85. package/src/server/tool-audit-skills.mjs +82 -0
  86. package/src/server/tool-audit.mjs +53 -11
  87. package/src/server/traffic-radar-capture.mjs +10 -1
  88. package/src/server/traffic-radar-monitor.mjs +4 -2
  89. package/src/server/traffic-radar-routes.mjs +13 -0
  90. package/src/server/traffic-radar-service.mjs +133 -0
  91. package/src/server/usage-day.mjs +3 -1
  92. package/dist/web/assets/BrowserWatchModal-nOxhGOO4.js +0 -1
  93. package/dist/web/assets/RadioPanel-BLfpQ1Iw.js +0 -1
  94. package/dist/web/assets/TrafficRadar-BFrObpjW.js +0 -1
  95. package/dist/web/assets/UsageHistoryModal-DCnFf1vi.js +0 -1
  96. package/dist/web/assets/app-BiJ8TfUl.css +0 -1
  97. package/dist/web/assets/app-DBsNB-2y.js +0 -312
@@ -0,0 +1,498 @@
1
+ // /api/integrations — the connections to trackers (#2178), and the doorway to
2
+ // each provider's own read routes.
3
+ //
4
+ // GET /api/integrations every registered provider, with whether it
5
+ // is available now, and every connection
6
+ // (no secrets)
7
+ // POST /api/integrations/connections create: { kind, name?, input }
8
+ // PATCH /api/integrations/connections/<id> edit: { name?, input? } — re-validates when
9
+ // the address, the token or the provider's
10
+ // options change
11
+ // POST /api/integrations/connections/<id>/test ask the tracker again with the saved token
12
+ // DELETE /api/integrations/connections/<id> disconnect: the connection and its token,
13
+ // with or without a provider for its kind
14
+ // GET /api/integrations/<kind>/<id>/asset?url= an image, read by the provider's client from its
15
+ // own image hosts (raster only; empty 403/404)
16
+ // GET /api/integrations/<kind>/<id>/<subpath…> the provider's own reads (registry.mjs)
17
+ //
18
+ // A route that would ask a provider that is not answering (registry.mjs,
19
+ // `available`), or ask for a saved connection whose provider this deck does
20
+ // not have, answers `unavailable` (503) and asks nothing; an image is an
21
+ // empty 404 as always; Disconnect needs no provider at all.
22
+ //
23
+ // Every GET here is a guarded read (request-gates.mjs, GUARDED_READ_PREFIXES):
24
+ // what a tracker holds is the user's work, and the deck's own page is the only
25
+ // caller. Every mutation already needs the page or the deck token.
26
+ //
27
+ // A SECRET NEVER LEAVES THIS FILE IN AN ANSWER. Connections come out of the
28
+ // store already without one (store.mjs); the routes below add only
29
+ // `tokenSaved`. The secret is read for exactly two things: handing it to the
30
+ // provider to build a client, and through that client, signing a request.
31
+ // And what comes back is sent without it: a tracker that echoes the token
32
+ // reaches the page, the saved connection and the log as `[redacted]`.
33
+ import { createHash } from "node:crypto";
34
+ import { readBody, send } from "../http-io.mjs";
35
+ import { errorBody, errorResponse, integrationError, isTypedError } from "./errors.mjs";
36
+ import { fetchImage, IMAGE_MAX_BYTES, isSafeTarget, normaliseBaseUrl, originOf, RASTER_TYPES, rasterMatches } from "./proxy.mjs";
37
+ import { getProvider, isAvailable, providerList } from "./registry.mjs";
38
+ import { integrationsStore } from "./store.mjs";
39
+ import "./providers.mjs";
40
+
41
+ // ── one client per connection ───────────────────────────────────────────────
42
+
43
+ /** id -> { print, client }. A client holds the provider's short caches (a
44
+ * field map, a board list), so it is kept, and rebuilt the moment what it
45
+ * was made from changes: the kind, the address, the token, or the saved
46
+ * connection itself (an edit or a Test moves `updatedAt`). */
47
+ const clients = new Map();
48
+
49
+ function fingerprint(conn, secret) {
50
+ return createHash("sha256")
51
+ .update(JSON.stringify([conn.kind, conn.baseUrl, conn.updatedAt, conn.allowedOrigins, secret]))
52
+ .digest("base64url");
53
+ }
54
+
55
+ /** The kept client for a saved connection, made again when it is stale. */
56
+ export function clientFor(provider, conn, secret, { fetchImpl } = {}) {
57
+ const print = fingerprint(conn, secret);
58
+ const kept = clients.get(conn.id);
59
+ if (kept && kept.print === print && kept.fetchImpl === fetchImpl) return kept.client;
60
+ const client = provider.createClient(conn, secret, { fetchImpl });
61
+ clients.set(conn.id, { print, client, fetchImpl });
62
+ forgetAssets(conn.id);
63
+ return client;
64
+ }
65
+
66
+ /** Drop a connection's client and every image read through it. */
67
+ export function forgetConnection(id) {
68
+ clients.delete(id);
69
+ forgetAssets(id);
70
+ }
71
+
72
+ // ── the asset route ─────────────────────────────────────────────────────────
73
+ //
74
+ // GET /api/integrations/<kind>/<id>/asset?url= — an image the panel draws (an
75
+ // avatar, an icon, a picture in a description), read by the deck and passed on
76
+ // from its own origin.
77
+ //
78
+ // THE CLIENT DECIDES WHERE AN IMAGE MAY COME FROM. `client.allowedOrigins` are
79
+ // where the token goes, not where images live — a Cloud avatar is on
80
+ // Atlassian's CDN, a GitHub one on githubusercontent.com, and neither may ever
81
+ // see the token. So a provider's `client.fetchAsset(url)` owns the read: its
82
+ // own list of image hosts, checked on the address and on every redirect hop;
83
+ // the token on its token origins only; raster images only; a streamed cap. A
84
+ // provider without one gets the generic read below, from its token origins
85
+ // and nowhere else, unsigned.
86
+ //
87
+ // AN IMAGE THAT FAILS SAYS NOTHING. Any failure is a 403 (not a host this
88
+ // connection reads from) or a 404 (anything else) with an empty body: the
89
+ // page's <img> falls back to what it draws itself, and nothing about the
90
+ // failure reaches the connection — a 401 on an avatar is not an expired token.
91
+
92
+ /** Images read recently, in memory, briefly; and the ones that failed, for a
93
+ * minute, so a list of rows with one broken icon asks once, not per row. */
94
+ const assetCache = new Map();
95
+ const assetFailures = new Map();
96
+ const ASSET_TTL_MS = 5 * 60_000;
97
+ const ASSET_FAILURE_TTL_MS = 60_000;
98
+ const ASSET_CACHE_BYTES = 24 * 1024 * 1024;
99
+ /** One image larger than this is served but not kept. */
100
+ const ASSET_KEEP_BYTES = 1024 * 1024;
101
+ let assetCacheBytes = 0;
102
+
103
+ function forgetAssets(id) {
104
+ for (const [key, value] of assetCache) {
105
+ if (key.startsWith(`${id}|`)) { assetCache.delete(key); assetCacheBytes -= value.bytes.length; }
106
+ }
107
+ for (const key of assetFailures.keys()) if (key.startsWith(`${id}|`)) assetFailures.delete(key);
108
+ }
109
+
110
+ /** The headers an image is answered with: its raster type, never sniffed,
111
+ * sandboxed if it is ever opened as a document, kept only by this browser. */
112
+ function assetHeaders(contentType, length) {
113
+ return {
114
+ "Content-Type": contentType,
115
+ "Content-Length": String(length),
116
+ "Cache-Control": "private, max-age=300",
117
+ "X-Content-Type-Options": "nosniff",
118
+ "Content-Security-Policy": "default-src 'none'; sandbox",
119
+ "Cross-Origin-Resource-Policy": "same-origin",
120
+ };
121
+ }
122
+
123
+ function rememberAsset(key, entry) {
124
+ const old = assetCache.get(key);
125
+ if (old) { assetCacheBytes -= old.bytes.length; assetCache.delete(key); }
126
+ if (entry.bytes.length > ASSET_KEEP_BYTES) return;
127
+ assetCache.set(key, entry);
128
+ assetCacheBytes += entry.bytes.length;
129
+ while (assetCacheBytes > ASSET_CACHE_BYTES && assetCache.size) {
130
+ const [oldest, value] = assetCache.entries().next().value;
131
+ assetCache.delete(oldest);
132
+ assetCacheBytes -= value.bytes.length;
133
+ }
134
+ }
135
+
136
+ /** A failed image: an empty 403 or 404, never a JSON error and never kept. */
137
+ function assetRefused(res, status = 404) {
138
+ if (res.writableEnded || res.headersSent) return;
139
+ res.writeHead(status === 403 ? 403 : 404, { "Content-Length": "0", "Cache-Control": "no-store", "X-Content-Type-Options": "nosniff" });
140
+ res.end();
141
+ }
142
+
143
+ /** The read a provider without its own fetchAsset gets: its token origins
144
+ * only, no token, the same checks. */
145
+ function genericAsset(client, url, opts) {
146
+ const origins = Array.isArray(client.allowedOrigins) ? client.allowedOrigins : [];
147
+ return fetchImage(url, { ...opts, tokenOrigins: origins, sign: () => ({}) });
148
+ }
149
+
150
+ async function serveAsset(res, conn, client, raw, { signal, method, fetchImpl }) {
151
+ let target;
152
+ try { target = new URL(String(raw ?? "")); } catch { return assetRefused(res, 404); }
153
+ if (!raw || raw.length > 4000 || (target.protocol !== "https:" && target.protocol !== "http:")) return assetRefused(res, 404);
154
+ const key = `${conn.id}|${target.href}`;
155
+ const failed = assetFailures.get(key);
156
+ if (failed && Date.now() - failed.at < ASSET_FAILURE_TTL_MS) return assetRefused(res, failed.status);
157
+ let image = assetCache.get(key);
158
+ if (!image || Date.now() - image.at >= ASSET_TTL_MS) {
159
+ try {
160
+ const got = typeof client.fetchAsset === "function"
161
+ ? await client.fetchAsset(target.href, { maxBytes: IMAGE_MAX_BYTES, signal })
162
+ : await genericAsset(client, target.href, { maxBytes: IMAGE_MAX_BYTES, signal, fetchImpl });
163
+ const contentType = String(got?.contentType ?? "").split(";")[0].trim().toLowerCase();
164
+ const bytes = got?.bytes;
165
+ // The route checks again what every client promised.
166
+ if (!RASTER_TYPES.has(contentType) || !Buffer.isBuffer(bytes) || bytes.length > IMAGE_MAX_BYTES || !rasterMatches(contentType, bytes)) {
167
+ throw integrationError("not-found", "Not an image the deck shows.");
168
+ }
169
+ image = { at: Date.now(), contentType, bytes };
170
+ rememberAsset(key, image);
171
+ } catch (err) {
172
+ if (signal?.aborted) return;
173
+ const status = isTypedError(err) && err.kind === "permission" ? 403 : 404;
174
+ assetFailures.set(key, { at: Date.now(), status });
175
+ if (assetFailures.size > 2000) assetFailures.delete(assetFailures.keys().next().value);
176
+ return assetRefused(res, status);
177
+ }
178
+ }
179
+ res.writeHead(200, assetHeaders(image.contentType, image.bytes.length));
180
+ res.end(method === "HEAD" ? undefined : image.bytes);
181
+ }
182
+
183
+ // ── connections ─────────────────────────────────────────────────────────────
184
+
185
+ /** The form's values as a candidate, by the provider's rules or the default
186
+ * ones: an address and a token, a blank token keeping the saved one. */
187
+ function parseInput(provider, input, existing) {
188
+ if (typeof provider.parseInput === "function") return provider.parseInput(input, existing);
189
+ const baseUrl = normaliseBaseUrl(input?.baseUrl, { field: "baseUrl", label: `the ${provider.label} address` });
190
+ const typed = typeof input?.token === "string" ? input.token.trim() : "";
191
+ const secret = typed || existing?.secret || null;
192
+ if (!secret) throw integrationError("invalid", "Enter a token.", { field: "token" });
193
+ return { baseUrl, secret, meta: {} };
194
+ }
195
+
196
+ /** Ask the tracker, with a fresh client for the candidate, who this is — and
197
+ * settle what the connection may reach. What the provider answers is the
198
+ * whole of the new meta; `inputMeta` (the form's own choices, from
199
+ * parseInput) fills only what it left out, so nothing a previous check
200
+ * learned outlives the check that no longer finds it. What the tracker said
201
+ * about the account is kept without the secret in it (see below). */
202
+ async function validateCandidate(provider, candidate, secret, { phase, signal, fetchImpl, inputMeta = {} }) {
203
+ const conn = { allowedOrigins: [originOf(candidate.baseUrl)].filter(Boolean), ...candidate };
204
+ const client = provider.createClient(conn, secret, { fetchImpl });
205
+ const result = await client.validate({ phase, signal });
206
+ const origins = new Set([originOf(candidate.baseUrl), ...(Array.isArray(client.allowedOrigins) ? client.allowedOrigins : [])].filter(Boolean));
207
+ for (const origin of origins) {
208
+ if (!isSafeTarget(origin)) throw integrationError("invalid", `The deck will not send a token to ${origin}.`);
209
+ }
210
+ const needles = secretNeedles(secret);
211
+ return {
212
+ baseUrl: candidate.baseUrl,
213
+ account: redactSecrets(result?.account ?? {}, needles),
214
+ meta: redactSecrets({ ...inputMeta, ...(result?.meta ?? {}), checkedAt: new Date().toISOString() }, needles),
215
+ allowedOrigins: [...origins],
216
+ };
217
+ }
218
+
219
+ // ── the connection's own secret, in nothing the deck sends ──────────────────
220
+ //
221
+ // A tracker may echo what it was sent — a 401 that quotes the Authorization
222
+ // header, a proxy's error page, an account name — and a provider passes the
223
+ // tracker's words on: an error's reason, the account it found. So whatever
224
+ // this file sends or keeps once a secret is in hand — an answer, an error,
225
+ // the account saved with a connection, a failure handed to the log and the
226
+ // error report — has the connection's own secret taken out of it first, each
227
+ // time as `[redacted]`.
228
+
229
+ export const REDACTED = "[redacted]";
230
+ /** A string of a secret shorter than this is not looked for: "basic" or a
231
+ * scheme name occurs anywhere, and no token is this short. */
232
+ const REDACT_MIN = 8;
233
+ const EMAIL = /^[^\s@]+@[^\s@]+$/;
234
+
235
+ /**
236
+ * What of one or more secrets must never leave the deck, longest first: each
237
+ * string in a secret of REDACT_MIN characters or more — but an email address,
238
+ * which names who signs and is the account the page already shows — and the
239
+ * HTTP Basic credential one of its strings makes with another. The secret
240
+ * stays opaque: nothing here reads one of its fields by name.
241
+ */
242
+ export function secretNeedles(...secrets) {
243
+ const needles = new Set();
244
+ for (const secret of secrets) {
245
+ const strings = [];
246
+ const walk = (value, depth) => {
247
+ if (typeof value === "string") strings.push(value);
248
+ else if (value && typeof value === "object" && depth < 8) for (const v of Object.values(value)) walk(v, depth + 1);
249
+ };
250
+ walk(secret, 0);
251
+ const own = strings.filter(s => s.length >= REDACT_MIN && !EMAIL.test(s));
252
+ for (const s of own) needles.add(s);
253
+ for (const user of strings) for (const s of own) {
254
+ if (user !== s) needles.add(Buffer.from(`${user}:${s}`, "utf8").toString("base64"));
255
+ }
256
+ }
257
+ return [...needles].sort((a, b) => b.length - a.length);
258
+ }
259
+
260
+ function redactText(text, needles) {
261
+ let out = text;
262
+ for (const needle of needles) if (out.includes(needle)) out = out.split(needle).join(REDACTED);
263
+ return out;
264
+ }
265
+
266
+ /**
267
+ * `value` with every needle replaced in every string in it, its keys too —
268
+ * or `value` itself, untouched, when no needle is anywhere in it (the
269
+ * answer as JSON is searched first, so an ordinary read costs one pass).
270
+ */
271
+ export function redactSecrets(value, needles) {
272
+ if (!needles.length || value == null) return value;
273
+ if (typeof value === "string") return redactText(value, needles);
274
+ let json;
275
+ try { json = JSON.stringify(value); } catch { return value; }
276
+ if (json === undefined || !needles.some(n => json.includes(JSON.stringify(n).slice(1, -1)))) return value;
277
+ const walk = v => {
278
+ if (typeof v === "string") return redactText(v, needles);
279
+ if (Array.isArray(v)) return v.map(walk);
280
+ if (v && typeof v === "object") return Object.fromEntries(Object.entries(v).map(([k, x]) => [redactText(k, needles), walk(x)]));
281
+ return v;
282
+ };
283
+ return walk(JSON.parse(json));
284
+ }
285
+
286
+ /** A saved connection without its own secret in what a page reads of it.
287
+ * Every save here already takes it out, but builds before this host did kept
288
+ * the account a tracker answered as it came, echo and all. Its id, kind and
289
+ * address are left as they are, so it can always be found and disconnected. */
290
+ function withoutOwnSecret(conn, secret) {
291
+ const needles = secretNeedles(secret);
292
+ if (!needles.length) return conn;
293
+ return { ...conn, name: redactSecrets(conn.name, needles), account: redactSecrets(conn.account, needles), meta: redactSecrets(conn.meta, needles) };
294
+ }
295
+
296
+ /** A failure that is a bug rather than a typed error, as the log and the
297
+ * error report get it: its message and stack without the secret, and none
298
+ * of what it carried besides (a cause may hold a request). */
299
+ function redactedError(err, needles) {
300
+ const copy = new Error(redactText(String(err?.message ?? err), needles));
301
+ copy.name = typeof err?.name === "string" ? err.name : "Error";
302
+ copy.stack = typeof err?.stack === "string" ? redactText(err.stack, needles) : copy.stack;
303
+ return copy;
304
+ }
305
+
306
+ async function readJsonBody(req, res) {
307
+ const raw = await readBody(req, res).catch(() => null);
308
+ if (raw == null) return null;
309
+ try {
310
+ const body = JSON.parse(raw);
311
+ return body && typeof body === "object" && !Array.isArray(body) ? body : {};
312
+ } catch { return {}; }
313
+ }
314
+
315
+ /** A connection as the page draws it: the account's avatar made loadable,
316
+ * through the asset route, where the provider's client decides whether its
317
+ * host is one to read from (and whether the token goes with it). */
318
+ export function forPage(conn) {
319
+ if (!conn) return conn;
320
+ const raw = conn.account?.avatarUrl;
321
+ const avatarUrl = raw && isSafeTarget(raw)
322
+ ? `/api/integrations/${encodeURIComponent(conn.kind)}/${encodeURIComponent(conn.id)}/asset?url=${encodeURIComponent(raw)}`
323
+ : "";
324
+ return { ...conn, account: { ...conn.account, avatarUrl } };
325
+ }
326
+
327
+ /** Whether an edit asks for a re-check: a new address, a new token, or a
328
+ * provider option (Cloud or Server) the saved meta does not already say. */
329
+ function needsValidation(existing, existingSecret, candidate) {
330
+ if (candidate.baseUrl !== existing.baseUrl) return true;
331
+ if (JSON.stringify(candidate.secret) !== JSON.stringify(existingSecret)) return true;
332
+ for (const [key, value] of Object.entries(candidate.meta ?? {})) {
333
+ if (JSON.stringify(existing.meta?.[key]) !== JSON.stringify(value)) return true;
334
+ }
335
+ return false;
336
+ }
337
+
338
+ const notFound = res => send(res, 404, errorBody(integrationError("not-found", "No such integrations route.")));
339
+ const notAllowed = res => send(res, 405, errorBody(integrationError("invalid", "That method is not allowed here.")));
340
+ /** The provider for this kind is not on this deck, or not answering it. */
341
+ const unavailable = label => integrationError("unavailable", `${label} is not available on this deck.`);
342
+
343
+ /**
344
+ * Every /api/integrations request. `deps` is for the suite: a store in a temp
345
+ * directory and a fetch that answers as a tracker would.
346
+ */
347
+ export async function handleIntegrations(req, res, url, deps = {}) {
348
+ const store = deps.store ?? integrationsStore;
349
+ const fetchImpl = deps.fetchImpl;
350
+ const segments = url.pathname.split("/").filter(Boolean).slice(2).map(s => {
351
+ try { return decodeURIComponent(s); } catch { return "\u0000"; }
352
+ });
353
+ const method = req.method;
354
+ // The page's request: a panel closed mid-read stops the read upstream too.
355
+ const controller = new AbortController();
356
+ res.on?.("close", () => { if (!res.writableEnded) controller.abort(); });
357
+ const signal = controller.signal;
358
+
359
+ // Every secret this request has in hand — a saved one, a candidate, the
360
+ // token as it was typed — and the answer without any of them.
361
+ const held = [];
362
+ const hold = secret => { if (secret != null) held.push(secret); return secret; };
363
+ const needles = () => secretNeedles(...held);
364
+ const answer = (status, body) => send(res, status, held.length ? redactSecrets(body, needles()) : body);
365
+ const clean = text => (held.length ? redactSecrets(text, needles()) : text);
366
+
367
+ try {
368
+ if (segments.length === 0) {
369
+ if (method !== "GET" && method !== "HEAD") return notAllowed(res);
370
+ // Every registered provider, with whether this deck can connect and read
371
+ // it now (`available`): the page offers a kind only while that is true,
372
+ // and keeps reading the list where a provider is listed, so one that
373
+ // cannot serve at load shows once it can. A kind with no
374
+ // registered provider is not listed: a deck with none lists none.
375
+ // Every saved connection is listed whatever its kind, so it can always
376
+ // be disconnected.
377
+ const providers = providerList();
378
+ return send(res, 200, { ok: true, providers, connections: (await store.list({ own: withoutOwnSecret })).map(forPage) });
379
+ }
380
+
381
+ if (segments[0] === "connections") {
382
+ const [, id, action] = segments;
383
+ if (!id && method === "POST" && segments.length === 1) {
384
+ const body = await readJsonBody(req, res);
385
+ if (body == null) return;
386
+ const provider = getProvider(String(body.kind ?? ""));
387
+ if (!provider) throw integrationError("invalid", "Choose which tracker to connect.", { field: "kind" });
388
+ if (!isAvailable(provider)) throw unavailable(provider.label);
389
+ hold(typeof body.input?.token === "string" ? body.input.token : null);
390
+ const candidate = parseInput(provider, body.input ?? {}, null);
391
+ hold(candidate.secret);
392
+ const checked = await validateCandidate(provider, { kind: provider.kind, name: "", baseUrl: candidate.baseUrl, meta: candidate.meta ?? {} }, candidate.secret,
393
+ { phase: "validate", signal, fetchImpl, inputMeta: candidate.meta });
394
+ const name = clean(String(body.name ?? candidate.name ?? "").trim()).slice(0, 120)
395
+ || clean(provider.defaultName ? provider.defaultName(checked) : provider.label);
396
+ const connection = await store.create({ kind: provider.kind, name, ...checked }, candidate.secret);
397
+ return answer(201, { ok: true, connection: forPage(connection) });
398
+ }
399
+ if (!id || segments.length > 3) return notFound(res);
400
+ const existing = await store.get(id);
401
+ if (!existing) throw integrationError("not-found", "That connection is not there any more.");
402
+
403
+ // Disconnect asks no provider: a connection, and the token saved with
404
+ // it, can always be deleted — also one whose provider this deck does not
405
+ // have, which nothing else here can read or check.
406
+ if (!action && method === "DELETE") {
407
+ forgetConnection(id);
408
+ await store.remove(id);
409
+ return send(res, 200, { ok: true });
410
+ }
411
+
412
+ // Test and Edit ask the provider: without one that answers, they say so.
413
+ const provider = getProvider(existing.kind);
414
+ if (!isAvailable(provider)) throw unavailable(provider?.label ?? existing.kind);
415
+
416
+ if (action === "test" && method === "POST") {
417
+ const secret = hold(await store.secret(id));
418
+ if (secret == null) throw integrationError("expired", "No token is saved for this connection. Edit it and enter one.");
419
+ const checked = await validateCandidate(provider, existing, secret, { phase: "use", signal, fetchImpl });
420
+ const connection = await store.update(id, checked);
421
+ forgetConnection(id);
422
+ return answer(200, { ok: true, connection: forPage(connection) });
423
+ }
424
+
425
+ if (!action && (method === "PATCH" || method === "POST")) {
426
+ const body = await readJsonBody(req, res);
427
+ if (body == null) return;
428
+ const existingSecret = hold(await store.secret(id));
429
+ const name = typeof body.name === "string" ? clean(body.name.trim()).slice(0, 120) : undefined;
430
+ if (body.input && typeof body.input === "object") {
431
+ hold(typeof body.input.token === "string" ? body.input.token : null);
432
+ const candidate = parseInput(provider, body.input, { conn: existing, secret: existingSecret });
433
+ if (candidate.secret == null) throw integrationError("invalid", "Enter a token.", { field: "token" });
434
+ hold(candidate.secret);
435
+ if (needsValidation(existing, existingSecret, candidate)) {
436
+ const checked = await validateCandidate(provider, { ...existing, baseUrl: candidate.baseUrl, meta: { ...existing.meta, ...(candidate.meta ?? {}) } }, candidate.secret,
437
+ { phase: "validate", signal, fetchImpl, inputMeta: candidate.meta });
438
+ const connection = await store.update(id, { ...checked, ...(name ? { name } : {}) }, candidate.secret);
439
+ forgetConnection(id);
440
+ return answer(200, { ok: true, connection: forPage(connection), validated: true });
441
+ }
442
+ }
443
+ const connection = await store.update(id, name ? { name } : {});
444
+ return answer(200, { ok: true, connection: forPage(connection), validated: false });
445
+ }
446
+ return notAllowed(res);
447
+ }
448
+
449
+ // /api/integrations/<kind>/<id>/<subpath…>
450
+ const [kind, id, ...rest] = segments;
451
+ if (!id || rest.length === 0) return notFound(res);
452
+ // Reads only, whatever the kind and whether or not this deck has its
453
+ // provider: nothing under here may change anything.
454
+ if (method !== "GET" && method !== "HEAD") return notAllowed(res);
455
+ const provider = getProvider(kind);
456
+
457
+ // An image: whatever goes wrong, an empty 403 or 404, and the connection
458
+ // is not touched (see the asset route above) — also when no provider
459
+ // here can read it.
460
+ if (rest.length === 1 && rest[0] === "asset") {
461
+ if (!provider) return assetRefused(res, 404);
462
+ let conn, secret;
463
+ try {
464
+ conn = await store.get(id);
465
+ secret = conn && conn.kind === kind ? await store.secret(id) : null;
466
+ } catch { return assetRefused(res, 404); }
467
+ if (!conn || conn.kind !== kind || secret == null || !isAvailable(provider)) return assetRefused(res, 404);
468
+ let client;
469
+ try { client = clientFor(provider, conn, secret, { fetchImpl }); } catch { return assetRefused(res, 404); }
470
+ return await serveAsset(res, conn, client, url.searchParams.get("url"), { signal, method, fetchImpl });
471
+ }
472
+
473
+ const conn = await store.get(id);
474
+ if (!provider) {
475
+ // No provider for this kind on this deck. A saved connection of the kind
476
+ // is there and cannot be read here, and says so; anything else is not a
477
+ // route at all.
478
+ if (conn && conn.kind === kind) throw unavailable(kind);
479
+ return notFound(res);
480
+ }
481
+ if (!conn || conn.kind !== kind) throw integrationError("not-found", "That connection is not there any more.");
482
+ if (!isAvailable(provider)) throw unavailable(provider.label);
483
+ const secret = hold(await store.secret(id));
484
+ if (secret == null) throw integrationError("expired", "No token is saved for this connection. Reconnect it in Settings.");
485
+ const client = clientFor(provider, conn, secret, { fetchImpl });
486
+
487
+ const read = await provider.routes(client, method, rest.join("/"), url.searchParams, req, { signal });
488
+ if (res.writableEnded || signal.aborted) return;
489
+ const status = Number.isInteger(read?.status) ? read.status : 500;
490
+ return answer(status, read?.body ?? errorBody(null));
491
+ } catch (err) {
492
+ if (isTypedError(err)) {
493
+ const { status, body } = errorResponse(err);
494
+ return answer(status, body);
495
+ }
496
+ throw held.length ? redactedError(err, needles()) : err;
497
+ }
498
+ }