@goodandready/dsh-key-rotation 0.8.78 → 0.8.79

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
@@ -55,6 +55,13 @@
55
55
  - **Fail-Closed Balance & Probe Inspection**: Prevent sending ciphertext credentials (`enc:v1:...`) upstream if decryption fails (#539).
56
56
  - **PBKDF2 Iteration Bounds & Async KDF**: Enforce strict iteration limits (`10000..200000`) and async derivation to eliminate event-loop blocking DoS (#542).
57
57
 
58
+ ## [0.8.79] - 2026-10-30
59
+
60
+ ### Changed
61
+ - **#522 (M) Routes Modularization (`lib/routes/`)**: Unified HTTP operational routes structure. Extracted individual route registrars (`status.js`, `keys.js`, `metrics.js`, `balance.js`, `telemetry.js`, `testing.js`, `webhook.js`, `config.js`, `quick-action.js`, `guard.js`) with backwards-compatible re-export facades in `lib/ops-*.js` and `lib/routes-ops.js`.
62
+ - **#551 (M) Schemastery Volatile Config Migration**: Eliminated custom HTTP config bridge dependency in favor of native Schemastery `.volatile()` schema and `unwrapVolatileConfig()`. Added deprecation warnings and `X-DSH-Deprecated` headers on legacy `/dsh-key-rotation/config` endpoints.
63
+ - **#529 (M) Key State Machine (`lib/key-state-machine.js`)**: Encapsulated key rotation state transitions into a formal, deterministic State Machine (`ACTIVE`, `COOLDOWN`, `PROBING`, `EXHAUSTED`, `PAUSED`, `REVOKED`) with transition history tracking, strict transition rule enforcement, and state distribution statistics.
64
+
58
65
  ## [0.8.72] — 2026-10-23
59
66
 
60
67
  ### Security
@@ -1,7 +1,23 @@
1
+ // lib/config-compat.js — Schemastery volatile decoding and config resolution (#551).
1
2
  // Decode modern volatile Config references while retaining stable identities for
2
3
  // the request hot path. Legacy cores pass ordinary resolved configuration.
3
4
  // If ctx is provided, invalidation listeners cache the unwrapped tree so the hot
4
5
  // path never recursively walks volatile nodes on unchanged configuration.
6
+
7
+ /** Read each volatile field through its live reference, if the loader installed one (#551). */
8
+ export function unwrapVolatileConfig(config) {
9
+ if (!config || typeof config !== 'object') return {};
10
+ const source = config;
11
+ const out = {};
12
+ for (const key of Object.keys(source)) {
13
+ const value = source[key];
14
+ out[key] = (value !== null && typeof value === 'object' && typeof value.get === 'function')
15
+ ? value.get()
16
+ : value;
17
+ }
18
+ return out;
19
+ }
20
+
5
21
  export function createConfigReader(config, ctx) {
6
22
  let previous;
7
23
  let dirty = true;
@@ -34,7 +50,8 @@ export function createConfigReader(config, ctx) {
34
50
  if (ctx && !dirty && previous !== undefined) {
35
51
  return previous;
36
52
  }
37
- previous = read(config, previous);
53
+ const unwrapped = unwrapVolatileConfig(config);
54
+ previous = read(unwrapped, previous);
38
55
  dirty = false;
39
56
  return previous;
40
57
  };
@@ -183,6 +183,8 @@ export function scanForLiveSecrets(section) {
183
183
 
184
184
  /** Config bridge GET/PUT/DELETE — extracted from lib/index.js for size (#253). */
185
185
  export async function handleConfigBridge(ctx, request, res, getCloneIds) {
186
+ res.setHeader?.('X-DSH-Deprecated', 'true');
187
+ res.setHeader?.('Deprecation', '@goodandready/dsh-key-rotation v0.8.79: use Schemastery volatile schema instead of direct config HTTP bridge');
186
188
  if (!isTrustedBridgeRequest(request)) {
187
189
  json(res, 403, { error: { code: 'forbidden', message: 'Settings writes require a trusted loopback origin' } });
188
190
  return;
package/lib/index.js CHANGED
@@ -11,7 +11,8 @@ import { decryptSecret } from './crypto-storage.js';
11
11
 
12
12
  import { AsyncLocalStorage } from 'node:async_hooks';
13
13
  import Schema from '@deepseek-ai/schemastery';
14
- import { createConfigReader, createProviderProfilesReader } from './config-compat.js';
14
+ import { createConfigReader, createProviderProfilesReader, unwrapVolatileConfig } from './config-compat.js';
15
+ import { KeyStateMachine, KEY_STATES, VALID_TRANSITIONS } from './key-state-machine.js';
15
16
  import {
16
17
  keyTail, KEY_TAIL_CHARS, KEY_TAIL_PLACEHOLDER, isLoopbackAddress, isTrustedBridgeRequest, isDesktopShellRequest, SWITCHABLE_MESSAGE_PATTERN,
17
18
  DEFAULT_SWITCH_CODES, isValidRef, pickNext, applyCooldown, recordFailure,
@@ -61,6 +62,8 @@ export { classifyResponseError } from './errors.js';
61
62
  export { SessionRouter, defaultSessionRouter, unstickFailedKey } from './session-router.js';
62
63
  export { isContentChunk } from './rotate.js';
63
64
  export { ModelCatalog, defaultModelCatalog } from './model-catalog.js';
65
+ export { KeyStateMachine, KEY_STATES, VALID_TRANSITIONS } from './key-state-machine.js';
66
+ export { unwrapVolatileConfig } from './config-compat.js';
64
67
  export { QuotaRunway, defaultQuotaRunway, estimateRunway } from './quota-runway.js';
65
68
  export * as transports from './transports/index.js';
66
69
 
@@ -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();
@@ -1,147 +1,2 @@
1
- // lib/ops-balance.js — operational HTTP routes for balance inspection (#495).
2
-
3
- import {
4
- json,
5
- readJson,
6
- } from './http-bridge.js';
7
- import {
8
- isTrustedBridgeRequest,
9
- isValidRef,
10
- } from './pool.js';
11
- import { BALANCE_PATH } from './ops-paths.js';
12
- import { inspectBalanceForRef } from './balance-inspector.js';
13
- import { pushEvent } from './notify-events.js';
14
-
15
- export function registerBalanceRoutes(ctx, deps) {
16
- const {
17
- poolState,
18
- buildRuntime,
19
- } = deps;
20
-
21
- ctx.effect(() => ctx.webServer.register({
22
- kind: 'exact',
23
- path: BALANCE_PATH,
24
- handler: async (req, res) => {
25
- if (!isTrustedBridgeRequest(req)) {
26
- json(res, 403, { ok: false, error: 'Forbidden' });
27
- return;
28
- }
29
-
30
- const urlObj = new URL(req.url, 'http://127.0.0.1');
31
-
32
- // GET: Query single key or provider cached balances
33
- if (req.method === 'GET') {
34
- const ref = urlObj.searchParams.get('ref');
35
- const provider = urlObj.searchParams.get('provider');
36
-
37
- if (ref) {
38
- const runtime = buildRuntime();
39
- const targetPool = provider ? runtime.pools?.find((p) => p.provider === provider) : null;
40
- const pool = targetPool ?? runtime.poolByRef?.get(ref);
41
- const cached = pool?.state?.balances?.get(ref) ?? null;
42
-
43
- if (urlObj.searchParams.get('refresh') === 'true' && provider) {
44
- const inspectRes = await inspectBalanceForRef(ctx, ref, provider);
45
- if (inspectRes.ok) {
46
- if (pool && pool.state) {
47
- if (!pool.state.balances) pool.state.balances = new Map();
48
- pool.state.balances.set(ref, { ...inspectRes, at: Date.now() });
49
-
50
- // Check soft pause
51
- applyBalanceState(pool, ref, inspectRes);
52
- }
53
- json(res, 200, { ok: true, ref, balance: inspectRes });
54
- return;
55
- }
56
- json(res, 200, { ok: false, ref, error: inspectRes.error, cached });
57
- return;
58
- }
59
-
60
- json(res, 200, { ok: true, ref, balance: cached });
61
- return;
62
- }
63
-
64
- // Return all cached balances
65
- const out = {};
66
- if (poolState && typeof poolState.forEach === 'function') {
67
- poolState.forEach((st, provName) => {
68
- if (st && st.balances instanceof Map) {
69
- for (const [k, v] of st.balances) {
70
- out[k] = v;
71
- }
72
- }
73
- });
74
- }
75
- json(res, 200, { ok: true, balances: out });
76
- return;
77
- }
78
-
79
- // POST: Trigger refresh of balances for a provider or key list
80
- if (req.method === 'POST') {
81
- const body = await readJson(req);
82
- const provider = body?.provider;
83
- const ref = body?.ref;
84
- const testValue = body?.value;
85
-
86
- if (ref && provider) {
87
- const inspectRes = await inspectBalanceForRef(ctx, ref, provider, { testValue });
88
- const runtime = buildRuntime();
89
- const pool = runtime.pools?.find((p) => p.provider === provider) ?? runtime.poolByRef?.get(ref);
90
-
91
- if (inspectRes.ok && pool && pool.state) {
92
- if (!pool.state.balances) pool.state.balances = new Map();
93
- pool.state.balances.set(ref, { ...inspectRes, at: Date.now() });
94
- applyBalanceState(pool, ref, inspectRes);
95
- }
96
-
97
- json(res, inspectRes.ok ? 200 : 400, { ok: inspectRes.ok, ref, balance: inspectRes });
98
- return;
99
- }
100
-
101
- if (provider) {
102
- const runtime = buildRuntime();
103
- const pool = runtime.pools?.find((p) => p.provider === provider);
104
- if (!pool || !Array.isArray(pool.refs) || pool.refs.length === 0) {
105
- json(res, 404, { ok: false, error: `Provider '${provider}' not found or has no keys` });
106
- return;
107
- }
108
-
109
- const results = {};
110
- for (const kRef of pool.refs) {
111
- const r = await inspectBalanceForRef(ctx, kRef, provider);
112
- results[kRef] = r;
113
- if (r.ok && pool.state) {
114
- if (!pool.state.balances) pool.state.balances = new Map();
115
- pool.state.balances.set(kRef, { ...r, at: Date.now() });
116
- applyBalanceState(pool, kRef, r);
117
- }
118
- }
119
- json(res, 200, { ok: true, provider, balances: results });
120
- return;
121
- }
122
-
123
- json(res, 400, { ok: false, error: 'ref or provider required' });
124
- return;
125
- }
126
-
127
- json(res, 405, { ok: false, error: 'Method Not Allowed' });
128
- },
129
- }), 'dsh-key-rotation: balance routes');
130
- }
131
-
132
- function applyBalanceState(pool, ref, balanceRes) {
133
- if (!pool || !pool.state) return;
134
- const isZero = balanceRes.isAvailable === false || (typeof balanceRes.total === 'number' && balanceRes.total <= 0);
135
-
136
- if (isZero) {
137
- if (!pool.state.pausedRefs) pool.state.pausedRefs = new Set();
138
- if (pool.state.pausedRefs instanceof Set) pool.state.pausedRefs.add(ref);
139
- if (!pool.state.pauseReasons) pool.state.pauseReasons = new Map();
140
- pool.state.pauseReasons.set(ref, 'EMPTY_BALANCE_PAUSED');
141
- pushEvent(pool, ref, 'BALANCE_EMPTY', 0);
142
- } else if (pool.state.pauseReasons?.get?.(ref) === 'EMPTY_BALANCE_PAUSED') {
143
- if (pool.state.pausedRefs instanceof Set) pool.state.pausedRefs.delete(ref);
144
- pool.state.pauseReasons.delete(ref);
145
- pushEvent(pool, ref, 'BALANCE_RESTORED', 0);
146
- }
147
- }
1
+ // lib/ops-balance.js — Re-export facade for lib/routes/ (#522).
2
+ export * from "./routes/balance.js";