@goodandready/dsh-key-rotation 0.8.25 → 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
@@ -1,9 +1,16 @@
1
- // cascade.js — cross-provider failover cascade (issue #194).
2
- // ponytail: minimal — pick fallback provider from a RegionMap-like config.
1
+ // cascade.js — cross-provider failover cascade (issues #194, #364).
2
+ // ponytail: minimal — pick fallback provider with model mapping from config.
3
3
 
4
- export const CASCADE_MAX_DEPTH = 1;
4
+ export const CASCADE_MAX_DEPTH = 3;
5
5
 
6
- export function pickCascadeFallback(provider, cfg, pools) {
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
+ export function pickCascadeFallback(provider, cfg, pools, currentModel) {
7
14
  const list = Array.isArray(cfg && cfg.cascade) ? cfg.cascade : [];
8
15
  for (const entry of list) {
9
16
  const fb = typeof entry === 'string' ? { provider: entry } : entry;
@@ -13,6 +20,7 @@ export function pickCascadeFallback(provider, cfg, pools) {
13
20
  const now = Date.now();
14
21
  let healthy = 0;
15
22
  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;
16
24
  const failedUntil = (pool.state && pool.state.failedUntil && pool.state.failedUntil.get(ref)) || 0;
17
25
  if (failedUntil > now) continue;
18
26
  const exp = pool.expiresAt ? pool.expiresAt[ref] : undefined;
@@ -20,8 +28,8 @@ export function pickCascadeFallback(provider, cfg, pools) {
20
28
  healthy += 1;
21
29
  }
22
30
  if (healthy === 0) continue;
23
- return { provider: fb.provider, pool, model: fb.model || null };
31
+ const mappedModel = (fb.modelMapping && currentModel && fb.modelMapping[currentModel]) || fb.model || currentModel || null;
32
+ return { provider: fb.provider, pool, model: mappedModel };
24
33
  }
25
34
  return null;
26
35
  }
27
-
package/lib/client.js CHANGED
@@ -78,6 +78,17 @@ window.__ModuleLoader__.load({
78
78
  addKey: '+ Add key',
79
79
  addKeyTitle: 'Add API key',
80
80
  noProviders: 'No providers registered with DSH — nothing to pick from yet.',
81
+ bulkImport: 'Bulk Import',
82
+ bulkImportDesc: 'Paste API keys line-by-line, as .env (KEY=value), or CSV (KEY, weight):',
83
+ bulkImportBtn: 'Import Keys',
84
+ bulkImportCancel: 'Cancel',
85
+ bulkImportSuccess: 'Imported {n} keys',
86
+ keyPaused: 'Paused',
87
+ keyRevoked: 'Revoked (401)',
88
+ pauseKey: 'Pause key from rotation',
89
+ resumeKey: 'Resume key rotation',
90
+ sparklineTraffic: 'Traffic Trend',
91
+ sparklineLatency: 'Latency Trend',
81
92
  addProvider: '+ Add provider',
82
93
  desc: 'Per-provider API key rotation. For each provider, list its API keys (env names, stored in DSH credentials). The plugin routes a model through that provider\u2019s keys in order and switches to the next on a quota/rate-limit failure.',
83
94
  cooldown: 'Cooldown after failure (ms)',
@@ -223,6 +234,17 @@ window.__ModuleLoader__.load({
223
234
  addKey: '+ 添加密钥',
224
235
  addKeyTitle: '添加 API 密钥',
225
236
  noProviders: 'DSH 尚未注册任何提供商 — 暂无可选项目。',
237
+ bulkImport: '批量导入',
238
+ bulkImportDesc: '按行粘贴 API 密钥,支持 .env (KEY=value) 或 CSV (KEY, 权重) 格式:',
239
+ bulkImportBtn: '导入密钥',
240
+ bulkImportCancel: '取消',
241
+ bulkImportSuccess: '已导入 {n} 个密钥',
242
+ keyPaused: '已暂停',
243
+ keyRevoked: '已撤销 (401)',
244
+ pauseKey: '暂停此密钥轮换',
245
+ resumeKey: '恢复此密钥轮换',
246
+ sparklineTraffic: '流量趋势',
247
+ sparklineLatency: '延迟趋势',
226
248
  addProvider: '+ 添加提供商',
227
249
  desc: '按提供商的 API 密钥轮换。为每个提供商列出其 API 密钥(环境变量名,存储在 DSH 凭据中)。插件按顺序通过提供商的密钥路由模型,并在配额/速率限制失败时切换到下一个。',
228
250
  cooldown: '故障后冷却时间 (毫秒)',
@@ -590,6 +612,74 @@ window.__ModuleLoader__.load({
590
612
  };
591
613
  }
592
614
 
615
+
616
+ function Sparkline({ points = [], width = 120, height = 22, stroke = 'var(--dsw-alias-state-brand-primary, #3b82f6)' }) {
617
+ if (!Array.isArray(points) || points.length < 2) {
618
+ return h('svg', { width, height, viewBox: `0 0 ${width} ${height}`, style: { opacity: 0.3 } },
619
+ h('line', { x1: 0, y1: height / 2, x2: width, y2: height / 2, stroke, strokeWidth: 1, strokeDasharray: '3 3' })
620
+ );
621
+ }
622
+ const min = Math.min(...points);
623
+ const max = Math.max(...points);
624
+ const range = max - min || 1;
625
+ const step = width / (points.length - 1);
626
+ const coords = points.map((p, i) => {
627
+ const x = Math.round(i * step * 10) / 10;
628
+ const y = Math.round((height - ((p - min) / range) * (height - 4) - 2) * 10) / 10;
629
+ return `${x},${y}`;
630
+ }).join(' ');
631
+
632
+ return h('svg', {
633
+ width,
634
+ height,
635
+ viewBox: `0 0 ${width} ${height}`,
636
+ style: { display: 'inline-block', verticalAlign: 'middle', overflow: 'visible' },
637
+ }, h('polyline', {
638
+ points: coords,
639
+ fill: 'none',
640
+ stroke,
641
+ strokeWidth: 2,
642
+ strokeLinecap: 'round',
643
+ strokeLinejoin: 'round',
644
+ }));
645
+ }
646
+
647
+ function parseBulkKeys(text) {
648
+ if (!text || typeof text !== 'string') return [];
649
+ const lines = text.split(/\r?\n/);
650
+ const result = [];
651
+ const seen = new Set();
652
+ for (let raw of lines) {
653
+ const line = raw.trim();
654
+ if (!line || line.startsWith('#') || line.startsWith('//')) continue;
655
+ const envMatch = line.match(/^(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)=(.*)$/);
656
+ if (envMatch) {
657
+ const ref = envMatch[1];
658
+ if (!seen.has(ref)) {
659
+ seen.add(ref);
660
+ let weight = 1;
661
+ const rest = envMatch[2].trim().replace(/^['"]|['"]$/g, '');
662
+ const wMatch = rest.match(/,\s*(?:weight\s*=\s*)?([0-9]+)$/i);
663
+ if (wMatch) weight = Math.max(1, parseInt(wMatch[1], 10) || 1);
664
+ result.push({ ref, weight });
665
+ }
666
+ continue;
667
+ }
668
+ const parts = line.split(',');
669
+ const ref = parts[0].trim();
670
+ if (/^[A-Za-z_][A-Za-z0-9_]*$/.test(ref) && !seen.has(ref)) {
671
+ seen.add(ref);
672
+ let weight = 1;
673
+ if (parts[1]) {
674
+ const wm = parts[1].trim().match(/(?:weight\s*=\s*)?([0-9]+)/i);
675
+ if (wm) weight = Math.max(1, parseInt(wm[1], 10) || 1);
676
+ }
677
+ result.push({ ref, weight });
678
+ }
679
+ }
680
+ return result;
681
+ }
682
+
593
683
  function UpdaterSection({ t }) {
594
684
  const [state, setState] = React.useState({ phase: 'idle', current: '', latest: '', message: '' });
595
685
  const load = React.useCallback(async () => {
@@ -883,6 +973,47 @@ window.__ModuleLoader__.load({
883
973
  const [selected, setSelected] = React.useState(new Set());
884
974
  const [bulkCooldown, setBulkCooldown] = React.useState('');
885
975
  const [undo, setUndo] = React.useState(null);
976
+ const [bulkOpen, setBulkOpen] = React.useState({});
977
+ const [bulkText, setBulkText] = React.useState({});
978
+
979
+ const togglePauseKey = (pIndex, kIndex) => setField((cur) => {
980
+ const providers = [...(cur.providers ?? [])];
981
+ const ent = { ...providers[pIndex] };
982
+ const paused = [...(ent.paused ?? [])];
983
+ paused[kIndex] = !paused[kIndex];
984
+ ent.paused = paused;
985
+ providers[pIndex] = ent;
986
+ return { ...cur, providers };
987
+ });
988
+
989
+ const applyBulkImport = (pIndex) => {
990
+ const text = bulkText[pIndex] || '';
991
+ const parsed = parseBulkKeys(text);
992
+ if (!parsed.length) return;
993
+ setField((cur) => {
994
+ const providers = [...(cur.providers ?? [])];
995
+ const ent = { ...providers[pIndex] };
996
+ const existingKeys = new Set(ent.keys ?? []);
997
+ const nextKeys = [...(ent.keys ?? [])];
998
+ const nextWeights = [...(ent.weights ?? [])];
999
+ const nextPaused = [...(ent.paused ?? [])];
1000
+ for (const item of parsed) {
1001
+ if (!existingKeys.has(item.ref)) {
1002
+ existingKeys.add(item.ref);
1003
+ nextKeys.push(item.ref);
1004
+ nextWeights.push(item.weight);
1005
+ nextPaused.push(false);
1006
+ }
1007
+ }
1008
+ ent.keys = nextKeys;
1009
+ ent.weights = nextWeights;
1010
+ ent.paused = nextPaused;
1011
+ providers[pIndex] = ent;
1012
+ return { ...cur, providers };
1013
+ });
1014
+ setBulkText((cur) => ({ ...cur, [pIndex]: '' }));
1015
+ setBulkOpen((cur) => ({ ...cur, [pIndex]: false }));
1016
+ };
886
1017
  const undoTimer = React.useRef(null);
887
1018
  React.useEffect(() => () => { if (undoTimer.current) clearTimeout(undoTimer.current); }, []);
888
1019
  // #289 a11y: confirm dialog focus trap / Escape / restore (hooks above returns)
@@ -1451,7 +1582,8 @@ window.__ModuleLoader__.load({
1451
1582
 
1452
1583
  // Key state dot: color and label read at a glance; "key not found"
1453
1584
  // catches an env-name typo that would otherwise stay silent.
1454
- const keyStatus = (providerId, ref) => {
1585
+ const keyStatus = (providerId, ref, isPaused = false) => {
1586
+ if (isPaused) return { color: 'var(--dsw-alias-state-warning-primary)', text: t('keyPaused') };
1455
1587
  const hit = keyInfo(providerId, ref);
1456
1588
  if (!hit) return null;
1457
1589
  if (hit.expired) return { color: 'var(--dsw-alias-state-error-primary)', text: t('keyExpired') };
@@ -1460,6 +1592,7 @@ window.__ModuleLoader__.load({
1460
1592
  const warnDays = Number(val?.expiryWarnDays) || 7; // #207: configurable horizon
1461
1593
  if (days <= warnDays) return { color: 'var(--dsw-alias-state-warning-primary)', text: t('keyExpiringSoon').replace('{n}', String(days)) };
1462
1594
  }
1595
+ if (hit.broken || hit.revoked || hit.status === 'revoked') return { color: 'var(--dsw-alias-state-error-primary)', text: t('keyRevoked') };
1463
1596
  if (hit.broken) return { color: 'var(--dsw-alias-state-error-primary)', text: t('brokenKey') };
1464
1597
  if (!hit.present) return { color: 'var(--dsw-alias-state-error-primary)', text: t('keyMissing') };
1465
1598
  if (hit.cooldownMsLeft > 0 && !optimisticReset[ref]) {
@@ -1484,10 +1617,12 @@ window.__ModuleLoader__.load({
1484
1617
 
1485
1618
  const keys = entry.keys ?? [];
1486
1619
  const entryWeights = entry.weights ?? [];
1620
+ const entryPaused = entry.paused ?? [];
1487
1621
 
1488
1622
  let readyCount = 0, cooldownCount = 0, errorCount = 0;
1489
- for (const k of keys) {
1490
- const st = keyStatus(entry.provider, k);
1623
+ for (let idx = 0; idx < keys.length; idx++) {
1624
+ const k = keys[idx];
1625
+ const st = keyStatus(entry.provider, k, Boolean(entryPaused[idx]));
1491
1626
  const isCool = (st && (st.text === t('brokenKey') || (st.text && st.text.includes(t('keyCooling').slice(0, 4))))) && !optimisticReset[k];
1492
1627
  const tr = testResult[k];
1493
1628
  if (tr && !tr.ok) errorCount++;
@@ -1495,9 +1630,9 @@ window.__ModuleLoader__.load({
1495
1630
  else readyCount++;
1496
1631
  }
1497
1632
 
1498
- const filteredIndices = keys.map((k, idx) => ({ key: k, kIndex: idx })).filter(({ key: k }) => {
1633
+ const filteredIndices = keys.map((k, idx) => ({ key: k, kIndex: idx })).filter(({ key: k, kIndex: idx }) => {
1499
1634
  if (statusFilter === 'all') return true;
1500
- const st = keyStatus(entry.provider, k);
1635
+ const st = keyStatus(entry.provider, k, Boolean(entryPaused[idx]));
1501
1636
  const isCool = (st && (st.text === t('brokenKey') || (st.text && st.text.includes(t('keyCooling').slice(0, 4))))) && !optimisticReset[k];
1502
1637
  const tr = testResult[k];
1503
1638
  if (statusFilter === 'ready') return !isCool;
@@ -1514,7 +1649,8 @@ window.__ModuleLoader__.load({
1514
1649
  ) : null;
1515
1650
 
1516
1651
  const keyRows = filteredIndices.map(({ key, kIndex }) => {
1517
- const st = keyStatus(entry.provider, key);
1652
+ const isP = Boolean(entryPaused[kIndex]);
1653
+ const st = keyStatus(entry.provider, key, isP);
1518
1654
  const info = keyInfo(entry.provider, key);
1519
1655
  const rowKey = entry.provider + '/' + key;
1520
1656
  const typed = secretDraft[rowKey];
@@ -1570,6 +1706,14 @@ window.__ModuleLoader__.load({
1570
1706
  title: t('weightHint'),
1571
1707
  onChange: (e) => setKeyWeight(pIndex, kIndex, e.target.value),
1572
1708
  style: { width: '52px', padding: '2px 6px', fontSize: '12px' } }));
1709
+ meta.push(h('button', {
1710
+ key: 'pause-btn',
1711
+ type: 'button',
1712
+ className: 'krot-btn' + (isP ? ' krot-pill-warn' : ''),
1713
+ title: isP ? t('resumeKey') : t('pauseKey'),
1714
+ style: { padding: '2px 6px', fontSize: '11px' },
1715
+ onClick: () => togglePauseKey(pIndex, kIndex),
1716
+ }, isP ? '⏸ ' + t('keyPaused') : '▶'));
1573
1717
  // #210: RPM capacity indicator (only when rpmLimit is active)
1574
1718
  if (info && info.rpm) meta.push(h('span', { key: 'rpm', className: 'krot-tail',
1575
1719
  title: t('rpmTitle').replace('{u}', String(info.rpm.used)).replace('{r}', String(info.rpm.remaining)),
@@ -1701,6 +1845,10 @@ window.__ModuleLoader__.load({
1701
1845
  h('span', { style: { color: 'var(--dsw-alias-label-secondary)' } }, t('keyLabel').replace('{n}', String(x.index + 1)) + ': ' + x.usage + ' (' + pct + '%)')
1702
1846
  );
1703
1847
  })
1848
+ ) : null,
1849
+ total > 0 && keyUsages.length > 1 ? h('div', { style: { marginTop: '8px', display: 'flex', alignItems: 'center', gap: '8px' } },
1850
+ h('span', { style: { fontSize: '11px', color: 'var(--dsw-alias-label-secondary)' } }, '📈 ' + t('sparklineTraffic') + ':'),
1851
+ h(Sparkline, { points: keyUsages.map(x => x.usage), width: 140, height: 20 })
1704
1852
  ) : null
1705
1853
  );
1706
1854
  })();
@@ -1773,6 +1921,22 @@ window.__ModuleLoader__.load({
1773
1921
  h(UpdaterSection, { t }),
1774
1922
  h('div', { className: 'krot-foot' },
1775
1923
  btn(t('addKey'), () => addKey(pIndex), { title: t('addKeyTitle'), disabled: !entry.provider.trim() }),
1924
+ btn('📋 ' + t('bulkImport'), () => setBulkOpen(cur => ({ ...cur, [pIndex]: !cur[pIndex] })), { disabled: !entry.provider.trim(), title: t('bulkImport') }),
1925
+ bulkOpen[pIndex] ? h('div', { className: 'krot-bulk-import-box', style: { width: '100%', marginTop: '8px', padding: '8px', background: 'var(--dsw-alias-bg-layer-2)', borderRadius: '6px' } },
1926
+ h('p', { style: { margin: '0 0 6px 0', fontSize: '12px', color: 'var(--dsw-alias-label-secondary)' } }, t('bulkImportDesc')),
1927
+ h('textarea', {
1928
+ className: 'krot-in',
1929
+ rows: 4,
1930
+ style: { width: '100%', fontFamily: 'monospace', fontSize: '11px', resize: 'vertical' },
1931
+ placeholder: 'KEY_1\nKEY_2, weight=5\nOPENAI_KEY=sk-...',
1932
+ value: bulkText[pIndex] || '',
1933
+ onChange: (e) => setBulkText(cur => ({ ...cur, [pIndex]: e.target.value })),
1934
+ }),
1935
+ h('div', { style: { display: 'flex', gap: '8px', marginTop: '6px' } },
1936
+ btn(t('bulkImportBtn'), () => applyBulkImport(pIndex), { primary: true }),
1937
+ btn(t('bulkImportCancel'), () => setBulkOpen(cur => ({ ...cur, [pIndex]: false })))
1938
+ )
1939
+ ) : null,
1776
1940
  switchesLine,
1777
1941
  budgetLine,
1778
1942
  sloLine,
@@ -55,19 +55,21 @@ export class ConcurrencyTracker {
55
55
  return out;
56
56
  }
57
57
 
58
- pickLeastLoaded(candidates, now) {
58
+ pickLeastLoaded(candidates, now, weights) {
59
59
  now = now || Date.now();
60
60
  if (!Array.isArray(candidates) || candidates.length === 0) return null;
61
61
  let best = null;
62
- let bestCount = Infinity;
62
+ let bestScore = Infinity;
63
63
  for (const ref of candidates) {
64
64
  const e = this._inFlight.get(ref);
65
65
  let count = e ? e.count : 0;
66
66
  if (e && now - e.lastAcquired > this._staleMs) count = 0;
67
67
  if (this.isEnabled() && count >= this._limit) continue;
68
- if (count < bestCount) {
68
+ const w = (weights && Number(weights[ref]) > 0) ? Number(weights[ref]) : 1;
69
+ const score = count / w;
70
+ if (score < bestScore) {
69
71
  best = ref;
70
- bestCount = count;
72
+ bestScore = score;
71
73
  }
72
74
  }
73
75
  return best;
@@ -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
@@ -263,13 +263,13 @@ export function apply(ctx, config = {}) {
263
263
  return initializePoolState(st);
264
264
  };
265
265
 
266
- const buildPool = (base, keys, weights, poolCooldown, poolMax, expiresAt, poolStrategy, poolGuard) =>
267
- buildPoolItem({ base, keys, weights, poolCooldown, poolMax, expiresAt, poolStrategy, poolGuard, rpmLimit, makeState });
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 });
268
268
 
269
269
  for (const p of cfg.providers ?? []) {
270
270
  const poolCooldown = typeof p.cooldownMs === 'number' ? p.cooldownMs : (cfg.cooldownMs ?? 60000);
271
271
  const poolMax = typeof p.maxCooldownMs === 'number' ? p.maxCooldownMs : (cfg.maxCooldownMs ?? undefined);
272
- const pool = buildPool(p.provider, p.keys, p.weights, poolCooldown, poolMax, p.expiresAt, p.routingStrategy, p.proactiveRateLimitGuard);
272
+ const pool = buildPool(p.provider, p.keys, p.weights, p.paused, poolCooldown, poolMax, p.expiresAt, p.routingStrategy, p.proactiveRateLimitGuard);
273
273
  if (pool) {
274
274
  for (const ref of pool.refs) poolByRef.set(ref, pool);
275
275
  for (let i = 1; i < pool.refs.length; i++) cloneIds.add(`${p.provider}-${i + 1}`);
@@ -277,7 +277,7 @@ export function apply(ctx, config = {}) {
277
277
  const models = p.models ?? {};
278
278
  const byModel = new Map();
279
279
  for (const [model, mp] of Object.entries(models)) {
280
- const mpool = buildPool(`${p.provider}::${model}`, mp.keys, mp.weights, poolCooldown, poolMax, undefined, p.routingStrategy, p.proactiveRateLimitGuard);
280
+ const mpool = buildPool(`${p.provider}::${model}`, mp.keys, mp.weights, mp.paused, poolCooldown, poolMax, undefined, p.routingStrategy, p.proactiveRateLimitGuard);
281
281
  if (mpool) {
282
282
  byModel.set(model, mpool);
283
283
  for (const ref of mpool.refs) poolByRef.set(ref, mpool);
@@ -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';
@@ -15,6 +15,8 @@ export function buildPoolItem({
15
15
  base,
16
16
  keys,
17
17
  weights,
18
+ paused,
19
+ revoked,
18
20
  poolCooldown,
19
21
  poolMax,
20
22
  expiresAt,
@@ -44,11 +46,30 @@ export function buildPoolItem({
44
46
  if (exp !== undefined) parsedExpiry[refs[i]] = exp;
45
47
  }
46
48
  }
49
+ const parsedPaused = new Set();
50
+ if (Array.isArray(paused)) {
51
+ for (let i = 0; i < refs.length; i++) {
52
+ if (paused[indexed[i].index] === true) parsedPaused.add(refs[i]);
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
+ }
61
+ const weightsMap = {};
62
+ for (let i = 0; i < refs.length; i++) {
63
+ weightsMap[refs[i]] = typeof w[i] === 'number' && w[i] > 0 ? Math.floor(w[i]) : 1;
64
+ }
47
65
  return {
48
66
  base,
49
67
  refs,
50
68
  weights: refs.map((_, i) => (typeof w[i] === 'number' && w[i] > 0 ? Math.floor(w[i]) : 1)),
69
+ weightsMap,
51
70
  weightedRefs: weightedRefs.length > 0 ? weightedRefs : refs,
71
+ pausedRefs: parsedPaused,
72
+ revokedRefs: parsedRevoked,
52
73
  state: makeState(base),
53
74
  cooldownMs: poolCooldown,
54
75
  maxCooldownMs: poolMax,
package/lib/pool.js CHANGED
@@ -261,24 +261,52 @@ export function computeHealthScore(state) {
261
261
  return Math.max(0, Math.min(100, 100 - (switches * 5) - (exhaustions * 10) - (broken * 15)));
262
262
  }
263
263
 
264
+
265
+ /** Parse duration strings (e.g. '1.5s', '500ms', '2m', '1h'), numbers, or ISO dates into seconds. */
266
+ export function parseDurationToSeconds(v) {
267
+ if (typeof v === 'number') {
268
+ if (!Number.isFinite(v) || v < 0) return null;
269
+ return v;
270
+ }
271
+ if (!v || typeof v !== 'string') return null;
272
+ const s = v.trim();
273
+ const m = s.match(/^([0-9]+(?:\.[0-9]+)?)\s*(ms|s|m|h|d)?$/i);
274
+ if (m) {
275
+ const val = parseFloat(m[1]);
276
+ const unit = (m[2] || 's').toLowerCase();
277
+ if (unit === 'ms') return val / 1000;
278
+ if (unit === 's') return val;
279
+ if (unit === 'm') return val * 60;
280
+ if (unit === 'h') return val * 3600;
281
+ if (unit === 'd') return val * 86400;
282
+ }
283
+ const dt = Date.parse(s);
284
+ if (!Number.isNaN(dt)) {
285
+ return Math.max(0, (dt - Date.now()) / 1000);
286
+ }
287
+ return null;
288
+ }
289
+
290
+ /** Check if a key reference is in maintenance/paused mode. */
291
+ export function isKeyPaused(pool, ref) {
292
+ 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);
296
+ return false;
297
+ }
298
+
264
299
  /** Extract rate-limit info from an object that may carry response headers. */
265
300
  export function extractRateLimit(headers) {
266
301
  if (!headers || typeof headers !== 'object') return null;
267
- let remaining, limit, reset, retryAfter;
302
+ let remaining, limit, reset, resetMs, retryAfter;
268
303
  for (const k in headers) {
269
304
  const lower = k.toLowerCase();
270
305
  const v = headers[k];
271
306
  if (v == null) continue;
272
307
  if (lower === 'retry-after') {
273
- const num = Number(v);
274
- if (Number.isFinite(num) && num >= 0) {
275
- retryAfter = Math.ceil(num);
276
- } else {
277
- const parsed = Date.parse(v);
278
- if (!Number.isNaN(parsed)) {
279
- retryAfter = Math.max(0, Math.ceil((parsed - Date.now()) / 1000));
280
- }
281
- }
308
+ const dur = parseDurationToSeconds(v);
309
+ if (dur !== null) retryAfter = Math.ceil(dur);
282
310
  } else if (
283
311
  lower === 'x-ratelimit-remaining' ||
284
312
  lower === 'x-ratelimit-remaining-requests' ||
@@ -289,7 +317,7 @@ export function extractRateLimit(headers) {
289
317
  const n = Number(String(v));
290
318
  if (Number.isFinite(n)) remaining = n;
291
319
  } else if (
292
- (lower === 'x-ratelimit-remaining-tokens' || lower === 'anthropic-ratelimit-tokens-remaining') &&
320
+ (lower === 'x-ratelimit-remaining-tokens' || lower === 'anthropic-ratelimit-tokens-remaining' || lower === 'openai-ratelimit-remaining-tokens') &&
293
321
  remaining === undefined
294
322
  ) {
295
323
  const n = Number(String(v));
@@ -306,15 +334,21 @@ export function extractRateLimit(headers) {
306
334
  } else if (
307
335
  lower === 'x-ratelimit-reset' ||
308
336
  lower === 'x-ratelimit-reset-requests' ||
337
+ lower === 'x-ratelimit-reset-tokens' ||
309
338
  lower === 'anthropic-ratelimit-requests-reset' ||
339
+ lower === 'anthropic-ratelimit-tokens-reset' ||
310
340
  lower === 'ratelimit-reset'
311
341
  ) {
312
- const n = Number(String(v));
313
- if (Number.isFinite(n)) {
314
- reset = n;
315
- } else {
316
- const parsed = Date.parse(v);
317
- if (!Number.isNaN(parsed)) reset = parsed;
342
+ const dur = parseDurationToSeconds(v);
343
+ if (dur !== null) {
344
+ if (dur > 1000000000) {
345
+ const epochMs = dur > 100000000000 ? dur : dur * 1000;
346
+ reset = dur;
347
+ resetMs = Math.max(0, epochMs - Date.now());
348
+ } else {
349
+ reset = dur;
350
+ resetMs = Math.max(0, Math.round(dur * 1000));
351
+ }
318
352
  }
319
353
  }
320
354
  }
@@ -347,9 +381,15 @@ export function sortAttemptList(refs, strategy = 'round-robin', deps = {}) {
347
381
  if (!Array.isArray(refs) || refs.length <= 1) return (refs ?? []).slice();
348
382
  const list = refs.slice();
349
383
  if (strategy === 'least-loaded' && deps.concurrencyTracker && typeof deps.concurrencyTracker.getActive === 'function') {
384
+ const weights = deps.weights || (deps.pool && deps.pool.weightsMap) || {};
350
385
  return list.sort((a, b) => {
351
386
  const ca = deps.concurrencyTracker.getActive(a) ?? 0;
352
387
  const cb = deps.concurrencyTracker.getActive(b) ?? 0;
388
+ const wa = (weights[a] && Number(weights[a]) > 0) ? Number(weights[a]) : 1;
389
+ const wb = (weights[b] && Number(weights[b]) > 0) ? Number(weights[b]) : 1;
390
+ const scoreA = ca / wa;
391
+ const scoreB = cb / wb;
392
+ if (scoreA !== scoreB) return scoreA - scoreB;
353
393
  return ca - cb;
354
394
  });
355
395
  }
@@ -489,3 +529,13 @@ export function resetCircuitForProvider(circuitBreaker, provider) {
489
529
  });
490
530
  return ok === true;
491
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
@@ -9,10 +9,12 @@ import {
9
9
  parseRetryAfter,
10
10
  extractRateLimit,
11
11
  isRateLimited,
12
+ isKeyPaused,
13
+ isKeyRevoked,
12
14
  formatExhaustionMessage,
13
15
  sortAttemptList,
14
16
  } from './pool.js';
15
- import { pickCascadeFallback } from './cascade.js';
17
+ import { pickCascadeFallback, CASCADE_MAX_DEPTH } from './cascade.js';
16
18
  import { nextQuotaReset } from './quota-window.js';
17
19
 
18
20
  /**
@@ -52,20 +54,22 @@ export function createRotate(deps) {
52
54
  const reqStore = { pool, pickedRef: undefined, startMs: now() };
53
55
  setRotateStartMs(reqStore.startMs);
54
56
  let attemptList = (pool.weightedRefs ?? pool.refs).slice();
57
+ attemptList = attemptList.filter((r) => !isKeyPaused(pool, r) && !isKeyRevoked(pool, r));
55
58
  const strategy = pool.routingStrategy ?? runtime0.routingStrategy ?? 'round-robin';
56
59
  if (strategy !== 'round-robin') {
57
- attemptList = sortAttemptList(attemptList, strategy, { latencyHistogram, concurrencyTracker });
60
+ attemptList = sortAttemptList(attemptList, strategy, { latencyHistogram, concurrencyTracker, weights: pool.weightsMap });
58
61
  }
59
62
  if (strategy !== 'least-loaded' && runtime0.concurrencyLimit > 0 && concurrencyTracker.isEnabled()) {
60
- // #193: prefer least-loaded key within limit
63
+ // #193 / #365: prefer least-loaded key within limit with weights
61
64
  const available = attemptList.filter((r) => {
65
+ if (isKeyPaused(pool, r)) return false;
62
66
  const fu = pool.state.failedUntil.get(r) ?? 0;
63
67
  if (fu > now()) return false;
64
68
  const exp = pool.expiresAt ? pool.expiresAt[r] : undefined;
65
69
  if (exp !== undefined && now() >= exp) return false;
66
70
  return true;
67
71
  });
68
- const preferred = concurrencyTracker.pickLeastLoaded(available);
72
+ const preferred = concurrencyTracker.pickLeastLoaded(available, now(), pool.weightsMap);
69
73
  if (preferred && attemptList[0] !== preferred) {
70
74
  const list = attemptList.slice();
71
75
  const i = list.indexOf(preferred);
@@ -106,13 +110,14 @@ export function createRotate(deps) {
106
110
  if (!pool.state.authFailCounts) pool.state.authFailCounts = new Map();
107
111
  if (!pool.state.brokenUntil) pool.state.brokenUntil = new Map();
108
112
  const _cStr = String(errCode ?? '');
109
- if (_cStr === 'AUTH' || /auth/i.test(errMsg)) {
113
+ if (_cStr === 'AUTH' || /auth|unauthorized|invalid[_\s-]?api[_\s-]?key|unauthenticated/i.test(errMsg)) {
110
114
  const _c2 = (pool.state.authFailCounts.get(targetRef) ?? 0) + 1;
111
115
  pool.state.authFailCounts.set(targetRef, _c2);
112
- if (_c2 >= 3) {
113
- pool.state.brokenUntil.set(targetRef, now() + 86400000 * 30);
114
- pool.state.failedUntil.set(targetRef, now() + 86400000 * 30);
115
- }
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`);
116
121
  } else {
117
122
  pool.state.authFailCounts.delete(targetRef);
118
123
  }
@@ -240,12 +245,15 @@ export function createRotate(deps) {
240
245
  cool = rate.retryAfter * 1000;
241
246
  } else if (rate.reset && rate.reset > now()) {
242
247
  cool = rate.reset - now();
248
+ } else if (rate.reset && rate.reset > 0 && rate.reset < 86400) {
249
+ cool = Math.round(rate.reset * 1000);
243
250
  } else {
244
251
  cool = pool.cooldownMs ?? cooldownMs;
245
252
  }
246
253
  const maxCool = pool.maxCooldownMs ?? maxCooldownMs;
247
254
  const effCool = Math.min(cool, maxCool ?? cool);
248
255
  recordFailure(pool, activeRef, now(), effCool, maxCool);
256
+ pool.state.proactiveSwitches = (pool.state.proactiveSwitches ?? 0) + 1;
249
257
  pushEvent(pool, activeRef, 'RATE_LIMIT', effCool);
250
258
  logWarn(`[dsh-key-rotation] ${options.provider}: key ${activeRef} proactive pause (remaining ${String(rate.remaining ?? '?')}/${String(rate.limit ?? '?')}, cool ${Math.round(effCool / 1000)}s) — next request will rotate`);
251
259
  }
@@ -315,16 +323,25 @@ export function createRotate(deps) {
315
323
  // notify via extracted helper (see notifyExhaustion above)
316
324
  notifyExhaustion(runtime, pool, { provider: options.provider });
317
325
 
318
- // #194: cross-provider cascade failover (guarded against infinite recursion)
319
- if (!options.__isCascade && Array.isArray(runtime.cascade) && runtime.cascade.length > 0) {
326
+ // #194 / #364: cross-provider cascade failover with model mapping
327
+ const maxDepth = runtime.cascadeMaxDepth ?? CASCADE_MAX_DEPTH;
328
+ const currentDepth = options.__cascadeDepth ?? (options.__isCascade ? 1 : 0);
329
+ if (currentDepth < maxDepth && Array.isArray(runtime.cascade) && runtime.cascade.length > 0) {
320
330
  const pools = runtime.providerToPool;
321
- const fb = pickCascadeFallback(options.provider, runtime, pools);
331
+ const fb = pickCascadeFallback(options.provider, runtime, pools, options.model);
322
332
  if (fb && fb.pool && fb.pool !== pool) {
323
- logWarn(`[dsh-key-rotation] ${options.provider}: pool exhausted — cascading to ${fb.provider}`);
333
+ logWarn(`[dsh-key-rotation] ${options.provider}: pool exhausted — cascading to ${fb.provider} (model: ${fb.model || options.model || 'default'})`);
324
334
  pool.state.lastReason = 'CASCADE';
325
335
  pool.state.lastSwitchAt = now();
326
- // Re-dispatch on the fallback pool (depth-1 via __isCascade guard)
327
- const innerCascade = rotate({ ...options, provider: fb.provider, __isCascade: true }, fb.pool);
336
+ pool.state.cascadeCount = (pool.state.cascadeCount ?? 0) + 1;
337
+ pushEvent(pool, reqStore.pickedRef ?? 'pool', 'CASCADE_TRIGGERED', 0);
338
+ const innerCascade = rotate({
339
+ ...options,
340
+ provider: fb.provider,
341
+ model: fb.model || options.model,
342
+ __isCascade: true,
343
+ __cascadeDepth: currentDepth + 1,
344
+ }, fb.pool);
328
345
  for await (const chunk of innerCascade) {
329
346
  yield chunk;
330
347
  }
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.25",
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": [