@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 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
@@ -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.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": [