@goodandready/dsh-key-rotation 0.8.33 → 0.8.34
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 +19 -0
- 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 +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,25 @@
|
|
|
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.34 - 2026-09-28
|
|
7
|
+
|
|
8
|
+
### Security & Hardening
|
|
9
|
+
- **Credentials Encryption at Rest on PUT (#395)**:
|
|
10
|
+
- Wired `encryptSecret` in `lib/ops-keys.js` (`PUT /dsh-key-rotation/key`).
|
|
11
|
+
- When `DSH_KEY_SECRET` is set in the environment, new keys are automatically encrypted at rest via AES-256-GCM (`enc:v1:...`).
|
|
12
|
+
- Key tail calculation and leak shape analysis continue to operate on raw values for uninterrupted UI diagnostics.
|
|
13
|
+
- Added dedicated integration test suite `test/crypto-storage-keys.test.mjs`.
|
|
14
|
+
|
|
15
|
+
### Refactored & Tech-Debt
|
|
16
|
+
- **Preflight Cleanliness & Safe Diagnostics (#393)**:
|
|
17
|
+
- Eliminated 5 empty `catch {}` blocks across `lib/plugin-updater.js` and `lib/ops-webhook.js`.
|
|
18
|
+
- Replaced unhandled exceptions with `bestEffort(label, fn, logger)` utilizing structured debug logging to `ctx?.logger`.
|
|
19
|
+
- Resolved preflight check blocker (`FAIL=0`).
|
|
20
|
+
- **Dead Export Wiring & Core Cohesion (#394)**:
|
|
21
|
+
- Reused `scanForLiveSecrets` in `lib/http-bridge.js` to parse patch ops arrays directly in `handleConfigBridge`.
|
|
22
|
+
- Connected `filterQuotaEligible` to `anyModelQuotaAvailable` in `lib/model-quota.js`.
|
|
23
|
+
- Integrated `nowWall()` in `lib/quota-window.js` for default calendar reset calculations.
|
|
24
|
+
|
|
6
25
|
## 0.8.33 - 2026-09-28
|
|
7
26
|
|
|
8
27
|
### Fixed & Hardened
|
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.34",
|
|
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": [
|