@goodandready/dsh-key-rotation 0.8.72 → 0.8.74
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 +24 -0
- package/lib/balance-inspector.js +9 -1
- package/lib/client.js +18 -7
- package/lib/clone-lifecycle.js +5 -5
- package/lib/crypto-storage.js +25 -0
- package/lib/index.js +17 -4
- package/lib/key-drain.js +76 -41
- package/lib/lifecycle.js +2 -2
- package/lib/ops-config-io.js +144 -47
- package/lib/ops-keys.js +8 -26
- package/lib/ops-quick-action.js +6 -5
- package/lib/ops-telemetry.js +48 -14
- package/lib/ops-test.js +9 -1
- package/lib/pool-builder.js +2 -1
- package/lib/pool.js +14 -7
- package/lib/resolver.js +1 -1
- package/lib/rotate.js +4 -2
- package/lib/selection-trace.js +45 -13
- package/lib/simulator.js +8 -2
- package/lib/traffic-smoother.js +31 -20
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,29 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.8.74 (2026-10-30)
|
|
4
|
+
|
|
5
|
+
### Rotation & Routing Reliability (Block 2 Release)
|
|
6
|
+
- **Error Status Normalization (Issue #545)**: `isSwitchableError` now normalizes string and numeric `code: "429"` / `429` to HTTP 429 when `status` is absent, ensuring default `switchCodes` trigger key rotation.
|
|
7
|
+
- **Quick Action Skip (Issue #540)**: Quick action skip now atomically synchronizes both `targetPool.state.pointer` and `cursor`, ensuring resolver round-robin picks the newly advanced key immediately.
|
|
8
|
+
- **Tiers & Tier-Priority Routing (Issue #541)**: Propagate `p.tiers` to `pool-builder` options, fix `typeof tiers === 'object'` syntax error, and add `tier-priority` strategy to Schemastery and resolver.
|
|
9
|
+
- **Traffic Smoothing Leaky Bucket (Issue #538)**: Implemented Generic Cell Rate Algorithm (GCRA) in `TrafficSmoother` to atomically reserve micro-queue slots ahead of sleep, preventing two concurrent requests from claiming the same time slot.
|
|
10
|
+
- **Simulator Quota & Broken Key Parity (Issue #534)**: `simulateRouting` now clones `brokenUntil`, `tokenUsage`, and `quotaWindows` into simulator pool state, properly filtering out unavailable keys.
|
|
11
|
+
- **Simulator Provider Map Lookup (Issue #536)**: Corrected `runtime.providerToPool` Map resolution in `ops-telemetry` simulator route, returning 404 for unrecognized providers.
|
|
12
|
+
- **Config Import Concurrency (Issue #553)**: Added optimistic revision check via `expectedRevision` in `handleConfigImport`, returning 409 Conflict upon concurrent modification.
|
|
13
|
+
- **Config Import Fail-Closed (Issue #533)**: `handleConfigImport` fails closed with 503 Service Unavailable when `settings.replace` is unavailable, preventing false-positive `{ applied: true }`.
|
|
14
|
+
- **Instance & Context Isolation (Issue #544)**: Introduced `KeyDrainManager`, `SelectionTraceStore`, and `DiagBuffer` classes with per-context lifecycle binding; disposal of one plugin instance cleanly purges only its own draining keys and telemetry without corrupting peer instances.
|
|
15
|
+
|
|
16
|
+
## 0.8.73 (2026-10-30)
|
|
17
|
+
|
|
18
|
+
### Security & Hardening (Block 1 Security Release)
|
|
19
|
+
- **Origin & Bridge Guard**: Reject unauthenticated mutating requests without Origin/Referer (#516).
|
|
20
|
+
- **Key Endpoint Protection**: Forbid plaintext credential reveal via `GET /dsh-key-rotation/key` (403 Forbidden) (#543).
|
|
21
|
+
- **Safe Export Webhook Masking**: Mask real `notifyWebhook` URLs and embedded credentials during masked config exports (#532).
|
|
22
|
+
- **Secure Export Passphrase Transport**: Passphrase for encrypted exports is now transmitted via `POST` JSON body instead of URL query string (#552).
|
|
23
|
+
- **Selection Trace Sanitization**: Redact upstream API tokens and error details before recording steps to selection trace (#537).
|
|
24
|
+
- **Fail-Closed Balance & Probe Inspection**: Prevent sending ciphertext credentials (`enc:v1:...`) upstream if decryption fails (#539).
|
|
25
|
+
- **PBKDF2 Iteration Bounds & Async KDF**: Enforce strict iteration limits (`10000..200000`) and async derivation to eliminate event-loop blocking DoS (#542).
|
|
26
|
+
|
|
3
27
|
## [0.8.72] — 2026-10-23
|
|
4
28
|
|
|
5
29
|
### Security
|
package/lib/balance-inspector.js
CHANGED
|
@@ -169,7 +169,15 @@ export async function inspectBalanceForRef(ctx, ref, provider, { testValue, fetc
|
|
|
169
169
|
let rawValue = testValue || hit?.value;
|
|
170
170
|
if (!rawValue && process.env[ref]) rawValue = process.env[ref];
|
|
171
171
|
if (typeof rawValue === 'string' && rawValue.startsWith('enc:v1:')) {
|
|
172
|
-
|
|
172
|
+
try {
|
|
173
|
+
const dec = decryptSecret(rawValue);
|
|
174
|
+
if (!dec || dec === rawValue || dec.startsWith('enc:v1:')) {
|
|
175
|
+
return { ok: false, error: 'failed to decrypt credential: missing or invalid DSH_KEY_SECRET' };
|
|
176
|
+
}
|
|
177
|
+
rawValue = dec;
|
|
178
|
+
} catch (err) {
|
|
179
|
+
return { ok: false, error: `failed to decrypt credential: ${err.message}` };
|
|
180
|
+
}
|
|
173
181
|
}
|
|
174
182
|
if (!rawValue) return { ok: false, error: 'no credential' };
|
|
175
183
|
return checkProviderBalance(provider, rawValue, { fetchImpl, timeoutMs });
|
package/lib/client.js
CHANGED
|
@@ -1276,13 +1276,24 @@ window.__ModuleLoader__.load({
|
|
|
1276
1276
|
const runExport = async () => {
|
|
1277
1277
|
setExportError('');
|
|
1278
1278
|
try {
|
|
1279
|
-
const
|
|
1280
|
-
|
|
1281
|
-
if (
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1279
|
+
const passphrase = exportPassphrase.trim();
|
|
1280
|
+
let res;
|
|
1281
|
+
if (passphrase) {
|
|
1282
|
+
// Secure POST transmission for encrypted export (#552)
|
|
1283
|
+
res = await fetch('/dsh-key-rotation/export', {
|
|
1284
|
+
method: 'POST',
|
|
1285
|
+
headers: { 'content-type': 'application/json', accept: 'application/json' },
|
|
1286
|
+
body: JSON.stringify({ masked: exportMasked, passphrase }),
|
|
1287
|
+
signal: (typeof AbortSignal !== 'undefined' && typeof AbortSignal.timeout === 'function') ? AbortSignal.timeout(10000) : undefined,
|
|
1288
|
+
});
|
|
1289
|
+
} else {
|
|
1290
|
+
const q = new URLSearchParams();
|
|
1291
|
+
q.set('masked', exportMasked ? 'true' : 'false');
|
|
1292
|
+
res = await fetch(`/dsh-key-rotation/export?${q.toString()}`, {
|
|
1293
|
+
headers: { accept: 'application/json' },
|
|
1294
|
+
signal: (typeof AbortSignal !== 'undefined' && typeof AbortSignal.timeout === 'function') ? AbortSignal.timeout(10000) : undefined,
|
|
1295
|
+
});
|
|
1296
|
+
}
|
|
1286
1297
|
const data = await res.json();
|
|
1287
1298
|
if (!res.ok || !data.ok) throw new Error(data?.error?.message || 'Export failed');
|
|
1288
1299
|
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
|
package/lib/clone-lifecycle.js
CHANGED
|
@@ -148,7 +148,7 @@ export class CloneLifecycleManager {
|
|
|
148
148
|
startDrain(cloneId, provider, onDeregister) {
|
|
149
149
|
if (this.drainingClones.has(cloneId)) return;
|
|
150
150
|
|
|
151
|
-
markKeyDraining(cloneId, provider);
|
|
151
|
+
markKeyDraining(cloneId, provider, this.keyDrainManager);
|
|
152
152
|
const entry = {
|
|
153
153
|
cloneId,
|
|
154
154
|
provider,
|
|
@@ -176,7 +176,7 @@ export class CloneLifecycleManager {
|
|
|
176
176
|
|
|
177
177
|
if (inFlight === 0) {
|
|
178
178
|
this.drainingClones.delete(cloneId);
|
|
179
|
-
cancelKeyDrain(cloneId);
|
|
179
|
+
cancelKeyDrain(cloneId, this.keyDrainManager);
|
|
180
180
|
if (this.logger?.info) {
|
|
181
181
|
this.logger.info(`[dsh-key-rotation] clone ${cloneId} (${entry.provider}) drained cleanly; safe to deregister`);
|
|
182
182
|
}
|
|
@@ -191,11 +191,11 @@ export class CloneLifecycleManager {
|
|
|
191
191
|
|
|
192
192
|
cancelDrain(cloneId) {
|
|
193
193
|
this.drainingClones.delete(cloneId);
|
|
194
|
-
cancelKeyDrain(cloneId);
|
|
194
|
+
cancelKeyDrain(cloneId, this.keyDrainManager);
|
|
195
195
|
}
|
|
196
196
|
|
|
197
197
|
isDraining(cloneId) {
|
|
198
|
-
return this.drainingClones.has(cloneId) || isKeyDraining(cloneId);
|
|
198
|
+
return this.drainingClones.has(cloneId) || isKeyDraining(cloneId, this.keyDrainManager);
|
|
199
199
|
}
|
|
200
200
|
|
|
201
201
|
getDrainingList() {
|
|
@@ -209,7 +209,7 @@ export class CloneLifecycleManager {
|
|
|
209
209
|
|
|
210
210
|
destroy() {
|
|
211
211
|
for (const cloneId of this.drainingClones.keys()) {
|
|
212
|
-
cancelKeyDrain(cloneId);
|
|
212
|
+
cancelKeyDrain(cloneId, this.keyDrainManager);
|
|
213
213
|
}
|
|
214
214
|
this.drainingClones.clear();
|
|
215
215
|
}
|
package/lib/crypto-storage.js
CHANGED
|
@@ -47,3 +47,28 @@ export function decryptSecret(ciphertext, secretKey = process.env.DSH_KEY_SECRET
|
|
|
47
47
|
return ciphertext;
|
|
48
48
|
}
|
|
49
49
|
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Decrypt ciphertext using AES-256-GCM.
|
|
53
|
+
* Fails closed by throwing or returning null if decryption fails or secretKey is missing.
|
|
54
|
+
*/
|
|
55
|
+
export function decryptSecretFailClosed(ciphertext, secretKey = process.env.DSH_KEY_SECRET) {
|
|
56
|
+
if (!ciphertext || typeof ciphertext !== 'string') return ciphertext;
|
|
57
|
+
if (!ciphertext.startsWith('enc:v1:')) return ciphertext; // plaintext is allowed as-is
|
|
58
|
+
if (!secretKey) {
|
|
59
|
+
throw new Error('DSH_KEY_SECRET is required to decrypt credential');
|
|
60
|
+
}
|
|
61
|
+
const parts = ciphertext.slice('enc:v1:'.length).split(':');
|
|
62
|
+
if (parts.length !== 3) {
|
|
63
|
+
throw new Error('Malformed encrypted secret payload');
|
|
64
|
+
}
|
|
65
|
+
const [ivHex, tagHex, dataHex] = parts;
|
|
66
|
+
const key = crypto.createHash('sha256').update(String(secretKey)).digest();
|
|
67
|
+
const iv = Buffer.from(ivHex, 'hex');
|
|
68
|
+
const authTag = Buffer.from(tagHex, 'hex');
|
|
69
|
+
const decipher = crypto.createDecipheriv('aes-256-gcm', key, iv);
|
|
70
|
+
decipher.setAuthTag(authTag);
|
|
71
|
+
let decrypted = decipher.update(dataHex, 'hex', 'utf8');
|
|
72
|
+
decrypted += decipher.final('utf8');
|
|
73
|
+
return decrypted;
|
|
74
|
+
}
|
package/lib/index.js
CHANGED
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import { KeyDrainManager } from './key-drain.js';
|
|
2
|
+
import { SelectionTraceStore } from './selection-trace.js';
|
|
3
|
+
import { DiagBuffer } from './ops-telemetry.js';
|
|
1
4
|
import { createResolver } from './resolver.js';
|
|
2
5
|
import { decryptSecret } from './crypto-storage.js';
|
|
3
6
|
// dsh-key-rotation — per-provider API key rotation for DeepSeek Harness.
|
|
@@ -98,7 +101,7 @@ const SettingsSchema = Schema.object({
|
|
|
98
101
|
rateLimitThreshold: Schema.number().default(0.1),
|
|
99
102
|
proactiveRateLimitGuard: Schema.boolean().default(true),
|
|
100
103
|
selfHealingIntervalMinutes: Schema.number().default(30),
|
|
101
|
-
routingStrategy: Schema.union(['round-robin', 'least-loaded', 'lowest-latency', 'sticky']).default('round-robin'),
|
|
104
|
+
routingStrategy: Schema.union(['round-robin', 'least-loaded', 'lowest-latency', 'sticky', 'tier-priority']).default('round-robin'),
|
|
102
105
|
rpmLimit: Schema.number().default(0),
|
|
103
106
|
tpmLimit: Schema.number().default(0),
|
|
104
107
|
webhookActionToken: Schema.string().role('secret').default(''),
|
|
@@ -148,7 +151,7 @@ const SettingsSchema = Schema.object({
|
|
|
148
151
|
})).default({}),
|
|
149
152
|
cooldownMs: Schema.number(),
|
|
150
153
|
maxCooldownMs: Schema.number(),
|
|
151
|
-
routingStrategy: Schema.union(['round-robin', 'least-loaded', 'lowest-latency', 'sticky']),
|
|
154
|
+
routingStrategy: Schema.union(['round-robin', 'least-loaded', 'lowest-latency', 'sticky', 'tier-priority']),
|
|
152
155
|
proactiveRateLimitGuard: Schema.boolean(),
|
|
153
156
|
rpmLimit: Schema.number(),
|
|
154
157
|
tpmLimit: Schema.number(),
|
|
@@ -196,13 +199,16 @@ export function apply(ctx, config = {}) {
|
|
|
196
199
|
const latencyHistogram = new LatencyHistogram();
|
|
197
200
|
const quotaStore = new QuotaStore();
|
|
198
201
|
const concurrencyTracker = new ConcurrencyTracker();
|
|
202
|
+
const keyDrainManager = new KeyDrainManager();
|
|
203
|
+
const selectionTraceStore = new SelectionTraceStore();
|
|
204
|
+
const diagBuffer = new DiagBuffer();
|
|
199
205
|
|
|
200
206
|
const moduleBreaker = new CircuitBreaker({ threshold: 5, openMs: 30000, halfOpenProbes: 1, now: nowMono });
|
|
201
207
|
const webhookSender = new WebhookSender({ fetchImpl: globalThis.fetch });
|
|
202
208
|
const alertDebouncer = new AlertDebouncer({ sender: webhookSender, debounceMs: 2000 });
|
|
203
209
|
const moduleNotifyQueue = new NotifyQueue({ send: (url, payload) => alertDebouncer.enqueue(url, payload) });
|
|
204
210
|
const trafficSmoother = new TrafficSmoother({ maxQueueWaitMs: 800 });
|
|
205
|
-
const cloneLifecycle = new CloneLifecycleManager({ concurrencyTracker, logger });
|
|
211
|
+
const cloneLifecycle = new CloneLifecycleManager({ concurrencyTracker, logger, keyDrainManager });
|
|
206
212
|
ctx.effect(() => () => { cloneLifecycle.destroy(); }, 'dsh-key-rotation: clone lifecycle cleanup');
|
|
207
213
|
|
|
208
214
|
const sandboxService = createSandboxService({ getRuntime: () => buildRuntime() });
|
|
@@ -214,7 +220,11 @@ export function apply(ctx, config = {}) {
|
|
|
214
220
|
|
|
215
221
|
setupIdleHealEffect(ctx, getConfig, buildRuntime);
|
|
216
222
|
setupProactiveHealthProbeEffect(ctx, getConfig, buildRuntime, ensureSandboxRunner, logger);
|
|
217
|
-
setupKeyDrainEffect(ctx);
|
|
223
|
+
setupKeyDrainEffect(ctx, keyDrainManager);
|
|
224
|
+
ctx.effect(() => () => {
|
|
225
|
+
selectionTraceStore.clear();
|
|
226
|
+
diagBuffer.clear();
|
|
227
|
+
}, 'dsh-key-rotation: telemetry cleanup');
|
|
218
228
|
|
|
219
229
|
const poolState = new Map();
|
|
220
230
|
const { schedulePersist, persistenceSnapshot } = setupPersistence(ctx, {
|
|
@@ -461,6 +471,9 @@ export function apply(ctx, config = {}) {
|
|
|
461
471
|
circuitBreaker: moduleBreaker,
|
|
462
472
|
notifyQueue: moduleNotifyQueue,
|
|
463
473
|
quotaStore,
|
|
474
|
+
keyDrainManager,
|
|
475
|
+
selectionTraceStore,
|
|
476
|
+
diagBuffer,
|
|
464
477
|
});
|
|
465
478
|
|
|
466
479
|
ctx.effect(() => {
|
package/lib/key-drain.js
CHANGED
|
@@ -1,56 +1,91 @@
|
|
|
1
|
-
// lib/key-drain.js — Managed key draining and graceful replacement (#463, #502).
|
|
1
|
+
// lib/key-drain.js — Managed key draining and graceful replacement (#463, #502, #544).
|
|
2
2
|
// Allows draining an old key ref (no new dispatches assigned) while letting
|
|
3
3
|
// in-flight active streams complete cleanly before permanent removal.
|
|
4
|
+
// KeyDrainManager provides instance-level isolation (#544).
|
|
4
5
|
|
|
5
|
-
|
|
6
|
+
export class KeyDrainManager {
|
|
7
|
+
constructor() {
|
|
8
|
+
this.drainingKeys = new Map(); // ref -> { provider, startedAt, inFlightCount }
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
markKeyDraining(ref, provider = '') {
|
|
12
|
+
if (!ref) return false;
|
|
13
|
+
this.drainingKeys.set(ref, {
|
|
14
|
+
ref,
|
|
15
|
+
provider,
|
|
16
|
+
startedAt: Date.now(),
|
|
17
|
+
});
|
|
18
|
+
return true;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
isKeyDraining(ref) {
|
|
22
|
+
if (!ref) return false;
|
|
23
|
+
return this.drainingKeys.has(ref);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
cancelKeyDrain(ref) {
|
|
27
|
+
if (!ref) return false;
|
|
28
|
+
return this.drainingKeys.delete(ref);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
getDrainingStatus(ref, concurrencyTracker) {
|
|
32
|
+
if (!ref || !this.drainingKeys.has(ref)) return null;
|
|
33
|
+
const entry = this.drainingKeys.get(ref);
|
|
34
|
+
const inFlight = concurrencyTracker && typeof concurrencyTracker.getActive === 'function'
|
|
35
|
+
? concurrencyTracker.getActive(ref)
|
|
36
|
+
: (concurrencyTracker && typeof concurrencyTracker.getInFlight === 'function'
|
|
37
|
+
? concurrencyTracker.getInFlight(ref)
|
|
38
|
+
: 0);
|
|
39
|
+
|
|
40
|
+
return {
|
|
41
|
+
ref,
|
|
42
|
+
provider: entry.provider,
|
|
43
|
+
startedAt: entry.startedAt,
|
|
44
|
+
elapsedMs: Date.now() - entry.startedAt,
|
|
45
|
+
inFlight,
|
|
46
|
+
canComplete: inFlight === 0,
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
getAllDrainingKeys(concurrencyTracker) {
|
|
51
|
+
const result = [];
|
|
52
|
+
for (const ref of this.drainingKeys.keys()) {
|
|
53
|
+
result.push(this.getDrainingStatus(ref, concurrencyTracker));
|
|
54
|
+
}
|
|
55
|
+
return result;
|
|
56
|
+
}
|
|
6
57
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
ref,
|
|
11
|
-
provider,
|
|
12
|
-
startedAt: Date.now(),
|
|
13
|
-
});
|
|
14
|
-
return true;
|
|
58
|
+
clearAllDraining() {
|
|
59
|
+
this.drainingKeys.clear();
|
|
60
|
+
}
|
|
15
61
|
}
|
|
16
62
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
63
|
+
const defaultDrainManager = new KeyDrainManager();
|
|
64
|
+
|
|
65
|
+
export function markKeyDraining(ref, provider = '', manager = null) {
|
|
66
|
+
return (manager || defaultDrainManager).markKeyDraining(ref, provider);
|
|
20
67
|
}
|
|
21
68
|
|
|
22
|
-
export function
|
|
23
|
-
|
|
24
|
-
return drainingKeys.delete(ref);
|
|
69
|
+
export function isKeyDraining(ref, manager = null) {
|
|
70
|
+
return (manager || defaultDrainManager).isKeyDraining(ref);
|
|
25
71
|
}
|
|
26
72
|
|
|
27
|
-
export function
|
|
28
|
-
|
|
29
|
-
const entry = drainingKeys.get(ref);
|
|
30
|
-
const inFlight = concurrencyTracker && typeof concurrencyTracker.getActive === 'function'
|
|
31
|
-
? concurrencyTracker.getActive(ref)
|
|
32
|
-
: (concurrencyTracker && typeof concurrencyTracker.getInFlight === 'function'
|
|
33
|
-
? concurrencyTracker.getInFlight(ref)
|
|
34
|
-
: 0);
|
|
35
|
-
|
|
36
|
-
return {
|
|
37
|
-
ref,
|
|
38
|
-
provider: entry.provider,
|
|
39
|
-
startedAt: entry.startedAt,
|
|
40
|
-
elapsedMs: Date.now() - entry.startedAt,
|
|
41
|
-
inFlight,
|
|
42
|
-
canComplete: inFlight === 0,
|
|
43
|
-
};
|
|
73
|
+
export function cancelKeyDrain(ref, manager = null) {
|
|
74
|
+
return (manager || defaultDrainManager).cancelKeyDrain(ref);
|
|
44
75
|
}
|
|
45
76
|
|
|
46
|
-
export function
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
return
|
|
77
|
+
export function getDrainingStatus(ref, concurrencyTracker, manager = null) {
|
|
78
|
+
return (manager || defaultDrainManager).getDrainingStatus(ref, concurrencyTracker);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function getAllDrainingKeys(concurrencyTracker, manager = null) {
|
|
82
|
+
return (manager || defaultDrainManager).getAllDrainingKeys(concurrencyTracker);
|
|
52
83
|
}
|
|
53
84
|
|
|
54
|
-
export function clearAllDraining() {
|
|
55
|
-
|
|
85
|
+
export function clearAllDraining(manager = null) {
|
|
86
|
+
if (manager) {
|
|
87
|
+
manager.clearAllDraining();
|
|
88
|
+
} else {
|
|
89
|
+
defaultDrainManager.clearAllDraining();
|
|
90
|
+
}
|
|
56
91
|
}
|
package/lib/lifecycle.js
CHANGED
|
@@ -251,10 +251,10 @@ export function setupProactiveHealthProbeEffect(ctx, getConfig, buildRuntime, en
|
|
|
251
251
|
}, 'dsh-key-rotation: proactive health probing');
|
|
252
252
|
}
|
|
253
253
|
|
|
254
|
-
export function setupKeyDrainEffect(ctx) {
|
|
254
|
+
export function setupKeyDrainEffect(ctx, keyDrainManager = null) {
|
|
255
255
|
return ctx.effect(() => {
|
|
256
256
|
return () => {
|
|
257
|
-
clearAllDraining();
|
|
257
|
+
clearAllDraining(keyDrainManager);
|
|
258
258
|
};
|
|
259
259
|
}, 'dsh-key-rotation: clear draining keys on teardown');
|
|
260
260
|
}
|
package/lib/ops-config-io.js
CHANGED
|
@@ -12,9 +12,43 @@ import { CONFIG_EXPORT_PATH, CONFIG_IMPORT_PATH, IMPORT_PATH } from './ops-paths
|
|
|
12
12
|
import { keyTail } from './pool.js';
|
|
13
13
|
import { plainConfig } from './index.js';
|
|
14
14
|
|
|
15
|
+
export const MAX_PBKDF2_ITERATIONS = 200000;
|
|
16
|
+
export const MIN_PBKDF2_ITERATIONS = 10000;
|
|
17
|
+
export const DEFAULT_PBKDF2_ITERATIONS = 100000;
|
|
18
|
+
|
|
19
|
+
export function deriveKeyAsync(passphrase, salt, iterations) {
|
|
20
|
+
return new Promise((resolve, reject) => {
|
|
21
|
+
crypto.pbkdf2(passphrase, salt, iterations, 32, 'sha256', (err, derivedKey) => {
|
|
22
|
+
if (err) reject(err);
|
|
23
|
+
else resolve(derivedKey);
|
|
24
|
+
});
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function validateAndExtractCryptoParams(payload) {
|
|
29
|
+
if (!payload || typeof payload !== 'object') {
|
|
30
|
+
throw new Error('Invalid encrypted config payload structure');
|
|
31
|
+
}
|
|
32
|
+
if (!payload.salt || !payload.iv || !payload.tag || !payload.ciphertext) {
|
|
33
|
+
throw new Error('Missing salt, iv, tag or ciphertext in payload');
|
|
34
|
+
}
|
|
35
|
+
const iters = Number(payload.iterations) || DEFAULT_PBKDF2_ITERATIONS;
|
|
36
|
+
if (!Number.isFinite(iters) || iters < MIN_PBKDF2_ITERATIONS || iters > MAX_PBKDF2_ITERATIONS) {
|
|
37
|
+
throw new Error(`PBKDF2 iterations must be between ${MIN_PBKDF2_ITERATIONS} and ${MAX_PBKDF2_ITERATIONS}`);
|
|
38
|
+
}
|
|
39
|
+
const salt = Buffer.from(payload.salt, 'hex');
|
|
40
|
+
const iv = Buffer.from(payload.iv, 'hex');
|
|
41
|
+
const tag = Buffer.from(payload.tag, 'hex');
|
|
42
|
+
const ciphertext = Buffer.from(payload.ciphertext, 'hex');
|
|
43
|
+
if (salt.length !== 16 || iv.length !== 12 || tag.length !== 16) {
|
|
44
|
+
throw new Error('Malformed salt, iv or auth tag length in encrypted payload');
|
|
45
|
+
}
|
|
46
|
+
return { iters, salt, iv, tag, ciphertext };
|
|
47
|
+
}
|
|
48
|
+
|
|
15
49
|
export function encryptConfig(plainText, passphrase) {
|
|
16
50
|
const salt = crypto.randomBytes(16);
|
|
17
|
-
const key = crypto.pbkdf2Sync(passphrase, salt,
|
|
51
|
+
const key = crypto.pbkdf2Sync(passphrase, salt, DEFAULT_PBKDF2_ITERATIONS, 32, 'sha256');
|
|
18
52
|
const iv = crypto.randomBytes(12);
|
|
19
53
|
const cipher = crypto.createCipheriv('aes-256-gcm', key, iv);
|
|
20
54
|
const encrypted = Buffer.concat([cipher.update(plainText, 'utf8'), cipher.final()]);
|
|
@@ -23,7 +57,7 @@ export function encryptConfig(plainText, passphrase) {
|
|
|
23
57
|
encrypted: true,
|
|
24
58
|
algorithm: 'aes-256-gcm',
|
|
25
59
|
kdf: 'pbkdf2',
|
|
26
|
-
iterations:
|
|
60
|
+
iterations: DEFAULT_PBKDF2_ITERATIONS,
|
|
27
61
|
salt: salt.toString('hex'),
|
|
28
62
|
iv: iv.toString('hex'),
|
|
29
63
|
tag: tag.toString('hex'),
|
|
@@ -32,14 +66,17 @@ export function encryptConfig(plainText, passphrase) {
|
|
|
32
66
|
}
|
|
33
67
|
|
|
34
68
|
export function decryptConfig(payload, passphrase) {
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
const
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
69
|
+
const { iters, salt, iv, tag, ciphertext } = validateAndExtractCryptoParams(payload);
|
|
70
|
+
const key = crypto.pbkdf2Sync(passphrase, salt, iters, 32, 'sha256');
|
|
71
|
+
const decipher = crypto.createDecipheriv('aes-256-gcm', key, iv);
|
|
72
|
+
decipher.setAuthTag(tag);
|
|
73
|
+
const decrypted = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
|
|
74
|
+
return decrypted.toString('utf8');
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export async function decryptConfigAsync(payload, passphrase) {
|
|
78
|
+
const { iters, salt, iv, tag, ciphertext } = validateAndExtractCryptoParams(payload);
|
|
79
|
+
const key = await deriveKeyAsync(passphrase, salt, iters);
|
|
43
80
|
const decipher = crypto.createDecipheriv('aes-256-gcm', key, iv);
|
|
44
81
|
decipher.setAuthTag(tag);
|
|
45
82
|
const decrypted = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
|
|
@@ -65,19 +102,30 @@ export function maskConfig(config) {
|
|
|
65
102
|
}
|
|
66
103
|
}
|
|
67
104
|
}
|
|
68
|
-
|
|
105
|
+
function maskWebhookString(val) {
|
|
106
|
+
if (!val || typeof val !== 'string') return val;
|
|
69
107
|
try {
|
|
70
|
-
const u = new URL(
|
|
108
|
+
const u = new URL(val);
|
|
109
|
+
if (u.username) u.username = '***';
|
|
110
|
+
if (u.password) u.password = '***';
|
|
111
|
+
if (u.search) u.search = '?token=***';
|
|
71
112
|
const parts = u.pathname.split('/');
|
|
72
113
|
if (parts.length > 2) {
|
|
73
114
|
parts[parts.length - 1] = '***';
|
|
74
115
|
u.pathname = parts.join('/');
|
|
75
116
|
}
|
|
76
|
-
|
|
117
|
+
return u.toString();
|
|
77
118
|
} catch (_) {
|
|
78
|
-
|
|
119
|
+
return '***';
|
|
79
120
|
}
|
|
80
121
|
}
|
|
122
|
+
|
|
123
|
+
if (clone.webhookUrl) {
|
|
124
|
+
clone.webhookUrl = maskWebhookString(clone.webhookUrl);
|
|
125
|
+
}
|
|
126
|
+
if (clone.notifyWebhook) {
|
|
127
|
+
clone.notifyWebhook = maskWebhookString(clone.notifyWebhook);
|
|
128
|
+
}
|
|
81
129
|
if (clone.webhookActionToken) clone.webhookActionToken = '***';
|
|
82
130
|
return clone;
|
|
83
131
|
}
|
|
@@ -138,13 +186,13 @@ export function diffConfigs(currentConfig, incomingConfig, mode = 'merge') {
|
|
|
138
186
|
}
|
|
139
187
|
|
|
140
188
|
export function registerConfigIoRoutes(ctx, deps) {
|
|
141
|
-
// ── GET /dsh-key-rotation/export ──
|
|
189
|
+
// ── GET & POST /dsh-key-rotation/export (#552: support POST for safe passphrase transmission) ──
|
|
142
190
|
ctx.effect(() => ctx.webServer.register({
|
|
143
191
|
kind: 'exact',
|
|
144
192
|
path: CONFIG_EXPORT_PATH,
|
|
145
193
|
handler: async (req, res) => {
|
|
146
|
-
if (req.method !== 'GET') {
|
|
147
|
-
json(res, 405, { error: { code: 'method', message: 'GET only' } });
|
|
194
|
+
if (req.method !== 'GET' && req.method !== 'POST') {
|
|
195
|
+
json(res, 405, { error: { code: 'method', message: 'GET or POST only' } });
|
|
148
196
|
return;
|
|
149
197
|
}
|
|
150
198
|
if (!isTrustedBridgeRequest(req)) {
|
|
@@ -152,12 +200,31 @@ export function registerConfigIoRoutes(ctx, deps) {
|
|
|
152
200
|
return;
|
|
153
201
|
}
|
|
154
202
|
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
const masked = maskedParam === null ? true : maskedParam !== 'false';
|
|
158
|
-
const passphrase = urlObj.searchParams.get('passphrase') || null;
|
|
203
|
+
let masked = true;
|
|
204
|
+
let passphrase = null;
|
|
159
205
|
|
|
160
|
-
|
|
206
|
+
if (req.method === 'POST') {
|
|
207
|
+
let body = {};
|
|
208
|
+
try {
|
|
209
|
+
body = await readJson(req);
|
|
210
|
+
} catch (_) {
|
|
211
|
+
body = {};
|
|
212
|
+
}
|
|
213
|
+
masked = body.masked !== false;
|
|
214
|
+
passphrase = typeof body.passphrase === 'string' && body.passphrase.trim() ? body.passphrase.trim() : null;
|
|
215
|
+
} else {
|
|
216
|
+
const urlObj = new URL(req.url, 'http://127.0.0.1');
|
|
217
|
+
const maskedParam = urlObj.searchParams.get('masked');
|
|
218
|
+
masked = maskedParam === null ? true : maskedParam !== 'false';
|
|
219
|
+
if (urlObj.searchParams.has('passphrase')) {
|
|
220
|
+
passphrase = urlObj.searchParams.get('passphrase') || null;
|
|
221
|
+
try {
|
|
222
|
+
res.setHeader?.('X-Deprecated-Security-Warning', 'Passphrase in query string is deprecated and will be removed; use POST /dsh-key-rotation/export with body');
|
|
223
|
+
} catch (_) {}
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const settings = typeof ctx?.get === 'function' ? ctx.get('settings') : (ctx?.settings || null);
|
|
161
228
|
let currentConfig = {};
|
|
162
229
|
try {
|
|
163
230
|
if (settings && typeof settings.get === 'function') {
|
|
@@ -176,16 +243,21 @@ export function registerConfigIoRoutes(ctx, deps) {
|
|
|
176
243
|
const exportData = masked ? maskConfig(rawConfig) : rawConfig;
|
|
177
244
|
|
|
178
245
|
if (passphrase) {
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
246
|
+
try {
|
|
247
|
+
const encryptedPayload = await encryptConfig(JSON.stringify(exportData), passphrase);
|
|
248
|
+
json(res, 200, {
|
|
249
|
+
ok: true,
|
|
250
|
+
version: PLUGIN_VERSION,
|
|
251
|
+
exportedAt: new Date().toISOString(),
|
|
252
|
+
masked,
|
|
253
|
+
encrypted: true,
|
|
254
|
+
...encryptedPayload,
|
|
255
|
+
});
|
|
256
|
+
return;
|
|
257
|
+
} catch (err) {
|
|
258
|
+
json(res, 500, { error: { code: 'encryption-failed', message: err.message } });
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
189
261
|
}
|
|
190
262
|
|
|
191
263
|
json(res, 200, {
|
|
@@ -227,7 +299,7 @@ export function registerConfigIoRoutes(ctx, deps) {
|
|
|
227
299
|
return;
|
|
228
300
|
}
|
|
229
301
|
try {
|
|
230
|
-
const decryptedStr =
|
|
302
|
+
const decryptedStr = await decryptConfigAsync(body, passphrase);
|
|
231
303
|
incomingConfig = JSON.parse(decryptedStr);
|
|
232
304
|
} catch (err) {
|
|
233
305
|
json(res, 400, { error: { code: 'decryption-failed', message: `Decryption failed: ${err.message}` } });
|
|
@@ -250,28 +322,54 @@ export function registerConfigIoRoutes(ctx, deps) {
|
|
|
250
322
|
const mode = body.mode === 'replace' ? 'replace' : 'merge';
|
|
251
323
|
const dryRun = Boolean(body.dryRun);
|
|
252
324
|
|
|
253
|
-
const settings = ctx.get('settings');
|
|
325
|
+
const settings = typeof ctx?.get === 'function' ? ctx.get('settings') : (ctx?.settings || null);
|
|
326
|
+
if (!settings || typeof settings.replace !== 'function') {
|
|
327
|
+
// Fail closed when settings service is missing or lacks replace (#533)
|
|
328
|
+
json(res, 503, { error: { code: 'settings-unavailable', message: 'Settings service is not available or does not support writing configuration' } });
|
|
329
|
+
return;
|
|
330
|
+
}
|
|
331
|
+
|
|
254
332
|
let currentConfig = {};
|
|
333
|
+
let currentRevision = null;
|
|
255
334
|
try {
|
|
256
|
-
if (
|
|
257
|
-
currentConfig = settings.get(NS) || {};
|
|
258
|
-
} else if (settings && typeof settings.describe === 'function') {
|
|
335
|
+
if (typeof settings.describe === 'function') {
|
|
259
336
|
const desc = settings.describe({ redactSecrets: false })?.find((c) => c.ns === NS);
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
337
|
+
if (desc) {
|
|
338
|
+
currentConfig = desc.value || {};
|
|
339
|
+
currentRevision = desc.revision ?? null;
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
if (!currentConfig || Object.keys(currentConfig).length === 0) {
|
|
343
|
+
if (typeof settings.get === 'function') {
|
|
344
|
+
currentConfig = settings.get(NS) || {};
|
|
345
|
+
}
|
|
263
346
|
}
|
|
264
347
|
} catch (_) {
|
|
265
348
|
currentConfig = deps.buildRuntime().cfg0 || {};
|
|
266
349
|
}
|
|
267
350
|
const rawCurrent = plainConfig(currentConfig);
|
|
268
351
|
|
|
352
|
+
// Concurrency control: if client provided expectedRevision, reject if stale (#553)
|
|
353
|
+
if (typeof body.expectedRevision === 'number' && currentRevision !== null) {
|
|
354
|
+
if (body.expectedRevision !== currentRevision) {
|
|
355
|
+
json(res, 409, {
|
|
356
|
+
error: {
|
|
357
|
+
code: 'conflict',
|
|
358
|
+
message: `Configuration has been modified concurrently (expected revision ${body.expectedRevision}, current is ${currentRevision})`,
|
|
359
|
+
currentRevision,
|
|
360
|
+
}
|
|
361
|
+
});
|
|
362
|
+
return;
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
269
366
|
const preview = diffConfigs(rawCurrent, incomingConfig, mode);
|
|
270
367
|
|
|
271
368
|
if (dryRun) {
|
|
272
369
|
json(res, 200, {
|
|
273
370
|
ok: true,
|
|
274
371
|
dryRun: true,
|
|
372
|
+
revision: currentRevision,
|
|
275
373
|
preview,
|
|
276
374
|
});
|
|
277
375
|
return;
|
|
@@ -307,19 +405,18 @@ export function registerConfigIoRoutes(ctx, deps) {
|
|
|
307
405
|
mergedConfig.providers = curProviders;
|
|
308
406
|
}
|
|
309
407
|
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
return;
|
|
316
|
-
}
|
|
408
|
+
try {
|
|
409
|
+
await settings.replace(NS, mergedConfig, currentRevision ?? undefined);
|
|
410
|
+
} catch (err) {
|
|
411
|
+
json(res, 500, { error: { code: 'settings-write-failed', message: err.message } });
|
|
412
|
+
return;
|
|
317
413
|
}
|
|
318
414
|
|
|
319
415
|
json(res, 200, {
|
|
320
416
|
ok: true,
|
|
321
417
|
applied: true,
|
|
322
418
|
mode,
|
|
419
|
+
revision: typeof currentRevision === 'number' ? currentRevision + 1 : undefined,
|
|
323
420
|
preview,
|
|
324
421
|
});
|
|
325
422
|
};
|
package/lib/ops-keys.js
CHANGED
|
@@ -165,17 +165,18 @@ ctx.effect(() => ctx.webServer.register({
|
|
|
165
165
|
return;
|
|
166
166
|
}
|
|
167
167
|
const concurrency = deps.concurrencyTracker;
|
|
168
|
+
const keyDrainManager = deps.keyDrainManager;
|
|
168
169
|
|
|
169
170
|
if (req.method === 'GET') {
|
|
170
171
|
const url = new URL(req.url ?? DRAIN_PATH, 'http://localhost');
|
|
171
172
|
const ref = url.searchParams.get('ref');
|
|
172
173
|
if (ref) {
|
|
173
|
-
const status = getDrainingStatus(ref, concurrency);
|
|
174
|
+
const status = getDrainingStatus(ref, concurrency, keyDrainManager);
|
|
174
175
|
if (!status) { json(res, 404, { error: { code: 'not-found', message: `Key ${ref} is not currently draining` } }); return; }
|
|
175
176
|
json(res, 200, { ok: true, draining: status });
|
|
176
177
|
return;
|
|
177
178
|
}
|
|
178
|
-
json(res, 200, { ok: true, draining: getAllDrainingKeys(concurrency) });
|
|
179
|
+
json(res, 200, { ok: true, draining: getAllDrainingKeys(concurrency, keyDrainManager) });
|
|
179
180
|
return;
|
|
180
181
|
}
|
|
181
182
|
|
|
@@ -187,8 +188,8 @@ ctx.effect(() => ctx.webServer.register({
|
|
|
187
188
|
}
|
|
188
189
|
const ref = body?.ref;
|
|
189
190
|
if (!ref) { json(res, 400, { error: { code: 'bad-request', message: 'Missing ref' } }); return; }
|
|
190
|
-
markKeyDraining(ref, body?.provider || '');
|
|
191
|
-
json(res, 200, { ok: true, status: getDrainingStatus(ref, concurrency) });
|
|
191
|
+
markKeyDraining(ref, body?.provider || '', keyDrainManager);
|
|
192
|
+
json(res, 200, { ok: true, status: getDrainingStatus(ref, concurrency, keyDrainManager) });
|
|
192
193
|
return;
|
|
193
194
|
}
|
|
194
195
|
|
|
@@ -196,7 +197,7 @@ ctx.effect(() => ctx.webServer.register({
|
|
|
196
197
|
const url = new URL(req.url ?? DRAIN_PATH, 'http://localhost');
|
|
197
198
|
const ref = url.searchParams.get('ref');
|
|
198
199
|
if (!ref) { json(res, 400, { error: { code: 'bad-request', message: 'Missing ref query parameter' } }); return; }
|
|
199
|
-
const cancelled = cancelKeyDrain(ref);
|
|
200
|
+
const cancelled = cancelKeyDrain(ref, keyDrainManager);
|
|
200
201
|
json(res, 200, { ok: true, cancelled });
|
|
201
202
|
return;
|
|
202
203
|
}
|
|
@@ -232,27 +233,8 @@ ctx.effect(() => ctx.webServer.register({
|
|
|
232
233
|
}
|
|
233
234
|
const credentialsService = ctx.get('credentials');
|
|
234
235
|
if (req.method === 'GET') {
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
if (!isValidRef(ref)) {
|
|
238
|
-
json(res, 400, { error: { code: 'bad-ref', message: 'dsh-key-rotation: ref must be an environment variable name' } });
|
|
239
|
-
return;
|
|
240
|
-
}
|
|
241
|
-
try {
|
|
242
|
-
let hit = await (credentialsService?.__dshKeyRotationOriginalResolve ?? credentialsService?.resolve)?.call(credentialsService, ref);
|
|
243
|
-
let raw = hit?.value;
|
|
244
|
-
if (!raw) {
|
|
245
|
-
raw = envValue(ref);
|
|
246
|
-
}
|
|
247
|
-
if (!raw) {
|
|
248
|
-
json(res, 404, { error: { code: 'not-found', message: `dsh-key-rotation: secret for "${ref}" not found` } });
|
|
249
|
-
return;
|
|
250
|
-
}
|
|
251
|
-
const plain = decryptSecret(raw);
|
|
252
|
-
json(res, 200, { ref, plaintext: plain, tail: keyTail(plain) });
|
|
253
|
-
} catch (err) {
|
|
254
|
-
json(res, 500, { error: { code: 'reveal-failed', message: String(err?.message ?? err) } });
|
|
255
|
-
}
|
|
236
|
+
// Disallow plaintext key reveal without authorized session (#543)
|
|
237
|
+
json(res, 403, { error: { code: 'forbidden', message: 'dsh-key-rotation: plaintext key extraction via GET is forbidden' } });
|
|
256
238
|
return;
|
|
257
239
|
}
|
|
258
240
|
if (!credentialsService || typeof credentialsService.set !== 'function') {
|
package/lib/ops-quick-action.js
CHANGED
|
@@ -66,10 +66,11 @@ export function registerQuickActionRoutes(ctx, deps) {
|
|
|
66
66
|
json(res, 400, { error: { code: 'no-keys', message: 'Pool has no keys to rotate' } });
|
|
67
67
|
return;
|
|
68
68
|
}
|
|
69
|
-
const
|
|
70
|
-
const
|
|
71
|
-
targetPool.state.
|
|
72
|
-
|
|
69
|
+
const currentPointer = targetPool.state.pointer ?? targetPool.state.cursor ?? 0;
|
|
70
|
+
const nextPointer = (currentPointer + 1) % total;
|
|
71
|
+
targetPool.state.pointer = nextPointer;
|
|
72
|
+
targetPool.state.cursor = nextPointer;
|
|
73
|
+
const nextRef = targetPool.refs[nextPointer];
|
|
73
74
|
targetPool.state.lastUsed = nextRef;
|
|
74
75
|
|
|
75
76
|
if (typeof pushEvent === 'function') {
|
|
@@ -80,7 +81,7 @@ export function registerQuickActionRoutes(ctx, deps) {
|
|
|
80
81
|
ok: true,
|
|
81
82
|
action: 'skip',
|
|
82
83
|
provider,
|
|
83
|
-
cursor:
|
|
84
|
+
cursor: nextPointer,
|
|
84
85
|
activeRef: nextRef,
|
|
85
86
|
activeTail: keyTail(nextRef),
|
|
86
87
|
});
|
package/lib/ops-telemetry.js
CHANGED
|
@@ -30,20 +30,40 @@ import { getPromptCacheSummary } from './prompt-cache.js';
|
|
|
30
30
|
*/
|
|
31
31
|
|
|
32
32
|
const DIAG_BUFFER_LIMIT = 200;
|
|
33
|
-
const diagBuffer = [];
|
|
34
33
|
|
|
35
|
-
export
|
|
36
|
-
|
|
37
|
-
|
|
34
|
+
export class DiagBuffer {
|
|
35
|
+
constructor(limit = DIAG_BUFFER_LIMIT) {
|
|
36
|
+
this.limit = limit;
|
|
37
|
+
this.buffer = [];
|
|
38
38
|
}
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
39
|
+
|
|
40
|
+
push(entry) {
|
|
41
|
+
if (this.buffer.length >= this.limit) {
|
|
42
|
+
this.buffer.shift();
|
|
43
|
+
}
|
|
44
|
+
this.buffer.push({
|
|
45
|
+
timestamp: Date.now(),
|
|
46
|
+
...entry,
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
get() {
|
|
51
|
+
return this.buffer.slice();
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
clear() {
|
|
55
|
+
this.buffer.length = 0;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const defaultDiagBuffer = new DiagBuffer();
|
|
60
|
+
|
|
61
|
+
export function pushDiag(entry, buffer = null) {
|
|
62
|
+
(buffer || defaultDiagBuffer).push(entry);
|
|
43
63
|
}
|
|
44
64
|
|
|
45
|
-
export function getDiagBuffer() {
|
|
46
|
-
return
|
|
65
|
+
export function getDiagBuffer(buffer = null) {
|
|
66
|
+
return (buffer || defaultDiagBuffer).get();
|
|
47
67
|
}
|
|
48
68
|
|
|
49
69
|
export function registerTelemetryRoutes(ctx, deps) {
|
|
@@ -185,11 +205,25 @@ ctx.effect(() => ctx.webServer.register({
|
|
|
185
205
|
}
|
|
186
206
|
|
|
187
207
|
const runtime = buildRuntime();
|
|
188
|
-
const provider = body?.provider
|
|
189
|
-
|
|
208
|
+
const provider = body?.provider?.trim();
|
|
209
|
+
if (!provider) {
|
|
210
|
+
json(res, 400, { error: { code: 'bad-request', message: 'provider is required for simulation' } });
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
let pool = null;
|
|
215
|
+
if (runtime.providerToPool instanceof Map) {
|
|
216
|
+
pool = runtime.providerToPool.get(provider);
|
|
217
|
+
} else if (runtime.providerToPool && typeof runtime.providerToPool === 'object') {
|
|
218
|
+
pool = runtime.providerToPool[provider];
|
|
219
|
+
}
|
|
220
|
+
if (!pool) {
|
|
221
|
+
const pools = runtime.pools ?? [];
|
|
222
|
+
pool = pools.find(p => p.provider === provider || p.base === provider);
|
|
223
|
+
}
|
|
190
224
|
|
|
191
225
|
if (!pool) {
|
|
192
|
-
json(res, 404, { error: { code: 'not-found', message: `Provider '${provider}' not found` } });
|
|
226
|
+
json(res, 404, { error: { code: 'not-found', message: `Provider '${provider}' not found in configured pools` } });
|
|
193
227
|
return;
|
|
194
228
|
}
|
|
195
229
|
|
|
@@ -234,7 +268,7 @@ ctx.effect(() => ctx.webServer.register({
|
|
|
234
268
|
json(res, 200, { ok: true, count: diagBuffer.length });
|
|
235
269
|
return;
|
|
236
270
|
}
|
|
237
|
-
json(res, 200, { diag: getDiagBuffer() });
|
|
271
|
+
json(res, 200, { diag: deps.diagBuffer ? deps.diagBuffer.get() : getDiagBuffer() });
|
|
238
272
|
},
|
|
239
273
|
}), 'dsh-key-rotation: diag route');
|
|
240
274
|
|
package/lib/ops-test.js
CHANGED
|
@@ -171,7 +171,15 @@ ctx.effect(() => ctx.webServer.register({
|
|
|
171
171
|
const ev = envValue(ref);
|
|
172
172
|
if (ev !== undefined) rawValue = ev;
|
|
173
173
|
}
|
|
174
|
-
|
|
174
|
+
if (typeof rawValue === 'string' && rawValue.startsWith('enc:v1:')) {
|
|
175
|
+
const dec = decryptSecret(rawValue);
|
|
176
|
+
if (!dec || dec === rawValue || dec.startsWith('enc:v1:')) {
|
|
177
|
+
results.push({ provider, ref, keyTail: '', ok: false, code: 'decryption-failed', message: 'failed to decrypt credential', latencyMs: 0 });
|
|
178
|
+
continue;
|
|
179
|
+
}
|
|
180
|
+
rawValue = dec;
|
|
181
|
+
}
|
|
182
|
+
let effectiveValue = rawValue;
|
|
175
183
|
const tail = effectiveValue ? keyTail(effectiveValue) : '';
|
|
176
184
|
if (!effectiveValue) {
|
|
177
185
|
results.push({ provider, ref, keyTail: tail, ok: false, code: 'no-credential', latencyMs: 0 });
|
package/lib/pool-builder.js
CHANGED
|
@@ -89,7 +89,7 @@ export function buildPoolItem({
|
|
|
89
89
|
|
|
90
90
|
const tiersMap = {};
|
|
91
91
|
for (let i = 0; i < refs.length; i++) {
|
|
92
|
-
const val = Array.isArray(tiers) ? tiers[indexed[i].index] : (tiers && typeof tiers ===
|
|
92
|
+
const val = Array.isArray(tiers) ? tiers[indexed[i].index] : (tiers && typeof tiers === 'object' ? tiers[refs[i]] : undefined);
|
|
93
93
|
tiersMap[refs[i]] = Number.isFinite(val) && val >= 1 ? Math.floor(val) : 1;
|
|
94
94
|
}
|
|
95
95
|
const weightsMap = {};
|
|
@@ -182,6 +182,7 @@ export function buildPools({ cfg = {}, poolState = new Map(), defaultState = ini
|
|
|
182
182
|
provider: p.provider,
|
|
183
183
|
model: null,
|
|
184
184
|
labels: p.labels,
|
|
185
|
+
tiers: p.tiers,
|
|
185
186
|
pinnedRef: p.pinnedRef ?? p.useKeyRef,
|
|
186
187
|
trafficSmoothingBurst: p.trafficSmoothingBurst ?? cfg.trafficSmoothingBurst,
|
|
187
188
|
maxQueueWaitMs: p.maxQueueWaitMs ?? cfg.maxQueueWaitMs,
|
package/lib/pool.js
CHANGED
|
@@ -63,11 +63,13 @@ export function isTrustedBridgeRequest(req) {
|
|
|
63
63
|
if (!(isLoopbackAddress(hostName) || hostName === 'localhost')) return false;
|
|
64
64
|
|
|
65
65
|
const origin = req?.headers?.origin;
|
|
66
|
+
const referer = req?.headers?.referer;
|
|
67
|
+
|
|
66
68
|
if (origin !== undefined) {
|
|
67
69
|
if (!origin) return false; // Explicit empty string or null-like falsy
|
|
68
70
|
try {
|
|
69
71
|
const originUrl = new URL(origin);
|
|
70
|
-
// Official Desktop build shell origin
|
|
72
|
+
// Official Desktop build shell origin (#22, #465, #516)
|
|
71
73
|
if (originUrl.protocol === 'dsh-app:' && (originUrl.host === 'app' || originUrl.hostname === 'app' || originUrl.host === 'localhost')) {
|
|
72
74
|
return true;
|
|
73
75
|
}
|
|
@@ -87,12 +89,11 @@ export function isTrustedBridgeRequest(req) {
|
|
|
87
89
|
return true;
|
|
88
90
|
}
|
|
89
91
|
|
|
90
|
-
// Mutating requests without Origin: allow
|
|
92
|
+
// Mutating requests without Origin: allow authentic Desktop shell requests (#516, #465) or valid Referer.
|
|
91
93
|
if (isDesktopShellRequest(req)) {
|
|
92
94
|
return true;
|
|
93
95
|
}
|
|
94
96
|
|
|
95
|
-
const referer = req?.headers?.referer;
|
|
96
97
|
if (referer) {
|
|
97
98
|
try {
|
|
98
99
|
const refUrl = new URL(referer);
|
|
@@ -467,14 +468,20 @@ export function isSwitchableError(failureOrPayload, switchCodes = new Set(DEFAUL
|
|
|
467
468
|
// and DEFAULT_SWITCH_CODES is exported as an array); normalize before .has().
|
|
468
469
|
if (!(switchCodes instanceof Set)) switchCodes = new Set(Array.isArray(switchCodes) ? switchCodes : DEFAULT_SWITCH_CODES);
|
|
469
470
|
const failure = failureOrPayload.failure ?? failureOrPayload;
|
|
470
|
-
|
|
471
|
-
let
|
|
471
|
+
let status = Number(failure.status ?? failure.statusCode ?? failure.httpStatus ?? 0);
|
|
472
|
+
let rawCode = String(failure.code ?? failure.reason ?? '').trim();
|
|
473
|
+
let code = rawCode.toUpperCase();
|
|
472
474
|
if (code && SOCKET_TRANSPORT_CODES.has(code)) code = 'TRANSPORT';
|
|
473
475
|
const message = String(failure.message ?? failureOrPayload.message ?? '');
|
|
474
476
|
|
|
475
|
-
//
|
|
477
|
+
// Treat numeric failure.code (e.g. code: "429") as HTTP status if status was not set (#545)
|
|
478
|
+
if (!status && /^\d{3}$/.test(rawCode)) {
|
|
479
|
+
status = Number(rawCode);
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
// 1. Direct HTTP status codes (#267: also 408/425 transient, #545: numeric status/code mapped to semantic switch codes)
|
|
476
483
|
if ((status === 429 || status === 408 || status === 425) && (switchCodes.has('RATE_LIMIT') || switchCodes.has('QUOTA') || switchCodes.has('429') || switchCodes.has('TIMEOUT') || switchCodes.has(String(status)))) return true;
|
|
477
|
-
if ((status === 401 || status === 403) && (switchCodes.has('AUTH') || switchCodes.has('401'))) return true;
|
|
484
|
+
if ((status === 401 || status === 403) && (switchCodes.has('AUTH') || switchCodes.has('401') || switchCodes.has('403') || switchCodes.has(String(status)))) return true;
|
|
478
485
|
if ((status >= 500 && status <= 504) && (switchCodes.has('SERVER') || switchCodes.has(String(status)))) return true;
|
|
479
486
|
|
|
480
487
|
// 2. Standard gRPC / cloud error codes
|
package/lib/resolver.js
CHANGED
|
@@ -121,7 +121,7 @@ export function createResolver({
|
|
|
121
121
|
const strategy = pool.routingStrategy ?? runtime.routingStrategy ?? 'round-robin';
|
|
122
122
|
const sessionId = resolveOpts?.sessionId ?? resolveOpts?.session ?? resolveOpts?.metadata?.sessionId ?? currentSession?.();
|
|
123
123
|
let list = pool.weightedRefs ?? pool.refs;
|
|
124
|
-
if (strategy === 'lowest-latency' || strategy === 'least-loaded' || strategy === 'sticky') {
|
|
124
|
+
if (strategy === 'lowest-latency' || strategy === 'least-loaded' || strategy === 'sticky' || strategy === 'tier-priority') {
|
|
125
125
|
list = sortAttemptList(list, strategy, {
|
|
126
126
|
latencyHistogram,
|
|
127
127
|
concurrencyTracker,
|
package/lib/rotate.js
CHANGED
|
@@ -80,6 +80,8 @@ export function createRotate(deps) {
|
|
|
80
80
|
logger,
|
|
81
81
|
schedulePersist,
|
|
82
82
|
trafficSmoother,
|
|
83
|
+
keyDrainManager,
|
|
84
|
+
selectionTraceStore,
|
|
83
85
|
now = nowMono,
|
|
84
86
|
} = deps;
|
|
85
87
|
|
|
@@ -102,7 +104,7 @@ export function createRotate(deps) {
|
|
|
102
104
|
const trace = options.__selectionTrace || createSelectionTrace(options.requestId, {
|
|
103
105
|
provider: options.provider,
|
|
104
106
|
model: options.model,
|
|
105
|
-
});
|
|
107
|
+
}, selectionTraceStore);
|
|
106
108
|
const isTraceOwner = !options.__selectionTrace;
|
|
107
109
|
const { switchCodes, cooldownMs, maxCooldownMs, switchNotify, rateLimitThreshold } = runtime0;
|
|
108
110
|
let lastFailure = null;
|
|
@@ -114,7 +116,7 @@ export function createRotate(deps) {
|
|
|
114
116
|
const wallNow = Date.now();
|
|
115
117
|
const quotaManaged = hasModelQuotaConfig(pool);
|
|
116
118
|
let attemptList = (pool.weightedRefs ?? pool.refs).slice();
|
|
117
|
-
attemptList = attemptList.filter((r) => !isKeyPaused(pool, r) && !isKeyRevoked(pool, r) && !isKeyBroken(pool, r, wallNow) && !isKeyDraining(r));
|
|
119
|
+
attemptList = attemptList.filter((r) => !isKeyPaused(pool, r) && !isKeyRevoked(pool, r) && !isKeyBroken(pool, r, wallNow) && !isKeyDraining(r, keyDrainManager));
|
|
118
120
|
// Issue #500: filter candidate keys by confirmed model capabilities
|
|
119
121
|
if (options.model && pool.state.modelCapabilities) {
|
|
120
122
|
attemptList = attemptList.filter((ref) => {
|
package/lib/selection-trace.js
CHANGED
|
@@ -1,13 +1,36 @@
|
|
|
1
|
-
// lib/selection-trace.js — Request-scoped key selection explanation and attempt history (#461).
|
|
1
|
+
// lib/selection-trace.js — Request-scoped key selection explanation and attempt history (#461, #544).
|
|
2
2
|
// Provides auditable reasoning for key skips, selection, failovers, and cascade with strict secret redaction.
|
|
3
3
|
|
|
4
4
|
import { keyTail } from './pool.js';
|
|
5
5
|
|
|
6
6
|
const TRACE_BUFFER_LIMIT = 100;
|
|
7
|
-
|
|
7
|
+
|
|
8
|
+
export class SelectionTraceStore {
|
|
9
|
+
constructor(limit = TRACE_BUFFER_LIMIT) {
|
|
10
|
+
this.limit = limit;
|
|
11
|
+
this.traces = [];
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
push(summary) {
|
|
15
|
+
if (this.traces.length >= this.limit) {
|
|
16
|
+
this.traces.shift();
|
|
17
|
+
}
|
|
18
|
+
this.traces.push(summary);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
get(limit = 50) {
|
|
22
|
+
return this.traces.slice(-Math.max(1, Math.min(this.limit, limit)));
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
clear() {
|
|
26
|
+
this.traces.length = 0;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const defaultSelectionTraceStore = new SelectionTraceStore();
|
|
8
31
|
|
|
9
32
|
/**
|
|
10
|
-
* Redacts any sensitive tokens from string / object representation.
|
|
33
|
+
* Redacts any sensitive tokens from string / object representation (#537).
|
|
11
34
|
*/
|
|
12
35
|
export function redactSecret(val) {
|
|
13
36
|
if (typeof val !== 'string') return val;
|
|
@@ -19,10 +42,22 @@ export function redactSecret(val) {
|
|
|
19
42
|
return val;
|
|
20
43
|
}
|
|
21
44
|
|
|
22
|
-
|
|
45
|
+
/**
|
|
46
|
+
* Aggressively sanitizes arbitrary error/details strings to prevent token/secret leaks in traces (#537).
|
|
47
|
+
*/
|
|
48
|
+
export function sanitizeTraceDetails(str) {
|
|
49
|
+
if (typeof str !== 'string') return '';
|
|
50
|
+
return str
|
|
51
|
+
.replace(/(?:sk-|key-|Bearer\s+|ghp_|token[=:\s]+|api[_-]?key[=:\s]+)[a-zA-Z0-9_\-\.]{8,}/gi, '[REDACTED_SECRET]')
|
|
52
|
+
.replace(/https?:\/\/[^\s/$.?#].[^\s]*[?&](?:token|key|api_key|secret|password)=[^\s&]+/gi, '[REDACTED_URL]')
|
|
53
|
+
.slice(0, 300);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function createSelectionTrace(requestId, options = {}, store = null) {
|
|
23
57
|
const reqId = requestId || `req-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
|
|
24
58
|
const startTime = Date.now();
|
|
25
59
|
const events = [];
|
|
60
|
+
const targetStore = store || defaultSelectionTraceStore;
|
|
26
61
|
|
|
27
62
|
return {
|
|
28
63
|
requestId: reqId,
|
|
@@ -43,7 +78,7 @@ export function createSelectionTrace(requestId, options = {}) {
|
|
|
43
78
|
ref: redactSecret(ref),
|
|
44
79
|
decision,
|
|
45
80
|
reasonCode,
|
|
46
|
-
details:
|
|
81
|
+
details: sanitizeTraceDetails(details),
|
|
47
82
|
});
|
|
48
83
|
},
|
|
49
84
|
|
|
@@ -62,19 +97,16 @@ export function createSelectionTrace(requestId, options = {}) {
|
|
|
62
97
|
steps: events,
|
|
63
98
|
};
|
|
64
99
|
|
|
65
|
-
|
|
66
|
-
traces.shift();
|
|
67
|
-
}
|
|
68
|
-
traces.push(summary);
|
|
100
|
+
targetStore.push(summary);
|
|
69
101
|
return summary;
|
|
70
102
|
}
|
|
71
103
|
};
|
|
72
104
|
}
|
|
73
105
|
|
|
74
|
-
export function getSelectionTraces(limit = 50) {
|
|
75
|
-
return
|
|
106
|
+
export function getSelectionTraces(limit = 50, store = null) {
|
|
107
|
+
return (store || defaultSelectionTraceStore).get(limit);
|
|
76
108
|
}
|
|
77
109
|
|
|
78
|
-
export function clearSelectionTraces() {
|
|
79
|
-
|
|
110
|
+
export function clearSelectionTraces(store = null) {
|
|
111
|
+
(store || defaultSelectionTraceStore).clear();
|
|
80
112
|
}
|
package/lib/simulator.js
CHANGED
|
@@ -37,18 +37,24 @@ export function simulateRouting(params = {}) {
|
|
|
37
37
|
throw new Error('simulateRouting: valid pool with refs is required');
|
|
38
38
|
}
|
|
39
39
|
|
|
40
|
-
// Clone pool state into isolated mock state so real runtime is never touched
|
|
40
|
+
// Clone pool state into isolated mock state so real runtime is never touched (#534)
|
|
41
41
|
const mockFailedUntil = new Map(pool.state?.failedUntil || []);
|
|
42
42
|
const mockFailCounts = new Map(pool.state?.failCounts || []);
|
|
43
43
|
const mockRevokedRefs = new Set(pool.state?.revokedRefs || []);
|
|
44
|
+
const mockBrokenUntil = new Map(pool.state?.brokenUntil || []);
|
|
45
|
+
const mockTokenUsage = pool.state?.tokenUsage ? new Map(pool.state.tokenUsage) : new Map();
|
|
46
|
+
const mockQuotaWindows = pool.state?.quotaWindows ? new Map(pool.state.quotaWindows) : new Map();
|
|
44
47
|
|
|
45
48
|
const simPool = {
|
|
46
49
|
...pool,
|
|
47
50
|
state: {
|
|
51
|
+
...pool.state,
|
|
48
52
|
failedUntil: mockFailedUntil,
|
|
49
53
|
failCounts: mockFailCounts,
|
|
50
54
|
revokedRefs: mockRevokedRefs,
|
|
51
|
-
brokenUntil:
|
|
55
|
+
brokenUntil: mockBrokenUntil,
|
|
56
|
+
tokenUsage: mockTokenUsage,
|
|
57
|
+
quotaWindows: mockQuotaWindows,
|
|
52
58
|
},
|
|
53
59
|
};
|
|
54
60
|
|
package/lib/traffic-smoother.js
CHANGED
|
@@ -1,21 +1,26 @@
|
|
|
1
|
-
// lib/traffic-smoother.js — leaky bucket micro-queue traffic smoother (#496).
|
|
1
|
+
// lib/traffic-smoother.js — GCRA leaky bucket micro-queue traffic smoother (#496, #538).
|
|
2
2
|
// Absorbs momentary request bursts without tripping upstream per-second/per-minute rate limits.
|
|
3
|
+
// Uses Generic Cell Rate Algorithm to ensure atomic slot reservation and burst compliance.
|
|
3
4
|
|
|
4
5
|
export class TrafficSmoother {
|
|
5
6
|
constructor({ maxQueueWaitMs = 800, now = Date.now, sleep = null } = {}) {
|
|
6
7
|
this.maxQueueWaitMs = maxQueueWaitMs;
|
|
7
8
|
this.now = now;
|
|
8
9
|
this.sleep = sleep || ((ms, signal) => new Promise((resolve, reject) => {
|
|
9
|
-
if (signal?.aborted)
|
|
10
|
+
if (signal?.aborted) {
|
|
11
|
+
const err = (signal.reason instanceof Error) ? signal.reason : Object.assign(new Error('This operation was aborted'), { name: 'AbortError' });
|
|
12
|
+
return reject(err);
|
|
13
|
+
}
|
|
10
14
|
const timer = setTimeout(resolve, ms);
|
|
11
15
|
if (signal) {
|
|
12
16
|
signal.addEventListener('abort', () => {
|
|
13
17
|
clearTimeout(timer);
|
|
14
|
-
|
|
18
|
+
const err = (signal.reason instanceof Error) ? signal.reason : Object.assign(new Error('This operation was aborted'), { name: 'AbortError' });
|
|
19
|
+
reject(err);
|
|
15
20
|
}, { once: true });
|
|
16
21
|
}
|
|
17
22
|
}));
|
|
18
|
-
// Map<ref, {
|
|
23
|
+
// Map<ref, { virtualTime: number }>
|
|
19
24
|
this._buckets = new Map();
|
|
20
25
|
}
|
|
21
26
|
|
|
@@ -45,35 +50,41 @@ export class TrafficSmoother {
|
|
|
45
50
|
|
|
46
51
|
let bucket = this._buckets.get(ref);
|
|
47
52
|
if (!bucket) {
|
|
48
|
-
bucket = {
|
|
53
|
+
bucket = { virtualTime: curNow - capacity * intervalMs };
|
|
49
54
|
this._buckets.set(ref, bucket);
|
|
50
55
|
}
|
|
51
56
|
|
|
52
|
-
//
|
|
53
|
-
const
|
|
54
|
-
const
|
|
55
|
-
|
|
56
|
-
bucket.lastUpdate = curNow;
|
|
57
|
+
// GCRA: virtualTime cannot fall behind (curNow - capacity * intervalMs)
|
|
58
|
+
const minVirtualTime = curNow - capacity * intervalMs;
|
|
59
|
+
const vTime = Math.max(minVirtualTime, bucket.virtualTime);
|
|
60
|
+
const targetSlot = vTime + intervalMs;
|
|
57
61
|
|
|
58
|
-
if (
|
|
59
|
-
|
|
60
|
-
bucket.
|
|
62
|
+
if (targetSlot <= curNow) {
|
|
63
|
+
// Immediate acquisition within available burst capacity
|
|
64
|
+
bucket.virtualTime = targetSlot;
|
|
61
65
|
return { allowed: true, delayed: false, delayMs: 0, overflow: false };
|
|
62
66
|
}
|
|
63
67
|
|
|
64
|
-
//
|
|
65
|
-
const
|
|
66
|
-
const delayMs = Math.max(0, targetSlot - curNow);
|
|
68
|
+
// Queue delay required
|
|
69
|
+
const delayMs = targetSlot - curNow;
|
|
67
70
|
|
|
68
71
|
if (delayMs > maxWait) {
|
|
69
72
|
// Exceeds max micro-queue wait: signal overflow so caller rotates to next candidate
|
|
70
73
|
return { allowed: false, delayed: false, delayMs, overflow: true };
|
|
71
74
|
}
|
|
72
75
|
|
|
73
|
-
|
|
76
|
+
// Atomically reserve the slot ahead of sleeping (#538)
|
|
77
|
+
bucket.virtualTime = targetSlot;
|
|
74
78
|
|
|
75
|
-
|
|
76
|
-
|
|
79
|
+
try {
|
|
80
|
+
await this.sleep(delayMs, signal);
|
|
81
|
+
} catch (err) {
|
|
82
|
+
// Rollback reservation if this was the last queued request
|
|
83
|
+
if (bucket.virtualTime === targetSlot) {
|
|
84
|
+
bucket.virtualTime = Math.max(minVirtualTime, targetSlot - intervalMs);
|
|
85
|
+
}
|
|
86
|
+
throw err;
|
|
87
|
+
}
|
|
77
88
|
|
|
78
89
|
return { allowed: true, delayed: true, delayMs, overflow: false };
|
|
79
90
|
}
|
|
@@ -83,7 +94,7 @@ export class TrafficSmoother {
|
|
|
83
94
|
const b = this._buckets.get(ref);
|
|
84
95
|
if (!b) return 0;
|
|
85
96
|
const curNow = this.now();
|
|
86
|
-
const wait = Math.max(0, b.
|
|
97
|
+
const wait = Math.max(0, b.virtualTime - curNow);
|
|
87
98
|
return wait > 0 ? Math.ceil(wait / 100) : 0;
|
|
88
99
|
}
|
|
89
100
|
|
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.74",
|
|
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": [
|