@goodandready/dsh-key-rotation 0.7.33 → 0.7.35

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
@@ -251,4 +251,11 @@ All management routes require loopback authentication (`127.0.0.1` / `::1`) with
251
251
 
252
252
  ## 📄 License
253
253
 
254
- MIT © [GooDAnDReaDY](https://github.com/GooDAnDReaDY)
254
+ MIT © [GooDAnDReaDY](https://github.com/GooDAnDReaDY)
255
+
256
+ ### v0.7.35
257
+ - **Lifecycle Cleanups**: Wrapped `credentials.resolve` patch and `ctx.on` event handlers (`llm/stream`, `agent/request-error`) in `ctx.effect` scopes with guaranteed unmount cleanup (#238, #239).
258
+ - **Settings & Secret Roles**: Added `.role('secret')` to `incidentGitHubToken` and `webhookActionToken` in `Config` schema for automatic UI masking (#237).
259
+ - **Settings Architecture & UI**: Added native `settingsScope` snapshot reading/saving in settings card with graceful bridge fallback (#235).
260
+ - **Localization**: Localized `settings.section` sidebar fallback label via `t('title')` and registered `zh` dictionary in `ctx.locale` alongside `en` and `ru` (#236).
261
+ - **Dead Code Purge**: Removed obsolete `mountDashboard` routine after header-chip migration (#240).
package/README.ru.md CHANGED
@@ -251,4 +251,11 @@ dsh-key-rotation:
251
251
 
252
252
  ## 📄 Лицензия
253
253
 
254
- MIT © [GooDAnDReaDY](https://github.com/GooDAnDReaDY)
254
+ MIT © [GooDAnDReaDY](https://github.com/GooDAnDReaDY)
255
+
256
+ ### v0.7.35
257
+ - **Очистка жизненного цикла**: Патч `credentials.resolve` и слушатели событий `ctx.on` (`llm/stream`, `agent/request-error`) переведены в скоупы `ctx.effect` с автоматическим восстановлением функций и отпиской при выгрузке плагина (#238, #239).
258
+ - **Роли секретов в схеме**: Полям `incidentGitHubToken` и `webhookActionToken` в схеме `Config` присвоена роль `.role('secret')` для маскирования в UI (#237).
259
+ - **Архитектура настроек**: Добавлена нативная интеграция со снимками `settingsScope` в карточке настроек с безопасным фоллбеком на HTTP-мост (#235).
260
+ - **Локализация**: Фоллбек секции настроек `settings.section` переведён на локализованную метку `t('title')` со слотом `locale: NS`, а китайский словарь `zh` зарегистрирован в `ctx.locale` наряду с `en` и `ru` (#236).
261
+ - **Удаление мёртвого кода**: Удалена неиспользуемая функция `mountDashboard` после перехода на header chip (#240).
package/README.zh.md CHANGED
@@ -212,4 +212,11 @@ dsh-key-rotation:
212
212
 
213
213
  ## 📄 开源许可
214
214
 
215
- MIT © [GooDAnDReaDY](https://github.com/GooDAnDReaDY)
215
+ MIT © [GooDAnDReaDY](https://github.com/GooDAnDReaDY)
216
+
217
+ ### v0.7.35
218
+ - **生命周期清理**: 将 `credentials.resolve` 猴子补丁和 `ctx.on` 事件监听器 (`llm/stream`, `agent/request-error`) 封装在 `ctx.effect` 作用域内,确保卸载时自动注销并恢复原始方法 (#238, #239)。
219
+ - **配置密钥角色**: 在 `Config` Schema 中为 `incidentGitHubToken` 和 `webhookActionToken` 增加 `.role('secret')`,避免明文泄露并在 UI 中掩码显示 (#237)。
220
+ - **设置架构与状态**: 在设置卡片中增加原生 `settingsScope` 绑定支持,保留 HTTP 桥接安全回退机制 (#235)。
221
+ - **本地化与文案**: 侧边栏备用项 `settings.section` 标签支持本地化 `t('title')` 并配置 `locale: NS`,并在 `ctx.locale` 中注册 `zh` 中文字典 (#236)。
222
+ - **死代码清理**: 移除 header-chip 迁移后残留的废弃 `mountDashboard` 函数 (#240)。
package/lib/client.js CHANGED
@@ -390,6 +390,20 @@ window.__ModuleLoader__.load({
390
390
  function KeyRotationSection(props) {
391
391
  const DICT = props.locale === 'ru' ? ru : (props.locale === 'zh' ? zh : en);
392
392
  const t = makeT(DICT, en);
393
+ const settingsScope = React.useMemo(() => {
394
+ if (props.ctx && props.ctx.settingsScope) {
395
+ try {
396
+ return props.ctx.settingsScope.bind({ namespace: NS });
397
+ } catch (_) {
398
+ return null;
399
+ }
400
+ }
401
+ return null;
402
+ }, [props.ctx]);
403
+ const scopeSnapshot = React.useSyncExternalStore(
404
+ React.useMemo(() => (cb) => (settingsScope ? settingsScope.subscribe(cb) : () => {}), [settingsScope]),
405
+ React.useCallback(() => (settingsScope ? settingsScope.getSnapshot() : null), [settingsScope])
406
+ );
393
407
  const [state, setState] = React.useState({ status: 'loading', value: null, revision: 0, error: '', providers: [] });
394
408
  const [draft, setDraft] = React.useState(null);
395
409
  // ── all hooks live ABOVE any early return (React error 310 otherwise) ──
@@ -455,7 +469,18 @@ window.__ModuleLoader__.load({
455
469
  .catch((e) => setState((s) => ({ ...s, status: 'error', error: String(e) })));
456
470
  }, []);
457
471
 
458
- React.useEffect(() => { load(); }, [load]);
472
+ React.useEffect(() => {
473
+ if (scopeSnapshot && scopeSnapshot.status === 'ready' && scopeSnapshot.value) {
474
+ setState((s) => ({
475
+ ...s,
476
+ status: 'ready',
477
+ value: scopeSnapshot.value,
478
+ revision: scopeSnapshot.revision ?? s.revision,
479
+ }));
480
+ } else {
481
+ load();
482
+ }
483
+ }, [load, scopeSnapshot]);
459
484
 
460
485
  const val = draft ?? state.value;
461
486
  const status = useRotationStatus();
@@ -650,21 +675,38 @@ window.__ModuleLoader__.load({
650
675
  const save = () => {
651
676
  if (!draft) return;
652
677
  setState((s) => ({ ...s, status: 'saving', error: '' }));
653
- fetch(CONFIG_PATH, {
654
- method: 'PUT',
655
- headers: { 'content-type': 'application/json' },
656
- body: JSON.stringify({ section: draft, expectedRevision: state.revision }),
657
- })
658
- .then((r) => r.json())
659
- .then((data) => {
660
- if (data.error) {
661
- setState((s) => ({ ...s, status: 'error', error: data.error.message }));
662
- return;
663
- }
664
- setState((s) => ({ status: 'ready', value: data.value ?? draft, revision: data.revision ?? s.revision, error: '', providers: s.providers }));
665
- setDraft(null);
678
+ const saveViaBridge = () => {
679
+ fetch(CONFIG_PATH, {
680
+ method: 'PUT',
681
+ headers: { 'content-type': 'application/json' },
682
+ body: JSON.stringify({ section: draft, expectedRevision: state.revision }),
666
683
  })
667
- .catch((e) => setState((s) => ({ ...s, status: 'error', error: String(e) })));
684
+ .then((r) => r.json())
685
+ .then((data) => {
686
+ if (data.error) {
687
+ setState((s) => ({ ...s, status: 'error', error: data.error.message }));
688
+ return;
689
+ }
690
+ setState((s) => ({ status: 'ready', value: data.value ?? draft, revision: data.revision ?? s.revision, error: '', providers: s.providers }));
691
+ setDraft(null);
692
+ })
693
+ .catch((e) => setState((s) => ({ ...s, status: 'error', error: String(e) })));
694
+ };
695
+ if (settingsScope && typeof settingsScope.set === 'function') {
696
+ (async () => {
697
+ try {
698
+ for (const [k, v] of Object.entries(draft)) {
699
+ await settingsScope.set(k, v);
700
+ }
701
+ setState((s) => ({ ...s, status: 'ready', value: draft, error: '' }));
702
+ setDraft(null);
703
+ } catch (_) {
704
+ saveViaBridge();
705
+ }
706
+ })();
707
+ return;
708
+ }
709
+ saveViaBridge();
668
710
  };
669
711
 
670
712
 
@@ -1021,13 +1063,7 @@ window.__ModuleLoader__.load({
1021
1063
  );
1022
1064
  }
1023
1065
 
1024
- function mountDashboard() {
1025
- // Floating dashboard disabled in favor of pinned header chip popover
1026
- if (typeof document !== 'undefined') {
1027
- var el = document.getElementById('krot-dash');
1028
- if (el) el.remove();
1029
- }
1030
- }
1066
+
1031
1067
 
1032
1068
  // #201 header chip: one dot + counts for all pools. Green = all healthy,
1033
1069
  // amber = some keys cooling, red = a pool fully exhausted. Click opens the
@@ -1096,9 +1132,7 @@ window.__ModuleLoader__.load({
1096
1132
  }
1097
1133
 
1098
1134
  function apply(ctx) {
1099
- ctx.effect(() => ctx.locale.register(NS, { en, ru }), 'dsh-key-rotation: dictionaries');
1100
- // Dashboard widget: lives on every page, polls /health (#152).
1101
- ctx.effect(() => mountDashboard(), 'dsh-key-rotation: dashboard widget');
1135
+ ctx.effect(() => ctx.locale.register(NS, { en, ru, zh }), 'dsh-key-rotation: dictionaries');
1102
1136
  // Header chip (#201): status dot in the session header utilities slot,
1103
1137
  // same slot dsh-gitea / dsh-subscriptions use for their header widgets.
1104
1138
  ctx.effect(() => {
@@ -1144,8 +1178,16 @@ window.__ModuleLoader__.load({
1144
1178
  };
1145
1179
  if (!tryPluginItem()) {
1146
1180
  // Fallback for builds without the Plugins tab slot: keep the sidebar section.
1181
+ const t = ctx.locale ? ctx.locale.bind(NS) : null;
1147
1182
  ctx.slots.inject('settings.section', () => ctx.slots.register(
1148
- { name: 'settings.section', id: 'dsh-key-rotation', order: 20, label: () => 'Key Rotation' },
1183
+ {
1184
+ name: 'settings.section',
1185
+ id: 'dsh-key-rotation',
1186
+ order: 20,
1187
+ locale: NS,
1188
+ label: () => (t ? t('title') : 'Key Rotation') || 'Key Rotation',
1189
+ inject: () => ({ ctx }),
1190
+ },
1149
1191
  (props) => h(KeyRotationSection, { ...props, locale: useLocale() }),
1150
1192
  ));
1151
1193
  }
package/lib/index.js CHANGED
@@ -219,7 +219,7 @@ export const Config = Schema.object({
219
219
  selfHealIdleMs: Schema.number().default(3600000),
220
220
  latencyEnabled: Schema.boolean().default(true),
221
221
  latencyWindow: Schema.number().default(200),
222
- incidentGitHubToken: Schema.string().default(''),
222
+ incidentGitHubToken: Schema.string().role('secret').default(''),
223
223
  incidentGitHubBaseUrl: Schema.string().default(''),
224
224
  incidentThreshold: Schema.number().default(5),
225
225
  concurrencyLimit: Schema.number().default(0),
@@ -235,7 +235,7 @@ export const Config = Schema.object({
235
235
  }),
236
236
  rateLimitThreshold: Schema.number().default(0.1),
237
237
  rpmLimit: Schema.number().default(0),
238
- webhookActionToken: Schema.string().default(''),
238
+ webhookActionToken: Schema.string().role('secret').default(''),
239
239
  expiryWarnDays: Schema.number().default(7),
240
240
  switchNotify: Schema.boolean().default(false),
241
241
  switchNotifyThrottleMs: Schema.number().default(60000),
@@ -862,13 +862,14 @@ export function apply(ctx, config = {}) {
862
862
  // ── patch credentials.resolve: pool refs resolve to the next healthy key ──
863
863
  // Round-robin over the pool, skipping keys in cooldown; the request's
864
864
  // provider identity never changes, so pi-ai replay state stays consistent.
865
- const credentials = ctx.get('credentials');
866
- if (credentials && typeof credentials.resolve === 'function' && !credentials.__dshKeyRotationPatched) {
867
- const original = credentials.resolve.bind(credentials);
868
- // Kept for the status route: it must ask about one exact ref instead of
869
- // being rotated to a different key by the patch below.
870
- credentials.__dshKeyRotationOriginalResolve = original;
871
- credentials.resolve = async (ref) => {
865
+ ctx.effect(() => {
866
+ const credentials = ctx.get('credentials');
867
+ if (credentials && typeof credentials.resolve === 'function' && !credentials.__dshKeyRotationPatched) {
868
+ const original = credentials.resolve.bind(credentials);
869
+ // Kept for the status route: it must ask about one exact ref instead of
870
+ // being rotated to a different key by the patch below.
871
+ credentials.__dshKeyRotationOriginalResolve = original;
872
+ credentials.resolve = async (ref) => {
872
873
  const { poolByRef } = buildRuntime();
873
874
  const pool = poolByRef.get(ref);
874
875
  if (!pool) return original(ref);
@@ -944,9 +945,17 @@ export function apply(ctx, config = {}) {
944
945
  }
945
946
  }
946
947
  return original(ref); // everything cooled/missing — surface the base value
947
- };
948
- credentials.__dshKeyRotationPatched = true;
949
- }
948
+ };
949
+ credentials.__dshKeyRotationPatched = true;
950
+ return () => {
951
+ if (credentials.__dshKeyRotationPatched) {
952
+ credentials.resolve = original;
953
+ delete credentials.__dshKeyRotationPatched;
954
+ delete credentials.__dshKeyRotationOriginalResolve;
955
+ }
956
+ };
957
+ }
958
+ }, 'dsh-key-rotation: patch credentials.resolve');
950
959
 
951
960
  const finishError = (code, message) => ({
952
961
  type: 'finish',
@@ -1777,7 +1786,7 @@ export function apply(ctx, config = {}) {
1777
1786
  },
1778
1787
  }), 'dsh-key-rotation: agent-budget');
1779
1788
 
1780
- ctx.on('llm/stream', (options, next) => {
1789
+ ctx.effect(() => ctx.on('llm/stream', (options, next) => {
1781
1790
  if (options[MARKER]) return next();
1782
1791
  if (rotationDisabled) return next(); // #199: disabled via webhook action
1783
1792
  const { providerToPool, modelPoolByProvider } = buildRuntime();
@@ -1786,13 +1795,13 @@ export function apply(ctx, config = {}) {
1786
1795
  if (!pool) return next();
1787
1796
  console.warn(`[dsh-key-rotation] rotating ${options.provider}/${options.model} across ${(pool.weightedRefs ?? pool.refs).length} slots (${pool.refs.length} keys)`);
1788
1797
  return rotate(options, pool);
1789
- });
1798
+ }), 'dsh-key-rotation: llm/stream');
1790
1799
 
1791
1800
  // Safety net for non-stream requests (agent/request-error waterfall).
1792
1801
  // llm/stream covers streaming calls; sync calls (embeddings, batch) go
1793
1802
  // through agent/request and surface errors here. If the error is
1794
1803
  // switchable, mark the key and ask the agent loop to retry.
1795
- ctx.on('agent/request-error', async (payload, next) => {
1804
+ ctx.effect(() => ctx.on('agent/request-error', async (payload, next) => {
1796
1805
  const provider = payload?.provider ?? payload?.failure?.provider ?? '';
1797
1806
  if (!provider) return next();
1798
1807
  const { providerToPool, modelPoolByProvider, switchCodes } = buildRuntime();
@@ -1815,7 +1824,7 @@ export function apply(ctx, config = {}) {
1815
1824
  console.warn(`[dsh-key-rotation] ${provider}: key ${String(ref)} failed via agent/request-error (${String(code)} ${String(message).slice(0, 80)}) — retry`);
1816
1825
  }
1817
1826
  return { kind: 'retry' };
1818
- });
1827
+ }), 'dsh-key-rotation: agent/request-error');
1819
1828
 
1820
1829
  ctx.inject(['settings'], (sctx) => {
1821
1830
  const scope = sctx.settings.register(NS, Config, { base: config });
package/package.json CHANGED
@@ -1,6 +1,7 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-key-rotation",
3
- "version": "0.7.33",
3
+ "version": "0.7.35",
4
+ "packageManager": "pnpm@10.33.2",
4
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.",
5
6
  "keywords": [
6
7
  "deepseek-harness",
@@ -40,13 +41,13 @@
40
41
  },
41
42
  "client": {
42
43
  "platform": "web",
43
- "inject": [
44
- "@deepseek-ai/dsh-client-runtime",
45
- "@deepseek-ai/dsh-client-ui-slots"
46
- ]
44
+ "inject": []
47
45
  }
48
46
  },
49
47
  "license": "MIT",
48
+ "engines": {
49
+ "node": ">=18"
50
+ },
50
51
  "peerDependencies": {
51
52
  "@deepseek-ai/cordis": "^4.0.1",
52
53
  "@deepseek-ai/schemastery": "^3.18.1",