@goodandready/dsh-key-rotation 0.8.27 → 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,63 @@
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
+
27
+ ## 0.8.28 - 2026-09-27
28
+
29
+ ### Security & Key Lifecycle (Block 3)
30
+ - **Permanent Key Revocation (#369)**:
31
+ - Added `revokedRefs` tracking in pool state and configuration. Keys returning permanent auth failures (HTTP 401 / `AUTH_FAILED`) are permanently revoked and excluded from rotation and cascade attempts.
32
+ - Added visual revoked status badges and tokens in WebUI (`lib/client.js`).
33
+ - **Key Encryption at Rest (#370)**:
34
+ - Added `lib/crypto-storage.js` implementing authenticated AES-256-GCM encryption with 12-byte IV and 16-byte auth tag.
35
+ - Transparently uses environment variable `DSH_KEY_SECRET` when configured, with safe plaintext fallback when unset.
36
+ - **Key Expiration Tracker (#371)**:
37
+ - Added automated expiration date tracking (`expiresAt`), horizon detection (`expiringSoon`), 24-hour notification deduplication (`shouldNotifyDaily`), and automated exclusion of expired keys from active routing.
38
+
39
+ ### Monitoring & Integrations (Block 4)
40
+ - **Multi-Platform Webhook Integrations (#372)**:
41
+ - Extended `lib/webhook.js` with native detection and formatting for Gotify (`/message` with JSON priority/extras) and Ntfy (`/alerts` with action buttons).
42
+ - **Prometheus Metrics Exporter (#373)**:
43
+ - Added `/dsh-key-rotation/metrics` route (`lib/ops-metrics.js`) exposing OpenMetrics / Prometheus standard text format with gauges for pool totals, active/cooldown/paused/revoked keys, and counters for proactive switches and cascades. Guarded by loopback origin fail-closed security.
44
+
45
+ ## 0.8.27 - 2026-09-27
46
+
47
+ ### Smart Routing (Block 1)
48
+ - **Proactive Rate-Limit Cooldown (#363)**:
49
+ - Proactive key cooldown based on upstream rate limit reset headers (`x-ratelimit-reset`, `x-ratelimit-reset-requests`, `x-ratelimit-reset-tokens`) supporting time deltas and ISO timestamps.
50
+ - **Cross-Provider Cascading (#364)**:
51
+ - Cascade routing across providers on pool exhaustion with `modelMapping` and max cascade depth guard.
52
+ - **Adaptive Concurrency Normalization (#365)**:
53
+ - Weighted least-loaded concurrency balancing (`inflight / weight`).
54
+
55
+ ### WebUI Suite (Block 2)
56
+ - **Bulk Key Import (#366)**:
57
+ - WebUI modal for bulk key import supporting line-by-line, `.env`, and CSV formats.
58
+ - **Key Maintenance Toggle (#367)**:
59
+ - Live key pause/resume toggle (`isPaused`) in WebUI.
60
+ - **Traffic Sparklines (#368)**:
61
+ - Native SVG Sparkline component for request and latency visual trends.
62
+
6
63
  ## 0.8.25 - 2026-09-27
7
64
 
8
65
  ### Fixed & Hardened
@@ -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)) 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/client.js CHANGED
@@ -84,6 +84,7 @@ window.__ModuleLoader__.load({
84
84
  bulkImportCancel: 'Cancel',
85
85
  bulkImportSuccess: 'Imported {n} keys',
86
86
  keyPaused: 'Paused',
87
+ keyRevoked: 'Revoked (401)',
87
88
  pauseKey: 'Pause key from rotation',
88
89
  resumeKey: 'Resume key rotation',
89
90
  sparklineTraffic: 'Traffic Trend',
@@ -239,6 +240,7 @@ window.__ModuleLoader__.load({
239
240
  bulkImportCancel: '取消',
240
241
  bulkImportSuccess: '已导入 {n} 个密钥',
241
242
  keyPaused: '已暂停',
243
+ keyRevoked: '已撤销 (401)',
242
244
  pauseKey: '暂停此密钥轮换',
243
245
  resumeKey: '恢复此密钥轮换',
244
246
  sparklineTraffic: '流量趋势',
@@ -1590,6 +1592,7 @@ window.__ModuleLoader__.load({
1590
1592
  const warnDays = Number(val?.expiryWarnDays) || 7; // #207: configurable horizon
1591
1593
  if (days <= warnDays) return { color: 'var(--dsw-alias-state-warning-primary)', text: t('keyExpiringSoon').replace('{n}', String(days)) };
1592
1594
  }
1595
+ if (hit.broken || hit.revoked || hit.status === 'revoked') return { color: 'var(--dsw-alias-state-error-primary)', text: t('keyRevoked') };
1593
1596
  if (hit.broken) return { color: 'var(--dsw-alias-state-error-primary)', text: t('brokenKey') };
1594
1597
  if (!hit.present) return { color: 'var(--dsw-alias-state-error-primary)', text: t('keyMissing') };
1595
1598
  if (hit.cooldownMsLeft > 0 && !optimisticReset[ref]) {
@@ -0,0 +1,49 @@
1
+ // lib/crypto-storage.js — AES-256-GCM encryption for credentials at rest (#370)
2
+ import crypto from 'node:crypto';
3
+
4
+ const PREFIX = 'enc:v1:';
5
+
6
+ function getDerivedKey(secret) {
7
+ return crypto.createHash('sha256').update(String(secret)).digest();
8
+ }
9
+
10
+ /**
11
+ * Encrypt plaintext using AES-256-GCM if secretKey is provided.
12
+ * If secretKey is not set, returns plaintext as-is.
13
+ */
14
+ export function encryptSecret(plaintext, secretKey = process.env.DSH_KEY_SECRET) {
15
+ if (!secretKey || !plaintext || typeof plaintext !== 'string') return plaintext;
16
+ if (plaintext.startsWith(PREFIX)) return plaintext; // already encrypted
17
+ const key = getDerivedKey(secretKey);
18
+ const iv = crypto.randomBytes(12);
19
+ const cipher = crypto.createCipheriv('aes-256-gcm', key, iv);
20
+ let ciphertext = cipher.update(plaintext, 'utf8', 'hex');
21
+ ciphertext += cipher.final('hex');
22
+ const authTag = cipher.getAuthTag().toString('hex');
23
+ return `${PREFIX}${iv.toString('hex')}:${authTag}:${ciphertext}`;
24
+ }
25
+
26
+ /**
27
+ * Decrypt ciphertext using AES-256-GCM if prefixed with enc:v1:.
28
+ * If not encrypted, or if secretKey is missing/incorrect, returns safe fallback.
29
+ */
30
+ export function decryptSecret(ciphertext, secretKey = process.env.DSH_KEY_SECRET) {
31
+ if (!ciphertext || typeof ciphertext !== 'string') return ciphertext;
32
+ if (!ciphertext.startsWith(PREFIX)) return ciphertext; // plaintext fallback
33
+ if (!secretKey) return ciphertext;
34
+ try {
35
+ const parts = ciphertext.slice(PREFIX.length).split(':');
36
+ if (parts.length !== 3) return ciphertext;
37
+ const [ivHex, tagHex, dataHex] = parts;
38
+ const key = getDerivedKey(secretKey);
39
+ const iv = Buffer.from(ivHex, 'hex');
40
+ const authTag = Buffer.from(tagHex, 'hex');
41
+ const decipher = crypto.createDecipheriv('aes-256-gcm', key, iv);
42
+ decipher.setAuthTag(authTag);
43
+ let decrypted = decipher.update(dataHex, 'hex', 'utf8');
44
+ decrypted += decipher.final('utf8');
45
+ return decrypted;
46
+ } catch {
47
+ return ciphertext;
48
+ }
49
+ }
@@ -1,6 +1,7 @@
1
1
  // lib/http-bridge.js — shared HTTP helpers for dsh-key-rotation routes.
2
2
  import { findSecrets } from './keycheck.js';
3
3
  import { isTrustedBridgeRequest } from './pool.js';
4
+ export { isTrustedBridgeRequest };
4
5
  import { mutateSettings, plainObject, requireRevision, sectionOps, validateOps } from './settings-write.js';
5
6
 
6
7
  export const NS = 'dsh-key-rotation';
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
  }
@@ -0,0 +1,85 @@
1
+ // lib/ops-metrics.js — Prometheus / OpenMetrics export route (#373).
2
+ import { METRICS_PATH } from './ops-paths.js';
3
+ import { isTrustedBridgeRequest, isKeyPaused, isKeyRevoked } from './pool.js';
4
+
5
+ /**
6
+ * @param {object} ctx cordis context
7
+ * @param {object} deps live dependencies from apply()
8
+ */
9
+ export function registerMetricsRoutes(ctx, deps) {
10
+ const { buildRuntime, poolState } = deps;
11
+
12
+ ctx.effect(() => ctx.webServer.register({
13
+ kind: 'exact',
14
+ path: METRICS_PATH,
15
+ handler: async (req, res) => {
16
+ if (req.method !== 'GET') {
17
+ res.statusCode = 405;
18
+ res.setHeader('content-type', 'text/plain');
19
+ res.end('Method Not Allowed\n');
20
+ return;
21
+ }
22
+ if (!isTrustedBridgeRequest(req)) {
23
+ res.statusCode = 403;
24
+ res.setHeader('content-type', 'text/plain');
25
+ res.end('Forbidden: local only\n');
26
+ return;
27
+ }
28
+
29
+ const lines = [];
30
+ const runtime = buildRuntime();
31
+ const pools = runtime.pools ?? [];
32
+ const now = Date.now();
33
+
34
+ lines.push('# HELP dsh_key_rotation_pools_total Total configured provider pools');
35
+ lines.push('# TYPE dsh_key_rotation_pools_total gauge');
36
+ lines.push(`dsh_key_rotation_pools_total ${pools.length}`);
37
+
38
+ lines.push('# HELP dsh_key_rotation_active_keys Number of currently active and ready keys');
39
+ lines.push('# TYPE dsh_key_rotation_active_keys gauge');
40
+
41
+ lines.push('# HELP dsh_key_rotation_cooldown_keys Number of keys in cooldown');
42
+ lines.push('# TYPE dsh_key_rotation_cooldown_keys gauge');
43
+
44
+ lines.push('# HELP dsh_key_rotation_paused_keys Number of paused keys');
45
+ lines.push('# TYPE dsh_key_rotation_paused_keys gauge');
46
+
47
+ lines.push('# HELP dsh_key_rotation_revoked_keys Number of permanently revoked keys');
48
+ lines.push('# TYPE dsh_key_rotation_revoked_keys gauge');
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
+
53
+ lines.push('# HELP dsh_key_rotation_proactive_switches_total Number of proactive rate-limit switches');
54
+ lines.push('# TYPE dsh_key_rotation_proactive_switches_total counter');
55
+
56
+ lines.push('# HELP dsh_key_rotation_cascade_switches_total Number of multi-vendor cascade switches');
57
+ lines.push('# TYPE dsh_key_rotation_cascade_switches_total counter');
58
+
59
+ for (const pool of pools) {
60
+ const p = pool.base;
61
+ const st = poolState.get(p);
62
+ let active = 0, cooling = 0, paused = 0, revoked = 0, expired = 0;
63
+ for (const r of (pool.refs ?? [])) {
64
+ if (isKeyRevoked(pool, r)) { revoked++; continue; }
65
+ if (isKeyPaused(pool, r)) { paused++; continue; }
66
+ if (pool.expiresAt?.[r] !== undefined && now >= pool.expiresAt[r]) { expired++; continue; }
67
+ const fu = st?.failedUntil?.get(r) ?? 0;
68
+ if (fu > now) cooling++;
69
+ else active++;
70
+ }
71
+ lines.push(`dsh_key_rotation_active_keys{provider="${p}"} ${active}`);
72
+ lines.push(`dsh_key_rotation_cooldown_keys{provider="${p}"} ${cooling}`);
73
+ lines.push(`dsh_key_rotation_paused_keys{provider="${p}"} ${paused}`);
74
+ lines.push(`dsh_key_rotation_revoked_keys{provider="${p}"} ${revoked}`);
75
+ lines.push(`dsh_key_rotation_expired_keys{provider="${p}"} ${expired}`);
76
+ lines.push(`dsh_key_rotation_proactive_switches_total{provider="${p}"} ${st?.proactiveSwitches ?? 0}`);
77
+ lines.push(`dsh_key_rotation_cascade_switches_total{provider="${p}"} ${st?.cascadeCount ?? 0}`);
78
+ }
79
+
80
+ res.statusCode = 200;
81
+ res.setHeader('content-type', 'text/plain; version=0.0.4; charset=utf-8');
82
+ res.end(lines.join('\n') + '\n');
83
+ }
84
+ }), 'dsh-key-rotation: metrics');
85
+ }
package/lib/ops-paths.js CHANGED
@@ -9,3 +9,4 @@ export const USAGE_PATH = '/dsh-key-rotation/usage';
9
9
  export const TEST_PATH = '/dsh-key-rotation/test';
10
10
  export const SANDBOX_CACHE_PATH = '/dsh-key-rotation/sandbox-cache';
11
11
  export const WEBHOOK_ACTION_PATH = '/dsh-key-rotation/webhook-action';
12
+ export const METRICS_PATH = '/dsh-key-rotation/metrics';
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({
@@ -16,6 +16,7 @@ export function buildPoolItem({
16
16
  keys,
17
17
  weights,
18
18
  paused,
19
+ revoked,
19
20
  poolCooldown,
20
21
  poolMax,
21
22
  expiresAt,
@@ -51,6 +52,12 @@ export function buildPoolItem({
51
52
  if (paused[indexed[i].index] === true) parsedPaused.add(refs[i]);
52
53
  }
53
54
  }
55
+ const parsedRevoked = new Set();
56
+ if (Array.isArray(revoked)) {
57
+ for (let i = 0; i < refs.length; i++) {
58
+ if (revoked[indexed[i].index] === true) parsedRevoked.add(refs[i]);
59
+ }
60
+ }
54
61
  const weightsMap = {};
55
62
  for (let i = 0; i < refs.length; i++) {
56
63
  weightsMap[refs[i]] = typeof w[i] === 'number' && w[i] > 0 ? Math.floor(w[i]) : 1;
@@ -62,6 +69,7 @@ export function buildPoolItem({
62
69
  weightsMap,
63
70
  weightedRefs: weightedRefs.length > 0 ? weightedRefs : refs,
64
71
  pausedRefs: parsedPaused,
72
+ revokedRefs: parsedRevoked,
65
73
  state: makeState(base),
66
74
  cooldownMs: poolCooldown,
67
75
  maxCooldownMs: poolMax,
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
 
@@ -529,3 +532,13 @@ export function resetCircuitForProvider(circuitBreaker, provider) {
529
532
  });
530
533
  return ok === true;
531
534
  }
535
+
536
+ /** Check if a key reference is permanently revoked due to auth/401 failure (#369). */
537
+ export function isKeyRevoked(pool, ref) {
538
+ if (!pool || !ref) return false;
539
+ if (pool.revokedRefs instanceof Set && pool.revokedRefs.has(ref)) return true;
540
+ if (Array.isArray(pool.revokedRefs) && pool.revokedRefs.includes(ref)) return true;
541
+ if (pool.state && pool.state.revokedRefs instanceof Set && pool.state.revokedRefs.has(ref)) return true;
542
+ if (pool.state && Array.isArray(pool.state.revokedRefs) && pool.state.revokedRefs.includes(ref)) return true;
543
+ return false;
544
+ }
package/lib/rotate.js CHANGED
@@ -10,6 +10,7 @@ import {
10
10
  extractRateLimit,
11
11
  isRateLimited,
12
12
  isKeyPaused,
13
+ isKeyRevoked,
13
14
  formatExhaustionMessage,
14
15
  sortAttemptList,
15
16
  } from './pool.js';
@@ -53,7 +54,7 @@ export function createRotate(deps) {
53
54
  const reqStore = { pool, pickedRef: undefined, startMs: now() };
54
55
  setRotateStartMs(reqStore.startMs);
55
56
  let attemptList = (pool.weightedRefs ?? pool.refs).slice();
56
- attemptList = attemptList.filter((r) => !isKeyPaused(pool, r));
57
+ attemptList = attemptList.filter((r) => !isKeyPaused(pool, r) && !isKeyRevoked(pool, r));
57
58
  const strategy = pool.routingStrategy ?? runtime0.routingStrategy ?? 'round-robin';
58
59
  if (strategy !== 'round-robin') {
59
60
  attemptList = sortAttemptList(attemptList, strategy, { latencyHistogram, concurrencyTracker, weights: pool.weightsMap });
@@ -109,13 +110,14 @@ export function createRotate(deps) {
109
110
  if (!pool.state.authFailCounts) pool.state.authFailCounts = new Map();
110
111
  if (!pool.state.brokenUntil) pool.state.brokenUntil = new Map();
111
112
  const _cStr = String(errCode ?? '');
112
- if (_cStr === 'AUTH' || /auth/i.test(errMsg)) {
113
+ if (_cStr === 'AUTH' || /auth|unauthorized|invalid[_\s-]?api[_\s-]?key|unauthenticated/i.test(errMsg)) {
113
114
  const _c2 = (pool.state.authFailCounts.get(targetRef) ?? 0) + 1;
114
115
  pool.state.authFailCounts.set(targetRef, _c2);
115
- if (_c2 >= 3) {
116
- pool.state.brokenUntil.set(targetRef, now() + 86400000 * 30);
117
- pool.state.failedUntil.set(targetRef, now() + 86400000 * 30);
118
- }
116
+ if (!pool.state.revokedRefs) pool.state.revokedRefs = new Set();
117
+ pool.state.revokedRefs.add(targetRef);
118
+ pool.state.failedUntil.set(targetRef, Infinity);
119
+ pushEvent(pool, targetRef, 'KEY_REVOKED', 0);
120
+ logWarn(`[dsh-key-rotation] ${options.provider}: key ${targetRef} permanently REVOKED on auth failure`);
119
121
  } else {
120
122
  pool.state.authFailCounts.delete(targetRef);
121
123
  }
package/lib/routes-ops.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { registerMetricsRoutes } from './ops-metrics.js';
1
2
  // lib/routes-ops.js — operational HTTP routes for dsh-key-rotation (#253/#312).
2
3
  // Thin orchestrator: each route family lives in its own lib/ops-*.js module.
3
4
  // Registration is injected with the live apply() dependencies.
@@ -18,4 +19,5 @@ export function registerOpsRoutes(ctx, deps) {
18
19
  registerKeyRoutes(ctx, deps);
19
20
  registerTestRoutes(ctx, deps);
20
21
  registerWebhookActionRoute(ctx, deps);
22
+ registerMetricsRoutes(ctx, deps);
21
23
  }
package/lib/webhook.js CHANGED
@@ -12,6 +12,8 @@ export function detectPlatform(url) {
12
12
  if (u.includes('api.telegram.org')) return 'telegram';
13
13
  if (u.includes('discord.com/api/webhooks')) return 'discord';
14
14
  if (u.includes('hooks.slack.com')) return 'slack';
15
+ if (u.includes('ntfy.sh') || u.includes('/ntfy')) return 'ntfy';
16
+ if (u.includes('gotify') || u.includes('/message')) return 'gotify';
15
17
  return 'generic';
16
18
  }
17
19
 
@@ -66,6 +68,40 @@ export function formatInteractive(url, payload, actionToken) {
66
68
  ],
67
69
  };
68
70
  }
71
+ if (platform === 'gotify') {
72
+ return {
73
+ title,
74
+ message: text,
75
+ priority: 5,
76
+ extras: {
77
+ 'client::notification': {
78
+ click: { url: actions[0]?.url || '' },
79
+ },
80
+ },
81
+ };
82
+ }
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
+ }
92
+ return {
93
+ topic,
94
+ title,
95
+ message: text,
96
+ priority: 4,
97
+ tags: ['warning', 'key-rotation'],
98
+ actions: actions.map((a) => ({
99
+ action: 'view',
100
+ label: a.label,
101
+ url: a.url || '',
102
+ })),
103
+ };
104
+ }
69
105
  // generic JSON webhook: actions as plain data, receiver decides
70
106
  return { ...payload, actions };
71
107
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-key-rotation",
3
- "version": "0.8.27",
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": [