@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.
- package/README.md +18 -0
- package/README.ru.md +18 -0
- package/README.zh.md +7 -0
- package/lib/best-effort.js +26 -0
- package/lib/bucket.js +1 -87
- package/lib/cascade.js +27 -39
- package/lib/client.js +141 -51
- package/lib/clock.js +0 -2
- package/lib/error-taxonomy.js +0 -3
- package/lib/http-bridge.js +0 -7
- package/lib/index.js +22 -56
- package/lib/notify-events.js +64 -0
- package/lib/ops-keys.js +189 -0
- package/lib/ops-paths.js +11 -0
- package/lib/ops-status.js +202 -0
- package/lib/ops-telemetry.js +113 -0
- package/lib/ops-test.js +106 -0
- package/lib/ops-webhook.js +115 -0
- package/lib/plugin-updater.js +277 -0
- package/lib/pool.js +5 -8
- package/lib/routes-ops.js +12 -630
- package/lib/webhook.js +2 -1
- package/package.json +5 -2
|
@@ -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
|
-
|
|
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
|
-
|
|
482
|
-
|
|
477
|
+
return false;
|
|
478
|
+
});
|
|
479
|
+
return ok === true;
|
|
483
480
|
}
|