@goodandready/dsh-key-rotation 0.8.10 → 0.8.12

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.
@@ -0,0 +1,115 @@
1
+ // lib/ops-webhook.js — webhook interactive action route (#312 split from routes-ops.js).
2
+ import {
3
+ json,
4
+ readJson,
5
+ } from './http-bridge.js';
6
+ import { WEBHOOK_ACTION_PATH } from './ops-paths.js';
7
+
8
+ /**
9
+ * @param {object} ctx cordis context
10
+ * @param {object} deps live dependencies from apply()
11
+ */
12
+ export function registerWebhookActionRoute(ctx, deps) {
13
+ const {
14
+ buildRuntime,
15
+ poolState,
16
+ setRotationDisabled,
17
+ circuitBreaker,
18
+ } = deps;
19
+
20
+ ctx.effect(() => ctx.webServer.register({
21
+ kind: 'exact',
22
+ path: WEBHOOK_ACTION_PATH,
23
+ handler: async (req, res) => {
24
+ if (req.method !== 'POST') { json(res, 405, { error: { code: 'method', message: 'POST only' } }); return; }
25
+ const runtime = buildRuntime();
26
+ const expected = runtime.webhookActionToken;
27
+ if (!expected) { json(res, 503, { error: { code: 'no-token', message: 'dsh-key-rotation: webhookActionToken is not configured' } }); return; }
28
+ 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
+ let body;
31
+ try { body = await readJson(req); } catch (e) { json(res, 400, { error: { code: 'bad-request', message: String(e?.message ?? e) } }); return; }
32
+ // Accept callback payloads from formatInteractive (Telegram/Discord/Slack) or plain {action}
33
+ let action = typeof body?.action === 'string' ? body.action : '';
34
+ if (!action && typeof body?.data === 'string') {
35
+ try { action = String(JSON.parse(body.data)?.id ?? ''); } catch { action = ''; }
36
+ }
37
+ if (!action && typeof body?.callback_data === 'string') {
38
+ try { action = String(JSON.parse(body.callback_data)?.id ?? ''); } catch { action = ''; }
39
+ }
40
+ // #222: Telegram update envelope {update_id, callback_query:{data}}
41
+ if (!action && typeof body?.callback_query?.data === 'string') {
42
+ try { action = String(JSON.parse(body.callback_query.data)?.id ?? ''); } catch { action = ''; }
43
+ }
44
+ // #222: Telegram setWebhook registration helper
45
+ if (typeof body?.setWebhook === 'object' && body.setWebhook) {
46
+ const botToken = typeof body.setWebhook.botToken === 'string' ? body.setWebhook.botToken : '';
47
+ if (!botToken) { json(res, 400, { error: { code: 'bad-request', message: 'dsh-key-rotation: setWebhook.botToken required' } }); return; }
48
+ // derive the public URL from request headers; explicit URL wins
49
+ const url = typeof body.setWebhook.url === 'string' && body.setWebhook.url ? body.setWebhook.url : `https://${String(req.headers.host ?? '')}/dsh-key-rotation/webhook-action`;
50
+ try {
51
+ const hookRes = await fetch(`https://api.telegram.org/bot${botToken}/setWebhook`, {
52
+ method: 'POST', headers: { 'content-type': 'application/json' },
53
+ body: JSON.stringify({ url, allowed_updates: ['callback_query'] }),
54
+ });
55
+ const hookData = await hookRes.json().catch(() => ({}));
56
+ json(res, 200, { ok: hookRes.ok, url, telegram: hookData });
57
+ } catch (e) {
58
+ json(res, 502, { error: { code: 'telegram-failed', message: String(e?.message ?? e) } });
59
+ }
60
+ return;
61
+ }
62
+ if (!action) { json(res, 400, { error: { code: 'bad-action', message: 'dsh-key-rotation: no action in payload' } }); return; }
63
+ const provider = action.startsWith('pause-') || action.startsWith('reset-') ? action.replace(/^(pause|reset)-/, '') : '';
64
+ try {
65
+ if (action === 'disable-rotation') {
66
+ setRotationDisabled(true);
67
+ console.warn('[dsh-key-rotation] rotation DISABLED via webhook action');
68
+ json(res, 200, { ok: true, action });
69
+ return;
70
+ }
71
+ if (action === 'enable-rotation') {
72
+ setRotationDisabled(false);
73
+ json(res, 200, { ok: true, action });
74
+ return;
75
+ }
76
+ if (action.startsWith('pause-') || action.startsWith('reset-')) {
77
+ const st = poolState.get(provider);
78
+ if (!st) { json(res, 404, { error: { code: 'not-found', message: `dsh-key-rotation: no pool for '${provider}'` } }); return; }
79
+ if (action.startsWith('pause-')) {
80
+ const until = Date.now() + 3600000; // 1h pause
81
+ for (const ref of (st.failedUntil ? [...st.failedUntil.keys()] : [])) st.failedUntil.set(ref, Math.max(st.failedUntil.get(ref) ?? 0, until));
82
+ // also pause every key currently healthy
83
+ for (const p of buildRuntime().poolByRef.values()) {
84
+ if (p.base !== provider) continue;
85
+ for (const ref of p.refs) st.failedUntil.set(ref, Math.max(st.failedUntil.get(ref) ?? 0, until));
86
+ }
87
+ console.warn(`[dsh-key-rotation] pool ${provider} PAUSED 1h via webhook action`);
88
+ json(res, 200, { ok: true, action, provider, until: Date.now() + 3600000 });
89
+ return;
90
+ }
91
+ const cleared = st.failedUntil.size;
92
+ st.failedUntil.clear();
93
+ st.failCounts?.clear();
94
+ st.authFailCounts?.clear();
95
+ st.brokenUntil?.clear();
96
+ st.switches = 0;
97
+ st.lastReason = undefined;
98
+ st.lastSwitchAt = undefined;
99
+ let circuitReset = false;
100
+ const br = circuitBreaker ?? buildRuntime().breaker;
101
+ if (br) {
102
+ if (typeof br.reset === 'function') { br.reset(provider); circuitReset = true; }
103
+ else if (typeof br.onSuccess === 'function') { br.onSuccess(provider); circuitReset = true; }
104
+ }
105
+ console.warn(`[dsh-key-rotation] pool ${provider} RESET via webhook action (circuitReset=${circuitReset})`);
106
+ json(res, 200, { ok: true, action, provider, cleared, circuitReset });
107
+ return;
108
+ }
109
+ json(res, 400, { error: { code: 'unknown-action', message: `dsh-key-rotation: unknown action '${action}'` } });
110
+ } catch (e) {
111
+ json(res, 500, { error: { code: 'action-failed', message: String(e?.message ?? e) } });
112
+ }
113
+ },
114
+ }), 'dsh-key-rotation: webhook-action');
115
+ }
@@ -0,0 +1,277 @@
1
+ import { spawn } from 'node:child_process'
2
+ import { existsSync, readFileSync } from 'node:fs'
3
+ import { readFile } from 'node:fs/promises'
4
+ import { homedir } from 'node:os'
5
+ import { basename, dirname, isAbsolute, resolve } from 'node:path'
6
+ import { fileURLToPath } from 'node:url'
7
+
8
+ /**
9
+ * Host-side one-click updater for a DSH Web plugin.
10
+ *
11
+ * Mount from apply():
12
+ *
13
+ * registerPluginUpdater(ctx, {
14
+ * endpoint: '/dsh-key-rotation/update',
15
+ * packageName: '@goodandready/dsh-key-rotation',
16
+ * manifestUrl: new URL('../package.json', import.meta.url),
17
+ * })
18
+ *
19
+ * GET returns status. POST is accepted only from the local same-origin UI and
20
+ * installs the exact registry version through the normal DSH CLI.
21
+ */
22
+
23
+ const UPDATE_HEADER = 'x-dsh-plugin-update'
24
+ const UPDATE_TIMEOUT_MS = 10 * 60_000
25
+ const VERSION_CACHE_MS = 5 * 60_000
26
+ let latestCache
27
+
28
+ function header(request, name) {
29
+ const value = request.headers?.[name]
30
+ return Array.isArray(value) ? value[0] : value
31
+ }
32
+
33
+ function isLoopback(value) {
34
+ const address = value?.toLowerCase().replace(/^\[|\]$/g, '')
35
+ return address === 'localhost' || address === 'localhost.' || address === '::1'
36
+ || address?.startsWith('127.') === true
37
+ || address?.startsWith('::ffff:127.') === true
38
+ }
39
+
40
+ export function isTrustedUpdateRequest(request) {
41
+ if (header(request, UPDATE_HEADER) !== '1') return false
42
+ if (!isLoopback(request.socket?.remoteAddress)) return false
43
+ const site = header(request, 'sec-fetch-site')
44
+ if (site !== undefined && site !== 'same-origin') return false
45
+ const origin = header(request, 'origin')
46
+ const host = header(request, 'host')
47
+ if (origin === undefined || host === undefined) return false
48
+ try {
49
+ const url = new URL(origin)
50
+ return (url.protocol === 'http:' || url.protocol === 'https:')
51
+ && isLoopback(url.hostname) && url.host === host
52
+ } catch {
53
+ return false
54
+ }
55
+ }
56
+
57
+ function validProfileName(value) {
58
+ return typeof value === 'string' && value !== '' && value !== '.' && value !== '..'
59
+ && !value.includes('/') && !value.includes('\\') && !/[\0-\x1f\x7f]/.test(value)
60
+ }
61
+
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)
66
+ }
67
+ return argv[2] === 'web' ? 'web' : undefined
68
+ }
69
+
70
+ 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)
74
+ if (!existsSync(entry)) return undefined
75
+ for (let directory = dirname(entry); ; directory = dirname(directory)) {
76
+ const manifestPath = resolve(directory, 'package.json')
77
+ if (existsSync(manifestPath)) {
78
+ try {
79
+ const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'))
80
+ const bin = typeof manifest.bin === 'string'
81
+ ? manifest.bin
82
+ : typeof manifest.bin === 'object' && manifest.bin !== null
83
+ ? manifest.bin.dsh
84
+ : undefined
85
+ if (manifest.name === '@deepseek-ai/dsh' && typeof bin === 'string'
86
+ && !isAbsolute(bin) && resolve(directory, bin) === resolve(entry)) return entry
87
+ } catch {
88
+ // Continue searching parent package directories.
89
+ }
90
+ }
91
+ const parent = dirname(directory)
92
+ if (parent === directory) return undefined
93
+ }
94
+ }
95
+
96
+ function runtime() {
97
+ const profileDir = resolve(process.env.DSH_PROFILE_DIR
98
+ ?? resolve(homedir(), '.dsh', 'profiles', 'web'))
99
+ const selected = profileNameFromArgv(process.argv)
100
+ const profileName = validProfileName(selected)
101
+ ? selected
102
+ : validProfileName(basename(profileDir)) ? basename(profileDir) : 'web'
103
+ const cliEntry = findDshCliEntry()
104
+ return cliEntry === undefined ? { profileName, profileDir } : { profileName, profileDir, cliEntry }
105
+ }
106
+
107
+ function parseSemver(value) {
108
+ const match = /^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/.exec(value)
109
+ if (match === null) return undefined
110
+ return {
111
+ core: [Number(match[1]), Number(match[2]), Number(match[3])],
112
+ prerelease: match[4]?.split('.') ?? [],
113
+ }
114
+ }
115
+
116
+ function comparePrerelease(left, right) {
117
+ if (left.length === 0 || right.length === 0) return left.length === right.length ? 0 : left.length === 0 ? 1 : -1
118
+ const length = Math.max(left.length, right.length)
119
+ for (let index = 0; index < length; index += 1) {
120
+ const a = left[index]
121
+ const b = right[index]
122
+ if (a === undefined || b === undefined) return a === b ? 0 : a === undefined ? -1 : 1
123
+ if (a === b) continue
124
+ const aNumeric = /^\d+$/.test(a)
125
+ const bNumeric = /^\d+$/.test(b)
126
+ if (aNumeric && bNumeric) {
127
+ const aNumber = BigInt(a)
128
+ const bNumber = BigInt(b)
129
+ if (aNumber !== bNumber) return aNumber > bNumber ? 1 : -1
130
+ continue
131
+ }
132
+ if (aNumeric !== bNumeric) return aNumeric ? -1 : 1
133
+ return a > b ? 1 : -1
134
+ }
135
+ return 0
136
+ }
137
+
138
+ export function isNewerVersion(currentValue, candidateValue) {
139
+ const current = parseSemver(currentValue)
140
+ const candidate = parseSemver(candidateValue)
141
+ if (current === undefined || candidate === undefined) return false
142
+ for (let index = 0; index < 3; index += 1) {
143
+ if (candidate.core[index] !== current.core[index]) return candidate.core[index] > current.core[index]
144
+ }
145
+ return comparePrerelease(candidate.prerelease, current.prerelease) > 0
146
+ }
147
+
148
+ async function latestVersion(packageName, registry) {
149
+ if (latestCache?.packageName === packageName && latestCache.registry === registry && Date.now() < latestCache.expiresAt) return latestCache.version
150
+ try {
151
+ const response = await fetch(`${registry.replace(/\/$/, '')}/${encodeURIComponent(packageName)}/latest`, {
152
+ signal: AbortSignal.timeout(8_000),
153
+ })
154
+ if (!response.ok) return undefined
155
+ const value = await response.json()
156
+ if (typeof value.version !== 'string' || value.version === '') return undefined
157
+ latestCache = { packageName, registry, version: value.version, expiresAt: Date.now() + VERSION_CACHE_MS }
158
+ return value.version
159
+ } catch {
160
+ return undefined
161
+ }
162
+ }
163
+
164
+ async function currentVersion(manifestUrl) {
165
+ const value = JSON.parse(await readFile(manifestUrl, 'utf8'))
166
+ if (typeof value.version !== 'string' || value.version === '') throw new Error('Cannot read current plugin version.')
167
+ return value.version
168
+ }
169
+
170
+ async function status(options, target) {
171
+ const current = await currentVersion(options.manifestUrl)
172
+ const latest = await latestVersion(options.packageName, options.registry ?? 'https://registry.npmjs.org')
173
+ return {
174
+ packageName: options.packageName,
175
+ currentVersion: current,
176
+ ...(latest === undefined ? {} : { latestVersion: latest }),
177
+ latestCheckFailed: latest === undefined,
178
+ updateAvailable: latest !== undefined && isNewerVersion(current, latest),
179
+ profileName: target.profileName,
180
+ canAutoUpdate: target.cliEntry !== undefined,
181
+ }
182
+ }
183
+
184
+ async function installExact(target, packageSpec, options) {
185
+ if (target.cliEntry === undefined) throw new Error('Automatic update is unavailable in this runtime.')
186
+ await new Promise((resolvePromise, reject) => {
187
+ const child = spawn(process.execPath, [
188
+ target.cliEntry, 'plugin', '--profile', target.profileName, 'add',
189
+ '--config.minimumReleaseAge=0', packageSpec,
190
+ `--registry=${options.registry ?? 'https://registry.npmjs.org/'}`,
191
+ ], {
192
+ cwd: target.profileDir,
193
+ windowsHide: true,
194
+ stdio: ['ignore', 'pipe', 'pipe'],
195
+ env: { ...process.env, NO_COLOR: '1' },
196
+ })
197
+ let detail = ''
198
+ child.stdout?.on('data', chunk => { detail = (detail + String(chunk)).slice(-4_000) })
199
+ child.stderr?.on('data', chunk => { detail = (detail + String(chunk)).slice(-4_000) })
200
+ const timer = setTimeout(() => {
201
+ child.kill()
202
+ reject(new Error('Update timed out; use the normal DSH update flow.'))
203
+ }, UPDATE_TIMEOUT_MS)
204
+ child.once('error', error => { clearTimeout(timer); reject(error) })
205
+ child.once('exit', code => {
206
+ clearTimeout(timer)
207
+ if (code === 0) resolvePromise()
208
+ else reject(new Error(detail.trim() || `Update exited with code ${String(code)}.`))
209
+ })
210
+ })
211
+ }
212
+
213
+ function json(response, statusCode, value) {
214
+ response.writeHead(statusCode, {
215
+ 'content-type': 'application/json; charset=utf-8',
216
+ 'cache-control': 'no-store',
217
+ })
218
+ response.end(JSON.stringify(value))
219
+ }
220
+
221
+ export function registerPluginUpdater(ctx, options) {
222
+ let installing = false
223
+ return ctx.webServer.register({
224
+ kind: 'exact',
225
+ path: options.endpoint,
226
+ handler: async (request, response) => {
227
+ try {
228
+ const target = runtime()
229
+ if (request.method === 'GET' || request.method === 'HEAD') {
230
+ const payload = await status(options, target)
231
+ response.writeHead(200, {
232
+ 'content-type': 'application/json; charset=utf-8',
233
+ 'cache-control': 'no-store',
234
+ })
235
+ response.end(request.method === 'HEAD' ? undefined : JSON.stringify(payload))
236
+ return
237
+ }
238
+ if (request.method !== 'POST') {
239
+ response.writeHead(405, { allow: 'GET, HEAD, POST' })
240
+ response.end()
241
+ return
242
+ }
243
+ if (!isTrustedUpdateRequest(request)) {
244
+ json(response, 403, { error: 'Rejected non-local or cross-origin update request.' })
245
+ return
246
+ }
247
+ if (installing) {
248
+ json(response, 409, { error: 'This plugin is already updating.' })
249
+ return
250
+ }
251
+ installing = true
252
+ try {
253
+ const before = await status(options, target)
254
+ if (before.latestVersion === undefined) {
255
+ json(response, 503, { error: 'The latest version is temporarily unavailable.' })
256
+ return
257
+ }
258
+ if (!before.updateAvailable) {
259
+ json(response, 200, before)
260
+ return
261
+ }
262
+ await installExact(target, `${options.packageName}@${before.latestVersion}`, options)
263
+ json(response, 200, {
264
+ ...before,
265
+ updatedVersion: before.latestVersion,
266
+ restartRequired: true,
267
+ })
268
+ } finally {
269
+ installing = false
270
+ }
271
+ } catch (error) {
272
+ ctx.logger?.warn?.(`plugin updater failed: ${String(error)}`)
273
+ json(response, 503, { error: 'Plugin update failed; see server logs.' })
274
+ }
275
+ },
276
+ })
277
+ }
package/lib/pool.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { bestEffort } from './best-effort.js';
1
2
  // lib/pool.js — pure pool arithmetic and selection logic for dsh-key-rotation.
