@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
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
|
+
|