@goodandready/dsh-key-rotation 0.7.28 → 0.7.29

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -44,6 +44,11 @@
44
44
  - **Usage report + CSV export** (added in 0.7.28) — `GET /dsh-key-rotation/usage?days=N[&provider=…][&format=csv]` returns per-key requests/cost over the window; an *Export CSV* button in the card downloads the same data for one provider.
45
45
  - **RPM capacity indicator** (added in 0.7.28) — `/status` carries `rpm: {used, remaining, resetMs}` per key; the card shows a ⏱ counter next to the active key.
46
46
  - **Real key test probe** (added in 0.7.28) — the per-key *Test* button now sends `probe=models` through the existing `/test` route, so it validates the key against the live API (models list + latency), not just credential presence.
47
+ - **Per-key weights in the GUI** (added in 0.7.29) — a small number input per key edits its round-robin weight (1 = equal share); reorder/remove/add keep the positional `weights` array in sync.
48
+ - **Switch notifications** (added in 0.7.29) — opt-in `switchNotify: true` sends a webhook on every key switch (who failed, why, when), deduped to at most one message per provider per `switchNotifyThrottleMs` (default 60 s).
49
+ - **Budget action buttons** (added in 0.7.29) — when `webhookActionToken` is set, budget notifications carry *Pause 1h* / *Reset cooldown* buttons (same callback route as exhaustion alerts).
50
+ - **Config snapshot export/restore** (added in 0.7.29) — *Snapshot ⬇* downloads the whole config as one JSON (token fields exported empty, keys are credential names only); *Restore* imports it back — empty token fields never wipe existing secrets, and a live-looking credential in the file is rejected.
51
+ - **Last probe result per key** (added in 0.7.29) — the card polls `/sandbox-cache` and shows the most recent probe outcome (✓/✕ + latency) next to each key, greyed out when older than 24 h.
47
52
 
48
53
  ## Install
49
54
 
@@ -89,6 +94,9 @@ dsh-key-rotation:
89
94
  | `rpmLimit` | `0` | Requests-per-minute cap **per key** (0 = off). A capped key is skipped pre-emptively until its 60 s window frees up. |
90
95
  | `webhookActionToken` | `''` | Bearer token for the interactive webhook callback route. When set, exhaustion webhooks carry action buttons; empty disables them. |
91
96
  | `expiryWarnDays` | `7` | Pre-warning horizon (days) for keys with `expiresAt`: webhook + card badge. |
97
+ | `switchNotify` | `false` | Send a webhook on every key switch (opt-in — can be chatty). |
98
+ | `switchNotifyThrottleMs` | `60000` | Minimum gap between switch notifications for the same provider. |
99
+ | `providers[].weights` | `[]` | Positional round-robin weights per key (editable in the card, 0.7.29). |
92
100
  | `providers[].costBudgetDaily` / `.costBudgetWeekly` | `0` | Daily / weekly spend budget per provider (0 = off). Warn webhook from 80%. |
93
101
  | `providers[].pauseOnBudget` | `false` | Pause the whole pool for 24 h when a budget is exceeded. |
94
102
  | `providers[].tags` | `[]` | Free-form labels for a provider pool, surfaced in `GET /status`. |