2
3
  // Isolated from cordis/dsh runtime so unit tests run in vanilla Node.js.
3
4
 
@@ -71,11 +72,6 @@ export const SOFT_FAILURE_CODES = new Set([
71
72
  ]);
72
73
 
73
74
  /** True if failure code or message represents a soft/transient drop. */
74
- export function isSoftFailure(code, message = '') {
75
- if (code && SOFT_FAILURE_CODES.has(String(code).toUpperCase())) return true;
76
- if (/502|503|504|timeout|econnreset|econnrefused|socket hang up/i.test(message)) return true;
77
- return false;
78
- }
79
75
 
80
76
  /** Ref name validator. Same rule lib/index.js enforces in PUT/DELETE /key. */
81
77
  const REF_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
@@ -469,7 +465,7 @@ export function budgetVerdict(spend, budget) {
469
465
  /** Safely reset a provider's circuit breaker to closed state. */
470
466
  export function resetCircuitForProvider(circuitBreaker, provider) {
471
467
  if (!circuitBreaker || !provider) return false;
472
- try {
468
+ const ok = bestEffort('resetCircuitForProvider', () => {
473
469
  if (typeof circuitBreaker.reset === 'function') {
474
470
  circuitBreaker.reset(provider);
475
471
  return true;
@@ -478,6 +474,7 @@ export function resetCircuitForProvider(circuitBreaker, provider) {
478
474
  circuitBreaker.onSuccess(provider);
479
475
  return true;
480
476
  }
481
- } catch (_) {}
482
- return false;
477
+ return false;
478
+ });
479
+ return ok === true;
483
480
  }