@goodandready/dsh-key-rotation 0.7.38 → 0.7.40

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 CHANGED
@@ -57,7 +57,7 @@ Unlike naive routing proxies that alter provider identifiers, `dsh-key-rotation`
57
57
  * **The provider identity never changes**: Agent replay states, multi-call turns, and tool schemas remain 100% consistent.
58
58
  * **Pre-emptive Token Bucket**: Throttled keys are skipped *before* issuing network calls, eliminating retry latency.
59
59
  * **Least-Connections Concurrency Control**: Balances in-flight streams across keys to prevent burst saturation.
60
- * **Autonomous Self-Healing & Cascades**: Proactively tests quarantined keys via canary probes and smoothly escalates to fallback providers if an entire pool is exhausted.
60
+ * **Autonomous Self-Healing & Cascades**: Lifts expired quarantines on idle keys and smoothly escalates to fallback providers if an entire pool is exhausted.
61
61
 
62
62
  ---
63
63
 
@@ -85,8 +85,8 @@ graph LR
85
85
  Failover -.->|All Pool Keys Exhausted| CascadeEngine["Cross-Provider Cascade"]
86
86
 
87
87
  BackoffCalc --> QuotaWindow["Calendar Reset / Midnight Window"]
88
- BackoffCalc --> CanaryProbe["Active Canary Prober (Sandbox Ping)"]
89
- CanaryProbe -->|Verified Healthy| PoolReady["Restored to Ready Pool"]
88
+ BackoffCalc --> SelfHeal["Self-Heal Idle Sweep"]
89
+ SelfHeal -->|Cooldown Expired| PoolReady["Restored to Ready Pool"]
90
90
  end
91
91
 
92
92
  subgraph UpstreamLayer ["Model Provider Endpoints"]
@@ -117,21 +117,18 @@ graph LR
117
117
 
118
118
  ### 🛡️ 3. Autonomous Healing & Cascade Escalation
119
119
  * **Cross-Provider Failover Cascade (`lib/cascade.js`)**: If all keys for a selected provider are in cooldown, requests automatically cascade to an alternative fallback provider pool (e.g., primary provider → fallback proxy / secondary provider).
120
- * **Active Canary Prober (`lib/canary.js`)**: Before releasing a key from quarantine, a lightweight background probe (`/models` probe or single-token check via `SandboxRunner`) validates upstream availability without exposing real user traffic to risk.
120
+ * **Sandbox Key Probes (`lib/sandbox.js`)**: On-demand `/models` probes validate a key before returning it to rotation; idle cooldowns are lifted by the self-heal sweep.
121
121
  * **Calendar & Rolling Quota Reset Windows (`lib/quota-window.js`)**: Supports scheduled quota reset alignments (`midnight_utc`, `midnight_pst`, and `rolling_24h`) so daily free/tier quotas unfreeze exactly when upstream resets them.
122
122
  * **Adaptive Exponential Backoff (`lib/pool.js`)**: Successive failures on a key double its quarantine duration (base → ×2 → ×4 → cap ×8). Successful requests gradually restore healthy status.
123
123
 
124
- ### 🎯 4. Model-Aware & Geolocation Routing
124
+ ### 🎯 4. Model-Aware Routing
125
125
  * **Model Sub-Pools (`lib/pool.js`)**: Configure dedicated key pools for specific model tiers (e.g. reasoning/heavy models vs fast/cheap utility models).
126
126
  * **Tag-Based Routing**: Assign operational tags (`production`, `background`, `eval`) to match key usage with workload priorities.
127
- * **Region Mapping (`lib/region.js`)**: Route queries through geographically optimal credentials and endpoints.
128
127
 
129
128
  ### 📊 5. Observability, Telemetry & Webhooks
130
129
  * **Interactive Multi-Platform Webhooks (`lib/webhook.js`)**: Dispatches rich notifications with HMAC-signed action buttons for **Telegram** (Inline Keyboards), **Discord** (Action Rows), and **Slack** (Block Kit). Administrators can click buttons to reset cooldowns or pause providers directly from their mobile chat.
