@goodandready/dsh-key-rotation 0.8.11 → 0.8.13

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/lib/routes-ops.js CHANGED
@@ -1,640 +1,21 @@
1
- // lib/routes-ops.js — operational HTTP routes for dsh-key-rotation (#253).
1
+ // lib/routes-ops.js — operational HTTP routes for dsh-key-rotation (#253/#312).
2
+ // Thin orchestrator: each route family lives in its own lib/ops-*.js module.
2
3
  // Registration is injected with the live apply() dependencies.
3
4
 
4
- import {
5
- json,
6
- readJson,
7
- descriptorOf,
8
- NS,
9
- } from './http-bridge.js';
10
- import {
11
- isLoopbackAddress,
12
- isTrustedBridgeRequest,
13
- keyTail,
14
- envValue,
15
- isValidRef,
16
- computeHealthScore,
17
- recordFailure,
18
- costForDay,
19
- costForWeek,
20
- } from './pool.js';
21
- import { bucketInfo } from './bucket.js';
22
- import { usageRows, usageCsv } from './usage-report.js';
23
- import { findSecrets, looksLikeApiSecret } from './keycheck.js';
24
- import { nextQuotaReset } from './quota-window.js';
25
- import { classifyFailure } from './error-taxonomy.js';
26
- import { sanitizeSnapshot } from './sanitize-snapshot.js';
27
- import { bestEffort } from './best-effort.js';
28
-
29
- const STATUS_PATH = '/dsh-key-rotation/status';
30
- const SNAPSHOT_PATH = '/dsh-key-rotation/snapshot';
31
- const KEY_PATH = '/dsh-key-rotation/key';
32
- const RESET_PATH = '/dsh-key-rotation/reset';
33
- const IMPORT_PATH = '/dsh-key-rotation/import';
34
- const HEALTH_PATH = '/dsh-key-rotation/health';
35
- const USAGE_PATH = '/dsh-key-rotation/usage';
36
- const TEST_PATH = '/dsh-key-rotation/test';
37
- const SANDBOX_CACHE_PATH = '/dsh-key-rotation/sandbox-cache';
5
+ import { registerStatusRoutes } from './ops-status.js';
6
+ import { registerTelemetryRoutes } from './ops-telemetry.js';
7
+ import { registerKeyRoutes } from './ops-keys.js';
8
+ import { registerTestRoutes } from './ops-test.js';
9
+ import { registerWebhookActionRoute } from './ops-webhook.js';
38
10
 
39
11
  /**
40
12
  * @param {object} ctx cordis context
41
13
  * @param {object} deps live dependencies from apply()
42
14
  */
