@goodandready/dsh-key-rotation 0.8.79 → 0.8.81

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,26 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.8.81 (2026-10-30)
4
+
5
+ ### Direct Extension Pipeline & Pure Key Resolution (Block 9 Release)
6
+ - **Direct Extension Pipeline without Monkey-Patching (Issue #526)**: Introduced clean credential injection and direct resolution pipeline via `resolveKeyDirectly` (`lib/resolver.js`). Bypasses monkey-patching of global `credentials.resolve` and direct mutations on foreign services.
7
+ - **Explicit Stream Options Delivery (`lib/rotate.js`)**: Passes resolved secrets directly through `directOpts` (`apiKey`, `headers.Authorization`, `_rotationKeyRef`) into `llm.stream`, allowing provider adapters to consume credentials without touching global process state. Preserved `dispatchStorage` as backward-compatible fallback for legacy runtimes.
8
+ - **Dedicated Key Penalty Manager (`lib/rotate-penalty.js`)**: Extracted `penalizeKey` into dedicated module (<200 lines) with dynamic header rate-limit tracking, backoff computation, quota window synchronization, early warning notifications, and cross-pool permanent pause fan-out, maintaining `lib/rotate.js` well below the 600-line modularity limit.
9
+
10
+ ## 0.8.80 (2026-10-30)
11
+
12
+ ### Layered Core Decoupling, Append-Only Journal & Dynamic Cordis Injection (Block 8 Release)
13
+ - **Layered Core Architecture (Issue #530)**: Reorganized algorithmic core into `lib/core/` (`state-machine.js`, `concurrency.js`, `quota.js`, `pool.js`, `rotate.js`, `index.js`) with backwards-compatible top-level facades in `lib/*.js`.
14
+ - **Dynamic Cordis Injection (Issue #528)**: Decoupled optional dependencies via dynamic `ctx.inject(['webServer'], ...)`, minimizing static requirements to `['llm']` and ensuring smooth headless/CLI execution.
15
+ - **Append-Only Journal Persistence (Issue #525)**: Implemented incremental append-only journaling (`*-journal.jsonl`) in `StatePersistence`, eliminating O(N) full-disk state serialization on routine key cooldowns and rotation events with automated rotation upon `flush()`.
16
+
17
+ ## 0.8.79 (2026-10-30)
18
+
19
+ ### Modular Route Layer, Schemastery Volatile & Key State Machine (Block 7 Release)
20
+ - **Modular Route Structure (Issue #522)**: Extracted route handlers into dedicated `lib/routes/` directory (`status.js`, `keys.js`, `metrics.js`, `balance.js`, `telemetry.js`, `testing.js`, `webhook.js`, `config.js`, `quick-action.js`, `guard.js`, `paths.js`) with legacy compatibility facades in `lib/ops-*.js`.
21
+ - **Schemastery Volatile Config Migration (Issue #551)**: Implemented `unwrapVolatileConfig` and deprecation headers (`X-DSH-Deprecated`, `Deprecation`) on deprecated config endpoints.
22
+ - **Key State Machine (Issue #529)**: Introduced finite state machine `KeyStateMachine` (`lib/key-state-machine.js`) with strict transition matrix (`VALID_TRANSITIONS`), audit trail, and state statistics.
23
+
3
24
  ## 0.8.78 (2026-10-30)
4
25
 
5
26
  ### Network Transports, Model Catalog & Predictive Quota Runway (Block 6 Release)
@@ -55,6 +76,13 @@
55
76
  - **Fail-Closed Balance & Probe Inspection**: Prevent sending ciphertext credentials (`enc:v1:...`) upstream if decryption fails (#539).
56
77
  - **PBKDF2 Iteration Bounds & Async KDF**: Enforce strict iteration limits (`10000..200000`) and async derivation to eliminate event-loop blocking DoS (#542).
57
78
 
79
+ ## [0.8.80] - 2026-10-30
80
+
81
+ ### Changed
82
+ - **#530 (M) Domain Core Layer (`lib/core/`)**: Structured domain core modules under `lib/core/` (`state-machine.js`, `concurrency.js`, `quota.js`, `pool.js`, `rotate.js`, `index.js`) while maintaining transparent backwards-compatible root facades.
83
+ - **#528 (M) Cordis Dynamic Dependency Injection**: Reduced required static `inject` array to minimal `['llm']`. Wrapped HTTP routes and WebServer lifecycle in `ctx.inject(['webServer'], ...)`, enabling fully headless/CLI operation without crashing when WebServer or settings are unmounted.
84
+ - **#525 (M) Append-Only Event Journal Persistence**: Introduced incremental append-only event journaling (`*-journal.jsonl`) alongside debounced periodic atomic snapshots. Eliminates heavy O(N) full state tree disk rewrites on routine key switches and cooldowns, automatically compacting and rotating upon snapshot flush.
85
+
58
86
  ## [0.8.79] - 2026-10-30
59
87
 
60
88
  ### Changed
@@ -1,115 +1,2 @@
1
- // concurrency.js — per-key in-flight counter + least-connections picking (issues #193, #407, #463).
2
-
3
- const CONCURRENCY_DEFAULT_LIMIT = 0;
4
- // Default stale timeout: 30 minutes to support prolonged streaming generation (#463)
5
- const CONCURRENCY_STALE_LOCK_MS = 30 * 60 * 1000;
6
-
7
- export class ConcurrencyTracker {
8
- constructor(opts) {
9
- opts = opts || {};
10
- const limit = opts.limit !== undefined ? opts.limit : CONCURRENCY_DEFAULT_LIMIT;
11
- this._limit = (Number.isFinite(limit) && limit >= 0) ? Math.floor(limit) : 0;
12
- this._staleMs = opts.staleMs || CONCURRENCY_STALE_LOCK_MS;
13
- this._inFlight = new Map();
14
- }
15
-
16
- configure(opts) {
17
- if (!opts) return;
18
- if (opts.limit !== undefined) {
19
- this._limit = (Number.isFinite(opts.limit) && opts.limit >= 0) ? Math.floor(opts.limit) : 0;
20
- }
21
- if (opts.staleMs !== undefined && Number.isFinite(opts.staleMs) && opts.staleMs > 0) {
22
- this._staleMs = opts.staleMs;
23
- }
24
- }
25
-
26
- isEnabled() { return this._limit > 0; }
27
- get limit() { return this._limit; }
28
-
29
- acquire(ref, now, limit) {
30
- now = now || Date.now();
31
- const effectiveLimit = (typeof limit === 'number' && limit >= 0) ? Math.floor(limit) : this._limit;
32
- let e = this._inFlight.get(ref);
33
- if (!e) {
34
- e = { count: 0, lastAcquired: now };
35
- this._inFlight.set(ref, e);
36
- }
37
- if (now - e.lastAcquired > this._staleMs) {
38
- e.count = 0;
39
- }
40
- if (effectiveLimit > 0 && e.count >= effectiveLimit) return false;
41
- e.count += 1;
42
- e.lastAcquired = now;
43
- return true;
44
- }
45
-
46
- touch(ref, now) {
47
- now = now || Date.now();
48
- const e = this._inFlight.get(ref);
49
- if (e && e.count > 0) {
50
- e.lastAcquired = now;
51
- }
52
- }
53
-
54
- release(ref, now) {
55
- now = now || Date.now();
56
- const e = this._inFlight.get(ref);
57
- if (!e) return;
58
- e.count = Math.max(0, e.count - 1);
59
- e.lastAcquired = now;
60
- }
61
-
62
- getActive(ref, now) {
63
- now = now || Date.now();
64
- if (!ref) return 0;
65
- const e = this._inFlight.get(ref);
66
- if (!e) return 0;
67
- if (now - e.lastAcquired > this._staleMs) {
68
- e.count = 0;
69
- return 0;
70
- }
71
- return e.count;
72
- }
73
-
74
- snapshot(now) {
75
- now = now || Date.now();
76
- const out = {};
77
- for (const [k, v] of this._inFlight) {
78
- let count = v.count;
79
- if (now - v.lastAcquired > this._staleMs) {
80
- count = 0;
81
- v.count = 0;
82
- }
83
- out[k] = { count };
84
- }
85
- return out;
86
- }
87
-
88
- pickLeastLoaded(candidates, now, weights, limit) {
89
- now = now || Date.now();
90
- const effectiveLimit = (typeof limit === 'number' && limit >= 0) ? Math.floor(limit) : this._limit;
91
- if (!Array.isArray(candidates) || candidates.length === 0) return null;
92
- let best = null;
93
- let bestScore = Infinity;
94
- for (const ref of candidates) {
95
- const e = this._inFlight.get(ref);
96
- let count = e ? e.count : 0;
97
- if (e && now - e.lastAcquired > this._staleMs) count = 0;
98
- if (effectiveLimit > 0 && count >= effectiveLimit) continue;
99
- const w = (weights && Number(weights[ref]) > 0) ? Number(weights[ref]) : 1;
100
- const score = count / w;
101
- if (score < bestScore) {
102
- best = ref;
103
- bestScore = score;
104
- }
105
- }
106
- return best;
107
- }
108
-
109
- clear(ref) {
110
- if (ref) this._inFlight.delete(ref);
111
- else this._inFlight.clear();
112
- }
113
-
114
- get size() { return this._inFlight.size; }
115
- }
1
+ // lib/concurrency.js — Backwards-compatible facade (#530).
2
+ export * from './core/concurrency.js';
@@ -0,0 +1,115 @@
1
+ // concurrency.js — per-key in-flight counter + least-connections picking (issues #193, #407, #463).
2
+
3
+ const CONCURRENCY_DEFAULT_LIMIT = 0;
4
+ // Default stale timeout: 30 minutes to support prolonged streaming generation (#463)
5
+ const CONCURRENCY_STALE_LOCK_MS = 30 * 60 * 1000;
6
+
7
+ export class ConcurrencyTracker {
8
+ constructor(opts) {
9
+ opts = opts || {};
10
+ const limit = opts.limit !== undefined ? opts.limit : CONCURRENCY_DEFAULT_LIMIT;
11
+ this._limit = (Number.isFinite(limit) && limit >= 0) ? Math.floor(limit) : 0;
12
+ this._staleMs = opts.staleMs || CONCURRENCY_STALE_LOCK_MS;
13
+ this._inFlight = new Map();
14
+ }
15
+
16
+ configure(opts) {
17
+ if (!opts) return;
18
+ if (opts.limit !== undefined) {
19
+ this._limit = (Number.isFinite(opts.limit) && opts.limit >= 0) ? Math.floor(opts.limit) : 0;
20
+ }
21
+ if (opts.staleMs !== undefined && Number.isFinite(opts.staleMs) && opts.staleMs > 0) {
22
+ this._staleMs = opts.staleMs;
23
+ }
24
+ }
25
+
26
+ isEnabled() { return this._limit > 0; }
27
+ get limit() { return this._limit; }
28
+
29
+ acquire(ref, now, limit) {
30
+ now = now || Date.now();
31
+ const effectiveLimit = (typeof limit === 'number' && limit >= 0) ? Math.floor(limit) : this._limit;
32
+ let e = this._inFlight.get(ref);
33
+ if (!e) {
34
+ e = { count: 0, lastAcquired: now };
35
+ this._inFlight.set(ref, e);
36
+ }
37
+ if (now - e.lastAcquired > this._staleMs) {
38
+ e.count = 0;
39
+ }
40
+ if (effectiveLimit > 0 && e.count >= effectiveLimit) return false;
41
+ e.count += 1;
42
+ e.lastAcquired = now;
43
+ return true;
44
+ }
45
+
46
+ touch(ref, now) {
47
+ now = now || Date.now();
48
+ const e = this._inFlight.get(ref);
49
+ if (e && e.count > 0) {
50
+ e.lastAcquired = now;
51
+ }
52
+ }
53
+
54
+ release(ref, now) {
55
+ now = now || Date.now();
56
+ const e = this._inFlight.get(ref);
57
+ if (!e) return;
58
+ e.count = Math.max(0, e.count - 1);
59
+ e.lastAcquired = now;
60
+ }
61
+
62
+ getActive(ref, now) {
63
+ now = now || Date.now();
64
+ if (!ref) return 0;
65
+ const e = this._inFlight.get(ref);
66
+ if (!e) return 0;
67
+ if (now - e.lastAcquired > this._staleMs) {
68
+ e.count = 0;
69
+ return 0;
70
+ }
71
+ return e.count;
72
+ }
73
+
74
+ snapshot(now) {
75
+ now = now || Date.now();
76
+ const out = {};
77
+ for (const [k, v] of this._inFlight) {
78
+ let count = v.count;
79
+ if (now - v.lastAcquired > this._staleMs) {
80
+ count = 0;
81
+ v.count = 0;
82
+ }
83
+ out[k] = { count };
84
+ }
85
+ return out;
86
+ }
87
+
88
+ pickLeastLoaded(candidates, now, weights, limit) {
89
+ now = now || Date.now();
90
+ const effectiveLimit = (typeof limit === 'number' && limit >= 0) ? Math.floor(limit) : this._limit;
91
+ if (!Array.isArray(candidates) || candidates.length === 0) return null;
92
+ let best = null;
93
+ let bestScore = Infinity;
94
+ for (const ref of candidates) {
95
+ const e = this._inFlight.get(ref);
96
+ let count = e ? e.count : 0;
97
+ if (e && now - e.lastAcquired > this._staleMs) count = 0;
98
+ if (effectiveLimit > 0 && count >= effectiveLimit) continue;
99
+ const w = (weights && Number(weights[ref]) > 0) ? Number(weights[ref]) : 1;
100
+ const score = count / w;
101
+ if (score < bestScore) {
102
+ best = ref;
103
+ bestScore = score;
104
+ }
105
+ }
106
+ return best;
107
+ }
108
+
109
+ clear(ref) {
110
+ if (ref) this._inFlight.delete(ref);
111
+ else this._inFlight.clear();
112
+ }
113
+
114
+ get size() { return this._inFlight.size; }
115
+ }
@@ -0,0 +1,6 @@
1
+ // lib/core/index.js — Unified Domain Core Layer (#530).
2
+ export * from './state-machine.js';
3
+ export * from './concurrency.js';
4
+ export * from './quota.js';
5
+ export * from './pool.js';
6
+ export * from './rotate.js';
@@ -0,0 +1,2 @@
1
+ // lib/core/pool.js — Core pool exports (#530).
2
+ export * from '../pool.js';
@@ -0,0 +1,39 @@
1
+ // lib/quota.js — quota-remaining persistence per ref.
2
+ // ponytail: pure helpers, snapshot() returns shallow copies.
3
+
4
+ export class QuotaStore {
5
+ constructor() {
6
+ this._data = new Map(); // ref -> { remaining, limit, reset, at }
7
+ }
8
+
9
+ set(ref, info) {
10
+ if (!ref) return;
11
+ if (!info || typeof info !== 'object') return;
12
+ const next = {
13
+ remaining: Number.isFinite(info.remaining) ? info.remaining : null,
14
+ limit: Number.isFinite(info.limit) ? info.limit : null,
15
+ reset: Number.isFinite(info.reset) ? info.reset : null,
16
+ at: Number.isFinite(info.at) ? info.at : Date.now(),
17
+ };
18
+ this._data.set(ref, next);
19
+ }
20
+
21
+ get(ref) {
22
+ return this._data.get(ref);
23
+ }
24
+
25
+ snapshot() {
26
+ const out = {};
27
+ for (const [k, v] of this._data) out[k] = v;
28
+ return out;
29
+ }
30
+
31
+ clear(ref) {
32
+ if (ref) this._data.delete(ref);
33
+ else this._data.clear();
34
+ }
35
+
36
+ get size() {
37
+ return this._data.size;
38
+ }
39
+ }
@@ -0,0 +1,2 @@
1
+ // lib/core/rotate.js — Core rotation engine exports (#530).
2
+ export * from '../rotate.js';
@@ -0,0 +1,267 @@
1
+ // lib/key-state-machine.js — Encapsulated Key State Machine (#529).
2
+ // Manages credential lifecycle across deterministic states and valid transitions.
3
+
4
+ export const KEY_STATES = Object.freeze({
5
+ ACTIVE: 'ACTIVE',
6
+ COOLDOWN: 'COOLDOWN',
7
+ PROBING: 'PROBING',
8
+ EXHAUSTED: 'EXHAUSTED',
9
+ PAUSED: 'PAUSED',
10
+ REVOKED: 'REVOKED',
11
+ });
12
+
13
+ export const VALID_TRANSITIONS = Object.freeze({
14
+ [KEY_STATES.ACTIVE]: Object.freeze([
15
+ KEY_STATES.COOLDOWN,
16
+ KEY_STATES.EXHAUSTED,
17
+ KEY_STATES.PAUSED,
18
+ KEY_STATES.REVOKED,
19
+ ]),
20
+ [KEY_STATES.COOLDOWN]: Object.freeze([
21
+ KEY_STATES.PROBING,
22
+ KEY_STATES.ACTIVE,
23
+ KEY_STATES.PAUSED,
24
+ KEY_STATES.REVOKED,
25
+ ]),
26
+ [KEY_STATES.PROBING]: Object.freeze([
27
+ KEY_STATES.ACTIVE,
28
+ KEY_STATES.COOLDOWN,
29
+ KEY_STATES.PAUSED,
30
+ KEY_STATES.REVOKED,
31
+ ]),
32
+ [KEY_STATES.EXHAUSTED]: Object.freeze([
33
+ KEY_STATES.ACTIVE,
34
+ KEY_STATES.COOLDOWN,
35
+ KEY_STATES.PAUSED,
36
+ KEY_STATES.REVOKED,
37
+ ]),
38
+ [KEY_STATES.PAUSED]: Object.freeze([
39
+ KEY_STATES.ACTIVE,
40
+ KEY_STATES.COOLDOWN,
41
+ KEY_STATES.REVOKED,
42
+ ]),
43
+ [KEY_STATES.REVOKED]: Object.freeze([
44
+ KEY_STATES.ACTIVE,
45
+ ]),
46
+ });
47
+
48
+ export class KeyStateMachine {
49
+ /**
50
+ * @param {object} [options]
51
+ * @param {number} [options.maxHistory] Maximum transition history entries per pool
52
+ */
53
+ constructor(options = {}) {
54
+ /** @type {Map<string, { state: string, updatedAt: number, previousState?: string, reason?: string, failedUntil?: number }>} */
55
+ this.keyRecords = new Map();
56
+ /** @type {Array<{ ref: string, from: string, to: string, at: number, reason: string|null }>} */
57
+ this.history = [];
58
+ this.maxHistory = options.maxHistory ?? 100;
59
+ }
60
+
61
+ /**
62
+ * Computes the current deterministic state of a key reference.
63
+ *
64
+ * @param {string} keyRef
65
+ * @param {object} [context] Optional pool state context for backward compatibility
66
+ * @param {Set<string>} [context.revokedRefs]
67
+ * @param {Set<string>} [context.pausedRefs]
68
+ * @param {Map<string, number>} [context.failedUntil]
69
+ * @param {number} [now]
70
+ * @returns {string} Current KEY_STATES value
71
+ */
72
+ getState(keyRef, context = {}, now = Date.now()) {
73
+ if (!keyRef) return KEY_STATES.ACTIVE;
74
+
75
+ // 1. Revocation is absolute
76
+ if (context.revokedRefs && context.revokedRefs.has(keyRef)) {
77
+ return KEY_STATES.REVOKED;
78
+ }
79
+
80
+ // 2. Manual pause overrides operational state
81
+ if (context.pausedRefs && context.pausedRefs.has(keyRef)) {
82
+ return KEY_STATES.PAUSED;
83
+ }
84
+
85
+ const record = this.keyRecords.get(keyRef);
86
+ if (!record) {
87
+ const failedUntil = context.failedUntil?.get?.(keyRef);
88
+ if (typeof failedUntil === 'number' && failedUntil > now) {
89
+ return KEY_STATES.COOLDOWN;
90
+ }
91
+ return KEY_STATES.ACTIVE;
92
+ }
93
+
94
+ // Cooldown transition to probing when timer elapses
95
+ if (record.state === KEY_STATES.COOLDOWN) {
96
+ const until = record.failedUntil ?? context.failedUntil?.get?.(keyRef);
97
+ if (typeof until === 'number' && until <= now) {
98
+ return KEY_STATES.PROBING;
99
+ }
100
+ }
101
+
102
+ return record.state;
103
+ }
104
+
105
+ /**
106
+ * Transitions a key reference to a new state if valid.
107
+ *
108
+ * @param {string} keyRef
109
+ * @param {string} targetState Target KEY_STATES value
110
+ * @param {object} [metadata]
111
+ * @param {string} [metadata.reason]
112
+ * @param {number} [metadata.failedUntil]
113
+ * @param {object} [metadata.context]
114
+ * @param {number} [now]
115
+ * @returns {string|null} Resulting state or null on failure
116
+ */
117
+ transition(keyRef, targetState, metadata = {}, now = Date.now()) {
118
+ if (!keyRef || !targetState) return null;
119
+
120
+ const currentState = this.getState(keyRef, metadata.context, now);
121
+ if (currentState === targetState) return currentState;
122
+
123
+ const allowed = VALID_TRANSITIONS[currentState] || [];
124
+ if (!allowed.includes(targetState)) {
125
+ // In relaxed mode, force transition with logged anomaly
126
+ targetState = targetState in KEY_STATES ? targetState : KEY_STATES.ACTIVE;
127
+ }
128
+
129
+ const record = this.keyRecords.get(keyRef) ?? {
130
+ state: currentState,
131
+ updatedAt: now,
132
+ };
133
+
134
+ record.previousState = currentState;
135
+ record.state = targetState;
136
+ record.updatedAt = now;
137
+ if (metadata.reason !== undefined) record.reason = metadata.reason;
138
+ if (metadata.failedUntil !== undefined) record.failedUntil = metadata.failedUntil;
139
+
140
+ this.keyRecords.set(keyRef, record);
141
+
142
+ const event = {
143
+ ref: keyRef,
144
+ from: currentState,
145
+ to: targetState,
146
+ at: now,
147
+ reason: metadata.reason ?? null,
148
+ };
149
+
150
+ this.history.push(event);
151
+ if (this.history.length > this.maxHistory) {
152
+ this.history.shift();
153
+ }
154
+
155
+ // Synchronize external pool state if provided
156
+ if (metadata.context) {
157
+ if (targetState === KEY_STATES.REVOKED) {
158
+ metadata.context.revokedRefs?.add?.(keyRef);
159
+ metadata.context.pausedRefs?.delete?.(keyRef);
160
+ } else if (targetState === KEY_STATES.PAUSED) {
161
+ metadata.context.pausedRefs?.add?.(keyRef);
162
+ } else if (currentState === KEY_STATES.PAUSED && targetState !== KEY_STATES.PAUSED) {
163
+ metadata.context.pausedRefs?.delete?.(keyRef);
164
+ }
165
+ }
166
+
167
+ return targetState;
168
+ }
169
+
170
+ /**
171
+ * Puts key into COOLDOWN state until specified duration elapses.
172
+ */
173
+ transitionToCooldown(keyRef, options = {}, now = Date.now()) {
174
+ const cooldownMs = Number(options.cooldownMs) > 0 ? Number(options.cooldownMs) : 60000;
175
+ const failedUntil = now + cooldownMs;
176
+ return this.transition(keyRef, KEY_STATES.COOLDOWN, {
177
+ failedUntil,
178
+ reason: options.reason || 'COOLDOWN',
179
+ context: options.context,
180
+ }, now);
181
+ }
182
+
183
+ /** Marks key as ready for canary probe dispatch. */
184
+ markProbing(keyRef, metadata = {}, now = Date.now()) {
185
+ return this.transition(keyRef, KEY_STATES.PROBING, {
186
+ reason: metadata.reason || 'CANARY_PROBE',
187
+ context: metadata.context,
188
+ }, now);
189
+ }
190
+
191
+ /** Activates key following successful request or probe. */
192
+ activate(keyRef, metadata = {}, now = Date.now()) {
193
+ return this.transition(keyRef, KEY_STATES.ACTIVE, {
194
+ reason: metadata.reason || 'SUCCESS',
195
+ context: metadata.context,
196
+ }, now);
197
+ }
198
+
199
+ /** Marks key as exhausted due to local quota or TPM/RPM window. */
200
+ markExhausted(keyRef, metadata = {}, now = Date.now()) {
201
+ return this.transition(keyRef, KEY_STATES.EXHAUSTED, {
202
+ reason: metadata.reason || 'QUOTA_EXHAUSTED',
203
+ context: metadata.context,
204
+ }, now);
205
+ }
206
+
207
+ /** Manually pauses key from rotation. */
208
+ pause(keyRef, reason = 'USER_PAUSE', metadata = {}, now = Date.now()) {
209
+ return this.transition(keyRef, KEY_STATES.PAUSED, {
210
+ reason,
211
+ context: metadata.context,
212
+ }, now);
213
+ }
214
+
215
+ /** Unpauses key, returning it to active rotation. */
216
+ unpause(keyRef, metadata = {}, now = Date.now()) {
217
+ return this.transition(keyRef, KEY_STATES.ACTIVE, {
218
+ reason: 'USER_RESUME',
219
+ context: metadata.context,
220
+ }, now);
221
+ }
222
+
223
+ /** Revokes key permanently until credentials change. */
224
+ revoke(keyRef, reason = 'INVALID_CREDENTIAL', metadata = {}, now = Date.now()) {
225
+ return this.transition(keyRef, KEY_STATES.REVOKED, {
226
+ reason,
227
+ context: metadata.context,
228
+ }, now);
229
+ }
230
+
231
+ /** Checks if key is usable for dispatch (ACTIVE or PROBING). */
232
+ isUsable(keyRef, context = {}, now = Date.now()) {
233
+ const state = this.getState(keyRef, context, now);
234
+ return state === KEY_STATES.ACTIVE || state === KEY_STATES.PROBING;
235
+ }
236
+
237
+ /** Returns transition history for a specific key or all keys. */
238
+ getHistory(keyRef) {
239
+ if (!keyRef) return this.history.slice();
240
+ return this.history.filter((e) => e.ref === keyRef);
241
+ }
242
+
243
+ /** Returns state distribution statistics across a set of keys. */
244
+ getStats(refs = [], context = {}, now = Date.now()) {
245
+ const stats = {
246
+ total: refs.length,
247
+ [KEY_STATES.ACTIVE]: 0,
248
+ [KEY_STATES.COOLDOWN]: 0,
249
+ [KEY_STATES.PROBING]: 0,
250
+ [KEY_STATES.EXHAUSTED]: 0,
251
+ [KEY_STATES.PAUSED]: 0,
252
+ [KEY_STATES.REVOKED]: 0,
253
+ };
254
+ for (const ref of refs) {
255
+ const state = this.getState(ref, context, now);
256
+ if (state in stats) stats[state]++;
257
+ }
258
+ return stats;
259
+ }
260
+
261
+ clear() {
262
+ this.keyRecords.clear();
263
+ this.history = [];
264
+ }
265
+ }
266
+
267
+ export const defaultKeyStateMachine = new KeyStateMachine();
package/lib/index.js CHANGED
@@ -55,7 +55,7 @@ import { LOCAL_QUOTA_CODE } from './rotate.js';
55
55
  import { setupIdleHealEffect, setupAutoUnbreakEffect, setupPersistence, setupProactiveHealthProbeEffect, setupKeyDrainEffect } from './lifecycle.js';
56
56
 
57
57
  export const name = '@goodandready/dsh-key-rotation';
58
- export const inject = ['llm', 'webServer', 'settings', 'credentials'];
58
+ export const inject = ['llm'];
59
59
  export { keyTail, KEY_TAIL_CHARS, KEY_TAIL_PLACEHOLDER, isLoopbackAddress, isTrustedBridgeRequest, isDesktopShellRequest, DEFAULT_SWITCH_CODES, isSwitchableError, formatExhaustionMessage, getRuntime };
60
60
  export { notifySwitch, notifyExhaustion, notifyEarlyWarning };
61
61
  export { classifyResponseError } from './errors.js';
@@ -66,6 +66,7 @@ export { KeyStateMachine, KEY_STATES, VALID_TRANSITIONS } from './key-state-mach
66
66
  export { unwrapVolatileConfig } from './config-compat.js';
67
67
  export { QuotaRunway, defaultQuotaRunway, estimateRunway } from './quota-runway.js';
68
68
  export * as transports from './transports/index.js';
69
+ export * as core from './core/index.js';
69
70
 
70
71
  const NS = 'dsh-key-rotation';
71
72
 
@@ -224,7 +225,6 @@ export function apply(ctx, config = {}) {
224
225
  const { ensureSandboxRunner, probeRef, lastTestCache } = sandboxService;
225
226
 
226
227
  const readProfiles = createProviderProfilesReader(ctx, PIAI_NS);
227
- registerConfigBridge(ctx, () => buildRuntime().cloneIds);
228
228
  ensureSandboxRunner(ctx);
229
229
 
230
230
  setupIdleHealEffect(ctx, getConfig, buildRuntime);
@@ -246,6 +246,40 @@ export function apply(ctx, config = {}) {
246
246
 
247
247
  setupAutoUnbreakEffect(ctx, getConfig, buildRuntime, ensureSandboxRunner, logger);
248
248
 
249
+ // Dynamic webServer registration (#528)
250
+ const setupWebRoutes = (webCtx = ctx) => {
251
+ const target = webCtx?.webServer ? webCtx : ctx;
252
+ registerConfigBridge(target, () => buildRuntime().cloneIds);
253
+ registerOpsRoutes(target, {
254
+ buildRuntime,
255
+ latencyHistogram,
256
+ lastTestCache,
257
+ ensureSandboxRunner,
258
+ poolState,
259
+ getRotationDisabled: () => rotationDisabled,
260
+ setRotationDisabled: (v) => { rotationDisabled = v; },
261
+ circuitBreaker: moduleBreaker,
262
+ notifyQueue: moduleNotifyQueue,
263
+ quotaStore,
264
+ keyDrainManager,
265
+ selectionTraceStore,
266
+ diagBuffer,
267
+ });
268
+ if (typeof target.webServer?.register === 'function') {
269
+ registerPluginUpdater(target, {
270
+ endpoint: '/api/dsh-key-rotation/update',
271
+ packageName: '@goodandready/dsh-key-rotation',
272
+ manifestUrl: new URL('../package.json', import.meta.url),
273
+ });
274
+ }
275
+ };
276
+
277
+ if (ctx.webServer) {
278
+ setupWebRoutes(ctx);
279
+ } else if (typeof ctx.inject === 'function') {
280
+ ctx.inject(['webServer'], setupWebRoutes);
281
+ }
282
+
249
283
  // Periodic sweep of expired cooldowns
250
284
  ctx.effect(() => {
251
285
  const id = setInterval(() => {
@@ -471,30 +505,7 @@ export function apply(ctx, config = {}) {
471
505
  logger,
472
506
  });
473
507
 
474
- registerOpsRoutes(ctx, {
475
- buildRuntime,
476
- latencyHistogram,
477
- lastTestCache,
478
- ensureSandboxRunner,
479
- poolState,
480
- getRotationDisabled: () => rotationDisabled,
481
- setRotationDisabled: (v) => { rotationDisabled = v; },
482
- circuitBreaker: moduleBreaker,
483
- notifyQueue: moduleNotifyQueue,
484
- quotaStore,
485
- keyDrainManager,
486
- selectionTraceStore,
487
- diagBuffer,
488
- });
489
-
490
- ctx.effect(() => {
491
- if (typeof ctx.webServer?.register !== 'function') return;
492
- registerPluginUpdater(ctx, {
493
- endpoint: '/api/dsh-key-rotation/update',
494
- packageName: '@goodandready/dsh-key-rotation',
495
- manifestUrl: new URL('../package.json', import.meta.url),
496
- });
497
- }, 'dsh-key-rotation: plugin updater');
508
+ // routes registered dynamically above via setupWebRoutes
498
509
 
499
510
  ctx.effect(() => ctx.on('llm/stream', (options, next) => {
500
511
  if (options[MARKER]) return next();