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,11 @@
1
+ // Every tracker the deck can connect to, registered once (#2178). A new
2
+ // provider is one import and one registerProvider call here, plus its web half
3
+ // (src/web/integrations/registry.ts). registry.mjs has the contract, and
4
+ // docs/features/jira-integration-2178.md walks through it.
5
+ import { registerProvider } from "./registry.mjs";
6
+ import { jiraProvider } from "./jira.mjs";
7
+ import { githubProvider, gitlabProvider } from "./forge.mjs";
8
+
9
+ registerProvider(jiraProvider);
10
+ registerProvider(githubProvider);
11
+ registerProvider(gitlabProvider);
@@ -0,0 +1,314 @@
1
+ // The one way a request reaches a connected tracker (#2178), and the rules that
2
+ // keep a saved token where it belongs.
3
+ //
4
+ // THE TOKEN GOES ONLY WHERE THE CONNECTION SAYS. Every connection carries
5
+ // `allowedOrigins` — its own base URL's origin, plus any the provider found it
6
+ // needs while validating (Jira Cloud's scoped tokens answer on
7
+ // api.atlassian.com, not on the site). A URL outside that list is refused
8
+ // before anything is signed, so a link inside an issue, an avatar on a CDN or a
9
+ // redirect cannot carry the credential somewhere else.
10
+ //
11
+ // REDIRECTS ARE NOT FOLLOWED BLIND. Fetch is asked for `redirect: "manual"`;
12
+ // a redirect to an allowed origin is followed (a few hops at most) and signed
13
+ // again, and one anywhere else is an error — the token is never re-sent to a
14
+ // host the user did not connect.
15
+ //
16
+ // READ ONLY BY CONSTRUCTION. The proxy sends GET and nothing else. Browsing a
17
+ // tracker is the whole feature; there is no route here that could change an
18
+ // issue, and there is no generic "fetch this URL with my token" route anywhere
19
+ // in the deck.
20
+ import { errorFromFetchFailure, errorFromStatus, integrationError } from "./errors.mjs";
21
+
22
+ /** HTTPS only, with one exception: a tracker on this machine (a mock, a
23
+ * container on localhost), which has no certificate to check. */
24
+ const LOCAL_HOSTS = new Set(["localhost", "127.0.0.1", "[::1]", "::1"]);
25
+
26
+ export function isLocalHost(hostname) {
27
+ return LOCAL_HOSTS.has(String(hostname).toLowerCase());
28
+ }
29
+
30
+ /** The origin of a URL ("https://jira.example.com"), or null. */
31
+ export function originOf(raw) {
32
+ try {
33
+ const u = new URL(String(raw));
34
+ return u.protocol === "https:" || u.protocol === "http:" ? u.origin : null;
35
+ } catch { return null; }
36
+ }
37
+
38
+ /** Whether a URL may carry a credential at all: https, or http on this machine. */
39
+ export function isSafeTarget(raw) {
40
+ let u;
41
+ try { u = new URL(String(raw)); } catch { return false; }
42
+ if (u.username || u.password) return false;
43
+ if (u.protocol === "https:") return true;
44
+ return u.protocol === "http:" && isLocalHost(u.hostname);
45
+ }
46
+
47
+ /**
48
+ * A base URL as the user typed it, made into the one the deck keeps: scheme
49
+ * and host lower-cased, no credentials, query or fragment, no trailing slash.
50
+ * Throws an `invalid` IntegrationError naming the field.
51
+ */
52
+ export function normaliseBaseUrl(raw, { field = "baseUrl", label = "the address" } = {}) {
53
+ const typed = String(raw ?? "").trim();
54
+ if (!typed) throw integrationError("invalid", `Enter ${label}.`, { field });
55
+ let u;
56
+ try { u = new URL(/^[a-z][a-z0-9+.-]*:\/\//i.test(typed) ? typed : `https://${typed}`); }
57
+ catch { throw integrationError("invalid", `${label[0].toUpperCase()}${label.slice(1)} is not a valid URL.`, { field }); }
58
+ if (u.username || u.password) throw integrationError("invalid", "Leave the user name and password out of the address.", { field });
59
+ if (u.protocol === "http:" && !isLocalHost(u.hostname)) {
60
+ throw integrationError("invalid", "Use an https:// address. Plain http is only accepted for a server on this machine.", { field });
61
+ }
62
+ if (u.protocol !== "https:" && u.protocol !== "http:") throw integrationError("invalid", "Use an https:// address.", { field });
63
+ const path = u.pathname.replace(/\/+$/, "");
64
+ return `${u.protocol}//${u.host}${path}`;
65
+ }
66
+
67
+ /** Wait for whichever comes first: the caller's abort or the deadline. */
68
+ function deadlineSignal(signal, timeoutMs) {
69
+ const timeout = AbortSignal.timeout(timeoutMs);
70
+ if (!signal) return timeout;
71
+ if (typeof AbortSignal.any === "function") return AbortSignal.any([signal, timeout]);
72
+ const controller = new AbortController();
73
+ const stop = s => () => controller.abort(s.reason);
74
+ if (signal.aborted) controller.abort(signal.reason);
75
+ signal.addEventListener("abort", stop(signal), { once: true });
76
+ timeout.addEventListener("abort", stop(timeout), { once: true });
77
+ return controller.signal;
78
+ }
79
+
80
+ /** Read a body into a Buffer, refusing one larger than `maxBytes`. */
81
+ async function readCapped(res, maxBytes) {
82
+ const declared = Number(res.headers.get("content-length"));
83
+ if (Number.isFinite(declared) && declared > maxBytes) {
84
+ await res.body?.cancel().catch(() => {});
85
+ throw integrationError("upstream", "The answer was larger than the deck accepts.");
86
+ }
87
+ if (!res.body) return Buffer.alloc(0);
88
+ const reader = res.body.getReader();
89
+ const chunks = [];
90
+ let size = 0;
91
+ for (;;) {
92
+ const { done, value } = await reader.read();
93
+ if (done) break;
94
+ size += value.byteLength;
95
+ if (size > maxBytes) {
96
+ await reader.cancel().catch(() => {});
97
+ throw integrationError("upstream", "The answer was larger than the deck accepts.");
98
+ }
99
+ chunks.push(value);
100
+ }
101
+ return Buffer.concat(chunks.map(c => Buffer.from(c.buffer, c.byteOffset, c.byteLength)), size);
102
+ }
103
+
104
+ const MAX_HOPS = 3;
105
+
106
+ /**
107
+ * A client for one connection.
108
+ *
109
+ * @param {object} opts
110
+ * @param {{ id: string, allowedOrigins: string[], baseUrl: string }} opts.conn
111
+ * @param {unknown} opts.secret the opaque secret, handed only to `sign`
112
+ * @param {(secret: unknown, url: URL) => Record<string, string>} opts.sign the provider's auth headers
113
+ * @param {"validate" | "use"} [opts.phase] how a 401 reads (integrations-errors.mjs)
114
+ * @param {string} [opts.label] what the tracker is called in an error ("Jira")
115
+ * @param {(json: any) => string} [opts.errorDetail] a short, safe reason out of an error body
116
+ * @param {(headers: Headers, secret: unknown) => string | null} [opts.authCheck] a refusal the
117
+ * status does not show: a tracker that answers a bad credential as an anonymous visitor, with
118
+ * a 200, says so only in a header. Returns why, or null.
119
+ * @param {typeof fetch} [opts.fetchImpl]
120
+ * @param {AbortSignal} [opts.signal] the page's request, so a closed panel stops its reads
121
+ */
122
+ export function createProxy({ conn, secret, sign, phase = "use", label = "the tracker", errorDetail, authCheck, fetchImpl = globalThis.fetch, signal } = {}) {
123
+ const allowed = new Set((conn?.allowedOrigins?.length ? conn.allowedOrigins : [originOf(conn?.baseUrl)]).filter(Boolean));
124
+
125
+ /** Whether `url` is somewhere this connection's token may go. */
126
+ const allows = url => isSafeTarget(url) && allowed.has(originOf(url));
127
+
128
+ async function send(target, { accept = "application/json", timeoutMs = 15_000, extraHeaders = {}, signal: callSignal } = {}) {
129
+ let url = new URL(target);
130
+ for (let hop = 0; ; hop++) {
131
+ if (!allows(url.href)) {
132
+ throw integrationError("permission", `The deck only sends this connection's token to ${[...allowed].join(", ")}.`);
133
+ }
134
+ let res;
135
+ try {
136
+ res = await fetchImpl(url.href, {
137
+ method: "GET",
138
+ redirect: "manual",
139
+ headers: { Accept: accept, "User-Agent": "ccdeck", ...extraHeaders, ...sign(secret, url) },
140
+ signal: deadlineSignal(callSignal ?? signal, timeoutMs),
141
+ });
142
+ } catch (err) {
143
+ throw errorFromFetchFailure(err, label);
144
+ }
145
+ if (res.status >= 300 && res.status < 400 && res.headers.get("location")) {
146
+ await res.body?.cancel().catch(() => {});
147
+ let next;
148
+ try { next = new URL(res.headers.get("location"), url); } catch { next = null; }
149
+ if (!next || !allows(next.href)) {
150
+ throw integrationError("upstream",
151
+ `${label} redirected to ${next ? next.origin : "an unreadable address"}, and the deck does not send the token there. Check the address you connected.`);
152
+ }
153
+ if (hop + 1 >= MAX_HOPS) throw integrationError("upstream", `${label} redirected too many times.`);
154
+ url = next;
155
+ continue;
156
+ }
157
+ const refused = authCheck?.(res.headers, secret);
158
+ if (refused) {
159
+ await res.body?.cancel().catch(() => {});
160
+ throw integrationError(phase === "validate" ? "auth" : "expired", refused);
161
+ }
162
+ return { res, url };
163
+ }
164
+ }
165
+
166
+ async function failure(res) {
167
+ let detail;
168
+ try {
169
+ const body = await readCapped(res, 32_000);
170
+ detail = errorDetail ? errorDetail(JSON.parse(body.toString("utf8"))) : undefined;
171
+ } catch { /* an unreadable error body says nothing more than its status */ }
172
+ return errorFromStatus(res.status, res.headers, { phase, label, detail });
173
+ }
174
+
175
+ return {
176
+ allows,
177
+ origins: () => [...allowed],
178
+
179
+ /** GET a JSON document. Throws an IntegrationError for anything else.
180
+ * `signal` is this read's own (the page's request), over the client's. */
181
+ async json(target, { timeoutMs, maxBytes = 16 * 1024 * 1024, signal: callSignal } = {}) {
182
+ const { res } = await send(target, { timeoutMs, signal: callSignal });
183
+ if (!res.ok) throw await failure(res);
184
+ const body = await readCapped(res, maxBytes);
185
+ try { return JSON.parse(body.toString("utf8")); }
186
+ catch {
187
+ throw integrationError("incompatible", `${label} answered with something that is not JSON. Check that the address is the ${label} site itself.`);
188
+ }
189
+ },
190
+
191
+ /** GET bytes (an avatar, an icon): the status, the type and the body. */
192
+ async bytes(target, { timeoutMs = 10_000, maxBytes = 2 * 1024 * 1024, accept = "image/*", signal: callSignal } = {}) {
193
+ const { res } = await send(target, { timeoutMs, accept, signal: callSignal });
194
+ if (!res.ok) throw await failure(res);
195
+ const body = await readCapped(res, maxBytes);
196
+ return { status: res.status, type: String(res.headers.get("content-type") ?? "").split(";")[0].trim().toLowerCase(), body };
197
+ },
198
+ };
199
+ }
200
+
201
+ // ── images ──────────────────────────────────────────────────────────────────
202
+
203
+ /** The image types the deck passes on: raster only. An SVG is a document that
204
+ * can carry script and links, and no tracker icon is worth serving one. */
205
+ export const RASTER_TYPES = new Set(["image/png", "image/jpeg", "image/gif", "image/webp"]);
206
+ export const IMAGE_MAX_BYTES = 8 * 1024 * 1024;
207
+
208
+ /** Whether the bytes start the way their declared raster type does. */
209
+ export function rasterMatches(type, b) {
210
+ if (!Buffer.isBuffer(b)) return false;
211
+ if (type === "image/png") return b.length > 8 && b[0] === 0x89 && b[1] === 0x50 && b[2] === 0x4e && b[3] === 0x47;
212
+ if (type === "image/jpeg") return b.length > 3 && b[0] === 0xff && b[1] === 0xd8 && b[2] === 0xff;
213
+ if (type === "image/gif") return b.length > 6 && b.toString("ascii", 0, 3) === "GIF";
214
+ if (type === "image/webp") return b.length > 12 && b.toString("ascii", 0, 4) === "RIFF" && b.toString("ascii", 8, 12) === "WEBP";
215
+ return false;
216
+ }
217
+
218
+ /** An address that names this machine or a private network: loopback,
219
+ * RFC 1918, link-local, CGNAT, unique-local IPv6, an IPv4-mapped one of
220
+ * those, or a name with no public suffix (an intranet host). Only a token
221
+ * origin — the tracker the user connected — may be one of these. */
222
+ export function isPrivateHost(hostname) {
223
+ const host = String(hostname ?? "").toLowerCase().replace(/^\[|\]$/g, "").replace(/\.$/, "");
224
+ if (!host) return true;
225
+ if (isLocalHost(host) || host === "0.0.0.0" || host === "::") return true;
226
+ if (/(^|\.)(localhost|local|internal|intranet|lan|home|corp|localdomain|home\.arpa)$/.test(host)) return true;
227
+ const v4 = /^(?:::ffff:)?(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(host);
228
+ if (v4) {
229
+ const [a, b] = [Number(v4[1]), Number(v4[2])];
230
+ return a === 0 || a === 10 || a === 127 || (a === 169 && b === 254) || (a === 172 && b >= 16 && b <= 31)
231
+ || (a === 192 && b === 168) || (a === 100 && b >= 64 && b <= 127) || a >= 224;
232
+ }
233
+ if (host.includes(":")) {
234
+ // Any other IPv6 literal: loopback, link-local, unique-local and mapped
235
+ // forms are private; the deck has no reason to fetch an image from a bare
236
+ // public IPv6 address either.
237
+ return true;
238
+ }
239
+ if (/^\d+$/.test(host) || /^0x[0-9a-f]+$/.test(host)) return true; // 2130706433, 0x7f000001
240
+ return !host.includes(".");
241
+ }
242
+
243
+ /**
244
+ * One image for the page to draw, fetched by the deck: an avatar, an icon, a
245
+ * picture in a description.
246
+ *
247
+ * `tokenOrigins` are where the connection's token goes, and `sign` is asked
248
+ * only for those. `imageHost(url)` names the other hosts this tracker serves
249
+ * public images from (a CDN, Gravatar), which get no token. The address and
250
+ * EVERY redirect hop are checked against both, again, by hand; a hop to any
251
+ * other host, or to a private address that is not the tracker itself, ends
252
+ * the read. What comes back must be a raster image no larger than `maxBytes`,
253
+ * read as a stream so a lying Content-Length cannot get past the cap.
254
+ *
255
+ * Every failure is a plain `not-found` or `permission`: an image the tracker
256
+ * would not hand over says nothing about the token, so nothing here ever
257
+ * reads as `auth` or `expired`.
258
+ *
259
+ * @returns {Promise<{ contentType: string, bytes: Buffer }>}
260
+ */
261
+ export async function fetchImage(target, {
262
+ tokenOrigins = [], imageHost = () => false, sign = () => ({}), secret, fetchImpl = globalThis.fetch,
263
+ maxBytes = IMAGE_MAX_BYTES, timeoutMs = 15_000, signal,
264
+ } = {}) {
265
+ const tokened = new Set(tokenOrigins.filter(Boolean));
266
+ const allowed = url => {
267
+ if (url.username || url.password) return false;
268
+ if (tokened.has(url.origin)) return isSafeTarget(url.href);
269
+ return url.protocol === "https:" && !isPrivateHost(url.hostname) && Boolean(imageHost(url));
270
+ };
271
+ let url;
272
+ try { url = new URL(String(target)); } catch { throw integrationError("not-found", "Not an image address."); }
273
+ const deadline = deadlineSignal(signal, timeoutMs);
274
+ for (let hop = 0; ; hop++) {
275
+ if (!allowed(url)) throw integrationError("permission", "That image is not on a host this connection reads from.");
276
+ let res;
277
+ try {
278
+ res = await fetchImpl(url.href, {
279
+ method: "GET",
280
+ redirect: "manual",
281
+ headers: {
282
+ Accept: "image/webp,image/png,image/jpeg,image/gif;q=0.9",
283
+ "User-Agent": "ccdeck",
284
+ ...(tokened.has(url.origin) ? sign(secret, url) : {}),
285
+ },
286
+ signal: deadline,
287
+ });
288
+ } catch {
289
+ throw integrationError("not-found", "The image could not be read.");
290
+ }
291
+ if (res.status >= 300 && res.status < 400) {
292
+ await res.body?.cancel().catch(() => {});
293
+ let next = null;
294
+ try { next = res.headers.get("location") ? new URL(res.headers.get("location"), url) : null; } catch { next = null; }
295
+ if (!next) throw integrationError("not-found", "The image moved without saying where.");
296
+ if (hop + 1 >= MAX_HOPS) throw integrationError("not-found", "The image redirected too many times.");
297
+ url = next;
298
+ continue;
299
+ }
300
+ if (!res.ok) {
301
+ await res.body?.cancel().catch(() => {});
302
+ throw integrationError("not-found", `The image answered ${res.status}.`);
303
+ }
304
+ const type = String(res.headers.get("content-type") ?? "").split(";")[0].trim().toLowerCase();
305
+ if (!RASTER_TYPES.has(type)) {
306
+ await res.body?.cancel().catch(() => {});
307
+ throw integrationError("not-found", "That is not an image the deck shows.");
308
+ }
309
+ let bytes;
310
+ try { bytes = await readCapped(res, maxBytes); } catch { throw integrationError("not-found", "The image is larger than the deck shows."); }
311
+ if (!rasterMatches(type, bytes)) throw integrationError("not-found", "That is not an image the deck shows.");
312
+ return { contentType: type, bytes };
313
+ }
314
+ }
@@ -0,0 +1,142 @@
1
+ // The trackers the deck knows how to connect to (#2178), keyed by kind.
2
+ //
3
+ // A PROVIDER is one object, registered once at import (providers.mjs). The
4
+ // core — the store, the generic connection routes, the asset route, the one
5
+ // client per connection — never names a tracker; what it needs to know about
6
+ // one is this:
7
+ //
8
+ // {
9
+ // kind: "jira", // the key, in URLs and in the store
10
+ // label: "Jira", // what the page and errors call it
11
+ // createClient(conn, secret, opts?), // -> a client for one connection (below)
12
+ // routes(client, method, subpath, searchParams, req, opts?)
13
+ // // -> Promise<{ status, body }>: the provider's own reads
14
+ // parseInput?(input, existing?), // the form's values -> { baseUrl, secret, meta?, name? };
15
+ // // throws an `invalid` error naming the field.
16
+ // // Default: { baseUrl: input.baseUrl, secret: input.token },
17
+ // // a blank token keeping the saved one.
18
+ // defaultName?(checked), // a name for a connection the form left unnamed
19
+ // available?(), // whether this deck can connect and read this kind
20
+ // // now: false when what serves it has gone away.
21
+ // // Default: always. While false, every route that
22
+ // // would ask the provider answers `unavailable`
23
+ // // (503) without asking it; Disconnect still works.
24
+ // }
25
+ //
26
+ // A CLIENT is the provider's, made for one connection and kept by the core —
27
+ // one per connection id, rebuilt when the token, the address, the kind or the
28
+ // saved connection change, and dropped when it is removed, so the short caches
29
+ // a client holds never outlive what they were read with:
30
+ //
31
+ // {
32
+ // allowedOrigins: string[], // every origin its token may be sent to
33
+ // validate(opts?) -> Promise<{ account, meta }>,
34
+ // // ask the tracker who this is; throws a typed error.
35
+ // // `opts.phase` is "validate" while a form is being
36
+ // // checked (a refusal is a wrong token) and "use" for
37
+ // // Test (a refusal is an expired one). allowedOrigins
38
+ // // is read again after it: validation may find more
39
+ // // (Jira Cloud's scoped tokens answer elsewhere).
40
+ // fetchAsset?(url, { maxBytes, signal }) -> Promise<{ contentType, bytes: Buffer }>
41
+ // // an image for the asset route. The client owns
42
+ // // the read: its own image-host list, checked on
43
+ // // the URL and on every redirect hop (no loopback
44
+ // // or private address but its own origin); the
45
+ // // token on its token origins only; raster only
46
+ // // (png/jpeg/gif/webp, no SVG); a streamed cap
47
+ // // (8 MB). Throws a typed error; the route answers
48
+ // // any failure with an empty 403/404 and never
49
+ // // lets it touch the connection. Without one, the
50
+ // // route reads from allowedOrigins only, unsigned
51
+ // // (proxy.mjs's fetchImage does all of the above).
52
+ // }
53
+ //
54
+ // `opts` to createClient is `{ fetchImpl }` (the suite's fake tracker); `opts`
55
+ // to routes is `{ signal }`, aborted when the page gives up on the request. A
56
+ // typed error — `{ kind, message, status?, retryAfter? }`, errors.mjs — thrown
57
+ // anywhere becomes the shared envelope; `errorResponse(err)` builds it for a
58
+ // provider that answers rather than throws.
59
+ //
60
+ // A provider may use the helpers here and in proxy.mjs — `createRouter` for
61
+ // its paths, `createProxy` for a transport that signs only for its origins and
62
+ // never follows a redirect elsewhere, `fetchImage` for fetchAsset — or bring
63
+ // its own (#2179's forge-http). providers.mjs registers them.
64
+
65
+ const providers = new Map();
66
+
67
+ /** A path pattern ("board/:boardId/sprints") against the rest of a URL. */
68
+ function compile(pattern) {
69
+ const parts = pattern.split("/").filter(Boolean);
70
+ return segments => {
71
+ if (segments.length !== parts.length) return null;
72
+ const params = {};
73
+ for (let i = 0; i < parts.length; i++) {
74
+ if (parts[i].startsWith(":")) params[parts[i].slice(1)] = segments[i];
75
+ else if (parts[i] !== segments[i]) return null;
76
+ }
77
+ return params;
78
+ };
79
+ }
80
+
81
+ /** A small GET router a provider can build its `routes` on. */
82
+ export function createRouter() {
83
+ const routes = [];
84
+ return {
85
+ /** Add a GET route, relative to /api/integrations/<kind>/<connId>/. */
86
+ get(pattern, handler) {
87
+ if (typeof handler !== "function") throw new TypeError("route handler must be a function");
88
+ routes.push({ match: compile(pattern), handler, pattern });
89
+ },
90
+ /** The route for a sub-path ("board/1/issues") or its segments, or null. */
91
+ find(subpath) {
92
+ const segments = Array.isArray(subpath) ? subpath : String(subpath).split("/").filter(Boolean);
93
+ for (const route of routes) {
94
+ const params = route.match(segments);
95
+ if (params) return { handler: route.handler, params, pattern: route.pattern };
96
+ }
97
+ return null;
98
+ },
99
+ patterns: () => routes.map(r => r.pattern),
100
+ };
101
+ }
102
+
103
+ const REQUIRED = ["kind", "label", "createClient", "routes"];
104
+
105
+ /** Register a provider. Its kind must be new, lower-case and URL-safe. */
106
+ export function registerProvider(provider) {
107
+ for (const key of REQUIRED) {
108
+ if (!provider?.[key]) throw new TypeError(`integration provider is missing ${key}`);
109
+ }
110
+ if (typeof provider.createClient !== "function" || typeof provider.routes !== "function") {
111
+ throw new TypeError("createClient and routes must be functions");
112
+ }
113
+ if (provider.available !== undefined && typeof provider.available !== "function") {
114
+ throw new TypeError("available must be a function");
115
+ }
116
+ if (!/^[a-z][a-z0-9-]{1,30}$/.test(provider.kind)) throw new TypeError(`bad provider kind ${provider.kind}`);
117
+ if (providers.has(provider.kind)) throw new Error(`provider ${provider.kind} is registered twice`);
118
+ providers.set(provider.kind, provider);
119
+ }
120
+
121
+ export function getProvider(kind) {
122
+ return providers.get(kind) ?? null;
123
+ }
124
+
125
+ /** Whether a provider can connect and read right now. One that cannot say,
126
+ * or fails saying it, cannot. */
127
+ export function isAvailable(provider) {
128
+ if (!provider) return false;
129
+ if (typeof provider.available !== "function") return true;
130
+ try { return provider.available() === true; } catch { return false; }
131
+ }
132
+
133
+ /** Every registered provider, as the page may see it: kind, label, and
134
+ * whether this deck can connect and read it now. */
135
+ export function providerList() {
136
+ return [...providers.values()].map(provider => ({ kind: provider.kind, label: provider.label, available: isAvailable(provider) }));
137
+ }
138
+
139
+ /** For tests: forget every provider, or put one back. */
140
+ export function resetProvidersForTest() {
141
+ providers.clear();
142
+ }