@goodandready/dsh-key-rotation 0.8.14 → 0.8.16

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,25 @@
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.16 - 2026-09-20
7
+
8
+ ### Fixed
9
+ - **Header chip translation scope (#330, GitHub #7 / PR #8)**: `KeyRotationHeaderChip` in `lib/client.js` now accepts props and resolves translator `resolveT(props)` before opening popover, preventing `ReferenceError: t is not defined` and fixing disappearance on first click. Header slot registration includes `locale: NS`.
10
+ - **Live bulk key testing & bounded concurrency (#330, GitHub #9 / PR #10)**: "Test all keys" now executes live API probes (`probe: 'models'`) identically to individual testing via unified `runKeyTests()`, eliminating false-positive green status on invalid keys. Requests are serialized (concurrency 1) to avoid bursting endpoints, mutex `testRunRef` guards duplicate clicks, and returned failure error codes (`tr.code`) are displayed in the UI.
11
+
12
+ ## 0.8.15 - 2026-09-20
13
+
14
+ ### Fixed
15
+ - **Settings on the plugin's own page**: the plugin-list seat `plugins.item` is the one
16
+ the current core (0.1.6-alpha.2) renders as the plugin's page with its configuration;
17
+ the row seat alone leaves the page without the form. `KeyRotationCard` is now
18
+ registered there too (`id: 'dsh-key-rotation'`, order 60) with a **static** label —
19
+ the label is resolved while the page renders, and a locale lookup there aborts the
20
+ whole client batch. The row seat and the legacy `settings.plugin.item` card stay as
21
+ fallbacks.
22
+ - The authoring test no longer forbids every hardcoded label: it now requires the
23
+ list-seat label to be static while the card body keeps using `t(...)`.
24
+
6
25
  ## 0.8.14 - 2026-09-19
7
26
 
8
27
  ### Fixed
package/lib/client.js CHANGED
@@ -737,6 +737,7 @@ window.__ModuleLoader__.load({
737
737
  }
738
738
  };
739
739
  }, [confirmModal]);
740
+ const testRunRef = React.useRef(false);
740
741
  const [testing, setTesting] = React.useState('');
741
742
  const [testResult, setTestResult] = React.useState({});
742
743
  const [testAllProvider, setTestAllProvider] = React.useState('');
@@ -750,30 +751,59 @@ window.__ModuleLoader__.load({
750
751
  else if (providers[u.index]) { const keys=[...providers[u.index].keys]; keys.splice(Math.min(u.kIndex, keys.length), 0, u.key); providers[u.index] = { ...providers[u.index], keys }; }
751
752
  return { ...cur, providers };
752
753
  }); };
753
- const doTest = (ref) => {
754
- setTesting(ref); setTestResult((m) => ({ ...m, [ref]: null }));
755
- // #212: real API probe (models is free on most providers), not just presence
756
- fetch('/dsh-key-rotation/test', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ ref, probe: 'models' }) })
757
- .then((r) => r.json())
758
- .then((data) => setTestResult((m) => ({ ...m, [ref]: data })))
759
- .catch((e) => setTestResult((m) => ({ ...m, [ref]: { ok: false, message: String(e?.message ?? e) } })))
760
- .finally(() => setTesting(''));
754
+ // Both buttons must run the same live probe, never a presence-only check.
755
+ // Serialize probes: a large pool must not burst /models requests. The ref
756
+ // also guards repeated clicks before React has rendered disabled buttons.
757
+ const runKeyTests = async (refs, providerId = '') => {
758
+ if (testRunRef.current || refs.length === 0) return;
759
+ testRunRef.current = true;
760
+ setTestAllProvider(providerId);
761
+ setTestResult((m) => {
762
+ const next = { ...m };
763
+ for (const ref of refs) next[ref] = null;
764
+ return next;
765
+ });
766
+ if (providerId) setTestingProgress((m) => ({ ...m, [providerId]: '0/' + refs.length }));
767
+ try {
768
+ let completed = 0;
769
+ for (const ref of refs) {
770
+ setTesting(ref);
771
+ let result;
772
+ try {
773
+ const response = await fetch('/dsh-key-rotation/test', {
774
+ method: 'POST',
775
+ headers: { 'content-type': 'application/json' },
776
+ body: JSON.stringify({ ref, probe: 'models' }),
777
+ });
778
+ const data = await response.json();
779
+ if (!response.ok) {
780
+ result = { ok: false, code: data?.error?.code || ('http-' + response.status),
781
+ message: data?.error?.message || data?.message };
782
+ } else if (typeof data?.ok !== 'boolean') {
783
+ result = { ok: false, code: 'invalid-response' };
784
+ } else {
785
+ result = data;
786
+ }
787
+ } catch (e) {
788
+ result = { ok: false, code: 'error', message: String(e?.message ?? e) };
789
+ }
790
+ setTestResult((m) => ({ ...m, [ref]: result }));
791
+ completed++;
792
+ if (providerId) setTestingProgress((m) => ({ ...m, [providerId]: completed + '/' + refs.length }));
793
+ }
794
+ } finally {
795
+ setTesting('');
796
+ setTestAllProvider('');
797
+ testRunRef.current = false;
798
+ }
761
799
  };