131
130
  * **Usage & Cost Reporting (`lib/usage-report.js`)**: Per-key daily request counters and estimated cost breakdown with one-click CSV/JSON export (`GET /dsh-key-rotation/usage-report`).
132
131
  * **Latency SLO & Histogram (`lib/histogram.js`)**: Tracks Time-To-First-Token (TTFT) and stream durations with health score degradation scoring (`0..100`).
133
- * **Automated Incident Reporting (`lib/incident.js`)**: Lazily creates structured GitHub Issues on sustained upstream outages.
134
- * **Shadow Traffic Routing (`lib/shadow.js`)**: Fork a configurable percentage of live requests to evaluate secondary providers in shadow mode.
135
132
 
136
133
  ---
137
134
 
@@ -195,7 +192,6 @@ dsh-key-rotation:
195
192
  - UNKNOWN_MODEL
196
193
  - AUTH
197
194
  cooldownMs: 60000
198
- canaryProbing: true
199
195
  concurrencyLimit: 5
200
196
  quotaResetWindow:
201
197
  type: midnight_utc
@@ -224,7 +220,6 @@ dsh-key-rotation:
224
220
  |---|---|---|---|
225
221
  | `switchCodes` | `string[]` | `[QUOTA, RATE_LIMIT, ...]` | List of error codes that immediately trigger failover. |
226
222
  | `cooldownMs` | `number` | `60000` (1 min) | Base penalty duration (in ms) for quarantined keys. |
227
- | `canaryProbing` | `boolean` | `true` | Runs background ping probe before restoring quarantined keys. |
228
223
  | `concurrencyLimit` | `number` | `0` (disabled) | Max concurrent in-flight streams per key (0 = unlimited). |
229
224
  | `quotaResetWindow` | `object` | `null` | Calendar reset alignment (`midnight_utc`, `midnight_pst`, `rolling_24h`). |
230
225
  | `cascade` | `array` | `[]` | Fallback provider chain when primary pool is completely exhausted. |
@@ -253,6 +248,11 @@ All management routes require loopback authentication (`127.0.0.1` / `::1`) with
253
248
 
