@goodandready/dsh-key-rotation 0.8.28 → 0.8.29

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
@@ -3,6 +3,27 @@
3
3
  All notable changes to `@goodandready/dsh-key-rotation` are documented here.
4
4
  User-facing feature notes also appear in README (en is source of truth).
5
5
 
6
+ ## 0.8.29 - 2026-09-27
7
+
8
+ ### Fixed & Hardened (#376)
9
+ - **Decrypted secret resolution at rest**:
10
+ - `lib/index.js`: patched `credentials.resolve` now decrypts ciphertext (`enc:v1:...`) via `decryptSecret` for pooled hits, fallback environment variables, and unpooled standalone key queries, preventing raw encrypted strings from being passed to upstream LLM providers.
11
+ - **Fail-safe candidate selection in rotation pool**:
12
+ - `lib/index.js`: `credentials.resolve` candidate check loop now explicitly enforces `!isKeyRevoked(pool, candidate)` and `!isKeyPaused(pool, candidate)` before selecting candidates.
13
+ - **Predicate logic & dynamic state resolution**:
14
+ - `lib/pool.js`: corrected `isKeyPaused` predicate to ensure `pool.state.pausedRefs` is inspected when `pool.pausedRefs` Set or Array does not contain the ref, allowing runtime key pause mutations to take effect.
15
+ - `lib/cascade.js`: replaced raw `.has()` call with `isKeyRevoked(pool, ref)` and unified `isKeyPaused`, preventing `TypeError` crashes when `revokedRefs` is represented as an Array.
16
+ - **Config & schema synchronization**:
17
+ - `lib/index.js`: propagated `p.revoked` into `buildPool` / `buildPoolItem` and added missing `paused` and `revoked` definitions to `SettingsSchema.providers`.
18
+ - **Status & key administrative routes**:
19
+ - `lib/ops-status.js`: exposed `paused` and `revoked` booleans on key entries in `/dsh-key-rotation/status`.
20
+ - `lib/pool.js`: updated `keyTail` to transparently decrypt ciphertext before extracting the tail, displaying the actual credential mask instead of random ciphertext characters while preserving test safety contracts.
21
+ - `lib/ops-keys.js`: `/dsh-key-rotation/reset` now clears `st.revokedRefs` for provider resets and single ref resets, enabling operators to unblock fixed keys.
22
+ - **Prometheus metrics & monitoring**:
23
+ - `lib/ops-metrics.js`: separated expired keys from active counts and added `dsh_key_rotation_expired_keys` gauge.
24
+ - `lib/budget-monitor.js`: excluded revoked and paused keys from healthy count in `warnBelowHealthy` threshold evaluation.
25
+ - `lib/webhook.js`: hardened Ntfy topic extraction against URLs with trailing slashes and query strings.
26
+
6
27
  ## 0.8.28 - 2026-09-27
7
28
 
8
29
  ### Security & Key Lifecycle (Block 3)
@@ -1,5 +1,5 @@
1
1
  // lib/budget-monitor.js — periodic budget, expiry, low health and SLO alerts
2
- import { expiringSoon, shouldNotifyDaily, costForDay, costForWeek, budgetVerdict } from './pool.js';
2
+ import { expiringSoon, shouldNotifyDaily, costForDay, costForWeek, budgetVerdict, isKeyPaused, isKeyRevoked } from './pool.js';
3
3
 
4
4
  const DAY_MS = 86400000;
5
5
 
