@goodandready/dsh-key-rotation 0.8.10 → 0.8.11
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 +20 -3
- package/lib/plugin-updater.js +277 -0
- package/lib/pool.js +5 -8
- package/lib/routes-ops.js +3 -2
- package/lib/webhook.js +2 -1
- package/package.json +5 -2
package/README.md
CHANGED
|
@@ -38,6 +38,13 @@
|
|
|
38
38
|
|
|
39
39
|
## ⚡ Overview & The Problem
|
|
40
40
|
|
|
41
|
+
### 🚀 What's New in v0.8.11 (One-click Updater & Quality Gate)
|
|
42
|
+
|
|
43
|
+
- **Plugin updater in Settings**: see current/latest version and update from the card without leaving DSH (`#307`).
|
|
44
|
+
- **Safer best-effort side effects**: intentional non-critical failures log at debug instead of silent empty `catch` (`#315`).
|
|
45
|
+
- **Theme-only client colors** and production-path test cleanup (`#311`, `#314`).
|
|
46
|
+
- **Leaner publication set**: agent-only files no longer ship in git/npm (`#308`).
|
|
47
|
+
|
|
41
48
|
### 🚀 What's New in v0.8.10 (Stream Concurrency Hardening & Auto-Pruning)
|
|
42
49
|
- **Zero Concurrency Leaks**: Guaranteed release of stream concurrency slots via deterministic `try ... finally` block, preventing key starvation during clean finishes or client stream aborts.
|
|
43
50
|
- **Robust Probe Retry**: Added transient network socket error retry (`PROBE_RETRY_DELAY_MS`) in `SandboxRunner.probeModels` before marking keys as broken.
|
|
@@ -164,6 +171,17 @@ graph LR
|
|
|
164
171
|
|
|
165
172
|
---
|
|
166
173
|
|
|
174
|
+
|
|
175
|
+
### 🔁 8. One-click Plugin Updater
|
|
176
|
+
|
|
177
|
+
The settings card includes an **Updater** section:
|
|
178
|
+
|
|
179
|
+
1. **Check for updates** — `GET /api/dsh-key-rotation/update` returns `currentVersion`, `latestVersion`, `updateAvailable`, `canAutoUpdate` (version metadata only; no secrets).
|
|
180
|
+
2. **Update now** — `POST` with the same path installs the exact latest npm version through the standard `dsh plugin add` flow. The request is accepted only from loopback with a matching same-origin `Origin`/`Host` (and the dedicated header). Cross-origin or missing-origin POST is rejected with `403`.
|
|
181
|
+
3. After a successful install the UI tells you to **restart DSH** so the new host code loads.
|
|
182
|
+
|
|
183
|
+
No `--force`, no raw shell, no install from worktree/DEV paths. Update runs only after an explicit click.
|
|
184
|
+
|
|
167
185
|
## 🖥️ Rich Web GUI & Dashboard
|
|
168
186
|
|
|
169
187
|
Access full visual management under **Settings → Key Rotation** or via the Header quick-widget.
|
package/README.ru.md
CHANGED
|
@@ -36,6 +36,13 @@
|
|
|
36
36
|
|
|
37
37
|
---
|
|
38
38
|
|
|
39
|
+
### 🚀 Что нового в v0.8.11 (обновление в один клик и quality gate)
|
|
40
|
+
|
|
41
|
+
- **Обновление плагина в карточке настроек**: текущая/последняя версия и кнопка установки без выхода из DSH (#307).
|
|
42
|
+
- **bestEffort вместо пустых catch**: осознанные побочные эффекты логируются на debug, а не глотаются (#315).
|
|
43
|
+
- **Цвета клиента только из токенов темы** и чистка production-path тестов (#311, #314).
|
|
44
|
+
- **Публикационный набор без служебных файлов агентов** (#308).
|
|
45
|
+
|
|
39
46
|
## ⚡ Обзор и решаемая проблема
|
|
40
47
|
|
|
41
48
|
### 🚀 Что нового в версии 0.8.10 (Надёжность стриминга и очистка памяти)
|
|
@@ -190,6 +197,17 @@ graph LR
|
|
|
190
197
|
|
|
191
198
|
---
|
|
192
199
|
|
|
200
|
+
|
|
201
|
+
### 🔁 8. Обновление плагина в один клик
|
|
202
|
+
|
|
203
|
+
В карточке настроек есть раздел **Updater**:
|
|
204
|
+
|
|
205
|
+
1. **Проверить обновления** — `GET /api/dsh-key-rotation/update` возвращает `currentVersion`, `latestVersion`, `updateAvailable`, `canAutoUpdate` (только метаданные версии).
|
|
206
|
+
2. **Обновить сейчас** — `POST` ставит точную последнюю npm-версию штатным `dsh plugin add`. Запрос принимается только с loopback и совпадающим same-origin `Origin`/`Host`; иначе `403`.
|
|
207
|
+
3. После установки UI предлагает **перезапустить DSH**.
|
|
208
|
+
|
|
209
|
+
Без `--force`, без raw shell и установки из worktree/DEV. Обновление — только по явному нажатию.
|
|
210
|
+
|
|
193
211
|
## 📦 Установка
|
|
194
212
|
|
|
195
213
|
```bash
|
package/README.zh.md
CHANGED
|
@@ -38,6 +38,13 @@
|
|
|
38
38
|
|
|
39
39
|
## ⚡ 概述与核心痛点
|
|
40
40
|
|
|
41
|
+
### 🚀 v0.8.11 新特性(一键更新与质量门禁)
|
|
42
|
+
|
|
43
|
+
- **设置卡片中的插件更新器**:查看当前/最新版本并一键安装(#307)。
|
|
44
|
+
- **bestEffort 替代空 catch**:非关键副作用在 debug 级别记录,不再被静默吞掉(#315).
|
|
45
|
+
- **客户端颜色仅使用主题变量**,并清理 production-path 测试(#311、#314)。
|
|
46
|
+
- **发布集不再包含代理专用文件**(#308)。
|
|
47
|
+
|
|
41
48
|
### 🚀 v0.8.10 版本新特性(流并发加固与状态自动修剪)
|
|
42
49
|
- **杜绝并发计数泄漏**:在 `try ... finally` 中强制释放流并发占用,防止在正常完成或客户端中断时密钥被永久锁定。
|
|
43
50
|
- **探测抗网络抖动重试**:在 `probeModels` 中遇到套接字网络临时错误时自动重试一次,避免密钥被误判损坏。
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/** Run a non-critical side effect; never throw. Logs at debug when a logger is provided. */
|
|
2
|
+
export function bestEffort(label, fn, logger) {
|
|
3
|
+
const log = (err) => {
|
|
4
|
+
try {
|
|
5
|
+
logger?.debug?.(`dsh-key-rotation: best-effort ${label}`, err);
|
|
6
|
+
} catch {
|
|
7
|
+
/* logger itself must not throw */
|
|
8
|
+
}
|
|
9
|
+
};
|
|
10
|
+
try {
|
|
11
|
+
const result = fn();
|
|
12
|
+
if (result && typeof result.then === 'function') {
|
|
13
|
+
return result.then(
|
|
14
|
+
(value) => value,
|
|
15
|
+
(err) => {
|
|
16
|
+
log(err);
|
|
17
|
+
return undefined;
|
|
18
|
+
},
|
|
19
|
+
);
|
|
20
|
+
}
|
|
21
|
+
return result;
|
|
22
|
+
} catch (err) {
|
|
23
|
+
log(err);
|
|
24
|
+
return undefined;
|
|
25
|
+
}
|
|
26
|
+
}
|
package/lib/bucket.js
CHANGED
|
@@ -1,65 +1,6 @@
|
|
|
1
|
-
// lib/bucket.js - per-key RPM token bucket (#192)
|
|
1
|
+
// lib/bucket.js - per-key RPM token bucket (#192).
|
|
2
2
|
const WINDOW_MS = 60000;
|
|
3
3
|
|
|
4
|
-
/**
|
|
5
|
-
* O(1) Mathematical Token Bucket Accumulator.
|
|
6
|
-
* tokens = min(capacity, tokens + (now - lastRefill) * refillRate)
|
|
7
|
-
*/
|
|
8
|
-
export class TokenBucketAccumulator {
|
|
9
|
-
constructor(capacity, windowMs = WINDOW_MS, now = Date.now()) {
|
|
10
|
-
this.capacity = Math.max(1, capacity);
|
|
11
|
-
this.tokens = this.capacity;
|
|
12
|
-
this.windowMs = windowMs;
|
|
13
|
-
this.refillRate = this.capacity / this.windowMs; // tokens per ms
|
|
14
|
-
this.lastRefill = now;
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
refill(now = Date.now()) {
|
|
18
|
-
const elapsed = Math.max(0, now - this.lastRefill);
|
|
19
|
-
if (elapsed > 0) {
|
|
20
|
-
this.tokens = Math.min(this.capacity, this.tokens + elapsed * this.refillRate);
|
|
21
|
-
this.lastRefill = now;
|
|
22
|
-
}
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
allow(cost = 1, now = Date.now()) {
|
|
26
|
-
this.refill(now);
|
|
27
|
-
if (this.tokens >= cost) {
|
|
28
|
-
this.tokens -= cost;
|
|
29
|
-
return true;
|
|
30
|
-
}
|
|
31
|
-
return false;
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
retryMs(cost = 1, now = Date.now()) {
|
|
35
|
-
this.refill(now);
|
|
36
|
-
if (this.tokens >= cost) return 0;
|
|
37
|
-
const needed = cost - this.tokens;
|
|
38
|
-
return Math.ceil(needed / this.refillRate);
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
updateCapacity(newCapacity, now = Date.now()) {
|
|
42
|
-
this.refill(now);
|
|
43
|
-
const prevCap = this.capacity;
|
|
44
|
-
this.capacity = Math.max(1, newCapacity);
|
|
45
|
-
this.refillRate = this.capacity / this.windowMs;
|
|
46
|
-
// Scale current tokens proportionally or clamp
|
|
47
|
-
this.tokens = Math.min(this.capacity, Math.max(0, this.tokens + (this.capacity - prevCap)));
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
info(now = Date.now()) {
|
|
51
|
-
this.refill(now);
|
|
52
|
-
const used = Math.max(0, Math.round(this.capacity - this.tokens));
|
|
53
|
-
const remaining = Math.max(0, Math.floor(this.tokens));
|
|
54
|
-
return {
|
|
55
|
-
used,
|
|
56
|
-
remaining,
|
|
57
|
-
resetMs: this.retryMs(1, now),
|
|
58
|
-
capacity: this.capacity,
|
|
59
|
-
};
|
|
60
|
-
}
|
|
61
|
-
}
|
|
62
|
-
|
|
63
4
|
/** Sliding-window check: true if `ref` is under `limit` requests/min. */
|
|
64
5
|
export function bucketAllow(windows, ref, limit, now = Date.now()) {
|
|
65
6
|
if (!limit || limit <= 0) return true;
|
|
@@ -74,14 +15,6 @@ export function bucketAllow(windows, ref, limit, now = Date.now()) {
|
|
|
74
15
|
return true;
|
|
75
16
|
}
|
|
76
17
|
|
|
77
|
-
/** Record a hit without checking (use after a successful resolve). */
|
|
78
|
-
export function bucketHit(windows, ref, now = Date.now()) {
|
|
79
|
-
const cut = now - WINDOW_MS;
|
|
80
|
-
const hits = (windows.get(ref) ?? []).filter((t) => t > cut);
|
|
81
|
-
hits.push(now);
|
|
82
|
-
windows.set(ref, hits);
|
|
83
|
-
}
|
|
84
|
-
|
|
85
18
|
/** ms until `ref` may retry again (0 = now). */
|
|
86
19
|
export function bucketRetryMs(windows, ref, limit, now = Date.now()) {
|
|
87
20
|
if (!limit || limit <= 0) return 0;
|
|
@@ -108,22 +41,3 @@ export function bucketInfo(windows, ref, limit, now = Date.now()) {
|
|
|
108
41
|
};
|
|
109
42
|
}
|
|
110
43
|
|
|
111
|
-
/**
|
|
112
|
-
* Adaptive limit computation from HTTP headers with manual limit precedence.
|
|
113
|
-
* If manualLimit is given (> 0), it acts as the upper ceiling.
|
|
114
|
-
* If adaptive is enabled and upstream reports a lower limit/remaining, adapt downwards.
|
|
115
|
-
*/
|
|
116
|
-
export function computeEffectiveLimit(headerRateLimit, manualLimit, adaptiveEnabled = true) {
|
|
117
|
-
const manual = (Number.isFinite(manualLimit) && manualLimit > 0) ? manualLimit : null;
|
|
118
|
-
if (!adaptiveEnabled || !headerRateLimit) {
|
|
119
|
-
return manual;
|
|
120
|
-
}
|
|
121
|
-
const headerLimit = headerRateLimit.limit;
|
|
122
|
-
if (Number.isFinite(headerLimit) && headerLimit > 0) {
|
|
123
|
-
if (manual) {
|
|
124
|
-
return Math.min(manual, headerLimit); // manual acts as upper ceiling
|
|
125
|
-
}
|
|
126
|
-
return headerLimit;
|
|
127
|
-
}
|
|
128
|
-
return manual;
|
|
129
|
-
}
|
package/lib/cascade.js
CHANGED
|
@@ -1,39 +1,27 @@
|
|
|
1
|
-
// cascade.js — cross-provider failover cascade (issue #194).
|
|
2
|
-
// ponytail: minimal — pick fallback provider from a RegionMap-like config.
|
|
3
|
-
|
|
4
|
-
export const CASCADE_MAX_DEPTH = 1;
|
|
5
|
-
|
|
6
|
-
export function pickCascadeFallback(provider, cfg, pools) {
|
|
7
|
-
const list = Array.isArray(cfg && cfg.cascade) ? cfg.cascade : [];
|
|
8
|
-
for (const entry of list) {
|
|
9
|
-
const fb = typeof entry === 'string' ? { provider: entry } : entry;
|
|
10
|
-
if (!fb || !fb.provider || fb.provider === provider) continue;
|
|
11
|
-
const pool = pools instanceof Map ? pools.get(fb.provider) : (pools ? pools[fb.provider] : null);
|
|
12
|
-
if (!pool) continue;
|
|
13
|
-
const now = Date.now();
|
|
14
|
-
let healthy = 0;
|
|
15
|
-
for (const ref of pool.refs) {
|
|
16
|
-
const failedUntil = (pool.state && pool.state.failedUntil && pool.state.failedUntil.get(ref)) || 0;
|
|
17
|
-
if (failedUntil > now) continue;
|
|
18
|
-
const exp = pool.expiresAt ? pool.expiresAt[ref] : undefined;
|
|
19
|
-
if (exp !== undefined && now >= exp) continue;
|
|
20
|
-
healthy += 1;
|
|
21
|
-
}
|
|
22
|
-
if (healthy === 0) continue;
|
|
23
|
-
return { provider: fb.provider, pool, model: fb.model || null };
|
|
24
|
-
}
|
|
25
|
-
return null;
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
export function hasHealthyKey(pool, now) {
|
|
29
|
-
now = now || Date.now();
|
|
30
|
-
if (!pool || !Array.isArray(pool.refs)) return false;
|
|
31
|
-
for (const ref of pool.refs) {
|
|
32
|
-
const failedUntil = (pool.state && pool.state.failedUntil && pool.state.failedUntil.get(ref)) || 0;
|
|
33
|
-
if (failedUntil > now) continue;
|
|
34
|
-
const exp = pool.expiresAt ? pool.expiresAt[ref] : undefined;
|
|
35
|
-
if (exp !== undefined && now >= exp) continue;
|
|
36
|
-
return true;
|
|
37
|
-
}
|
|
38
|
-
return false;
|
|
39
|
-
}
|
|
1
|
+
// cascade.js — cross-provider failover cascade (issue #194).
|
|
2
|
+
// ponytail: minimal — pick fallback provider from a RegionMap-like config.
|
|
3
|
+
|
|
4
|
+
export const CASCADE_MAX_DEPTH = 1;
|
|
5
|
+
|
|
6
|
+
export function pickCascadeFallback(provider, cfg, pools) {
|
|
7
|
+
const list = Array.isArray(cfg && cfg.cascade) ? cfg.cascade : [];
|
|
8
|
+
for (const entry of list) {
|
|
9
|
+
const fb = typeof entry === 'string' ? { provider: entry } : entry;
|
|
10
|
+
if (!fb || !fb.provider || fb.provider === provider) continue;
|
|
11
|
+
const pool = pools instanceof Map ? pools.get(fb.provider) : (pools ? pools[fb.provider] : null);
|
|
12
|
+
if (!pool) continue;
|
|
13
|
+
const now = Date.now();
|
|
14
|
+
let healthy = 0;
|
|
15
|
+
for (const ref of pool.refs) {
|
|
16
|
+
const failedUntil = (pool.state && pool.state.failedUntil && pool.state.failedUntil.get(ref)) || 0;
|
|
17
|
+
if (failedUntil > now) continue;
|
|
18
|
+
const exp = pool.expiresAt ? pool.expiresAt[ref] : undefined;
|
|
19
|
+
if (exp !== undefined && now >= exp) continue;
|
|
20
|
+
healthy += 1;
|
|
21
|
+
}
|
|
22
|
+
if (healthy === 0) continue;
|
|
23
|
+
return { provider: fb.provider, pool, model: fb.model || null };
|
|
24
|
+
}
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
|
package/lib/client.js
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
// manual route typing is ever needed. The plugin derives the fallback chain and
|
|
10
10
|
// auto-creates clone routes from the key count.
|
|
11
11
|
//
|
|
12
|
-
// Localization: source strings are English
|
|
12
|
+
// Localization: source strings are English; Chinese is a first-class locale (en + zh).
|
|
13
13
|
// Other languages come from the DSH core locale service / translation plugins
|
|
14
14
|
// via props.t (slot locale: NS). Active locale prefers ctx.locale.getSnapshot().active,
|
|
15
15
|
// else first navigator.languages entry, else 'en' — same fallback chain as DSH core.
|
|
@@ -301,12 +301,18 @@ window.__ModuleLoader__.load({
|
|
|
301
301
|
selfHealingHint: '定期轻量探测以自动恢复损坏密钥 (0 为禁用)',
|
|
302
302
|
};
|
|
303
303
|
|
|
304
|
-
//
|
|
305
|
-
//
|
|
306
|
-
//
|
|
304
|
+
// Codes worth switching on. Host list is DEFAULT_SWITCH_CODES; config may
|
|
305
|
+
// add extras — those render as separate checked boxes so a custom rule is
|
|
306
|
+
// not dropped on first save.
|
|
307
307
|
const KNOWN_CODES = ['QUOTA', 'RATE_LIMIT', 'SERVER', 'TIMEOUT', 'TRANSPORT', 'EMPTY_RESPONSE', 'UNKNOWN_MODEL', 'AUTH'];
|
|
308
308
|
|
|
309
309
|
// #273: keep the settings list item mounted if the section throws.
|
|
310
|
+
const bestEffort = (label, fn) => {
|
|
311
|
+
try { return fn(); } catch (err) {
|
|
312
|
+
try { console.debug('[dsh-key-rotation] best-effort ' + label, err); } catch { /* console unavailable */ }
|
|
313
|
+
return undefined;
|
|
314
|
+
}
|
|
315
|
+
};
|
|
310
316
|
class KeyRotationErrorBoundary extends React.Component {
|
|
311
317
|
constructor(props) {
|
|
312
318
|
super(props);
|
|
@@ -316,7 +322,7 @@ window.__ModuleLoader__.load({
|
|
|
316
322
|
return { error };
|
|
317
323
|
}
|
|
318
324
|
componentDidCatch(error, info) {
|
|
319
|
-
|
|
325
|
+
bestEffort('error-boundary.log', () => { console.error('[dsh-key-rotation] settings card crashed', error, info); });
|
|
320
326
|
}
|
|
321
327
|
render() {
|
|
322
328
|
if (this.state.error) {
|
|
@@ -333,7 +339,7 @@ window.__ModuleLoader__.load({
|
|
|
333
339
|
}
|
|
334
340
|
}
|
|
335
341
|
|
|
336
|
-
/**
|
|
342
|
+
/** Poll rotation status while the settings section is open (smart polling). */
|
|
337
343
|
function useRotationStatus() {
|
|
338
344
|
const [byProvider, setByProvider] = React.useState({});
|
|
339
345
|
React.useEffect(() => {
|
|
@@ -348,7 +354,7 @@ window.__ModuleLoader__.load({
|
|
|
348
354
|
for (const entry of data.providers) map[entry.provider] = entry;
|
|
349
355
|
setByProvider(map);
|
|
350
356
|
})
|
|
351
|
-
.catch(() => { /*
|
|
357
|
+
.catch(() => { /* status is optional: card stays a working editor */ });
|
|
352
358
|
};
|
|
353
359
|
pull();
|
|
354
360
|
const id = setInterval(pull, 4000);
|
|
@@ -363,7 +369,7 @@ window.__ModuleLoader__.load({
|
|
|
363
369
|
return byProvider;
|
|
364
370
|
}
|
|
365
371
|
|
|
366
|
-
/**
|
|
372
|
+
/** Latest probe result per key (#219): /sandbox-cache (smart polling). */
|
|
367
373
|
function useProbeCache() {
|
|
368
374
|
const [cache, setCache] = React.useState({});
|
|
369
375
|
React.useEffect(() => {
|
|
@@ -373,7 +379,7 @@ window.__ModuleLoader__.load({
|
|
|
373
379
|
fetch('/dsh-key-rotation/sandbox-cache', { headers: { accept: 'application/json' } })
|
|
374
380
|
.then((r) => (r.ok ? r.json() : null))
|
|
375
381
|
.then((data) => { if (alive && data) setCache(data); })
|
|
376
|
-
.catch(() => { /*
|
|
382
|
+
.catch(() => { /* cache is non-critical: card works without it */ });
|
|
377
383
|
};
|
|
378
384
|
pull();
|
|
379
385
|
const id = setInterval(pull, 4000);
|
|
@@ -397,10 +403,9 @@ window.__ModuleLoader__.load({
|
|
|
397
403
|
return t('hoursAgo').replace('{n}', String(Math.round(sec / 3600)));
|
|
398
404
|
}
|
|
399
405
|
|
|
400
|
-
//
|
|
401
|
-
//
|
|
402
|
-
//
|
|
403
|
-
// под ним ужимается сама.
|
|
406
|
+
// Card layout uses a grid, not ad-hoc inline widths. Fixed widths used to
|
|
407
|
+
// clip key names and collide actions with the value field, so the name owns
|
|
408
|
+
// its own row and the meta row shrinks on its own.
|
|
404
409
|
const CARD_CSS = [
|
|
405
410
|
'.krot{display:flex;flex-direction:column;gap:20px;max-width:960px;padding:6px 0 24px;box-sizing:border-box}',
|
|
406
411
|
'.krot-header{display:flex;flex-direction:column;gap:8px;padding-bottom:16px;border-bottom:1px solid var(--dsw-alias-border-l2)}',
|
|
@@ -408,7 +413,7 @@ window.__ModuleLoader__.load({
|
|
|
408
413
|
'.krot-page-sub{font-size:13px;color:var(--dsw-alias-label-secondary);line-height:1.5}',
|
|
409
414
|
'.krot p{margin:0}',
|
|
410
415
|
'.krot-hint{font-size:12px;color:var(--dsw-alias-label-secondary);line-height:1.4}',
|
|
411
|
-
'.krot-err{font-size:13px;color:var(--dsw-alias-state-error-primary);padding:10px 14px;border-radius:8px;background:
|
|
416
|
+
'.krot-err{font-size:13px;color:var(--dsw-alias-state-error-primary);padding:10px 14px;border-radius:8px;background:color-mix(in srgb, var(--dsw-alias-state-error-primary, #ef4444) 10%, transparent);display:flex;align-items:center;gap:8px}',
|
|
412
417
|
'.krot-label{font-size:13px;font-weight:500;color:var(--dsw-alias-label-primary)}',
|
|
413
418
|
'.krot-field{display:flex;flex-direction:column;gap:6px}',
|
|
414
419
|
'.krot-in{background:var(--dsw-alias-bg-layer-2);border:1px solid var(--dsw-alias-border-l2);color:var(--dsw-alias-label-primary);border-radius:8px;padding:0 12px;font-size:13px;height:36px;font-family:inherit;min-width:0;width:100%;box-sizing:border-box;transition:border-color .15s ease}',
|
|
@@ -431,15 +436,15 @@ window.__ModuleLoader__.load({
|
|
|
431
436
|
'.krot-load-legend{display:flex;flex-wrap:wrap;gap:8px;margin-top:6px;font-size:11px}',
|
|
432
437
|
'.krot-load-item{display:inline-flex;align-items:center;gap:4px}',
|
|
433
438
|
'.krot-load-dot{width:7px;height:7px;border-radius:50%}',
|
|
434
|
-
'.krot-modal-backdrop{position:fixed;top:0;left:0;right:0;bottom:0;background:
|
|
435
|
-
'.krot-modal-card{width:90%;max-width:420px;background:var(--dsw-alias-bg-layer-3);border:1px solid var(--dsw-alias-border-l2);border-radius:12px;box-shadow:0 12px 36px
|
|
439
|
+
'.krot-modal-backdrop{position:fixed;top:0;left:0;right:0;bottom:0;background:var(--dsw-alias-bg-overlay, color-mix(in srgb, var(--dsw-alias-bg-layer-1, #000) 55%, transparent));z-index:9999;display:flex;align-items:center;justify-content:center;backdrop-filter:blur(2px)}',
|
|
440
|
+
'.krot-modal-card{width:90%;max-width:420px;background:var(--dsw-alias-bg-layer-3);border:1px solid var(--dsw-alias-border-l2);border-radius:12px;box-shadow:0 12px 36px color-mix(in srgb, var(--dsw-alias-bg-layer-1, #000) 28%, transparent);padding:20px;display:flex;flex-direction:column;gap:12px}',
|
|
436
441
|
'.krot-modal-title{font-size:16px;font-weight:600;color:var(--dsw-alias-label-primary)}',
|
|
437
442
|
'.krot-modal-desc{font-size:13px;color:var(--dsw-alias-label-secondary);line-height:1.45}',
|
|
438
443
|
'.krot-modal-actions{display:flex;justify-content:flex-end;gap:10px;margin-top:8px}',
|
|
439
444
|
'.krot-badge{font-size:12px;padding:3px 10px;border-radius:999px;border:1px solid var(--dsw-alias-border-l2);display:inline-flex;align-items:center;gap:5px;font-weight:500;white-space:nowrap}',
|
|
440
|
-
'.krot-badge-ok{border-color:var(--dsw-alias-state-success-primary);color:var(--dsw-alias-state-success-primary);background:
|
|
441
|
-
'.krot-badge-warn{border-color:var(--dsw-alias-state-warning-primary);color:var(--dsw-alias-state-warning-primary);background:
|
|
442
|
-
'.krot-badge-bad{border-color:var(--dsw-alias-state-error-primary);color:var(--dsw-alias-state-error-primary);background:
|
|
445
|
+
'.krot-badge-ok{border-color:var(--dsw-alias-state-success-primary);color:var(--dsw-alias-state-success-primary);background:color-mix(in srgb, var(--dsw-alias-state-success-primary, #10b981) 8%, transparent)}',
|
|
446
|
+
'.krot-badge-warn{border-color:var(--dsw-alias-state-warning-primary);color:var(--dsw-alias-state-warning-primary);background:color-mix(in srgb, var(--dsw-alias-state-warning-primary, #f59e0b) 8%, transparent)}',
|
|
447
|
+
'.krot-badge-bad{border-color:var(--dsw-alias-state-error-primary);color:var(--dsw-alias-state-error-primary);background:color-mix(in srgb, var(--dsw-alias-state-error-primary, #ef4444) 8%, transparent)}',
|
|
443
448
|
'.krot-prov{display:flex;flex-direction:column;gap:12px;border:1px solid var(--dsw-alias-border-l2);border-radius:10px;background:var(--dsw-alias-bg-layer-2);padding:14px 16px}',
|
|
444
449
|
'.krot-prov-head{display:flex;gap:10px;align-items:center;flex-wrap:wrap}',
|
|
445
450
|
'.krot-prov-head select{flex:1;min-width:180px}',
|
|
@@ -457,21 +462,22 @@ window.__ModuleLoader__.load({
|
|
|
457
462
|
'.krot-btn:hover:not(:disabled){background:var(--dsw-alias-bg-layer-4,var(--dsw-alias-bg-layer-2));border-color:var(--dsw-alias-label-dimmed,var(--dsw-alias-border-l2))}',
|
|
458
463
|
'.krot-btn-primary,.krot-save{background:var(--dsw-alias-label-primary);color:var(--dsw-alias-bg-layer-3);border-color:transparent;font-weight:600}',
|
|
459
464
|
'.krot-btn-primary:hover:not(:disabled),.krot-save:hover:not(:disabled){background:var(--dsw-alias-label-primary)!important;color:var(--dsw-alias-bg-layer-3)!important;opacity:0.88}',
|
|
460
|
-
'.krot-btn-danger{color:var(--dsw-alias-state-error-primary);border-color:
|
|
461
|
-
'.krot-btn-danger:hover:not(:disabled){background:
|
|
465
|
+
'.krot-btn-danger{color:var(--dsw-alias-state-error-primary);border-color:color-mix(in srgb, var(--dsw-alias-state-error-primary, #ef4444) 30%, transparent)}',
|
|
466
|
+
'.krot-btn-danger:hover:not(:disabled){background:color-mix(in srgb, var(--dsw-alias-state-error-primary, #ef4444) 12%, transparent)!important;border-color:color-mix(in srgb, var(--dsw-alias-state-error-primary, #ef4444) 50%, transparent)}',
|
|
462
467
|
'.krot-btn:disabled{opacity:0.45;cursor:not-allowed}',
|
|
463
468
|
'.krot-foot{display:flex;gap:10px;align-items:center;flex-wrap:wrap}',
|
|
464
469
|
'.krot-filter-bar{display:flex;gap:8px;margin:4px 0 10px;flex-wrap:wrap}',
|
|
465
470
|
'.krot-pill{appearance:none;background:var(--dsw-alias-bg-layer-2);border:1px solid var(--dsw-alias-border-l2);border-radius:999px;padding:4px 12px;font-size:12px;font-weight:500;color:var(--dsw-alias-label-secondary);cursor:pointer;transition:all .15s ease}',
|
|
466
471
|
'.krot-pill:hover{background:var(--dsw-alias-bg-layer-1);color:var(--dsw-alias-label-primary)}',
|
|
467
472
|
'.krot-pill-active{background:var(--dsw-alias-label-primary);border-color:var(--dsw-alias-label-primary);color:var(--dsw-alias-bg-layer-3)!important;font-weight:600}',
|
|
468
|
-
'.krot-pill-warn{border-color:
|
|
469
|
-
'.krot-pill-err{border-color:
|
|
470
|
-
'.krot-alert-ok{padding:10px 14px;border-radius:8px;background:
|
|
471
|
-
'.krot-alert-bad{padding:10px 14px;border-radius:8px;background:
|
|
473
|
+
'.krot-pill-warn{border-color:color-mix(in srgb, var(--dsw-alias-state-warning-primary, #f59e0b) 30%, transparent);color:var(--dsw-alias-state-warning-primary)}',
|
|
474
|
+
'.krot-pill-err{border-color:color-mix(in srgb, var(--dsw-alias-state-error-primary, #ef4444) 30%, transparent);color:var(--dsw-alias-state-error-primary)}',
|
|
475
|
+
'.krot-alert-ok{padding:10px 14px;border-radius:8px;background:color-mix(in srgb, var(--dsw-alias-state-success-primary, #10b981) 10%, transparent);color:var(--dsw-alias-state-success-primary);font-size:13px;display:flex;align-items:center;gap:10px}',
|
|
476
|
+
'.krot-alert-bad{padding:10px 14px;border-radius:8px;background:color-mix(in srgb, var(--dsw-alias-state-error-primary, #ef4444) 10%, transparent);color:var(--dsw-alias-state-error-primary);font-size:13px;display:flex;align-items:center;gap:10px}',
|
|
472
477
|
'.krot-event-stream{display:flex;flex-direction:column;gap:6px;margin-top:8px;padding:10px 12px;background:var(--dsw-alias-bg-layer-3);border:1px solid var(--dsw-alias-border-l2);border-radius:8px}',
|
|
473
478
|
'.krot-event-row{display:flex;align-items:center;gap:8px;font-size:12px;padding:4px 0;border-bottom:1px solid var(--dsw-alias-border-l1)}',
|
|
474
479
|
'.krot-event-time{font-family:ui-monospace,Menlo,Consolas,monospace;color:var(--dsw-alias-label-tertiary);font-size:11px}',
|
|
480
|
+
':root{--krot-chart-4:var(--dsw-alias-state-brand-primary);--krot-chart-5:var(--dsw-alias-state-error-primary);--krot-chart-6:var(--dsw-alias-state-success-primary)}',
|
|
475
481
|
'.krot-card{border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-3);border-radius:12px;list-style:none}',
|
|
476
482
|
'.krot-card-header{appearance:none;width:100%;font:inherit;color:inherit;text-align:left;cursor:pointer;background:0 0;border:0;border-radius:12px;display:flex;align-items:center;gap:12px;padding:14px 16px}',
|
|
477
483
|
'.krot-card-head-text{display:flex;flex-direction:column;flex:1;gap:4px;min-width:0}',
|
|
@@ -481,7 +487,7 @@ window.__ModuleLoader__.load({
|
|
|
481
487
|
'.krot-card-body{border-top:1px solid var(--dsw-alias-border-l2);margin:0 16px;padding:16px 0 8px}',
|
|
482
488
|
'.krot-header-chip{display:inline-flex;align-items:center;gap:6px;height:26px;padding:0 10px;border-radius:999px;border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-primary);font-size:12px;font-weight:600;cursor:pointer;position:relative;user-select:none;transition:all .15s ease}',
|
|
483
489
|
'.krot-header-chip:hover{background:var(--dsw-alias-interactive-bg-hover,var(--dsw-alias-bg-layer-3));border-color:var(--dsw-alias-border-l1);transform:translateY(-0.5px)}',
|
|
484
|
-
'.krot-popover{position:absolute;top:calc(100% + 6px);right:0;z-index:10000;min-width:220px;background:var(--dsw-alias-bg-layer-3);border:1px solid var(--dsw-alias-border-l2);border-radius:12px;padding:12px 14px;box-shadow:0 16px 40px
|
|
490
|
+
'.krot-popover{position:absolute;top:calc(100% + 6px);right:0;z-index:10000;min-width:220px;background:var(--dsw-alias-bg-layer-3);border:1px solid var(--dsw-alias-border-l2);border-radius:12px;padding:12px 14px;box-shadow:0 16px 40px color-mix(in srgb, var(--dsw-alias-bg-layer-1, #000) 45%, transparent),0 2px 8px color-mix(in srgb, var(--dsw-alias-bg-layer-1, #000) 15%, transparent);backdrop-filter:blur(16px);-webkit-backdrop-filter:blur(16px);display:flex;flex-direction:column;gap:8px;text-align:left}',
|
|
485
491
|
'.krot-pop-title{font-size:11px;font-weight:700;letter-spacing:.06em;text-transform:uppercase;color:var(--dsw-alias-label-secondary)}',
|
|
486
492
|
'.krot-pop-row{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:4px 0}',
|
|
487
493
|
'.krot-pop-name{font-size:13px;font-weight:600;color:var(--dsw-alias-label-primary);display:flex;align-items:center;gap:8px}',
|
|
@@ -499,13 +505,13 @@ window.__ModuleLoader__.load({
|
|
|
499
505
|
}
|
|
500
506
|
|
|
501
507
|
/**
|
|
502
|
-
*
|
|
508
|
+
* Env-var name for a newly added key.
|
|
503
509
|
*
|
|
504
|
-
*
|
|
505
|
-
*
|
|
506
|
-
*
|
|
507
|
-
*
|
|
508
|
-
*
|
|
510
|
+
* Users no longer type it: the first key of a provider becomes
|
|
511
|
+
* <PROVIDER>_API_KEY, later keys reuse that root with _2, _3… suffixes.
|
|
512
|
+
* The root is taken from existing keys so hand-made names keep working,
|
|
513
|
+
* and uniqueness is checked across ALL providers — otherwise two providers
|
|
514
|
+
* would silently share one credential.
|
|
509
515
|
*/
|
|
510
516
|
// nextKeyRef also in lib/client-helpers.js
|
|
511
517
|
function nextKeyRef(providerId, existingKeys, allRefs) {
|
|
@@ -650,7 +656,7 @@ window.__ModuleLoader__.load({
|
|
|
650
656
|
cancelAnimationFrame(id);
|
|
651
657
|
const prev = modalReturnFocusRef.current;
|
|
652
658
|
if (prev && typeof prev.focus === 'function') {
|
|
653
|
-
|
|
659
|
+
bestEffort('modal.returnFocus', () => { prev.focus(); });
|
|
654
660
|
}
|
|
655
661
|
};
|
|
656
662
|
}, [confirmModal]);
|
|
@@ -885,8 +891,7 @@ window.__ModuleLoader__.load({
|
|
|
885
891
|
stashUndo({ type: 'provider', index: pIndex, entry: arr[pIndex] });
|
|
886
892
|
return { ...cur, providers: arr.filter((_, i) => i !== pIndex) };
|
|
887
893
|
});
|
|
888
|
-
//
|
|
889
|
-
// а не перепечатыванием имён.
|
|
894
|
+
// Key order is attempt order — move it with buttons, do not retype names.
|
|
890
895
|
const moveKey = (pIndex, kIndex, delta) => setField((cur) => {
|
|
891
896
|
const providers = [...(cur.providers ?? [])];
|
|
892
897
|
const entry = { ...(providers[pIndex] ?? {}) };
|
|
@@ -921,8 +926,8 @@ window.__ModuleLoader__.load({
|
|
|
921
926
|
return { ...cur, providers };
|
|
922
927
|
});
|
|
923
928
|
|
|
924
|
-
//
|
|
925
|
-
//
|
|
929
|
+
// Config codes missing from the known list still render as checked items;
|
|
930
|
+
// otherwise the checkboxes would silently drop a custom rule on first save.
|
|
926
931
|
const selectedCodes = new Set(Array.isArray(val.switchCodes) ? val.switchCodes : []);
|
|
927
932
|
const codeList = [...KNOWN_CODES, ...[...selectedCodes].filter((c) => !KNOWN_CODES.includes(c))];
|
|
928
933
|
const toggleCode = (code, on) => setField((cur) => {
|
|
@@ -992,8 +997,8 @@ window.__ModuleLoader__.load({
|
|
|
992
997
|
title: (opts && opts.title) || undefined,
|
|
993
998
|
}, labelText);
|
|
994
999
|
|
|
995
|
-
//
|
|
996
|
-
//
|
|
1000
|
+
// Key state dot: color and label read at a glance; "key not found"
|
|
1001
|
+
// catches an env-name typo that would otherwise stay silent.
|
|
997
1002
|
const keyStatus = (providerId, ref) => {
|
|
998
1003
|
const hit = keyInfo(providerId, ref);
|
|
999
1004
|
if (!hit) return null;
|
|
@@ -1062,8 +1067,7 @@ window.__ModuleLoader__.load({
|
|
|
1062
1067
|
const typed = secretDraft[rowKey];
|
|
1063
1068
|
const fromEnv = Boolean(info && info.source === 'env');
|
|
1064
1069
|
|
|
1065
|
-
//
|
|
1066
|
-
// соседние ключи выглядели одинаково.
|
|
1070
|
+
// Key name owns a full row so neighbouring keys stay distinguishable.
|
|
1067
1071
|
const nameRow = [
|
|
1068
1072
|
h('span', { className: 'krot-num', key: 'n' }, String(kIndex + 1)),
|
|
1069
1073
|
h('span', { key: 'i', className: 'krot-name', title: key + ' (click to copy)', style: { cursor: 'copy' }, onClick: () => {
|
|
@@ -1204,13 +1208,13 @@ window.__ModuleLoader__.load({
|
|
|
1204
1208
|
});
|
|
1205
1209
|
const total = keyUsages.reduce((acc, x) => acc + x.usage, 0);
|
|
1206
1210
|
const palette = [
|
|
1207
|
-
'var(--dsw-alias-state-brand-primary
|
|
1208
|
-
'var(--dsw-alias-state-success-primary
|
|
1209
|
-
'var(--dsw-alias-state-warning-primary
|
|
1210
|
-
'var(--dsw-alias-state-info-primary
|
|
1211
|
-
'
|
|
1212
|
-
'
|
|
1213
|
-
'
|
|
1211
|
+
'var(--dsw-alias-state-brand-primary)',
|
|
1212
|
+
'var(--dsw-alias-state-success-primary)',
|
|
1213
|
+
'var(--dsw-alias-state-warning-primary)',
|
|
1214
|
+
'var(--dsw-alias-state-info-primary)',
|
|
1215
|
+
'var(--krot-chart-4)',
|
|
1216
|
+
'var(--krot-chart-5)',
|
|
1217
|
+
'var(--krot-chart-6)',
|
|
1214
1218
|
];
|
|
1215
1219
|
return h('div', { className: 'krot-load-chart' },
|
|
1216
1220
|
h('div', { className: 'krot-load-header' },
|
|
@@ -1311,7 +1315,8 @@ window.__ModuleLoader__.load({
|
|
|
1311
1315
|
filterBar,
|
|
1312
1316
|
loadChart,
|
|
1313
1317
|
h('div', { className: 'krot-keys' }, keyRows),
|
|
1314
|
-
h(
|
|
1318
|
+
h(UpdaterSection, { t }),
|
|
1319
|
+
h('div', { className: 'krot-foot' },
|
|
1315
1320
|
btn(t('addKey'), () => addKey(pIndex), { title: t('addKeyTitle') }),
|
|
1316
1321
|
switchesLine,
|
|
1317
1322
|
budgetLine,
|
|
@@ -1710,7 +1715,72 @@ window.__ModuleLoader__.load({
|
|
|
1710
1715
|
// Collapsible card in Settings -> Plugins -> Plugin settings
|
|
1711
1716
|
// (settings.plugin.item), matching Model Sync / Spendmeter / Vision Bridge.
|
|
1712
1717
|
// key MUST equal the settings namespace (NS), else the tab silently skips it.
|
|
1713
|
-
|
|
1718
|
+
|
|
1719
|
+
function UpdaterSection({ t }) {
|
|
1720
|
+
const [state, setState] = React.useState({ phase: 'idle', current: '', latest: '', message: '' });
|
|
1721
|
+
const load = React.useCallback(async () => {
|
|
1722
|
+
setState((s) => ({ ...s, phase: 'loading', message: '' }));
|
|
1723
|
+
try {
|
|
1724
|
+
const res = await fetch('/api/dsh-key-rotation/update', { headers: { 'x-dsh-plugin-update': '1' } });
|
|
1725
|
+
const data = await res.json();
|
|
1726
|
+
if (!res.ok) throw new Error(data?.error?.message || t('updateFailed'));
|
|
1727
|
+
setState({
|
|
1728
|
+
phase: data.updateAvailable ? 'available' : 'current',
|
|
1729
|
+
current: data.currentVersion || '',
|
|
1730
|
+
latest: data.latestVersion || '',
|
|
1731
|
+
message: data.updateAvailable ? t('updateAvailable') : t('updateNone'),
|
|
1732
|
+
});
|
|
1733
|
+
} catch (e) {
|
|
1734
|
+
setState({ phase: 'error', current: '', latest: '', message: e?.message || t('updateFailed') });
|
|
1735
|
+
}
|
|
1736
|
+
}, [t]);
|
|
1737
|
+
React.useEffect(() => { load(); }, [load]);
|
|
1738
|
+
const run = async () => {
|
|
1739
|
+
setState((s) => ({ ...s, phase: 'running', message: t('updateRunning') }));
|
|
1740
|
+
try {
|
|
1741
|
+
const res = await fetch('/api/dsh-key-rotation/update', {
|
|
1742
|
+
method: 'POST',
|
|
1743
|
+
headers: { 'x-dsh-plugin-update': '1', 'content-type': 'application/json' },
|
|
1744
|
+
});
|
|
1745
|
+
const data = await res.json();
|
|
1746
|
+
if (!res.ok) throw new Error(data?.error?.message || t('updateFailed'));
|
|
1747
|
+
setState({
|
|
1748
|
+
phase: 'done',
|
|
1749
|
+
current: data.currentVersion || '',
|
|
1750
|
+
latest: data.installedVersion || data.latestVersion || '',
|
|
1751
|
+
message: t('updateRestart'),
|
|
1752
|
+
});
|
|
1753
|
+
} catch (e) {
|
|
1754
|
+
setState({ phase: 'error', current: '', latest: '', message: e?.message || t('updateFailed') });
|
|
1755
|
+
}
|
|
1756
|
+
};
|
|
1757
|
+
return h('div', { className: 'krot-update', style: { marginTop: 12, paddingTop: 12, borderTop: '1px solid var(--dsw-alias-border-l2)' } },
|
|
1758
|
+
h('div', { style: { display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap' } },
|
|
1759
|
+
h('span', { style: { fontSize: 12, color: 'var(--dsw-alias-label-secondary)' } },
|
|
1760
|
+
t('currentVersion') + ': ' + (state.current || '—')),
|
|
1761
|
+
state.latest ? h('span', { style: { fontSize: 12, color: 'var(--dsw-alias-label-secondary)' } },
|
|
1762
|
+
t('latestVersion') + ': ' + state.latest) : null,
|
|
1763
|
+
h('button', {
|
|
1764
|
+
className: 'krot-btn',
|
|
1765
|
+
type: 'button',
|
|
1766
|
+
disabled: state.phase === 'running' || state.phase === 'loading',
|
|
1767
|
+
onClick: load,
|
|
1768
|
+
}, t('updateCheck')),
|
|
1769
|
+
state.phase === 'available' ? h('button', {
|
|
1770
|
+
className: 'krot-btn krot-btn-primary',
|
|
1771
|
+
type: 'button',
|
|
1772
|
+
disabled: state.phase === 'running',
|
|
1773
|
+
onClick: run,
|
|
1774
|
+
}, t('updateRun')) : null,
|
|
1775
|
+
),
|
|
1776
|
+
state.message ? h('div', {
|
|
1777
|
+
className: state.phase === 'error' ? 'krot-alert-bad' : state.phase === 'done' ? 'krot-alert-ok' : '',
|
|
1778
|
+
style: { marginTop: 8, fontSize: 12 },
|
|
1779
|
+
}, state.message) : null,
|
|
1780
|
+
);
|
|
1781
|
+
}
|
|
1782
|
+
|
|
1783
|
+
function KeyRotationCard(props) {
|
|
1714
1784
|
const locale = useLocale();
|
|
1715
1785
|
const t = resolveT(props);
|
|
1716
1786
|
const [open, setOpen] = React.useState(false);
|
|
@@ -1733,11 +1803,31 @@ window.__ModuleLoader__.load({
|
|
|
1733
1803
|
KeyRotationCard,
|
|
1734
1804
|
));
|
|
1735
1805
|
} catch (e) {
|
|
1736
|
-
|
|
1806
|
+
bestEffort('slots.register.log', () => { console.error('[dsh-key-rotation] settings.plugin.item register failed', e); });
|
|
1737
1807
|
}
|
|
1738
1808
|
}
|
|
1739
1809
|
|
|
1740
1810
|
module.exports = { apply, inject: ['slots', 'locale', 'settingsScope'] };
|
|
1741
1811
|
return module.exports;
|
|
1742
1812
|
},
|
|
1813
|
+
updateCheck: 'Check for updates',
|
|
1814
|
+
updateAvailable: 'Update available',
|
|
1815
|
+
updateNone: 'Plugin is up to date',
|
|
1816
|
+
updateRun: 'Update now',
|
|
1817
|
+
updateRestart: 'Update installed. Restart DSH to load the new version.',
|
|
1818
|
+
updateRunning: 'Updating…',
|
|
1819
|
+
updateFailed: 'Update failed',
|
|
1820
|
+
currentVersion: 'Current version',
|
|
1821
|
+
latestVersion: 'Latest version',
|
|
1822
|
+
|
|
1823
|
+
updateCheck: '检查更新',
|
|
1824
|
+
updateAvailable: '有可用更新',
|
|
1825
|
+
updateNone: '插件已是最新版本',
|
|
1826
|
+
updateRun: '立即更新',
|
|
1827
|
+
updateRestart: '更新已安装。请重启 DSH 以加载新版本。',
|
|
1828
|
+
updateRunning: '更新中…',
|
|
1829
|
+
updateFailed: '更新失败',
|
|
1830
|
+
currentVersion: '当前版本',
|
|
1831
|
+
latestVersion: '最新版本',
|
|
1832
|
+
|
|
1743
1833
|
});
|
package/lib/clock.js
CHANGED
package/lib/error-taxonomy.js
CHANGED
package/lib/http-bridge.js
CHANGED
|
@@ -78,13 +78,6 @@ export function providerCatalog(ctx, cloneIds) {
|
|
|
78
78
|
return out;
|
|
79
79
|
}
|
|
80
80
|
|
|
81
|
-
export function guardLocal(req, res, label) {
|
|
82
|
-
if (!isTrustedBridgeRequest(req)) {
|
|
83
|
-
json(res, 403, { error: { code: 'forbidden', message: `dsh-key-rotation: ${label} is local-only` } });
|
|
84
|
-
return false;
|
|
85
|
-
}
|
|
86
|
-
return true;
|
|
87
|
-
}
|
|
88
81
|
|
|
89
82
|
export function scanForLiveSecrets(section) {
|
|
90
83
|
const masked = structuredClone(section);
|
package/lib/index.js
CHANGED
|
@@ -52,6 +52,7 @@ import { safeParseJson } from './atomic-io.js';
|
|
|
52
52
|
import { registerOpsRoutes } from './routes-ops.js';
|
|
53
53
|
import { StatePersistence, resolveStatePath } from './persistence.js';
|
|
54
54
|
import path from 'node:path';
|
|
55
|
+
import { registerPluginUpdater } from './plugin-updater.js'
|
|
55
56
|
|
|
56
57
|
/** The llm-pi-ai namespace whose provider profiles map providers to pools. */
|
|
57
58
|
const PIAI_NS = 'llm-pi-ai';
|
|
@@ -362,14 +363,17 @@ export function apply(ctx, config = {}) {
|
|
|
362
363
|
if (!val) return { ok: false };
|
|
363
364
|
return runner.probeModels(ref, val);
|
|
364
365
|
});
|
|
365
|
-
} catch (
|
|
366
|
+
} catch (e) { ctx.logger?.warn?.('[dsh-key-rotation] auto-unbreak failed', e); }
|
|
366
367
|
}, intervalMs);
|
|
367
368
|
if (typeof timer.unref === 'function') timer.unref();
|
|
368
369
|
return () => clearInterval(timer);
|
|
369
370
|
}, 'dsh-key-rotation: auto-unbreak');
|
|
370
371
|
|
|
371
372
|
ctx.effect(() => {
|
|
372
|
-
const timer = setInterval(() => {
|
|
373
|
+
const timer = setInterval(() => {
|
|
374
|
+
try { schedulePersist(); }
|
|
375
|
+
catch (e) { ctx.logger?.warn?.('[dsh-key-rotation] periodic persist failed', e); }
|
|
376
|
+
}, 15000);
|
|
373
377
|
if (typeof timer.unref === 'function') timer.unref();
|
|
374
378
|
return () => {
|
|
375
379
|
clearInterval(timer);
|
|
@@ -383,7 +387,9 @@ export function apply(ctx, config = {}) {
|
|
|
383
387
|
}
|
|
384
388
|
statePersistence.dispose();
|
|
385
389
|
}
|
|
386
|
-
} catch (
|
|
390
|
+
} catch (e) {
|
|
391
|
+
ctx.logger?.warn?.('[dsh-key-rotation] dispose persist failed', e);
|
|
392
|
+
}
|
|
387
393
|
};
|
|
388
394
|
}, 'dsh-key-rotation: state persistence');
|
|
389
395
|
// Periodic sweep of expired cooldowns — keeps health probe cheap and avoids waiting for next user request
|
|
@@ -857,6 +863,7 @@ export function apply(ctx, config = {}) {
|
|
|
857
863
|
// the resolve patch hands out the next key on each dispatch.
|
|
858
864
|
// Operational routes (status/usage/snapshot/key/import/test/health/webhook) (#253)
|
|
859
865
|
registerOpsRoutes(ctx, {
|
|
866
|
+
|
|
860
867
|
buildRuntime,
|
|
861
868
|
latencyHistogram,
|
|
862
869
|
lastTestCache,
|
|
@@ -868,6 +875,16 @@ export function apply(ctx, config = {}) {
|
|
|
868
875
|
quotaStore,
|
|
869
876
|
});
|
|
870
877
|
|
|
878
|
+
// One-click plugin updater (#307). GET status / POST install exact registry version.
|
|
879
|
+
ctx.effect(() => {
|
|
880
|
+
if (typeof ctx.webServer?.register !== 'function') return;
|
|
881
|
+
registerPluginUpdater(ctx, {
|
|
882
|
+
endpoint: '/api/dsh-key-rotation/update',
|
|
883
|
+
packageName: '@goodandready/dsh-key-rotation',
|
|
884
|
+
manifestUrl: new URL('../package.json', import.meta.url),
|
|
885
|
+
});
|
|
886
|
+
}, 'dsh-key-rotation: plugin updater');
|
|
887
|
+
|
|
871
888
|
ctx.effect(() => ctx.on('llm/stream', (options, next) => {
|
|
872
889
|
if (options[MARKER]) return next();
|
|
873
890
|
if (rotationDisabled) return next(); // #199: disabled via webhook action
|
|
@@ -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
|
}
|
package/lib/routes-ops.js
CHANGED
|
@@ -24,6 +24,7 @@ import { findSecrets, looksLikeApiSecret } from './keycheck.js';
|
|
|
24
24
|
import { nextQuotaReset } from './quota-window.js';
|
|
25
25
|
import { classifyFailure } from './error-taxonomy.js';
|
|
26
26
|
import { sanitizeSnapshot } from './sanitize-snapshot.js';
|
|
27
|
+
import { bestEffort } from './best-effort.js';
|
|
27
28
|
|
|
28
29
|
const STATUS_PATH = '/dsh-key-rotation/status';
|
|
29
30
|
const SNAPSHOT_PATH = '/dsh-key-rotation/snapshot';
|
|
@@ -316,7 +317,7 @@ export function registerOpsRoutes(ctx, deps) {
|
|
|
316
317
|
st.brokenUntil?.delete(ref);
|
|
317
318
|
if (st.lastUsed === ref) st.lastUsed = undefined;
|
|
318
319
|
}
|
|
319
|
-
|
|
320
|
+
bestEffort('lastTestCache.delete', () => { lastTestCache?.delete?.(ref); }, ctx.logger);
|
|
320
321
|
json(res, 200, { ok: true, ref });
|
|
321
322
|
return;
|
|
322
323
|
}
|
|
@@ -482,7 +483,7 @@ export function registerOpsRoutes(ctx, deps) {
|
|
|
482
483
|
const valid = present ? Boolean(effectiveValue && typeof effectiveValue === 'string' && effectiveValue.length > 0) : Boolean(testValue);
|
|
483
484
|
const tail = valid ? keyTail(effectiveValue) : '';
|
|
484
485
|
let source = null;
|
|
485
|
-
|
|
486
|
+
await bestEffort('credentials.describe', async () => { const d = await base?.describe?.(ref); source = d?.source ?? null; }, ctx.logger);
|
|
486
487
|
if (!present && !testValue) { json(res, 200, { ok: false, ref, code: 'no-credential', message: 'no such credential' }); return; }
|
|
487
488
|
if (!present && testValue) { source = 'pre-save'; }
|
|
488
489
|
else if (!present) {
|
package/lib/webhook.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { bestEffort } from './best-effort.js';
|
|
1
2
|
// lib/webhook.js — webhook sender with throttle, interactive actions + alert digest debouncer (#199, #10).
|
|
2
3
|
export const WEBHOOK_TIMEOUT_MS = 5000;
|
|
3
4
|
export const WEBHOOK_MIN_INTERVAL_MS = 1000;
|
|
@@ -187,7 +188,7 @@ export class AlertDebouncer {
|
|
|
187
188
|
|
|
188
189
|
const res = await this._sender.send(url, payload);
|
|
189
190
|
for (const cb of entry.callbacks) {
|
|
190
|
-
|
|
191
|
+
bestEffort('webhook.callback', () => { cb(res); }, this._logger);
|
|
191
192
|
}
|
|
192
193
|
return res;
|
|
193
194
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@goodandready/dsh-key-rotation",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.11",
|
|
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": [
|
|
@@ -41,7 +41,10 @@
|
|
|
41
41
|
},
|
|
42
42
|
"client": {
|
|
43
43
|
"platform": "web",
|
|
44
|
-
"inject": [
|
|
44
|
+
"inject": [
|
|
45
|
+
"@deepseek-ai/dsh-client-locale",
|
|
46
|
+
"@deepseek-ai/dsh-client-ui-settings"
|
|
47
|
+
]
|
|
45
48
|
}
|
|
46
49
|
},
|
|
47
50
|
"license": "MIT",
|