@goodandready/dsh-key-rotation 0.8.78 → 0.8.80

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/ops-keys.js CHANGED
@@ -1,567 +1,2 @@
1
- import { decryptSecret, encryptSecret } from './crypto-storage.js';
2
- // lib/ops-keys.js — key store + cooldown reset + import routes (#312 split from routes-ops.js).
3
- import {
4
- json,
5
- readJson,
6
- NS,
7
- } from './http-bridge.js';
8
- import { safeFetchJson } from './safe-fetch.js';
9
- import {
10
- isTrustedBridgeRequest,
11
- isValidRef,
12
- keyTail,
13
- envValue,
14
- } from './pool.js';
15
- import { looksLikeApiSecret } from './keycheck.js';
16
- import { mutateSettings, validateProviders } from './settings-write.js';
17
- import { bestEffort } from './best-effort.js';
18
- import {
19
- KEY_PATH,
20
- RESET_PATH,
21
- IMPORT_PATH,
22
- DRAIN_PATH,
23
- DISCOVER_PATH,
24
- AUTO_IMPORT_PATH,
25
- PRESET_LIMITS_PATH,
26
- } from './ops-paths.js';
27
- import { lookupPreset, CANONICAL_PROVIDER_PRESETS } from './provider-limits-detector.js';
28
- import fs from 'node:fs';
29
- import os from 'node:os';
30
- import path from 'node:path';
31
- import { markKeyDraining, isKeyDraining, cancelKeyDrain, getDrainingStatus, getAllDrainingKeys } from './key-drain.js';
32
-
33
- /**
34
- * @param {object} ctx cordis context
35
- * @param {object} deps live dependencies from apply()
36
- */
37
- const KNOWN_PROVIDER_PATTERNS = [
38
- { provider: 'deepseek', pattern: /^DEEPSEEK_API_KEY(?:_\w+)?$/i },
39
- { provider: 'opencode', pattern: /^OPENCODE(?:_GO)?_API_KEY(?:_\w+)?$/i },
40
- { provider: 'openai', pattern: /^OPENAI_API_KEY(?:_\w+)?$/i },
41
- { provider: 'anthropic', pattern: /^ANTHROPIC_API_KEY(?:_\w+)?$/i },
42
- { provider: 'openrouter', pattern: /^OPENROUTER_API_KEY(?:_\w+)?$/i },
43
- { provider: 'siliconflow', pattern: /^SILICONFLOW_API_KEY(?:_\w+)?$/i },
44
- { provider: 'groq', pattern: /^GROQ_API_KEY(?:_\w+)?$/i },
45
- { provider: 'mistral', pattern: /^MISTRAL_API_KEY(?:_\w+)?$/i },
46
- { provider: 'gemini', pattern: /^(?:GEMINI|GOOGLE)_API_KEY(?:_\w+)?$/i },
47
- { provider: 'ollama', pattern: /^OLLAMA_API_KEY(?:_\w+)?$/i },
48
- { provider: 'sensenova', pattern: /^SENSENOVA_API_KEY(?:_\w+)?$/i },
49
- { provider: 'cohere', pattern: /^COHERE_API_KEY(?:_\w+)?$/i },
50
- { provider: 'fal', pattern: /^FAL_API_KEY(?:_\w+)?$/i },
51
- { provider: 'together', pattern: /^TOGETHER_API_KEY(?:_\w+)?$/i },
52
- { provider: 'xai', pattern: /^XAI_API_KEY(?:_\w+)?$/i },
53
- ];
54
-
55
- export function inferProviderFromRef(ref) {
56
- if (!ref || typeof ref !== 'string') return null;
57
- for (const { provider, pattern } of KNOWN_PROVIDER_PATTERNS) {
58
- if (pattern.test(ref)) return provider;
59
- }
60
- const generic = /^([A-Z0-9]+)_API_KEY(?:_\w+)?$/i.exec(ref);
61
- if (generic && generic[1]) {
62
- const prov = generic[1].toLowerCase();
63
- if (!/TOKEN|SECRET|WEBHOOK|PASSWORD|AUTH|SESSION|OAUTH|GITEA|KANBAN/i.test(prov)) {
64
- return prov;
65
- }
66
- }
67
- return null;
68
- }
69
-
70
- export async function discoverUnconfiguredKeys(ctx, runtimePools = []) {
71
- const configuredRefs = new Set();
72
- for (const p of runtimePools) {
73
- for (const r of (p.refs || [])) configuredRefs.add(r);
74
- for (const r of (p.keys || [])) configuredRefs.add(typeof r === 'string' ? r : r?.ref);
75
- }
76
-
77
- // Also check DSH settings describe if available
78
- const settings = ctx.get('settings');
79
- if (settings && typeof settings.describe === 'function') {
80
- try {
81
- const desc = settings.describe({ redactSecrets: true })?.find?.((c) => c.ns === NS);
82
- const providers = desc?.value?.providers ?? [];
83
- for (const p of providers) {
84
- for (const k of (p.keys || [])) {
85
- configuredRefs.add(typeof k === 'string' ? k : k?.ref);
86
- }
87
- }
88
- } catch (_) { /* best-effort */ }
89
- }
90
-
91
- const candidates = new Map(); // ref -> { provider, source }
92
-
93
- // 1. Process environment
94
- for (const k of Object.keys(process.env)) {
95
- if (configuredRefs.has(k)) continue;
96
- const prov = inferProviderFromRef(k);
97
- if (prov && process.env[k]?.trim()) {
98
- candidates.set(k, { provider: prov, source: 'env' });
99
- }
100
- }
101
-
102
- // 2. ~/.dsh/.credentials.yaml
103
- try {
104
- const credPath = path.join(os.homedir(), '.dsh', '.credentials.yaml');
105
- if (fs.existsSync(credPath)) {
106
- const content = fs.readFileSync(credPath, 'utf8');
107
- let inRefs = false;
108
- for (const line of content.split('\n')) {
109
- const trimmed = line.trim();
110
- if (trimmed === 'refs:') {
111
- inRefs = true;
112
- continue;
113
- }
114
- if (inRefs) {
115
- if (line.startsWith(' ') && line.includes(':')) {
116
- const refName = trimmed.split(':')[0].trim();
117
- if (!configuredRefs.has(refName)) {
118
- const prov = inferProviderFromRef(refName);
119
- if (prov && !candidates.has(refName)) {
120
- candidates.set(refName, { provider: prov, source: 'credentials' });
121
- }
122
- }
123
- } else if (!line.startsWith(' ') && trimmed.length > 0) {
124
- inRefs = false;
125
- }
126
- }
127
- }
128
- }
129
- } catch (_) { /* best-effort */ }
130
-
131
- const credentialsService = ctx.get('credentials');
132
- const discovered = [];
133
-
134
- for (const [ref, { provider, source }] of candidates) {
135
- let tail = '';
136
- try {
137
- let hit = await (credentialsService?.__dshKeyRotationOriginalResolve ?? credentialsService?.resolve)?.call(credentialsService, ref);
138
- let raw = hit?.value;
139
- if (!raw) raw = envValue(ref);
140
- if (raw) {
141
- const plain = decryptSecret(raw);
142
- tail = keyTail(plain);
143
- }
144
- } catch (_) { /* best-effort tail */ }
145
- discovered.push({
146
- provider,
147
- ref,
148
- keyTail: tail,
149
- source,
150
- });
151
- }
152
-
153
- return discovered;
154
- }
155
-
156
-
157
- export function registerKeyRoutes(ctx, deps) {
158
-
159
- ctx.effect(() => ctx.webServer.register({
160
- kind: 'exact',
161
- path: DRAIN_PATH,
162
- handler: async (req, res) => {
163
- if (!isTrustedBridgeRequest(req)) {
164
- json(res, 403, { error: { code: 'forbidden', message: 'dsh-key-rotation: drain is local-only' } });
165
- return;
166
- }
167
- const concurrency = deps.concurrencyTracker;
168
- const keyDrainManager = deps.keyDrainManager;
169
-
170
- if (req.method === 'GET') {
171
- const url = new URL(req.url ?? DRAIN_PATH, 'http://localhost');
172
- const ref = url.searchParams.get('ref');
173
- if (ref) {
174
- const status = getDrainingStatus(ref, concurrency, keyDrainManager);
175
- if (!status) { json(res, 404, { error: { code: 'not-found', message: `Key ${ref} is not currently draining` } }); return; }
176
- json(res, 200, { ok: true, draining: status });
177
- return;
178
- }
179
- json(res, 200, { ok: true, draining: getAllDrainingKeys(concurrency, keyDrainManager) });
180
- return;
181
- }
182
-
183
- if (req.method === 'POST') {
184
- let body;
185
- try { body = await readJson(req); } catch (e) {
186
- json(res, 400, { error: { code: 'bad-request', message: String(e?.message ?? e) } });
187
- return;
188
- }
189
- const ref = body?.ref;
190
- if (!ref) { json(res, 400, { error: { code: 'bad-request', message: 'Missing ref' } }); return; }
191
- markKeyDraining(ref, body?.provider || '', keyDrainManager);
192
- json(res, 200, { ok: true, status: getDrainingStatus(ref, concurrency, keyDrainManager) });
193
- return;
194
- }
195
-
196
- if (req.method === 'DELETE') {
197
- const url = new URL(req.url ?? DRAIN_PATH, 'http://localhost');
198
- const ref = url.searchParams.get('ref');
199
- if (!ref) { json(res, 400, { error: { code: 'bad-request', message: 'Missing ref query parameter' } }); return; }
200
- const cancelled = cancelKeyDrain(ref, keyDrainManager);
201
- json(res, 200, { ok: true, cancelled });
202
- return;
203
- }
204
-
205
- json(res, 405, { error: { code: 'method', message: 'GET, POST, or DELETE only' } });
206
- },
207
- }), 'dsh-key-rotation: drain route');
208
- const {
209
- lastTestCache,
210
- poolState,
211
- buildRuntime,
212
- circuitBreaker,
213
- } = deps;
214
-
215
- // ── key route: store a key value without leaving the rotation card ──
216
- //
217
- // Adding a key used to mean two screens: create the credential elsewhere,
218
- // then type its env name here. The value is write-only from the browser —
219
- // it is never sent back, only its last few characters are (see the status
220
- // route) — and the route is loopback- and same-origin-gated like the config
221
- // bridge next to it.
222
- ctx.effect(() => ctx.webServer.register({
223
- kind: 'exact',
224
- path: KEY_PATH,
225
- handler: async (req, res) => {
226
- if (req.method !== 'PUT' && req.method !== 'DELETE' && req.method !== 'GET') {
227
- json(res, 405, { error: { code: 'method', message: 'GET, PUT or DELETE only' } });
228
- return;
229
- }
230
- if (!isTrustedBridgeRequest(req)) {
231
- json(res, 403, { error: { code: 'forbidden', message: 'dsh-key-rotation: keys are local-only' } });
232
- return;
233
- }
234
- const credentialsService = ctx.get('credentials');
235
- if (req.method === 'GET') {
236
- // Disallow plaintext key reveal without authorized session (#543)
237
- json(res, 403, { error: { code: 'forbidden', message: 'dsh-key-rotation: plaintext key extraction via GET is forbidden' } });
238
- return;
239
- }
240
- if (!credentialsService || typeof credentialsService.set !== 'function') {
241
- json(res, 503, { error: { code: 'no-credentials', message: 'dsh-key-rotation: no credentials service is mounted' } });
242
- return;
243
- }
244
- let body;
245
- try {
246
- body = await readJson(req);
247
- } catch (error) {
248
- json(res, 400, { error: { code: 'bad-request', message: String(error?.message ?? error) } });
249
- return;
250
- }
251
- const ref = typeof body?.ref === 'string' ? body.ref.trim() : '';
252
- if (!isValidRef(ref)) {
253
- json(res, 400, { error: { code: 'bad-ref', message: 'dsh-key-rotation: ref must be an environment variable name' } });
254
- return;
255
- }
256
- try {
257
- if (req.method === 'DELETE') {
258
- await credentialsService.unset(ref);
259
- for (const st of poolState.values()) {
260
- st.failedUntil?.delete(ref);
261
- st.failCounts?.delete(ref);
262
- st.authFailCounts?.delete(ref);
263
- st.brokenUntil?.delete(ref);
264
- st.revokedRefs?.delete(ref);
265
- st.tokenUsage?.delete(ref);
266
- if (st.lastUsed === ref) st.lastUsed = undefined;
267
- }
268
- bestEffort('lastTestCache.delete', () => { lastTestCache?.delete?.(ref); }, ctx.logger);
269
- json(res, 200, { ok: true, ref });
270
- return;
271
- }
272
- const value = typeof body?.value === 'string' ? body.value.trim() : '';
273
- if (value.length === 0) {
274
- json(res, 400, { error: { code: 'empty-value', message: 'dsh-key-rotation: an empty key cannot be stored' } });
275
- return;
276
- }
277
- const storedValue = encryptSecret(value);
278
- await credentialsService.set(ref, storedValue);
279
- // #200: leak-detector hint - stored value should look like a credential
280
- const secretShape = looksLikeApiSecret(value);
281
- json(res, 200, { ok: true, ref, tail: keyTail(value), looksLikeSecret: secretShape });
282
- } catch (error) {
283
- // A ref supplied by the launching environment is read-only, and the
284
- // service says so in plain words — pass that through to the card.
285
- json(res, 409, { error: { code: 'write-rejected', message: String(error?.message ?? error) } });
286
- }
287
- },
288
- }), 'dsh-key-rotation: key route');
289
-
290
-
291
- // ── reset route: clear cooldown for a provider (or a single ref) ──
292
- ctx.effect(() => ctx.webServer.register({
293
- kind: 'exact',
294
- path: RESET_PATH,
295
- handler: async (req, res) => {
296
- if (req.method !== 'POST') {
297
- json(res, 405, { error: { code: 'method', message: 'POST only' } });
298
- return;
299
- }
300
- if (!isTrustedBridgeRequest(req)) {
301
- json(res, 403, { error: { code: 'forbidden', message: 'dsh-key-rotation: reset is local-only' } });
302
- return;
303
- }
304
- let body;
305
- try { body = await readJson(req); } catch (e) {
306
- json(res, 400, { error: { code: 'bad-request', message: String(e?.message ?? e) } });
307
- return;
308
- }
309
- const provider = typeof body?.provider === 'string' ? body.provider.trim() : '';
310
- const ref = typeof body?.ref === 'string' ? body.ref.trim() : '';
311
- if (provider) {
312
- let found = false;
313
- let cleared = 0;
314
- for (const [key, st] of poolState.entries()) {
315
- if (key === provider || key.startsWith(provider + '::')) {
316
- found = true;
317
- cleared += st.failedUntil ? st.failedUntil.size : 0;
318
- st.failedUntil?.clear();
319
- st.failCounts?.clear();
320
- st.authFailCounts?.clear();
321
- st.brokenUntil?.clear();
322
- st.revokedRefs?.clear();
323
- st.tokenUsage?.clear();
324
- st.switches = 0;
325
- st.lastReason = undefined;
326
- st.lastSwitchAt = undefined;
327
- }
328
- }
329
- if (!found) {
330
- json(res, 404, { error: { code: 'not-found', message: `dsh-key-rotation: no pool for '${provider}'` } });
331
- return;
332
- }
333
- let circuitReset = false;
334
- const br = circuitBreaker ?? buildRuntime().breaker;
335
- if (br) {
336
- if (typeof br.reset === 'function') { br.reset(provider); circuitReset = true; }
337
- else if (typeof br.onSuccess === 'function') { br.onSuccess(provider); circuitReset = true; }
338
- }
339
- json(res, 200, { ok: true, provider, cleared, circuitReset });
340
- return;
341
- }
342
- if (ref) {
343
- let found = false;
344
- for (const st of poolState.values()) {
345
- if (
346
- st.failedUntil?.has(ref) ||
347
- st.failCounts?.has(ref) ||
348
- st.authFailCounts?.has(ref) ||
349
- st.brokenUntil?.has(ref) ||
350
- st.revokedRefs?.has(ref) ||
351
- st.tokenUsage?.has(ref) ||
352
- st.lastUsed === ref
353
- ) {
354
- st.failedUntil?.delete(ref);
355
- st.failCounts?.delete(ref);
356
- st.authFailCounts?.delete(ref);
357
- st.brokenUntil?.delete(ref);
358
- st.revokedRefs?.delete(ref);
359
- st.tokenUsage?.delete(ref);
360
- if (st.lastUsed === ref) st.lastUsed = undefined;
361
- found = true;
362
- }
363
- }
364
- // idempotent: even if ref was not cooling, report ok if it looks like a valid ref name
365
- if (!found && !isValidRef(ref)) {
366
- json(res, 400, { error: { code: 'bad-ref', message: 'dsh-key-rotation: ref must be an environment variable name' } });
367
- return;
368
- }
369
- json(res, 200, { ok: true, ref });
370
- return;
371
- }
372
- json(res, 400, { error: { code: 'bad-request', message: 'dsh-key-rotation: POST requires {"provider": "..."} or {"ref": "..."}' } });
373
- },
374
- }), 'dsh-key-rotation: reset route');
375
-
376
-
377
- ctx.effect(() => ctx.webServer.register({
378
- kind: 'exact',
379
- path: IMPORT_PATH,
380
- handler: async (req, res) => {
381
- if (req.method !== 'POST') { json(res, 405, { error: { code: 'method', message: 'POST only' } }); return; }
382
- if (!isTrustedBridgeRequest(req)) { json(res, 403, { error: { code: 'forbidden', message: 'dsh-key-rotation: import is local-only' } }); return; }
383
- let body; try { body = await readJson(req); } catch (e) { json(res, 400, { error: { code: 'bad-request', message: String(e?.message ?? e) } }); return; }
384
- const url = typeof body?.url === 'string' ? body.url.trim() : '';
385
- if (!url || !url.startsWith('https://')) { json(res, 400, { error: { code: 'bad-url', message: 'dsh-key-rotation: only HTTPS URLs are allowed' } }); return; }
386
- try {
387
- const data = await safeFetchJson(url, {
388
- signal: AbortSignal.timeout(10000),
389
- fetchImpl: deps.fetchImpl ?? globalThis.fetch,
390
- lookupImpl: deps.lookupImpl ?? undefined,
391
- });
392
- validateProviders(data);
393
- const settings = ctx.get('settings');
394
- if (!settings) { json(res, 503, { error: { code: 'settings-rejected', message: 'dsh-key-rotation: no settings provider' } }); return; }
395
- const desc = settings.describe({ redactSecrets: true }).find((c) => c.ns === NS);
396
- if (!desc) throw new Error('Settings namespace is unavailable');
397
- const cur = desc.value?.providers ?? [];
398
- const merged = new Map();
399
- for (const p of cur) if (p && p.provider) merged.set(p.provider, p);
400
- for (const p of data) if (p && p.provider && typeof p.provider === 'string') merged.set(p.provider, p);
401
- const mergedArr = [...merged.values()];
402
- await mutateSettings(settings, NS, [{ op: 'set', path: ['providers'], value: mergedArr }], body.expectedRevision ?? desc.revision);
403
- json(res, 200, { ok: true, providersImported: data.length, total: mergedArr.length });
404
- } catch (e) {
405
- const isTimeout = e?.name === 'TimeoutError' || e?.name === 'AbortError' || e?.code === 'timeout';
406
- json(res, isTimeout ? 504 : e?.code === 'SETTINGS_CONFLICT' ? 409 : 400, { error: { code: isTimeout ? 'timeout' : 'import-failed', message: String(e?.message ?? e) } });
407
- }
408
- },
409
- }), 'dsh-key-rotation: import route');
410
-
411
- // Health for external panels (Beszel/Uptime)
412
-
413
- // ── discover unconfigured keys route (#491) ──
414
- ctx.effect(() => ctx.webServer.register({
415
- kind: 'exact',
416
- path: DISCOVER_PATH,
417
- handler: async (req, res) => {
418
- if (req.method !== 'GET') {
419
- json(res, 405, { error: { code: 'method', message: 'GET only' } });
420
- return;
421
- }
422
- if (!isTrustedBridgeRequest(req)) {
423
- json(res, 403, { error: { code: 'forbidden', message: 'dsh-key-rotation: discover is local-only' } });
424
- return;
425
- }
426
- try {
427
- const runtime = deps.buildRuntime?.() ?? {};
428
- const discovered = await discoverUnconfiguredKeys(ctx, runtime.pools || []);
429
- json(res, 200, { ok: true, discovered });
430
- } catch (e) {
431
- json(res, 500, { error: { code: 'discover-failed', message: String(e?.message ?? e) } });
432
- }
433
- },
434
- }), 'dsh-key-rotation: discover route');
435
-
436
- // ── auto-import discovered keys into pools (#491) ──
437
- ctx.effect(() => ctx.webServer.register({
438
- kind: 'exact',
439
- path: AUTO_IMPORT_PATH,
440
- handler: async (req, res) => {
441
- if (req.method !== 'POST') {
442
- json(res, 405, { error: { code: 'method', message: 'POST only' } });
443
- return;
444
- }
445
- if (!isTrustedBridgeRequest(req)) {
446
- json(res, 403, { error: { code: 'forbidden', message: 'dsh-key-rotation: auto-import is local-only' } });
447
- return;
448
- }
449
- let body;
450
- try {
451
- body = await readJson(req);
452
- } catch (e) {
453
- json(res, 400, { error: { code: 'bad-request', message: e.message || 'invalid request body' } });
454
- return;
455
- }
456
- const hasRefs = Array.isArray(body?.refs) && body.refs.length > 0;
457
- if (!hasRefs && body?.all !== true) {
458
- json(res, 400, { error: { code: 'bad-request', message: 'auto-import requires explicit "refs" array or "all: true"' } });
459
- return;
460
- }
461
-
462
- try {
463
- const runtime = deps.buildRuntime?.() ?? {};
464
- const discovered = await discoverUnconfiguredKeys(ctx, runtime.pools || []);
465
- const targetRefs = hasRefs ? new Set(body.refs) : null;
466
- const toImport = targetRefs ? discovered.filter((d) => targetRefs.has(d.ref)) : discovered;
467
-
468
- if (toImport.length === 0) {
469
- json(res, 200, { ok: true, importedCount: 0, message: 'No unassigned keys to import' });
470
- return;
471
- }
472
-
473
- const settings = ctx.get('settings');
474
- if (!settings) {
475
- json(res, 503, { error: { code: 'settings-rejected', message: 'dsh-key-rotation: no settings provider' } });
476
- return;
477
- }
478
- const desc = settings.describe({ redactSecrets: true })?.find?.((c) => c.ns === NS);
479
- if (!desc) throw new Error('Settings namespace is unavailable');
480
-
481
- const curProviders = (desc.value?.providers ?? []).map((p) => ({
482
- ...p,
483
- keys: Array.isArray(p.keys) ? [...p.keys] : [],
484
- }));
485
-
486
- const providerMap = new Map();
487
- for (const p of curProviders) {
488
- if (p?.provider) providerMap.set(p.provider, p);
489
- }
490
-
491
- let importedCount = 0;
492
- for (const item of toImport) {
493
- const { provider, ref } = item;
494
- let poolConfig = providerMap.get(provider);
495
- if (!poolConfig) {
496
- poolConfig = {
497
- provider,
498
- strategy: 'round-robin',
499
- cooldownMs: 30000,
500
- keys: [],
501
- };
502
- providerMap.set(provider, poolConfig);
503
- }
504
- const existingKeys = poolConfig.keys.map((k) => (typeof k === 'string' ? k : k?.ref));
505
- if (!existingKeys.includes(ref)) {
506
- poolConfig.keys.push(ref);
507
- importedCount++;
508
- }
509
- }
510
-
511
- const updatedProviders = [...providerMap.values()];
512
- await mutateSettings(
513
- settings,
514
- NS,
515
- [{ op: 'set', path: ['providers'], value: updatedProviders }],
516
- body.expectedRevision ?? desc.revision
517
- );
518
-
519
- json(res, 200, {
520
- ok: true,
521
- importedCount,
522
- providersCount: updatedProviders.length,
523
- providers: updatedProviders,
524
- });
525
- } catch (e) {
526
- json(res, 400, { error: { code: 'auto-import-failed', message: String(e?.message ?? e) } });
527
- }
528
- },
529
- }), 'dsh-key-rotation: auto-import route');
530
-
531
- ctx.effect(() => ctx.webServer.register({
532
- kind: 'exact',
533
- path: PRESET_LIMITS_PATH,
534
- handler: async (req, res) => {
535
- if (!isTrustedBridgeRequest(req)) {
536
- res.writeHead(403, { 'Content-Type': 'application/json' });
537
- res.end(JSON.stringify({ ok: false, error: 'Forbidden' }));
538
- return;
539
- }
540
- if (req.method === 'GET') {
541
- const urlObj = new URL(req.url, 'http://127.0.0.1');
542
- const prov = urlObj.searchParams.get('provider');
543
- if (prov) {
544
- const preset = lookupPreset(prov);
545
- json(res, 200, { ok: true, provider: prov, preset });
546
- return;
547
- }
548
- json(res, 200, { ok: true, presets: CANONICAL_PROVIDER_PRESETS });
549
- return;
550
- }
551
- if (req.method === 'POST') {
552
- const body = await readJson(req);
553
- const prov = body?.provider;
554
- const preset = lookupPreset(prov);
555
- if (!preset) {
556
- json(res, 404, { ok: false, error: `No canonical preset found for provider '${prov}'` });
557
- return;
558
- }
559
- json(res, 200, { ok: true, provider: prov, preset });
560
- return;
561
- }
562
- res.writeHead(405, { 'Content-Type': 'application/json' });
563
- res.end(JSON.stringify({ ok: false, error: 'Method Not Allowed' }));
564
- },
565
- }), 'dsh-key-rotation: preset limits route');
566
-
567
- }
1
+ // lib/ops-keys.js — Re-export facade for lib/routes/ (#522).
2
+ export * from "./routes/keys.js";
@@ -1,92 +1,2 @@
1
- // lib/ops-metrics.js — Prometheus / OpenMetrics export route (#373).
2
- import { METRICS_PATH } from './ops-paths.js';
3
- import { isTrustedBridgeRequest, isKeyPaused, isKeyRevoked } from './pool.js';
4
- import { hasModelQuotaConfig, isModelQuotaAvailable } from './model-quota.js';
5
-
6
- /**
7
- * @param {object} ctx cordis context
8
- * @param {object} deps live dependencies from apply()
9
- */
10
- export function registerMetricsRoutes(ctx, deps) {
11
- const { buildRuntime, poolState } = deps;
12
-
13
- ctx.effect(() => ctx.webServer.register({
14
- kind: 'exact',
15
- path: METRICS_PATH,
16
- handler: async (req, res) => {
17
- if (req.method !== 'GET') {
18
- res.statusCode = 405;
19
- res.setHeader('content-type', 'text/plain');
20
- res.end('Method Not Allowed\n');
21
- return;
22
- }
23
- if (!isTrustedBridgeRequest(req)) {
24
- res.statusCode = 403;
25
- res.setHeader('content-type', 'text/plain');
26
- res.end('Forbidden: local only\n');
27
- return;
28
- }
29
-
30
- const lines = [];
31
- const runtime = buildRuntime();
32
- const pools = runtime.pools ?? [];
33
- const now = Date.now();
34
-
35
- lines.push('# HELP dsh_key_rotation_pools_total Total configured provider pools');
36
- lines.push('# TYPE dsh_key_rotation_pools_total gauge');
37
- lines.push(`dsh_key_rotation_pools_total ${pools.length}`);
38
-
39
- lines.push('# HELP dsh_key_rotation_active_keys Number of currently active and ready keys');
40
- lines.push('# TYPE dsh_key_rotation_active_keys gauge');
41
-
42
- lines.push('# HELP dsh_key_rotation_cooldown_keys Number of keys in cooldown');
43
- lines.push('# TYPE dsh_key_rotation_cooldown_keys gauge');
44
-
45
- lines.push('# HELP dsh_key_rotation_paused_keys Number of paused keys');
46
- lines.push('# TYPE dsh_key_rotation_paused_keys gauge');
47
-
48
- lines.push('# HELP dsh_key_rotation_revoked_keys Number of permanently revoked keys');
49
- lines.push('# TYPE dsh_key_rotation_revoked_keys gauge');
50
-
51
- lines.push('# HELP dsh_key_rotation_expired_keys Number of expired keys');
52
- lines.push('# TYPE dsh_key_rotation_expired_keys gauge');
53
-
54
- lines.push('# HELP dsh_key_rotation_quota_exhausted_keys Number of keys blocked by model token quota');
55
- lines.push('# TYPE dsh_key_rotation_quota_exhausted_keys gauge');
56
-
57
- lines.push('# HELP dsh_key_rotation_proactive_switches_total Number of proactive rate-limit switches');
58
- lines.push('# TYPE dsh_key_rotation_proactive_switches_total counter');
59
-
60
- lines.push('# HELP dsh_key_rotation_cascade_switches_total Number of multi-vendor cascade switches');
61
- lines.push('# TYPE dsh_key_rotation_cascade_switches_total counter');
62
-
63
- for (const pool of pools) {
64
- const p = pool.base;
65
- const st = poolState.get(p);
66
- const quotaAware = hasModelQuotaConfig(pool);
67
- let active = 0, cooling = 0, paused = 0, revoked = 0, expired = 0, quotaExhausted = 0;
68
- for (const r of (pool.refs ?? [])) {
69
- if (isKeyRevoked(pool, r)) { revoked++; continue; }
70
- if (isKeyPaused(pool, r)) { paused++; continue; }
71
- if (pool.expiresAt?.[r] !== undefined && now >= pool.expiresAt[r]) { expired++; continue; }
72
- if (quotaAware && !isModelQuotaAvailable(pool, r, now)) { quotaExhausted++; continue; }
73
- const fu = st?.failedUntil?.get(r) ?? 0;
74
- if (fu > now) cooling++;
75
- else active++;
76
- }
77
- lines.push(`dsh_key_rotation_active_keys{provider="${p}"} ${active}`);
78
- lines.push(`dsh_key_rotation_cooldown_keys{provider="${p}"} ${cooling}`);
79
- lines.push(`dsh_key_rotation_paused_keys{provider="${p}"} ${paused}`);
80
- lines.push(`dsh_key_rotation_revoked_keys{provider="${p}"} ${revoked}`);
81
- lines.push(`dsh_key_rotation_expired_keys{provider="${p}"} ${expired}`);
82
- lines.push(`dsh_key_rotation_quota_exhausted_keys{provider="${p}"} ${quotaExhausted}`);
83
- lines.push(`dsh_key_rotation_proactive_switches_total{provider="${p}"} ${st?.proactiveSwitches ?? 0}`);
84
- lines.push(`dsh_key_rotation_cascade_switches_total{provider="${p}"} ${st?.cascadeCount ?? 0}`);
85
- }
86
-
87
- res.statusCode = 200;
88
- res.setHeader('content-type', 'text/plain; version=0.0.4; charset=utf-8');
89
- res.end(lines.join('\n') + '\n');
90
- }
91
- }), 'dsh-key-rotation: metrics');
92
- }
1
+ // lib/ops-metrics.js — Re-export facade for lib/routes/ (#522).
2
+ export * from "./routes/metrics.js";
package/lib/ops-paths.js CHANGED
@@ -1,24 +1,2 @@
1
- // lib/ops-paths.js — shared HTTP path constants for operational routes (#312).
2
- export const STATUS_PATH = '/dsh-key-rotation/status';
3
- export const SNAPSHOT_PATH = '/dsh-key-rotation/snapshot';
4
- export const KEY_PATH = '/dsh-key-rotation/key';
5
- export const RESET_PATH = '/dsh-key-rotation/reset';
6
- export const IMPORT_PATH = '/dsh-key-rotation/import';
7
- export const HEALTH_PATH = '/dsh-key-rotation/health';
8
- export const USAGE_PATH = '/dsh-key-rotation/usage';
9
- export const TEST_PATH = '/dsh-key-rotation/test';
10
- export const SANDBOX_CACHE_PATH = '/dsh-key-rotation/sandbox-cache';
11
- export const WEBHOOK_ACTION_PATH = '/dsh-key-rotation/webhook-action';
12
- export const METRICS_PATH = '/dsh-key-rotation/metrics';
13
- export const DIAG_PATH = '/dsh-key-rotation/diag';
14
- export const TRACE_PATH = '/dsh-key-rotation/trace';
15
- export const SIMULATE_PATH = '/dsh-key-rotation/simulate';
16
- export const DRAIN_PATH = '/dsh-key-rotation/drain';
17
- export const TEST_MATRIX_PATH = '/dsh-key-rotation/test-matrix';
18
- export const DISCOVER_PATH = '/dsh-key-rotation/discover';
19
- export const AUTO_IMPORT_PATH = '/dsh-key-rotation/auto-import';
20
- export const PRESET_LIMITS_PATH = '/dsh-key-rotation/preset-limits';
21
- export const BALANCE_PATH = '/dsh-key-rotation/balance';
22
- export const CONFIG_EXPORT_PATH = '/dsh-key-rotation/export';
23
- export const CONFIG_IMPORT_PATH = '/dsh-key-rotation/config-import';
24
- export const QUICK_ACTION_PATH = '/dsh-key-rotation/quick-action';
1
+ // lib/ops-paths.js — Re-export facade for lib/routes/ (#522).
2
+ export * from "./routes/paths.js";