@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 +38 -0
- package/lib/client.js +18 -21
- package/lib/http-bridge.js +8 -3
- package/lib/model-quota.js +1 -5
- package/lib/ops-keys.js +3 -2
- package/lib/ops-webhook.js +7 -8
- package/lib/plugin-updater.js +14 -12
- package/lib/quota-window.js +3 -2
- package/package.json +5 -3
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
|
});
|
package/lib/http-bridge.js
CHANGED
|
@@ -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
|
|
175
|
-
|
|
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 =
|
|
238
|
+
const findings = scanForLiveSecrets(section);
|
|
234
239
|
if (findings.length > 0) {
|
|
235
240
|
json(res, 400, {
|
|
236
241
|
error: {
|
package/lib/model-quota.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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 });
|
package/lib/ops-webhook.js
CHANGED
|
@@ -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
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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);
|
package/lib/plugin-updater.js
CHANGED
|
@@ -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
|
-
|
|
198
|
-
|
|
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
|
-
|
|
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
|
-
}
|
|
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
|
-
|
|
287
|
+
bestEffort('timeout-sigterm', () => child.kill('SIGTERM'), options.logger)
|
|
286
288
|
setTimeout(() => {
|
|
287
|
-
|
|
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)
|
package/lib/quota-window.js
CHANGED
|
@@ -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 ||
|
|
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 ||
|
|
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.
|
|
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",
|