@goodandready/dsh-key-rotation 0.8.49 → 0.8.50

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/CHANGELOG.md CHANGED
@@ -1,5 +1,14 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.8.50 - 2026-10-05
4
+
5
+ ### Fixed
6
+ - **Multi-Instance Isolation in Single Process (#446)**: Scoped all module-level state (`moduleBreaker`, `latencyHistogram`, `quotaStore`, `concurrencyTracker`, `webhookSender`, `alertDebouncer`, `moduleNotifyQueue`, and alert suppression maps `*NotifiedAt`) into `apply(ctx, config)` factory scope, eliminating shared state across profiles/instances.
7
+ - **Config Bridge GET Error Handling (#444)**: Wrapped `handleConfigBridge` GET branch in `try ... catch` to prevent unhandled promise rejections on catalog/settings provider read failures, safely returning HTTP 500 JSON with error code `settings-read-failed`.
8
+ - **Safe Settings Service Property Access (#447)**: Guarded `settings.writable` and `settings.documentPath` reads with defensive fallbacks and verified `typeof settings?.describe === function` in `descriptorOf`, `viewOf`, `writeSection`, and `handleConfigBridge`.
9
+ - **Clean Stream Consumer Finally Block (#445)**: Removed redundant empty `try { ... } finally { }` block in `lib/rotate.js`, keeping the outer resource cleanup block for concurrency permit and picked reference disposal.
10
+ - **Wired Diagnostics Pool Helpers (#440)**: Connected exported `providerOfBase` and `basePoolForBase` in `lib/resolver.js` (provider budget checks and exhaustion error formatting) and `lib/ops-status.js` (`/status` and `/health` routes, circuit breaker evaluation), and added `test/pool-index.test.js`.
11
+
3
12
  ## 0.8.49 - 2026-10-03
4
13
 
5
14
  ### Added
@@ -47,16 +47,16 @@ export function readJson(request, maxBytes = 1024 * 1024) {
47
47
 
48
48
  export function descriptorOf(ctx, ns) {
49
49
  const settings = ctx.get('settings');
50
- if (settings === void 0) return void 0;
51
- return settings.describe({ redactSecrets: true }).find((candidate) => candidate.ns === ns);
50
+ if (settings === void 0 || typeof settings?.describe !== 'function') return void 0;
51
+ return settings.describe({ redactSecrets: true })?.find((candidate) => candidate.ns === ns);
52
52
  }
53
53
 
54
54
  export function viewOf(descriptor, settings) {
55
55
  return {
56
56
  available: true,
57
- writable: settings.writable,
58
- hasDocument: settings.hasDocument,
59
- value: descriptor.value,
57
+ writable: Boolean(settings?.writable),
58
+ hasDocument: Boolean(settings?.hasDocument),
59
+ value: descriptor?.value,
60
60
  ...descriptor.base === void 0 ? {} : { base: descriptor.base },
61
61
  ...descriptor.user === void 0 || Object.keys(descriptor.user).length === 0 ? {} : { user: descriptor.user },
62
62
  revision: descriptor.revision,
@@ -90,7 +90,7 @@ export async function writeSection(ctx, ns, section, expectedRevision, res, rese
90
90
  json(res, 500, { error: { code: 'settings-rejected', message: 'dsh-key-rotation: namespace vanished after write' } });
91
91
  return;
92
92
  }
93
- json(res, 200, viewOf(descriptor, { writable: settings.writable, hasDocument: settings.documentPath !== void 0 }));
93
+ json(res, 200, viewOf(descriptor, { writable: Boolean(settings?.writable), hasDocument: settings?.documentPath !== void 0 }));
94
94
  }
95
95
 
96
96
  /**
@@ -189,29 +189,38 @@ export async function handleConfigBridge(ctx, request, res, getCloneIds) {
189
189
  }
190
190
  const method = request.method ?? 'GET';
191
191
  if (method === 'GET') {
192
- const settings = ctx.get('settings');
193
- const descriptor = descriptorOf(ctx, NS);
194
- const body = {
195
- providers: providerCatalog(ctx, getCloneIds()),
196
- };
197
- if (descriptor === void 0) {
192
+ try {
193
+ const settings = ctx.get('settings');
194
+ const descriptor = descriptorOf(ctx, NS);
195
+ const body = {
196
+ providers: providerCatalog(ctx, getCloneIds()),
197
+ };
198
+ if (descriptor === void 0) {
199
+ json(res, 200, {
200
+ ...body,
201
+ available: false,
202
+ writable: Boolean(settings?.writable),
203
+ hasDocument: settings?.documentPath !== void 0,
204
+ value: void 0,
205
+ revision: 0,
206
+ });
207
+ return;
208
+ }
198
209
  json(res, 200, {
199
210
  ...body,
200
- available: false,
201
- writable: settings?.writable ?? false,
202
- hasDocument: settings?.documentPath !== void 0,
203
- value: void 0,
204
- revision: 0,
211
+ ...viewOf(descriptor, {
212
+ writable: Boolean(settings?.writable),
213
+ hasDocument: settings?.documentPath !== void 0,
214
+ }),
215
+ });
216
+ } catch (error) {
217
+ json(res, 500, {
218
+ error: {
219
+ code: 'settings-read-failed',
220
+ message: `dsh-key-rotation: failed to read settings: ${error instanceof Error ? error.message : String(error)}`,
221
+ },
205
222
  });
206
- return;
207
223
  }
208
- json(res, 200, {
209
- ...body,
210
- ...viewOf(descriptor, {
211
- writable: settings?.writable ?? false,
212
- hasDocument: settings?.documentPath !== void 0,
213
- }),
214
- });
215
224
  return;
216
225
  }
217
226
  if (method === 'PUT' || method === 'DELETE') {
package/lib/index.js CHANGED
@@ -67,32 +67,9 @@ const CONFIG_PATH = '/dsh-key-rotation/config';
67
67
  const PIAI_NS = 'llm-pi-ai';
68
68
  const MARKER = '__dshKeyRotation';
69
69
 
70
- let rotationDisabled = false;
71
- const expiryNotifiedAt = new Map();
72
- const budgetNotifiedAt = new Map();
73
- const lowHealthNotifiedAt = new Map();
74
- const sloNotifiedAt = new Map();
75
-
76
70
  const dispatchStorage = new AsyncLocalStorage();
77
- const latencyHistogram = new LatencyHistogram();
78
- const quotaStore = new QuotaStore();
79
- const concurrencyTracker = new ConcurrencyTracker();
80
-
81
- let moduleBreaker = new CircuitBreaker({ threshold: 5, openMs: 30000, halfOpenProbes: 1, now: nowMono });
82
- const webhookSender = new WebhookSender({ fetchImpl: globalThis.fetch });
83
- const alertDebouncer = new AlertDebouncer({ sender: webhookSender, debounceMs: 2000 });
84
- let moduleNotifyQueue = new NotifyQueue({ send: (url, payload) => alertDebouncer.enqueue(url, payload) });
85
-
86
- let getConfig = () => null;
87
- function verboseLoggingOn() {
88
- try { return Boolean(getConfig()?.verboseLogging); } catch (_) { return false; }
89
- }
90
-
91
71
  let getRuntime = () => null;
92
72
 
93
- const sandboxService = createSandboxService({ getRuntime: () => getRuntime() });
94
- const { ensureSandboxRunner, probeRef, lastTestCache } = sandboxService;
95
-
96
73
  const DEFAULT_PROVIDERS = [];
97
74
 
98
75
  const SettingsSchema = Schema.object({
@@ -189,7 +166,29 @@ function registerConfigBridge(ctx, getCloneIds) {
189
166
  export function apply(ctx, config = {}) {
190
167
  const logger = getLogger(ctx);
191
168
  const readEntryConfig = createConfigReader(config, ctx);
192
- getConfig = readEntryConfig;
169
+ let getConfig = readEntryConfig;
170
+ const verboseLoggingOn = () => {
171
+ try { return Boolean(getConfig()?.verboseLogging); } catch (_) { return false; }
172
+ };
173
+
174
+ let rotationDisabled = false;
175
+ const expiryNotifiedAt = new Map();
176
+ const budgetNotifiedAt = new Map();
177
+ const lowHealthNotifiedAt = new Map();
178
+ const sloNotifiedAt = new Map();
179
+
180
+ const latencyHistogram = new LatencyHistogram();
181
+ const quotaStore = new QuotaStore();
182
+ const concurrencyTracker = new ConcurrencyTracker();
183
+
184
+ const moduleBreaker = new CircuitBreaker({ threshold: 5, openMs: 30000, halfOpenProbes: 1, now: nowMono });
185
+ const webhookSender = new WebhookSender({ fetchImpl: globalThis.fetch });
186
+ const alertDebouncer = new AlertDebouncer({ sender: webhookSender, debounceMs: 2000 });
187
+ const moduleNotifyQueue = new NotifyQueue({ send: (url, payload) => alertDebouncer.enqueue(url, payload) });
188
+
189
+ const sandboxService = createSandboxService({ getRuntime: () => buildRuntime() });
190
+ const { ensureSandboxRunner, probeRef, lastTestCache } = sandboxService;
191
+
193
192
  const readProfiles = createProviderProfilesReader(ctx, PIAI_NS);
194
193
  registerConfigBridge(ctx, () => buildRuntime().cloneIds);
195
194
  ensureSandboxRunner(ctx);
@@ -340,7 +339,7 @@ export function apply(ctx, config = {}) {
340
339
  switchNotifyThrottleMs: cfg.switchNotifyThrottleMs ?? 60000,
341
340
  warnBelowHealthy: cfg.warnBelowHealthy ?? 0, latencySloMs: cfg.latencySloMs ?? 0,
342
341
  index, pools,
343
- breaker: moduleBreaker, notifyQueue: moduleNotifyQueue,
342
+ breaker: moduleBreaker, notifyQueue: moduleNotifyQueue, concurrencyTracker, quotaStore, latencyHistogram,
344
343
  providerTags, providerBudgets, poolByRef, providerToPool, modelPoolByProvider,
345
344
  cloneIds, expectedClones,
346
345
  circuitBreakerEnabled: cfg.circuitBreakerEnabled ?? true,
package/lib/ops-status.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { getModelQuotaStatus, isModelQuotaAvailable, hasModelQuotaConfig } from './model-quota.js';
2
+ import { providerOfBase, basePoolForBase } from './pool-index.js';
2
3
  import { decryptSecret } from './crypto-storage.js';
3
4
  // lib/ops-status.js — status + health operational routes (#312 split from routes-ops.js).
4
5
  import { json } from './http-bridge.js';
@@ -119,8 +120,11 @@ ctx.effect(() => ctx.webServer.register({
119
120
  modelQuota,
120
121
  });
121
122
  }
123
+ const basePool = pool.basePool ?? (runtime.index ? basePoolForBase(runtime.index, pool.base) : null);
122
124
  providers.push({
123
125
  provider: pool.base,
126
+ providerName: pool.provider ?? providerOfBase(pool.base),
127
+ basePool: basePool?.base ?? null,
124
128
  model: pool.model ?? null,
125
129
  quotasConfigured: hasModelQuotaConfig(pool),
126
130
  keys,
@@ -130,8 +134,9 @@ ctx.effect(() => ctx.webServer.register({
130
134
  const br = circuitBreaker ?? runtime.breaker;
131
135
  if (!br) return null;
132
136
  let st = br.state(pool.base);
133
- if (st === 'closed' && pool.provider && pool.provider !== pool.base) {
134
- const baseSt = br.state(pool.provider);
137
+ const provKey = pool.provider ?? providerOfBase(pool.base);
138
+ if (st === 'closed' && provKey && provKey !== pool.base) {
139
+ const baseSt = br.state(provKey);
135
140
  if (baseSt !== 'closed') st = baseSt;
136
141
  }
137
142
  return { state: st, threshold: br.threshold, openMs: br.openMs };
@@ -212,12 +217,15 @@ ctx.effect(() => ctx.webServer.register({
212
217
  const total = pool.refs.length;
213
218
  const exhausted = healthy === 0 && total > 0;
214
219
  if (exhausted) exhaustedAny = true;
220
+ const basePoolH = pool.basePool ?? (runtimeH.index ? basePoolForBase(runtimeH.index, pool.base) : null);
215
221
  pools[pool.base] = {
216
222
  healthy,
217
223
  total,
218
224
  exhausted,
219
225
  healthScore: computeHealthScore(pool.state),
220
226
  model: pool.model ?? null,
227
+ providerName: pool.provider ?? providerOfBase(pool.base),
228
+ basePool: basePoolH?.base ?? null,
221
229
  modelQuota: quotaAware ? { configured: true, exhaustedKeys: quotaExhausted } : null,
222
230
  };
223
231
  }
@@ -225,7 +233,7 @@ ctx.effect(() => ctx.webServer.register({
225
233
  const brH = circuitBreaker ?? runtimeH.breaker;
226
234
  if (brH) {
227
235
  for (const pool of (runtimeH.pools ?? runtimeH.poolByRef.values())) {
228
- const provKey = pool.provider || pool.base;
236
+ const provKey = pool.provider ?? providerOfBase(pool.base);
229
237
  if (brH.state(pool.base) === 'open' || brH.state(provKey) === 'open') {
230
238
  circuitOpenAny = true;
231
239
  break;
package/lib/resolver.js CHANGED
@@ -11,7 +11,7 @@
11
11
  import { bucketAllow, bucketRetryMs, tpmAllow, tpmRetryMs } from './bucket.js';
12
12
  import { envValue, sortAttemptList, isKeyPaused, isKeyRevoked, isKeyBroken, costForDay, costForWeek, budgetVerdict } from './pool.js';
13
13
  import { decryptSecret } from './crypto-storage.js';
14
- import { resolveRefPool, isQuotaManagedRequest } from './pool-index.js';
14
+ import { resolveRefPool, isQuotaManagedRequest, providerOfBase, basePoolForBase } from './pool-index.js';
15
15
  import {
16
16
  isModelQuotaAvailable,
17
17
  hasModelQuotaConfig,
@@ -101,7 +101,7 @@ export function createResolver({
101
101
  const at = now();
102
102
 
103
103
  // Provider monetary budget refusal (#422):
104
- const providerName = pool.provider ?? pool.base?.split?.('::')?.[0];
104
+ const providerName = pool.provider ?? providerOfBase(pool.base);
105
105
  const budget = runtime.providerBudgets?.get?.(providerName);
106
106
  if (budget && budget.pauseOnBudget) {
107
107
  const { daily, weekly } = getProviderCost(providerName, runtime, at);
@@ -221,7 +221,7 @@ export function createResolver({
221
221
  // local refusal: it is not penalised and not classified as an upstream failure.
222
222
  if (quotaManaged && !anyModelQuotaAvailable(pool, at)) {
223
223
  throw Object.assign(
224
- new Error(formatModelQuotaExhaustion(pool.provider ?? pool.base, pool)),
224
+ new Error(formatModelQuotaExhaustion(pool.provider ?? providerOfBase(pool.base), pool)),
225
225
  { code: LOCAL_QUOTA_CODE, localQuota: true },
226
226
  );
227
227
  }
package/lib/rotate.js CHANGED
@@ -287,8 +287,7 @@ export function createRotate(deps) {
287
287
 
288
288
  const _pickedRef = reqStore.pickedRef ?? pool.state.lastUsed;
289
289
  try {
290
- try {
291
- for await (const chunk of preserveDispatchContext(inner, dispatchStorage, reqStore)) {
290
+ for await (const chunk of preserveDispatchContext(inner, dispatchStorage, reqStore)) {
292
291
  if (!reqStore.concurrencyRef && typeof concurrencyTracker?.acquire === 'function') {
293
292
  const fallbackRef = reqStore.pickedRef ?? _pickedRef;
294
293
  if (fallbackRef) {
@@ -464,8 +463,6 @@ export function createRotate(deps) {
464
463
  activePermit?.success();
465
464
  }
466
465
  return; // clean end — served
467
- } finally {
468
- }
469
466
  } finally {
470
467
  if (reqStore.concurrencyRef) {
471
468
  concurrencyTracker?.release(reqStore.concurrencyRef);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-key-rotation",
3
- "version": "0.8.49",
3
+ "version": "0.8.50",
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": [