@goodandready/dsh-key-rotation 0.8.24 → 0.8.25

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,24 @@
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.25 - 2026-09-27
7
+
8
+ ### Fixed & Hardened
9
+ - **Sandbox cache HTTP method validation (#357)**:
10
+ - `lib/ops-test.js`: enforced strict `GET` method check on `/dsh-key-rotation/sandbox-cache`. Non-GET requests (`POST`, `PUT`, `DELETE`) now consistently return HTTP 405 Method Not Allowed (`{ error: { code: 'method', message: 'GET only' } }`).
11
+ - **Webhook action security and timeout hardening (#358)**:
12
+ - `lib/ops-webhook.js`: converted authorization bearer token validation to constant-time comparison via `crypto.timingSafeEqual`, preventing timing side-channel attacks on `webhookActionToken`.
13
+ - Added strict Telegram bot token format validation (`TELEGRAM_BOT_TOKEN_RE = /^[0-9]{5,16}:[a-zA-Z0-9_-]{20,50}$/`).
14
+ - Added 10-second timeout guard (`signal: AbortSignal.timeout(10000)`) on outgoing Telegram API `setWebhook` requests to eliminate socket hang risks.
15
+ - **Dead code removal & unexported private symbols (#359)**:
16
+ - Removed unimported orphaned module `lib/bounded-map.js` and removed its standalone test cases.
17
+ - Eliminated dead exports in internal modules: unexported `CONCURRENCY_DEFAULT_LIMIT`, `CONCURRENCY_STALE_LOCK_MS` in `lib/concurrency.js`, `MAX_USAGE_RETENTION_DAYS` in `lib/usage-report.js`, `WEBHOOK_*` constants in `lib/webhook.js`, `assertJson` and `SECRET_FIELDS` in `lib/settings-write.js`, and `isNewerVersion` in `lib/plugin-updater.js`.
18
+ - **Documentation contract alignment (#360)**:
19
+ - `docs/design/DESIGN.md`: added mandatory sections «Политика маршрутов» (complete 12-route method, origin, and authorization matrix) and «Что публикуется» (npm files, exclusions, and verification timestamp) per `dsh-documentation-standard`.
20
+ - Corrected webhook endpoint URI in DESIGN.md to `/dsh-key-rotation/webhook-action`.
21
+ - **Canonical scoped package name in module export (#361)**:
22
+ - `lib/index.js`: unified `export const name = '@goodandready/dsh-key-rotation';` across all four package identity checkpoints (`package.json`, `cordis.patch.yml`, `lib/client.js`, and `lib/index.js`), resolving preflight check failure.
23
+
6
24
  ## 0.8.24 - 2026-09-25
7
25
 
8
26
  ### Security Hardening
@@ -1,7 +1,7 @@
1
1
  // concurrency.js — per-key in-flight counter + least-connections picking (issue #193).
2
2
 
3
- export const CONCURRENCY_DEFAULT_LIMIT = 0;
4
- export const CONCURRENCY_STALE_LOCK_MS = 5 * 60 * 1000;
3
+ const CONCURRENCY_DEFAULT_LIMIT = 0;
4
+ const CONCURRENCY_STALE_LOCK_MS = 5 * 60 * 1000;
5
5
 
6
6
  export class ConcurrencyTracker {
7
7
  constructor(opts) {
package/lib/index.js CHANGED
@@ -37,7 +37,7 @@ import { checkBudgetAndHealthAlerts } from './budget-monitor.js';
37
37
  import { buildPoolItem, cleanupRemovedProviders } from './pool-builder.js';
38
38
  import { setupIdleHealEffect, setupAutoUnbreakEffect, setupPersistence } from './lifecycle.js';
39
39
 
40
- export const name = 'dsh-key-rotation';
40
+ export const name = '@goodandready/dsh-key-rotation';
41
41
  export const inject = ['llm', 'webServer', 'settings', 'credentials'];
42
42
  export { keyTail, KEY_TAIL_CHARS, KEY_TAIL_PLACEHOLDER, isLoopbackAddress, isTrustedBridgeRequest, DEFAULT_SWITCH_CODES, isSwitchableError, formatExhaustionMessage, getRuntime };
43
43
  export { notifySwitch, notifyExhaustion };
package/lib/ops-test.js CHANGED
@@ -90,6 +90,7 @@ ctx.effect(() => ctx.webServer.register({
90
90
  kind: 'exact',
91
91
  path: SANDBOX_CACHE_PATH,
92
92
  handler: (req, res) => {
93
+ if (req.method !== 'GET') { json(res, 405, { error: { code: 'method', message: 'GET only' } }); return; }
93
94
  if (!isTrustedBridgeRequest(req)) { json(res, 403, { error: { code: 'forbidden', message: 'dsh-key-rotation: cache is local-only' } }); return; }
94
95
  const cacheSnap = typeof lastTestCache?.snapshot === 'function'
95
96
  ? lastTestCache.snapshot()
@@ -1,3 +1,4 @@
1
+ import crypto from 'node:crypto';
1
2
  // lib/ops-webhook.js — webhook interactive action route (#312 split from routes-ops.js).
2
3
  import {
3
4
  json,
@@ -26,7 +27,11 @@ ctx.effect(() => ctx.webServer.register({
26
27
  const expected = runtime.webhookActionToken;
27
28
  if (!expected) { json(res, 503, { error: { code: 'no-token', message: 'dsh-key-rotation: webhookActionToken is not configured' } }); return; }
28
29
  const auth = String(req.headers.authorization ?? '');
29
- if (auth !== `Bearer ${expected}`) { json(res, 401, { error: { code: 'unauthorized', message: 'dsh-key-rotation: bad webhook action token' } }); return; }
30
+ const expectedAuth = `Bearer ${expected}`;
31
+ const authBuf = Buffer.from(auth);
32
+ const expBuf = Buffer.from(expectedAuth);
33
+ const tokenMatch = authBuf.length === expBuf.length && crypto.timingSafeEqual(authBuf, expBuf);
34
+ if (!tokenMatch) { json(res, 401, { error: { code: 'unauthorized', message: 'dsh-key-rotation: bad webhook action token' } }); return; }
30
35
  let body;
31
36
  try { body = await readJson(req); } catch (e) { json(res, 400, { error: { code: 'bad-request', message: String(e?.message ?? e) } }); return; }
32
37
  // Accept callback payloads from formatInteractive (Telegram/Discord/Slack) or plain {action}
@@ -45,12 +50,18 @@ ctx.effect(() => ctx.webServer.register({
45
50
  if (typeof body?.setWebhook === 'object' && body.setWebhook) {
46
51
  const botToken = typeof body.setWebhook.botToken === 'string' ? body.setWebhook.botToken : '';
47
52
  if (!botToken) { json(res, 400, { error: { code: 'bad-request', message: 'dsh-key-rotation: setWebhook.botToken required' } }); return; }
53
+ const TELEGRAM_BOT_TOKEN_RE = /^[0-9]{5,16}:[a-zA-Z0-9_-]{20,50}$/;
54
+ if (!TELEGRAM_BOT_TOKEN_RE.test(botToken)) {
55
+ json(res, 400, { error: { code: 'bad-request', message: 'dsh-key-rotation: invalid telegram bot token format' } });
56
+ return;
57
+ }
48
58
  // derive the public URL from request headers; explicit URL wins
49
59
  const url = typeof body.setWebhook.url === 'string' && body.setWebhook.url ? body.setWebhook.url : `https://${String(req.headers.host ?? '')}/dsh-key-rotation/webhook-action`;
50
60
  try {
51
61
  const hookRes = await fetch(`https://api.telegram.org/bot${botToken}/setWebhook`, {
52
62
  method: 'POST', headers: { 'content-type': 'application/json' },
53
63
  body: JSON.stringify({ url, allowed_updates: ['callback_query'] }),
64
+ signal: AbortSignal.timeout(10000),
54
65
  });
55
66
  const hookData = await hookRes.json().catch(() => ({}));
56
67
  json(res, 200, { ok: hookRes.ok, url, telegram: hookData });
@@ -135,7 +135,7 @@ function comparePrerelease(left, right) {
135
135
  return 0
136
136
  }
137
137
 
138
- export function isNewerVersion(currentValue, candidateValue) {
138
+ function isNewerVersion(currentValue, candidateValue) {
139
139
  const current = parseSemver(currentValue)
140
140
  const candidate = parseSemver(candidateValue)
141
141
  if (current === undefined || candidate === undefined) return false
@@ -1,6 +1,6 @@
1
1
  // Settings writes from redacted browser views must never replace an entire
2
2
  // namespace. Host-side mutate is atomic and rejects conflicts on both core eras.
3
- export const SECRET_FIELDS = new Set(['webhookActionToken']);
3
+ const SECRET_FIELDS = new Set(['webhookActionToken']);
4
4
  const UNSAFE_FIELDS = new Set(['__proto__', 'prototype', 'constructor']);
5
5
 
6
6
  export function plainObject(value) {
@@ -9,7 +9,7 @@ export function plainObject(value) {
9
9
  return proto === Object.prototype || proto === null;
10
10
  }
11
11
 
12
- export function assertJson(value, depth = 0) {
12
+ function assertJson(value, depth = 0) {
13
13
  if (depth > 40) throw new TypeError('Configuration is nested too deeply');
14
14
  if (value === null || typeof value === 'string' || typeof value === 'boolean') return;
15
15
  if (typeof value === 'number' && Number.isFinite(value)) return;
@@ -1,6 +1,6 @@
1
1
  // lib/usage-report.js - #209 usage report rows from pool state + 30-day compaction (#11). Pure helpers.
2
2
  const DAY_MS = 86400000;
3
- export const MAX_USAGE_RETENTION_DAYS = 30;
3
+ const MAX_USAGE_RETENTION_DAYS = 30;
4
4
 
5
5
  /**
6
6
  * Build per-key usage rows for the last `days` ISO days (default 7).
package/lib/webhook.js CHANGED
@@ -1,8 +1,8 @@
1
1
  import { bestEffort } from './best-effort.js';
2
2
  // lib/webhook.js — webhook sender with throttle, interactive actions + alert digest debouncer (#199, #10).
3
- export const WEBHOOK_TIMEOUT_MS = 5000;
4
- export const WEBHOOK_MIN_INTERVAL_MS = 1000;
5
- export const WEBHOOK_RETRY_DELAY_MS = 2000;
3
+ const WEBHOOK_TIMEOUT_MS = 5000;
4
+ const WEBHOOK_MIN_INTERVAL_MS = 1000;
5
+ const WEBHOOK_RETRY_DELAY_MS = 2000;
6
6
 
7
7
  /**
8
8
  * Detect platform from webhook URL (#199).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-key-rotation",
3
- "version": "0.8.24",
3
+ "version": "0.8.25",
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": [
@@ -1,65 +0,0 @@
1
- // lib/bounded-map.js — Map with max size + optional TTL (#262).
2
-
3
- export class BoundedMap {
4
- /** @param {{ max?: number, ttlMs?: number|null }} opts */
5
- constructor({ max = 1000, ttlMs = null } = {}) {
6
- if (!Number.isFinite(max) || max < 1) throw new Error('BoundedMap: max must be >= 1');
7
- this.max = max;
8
- this.ttlMs = Number.isFinite(ttlMs) && ttlMs > 0 ? ttlMs : null;
9
- this._m = new Map(); // insertion order = LRU order when we re-insert on get/set
10
- }
11
-
12
- get size() { return this._m.size; }
13
-
14
- _expired(entry, now) {
15
- if (!this.ttlMs || !entry) return false;
16
- return now - entry.at >= this.ttlMs;
17
- }
18
-
19
- get(key, now = Date.now()) {
20
- const e = this._m.get(key);
21
- if (!e) return undefined;
22
- if (this._expired(e, now)) {
23
- this._m.delete(key);
24
- return undefined;
25
- }
26
- // refresh LRU
27
- this._m.delete(key);
28
- this._m.set(key, e);
29
- return e.v;
30
- }
31
-
32
- set(key, value, now = Date.now()) {
33
- if (this._m.has(key)) this._m.delete(key);
34
- this._m.set(key, { v: value, at: now });
35
- while (this._m.size > this.max) {
36
- const oldest = this._m.keys().next().value;
37
- this._m.delete(oldest);
38
- }
39
- return this;
40
- }
41
-
42
- has(key, now = Date.now()) {
43
- return this.get(key, now) !== undefined;
44
- }
45
-
46
- delete(key) { return this._m.delete(key); }
47
- clear() { this._m.clear(); }
48
-
49
- *entries(now = Date.now()) {
50
- for (const [k, e] of [...this._m.entries()]) {
51
- if (this._expired(e, now)) { this._m.delete(k); continue; }
52
- yield [k, e.v];
53
- }
54
- }
55
-
56
- *keys(now = Date.now()) {
57
- for (const [k] of this.entries(now)) yield k;
58
- }
59
-
60
- toObject(now = Date.now()) {
61
- const out = {};
62
- for (const [k, v] of this.entries(now)) out[k] = v;
63
- return out;
64
- }
65
- }