254
249
  MIT © [GooDAnDReaDY](https://github.com/GooDAnDReaDY)
255
250
 
251
+ ### v0.7.39
252
+ - **Self-Healing & lastUsedAt Accuracy**: Fixed key timestamp lookup in `healIdleCooldowns` to read from the modern `pool.state.lastUsedAt` map (with backwards-compatible fallback). `credentials.resolve` now properly records each key invocation timestamp in `lastUsedAt`, surfacing accurate "last used" indicators in the status dashboard and enabling background idle cooldown restoration.
253
+ - **Hotpath & Runtime Memoization**: Eliminated redundant `buildRuntime()` calls across periodic sweeps, provider exhaustion handling, and `/status` query processing.
254
+ - **Test Suite & CI Hardening**: Periodic interval sweep timers and debounce timers unref'ed to allow Node.js event loop natural exit without stalling CI runners. Purged obsolete incident test artifacts.
255
+
256
256
  ### v0.7.38
257
257
  - **Hot-Path Stream Optimization**: Eliminated 4 redundant `buildRuntime()` calls inside the `rotate()` finish chunk handler by reusing the request-scoped `runtime0` snapshot.
258
258
  - **Allocation-Free Rate Limit Header Parsing**: Optimized `extractRateLimit()` with single-pass header inspection and length guards, completely removing dynamic lowercase/uppercase string allocations on every response chunk.
package/README.ru.md CHANGED
@@ -57,7 +57,7 @@
57
57
  * **Идентичность провайдера остаётся неизменной**: Внутреннее Replay-состояние агента `pi-ai` и контекст инструментов остаются на 100% консистентными.
58
58
  * **Предиктивный Token Bucket**: Перегруженные ключи пропускаются **до** выполнения сетевого запроса, устраняя задержку на сетевой ретрай.
59
59
  * **Балансировка Least-Connections**: Запросы равномерно распределяются по свободным ключам с контролем параллелизма (`maxConcurrency`).
60
- * **Автономное самовосстановление и каскад**: Фоновые canary-зонды проверяют заблокированные ключи, а при полном исчерпании пула запрос бесшовно передаётся запасному провайдеру.
60
+ * **Автономное самовосстановление и каскад**: Просроченные кулдауны простаивающих ключей снимаются автоматически, а при полном исчерпании пула запрос бесшовно передаётся запасному провайдеру.
61
61
 
62
62
  ---
63
63
 
@@ -85,8 +85,8 @@ graph LR
85
85
  Failover -.->|Все ключи в кулдауне| CascadeEngine["Межпровайдерный каскад"]
86
86
 
87
87
  BackoffCalc --> QuotaWindow["Календарный сброс / Полночь UTC/PST"]
88
- BackoffCalc --> CanaryProbe["Active Canary-зонд (Sandbox Ping)"]
89
- CanaryProbe -->|Ключ работоспособен| PoolReady["Возврат в пул готовых ключей"]
88
+ BackoffCalc --> SelfHeal["Self-Heal sweep простаивания"]
89
+ SelfHeal -->|Кулдаун истёк| PoolReady["Возврат в пул готовых ключей"]
90
90
  end
91
91
 
92
92
  subgraph UpstreamLayer ["Эндпоинты провайдеров"]
@@ -117,21 +117,18 @@ graph LR
117
117
 
118
118
  ### 🛡️ 3. Автономное самовосстановление и каскадный Failover
119
119
  * **Межпровайдерный каскад (`lib/cascade.js`)**: При исчерпании всех ключей выбранного провайдера запрос автоматически каскадируется на настроенного резервного провайдера (`cascade: [{ provider, model }]`).
120
- * **Active Canary Prober (`lib/canary.js`)**: Перед выводом ключа из кулдауна плагин выполняет легкий фоновый зонд (`/models` probe через `SandboxRunner`), защищая боевой трафик от повторных сбоев.
120
+ * **Sandbox-пробы ключей (`lib/sandbox.js`)**: По запросу выполняется `/models`-проба ключа перед возвратом в ротацию; простаивающие кулдауны снимаются self-heal sweep.
121
121
  * **Календарный сброс квот (`lib/quota-window.js`)**: Учитывает окна сброса суточных квот провайдеров (`midnight_utc`, `midnight_pst`, `rolling_24h`), снимая карантин ровно в момент обновления лимитов у апстрима.
122
122
  * **Экспоненциальный бэкофф (`lib/pool.js`)**: Повторные сбои на ключе прогрессивно увеличивают время кулдауна (базовое → ×2 → ×4 → максимум ×8).
123
123
 
124
- ### 🎯 4. Маршрутизация по моделям и гео-регионам
124
+ ### 🎯 4. Маршрутизация по моделям
125
125
  * **Модельные подпулы (`lib/pool.js`)**: Назначение выделенных ключей под конкретные модели (например, отдельные ключи для тяжелых reasoning-моделей и дешевые ключи для утилит).
126
126
  * **Тегирование ключей**: Метки приоритета (`production`, `background`, `eval`) для разделения квот между интерактивными и фоновыми задачами.
127
- * **Гео-роутинг (`lib/region.js`)**: Маршрутизация запросов через оптимальные региональные эндпоинты.
128
127
 
129
128
  ### 📊 5. Телеметрия, аналитика и интерактивные вебхуки
130
129
  * **Интерактивные вебхуки (`lib/webhook.js`)**: Отправка форматированных алертов с кнопками действий в **Telegram** (Inline Keyboards), **Discord** (Action Rows) и **Slack** (Block Kit). Администратор может сбросить кулдаун или отключить провайдер прямо из мессенджера.
131
130
  * **Отчеты об использовании и расходах (`lib/usage-report.js`)**: Учет суточного числа запросов и расчетной стоимости по каждому ключу с экспортом в CSV/JSON (`GET /dsh-key-rotation/usage-report`).
132
131
  * **Гистограмма задержек SLO (`lib/histogram.js`)**: Измерение времени до первого токена (TTFT) и расчет индекса здоровья пула (`0..100`).
133
- * **Автоматические инциденты (`lib/incident.js`)**: Создание issue в GitHub при масштабных системных сбоях провайдеров.
134
- * **Shadow-трафик (`lib/shadow.js`)**: Теневое дублирование процента запросов для тестирования альтернативных провайдеров.
135
132
 
136
133
  ---
137
134
 
@@ -195,7 +192,6 @@ dsh-key-rotation:
195
192
  - UNKNOWN_MODEL
196
193
  - AUTH
197
194
  cooldownMs: 60000
198
- canaryProbing: true
199
195
  concurrencyLimit: 5
200
196
  quotaResetWindow:
201
197
  type: midnight_utc
@@ -224,7 +220,6 @@ dsh-key-rotation:
224
220
  |---|---|---|---|
225
221
  | `switchCodes` | `string[]` | `[QUOTA, RATE_LIMIT, ...]` | Список кодов ошибок, инициирующих немедленный переход на следующий ключ. |
226
222
  | `cooldownMs` | `number` | `60000` (1 мин) | Базовая длительность нахождения ключа в карантине (в мс). |
227
- | `canaryProbing` | `boolean` | `true` | Фоновая проверка ключа canary-зондом перед возвратом из карантина. |
228
223
  | `concurrencyLimit` | `number` | `0` (отключено) | Лимит одновременных активных запросов на ключ (0 = без ограничений). |
229
224
  | `quotaResetWindow` | `object` | `null` | Календарное расписание сброса квот (`midnight_utc`, `midnight_pst`, `rolling_24h`). |
230
225
  | `cascade` | `array` | `[]` | Цепочка резервных провайдеров при исчерпании всех ключей основного пула. |
@@ -253,6 +248,11 @@ dsh-key-rotation:
253
248
 
254
249
  MIT © [GooDAnDReaDY](https://github.com/GooDAnDReaDY)
255
250
 
251
+ ### v0.7.39
252
+ - **Самоисцеление ключей и фиксация lastUsedAt**: Исправлено чтение меток времени в `healIdleCooldowns`, использующее теперь карту `pool.state.lastUsedAt`. В `credentials.resolve` добавлено сохранение точного времени каждого обращения к ключу, что активирует вывод времени использования в панели мониторинга и корректное самоисцеление ключей после простоя.
253
+ - **Оптимизация обращений к buildRuntime**: Устранены повторные вызовы `buildRuntime()` в процедурах периодической очистки, маршруте `/status` и блоке обработки исчерпания пула.
254
+ - **Стабилизация CI и устранение утечек таймеров**: Фоновые таймеры дебаунса и очистки теперь отвязываются (`unref`) от цикла событий Node.js, предотвращая зависание раннера. Удалены тесты устаревшего модуля инцидентов.
255
+
256
256
  ### v0.7.38
257
257
  - **Оптимизация горячего пути стриминга**: Устранены 4 избыточных вызова `buildRuntime()` при обработке завершающего чанка в `rotate()` за счёт повторного использования снимка `runtime0`.
258
258
  - **Парсинг заголовков лимитов без лишних аллокаций**: Оптимизирована функция `extractRateLimit()` — однократный проход по объекту с проверкой длины ключей без постоянных аллокаций строк `.toLowerCase()` и `.toUpperCase()`.
package/README.zh.md CHANGED
@@ -117,7 +117,7 @@ graph LR
117
117
 
118
118
  ### 🛡️ 3. 自动愈合与跨提供商级联
119
119
  * **跨提供商故障转移级联 (`lib/cascade.js`)**:主提供商密钥全部冷却时,自动级联路由到备用提供商池。
120
- * **金丝雀探针探活 (`lib/canary.js`)**:密钥出冷却期前,自动发起轻量探测验证上游可用性,避免影响用户真实请求。
120
+ * **沙箱密钥探测 (`lib/sandbox.js`)**:按需对密钥执行 `/models` 探测后再回到轮换;空闲冷却由 self-heal sweep 解除。
121
121
  * **配额日历重置对齐 (`lib/quota-window.js`)**:支持 `midnight_utc`、`midnight_pst` 与 `rolling_24h` 配额刷新窗口。
122
122
  * **自适应指数退避 (`lib/pool.js`)**:连续失败使冷却时间呈指数递增(基准 → ×2 → ×4 → 上限 ×8)。
123
123
 
@@ -125,7 +125,6 @@ graph LR
125
125
  * **交互式 Webhook (`lib/webhook.js`)**:向 **Telegram**、**Discord**、**Slack** 推送带交互按钮的富文本警报,可在移动聊天中一键重置冷却或暂停提供商。
126
126
  * **使用量与成本报表 (`lib/usage-report.js`)**:按日统计各密钥请求数与预估成本,支持一键导出 CSV/JSON (`GET /dsh-key-rotation/usage-report`)。
127
127
  * **延迟 SLO 监控 (`lib/histogram.js`)**:记录首字延迟(TTFT)与健康度评分 (`0..100`)。
128
- * **影子流量测试 (`lib/shadow.js`)**:支持配置百分比的流量镜像复制以评估次要提供商。
129
128
 
130
129
  ---
131
130
 
@@ -185,7 +184,6 @@ dsh-key-rotation:
185
184
  - UNKNOWN_MODEL
186
185
  - AUTH
187
186
  cooldownMs: 60000
188
- canaryProbing: true
189
187
  concurrencyLimit: 5
190
188
  quotaResetWindow:
191
189
  type: midnight_utc
@@ -214,6 +212,11 @@ dsh-key-rotation:
214
212
 
215
213
  MIT © [GooDAnDReaDY](https://github.com/GooDAnDReaDY)
216
214
 
215
+ ### v0.7.39
216
+ - **自动恢复与 lastUsedAt 修复**:修复了 `healIdleCooldowns` 中对密钥调用时间戳的读取逻辑,直接从 `lastUsedAt` 映射读取。在 `credentials.resolve` 中补充记录每次密钥调用的时间戳,使状态面板的最近使用时间生效并正确支持空闲密钥恢复。
217
+ - **运行期快照缓存优化**:消除了定时维护清理、`/status` 路由及密钥池耗尽处理中重复调用 `buildRuntime()` 的开销。
218
+ - **CI 测试稳定性提升**:对维护定时器与通知防抖定时器执行 `unref`,确保 Node.js 事件循环在测试完成后干净退出,彻底解决 CI 运行器假死问题。
219
+
217
220
  ### v0.7.38
218
221
  - **热路径流处理优化**:在 `rotate()` 结束块处理中消除 4 次多余的 `buildRuntime()` 重复调用,直接复用请求作用域内的 `runtime0` 快照。
219
222
  - **零额外字符串分配的限流头解析**:重构 `extractRateLimit()`,采用单次遍历结合键长度检查,彻底消除对每个响应头执行 `.toLowerCase()` / `.toUpperCase()` 的内存碎片分配。
package/lib/heal.js CHANGED
@@ -11,13 +11,13 @@ export function healIdleCooldowns(pools, idleMs, now = Date.now()) {
11
11
  for (const pool of pools) {
12
12
  if (!pool || !pool.state || !pool.base) continue;
13
13
  const fu = pool.state.failedUntil;
14
- const lu = pool.state.lastUsed;
14
+ const lua = pool.state.lastUsedAt ?? pool.state.lastUsed;
15
15
  if (!fu || fu.size === 0) continue;
16
16
  const expiredRefs = [];
17
17
  for (const [ref, until] of fu.entries()) {
18
18
  if (!Number.isFinite(until)) continue;
19
19
  if (until > now) continue; // cooldown still active
20
- const last = lu ? lu.get(ref) : undefined;
20
+ const last = typeof lua?.get === 'function' ? lua.get(ref) : undefined;
21
21
  if (!Number.isFinite(last)) continue; // never used → no signal, skip
22
22
  if (now - last < idleMs) continue; // used recently → don't heal
23
23
  expiredRefs.push(ref);
@@ -0,0 +1,169 @@
1
+ // lib/http-bridge.js — shared HTTP helpers for dsh-key-rotation routes.
2
+ import { findSecrets } from './keycheck.js';
3
+ import { isTrustedBridgeRequest } from './pool.js';
4
+
5
+ export const NS = 'dsh-key-rotation';
6
+
7
+ export function json(res, status, obj) {
8
+ res.writeHead(status, { 'content-type': 'application/json' });
9
+ res.end(JSON.stringify(obj));
10
+ }
11
+
12
+ export function readJson(request) {
13
+ return new Promise((resolve, reject) => {
14
+ let raw = '';
15
+ request.on('data', (c) => { raw += c; });
16
+ request.on('end', () => {
17
+ try {
18
+ resolve(JSON.parse(raw || '{}'));
19
+ } catch (e) {
20
+ reject(e);
21
+ }
22
+ });
23
+ request.on('error', reject);
24
+ });
25
+ }
26
+
27
+ export function descriptorOf(ctx, ns) {
28
+ const settings = ctx.get('settings');
29
+ if (settings === void 0) return void 0;
30
+ return settings.describe({ redactSecrets: true }).find((candidate) => candidate.ns === ns);
31
+ }
32
+
33
+ export function viewOf(descriptor, settings) {
34
+ return {
35
+ available: true,
36
+ writable: settings.writable,
37
+ hasDocument: settings.hasDocument,
38
+ value: descriptor.value,
39
+ ...descriptor.base === void 0 ? {} : { base: descriptor.base },
40
+ ...descriptor.user === void 0 || Object.keys(descriptor.user).length === 0 ? {} : { user: descriptor.user },
41
+ revision: descriptor.revision,
42
+ };
43
+ }
44
+
45
+ export async function writeSection(ctx, ns, section, expectedRevision, res) {
46
+ const settings = ctx.get('settings');
47
+ if (settings === void 0) {
48
+ json(res, 503, { error: { code: 'settings-rejected', message: 'dsh-key-rotation: no settings provider is mounted' } });
49
+ return;
50
+ }
51
+ try {
52
+ await settings.replace(ns, section, expectedRevision);
53
+ } catch (error) {
54
+ if (error?.code === 'SETTINGS_CONFLICT') {
55
+ json(res, 409, { error: { code: 'settings-conflict', message: `dsh-key-rotation: changed elsewhere (expected revision ${String(error.expected)}, current ${String(error.actual)}); reload and retry` } });
56
+ return;
57
+ }
58
+ json(res, 400, { error: { code: 'settings-rejected', message: error instanceof Error ? error.message : String(error) } });
59
+ return;
60
+ }
61
+ const descriptor = descriptorOf(ctx, ns);
62
+ if (descriptor === void 0) {
63
+ json(res, 500, { error: { code: 'settings-rejected', message: 'dsh-key-rotation: namespace vanished after write' } });
64
+ return;
65
+ }
66
+ json(res, 200, viewOf(descriptor, { writable: settings.writable, hasDocument: settings.documentPath !== void 0 }));
67
+ }
68
+
69
+ export function providerCatalog(ctx, cloneIds) {
70
+ const seen = new Set();
71
+ const out = [];
72
+ for (const info of ctx.llm.listProviders()) {
73
+ if (seen.has(info.id) || cloneIds.has(info.id)) continue;
74
+ seen.add(info.id);
75
+ out.push({ id: info.id, name: info.name ?? info.id });
76
+ }
77
+ return out;
78
+ }
79
+
80
+ export function guardLocal(req, res, label) {
81
+ if (!isTrustedBridgeRequest(req)) {
82
+ json(res, 403, { error: { code: 'forbidden', message: `dsh-key-rotation: ${label} is local-only` } });
83
+ return false;
84
+ }
85
+ return true;
86
+ }
87
+
88
+ export function scanForLiveSecrets(section) {
89
+ const masked = structuredClone(section);
90
+ if (masked.webhookActionToken) masked.webhookActionToken = '***';
91
+ if (masked.notifyWebhook) masked.notifyWebhook = '***';
92
+ return findSecrets(JSON.stringify(masked));
93
+ }
94
+
95
+ /** Config bridge GET/PUT/DELETE — extracted from lib/index.js for size (#253). */
96
+ export async function handleConfigBridge(ctx, request, res, getCloneIds) {
97
+ if (!isTrustedBridgeRequest(request)) {
98
+ res.writeHead(403);
99
+ res.end();
100
+ return;
101
+ }
102
+ const method = request.method ?? 'GET';
103
+ if (method === 'GET') {
104
+ const settings = ctx.get('settings');
105
+ const descriptor = descriptorOf(ctx, NS);
106
+ const body = {
107
+ providers: providerCatalog(ctx, getCloneIds()),
108
+ };
109
+ if (descriptor === void 0) {
110
+ json(res, 200, {
111
+ ...body,
112
+ available: false,
113
+ writable: settings?.writable ?? false,
114
+ hasDocument: settings?.documentPath !== void 0,
115
+ value: void 0,
116
+ revision: 0,
117
+ });
118
+ return;
119
+ }
120
+ json(res, 200, {
121
+ ...body,
122
+ ...viewOf(descriptor, {
123
+ writable: settings?.writable ?? false,
124
+ hasDocument: settings?.documentPath !== void 0,
125
+ }),
126
+ });
127
+ return;
128
+ }
129
+ if (method === 'PUT' || method === 'DELETE') {
130
+ let section;
131
+ let expectedRevision;
132
+ if (method === 'PUT') {
133
+ let body;
134
+ try {
135
+ body = await readJson(request);
136
+ } catch (error) {
137
+ json(res, 400, { error: { code: 'settings-rejected', message: `dsh-key-rotation: invalid request body: ${error instanceof Error ? error.message : String(error)}` } });
138
+ return;
139
+ }
140
+ if (typeof body !== 'object' || body === null || typeof body.section !== 'object' || body.section === null || Array.isArray(body.section)) {
141
+ json(res, 400, { error: { code: 'settings-rejected', message: 'dsh-key-rotation: PUT requires {"section": {...}}' } });
142
+ return;
143
+ }
144
+ section = body.section;
145
+ expectedRevision = typeof body.expectedRevision === 'number' ? body.expectedRevision : void 0;
146
+ try {
147
+ const findings = scanForLiveSecrets(section);
148
+ if (findings.length > 0) {
149
+ json(res, 400, {
150
+ error: {
151
+ code: 'secret-in-config',
152
+ message: `dsh-key-rotation: value looks like a live credential (${findings[0].type}); store key values via the key field, not the config section`,
153
+ findings,
154
+ },
155
+ });
156
+ return;
157
+ }
158
+ } catch {
159
+ /* scanning must never block a valid save */
160
+ }
161
+ } else {
162
+ section = {};
163
+ expectedRevision = void 0;
164
+ }
165
+ await writeSection(ctx, NS, section, expectedRevision, res);
166
+ return;
167
+ }
168
+ json(res, 405, { error: { code: 'method', message: 'GET, PUT, or DELETE only' } });
169
+ }