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.
- package/README.md +79 -18
- package/THIRD_PARTY_NOTICES.md +70 -7
- package/bin/cli/commands.js +613 -0
- package/bin/cli/help.js +26 -3
- package/bin/cli/one-shot.js +2 -2
- package/bin/cli/picker.js +86 -0
- package/bin/cli/startup.js +13 -0
- package/bin/deck.js +16 -1
- package/dist/web/assets/BrowserWatchModal-Cbzf3TWa.js +1 -0
- package/dist/web/assets/IntegrationModal-BnnBr0a3.js +5 -0
- package/dist/web/assets/RadioPanel-BIqrMLQN.js +1 -0
- package/dist/web/assets/TrafficRadar-D1gZCIwX.js +8 -0
- package/dist/web/assets/UsageHistoryModal-DAhplVju.js +1 -0
- package/dist/web/assets/app-BW3NYUYB.js +318 -0
- package/dist/web/assets/app-C5RY_wZ4.css +1 -0
- package/dist/web/assets/{index-Ch4ks76J.js → index-r8Ymr47Q.js} +1 -1
- package/dist/web/index.html +3 -3
- package/package.json +2 -1
- package/release-notes.json +52 -0
- package/src/server/account-notify.mjs +268 -9
- package/src/server/account-pick.mjs +160 -0
- package/src/server/account-projects.mjs +55 -15
- package/src/server/account-routes.mjs +47 -4
- package/src/server/account-watch.mjs +107 -4
- package/src/server/activity.mjs +3 -2
- package/src/server/args.mjs +112 -2
- package/src/server/autoswitch-failover.mjs +138 -0
- package/src/server/claude-accounts.mjs +127 -16
- package/src/server/claude-dir.mjs +142 -5
- package/src/server/claude-identity.mjs +23 -0
- package/src/server/cli-lines.mjs +224 -0
- package/src/server/codex-cost-usage.mjs +67 -0
- package/src/server/codex-enrichment.mjs +53 -9
- package/src/server/codex-review-policy.d.mts +4 -0
- package/src/server/codex-review-policy.mjs +11 -0
- package/src/server/codex-skills.mjs +146 -0
- package/src/server/codex-watch.mjs +5 -2
- package/src/server/cswap-auto-choice.mjs +53 -0
- package/src/server/cswap-auto-loop.mjs +100 -9
- package/src/server/deck-client.mjs +112 -0
- package/src/server/deck-prefs.mjs +26 -5
- package/src/server/event-pipeline.mjs +19 -3
- package/src/server/event-routes.mjs +15 -0
- package/src/server/exec.mjs +6 -2
- package/src/server/feature-use.mjs +11 -1
- package/src/server/forge-http.mjs +514 -0
- package/src/server/forge-public-fetch.mjs +228 -0
- package/src/server/forge-routes.mjs +154 -0
- package/src/server/github-api.mjs +1369 -0
- package/src/server/gitlab-api.mjs +1271 -0
- package/src/server/index.mjs +23 -3
- package/src/server/installer.mjs +149 -8
- package/src/server/integrations/cache.mjs +42 -0
- package/src/server/integrations/errors.mjs +167 -0
- package/src/server/integrations/forge.mjs +76 -0
- package/src/server/integrations/jira.mjs +1026 -0
- package/src/server/integrations/providers.mjs +11 -0
- package/src/server/integrations/proxy.mjs +314 -0
- package/src/server/integrations/registry.mjs +142 -0
- package/src/server/integrations/routes.mjs +498 -0
- package/src/server/integrations/store.mjs +226 -0
- package/src/server/lan-deck.mjs +7 -0
- package/src/server/lan-elsewhere.d.mts +30 -0
- package/src/server/lan-elsewhere.mjs +137 -0
- package/src/server/lan-engine.mjs +2 -2
- package/src/server/lan-sync.mjs +3 -3
- package/src/server/lan-wire.mjs +3 -3
- package/src/server/login-guard.mjs +162 -0
- package/src/server/login-service.mjs +3 -0
- package/src/server/org-telemetry.mjs +173 -0
- package/src/server/pinned-build.mjs +8 -0
- package/src/server/prefs-routes.mjs +4 -0
- package/src/server/request-gates.mjs +26 -0
- package/src/server/session-read-gate.mjs +5 -3
- package/src/server/session-summary.mjs +281 -0
- package/src/server/skill-hook.mjs +162 -0
- package/src/server/stop-continue-gate.d.mts +8 -0
- package/src/server/stop-continue-gate.mjs +77 -0
- package/src/server/stop-continue.mjs +544 -0
- package/src/server/stop-failure.d.mts +10 -0
- package/src/server/stop-failure.mjs +97 -0
- package/src/server/swap-log.mjs +19 -10
- package/src/server/tool-audit-report.mjs +5 -3
- package/src/server/tool-audit-routes.mjs +5 -3
- package/src/server/tool-audit-skills.mjs +82 -0
- package/src/server/tool-audit.mjs +53 -11
- package/src/server/traffic-radar-capture.mjs +10 -1
- package/src/server/traffic-radar-monitor.mjs +4 -2
- package/src/server/traffic-radar-routes.mjs +13 -0
- package/src/server/traffic-radar-service.mjs +133 -0
- package/src/server/usage-day.mjs +3 -1
- package/dist/web/assets/BrowserWatchModal-nOxhGOO4.js +0 -1
- package/dist/web/assets/RadioPanel-BLfpQ1Iw.js +0 -1
- package/dist/web/assets/TrafficRadar-BFrObpjW.js +0 -1
- package/dist/web/assets/UsageHistoryModal-DCnFf1vi.js +0 -1
- package/dist/web/assets/app-BiJ8TfUl.css +0 -1
- 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
|
+
}
|