@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 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
@@ -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
- rawValue = decryptSecret(rawValue);
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 q = new URLSearchParams();
1280
- q.set('masked', exportMasked ? 'true' : 'false');
1281
- if (exportPassphrase.trim()) q.set('passphrase', exportPassphrase.trim());
1282
- const res = await fetch(`/dsh-key-rotation/export?${q.toString()}`, {
1283
- headers: { accept: 'application/json' },
1284
- signal: (typeof AbortSignal !== 'undefined' && typeof AbortSignal.timeout === 'function') ? AbortSignal.timeout(10000) : undefined,
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' });
@@ -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
  }
@@ -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
- const drainingKeys = new Map(); // ref -> { provider, startedAt, inFlightCount }
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
- export function markKeyDraining(ref, provider = '') {
8
- if (!ref) return false;
9
- drainingKeys.set(ref, {
10
- ref,
11
- provider,
12
- startedAt: Date.now(),
13
- });
14
- return true;
58
+ clearAllDraining() {
59
+ this.drainingKeys.clear();
60
+ }
15
61
  }
16
62
 
17
- export function isKeyDraining(ref) {
18
- if (!ref) return false;
19
- return drainingKeys.has(ref);
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 cancelKeyDrain(ref) {
23
- if (!ref) return false;
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 getDrainingStatus(ref, concurrencyTracker) {
28
- if (!ref || !drainingKeys.has(ref)) return null;
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 getAllDrainingKeys(concurrencyTracker) {
47
- const result = [];
48
- for (const ref of drainingKeys.keys()) {
49
- result.push(getDrainingStatus(ref, concurrencyTracker));
50
- }
51
- return result;
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
- drainingKeys.clear();
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
  }
@@ -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, 100000, 32, 'sha256');
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: 100000,
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
- if (!payload || !payload.salt || !payload.iv || !payload.tag || !payload.ciphertext) {
36
- throw new Error('Invalid encrypted config payload structure');
37
- }
38
- const salt = Buffer.from(payload.salt, 'hex');
39
- const iv = Buffer.from(payload.iv, 'hex');
40
- const tag = Buffer.from(payload.tag, 'hex');
41
- const ciphertext = Buffer.from(payload.ciphertext, 'hex');
42
- const key = crypto.pbkdf2Sync(passphrase, salt, payload.iterations || 100000, 32, 'sha256');
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
- if (clone.webhookUrl && typeof clone.webhookUrl === 'string') {
105
+ function maskWebhookString(val) {
106
+ if (!val || typeof val !== 'string') return val;
69
107
  try {
70
- const u = new URL(clone.webhookUrl);
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
- clone.webhookUrl = u.toString();
117
+ return u.toString();
77
118
  } catch (_) {
78
- clone.webhookUrl = '***';
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
- const urlObj = new URL(req.url, 'http://127.0.0.1');
156
- const maskedParam = urlObj.searchParams.get('masked');
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
- const settings = ctx.get('settings');
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
- const encryptedPayload = encryptConfig(JSON.stringify(exportData), passphrase);
180
- json(res, 200, {
181
- ok: true,
182
- version: PLUGIN_VERSION,
183
- exportedAt: new Date().toISOString(),
184
- masked,
185
- encrypted: true,
186
- ...encryptedPayload,
187
- });
188
- return;
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 = decryptConfig(body, passphrase);
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 (settings && typeof settings.get === 'function') {
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
- currentConfig = desc?.value || {};
261
- } else {
262
- currentConfig = deps.buildRuntime().cfg0 || {};
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
- if (settings && typeof settings.replace === 'function') {
311
- try {
312
- await settings.replace(NS, mergedConfig);
313
- } catch (err) {
314
- json(res, 500, { error: { code: 'settings-write-failed', message: err.message } });
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
- const url = new URL(req.url, 'http://127.0.0.1');
236
- const ref = url.searchParams.get('ref')?.trim();
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') {
@@ -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 currentCursor = targetPool.state.cursor ?? 0;
70
- const nextCursor = (currentCursor + 1) % total;
71
- targetPool.state.cursor = nextCursor;
72
- const nextRef = targetPool.refs[nextCursor];
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: nextCursor,
84
+ cursor: nextPointer,
84
85
  activeRef: nextRef,
85
86
  activeTail: keyTail(nextRef),
86
87
  });
@@ -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 function pushDiag(entry) {
36
- if (diagBuffer.length >= DIAG_BUFFER_LIMIT) {
37
- diagBuffer.shift();
34
+ export class DiagBuffer {
35
+ constructor(limit = DIAG_BUFFER_LIMIT) {
36
+ this.limit = limit;
37
+ this.buffer = [];
38
38
  }
39
- diagBuffer.push({
40
- timestamp: Date.now(),
41
- ...entry,
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 diagBuffer.slice();
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 || (runtime.pools?.[0]?.base ?? '');
189
- const pool = runtime.providerToPool?.[provider] || runtime.pools?.[0];
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
- let effectiveValue = rawValue ? decryptSecret(rawValue) : rawValue;
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 });
@@ -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 === object ? tiers[refs[i]] : undefined);
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 only authenticated Desktop shell or valid Referer (#516).
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
- const status = Number(failure.status ?? failure.statusCode ?? failure.httpStatus ?? 0);
471
- let code = String(failure.code ?? failure.reason ?? '').toUpperCase();
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
- // 1. Direct HTTP status codes (#267: also 408/425 transient)
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) => {
@@ -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
- const traces = [];
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
- export function createSelectionTrace(requestId, options = {}) {
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: String(details).slice(0, 300),
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
- if (traces.length >= TRACE_BUFFER_LIMIT) {
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 traces.slice(-Math.max(1, Math.min(100, limit)));
106
+ export function getSelectionTraces(limit = 50, store = null) {
107
+ return (store || defaultSelectionTraceStore).get(limit);
76
108
  }
77
109
 
78
- export function clearSelectionTraces() {
79
- traces.length = 0;
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: new Map(),
55
+ brokenUntil: mockBrokenUntil,
56
+ tokenUsage: mockTokenUsage,
57
+ quotaWindows: mockQuotaWindows,
52
58
  },
53
59
  };
54
60
 
@@ -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) return reject(signal.reason ?? new Error('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
- reject(signal.reason ?? new Error('aborted'));
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, { lastTime: number, tokens: number, lastUpdate: number }>
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 = { tokens: capacity, lastUpdate: curNow, nextSlot: curNow };
53
+ bucket = { virtualTime: curNow - capacity * intervalMs };
49
54
  this._buckets.set(ref, bucket);
50
55
  }
51
56
 
52
- // Replenish tokens based on elapsed time
53
- const elapsed = Math.max(0, curNow - bucket.lastUpdate);
54
- const addedTokens = elapsed * (ratePerSec / 1000);
55
- bucket.tokens = Math.min(capacity, bucket.tokens + addedTokens);
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 (bucket.tokens >= 1) {
59
- bucket.tokens -= 1;
60
- bucket.nextSlot = Math.max(curNow, bucket.nextSlot);
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
- // Burst is exhausted: calculate delay required for next available slot
65
- const targetSlot = Math.max(curNow, bucket.nextSlot) + intervalMs;
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
- bucket.nextSlot = targetSlot;
76
+ // Atomically reserve the slot ahead of sleeping (#538)
77
+ bucket.virtualTime = targetSlot;
74
78
 
75
- // Wait for the scheduled slot
76
- await this.sleep(delayMs, signal);
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.nextSlot - curNow);
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.72",
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": [