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.
- package/README.md +21 -9
- package/THIRD_PARTY_NOTICES.md +27 -0
- package/bin/cli/startup.js +4 -3
- package/dist/web/assets/BrowserWatchModal-CoX9FIUE.js +1 -0
- package/dist/web/assets/IntegrationModal-B1ypkK-E.js +5 -0
- package/dist/web/assets/RadioPanel-biw_BbZq.js +1 -0
- package/dist/web/assets/TrafficRadar-nhtFoYVv.js +8 -0
- package/dist/web/assets/{UsageHistoryModal-Du0ZkIJ1.js → UsageHistoryModal-Bd-JRAa4.js} +1 -1
- package/dist/web/assets/app-BlRY6EME.css +1 -0
- package/dist/web/assets/app-DhGvSH7o.js +319 -0
- package/dist/web/assets/{index-BQbOfxbL.js → index-DmENqVdt.js} +1 -1
- package/dist/web/index.html +3 -3
- package/package.json +1 -1
- package/release-notes.json +48 -0
- package/src/server/account-notify.mjs +68 -6
- package/src/server/account-watch.mjs +61 -2
- package/src/server/claude-accounts.mjs +45 -19
- package/src/server/claude-login.mjs +111 -0
- package/src/server/cswap-admin.mjs +12 -3
- 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/installer.mjs +24 -7
- 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/login-service.mjs +2 -2
- 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/skill-hook.mjs +28 -22
- package/src/server/system-metrics.mjs +4 -0
- package/src/server/tool-audit-routes.mjs +4 -0
- package/src/server/traffic-radar-config.mjs +33 -0
- package/src/server/traffic-radar-platform.mjs +84 -17
- package/src/server/traffic-radar-service.mjs +6 -3
- package/src/server/traffic-radar.mjs +32 -9
- 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/TrafficRadar-Ch0LVdSl.js +0 -8
- package/dist/web/assets/app-CQIstkMF.js +0 -318
- package/dist/web/assets/app-Ch-0CYlK.css +0 -1
package/src/server/index.mjs
CHANGED
|
@@ -19,7 +19,7 @@ import { challengeDeck, challengeProof, isProcessAlive } from "./deck-probe.mjs"
|
|
|
19
19
|
export { challengeDeck, challengeProof, isProcessAlive };
|
|
20
20
|
// The gates in front of the route table — see src/server/request-gates.mjs,
|
|
21
21
|
// which also holds the per-process token the strictest of them checks.
|
|
22
|
-
import {
|
|
22
|
+
import { OPEN_MUTATIONS, isAuthorizedDataRead, isAuthorizedMutation, isGuardedRead, isTrustedMutation, isTrustedRead } from "./request-gates.mjs";
|
|
23
23
|
import { trafficCapture } from "./traffic-radar-capture.mjs";
|
|
24
24
|
import { trafficRadar } from "./traffic-radar.mjs";
|
|
25
25
|
import { handleTrafficCapture, isAuthorizedTrafficIngest } from "./traffic-radar-routes.mjs";
|
|
@@ -173,6 +173,9 @@ import { startToolAudit } from "./tool-audit.mjs";
|
|
|
173
173
|
import { handleToolAudit } from "./tool-audit-routes.mjs";
|
|
174
174
|
import { startQuotaHistory } from "./quota-history.mjs";
|
|
175
175
|
import { handleCcusage, handleCcusageSession, handleCodexQuota, handleCodexUsage, handleProviderStatus, handleQuota, handleQuotaHistory } from "./usage-routes.mjs";
|
|
176
|
+
// Connected trackers (#2178) and each provider's own reads. See
|
|
177
|
+
// integrations/routes.mjs; nothing in it is shared with paired decks.
|
|
178
|
+
import { handleIntegrations } from "./integrations/routes.mjs";
|
|
176
179
|
// The port fallback, one listen attempt, and the words for a failed one — see
|
|
177
180
|
// listen.mjs. The loop that uses them is startServer's.
|
|
178
181
|
import { listenFailure, portRetryable, randomPort, tryListen } from "./listen.mjs";
|
|
@@ -301,7 +304,7 @@ export async function startServer({ port = 4317, host = "127.0.0.1", persist = n
|
|
|
301
304
|
// gate above was written for a sandboxed subprocess with loopback egress,
|
|
302
305
|
// and that caller was reading the ring through a GET the whole time.
|
|
303
306
|
if ((req.method === "GET" || req.method === "HEAD")
|
|
304
|
-
&&
|
|
307
|
+
&& isGuardedRead(url.pathname) && !isAuthorizedDataRead(req)) {
|
|
305
308
|
return send(res, 401, { error: "unauthenticated" });
|
|
306
309
|
}
|
|
307
310
|
|
|
@@ -409,6 +412,7 @@ export async function startServer({ port = 4317, host = "127.0.0.1", persist = n
|
|
|
409
412
|
if (req.method === "GET" && url.pathname === "/api/best-of-nostalgia") return guard(handleBestOfNostalgia(req, res), res);
|
|
410
413
|
if (req.method === "GET" && url.pathname === "/api/good-life-radio") return guard(handleGoodLifeRadio(req, res), res);
|
|
411
414
|
if (req.method === "GET" && url.pathname === "/api/cafe-music-bgm") return guard(handleCafeMusicBgm(req, res), res);
|
|
415
|
+
if (url.pathname === "/api/integrations" || url.pathname.startsWith("/api/integrations/")) return guard(handleIntegrations(req, res, url), res);
|
|
412
416
|
|
|
413
417
|
// Through writeJsonArray rather than `send`, and through `guard` like every
|
|
414
418
|
// route above it: this is the one answer whose size is the ring's size, and
|
package/src/server/installer.mjs
CHANGED
|
@@ -18,9 +18,9 @@ import { shellQuoteArg } from "./exec.mjs";
|
|
|
18
18
|
// The read-before-rewrite and the atomic replace every settings writer goes
|
|
19
19
|
// through — see atomic-write.mjs.
|
|
20
20
|
import { readSettingsForWrite, writeFileAtomic, createTemp, renameWithRetry } from "./atomic-write.mjs";
|
|
21
|
-
// Skill activity's typed-command entry, its
|
|
22
|
-
// skill-hook.mjs for why it is not in CLAUDE_EVENTS.
|
|
23
|
-
import { USER_PROMPT_EXPANSION, skillHookDecision } from "./skill-hook.mjs";
|
|
21
|
+
// Skill activity's typed-command entry, its off switch and its version gate —
|
|
22
|
+
// see skill-hook.mjs for why it is not in CLAUDE_EVENTS.
|
|
23
|
+
import { USER_PROMPT_EXPANSION, skillHookDecision, skillHookEnabled } from "./skill-hook.mjs";
|
|
24
24
|
// The discovery dir every deck registers in and every hook reads — see
|
|
25
25
|
// discovery.mjs, which keeps this deck's record there.
|
|
26
26
|
import { AGENT_DAG_DIR } from "./discovery.mjs";
|
|
@@ -87,8 +87,8 @@ const PROVIDERS = {
|
|
|
87
87
|
hookInstallDir: join(CLAUDE_DIR, "agent-dag"),
|
|
88
88
|
events: CLAUDE_EVENTS,
|
|
89
89
|
versioned: VERSIONED_CLAUDE_EVENTS,
|
|
90
|
-
// UserPromptExpansion, for Skill activity (#2181):
|
|
91
|
-
// every Claude Code found being 2.1.116 or newer (skill-hook.mjs).
|
|
90
|
+
// UserPromptExpansion, for Skill activity (#2181): on unless turned off,
|
|
91
|
+
// and gated on every Claude Code found being 2.1.116 or newer (skill-hook.mjs).
|
|
92
92
|
skillHook: true,
|
|
93
93
|
ensureDir: CLAUDE_DIR,
|
|
94
94
|
},
|
|
@@ -445,10 +445,27 @@ async function decideSkillHook(options, settings, before, backupPath) {
|
|
|
445
445
|
return decision;
|
|
446
446
|
}
|
|
447
447
|
|
|
448
|
+
/**
|
|
449
|
+
* Whether Claude Code reports typed slash commands to this deck now, for the
|
|
450
|
+
* Skills tab: "on" when our UserPromptExpansion entry is in settings.json,
|
|
451
|
+
* "off" when the deck was started with the entry turned off, and "skipped"
|
|
452
|
+
* when the gate or the file kept it out (the boot report says which). Read
|
|
453
|
+
* from the file rather than remembered from the boot, because the launcher
|
|
454
|
+
* installed it in another process and the file is what Claude Code reads.
|
|
455
|
+
*/
|
|
456
|
+
export async function skillHookState(env = process.env, settingsPath = PROVIDERS.claude.settingsPath) {
|
|
457
|
+
if (!skillHookEnabled(env)) return "off";
|
|
458
|
+
try {
|
|
459
|
+
return holdsOurs(JSON.parse(await readFile(settingsPath, "utf8"))?.hooks?.[USER_PROMPT_EXPANSION]) ? "on" : "skipped";
|
|
460
|
+
} catch {
|
|
461
|
+
return "skipped";
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
|
|
448
465
|
/** Install hooks for a single provider. Returns {settingsPath, hookPath, events, changed}.
|
|
449
466
|
* `claudeVersions` is the suite's: the Claude Code versions to gate the
|
|
450
467
|
* versioned events on, in place of the ones on this machine's disk.
|
|
451
|
-
* `skillHook` is the suite's too: skillHookDecision's options (the
|
|
468
|
+
* `skillHook` is the suite's too: skillHookDecision's options (the switch,
|
|
452
469
|
* the installs found, the probe), in place of this machine's. The answer
|
|
453
470
|
* carries the decision as `skillHook` for the claude provider. */
|
|
454
471
|
export async function installHooks({ provider = "claude", beforeWrite = null, claudeVersions = null, skillHook = null } = {}) {
|
|
@@ -490,7 +507,7 @@ export async function installHooks({ provider = "claude", beforeWrite = null, cl
|
|
|
490
507
|
current.hooks[evt] = cleaned;
|
|
491
508
|
}
|
|
492
509
|
// A gated event this machine's Claude Code does not know — or, for Skill
|
|
493
|
-
// activity's, one
|
|
510
|
+
// activity's, one turned off — loses our entry, and the key goes with
|
|
494
511
|
// it when nothing of the user's is left under it: that key is the thing an
|
|
495
512
|
// older Claude Code cannot read.
|
|
496
513
|
// A value under that key that is not a list, or a list with nothing of ours
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// A provider client's memory (#2178): the slow, rarely changing reads — a
|
|
2
|
+
// board list, a field map, a server's time-tracking settings — kept for a
|
|
3
|
+
// while. One per client, and the core keeps one client per connection and
|
|
4
|
+
// makes a new one whenever the connection changes (routes.mjs), so nothing
|
|
5
|
+
// cached here outlives the token or the address it was read with.
|
|
6
|
+
|
|
7
|
+
/** A bounded key/value memory with per-read ages. */
|
|
8
|
+
export function createCache({ max = 500 } = {}) {
|
|
9
|
+
const entries = new Map();
|
|
10
|
+
const set = (key, value) => {
|
|
11
|
+
entries.delete(key);
|
|
12
|
+
entries.set(key, { at: Date.now(), value });
|
|
13
|
+
// A bound, not a policy: a provider caching per board per page is still
|
|
14
|
+
// a few hundred entries.
|
|
15
|
+
while (entries.size > max) entries.delete(entries.keys().next().value);
|
|
16
|
+
return value;
|
|
17
|
+
};
|
|
18
|
+
const peek = (key, ttlMs) => {
|
|
19
|
+
const hit = entries.get(key);
|
|
20
|
+
return hit && Date.now() - hit.at < ttlMs ? hit.value : undefined;
|
|
21
|
+
};
|
|
22
|
+
/** Loads in flight, so two panels asking at once share one read. */
|
|
23
|
+
const pending = new Map();
|
|
24
|
+
return {
|
|
25
|
+
/** The value under `key` younger than `ttlMs`, or a fresh one from `load`. */
|
|
26
|
+
async remember(key, ttlMs, load) {
|
|
27
|
+
const hit = peek(key, ttlMs);
|
|
28
|
+
if (hit !== undefined) return hit;
|
|
29
|
+
if (pending.has(key)) return pending.get(key);
|
|
30
|
+
const promise = (async () => {
|
|
31
|
+
try { return set(key, await load()); } finally { pending.delete(key); }
|
|
32
|
+
})();
|
|
33
|
+
pending.set(key, promise);
|
|
34
|
+
return promise;
|
|
35
|
+
},
|
|
36
|
+
/** The value under `key` younger than `ttlMs`, or undefined. */
|
|
37
|
+
peek,
|
|
38
|
+
set,
|
|
39
|
+
forget(key) { entries.delete(key); },
|
|
40
|
+
clear() { entries.clear(); },
|
|
41
|
+
};
|
|
42
|
+
}
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
// What can go wrong between the deck and a tracker it is connected to (#2178),
|
|
2
|
+
// as a short list of kinds the page can act on rather than a status code it
|
|
3
|
+
// would have to interpret.
|
|
4
|
+
//
|
|
5
|
+
// One vocabulary for every provider: Jira today, GitHub and GitLab next. A
|
|
6
|
+
// provider maps its own answers onto these kinds, and the page draws one state
|
|
7
|
+
// per kind — "Jira no longer accepts this token" with a Reconnect, "try again in
|
|
8
|
+
// 30 s", "this server is older than 8.14" — without knowing which tracker said
|
|
9
|
+
// it. The message is ours, written for the person reading the panel; a remote
|
|
10
|
+
// body is never passed through whole, so nothing the tracker echoes (a header, a
|
|
11
|
+
// URL with a query) can ride out on an error.
|
|
12
|
+
import { cooldownFromHeader } from "../quota-oauth.mjs";
|
|
13
|
+
|
|
14
|
+
/** Every kind an integration error can be, and the status the deck answers
|
|
15
|
+
* the page with for it — the contract every provider shares (#2178, #2179):
|
|
16
|
+
* 401 for a refused or expired token, 403 for an account that may not read
|
|
17
|
+
* it, 404 for what is not there, 429 for a rate limit, and 502 for every way
|
|
18
|
+
* the tracker itself failed. `invalid` is the deck's own: a form, or a
|
|
19
|
+
* parameter, that is not the shape it must be (400). `unavailable` is the
|
|
20
|
+
* deck's too: the provider for this kind is not on this deck, or is not
|
|
21
|
+
* answering it (503) — nothing is wrong with the connection or the token,
|
|
22
|
+
* and a page that does not know the kind reads it as `upstream`. */
|
|
23
|
+
export const ERROR_STATUS = Object.freeze({
|
|
24
|
+
auth: 401,
|
|
25
|
+
expired: 401,
|
|
26
|
+
permission: 403,
|
|
27
|
+
"not-found": 404,
|
|
28
|
+
"rate-limit": 429,
|
|
29
|
+
unreachable: 502,
|
|
30
|
+
tls: 502,
|
|
31
|
+
incompatible: 502,
|
|
32
|
+
upstream: 502,
|
|
33
|
+
invalid: 400,
|
|
34
|
+
unavailable: 503,
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
/** Whether a thrown thing carries one of the kinds — an IntegrationError, or a
|
|
38
|
+
* provider's own typed error (#2179's ForgeError) with the same fields. */
|
|
39
|
+
export function isTypedError(err) {
|
|
40
|
+
return Boolean(err && typeof err === "object" && typeof err.kind === "string" && Object.hasOwn(ERROR_STATUS, err.kind)
|
|
41
|
+
&& typeof err.message === "string");
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export class IntegrationError extends Error {
|
|
45
|
+
/**
|
|
46
|
+
* @param {keyof typeof ERROR_STATUS} kind
|
|
47
|
+
* @param {string} message what the panel says, in the deck's own words
|
|
48
|
+
* @param {{ status?: number, retryAfter?: number, field?: string, detail?: string }} [extra]
|
|
49
|
+
* status is the tracker's own HTTP status, when it answered one;
|
|
50
|
+
* retryAfter in seconds for a rate limit; field names the form input a bad
|
|
51
|
+
* value came from; detail is a short remote reason safe to show (a JQL
|
|
52
|
+
* error Jira wrote, never a header or a token)
|
|
53
|
+
*/
|
|
54
|
+
constructor(kind, message, extra = {}) {
|
|
55
|
+
super(message);
|
|
56
|
+
this.name = "IntegrationError";
|
|
57
|
+
this.kind = Object.hasOwn(ERROR_STATUS, kind) ? kind : "upstream";
|
|
58
|
+
if (Number.isInteger(extra.status) && extra.status >= 100 && extra.status < 600) this.status = extra.status;
|
|
59
|
+
if (Number.isFinite(extra.retryAfter)) this.retryAfter = Math.max(1, Math.round(extra.retryAfter));
|
|
60
|
+
if (typeof extra.field === "string") this.field = extra.field;
|
|
61
|
+
if (typeof extra.detail === "string" && extra.detail) this.detail = extra.detail.slice(0, 300);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function integrationError(kind, message, extra) {
|
|
66
|
+
return new IntegrationError(kind, message, extra);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** The JSON body an error is answered with — the one envelope every provider
|
|
70
|
+
* answers with: `{ ok: false, error: { kind, message, status?, retryAfter? } }`,
|
|
71
|
+
* plus `field` and `detail` when the deck has them. Nothing else of the error
|
|
72
|
+
* goes out: not its stack, not a cause, not anything the tracker echoed. */
|
|
73
|
+
export function errorBody(err) {
|
|
74
|
+
const e = isTypedError(err) ? err : new IntegrationError("upstream", "The deck failed while reading the tracker.");
|
|
75
|
+
const retryAfter = Number.isFinite(e.retryAfter) ? Math.max(1, Math.round(e.retryAfter)) : null;
|
|
76
|
+
return {
|
|
77
|
+
ok: false,
|
|
78
|
+
error: {
|
|
79
|
+
kind: e.kind,
|
|
80
|
+
message: String(e.message).slice(0, 500),
|
|
81
|
+
...(Number.isInteger(e.status) ? { status: e.status } : {}),
|
|
82
|
+
...(retryAfter ? { retryAfter } : {}),
|
|
83
|
+
...(typeof e.field === "string" && e.field ? { field: e.field.slice(0, 40) } : {}),
|
|
84
|
+
...(typeof e.detail === "string" && e.detail ? { detail: e.detail.slice(0, 300) } : {}),
|
|
85
|
+
},
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function errorStatus(err) {
|
|
90
|
+
return isTypedError(err) ? ERROR_STATUS[err.kind] : 500;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** A thrown error as a route answers it, `{ status, body }`: a provider's
|
|
94
|
+
* `routes` catches and returns this rather than spelling the envelope. An
|
|
95
|
+
* error that carries no kind is a bug of ours, a 500 that says nothing more. */
|
|
96
|
+
export function errorResponse(err) {
|
|
97
|
+
return { status: errorStatus(err), body: errorBody(err) };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Seconds to wait, from a Retry-After header: the deck's own floor and ceiling
|
|
101
|
+
* (cooldownFromHeader), because a remote header is not entitled to decide
|
|
102
|
+
* either. 30 s when the header is missing or unreadable. */
|
|
103
|
+
export function retryAfterSeconds(raw) {
|
|
104
|
+
return Math.round(cooldownFromHeader(raw, 30_000, 5_000, 3600_000) / 1000);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** The node:tls verification codes a fetch through undici surfaces as
|
|
108
|
+
* `err.cause.code`. A certificate the deck cannot verify is a `tls` error
|
|
109
|
+
* with its own message — there is deliberately no way to ignore one. */
|
|
110
|
+
const TLS_CODES = new Set([
|
|
111
|
+
"CERT_HAS_EXPIRED", "CERT_NOT_YET_VALID", "CERT_REVOKED", "CERT_UNTRUSTED", "CERT_REJECTED",
|
|
112
|
+
"DEPTH_ZERO_SELF_SIGNED_CERT", "SELF_SIGNED_CERT_IN_CHAIN", "UNABLE_TO_VERIFY_LEAF_SIGNATURE",
|
|
113
|
+
"UNABLE_TO_GET_ISSUER_CERT", "UNABLE_TO_GET_ISSUER_CERT_LOCALLY", "ERR_TLS_CERT_ALTNAME_INVALID",
|
|
114
|
+
"HOSTNAME_MISMATCH", "ERR_SSL_WRONG_VERSION_NUMBER", "EPROTO", "ERR_TLS_HANDSHAKE_TIMEOUT",
|
|
115
|
+
]);
|
|
116
|
+
|
|
117
|
+
/** A fetch that threw before any answer came back, as an IntegrationError. */
|
|
118
|
+
export function errorFromFetchFailure(err, label = "the tracker") {
|
|
119
|
+
if (err instanceof IntegrationError) return err;
|
|
120
|
+
const cause = err?.cause ?? {};
|
|
121
|
+
const code = String(cause.code ?? err?.code ?? "");
|
|
122
|
+
if (TLS_CODES.has(code) || /certificate|self[- ]signed|SSL routines|tls/i.test(String(cause.message ?? ""))) {
|
|
123
|
+
return new IntegrationError("tls",
|
|
124
|
+
`The deck could not verify ${label}'s HTTPS certificate (${code || "certificate error"}). ` +
|
|
125
|
+
"Check that the address is right and that the server's certificate is valid for it.");
|
|
126
|
+
}
|
|
127
|
+
if (err?.name === "TimeoutError" || err?.name === "AbortError" || code === "UND_ERR_CONNECT_TIMEOUT" || code === "ETIMEDOUT") {
|
|
128
|
+
return new IntegrationError("unreachable", `${capital(label)} did not answer in time.`);
|
|
129
|
+
}
|
|
130
|
+
if (code === "ENOTFOUND" || code === "EAI_AGAIN") {
|
|
131
|
+
return new IntegrationError("unreachable", `The deck could not find ${label}'s address. Check the URL and your network.`);
|
|
132
|
+
}
|
|
133
|
+
return new IntegrationError("unreachable",
|
|
134
|
+
`The deck could not reach ${label}${code ? ` (${code})` : ""}. Check the URL, your network or VPN.`);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** An HTTP answer that was not a success, as an IntegrationError. `phase` is
|
|
138
|
+
* "validate" while a form is being checked and "use" afterwards: a token
|
|
139
|
+
* refused while it is being entered is a wrong token, and refused after it
|
|
140
|
+
* has worked is an expired or revoked one. */
|
|
141
|
+
export function errorFromStatus(status, headers, { phase = "use", label = "the tracker", detail } = {}) {
|
|
142
|
+
const get = name => (typeof headers?.get === "function" ? headers.get(name) : headers?.[name]) ?? null;
|
|
143
|
+
if (status === 401) {
|
|
144
|
+
return phase === "validate"
|
|
145
|
+
? new IntegrationError("auth", `${capital(label)} did not accept these credentials.`, { status })
|
|
146
|
+
: new IntegrationError("expired", `${capital(label)} no longer accepts the saved token. It may have expired or been revoked — reconnect with a new one.`, { status });
|
|
147
|
+
}
|
|
148
|
+
if (status === 403) {
|
|
149
|
+
return new IntegrationError("permission", `${capital(label)} refused: the account or token is not allowed to read this.`, { status, detail });
|
|
150
|
+
}
|
|
151
|
+
if (status === 429) {
|
|
152
|
+
const retryAfter = retryAfterSeconds(get("retry-after") ?? get("beta-retry-after"));
|
|
153
|
+
return new IntegrationError("rate-limit", `${capital(label)} is limiting requests. Try again in ${retryAfter} s.`, { status, retryAfter });
|
|
154
|
+
}
|
|
155
|
+
if (status === 404) return new IntegrationError("not-found", `${capital(label)} has no such page or record, or this account cannot see it.`, { status, detail });
|
|
156
|
+
if (status === 400) return new IntegrationError("upstream", `${capital(label)} refused the request.`, { status, detail });
|
|
157
|
+
if (status === 502 || status === 503 || status === 504) {
|
|
158
|
+
const wait = get("retry-after");
|
|
159
|
+
return new IntegrationError("unreachable", `${capital(label)} is not answering right now (${status}). Try again shortly.`,
|
|
160
|
+
{ status, ...(wait ? { retryAfter: retryAfterSeconds(wait) } : {}) });
|
|
161
|
+
}
|
|
162
|
+
return new IntegrationError("upstream", `${capital(label)} answered ${status}.`, { status, detail });
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function capital(text) {
|
|
166
|
+
return text ? text[0].toUpperCase() + text.slice(1) : text;
|
|
167
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
// The GitHub and GitLab providers (#2179), for the integrations core's table;
|
|
2
|
+
// registry.mjs has the contract.
|
|
3
|
+
//
|
|
4
|
+
// Both are the same shape: a client from github-api.mjs or gitlab-api.mjs made
|
|
5
|
+
// for one connection — which reads, keeps its short caches, and fetches the
|
|
6
|
+
// images the panel draws — and the reads in forge-routes.mjs under
|
|
7
|
+
// /api/integrations/<kind>/<connId>/. What differs is where the forge is:
|
|
8
|
+
// GitHub is github.com (an Enterprise Server address can come later; the
|
|
9
|
+
// client already takes one), GitLab is gitlab.com or the instance the user
|
|
10
|
+
// typed, under its path if it has one.
|
|
11
|
+
import { ForgeError } from "../forge-http.mjs";
|
|
12
|
+
import { forgeRead } from "../forge-routes.mjs";
|
|
13
|
+
import { createGithubClient } from "../github-api.mjs";
|
|
14
|
+
import { createGitlabClient } from "../gitlab-api.mjs";
|
|
15
|
+
import { integrationError } from "./errors.mjs";
|
|
16
|
+
import { normaliseBaseUrl } from "./proxy.mjs";
|
|
17
|
+
|
|
18
|
+
const GITHUB = "https://github.com";
|
|
19
|
+
const GITLAB = "https://gitlab.com";
|
|
20
|
+
|
|
21
|
+
/** The token typed, or the saved one when an edit left the field blank. */
|
|
22
|
+
function tokenFrom(input, existing) {
|
|
23
|
+
const typed = typeof input?.token === "string" ? input.token.trim() : "";
|
|
24
|
+
const secret = typed || existing?.secret || null;
|
|
25
|
+
if (!secret) throw integrationError("invalid", "Enter a token.", { field: "token" });
|
|
26
|
+
return secret;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** The client, with validate() in the core's terms: a token refused while a
|
|
30
|
+
* form is checked is a wrong one (auth); refused on Test, after it was
|
|
31
|
+
* saved, it has expired or been revoked. */
|
|
32
|
+
function withPhase(client) {
|
|
33
|
+
return {
|
|
34
|
+
...client,
|
|
35
|
+
async validate({ phase = "validate" } = {}) {
|
|
36
|
+
try {
|
|
37
|
+
return await client.validate();
|
|
38
|
+
} catch (err) {
|
|
39
|
+
if (phase === "use" && err?.kind === "auth") throw new ForgeError("expired", err.message, { status: err.status });
|
|
40
|
+
throw err;
|
|
41
|
+
}
|
|
42
|
+
},
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const reads = (client, method, subpath, params) => forgeRead(client, method, subpath, params);
|
|
47
|
+
|
|
48
|
+
export const githubProvider = {
|
|
49
|
+
kind: "github",
|
|
50
|
+
label: "GitHub",
|
|
51
|
+
createClient: (conn, secret, { fetchImpl } = {}) =>
|
|
52
|
+
withPhase(createGithubClient({ baseUrl: conn.baseUrl || GITHUB, token: secret, fetchImpl })),
|
|
53
|
+
routes: reads,
|
|
54
|
+
parseInput: (input, existing) => ({ baseUrl: GITHUB, secret: tokenFrom(input, existing), meta: {} }),
|
|
55
|
+
// Two GitHub connections are two accounts; the login tells them apart.
|
|
56
|
+
defaultName: checked => checked?.account?.login || "GitHub",
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
export const gitlabProvider = {
|
|
60
|
+
kind: "gitlab",
|
|
61
|
+
label: "GitLab",
|
|
62
|
+
createClient: (conn, secret, { fetchImpl } = {}) =>
|
|
63
|
+
withPhase(createGitlabClient({ baseUrl: conn.baseUrl || GITLAB, token: secret, fetchImpl })),
|
|
64
|
+
routes: reads,
|
|
65
|
+
parseInput: (input, existing) => ({
|
|
66
|
+
baseUrl: input?.where === "self"
|
|
67
|
+
? normaliseBaseUrl(input?.baseUrl, { field: "baseUrl", label: "your GitLab's address" })
|
|
68
|
+
: GITLAB,
|
|
69
|
+
secret: tokenFrom(input, existing),
|
|
70
|
+
meta: {},
|
|
71
|
+
}),
|
|
72
|
+
// Two GitLab connections are usually two instances; the host tells them apart.
|
|
73
|
+
defaultName: checked => {
|
|
74
|
+
try { return new URL(checked.baseUrl).host; } catch { return "GitLab"; }
|
|
75
|
+
},
|
|
76
|
+
};
|