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,498 @@
|
|
|
1
|
+
// /api/integrations — the connections to trackers (#2178), and the doorway to
|
|
2
|
+
// each provider's own read routes.
|
|
3
|
+
//
|
|
4
|
+
// GET /api/integrations every registered provider, with whether it
|
|
5
|
+
// is available now, and every connection
|
|
6
|
+
// (no secrets)
|
|
7
|
+
// POST /api/integrations/connections create: { kind, name?, input }
|
|
8
|
+
// PATCH /api/integrations/connections/<id> edit: { name?, input? } — re-validates when
|
|
9
|
+
// the address, the token or the provider's
|
|
10
|
+
// options change
|
|
11
|
+
// POST /api/integrations/connections/<id>/test ask the tracker again with the saved token
|
|
12
|
+
// DELETE /api/integrations/connections/<id> disconnect: the connection and its token,
|
|
13
|
+
// with or without a provider for its kind
|
|
14
|
+
// GET /api/integrations/<kind>/<id>/asset?url= an image, read by the provider's client from its
|
|
15
|
+
// own image hosts (raster only; empty 403/404)
|
|
16
|
+
// GET /api/integrations/<kind>/<id>/<subpath…> the provider's own reads (registry.mjs)
|
|
17
|
+
//
|
|
18
|
+
// A route that would ask a provider that is not answering (registry.mjs,
|
|
19
|
+
// `available`), or ask for a saved connection whose provider this deck does
|
|
20
|
+
// not have, answers `unavailable` (503) and asks nothing; an image is an
|
|
21
|
+
// empty 404 as always; Disconnect needs no provider at all.
|
|
22
|
+
//
|
|
23
|
+
// Every GET here is a guarded read (request-gates.mjs, GUARDED_READ_PREFIXES):
|
|
24
|
+
// what a tracker holds is the user's work, and the deck's own page is the only
|
|
25
|
+
// caller. Every mutation already needs the page or the deck token.
|
|
26
|
+
//
|
|
27
|
+
// A SECRET NEVER LEAVES THIS FILE IN AN ANSWER. Connections come out of the
|
|
28
|
+
// store already without one (store.mjs); the routes below add only
|
|
29
|
+
// `tokenSaved`. The secret is read for exactly two things: handing it to the
|
|
30
|
+
// provider to build a client, and through that client, signing a request.
|
|
31
|
+
// And what comes back is sent without it: a tracker that echoes the token
|
|
32
|
+
// reaches the page, the saved connection and the log as `[redacted]`.
|
|
33
|
+
import { createHash } from "node:crypto";
|
|
34
|
+
import { readBody, send } from "../http-io.mjs";
|
|
35
|
+
import { errorBody, errorResponse, integrationError, isTypedError } from "./errors.mjs";
|
|
36
|
+
import { fetchImage, IMAGE_MAX_BYTES, isSafeTarget, normaliseBaseUrl, originOf, RASTER_TYPES, rasterMatches } from "./proxy.mjs";
|
|
37
|
+
import { getProvider, isAvailable, providerList } from "./registry.mjs";
|
|
38
|
+
import { integrationsStore } from "./store.mjs";
|
|
39
|
+
import "./providers.mjs";
|
|
40
|
+
|
|
41
|
+
// ── one client per connection ───────────────────────────────────────────────
|
|
42
|
+
|
|
43
|
+
/** id -> { print, client }. A client holds the provider's short caches (a
|
|
44
|
+
* field map, a board list), so it is kept, and rebuilt the moment what it
|
|
45
|
+
* was made from changes: the kind, the address, the token, or the saved
|
|
46
|
+
* connection itself (an edit or a Test moves `updatedAt`). */
|
|
47
|
+
const clients = new Map();
|
|
48
|
+
|
|
49
|
+
function fingerprint(conn, secret) {
|
|
50
|
+
return createHash("sha256")
|
|
51
|
+
.update(JSON.stringify([conn.kind, conn.baseUrl, conn.updatedAt, conn.allowedOrigins, secret]))
|
|
52
|
+
.digest("base64url");
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** The kept client for a saved connection, made again when it is stale. */
|
|
56
|
+
export function clientFor(provider, conn, secret, { fetchImpl } = {}) {
|
|
57
|
+
const print = fingerprint(conn, secret);
|
|
58
|
+
const kept = clients.get(conn.id);
|
|
59
|
+
if (kept && kept.print === print && kept.fetchImpl === fetchImpl) return kept.client;
|
|
60
|
+
const client = provider.createClient(conn, secret, { fetchImpl });
|
|
61
|
+
clients.set(conn.id, { print, client, fetchImpl });
|
|
62
|
+
forgetAssets(conn.id);
|
|
63
|
+
return client;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Drop a connection's client and every image read through it. */
|
|
67
|
+
export function forgetConnection(id) {
|
|
68
|
+
clients.delete(id);
|
|
69
|
+
forgetAssets(id);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// ── the asset route ─────────────────────────────────────────────────────────
|
|
73
|
+
//
|
|
74
|
+
// GET /api/integrations/<kind>/<id>/asset?url= — an image the panel draws (an
|
|
75
|
+
// avatar, an icon, a picture in a description), read by the deck and passed on
|
|
76
|
+
// from its own origin.
|
|
77
|
+
//
|
|
78
|
+
// THE CLIENT DECIDES WHERE AN IMAGE MAY COME FROM. `client.allowedOrigins` are
|
|
79
|
+
// where the token goes, not where images live — a Cloud avatar is on
|
|
80
|
+
// Atlassian's CDN, a GitHub one on githubusercontent.com, and neither may ever
|
|
81
|
+
// see the token. So a provider's `client.fetchAsset(url)` owns the read: its
|
|
82
|
+
// own list of image hosts, checked on the address and on every redirect hop;
|
|
83
|
+
// the token on its token origins only; raster images only; a streamed cap. A
|
|
84
|
+
// provider without one gets the generic read below, from its token origins
|
|
85
|
+
// and nowhere else, unsigned.
|
|
86
|
+
//
|
|
87
|
+
// AN IMAGE THAT FAILS SAYS NOTHING. Any failure is a 403 (not a host this
|
|
88
|
+
// connection reads from) or a 404 (anything else) with an empty body: the
|
|
89
|
+
// page's <img> falls back to what it draws itself, and nothing about the
|
|
90
|
+
// failure reaches the connection — a 401 on an avatar is not an expired token.
|
|
91
|
+
|
|
92
|
+
/** Images read recently, in memory, briefly; and the ones that failed, for a
|
|
93
|
+
* minute, so a list of rows with one broken icon asks once, not per row. */
|
|
94
|
+
const assetCache = new Map();
|
|
95
|
+
const assetFailures = new Map();
|
|
96
|
+
const ASSET_TTL_MS = 5 * 60_000;
|
|
97
|
+
const ASSET_FAILURE_TTL_MS = 60_000;
|
|
98
|
+
const ASSET_CACHE_BYTES = 24 * 1024 * 1024;
|
|
99
|
+
/** One image larger than this is served but not kept. */
|
|
100
|
+
const ASSET_KEEP_BYTES = 1024 * 1024;
|
|
101
|
+
let assetCacheBytes = 0;
|
|
102
|
+
|
|
103
|
+
function forgetAssets(id) {
|
|
104
|
+
for (const [key, value] of assetCache) {
|
|
105
|
+
if (key.startsWith(`${id}|`)) { assetCache.delete(key); assetCacheBytes -= value.bytes.length; }
|
|
106
|
+
}
|
|
107
|
+
for (const key of assetFailures.keys()) if (key.startsWith(`${id}|`)) assetFailures.delete(key);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** The headers an image is answered with: its raster type, never sniffed,
|
|
111
|
+
* sandboxed if it is ever opened as a document, kept only by this browser. */
|
|
112
|
+
function assetHeaders(contentType, length) {
|
|
113
|
+
return {
|
|
114
|
+
"Content-Type": contentType,
|
|
115
|
+
"Content-Length": String(length),
|
|
116
|
+
"Cache-Control": "private, max-age=300",
|
|
117
|
+
"X-Content-Type-Options": "nosniff",
|
|
118
|
+
"Content-Security-Policy": "default-src 'none'; sandbox",
|
|
119
|
+
"Cross-Origin-Resource-Policy": "same-origin",
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function rememberAsset(key, entry) {
|
|
124
|
+
const old = assetCache.get(key);
|
|
125
|
+
if (old) { assetCacheBytes -= old.bytes.length; assetCache.delete(key); }
|
|
126
|
+
if (entry.bytes.length > ASSET_KEEP_BYTES) return;
|
|
127
|
+
assetCache.set(key, entry);
|
|
128
|
+
assetCacheBytes += entry.bytes.length;
|
|
129
|
+
while (assetCacheBytes > ASSET_CACHE_BYTES && assetCache.size) {
|
|
130
|
+
const [oldest, value] = assetCache.entries().next().value;
|
|
131
|
+
assetCache.delete(oldest);
|
|
132
|
+
assetCacheBytes -= value.bytes.length;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** A failed image: an empty 403 or 404, never a JSON error and never kept. */
|
|
137
|
+
function assetRefused(res, status = 404) {
|
|
138
|
+
if (res.writableEnded || res.headersSent) return;
|
|
139
|
+
res.writeHead(status === 403 ? 403 : 404, { "Content-Length": "0", "Cache-Control": "no-store", "X-Content-Type-Options": "nosniff" });
|
|
140
|
+
res.end();
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** The read a provider without its own fetchAsset gets: its token origins
|
|
144
|
+
* only, no token, the same checks. */
|
|
145
|
+
function genericAsset(client, url, opts) {
|
|
146
|
+
const origins = Array.isArray(client.allowedOrigins) ? client.allowedOrigins : [];
|
|
147
|
+
return fetchImage(url, { ...opts, tokenOrigins: origins, sign: () => ({}) });
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
async function serveAsset(res, conn, client, raw, { signal, method, fetchImpl }) {
|
|
151
|
+
let target;
|
|
152
|
+
try { target = new URL(String(raw ?? "")); } catch { return assetRefused(res, 404); }
|
|
153
|
+
if (!raw || raw.length > 4000 || (target.protocol !== "https:" && target.protocol !== "http:")) return assetRefused(res, 404);
|
|
154
|
+
const key = `${conn.id}|${target.href}`;
|
|
155
|
+
const failed = assetFailures.get(key);
|
|
156
|
+
if (failed && Date.now() - failed.at < ASSET_FAILURE_TTL_MS) return assetRefused(res, failed.status);
|
|
157
|
+
let image = assetCache.get(key);
|
|
158
|
+
if (!image || Date.now() - image.at >= ASSET_TTL_MS) {
|
|
159
|
+
try {
|
|
160
|
+
const got = typeof client.fetchAsset === "function"
|
|
161
|
+
? await client.fetchAsset(target.href, { maxBytes: IMAGE_MAX_BYTES, signal })
|
|
162
|
+
: await genericAsset(client, target.href, { maxBytes: IMAGE_MAX_BYTES, signal, fetchImpl });
|
|
163
|
+
const contentType = String(got?.contentType ?? "").split(";")[0].trim().toLowerCase();
|
|
164
|
+
const bytes = got?.bytes;
|
|
165
|
+
// The route checks again what every client promised.
|
|
166
|
+
if (!RASTER_TYPES.has(contentType) || !Buffer.isBuffer(bytes) || bytes.length > IMAGE_MAX_BYTES || !rasterMatches(contentType, bytes)) {
|
|
167
|
+
throw integrationError("not-found", "Not an image the deck shows.");
|
|
168
|
+
}
|
|
169
|
+
image = { at: Date.now(), contentType, bytes };
|
|
170
|
+
rememberAsset(key, image);
|
|
171
|
+
} catch (err) {
|
|
172
|
+
if (signal?.aborted) return;
|
|
173
|
+
const status = isTypedError(err) && err.kind === "permission" ? 403 : 404;
|
|
174
|
+
assetFailures.set(key, { at: Date.now(), status });
|
|
175
|
+
if (assetFailures.size > 2000) assetFailures.delete(assetFailures.keys().next().value);
|
|
176
|
+
return assetRefused(res, status);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
res.writeHead(200, assetHeaders(image.contentType, image.bytes.length));
|
|
180
|
+
res.end(method === "HEAD" ? undefined : image.bytes);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// ── connections ─────────────────────────────────────────────────────────────
|
|
184
|
+
|
|
185
|
+
/** The form's values as a candidate, by the provider's rules or the default
|
|
186
|
+
* ones: an address and a token, a blank token keeping the saved one. */
|
|
187
|
+
function parseInput(provider, input, existing) {
|
|
188
|
+
if (typeof provider.parseInput === "function") return provider.parseInput(input, existing);
|
|
189
|
+
const baseUrl = normaliseBaseUrl(input?.baseUrl, { field: "baseUrl", label: `the ${provider.label} address` });
|
|
190
|
+
const typed = typeof input?.token === "string" ? input.token.trim() : "";
|
|
191
|
+
const secret = typed || existing?.secret || null;
|
|
192
|
+
if (!secret) throw integrationError("invalid", "Enter a token.", { field: "token" });
|
|
193
|
+
return { baseUrl, secret, meta: {} };
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** Ask the tracker, with a fresh client for the candidate, who this is — and
|
|
197
|
+
* settle what the connection may reach. What the provider answers is the
|
|
198
|
+
* whole of the new meta; `inputMeta` (the form's own choices, from
|
|
199
|
+
* parseInput) fills only what it left out, so nothing a previous check
|
|
200
|
+
* learned outlives the check that no longer finds it. What the tracker said
|
|
201
|
+
* about the account is kept without the secret in it (see below). */
|
|
202
|
+
async function validateCandidate(provider, candidate, secret, { phase, signal, fetchImpl, inputMeta = {} }) {
|
|
203
|
+
const conn = { allowedOrigins: [originOf(candidate.baseUrl)].filter(Boolean), ...candidate };
|
|
204
|
+
const client = provider.createClient(conn, secret, { fetchImpl });
|
|
205
|
+
const result = await client.validate({ phase, signal });
|
|
206
|
+
const origins = new Set([originOf(candidate.baseUrl), ...(Array.isArray(client.allowedOrigins) ? client.allowedOrigins : [])].filter(Boolean));
|
|
207
|
+
for (const origin of origins) {
|
|
208
|
+
if (!isSafeTarget(origin)) throw integrationError("invalid", `The deck will not send a token to ${origin}.`);
|
|
209
|
+
}
|
|
210
|
+
const needles = secretNeedles(secret);
|
|
211
|
+
return {
|
|
212
|
+
baseUrl: candidate.baseUrl,
|
|
213
|
+
account: redactSecrets(result?.account ?? {}, needles),
|
|
214
|
+
meta: redactSecrets({ ...inputMeta, ...(result?.meta ?? {}), checkedAt: new Date().toISOString() }, needles),
|
|
215
|
+
allowedOrigins: [...origins],
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// ── the connection's own secret, in nothing the deck sends ──────────────────
|
|
220
|
+
//
|
|
221
|
+
// A tracker may echo what it was sent — a 401 that quotes the Authorization
|
|
222
|
+
// header, a proxy's error page, an account name — and a provider passes the
|
|
223
|
+
// tracker's words on: an error's reason, the account it found. So whatever
|
|
224
|
+
// this file sends or keeps once a secret is in hand — an answer, an error,
|
|
225
|
+
// the account saved with a connection, a failure handed to the log and the
|
|
226
|
+
// error report — has the connection's own secret taken out of it first, each
|
|
227
|
+
// time as `[redacted]`.
|
|
228
|
+
|
|
229
|
+
export const REDACTED = "[redacted]";
|
|
230
|
+
/** A string of a secret shorter than this is not looked for: "basic" or a
|
|
231
|
+
* scheme name occurs anywhere, and no token is this short. */
|
|
232
|
+
const REDACT_MIN = 8;
|
|
233
|
+
const EMAIL = /^[^\s@]+@[^\s@]+$/;
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* What of one or more secrets must never leave the deck, longest first: each
|
|
237
|
+
* string in a secret of REDACT_MIN characters or more — but an email address,
|
|
238
|
+
* which names who signs and is the account the page already shows — and the
|
|
239
|
+
* HTTP Basic credential one of its strings makes with another. The secret
|
|
240
|
+
* stays opaque: nothing here reads one of its fields by name.
|
|
241
|
+
*/
|
|
242
|
+
export function secretNeedles(...secrets) {
|
|
243
|
+
const needles = new Set();
|
|
244
|
+
for (const secret of secrets) {
|
|
245
|
+
const strings = [];
|
|
246
|
+
const walk = (value, depth) => {
|
|
247
|
+
if (typeof value === "string") strings.push(value);
|
|
248
|
+
else if (value && typeof value === "object" && depth < 8) for (const v of Object.values(value)) walk(v, depth + 1);
|
|
249
|
+
};
|
|
250
|
+
walk(secret, 0);
|
|
251
|
+
const own = strings.filter(s => s.length >= REDACT_MIN && !EMAIL.test(s));
|
|
252
|
+
for (const s of own) needles.add(s);
|
|
253
|
+
for (const user of strings) for (const s of own) {
|
|
254
|
+
if (user !== s) needles.add(Buffer.from(`${user}:${s}`, "utf8").toString("base64"));
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
return [...needles].sort((a, b) => b.length - a.length);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function redactText(text, needles) {
|
|
261
|
+
let out = text;
|
|
262
|
+
for (const needle of needles) if (out.includes(needle)) out = out.split(needle).join(REDACTED);
|
|
263
|
+
return out;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* `value` with every needle replaced in every string in it, its keys too —
|
|
268
|
+
* or `value` itself, untouched, when no needle is anywhere in it (the
|
|
269
|
+
* answer as JSON is searched first, so an ordinary read costs one pass).
|
|
270
|
+
*/
|
|
271
|
+
export function redactSecrets(value, needles) {
|
|
272
|
+
if (!needles.length || value == null) return value;
|
|
273
|
+
if (typeof value === "string") return redactText(value, needles);
|
|
274
|
+
let json;
|
|
275
|
+
try { json = JSON.stringify(value); } catch { return value; }
|
|
276
|
+
if (json === undefined || !needles.some(n => json.includes(JSON.stringify(n).slice(1, -1)))) return value;
|
|
277
|
+
const walk = v => {
|
|
278
|
+
if (typeof v === "string") return redactText(v, needles);
|
|
279
|
+
if (Array.isArray(v)) return v.map(walk);
|
|
280
|
+
if (v && typeof v === "object") return Object.fromEntries(Object.entries(v).map(([k, x]) => [redactText(k, needles), walk(x)]));
|
|
281
|
+
return v;
|
|
282
|
+
};
|
|
283
|
+
return walk(JSON.parse(json));
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/** A saved connection without its own secret in what a page reads of it.
|
|
287
|
+
* Every save here already takes it out, but builds before this host did kept
|
|
288
|
+
* the account a tracker answered as it came, echo and all. Its id, kind and
|
|
289
|
+
* address are left as they are, so it can always be found and disconnected. */
|
|
290
|
+
function withoutOwnSecret(conn, secret) {
|
|
291
|
+
const needles = secretNeedles(secret);
|
|
292
|
+
if (!needles.length) return conn;
|
|
293
|
+
return { ...conn, name: redactSecrets(conn.name, needles), account: redactSecrets(conn.account, needles), meta: redactSecrets(conn.meta, needles) };
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/** A failure that is a bug rather than a typed error, as the log and the
|
|
297
|
+
* error report get it: its message and stack without the secret, and none
|
|
298
|
+
* of what it carried besides (a cause may hold a request). */
|
|
299
|
+
function redactedError(err, needles) {
|
|
300
|
+
const copy = new Error(redactText(String(err?.message ?? err), needles));
|
|
301
|
+
copy.name = typeof err?.name === "string" ? err.name : "Error";
|
|
302
|
+
copy.stack = typeof err?.stack === "string" ? redactText(err.stack, needles) : copy.stack;
|
|
303
|
+
return copy;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
async function readJsonBody(req, res) {
|
|
307
|
+
const raw = await readBody(req, res).catch(() => null);
|
|
308
|
+
if (raw == null) return null;
|
|
309
|
+
try {
|
|
310
|
+
const body = JSON.parse(raw);
|
|
311
|
+
return body && typeof body === "object" && !Array.isArray(body) ? body : {};
|
|
312
|
+
} catch { return {}; }
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/** A connection as the page draws it: the account's avatar made loadable,
|
|
316
|
+
* through the asset route, where the provider's client decides whether its
|
|
317
|
+
* host is one to read from (and whether the token goes with it). */
|
|
318
|
+
export function forPage(conn) {
|
|
319
|
+
if (!conn) return conn;
|
|
320
|
+
const raw = conn.account?.avatarUrl;
|
|
321
|
+
const avatarUrl = raw && isSafeTarget(raw)
|
|
322
|
+
? `/api/integrations/${encodeURIComponent(conn.kind)}/${encodeURIComponent(conn.id)}/asset?url=${encodeURIComponent(raw)}`
|
|
323
|
+
: "";
|
|
324
|
+
return { ...conn, account: { ...conn.account, avatarUrl } };
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/** Whether an edit asks for a re-check: a new address, a new token, or a
|
|
328
|
+
* provider option (Cloud or Server) the saved meta does not already say. */
|
|
329
|
+
function needsValidation(existing, existingSecret, candidate) {
|
|
330
|
+
if (candidate.baseUrl !== existing.baseUrl) return true;
|
|
331
|
+
if (JSON.stringify(candidate.secret) !== JSON.stringify(existingSecret)) return true;
|
|
332
|
+
for (const [key, value] of Object.entries(candidate.meta ?? {})) {
|
|
333
|
+
if (JSON.stringify(existing.meta?.[key]) !== JSON.stringify(value)) return true;
|
|
334
|
+
}
|
|
335
|
+
return false;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
const notFound = res => send(res, 404, errorBody(integrationError("not-found", "No such integrations route.")));
|
|
339
|
+
const notAllowed = res => send(res, 405, errorBody(integrationError("invalid", "That method is not allowed here.")));
|
|
340
|
+
/** The provider for this kind is not on this deck, or not answering it. */
|
|
341
|
+
const unavailable = label => integrationError("unavailable", `${label} is not available on this deck.`);
|
|
342
|
+
|
|
343
|
+
/**
|
|
344
|
+
* Every /api/integrations request. `deps` is for the suite: a store in a temp
|
|
345
|
+
* directory and a fetch that answers as a tracker would.
|
|
346
|
+
*/
|
|
347
|
+
export async function handleIntegrations(req, res, url, deps = {}) {
|
|
348
|
+
const store = deps.store ?? integrationsStore;
|
|
349
|
+
const fetchImpl = deps.fetchImpl;
|
|
350
|
+
const segments = url.pathname.split("/").filter(Boolean).slice(2).map(s => {
|
|
351
|
+
try { return decodeURIComponent(s); } catch { return "\u0000"; }
|
|
352
|
+
});
|
|
353
|
+
const method = req.method;
|
|
354
|
+
// The page's request: a panel closed mid-read stops the read upstream too.
|
|
355
|
+
const controller = new AbortController();
|
|
356
|
+
res.on?.("close", () => { if (!res.writableEnded) controller.abort(); });
|
|
357
|
+
const signal = controller.signal;
|
|
358
|
+
|
|
359
|
+
// Every secret this request has in hand — a saved one, a candidate, the
|
|
360
|
+
// token as it was typed — and the answer without any of them.
|
|
361
|
+
const held = [];
|
|
362
|
+
const hold = secret => { if (secret != null) held.push(secret); return secret; };
|
|
363
|
+
const needles = () => secretNeedles(...held);
|
|
364
|
+
const answer = (status, body) => send(res, status, held.length ? redactSecrets(body, needles()) : body);
|
|
365
|
+
const clean = text => (held.length ? redactSecrets(text, needles()) : text);
|
|
366
|
+
|
|
367
|
+
try {
|
|
368
|
+
if (segments.length === 0) {
|
|
369
|
+
if (method !== "GET" && method !== "HEAD") return notAllowed(res);
|
|
370
|
+
// Every registered provider, with whether this deck can connect and read
|
|
371
|
+
// it now (`available`): the page offers a kind only while that is true,
|
|
372
|
+
// and keeps reading the list where a provider is listed, so one that
|
|
373
|
+
// cannot serve at load shows once it can. A kind with no
|
|
374
|
+
// registered provider is not listed: a deck with none lists none.
|
|
375
|
+
// Every saved connection is listed whatever its kind, so it can always
|
|
376
|
+
// be disconnected.
|
|
377
|
+
const providers = providerList();
|
|
378
|
+
return send(res, 200, { ok: true, providers, connections: (await store.list({ own: withoutOwnSecret })).map(forPage) });
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
if (segments[0] === "connections") {
|
|
382
|
+
const [, id, action] = segments;
|
|
383
|
+
if (!id && method === "POST" && segments.length === 1) {
|
|
384
|
+
const body = await readJsonBody(req, res);
|
|
385
|
+
if (body == null) return;
|
|
386
|
+
const provider = getProvider(String(body.kind ?? ""));
|
|
387
|
+
if (!provider) throw integrationError("invalid", "Choose which tracker to connect.", { field: "kind" });
|
|
388
|
+
if (!isAvailable(provider)) throw unavailable(provider.label);
|
|
389
|
+
hold(typeof body.input?.token === "string" ? body.input.token : null);
|
|
390
|
+
const candidate = parseInput(provider, body.input ?? {}, null);
|
|
391
|
+
hold(candidate.secret);
|
|
392
|
+
const checked = await validateCandidate(provider, { kind: provider.kind, name: "", baseUrl: candidate.baseUrl, meta: candidate.meta ?? {} }, candidate.secret,
|
|
393
|
+
{ phase: "validate", signal, fetchImpl, inputMeta: candidate.meta });
|
|
394
|
+
const name = clean(String(body.name ?? candidate.name ?? "").trim()).slice(0, 120)
|
|
395
|
+
|| clean(provider.defaultName ? provider.defaultName(checked) : provider.label);
|
|
396
|
+
const connection = await store.create({ kind: provider.kind, name, ...checked }, candidate.secret);
|
|
397
|
+
return answer(201, { ok: true, connection: forPage(connection) });
|
|
398
|
+
}
|
|
399
|
+
if (!id || segments.length > 3) return notFound(res);
|
|
400
|
+
const existing = await store.get(id);
|
|
401
|
+
if (!existing) throw integrationError("not-found", "That connection is not there any more.");
|
|
402
|
+
|
|
403
|
+
// Disconnect asks no provider: a connection, and the token saved with
|
|
404
|
+
// it, can always be deleted — also one whose provider this deck does not
|
|
405
|
+
// have, which nothing else here can read or check.
|
|
406
|
+
if (!action && method === "DELETE") {
|
|
407
|
+
forgetConnection(id);
|
|
408
|
+
await store.remove(id);
|
|
409
|
+
return send(res, 200, { ok: true });
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
// Test and Edit ask the provider: without one that answers, they say so.
|
|
413
|
+
const provider = getProvider(existing.kind);
|
|
414
|
+
if (!isAvailable(provider)) throw unavailable(provider?.label ?? existing.kind);
|
|
415
|
+
|
|
416
|
+
if (action === "test" && method === "POST") {
|
|
417
|
+
const secret = hold(await store.secret(id));
|
|
418
|
+
if (secret == null) throw integrationError("expired", "No token is saved for this connection. Edit it and enter one.");
|
|
419
|
+
const checked = await validateCandidate(provider, existing, secret, { phase: "use", signal, fetchImpl });
|
|
420
|
+
const connection = await store.update(id, checked);
|
|
421
|
+
forgetConnection(id);
|
|
422
|
+
return answer(200, { ok: true, connection: forPage(connection) });
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
if (!action && (method === "PATCH" || method === "POST")) {
|
|
426
|
+
const body = await readJsonBody(req, res);
|
|
427
|
+
if (body == null) return;
|
|
428
|
+
const existingSecret = hold(await store.secret(id));
|
|
429
|
+
const name = typeof body.name === "string" ? clean(body.name.trim()).slice(0, 120) : undefined;
|
|
430
|
+
if (body.input && typeof body.input === "object") {
|
|
431
|
+
hold(typeof body.input.token === "string" ? body.input.token : null);
|
|
432
|
+
const candidate = parseInput(provider, body.input, { conn: existing, secret: existingSecret });
|
|
433
|
+
if (candidate.secret == null) throw integrationError("invalid", "Enter a token.", { field: "token" });
|
|
434
|
+
hold(candidate.secret);
|
|
435
|
+
if (needsValidation(existing, existingSecret, candidate)) {
|
|
436
|
+
const checked = await validateCandidate(provider, { ...existing, baseUrl: candidate.baseUrl, meta: { ...existing.meta, ...(candidate.meta ?? {}) } }, candidate.secret,
|
|
437
|
+
{ phase: "validate", signal, fetchImpl, inputMeta: candidate.meta });
|
|
438
|
+
const connection = await store.update(id, { ...checked, ...(name ? { name } : {}) }, candidate.secret);
|
|
439
|
+
forgetConnection(id);
|
|
440
|
+
return answer(200, { ok: true, connection: forPage(connection), validated: true });
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
const connection = await store.update(id, name ? { name } : {});
|
|
444
|
+
return answer(200, { ok: true, connection: forPage(connection), validated: false });
|
|
445
|
+
}
|
|
446
|
+
return notAllowed(res);
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
// /api/integrations/<kind>/<id>/<subpath…>
|
|
450
|
+
const [kind, id, ...rest] = segments;
|
|
451
|
+
if (!id || rest.length === 0) return notFound(res);
|
|
452
|
+
// Reads only, whatever the kind and whether or not this deck has its
|
|
453
|
+
// provider: nothing under here may change anything.
|
|
454
|
+
if (method !== "GET" && method !== "HEAD") return notAllowed(res);
|
|
455
|
+
const provider = getProvider(kind);
|
|
456
|
+
|
|
457
|
+
// An image: whatever goes wrong, an empty 403 or 404, and the connection
|
|
458
|
+
// is not touched (see the asset route above) — also when no provider
|
|
459
|
+
// here can read it.
|
|
460
|
+
if (rest.length === 1 && rest[0] === "asset") {
|
|
461
|
+
if (!provider) return assetRefused(res, 404);
|
|
462
|
+
let conn, secret;
|
|
463
|
+
try {
|
|
464
|
+
conn = await store.get(id);
|
|
465
|
+
secret = conn && conn.kind === kind ? await store.secret(id) : null;
|
|
466
|
+
} catch { return assetRefused(res, 404); }
|
|
467
|
+
if (!conn || conn.kind !== kind || secret == null || !isAvailable(provider)) return assetRefused(res, 404);
|
|
468
|
+
let client;
|
|
469
|
+
try { client = clientFor(provider, conn, secret, { fetchImpl }); } catch { return assetRefused(res, 404); }
|
|
470
|
+
return await serveAsset(res, conn, client, url.searchParams.get("url"), { signal, method, fetchImpl });
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
const conn = await store.get(id);
|
|
474
|
+
if (!provider) {
|
|
475
|
+
// No provider for this kind on this deck. A saved connection of the kind
|
|
476
|
+
// is there and cannot be read here, and says so; anything else is not a
|
|
477
|
+
// route at all.
|
|
478
|
+
if (conn && conn.kind === kind) throw unavailable(kind);
|
|
479
|
+
return notFound(res);
|
|
480
|
+
}
|
|
481
|
+
if (!conn || conn.kind !== kind) throw integrationError("not-found", "That connection is not there any more.");
|
|
482
|
+
if (!isAvailable(provider)) throw unavailable(provider.label);
|
|
483
|
+
const secret = hold(await store.secret(id));
|
|
484
|
+
if (secret == null) throw integrationError("expired", "No token is saved for this connection. Reconnect it in Settings.");
|
|
485
|
+
const client = clientFor(provider, conn, secret, { fetchImpl });
|
|
486
|
+
|
|
487
|
+
const read = await provider.routes(client, method, rest.join("/"), url.searchParams, req, { signal });
|
|
488
|
+
if (res.writableEnded || signal.aborted) return;
|
|
489
|
+
const status = Number.isInteger(read?.status) ? read.status : 500;
|
|
490
|
+
return answer(status, read?.body ?? errorBody(null));
|
|
491
|
+
} catch (err) {
|
|
492
|
+
if (isTypedError(err)) {
|
|
493
|
+
const { status, body } = errorResponse(err);
|
|
494
|
+
return answer(status, body);
|
|
495
|
+
}
|
|
496
|
+
throw held.length ? redactedError(err, needles()) : err;
|
|
497
|
+
}
|
|
498
|
+
}
|