package/lib/client.js CHANGED
@@ -67,6 +67,9 @@ window.__ModuleLoader__.load({
67
67
  rpmTitle: 'requests/min: {u} used, {r} remaining',
68
68
  budgetLabel: 'budget:',
69
69
  exportCsv: 'Export CSV',
70
+ weightHint: 'round-robin weight: how many times this key joins the cycle (1 = equal share)',
71
+ snapshotExport: 'Snapshot ⬇',
72
+ snapshotImport: 'Restore',
70
73
  keyHint: 'The value is stored in DSH credentials and never sent back to the browser — only its last 5 characters are shown. Names are generated for you; hover a key to see the one it uses.',
71
74
  brokenKey: 'broken (3× AUTH)',
72
75
  keyExpired: 'expired',
@@ -127,6 +130,9 @@ window.__ModuleLoader__.load({
127
130
  rpmTitle: 'запросов/мин: {u} использовано, {r} осталось',
128
131
  budgetLabel: 'бюджет:',
129
132
  exportCsv: 'CSV',
133
+ weightHint: 'вес в круге: сколько раз ключ участвует в ротации (1 = поровну)',
134
+ snapshotExport: 'Снапшот ⬇',
135
+ snapshotImport: 'Восстановить',
130
136
  keyHint: 'Значение хранится в учётных данных DSH и обратно в браузер не отдаётся — показываются только последние 5 символов. Имена переменных создаются автоматически; наведите на ключ, чтобы увидеть используемое имя.',
131
137
  brokenKey: 'сломан (3× AUTH)',
132
138
  keyExpired: 'истёк',
@@ -175,6 +181,24 @@ window.__ModuleLoader__.load({
175
181
  return byProvider;
176
182
  }
177
183
 
184
+ /** Последний probe-результат по каждому ключу (#219): /sandbox-cache. */
185
+ function useProbeCache() {
186
+ const [cache, setCache] = React.useState({});
187
+ React.useEffect(() => {
188
+ let alive = true;
189
+ const pull = () => {
190
+ fetch('/dsh-key-rotation/sandbox-cache', { headers: { accept: 'application/json' } })
191
+ .then((r) => (r.ok ? r.json() : null))
192
+ .then((data) => { if (alive && data) setCache(data); })
193
+ .catch(() => { /* кэш не критичен: карточка работает и без него */ });
194
+ };
195
+ pull();
196
+ const id = setInterval(pull, 4000);
197
+ return () => { alive = false; clearInterval(id); };
198
+ }, []);
199
+ return cache;
200
+ }
201
+
178
202
  // formatAgo moved to lib/client-helpers.js for testability — keep local alias for bundle self-containment
179
203
  function formatAgo(t, at) {
180
204
  if (!at) return '';
@@ -345,6 +369,7 @@ window.__ModuleLoader__.load({
345
369
 
346
370
  const val = draft ?? state.value;
347
371
  const status = useRotationStatus();
372
+ const probeCache = useProbeCache();
348
373
  const [resetting, setResetting] = React.useState('');
349
374
  const doReset = (providerId) => {
350
375
  setResetting(providerId);
@@ -432,6 +457,8 @@ window.__ModuleLoader__.load({
432
457
  const entry = { ...(providers[pIndex] ?? {}) };
433
458
  const allRefs = providers.flatMap((prov) => prov?.keys ?? []);
434
459
  entry.keys = [...(entry.keys ?? []), nextKeyRef(entry.provider, entry.keys, allRefs)];
460
+ // keep weights aligned with keys (#215): new key gets default weight 1
461
+ if (Array.isArray(entry.weights) && entry.weights.length > 0) entry.weights = [...entry.weights, 1];
435
462
  providers[pIndex] = entry;
436
463
  return { ...cur, providers };
437
464
  });
@@ -439,6 +466,10 @@ window.__ModuleLoader__.load({
439
466
  const next = [...(Array.isArray(cur.providers) ? cur.providers : [])];
440
467
  stashUndo({ type: 'key', index: pIndex, kIndex, key: next[pIndex]?.keys?.[kIndex] });
441
468
  next[pIndex] = { ...next[pIndex], keys: (next[pIndex].keys ?? []).filter((_, i) => i !== kIndex) };
469
+ // weights are positional - drop along with the key (#215)
470
+ if (Array.isArray(next[pIndex].weights) && next[pIndex].weights.length > 0) {
471
+ next[pIndex] = { ...next[pIndex], weights: next[pIndex].weights.filter((_, i) => i !== kIndex) };
472
+ }
442
473
  return { ...cur, providers: next };
443
474
  }); };
444
475
  const removeProvider = (pIndex) => setField((cur) => {
@@ -458,6 +489,26 @@ window.__ModuleLoader__.load({
458
489
  keys[kIndex] = keys[target];
459
490
  keys[target] = moved;
460
491
  entry.keys = keys;
492
+ // weights are positional - swap along with the keys (#215)
493
+ const weights = [...(entry.weights ?? [])];
494
+ if (weights.length > 0) {
495
+ const w = weights[kIndex];
496
+ weights[kIndex] = weights[target];
497
+ weights[target] = w;
498
+ entry.weights = weights;
499
+ }
500
+ providers[pIndex] = entry;
501
+ return { ...cur, providers };
502
+ });
503
+ // #215: set a single key's round-robin weight (integer >= 1)
504
+ const setKeyWeight = (pIndex, kIndex, weight) => setField((cur) => {
505
+ const n = Math.max(1, Math.min(1000, Math.floor(Number(weight) || 1)));
506
+ const providers = [...(cur.providers ?? [])];
507
+ const entry = { ...(providers[pIndex] ?? {}) };
508
+ const weights = [...(entry.weights ?? [])];
509
+ while (weights.length < (entry.keys ?? []).length) weights.push(1);
510
+ weights[kIndex] = n;
511
+ entry.weights = weights;
461
512
  providers[pIndex] = entry;
462
513
  return { ...cur, providers };
463
514
  });
@@ -549,6 +600,7 @@ window.__ModuleLoader__.load({
549
600
  h('option', { key: prov.id, value: prov.id }, prov.name + (prov.id !== prov.name ? ' — ' + prov.id : ''))));
550
601
 
551
602
  const keys = entry.keys ?? [];
603
+ const entryWeights = entry.weights ?? [];
552
604
  const keyRows = keys.map((key, kIndex) => {
553
605
  const st = keyStatus(entry.provider, key);
554
606
  const info = keyInfo(entry.provider, key);
@@ -601,6 +653,12 @@ window.__ModuleLoader__.load({
601
653
  }
602
654
  }
603
655
  if (info && info.lastUsedAt) meta.push(h('span', { key: 'lu', className: 'krot-tail', title: 'last used' }, formatAgo((k)=>t(k), info.lastUsedAt)));
656
+ // #215: per-key weight input (default 1)
657
+ meta.push(h('input', { key: 'w', type: 'number', min: 1, max: 1000, className: 'krot-in krot-weight',
658
+ value: (entryWeights[kIndex] ?? info?.weight ?? 1),
659
+ title: t('weightHint'),
660
+ onChange: (e) => setKeyWeight(pIndex, kIndex, e.target.value),
661
+ style: { width: '52px', padding: '2px 6px', fontSize: '12px' } }));
604
662
  // #210: RPM capacity indicator (only when rpmLimit is active)
605
663
  if (info && info.rpm) meta.push(h('span', { key: 'rpm', className: 'krot-tail',
606
664
  title: t('rpmTitle').replace('{u}', String(info.rpm.used)).replace('{r}', String(info.rpm.remaining)),
@@ -614,6 +672,15 @@ window.__ModuleLoader__.load({
614
672
  + (tr.ok && tr.latencyMs ? ' · ' + tr.latencyMs + 'ms' : ''),
615
673
  style: { color: tr.ok ? 'var(--dsw-alias-state-success-primary)' : 'var(--dsw-alias-state-error-primary)', fontWeight: 700 } },
616
674
  tr.ok ? (tr.modelsCount ? tr.modelsCount + 'm' : '✓') : '✕'));
675
+ // #219: last probe from the sandbox cache, greyed when older than 24h
676
+ else if (probeCache && probeCache[key]) {
677
+ const pc = probeCache[key];
678
+ const stale = Date.now() - (pc.at ?? 0) > 86400000;
679
+ meta.push(h('span', { key: 'pc', className: 'krot-tail',
680
+ title: 'last probe ' + (pc.at ? new Date(pc.at).toLocaleTimeString() : '') + (pc.ok ? ' ok' : ' ' + (pc.code ?? 'fail')),
681
+ style: { opacity: stale ? 0.4 : 0.7, color: pc.ok ? 'var(--dsw-alias-state-success-primary)' : 'var(--dsw-alias-state-error-primary)' } },
682
+ (pc.ok ? '✓' : '✕') + (pc.latencyMs ? ' ' + pc.latencyMs + 'ms' : '')));
683
+ }
617
684
  meta.push(h('button', { key: 't', className: 'krot-btn', onClick: () => doTest(key), disabled: testing === key, title: t('testKey') }, testing === key ? '…' : t('testKey')));
618
685
  meta.push(h('span', { key: 'a', className: 'krot-acts' },
619
686
  btn('↑', () => moveKey(pIndex, kIndex, -1), { disabled: kIndex === 0, title: t('moveUp') }),
@@ -753,6 +820,32 @@ window.__ModuleLoader__.load({
753
820
  }); } catch (err) { setSecretError(String(err.message || err)); } };
754
821
  reader.readAsText(f);
755
822
  } }))),
823
+ // #218: full snapshot export/import - one file moves the whole config
824
+ btn(t('snapshotExport'), () => {
825
+ fetch('/dsh-key-rotation/snapshot', { headers: { accept: 'application/json' } })
826
+ .then((r) => r.json())
827
+ .then((data) => {
828
+ const blob = new Blob([JSON.stringify(data.snapshot ?? {}, null, 2)], { type: 'application/json' });
829
+ const url = URL.createObjectURL(blob);
830
+ const a = document.createElement('a'); a.href = url; a.download = 'dsh-key-rotation-snapshot.json'; a.click(); URL.revokeObjectURL(url);
831
+ })
832
+ .catch((e) => setSecretError(String(e?.message ?? e)));
833
+ }, {}),
834
+ h('label', { className: 'krot-btn', style: { cursor: 'pointer' } }, t('snapshotImport'), h('input', { type: 'file', accept: '.json', style: { display: 'none' }, onChange: (e) => {
835
+ const f = e.target.files[0]; if (!f) return;
836
+ const reader2 = new FileReader();
837
+ reader2.onload = () => {
838
+ try {
839
+ const snap = JSON.parse(String(reader2.result));
840
+ if (!snap || typeof snap !== 'object' || Array.isArray(snap)) throw new Error('expected snapshot object');
841
+ fetch('/dsh-key-rotation/snapshot', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ snapshot: snap }) })
842
+ .then((r) => r.json().then((data) => ({ ok: r.ok, data })))
843
+ .then(({ ok, data }) => { if (!ok) throw new Error(data?.error?.message ?? 'import failed'); load(); })
844
+ .catch((err) => setSecretError(t('keyWriteFailed').replace('{msg}', String(err?.message ?? err))));
845
+ } catch (err) { setSecretError(String(err.message || err)); }
846
+ };
847
+ reader2.readAsText(f);
848
+ } })),
756
849
  h('p', { className: 'krot-hint' }, t('keyHint')),
757
850
  secretError ? h('p', { className: 'krot-err' }, secretError) : null,
758
851
  state.error ? h('p', { className: 'krot-err' }, state.error) : null,
package/lib/index.js CHANGED
@@ -44,6 +44,7 @@ const NS = 'dsh-key-rotation';
44
44
  /** Config bridge route (GET / PUT / DELETE), loopback-fenced like llm-fallback. */
45
45
  const CONFIG_PATH = '/dsh-key-rotation/config';
46
46
  const STATUS_PATH = '/dsh-key-rotation/status';
47
+ const SNAPSHOT_PATH = '/dsh-key-rotation/snapshot';
47
48
  const KEY_PATH = '/dsh-key-rotation/key';
48
49
  const RESET_PATH = '/dsh-key-rotation/reset';
49
50
  const IMPORT_PATH = '/dsh-key-rotation/import';
@@ -84,7 +85,28 @@ let rotationDisabled = false;
84
85
  // #207/#208 dedupe maps: one notification per key/window per day.
85
86
  const expiryNotifiedAt = new Map();
86
87
  const budgetNotifiedAt = new Map();
88
+ const switchNotifiedAt = new Map();
87
89
  const DAY_MS = 86400000;
90
+
91
+ // #216: one webhook per switch, deduped to at most one message per provider
92
+ // per switchNotifyThrottleMs. Extracted for testability.
93
+ export function notifySwitch(runtime, pool, info, hooks = { webhookSender, now: () => Date.now() }) {
94
+ if (!runtime?.notifyWebhook) return;
95
+ const throttle = Math.max(0, runtime.switchNotifyThrottleMs ?? 60000);
96
+ const last = switchNotifiedAt.get(info.provider) ?? 0;
97
+ const now = hooks.now();
98
+ if (now - last < throttle) return;
99
+ switchNotifiedAt.set(info.provider, now);
100
+ hooks.webhookSender.send(runtime.notifyWebhook, {
101
+ title: `Key switched: ${info.provider}`,
102
+ text: `${info.from} failed (${info.code}) - next key in pool`,
103
+ provider: info.provider,
104
+ kind: 'switch',
105
+ from: info.from,
106
+ code: info.code,
107
+ at: info.at,
108
+ });
109
+ }
88
110
  const MAX_EVENTS = 50;
89
111
  function pushEvent(pool, ref, reason, cooldownMs, type) {
90
112
  const ev = { at: Date.now(), ref, reason: String(reason ?? 'UNKNOWN'), cooldownMs, type: type ?? 'fail' };
@@ -191,6 +213,8 @@ export const Config = Schema.object({
191
213
  rpmLimit: Schema.number().default(0),
192
214
  webhookActionToken: Schema.string().default(''),
193
215
  expiryWarnDays: Schema.number().default(7),
216
+ switchNotify: Schema.boolean().default(false),
217
+ switchNotifyThrottleMs: Schema.number().default(60000),
194
218
  providers: Schema.array(Schema.object({
195
219
  provider: Schema.string().required(),
196
220
  keys: Schema.array(Schema.string()).default([]),
@@ -586,12 +610,20 @@ export function apply(ctx, config = {}) {
586
610
  if (hit && shouldNotifyDaily(budgetNotifiedAt, pool.base + ':budget', now)) {
587
611
  console.warn(`[dsh-key-rotation] ${pool.base}: cost budget - day $${daily.toFixed(2)}/$${budget.costBudgetDaily} week $${weekly.toFixed(2)}/$${budget.costBudgetWeekly}`);
588
612
  if (runtime.notifyWebhook) {
613
+ // #217: budget webhook gains action buttons when a callback token
614
+ // is configured (the /webhook-action route already knows these ids)
615
+ const token = runtime.webhookActionToken ?? '';
589
616
  webhookSender.send(runtime.notifyWebhook, {
590
617
  title: `Cost budget: ${pool.base}`,
591
618
  text: `day $${daily.toFixed(2)} of $${budget.costBudgetDaily} · week $${weekly.toFixed(2)} of $${budget.costBudgetWeekly}` + (verdict.exceeded || wVerdict.exceeded ? ' · EXCEEDED' : ''),
592
619
  provider: pool.base,
593
620
  kind: 'budget',
594
621
  spend: { daily, weekly },
622
+ actionToken: token || undefined,
623
+ actions: token ? [
624
+ { id: `pause-${pool.base}`, label: 'Pause 1h' },
625
+ { id: `reset-${pool.base}`, label: 'Reset cooldown' },
626
+ ] : undefined,
595
627
  });
596
628
  }
597
629
  }
@@ -688,7 +720,8 @@ export function apply(ctx, config = {}) {
688
720
  if (exp !== undefined) parsedExpiry[refs[i]] = exp;
689
721
  }
690
722
  }
691
- return { base, refs, weightedRefs: weightedRefs.length > 0 ? weightedRefs : refs,
723
+ return { base, refs, weights: refs.map((_, i) => (typeof w[i] === 'number' && w[i] > 0 ? Math.floor(w[i]) : 1)),
724
+ weightedRefs: weightedRefs.length > 0 ? weightedRefs : refs,
692
725
  state: makeState(base), cooldownMs: poolCooldown, maxCooldownMs: poolMax, expiresAt: parsedExpiry, rpmLimit };
693
726
  };
694
727
  for (const p of cfg.providers ?? []) {
@@ -743,7 +776,7 @@ export function apply(ctx, config = {}) {
743
776
  const weekly = typeof p.costBudgetWeekly === 'number' ? p.costBudgetWeekly : 0;
744
777
  if (daily > 0 || weekly > 0) providerBudgets.set(p.provider, { costBudgetDaily: daily, costBudgetWeekly: weekly, pauseOnBudget: p.pauseOnBudget ?? false });
745
778
  }
746
- return { switchCodes, cooldownMs, maxCooldownMs, notifyWebhook, notifyThreshold, incidentThreshold, concurrencyLimit, canaryProbingEnabled, canaryIntervalMs, cascade, quotaResetWindow, backupDir, backupIntervalMs, backupKeep, rotationScheduleDays, rateLimitThreshold, rpmLimit, webhookActionToken, expiryWarnDays: cfg.expiryWarnDays ?? 7, providerTags, providerBudgets, poolByRef, providerToPool, modelPoolByProvider, cloneIds };
779
+ return { switchCodes, cooldownMs, maxCooldownMs, notifyWebhook, notifyThreshold, incidentThreshold, concurrencyLimit, canaryProbingEnabled, canaryIntervalMs, cascade, quotaResetWindow, backupDir, backupIntervalMs, backupKeep, rotationScheduleDays, rateLimitThreshold, rpmLimit, webhookActionToken, expiryWarnDays: cfg.expiryWarnDays ?? 7, switchNotify: cfg.switchNotify ?? false, switchNotifyThrottleMs: cfg.switchNotifyThrottleMs ?? 60000, providerTags, providerBudgets, poolByRef, providerToPool, modelPoolByProvider, cloneIds };
747
780
  }
748
781
 
749
782
  // ── patch credentials.resolve: pool refs resolve to the next healthy key ──
@@ -944,7 +977,16 @@ export function apply(ctx, config = {}) {
944
977
  pool.state.lastReason = String(code ?? 'UNKNOWN');
945
978
  pool.state.lastSwitchAt = Date.now();
946
979
  lastFailure = chunk;
947
- console.warn(`[dsh-key-rotation] ${options.provider}: key ${String(pool.state.lastUsed ?? '?')} failed (${String(code)} ${String(message).slice(0, 100)}) next key`);
980
+ console.warn(`[dsh-key-rotation] ${options.provider}: key ${String(pool.state.lastUsed ?? '?')} failed (${String(code)} ${String(message).slice(0, 100)}) - next key`);
981
+ // #216: per-switch webhook (opt-in switchNotify), deduped per provider
982
+ if (buildRuntime().switchNotify && pool.state.lastUsed) {
983
+ notifySwitch(buildRuntime(), pool, {
984
+ provider: options.provider,
985
+ from: pool.state.lastUsed,
986
+ code: String(code ?? 'UNKNOWN'),
987
+ at: pool.state.lastSwitchAt,
988
+ });
989
+ }
948
990
  switching = true;
949
991
  break;
950
992
  }
@@ -1107,6 +1149,8 @@ export function apply(ctx, config = {}) {
1107
1149
  cooldownMsLeft: until !== undefined && until > now ? until - now : 0,
1108
1150
  // #210: RPM capacity snapshot (null when rpmLimit is off)
1109
1151
  rpm: bucketInfo(pool.state.rpmWindows, ref, pool.rpmLimit, now),
1152
+ // #215: effective round-robin weight of this key
1153
+ weight: pool.weights?.[pool.refs.indexOf(ref)] ?? 1,
1110
1154
  usage: pool.state.usageCounts?.get(ref) ?? 0,
1111
1155
  byModel: pool.state.byModel?.get(ref) ? Object.fromEntries(pool.state.byModel.get(ref)) : {},
1112
1156
  usageDays: pool.state.usageDays?.get(ref) ? Object.fromEntries(pool.state.usageDays.get(ref)) : {},
@@ -1182,6 +1226,58 @@ export function apply(ctx, config = {}) {
1182
1226
  },
1183
1227
  }), 'dsh-key-rotation: usage route');
1184
1228
 
1229
+ // #218: full config snapshot - one JSON file to move between machines.
1230
+ // Secret values never travel: only credential/env names. Token fields are
1231
+ // exported as empty strings; on import they keep existing values when empty.
1232
+ ctx.effect(() => ctx.webServer.register({
1233
+ kind: 'exact',
1234
+ path: SNAPSHOT_PATH,
1235
+ handler: async (req, res) => {
1236
+ if (req.method !== 'GET' && req.method !== 'POST') { json(res, 405, { error: { code: 'method', message: 'GET (export) or POST (import) only' } }); return; }
1237
+ if (!isTrustedBridgeRequest(req)) { json(res, 403, { error: { code: 'forbidden', message: 'dsh-key-rotation: snapshot is local-only' } }); return; }
1238
+ if (req.method === 'GET') {
1239
+ const descriptor = descriptorOf(ctx, NS);
1240
+ const value = descriptor?.value ?? {};
1241
+ const exportable = { ...value };
1242
+ // token-shaped fields stay empty in the file; refs are names, not secrets
1243
+ exportable.webhookActionToken = '';
1244
+ if (exportable.incidentGitHubToken) exportable.incidentGitHubToken = '';
1245
+ json(res, 200, { at: Date.now(), version: 1, snapshot: exportable });
1246
+ return;
1247
+ }
1248
+ // POST = import: { snapshot } -> merge with current section, PUT semantics
1249
+ let body;
1250
+ try { body = await readJson(req); } catch (e) { json(res, 400, { error: { code: 'bad-request', message: String(e?.message ?? e) } }); return; }
1251
+ const snap = body?.snapshot;
1252
+ if (!snap || typeof snap !== 'object' || Array.isArray(snap)) { json(res, 400, { error: { code: 'bad-format', message: 'dsh-key-rotation: POST requires {"snapshot": {...}}' } }); return; }
1253
+ // #200 leak guard applies to imported content too
1254
+ try {
1255
+ const masked = structuredClone(snap);
1256
+ if (masked.incidentGitHubToken) masked.incidentGitHubToken = '***';
1257
+ if (masked.webhookActionToken) masked.webhookActionToken = '***';
1258
+ if (masked.notifyWebhook) masked.notifyWebhook = '***';
1259
+ const findings = findSecrets(JSON.stringify(masked));
1260
+ if (findings.length > 0) { json(res, 400, { error: { code: 'secret-in-snapshot', message: 'dsh-key-rotation: snapshot carries a live-looking credential', findings } }); return; }
1261
+ } catch { /* scanning must never block a valid import */ }
1262
+ const settings = ctx.get('settings');
1263
+ if (!settings) { json(res, 503, { error: { code: 'settings-rejected', message: 'dsh-key-rotation: no settings provider' } }); return; }
1264
+ const desc = descriptorOf(ctx, NS);
1265
+ if (desc === void 0) { json(res, 500, { error: { code: 'settings-rejected', message: 'dsh-key-rotation: namespace missing' } }); return; }
1266
+ const cur = desc.value ?? {};
1267
+ // empty token fields in the file keep the current values (never wipe a secret)
1268
+ const merged = { ...cur, ...snap };
1269
+ if (!snap.webhookActionToken) merged.webhookActionToken = cur.webhookActionToken ?? '';
1270
+ if (!snap.incidentGitHubToken) merged.incidentGitHubToken = cur.incidentGitHubToken ?? '';
1271
+ try {
1272
+ await settings.replace(NS, merged, desc.revision);
1273
+ const after = descriptorOf(ctx, NS);
1274
+ json(res, 200, { ok: true, revision: after?.revision });
1275
+ } catch (e) {
1276
+ json(res, e?.code === 'SETTINGS_CONFLICT' ? 409 : 400, { error: { code: 'settings-rejected', message: String(e?.message ?? e) } });
1277
+ }
1278
+ },
1279
+ }), 'dsh-key-rotation: snapshot route');
1280
+
1185
1281
  // ── key route: store a key value without leaving the rotation card ──
1186
1282
  //
1187
1283
  // Adding a key used to mean two screens: create the credential elsewhere,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-key-rotation",
3
- "version": "0.7.28",
3
+ "version": "0.7.29",
4
4
  "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.",
5
5
  "keywords": [
6
6
  "deepseek-harness",