@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
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/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). */
|
|
@@ -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
|
+
}
|