@goodandready/dsh-key-rotation 0.8.27 → 0.8.28

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,42 @@
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.28 - 2026-09-27
7
+
8
+ ### Security & Key Lifecycle (Block 3)
9
+ - **Permanent Key Revocation (#369)**:
10
+ - 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.
11
+ - Added visual revoked status badges and tokens in WebUI (`lib/client.js`).
12
+ - **Key Encryption at Rest (#370)**:
13
+ - Added `lib/crypto-storage.js` implementing authenticated AES-256-GCM encryption with 12-byte IV and 16-byte auth tag.
14
+ - Transparently uses environment variable `DSH_KEY_SECRET` when configured, with safe plaintext fallback when unset.
15
+ - **Key Expiration Tracker (#371)**:
16
+ - Added automated expiration date tracking (`expiresAt`), horizon detection (`expiringSoon`), 24-hour notification deduplication (`shouldNotifyDaily`), and automated exclusion of expired keys from active routing.
17
+
18
+ ### Monitoring & Integrations (Block 4)
19
+ - **Multi-Platform Webhook Integrations (#372)**:
20
+ - Extended `lib/webhook.js` with native detection and formatting for Gotify (`/message` with JSON priority/extras) and Ntfy (`/alerts` with action buttons).
21
+ - **Prometheus Metrics Exporter (#373)**:
22
+ - 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.
23
+
24
+ ## 0.8.27 - 2026-09-27
25
+
26
+ ### Smart Routing (Block 1)
27
+ - **Proactive Rate-Limit Cooldown (#363)**:
28
+ - 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.
29
+ - **Cross-Provider Cascading (#364)**:
30
+ - Cascade routing across providers on pool exhaustion with `modelMapping` and max cascade depth guard.
31
+ - **Adaptive Concurrency Normalization (#365)**:
32
+ - Weighted least-loaded concurrency balancing (`inflight / weight`).
33
+
34
+ ### WebUI Suite (Block 2)
35
+ - **Bulk Key Import (#366)**:
36
+ - WebUI modal for bulk key import supporting line-by-line, `.env`, and CSV formats.
37
+ - **Key Maintenance Toggle (#367)**:
38
+ - Live key pause/resume toggle (`isPaused`) in WebUI.
39
+ - **Traffic Sparklines (#368)**:
40
+ - Native SVG Sparkline component for request and latency visual trends.
41
+
6
42
  ## 0.8.25 - 2026-09-27
7
43
 
8
44
  ### Fixed & Hardened
package/lib/cascade.js CHANGED
@@ -20,7 +20,7 @@ export function pickCascadeFallback(provider, cfg, pools, currentModel) {
20
20
  const now = Date.now();
21
21
  let healthy = 0;
22
22
  for (const ref of pool.refs) {
23
- if (isKeyPaused(pool, ref)) continue;
23
+ if (isKeyPaused(pool, ref) || (pool.revokedRefs && pool.revokedRefs.has(ref)) || (pool.state?.revokedRefs && pool.state.revokedRefs.has(ref))) continue;
24
24
  const failedUntil = (pool.state && pool.state.failedUntil && pool.state.failedUntil.get(ref)) || 0;
25
25
  if (failedUntil > now) continue;
26
26
  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';
@@ -0,0 +1,80 @@
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_proactive_switches_total Number of proactive rate-limit switches');
51
+ lines.push('# TYPE dsh_key_rotation_proactive_switches_total counter');
52
+
53
+ lines.push('# HELP dsh_key_rotation_cascade_switches_total Number of multi-vendor cascade switches');
54
+ lines.push('# TYPE dsh_key_rotation_cascade_switches_total counter');
55
+
56
+ for (const pool of pools) {
57
+ const p = pool.base;
58
+ const st = poolState.get(p);
59
+ let active = 0, cooling = 0, paused = 0, revoked = 0;
60
+ for (const r of (pool.refs ?? [])) {
61
+ if (isKeyRevoked(pool, r)) { revoked++; continue; }
62
+ if (isKeyPaused(pool, r)) { paused++; continue; }
63
+ const fu = st?.failedUntil?.get(r) ?? 0;
64
+ if (fu > now) cooling++;
65
+ else active++;
66
+ }
67
+ lines.push(`dsh_key_rotation_active_keys{provider="${p}"} ${active}`);
68
+ lines.push(`dsh_key_rotation_cooldown_keys{provider="${p}"} ${cooling}`);
69
+ lines.push(`dsh_key_rotation_paused_keys{provider="${p}"} ${paused}`);
70
+ lines.push(`dsh_key_rotation_revoked_keys{provider="${p}"} ${revoked}`);
71
+ lines.push(`dsh_key_rotation_proactive_switches_total{provider="${p}"} ${st?.proactiveSwitches ?? 0}`);
72
+ lines.push(`dsh_key_rotation_cascade_switches_total{provider="${p}"} ${st?.cascadeCount ?? 0}`);
73
+ }
74
+
75
+ res.statusCode = 200;
76
+ res.setHeader('content-type', 'text/plain; version=0.0.4; charset=utf-8');
77
+ res.end(lines.join('\n') + '\n');
78
+ }
79
+ }), 'dsh-key-rotation: metrics');
80
+ }
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';
@@ -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
@@ -529,3 +529,13 @@ export function resetCircuitForProvider(circuitBreaker, provider) {
529
529
  });
530
530
  return ok === true;
531
531
  }
532
+
533
+ /** Check if a key reference is permanently revoked due to auth/401 failure (#369). */
534
+ export function isKeyRevoked(pool, ref) {
535
+ if (!pool || !ref) return false;
536
+ if (pool.revokedRefs instanceof Set && pool.revokedRefs.has(ref)) return true;
537
+ if (Array.isArray(pool.revokedRefs) && pool.revokedRefs.includes(ref)) return true;
538
+ if (pool.state && pool.state.revokedRefs instanceof Set && pool.state.revokedRefs.has(ref)) return true;
539
+ if (pool.state && Array.isArray(pool.state.revokedRefs) && pool.state.revokedRefs.includes(ref)) return true;
540
+ return false;
541
+ }
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,32 @@ 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
+ return {
85
+ topic: url.split('/').pop(),
86
+ title,
87
+ message: text,
88
+ priority: 4,
89
+ tags: ['warning', 'key-rotation'],
90
+ actions: actions.map((a) => ({
91
+ action: 'view',
92
+ label: a.label,
93
+ url: a.url || '',
94
+ })),
95
+ };
96
+ }
69
97
  // generic JSON webhook: actions as plain data, receiver decides
70
98
  return { ...payload, actions };
71
99
  }
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.28",
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": [