ccdeck 3.44.0 → 3.46.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/README.md +21 -9
  2. package/THIRD_PARTY_NOTICES.md +27 -0
  3. package/bin/cli/startup.js +4 -3
  4. package/dist/web/assets/BrowserWatchModal-CoX9FIUE.js +1 -0
  5. package/dist/web/assets/IntegrationModal-B1ypkK-E.js +5 -0
  6. package/dist/web/assets/RadioPanel-biw_BbZq.js +1 -0
  7. package/dist/web/assets/TrafficRadar-nhtFoYVv.js +8 -0
  8. package/dist/web/assets/{UsageHistoryModal-Du0ZkIJ1.js → UsageHistoryModal-Bd-JRAa4.js} +1 -1
  9. package/dist/web/assets/app-BlRY6EME.css +1 -0
  10. package/dist/web/assets/app-DhGvSH7o.js +319 -0
  11. package/dist/web/assets/{index-BQbOfxbL.js → index-DmENqVdt.js} +1 -1
  12. package/dist/web/index.html +3 -3
  13. package/package.json +1 -1
  14. package/release-notes.json +48 -0
  15. package/src/server/account-notify.mjs +68 -6
  16. package/src/server/account-watch.mjs +61 -2
  17. package/src/server/claude-accounts.mjs +45 -19
  18. package/src/server/claude-login.mjs +111 -0
  19. package/src/server/cswap-admin.mjs +12 -3
  20. package/src/server/deck-prefs.mjs +15 -5
  21. package/src/server/feature-use.mjs +11 -1
  22. package/src/server/forge-http.mjs +514 -0
  23. package/src/server/forge-public-fetch.mjs +228 -0
  24. package/src/server/forge-routes.mjs +154 -0
  25. package/src/server/github-api.mjs +1369 -0
  26. package/src/server/gitlab-api.mjs +1271 -0
  27. package/src/server/index.mjs +6 -2
  28. package/src/server/installer.mjs +24 -7
  29. package/src/server/integrations/cache.mjs +42 -0
  30. package/src/server/integrations/errors.mjs +167 -0
  31. package/src/server/integrations/forge.mjs +76 -0
  32. package/src/server/integrations/jira.mjs +1026 -0
  33. package/src/server/integrations/providers.mjs +11 -0
  34. package/src/server/integrations/proxy.mjs +314 -0
  35. package/src/server/integrations/registry.mjs +142 -0
  36. package/src/server/integrations/routes.mjs +498 -0
  37. package/src/server/integrations/store.mjs +226 -0
  38. package/src/server/lan-deck.mjs +7 -0
  39. package/src/server/lan-elsewhere.d.mts +30 -0
  40. package/src/server/lan-elsewhere.mjs +137 -0
  41. package/src/server/login-service.mjs +2 -2
  42. package/src/server/org-telemetry.mjs +173 -0
  43. package/src/server/pinned-build.mjs +3 -0
  44. package/src/server/request-gates.mjs +19 -0
  45. package/src/server/skill-hook.mjs +28 -22
  46. package/src/server/system-metrics.mjs +4 -0
  47. package/src/server/tool-audit-routes.mjs +4 -0
  48. package/src/server/traffic-radar-config.mjs +33 -0
  49. package/src/server/traffic-radar-platform.mjs +84 -17
  50. package/src/server/traffic-radar-service.mjs +6 -3
  51. package/src/server/traffic-radar.mjs +32 -9
  52. package/src/server/usage-day.mjs +2 -0
  53. package/dist/web/assets/BrowserWatchModal-DMIUygWz.js +0 -1
  54. package/dist/web/assets/RadioPanel-DNB_AHSs.js +0 -1
  55. package/dist/web/assets/TrafficRadar-Ch0LVdSl.js +0 -8
  56. package/dist/web/assets/app-CQIstkMF.js +0 -318
  57. package/dist/web/assets/app-Ch-0CYlK.css +0 -1
