ccdeck 3.44.0 → 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 +14 -5
- package/THIRD_PARTY_NOTICES.md +27 -0
- 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-Ch0LVdSl.js → TrafficRadar-D1gZCIwX.js} +1 -1
- package/dist/web/assets/{UsageHistoryModal-Du0ZkIJ1.js → UsageHistoryModal-DAhplVju.js} +1 -1
- package/dist/web/assets/app-BW3NYUYB.js +318 -0
- package/dist/web/assets/app-C5RY_wZ4.css +1 -0
- package/dist/web/assets/{index-BQbOfxbL.js → index-r8Ymr47Q.js} +1 -1
- package/dist/web/index.html +3 -3
- package/package.json +1 -1
- package/release-notes.json +18 -0
- package/src/server/account-notify.mjs +68 -6
- package/src/server/account-watch.mjs +61 -2
- package/src/server/claude-accounts.mjs +12 -2
- package/src/server/deck-prefs.mjs +15 -5
- 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 +6 -2
- 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/org-telemetry.mjs +173 -0
- package/src/server/pinned-build.mjs +3 -0
- package/src/server/request-gates.mjs +19 -0
- package/src/server/usage-day.mjs +2 -0
- package/dist/web/assets/BrowserWatchModal-DMIUygWz.js +0 -1
- package/dist/web/assets/RadioPanel-DNB_AHSs.js +0 -1
- package/dist/web/assets/app-CQIstkMF.js +0 -318
- package/dist/web/assets/app-Ch-0CYlK.css +0 -1
|
@@ -0,0 +1,514 @@
|
|
|
1
|
+
// The one road the GitHub and GitLab clients take to their forge (#2179).
|
|
2
|
+
//
|
|
3
|
+
// github-api.mjs and gitlab-api.mjs say WHAT to ask; this file is HOW, and it
|
|
4
|
+
// is where the promises about the token are kept, so they are kept once:
|
|
5
|
+
//
|
|
6
|
+
// THE TOKEN GOES ONLY WHERE IT WAS GIVEN FOR. A transport is made for one
|
|
7
|
+
// connection with the origins that connection's provider allowed — GitLab's
|
|
8
|
+
// own instance, api.github.com — and the token is attached to a request only
|
|
9
|
+
// when its URL is on one of them. Redirects are followed by hand, never by
|
|
10
|
+
// fetch, so a hop to another origin (GitHub sends a job's log to a signed
|
|
11
|
+
// blob-storage URL) goes out WITHOUT it. A token never rides to a host the
|
|
12
|
+
// user did not type.
|
|
13
|
+
//
|
|
14
|
+
// NOR DOES THE DECK GO WHERE A FORGE POINTS IT. A JSON or GraphQL read follows
|
|
15
|
+
// a redirect only on the connection's own origins; a job log's tail may leave
|
|
16
|
+
// them for a public https host only. No forge can send the deck to this
|
|
17
|
+
// machine, the LAN or a cloud metadata address (off-origin redirects) — not by
|
|
18
|
+
// naming one, and not by a public name whose DNS answers one: a request to a
|
|
19
|
+
// host off the connection's own goes through forge-public-fetch.mjs, whose
|
|
20
|
+
// socket connects only to an address it resolved and checked.
|
|
21
|
+
//
|
|
22
|
+
// NOTHING HERE SAYS THE TOKEN. Errors carry the forge's own words and the
|
|
23
|
+
// status; never a header, never the request. The messages are diagnostic —
|
|
24
|
+
// the panel picks its sentence from `kind`.
|
|
25
|
+
//
|
|
26
|
+
// ONE VOCABULARY FOR FAILURE. Every failure leaves as a ForgeError whose kind
|
|
27
|
+
// is one of forge-types.ts's ForgeErrorKind, because each kind has a different
|
|
28
|
+
// recovery on screen: auth and expired send the user to the connection,
|
|
29
|
+
// permission names the missing scope, unreachable and tls are about the
|
|
30
|
+
// network or the instance, rate-limit says when to try again.
|
|
31
|
+
import { cappedText } from "./provider-status.mjs";
|
|
32
|
+
import { isPrivateHost } from "./integrations/proxy.mjs";
|
|
33
|
+
import { NOT_PUBLIC, publicFetch } from "./forge-public-fetch.mjs";
|
|
34
|
+
|
|
35
|
+
const DEFAULT_TIMEOUT_MS = 20_000;
|
|
36
|
+
// A page of fifty merge requests with their reviewers is ~200 KB; a GraphQL
|
|
37
|
+
// detail query with every discussion, well under 2 MB. Sixteen is room for a
|
|
38
|
+
// big one without letting a wrong address stream a disk image into memory.
|
|
39
|
+
const DEFAULT_MAX_BYTES = 16 << 20;
|
|
40
|
+
const MAX_REDIRECTS = 3;
|
|
41
|
+
// The forge's own error body is read for its message only.
|
|
42
|
+
const ERROR_BODY_BYTES = 64 << 10;
|
|
43
|
+
// Lines a log tail keeps beyond what it was asked for, while it streams.
|
|
44
|
+
const TAIL_SLACK = 32;
|
|
45
|
+
// An avatar is a few KB, a pasted screenshot a few MB.
|
|
46
|
+
const ASSET_MAX_BYTES = 8 << 20;
|
|
47
|
+
// Raster images only: an SVG served from the deck's own origin and opened as
|
|
48
|
+
// a page could run script there.
|
|
49
|
+
// The four the integrations core's asset route passes on (#2178).
|
|
50
|
+
const RASTER = /^image\/(?:png|jpeg|gif|webp)$/;
|
|
51
|
+
|
|
52
|
+
/** A failure, in the panel's vocabulary. */
|
|
53
|
+
export class ForgeError extends Error {
|
|
54
|
+
/**
|
|
55
|
+
* @param {string} kind
|
|
56
|
+
* @param {string} message
|
|
57
|
+
* @param {{ status?: number, retryAfter?: number }} [extra]
|
|
58
|
+
*/
|
|
59
|
+
constructor(kind, message, extra = {}) {
|
|
60
|
+
super(message);
|
|
61
|
+
this.name = "ForgeError";
|
|
62
|
+
this.kind = kind;
|
|
63
|
+
if (extra.status) this.status = extra.status;
|
|
64
|
+
if (Number.isFinite(extra.retryAfter)) this.retryAfter = extra.retryAfter;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
toJSON() {
|
|
68
|
+
return {
|
|
69
|
+
kind: this.kind,
|
|
70
|
+
message: this.message,
|
|
71
|
+
...(this.status ? { status: this.status } : {}),
|
|
72
|
+
...(Number.isFinite(this.retryAfter) ? { retryAfter: this.retryAfter } : {}),
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Any thrown thing as the route answers it. A non-ForgeError is a bug of
|
|
78
|
+
* ours, and says so without passing its message through. */
|
|
79
|
+
export function forgeErrorInfo(err) {
|
|
80
|
+
if (err instanceof ForgeError) return err.toJSON();
|
|
81
|
+
return { kind: "upstream", message: "unexpected error reading the forge" };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// What Node's fetch (undici) puts in `cause.code` when the certificate is the
|
|
85
|
+
// problem. ERR_TLS_* and ERR_SSL_* are matched by prefix below.
|
|
86
|
+
const TLS_CODES = new Set([
|
|
87
|
+
"CERT_HAS_EXPIRED", "CERT_NOT_YET_VALID", "CERT_REVOKED", "CERT_UNTRUSTED", "CERT_REJECTED",
|
|
88
|
+
"DEPTH_ZERO_SELF_SIGNED_CERT", "SELF_SIGNED_CERT_IN_CHAIN", "UNABLE_TO_GET_ISSUER_CERT",
|
|
89
|
+
"UNABLE_TO_GET_ISSUER_CERT_LOCALLY", "UNABLE_TO_VERIFY_LEAF_SIGNATURE", "HOSTNAME_MISMATCH",
|
|
90
|
+
]);
|
|
91
|
+
|
|
92
|
+
/** The first error code anywhere down an error's causes — undici wraps the
|
|
93
|
+
* socket's error in `cause`, and a dual-stack connect in an AggregateError. */
|
|
94
|
+
function errorCode(err, depth = 0) {
|
|
95
|
+
if (!err || depth > 4) return null;
|
|
96
|
+
if (typeof err.code === "string" && err.code !== "UND_ERR") return err.code;
|
|
97
|
+
if (Array.isArray(err.errors)) {
|
|
98
|
+
for (const e of err.errors) {
|
|
99
|
+
const c = errorCode(e, depth + 1);
|
|
100
|
+
if (c) return c;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return errorCode(err.cause, depth + 1);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** A request that got no answer at all, as a ForgeError. */
|
|
107
|
+
export function networkError(err, origin) {
|
|
108
|
+
if (err instanceof ForgeError) return err;
|
|
109
|
+
if (err?.name === "TimeoutError" || err?.name === "AbortError") {
|
|
110
|
+
return new ForgeError("unreachable", `${origin} did not answer in time`);
|
|
111
|
+
}
|
|
112
|
+
const code = errorCode(err);
|
|
113
|
+
if (code && (TLS_CODES.has(code) || /^ERR_(?:TLS|SSL)_/.test(code))) {
|
|
114
|
+
return new ForgeError("tls", `${origin}: the certificate is not trusted (${code})`);
|
|
115
|
+
}
|
|
116
|
+
return new ForgeError("unreachable", `${origin}: ${code ?? "no answer"}`);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Seconds until the forge will answer again, from whichever header it sent:
|
|
121
|
+
* Retry-After (seconds or a date), GitHub's x-ratelimit-reset or GitLab's
|
|
122
|
+
* RateLimit-Reset (both epoch seconds). Undefined when it said nothing.
|
|
123
|
+
*/
|
|
124
|
+
export function retryAfterSeconds(headers, nowMs) {
|
|
125
|
+
const get = (name) => headers?.get?.(name) ?? null;
|
|
126
|
+
const retry = get("retry-after");
|
|
127
|
+
if (retry != null) {
|
|
128
|
+
const s = Number(retry);
|
|
129
|
+
if (Number.isFinite(s)) return Math.max(0, Math.ceil(s));
|
|
130
|
+
const at = Date.parse(retry);
|
|
131
|
+
if (Number.isFinite(at)) return Math.max(0, Math.ceil((at - nowMs) / 1000));
|
|
132
|
+
}
|
|
133
|
+
const reset = Number(get("x-ratelimit-reset") ?? get("ratelimit-reset"));
|
|
134
|
+
if (Number.isFinite(reset) && reset > 0) return Math.max(0, Math.ceil(reset - nowMs / 1000));
|
|
135
|
+
return undefined;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** The forge's own sentence out of an error body: GitHub says `message`,
|
|
139
|
+
* GitLab `message` or `error_description` or `error`. */
|
|
140
|
+
function bodyMessage(body, fallback) {
|
|
141
|
+
if (body && typeof body === "object") {
|
|
142
|
+
const m = body.error_description ?? body.message ?? body.error;
|
|
143
|
+
if (typeof m === "string" && m.trim()) return m.trim().slice(0, 300);
|
|
144
|
+
// GitLab sends validation failures as { message: { field: [..] } }.
|
|
145
|
+
if (m && typeof m === "object") return JSON.stringify(m).slice(0, 300);
|
|
146
|
+
}
|
|
147
|
+
if (typeof body === "string" && body.trim() && !/^\s*</.test(body)) return body.trim().slice(0, 300);
|
|
148
|
+
return fallback;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** A response that is not 2xx, as a ForgeError. Reads (a capped piece of) the
|
|
152
|
+
* body for the forge's message. */
|
|
153
|
+
export async function httpError(res, nowMs) {
|
|
154
|
+
let body = null;
|
|
155
|
+
try {
|
|
156
|
+
const text = await cappedText(res, ERROR_BODY_BYTES);
|
|
157
|
+
try { body = JSON.parse(text); } catch { body = text; }
|
|
158
|
+
} catch { /* the status alone will do */ }
|
|
159
|
+
const status = res.status;
|
|
160
|
+
const message = bodyMessage(body, `http ${status}`);
|
|
161
|
+
const retryAfter = retryAfterSeconds(res.headers, nowMs);
|
|
162
|
+
if (status === 401) {
|
|
163
|
+
// GitLab: { error: "invalid_token", error_description: "Token is expired…" }.
|
|
164
|
+
return new ForgeError(/expired/i.test(message) ? "expired" : "auth", message, { status });
|
|
165
|
+
}
|
|
166
|
+
if (status === 429) return new ForgeError("rate-limit", message, { status, retryAfter });
|
|
167
|
+
if (status === 403) {
|
|
168
|
+
// GitHub answers an exhausted limit with 403, not 429: the remaining
|
|
169
|
+
// count says which 403 this is, and its secondary limit says so in words.
|
|
170
|
+
const limited = res.headers?.get?.("x-ratelimit-remaining") === "0"
|
|
171
|
+
|| /rate limit/i.test(message)
|
|
172
|
+
|| res.headers?.get?.("retry-after") != null;
|
|
173
|
+
return limited
|
|
174
|
+
? new ForgeError("rate-limit", message, { status, retryAfter })
|
|
175
|
+
: new ForgeError("permission", message, { status });
|
|
176
|
+
}
|
|
177
|
+
if (status === 404) return new ForgeError("not-found", message, { status });
|
|
178
|
+
// A self-hosted instance that is down usually answers through its proxy.
|
|
179
|
+
if (status === 502 || status === 503 || status === 504) return new ForgeError("unreachable", message, { status });
|
|
180
|
+
return new ForgeError("upstream", message, { status });
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** The ForgeError for a GraphQL answer with errors and no data. GitHub types
|
|
184
|
+
* its errors; GitLab says it in words. */
|
|
185
|
+
export function graphqlError(errors) {
|
|
186
|
+
const list = Array.isArray(errors) ? errors : [];
|
|
187
|
+
const message = list.map(e => e?.message).filter(m => typeof m === "string").join("; ").slice(0, 300)
|
|
188
|
+
|| "the forge answered without data";
|
|
189
|
+
const types = new Set(list.map(e => e?.type ?? e?.extensions?.code).filter(Boolean));
|
|
190
|
+
if (types.has("RATE_LIMITED") || /rate limit/i.test(message)) return new ForgeError("rate-limit", message);
|
|
191
|
+
if (types.has("INSUFFICIENT_SCOPES") || types.has("FORBIDDEN") || /insufficient.scope|not have permission/i.test(message)) {
|
|
192
|
+
return new ForgeError("permission", message);
|
|
193
|
+
}
|
|
194
|
+
if (types.has("NOT_FOUND") || /does not exist|could not resolve/i.test(message)) return new ForgeError("not-found", message);
|
|
195
|
+
return new ForgeError("upstream", message);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** The URL of the next page from a Link header, or null. */
|
|
199
|
+
export function nextLink(headers) {
|
|
200
|
+
const link = headers?.get?.("link");
|
|
201
|
+
if (!link) return null;
|
|
202
|
+
for (const part of link.split(",")) {
|
|
203
|
+
const m = /<([^>]+)>\s*;\s*rel="?next"?/.exec(part);
|
|
204
|
+
if (m) return m[1];
|
|
205
|
+
}
|
|
206
|
+
return null;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** An ISO time as epoch ms, or null. */
|
|
210
|
+
export function epoch(s) {
|
|
211
|
+
if (typeof s !== "string" || !s) return null;
|
|
212
|
+
const t = Date.parse(s);
|
|
213
|
+
return Number.isFinite(t) ? t : null;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** Run `fn` over `items` with at most `limit` at once, in order. Used where a
|
|
217
|
+
* forge needs one call per row (GitLab's ahead/behind per branch). */
|
|
218
|
+
export async function mapLimit(items, limit, fn) {
|
|
219
|
+
const out = new Array(items.length);
|
|
220
|
+
let next = 0;
|
|
221
|
+
const worker = async () => {
|
|
222
|
+
while (next < items.length) {
|
|
223
|
+
const i = next++;
|
|
224
|
+
out[i] = await fn(items[i], i);
|
|
225
|
+
}
|
|
226
|
+
};
|
|
227
|
+
await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker));
|
|
228
|
+
return out;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// CSI and OSC escape sequences, and the two-byte ones — what CI runners colour
|
|
232
|
+
// and move the cursor with.
|
|
233
|
+
const ANSI = /\x1b\[[0-?]*[ -/]*[@-~]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)|\x1b[@-Z\\-_]/g;
|
|
234
|
+
// GitLab's collapsible-section markers: `section_start:1700000000:step_script[collapsed=true]`.
|
|
235
|
+
const GITLAB_SECTION = /section_(?:start|end):\d+:[A-Za-z0-9_.-]+(?:\[[^\]]*\])?/g;
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* One raw log line as a person reads it. A carriage return inside a line is a
|
|
239
|
+
* progress bar redrawn — the text after the last one is what the terminal
|
|
240
|
+
* showed — and GitLab puts its section markers before one too.
|
|
241
|
+
*/
|
|
242
|
+
export function cleanLogLine(line) {
|
|
243
|
+
let s = String(line ?? "").replace(/\r+$/, "");
|
|
244
|
+
const cr = s.lastIndexOf("\r");
|
|
245
|
+
if (cr >= 0) s = s.slice(cr + 1);
|
|
246
|
+
return s.replace(GITLAB_SECTION, "").replace(ANSI, "").trimEnd();
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* A transport for one connection.
|
|
251
|
+
*
|
|
252
|
+
* @param {object} opts
|
|
253
|
+
* @param {string} opts.token
|
|
254
|
+
* @param {(token: string) => Record<string, string>} opts.authHeaders how this forge wants the token
|
|
255
|
+
* @param {string[]} opts.allowedOrigins where the token may go
|
|
256
|
+
* @param {typeof fetch} [opts.fetchImpl] every request to the allowed origins, and every `allow`ed one
|
|
257
|
+
* @param {typeof fetch} [opts.publicFetchImpl] every other request — a redirect hop off the
|
|
258
|
+
* allowed origins. Connects only to public addresses (forge-public-fetch.mjs).
|
|
259
|
+
* @param {() => number} [opts.now]
|
|
260
|
+
* @param {number} [opts.timeoutMs]
|
|
261
|
+
* @param {number} [opts.maxBytes]
|
|
262
|
+
*/
|
|
263
|
+
export function createForgeTransport({
|
|
264
|
+
token,
|
|
265
|
+
authHeaders,
|
|
266
|
+
allowedOrigins,
|
|
267
|
+
fetchImpl = (...a) => fetch(...a),
|
|
268
|
+
publicFetchImpl = publicFetch(),
|
|
269
|
+
now = () => Date.now(),
|
|
270
|
+
timeoutMs = DEFAULT_TIMEOUT_MS,
|
|
271
|
+
maxBytes = DEFAULT_MAX_BYTES,
|
|
272
|
+
}) {
|
|
273
|
+
const origins = new Set((allowedOrigins ?? []).map(o => new URL(o).origin));
|
|
274
|
+
|
|
275
|
+
function headersFor(url, extra) {
|
|
276
|
+
const h = { accept: "application/json", "user-agent": "ccdeck", ...extra };
|
|
277
|
+
if (token && origins.has(new URL(url).origin)) Object.assign(h, authHeaders(token));
|
|
278
|
+
return h;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/** Whether a redirect may take a read to `next` (off-origin redirects): on the
|
|
282
|
+
* connection's own origins, always; elsewhere only for `offOrigin:
|
|
283
|
+
* "public"` (a job log's signed blob address), and then only a public
|
|
284
|
+
* https host — never plain http, credentials in the address, this machine
|
|
285
|
+
* or a private network. A forge can thus never point the deck at the
|
|
286
|
+
* machine it runs on, the LAN or a cloud metadata address. */
|
|
287
|
+
function mayFollow(next, offOrigin) {
|
|
288
|
+
if (origins.has(next.origin)) return true;
|
|
289
|
+
if (offOrigin !== "public") return false;
|
|
290
|
+
return next.protocol === "https:" && !next.username && !next.password && !isPrivateHost(next.hostname);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/** One request, redirects followed by hand: the token is re-decided at
|
|
294
|
+
* every hop, so a hop off the allowed origins drops it. `allow`, when
|
|
295
|
+
* given, is asked about the address and every hop after it; otherwise a
|
|
296
|
+
* hop must pass mayFollow, which refuses any off the connection's origins
|
|
297
|
+
* unless `offOrigin` is "public" — and such a hop is made by
|
|
298
|
+
* publicFetchImpl, which refuses a name that resolves to a private
|
|
299
|
+
* address. `signal` gives up early, as well as the timeout. */
|
|
300
|
+
async function send(url, { method = "GET", headers, body, timeout = timeoutMs, allow, offOrigin = "refuse", signal: caller } = {}) {
|
|
301
|
+
let target = String(url);
|
|
302
|
+
const signal = caller ? anySignal(caller, AbortSignal.timeout(timeout)) : AbortSignal.timeout(timeout);
|
|
303
|
+
for (let hop = 0; ; hop++) {
|
|
304
|
+
if (allow && !allow(new URL(target))) {
|
|
305
|
+
throw new ForgeError("permission", `${new URL(target).origin} is not a host this connection reads from`, { status: 403 });
|
|
306
|
+
}
|
|
307
|
+
const origin = new URL(target).origin;
|
|
308
|
+
const road = allow || origins.has(origin) ? fetchImpl : publicFetchImpl;
|
|
309
|
+
let res;
|
|
310
|
+
try {
|
|
311
|
+
res = await road(target, { method, headers: headersFor(target, headers), body, redirect: "manual", signal });
|
|
312
|
+
} catch (err) {
|
|
313
|
+
if (errorCode(err) === NOT_PUBLIC) {
|
|
314
|
+
throw new ForgeError("permission", `${origin} is not a host this connection reads from`, { status: 403 });
|
|
315
|
+
}
|
|
316
|
+
throw networkError(err, origin);
|
|
317
|
+
}
|
|
318
|
+
const location = res.status >= 300 && res.status < 400 ? res.headers?.get?.("location") : null;
|
|
319
|
+
if (!location) return res;
|
|
320
|
+
res.body?.cancel?.().catch(() => {});
|
|
321
|
+
if (hop >= MAX_REDIRECTS) throw new ForgeError("upstream", "too many redirects", { status: res.status });
|
|
322
|
+
const next = new URL(location, target);
|
|
323
|
+
if (!allow && !mayFollow(next, offOrigin)) {
|
|
324
|
+
// A read of the API sent off it is not the API answering (a sign-in
|
|
325
|
+
// page on another host, a proxy): as a 2xx that is not JSON reads.
|
|
326
|
+
throw offOrigin === "public"
|
|
327
|
+
? new ForgeError("permission", `${next.origin} is not a host this connection reads from`, { status: 403 })
|
|
328
|
+
: new ForgeError("incompatible", `${new URL(target).origin} sent this read to ${next.origin}, not to this connection's address`, { status: res.status });
|
|
329
|
+
}
|
|
330
|
+
target = next.toString();
|
|
331
|
+
// A redirect is a GET from here on (303, and what browsers do for 302).
|
|
332
|
+
if (method !== "GET" && res.status !== 307 && res.status !== 308) {
|
|
333
|
+
method = "GET";
|
|
334
|
+
body = undefined;
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/** GET (or send) and parse JSON. A 2xx that is not JSON is the wrong
|
|
340
|
+
* address — a login page, a proxy's splash — and says so as incompatible. */
|
|
341
|
+
async function json(url, opts = {}) {
|
|
342
|
+
const res = await send(url, opts);
|
|
343
|
+
if (!res.ok) throw await httpError(res, now());
|
|
344
|
+
const type = res.headers?.get?.("content-type") ?? "";
|
|
345
|
+
if (!/json/i.test(type)) {
|
|
346
|
+
res.body?.cancel?.().catch(() => {});
|
|
347
|
+
throw new ForgeError("incompatible", `${new URL(url).origin} answered ${type || "without a content type"}, not the API`, { status: res.status });
|
|
348
|
+
}
|
|
349
|
+
let text;
|
|
350
|
+
try {
|
|
351
|
+
text = await cappedText(res, maxBytes);
|
|
352
|
+
} catch (err) {
|
|
353
|
+
throw new ForgeError("upstream", err?.message === "body too large" ? "answer too large" : "answer cut off", { status: res.status });
|
|
354
|
+
}
|
|
355
|
+
try {
|
|
356
|
+
return { data: JSON.parse(text), headers: res.headers, status: res.status };
|
|
357
|
+
} catch {
|
|
358
|
+
throw new ForgeError("upstream", "unreadable JSON", { status: res.status });
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/** POST a GraphQL query. Errors WITH data are returned for the caller to
|
|
363
|
+
* weigh (a field the token cannot see); errors without data throw. */
|
|
364
|
+
async function graphql(url, query, variables = {}) {
|
|
365
|
+
const { data: body } = await json(url, {
|
|
366
|
+
method: "POST",
|
|
367
|
+
headers: { "content-type": "application/json" },
|
|
368
|
+
body: JSON.stringify({ query, variables }),
|
|
369
|
+
});
|
|
370
|
+
const errors = Array.isArray(body?.errors) ? body.errors : [];
|
|
371
|
+
if (!body?.data) throw graphqlError(errors);
|
|
372
|
+
return { data: body.data, errors };
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/**
|
|
376
|
+
* The last `lines` lines of a text resource — a job's log. Asks for only the
|
|
377
|
+
* end first (a suffix Range); a server that ignores Range sends it all, and
|
|
378
|
+
* that is streamed with only the last lines kept, up to `maxBytes`.
|
|
379
|
+
*/
|
|
380
|
+
async function tail(url, { lines = 80, tailBytes = 256 << 10, maxBytes: cap = 64 << 20, clean = cleanLogLine } = {}) {
|
|
381
|
+
// GitHub answers a job's log with a redirect to a signed blob address on
|
|
382
|
+
// another host: a tail may follow one, to a public https host only.
|
|
383
|
+
const res = await send(url, { headers: { accept: "text/plain, */*", range: `bytes=-${tailBytes}` }, timeout: 60_000, offOrigin: "public" });
|
|
384
|
+
if (!res.ok) throw await httpError(res, now());
|
|
385
|
+
// A suffix range starts mid-line: the first line is a fragment.
|
|
386
|
+
let fragment = res.status === 206;
|
|
387
|
+
let kept = [];
|
|
388
|
+
// Blank lines (once cleaned) since the last real one. They are kept only
|
|
389
|
+
// when a real line follows them: the blank lines a log ends on are not
|
|
390
|
+
// shown, and must never take the place of real ones — however many there
|
|
391
|
+
// are, and wherever the body was cut into chunks.
|
|
392
|
+
let blanks = 0;
|
|
393
|
+
let dropped = false;
|
|
394
|
+
let carry = "";
|
|
395
|
+
let bytes = 0;
|
|
396
|
+
const decoder = new TextDecoder();
|
|
397
|
+
const keep = (raw) => {
|
|
398
|
+
if (fragment) {
|
|
399
|
+
fragment = false;
|
|
400
|
+
dropped = true;
|
|
401
|
+
return;
|
|
402
|
+
}
|
|
403
|
+
const line = clean(raw);
|
|
404
|
+
if (line === "") {
|
|
405
|
+
blanks++;
|
|
406
|
+
return;
|
|
407
|
+
}
|
|
408
|
+
if (blanks > 0) {
|
|
409
|
+
// More than the trim below keeps could never be shown.
|
|
410
|
+
const room = lines + TAIL_SLACK;
|
|
411
|
+
if (blanks > room) dropped = true;
|
|
412
|
+
for (let i = Math.min(blanks, room); i > 0; i--) kept.push("");
|
|
413
|
+
blanks = 0;
|
|
414
|
+
}
|
|
415
|
+
kept.push(line);
|
|
416
|
+
// Trimmed with room to spare while it streams.
|
|
417
|
+
if (kept.length > lines * 2 + TAIL_SLACK) {
|
|
418
|
+
kept = kept.slice(-(lines + TAIL_SLACK));
|
|
419
|
+
dropped = true;
|
|
420
|
+
}
|
|
421
|
+
};
|
|
422
|
+
const take = (text, final) => {
|
|
423
|
+
const parts = (carry + text).split("\n");
|
|
424
|
+
carry = final ? "" : parts.pop();
|
|
425
|
+
for (const p of parts) keep(p);
|
|
426
|
+
};
|
|
427
|
+
const reader = res.body?.getReader?.();
|
|
428
|
+
if (!reader) {
|
|
429
|
+
take(await res.text(), true);
|
|
430
|
+
} else {
|
|
431
|
+
for (;;) {
|
|
432
|
+
const { done, value } = await reader.read();
|
|
433
|
+
if (done) break;
|
|
434
|
+
bytes += value.byteLength;
|
|
435
|
+
if (bytes > cap) {
|
|
436
|
+
reader.cancel().catch(() => {});
|
|
437
|
+
throw new ForgeError("upstream", "log too large to read the end of");
|
|
438
|
+
}
|
|
439
|
+
take(decoder.decode(value, { stream: true }), false);
|
|
440
|
+
}
|
|
441
|
+
take(decoder.decode(), true);
|
|
442
|
+
}
|
|
443
|
+
let out = kept;
|
|
444
|
+
if (out.length > lines) {
|
|
445
|
+
out = out.slice(-lines);
|
|
446
|
+
dropped = true;
|
|
447
|
+
}
|
|
448
|
+
return { lines: out, truncated: dropped };
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
/**
|
|
452
|
+
* An image for the panel — an avatar, a screenshot in a description — for
|
|
453
|
+
* the deck to pass on from its own origin. `allow` names the hosts the
|
|
454
|
+
* forge serves images from and is asked again at every redirect, so the
|
|
455
|
+
* deck is never sent off to fetch an arbitrary address; the token rides
|
|
456
|
+
* only where it always may. What comes back must be a raster image under
|
|
457
|
+
* `maxBytes`.
|
|
458
|
+
*
|
|
459
|
+
* @returns {Promise<{ contentType: string, bytes: Buffer }>}
|
|
460
|
+
*/
|
|
461
|
+
async function asset(url, { allow, maxBytes: cap = ASSET_MAX_BYTES, signal }) {
|
|
462
|
+
const res = await send(url, { headers: { accept: "image/webp,image/png,image/jpeg,image/gif;q=0.9,*/*;q=0.1" }, timeout: 15_000, allow, signal });
|
|
463
|
+
if (!res.ok) throw await httpError(res, now());
|
|
464
|
+
const type = (res.headers?.get?.("content-type") ?? "").split(";")[0].trim().toLowerCase();
|
|
465
|
+
const declared = Number(res.headers?.get?.("content-length") ?? NaN);
|
|
466
|
+
if (!RASTER.test(type) || declared > cap) {
|
|
467
|
+
res.body?.cancel?.().catch(() => {});
|
|
468
|
+
throw RASTER.test(type)
|
|
469
|
+
? new ForgeError("upstream", "image too large", { status: res.status })
|
|
470
|
+
: new ForgeError("incompatible", `not an image (${type || "no content type"})`, { status: res.status });
|
|
471
|
+
}
|
|
472
|
+
try {
|
|
473
|
+
return { contentType: type, bytes: await cappedBytes(res, cap) };
|
|
474
|
+
} catch (err) {
|
|
475
|
+
throw new ForgeError("upstream", err?.message === "body too large" ? "image too large" : "image cut off", { status: res.status });
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
return { send, json, graphql, tail, asset };
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
/** Aborted when either is: AbortSignal.any where Node has it (20.3+). */
|
|
483
|
+
function anySignal(a, b) {
|
|
484
|
+
if (typeof AbortSignal.any === "function") return AbortSignal.any([a, b]);
|
|
485
|
+
const controller = new AbortController();
|
|
486
|
+
for (const s of [a, b]) {
|
|
487
|
+
if (s.aborted) controller.abort(s.reason);
|
|
488
|
+
else s.addEventListener("abort", () => controller.abort(s.reason), { once: true });
|
|
489
|
+
}
|
|
490
|
+
return controller.signal;
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
/** The body as bytes, refused past `max` while it is still arriving. */
|
|
494
|
+
async function cappedBytes(res, max) {
|
|
495
|
+
const reader = res.body?.getReader?.();
|
|
496
|
+
if (!reader) {
|
|
497
|
+
const buf = Buffer.from(await res.arrayBuffer());
|
|
498
|
+
if (buf.byteLength > max) throw new Error("body too large");
|
|
499
|
+
return buf;
|
|
500
|
+
}
|
|
501
|
+
const chunks = [];
|
|
502
|
+
let bytes = 0;
|
|
503
|
+
for (;;) {
|
|
504
|
+
const { done, value } = await reader.read();
|
|
505
|
+
if (done) break;
|
|
506
|
+
bytes += value.byteLength;
|
|
507
|
+
if (bytes > max) {
|
|
508
|
+
reader.cancel().catch(() => {});
|
|
509
|
+
throw new Error("body too large");
|
|
510
|
+
}
|
|
511
|
+
chunks.push(Buffer.from(value.buffer, value.byteOffset, value.byteLength));
|
|
512
|
+
}
|
|
513
|
+
return Buffer.concat(chunks);
|
|
514
|
+
}
|