@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/CHANGELOG.md +14 -0
- package/lib/concurrency.js +2 -115
- package/lib/config-compat.js +18 -1
- package/lib/core/concurrency.js +115 -0
- package/lib/core/index.js +6 -0
- package/lib/core/pool.js +2 -0
- package/lib/core/quota.js +39 -0
- package/lib/core/rotate.js +2 -0
- package/lib/core/state-machine.js +267 -0
- package/lib/http-bridge.js +2 -0
- package/lib/index.js +41 -27
- package/lib/key-state-machine.js +2 -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/persistence.js +69 -10
- package/lib/pool-state.js +3 -0
- package/lib/pool.js +10 -15
- package/lib/quota.js +2 -39
- 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
package/lib/ops-webhook.js
CHANGED
|
@@ -1,218 +1,2 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
import {
|
|
4
|
-
json,
|
|
5
|
-
readJson,
|
|
6
|
-
} from './http-bridge.js';
|
|
7
|
-
import { bestEffort } from './best-effort.js';
|
|
8
|
-
import { WEBHOOK_ACTION_PATH } from './ops-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
|
-
}
|
|
1
|
+
// lib/ops-webhook.js — Re-export facade for lib/routes/ (#522).
|
|
2
|
+
export * from "./routes/webhook.js";
|
package/lib/persistence.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
// lib/persistence.js — atomic snapshot
|
|
2
|
-
// Cooldowns, circuit-breaker entries and quota counters live in memory;
|
|
3
|
-
//
|
|
1
|
+
// lib/persistence.js — atomic snapshot and append-only event journal (#287, #525).
|
|
2
|
+
// Cooldowns, circuit-breaker entries and quota counters live in memory; an append-only
|
|
3
|
+
// journal records incremental events without rewriting the entire state tree on disk.
|
|
4
4
|
|
|
5
5
|
import path from 'node:path';
|
|
6
|
+
import fs from 'node:fs';
|
|
6
7
|
import { initializePoolState, hasPoolActivity } from './pool-state.js';
|
|
7
8
|
import { atomicWriteJson, safeReadJson } from './atomic-io.js';
|
|
8
9
|
|
|
@@ -18,6 +19,9 @@ export class StatePersistence {
|
|
|
18
19
|
throw new Error('StatePersistence: filePath is required');
|
|
19
20
|
}
|
|
20
21
|
this.filePath = filePath;
|
|
22
|
+
this.journalPath = filePath.endsWith('.json')
|
|
23
|
+
? filePath.slice(0, -5) + '-journal.jsonl'
|
|
24
|
+
: `${filePath}.jsonl`;
|
|
21
25
|
this._now = now;
|
|
22
26
|
this._timer = null;
|
|
23
27
|
this._dirty = false;
|
|
@@ -112,8 +116,8 @@ export class StatePersistence {
|
|
|
112
116
|
if (val instanceof Map || (typeof val === 'object' && !Array.isArray(val))) {
|
|
113
117
|
const innerObj = {};
|
|
114
118
|
const innerEntries = val instanceof Map ? val.entries() : Object.entries(val);
|
|
115
|
-
for (const [day,
|
|
116
|
-
const num = Number(
|
|
119
|
+
for (const [day, count] of innerEntries) {
|
|
120
|
+
const num = Number(count);
|
|
117
121
|
if (Number.isFinite(num) && num >= 0) innerObj[day] = Math.floor(num);
|
|
118
122
|
}
|
|
119
123
|
if (Object.keys(innerObj).length > 0) usageDays[k] = innerObj;
|
|
@@ -168,7 +172,22 @@ export class StatePersistence {
|
|
|
168
172
|
}
|
|
169
173
|
|
|
170
174
|
/**
|
|
171
|
-
*
|
|
175
|
+
* Append-only event journaling (#525). Records incremental event without full state rewrite.
|
|
176
|
+
* @param {object} event
|
|
177
|
+
*/
|
|
178
|
+
async appendEvent(event) {
|
|
179
|
+
if (!event || typeof event !== 'object') return false;
|
|
180
|
+
const line = JSON.stringify({ ...event, at: event.at ?? this._nowSafe() }) + '\n';
|
|
181
|
+
try {
|
|
182
|
+
await fs.promises.appendFile(this.journalPath, line, 'utf-8');
|
|
183
|
+
return true;
|
|
184
|
+
} catch {
|
|
185
|
+
return false;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Schedule a debounced atomic snapshot write. Never throws to the caller.
|
|
172
191
|
* @param {object} payload result of StatePersistence.serialize
|
|
173
192
|
*/
|
|
174
193
|
save(payload) {
|
|
@@ -190,9 +209,11 @@ export class StatePersistence {
|
|
|
190
209
|
try {
|
|
191
210
|
await atomicWriteJson(this.filePath, payload);
|
|
192
211
|
this._lastWrite = this._nowSafe();
|
|
212
|
+
try {
|
|
213
|
+
await fs.promises.writeFile(this.journalPath, '', 'utf-8');
|
|
214
|
+
} catch {}
|
|
193
215
|
return true;
|
|
194
216
|
} catch {
|
|
195
|
-
// Keep previous good file; mark dirty so a later save retries.
|
|
196
217
|
this._dirty = true;
|
|
197
218
|
this._pending = this._pending || payload;
|
|
198
219
|
return false;
|
|
@@ -200,16 +221,56 @@ export class StatePersistence {
|
|
|
200
221
|
}
|
|
201
222
|
|
|
202
223
|
/**
|
|
203
|
-
* Load
|
|
224
|
+
* Load snapshot and replay any journaled events (#525).
|
|
204
225
|
* @returns {Promise<object|null>}
|
|
205
226
|
*/
|
|
206
227
|
async load() {
|
|
207
228
|
const raw = await safeReadJson(this.filePath, null);
|
|
208
229
|
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null;
|
|
209
230
|
if (raw.version !== 1 && raw.version !== 2) return null;
|
|
231
|
+
|
|
232
|
+
if (fs.existsSync(this.journalPath)) {
|
|
233
|
+
try {
|
|
234
|
+
const content = await fs.promises.readFile(this.journalPath, 'utf-8');
|
|
235
|
+
const lines = content.split('\n');
|
|
236
|
+
for (const line of lines) {
|
|
237
|
+
const trimmed = line.trim();
|
|
238
|
+
if (trimmed) {
|
|
239
|
+
try { this.replayEvent(raw, JSON.parse(trimmed)); } catch {}
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
} catch {}
|
|
243
|
+
}
|
|
244
|
+
|
|
210
245
|
return raw;
|
|
211
246
|
}
|
|
212
247
|
|
|
248
|
+
/** Replay individual event into a snapshot object (#525). */
|
|
249
|
+
replayEvent(snapshot, event) {
|
|
250
|
+
if (!snapshot || !event?.pool || !event?.ref) return;
|
|
251
|
+
if (!snapshot.pools) snapshot.pools = {};
|
|
252
|
+
if (!snapshot.pools[event.pool]) {
|
|
253
|
+
snapshot.pools[event.pool] = { failedUntil: {}, pausedRefs: [], revokedRefs: [] };
|
|
254
|
+
}
|
|
255
|
+
const p = snapshot.pools[event.pool];
|
|
256
|
+
if (event.type === 'cooldown' || event.type === 'failure') {
|
|
257
|
+
if (!p.failedUntil) p.failedUntil = {};
|
|
258
|
+
p.failedUntil[event.ref] = event.until ?? (event.at + (event.cooldownMs || 60000));
|
|
259
|
+
} else if (event.type === 'success' || event.type === 'reset') {
|
|
260
|
+
if (p.failedUntil) delete p.failedUntil[event.ref];
|
|
261
|
+
} else if (event.type === 'pause') {
|
|
262
|
+
if (!Array.isArray(p.pausedRefs)) p.pausedRefs = [];
|
|
263
|
+
if (!p.pausedRefs.includes(event.ref)) p.pausedRefs.push(event.ref);
|
|
264
|
+
} else if (event.type === 'unpause') {
|
|
265
|
+
if (Array.isArray(p.pausedRefs)) {
|
|
266
|
+
p.pausedRefs = p.pausedRefs.filter((r) => r !== event.ref);
|
|
267
|
+
}
|
|
268
|
+
} else if (event.type === 'revoke') {
|
|
269
|
+
if (!Array.isArray(p.revokedRefs)) p.revokedRefs = [];
|
|
270
|
+
if (!p.revokedRefs.includes(event.ref)) p.revokedRefs.push(event.ref);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
213
274
|
/** Restore poolState Map from a loaded snapshot. */
|
|
214
275
|
static restorePools(poolState, snapshot) {
|
|
215
276
|
if (!poolState || !snapshot?.pools || typeof snapshot.pools !== 'object' || Array.isArray(snapshot.pools)) return 0;
|
|
@@ -218,8 +279,6 @@ export class StatePersistence {
|
|
|
218
279
|
if (!saved || typeof saved !== 'object' || Array.isArray(saved)) continue;
|
|
219
280
|
const existing = poolState.get(base);
|
|
220
281
|
const state = initializePoolState(existing);
|
|
221
|
-
// Never replace objects retained by buildRuntime(), nor roll back a pool
|
|
222
|
-
// already used while the asynchronous load was in flight.
|
|
223
282
|
if (!existing || !hasPoolActivity(state)) {
|
|
224
283
|
if (saved.failedUntil && typeof saved.failedUntil === 'object' && !Array.isArray(saved.failedUntil)) {
|
|
225
284
|
for (const [ref, until] of Object.entries(saved.failedUntil)) {
|
package/lib/pool-state.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
// Shared runtime shape for fresh and persisted pools. Hydrate in place so cached
|
|
2
2
|
// runtime pools, persistence and timers continue to observe the same object.
|
|
3
|
+
import { KeyStateMachine } from './key-state-machine.js';
|
|
4
|
+
|
|
3
5
|
const MAP_FIELDS = [
|
|
4
6
|
'failedUntil', 'failCounts', 'authFailCounts', 'brokenUntil', 'costPerKey',
|
|
5
7
|
'lastUsedAt', 'usageCounts', 'byModel', 'usageDays', 'quotaWindows', 'costDays',
|
|
@@ -18,6 +20,7 @@ export function initializePoolState(state = {}) {
|
|
|
18
20
|
if (!Array.isArray(state.events)) state.events = [];
|
|
19
21
|
if (!Number.isSafeInteger(state.pointer) || state.pointer < 0) state.pointer = 0;
|
|
20
22
|
if (typeof state.lastUsed !== 'string') state.lastUsed = null;
|
|
23
|
+
if (!state.keyStateMachine) state.keyStateMachine = new KeyStateMachine();
|
|
21
24
|
return state;
|
|
22
25
|
}
|
|
23
26
|
|
package/lib/pool.js
CHANGED
|
@@ -195,6 +195,7 @@ export function pickNext(pool, now, refsCount = pool.refs.length) {
|
|
|
195
195
|
/** Apply a failed-key cooldown to pool state. Pure: returns next state shape. */
|
|
196
196
|
export function applyCooldown(pool, ref, cooldownMs, now = Date.now()) {
|
|
197
197
|
unstickFailedKey(pool, ref);
|
|
198
|
+
pool?.state?.keyStateMachine?.transitionToCooldown(ref, { cooldownMs }, now);
|
|
198
199
|
return {
|
|
199
200
|
...pool,
|
|
200
201
|
state: {
|
|
@@ -231,10 +232,9 @@ export function recordFailure(pool, ref, now, baseMs, maxMs, isSoft = false, jit
|
|
|
231
232
|
pool.state.failCounts.set(ref, next);
|
|
232
233
|
}
|
|
233
234
|
let backoff = computeBackoff(baseMs, next || 1, maxMs, isSoft);
|
|
234
|
-
if (jitter)
|
|
235
|
-
backoff = applyJitter(backoff);
|
|
236
|
-
}
|
|
235
|
+
if (jitter) backoff = applyJitter(backoff);
|
|
237
236
|
pool.state.failedUntil.set(ref, now + backoff);
|
|
237
|
+
pool.state?.keyStateMachine?.transitionToCooldown(ref, { cooldownMs: backoff, reason: isSoft ? 'SOFT_FAILURE' : 'FAILURE' }, now);
|
|
238
238
|
return backoff;
|
|
239
239
|
}
|
|
240
240
|
|
|
@@ -245,6 +245,7 @@ export function recordSuccess(pool, ref, now = Date.now()) {
|
|
|
245
245
|
pool.state.quotaWindows?.delete(ref);
|
|
246
246
|
if (!pool.state.lastSuccessAt) pool.state.lastSuccessAt = new Map();
|
|
247
247
|
pool.state.lastSuccessAt.set(ref, now);
|
|
248
|
+
pool.state?.keyStateMachine?.activate(ref, { reason: 'SUCCESS' }, now);
|
|
248
249
|
}
|
|
249
250
|
|
|
250
251
|
/** Decay penalty failCounts for stable keys that haven't failed in decayIntervalMs. */
|
|
@@ -376,12 +377,9 @@ export function parseDurationToSeconds(v) {
|
|
|
376
377
|
/** Check if a key reference is in maintenance/paused mode. */
|
|
377
378
|
export function isKeyPaused(pool, ref) {
|
|
378
379
|
if (!pool || !ref) return false;
|
|
379
|
-
if (pool.pausedRefs
|
|
380
|
-
if (
|
|
381
|
-
|
|
382
|
-
if (pool.state && Array.isArray(pool.state.pausedRefs) && pool.state.pausedRefs.includes(ref)) return true;
|
|
383
|
-
if (pool.basePool) return isKeyPaused(pool.basePool, ref);
|
|
384
|
-
return false;
|
|
380
|
+
if (pool.pausedRefs?.has?.(ref) || pool.pausedRefs?.includes?.(ref)) return true;
|
|
381
|
+
if (pool.state?.pausedRefs?.has?.(ref) || pool.state?.pausedRefs?.includes?.(ref)) return true;
|
|
382
|
+
return pool.basePool ? isKeyPaused(pool.basePool, ref) : false;
|
|
385
383
|
}
|
|
386
384
|
|
|
387
385
|
/** Extract rate-limit info from an object that may carry response headers. */
|
|
@@ -578,12 +576,9 @@ export function resetCircuitForProvider(circuitBreaker, provider) {
|
|
|
578
576
|
/** Check if a key reference is permanently revoked due to auth/401 failure (#369). */
|
|
579
577
|
export function isKeyRevoked(pool, ref) {
|
|
580
578
|
if (!pool || !ref) return false;
|
|
581
|
-
if (pool.revokedRefs
|
|
582
|
-
if (
|
|
583
|
-
|
|
584
|
-
if (pool.state && Array.isArray(pool.state.revokedRefs) && pool.state.revokedRefs.includes(ref)) return true;
|
|
585
|
-
if (pool.basePool) return isKeyRevoked(pool.basePool, ref);
|
|
586
|
-
return false;
|
|
579
|
+
if (pool.revokedRefs?.has?.(ref) || pool.revokedRefs?.includes?.(ref)) return true;
|
|
580
|
+
if (pool.state?.revokedRefs?.has?.(ref) || pool.state?.revokedRefs?.includes?.(ref)) return true;
|
|
581
|
+
return pool.basePool ? isKeyRevoked(pool.basePool, ref) : false;
|
|
587
582
|
}
|
|
588
583
|
|
|
589
584
|
/** Check if a key reference is in active recoverable or permanent quarantine (#427). */
|
package/lib/quota.js
CHANGED
|
@@ -1,39 +1,2 @@
|
|
|
1
|
-
// lib/quota.js —
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
export class QuotaStore {
|
|
5
|
-
constructor() {
|
|
6
|
-
this._data = new Map(); // ref -> { remaining, limit, reset, at }
|
|
7
|
-
}
|
|
8
|
-
|
|
9
|
-
set(ref, info) {
|
|
10
|
-
if (!ref) return;
|
|
11
|
-
if (!info || typeof info !== 'object') return;
|
|
12
|
-
const next = {
|
|
13
|
-
remaining: Number.isFinite(info.remaining) ? info.remaining : null,
|
|
14
|
-
limit: Number.isFinite(info.limit) ? info.limit : null,
|
|
15
|
-
reset: Number.isFinite(info.reset) ? info.reset : null,
|
|
16
|
-
at: Number.isFinite(info.at) ? info.at : Date.now(),
|
|
17
|
-
};
|
|
18
|
-
this._data.set(ref, next);
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
get(ref) {
|
|
22
|
-
return this._data.get(ref);
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
snapshot() {
|
|
26
|
-
const out = {};
|
|
27
|
-
for (const [k, v] of this._data) out[k] = v;
|
|
28
|
-
return out;
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
clear(ref) {
|
|
32
|
-
if (ref) this._data.delete(ref);
|
|
33
|
-
else this._data.clear();
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
get size() {
|
|
37
|
-
return this._data.size;
|
|
38
|
-
}
|
|
39
|
-
}
|
|
1
|
+
// lib/quota.js — Backwards-compatible facade (#530).
|
|
2
|
+
export * from './core/quota.js';
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
// lib/ops-balance.js — operational HTTP routes for balance inspection (#495).
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
json,
|
|
5
|
+
readJson,
|
|
6
|
+
} from '../http-bridge.js';
|
|
7
|
+
import {
|
|
8
|
+
isTrustedBridgeRequest,
|
|
9
|
+
isValidRef,
|
|
10
|
+
} from '../pool.js';
|
|
11
|
+
import { BALANCE_PATH } from './paths.js';
|
|
12
|
+
import { inspectBalanceForRef } from '../balance-inspector.js';
|
|
13
|
+
import { pushEvent } from '../notify-events.js';
|
|
14
|
+
|
|
15
|
+
export function registerBalanceRoutes(ctx, deps) {
|
|
16
|
+
const {
|
|
17
|
+
poolState,
|
|
18
|
+
buildRuntime,
|
|
19
|
+
} = deps;
|
|
20
|
+
|
|
21
|
+
ctx.effect(() => ctx.webServer.register({
|
|
22
|
+
kind: 'exact',
|
|
23
|
+
path: BALANCE_PATH,
|
|
24
|
+
handler: async (req, res) => {
|
|
25
|
+
if (!isTrustedBridgeRequest(req)) {
|
|
26
|
+
json(res, 403, { ok: false, error: 'Forbidden' });
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const urlObj = new URL(req.url, 'http://127.0.0.1');
|
|
31
|
+
|
|
32
|
+
// GET: Query single key or provider cached balances
|
|
33
|
+
if (req.method === 'GET') {
|
|
34
|
+
const ref = urlObj.searchParams.get('ref');
|
|
35
|
+
const provider = urlObj.searchParams.get('provider');
|
|
36
|
+
|
|
37
|
+
if (ref) {
|
|
38
|
+
const runtime = buildRuntime();
|
|
39
|
+
const targetPool = provider ? runtime.pools?.find((p) => p.provider === provider) : null;
|
|
40
|
+
const pool = targetPool ?? runtime.poolByRef?.get(ref);
|
|
41
|
+
const cached = pool?.state?.balances?.get(ref) ?? null;
|
|
42
|
+
|
|
43
|
+
if (urlObj.searchParams.get('refresh') === 'true' && provider) {
|
|
44
|
+
const inspectRes = await inspectBalanceForRef(ctx, ref, provider);
|
|
45
|
+
if (inspectRes.ok) {
|
|
46
|
+
if (pool && pool.state) {
|
|
47
|
+
if (!pool.state.balances) pool.state.balances = new Map();
|
|
48
|
+
pool.state.balances.set(ref, { ...inspectRes, at: Date.now() });
|
|
49
|
+
|
|
50
|
+
// Check soft pause
|
|
51
|
+
applyBalanceState(pool, ref, inspectRes);
|
|
52
|
+
}
|
|
53
|
+
json(res, 200, { ok: true, ref, balance: inspectRes });
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
json(res, 200, { ok: false, ref, error: inspectRes.error, cached });
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
json(res, 200, { ok: true, ref, balance: cached });
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Return all cached balances
|
|
65
|
+
const out = {};
|
|
66
|
+
if (poolState && typeof poolState.forEach === 'function') {
|
|
67
|
+
poolState.forEach((st, provName) => {
|
|
68
|
+
if (st && st.balances instanceof Map) {
|
|
69
|
+
for (const [k, v] of st.balances) {
|
|
70
|
+
out[k] = v;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
json(res, 200, { ok: true, balances: out });
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// POST: Trigger refresh of balances for a provider or key list
|
|
80
|
+
if (req.method === 'POST') {
|
|
81
|
+
const body = await readJson(req);
|
|
82
|
+
const provider = body?.provider;
|
|
83
|
+
const ref = body?.ref;
|
|
84
|
+
const testValue = body?.value;
|
|
85
|
+
|
|
86
|
+
if (ref && provider) {
|
|
87
|
+
const inspectRes = await inspectBalanceForRef(ctx, ref, provider, { testValue });
|
|
88
|
+
const runtime = buildRuntime();
|
|
89
|
+
const pool = runtime.pools?.find((p) => p.provider === provider) ?? runtime.poolByRef?.get(ref);
|
|
90
|
+
|
|
91
|
+
if (inspectRes.ok && pool && pool.state) {
|
|
92
|
+
if (!pool.state.balances) pool.state.balances = new Map();
|
|
93
|
+
pool.state.balances.set(ref, { ...inspectRes, at: Date.now() });
|
|
94
|
+
applyBalanceState(pool, ref, inspectRes);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
json(res, inspectRes.ok ? 200 : 400, { ok: inspectRes.ok, ref, balance: inspectRes });
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (provider) {
|
|
102
|
+
const runtime = buildRuntime();
|
|
103
|
+
const pool = runtime.pools?.find((p) => p.provider === provider);
|
|
104
|
+
if (!pool || !Array.isArray(pool.refs) || pool.refs.length === 0) {
|
|
105
|
+
json(res, 404, { ok: false, error: `Provider '${provider}' not found or has no keys` });
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const results = {};
|
|
110
|
+
for (const kRef of pool.refs) {
|
|
111
|
+
const r = await inspectBalanceForRef(ctx, kRef, provider);
|
|
112
|
+
results[kRef] = r;
|
|
113
|
+
if (r.ok && pool.state) {
|
|
114
|
+
if (!pool.state.balances) pool.state.balances = new Map();
|
|
115
|
+
pool.state.balances.set(kRef, { ...r, at: Date.now() });
|
|
116
|
+
applyBalanceState(pool, kRef, r);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
json(res, 200, { ok: true, provider, balances: results });
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
json(res, 400, { ok: false, error: 'ref or provider required' });
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
json(res, 405, { ok: false, error: 'Method Not Allowed' });
|
|
128
|
+
},
|
|
129
|
+
}), 'dsh-key-rotation: balance routes');
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function applyBalanceState(pool, ref, balanceRes) {
|
|
133
|
+
if (!pool || !pool.state) return;
|
|
134
|
+
const isZero = balanceRes.isAvailable === false || (typeof balanceRes.total === 'number' && balanceRes.total <= 0);
|
|
135
|
+
|
|
136
|
+
if (isZero) {
|
|
137
|
+
if (!pool.state.pausedRefs) pool.state.pausedRefs = new Set();
|
|
138
|
+
if (pool.state.pausedRefs instanceof Set) pool.state.pausedRefs.add(ref);
|
|
139
|
+
if (!pool.state.pauseReasons) pool.state.pauseReasons = new Map();
|
|
140
|
+
pool.state.pauseReasons.set(ref, 'EMPTY_BALANCE_PAUSED');
|
|
141
|
+
pushEvent(pool, ref, 'BALANCE_EMPTY', 0);
|
|
142
|
+
} else if (pool.state.pauseReasons?.get?.(ref) === 'EMPTY_BALANCE_PAUSED') {
|
|
143
|
+
if (pool.state.pausedRefs instanceof Set) pool.state.pausedRefs.delete(ref);
|
|
144
|
+
pool.state.pauseReasons.delete(ref);
|
|
145
|
+
pushEvent(pool, ref, 'BALANCE_RESTORED', 0);
|
|
146
|
+
}
|
|
147
|
+
}
|