@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.
@@ -1,218 +1,2 @@
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 './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";
@@ -1,8 +1,9 @@
1
- // lib/persistence.js — atomic snapshot of rotation state across restarts (#287).
2
- // Cooldowns, circuit-breaker entries and quota counters live in memory; a DSH
3
- // reload would otherwise forget cooldowns and stampede recovered keys.
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, c] of innerEntries) {
116
- const num = Number(c);
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
- * Schedule a debounced atomic write. Never throws to the caller.
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 a snapshot. Corrupt/missing file → null (never wipes memory).
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 instanceof Set && pool.pausedRefs.has(ref)) return true;
380
- if (Array.isArray(pool.pausedRefs) && pool.pausedRefs.includes(ref)) return true;
381
- if (pool.state && pool.state.pausedRefs instanceof Set && pool.state.pausedRefs.has(ref)) return true;
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 instanceof Set && pool.revokedRefs.has(ref)) return true;
582
- if (Array.isArray(pool.revokedRefs) && pool.revokedRefs.includes(ref)) return true;
583
- if (pool.state && pool.state.revokedRefs instanceof Set && pool.state.revokedRefs.has(ref)) return true;
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 — quota-remaining persistence per ref.
2
- // ponytail: pure helpers, snapshot() returns shallow copies.
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
+ }