@@ -0,0 +1,228 @@
1
+ // The road a forge read takes when it leaves the connection's own hosts.
2
+ //
3
+ // A job log's tail may follow a redirect to a host nobody typed: GitHub sends
4
+ // it to a signed blob-storage address. forge-http.mjs lets such a hop go only
5
+ // to a public https NAME, but a name is not where a socket goes: whoever runs
6
+ // its DNS can answer 127.0.0.1, a LAN address or the cloud metadata address,
7
+ // and answer differently the second time it is asked (DNS rebinding). So a
8
+ // request to such a host is made here, not through fetch: the name is resolved
9
+ // once, by `publicLookup`, every address it gave is checked, and the socket
10
+ // connects to the checked address and nothing else. One answer that is not a
11
+ // public address refuses the whole name.
12
+ //
13
+ // Refused: loopback, RFC 1918, link-local (169.254/16, fe80::/10), CGNAT
14
+ // (100.64/10), unique-local (fc00::/7), multicast, the unspecified and
15
+ // reserved ranges, the documentation and benchmarking ranges, and every IPv6
16
+ // form that carries one of those IPv4 addresses (::ffff:a.b.c.d, ::a.b.c.d,
17
+ // 64:ff9b::/96, 2002::/16).
18
+ //
19
+ // The connection's own hosts never come here: a self-hosted instance on the
20
+ // LAN is where the user pointed the deck, and fetch keeps reading it.
21
+ import { lookup as dnsLookup } from "node:dns";
22
+ import { request } from "node:https";
23
+ import { isIP } from "node:net";
24
+
25
+ /** The error code a refused address carries; forge-http.mjs answers it as a
26
+ * permission failure, never as the network's. */
27
+ export const NOT_PUBLIC = "ERR_FORGE_NOT_PUBLIC";
28
+
29
+ function notPublic(host) {
30
+ const err = new Error(`${host} is not a public address`);
31
+ err.code = NOT_PUBLIC;
32
+ return err;
33
+ }
34
+
35
+ function v4Public([a, b, c]) {
36
+ if (a === 0 || a === 10 || a === 127) return false;
37
+ if (a === 100 && b >= 64 && b <= 127) return false; // CGNAT
38
+ if (a === 169 && b === 254) return false; // link-local, cloud metadata
39
+ if (a === 172 && b >= 16 && b <= 31) return false;
40
+ if (a === 192 && b === 168) return false;
41
+ if (a === 192 && b === 0 && (c === 0 || c === 2)) return false; // IETF assignments, TEST-NET-1
42
+ if (a === 198 && (b === 18 || b === 19)) return false; // benchmarking
43
+ if (a === 198 && b === 51 && c === 100) return false; // TEST-NET-2
44
+ if (a === 203 && b === 0 && c === 113) return false; // TEST-NET-3
45
+ return a < 224; // multicast, reserved, broadcast
46
+ }
47
+
48
+ function v4Of(text) {
49
+ const parts = text.split(".").map(Number);
50
+ return parts.length === 4 && parts.every(n => Number.isInteger(n) && n >= 0 && n <= 255) ? parts : null;
51
+ }
52
+
53
+ /** An IPv6 address as its eight 16-bit groups, or null. */
54
+ function v6Groups(text) {
55
+ let s = text.toLowerCase().split("%")[0];
56
+ const dotted = /^(.*:)(\d+\.\d+\.\d+\.\d+)$/.exec(s);
57
+ if (dotted) {
58
+ const v4 = v4Of(dotted[2]);
59
+ if (!v4) return null;
60
+ s = `${dotted[1]}${((v4[0] << 8) | v4[1]).toString(16)}:${((v4[2] << 8) | v4[3]).toString(16)}`;
61
+ }
62
+ const halves = s.split("::");
63
+ if (halves.length > 2) return null;
64
+ const head = halves[0] ? halves[0].split(":") : [];
65
+ const tail = halves.length === 2 && halves[1] ? halves[1].split(":") : [];
66
+ const fill = halves.length === 2 ? 8 - head.length - tail.length : 0;
67
+ if (fill < 0) return null;
68
+ const groups = [...head, ...Array(fill).fill("0"), ...tail].map(g => Number.parseInt(g, 16));
69
+ return groups.length === 8 && groups.every(g => Number.isInteger(g) && g >= 0 && g <= 0xffff) ? groups : null;
70
+ }
71
+
72
+ const embedded = (hi, lo) => [hi >> 8, hi & 0xff, lo >> 8, lo & 0xff];
73
+
74
+ /** Whether a socket may connect to `address` for a host nobody typed. Anything
75
+ * that is not an IP address is not public. */
76
+ export function isPublicAddress(address) {
77
+ const text = String(address ?? "").replace(/^\[|\]$/g, "");
78
+ const family = isIP(text);
79
+ if (family === 4) return v4Public(v4Of(text));
80
+ if (family !== 6) return false;
81
+ const g = v6Groups(text);
82
+ if (!g) return false;
83
+ // ::/96 (unspecified, loopback, IPv4-compatible) and ::ffff:0:0/96 (mapped).
84
+ if (g.slice(0, 5).every(x => x === 0) && (g[5] === 0 || g[5] === 0xffff)) {
85
+ if (g[5] === 0 && g[6] === 0 && g[7] <= 1) return false;
86
+ return v4Public(embedded(g[6], g[7]));
87
+ }
88
+ if (g[0] === 0x64 && g[1] === 0xff9b) {
89
+ // NAT64: the well-known prefix carries an IPv4 address; the local-use one is private.
90
+ return g[2] === 0 && g[3] === 0 && g[4] === 0 && g[5] === 0 && v4Public(embedded(g[6], g[7]));
91
+ }
92
+ if (g[0] === 0x2002) return v4Public(embedded(g[1], g[2])); // 6to4
93
+ if (g[0] === 0x0100 && g[1] === 0 && g[2] === 0 && g[3] === 0) return false; // discard-only
94
+ if (g[0] === 0x2001 && (g[1] === 0 || g[1] === 0xdb8)) return false; // Teredo, documentation
95
+ if ((g[0] & 0xfe00) === 0xfc00) return false; // unique-local
96
+ if ((g[0] & 0xffc0) === 0xfe80 || (g[0] & 0xffc0) === 0xfec0) return false; // link-local, site-local
97
+ if ((g[0] & 0xff00) === 0xff00) return false; // multicast
98
+ return true;
99
+ }
100
+
101
+ /**
102
+ * A `lookup` for a socket (the shape of dns.lookup) that resolves the name
103
+ * with `resolve` and hands the socket only public addresses. Every address
104
+ * the name has is checked, and one that is not public refuses the name: a
105
+ * socket that would try them in turn must not reach a private one.
106
+ */
107
+ export function publicLookup(resolve = dnsLookup) {
108
+ return function lookup(hostname, options, callback) {
109
+ if (typeof options === "function") {
110
+ callback = options;
111
+ options = {};
112
+ }
113
+ const opts = typeof options === "number" ? { family: options } : { ...(options ?? {}) };
114
+ resolve(hostname, { ...opts, all: true }, (err, found, foundFamily) => {
115
+ if (err) return callback(err);
116
+ const list = (Array.isArray(found) ? found : typeof found === "string" ? [{ address: found, family: foundFamily }] : [])
117
+ .filter(a => a && typeof a.address === "string")
118
+ .map(a => ({ address: a.address, family: a.family === 4 || a.family === 6 ? a.family : isIP(a.address) }));
119
+ if (list.length === 0) {
120
+ const none = new Error(`${hostname} did not resolve`);
121
+ none.code = "ENOTFOUND";
122
+ return callback(none);
123
+ }
124
+ if (list.some(a => !isPublicAddress(a.address))) return callback(notPublic(hostname));
125
+ if (opts.all) return callback(null, list);
126
+ return callback(null, list[0].address, list[0].family);
127
+ });
128
+ };
129
+ }
130
+
131
+ /**
132
+ * fetch for a host nobody typed: https only, the name resolved by
133
+ * publicLookup and the socket connected to what it checked. It answers a
134
+ * Response as fetch does, never follows a redirect (forge-http.mjs decides
135
+ * every hop), and keeps no connection open for another request.
136
+ *
137
+ * @param {{ resolve?: typeof dnsLookup }} [opts]
138
+ * @returns {typeof fetch}
139
+ */
140
+ export function publicFetch({ resolve = dnsLookup } = {}) {
141
+ const lookup = publicLookup(resolve);
142
+ return (url, { method = "GET", headers = {}, body, signal } = {}) => new Promise((done, fail) => {
143
+ let u;
144
+ try {
145
+ u = new URL(String(url));
146
+ } catch (err) {
147
+ fail(err);
148
+ return;
149
+ }
150
+ const host = u.hostname.replace(/^\[|\]$/g, "");
151
+ // A socket asks no lookup about an address, so an address is checked here.
152
+ if (u.protocol !== "https:" || u.username || u.password || (isIP(host) && !isPublicAddress(host))) {
153
+ fail(notPublic(host));
154
+ return;
155
+ }
156
+ const req = request({
157
+ host,
158
+ port: u.port || 443,
159
+ path: `${u.pathname}${u.search}`,
160
+ method,
161
+ headers,
162
+ lookup,
163
+ signal,
164
+ agent: false,
165
+ }, (res) => {
166
+ try {
167
+ done(responseOf(res, method));
168
+ } catch (err) {
169
+ res.destroy();
170
+ fail(err);
171
+ }
172
+ });
173
+ req.on("error", fail);
174
+ req.end(body ?? undefined);
175
+ });
176
+ }
177
+
178
+ /** An http.IncomingMessage as fetch's Response: its status, its headers and
179
+ * its body as a stream, read as it arrives (a log tail keeps only its end). */
180
+ export function responseOf(res, method = "GET") {
181
+ const headers = new Headers();
182
+ for (const [name, value] of Object.entries(res.headers)) {
183
+ if (Array.isArray(value)) for (const v of value) headers.append(name, v);
184
+ else if (value != null) headers.set(name, String(value));
185
+ }
186
+ const status = res.statusCode ?? 0;
187
+ const empty = method === "HEAD" || status === 204 || status === 205 || status === 304;
188
+ const response = new Response(empty ? null : streamOf(res), { status, statusText: res.statusMessage ?? "", headers });
189
+ if (empty) res.resume();
190
+ return response;
191
+ }
192
+
193
+ /** A Node stream as a web ReadableStream, paused while the reader is behind.
194
+ * Not Readable.toWeb: on Node 18 it can throw "Controller is already closed"
195
+ * out of a close event, where nothing catches it. Nothing here throws. */
196
+ function streamOf(res) {
197
+ let settled = false;
198
+ const settle = (fn) => {
199
+ if (settled) return;
200
+ settled = true;
201
+ try { fn(); } catch { /* the reader has gone */ }
202
+ };
203
+ return new ReadableStream({
204
+ start(controller) {
205
+ res.on("data", (chunk) => {
206
+ if (settled) return;
207
+ try {
208
+ controller.enqueue(new Uint8Array(chunk));
209
+ if ((controller.desiredSize ?? 1) <= 0) res.pause();
210
+ } catch {
211
+ settled = true;
212
+ res.destroy();
213
+ }
214
+ });
215
+ res.on("end", () => settle(() => controller.close()));
216
+ res.on("error", (err) => settle(() => controller.error(err)));
217
+ // Closed before its end: the connection was cut.
218
+ res.on("close", () => settle(() => controller.error(Object.assign(new Error("answer cut off"), { code: "ECONNRESET" }))));
219
+ },
220
+ pull() {
221
+ res.resume();
222
+ },
223
+ cancel() {
224
+ settled = true;
225
+ res.destroy();
226
+ },
227
+ });
228
+ }
@@ -0,0 +1,154 @@
1
+ // The GitHub and GitLab panels' reads, as one route each (#2179).
2
+ //
3
+ // The integrations core (#2178) owns the address — /api/integrations/<kind>/
4
+ // <connId>/… — the connection, and the token; it hands this file a client made
5
+ // for that connection (github-api.mjs or gitlab-api.mjs, the same interface)
6
+ // and the rest of the path. What is here is the part that is the same for both
7
+ // forges: which sub-path is which read, what its parameters must look like
8
+ // before anything is asked of the forge, and how an answer or a failure goes
9
+ // back to the page.
10
+ //
11
+ // GET ONLY, AND EVERY PARAMETER CHECKED. The panels read; nothing here writes
12
+ // to a forge, and a parameter that is not the shape it should be is a 400
13
+ // before the forge hears of it — a repository path is a path, a number is a
14
+ // positive integer, a filter is one of its words.
15
+ //
16
+ // ONE ENVELOPE FOR EVERY INTEGRATION. A failure answers { ok: false, error:
17
+ // { kind, message, status?, retryAfter? } } — the same shape the Jira panel's
18
+ // reads answer in (#2178) — with the HTTP status the kind implies: 401 for a
19
+ // token refused or expired, 403 permission, 404 not found, 429 rate limit,
20
+ // 502 for a forge that could not be reached or answered wrongly, 400 for a
21
+ // parameter that is not the shape it should be. The page picks its recovery
22
+ // from `kind`, never from the status.
23
+ import { forgeErrorInfo } from "./forge-http.mjs";
24
+
25
+ const STATUS_OF_KIND = {
26
+ auth: 401, expired: 401, permission: 403, "not-found": 404, "rate-limit": 429,
27
+ unreachable: 502, tls: 502, incompatible: 502, upstream: 502, invalid: 400,
28
+ };
29
+
30
+ /** The answer for a failure: the uniform envelope and the status its kind implies. */
31
+ export function failure(info, status = STATUS_OF_KIND[info.kind] ?? 502) {
32
+ return { status, body: { ok: false, error: info } };
33
+ }
34
+
35
+ const REPO = /^[A-Za-z0-9_.-]+(?:\/[A-Za-z0-9_.-]+){1,20}$/;
36
+ // "~" joins the time and the run ids of GitHub's Actions list across
37
+ // repositories (github-api.mjs listPipelines): without it that list stopped
38
+ // at its first page.
39
+ const CURSOR = /^[A-Za-z0-9_=+/.:~-]{1,512}$/;
40
+ const JOB_ID = /^[A-Za-z0-9_-]{1,64}$/;
41
+
42
+ class BadRequest extends Error {}
43
+
44
+ function repo(params, required = true) {
45
+ const v = params.get("repo");
46
+ if (v == null || v === "") {
47
+ if (required) throw new BadRequest("repo is required");
48
+ return null;
49
+ }
50
+ if (v.length > 255 || !REPO.test(v) || v.split("/").some(s => s === "." || s === "..")) throw new BadRequest("repo is not a repository path");
51
+ return v;
52
+ }
53
+
54
+ // Up to fifteen digits: GitHub's workflow run ids are past ten billion already,
55
+ // and fifteen still fits a JavaScript number exactly.
56
+ function number(params, name = "number") {
57
+ const v = params.get(name);
58
+ if (!/^\d{1,15}$/.test(v ?? "") || Number(v) < 1) throw new BadRequest(`${name} is not a positive number`);
59
+ return Number(v);
60
+ }
61
+
62
+ // A pipeline's id as the clients hand them out: a number (a GitLab pipeline, an
63
+ // Actions run), or on GitHub another app's check suite (`suite:<id>`) or a
64
+ // commit's statuses (`status:<sha>`), which listChangePipelines lists beside
65
+ // the runs.
66
+ const PIPELINE_ID = /^(?:suite:\d{1,15}|status:[0-9a-f]{7,64})$/;
67
+
68
+ function pipelineId(params) {
69
+ const v = params.get("id") ?? "";
70
+ return PIPELINE_ID.test(v) ? v : number(params, "id");
71
+ }
72
+
73
+ function oneOf(params, name, words, fallback) {
74
+ const v = params.get(name) ?? fallback;
75
+ if (!words.includes(v)) throw new BadRequest(`${name} must be one of ${words.join(", ")}`);
76
+ return v;
77
+ }
78
+
79
+ function cursor(params) {
80
+ const v = params.get("cursor");
81
+ if (v == null || v === "") return undefined;
82
+ if (!CURSOR.test(v)) throw new BadRequest("cursor is not a cursor");
83
+ return v;
84
+ }
85
+
86
+ function text(params, name, max) {
87
+ const v = params.get(name);
88
+ if (v == null) return undefined;
89
+ if (v.length > max) throw new BadRequest(`${name} is too long`);
90
+ return v;
91
+ }
92
+
93
+ /** Sub-path → the read it is. Each takes the query and the client. */
94
+ const READS = {
95
+ "repos": (p, c) => c.listRepos({ search: text(p, "search", 100), cursor: cursor(p) }),
96
+ "branches": (p, c) => c.listBranches(repo(p), { cursor: cursor(p) }),
97
+ "branch": (p, c) => {
98
+ const name = text(p, "name", 255);
99
+ if (!name || /[\0\s]/.test(name)) throw new BadRequest("name is not a branch name");
100
+ return c.getBranch(repo(p), name);
101
+ },
102
+ "changes": (p, c) => c.listChanges({
103
+ repo: repo(p, false),
104
+ filter: oneOf(p, "filter", ["all", "mine", "review"], "all"),
105
+ state: oneOf(p, "state", ["open", "merged", "closed"], "open"),
106
+ sort: oneOf(p, "sort", ["created", "updated"], "created"),
107
+ cursor: cursor(p),
108
+ }),
109
+ "change-counts": (p, c) => c.countChanges({ repo: repo(p, false), filter: oneOf(p, "filter", ["all", "mine", "review"], "all") }),
110
+ "change": (p, c) => c.getChange(repo(p), number(p)),
111
+ "change/commits": (p, c) => c.listChangeCommits(repo(p), number(p), { cursor: cursor(p) }),
112
+ "change/pipelines": (p, c) => c.listChangePipelines(repo(p), number(p)),
113
+ "change/files": (p, c) => c.listChangeFiles(repo(p), number(p), { cursor: cursor(p) }),
114
+ "pipelines": (p, c) => c.listPipelines({ repo: repo(p, false), cursor: cursor(p) }),
115
+ "pipeline": (p, c) => c.getPipeline(repo(p), pipelineId(p)),
116
+ "job-log": (p, c) => {
117
+ const id = p.get("id") ?? "";
118
+ if (!JOB_ID.test(id)) throw new BadRequest("id is not a job id");
119
+ const lines = p.get("lines");
120
+ return c.jobLogTail(repo(p), id, { lines: lines && /^\d{1,6}$/.test(lines) ? Math.min(500, Math.max(10, Number(lines))) : 80 });
121
+ },
122
+ "issues": (p, c) => c.listIssues({ repo: repo(p, false), state: oneOf(p, "state", ["open", "closed"], "open"), cursor: cursor(p) }),
123
+ "issue": (p, c) => c.getIssue(repo(p), number(p)),
124
+ "issue/comments": (p, c) => c.listIssueComments(repo(p), number(p), { cursor: cursor(p) }),
125
+ "resolve": (p, c) => {
126
+ const q = text(p, "q", 500)?.trim();
127
+ if (!q) throw new BadRequest("q is required");
128
+ return c.resolveRef(q);
129
+ },
130
+ };
131
+
132
+ /** The sub-paths there are, for the core's route table and for tests. */
133
+ export const FORGE_READS = Object.freeze(Object.keys(READS));
134
+
135
+ /**
136
+ * One panel read.
137
+ *
138
+ * @param {object} client a github-api.mjs or gitlab-api.mjs client for the connection
139
+ * @param {string} method the request's method
140
+ * @param {string} subpath what follows /api/integrations/<kind>/<connId>/, without a leading slash
141
+ * @param {URLSearchParams} params
142
+ * @returns {Promise<{ status: number, body: object }>}
143
+ */
144
+ export async function forgeRead(client, method, subpath, params) {
145
+ const read = Object.hasOwn(READS, subpath) ? READS[subpath] : null;
146
+ if (!read) return failure({ kind: "not-found", message: "no such read" });
147
+ if (method !== "GET") return failure({ kind: "upstream", message: "the panels only read" }, 405);
148
+ try {
149
+ return { status: 200, body: { ok: true, data: await read(params, client) } };
150
+ } catch (err) {
151
+ if (err instanceof BadRequest) return failure({ kind: "invalid", message: err.message });
152
+ return failure(forgeErrorInfo(err));
153
+ }
154
+ }