@goodandready/dsh-key-rotation 0.8.11 → 0.8.12

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.
@@ -0,0 +1,106 @@
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
+ } from './ops-paths.js';
17
+
18
+ /**
19
+ * @param {object} ctx cordis context
20
+ * @param {object} deps live dependencies from apply()
21
+ */
22
+ export function registerTestRoutes(ctx, deps) {
23
+ const {
24
+ lastTestCache,
25
+ ensureSandboxRunner,
26
+ poolState,
27
+ } = deps;
28
+
29
+ // ── test route: dry-run a single key without rotation ──
30
+ ctx.effect(() => ctx.webServer.register({
31
+ kind: 'exact',
32
+ path: TEST_PATH,
33
+ handler: async (req, res) => {
34
+ if (req.method !== 'POST') { json(res, 405, { error: { code: 'method', message: 'POST only' } }); return; }
35
+ if (!isTrustedBridgeRequest(req)) { json(res, 403, { error: { code: 'forbidden', message: 'dsh-key-rotation: test is local-only' } }); return; }
36
+ let body; try { body = await readJson(req); } catch (e) { json(res, 400, { error: { code: 'bad-request', message: String(e?.message ?? e) } }); return; }
37
+ const ref = typeof body?.ref === 'string' ? body.ref.trim() : '';
38
+ if (!isValidRef(ref)) { json(res, 400, { error: { code: 'bad-ref', message: 'dsh-key-rotation: ref must be an environment variable name' } }); return; }
39
+ // Optional value for pre-save validation (issue #118)
40
+ const testValue = typeof body?.value === 'string' && body.value.length > 0 ? body.value : undefined;
41
+ const probe = body?.probe === 'models' || body?.probe === 'chat' ? body.probe : undefined;
42
+ const base = ctx.get('credentials');
43
+ try {
44
+ let hit = await (base?.__dshKeyRotationOriginalResolve ?? base?.resolve)?.call(base, ref);
45
+ let present = Boolean(hit && typeof hit.value === 'string' && hit.value.length > 0);
46
+ const effectiveValue = testValue || hit?.value;
47
+ const valid = present ? Boolean(effectiveValue && typeof effectiveValue === 'string' && effectiveValue.length > 0) : Boolean(testValue);
48
+ const tail = valid ? keyTail(effectiveValue) : '';
49
+ let source = null;
50
+ await bestEffort('credentials.describe', async () => { const d = await base?.describe?.(ref); source = d?.source ?? null; }, ctx.logger);
51
+ if (!present && !testValue) { json(res, 200, { ok: false, ref, code: 'no-credential', message: 'no such credential' }); return; }
52
+ if (!present && testValue) { source = 'pre-save'; }
53
+ else if (!present) {
54
+ const ev = envValue(ref);
55
+ if (ev !== undefined) { present = true; json(res, 200, { ok: true, ref, tail: keyTail(ev), source: 'env' }); return; }
56
+ }
57
+ // sandbox probe (models is free; chat is hook-only, see sandbox.js)
58
+ if (probe) {
59
+ const keyForProbe = effectiveValue;
60
+ const runner = ensureSandboxRunner(ctx);
61
+ const result = probe === 'chat' ? await runner.probeChat(ref, keyForProbe) : await runner.probeModels(ref, keyForProbe);
62
+ const cached = { ...result, at: Date.now() };
63
+ lastTestCache.set(ref, cached);
64
+ if (cached.ok) {
65
+ for (const st of poolState.values()) {
66
+ if (st.failedUntil?.has(ref) || st.failCounts?.has(ref) || st.brokenUntil?.has(ref)) {
67
+ st.failedUntil?.delete(ref);
68
+ st.failCounts?.delete(ref);
69
+ st.authFailCounts?.delete(ref);
70
+ st.brokenUntil?.delete(ref);
71
+ }
72
+ }
73
+ }
74
+ json(res, 200, { ok: cached.ok, ref, tail, source, probe, code: cached.code, latencyMs: cached.latencyMs, modelsCount: cached.modelsCount });
75
+ return;
76
+ }
77
+ json(res, 200, { ok: true, ref, tail, source });
78
+ } catch (e) {
79
+ json(res, 200, { ok: false, ref, code: 'error', message: String(e?.message ?? e) });
80
+ }
81
+ },
82
+ }), 'dsh-key-rotation: test route');
83
+
84
+ // Intercept the llm/stream waterfall: rotate any request whose provider maps
85
+ // to a configured key pool; pass everything else (and internal dispatches)
86
+ // straight through.
87
+ // Read-only cache snapshot for clients (badge polling).
88
+
89
+ ctx.effect(() => ctx.webServer.register({
90
+ kind: 'exact',
91
+ path: SANDBOX_CACHE_PATH,
92
+ handler: (req, res) => {
93
+ if (!isTrustedBridgeRequest(req)) { json(res, 403, { error: { code: 'forbidden', message: 'dsh-key-rotation: cache is local-only' } }); return; }
94
+ const cacheSnap = typeof lastTestCache?.snapshot === 'function'
95
+ ? lastTestCache.snapshot()
96
+ : (lastTestCache instanceof Map ? Object.fromEntries(lastTestCache) : (lastTestCache ?? {}));
97
+ json(res, 200, cacheSnap);
98
+ },
99
+ }), 'dsh-key-rotation: sandbox cache');
100
+
101
+
102
+
103
+ // #199 webhook-action: interactive webhook buttons call back here.
104
+ // Auth: bearer token from Config (external services like Telegram/Discord
105
+ // cannot be same-origin, so a shared secret is the gate).
106
+ }
@@ -0,0 +1,115 @@
1
+ // lib/ops-webhook.js — webhook interactive action route (#312 split from routes-ops.js).
2
+ import {
3
+ json,
4
+ readJson,
5
+ } from './http-bridge.js';
6
+ import { WEBHOOK_ACTION_PATH } from './ops-paths.js';
7
+
8
+ /**
9
+ * @param {object} ctx cordis context
10
+ * @param {object} deps live dependencies from apply()
11
+ */
12
+ export function registerWebhookActionRoute(ctx, deps) {
13
+ const {
14
+ buildRuntime,
15
+ poolState,
16
+ setRotationDisabled,
17
+ circuitBreaker,
18
+ } = deps;
19
+
20
+ ctx.effect(() => ctx.webServer.register({
21
+ kind: 'exact',
22
+ path: WEBHOOK_ACTION_PATH,
23
+ handler: async (req, res) => {
24
+ if (req.method !== 'POST') { json(res, 405, { error: { code: 'method', message: 'POST only' } }); return; }
25
+ const runtime = buildRuntime();
26
+ const expected = runtime.webhookActionToken;
27
+ if (!expected) { json(res, 503, { error: { code: 'no-token', message: 'dsh-key-rotation: webhookActionToken is not configured' } }); return; }
28
+ const auth = String(req.headers.authorization ?? '');
29
+ if (auth !== `Bearer ${expected}`) { json(res, 401, { error: { code: 'unauthorized', message: 'dsh-key-rotation: bad webhook action token' } }); return; }
30
+ let body;
31
+ try { body = await readJson(req); } catch (e) { json(res, 400, { error: { code: 'bad-request', message: String(e?.message ?? e) } }); return; }
32
+ // Accept callback payloads from formatInteractive (Telegram/Discord/Slack) or plain {action}
33
+ let action = typeof body?.action === 'string' ? body.action : '';
34
+ if (!action && typeof body?.data === 'string') {
35
+ try { action = String(JSON.parse(body.data)?.id ?? ''); } catch { action = ''; }
36
+ }
37
+ if (!action && typeof body?.callback_data === 'string') {
38
+ try { action = String(JSON.parse(body.callback_data)?.id ?? ''); } catch { action = ''; }
39
+ }
40
+ // #222: Telegram update envelope {update_id, callback_query:{data}}
41
+ if (!action && typeof body?.callback_query?.data === 'string') {
42
+ try { action = String(JSON.parse(body.callback_query.data)?.id ?? ''); } catch { action = ''; }
43
+ }
44
+ // #222: Telegram setWebhook registration helper
45
+ if (typeof body?.setWebhook === 'object' && body.setWebhook) {
46
+ const botToken = typeof body.setWebhook.botToken === 'string' ? body.setWebhook.botToken : '';
47
+ if (!botToken) { json(res, 400, { error: { code: 'bad-request', message: 'dsh-key-rotation: setWebhook.botToken required' } }); return; }
48
+ // derive the public URL from request headers; explicit URL wins
49
+ const url = typeof body.setWebhook.url === 'string' && body.setWebhook.url ? body.setWebhook.url : `https://${String(req.headers.host ?? '')}/dsh-key-rotation/webhook-action`;
50
+ try {
51
+ const hookRes = await fetch(`https://api.telegram.org/bot${botToken}/setWebhook`, {
52
+ method: 'POST', headers: { 'content-type': 'application/json' },
53
+ body: JSON.stringify({ url, allowed_updates: ['callback_query'] }),
54
+ });
55
+ const hookData = await hookRes.json().catch(() => ({}));
56
+ json(res, 200, { ok: hookRes.ok, url, telegram: hookData });
57
+ } catch (e) {
58
+ json(res, 502, { error: { code: 'telegram-failed', message: String(e?.message ?? e) } });
59
+ }
60
+ return;
61
+ }
62
+ if (!action) { json(res, 400, { error: { code: 'bad-action', message: 'dsh-key-rotation: no action in payload' } }); return; }
63
+ const provider = action.startsWith('pause-') || action.startsWith('reset-') ? action.replace(/^(pause|reset)-/, '') : '';
64
+ try {
65
+ if (action === 'disable-rotation') {
66
+ setRotationDisabled(true);
67
+ console.warn('[dsh-key-rotation] rotation DISABLED via webhook action');
68
+ json(res, 200, { ok: true, action });
69
+ return;
70
+ }
71
+ if (action === 'enable-rotation') {
72
+ setRotationDisabled(false);
73
+ json(res, 200, { ok: true, action });
74
+ return;
75
+ }
76
+ if (action.startsWith('pause-') || action.startsWith('reset-')) {
77
+ const st = poolState.get(provider);
78
+ if (!st) { json(res, 404, { error: { code: 'not-found', message: `dsh-key-rotation: no pool for '${provider}'` } }); return; }
79
+ if (action.startsWith('pause-')) {
80
+ const until = Date.now() + 3600000; // 1h pause
81
+ for (const ref of (st.failedUntil ? [...st.failedUntil.keys()] : [])) st.failedUntil.set(ref, Math.max(st.failedUntil.get(ref) ?? 0, until));
82
+ // also pause every key currently healthy
83
+ for (const p of buildRuntime().poolByRef.values()) {
84
+ if (p.base !== provider) continue;
85
+ for (const ref of p.refs) st.failedUntil.set(ref, Math.max(st.failedUntil.get(ref) ?? 0, until));
86
+ }
87
+ console.warn(`[dsh-key-rotation] pool ${provider} PAUSED 1h via webhook action`);
88
+ json(res, 200, { ok: true, action, provider, until: Date.now() + 3600000 });
89
+ return;
90
+ }
91
+ const cleared = st.failedUntil.size;
92
+ st.failedUntil.clear();
93
+ st.failCounts?.clear();
94
+ st.authFailCounts?.clear();
95
+ st.brokenUntil?.clear();
96
+ st.switches = 0;
97
+ st.lastReason = undefined;
98
+ st.lastSwitchAt = undefined;
99
+ let circuitReset = false;
100
+ const br = circuitBreaker ?? buildRuntime().breaker;
101
+ if (br) {
102
+ if (typeof br.reset === 'function') { br.reset(provider); circuitReset = true; }
103
+ else if (typeof br.onSuccess === 'function') { br.onSuccess(provider); circuitReset = true; }
104
+ }
105
+ console.warn(`[dsh-key-rotation] pool ${provider} RESET via webhook action (circuitReset=${circuitReset})`);
106
+ json(res, 200, { ok: true, action, provider, cleared, circuitReset });
107
+ return;
108
+ }
109
+ json(res, 400, { error: { code: 'unknown-action', message: `dsh-key-rotation: unknown action '${action}'` } });
110
+ } catch (e) {
111
+ json(res, 500, { error: { code: 'action-failed', message: String(e?.message ?? e) } });
112
+ }
113
+ },
114
+ }), 'dsh-key-rotation: webhook-action');
115
+ }