@goodandready/dsh-key-rotation 0.8.66 → 0.8.67

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,22 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.8.67 - 2026-10-19
4
+
5
+ ### Features
6
+ - **Full Pool Configuration Export & Import (#504)**:
7
+ - Masked export: exports topology and pools with masked credentials (`sk-...cdef`) and hidden webhook secrets, safe for bug reports and sharing.
8
+ - Full backup export with AES-256-GCM encryption: optional passphrase encrypts configuration with PBKDF2 (100,000 iterations) using standard `node:crypto`.
9
+ - Config import route `POST /dsh-key-rotation/config-import`: supports file upload, raw JSON, or encrypted payload with passphrase decryption.
10
+ - Diff preview mode: `dryRun: true` returns summary of created/updated/removed providers and keys count before applying.
11
+ - Interactive UI in Settings card: modal drawers for Export (masked vs full, optional passphrase) and Import (file upload, merge vs replace modes, diff preview, apply).
12
+ - **Quick Key Actions in Chat Header Popover (#499)**:
13
+ - Extended `KeyRotationHeaderChip` popover: displays active provider and key tail (`...abcd`) for each configured pool.
14
+ - Interactive inline action buttons:
15
+ - ⏭️ **Skip**: advances active key cursor to the next healthy candidate in the pool without leaving chat.
16
+ - 🔄 **Reset**: immediately resets failure counts, cooldowns, and circuit breaker for the active provider.
17
+ - ⏸️ **Pause**: toggles pause state on the currently active key for immediate fault isolation.
18
+ - Endpoint `POST /dsh-key-rotation/quick-action`: secure loopback-validated operational route executing skip, reset, and pause commands with audit telemetry events.
19
+
3
20
  ## 0.8.66 - 2026-10-19
4
21
 
5
22
  ### Features
package/README.md CHANGED
@@ -67,6 +67,10 @@
67
67
  - **📡 Status extras**: per-provider `circuit` plus `meta.expectedClones` / `meta.notifyQueue`.
68
68
  - **🧪 Smoke harness**: scripted 429 → next-key → success path in `test/smoke-rotation-080.test.mjs`.
69
69
 
70
+ ### 🚀 What's New in v0.8.67
71
+ - **📥 Full Pool Config Export & Import (#504)**: Masked export for safe sharing, full backup export with AES-256-GCM encryption (PBKDF2), diff preview mode, and import with merge/replace strategies.
72
+ - **⚡ Quick Actions in Chat Header Popover (#499)**: Instant key management directly from the conversation toolbar — skip to next key, reset cooldowns, or pause problematic keys via `/dsh-key-rotation/quick-action`.
73
+
70
74
  ### 🚀 What's New in v0.8.66
71
75
  - **⏳ Dynamic Reset Scheduler (#498)**: Reads upstream `retry-after` and `x-ratelimit-reset-*` headers to set exact cooldowns (+250ms safety jitter) and schedules immediate unref wake-up timers when the upstream reset window rolls over.
72
76
  - **💰 Upstream Balance Inspector & Credit Tracking (#495)**: Live balance checks for DeepSeek, SiliconFlow, OpenRouter, and OpenAI via `/dsh-key-rotation/balance`, pre-emptive empty balance protection (`EMPTY_BALANCE_PAUSED`), persistent balance cache, and WebUI `💰 Balance` pills.
package/README.ru.md CHANGED
@@ -36,6 +36,10 @@
36
36
 
37
37
  ---
38
38
 
39
+ ### 🚀 Что нового в v0.8.67
40
+ - **📥 Экспорт и импорт полной конфигурации пулов (#504)**: Безопасный маскированный экспорт для баг-репортов, полный бэкап с AES-256-GCM шифрованием (PBKDF2), интерактивный diff-предпросмотр и режимы объединения/замены.
41
+ - **⚡ Быстрые действия в поповере шапки чата (#499)**: Управление ключами прямо из тулбара диалога — принудительный пропуск (Skip) на следующий ключ, мгновенный сброс кулдаунов (Reset) и изоляция сбойного ключа (Pause).
42
+
39
43
  ### 🚀 Что нового в v0.8.66
40
44
  - **⏳ Динамический планировщик сброса кулдауна (#498)**: Точное вычисление времени восстановления ключей по заголовкам `retry-after` и `x-ratelimit-reset-*` (+250мс джиттер) с запуском точного unref-таймера восстановления сразу по истечении окна, не дожидаясь 30-секундного периодического свипа.
41
45
  - **💰 Инспектор баланса и отслеживание кредитов (#495)**: Запрос реального баланса провайдеров (DeepSeek, SiliconFlow, OpenRouter, OpenAI) через маршрут `/dsh-key-rotation/balance`, автоматическая мягкая пауза при нулевом балансе (`EMPTY_BALANCE_PAUSED`), персистентное сохранение и плашки баланса в WebUI карточках ключей.
package/README.zh.md CHANGED
@@ -38,6 +38,10 @@
38
38
 
39
39
  ## ⚡ 概述与核心痛点
40
40
 
41
+ ### 🚀 v0.8.67 新特性
42
+ - **📥 密钥池完整配置导入与导出 (#504)**: 支持脱敏导出安全分享、AES-256-GCM 密码加密完整备份、导入差异预览 (Diff Preview) 以及合并/全量替换模式。
43
+ - **⚡ 聊天头部快捷键操作 (#499)**: 直接在对话工具栏气泡中管理密钥 — 快速跳过当前密钥 (Skip)、一键重置冷却与断路器 (Reset)、暂停故障密钥 (Pause)。
44
+
41
45
  ### 🚀 v0.8.66 新特性
42
46
  - **⏳ 基于限流响应头的动态重置调度器 (#498)**: 解析上游 `retry-after` 与 `x-ratelimit-reset-*` 响应头,设定精确冷却时间(+250ms抖动保护),并通过独立 unref 定时器在窗口重置瞬间立刻唤醒可用密钥,无需等待30秒周期性轮询。
43
47
  - **💰 上游余额与信用额度巡检 (#495)**: 支持 DeepSeek、SiliconFlow、OpenRouter、OpenAI 实时余额查询 (`/dsh-key-rotation/balance`),零余额前置软暂停 (`EMPTY_BALANCE_PAUSED`),持久化余额状态以及 WebUI 密钥卡片实时余额标签 (`💰 15.50 CNY`)。
package/lib/client.js CHANGED
@@ -261,6 +261,28 @@ window.__ModuleLoader__.load({
261
261
  autoImportBtn: '1-Click Auto-Import',
262
262
  autoImportSuccess: 'Imported {n} keys into rotation pools',
263
263
  autoImporting: 'Importing…',
264
+ quickSkip: 'Skip',
265
+ quickSkipTitle: 'Skip to next key in pool',
266
+ quickReset: 'Reset',
267
+ quickResetTitle: 'Reset cooldowns and circuit breaker for active provider',
268
+ quickPause: 'Pause',
269
+ quickPauseTitle: 'Pause or resume active key',
270
+ exportConfig: 'Export Pools',
271
+ importConfig: 'Import Pools',
272
+ exportSafe: 'Masked (Safe for sharing)',
273
+ exportFull: 'Full Backup (Includes secrets)',
274
+ exportWithPassphrase: 'Encrypted with passphrase',
275
+ importMode: 'Import Mode',
276
+ importMerge: 'Merge with existing pools',
277
+ importReplace: 'Replace entire pool configuration',
278
+ importPreview: 'Preview Diff',
279
+ importApply: 'Apply Configuration',
280
+ importSuccess: 'Configuration successfully imported and applied',
281
+ exportSuccess: 'Configuration exported successfully',
282
+ passphraseLabel: 'Passphrase (optional)',
283
+ passphrasePlaceholder: 'Enter master passphrase for encryption/decryption',
284
+ jsonConfigPlaceholder: 'Paste JSON configuration here or choose a file...',
285
+ previewSummary: 'Diff: {created} new providers, {updated} updated providers, {keys} keys ({added} new).',
264
286
  };
265
287
 
266
288
  const zh = {
@@ -472,6 +494,28 @@ window.__ModuleLoader__.load({
472
494
  autoImportBtn: '一键自动导入',
473
495
  autoImportSuccess: '成功导入 {n} 个密钥到轮换池',
474
496
  autoImporting: '正在导入…',
497
+ quickSkip: '跳过',
498
+ quickSkipTitle: '跳过并轮换到池中下一个密钥',
499
+ quickReset: '重置',
500
+ quickResetTitle: '重置当前提供商的冷却时间与断路器',
501
+ quickPause: '暂停',
502
+ quickPauseTitle: '暂停或恢复当前正在使用的密钥',
503
+ exportConfig: '导出密钥池',
504
+ importConfig: '导入密钥池',
505
+ exportSafe: '脱敏导出 (安全分享)',
506
+ exportFull: '完整备份 (包含密钥)',
507
+ exportWithPassphrase: '使用密码加密导出',
508
+ importMode: '导入模式',
509
+ importMerge: '与现有密钥池合并',
510
+ importReplace: '覆盖现有全部密钥池配置',
511
+ importPreview: '预览差异',
512
+ importApply: '应用配置',
513
+ importSuccess: '配置已成功导入并生效',
514
+ exportSuccess: '配置导出成功',
515
+ passphraseLabel: '加密密码 (可选)',
516
+ passphrasePlaceholder: '输入用于加密/解密的主密码',
517
+ jsonConfigPlaceholder: '在此粘贴 JSON 配置或选择文件...',
518
+ previewSummary: '差异: 新增 {created} 个提供商, 更新 {updated} 个提供商, 包含 {keys} 个密钥 (新增 {added} 个)。',
475
519
  };
476
520
 
477
521
  // Codes worth switching on. Host list is DEFAULT_SWITCH_CODES; config may
@@ -1186,6 +1230,111 @@ window.__ModuleLoader__.load({
1186
1230
 
1187
1231
  // Test Matrix State (#490)
1188
1232
  const [testMatrixRunning, setTestMatrixRunning] = React.useState(false);
1233
+ const [exportModal, setExportModal] = React.useState(false);
1234
+ const [exportMasked, setExportMasked] = React.useState(true);
1235
+ const [exportPassphrase, setExportPassphrase] = React.useState('');
1236
+ const [exportError, setExportError] = React.useState('');
1237
+ const [importModal, setImportModal] = React.useState(false);
1238
+ const [importText, setImportText] = React.useState('');
1239
+ const [importPassphrase, setImportPassphrase] = React.useState('');
1240
+ const [importMode, setImportMode] = React.useState('merge');
1241
+ const [importPreview, setImportPreview] = React.useState(null);
1242
+ const [importError, setImportError] = React.useState('');
1243
+ const [importRunning, setImportRunning] = React.useState(false);
1244
+
1245
+ const runExport = async () => {
1246
+ setExportError('');
1247
+ try {
1248
+ const q = new URLSearchParams();
1249
+ q.set('masked', exportMasked ? 'true' : 'false');
1250
+ if (exportPassphrase.trim()) q.set('passphrase', exportPassphrase.trim());
1251
+ const res = await fetch(`/dsh-key-rotation/export?${q.toString()}`, {
1252
+ headers: { accept: 'application/json' },
1253
+ signal: (typeof AbortSignal !== 'undefined' && typeof AbortSignal.timeout === 'function') ? AbortSignal.timeout(10000) : undefined,
1254
+ });
1255
+ const data = await res.json();
1256
+ if (!res.ok || !data.ok) throw new Error(data?.error?.message || 'Export failed');
1257
+ const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
1258
+ const url = URL.createObjectURL(blob);
1259
+ const a = document.createElement('a');
1260
+ a.href = url;
1261
+ a.download = `dsh-key-rotation-${exportMasked ? 'masked' : 'full'}-${Date.now()}.json`;
1262
+ a.click();
1263
+ URL.revokeObjectURL(url);
1264
+ setExportModal(false);
1265
+ } catch (err) {
1266
+ setExportError(String(err?.message ?? err));
1267
+ }
1268
+ };
1269
+
1270
+ const runImportPreview = async () => {
1271
+ setImportError('');
1272
+ setImportPreview(null);
1273
+ setImportRunning(true);
1274
+ try {
1275
+ let parsed;
1276
+ try { parsed = JSON.parse(importText); } catch (_) { throw new Error('Invalid JSON format'); }
1277
+ const res = await fetch('/dsh-key-rotation/config-import', {
1278
+ method: 'POST',
1279
+ headers: { 'Content-Type': 'application/json', accept: 'application/json' },
1280
+ body: JSON.stringify({
1281
+ config: parsed.config || (parsed.providers ? parsed : undefined),
1282
+ ciphertext: parsed.ciphertext,
1283
+ salt: parsed.salt,
1284
+ iv: parsed.iv,
1285
+ tag: parsed.tag,
1286
+ encrypted: parsed.encrypted,
1287
+ passphrase: importPassphrase.trim() || undefined,
1288
+ mode: importMode,
1289
+ dryRun: true,
1290
+ }),
1291
+ signal: (typeof AbortSignal !== 'undefined' && typeof AbortSignal.timeout === 'function') ? AbortSignal.timeout(10000) : undefined,
1292
+ });
1293
+ const data = await res.json();
1294
+ if (!res.ok || !data.ok) throw new Error(data?.error?.message || 'Import preview failed');
1295
+ setImportPreview(data.preview);
1296
+ } catch (err) {
1297
+ setImportError(String(err?.message ?? err));
1298
+ } finally {
1299
+ setImportRunning(false);
1300
+ }
1301
+ };
1302
+
1303
+ const runImportApply = async () => {
1304
+ setImportError('');
1305
+ setImportRunning(true);
1306
+ try {
1307
+ let parsed;
1308
+ try { parsed = JSON.parse(importText); } catch (_) { throw new Error('Invalid JSON format'); }
1309
+ const res = await fetch('/dsh-key-rotation/config-import', {
1310
+ method: 'POST',
1311
+ headers: { 'Content-Type': 'application/json', accept: 'application/json' },
1312
+ body: JSON.stringify({
1313
+ config: parsed.config || (parsed.providers ? parsed : undefined),
1314
+ ciphertext: parsed.ciphertext,
1315
+ salt: parsed.salt,
1316
+ iv: parsed.iv,
1317
+ tag: parsed.tag,
1318
+ encrypted: parsed.encrypted,
1319
+ passphrase: importPassphrase.trim() || undefined,
1320
+ mode: importMode,
1321
+ dryRun: false,
1322
+ }),
1323
+ signal: (typeof AbortSignal !== 'undefined' && typeof AbortSignal.timeout === 'function') ? AbortSignal.timeout(10000) : undefined,
1324
+ });
1325
+ const data = await res.json();
1326
+ if (!res.ok || !data.ok) throw new Error(data?.error?.message || 'Import apply failed');
1327
+ setImportModal(false);
1328
+ setImportPreview(null);
1329
+ setImportText('');
1330
+ void load();
1331
+ } catch (err) {
1332
+ setImportError(String(err?.message ?? err));
1333
+ } finally {
1334
+ setImportRunning(false);
1335
+ }
1336
+ };
1337
+
1189
1338
  const [testMatrixResults, setTestMatrixResults] = React.useState(null);
1190
1339
  const [testMatrixModal, setTestMatrixModal] = React.useState(false);
1191
1340
 
@@ -2782,7 +2931,91 @@ window.__ModuleLoader__.load({
2782
2931
  h('span', null, '💾 ' + t('sectionBackup')),
2783
2932
  ),
2784
2933
  h('div', { className: 'krot-section-desc' }, t('sectionBackupDesc')),
2785
- h('div', { className: 'krot-row' },
2934
+ exportModal ? h('div', { className: 'krot-box', style: { marginBottom: '12px', padding: '12px', border: '1px solid var(--dsw-alias-border-l2)', borderRadius: '6px', background: 'var(--dsw-alias-bg-hover)' } },
2935
+ h('div', { style: { fontWeight: 600, marginBottom: '8px' } }, '📥 ' + (t('exportConfig') || 'Export Pools')),
2936
+ h('label', { style: { display: 'flex', alignItems: 'center', gap: '6px', fontSize: '13px', cursor: 'pointer', marginBottom: '8px' } },
2937
+ h('input', { type: 'checkbox', checked: exportMasked, onChange: (e) => setExportMasked(e.target.checked) }),
2938
+ t('exportSafe') || 'Masked (Safe for sharing)'
2939
+ ),
2940
+ h('div', { style: { display: 'flex', flexDirection: 'column', gap: '4px', marginBottom: '10px' } },
2941
+ h('span', { style: { fontSize: '12px', color: 'var(--dsw-alias-label-secondary)' } }, t('passphraseLabel') || 'Passphrase (optional)'),
2942
+ h('input', {
2943
+ type: 'password',
2944
+ className: 'krot-input',
2945
+ value: exportPassphrase,
2946
+ onChange: (e) => setExportPassphrase(e.target.value),
2947
+ placeholder: t('passphrasePlaceholder') || 'Enter passphrase for AES-256-GCM encryption',
2948
+ style: { maxWidth: '360px' }
2949
+ })
2950
+ ),
2951
+ exportError ? h('div', { className: 'krot-alert krot-alert-bad', style: { marginBottom: '8px' } }, exportError) : null,
2952
+ h('div', { style: { display: 'flex', gap: '8px' } },
2953
+ btn('⬇️ ' + (t('exportConfig') || 'Download JSON'), runExport, { primary: true }),
2954
+ btn(t('cancel'), () => setExportModal(false))
2955
+ )
2956
+ ) : null,
2957
+ importModal ? h('div', { className: 'krot-box', style: { marginBottom: '12px', padding: '12px', border: '1px solid var(--dsw-alias-border-l2)', borderRadius: '6px', background: 'var(--dsw-alias-bg-hover)' } },
2958
+ h('div', { style: { fontWeight: 600, marginBottom: '8px' } }, '📤 ' + (t('importConfig') || 'Import Pools')),
2959
+ h('div', { style: { display: 'flex', gap: '8px', alignItems: 'center', marginBottom: '8px' } },
2960
+ h('label', { className: 'krot-btn', style: { cursor: 'pointer', fontSize: '12px' } },
2961
+ '📁 Choose File',
2962
+ h('input', {
2963
+ type: 'file',
2964
+ accept: '.json',
2965
+ style: { display: 'none' },
2966
+ onChange: (e) => {
2967
+ const file = e.target.files?.[0];
2968
+ if (!file) return;
2969
+ const reader = new FileReader();
2970
+ reader.onload = (ev) => { setImportText(String(ev.target?.result || '')); };
2971
+ reader.readAsText(file);
2972
+ }
2973
+ })
2974
+ ),
2975
+ h('span', { style: { fontSize: '12px', opacity: 0.7 } }, 'or paste JSON below')
2976
+ ),
2977
+ h('textarea', {
2978
+ className: 'krot-input',
2979
+ rows: 6,
2980
+ value: importText,
2981
+ onChange: (e) => { setImportText(e.target.value); setImportPreview(null); },
2982
+ placeholder: t('jsonConfigPlaceholder') || 'Paste JSON configuration here...',
2983
+ style: { width: '100%', fontFamily: 'monospace', fontSize: '11px', marginBottom: '8px' }
2984
+ }),
2985
+ h('div', { style: { display: 'flex', gap: '16px', alignItems: 'center', marginBottom: '8px' } },
2986
+ h('label', { style: { display: 'flex', alignItems: 'center', gap: '4px', fontSize: '12px' } },
2987
+ h('input', { type: 'radio', name: 'importMode', checked: importMode === 'merge', onChange: () => setImportMode('merge') }),
2988
+ t('importMerge') || 'Merge'
2989
+ ),
2990
+ h('label', { style: { display: 'flex', alignItems: 'center', gap: '4px', fontSize: '12px' } },
2991
+ h('input', { type: 'radio', name: 'importMode', checked: importMode === 'replace', onChange: () => setImportMode('replace') }),
2992
+ t('importReplace') || 'Replace'
2993
+ )
2994
+ ),
2995
+ h('div', { style: { display: 'flex', flexDirection: 'column', gap: '4px', marginBottom: '10px' } },
2996
+ h('span', { style: { fontSize: '12px', color: 'var(--dsw-alias-label-secondary)' } }, t('passphraseLabel') || 'Passphrase (if encrypted)'),
2997
+ h('input', {
2998
+ type: 'password',
2999
+ className: 'krot-input',
3000
+ value: importPassphrase,
3001
+ onChange: (e) => setImportPassphrase(e.target.value),
3002
+ placeholder: t('passphrasePlaceholder') || 'Enter passphrase for decryption',
3003
+ style: { maxWidth: '360px' }
3004
+ })
3005
+ ),
3006
+ importError ? h('div', { className: 'krot-alert krot-alert-bad', style: { marginBottom: '8px' } }, importError) : null,
3007
+ importPreview ? h('div', { className: 'krot-alert krot-alert-ok', style: { marginBottom: '8px', fontSize: '12px' } },
3008
+ `Preview: ${importPreview.createdProviders?.length || 0} created, ${importPreview.updatedProviders?.length || 0} updated, ${importPreview.keysAdded || 0} keys added, ${importPreview.totalKeys || 0} total keys.`
3009
+ ) : null,
3010
+ h('div', { style: { display: 'flex', gap: '8px' } },
3011
+ btn('🔍 ' + (t('importPreview') || 'Preview Diff'), runImportPreview, { disabled: !importText.trim() || importRunning }),
3012
+ btn('✅ ' + (t('importApply') || 'Apply Configuration'), runImportApply, { primary: true, disabled: !importText.trim() || importRunning }),
3013
+ btn(t('cancel'), () => { setImportModal(false); setImportPreview(null); })
3014
+ )
3015
+ ) : null,
3016
+ h('div', { className: 'krot-row', style: { display: 'flex', gap: '8px', flexWrap: 'wrap' } },
3017
+ btn('📥 ' + (t('exportConfig') || 'Export Pools'), () => { setExportModal((v) => !v); setImportModal(false); }, { primary: exportModal }),
3018
+ btn('📤 ' + (t('importConfig') || 'Import Pools'), () => { setImportModal((v) => !v); setExportModal(false); }, { primary: importModal }),
2786
3019
  btn(t('exportPools'), () => {
2787
3020
  const data = JSON.stringify(val.providers ?? [], null, 2);
2788
3021
  const blob = new Blob([data], { type: 'application/json' });
@@ -3001,31 +3234,34 @@ window.__ModuleLoader__.load({
3001
3234
 
3002
3235
 
3003
3236
 
3004
- // (issue 201) header chip: one dot + counts for all pools. Green = all healthy,
3005
- // amber = some keys cooling, red = a pool fully exhausted. Click opens the
3006
- // same summary the floating dashboard shows.
3237
+ // (issue 201, #499) header chip: one dot + counts for all pools. Green = all healthy,
3238
+ // amber = some keys cooling, red = a pool fully exhausted. Click opens quick actions popover.
3007
3239
  function KeyRotationHeaderChip(props) {
3008
3240
  const t = resolveT(props);
3009
3241
  const [snap, setSnap] = React.useState(null);
3010
3242
  const [open, setOpen] = React.useState(false);
3011
3243
  const [, setTick] = React.useState(0);
3244
+ const [busy, setBusy] = React.useState(false);
3012
3245
  const ref = React.useRef(null);
3013
3246
 
3247
+ const load = () => {
3248
+ if (typeof document !== 'undefined' && document.visibilityState !== 'visible') return;
3249
+ fetch('/dsh-key-rotation/health', {
3250
+ headers: { accept: 'application/json' },
3251
+ credentials: 'same-origin',
3252
+ signal: (typeof AbortSignal !== 'undefined' && typeof AbortSignal.timeout === 'function') ? AbortSignal.timeout(8000) : undefined
3253
+ })
3254
+ .then((r) => (r.ok ? r.json() : null))
3255
+ .then((d) => { if (d) setSnap(d); })
3256
+ .catch((e) => { void e; });
3257
+ };
3258
+
3014
3259
  React.useEffect(() => {
3015
- let alive = true;
3016
- const load = () => {
3017
- if (typeof document !== 'undefined' && document.visibilityState !== 'visible') return;
3018
- fetch('/dsh-key-rotation/health', { headers: { accept: 'application/json' }, credentials: 'same-origin', signal: (typeof AbortSignal !== 'undefined' && typeof AbortSignal.timeout === 'function') ? AbortSignal.timeout(8000) : undefined })
3019
- .then((r) => (r.ok ? r.json() : null))
3020
- .then((d) => { if (alive) setSnap(d); })
3021
- .catch((e) => { void e; });
3022
- };
3023
3260
  load();
3024
3261
  const id = setInterval(load, 5000);
3025
3262
  const onVis = () => { if (typeof document !== 'undefined' && document.visibilityState === 'visible') load(); };
3026
3263
  if (typeof document !== 'undefined') document.addEventListener('visibilitychange', onVis);
3027
3264
  return () => {
3028
- alive = false;
3029
3265
  clearInterval(id);
3030
3266
  if (typeof document !== 'undefined') document.removeEventListener('visibilitychange', onVis);
3031
3267
  };
@@ -3047,6 +3283,21 @@ window.__ModuleLoader__.load({
3047
3283
  return () => document.removeEventListener('click', onDocClick);
3048
3284
  }, [open]);
3049
3285
 
3286
+ const runAction = (action, provider, keyRef) => {
3287
+ setBusy(true);
3288
+ fetch('/dsh-key-rotation/quick-action', {
3289
+ method: 'POST',
3290
+ headers: { 'content-type': 'application/json', accept: 'application/json' },
3291
+ body: JSON.stringify({ action, provider, ref: keyRef }),
3292
+ credentials: 'same-origin',
3293
+ signal: (typeof AbortSignal !== 'undefined' && typeof AbortSignal.timeout === 'function') ? AbortSignal.timeout(5000) : undefined,
3294
+ })
3295
+ .then((r) => r.json())
3296
+ .then(() => { load(); })
3297
+ .catch((err) => { void err; })
3298
+ .finally(() => { setBusy(false); });
3299
+ };
3300
+
3050
3301
  let settingsVal = null;
3051
3302
  try {
3052
3303
  const sSnap = props?.settingsSource?.getSnapshot?.()?.service?.getSnapshot?.();
@@ -3076,17 +3327,45 @@ window.__ModuleLoader__.load({
3076
3327
  label,
3077
3328
  h('span', { style: { fontSize: '9px', opacity: 0.6 } }, open ? '▲' : '▼')
3078
3329
  ),
3079
- open ? h('div', { className: 'krot-popover' },
3330
+ open ? h('div', { className: 'krot-popover', style: { minWidth: '220px' } },
3080
3331
  h('div', { className: 'krot-pop-title' }, t('headerPoolsTitle')),
3081
3332
  !poolEntries.length ? h('div', { style: { fontSize: '12px', color: 'var(--dsw-alias-label-tertiary)' } }, t('noActivePools')) : null,
3082
3333
  poolEntries.map(([name, p]) => {
3083
3334
  const c = p.exhausted ? 'var(--dsw-alias-state-error-primary)' : (p.healthy < p.total ? 'var(--dsw-alias-state-warn-primary)' : 'var(--dsw-alias-state-success-primary)');
3084
- return h('div', { key: name, className: 'krot-pop-row' },
3085
- h('div', { className: 'krot-pop-name' },
3086
- h('span', { style: { width: '7px', height: '7px', borderRadius: '50%', background: c, flex: 'none' } }),
3087
- name
3335
+ const provName = p.providerName || name;
3336
+ const tail = p.lastUsedTail;
3337
+ return h('div', { key: name, className: 'krot-pop-row', style: { display: 'flex', flexDirection: 'column', gap: '4px', padding: '6px 0', borderBottom: '1px solid var(--dsw-alias-border-subtle)' } },
3338
+ h('div', { style: { display: 'flex', justifyContent: 'space-between', alignItems: 'center' } },
3339
+ h('div', { className: 'krot-pop-name' },
3340
+ h('span', { style: { width: '7px', height: '7px', borderRadius: '50%', background: c, flex: 'none' } }),
3341
+ name,
3342
+ tail ? h('span', { style: { marginLeft: '6px', fontSize: '11px', color: 'var(--dsw-alias-label-tertiary)', fontFeatureSettings: '"tnum"', fontFamily: 'monospace' } }, `...${tail}`) : null
3343
+ ),
3344
+ h('div', { className: 'krot-pop-count', style: { color: c, fontWeight: 600 } }, `${p.healthy}/${p.total}`)
3088
3345
  ),
3089
- h('div', { className: 'krot-pop-count', style: { color: c } }, `${p.healthy}/${p.total}`)
3346
+ h('div', { style: { display: 'flex', gap: '4px', marginTop: '2px' } },
3347
+ h('button', {
3348
+ type: 'button',
3349
+ disabled: busy,
3350
+ onClick: (e) => { e.stopPropagation(); runAction('skip', provName, p.lastUsed); },
3351
+ title: t('quickSkipTitle'),
3352
+ style: { padding: '2px 6px', fontSize: '11px', borderRadius: '4px', border: '1px solid var(--dsw-alias-border-subtle)', background: 'transparent', cursor: 'pointer', color: 'inherit' }
3353
+ }, `⏭️ ${t('quickSkip')}`),
3354
+ h('button', {
3355
+ type: 'button',
3356
+ disabled: busy,
3357
+ onClick: (e) => { e.stopPropagation(); runAction('reset', provName, p.lastUsed); },
3358
+ title: t('quickResetTitle'),
3359
+ style: { padding: '2px 6px', fontSize: '11px', borderRadius: '4px', border: '1px solid var(--dsw-alias-border-subtle)', background: 'transparent', cursor: 'pointer', color: 'inherit' }
3360
+ }, `🔄 ${t('quickReset')}`),
3361
+ h('button', {
3362
+ type: 'button',
3363
+ disabled: busy || !p.lastUsed,
3364
+ onClick: (e) => { e.stopPropagation(); runAction('pause', provName, p.lastUsed); },
3365
+ title: t('quickPauseTitle'),
3366
+ style: { padding: '2px 6px', fontSize: '11px', borderRadius: '4px', border: '1px solid var(--dsw-alias-border-subtle)', background: 'transparent', cursor: 'pointer', color: 'inherit' }
3367
+ }, `⏸️ ${t('quickPause')}`)
3368
+ )
3090
3369
  );
3091
3370
  })
3092
3371
  ) : null
@@ -0,0 +1,325 @@
1
+ // lib/ops-config-io.js — full configuration export and import (#504).
2
+ import crypto from 'node:crypto';
3
+ import { json, readJson, isTrustedBridgeRequest, NS } from './http-bridge.js';
4
+ import { CONFIG_EXPORT_PATH, CONFIG_IMPORT_PATH, IMPORT_PATH } from './ops-paths.js';
5
+ import { keyTail } from './pool.js';
6
+ import { plainConfig } from './index.js';
7
+
8
+ export function encryptConfig(plainText, passphrase) {
9
+ const salt = crypto.randomBytes(16);
10
+ const key = crypto.pbkdf2Sync(passphrase, salt, 100000, 32, 'sha256');
11
+ const iv = crypto.randomBytes(12);
12
+ const cipher = crypto.createCipheriv('aes-256-gcm', key, iv);
13
+ const encrypted = Buffer.concat([cipher.update(plainText, 'utf8'), cipher.final()]);
14
+ const tag = cipher.getAuthTag();
15
+ return {
16
+ encrypted: true,
17
+ algorithm: 'aes-256-gcm',
18
+ kdf: 'pbkdf2',
19
+ iterations: 100000,
20
+ salt: salt.toString('hex'),
21
+ iv: iv.toString('hex'),
22
+ tag: tag.toString('hex'),
23
+ ciphertext: encrypted.toString('hex'),
24
+ };
25
+ }
26
+
27
+ export function decryptConfig(payload, passphrase) {
28
+ if (!payload || !payload.salt || !payload.iv || !payload.tag || !payload.ciphertext) {
29
+ throw new Error('Invalid encrypted config payload structure');
30
+ }
31
+ const salt = Buffer.from(payload.salt, 'hex');
32
+ const iv = Buffer.from(payload.iv, 'hex');
33
+ const tag = Buffer.from(payload.tag, 'hex');
34
+ const ciphertext = Buffer.from(payload.ciphertext, 'hex');
35
+ const key = crypto.pbkdf2Sync(passphrase, salt, payload.iterations || 100000, 32, 'sha256');
36
+ const decipher = crypto.createDecipheriv('aes-256-gcm', key, iv);
37
+ decipher.setAuthTag(tag);
38
+ const decrypted = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
39
+ return decrypted.toString('utf8');
40
+ }
41
+
42
+ export function maskConfig(config) {
43
+ if (!config || typeof config !== 'object') return config;
44
+ const clone = JSON.parse(JSON.stringify(config));
45
+ if (Array.isArray(clone.providers)) {
46
+ for (const prov of clone.providers) {
47
+ if (Array.isArray(prov.keys)) {
48
+ prov.keys = prov.keys.map((k) => {
49
+ if (typeof k === 'string') return k.length > 8 ? `sk-...${k.slice(-4)}` : '***';
50
+ if (k && typeof k === 'object') {
51
+ return {
52
+ ...k,
53
+ value: typeof k.value === 'string' ? (k.value.length > 8 ? `sk-...${k.value.slice(-4)}` : '***') : '***',
54
+ };
55
+ }
56
+ return '***';
57
+ });
58
+ }
59
+ }
60
+ }
61
+ if (clone.webhookUrl && typeof clone.webhookUrl === 'string') {
62
+ try {
63
+ const u = new URL(clone.webhookUrl);
64
+ const parts = u.pathname.split('/');
65
+ if (parts.length > 2) {
66
+ parts[parts.length - 1] = '***';
67
+ u.pathname = parts.join('/');
68
+ }
69
+ clone.webhookUrl = u.toString();
70
+ } catch (_) {
71
+ clone.webhookUrl = '***';
72
+ }
73
+ }
74
+ if (clone.webhookActionToken) clone.webhookActionToken = '***';
75
+ return clone;
76
+ }
77
+
78
+ export function diffConfigs(currentConfig, incomingConfig, mode = 'merge') {
79
+ const currentProviders = Array.isArray(currentConfig?.providers) ? currentConfig.providers : [];
80
+ const incomingProviders = Array.isArray(incomingConfig?.providers) ? incomingConfig.providers : [];
81
+
82
+ const currentMap = new Map();
83
+ for (const p of currentProviders) {
84
+ const name = p.provider || p.name || p.id;
85
+ if (name) currentMap.set(name, p);
86
+ }
87
+
88
+ const createdProviders = [];
89
+ const updatedProviders = [];
90
+ let keysAdded = 0;
91
+ let keysUpdated = 0;
92
+
93
+ for (const p of incomingProviders) {
94
+ const name = p.provider || p.name || p.id;
95
+ if (!name) continue;
96
+ const inKeys = Array.isArray(p.keys) ? p.keys : [];
97
+ if (!currentMap.has(name)) {
98
+ createdProviders.push(name);
99
+ keysAdded += inKeys.length;
100
+ } else {
101
+ updatedProviders.push(name);
102
+ const curP = currentMap.get(name);
103
+ const curKeys = new Set(Array.isArray(curP.keys) ? curP.keys.map((k) => (typeof k === 'string' ? k : k?.value)) : []);
104
+ for (const k of inKeys) {
105
+ const val = typeof k === 'string' ? k : k?.value;
106
+ if (!curKeys.has(val)) keysAdded++;
107
+ else keysUpdated++;
108
+ }
109
+ }
110
+ }
111
+
112
+ const removedProviders = [];
113
+ if (mode === 'replace') {
114
+ const incomingNames = new Set(incomingProviders.map((p) => p.provider || p.name || p.id).filter(Boolean));
115
+ for (const [name] of currentMap.entries()) {
116
+ if (!incomingNames.has(name)) removedProviders.push(name);
117
+ }
118
+ }
119
+
120
+ const totalKeys = incomingProviders.reduce((acc, p) => acc + (Array.isArray(p.keys) ? p.keys.length : 0), 0);
121
+
122
+ return {
123
+ mode,
124
+ createdProviders,
125
+ updatedProviders,
126
+ removedProviders,
127
+ keysAdded,
128
+ keysUpdated,
129
+ totalKeys,
130
+ };
131
+ }
132
+
133
+ export function registerConfigIoRoutes(ctx, deps) {
134
+ // ── GET /dsh-key-rotation/export ──
135
+ ctx.effect(() => ctx.webServer.register({
136
+ kind: 'exact',
137
+ path: CONFIG_EXPORT_PATH,
138
+ handler: async (req, res) => {
139
+ if (req.method !== 'GET') {
140
+ json(res, 405, { error: { code: 'method', message: 'GET only' } });
141
+ return;
142
+ }
143
+ if (!isTrustedBridgeRequest(req)) {
144
+ json(res, 403, { error: { code: 'forbidden', message: 'dsh-key-rotation: export is local-only' } });
145
+ return;
146
+ }
147
+
148
+ const urlObj = new URL(req.url, 'http://127.0.0.1');
149
+ const maskedParam = urlObj.searchParams.get('masked');
150
+ const masked = maskedParam === null ? true : maskedParam !== 'false';
151
+ const passphrase = urlObj.searchParams.get('passphrase') || null;
152
+
153
+ const settings = ctx.get('settings');
154
+ let currentConfig = {};
155
+ try {
156
+ if (settings && typeof settings.get === 'function') {
157
+ currentConfig = settings.get(NS) || {};
158
+ } else if (settings && typeof settings.describe === 'function') {
159
+ const desc = settings.describe({ redactSecrets: false })?.find((c) => c.ns === NS);
160
+ currentConfig = desc?.value || {};
161
+ } else {
162
+ currentConfig = deps.buildRuntime().cfg0 || {};
163
+ }
164
+ } catch (_) {
165
+ currentConfig = deps.buildRuntime().cfg0 || {};
166
+ }
167
+
168
+ const rawConfig = plainConfig(currentConfig);
169
+ const exportData = masked ? maskConfig(rawConfig) : rawConfig;
170
+
171
+ if (passphrase) {
172
+ const encryptedPayload = encryptConfig(JSON.stringify(exportData), passphrase);
173
+ json(res, 200, {
174
+ ok: true,
175
+ version: '0.8.67',
176
+ exportedAt: new Date().toISOString(),
177
+ masked,
178
+ encrypted: true,
179
+ ...encryptedPayload,
180
+ });
181
+ return;
182
+ }
183
+
184
+ json(res, 200, {
185
+ ok: true,
186
+ version: '0.8.67',
187
+ exportedAt: new Date().toISOString(),
188
+ masked,
189
+ encrypted: false,
190
+ config: exportData,
191
+ });
192
+ },
193
+ }));
194
+
195
+ // ── POST /dsh-key-rotation/config-import ──
196
+ const handleImport = async (req, res) => {
197
+ if (req.method !== 'POST') {
198
+ json(res, 405, { error: { code: 'method', message: 'POST only' } });
199
+ return;
200
+ }
201
+ if (!isTrustedBridgeRequest(req)) {
202
+ json(res, 403, { error: { code: 'forbidden', message: 'dsh-key-rotation: import is local-only' } });
203
+ return;
204
+ }
205
+
206
+ let body;
207
+ try {
208
+ body = await readJson(req);
209
+ } catch (e) {
210
+ json(res, 400, { error: { code: 'bad-request', message: String(e?.message ?? e) } });
211
+ return;
212
+ }
213
+
214
+ let incomingConfig = null;
215
+ const passphrase = body.passphrase || null;
216
+
217
+ if (body.encrypted && body.ciphertext) {
218
+ if (!passphrase) {
219
+ json(res, 400, { error: { code: 'passphrase-required', message: 'Passphrase is required for encrypted configuration' } });
220
+ return;
221
+ }
222
+ try {
223
+ const decryptedStr = decryptConfig(body, passphrase);
224
+ incomingConfig = JSON.parse(decryptedStr);
225
+ } catch (err) {
226
+ json(res, 400, { error: { code: 'decryption-failed', message: `Decryption failed: ${err.message}` } });
227
+ return;
228
+ }
229
+ } else if (body.config) {
230
+ incomingConfig = body.config;
231
+ } else if (body.providers) {
232
+ incomingConfig = body;
233
+ } else {
234
+ json(res, 400, { error: { code: 'invalid-payload', message: 'Missing config or providers in request' } });
235
+ return;
236
+ }
237
+
238
+ if (!incomingConfig || typeof incomingConfig !== 'object') {
239
+ json(res, 400, { error: { code: 'invalid-config', message: 'Imported configuration must be an object' } });
240
+ return;
241
+ }
242
+
243
+ const mode = body.mode === 'replace' ? 'replace' : 'merge';
244
+ const dryRun = Boolean(body.dryRun);
245
+
246
+ const settings = ctx.get('settings');
247
+ let currentConfig = {};
248
+ try {
249
+ if (settings && typeof settings.get === 'function') {
250
+ currentConfig = settings.get(NS) || {};
251
+ } else if (settings && typeof settings.describe === 'function') {
252
+ const desc = settings.describe({ redactSecrets: false })?.find((c) => c.ns === NS);
253
+ currentConfig = desc?.value || {};
254
+ } else {
255
+ currentConfig = deps.buildRuntime().cfg0 || {};
256
+ }
257
+ } catch (_) {
258
+ currentConfig = deps.buildRuntime().cfg0 || {};
259
+ }
260
+ const rawCurrent = plainConfig(currentConfig);
261
+
262
+ const preview = diffConfigs(rawCurrent, incomingConfig, mode);
263
+
264
+ if (dryRun) {
265
+ json(res, 200, {
266
+ ok: true,
267
+ dryRun: true,
268
+ preview,
269
+ });
270
+ return;
271
+ }
272
+
273
+ // Apply configuration
274
+ let mergedConfig;
275
+ if (mode === 'replace') {
276
+ mergedConfig = { ...rawCurrent, ...incomingConfig };
277
+ if (Array.isArray(incomingConfig.providers)) {
278
+ mergedConfig.providers = incomingConfig.providers;
279
+ }
280
+ } else {
281
+ // Merge mode
282
+ mergedConfig = { ...rawCurrent, ...incomingConfig };
283
+ const curProviders = Array.isArray(rawCurrent.providers) ? [...rawCurrent.providers] : [];
284
+ const curMap = new Map();
285
+ for (let i = 0; i < curProviders.length; i++) {
286
+ const name = curProviders[i].provider || curProviders[i].name || curProviders[i].id;
287
+ if (name) curMap.set(name, i);
288
+ }
289
+ const inProviders = Array.isArray(incomingConfig.providers) ? incomingConfig.providers : [];
290
+ for (const p of inProviders) {
291
+ const name = p.provider || p.name || p.id;
292
+ if (!name) continue;
293
+ if (curMap.has(name)) {
294
+ const idx = curMap.get(name);
295
+ curProviders[idx] = { ...curProviders[idx], ...p };
296
+ } else {
297
+ curProviders.push(p);
298
+ }
299
+ }
300
+ mergedConfig.providers = curProviders;
301
+ }
302
+
303
+ if (settings && typeof settings.replace === 'function') {
304
+ try {
305
+ await settings.replace(NS, mergedConfig);
306
+ } catch (err) {
307
+ json(res, 500, { error: { code: 'settings-write-failed', message: err.message } });
308
+ return;
309
+ }
310
+ }
311
+
312
+ json(res, 200, {
313
+ ok: true,
314
+ applied: true,
315
+ mode,
316
+ preview,
317
+ });
318
+ };
319
+
320
+ ctx.effect(() => ctx.webServer.register({
321
+ kind: 'exact',
322
+ path: CONFIG_IMPORT_PATH,
323
+ handler: handleImport,
324
+ }));
325
+ }
package/lib/ops-paths.js CHANGED
@@ -19,3 +19,6 @@ export const DISCOVER_PATH = '/dsh-key-rotation/discover';
19
19
  export const AUTO_IMPORT_PATH = '/dsh-key-rotation/auto-import';
20
20
  export const PRESET_LIMITS_PATH = '/dsh-key-rotation/preset-limits';
21
21
  export const BALANCE_PATH = '/dsh-key-rotation/balance';
22
+ export const CONFIG_EXPORT_PATH = '/dsh-key-rotation/export';
23
+ export const CONFIG_IMPORT_PATH = '/dsh-key-rotation/config-import';
24
+ export const QUICK_ACTION_PATH = '/dsh-key-rotation/quick-action';
@@ -0,0 +1,167 @@
1
+ import { json, readJson, isTrustedBridgeRequest } from './http-bridge.js';
2
+ import { QUICK_ACTION_PATH } from './ops-paths.js';
3
+ import { keyTail, isKeyPaused } from './pool.js';
4
+
5
+ /**
6
+ * @param {object} ctx cordis context
7
+ * @param {object} deps live dependencies from apply()
8
+ */
9
+ export function registerQuickActionRoutes(ctx, deps) {
10
+ const { buildRuntime, pushEvent, circuitBreaker } = deps;
11
+
12
+ ctx.effect(() => ctx.webServer.register({
13
+ kind: 'exact',
14
+ path: QUICK_ACTION_PATH,
15
+ handler: async (req, res) => {
16
+ if (req.method !== 'POST') {
17
+ json(res, 405, { error: { code: 'method', message: 'POST only' } });
18
+ return;
19
+ }
20
+ if (!isTrustedBridgeRequest(req)) {
21
+ json(res, 403, { error: { code: 'forbidden', message: 'dsh-key-rotation: quick-action is local-only' } });
22
+ return;
23
+ }
24
+
25
+ let body;
26
+ try {
27
+ body = await readJson(req);
28
+ } catch (e) {
29
+ json(res, 400, { error: { code: 'bad-request', message: String(e?.message ?? e) } });
30
+ return;
31
+ }
32
+
33
+ const action = typeof body?.action === 'string' ? body.action.trim().toLowerCase() : '';
34
+ const provider = typeof body?.provider === 'string' ? body.provider.trim() : '';
35
+ const ref = typeof body?.ref === 'string' ? body.ref.trim() : null;
36
+
37
+ if (!action || !['skip', 'reset', 'pause'].includes(action)) {
38
+ json(res, 400, { error: { code: 'invalid-action', message: 'action must be skip, reset, or pause' } });
39
+ return;
40
+ }
41
+
42
+ if (!provider) {
43
+ json(res, 400, { error: { code: 'missing-provider', message: 'provider is required' } });
44
+ return;
45
+ }
46
+
47
+ const runtime = buildRuntime();
48
+ let targetPool = null;
49
+
50
+ const pools = runtime.pools ?? Array.from(runtime.poolByRef?.values() || []);
51
+ for (const pool of pools) {
52
+ if (pool.provider === provider || pool.base === provider) {
53
+ targetPool = pool;
54
+ break;
55
+ }
56
+ }
57
+
58
+ if (!targetPool) {
59
+ json(res, 404, { error: { code: 'pool-not-found', message: `Pool not found for provider: ${provider}` } });
60
+ return;
61
+ }
62
+
63
+ if (action === 'skip') {
64
+ const total = targetPool.refs?.length || 0;
65
+ if (total === 0) {
66
+ json(res, 400, { error: { code: 'no-keys', message: 'Pool has no keys to rotate' } });
67
+ return;
68
+ }
69
+ const currentCursor = targetPool.state.cursor ?? 0;
70
+ const nextCursor = (currentCursor + 1) % total;
71
+ targetPool.state.cursor = nextCursor;
72
+ const nextRef = targetPool.refs[nextCursor];
73
+ targetPool.state.lastUsed = nextRef;
74
+
75
+ if (typeof pushEvent === 'function') {
76
+ pushEvent(targetPool, nextRef, 'QUICK_ACTION_SKIP', 0);
77
+ }
78
+
79
+ json(res, 200, {
80
+ ok: true,
81
+ action: 'skip',
82
+ provider,
83
+ cursor: nextCursor,
84
+ activeRef: nextRef,
85
+ activeTail: keyTail(nextRef),
86
+ });
87
+ return;
88
+ }
89
+
90
+ if (action === 'reset') {
91
+ targetPool.state.failedUntil?.clear();
92
+ targetPool.state.failCounts?.clear();
93
+ targetPool.state.resetReasons?.clear();
94
+ targetPool.state.headerResetTimes?.clear();
95
+ targetPool.state.switches = 0;
96
+ targetPool.state.lastReason = null;
97
+
98
+ const breaker = circuitBreaker ?? runtime.breaker;
99
+ if (breaker) {
100
+ if (typeof breaker.reset === 'function') breaker.reset(provider);
101
+ else if (typeof breaker.onSuccess === 'function') breaker.onSuccess(provider);
102
+ }
103
+
104
+ if (typeof pushEvent === 'function') {
105
+ pushEvent(targetPool, '*', 'QUICK_ACTION_RESET', 0);
106
+ }
107
+
108
+ json(res, 200, {
109
+ ok: true,
110
+ action: 'reset',
111
+ provider,
112
+ healthyKeys: targetPool.refs?.length || 0,
113
+ });
114
+ return;
115
+ }
116
+
117
+ if (action === 'pause') {
118
+ const targetRef = ref || targetPool.state.lastUsed || targetPool.refs?.[0];
119
+ if (!targetRef) {
120
+ json(res, 400, { error: { code: 'no-ref', message: 'No key specified or active in pool' } });
121
+ return;
122
+ }
123
+
124
+ let isPaused = false;
125
+ if (!targetPool.state.pausedRefs) targetPool.state.pausedRefs = new Set();
126
+
127
+ if (targetPool.state.pausedRefs instanceof Set) {
128
+ if (targetPool.state.pausedRefs.has(targetRef)) {
129
+ targetPool.state.pausedRefs.delete(targetRef);
130
+ targetPool.state.pauseReasons?.delete(targetRef);
131
+ isPaused = false;
132
+ } else {
133
+ targetPool.state.pausedRefs.add(targetRef);
134
+ if (!targetPool.state.pauseReasons) targetPool.state.pauseReasons = new Map();
135
+ targetPool.state.pauseReasons.set(targetRef, 'MANUAL_QUICK_PAUSED');
136
+ isPaused = true;
137
+ }
138
+ } else if (Array.isArray(targetPool.state.pausedRefs)) {
139
+ const idx = targetPool.state.pausedRefs.indexOf(targetRef);
140
+ if (idx >= 0) {
141
+ targetPool.state.pausedRefs.splice(idx, 1);
142
+ targetPool.state.pauseReasons?.delete(targetRef);
143
+ isPaused = false;
144
+ } else {
145
+ targetPool.state.pausedRefs.push(targetRef);
146
+ if (!targetPool.state.pauseReasons) targetPool.state.pauseReasons = new Map();
147
+ targetPool.state.pauseReasons.set(targetRef, 'MANUAL_QUICK_PAUSED');
148
+ isPaused = true;
149
+ }
150
+ }
151
+
152
+ if (typeof pushEvent === 'function') {
153
+ pushEvent(targetPool, targetRef, isPaused ? 'KEY_PAUSED' : 'KEY_RESUMED', 0);
154
+ }
155
+
156
+ json(res, 200, {
157
+ ok: true,
158
+ action: 'pause',
159
+ provider,
160
+ ref: targetRef,
161
+ paused: isPaused,
162
+ });
163
+ return;
164
+ }
165
+ },
166
+ }));
167
+ }
package/lib/ops-status.js CHANGED
@@ -242,6 +242,8 @@ ctx.effect(() => ctx.webServer.register({
242
242
  providerName: pool.provider ?? providerOfBase(pool.base),
243
243
  basePool: basePoolH?.base ?? null,
244
244
  modelQuota: quotaAware ? { configured: true, exhaustedKeys: quotaExhausted } : null,
245
+ lastUsed: pool.state.lastUsed ?? null,
246
+ lastUsedTail: pool.state.lastUsed ? keyTail(pool.state.lastUsed) : null,
245
247
  };
246
248
  }
247
249
  let circuitOpenAny = false;
package/lib/routes-ops.js CHANGED
@@ -1,6 +1,4 @@
1
- import { registerMetricsRoutes } from './ops-metrics.js';
2
- import { registerBalanceRoutes } from './ops-balance.js';
3
- // lib/routes-ops.js — operational HTTP routes for dsh-key-rotation (#253/#312).
1
+ // lib/routes-ops.js — operational HTTP routes for dsh-key-rotation (#253/#312/#504/#499).
4
2
  // Thin orchestrator: each route family lives in its own lib/ops-*.js module.
5
3
  // Registration is injected with the live apply() dependencies.
6
4
 
@@ -9,6 +7,10 @@ import { registerTelemetryRoutes } from './ops-telemetry.js';
9
7
  import { registerKeyRoutes } from './ops-keys.js';
10
8
  import { registerTestRoutes } from './ops-test.js';
11
9
  import { registerWebhookActionRoute } from './ops-webhook.js';
10
+ import { registerMetricsRoutes } from './ops-metrics.js';
11
+ import { registerBalanceRoutes } from './ops-balance.js';
12
+ import { registerConfigIoRoutes } from './ops-config-io.js';
13
+ import { registerQuickActionRoutes } from './ops-quick-action.js';
12
14
 
13
15
  /**
14
16
  * @param {object} ctx cordis context
@@ -22,4 +24,6 @@ export function registerOpsRoutes(ctx, deps) {
22
24
  registerWebhookActionRoute(ctx, deps);
23
25
  registerMetricsRoutes(ctx, deps);
24
26
  registerBalanceRoutes(ctx, deps);
27
+ registerConfigIoRoutes(ctx, deps);
28
+ registerQuickActionRoutes(ctx, deps);
25
29
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-key-rotation",
3
- "version": "0.8.66",
3
+ "version": "0.8.67",
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": [