800
+ const doTest = (ref) => runKeyTests([ref]);
762
801
  const doTestAll = (providerId) => {
763
802
  if (!val || !Array.isArray(val.providers)) return;
764
803
  const entry = val.providers.find((p) => p.provider === providerId);
765
804
  if (!entry || !Array.isArray(entry.keys)) return;
766
- const refs = entry.keys.filter((k) => k && typeof k === 'string' && k.length > 0);
767
- setTestAllProvider(providerId);
768
- Promise.all(refs.map((ref) =>
769
- fetch('/dsh-key-rotation/test', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ ref }) })
770
- .then((r) => r.json())
771
- .then((data) => ({ ref, data }))
772
- .catch((e) => ({ ref, data: { ok: false, message: String(e?.message ?? e) } }))
773
- )).then((results) => {
774
- setTestResult((m) => { const nm = { ...m }; for (const { ref, data } of results) nm[ref] = data; return nm; });
775
- setTestAllProvider('');
776
- });
805
+ const refs = [...new Set(entry.keys.filter((k) => typeof k === 'string' && k.trim().length > 0))];
806
+ return runKeyTests(refs, providerId);
777
807
  };
778
808
 
779
809
  const load = React.useCallback(() => {
@@ -1203,12 +1233,13 @@ window.__ModuleLoader__.load({
1203
1233
  const tr = testResult[key];
1204
1234
  if (tr) meta.push(h('span', { key: 'tr', className: 'krot-tail',
1205
1235
  title: (tr.message || (tr.ok ? t('testOk') : t('testFail')))
1236
+ + (!tr.ok && tr.code ? ' · ' + tr.code : '')
1206
1237
  + (tr.ok && tr.modelsCount ? ' · ' + tr.modelsCount + ' models' : '')
1207
1238
  + (tr.ok && tr.latencyMs ? ' · ' + tr.latencyMs + 'ms' : ''),
1208
1239
  style: { color: tr.ok ? 'var(--dsw-alias-state-success-primary)' : 'var(--dsw-alias-state-error-primary)', fontWeight: 700 } },
1209
- tr.ok ? (tr.modelsCount ? tr.modelsCount + 'm' : '✓') : '✕'));
1240
+ tr.ok ? (tr.modelsCount ? tr.modelsCount + 'm' : '✓') : ('✕' + (tr.code ? ' ' + tr.code : ''))));
1210
1241
  // #219: last probe from the sandbox cache, greyed when older than 24h
1211
- else if (probeCache && probeCache[key]) {
1242
+ else if (!Object.prototype.hasOwnProperty.call(testResult, key) && probeCache && probeCache[key]) {
1212
1243
  const pc = probeCache[key];
1213
1244
  const stale = Date.now() - (pc.at ?? 0) > 86400000;
1214
1245
  meta.push(h('span', { key: 'pc', className: 'krot-tail',
@@ -1216,7 +1247,7 @@ window.__ModuleLoader__.load({
1216
1247
  style: { opacity: stale ? 0.4 : 0.7, color: pc.ok ? 'var(--dsw-alias-state-success-primary)' : 'var(--dsw-alias-state-error-primary)' } },
1217
1248
  (pc.ok ? '✓' : '✕') + (pc.latencyMs ? ' ' + pc.latencyMs + 'ms' : '')));
1218
1249
  }
1219
- meta.push(h('button', { key: 't', className: 'krot-btn', onClick: () => doTest(key), disabled: testing === key, title: t('testKey') }, testing === key ? '…' : t('testKey')));
1250
+ meta.push(h('button', { key: 't', className: 'krot-btn', onClick: () => doTest(key), disabled: Boolean(testing || testAllProvider), title: t('testKey') }, testing === key ? '…' : t('testKey')));
1220
1251
  // #223: broken keys get a one-click live re-test + auto-unbreak
1221
1252
  if (info && info.broken) {
1222
1253
  meta.push(h('button', { key: 'rt', className: 'krot-btn', onClick: () => retestBroken(key), title: t('retestBroken') }, t('retestBroken')));
@@ -1429,7 +1460,7 @@ window.__ModuleLoader__.load({
1429
1460
  danger: false,
1430
1461
  onConfirm: () => doReset(entry.provider),
1431
1462
  }), { disabled: !(providerStatus && providerStatus.switches > 0) || resetting === entry.provider, title: t('resetCooldown') }),
1432
- btn(testAllProvider === entry.provider ? (testingProgress[entry.provider] ? t('testingAllProgress').replace('{n}', testingProgress[entry.provider].split('/')[0]).replace('{total}', testingProgress[entry.provider].split('/')[1]) : t('testing')) : t('testAll'), () => doTestAll(entry.provider), { disabled: testAllProvider === entry.provider, title: t('testAll') }),
1463
+ btn(testAllProvider === entry.provider ? (testingProgress[entry.provider] ? t('testingAllProgress').replace('{n}', testingProgress[entry.provider].split('/')[0]).replace('{total}', testingProgress[entry.provider].split('/')[1]) : t('testing')) : t('testAll'), () => doTestAll(entry.provider), { disabled: Boolean(testing || testAllProvider), title: t('testAll') }),
1433
1464
  exportCsv,
1434
1465
  ),
1435
1466
  );
@@ -1701,7 +1732,8 @@ window.__ModuleLoader__.load({
1701
1732
  // #201 header chip: one dot + counts for all pools. Green = all healthy,
1702
1733
  // amber = some keys cooling, red = a pool fully exhausted. Click opens the
1703
1734
  // same summary the floating dashboard shows.
1704
- function KeyRotationHeaderChip() {
1735
+ function KeyRotationHeaderChip(props) {
1736
+ const t = resolveT(props);
1705
1737
  const [snap, setSnap] = React.useState(null);
1706
1738
  const [open, setOpen] = React.useState(false);
1707
1739
  const ref = React.useRef(null);
@@ -1747,7 +1779,7 @@ window.__ModuleLoader__.load({
1747
1779
  h('button', {
1748
1780
  type: 'button',
1749
1781
  className: 'krot-header-chip',
1750
- title: 'Key Rotation',
1782
+ title: t('title'),
1751
1783
  onClick: () => setOpen((v) => !v),
1752
1784
  },
1753
1785
  h('span', { style: { width: '8px', height: '8px', borderRadius: '50%', background: color, flex: 'none', boxShadow: `0 0 6px ${color}` } }),
@@ -1756,7 +1788,7 @@ window.__ModuleLoader__.load({
1756
1788
  ),
1757
1789
  open ? h('div', { className: 'krot-popover' },
1758
1790
  h('div', { className: 'krot-pop-title' }, t('headerPoolsTitle')),
1759
- !poolEntries.length ? h('div', { style: { fontSize: '12px', color: 'var(--dsw-alias-label-tertiary)' } }, 'No active pools') : null,
1791
+ !poolEntries.length ? h('div', { style: { fontSize: '12px', color: 'var(--dsw-alias-label-tertiary)' } }, t('noActivePools')) : null,
1760
1792
  poolEntries.map(([name, p]) => {
1761
1793
  const c = p.exhausted ? 'var(--dsw-alias-state-error-primary)' : (p.healthy < p.total ? 'var(--dsw-alias-state-warning-primary)' : 'var(--dsw-alias-state-success-primary)');
1762
1794
  return h('div', { key: name, className: 'krot-pop-row' },
@@ -1780,7 +1812,7 @@ window.__ModuleLoader__.load({
1780
1812
  try {
1781
1813
  ctx.slots.inject('conversation.session.header.utilities', () =>
1782
1814
  ctx.slots.register(
1783
- { name: 'conversation.session.header.utilities', id: 'dsh-key-rotation-header-chip', order: 6 },
1815
+ { name: 'conversation.session.header.utilities', id: 'dsh-key-rotation-header-chip', order: 6, locale: NS },
1784
1816
  KeyRotationHeaderChip,
1785
1817
  ));
1786
1818
  } catch { /* slot not available in this build */ }
@@ -1815,8 +1847,17 @@ window.__ModuleLoader__.load({
1815
1847
  }
1816
1848
  // #275: card only — no top-level sidebar row. The host sidebar is a
1817
1849
  // shared flat list; a duplicate row wastes a slot and confuses Settings.
1818
- // Row seat first (the seat the current core renders), legacy seat after it.
1850
+ // Plugin-list seat (plugins.item) first: it is what the current core
1851
+ // (0.1.6-alpha.2) renders as the plugin's own page with its configuration. The
1852
+ // label is a static string on purpose — it is resolved while the page renders,
1853
+ // and a locale lookup there would take the whole client batch down with it.
1819
1854
  try {
1855
+ ctx.slots.inject('plugins.item', () =>
1856
+ ctx.slots.register(
1857
+ { name: 'plugins.item', id: ROW_ID, order: 60, label: () => 'Key Rotation', locale: NS, inject: () => ({ ctx }) },
1858
+ KeyRotationCard,
1859
+ ));
1860
+ // Row seat and the legacy seat stay as fallbacks.
1820
1861
  ctx.slots.inject('plugins.row.config', () =>
1821
1862
  ctx.slots.register(
1822
1863
  { name: 'plugins.row.config', key: ROW_CONFIG_KEY, locale: NS, inject: () => ({ ctx }) },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-key-rotation",
3
- "version": "0.8.14",
3
+ "version": "0.8.16",
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": [