@goodandready/dsh-key-rotation 0.8.33 → 0.8.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/CHANGELOG.md CHANGED
@@ -3,6 +3,44 @@
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.35 - 2026-09-28
7
+
8
+ ### Refactored & Architecture
9
+ - **Frontend Modularization (`lib/client-src/`) (#399, PR #400)**:
10
+ - Decomposed monolithic `lib/client.js` (~152 KiB, 2,691 lines) into 8 clean, domain-focused fragments under `lib/client-src/`:
11
+ - `00-header.js`: Module loader entry, requires, scoped CSS injection.
12
+ - `10-locales.js`: English (`en`) and Chinese (`zh`) translation dictionaries, error boundary, and helper constants.
13
+ - `20-hooks.js`: `useRotationStatus` and `useProbeCache` smart hooks.
14
+ - `30-utils.js`: Formatting utilities, CSS stylesheet, and dynamic locale resolver.
15
+ - `40-components.js`: Micro-components, JSON diff/patch operators, quota utilities, and pool validation.
16
+ - `50-card-main.js`: `KeyRotationSection` main settings surface and live telemetry controls.
17
+ - `60-header-chip.js`: `KeyRotationHeaderChip` session header status widget.
18
+ - `90-apply.js`: Plugin apply lifecycle, slot registrations, and internals exposure.
19
+ - Added deterministic zero-dependency Node build script `scripts/build-client.mjs` matching sister plugin standards (`dsh-cron`).
20
+ - Integrated `build:client` and `pretest` lifecycle triggers into `package.json`.
21
+ - Excluded `lib/client-src/` from distributed npm package tarballs via `files: ["lib/*.js", ...]`.
22
+ - Fixed misplaced updater translation keys, moving them cleanly inside `en` and `zh` dictionaries.
23
+ - Added automated CI regression test `test/build-client.test.mjs`.
24
+
25
+ ## 0.8.34 - 2026-09-28
26
+
27
+ ### Security & Hardening
28
+ - **Credentials Encryption at Rest on PUT (#395)**:
29
+ - Wired `encryptSecret` in `lib/ops-keys.js` (`PUT /dsh-key-rotation/key`).
30
+ - When `DSH_KEY_SECRET` is set in the environment, new keys are automatically encrypted at rest via AES-256-GCM (`enc:v1:...`).
31
+ - Key tail calculation and leak shape analysis continue to operate on raw values for uninterrupted UI diagnostics.
32
+ - Added dedicated integration test suite `test/crypto-storage-keys.test.mjs`.
33
+
34
+ ### Refactored & Tech-Debt
35
+ - **Preflight Cleanliness & Safe Diagnostics (#393)**:
36
+ - Eliminated 5 empty `catch {}` blocks across `lib/plugin-updater.js` and `lib/ops-webhook.js`.
37
+ - Replaced unhandled exceptions with `bestEffort(label, fn, logger)` utilizing structured debug logging to `ctx?.logger`.
38
+ - Resolved preflight check blocker (`FAIL=0`).
39
+ - **Dead Export Wiring & Core Cohesion (#394)**:
40
+ - Reused `scanForLiveSecrets` in `lib/http-bridge.js` to parse patch ops arrays directly in `handleConfigBridge`.
41
+ - Connected `filterQuotaEligible` to `anyModelQuotaAvailable` in `lib/model-quota.js`.
42
+ - Integrated `nowWall()` in `lib/quota-window.js` for default calendar reset calculations.
43
+
6
44
  ## 0.8.33 - 2026-09-28
7
45
 
8
46
  ### Fixed & Hardened
package/lib/client.js CHANGED
@@ -209,9 +209,17 @@ window.__ModuleLoader__.load({
209
209
  proactiveGuardHint: 'Pre-emptively pause keys when remaining quota or Retry-After header indicates limit before hitting 429',
210
210
  selfHealingLabel: 'Auto-Unbreak Interval (min)',
211
211
  selfHealingHint: 'Periodic free model check to revive broken keys (0 to disable)',
212
+ updateCheck: 'Check for updates',
213
+ updateAvailable: 'Update available',
214
+ updateNone: 'Plugin is up to date',
215
+ updateRun: 'Update now',
216
+ updateRestart: 'Update installed. Restart DSH to load the new version.',
217
+ updateRunning: 'Updating…',
218
+ updateFailed: 'Update failed',
219
+ currentVersion: 'Current version',
220
+ latestVersion: 'Latest version',
212
221
  };
213
222
 
214
-
215
223
  const zh = {
216
224
  title: '密钥轮换',
217
225
  'header.title': '密钥轮换与故障转移',
@@ -382,6 +390,15 @@ window.__ModuleLoader__.load({
382
390
  proactiveGuardHint: '当响应中的剩余配额或 Retry-After 头提示即将超限时,预先冷却密钥以防触发 429',
383
391
  selfHealingLabel: '自愈检查间隔 (分钟)',
384
392
  selfHealingHint: '定期轻量探测以自动恢复损坏密钥 (0 为禁用)',
393
+ updateCheck: '检查更新',
394
+ updateAvailable: '有可用更新',
395
+ updateNone: '插件已是最新版本',
396
+ updateRun: '立即更新',
397
+ updateRestart: '更新已安装。请重启 DSH 以加载新版本。',
398
+ updateRunning: '更新中…',
399
+ updateFailed: '更新失败',
400
+ currentVersion: '当前版本',
401
+ latestVersion: '最新版本',
385
402
  };
386
403
 
387
404
  // Codes worth switching on. Host list is DEFAULT_SWITCH_CODES; config may
@@ -2668,24 +2685,4 @@ window.__ModuleLoader__.load({
2668
2685
  }
2669
2686
  return module.exports;
2670
2687
  },
2671
- updateCheck: 'Check for updates',
2672
- updateAvailable: 'Update available',
2673
- updateNone: 'Plugin is up to date',
2674
- updateRun: 'Update now',
2675
- updateRestart: 'Update installed. Restart DSH to load the new version.',
2676
- updateRunning: 'Updating…',
2677
- updateFailed: 'Update failed',
2678
- currentVersion: 'Current version',
2679
- latestVersion: 'Latest version',
2680
-
2681
- updateCheck: '检查更新',
2682
- updateAvailable: '有可用更新',
2683
- updateNone: '插件已是最新版本',
2684
- updateRun: '立即更新',
2685
- updateRestart: '更新已安装。请重启 DSH 以加载新版本。',
2686
- updateRunning: '更新中…',
2687
- updateFailed: '更新失败',
2688
- currentVersion: '当前版本',
2689
- latestVersion: '最新版本',
2690
-
2691
2688
  });
@@ -170,9 +170,14 @@ export function providerCatalog(ctx, cloneIds) {
170
170
 
171
171
 
172
172
  export function scanForLiveSecrets(section) {
173
+ if (Array.isArray(section)) {
174
+ return findSecrets(JSON.stringify(section.filter(op => !['webhookActionToken', 'notifyWebhook'].includes(op?.path?.[0]))));
175
+ }
173
176
  const masked = structuredClone(section);
174
- if (masked.webhookActionToken) masked.webhookActionToken = '***';
175
- if (masked.notifyWebhook) masked.notifyWebhook = '***';
177
+ if (masked && typeof masked === 'object') {
178
+ if (masked.webhookActionToken) masked.webhookActionToken = '***';
179
+ if (masked.notifyWebhook) masked.notifyWebhook = '***';
180
+ }
176
181
  return findSecrets(JSON.stringify(masked));
177
182
  }
178
183
 
@@ -230,7 +235,7 @@ export async function handleConfigBridge(ctx, request, res, getCloneIds) {
230
235
  return;
231
236
  }
232
237
  try {
233
- const findings = findSecrets(JSON.stringify(section.filter(op => !['webhookActionToken', 'notifyWebhook'].includes(op.path[0]))));
238
+ const findings = scanForLiveSecrets(section);
234
239
  if (findings.length > 0) {
235
240
  json(res, 400, {
236
241
  error: {
@@ -187,11 +187,7 @@ export function isModelQuotaAvailable(pool, ref, now) {
187
187
 
188
188
  /** True when at least one ref of `pool` still has local quota left. */
189
189
  export function anyModelQuotaAvailable(pool, now) {
190
- const refs = pool?.refs ?? [];
191
- for (const ref of refs) {
192
- if (isModelQuotaAvailable(pool, ref, now)) return true;
193
- }
194
- return false;
190
+ return filterQuotaEligible(pool, pool?.refs ?? [], now).length > 0;
195
191
  }
196
192
 
197
193
  /**
package/lib/ops-keys.js CHANGED
@@ -1,4 +1,4 @@
1
- import { decryptSecret } from './crypto-storage.js';
1
+ import { decryptSecret, encryptSecret } from './crypto-storage.js';
2
2
  // lib/ops-keys.js — key store + cooldown reset + import routes (#312 split from routes-ops.js).
3
3
  import {
4
4
  json,
@@ -89,7 +89,8 @@ ctx.effect(() => ctx.webServer.register({
89
89
  json(res, 400, { error: { code: 'empty-value', message: 'dsh-key-rotation: an empty key cannot be stored' } });
90
90
  return;
91
91
  }
92
- await credentialsService.set(ref, value);
92
+ const storedValue = encryptSecret(value);
93
+ await credentialsService.set(ref, storedValue);
93
94
  // #200: leak-detector hint - stored value should look like a credential
94
95
  const secretShape = looksLikeApiSecret(value);
95
96
  json(res, 200, { ok: true, ref, tail: keyTail(value), looksLikeSecret: secretShape });
@@ -4,6 +4,7 @@ import {
4
4
  json,
5
5
  readJson,
6
6
  } from './http-bridge.js';
7
+ import { bestEffort } from './best-effort.js';
7
8
  import { WEBHOOK_ACTION_PATH } from './ops-paths.js';
8
9
 
9
10
  function constantTimeEqual(a, b) {
@@ -99,14 +100,12 @@ ctx.effect(() => ctx.webServer.register({
99
100
  payload.callback_query_id = callbackQueryId;
100
101
  payload.text = answerText || payload.error?.message || 'OK';
101
102
  if (botToken) {
102
- try {
103
- await fetch(`https://api.telegram.org/bot${botToken}/answerCallbackQuery`, {
104
- method: 'POST',
105
- headers: { 'content-type': 'application/json' },
106
- body: JSON.stringify({ callback_query_id: callbackQueryId, text: payload.text }),
107
- signal: AbortSignal.timeout(5000),
108
- });
109
- } catch {}
103
+ await bestEffort('telegram-answerCallbackQuery', () => fetch(`https://api.telegram.org/bot${botToken}/answerCallbackQuery`, {
104
+ method: 'POST',
105
+ headers: { 'content-type': 'application/json' },
106
+ body: JSON.stringify({ callback_query_id: callbackQueryId, text: payload.text }),
107
+ signal: AbortSignal.timeout(5000),
108
+ }), ctx?.logger);
110
109
  }
111
110
  }
112
111
  json(res, statusCode, payload);
@@ -4,6 +4,7 @@ import { readFile } from 'node:fs/promises'
4
4
  import { homedir } from 'node:os'
5
5
  import { basename, dirname, isAbsolute, resolve } from 'node:path'
6
6
  import { fileURLToPath } from 'node:url'
7
+ import { bestEffort } from './best-effort.js'
7
8
 
8
9
  /**
9
10
  * Host-side one-click updater for a DSH Web plugin.
@@ -187,17 +188,15 @@ export function isValidPackageSpec(packageSpec) {
187
188
  return typeof packageSpec === 'string' && PACKAGE_SPEC_PATTERN.test(packageSpec)
188
189
  }
189
190
 
190
- export function readLockPid(lockPath) {
191
+ export function readLockPid(lockPath, logger) {
191
192
  try {
192
193
  if (!existsSync(lockPath)) return undefined
193
194
  const stat = statSync(lockPath)
194
195
  if (stat.isDirectory()) return undefined
195
196
  const content = readFileSync(lockPath, 'utf8').trim()
196
197
  if (!content) return undefined
197
- try {
198
- const parsed = JSON.parse(content)
199
- if (parsed && typeof parsed.pid === 'number') return parsed.pid
200
- } catch {}
198
+ const parsed = bestEffort('readLockPid-json', () => JSON.parse(content), logger)
199
+ if (parsed && typeof parsed.pid === 'number') return parsed.pid
201
200
  const num = parseInt(content, 10)
202
201
  if (!isNaN(num) && num > 0) return num
203
202
  const match = /\b(\d{1,8})\b/.exec(content)
@@ -218,10 +217,10 @@ export function isPidAlive(pid) {
218
217
  }
219
218
  }
220
219
 
221
- export function checkAndCleanLock(profileDir, childPid) {
220
+ export function checkAndCleanLock(profileDir, childPid, logger) {
222
221
  const lockPath = resolve(profileDir, 'package.json.lock')
223
222
  if (!existsSync(lockPath)) return { locked: false }
224
- const pid = readLockPid(lockPath)
223
+ const pid = readLockPid(lockPath, logger)
225
224
  if (pid !== undefined) {
226
225
  if (isPidAlive(pid)) {
227
226
  if (childPid !== undefined && pid === childPid) {
@@ -244,10 +243,13 @@ export function checkAndCleanLock(profileDir, childPid) {
244
243
  }
245
244
  // Unknown or empty lockfile format
246
245
  if (childPid !== undefined) {
247
- try {
246
+ const cleaned = bestEffort('clean-unknown-lock', () => {
248
247
  rmSync(lockPath, { recursive: true, force: true })
248
+ return true
249
+ }, logger)
250
+ if (cleaned) {
249
251
  return { locked: false, cleaned: true }
250
- } catch {}
252
+ }
251
253
  }
252
254
  return { locked: true }
253
255
  }
@@ -282,10 +284,10 @@ export async function installExact(target, packageSpec, options = {}) {
282
284
  const timeoutMs = options.timeoutMs ?? UPDATE_TIMEOUT_MS
283
285
  const timer = setTimeout(() => {
284
286
  timedOut = true
285
- try { child.kill('SIGTERM') } catch {}
287
+ bestEffort('timeout-sigterm', () => child.kill('SIGTERM'), options.logger)
286
288
  setTimeout(() => {
287
- try { child.kill('SIGKILL') } catch {}
288
- checkAndCleanLock(target.profileDir, child.pid)
289
+ bestEffort('timeout-sigkill', () => child.kill('SIGKILL'), options.logger)
290
+ checkAndCleanLock(target.profileDir, child.pid, options.logger)
289
291
  }, 500).unref?.()
290
292
  reject(new Error('Update timed out; use the normal DSH update flow.'))
291
293
  }, timeoutMs)
@@ -1,9 +1,10 @@
1
+ import { nowWall } from './clock.js';
1
2
  // quota-window.js — calendar-based quota reset windows (issue #197).
2
3
 
3
4
  export const QUOTA_WINDOW_TYPES = ['midnight_utc', 'midnight_pst', 'rolling_24h'];
4
5
 
5
6
  export function nextQuotaReset(quotaResetWindow, now) {
6
- now = now || Date.now();
7
+ now = now || nowWall();
7
8
  const win = typeof quotaResetWindow === 'string' ? { type: quotaResetWindow === 'daily' ? 'midnight_utc' : quotaResetWindow } : quotaResetWindow;
8
9
  if (!win || typeof win !== 'object') return null;
9
10
  const type = win.type === 'daily' ? 'midnight_utc' : win.type;
@@ -39,7 +40,7 @@ export function poolResetAt(pool, quotaResetWindow, now) {
39
40
  }
40
41
 
41
42
  export function isBlockedUntilReset(failedUntil, resetAt, now) {
42
- now = now || Date.now();
43
+ now = now || nowWall();
43
44
  if (!Number.isFinite(failedUntil)) return false;
44
45
  if (resetAt === null || resetAt === undefined) return false;
45
46
  return failedUntil >= resetAt;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-key-rotation",
3
- "version": "0.8.33",
3
+ "version": "0.8.35",
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": [
@@ -30,7 +30,7 @@
30
30
  "./cordis.patch.yml": "./cordis.patch.yml"
31
31
  },
32
32
  "files": [
33
- "lib",
33
+ "lib/*.js",
34
34
  "cordis.patch.yml",
35
35
  "README.md",
36
36
  "README.zh.md",
@@ -63,7 +63,9 @@
63
63
  "scripts": {
64
64
  "test": "node tools/test.mjs",
65
65
  "test:core": "node tools/test.mjs --core",
66
- "check": "node tools/check.mjs"
66
+ "check": "node tools/check.mjs",
67
+ "build:client": "node scripts/build-client.mjs",
68
+ "pretest": "node scripts/build-client.mjs"
67
69
  },
68
70
  "devDependencies": {
69
71
  "react": "18.3.1",