43
15
  export function registerOpsRoutes(ctx, deps) {
44
- const {
45
- buildRuntime,
46
- latencyHistogram,
47
- lastTestCache,
48
- ensureSandboxRunner,
49
- poolState,
50
- getRotationDisabled,
51
- setRotationDisabled,
52
- circuitBreaker,
53
- quotaStore,
54
- } = deps;
55
-
56
- // ── status route: what the settings card cannot know on its own ──
57
- //
58
- // Reports, per configured provider, which key is in use, which are cooling
59
- // down and until when, whether an env name resolves to a credential at all
60
- // (a typo is otherwise silent), and how often rotation has fired.
61
- //
62
- // Key VALUES never leave the host — only the boolean fact that one exists.
63
- ctx.effect(() => ctx.webServer.register({
64
- kind: 'exact',
65
- path: STATUS_PATH,
66
- handler: async (req, res) => {
67
- if (req.method !== 'GET') {
68
- json(res, 405, { error: { code: 'method', message: 'GET only' } });
69
- return;
70
- }
71
- if (!isTrustedBridgeRequest(req)) {
72
- json(res, 403, { error: { code: 'forbidden', message: 'dsh-key-rotation: status is local-only' } });
73
- return;
74
- }
75
- const runtime = buildRuntime();
76
- const { poolByRef, providerTags, providerBudgets, latencySloMs } = runtime;
77
- const base = ctx.get('credentials');
78
- const now = Date.now();
79
- const seen = new Set();
80
- const providers = [];
81
- for (const pool of poolByRef.values()) {
82
- if (seen.has(pool.base)) continue;
83
- seen.add(pool.base);
84
- try {
85
- const keys = [];
86
- for (const ref of pool.refs) {
87
- let present = false;
88
- let tail = '';
89
- let source = null;
90
- let writable = true;
91
- try {
92
- // The resolve patch is installed on this same service, so ask for
93
- // the exact ref: a pool ref would otherwise round-robin to another
94
- // key and report a missing name as present.
95
- let hit = await (base?.__dshKeyRotationOriginalResolve ?? base?.resolve)?.call(base, ref);
96
- present = Boolean(hit && typeof hit.value === 'string' && hit.value.length > 0);
97
- if (present) tail = keyTail(hit.value);
98
- // fallback: env var bootstrapping (issue #7)
99
- if (!present) {
100
- const ev = envValue(ref);
101
- if (ev !== undefined) { present = true; tail = keyTail(ev); source = 'env'; writable = false; }
102
- }
103
- } catch {
104
- present = false;
105
- }
106
- try {
107
- const described = await base?.describe?.(ref);
108
- source = described?.source ?? null;
109
- writable = described?.writable !== false;
110
- } catch {
111
- /* describe is optional — the card falls back to editable */
112
- }
113
- const until = pool.state.failedUntil.get(ref);
114
- keys.push({
115
- ref,
116
- present,
117
- tail,
118
- source,
119
- writable,
120
- active: pool.state.lastUsed === ref,
121
- cooldownMsLeft: until !== undefined && until > now ? until - now : 0,
122
- // #210: RPM capacity snapshot (null when rpmLimit is off)
123
- rpm: bucketInfo(pool.state.rpmWindows, ref, pool.rpmLimit, now),
124
- // #215: effective round-robin weight of this key
125
- weight: pool.weights?.[pool.refs.indexOf(ref)] ?? 1,
126
- usage: pool.state.usageCounts?.get(ref) ?? 0,
127
- byModel: pool.state.byModel?.get(ref) ? Object.fromEntries(pool.state.byModel.get(ref)) : {},
128
- usageDays: pool.state.usageDays?.get(ref) ? Object.fromEntries(pool.state.usageDays.get(ref)) : {},
129
- cost: pool.state.costPerKey?.get(ref) ?? 0,
130
- lastUsedAt: pool.state.lastUsedAt?.get(ref) ?? null,
131
- expiresAt: pool.expiresAt?.[ref] ?? null,
132
- expired: pool.expiresAt?.[ref] !== undefined && now >= pool.expiresAt[ref],
133
- broken: pool.state.brokenUntil?.has(ref) ?? false,
134
- });
135
- }
136
- providers.push({
137
- provider: pool.base,
138
- keys,
139
- tags: providerTags.get(pool.base) ?? [],
140
- // #260 circuit breaker state (may be null if not yet tripped)
141
- circuit: (() => {
142
- const br = runtime.breaker;
143
- if (!br) return null;
144
- const st = br.state(pool.base);
145
- return { state: st, threshold: br.threshold, openMs: br.openMs };
146
- })(),
147
- switches: pool.state.switches ?? 0,
148
- lastReason: pool.state.lastReason ?? null,
149
- lastSwitchAt: pool.state.lastSwitchAt ?? null,
150
- lastExhaustionAt: pool.state.lastExhaustionAt ?? null,
151
- exhaustionCount: pool.state.exhaustionCount ?? 0,
152
- totalUsage: (() => { let s = 0; if (pool.state.usageCounts) for (const v of pool.state.usageCounts.values()) s += v; return s; })(),
153
- // #225: aggregate p95 across the pool's keys
154
- p95: (() => {
155
- const vals = (pool.refs ?? []).map((r) => (typeof latencyHistogram?.snapshot === 'function' ? latencyHistogram.snapshot(r) : null)).filter((s) => s && s.p95 != null).map((s) => s.p95);
156
- return vals.length ? Math.round(Math.max(...vals)) : null;
157
- })(),
158
- latencySloMs,
159
- events: (pool.state.events ?? []).slice(-50),
160
- healthScore: computeHealthScore(pool.state),
161
- // #208: today/week spend + configured budget for the card
162
- todayCost: costForDay(pool.state.costDays),
163
- weeklyCost: costForWeek(pool.state.costDays, now),
164
- budgetDaily: (providerBudgets?.get ? providerBudgets.get(pool.base) : providerBudgets?.[pool.base])?.costBudgetDaily ?? 0,
165
- budgetWeekly: (providerBudgets?.get ? providerBudgets.get(pool.base) : providerBudgets?.[pool.base])?.costBudgetWeekly ?? 0,
166
- pauseOnBudget: (providerBudgets?.get ? providerBudgets.get(pool.base) : providerBudgets?.[pool.base])?.pauseOnBudget ?? false,
167
- routingStrategy: pool.routingStrategy ?? runtime.routingStrategy ?? 'round-robin',
168
- proactiveRateLimitGuard: pool.proactiveRateLimitGuard ?? runtime.proactiveRateLimitGuard ?? true,
169
- });
170
- } catch (e) {
171
- console.warn(`[dsh-key-rotation] status: pool ${pool.base} failed: ${String(e?.message ?? e)} ${e?.stack ?? ''}`);
172
- providers.push({ provider: pool.base, keys: [], statusError: String(e?.message ?? e) });
173
- }
174
- }
175
- json(res, 200, sanitizeSnapshot({
176
- providers,
177
- // #266/#263 operational extras (additive)
178
- meta: {
179
- expectedClones: [...(runtime.expectedClones ?? [])],
180
- notifyQueue: runtime.notifyQueue?.stats?.() ?? null,
181
- breakerEnabled: runtime.circuitBreakerEnabled !== false,
182
- at: now,
183
- },
184
- }, now));
185
- },
186
- }), 'dsh-key-rotation: status route');
187
-
188
- // #209: usage report - per-key requests/cost over the last N days.
189
- // ?format=csv returns text/csv; ?days=N window (1..90, default 7).
190
- ctx.effect(() => ctx.webServer.register({
191
- kind: 'exact',
192
- path: USAGE_PATH,
193
- handler: (req, res) => {
194
- if (req.method !== 'GET') { json(res, 405, { error: { code: 'method', message: 'GET only' } }); return; }
195
- if (!isTrustedBridgeRequest(req)) { json(res, 403, { error: { code: 'forbidden', message: 'dsh-key-rotation: usage is local-only' } }); return; }
196
- const url = new URL(req.url ?? USAGE_PATH, 'http://localhost');
197
- const days = Math.min(90, Math.max(1, Number(url.searchParams.get('days')) || 7));
198
- const csv = url.searchParams.get('format') === 'csv';
199
- const provider = url.searchParams.get('provider') ?? '';
200
- const runtime = buildRuntime();
201
- const now = Date.now();
202
- const seen = new Set();
203
- const report = [];
204
- for (const pool of runtime.poolByRef.values()) {
205
- if (seen.has(pool.base)) continue;
206
- seen.add(pool.base);
207
- if (provider && pool.base !== provider) continue;
208
- report.push({ provider: pool.base, rows: usageRows(pool, days, now) });
209
- }
210
- if (csv) {
211
- res.writeHead(200, { 'content-type': 'text/csv; charset=utf-8', 'content-disposition': 'attachment; filename="dsh-key-rotation-usage.csv"' });
212
- const parts = [];
213
- for (const p of report) {
214
- if (parts.length > 0) parts.push('');
215
- parts.push('# ' + p.provider);
216
- parts.push(usageCsv(p.rows));
217
- }
218
- res.end(parts.join('\n') + '\n');
219
- return;
220
- }
221
- json(res, 200, { at: now, days, providers: report });
222
- },
223
- }), 'dsh-key-rotation: usage route');
224
-
225
- // #218: full config snapshot - one JSON file to move between machines.
226
- // Secret values never travel: only credential/env names. Token fields are
227
- // exported as empty strings; on import they keep existing values when empty.
228
- ctx.effect(() => ctx.webServer.register({
229
- kind: 'exact',
230
- path: SNAPSHOT_PATH,
231
- handler: async (req, res) => {
232
- if (req.method !== 'GET' && req.method !== 'POST') { json(res, 405, { error: { code: 'method', message: 'GET (export) or POST (import) only' } }); return; }
233
- if (!isTrustedBridgeRequest(req)) { json(res, 403, { error: { code: 'forbidden', message: 'dsh-key-rotation: snapshot is local-only' } }); return; }
234
- if (req.method === 'GET') {
235
- const descriptor = descriptorOf(ctx, NS);
236
- const value = descriptor?.value ?? {};
237
- const exportable = { ...value };
238
- // token-shaped fields stay empty in the file; refs are names, not secrets
239
- exportable.webhookActionToken = '';
240
- json(res, 200, { at: Date.now(), version: 1, snapshot: exportable });
241
- return;
242
- }
243
- // POST = import: { snapshot } -> merge with current section, PUT semantics
244
- let body;
245
- try { body = await readJson(req); } catch (e) { json(res, 400, { error: { code: 'bad-request', message: String(e?.message ?? e) } }); return; }
246
- const snap = body?.snapshot;
247
- if (!snap || typeof snap !== 'object' || Array.isArray(snap)) { json(res, 400, { error: { code: 'bad-format', message: 'dsh-key-rotation: POST requires {"snapshot": {...}}' } }); return; }
248
- // #200 leak guard applies to imported content too
249
- try {
250
- const masked = structuredClone(snap);
251
- if (masked.webhookActionToken) masked.webhookActionToken = '***';
252
- if (masked.notifyWebhook) masked.notifyWebhook = '***';
253
- const findings = findSecrets(JSON.stringify(masked));
254
- if (findings.length > 0) { json(res, 400, { error: { code: 'secret-in-snapshot', message: 'dsh-key-rotation: snapshot carries a live-looking credential', findings } }); return; }
255
- } catch { /* scanning must never block a valid import */ }
256
- const settings = ctx.get('settings');
257
- if (!settings) { json(res, 503, { error: { code: 'settings-rejected', message: 'dsh-key-rotation: no settings provider' } }); return; }
258
- const desc = descriptorOf(ctx, NS);
259
- if (desc === void 0) { json(res, 500, { error: { code: 'settings-rejected', message: 'dsh-key-rotation: namespace missing' } }); return; }
260
- const cur = desc.value ?? {};
261
- // empty token fields in the file keep the current values (never wipe a secret)
262
- const merged = { ...cur, ...snap };
263
- if (!snap.webhookActionToken) merged.webhookActionToken = cur.webhookActionToken ?? '';
264
- try {
265
- await settings.replace(NS, merged, desc.revision);
266
- const after = descriptorOf(ctx, NS);
267
- json(res, 200, { ok: true, revision: after?.revision });
268
- } catch (e) {
269
- json(res, e?.code === 'SETTINGS_CONFLICT' ? 409 : 400, { error: { code: 'settings-rejected', message: String(e?.message ?? e) } });
270
- }
271
- },
272
- }), 'dsh-key-rotation: snapshot route');
273
-
274
- // ── key route: store a key value without leaving the rotation card ──
275
- //
276
- // Adding a key used to mean two screens: create the credential elsewhere,
277
- // then type its env name here. The value is write-only from the browser —
278
- // it is never sent back, only its last few characters are (see the status
279
- // route) — and the route is loopback- and same-origin-gated like the config
280
- // bridge next to it.
281
- ctx.effect(() => ctx.webServer.register({
282
- kind: 'exact',
283
- path: KEY_PATH,
284
- handler: async (req, res) => {
285
- if (req.method !== 'PUT' && req.method !== 'DELETE') {
286
- json(res, 405, { error: { code: 'method', message: 'PUT or DELETE only' } });
287
- return;
288
- }
289
- if (!isTrustedBridgeRequest(req)) {
290
- json(res, 403, { error: { code: 'forbidden', message: 'dsh-key-rotation: keys are local-only' } });
291
- return;
292
- }
293
- const credentialsService = ctx.get('credentials');
294
- if (!credentialsService || typeof credentialsService.set !== 'function') {
295
- json(res, 503, { error: { code: 'no-credentials', message: 'dsh-key-rotation: no credentials service is mounted' } });
296
- return;
297
- }
298
- let body;
299
- try {
300
- body = await readJson(req);
301
- } catch (error) {
302
- json(res, 400, { error: { code: 'bad-request', message: String(error?.message ?? error) } });
303
- return;
304
- }
305
- const ref = typeof body?.ref === 'string' ? body.ref.trim() : '';
306
- if (!isValidRef(ref)) {
307
- json(res, 400, { error: { code: 'bad-ref', message: 'dsh-key-rotation: ref must be an environment variable name' } });
308
- return;
309
- }
310
- try {
311
- if (req.method === 'DELETE') {
312
- await credentialsService.unset(ref);
313
- for (const st of poolState.values()) {
314
- st.failedUntil?.delete(ref);
315
- st.failCounts?.delete(ref);
316
- st.authFailCounts?.delete(ref);
317
- st.brokenUntil?.delete(ref);
318
- if (st.lastUsed === ref) st.lastUsed = undefined;
319
- }
320
- bestEffort('lastTestCache.delete', () => { lastTestCache?.delete?.(ref); }, ctx.logger);
321
- json(res, 200, { ok: true, ref });
322
- return;
323
- }
324
- const value = typeof body?.value === 'string' ? body.value.trim() : '';
325
- if (value.length === 0) {
326
- json(res, 400, { error: { code: 'empty-value', message: 'dsh-key-rotation: an empty key cannot be stored' } });
327
- return;
328
- }
329
- await credentialsService.set(ref, value);
330
- // #200: leak-detector hint - stored value should look like a credential
331
- const secretShape = looksLikeApiSecret(value);
332
- json(res, 200, { ok: true, ref, tail: keyTail(value), looksLikeSecret: secretShape });
333
- } catch (error) {
334
- // A ref supplied by the launching environment is read-only, and the
335
- // service says so in plain words — pass that through to the card.
336
- json(res, 409, { error: { code: 'write-rejected', message: String(error?.message ?? error) } });
337
- }
338
- },
339
- }), 'dsh-key-rotation: key route');
340
-
341
- // ── reset route: clear cooldown for a provider (or a single ref) ──
342
- ctx.effect(() => ctx.webServer.register({
343
- kind: 'exact',
344
- path: RESET_PATH,
345
- handler: async (req, res) => {
346
- if (req.method !== 'POST') {
347
- json(res, 405, { error: { code: 'method', message: 'POST only' } });
348
- return;
349
- }
350
- if (!isTrustedBridgeRequest(req)) {
351
- json(res, 403, { error: { code: 'forbidden', message: 'dsh-key-rotation: reset is local-only' } });
352
- return;
353
- }
354
- let body;
355
- try { body = await readJson(req); } catch (e) {
356
- json(res, 400, { error: { code: 'bad-request', message: String(e?.message ?? e) } });
357
- return;
358
- }
359
- const provider = typeof body?.provider === 'string' ? body.provider.trim() : '';
360
- const ref = typeof body?.ref === 'string' ? body.ref.trim() : '';
361
- if (provider) {
362
- const st = poolState.get(provider);
363
- if (!st) { json(res, 404, { error: { code: 'not-found', message: `dsh-key-rotation: no pool for '${provider}'` } }); return; }
364
- const cleared = st.failedUntil.size;
365
- st.failedUntil.clear();
366
- st.failCounts?.clear();
367
- st.authFailCounts?.clear();
368
- st.brokenUntil?.clear();
369
- st.switches = 0; st.lastReason = undefined; st.lastSwitchAt = undefined;
370
- let circuitReset = false;
371
- const br = circuitBreaker ?? buildRuntime().breaker;
372
- if (br) {
373
- if (typeof br.reset === 'function') { br.reset(provider); circuitReset = true; }
374
- else if (typeof br.onSuccess === 'function') { br.onSuccess(provider); circuitReset = true; }
375
- }
376
- json(res, 200, { ok: true, provider, cleared, circuitReset });
377
- return;
378
- }
379
- if (ref) {
380
- let found = false;
381
- for (const st of poolState.values()) {
382
- if (st.failedUntil?.has(ref) || st.failCounts?.has(ref) || st.authFailCounts?.has(ref) || st.brokenUntil?.has(ref)) {
383
- st.failedUntil?.delete(ref);
384
- st.failCounts?.delete(ref);
385
- st.authFailCounts?.delete(ref);
386
- st.brokenUntil?.delete(ref);
387
- if (st.lastUsed === ref) st.lastUsed = undefined;
388
- found = true; break;
389
- }
390
- }
391
- // idempotent: even if ref was not cooling, report ok if it looks like a valid ref name
392
- if (!found && !isValidRef(ref)) { json(res, 400, { error: { code: 'bad-ref', message: 'dsh-key-rotation: ref must be an environment variable name' } }); return; }
393
- json(res, 200, { ok: true, ref });
394
- return;
395
- }
396
- json(res, 400, { error: { code: 'bad-request', message: 'dsh-key-rotation: POST requires {"provider": "..."} or {"ref": "..."}' } });
397
- },
398
- }), 'dsh-key-rotation: reset route');
399
-
400
- ctx.effect(() => ctx.webServer.register({
401
- kind: 'exact',
402
- path: IMPORT_PATH,
403
- handler: async (req, res) => {
404
- if (req.method !== 'POST') { json(res, 405, { error: { code: 'method', message: 'POST only' } }); return; }
405
- if (!isTrustedBridgeRequest(req)) { json(res, 403, { error: { code: 'forbidden', message: 'dsh-key-rotation: import is local-only' } }); return; }
406
- let body; try { body = await readJson(req); } catch (e) { json(res, 400, { error: { code: 'bad-request', message: String(e?.message ?? e) } }); return; }
407
- const url = typeof body?.url === 'string' ? body.url.trim() : '';
408
- if (!url || !url.startsWith('https://')) { json(res, 400, { error: { code: 'bad-url', message: 'dsh-key-rotation: only HTTPS URLs are allowed' } }); return; }
409
- try {
410
- const resp = await fetch(url);
411
- if (!resp.ok) { json(res, 400, { error: { code: 'fetch-failed', message: 'dsh-key-rotation: fetch returned ' + resp.status } }); return; }
412
- const data = await resp.json();
413
- if (!Array.isArray(data)) { json(res, 400, { error: { code: 'bad-format', message: 'dsh-key-rotation: expected JSON array of providers' } }); return; }
414
- const settings = ctx.get('settings');
415
- if (!settings) { json(res, 503, { error: { code: 'settings-rejected', message: 'dsh-key-rotation: no settings provider' } }); return; }
416
- const desc = settings.describe({ redactSecrets: true }).find((c) => c.ns === NS);
417
- const cur = desc?.value?.providers ?? [];
418
- const merged = new Map();
419
- for (const p of cur) if (p && p.provider) merged.set(p.provider, p);
420
- for (const p of data) if (p && p.provider && typeof p.provider === 'string') merged.set(p.provider, p);
421
- const mergedArr = [...merged.values()];
422
- await settings.replace(NS, { ...(desc?.value ?? {}), providers: mergedArr }, desc?.revision);
423
- json(res, 200, { ok: true, providersImported: data.length, total: mergedArr.length });
424
- } catch (e) { json(res, 400, { error: { code: 'import-failed', message: String(e?.message ?? e) } }); }
425
- },
426
- }), 'dsh-key-rotation: import route');
427
-
428
- // Health for external panels (Beszel/Uptime)
429
- ctx.effect(() => ctx.webServer.register({
430
- kind: 'exact',
431
- path: HEALTH_PATH,
432
- handler: async (req, res) => {
433
- if (!isTrustedBridgeRequest(req) && req.socket?.remoteAddress !== '127.0.0.1' && req.socket?.remoteAddress !== '::1') { } // allow same-origin already checked
434
- if (!isTrustedBridgeRequest(req)) {
435
- // also allow plain loopback without Origin
436
- if (!isLoopbackAddress(req.socket?.remoteAddress)) { res.writeHead(403); res.end(); return; }
437
- if (req.headers['sec-fetch-site'] === 'cross-site') { res.writeHead(403); res.end(); return; }
438
- }
439
- if (req.method !== 'GET') { json(res, 405, { error: { code: 'method', message: 'GET only' } }); return; }
440
- const now = Date.now();
441
- const pools = {};
442
- let exhaustedAny = false;
443
- const { poolByRef: pr, providerTags } = buildRuntime();
444
- const seenH = new Set();
445
- for (const pool of pr.values()) {
446
- if (seenH.has(pool.base)) continue;
447
- seenH.add(pool.base);
448
- let healthy = 0;
449
- for (const ref of pool.refs) {
450
- const until = pool.state.failedUntil.get(ref);
451
- if (until !== undefined && until > now) continue;
452
- const exp = pool.expiresAt?.[ref];
453
- if (exp !== undefined && now >= exp) continue;
454
- healthy++;
455
- }
456
- const total = pool.refs.length;
457
- const exhausted = healthy === 0 && total > 0;
458
- if (exhausted) exhaustedAny = true;
459
- pools[pool.base] = { healthy, total, exhausted, healthScore: computeHealthScore(pool.state) };
460
- }
461
- json(res, 200, { status: exhaustedAny ? 'degraded' : 'ok', pools, exhaustedAny, latency: latencyHistogram.snapshotAll(), quota: typeof quotaStore?.snapshot === 'function' ? quotaStore.snapshot() : null });
462
- },
463
- }), 'dsh-key-rotation: health');
464
-
465
- // ── test route: dry-run a single key without rotation ──
466
- ctx.effect(() => ctx.webServer.register({
467
- kind: 'exact',
468
- path: TEST_PATH,
469
- handler: async (req, res) => {
470
- if (req.method !== 'POST') { json(res, 405, { error: { code: 'method', message: 'POST only' } }); return; }
471
- if (!isTrustedBridgeRequest(req)) { json(res, 403, { error: { code: 'forbidden', message: 'dsh-key-rotation: test is local-only' } }); return; }
472
- let body; try { body = await readJson(req); } catch (e) { json(res, 400, { error: { code: 'bad-request', message: String(e?.message ?? e) } }); return; }
473
- const ref = typeof body?.ref === 'string' ? body.ref.trim() : '';
474
- if (!isValidRef(ref)) { json(res, 400, { error: { code: 'bad-ref', message: 'dsh-key-rotation: ref must be an environment variable name' } }); return; }
475
- // Optional value for pre-save validation (issue #118)
476
- const testValue = typeof body?.value === 'string' && body.value.length > 0 ? body.value : undefined;
477
- const probe = body?.probe === 'models' || body?.probe === 'chat' ? body.probe : undefined;
478
- const base = ctx.get('credentials');
479
- try {
480
- let hit = await (base?.__dshKeyRotationOriginalResolve ?? base?.resolve)?.call(base, ref);
481
- let present = Boolean(hit && typeof hit.value === 'string' && hit.value.length > 0);
482
- const effectiveValue = testValue || hit?.value;
483
- const valid = present ? Boolean(effectiveValue && typeof effectiveValue === 'string' && effectiveValue.length > 0) : Boolean(testValue);
484
- const tail = valid ? keyTail(effectiveValue) : '';
485
- let source = null;
486
- await bestEffort('credentials.describe', async () => { const d = await base?.describe?.(ref); source = d?.source ?? null; }, ctx.logger);
487
- if (!present && !testValue) { json(res, 200, { ok: false, ref, code: 'no-credential', message: 'no such credential' }); return; }
488
- if (!present && testValue) { source = 'pre-save'; }
489
- else if (!present) {
490
- const ev = envValue(ref);
491
- if (ev !== undefined) { present = true; json(res, 200, { ok: true, ref, tail: keyTail(ev), source: 'env' }); return; }
492
- }
493
- // sandbox probe (models is free; chat is hook-only, see sandbox.js)
494
- if (probe) {
495
- const keyForProbe = effectiveValue;
496
- const runner = ensureSandboxRunner(ctx);
497
- const result = probe === 'chat' ? await runner.probeChat(ref, keyForProbe) : await runner.probeModels(ref, keyForProbe);
498
- const cached = { ...result, at: Date.now() };
499
- lastTestCache.set(ref, cached);
500
- if (cached.ok) {
501
- for (const st of poolState.values()) {
502
- if (st.failedUntil?.has(ref) || st.failCounts?.has(ref) || st.brokenUntil?.has(ref)) {
503
- st.failedUntil?.delete(ref);
504
- st.failCounts?.delete(ref);
505
- st.authFailCounts?.delete(ref);
506
- st.brokenUntil?.delete(ref);
507
- }
508
- }
509
- }
510
- json(res, 200, { ok: cached.ok, ref, tail, source, probe, code: cached.code, latencyMs: cached.latencyMs, modelsCount: cached.modelsCount });
511
- return;
512
- }
513
- json(res, 200, { ok: true, ref, tail, source });
514
- } catch (e) {
515
- json(res, 200, { ok: false, ref, code: 'error', message: String(e?.message ?? e) });
516
- }
517
- },
518
- }), 'dsh-key-rotation: test route');
519
-
520
- // Intercept the llm/stream waterfall: rotate any request whose provider maps
521
- // to a configured key pool; pass everything else (and internal dispatches)
522
- // straight through.
523
- // Read-only cache snapshot for clients (badge polling).
524
- ctx.effect(() => ctx.webServer.register({
525
- kind: 'exact',
526
- path: SANDBOX_CACHE_PATH,
527
- handler: (req, res) => {
528
- if (!isTrustedBridgeRequest(req)) { json(res, 403, { error: { code: 'forbidden', message: 'dsh-key-rotation: cache is local-only' } }); return; }
529
- const cacheSnap = typeof lastTestCache?.snapshot === 'function'
530
- ? lastTestCache.snapshot()
531
- : (lastTestCache instanceof Map ? Object.fromEntries(lastTestCache) : (lastTestCache ?? {}));
532
- json(res, 200, cacheSnap);
533
- },
534
- }), 'dsh-key-rotation: sandbox cache');
535
-
536
-
537
-
538
- // #199 webhook-action: interactive webhook buttons call back here.
539
- // Auth: bearer token from Config (external services like Telegram/Discord
540
- // cannot be same-origin, so a shared secret is the gate).
541
- ctx.effect(() => ctx.webServer.register({
542
- kind: 'exact',
543
- path: '/dsh-key-rotation/webhook-action',
544
- handler: async (req, res) => {
545
- if (req.method !== 'POST') { json(res, 405, { error: { code: 'method', message: 'POST only' } }); return; }
546
- const runtime = buildRuntime();
547
- const expected = runtime.webhookActionToken;
548
- if (!expected) { json(res, 503, { error: { code: 'no-token', message: 'dsh-key-rotation: webhookActionToken is not configured' } }); return; }
549
- const auth = String(req.headers.authorization ?? '');
550
- if (auth !== `Bearer ${expected}`) { json(res, 401, { error: { code: 'unauthorized', message: 'dsh-key-rotation: bad webhook action token' } }); return; }
551
- let body;
552
- try { body = await readJson(req); } catch (e) { json(res, 400, { error: { code: 'bad-request', message: String(e?.message ?? e) } }); return; }
553
- // Accept callback payloads from formatInteractive (Telegram/Discord/Slack) or plain {action}
554
- let action = typeof body?.action === 'string' ? body.action : '';
555
- if (!action && typeof body?.data === 'string') {
556
- try { action = String(JSON.parse(body.data)?.id ?? ''); } catch { action = ''; }
557
- }
558
- if (!action && typeof body?.callback_data === 'string') {
559
- try { action = String(JSON.parse(body.callback_data)?.id ?? ''); } catch { action = ''; }
560
- }
561
- // #222: Telegram update envelope {update_id, callback_query:{data}}
562
- if (!action && typeof body?.callback_query?.data === 'string') {
563
- try { action = String(JSON.parse(body.callback_query.data)?.id ?? ''); } catch { action = ''; }
564
- }
565
- // #222: Telegram setWebhook registration helper
566
- if (typeof body?.setWebhook === 'object' && body.setWebhook) {
567
- const botToken = typeof body.setWebhook.botToken === 'string' ? body.setWebhook.botToken : '';
568
- if (!botToken) { json(res, 400, { error: { code: 'bad-request', message: 'dsh-key-rotation: setWebhook.botToken required' } }); return; }
569
- // derive the public URL from request headers; explicit URL wins
570
- const url = typeof body.setWebhook.url === 'string' && body.setWebhook.url ? body.setWebhook.url : `https://${String(req.headers.host ?? '')}/dsh-key-rotation/webhook-action`;
571
- try {
572
- const hookRes = await fetch(`https://api.telegram.org/bot${botToken}/setWebhook`, {
573
- method: 'POST', headers: { 'content-type': 'application/json' },
574
- body: JSON.stringify({ url, allowed_updates: ['callback_query'] }),
575
- });
576
- const hookData = await hookRes.json().catch(() => ({}));
577
- json(res, 200, { ok: hookRes.ok, url, telegram: hookData });
578
- } catch (e) {
579
- json(res, 502, { error: { code: 'telegram-failed', message: String(e?.message ?? e) } });
580
- }
581
- return;
582
- }
583
- if (!action) { json(res, 400, { error: { code: 'bad-action', message: 'dsh-key-rotation: no action in payload' } }); return; }
584
- const provider = action.startsWith('pause-') || action.startsWith('reset-') ? action.replace(/^(pause|reset)-/, '') : '';
585
- try {
586
- if (action === 'disable-rotation') {
587
- setRotationDisabled(true);
588
- console.warn('[dsh-key-rotation] rotation DISABLED via webhook action');
589
- json(res, 200, { ok: true, action });
590
- return;
591
- }
592
- if (action === 'enable-rotation') {
593
- setRotationDisabled(false);
594
- json(res, 200, { ok: true, action });
595
- return;
596
- }
597
- if (action.startsWith('pause-') || action.startsWith('reset-')) {
598
- const st = poolState.get(provider);
599
- if (!st) { json(res, 404, { error: { code: 'not-found', message: `dsh-key-rotation: no pool for '${provider}'` } }); return; }
600
- if (action.startsWith('pause-')) {
601
- const until = Date.now() + 3600000; // 1h pause
602
- for (const ref of (st.failedUntil ? [...st.failedUntil.keys()] : [])) st.failedUntil.set(ref, Math.max(st.failedUntil.get(ref) ?? 0, until));
603
- // also pause every key currently healthy
604
- for (const p of buildRuntime().poolByRef.values()) {
605
- if (p.base !== provider) continue;
606
- for (const ref of p.refs) st.failedUntil.set(ref, Math.max(st.failedUntil.get(ref) ?? 0, until));
607
- }
608
- console.warn(`[dsh-key-rotation] pool ${provider} PAUSED 1h via webhook action`);
609
- json(res, 200, { ok: true, action, provider, until: Date.now() + 3600000 });
610
- return;
611
- }
612
- const cleared = st.failedUntil.size;
613
- st.failedUntil.clear();
614
- st.failCounts?.clear();
615
- st.authFailCounts?.clear();
616
- st.brokenUntil?.clear();
617
- st.switches = 0;
618
- st.lastReason = undefined;
619
- st.lastSwitchAt = undefined;
620
- let circuitReset = false;
621
- const br = circuitBreaker ?? buildRuntime().breaker;
622
- if (br) {
623
- if (typeof br.reset === 'function') { br.reset(provider); circuitReset = true; }
624
- else if (typeof br.onSuccess === 'function') { br.onSuccess(provider); circuitReset = true; }
625
- }
626
- console.warn(`[dsh-key-rotation] pool ${provider} RESET via webhook action (circuitReset=${circuitReset})`);
627
- json(res, 200, { ok: true, action, provider, cleared, circuitReset });
628
- return;
629
- }
630
- json(res, 400, { error: { code: 'unknown-action', message: `dsh-key-rotation: unknown action '${action}'` } });
631
- } catch (e) {
632
- json(res, 500, { error: { code: 'action-failed', message: String(e?.message ?? e) } });
633
- }
634
- },
635
- }), 'dsh-key-rotation: webhook-action');
636
-
637
-
638
-
639
-
16
+ registerStatusRoutes(ctx, deps);
17
+ registerTelemetryRoutes(ctx, deps);
18
+ registerKeyRoutes(ctx, deps);
19
+ registerTestRoutes(ctx, deps);
20
+ registerWebhookActionRoute(ctx, deps);
640
21
  }