@@ -72,6 +72,7 @@ export function checkBudgetAndHealthAlerts({
72
72
  if (warnBelow > 0) {
73
73
  let healthy = 0;
74
74
  for (const ref of pool.refs) {
75
+ if (isKeyRevoked(pool, ref) || isKeyPaused(pool, ref)) continue;
75
76
  const fu = pool.state.failedUntil.get(ref);
76
77
  if (fu !== undefined && fu > now) continue;
77
78
  const exp = pool.expiresAt?.[ref];
package/lib/cascade.js CHANGED
@@ -1,15 +1,9 @@
1
- // cascade.js — cross-provider failover cascade (issues #194, #364).
1
+ // cascade.js — cross-provider failover cascade (issues #194, #364, #376).
2
2
  // ponytail: minimal — pick fallback provider with model mapping from config.
3
+ import { isKeyPaused, isKeyRevoked } from './pool.js';
3
4
 
4
5
  export const CASCADE_MAX_DEPTH = 3;
5
6
 
6
- function isKeyPaused(pool, ref) {
7
- if (!pool || !ref) return false;
8
- if (pool.pausedRefs instanceof Set) return pool.pausedRefs.has(ref);
9
- if (Array.isArray(pool.pausedRefs)) return pool.pausedRefs.includes(ref);
10
- return false;
11
- }
12
-
13
7
  export function pickCascadeFallback(provider, cfg, pools, currentModel) {
14
8
  const list = Array.isArray(cfg && cfg.cascade) ? cfg.cascade : [];
15
9
  for (const entry of list) {
@@ -20,7 +14,7 @@ export function pickCascadeFallback(provider, cfg, pools, currentModel) {
20
14
  const now = Date.now();
21
15
  let healthy = 0;
22
16
  for (const ref of pool.refs) {
23
- if (isKeyPaused(pool, ref) || (pool.revokedRefs && pool.revokedRefs.has(ref)) || (pool.state?.revokedRefs && pool.state.revokedRefs.has(ref))) continue;
17
+ if (isKeyPaused(pool, ref) || isKeyRevoked(pool, ref)) continue;
24
18
  const failedUntil = (pool.state && pool.state.failedUntil && pool.state.failedUntil.get(ref)) || 0;
25
19
  if (failedUntil > now) continue;
26
20
  const exp = pool.expiresAt ? pool.expiresAt[ref] : undefined;
package/lib/index.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { decryptSecret } from './crypto-storage.js';
1
2
  // dsh-key-rotation — per-provider API key rotation for DeepSeek Harness.
2
3
  //
3
4
  // Transparent key pool rotation: credentials.resolve patch + llm/stream
@@ -13,7 +14,7 @@ import {
13
14
  recordSuccess, computeBackoff, envValue, sweepExpired, parseRetryAfter,
14
15
  computeHealthScore, extractRateLimit, isRateLimited, selectPool, isSwitchableError,
15
16
  formatExhaustionMessage, expiringSoon, shouldNotifyDaily, costForDay, costForWeek,
16
- budgetVerdict, sortAttemptList,
17
+ budgetVerdict, sortAttemptList, isKeyPaused, isKeyRevoked,
17
18
  } from './pool.js';
18
19
  import { LatencyHistogram } from './histogram.js';
19
20
  import { ConcurrencyTracker } from './concurrency.js';
@@ -115,6 +116,8 @@ const SettingsSchema = Schema.object({
115
116
  provider: Schema.string().required(),
116
117
  keys: Schema.array(Schema.string()).default([]),
117
118
  weights: Schema.array(Schema.number()).default([]),
119
+ paused: Schema.array(Schema.boolean()).default([]),
120
+ revoked: Schema.array(Schema.boolean()).default([]),
118
121
  expiresAt: Schema.array(Schema.union([Schema.number(), Schema.string()])).default([]),
119
122
  tags: Schema.array(Schema.string()).default([]),
120
123
  costBudgetDaily: Schema.number(),
@@ -263,13 +266,13 @@ export function apply(ctx, config = {}) {
263
266
  return initializePoolState(st);
264
267
  };
265
268
 
266
- const buildPool = (base, keys, weights, paused, poolCooldown, poolMax, expiresAt, poolStrategy, poolGuard) =>
267
- buildPoolItem({ base, keys, weights, paused, poolCooldown, poolMax, expiresAt, poolStrategy, poolGuard, rpmLimit, makeState });
269
+ const buildPool = (base, keys, weights, paused, poolCooldown, poolMax, expiresAt, poolStrategy, poolGuard, revoked) =>
270
+ buildPoolItem({ base, keys, weights, paused, revoked, poolCooldown, poolMax, expiresAt, poolStrategy, poolGuard, rpmLimit, makeState });
268
271
 
269
272
  for (const p of cfg.providers ?? []) {
270
273
  const poolCooldown = typeof p.cooldownMs === 'number' ? p.cooldownMs : (cfg.cooldownMs ?? 60000);
271
274
  const poolMax = typeof p.maxCooldownMs === 'number' ? p.maxCooldownMs : (cfg.maxCooldownMs ?? undefined);
272
- const pool = buildPool(p.provider, p.keys, p.weights, p.paused, poolCooldown, poolMax, p.expiresAt, p.routingStrategy, p.proactiveRateLimitGuard);
275
+ const pool = buildPool(p.provider, p.keys, p.weights, p.paused, poolCooldown, poolMax, p.expiresAt, p.routingStrategy, p.proactiveRateLimitGuard, p.revoked);
273
276
  if (pool) {
274
277
  for (const ref of pool.refs) poolByRef.set(ref, pool);
275
278
  for (let i = 1; i < pool.refs.length; i++) cloneIds.add(`${p.provider}-${i + 1}`);
@@ -277,7 +280,7 @@ export function apply(ctx, config = {}) {
277
280
  const models = p.models ?? {};
278
281
  const byModel = new Map();
279
282
  for (const [model, mp] of Object.entries(models)) {
280
- const mpool = buildPool(`${p.provider}::${model}`, mp.keys, mp.weights, mp.paused, poolCooldown, poolMax, undefined, p.routingStrategy, p.proactiveRateLimitGuard);
283
+ const mpool = buildPool(`${p.provider}::${model}`, mp.keys, mp.weights, mp.paused, poolCooldown, poolMax, undefined, p.routingStrategy, p.proactiveRateLimitGuard, mp.revoked);
281
284
  if (mpool) {
282
285
  byModel.set(model, mpool);
283
286
  for (const ref of mpool.refs) poolByRef.set(ref, mpool);
@@ -353,7 +356,15 @@ export function apply(ctx, config = {}) {
353
356
  credentials.resolve = async (ref) => {
354
357
  const { poolByRef } = buildRuntime();
355
358
  const pool = poolByRef.get(ref);
356
- if (!pool) return original(ref);
359
+ if (!pool) {
360
+ const res = await original(ref);
361
+ if (res && typeof res.value === 'string') {
362
+ return { ...res, value: decryptSecret(res.value) };
363
+ }
364
+ const ev = envValue(ref);
365
+ if (ev !== undefined) return { value: decryptSecret(ev) };
366
+ return res;
367
+ }
357
368
  const now = Date.now();
358
369
  const strat = pool.routingStrategy ?? buildRuntime().routingStrategy ?? 'round-robin';
359
370
  let list = pool.weightedRefs ?? pool.refs;
@@ -364,6 +375,8 @@ export function apply(ctx, config = {}) {
364
375
  for (let i = 0; i < list.length; i++) {
365
376
  const index = (start + i) % list.length;
366
377
  const candidate = list[index];
378
+ if (isKeyRevoked(pool, candidate)) continue;
379
+ if (isKeyPaused(pool, candidate)) continue;
367
380
  const until = pool.state.failedUntil.get(candidate);
368
381
  if (until !== undefined && until > now) continue;
369
382
  if (pool.expiresAt?.[candidate] !== undefined && now >= pool.expiresAt[candidate]) continue;
@@ -391,6 +404,7 @@ export function apply(ctx, config = {}) {
391
404
  pool.state.pointer = (index + 1) % list.length;
392
405
  let hit = await original(candidate);
393
406
  if (hit && typeof hit.value === 'string' && hit.value.length > 0) {
407
+ hit = { ...hit, value: decryptSecret(hit.value) };
394
408
  pool.state.lastUsed = candidate;
395
409
  if (!pool.state.lastUsedAt) pool.state.lastUsedAt = new Map();
396
410
  pool.state.lastUsedAt.set(candidate, now);
@@ -413,6 +427,7 @@ export function apply(ctx, config = {}) {
413
427
  }
414
428
  const envVal = envValue(candidate);
415
429
  if (envVal !== undefined) {
430
+ const decEnv = decryptSecret(envVal);
416
431
  pool.state.lastUsed = candidate;
417
432
  if (!pool.state.lastUsedAt) pool.state.lastUsedAt = new Map();
418
433
  pool.state.lastUsedAt.set(candidate, now);
package/lib/ops-keys.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { decryptSecret } from './crypto-storage.js';
1
2
  // lib/ops-keys.js — key store + cooldown reset + import routes (#312 split from routes-ops.js).
2
3
  import {
3
4
  json,
@@ -75,6 +76,7 @@ ctx.effect(() => ctx.webServer.register({
75
76
  st.failCounts?.delete(ref);
76
77
  st.authFailCounts?.delete(ref);
77
78
  st.brokenUntil?.delete(ref);
79
+ st.revokedRefs?.delete(ref);
78
80
  if (st.lastUsed === ref) st.lastUsed = undefined;
79
81
  }
80
82
  bestEffort('lastTestCache.delete', () => { lastTestCache?.delete?.(ref); }, ctx.logger);
@@ -127,6 +129,7 @@ ctx.effect(() => ctx.webServer.register({
127
129
  st.failCounts?.clear();
128
130
  st.authFailCounts?.clear();
129
131
  st.brokenUntil?.clear();
132
+ st.revokedRefs?.clear();
130
133
  st.switches = 0; st.lastReason = undefined; st.lastSwitchAt = undefined;
131
134
  let circuitReset = false;
132
135
  const br = circuitBreaker ?? buildRuntime().breaker;
@@ -145,6 +148,7 @@ ctx.effect(() => ctx.webServer.register({
145
148
  st.failCounts?.delete(ref);
146
149
  st.authFailCounts?.delete(ref);
147
150
  st.brokenUntil?.delete(ref);
151
+ st.revokedRefs?.delete(ref);
148
152
  if (st.lastUsed === ref) st.lastUsed = undefined;
149
153
  found = true; break;
150
154
  }
@@ -47,6 +47,9 @@ export function registerMetricsRoutes(ctx, deps) {
47
47
  lines.push('# HELP dsh_key_rotation_revoked_keys Number of permanently revoked keys');
48
48
  lines.push('# TYPE dsh_key_rotation_revoked_keys gauge');
49
49
 
50
+ lines.push('# HELP dsh_key_rotation_expired_keys Number of expired keys');
51
+ lines.push('# TYPE dsh_key_rotation_expired_keys gauge');
52
+
50
53
  lines.push('# HELP dsh_key_rotation_proactive_switches_total Number of proactive rate-limit switches');
51
54
  lines.push('# TYPE dsh_key_rotation_proactive_switches_total counter');
52
55
 
@@ -56,10 +59,11 @@ export function registerMetricsRoutes(ctx, deps) {
56
59
  for (const pool of pools) {
57
60
  const p = pool.base;
58
61
  const st = poolState.get(p);
59
- let active = 0, cooling = 0, paused = 0, revoked = 0;
62
+ let active = 0, cooling = 0, paused = 0, revoked = 0, expired = 0;
60
63
  for (const r of (pool.refs ?? [])) {
61
64
  if (isKeyRevoked(pool, r)) { revoked++; continue; }
62
65
  if (isKeyPaused(pool, r)) { paused++; continue; }
66
+ if (pool.expiresAt?.[r] !== undefined && now >= pool.expiresAt[r]) { expired++; continue; }
63
67
  const fu = st?.failedUntil?.get(r) ?? 0;
64
68
  if (fu > now) cooling++;
65
69
  else active++;
@@ -68,6 +72,7 @@ export function registerMetricsRoutes(ctx, deps) {
68
72
  lines.push(`dsh_key_rotation_cooldown_keys{provider="${p}"} ${cooling}`);
69
73
  lines.push(`dsh_key_rotation_paused_keys{provider="${p}"} ${paused}`);
70
74
  lines.push(`dsh_key_rotation_revoked_keys{provider="${p}"} ${revoked}`);
75
+ lines.push(`dsh_key_rotation_expired_keys{provider="${p}"} ${expired}`);
71
76
  lines.push(`dsh_key_rotation_proactive_switches_total{provider="${p}"} ${st?.proactiveSwitches ?? 0}`);
72
77
  lines.push(`dsh_key_rotation_cascade_switches_total{provider="${p}"} ${st?.cascadeCount ?? 0}`);
73
78
  }
package/lib/ops-status.js CHANGED
@@ -1,7 +1,10 @@
1
+ import { decryptSecret } from './crypto-storage.js';
1
2
  // lib/ops-status.js — status + health operational routes (#312 split from routes-ops.js).
2
3
  import { json } from './http-bridge.js';
3
4
  import {
4
5
  isTrustedBridgeRequest,
6
+ isKeyPaused,
7
+ isKeyRevoked,
5
8
  keyTail,
6
9
  envValue,
7
10
  computeHealthScore,
@@ -106,6 +109,8 @@ ctx.effect(() => ctx.webServer.register({
106
109
  expiresAt: pool.expiresAt?.[ref] ?? null,
107
110
  expired: pool.expiresAt?.[ref] !== undefined && now >= pool.expiresAt[ref],
108
111
  broken: pool.state.brokenUntil?.has(ref) ?? false,
112
+ paused: isKeyPaused(pool, ref),
113
+ revoked: isKeyRevoked(pool, ref),
109
114
  });
110
115
  }
111
116
  providers.push({
package/lib/pool.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { decryptSecret } from './crypto-storage.js';
1
2
  import { bestEffort } from './best-effort.js';
2
3
  // lib/pool.js — pure pool arithmetic and selection logic for dsh-key-rotation.
3
4
  // Isolated from cordis/dsh runtime so unit tests run in vanilla Node.js.
@@ -11,8 +12,9 @@ export const KEY_TAIL_PLACEHOLDER = '***';
11
12
  /** Mask key to show only the last KEY_TAIL_CHARS; returns placeholder for short keys (#355). */
12
13
  export function keyTail(key) {
13
14
  if (typeof key !== 'string' || key.length === 0) return '';
14
- if (key.length <= KEY_TAIL_CHARS) return KEY_TAIL_PLACEHOLDER;
15
- return key.slice(-KEY_TAIL_CHARS);
15
+ const plain = decryptSecret(key);
16
+ if (plain.length <= KEY_TAIL_CHARS) return KEY_TAIL_PLACEHOLDER;
17
+ return plain.slice(-KEY_TAIL_CHARS);
16
18
  }
17
19
 
18
20
  /** Check if an IP address is a loopback address. */
@@ -290,9 +292,10 @@ export function parseDurationToSeconds(v) {
290
292
  /** Check if a key reference is in maintenance/paused mode. */
291
293
  export function isKeyPaused(pool, ref) {
292
294
  if (!pool || !ref) return false;
293
- if (pool.pausedRefs instanceof Set) return pool.pausedRefs.has(ref);
294
- if (Array.isArray(pool.pausedRefs)) return pool.pausedRefs.includes(ref);
295
- if (pool.state && pool.state.pausedRefs instanceof Set) return pool.state.pausedRefs.has(ref);
295
+ if (pool.pausedRefs instanceof Set && pool.pausedRefs.has(ref)) return true;
296
+ if (Array.isArray(pool.pausedRefs) && pool.pausedRefs.includes(ref)) return true;
297
+ if (pool.state && pool.state.pausedRefs instanceof Set && pool.state.pausedRefs.has(ref)) return true;
298
+ if (pool.state && Array.isArray(pool.state.pausedRefs) && pool.state.pausedRefs.includes(ref)) return true;
296
299
  return false;
297
300
  }
298
301
 
package/lib/webhook.js CHANGED
@@ -81,8 +81,16 @@ export function formatInteractive(url, payload, actionToken) {
81
81
  };
82
82
  }
83
83
  if (platform === 'ntfy') {
84
+ let topic = 'alerts';
85
+ try {
86
+ const u = new URL(url);
87
+ const parts = u.pathname.split('/').filter(Boolean);
88
+ if (parts.length > 0) topic = parts[parts.length - 1];
89
+ } catch (_) {
90
+ topic = String(url).split('?')[0].split('/').filter(Boolean).pop() || 'alerts';
91
+ }
84
92
  return {
85
- topic: url.split('/').pop(),
93
+ topic,
86
94
  title,
87
95
  message: text,
88
96
  priority: 4,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-key-rotation",
3
- "version": "0.8.28",
3
+ "version": "0.8.29",
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": [