@goodandready/dsh-key-rotation 0.8.31 → 0.8.33

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,7 +3,22 @@
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.31 - 2026-09-28
6
+ ## 0.8.33 - 2026-09-28
7
+
8
+ ### Fixed & Hardened
9
+ - **Telegram Webhook Interactive Actions (#388)**:
10
+ - Route `/dsh-key-rotation/webhook-action` now accepts `X-Telegram-Bot-Api-Secret-Token` via constant-time comparison (`crypto.timingSafeEqual`) alongside `Authorization: Bearer`.
11
+ - Added native Telegram `answerCallbackQuery` webhook response (`{ method: 'answerCallbackQuery', callback_query_id, text }`) dismissing the spinner immediately on button presses.
12
+ - Enforced mandatory explicit HTTPS URL requirement in `setWebhook` helper and transmission of `secret_token`.
13
+ - **Plugin Self-Updater Resiliency & Supply Chain Protection (#390)**:
14
+ - Removed `--config.minimumReleaseAge=0` override from `dsh plugin add` arguments, restoring release age policies and package verification.
15
+ - Implemented live PID detection via `process.kill(pid, 0)` in `package.json.lock`, returning 409 Conflict if installation is already in progress.
16
+ - Added automatic cleanup of orphaned/stale lockfiles left by dead processes.
17
+ - Ensured child process timeout immediately cleans up its own lockfile.
18
+ - **Repository & Worktree Hygiene (#389)**:
19
+ - Pruned obsolete worktrees and deleted old merged feature branches.
20
+
21
+ ## 0.8.32 - 2026-09-28
7
22
 
8
23
  ### Added & Enhanced
9
24
  - **Per-Model Per-Key Token Quotas (#380)**:
package/README.md CHANGED
@@ -311,7 +311,7 @@ All management routes require loopback authentication (`127.0.0.1` / `::1`) with
311
311
  | `/dsh-key-rotation/reset` | `POST` | Instantly resets all cooldowns and restores all keys to `ready`. |
312
312
  | `/dsh-key-rotation/test-matrix` | `POST` | Triggers parallel health check across all configured keys and models. |
313
313
  | `/dsh-key-rotation/usage-report` | `GET` | Returns aggregated usage metrics in JSON or CSV format (`?format=csv`). |
314
- | `/dsh-key-rotation/webhook-callback`| `POST` | Receives and executes interactive actions from Telegram/Slack callbacks. |
314
+ | `/dsh-key-rotation/webhook-action` | `POST` | Receives and executes interactive actions from Telegram/Slack callbacks (`Authorization: Bearer` or `X-Telegram-Bot-Api-Secret-Token`). |
315
315
 
316
316
  ---
317
317
 
package/README.ru.md CHANGED
@@ -303,7 +303,7 @@ dsh-key-rotation:
303
303
  | `/dsh-key-rotation/reset` | `POST` | Мгновенный сброс всех кулдаунов и возврат ключей в статус `ready`. |
304
304
  | `/dsh-key-rotation/test-matrix` | `POST` | Запуск параллельного тестирования всей матрицы ключей и моделей. |
305
305
  | `/dsh-key-rotation/usage-report` | `GET` | Получение сводного отчета использования в формате JSON или CSV (`?format=csv`). |
306
- | `/dsh-key-rotation/webhook-callback`| `POST` | Обработка интерактивных действий от кнопок в Telegram/Slack. |
306
+ | `/dsh-key-rotation/webhook-action` | `POST` | Обработка интерактивных действий от кнопок в Telegram/Slack (`Authorization: Bearer` или `X-Telegram-Bot-Api-Secret-Token`). |
307
307
 
308
308
  ---
309
309
 
@@ -1,11 +1,27 @@
1
1
  import crypto from 'node:crypto';
2
- // lib/ops-webhook.js — webhook interactive action route (#312 split from routes-ops.js).
2
+ // lib/ops-webhook.js — webhook interactive action route (#312 split from routes-ops.js, #388 telegram secret-token & answerCallbackQuery).
3
3
  import {
4
4
  json,
5
5
  readJson,
6
6
  } from './http-bridge.js';
7
7
  import { WEBHOOK_ACTION_PATH } from './ops-paths.js';
8
8
 
9
+ function constantTimeEqual(a, b) {
10
+ if (typeof a !== 'string' || typeof b !== 'string') return false;
11
+ const aBuf = Buffer.from(a);
12
+ const bBuf = Buffer.from(b);
13
+ return aBuf.length === bBuf.length && crypto.timingSafeEqual(aBuf, bBuf);
14
+ }
15
+
16
+ function getHeader(headers, name) {
17
+ if (!headers || typeof headers !== 'object') return '';
18
+ const target = name.toLowerCase();
19
+ for (const [k, v] of Object.entries(headers)) {
20
+ if (k.toLowerCase() === target) return String(v ?? '');
21
+ }
22
+ return '';
23
+ }
24
+
9
25
  /**
10
26
  * @param {object} ctx cordis context
11
27
  * @param {object} deps live dependencies from apply()
@@ -26,27 +42,20 @@ ctx.effect(() => ctx.webServer.register({
26
42
  const runtime = buildRuntime();
27
43
  const expected = runtime.webhookActionToken;
28
44
  if (!expected) { json(res, 503, { error: { code: 'no-token', message: 'dsh-key-rotation: webhookActionToken is not configured' } }); return; }
29
- const auth = String(req.headers.authorization ?? '');
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; }
45
+
46
+ const authHeader = getHeader(req.headers, 'authorization');
47
+ const secretHeader = getHeader(req.headers, 'x-telegram-bot-api-secret-token');
48
+ const bearerMatch = constantTimeEqual(authHeader, `Bearer ${expected}`);
49
+ const secretMatch = constantTimeEqual(secretHeader, expected);
50
+ if (!bearerMatch && !secretMatch) {
51
+ json(res, 401, { error: { code: 'unauthorized', message: 'dsh-key-rotation: bad webhook action token' } });
52
+ return;
53
+ }
54
+
35
55
  let body;
36
56
  try { body = await readJson(req); } catch (e) { json(res, 400, { error: { code: 'bad-request', message: String(e?.message ?? e) } }); return; }
37
- // Accept callback payloads from formatInteractive (Telegram/Discord/Slack) or plain {action}
38
- let action = typeof body?.action === 'string' ? body.action : '';
39
- if (!action && typeof body?.data === 'string') {
40
- try { action = String(JSON.parse(body.data)?.id ?? ''); } catch { action = ''; }
41
- }
42
- if (!action && typeof body?.callback_data === 'string') {
43
- try { action = String(JSON.parse(body.callback_data)?.id ?? ''); } catch { action = ''; }
44
- }
45
- // #222: Telegram update envelope {update_id, callback_query:{data}}
46
- if (!action && typeof body?.callback_query?.data === 'string') {
47
- try { action = String(JSON.parse(body.callback_query.data)?.id ?? ''); } catch { action = ''; }
48
- }
49
- // #222: Telegram setWebhook registration helper
57
+
58
+ // #222 / #388: Telegram setWebhook registration helper
50
59
  if (typeof body?.setWebhook === 'object' && body.setWebhook) {
51
60
  const botToken = typeof body.setWebhook.botToken === 'string' ? body.setWebhook.botToken : '';
52
61
  if (!botToken) { json(res, 400, { error: { code: 'bad-request', message: 'dsh-key-rotation: setWebhook.botToken required' } }); return; }
@@ -55,38 +64,94 @@ ctx.effect(() => ctx.webServer.register({
55
64
  json(res, 400, { error: { code: 'bad-request', message: 'dsh-key-rotation: invalid telegram bot token format' } });
56
65
  return;
57
66
  }
58
- // derive the public URL from request headers; explicit URL wins
59
- const url = typeof body.setWebhook.url === 'string' && body.setWebhook.url ? body.setWebhook.url : `https://${String(req.headers.host ?? '')}/dsh-key-rotation/webhook-action`;
67
+ const rawUrl = typeof body.setWebhook.url === 'string' ? body.setWebhook.url.trim() : '';
68
+ if (!rawUrl || !rawUrl.startsWith('https://')) {
69
+ json(res, 400, { error: { code: 'bad-request', message: 'dsh-key-rotation: explicit public https url required in setWebhook.url' } });
70
+ return;
71
+ }
60
72
  try {
61
73
  const hookRes = await fetch(`https://api.telegram.org/bot${botToken}/setWebhook`, {
62
- method: 'POST', headers: { 'content-type': 'application/json' },
63
- body: JSON.stringify({ url, allowed_updates: ['callback_query'] }),
74
+ method: 'POST',
75
+ headers: { 'content-type': 'application/json' },
76
+ body: JSON.stringify({
77
+ url: rawUrl,
78
+ secret_token: expected,
79
+ allowed_updates: ['callback_query'],
80
+ }),
64
81
  signal: AbortSignal.timeout(10000),
65
82
  });
66
83
  const hookData = await hookRes.json().catch(() => ({}));
67
- json(res, 200, { ok: hookRes.ok, url, telegram: hookData });
84
+ json(res, 200, { ok: hookRes.ok, url: rawUrl, secret_token_set: true, telegram: hookData });
68
85
  } catch (e) {
69
86
  json(res, 502, { error: { code: 'telegram-failed', message: String(e?.message ?? e) } });
70
87
  }
71
88
  return;
72
89
  }
73
- if (!action) { json(res, 400, { error: { code: 'bad-action', message: 'dsh-key-rotation: no action in payload' } }); return; }
90
+
91
+ const callbackQueryId = typeof body?.callback_query?.id === 'string'
92
+ ? body.callback_query.id
93
+ : (typeof body?.callback_query?.id === 'number' ? String(body.callback_query.id) : '');
94
+ const botToken = typeof body?.botToken === 'string' ? body.botToken : '';
95
+
96
+ const respond = async (statusCode, payload, answerText) => {
97
+ if (callbackQueryId) {
98
+ payload.method = 'answerCallbackQuery';
99
+ payload.callback_query_id = callbackQueryId;
100
+ payload.text = answerText || payload.error?.message || 'OK';
101
+ 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 {}
110
+ }
111
+ }
112
+ json(res, statusCode, payload);
113
+ };
114
+
115
+ // Accept callback payloads from formatInteractive (Telegram/Discord/Slack) or plain {action}
116
+ let action = typeof body?.action === 'string' ? body.action : '';
117
+ if (!action && typeof body?.data === 'string') {
118
+ try { action = String(JSON.parse(body.data)?.id ?? ''); } catch { action = ''; }
119
+ }
120
+ if (!action && typeof body?.callback_data === 'string') {
121
+ try { action = String(JSON.parse(body.callback_data)?.id ?? ''); } catch { action = ''; }
122
+ }
123
+ // #222 / #388: Telegram update envelope {update_id, callback_query:{id, data}}
124
+ if (!action && typeof body?.callback_query?.data === 'string') {
125
+ try {
126
+ const parsed = JSON.parse(body.callback_query.data);
127
+ action = typeof parsed?.id === 'string' ? parsed.id : String(parsed ?? '');
128
+ } catch {
129
+ action = body.callback_query.data;
130
+ }
131
+ }
132
+ if (!action) {
133
+ await respond(400, { error: { code: 'bad-action', message: 'dsh-key-rotation: no action in payload' } }, 'No action in payload');
134
+ return;
135
+ }
74
136
  const provider = action.startsWith('pause-') || action.startsWith('reset-') ? action.replace(/^(pause|reset)-/, '') : '';
75
137
  try {
76
138
  if (action === 'disable-rotation') {
77
139
  setRotationDisabled(true);
78
140
  (ctx?.logger ? ctx.logger('dsh-key-rotation') : null)?.warn?.('[dsh-key-rotation] rotation DISABLED via webhook action');
79
- json(res, 200, { ok: true, action });
141
+ await respond(200, { ok: true, action }, 'Rotation disabled');
80
142
  return;
81
143
  }
82
144
  if (action === 'enable-rotation') {
83
145
  setRotationDisabled(false);
84
- json(res, 200, { ok: true, action });
146
+ await respond(200, { ok: true, action }, 'Rotation enabled');
85
147
  return;
86
148
  }
87
149
  if (action.startsWith('pause-') || action.startsWith('reset-')) {
88
150
  const st = poolState.get(provider);
89
- if (!st) { json(res, 404, { error: { code: 'not-found', message: `dsh-key-rotation: no pool for '${provider}'` } }); return; }
151
+ if (!st) {
152
+ await respond(404, { error: { code: 'not-found', message: `dsh-key-rotation: no pool for '${provider}'` } }, `Pool not found: ${provider}`);
153
+ return;
154
+ }
90
155
  if (action.startsWith('pause-')) {
91
156
  const until = Date.now() + 3600000; // 1h pause
92
157
  for (const ref of (st.failedUntil ? [...st.failedUntil.keys()] : [])) st.failedUntil.set(ref, Math.max(st.failedUntil.get(ref) ?? 0, until));
@@ -96,7 +161,7 @@ ctx.effect(() => ctx.webServer.register({
96
161
  for (const ref of p.refs) st.failedUntil.set(ref, Math.max(st.failedUntil.get(ref) ?? 0, until));
97
162
  }
98
163
  (ctx?.logger ? ctx.logger('dsh-key-rotation') : null)?.warn?.(`[dsh-key-rotation] pool ${provider} PAUSED 1h via webhook action`);
99
- json(res, 200, { ok: true, action, provider, until: Date.now() + 3600000 });
164
+ await respond(200, { ok: true, action, provider, until }, `Pool ${provider} paused 1h`);
100
165
  return;
101
166
  }
102
167
  const cleared = st.failedUntil.size;
@@ -114,12 +179,12 @@ ctx.effect(() => ctx.webServer.register({
114
179
  else if (typeof br.onSuccess === 'function') { br.onSuccess(provider); circuitReset = true; }
115
180
  }
116
181
  (ctx?.logger ? ctx.logger('dsh-key-rotation') : null)?.warn?.(`[dsh-key-rotation] pool ${provider} RESET via webhook action (circuitReset=${circuitReset})`);
117
- json(res, 200, { ok: true, action, provider, cleared, circuitReset });
182
+ await respond(200, { ok: true, action, provider, cleared, circuitReset }, `Pool ${provider} reset`);
118
183
  return;
119
184
  }
120
- json(res, 400, { error: { code: 'unknown-action', message: `dsh-key-rotation: unknown action '${action}'` } });
185
+ await respond(400, { error: { code: 'unknown-action', message: `dsh-key-rotation: unknown action '${action}'` } }, `Unknown action: ${action}`);
121
186
  } catch (e) {
122
- json(res, 500, { error: { code: 'action-failed', message: String(e?.message ?? e) } });
187
+ await respond(500, { error: { code: 'action-failed', message: String(e?.message ?? e) } }, 'Action failed');
123
188
  }
124
189
  },
125
190
  }), 'dsh-key-rotation: webhook-action');
@@ -1,5 +1,5 @@
1
1
  import { spawn } from 'node:child_process'
2
- import { existsSync, readFileSync } from 'node:fs'
2
+ import { existsSync, readFileSync, rmSync, statSync } from 'node:fs'
3
3
  import { readFile } from 'node:fs/promises'
4
4
  import { homedir } from 'node:os'
5
5
  import { basename, dirname, isAbsolute, resolve } from 'node:path'
@@ -60,17 +60,17 @@ function validProfileName(value) {
60
60
  }
61
61
 
62
62
  function profileNameFromArgv(argv) {
63
- for (let index = 2; index < argv.length; index += 1) {
64
- if (argv[index] === '--profile') return argv[index + 1]
65
- if (argv[index]?.startsWith('--profile=')) return argv[index].slice('--profile='.length)
63
+ for (let index = 0; index < argv.length; index += 1) {
64
+ const item = argv[index]
65
+ if (item === '--profile' || item === '-p') return argv[index + 1]
66
+ if (typeof item === 'string' && item.startsWith('--profile=')) return item.slice('--profile='.length)
66
67
  }
67
- return argv[2] === 'web' ? 'web' : undefined
68
+ return undefined
68
69
  }
69
70
 
70
71
  function findDshCliEntry() {
71
- const value = process.argv[1]
72
- if (value === undefined || value === '') return undefined
73
- const entry = value.startsWith('file:') ? fileURLToPath(value) : resolve(process.cwd(), value)
72
+ const entry = process.argv[1]
73
+ if (typeof entry !== 'string' || entry === '') return undefined
74
74
  if (!existsSync(entry)) return undefined
75
75
  for (let directory = dirname(entry); ; directory = dirname(directory)) {
76
76
  const manifestPath = resolve(directory, 'package.json')
@@ -187,13 +187,87 @@ export function isValidPackageSpec(packageSpec) {
187
187
  return typeof packageSpec === 'string' && PACKAGE_SPEC_PATTERN.test(packageSpec)
188
188
  }
189
189
 
190
- async function installExact(target, packageSpec, options) {
190
+ export function readLockPid(lockPath) {
191
+ try {
192
+ if (!existsSync(lockPath)) return undefined
193
+ const stat = statSync(lockPath)
194
+ if (stat.isDirectory()) return undefined
195
+ const content = readFileSync(lockPath, 'utf8').trim()
196
+ if (!content) return undefined
197
+ try {
198
+ const parsed = JSON.parse(content)
199
+ if (parsed && typeof parsed.pid === 'number') return parsed.pid
200
+ } catch {}
201
+ const num = parseInt(content, 10)
202
+ if (!isNaN(num) && num > 0) return num
203
+ const match = /\b(\d{1,8})\b/.exec(content)
204
+ if (match) return Number(match[1])
205
+ return undefined
206
+ } catch {
207
+ return undefined
208
+ }
209
+ }
210
+
211
+ export function isPidAlive(pid) {
212
+ if (typeof pid !== 'number' || isNaN(pid) || pid <= 0) return false
213
+ try {
214
+ process.kill(pid, 0)
215
+ return true
216
+ } catch (err) {
217
+ return err?.code === 'EPERM'
218
+ }
219
+ }
220
+
221
+ export function checkAndCleanLock(profileDir, childPid) {
222
+ const lockPath = resolve(profileDir, 'package.json.lock')
223
+ if (!existsSync(lockPath)) return { locked: false }
224
+ const pid = readLockPid(lockPath)
225
+ if (pid !== undefined) {
226
+ if (isPidAlive(pid)) {
227
+ if (childPid !== undefined && pid === childPid) {
228
+ try {
229
+ rmSync(lockPath, { recursive: true, force: true })
230
+ return { locked: false, cleaned: true }
231
+ } catch {
232
+ return { locked: true, pid }
233
+ }
234
+ }
235
+ return { locked: true, pid }
236
+ }
237
+ // PID is dead, remove stale lock
238
+ try {
239
+ rmSync(lockPath, { recursive: true, force: true })
240
+ return { locked: false, cleaned: true }
241
+ } catch {
242
+ return { locked: true, pid }
243
+ }
244
+ }
245
+ // Unknown or empty lockfile format
246
+ if (childPid !== undefined) {
247
+ try {
248
+ rmSync(lockPath, { recursive: true, force: true })
249
+ return { locked: false, cleaned: true }
250
+ } catch {}
251
+ }
252
+ return { locked: true }
253
+ }
254
+
255
+ export async function installExact(target, packageSpec, options = {}) {
191
256
  if (target.cliEntry === undefined) throw new Error('Automatic update is unavailable in this runtime.')
192
257
  if (!isValidPackageSpec(packageSpec)) throw new Error(`Invalid package spec: ${packageSpec}`)
258
+
259
+ const lockStatus = checkAndCleanLock(target.profileDir)
260
+ if (lockStatus.locked) {
261
+ const error = new Error('Another plugin installation is currently in progress.')
262
+ error.status = 409
263
+ error.code = 'ELOCKED'
264
+ throw error
265
+ }
266
+
193
267
  await new Promise((resolvePromise, reject) => {
194
268
  const child = spawn(process.execPath, [
195
269
  target.cliEntry, 'plugin', '--profile', target.profileName, 'add',
196
- '--config.minimumReleaseAge=0', packageSpec,
270
+ packageSpec,
197
271
  `--registry=${options.registry ?? 'https://registry.npmjs.org/'}`,
198
272
  ], {
199
273
  cwd: target.profileDir,
@@ -204,13 +278,24 @@ async function installExact(target, packageSpec, options) {
204
278
  let detail = ''
205
279
  child.stdout?.on('data', chunk => { detail = (detail + String(chunk)).slice(-4_000) })
206
280
  child.stderr?.on('data', chunk => { detail = (detail + String(chunk)).slice(-4_000) })
281
+ let timedOut = false
282
+ const timeoutMs = options.timeoutMs ?? UPDATE_TIMEOUT_MS
207
283
  const timer = setTimeout(() => {
208
- child.kill()
284
+ timedOut = true
285
+ try { child.kill('SIGTERM') } catch {}
286
+ setTimeout(() => {
287
+ try { child.kill('SIGKILL') } catch {}
288
+ checkAndCleanLock(target.profileDir, child.pid)
289
+ }, 500).unref?.()
209
290
  reject(new Error('Update timed out; use the normal DSH update flow.'))
210
- }, UPDATE_TIMEOUT_MS)
291
+ }, timeoutMs)
211
292
  child.once('error', error => { clearTimeout(timer); reject(error) })
212
- child.once('exit', code => {
293
+ child.once('exit', (code, signal) => {
213
294
  clearTimeout(timer)
295
+ if (timedOut) {
296
+ checkAndCleanLock(target.profileDir, child.pid)
297
+ return
298
+ }
214
299
  if (code === 0) resolvePromise()
215
300
  else reject(new Error(detail.trim() || `Update exited with code ${String(code)}.`))
216
301
  })
@@ -255,6 +340,11 @@ export function registerPluginUpdater(ctx, options) {
255
340
  json(response, 409, { error: 'This plugin is already updating.' })
256
341
  return
257
342
  }
343
+ const profileLock = checkAndCleanLock(target.profileDir)
344
+ if (profileLock.locked) {
345
+ json(response, 409, { error: 'Another plugin installation is currently in progress in this profile.' })
346
+ return
347
+ }
258
348
  installing = true
259
349
  try {
260
350
  const before = await status(options, target)
@@ -276,6 +366,10 @@ export function registerPluginUpdater(ctx, options) {
276
366
  installing = false
277
367
  }
278
368
  } catch (error) {
369
+ if (error?.status === 409 || error?.code === 'ELOCKED') {
370
+ json(response, 409, { error: error.message || 'Another plugin installation is currently in progress.' })
371
+ return
372
+ }
279
373
  ctx.logger?.warn?.(`plugin updater failed: ${String(error)}`)
280
374
  json(response, 503, { error: 'Plugin update failed; see server logs.' })
281
375
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-key-rotation",
3
- "version": "0.8.31",
3
+ "version": "0.8.33",
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": [