@goodandready/dsh-key-rotation 0.8.78 → 0.8.79

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.
@@ -1,276 +1,2 @@
1
- // lib/ops-telemetry.js — usage + snapshot operational routes (#312 split from routes-ops.js).
2
- import {
3
- json,
4
- readJson,
5
- descriptorOf,
6
- NS,
7
- } from './http-bridge.js';
8
- import { isTrustedBridgeRequest } from './pool.js';
9
- import {
10
- usageRows,
11
- usageCsv,
12
- } from './usage-report.js';
13
- import {
14
- USAGE_PATH,
15
- SNAPSHOT_PATH,
16
- DIAG_PATH,
17
- TRACE_PATH,
18
- SIMULATE_PATH,
19
- } from './ops-paths.js';
20
- import { simulateRouting } from './simulator.js';
21
- import { getSelectionTraces, clearSelectionTraces } from './selection-trace.js';
22
- import { findSecrets } from './keycheck.js';
23
- import { mutateSettings, sectionOps } from './settings-write.js';
24
- import { compute24hAnalytics } from './ops-analytics.js';
25
- import { getPromptCacheSummary } from './prompt-cache.js';
26
-
27
- /**
28
- * @param {object} ctx cordis context
29
- * @param {object} deps live dependencies from apply()
30
- */
31
-
32
- const DIAG_BUFFER_LIMIT = 200;
33
-
34
- export class DiagBuffer {
35
- constructor(limit = DIAG_BUFFER_LIMIT) {
36
- this.limit = limit;
37
- this.buffer = [];
38
- }
39
-
40
- push(entry) {
41
- if (this.buffer.length >= this.limit) {
42
- this.buffer.shift();
43
- }
44
- this.buffer.push({
45
- timestamp: Date.now(),
46
- ...entry,
47
- });
48
- }
49
-
50
- get() {
51
- return this.buffer.slice();
52
- }
53
-
54
- clear() {
55
- this.buffer.length = 0;
56
- }
57
- }
58
-
59
- const defaultDiagBuffer = new DiagBuffer();
60
-
61
- export function pushDiag(entry, buffer = null) {
62
- (buffer || defaultDiagBuffer).push(entry);
63
- }
64
-
65
- export function getDiagBuffer(buffer = null) {
66
- return (buffer || defaultDiagBuffer).get();
67
- }
68
-
69
- export function registerTelemetryRoutes(ctx, deps) {
70
- const {
71
- buildRuntime,
72
- } = deps;
73
-
74
- ctx.effect(() => ctx.webServer.register({
75
- kind: 'exact',
76
- path: USAGE_PATH,
77
- handler: (req, res) => {
78
- if (req.method !== 'GET') { json(res, 405, { error: { code: 'method', message: 'GET only' } }); return; }
79
- if (!isTrustedBridgeRequest(req)) { json(res, 403, { error: { code: 'forbidden', message: 'dsh-key-rotation: usage is local-only' } }); return; }
80
- const url = new URL(req.url ?? USAGE_PATH, 'http://localhost');
81
- const days = Math.min(90, Math.max(1, Number(url.searchParams.get('days')) || 7));
82
- const csv = url.searchParams.get('format') === 'csv';
83
- const provider = url.searchParams.get('provider') ?? '';
84
- const runtime = buildRuntime();
85
- const now = Date.now();
86
- const seen = new Set();
87
- const report = [];
88
- const poolsToScan = runtime.pools ?? runtime.poolByRef.values();
89
- for (const pool of poolsToScan) {
90
- if (seen.has(pool.base)) continue;
91
- seen.add(pool.base);
92
- if (provider && pool.base !== provider && pool.provider !== provider) continue;
93
- report.push({
94
- provider: pool.base,
95
- rows: usageRows(pool, days, now),
96
- promptCache: getPromptCacheSummary(pool, now),
97
- analytics24h: compute24hAnalytics(pool, now),
98
- });
99
- }
100
- if (csv) {
101
- res.writeHead(200, { 'content-type': 'text/csv; charset=utf-8', 'content-disposition': 'attachment; filename="dsh-key-rotation-usage.csv"' });
102
- const parts = [];
103
- for (const p of report) {
104
- if (parts.length > 0) parts.push('');
105
- parts.push('# ' + p.provider);
106
- parts.push(usageCsv(p.rows));
107
- }
108
- res.end(parts.join('\n') + '\n');
109
- return;
110
- }
111
- json(res, 200, { at: now, days, providers: report });
112
- },
113
- }), 'dsh-key-rotation: usage route');
114
-
115
- // #218: full config snapshot - one JSON file to move between machines.
116
- // Secret values never travel: only credential/env names. Token fields are
117
- // exported as empty strings; on import they keep existing values when empty.
118
-
119
- ctx.effect(() => ctx.webServer.register({
120
- kind: 'exact',
121
- path: SNAPSHOT_PATH,
122
- handler: async (req, res) => {
123
- if (req.method !== 'GET' && req.method !== 'POST') { json(res, 405, { error: { code: 'method', message: 'GET (export) or POST (import) only' } }); return; }
124
- if (!isTrustedBridgeRequest(req)) { json(res, 403, { error: { code: 'forbidden', message: 'dsh-key-rotation: snapshot is local-only' } }); return; }
125
- if (req.method === 'GET') {
126
- const descriptor = descriptorOf(ctx, NS);
127
- const value = descriptor?.value ?? {};
128
- const exportable = { ...value };
129
- // token-shaped fields stay empty in the file; refs are names, not secrets
130
- exportable.webhookActionToken = '';
131
- json(res, 200, { at: Date.now(), version: 1, snapshot: exportable });
132
- return;
133
- }
134
- // POST = import: { snapshot } -> merge with current section, PUT semantics
135
- let body;
136
- try { body = await readJson(req); } catch (e) { json(res, 400, { error: { code: 'bad-request', message: String(e?.message ?? e) } }); return; }
137
- const snap = body?.snapshot;
138
- if (!snap || typeof snap !== 'object' || Array.isArray(snap)) { json(res, 400, { error: { code: 'bad-format', message: 'dsh-key-rotation: POST requires {"snapshot": {...}}' } }); return; }
139
- // #200 leak guard applies to imported content too
140
- try {
141
- const masked = structuredClone(snap);
142
- if (masked.webhookActionToken) masked.webhookActionToken = '***';
143
- if (masked.notifyWebhook) masked.notifyWebhook = '***';
144
- const findings = findSecrets(JSON.stringify(masked));
145
- if (findings.length > 0) { json(res, 400, { error: { code: 'secret-in-snapshot', message: 'dsh-key-rotation: snapshot carries a live-looking credential', findings } }); return; }
146
- } catch { /* scanning must never block a valid import */ }
147
- const settings = ctx.get('settings');
148
- if (!settings) { json(res, 503, { error: { code: 'settings-rejected', message: 'dsh-key-rotation: no settings provider' } }); return; }
149
- const desc = descriptorOf(ctx, NS);
150
- if (desc === void 0) { json(res, 500, { error: { code: 'settings-rejected', message: 'dsh-key-rotation: namespace missing' } }); return; }
151
- try {
152
- // Empty exported tokens mean omitted, not erased. Never reconstruct a
153
- // complete section from the redacted describe response.
154
- const ops = sectionOps(snap, { preserveEmptySecrets: true });
155
- await mutateSettings(settings, NS, ops, body.expectedRevision ?? desc.revision);
156
- const after = descriptorOf(ctx, NS);
157
- json(res, 200, { ok: true, revision: after?.revision });
158
- } catch (e) {
159
- json(res, e?.code === 'SETTINGS_CONFLICT' ? 409 : 400, { error: { code: 'settings-rejected', message: String(e?.message ?? e) } });
160
- }
161
- },
162
- }), 'dsh-key-rotation: snapshot route');
163
-
164
-
165
- ctx.effect(() => ctx.webServer.register({
166
- kind: 'exact',
167
- path: TRACE_PATH,
168
- handler: (req, res) => {
169
- if (req.method !== 'GET' && req.method !== 'DELETE') {
170
- json(res, 405, { error: { code: 'method', message: 'GET or DELETE only' } });
171
- return;
172
- }
173
- if (!isTrustedBridgeRequest(req)) {
174
- json(res, 403, { error: { code: 'forbidden', message: 'dsh-key-rotation: trace is local-only' } });
175
- return;
176
- }
177
- const traceStore = deps.selectionTraceStore || null;
178
- if (req.method === 'DELETE') {
179
- clearSelectionTraces(traceStore);
180
- json(res, 200, { ok: true, message: 'trace history cleared' });
181
- return;
182
- }
183
- const url = new URL(req.url ?? TRACE_PATH, 'http://localhost');
184
- const limit = Math.min(100, Math.max(1, Number(url.searchParams.get('limit')) || 50));
185
- json(res, 200, { ok: true, traces: getSelectionTraces(limit, traceStore) });
186
- },
187
- }), 'dsh-key-rotation: trace route');
188
-
189
-
190
- ctx.effect(() => ctx.webServer.register({
191
- kind: 'exact',
192
- path: SIMULATE_PATH,
193
- handler: async (req, res) => {
194
- if (req.method !== 'POST') {
195
- json(res, 405, { error: { code: 'method', message: 'POST only' } });
196
- return;
197
- }
198
- if (!isTrustedBridgeRequest(req)) {
199
- json(res, 403, { error: { code: 'forbidden', message: 'dsh-key-rotation: simulate is local-only' } });
200
- return;
201
- }
202
- let body;
203
- try { body = await readJson(req); } catch (e) {
204
- json(res, 400, { error: { code: 'bad-request', message: String(e?.message ?? e) } });
205
- return;
206
- }
207
-
208
- const runtime = buildRuntime();
209
- const provider = body?.provider?.trim();
210
- if (!provider) {
211
- json(res, 400, { error: { code: 'bad-request', message: 'provider is required for simulation' } });
212
- return;
213
- }
214
-
215
- let pool = null;
216
- if (runtime.providerToPool instanceof Map) {
217
- pool = runtime.providerToPool.get(provider);
218
- } else if (runtime.providerToPool && typeof runtime.providerToPool === 'object') {
219
- pool = runtime.providerToPool[provider];
220
- }
221
- if (!pool) {
222
- const pools = runtime.pools ?? [];
223
- pool = pools.find(p => p.provider === provider || p.base === provider);
224
- }
225
-
226
- if (!pool) {
227
- json(res, 404, { error: { code: 'not-found', message: `Provider '${provider}' not found in configured pools` } });
228
- return;
229
- }
230
-
231
- try {
232
- const result = simulateRouting({
233
- pool,
234
- runtime,
235
- events: Array.isArray(body?.events) ? body.events : [],
236
- model: body?.model || '',
237
- maxAttempts: Number(body?.maxAttempts) || 0,
238
- });
239
- json(res, 200, { ok: true, simulation: result });
240
- } catch (e) {
241
- json(res, 500, { error: { code: 'sim-error', message: String(e?.message ?? e) } });
242
- }
243
- },
244
- }), 'dsh-key-rotation: simulate route');
245
-
246
- ctx.effect(() => ctx.webServer.register({
247
- kind: 'exact',
248
- path: DIAG_PATH,
249
- handler: async (req, res) => {
250
- if (req.method !== 'GET' && req.method !== 'POST') {
251
- json(res, 405, { error: { code: 'method', message: 'GET or POST only' } });
252
- return;
253
- }
254
- if (!isTrustedBridgeRequest(req)) {
255
- json(res, 403, { error: { code: 'forbidden', message: 'dsh-key-rotation: diag is local-only' } });
256
- return;
257
- }
258
- if (req.method === 'POST') {
259
- let body;
260
- try { body = await readJson(req); } catch (e) {
261
- json(res, 400, { error: { code: 'bad-request', message: String(e?.message ?? e) } });
262
- return;
263
- }
264
- pushDiag({
265
- level: typeof body?.level === 'string' ? body.level : 'info',
266
- message: typeof body?.message === 'string' ? body.message.slice(0, 500) : 'unknown',
267
- details: body?.details ?? null,
268
- });
269
- json(res, 200, { ok: true, count: diagBuffer.length });
270
- return;
271
- }
272
- json(res, 200, { diag: deps.diagBuffer ? deps.diagBuffer.get() : getDiagBuffer() });
273
- },
274
- }), 'dsh-key-rotation: diag route');
275
-
276
- }
1
+ // lib/ops-telemetry.js — Re-export facade for lib/routes/ (#522).
2
+ export * from "./routes/telemetry.js";
package/lib/ops-test.js CHANGED
@@ -1,255 +1,2 @@
1
- // lib/ops-test.js — sandbox test + sandbox cache routes (#312 split from routes-ops.js).
2
- import {
3
- json,
4
- readJson,
5
- } from './http-bridge.js';
6
- import {
7
- isTrustedBridgeRequest,
8
- isValidRef,
9
- keyTail,
10
- envValue,
11
- } from './pool.js';
12
- import { bestEffort } from './best-effort.js';
13
- import {
14
- TEST_PATH,
15
- SANDBOX_CACHE_PATH,
16
- TEST_MATRIX_PATH,
17
- } from './ops-paths.js';
18
- import { decryptSecret } from './crypto-storage.js';
19
-
20
- /**
21
- * @param {object} ctx cordis context
22
- * @param {object} deps live dependencies from apply()
23
- */
24
- export function registerTestRoutes(ctx, deps) {
25
- const {
26
- lastTestCache,
27
- ensureSandboxRunner,
28
- poolState,
29
- } = deps;
30
-
31
- // ── test route: dry-run a single key without rotation ──
32
- ctx.effect(() => ctx.webServer.register({
33
- kind: 'exact',
34
- path: TEST_PATH,
35
- handler: async (req, res) => {
36
- if (req.method !== 'POST') { json(res, 405, { error: { code: 'method', message: 'POST only' } }); return; }
37
- if (!isTrustedBridgeRequest(req)) { json(res, 403, { error: { code: 'forbidden', message: 'dsh-key-rotation: test is local-only' } }); return; }
38
- let body; try { body = await readJson(req); } catch (e) { json(res, 400, { error: { code: 'bad-request', message: String(e?.message ?? e) } }); return; }
39
- const ref = typeof body?.ref === 'string' ? body.ref.trim() : '';
40
- if (!isValidRef(ref)) { json(res, 400, { error: { code: 'bad-ref', message: 'dsh-key-rotation: ref must be an environment variable name' } }); return; }
41
- // Optional value for pre-save validation (issue #118)
42
- const testValue = typeof body?.value === 'string' && body.value.length > 0 ? body.value : undefined;
43
- const probe = body?.probe === 'models' || body?.probe === 'chat' ? body.probe : undefined;
44
- const base = ctx.get('credentials');
45
- try {
46
- let hit = await (base?.__dshKeyRotationOriginalResolve ?? base?.resolve)?.call(base, ref);
47
- let present = Boolean(hit && typeof hit.value === 'string' && hit.value.length > 0);
48
- const rawValue = testValue || hit?.value;
49
- if (typeof rawValue === 'string' && rawValue.startsWith('enc:v1:')) {
50
- const decrypted = decryptSecret(rawValue);
51
- if (!decrypted || decrypted === rawValue || decrypted.startsWith('enc:v1:')) {
52
- json(res, 200, { ok: false, ref, code: 'decryption-failed', message: 'dsh-key-rotation: failed to decrypt credential (missing or invalid DSH_KEY_SECRET)' });
53
- return;
54
- }
55
- }
56
- const effectiveValue = rawValue ? decryptSecret(rawValue) : rawValue;
57
- const valid = present ? Boolean(effectiveValue && typeof effectiveValue === 'string' && effectiveValue.length > 0) : Boolean(testValue);
58
- const tail = valid ? keyTail(effectiveValue) : '';
59
- let source = null;
60
- await bestEffort('credentials.describe', async () => { const d = await base?.describe?.(ref); source = d?.source ?? null; }, ctx.logger);
61
- if (!present && !testValue) { json(res, 200, { ok: false, ref, code: 'no-credential', message: 'no such credential' }); return; }
62
- if (!present && testValue) { source = 'pre-save'; }
63
- else if (!present) {
64
- const ev = envValue(ref);
65
- if (ev !== undefined) { present = true; json(res, 200, { ok: true, ref, tail: keyTail(ev), source: 'env' }); return; }
66
- }
67
- // sandbox probe (models is free; chat is hook-only, see sandbox.js)
68
- if (probe) {
69
- const keyForProbe = effectiveValue;
70
- const runner = ensureSandboxRunner(ctx);
71
- const result = probe === 'chat' ? await runner.probeChat(ref, keyForProbe) : await runner.probeModels(ref, keyForProbe);
72
- const cached = { ...result, at: Date.now() };
73
- lastTestCache.set(ref, cached);
74
- if (cached.ok) {
75
- for (const st of poolState.values()) {
76
- st.failedUntil?.delete(ref);
77
- st.failCounts?.delete(ref);
78
- st.authFailCounts?.delete(ref);
79
- st.brokenUntil?.delete(ref);
80
- st.revokedRefs?.delete(ref);
81
- if (st.pausedRefs instanceof Set) st.pausedRefs.delete(ref);
82
- else if (Array.isArray(st.pausedRefs)) {
83
- const pIdx = st.pausedRefs.indexOf(ref);
84
- if (pIdx >= 0) st.pausedRefs.splice(pIdx, 1);
85
- }
86
- st.pauseReasons?.delete(ref);
87
- if (Array.isArray(cached.models) && cached.models.length > 0) {
88
- if (!st.modelCapabilities) st.modelCapabilities = new Map();
89
- st.modelCapabilities.set(ref, new Set(cached.models));
90
- }
91
- }
92
- }
93
- json(res, 200, { ok: cached.ok, ref, tail, source, probe, code: cached.code, latencyMs: cached.latencyMs, modelsCount: cached.modelsCount, models: cached.models ?? [] });
94
- return;
95
- }
96
- json(res, 200, { ok: true, ref, tail, source });
97
- } catch (e) {
98
- json(res, 200, { ok: false, ref, code: 'error', message: String(e?.message ?? e) });
99
- }
100
- },
101
- }), 'dsh-key-rotation: test route');
102
-
103
- // Intercept the llm/stream waterfall: rotate any request whose provider maps
104
- // to a configured key pool; pass everything else (and internal dispatches)
105
- // straight through.
106
- // Read-only cache snapshot for clients (badge polling).
107
-
108
- ctx.effect(() => ctx.webServer.register({
109
- kind: 'exact',
110
- path: SANDBOX_CACHE_PATH,
111
- handler: (req, res) => {
112
- if (req.method !== 'GET') { json(res, 405, { error: { code: 'method', message: 'GET only' } }); return; }
113
- if (!isTrustedBridgeRequest(req)) { json(res, 403, { error: { code: 'forbidden', message: 'dsh-key-rotation: cache is local-only' } }); return; }
114
- const cacheSnap = typeof lastTestCache?.snapshot === 'function'
115
- ? lastTestCache.snapshot()
116
- : (lastTestCache instanceof Map ? Object.fromEntries(lastTestCache) : (lastTestCache ?? {}));
117
- json(res, 200, cacheSnap);
118
- },
119
- }), 'dsh-key-rotation: sandbox cache');
120
-
121
-
122
-
123
- // #199 webhook-action: interactive webhook buttons call back here.
124
- // Auth: bearer token from Config (external services like Telegram/Discord
125
- // cannot be same-origin, so a shared secret is the gate).
126
-
127
- // ── test matrix route: batch probe all configured keys (#490) ──
128
- ctx.effect(() => ctx.webServer.register({
129
- kind: 'exact',
130
- path: TEST_MATRIX_PATH,
131
- handler: async (req, res) => {
132
- if (req.method !== 'GET' && req.method !== 'POST') {
133
- json(res, 405, { error: { code: 'method', message: 'GET or POST only' } });
134
- return;
135
- }
136
- if (!isTrustedBridgeRequest(req)) {
137
- json(res, 403, { error: { code: 'forbidden', message: 'dsh-key-rotation: test-matrix is local-only' } });
138
- return;
139
- }
140
- const base = ctx.get('credentials');
141
- const runner = ensureSandboxRunner(ctx);
142
- const runtime = deps.buildRuntime?.() ?? {};
143
- const pools = runtime.pools || [];
144
-
145
- const keyItems = [];
146
- const seen = new Set();
147
- for (const p of pools) {
148
- const pName = p.provider ?? p.base ?? 'unknown';
149
- for (const ref of (p.refs || [])) {
150
- const id = `${pName}:${ref}`;
151
- if (!seen.has(id)) {
152
- seen.add(id);
153
- keyItems.push({ provider: pName, ref });
154
- }
155
- }
156
- }
157
-
158
- const CONCURRENCY = 5;
159
- const results = [];
160
- let idx = 0;
161
-
162
- async function worker() {
163
- while (idx < keyItems.length) {
164
- const item = keyItems[idx++];
165
- if (!item) break;
166
- const { provider, ref } = item;
167
- try {
168
- let hit = await (base?.__dshKeyRotationOriginalResolve ?? base?.resolve)?.call(base, ref);
169
- let rawValue = hit?.value;
170
- if (!rawValue) {
171
- const ev = envValue(ref);
172
- if (ev !== undefined) rawValue = ev;
173
- }
174
- if (typeof rawValue === 'string' && rawValue.startsWith('enc:v1:')) {
175
- const dec = decryptSecret(rawValue);
176
- if (!dec || dec === rawValue || dec.startsWith('enc:v1:')) {
177
- results.push({ provider, ref, keyTail: '', ok: false, code: 'decryption-failed', message: 'failed to decrypt credential', latencyMs: 0 });
178
- continue;
179
- }
180
- rawValue = dec;
181
- }
182
- let effectiveValue = rawValue;
183
- const tail = effectiveValue ? keyTail(effectiveValue) : '';
184
- if (!effectiveValue) {
185
- results.push({ provider, ref, keyTail: tail, ok: false, code: 'no-credential', latencyMs: 0 });
186
- continue;
187
- }
188
- const probeRes = await runner.probeModels(ref, effectiveValue);
189
- const cached = { ...probeRes, at: Date.now() };
190
- lastTestCache.set(ref, cached);
191
- if (cached.ok) {
192
- for (const st of poolState.values()) {
193
- st.failedUntil?.delete(ref);
194
- st.failCounts?.delete(ref);
195
- st.authFailCounts?.delete(ref);
196
- st.brokenUntil?.delete(ref);
197
- st.revokedRefs?.delete(ref);
198
- if (st.pausedRefs instanceof Set) st.pausedRefs.delete(ref);
199
- else if (Array.isArray(st.pausedRefs)) {
200
- const pIdx = st.pausedRefs.indexOf(ref);
201
- if (pIdx >= 0) st.pausedRefs.splice(pIdx, 1);
202
- }
203
- st.pauseReasons?.delete(ref);
204
- if (Array.isArray(cached.models) && cached.models.length > 0) {
205
- if (!st.modelCapabilities) st.modelCapabilities = new Map();
206
- st.modelCapabilities.set(ref, new Set(cached.models));
207
- }
208
- }
209
- }
210
- results.push({
211
- provider,
212
- ref,
213
- keyTail: tail,
214
- ok: cached.ok,
215
- code: cached.code,
216
- latencyMs: cached.latencyMs,
217
- modelsCount: cached.modelsCount,
218
- models: cached.models ?? [],
219
- });
220
- } catch (err) {
221
- results.push({
222
- provider,
223
- ref,
224
- keyTail: '',
225
- ok: false,
226
- code: 'error',
227
- error: String(err?.message ?? err),
228
- latencyMs: 0,
229
- });
230
- }
231
- }
232
- }
233
-
234
- const workers = [];
235
- for (let i = 0; i < Math.min(CONCURRENCY, keyItems.length); i++) {
236
- workers.push(worker());
237
- }
238
- await Promise.all(workers);
239
-
240
- const total = results.length;
241
- const healthy = results.filter((r) => r.ok).length;
242
- const failed = total - healthy;
243
-
244
- json(res, 200, {
245
- ok: true,
246
- at: Date.now(),
247
- total,
248
- healthy,
249
- failed,
250
- results,
251
- });
252
- },
253
- }), 'dsh-key-rotation: test matrix route');
254
-
255
- }
1
+ // lib/ops-test.js — Re-export facade for lib/routes/ (#522).
2
+ export * from "./routes/testing.js";