@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 +33 -0
- package/lib/client.js +94 -69
- package/lib/plugin-updater.js +7 -0
- package/lib/pool.js +3 -0
- package/package.json +1 -1
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
|
|
1796
|
-
|
|
1797
|
-
|
|
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
|
|
1840
|
+
bestEffort('slots.register.log', () => { console.error('[dsh-key-rotation] settings seats register failed', e); });
|
|
1816
1841
|
}
|
|
1817
1842
|
}
|
|
1818
1843
|
|
package/lib/plugin-updater.js
CHANGED
|
@@ -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.
|
|
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": [
|