@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.
- package/CHANGELOG.md +7 -0
- package/lib/config-compat.js +18 -1
- package/lib/http-bridge.js +2 -0
- package/lib/index.js +4 -1
- package/lib/key-state-machine.js +267 -0
- package/lib/ops-balance.js +2 -147
- package/lib/ops-config-io.js +2 -456
- package/lib/ops-keys.js +2 -567
- package/lib/ops-metrics.js +2 -92
- package/lib/ops-paths.js +2 -24
- package/lib/ops-quick-action.js +2 -168
- package/lib/ops-status.js +2 -269
- package/lib/ops-telemetry.js +2 -276
- package/lib/ops-test.js +2 -255
- package/lib/ops-webhook.js +2 -218
- package/lib/pool-state.js +3 -0
- package/lib/pool.js +10 -15
- package/lib/routes/balance.js +147 -0
- package/lib/routes/config.js +456 -0
- package/lib/routes/guard.js +13 -0
- package/lib/routes/index.js +43 -0
- package/lib/routes/keys.js +567 -0
- package/lib/routes/metrics.js +92 -0
- package/lib/routes/paths.js +24 -0
- package/lib/routes/quick-action.js +168 -0
- package/lib/routes/status.js +269 -0
- package/lib/routes/telemetry.js +276 -0
- package/lib/routes/testing.js +255 -0
- package/lib/routes/webhook.js +218 -0
- package/lib/routes-ops.js +2 -29
- package/package.json +1 -1
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
import crypto from 'node:crypto';
|
|
2
|
+
// lib/ops-webhook.js — webhook interactive action route (#312 split from routes-ops.js, #388 telegram secret-token & answerCallbackQuery).
|
|
3
|
+
import {
|
|
4
|
+
json,
|
|
5
|
+
readJson,
|
|
6
|
+
} from '../http-bridge.js';
|
|
7
|
+
import { bestEffort } from '../best-effort.js';
|
|
8
|
+
import { WEBHOOK_ACTION_PATH } from './paths.js';
|
|
9
|
+
import { resolveAction } from '../webhook.js';
|
|
10
|
+
|
|
11
|
+
function constantTimeEqual(a, b) {
|
|
12
|
+
if (typeof a !== 'string' || typeof b !== 'string') return false;
|
|
13
|
+
const aBuf = Buffer.from(a);
|
|
14
|
+
const bBuf = Buffer.from(b);
|
|
15
|
+
return aBuf.length === bBuf.length && crypto.timingSafeEqual(aBuf, bBuf);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function getHeader(headers, name) {
|
|
19
|
+
if (!headers || typeof headers !== 'object') return '';
|
|
20
|
+
const target = name.toLowerCase();
|
|
21
|
+
for (const [k, v] of Object.entries(headers)) {
|
|
22
|
+
if (k.toLowerCase() === target) return String(v ?? '');
|
|
23
|
+
}
|
|
24
|
+
return '';
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* @param {object} ctx cordis context
|
|
29
|
+
* @param {object} deps live dependencies from apply()
|
|
30
|
+
*/
|
|
31
|
+
export function registerWebhookActionRoute(ctx, deps) {
|
|
32
|
+
const {
|
|
33
|
+
buildRuntime,
|
|
34
|
+
poolState,
|
|
35
|
+
setRotationDisabled,
|
|
36
|
+
circuitBreaker,
|
|
37
|
+
} = deps;
|
|
38
|
+
|
|
39
|
+
ctx.effect(() => ctx.webServer.register({
|
|
40
|
+
kind: 'exact',
|
|
41
|
+
path: WEBHOOK_ACTION_PATH,
|
|
42
|
+
handler: async (req, res) => {
|
|
43
|
+
if (req.method !== 'POST') { json(res, 405, { error: { code: 'method', message: 'POST only' } }); return; }
|
|
44
|
+
const runtime = buildRuntime();
|
|
45
|
+
const expected = runtime.webhookActionToken;
|
|
46
|
+
if (!expected) { json(res, 503, { error: { code: 'no-token', message: 'dsh-key-rotation: webhookActionToken is not configured' } }); return; }
|
|
47
|
+
|
|
48
|
+
const authHeader = getHeader(req.headers, 'authorization');
|
|
49
|
+
const secretHeader = getHeader(req.headers, 'x-telegram-bot-api-secret-token');
|
|
50
|
+
const bearerMatch = constantTimeEqual(authHeader, `Bearer ${expected}`);
|
|
51
|
+
const secretMatch = constantTimeEqual(secretHeader, expected);
|
|
52
|
+
if (!bearerMatch && !secretMatch) {
|
|
53
|
+
json(res, 401, { error: { code: 'unauthorized', message: 'dsh-key-rotation: bad webhook action token' } });
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
let body;
|
|
58
|
+
try { body = await readJson(req); } catch (e) { json(res, 400, { error: { code: 'bad-request', message: String(e?.message ?? e) } }); return; }
|
|
59
|
+
|
|
60
|
+
// #222 / #388: Telegram setWebhook registration helper
|
|
61
|
+
if (typeof body?.setWebhook === 'object' && body.setWebhook) {
|
|
62
|
+
const botToken = typeof body.setWebhook.botToken === 'string' ? body.setWebhook.botToken : '';
|
|
63
|
+
if (!botToken) { json(res, 400, { error: { code: 'bad-request', message: 'dsh-key-rotation: setWebhook.botToken required' } }); return; }
|
|
64
|
+
const TELEGRAM_BOT_TOKEN_RE = /^[0-9]{5,16}:[a-zA-Z0-9_-]{20,50}$/;
|
|
65
|
+
if (!TELEGRAM_BOT_TOKEN_RE.test(botToken)) {
|
|
66
|
+
json(res, 400, { error: { code: 'bad-request', message: 'dsh-key-rotation: invalid telegram bot token format' } });
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
const rawUrl = typeof body.setWebhook.url === 'string' ? body.setWebhook.url.trim() : '';
|
|
70
|
+
if (!rawUrl || !rawUrl.startsWith('https://')) {
|
|
71
|
+
json(res, 400, { error: { code: 'bad-request', message: 'dsh-key-rotation: explicit public https url required in setWebhook.url' } });
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
try {
|
|
75
|
+
const hookRes = await fetch(`https://api.telegram.org/bot${botToken}/setWebhook`, {
|
|
76
|
+
method: 'POST',
|
|
77
|
+
headers: { 'content-type': 'application/json' },
|
|
78
|
+
body: JSON.stringify({
|
|
79
|
+
url: rawUrl,
|
|
80
|
+
secret_token: expected,
|
|
81
|
+
allowed_updates: ['callback_query'],
|
|
82
|
+
}),
|
|
83
|
+
signal: AbortSignal.timeout(10000),
|
|
84
|
+
});
|
|
85
|
+
const hookData = await hookRes.json().catch(() => ({}));
|
|
86
|
+
json(res, 200, { ok: hookRes.ok, url: rawUrl, secret_token_set: true, telegram: hookData });
|
|
87
|
+
} catch (e) {
|
|
88
|
+
json(res, 502, { error: { code: 'telegram-failed', message: String(e?.message ?? e) } });
|
|
89
|
+
}
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const callbackQueryId = typeof body?.callback_query?.id === 'string'
|
|
94
|
+
? body.callback_query.id
|
|
95
|
+
: (typeof body?.callback_query?.id === 'number' ? String(body.callback_query.id) : '');
|
|
96
|
+
const botToken = typeof body?.botToken === 'string' ? body.botToken : '';
|
|
97
|
+
|
|
98
|
+
const respond = async (statusCode, payload, answerText) => {
|
|
99
|
+
if (callbackQueryId) {
|
|
100
|
+
payload.method = 'answerCallbackQuery';
|
|
101
|
+
payload.callback_query_id = callbackQueryId;
|
|
102
|
+
payload.text = answerText || payload.error?.message || 'OK';
|
|
103
|
+
if (botToken) {
|
|
104
|
+
await bestEffort('telegram-answerCallbackQuery', () => fetch(`https://api.telegram.org/bot${botToken}/answerCallbackQuery`, {
|
|
105
|
+
method: 'POST',
|
|
106
|
+
headers: { 'content-type': 'application/json' },
|
|
107
|
+
body: JSON.stringify({ callback_query_id: callbackQueryId, text: payload.text }),
|
|
108
|
+
signal: AbortSignal.timeout(5000),
|
|
109
|
+
}), ctx?.logger);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
json(res, statusCode, payload);
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
// Accept callback payloads from formatInteractive (Telegram/Discord/Slack) or plain {action}
|
|
116
|
+
let action = typeof body?.action === 'string' ? body.action : '';
|
|
117
|
+
if (!action && typeof body?.data === 'string') {
|
|
118
|
+
try { action = String(JSON.parse(body.data)?.id ?? ''); } catch { action = ''; }
|
|
119
|
+
}
|
|
120
|
+
if (!action && typeof body?.callback_data === 'string') {
|
|
121
|
+
try { action = String(JSON.parse(body.callback_data)?.id ?? ''); } catch { action = ''; }
|
|
122
|
+
}
|
|
123
|
+
// #222 / #388: Telegram update envelope {update_id, callback_query:{id, data}}
|
|
124
|
+
if (!action && typeof body?.callback_query?.data === 'string') {
|
|
125
|
+
try {
|
|
126
|
+
const parsed = JSON.parse(body.callback_query.data);
|
|
127
|
+
action = typeof parsed?.id === 'string' ? parsed.id : String(parsed ?? '');
|
|
128
|
+
} catch {
|
|
129
|
+
action = body.callback_query.data;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
action = resolveAction(action);
|
|
133
|
+
if (!action) {
|
|
134
|
+
await respond(400, { error: { code: 'bad-action', message: 'dsh-key-rotation: no action in payload' } }, 'No action in payload');
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
const provider = action.startsWith('pause-') || action.startsWith('reset-') ? action.replace(/^(pause|reset)-/, '') : '';
|
|
138
|
+
try {
|
|
139
|
+
if (action === 'disable-rotation') {
|
|
140
|
+
setRotationDisabled(true);
|
|
141
|
+
(ctx?.logger ? ctx.logger('dsh-key-rotation') : null)?.warn?.('[dsh-key-rotation] rotation DISABLED via webhook action');
|
|
142
|
+
await respond(200, { ok: true, action }, 'Rotation disabled');
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
if (action === 'enable-rotation') {
|
|
146
|
+
setRotationDisabled(false);
|
|
147
|
+
await respond(200, { ok: true, action }, 'Rotation enabled');
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
if (action.startsWith('pause-') || action.startsWith('reset-')) {
|
|
151
|
+
const st = poolState.get(provider);
|
|
152
|
+
if (!st) {
|
|
153
|
+
await respond(404, { error: { code: 'not-found', message: `dsh-key-rotation: no pool for '${provider}'` } }, `Pool not found: ${provider}`);
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
if (action.startsWith('pause-')) {
|
|
157
|
+
const until = Date.now() + 3600000; // 1h pause
|
|
158
|
+
let found = false;
|
|
159
|
+
for (const [key, s] of poolState.entries()) {
|
|
160
|
+
if (key === provider || key.startsWith(provider + '::')) {
|
|
161
|
+
found = true;
|
|
162
|
+
for (const ref of (s.failedUntil ? [...s.failedUntil.keys()] : [])) {
|
|
163
|
+
s.failedUntil.set(ref, Math.max(s.failedUntil.get(ref) ?? 0, until));
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
for (const p of buildRuntime().poolByRef.values()) {
|
|
168
|
+
if (p.provider === provider || p.base === provider || p.base?.startsWith(provider + '::')) {
|
|
169
|
+
found = true;
|
|
170
|
+
for (const ref of p.refs) p.state?.failedUntil?.set(ref, Math.max(p.state?.failedUntil?.get(ref) ?? 0, until));
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
if (!found) {
|
|
174
|
+
await respond(404, { error: { code: 'not-found', message: `dsh-key-rotation: no pool for '${provider}'` } }, `Pool not found: ${provider}`);
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
(ctx?.logger ? ctx.logger('dsh-key-rotation') : null)?.warn?.(`[dsh-key-rotation] pool ${provider} PAUSED 1h via webhook action`);
|
|
178
|
+
await respond(200, { ok: true, action, provider, until }, `Pool ${provider} paused 1h`);
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
let found = false;
|
|
182
|
+
let cleared = 0;
|
|
183
|
+
for (const [key, s] of poolState.entries()) {
|
|
184
|
+
if (key === provider || key.startsWith(provider + '::')) {
|
|
185
|
+
found = true;
|
|
186
|
+
cleared += s.failedUntil ? s.failedUntil.size : 0;
|
|
187
|
+
s.failedUntil?.clear();
|
|
188
|
+
s.failCounts?.clear();
|
|
189
|
+
s.authFailCounts?.clear();
|
|
190
|
+
s.brokenUntil?.clear();
|
|
191
|
+
s.revokedRefs?.clear();
|
|
192
|
+
s.tokenUsage?.clear();
|
|
193
|
+
s.switches = 0;
|
|
194
|
+
s.lastReason = undefined;
|
|
195
|
+
s.lastSwitchAt = undefined;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
if (!found) {
|
|
199
|
+
await respond(404, { error: { code: 'not-found', message: `dsh-key-rotation: no pool for '${provider}'` } }, `Pool not found: ${provider}`);
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
let circuitReset = false;
|
|
203
|
+
const br = circuitBreaker ?? buildRuntime().breaker;
|
|
204
|
+
if (br) {
|
|
205
|
+
if (typeof br.reset === 'function') { br.reset(provider); circuitReset = true; }
|
|
206
|
+
else if (typeof br.onSuccess === 'function') { br.onSuccess(provider); circuitReset = true; }
|
|
207
|
+
}
|
|
208
|
+
(ctx?.logger ? ctx.logger('dsh-key-rotation') : null)?.warn?.(`[dsh-key-rotation] pool ${provider} RESET via webhook action (circuitReset=${circuitReset})`);
|
|
209
|
+
await respond(200, { ok: true, action, provider, cleared, circuitReset }, `Pool ${provider} reset`);
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
await respond(400, { error: { code: 'unknown-action', message: `dsh-key-rotation: unknown action '${action}'` } }, `Unknown action: ${action}`);
|
|
213
|
+
} catch (e) {
|
|
214
|
+
await respond(500, { error: { code: 'action-failed', message: String(e?.message ?? e) } }, 'Action failed');
|
|
215
|
+
}
|
|
216
|
+
},
|
|
217
|
+
}), 'dsh-key-rotation: webhook-action');
|
|
218
|
+
}
|
package/lib/routes-ops.js
CHANGED
|
@@ -1,29 +1,2 @@
|
|
|
1
|
-
// lib/routes-ops.js —
|
|
2
|
-
|
|
3
|
-
// Registration is injected with the live apply() dependencies.
|
|
4
|
-
|
|
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';
|
|
10
|
-
import { registerMetricsRoutes } from './ops-metrics.js';
|
|
11
|
-
import { registerBalanceRoutes } from './ops-balance.js';
|
|
12
|
-
import { registerConfigIoRoutes } from './ops-config-io.js';
|
|
13
|
-
import { registerQuickActionRoutes } from './ops-quick-action.js';
|
|
14
|
-
|
|
15
|
-
/**
|
|
16
|
-
* @param {object} ctx cordis context
|
|
17
|
-
* @param {object} deps live dependencies from apply()
|
|
18
|
-
*/
|
|
19
|
-
export function registerOpsRoutes(ctx, deps) {
|
|
20
|
-
registerStatusRoutes(ctx, deps);
|
|
21
|
-
registerTelemetryRoutes(ctx, deps);
|
|
22
|
-
registerKeyRoutes(ctx, deps);
|
|
23
|
-
registerTestRoutes(ctx, deps);
|
|
24
|
-
registerWebhookActionRoute(ctx, deps);
|
|
25
|
-
registerMetricsRoutes(ctx, deps);
|
|
26
|
-
registerBalanceRoutes(ctx, deps);
|
|
27
|
-
registerConfigIoRoutes(ctx, deps);
|
|
28
|
-
registerQuickActionRoutes(ctx, deps);
|
|
29
|
-
}
|
|
1
|
+
// lib/routes-ops.js — Re-export facade for lib/routes/ (#522).
|
|
2
|
+
export * from "./routes/index.js";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@goodandready/dsh-key-rotation",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.79",
|
|
4
4
|
"packageManager": "pnpm@10.33.2",
|
|
5
5
|
"description": "Per-provider API key rotation for DeepSeek Harness: a key pool per provider, auto-created clone routes, and switching to the next key on quota/rate-limit errors. Includes a Settings section (Key Rotation) to edit the key pools, cooldown and switch codes.",
|
|
6
6
|
"keywords": [
|