@goodandready/dsh-key-rotation 0.8.13 → 0.8.15

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,39 @@
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.15 - 2026-09-20
7
+
8
+ ### Fixed
9
+ - **Settings on the plugin's own page**: the plugin-list seat `plugins.item` is the one
10
+ the current core (0.1.6-alpha.2) renders as the plugin's page with its configuration;
11
+ the row seat alone leaves the page without the form. `KeyRotationCard` is now
12
+ registered there too (`id: 'dsh-key-rotation'`, order 60) with a **static** label —
13
+ the label is resolved while the page renders, and a locale lookup there aborts the
14
+ whole client batch. The row seat and the legacy `settings.plugin.item` card stay as
15
+ fallbacks.
16
+ - The authoring test no longer forbids every hardcoded label: it now requires the
17
+ list-seat label to be static while the card body keeps using `t(...)`.
18
+
19
+ ## 0.8.14 - 2026-09-19
20
+
21
+ ### Fixed
22
+ - **Critical fix: switchable error rotation crash (#324, GitHub #3 / PR #4)**: `isSwitchableError()` in `lib/pool.js` now normalizes `switchCodes` to a `Set` when passed as an array. Previously, runtime schema arrays (`cfg.switchCodes`, `DEFAULT_SWITCH_CODES`) caused `TypeError: switchCodes.has is not a function`, crashing turns on rate limits, quota exhaustion, and 5xx errors instead of rotating keys.
23
+ - **Critical fix: settings UI render crash (#324, GitHub #2 / PR #5)**: declared `UpdaterSection` at factory scope in `lib/client.js` before `KeyRotationSection`, removing the `apply(ctx)`-local declaration that caused `ReferenceError: UpdaterSection is not defined`.
24
+ - **Security hardening: packageSpec validation (#324, GitHub #6)**: added strict package spec regex validation in `lib/plugin-updater.js` `installExact()` before child process invocation, preventing command-option injection.
25
+ - **Settings reachable again**: the card registered into `settings.plugin.item`, a
26
+ slot the current DSH core (0.1.6-alpha.2) no longer renders, so the plugin's
27
+ settings were unreachable. The surface now registers into the Plugins page row
28
+ seat `plugins.row.config` first, keyed
29
+ `@goodandready/dsh-key-rotation#dsh-key-rotation` (`rowConfigKey(package, rowId)`):
30
+ the plugin's row gains a configure control whose page is the settings form
31
+ (`view: 'page'`, open and without our card chrome — the host page draws the title,
32
+ icon, crumb and padding) plus a one-line state for `view: 'summary'`. The legacy
33
+ seat stays registered as a fallback for older cores.
34
+
35
+ Version bump and public release are intentionally held back for now: the feature
36
+ work on `feat/issue-303-v089-evolution` is still in flight, so this change lands in
37
+ `main` without a release.
38
+
6
39
  ## 0.8.13 - 2026-09-17
7
40
 
8
41
  ### Changed
package/lib/client.js CHANGED
@@ -32,6 +32,10 @@ window.__ModuleLoader__.load({
32
32
 
33
33
  const CONFIG_PATH = '/dsh-key-rotation/config';
34
34
  const NS = 'dsh-key-rotation';
35
+ // Plugins page row seat (DSH 0.1.6-alpha.2): key = '<package name>#<row id>'.
36
+ const PKG = '@goodandready/dsh-key-rotation';
37
+ const ROW_ID = 'dsh-key-rotation';
38
+ const ROW_CONFIG_KEY = PKG + '#' + ROW_ID;
35
39
 
36
40
  // -------------------------------------------------------------- i18n
37
41
  const en = {
@@ -574,6 +578,70 @@ window.__ModuleLoader__.load({
574
578
  };
575
579
  }
576
580
 
581
+ function UpdaterSection({ t }) {
582
+ const [state, setState] = React.useState({ phase: 'idle', current: '', latest: '', message: '' });
583
+ const load = React.useCallback(async () => {
584
+ setState((s) => ({ ...s, phase: 'loading', message: '' }));
585
+ try {
586
+ const res = await fetch('/api/dsh-key-rotation/update', { headers: { 'x-dsh-plugin-update': '1' } });
587
+ const data = await res.json();
588
+ if (!res.ok) throw new Error(data?.error?.message || t('updateFailed'));
589
+ setState({
590
+ phase: data.updateAvailable ? 'available' : 'current',
591
+ current: data.currentVersion || '',
592
+ latest: data.latestVersion || '',
593
+ message: data.updateAvailable ? t('updateAvailable') : t('updateNone'),
594
+ });
595
+ } catch (e) {
596
+ setState({ phase: 'error', current: '', latest: '', message: e?.message || t('updateFailed') });
597
+ }
598
+ }, [t]);
599
+ React.useEffect(() => { load(); }, [load]);
600
+ const run = async () => {
601
+ setState((s) => ({ ...s, phase: 'running', message: t('updateRunning') }));
602
+ try {
603
+ const res = await fetch('/api/dsh-key-rotation/update', {
604
+ method: 'POST',
605
+ headers: { 'x-dsh-plugin-update': '1', 'content-type': 'application/json' },
606
+ });
607
+ const data = await res.json();
608
+ if (!res.ok) throw new Error(data?.error?.message || t('updateFailed'));
609
+ setState({
610
+ phase: 'done',
611
+ current: data.currentVersion || '',
612
+ latest: data.installedVersion || data.latestVersion || '',
613
+ message: t('updateRestart'),
614
+ });
615
+ } catch (e) {
616
+ setState({ phase: 'error', current: '', latest: '', message: e?.message || t('updateFailed') });
617
+ }
618
+ };
619
+ return h('div', { className: 'krot-update', style: { marginTop: 12, paddingTop: 12, borderTop: '1px solid var(--dsw-alias-border-l2)' } },
620
+ h('div', { style: { display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap' } },
621
+ h('span', { style: { fontSize: 12, color: 'var(--dsw-alias-label-secondary)' } },
622
+ t('currentVersion') + ': ' + (state.current || '—')),
623
+ state.latest ? h('span', { style: { fontSize: 12, color: 'var(--dsw-alias-label-secondary)' } },
624
+ t('latestVersion') + ': ' + state.latest) : null,
625
+ h('button', {
626
+ className: 'krot-btn',
627
+ type: 'button',
628
+ disabled: state.phase === 'running' || state.phase === 'loading',
629
+ onClick: load,
630
+ }, t('updateCheck')),
631
+ state.phase === 'available' ? h('button', {
632
+ className: 'krot-btn krot-btn-primary',
633
+ type: 'button',
634
+ disabled: state.phase === 'running',
635
+ onClick: run,
636
+ }, t('updateRun')) : null,
637
+ ),
638
+ state.message ? h('div', {
639
+ className: state.phase === 'error' ? 'krot-alert-bad' : state.phase === 'done' ? 'krot-alert-ok' : '',
640
+ style: { marginTop: 8, fontSize: 12 },
641
+ }, state.message) : null,
642
+ );
643
+ }
644
+
577
645
  function KeyRotationSection(props) {
578
646
  const t = resolveT(props);
579
647
  const settingsScope = React.useMemo(() => {
@@ -1725,94 +1793,51 @@ window.__ModuleLoader__.load({
1725
1793
  // (settings.plugin.item), matching Model Sync / Spendmeter / Vision Bridge.
1726
1794
  // key MUST equal the settings namespace (NS), else the tab silently skips it.
1727
1795
 
1728
- function UpdaterSection({ t }) {
1729
- const [state, setState] = React.useState({ phase: 'idle', current: '', latest: '', message: '' });
1730
- const load = React.useCallback(async () => {
1731
- setState((s) => ({ ...s, phase: 'loading', message: '' }));
1732
- try {
1733
- const res = await fetch('/api/dsh-key-rotation/update', { headers: { 'x-dsh-plugin-update': '1' } });
1734
- const data = await res.json();
1735
- if (!res.ok) throw new Error(data?.error?.message || t('updateFailed'));
1736
- setState({
1737
- phase: data.updateAvailable ? 'available' : 'current',
1738
- current: data.currentVersion || '',
1739
- latest: data.latestVersion || '',
1740
- message: data.updateAvailable ? t('updateAvailable') : t('updateNone'),
1741
- });
1742
- } catch (e) {
1743
- setState({ phase: 'error', current: '', latest: '', message: e?.message || t('updateFailed') });
1744
- }
1745
- }, [t]);
1746
- React.useEffect(() => { load(); }, [load]);
1747
- const run = async () => {
1748
- setState((s) => ({ ...s, phase: 'running', message: t('updateRunning') }));
1749
- try {
1750
- const res = await fetch('/api/dsh-key-rotation/update', {
1751
- method: 'POST',
1752
- headers: { 'x-dsh-plugin-update': '1', 'content-type': 'application/json' },
1753
- });
1754
- const data = await res.json();
1755
- if (!res.ok) throw new Error(data?.error?.message || t('updateFailed'));
1756
- setState({
1757
- phase: 'done',
1758
- current: data.currentVersion || '',
1759
- latest: data.installedVersion || data.latestVersion || '',
1760
- message: t('updateRestart'),
1761
- });
1762
- } catch (e) {
1763
- setState({ phase: 'error', current: '', latest: '', message: e?.message || t('updateFailed') });
1764
- }
1765
- };
1766
- return h('div', { className: 'krot-update', style: { marginTop: 12, paddingTop: 12, borderTop: '1px solid var(--dsw-alias-border-l2)' } },
1767
- h('div', { style: { display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap' } },
1768
- h('span', { style: { fontSize: 12, color: 'var(--dsw-alias-label-secondary)' } },
1769
- t('currentVersion') + ': ' + (state.current || '—')),
1770
- state.latest ? h('span', { style: { fontSize: 12, color: 'var(--dsw-alias-label-secondary)' } },
1771
- t('latestVersion') + ': ' + state.latest) : null,
1772
- h('button', {
1773
- className: 'krot-btn',
1774
- type: 'button',
1775
- disabled: state.phase === 'running' || state.phase === 'loading',
1776
- onClick: load,
1777
- }, t('updateCheck')),
1778
- state.phase === 'available' ? h('button', {
1779
- className: 'krot-btn krot-btn-primary',
1780
- type: 'button',
1781
- disabled: state.phase === 'running',
1782
- onClick: run,
1783
- }, t('updateRun')) : null,
1784
- ),
1785
- state.message ? h('div', {
1786
- className: state.phase === 'error' ? 'krot-alert-bad' : state.phase === 'done' ? 'krot-alert-ok' : '',
1787
- style: { marginTop: 8, fontSize: 12 },
1788
- }, state.message) : null,
1789
- );
1790
- }
1791
-
1792
1796
  function KeyRotationCard(props) {
1793
1797
  const locale = useLocale();
1794
1798
  const t = resolveT(props);
1795
- const [open, setOpen] = React.useState(false);
1796
- return h('div', { className: 'krot-card' + (open ? ' krot-card-open' : '') },
1797
- h('button', { type: 'button', className: 'krot-card-header', 'aria-expanded': open, onClick: () => setOpen((v) => !v) },
1799
+ const page = !!(props && props.view === 'page');
1800
+ const [open, setOpen] = React.useState(!!page);
1801
+ // Row seat (plugins.row.config): the host page draws title/icon/crumb and the
1802
+ // padding, so the summary is a one-liner and the page drops our card chrome.
1803
+ if (props && props.view === 'summary') {
1804
+ return h('span', { className: 'krot-card-description' }, t('subtitle'));
1805
+ }
1806
+ return h(page ? 'div' : 'div', { className: page ? 'krot-page' : 'krot-card' + (open ? ' krot-card-open' : '') },
1807
+ h('button', { type: 'button', className: 'krot-card-header', style: page ? { display: 'none' } : undefined, 'aria-expanded': page ? true : open, onClick: () => setOpen((v) => !v) },
1798
1808
  h('span', { className: 'krot-card-head-text' },
1799
1809
  h('span', { className: 'krot-card-name' }, t('title')),
1800
1810
  h('span', { className: 'krot-card-description' }, t('subtitle'))),
1801
1811
  h('span', { className: 'krot-card-chevron' + (open ? ' krot-card-chevron-open' : ''), 'aria-hidden': 'true' },
1802
1812
  h('svg', { width: 14, height: 14, viewBox: '0 0 14 14', fill: 'none', stroke: 'currentColor', strokeWidth: 1.5, style: { display: 'block' } },
1803
1813
  h('path', { d: 'M3.5 5.25L7 8.75L10.5 5.25' })))),
1804
- open ? h('div', { className: 'krot-card-body' }, h(KeyRotationErrorBoundary, null, h(KeyRotationSection, { ...props, locale }))) : null);
1814
+ (page || open) ? h('div', { className: 'krot-card-body' }, h(KeyRotationErrorBoundary, null, h(KeyRotationSection, { ...props, locale }))) : null);
1805
1815
  }
1806
1816
  // #275: card only — no top-level sidebar row. The host sidebar is a
1807
1817
  // shared flat list; a duplicate row wastes a slot and confuses Settings.
1818
+ // Plugin-list seat (plugins.item) first: it is what the current core
1819
+ // (0.1.6-alpha.2) renders as the plugin's own page with its configuration. The
1820
+ // label is a static string on purpose — it is resolved while the page renders,
1821
+ // and a locale lookup there would take the whole client batch down with it.
1808
1822
  try {
1823
+ ctx.slots.inject('plugins.item', () =>
1824
+ ctx.slots.register(
1825
+ { name: 'plugins.item', id: ROW_ID, order: 60, label: () => 'Key Rotation', locale: NS, inject: () => ({ ctx }) },
1826
+ KeyRotationCard,
1827
+ ));
1828
+ // Row seat and the legacy seat stay as fallbacks.
1829
+ ctx.slots.inject('plugins.row.config', () =>
1830
+ ctx.slots.register(
1831
+ { name: 'plugins.row.config', key: ROW_CONFIG_KEY, locale: NS, inject: () => ({ ctx }) },
1832
+ KeyRotationCard,
1833
+ ));
1809
1834
  ctx.slots.inject('settings.plugin.item', () =>
1810
1835
  ctx.slots.register(
1811
1836
  { name: 'settings.plugin.item', key: NS, locale: NS, inject: () => ({ ctx }) },
1812
1837
  KeyRotationCard,
1813
1838
  ));
1814
1839
  } catch (e) {
1815
- bestEffort('slots.register.log', () => { console.error('[dsh-key-rotation] settings.plugin.item register failed', e); });
1840
+ bestEffort('slots.register.log', () => { console.error('[dsh-key-rotation] settings seats register failed', e); });
1816
1841
  }
1817
1842
  }
1818
1843
 
@@ -181,8 +181,15 @@ async function status(options, target) {
181
181
  }
182
182
  }
183
183
 
184
+ export const PACKAGE_SPEC_PATTERN = /^(@[a-z0-9~][a-z0-9_.~-]*\/)?[a-z0-9~][a-z0-9_.~-]*(@[0-9a-zA-Z_.~+-]+)?$/i
185
+
186
+ export function isValidPackageSpec(packageSpec) {
187
+ return typeof packageSpec === 'string' && PACKAGE_SPEC_PATTERN.test(packageSpec)
188
+ }
189
+
184
190
  async function installExact(target, packageSpec, options) {
185
191
  if (target.cliEntry === undefined) throw new Error('Automatic update is unavailable in this runtime.')
192
+ if (!isValidPackageSpec(packageSpec)) throw new Error(`Invalid package spec: ${packageSpec}`)
186
193
  await new Promise((resolvePromise, reject) => {
187
194
  const child = spawn(process.execPath, [
188
195
  target.cliEntry, 'plugin', '--profile', target.profileName, 'add',
package/lib/pool.js CHANGED
@@ -362,6 +362,9 @@ export function sortAttemptList(refs, strategy = 'round-robin', deps = {}) {
362
362
  */
363
363
  export function isSwitchableError(failureOrPayload, switchCodes = new Set(DEFAULT_SWITCH_CODES)) {
364
364
  if (!failureOrPayload) return false;
365
+ // Runtime switch codes arrive as an array (cfg.switchCodes is a schema array,
366
+ // and DEFAULT_SWITCH_CODES is exported as an array); normalize before .has().
367
+ if (!(switchCodes instanceof Set)) switchCodes = new Set(Array.isArray(switchCodes) ? switchCodes : DEFAULT_SWITCH_CODES);
365
368
  const failure = failureOrPayload.failure ?? failureOrPayload;
366
369
  const status = Number(failure.status ?? failure.statusCode ?? failure.httpStatus ?? 0);
367
370
  const code = String(failure.code ?? failure.reason ?? '').toUpperCase();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-key-rotation",
3
- "version": "0.8.13",
3
+ "version": "0.8.15",
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": [