@goodandready/dsh-key-rotation 0.8.20 → 0.8.22
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 +19 -0
- package/lib/client.js +493 -210
- package/lib/config-compat.js +82 -0
- package/lib/http-bridge.js +55 -21
- package/lib/index.js +24 -13
- package/lib/lifecycle.js +28 -14
- package/lib/ops-keys.js +6 -4
- package/lib/ops-telemetry.js +5 -5
- package/lib/pool-builder.js +9 -3
- package/lib/pool.js +3 -2
- package/lib/settings-write.js +77 -0
- package/package.json +8 -2
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
// Decode modern volatile Config references while retaining stable identities for
|
|
2
|
+
// the request hot path. Legacy cores pass ordinary resolved configuration.
|
|
3
|
+
// If ctx is provided, invalidation listeners cache the unwrapped tree so the hot
|
|
4
|
+
// path never recursively walks volatile nodes on unchanged configuration.
|
|
5
|
+
export function createConfigReader(config, ctx) {
|
|
6
|
+
let previous;
|
|
7
|
+
let dirty = true;
|
|
8
|
+
if (ctx && typeof ctx.on === 'function') {
|
|
9
|
+
const markDirty = () => { dirty = true; };
|
|
10
|
+
if (typeof ctx.effect === 'function') {
|
|
11
|
+
ctx.effect(() => ctx.on('settings/document-updated', markDirty));
|
|
12
|
+
ctx.effect(() => ctx.on('loader/volatile-update', markDirty));
|
|
13
|
+
ctx.effect(() => ctx.on('config', markDirty));
|
|
14
|
+
} else {
|
|
15
|
+
ctx.on('settings/document-updated', markDirty);
|
|
16
|
+
ctx.on('loader/volatile-update', markDirty);
|
|
17
|
+
ctx.on('config', markDirty);
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const read = (value, held) => {
|
|
22
|
+
if (value && typeof value.get === 'function') value = value.get();
|
|
23
|
+
if (!value || typeof value !== 'object') return value;
|
|
24
|
+
const keys = Object.keys(value);
|
|
25
|
+
const next = Array.isArray(value) ? [] : {};
|
|
26
|
+
let same = held && typeof held === 'object' && Array.isArray(value) === Array.isArray(held)
|
|
27
|
+
&& keys.length === Object.keys(held).length;
|
|
28
|
+
for (const key of keys) {
|
|
29
|
+
Object.defineProperty(next, key, { value: read(value[key], held?.[key]), writable: true, enumerable: true, configurable: true });
|
|
30
|
+
if (!held || !Object.hasOwn(held, key) || !Object.is(next[key], held[key])) same = false;
|
|
31
|
+
}
|
|
32
|
+
return same ? held : next;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
return () => {
|
|
36
|
+
if (ctx && !dirty && previous !== undefined) {
|
|
37
|
+
return previous;
|
|
38
|
+
}
|
|
39
|
+
previous = read(config, previous);
|
|
40
|
+
dirty = false;
|
|
41
|
+
return previous;
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Modern settings owns descriptors rather than the removed get(namespace) API.
|
|
46
|
+
// Cache by document invalidation; clone-producing describe() and tree-materializing
|
|
47
|
+
// settings.get() on 0.1.7+ must not execute on every request.
|
|
48
|
+
export function createProviderProfilesReader(ctx, namespace) {
|
|
49
|
+
let dirty = true, held = null, owner;
|
|
50
|
+
const markDirty = () => { dirty = true; };
|
|
51
|
+
if (typeof ctx?.effect === 'function') {
|
|
52
|
+
ctx.effect(() => ctx.on('settings/document-updated', markDirty));
|
|
53
|
+
} else if (typeof ctx?.on === 'function') {
|
|
54
|
+
ctx.on('settings/document-updated', markDirty);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return () => {
|
|
58
|
+
const settings = ctx?.get?.('settings');
|
|
59
|
+
if (settings !== owner) { owner = settings; dirty = true; }
|
|
60
|
+
if (!settings) return null;
|
|
61
|
+
if (dirty) {
|
|
62
|
+
if (typeof settings.get === 'function') {
|
|
63
|
+
try {
|
|
64
|
+
held = settings.get(namespace)?.providers ?? null;
|
|
65
|
+
dirty = false;
|
|
66
|
+
} catch (_) {
|
|
67
|
+
held = null;
|
|
68
|
+
dirty = false;
|
|
69
|
+
}
|
|
70
|
+
} else {
|
|
71
|
+
try {
|
|
72
|
+
held = settings.describe({ redactSecrets: true }).find(row => row.ns === namespace)?.value?.providers ?? null;
|
|
73
|
+
dirty = false;
|
|
74
|
+
} catch (_) {
|
|
75
|
+
held = null;
|
|
76
|
+
dirty = false;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return held;
|
|
81
|
+
};
|
|
82
|
+
}
|
package/lib/http-bridge.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// lib/http-bridge.js — shared HTTP helpers for dsh-key-rotation routes.
|
|
2
2
|
import { findSecrets } from './keycheck.js';
|
|
3
3
|
import { isTrustedBridgeRequest } from './pool.js';
|
|
4
|
+
import { mutateSettings, plainObject, requireRevision, sectionOps, validateOps } from './settings-write.js';
|
|
4
5
|
|
|
5
6
|
export const NS = 'dsh-key-rotation';
|
|
6
7
|
|
|
@@ -9,18 +10,37 @@ export function json(res, status, obj) {
|
|
|
9
10
|
res.end(JSON.stringify(obj));
|
|
10
11
|
}
|
|
11
12
|
|
|
12
|
-
export function readJson(request) {
|
|
13
|
+
export function readJson(request, maxBytes = 1024 * 1024) {
|
|
13
14
|
return new Promise((resolve, reject) => {
|
|
14
|
-
let
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
15
|
+
let size = 0;
|
|
16
|
+
const chunks = [];
|
|
17
|
+
const off = (evt, fn) => {
|
|
18
|
+
if (typeof request?.off === 'function') request.off(evt, fn);
|
|
19
|
+
else if (typeof request?.removeListener === 'function') request.removeListener(evt, fn);
|
|
20
|
+
};
|
|
21
|
+
const cleanup = () => {
|
|
22
|
+
off('data', onData); off('end', onEnd);
|
|
23
|
+
off('error', onError); off('aborted', onAborted);
|
|
24
|
+
};
|
|
25
|
+
const onError = (error) => { cleanup(); reject(error); };
|
|
26
|
+
const onAborted = () => onError(new Error('Request body was aborted'));
|
|
27
|
+
const onData = (chunk) => {
|
|
28
|
+
const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
29
|
+
size += bytes.length;
|
|
30
|
+
if (size > maxBytes) {
|
|
31
|
+
onError(Object.assign(new Error('Request body exceeds the 1 MiB limit'), { status: 413 }));
|
|
32
|
+
request.resume();
|
|
33
|
+
return;
|
|
21
34
|
}
|
|
22
|
-
|
|
23
|
-
|
|
35
|
+
chunks.push(bytes);
|
|
36
|
+
};
|
|
37
|
+
const onEnd = () => {
|
|
38
|
+
cleanup();
|
|
39
|
+
try { resolve(JSON.parse(Buffer.concat(chunks).toString('utf8') || '{}')); }
|
|
40
|
+
catch (error) { reject(error); }
|
|
41
|
+
};
|
|
42
|
+
request.on('data', onData); request.on('end', onEnd);
|
|
43
|
+
request.on('error', onError); request.on('aborted', onAborted);
|
|
24
44
|
});
|
|
25
45
|
}
|
|
26
46
|
|
|
@@ -42,14 +62,20 @@ export function viewOf(descriptor, settings) {
|
|
|
42
62
|
};
|
|
43
63
|
}
|
|
44
64
|
|
|
45
|
-
export async function writeSection(ctx, ns, section, expectedRevision, res) {
|
|
65
|
+
export async function writeSection(ctx, ns, section, expectedRevision, res, reset = false) {
|
|
46
66
|
const settings = ctx.get('settings');
|
|
47
67
|
if (settings === void 0) {
|
|
48
68
|
json(res, 503, { error: { code: 'settings-rejected', message: 'dsh-key-rotation: no settings provider is mounted' } });
|
|
49
69
|
return;
|
|
50
70
|
}
|
|
51
71
|
try {
|
|
52
|
-
|
|
72
|
+
if (reset) {
|
|
73
|
+
// DELETE is the explicit reset API; an ordinary save/import never comes here.
|
|
74
|
+
await settings.replace(ns, {}, expectedRevision);
|
|
75
|
+
} else {
|
|
76
|
+
const ops = Array.isArray(section) ? validateOps(section) : sectionOps(section);
|
|
77
|
+
await mutateSettings(settings, ns, ops, expectedRevision);
|
|
78
|
+
}
|
|
53
79
|
} catch (error) {
|
|
54
80
|
if (error?.code === 'SETTINGS_CONFLICT') {
|
|
55
81
|
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` } });
|
|
@@ -89,8 +115,7 @@ export function scanForLiveSecrets(section) {
|
|
|
89
115
|
/** Config bridge GET/PUT/DELETE — extracted from lib/index.js for size (#253). */
|
|
90
116
|
export async function handleConfigBridge(ctx, request, res, getCloneIds) {
|
|
91
117
|
if (!isTrustedBridgeRequest(request)) {
|
|
92
|
-
res
|
|
93
|
-
res.end();
|
|
118
|
+
json(res, 403, { error: { code: 'forbidden', message: 'Settings writes require a trusted loopback origin' } });
|
|
94
119
|
return;
|
|
95
120
|
}
|
|
96
121
|
const method = request.method ?? 'GET';
|
|
@@ -128,17 +153,20 @@ export async function handleConfigBridge(ctx, request, res, getCloneIds) {
|
|
|
128
153
|
try {
|
|
129
154
|
body = await readJson(request);
|
|
130
155
|
} catch (error) {
|
|
131
|
-
json(res, 400, { error: { code: 'settings-rejected', message: `dsh-key-rotation: invalid request body: ${error instanceof Error ? error.message : String(error)}` } });
|
|
156
|
+
json(res, error?.status === 413 ? 413 : 400, { error: { code: 'settings-rejected', message: `dsh-key-rotation: invalid request body: ${error instanceof Error ? error.message : String(error)}` } });
|
|
132
157
|
return;
|
|
133
158
|
}
|
|
134
|
-
|
|
135
|
-
|
|
159
|
+
try {
|
|
160
|
+
if (!plainObject(body) || (Object.hasOwn(body, 'ops') === Object.hasOwn(body, 'section'))) {
|
|
161
|
+
throw new TypeError('PUT requires exactly one of {ops: [...]} or {section: {...}}');
|
|
162
|
+
}
|
|
163
|
+
section = Object.hasOwn(body, 'ops') ? validateOps(body.ops) : sectionOps(body.section);
|
|
164
|
+
} catch (error) {
|
|
165
|
+
json(res, 400, { error: { code: 'settings-rejected', message: error.message } });
|
|
136
166
|
return;
|
|
137
167
|
}
|
|
138
|
-
section = body.section;
|
|
139
|
-
expectedRevision = typeof body.expectedRevision === 'number' ? body.expectedRevision : void 0;
|
|
140
168
|
try {
|
|
141
|
-
const findings =
|
|
169
|
+
const findings = findSecrets(JSON.stringify(section.filter(op => !['webhookActionToken', 'notifyWebhook'].includes(op.path[0]))));
|
|
142
170
|
if (findings.length > 0) {
|
|
143
171
|
json(res, 400, {
|
|
144
172
|
error: {
|
|
@@ -152,11 +180,17 @@ export async function handleConfigBridge(ctx, request, res, getCloneIds) {
|
|
|
152
180
|
} catch {
|
|
153
181
|
/* scanning must never block a valid save */
|
|
154
182
|
}
|
|
183
|
+
try {
|
|
184
|
+
expectedRevision = requireRevision(body.expectedRevision);
|
|
185
|
+
} catch (error) {
|
|
186
|
+
json(res, 400, { error: { code: 'settings-rejected', message: error.message } });
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
155
189
|
} else {
|
|
156
190
|
section = {};
|
|
157
191
|
expectedRevision = void 0;
|
|
158
192
|
}
|
|
159
|
-
await writeSection(ctx, NS, section, expectedRevision, res);
|
|
193
|
+
await writeSection(ctx, NS, section, expectedRevision, res, method === 'DELETE');
|
|
160
194
|
return;
|
|
161
195
|
}
|
|
162
196
|
json(res, 405, { error: { code: 'method', message: 'GET, PUT, or DELETE only' } });
|
package/lib/index.js
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
|
|
7
7
|
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
8
8
|
import Schema from '@deepseek-ai/schemastery';
|
|
9
|
+
import { createConfigReader, createProviderProfilesReader } from './config-compat.js';
|
|
9
10
|
import {
|
|
10
11
|
keyTail, isLoopbackAddress, isTrustedBridgeRequest, SWITCHABLE_MESSAGE_PATTERN,
|
|
11
12
|
DEFAULT_SWITCH_CODES, isValidRef, pickNext, applyCooldown, recordFailure,
|
|
@@ -73,7 +74,7 @@ const { ensureSandboxRunner, probeRef, lastTestCache } = sandboxService;
|
|
|
73
74
|
|
|
74
75
|
const DEFAULT_PROVIDERS = [];
|
|
75
76
|
|
|
76
|
-
|
|
77
|
+
const SettingsSchema = Schema.object({
|
|
77
78
|
switchCodes: Schema.array(Schema.string()).default([...DEFAULT_SWITCH_CODES]),
|
|
78
79
|
cooldownMs: Schema.number().default(60000),
|
|
79
80
|
maxCooldownMs: Schema.number(),
|
|
@@ -130,6 +131,15 @@ export const Config = Schema.object({
|
|
|
130
131
|
})).default([...DEFAULT_PROVIDERS]),
|
|
131
132
|
});
|
|
132
133
|
|
|
134
|
+
// New settings derives editable forms from volatile entry Config fields. Keep
|
|
135
|
+
// initialization-only persistence options ordinary (restart-bound) and retain a
|
|
136
|
+
// plain schema for old namespace registration. Older schemastery has no volatile.
|
|
137
|
+
export const Config = typeof Schema.number().volatile === 'function'
|
|
138
|
+
? Schema.object(Object.fromEntries(Object.entries(SettingsSchema.dict).map(([name, field]) => [
|
|
139
|
+
name, ['persistenceEnabled', 'persistencePath'].includes(name) ? field : field.volatile(),
|
|
140
|
+
])))
|
|
141
|
+
: SettingsSchema;
|
|
142
|
+
|
|
133
143
|
function registerConfigBridge(ctx, getCloneIds) {
|
|
134
144
|
return ctx.webServer.register({
|
|
135
145
|
kind: 'exact',
|
|
@@ -140,14 +150,16 @@ function registerConfigBridge(ctx, getCloneIds) {
|
|
|
140
150
|
res.end(JSON.stringify({ error: 'Method Not Allowed' }));
|
|
141
151
|
return;
|
|
142
152
|
}
|
|
143
|
-
handleConfigBridge(ctx, req, res, getCloneIds);
|
|
153
|
+
return handleConfigBridge(ctx, req, res, getCloneIds);
|
|
144
154
|
},
|
|
145
155
|
});
|
|
146
156
|
}
|
|
147
157
|
|
|
148
158
|
export function apply(ctx, config = {}) {
|
|
149
159
|
const logger = getLogger(ctx);
|
|
150
|
-
|
|
160
|
+
const readEntryConfig = createConfigReader(config, ctx);
|
|
161
|
+
getConfig = readEntryConfig;
|
|
162
|
+
const readProfiles = createProviderProfilesReader(ctx, PIAI_NS);
|
|
151
163
|
registerConfigBridge(ctx, () => buildRuntime().cloneIds);
|
|
152
164
|
ensureSandboxRunner(ctx);
|
|
153
165
|
|
|
@@ -210,7 +222,7 @@ export function apply(ctx, config = {}) {
|
|
|
210
222
|
const cfg = getConfig() ?? {};
|
|
211
223
|
let profilesSnapshot = null;
|
|
212
224
|
try {
|
|
213
|
-
profilesSnapshot =
|
|
225
|
+
profilesSnapshot = readProfiles();
|
|
214
226
|
} catch {
|
|
215
227
|
profilesSnapshot = null;
|
|
216
228
|
}
|
|
@@ -276,7 +288,7 @@ export function apply(ctx, config = {}) {
|
|
|
276
288
|
|
|
277
289
|
let profiles = {};
|
|
278
290
|
try {
|
|
279
|
-
profiles =
|
|
291
|
+
profiles = profilesSnapshot ?? {};
|
|
280
292
|
} catch { /* settings not mounted yet */ }
|
|
281
293
|
for (const [provider, profile] of Object.entries(profiles)) {
|
|
282
294
|
if (profile?.apiKeyEnv && poolByRef.has(profile.apiKeyEnv)) {
|
|
@@ -536,14 +548,13 @@ export function apply(ctx, config = {}) {
|
|
|
536
548
|
|
|
537
549
|
ctx.inject(['settings'], (sctx) => {
|
|
538
550
|
const settingsSvc = sctx.get('settings');
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
551
|
+
// Modern SettingsForms already owns this Loader entry's volatile Config.
|
|
552
|
+
// Legacy SettingsProvider instead needs a plain namespace registration.
|
|
553
|
+
if (typeof settingsSvc?.register !== 'function') return;
|
|
554
|
+
const scope = settingsSvc.register(NS, SettingsSchema, { base: readEntryConfig() });
|
|
555
|
+
getConfig = () => scope.get() ?? readEntryConfig();
|
|
556
|
+
if (typeof sctx?.effect === 'function') {
|
|
557
|
+
sctx.effect(() => () => { getConfig = readEntryConfig; });
|
|
542
558
|
}
|
|
543
|
-
const scope = settingsSvc.register(NS, Config, { base: config });
|
|
544
|
-
getConfig = () => scope.get() ?? config;
|
|
545
|
-
sctx.effect(() => () => {
|
|
546
|
-
getConfig = () => config;
|
|
547
|
-
});
|
|
548
559
|
});
|
|
549
560
|
}
|
package/lib/lifecycle.js
CHANGED
|
@@ -4,11 +4,11 @@ import { StatePersistence, resolveStatePath } from './persistence.js';
|
|
|
4
4
|
import path from 'node:path';
|
|
5
5
|
import { envValue } from './pool.js';
|
|
6
6
|
|
|
7
|
-
export function setupIdleHealEffect(ctx, getConfig, buildRuntime) {
|
|
7
|
+
export function setupIdleHealEffect(ctx, getConfig, buildRuntime, timers = globalThis) {
|
|
8
8
|
return ctx.effect(() => {
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
const timer = setInterval(() => {
|
|
9
|
+
// Keep the cheap scheduler alive so a disabled-at-start preference can be
|
|
10
|
+
// enabled later without restarting the plugin.
|
|
11
|
+
const timer = timers.setInterval(() => {
|
|
12
12
|
try {
|
|
13
13
|
const c = getConfig();
|
|
14
14
|
if (!c || c.selfHealCooldown === false) return;
|
|
@@ -21,20 +21,17 @@ export function setupIdleHealEffect(ctx, getConfig, buildRuntime) {
|
|
|
21
21
|
} catch (_) { /* ponytail: never crash the timer */ }
|
|
22
22
|
}, 60000);
|
|
23
23
|
if (typeof timer.unref === 'function') timer.unref();
|
|
24
|
-
return () => clearInterval(timer);
|
|
24
|
+
return () => timers.clearInterval(timer);
|
|
25
25
|
}, 'dsh-key-rotation: self-healing idle');
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
-
export function setupAutoUnbreakEffect(ctx, getConfig, buildRuntime, ensureSandboxRunner, logger) {
|
|
28
|
+
export function setupAutoUnbreakEffect(ctx, getConfig, buildRuntime, ensureSandboxRunner, logger, timers = globalThis) {
|
|
29
29
|
return ctx.effect(() => {
|
|
30
|
-
|
|
31
|
-
const intervalMin = cfg?.selfHealingIntervalMinutes ?? 30;
|
|
32
|
-
if (!intervalMin || intervalMin <= 0) return () => {};
|
|
33
|
-
const intervalMs = intervalMin * 60 * 1000;
|
|
30
|
+
let timer, intervalMs;
|
|
34
31
|
let running = false;
|
|
35
32
|
let disposed = false;
|
|
36
33
|
const isActive = () => !disposed && (getConfig()?.selfHealingIntervalMinutes ?? 0) > 0;
|
|
37
|
-
const
|
|
34
|
+
const run = async () => {
|
|
38
35
|
if (running || disposed) return;
|
|
39
36
|
running = true;
|
|
40
37
|
try {
|
|
@@ -55,9 +52,26 @@ export function setupAutoUnbreakEffect(ctx, getConfig, buildRuntime, ensureSandb
|
|
|
55
52
|
}, Date.now(), { isActive });
|
|
56
53
|
} catch (e) { logger?.warn?.('[dsh-key-rotation] auto-unbreak failed', e); }
|
|
57
54
|
finally { running = false; }
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
|
|
55
|
+
};
|
|
56
|
+
const configure = () => {
|
|
57
|
+
if (disposed) return;
|
|
58
|
+
const minutes = getConfig()?.selfHealingIntervalMinutes ?? 30;
|
|
59
|
+
const next = Number.isFinite(minutes) && minutes > 0
|
|
60
|
+
? Math.max(1000, Math.min(2147483647, minutes * 60000)) : 0;
|
|
61
|
+
if (next === intervalMs) return;
|
|
62
|
+
if (timer !== undefined) timers.clearInterval(timer);
|
|
63
|
+
intervalMs = next;
|
|
64
|
+
timer = next ? timers.setInterval(run, next) : undefined;
|
|
65
|
+
timer?.unref?.();
|
|
66
|
+
};
|
|
67
|
+
const changed = () => queueMicrotask(configure);
|
|
68
|
+
const off = [
|
|
69
|
+
typeof ctx?.on === 'function' ? ctx.on('settings/document-updated', changed) : () => {},
|
|
70
|
+
typeof ctx?.on === 'function' ? ctx.on('loader/volatile-update', changed) : () => {},
|
|
71
|
+
];
|
|
72
|
+
configure();
|
|
73
|
+
return () => { disposed = true; if (timer !== undefined) timers.clearInterval(timer); for (const stop of off) stop(); };
|
|
74
|
+
|
|
61
75
|
}, 'dsh-key-rotation: auto-unbreak');
|
|
62
76
|
}
|
|
63
77
|
|
package/lib/ops-keys.js
CHANGED
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
keyTail,
|
|
11
11
|
} from './pool.js';
|
|
12
12
|
import { looksLikeApiSecret } from './keycheck.js';
|
|
13
|
+
import { mutateSettings, validateProviders } from './settings-write.js';
|
|
13
14
|
import { bestEffort } from './best-effort.js';
|
|
14
15
|
import {
|
|
15
16
|
KEY_PATH,
|
|
@@ -170,20 +171,21 @@ ctx.effect(() => ctx.webServer.register({
|
|
|
170
171
|
const resp = await fetch(url, { signal: AbortSignal.timeout(10000) });
|
|
171
172
|
if (!resp.ok) { json(res, 400, { error: { code: 'fetch-failed', message: 'dsh-key-rotation: fetch returned ' + resp.status } }); return; }
|
|
172
173
|
const data = await resp.json();
|
|
173
|
-
|
|
174
|
+
validateProviders(data);
|
|
174
175
|
const settings = ctx.get('settings');
|
|
175
176
|
if (!settings) { json(res, 503, { error: { code: 'settings-rejected', message: 'dsh-key-rotation: no settings provider' } }); return; }
|
|
176
177
|
const desc = settings.describe({ redactSecrets: true }).find((c) => c.ns === NS);
|
|
177
|
-
|
|
178
|
+
if (!desc) throw new Error('Settings namespace is unavailable');
|
|
179
|
+
const cur = desc.value?.providers ?? [];
|
|
178
180
|
const merged = new Map();
|
|
179
181
|
for (const p of cur) if (p && p.provider) merged.set(p.provider, p);
|
|
180
182
|
for (const p of data) if (p && p.provider && typeof p.provider === 'string') merged.set(p.provider, p);
|
|
181
183
|
const mergedArr = [...merged.values()];
|
|
182
|
-
await settings
|
|
184
|
+
await mutateSettings(settings, NS, [{ op: 'set', path: ['providers'], value: mergedArr }], body.expectedRevision ?? desc.revision);
|
|
183
185
|
json(res, 200, { ok: true, providersImported: data.length, total: mergedArr.length });
|
|
184
186
|
} catch (e) {
|
|
185
187
|
const isTimeout = e?.name === 'TimeoutError' || e?.name === 'AbortError';
|
|
186
|
-
json(res, isTimeout ? 504 : 400, { error: { code: isTimeout ? 'timeout' : 'import-failed', message: String(e?.message ?? e) } });
|
|
188
|
+
json(res, isTimeout ? 504 : e?.code === 'SETTINGS_CONFLICT' ? 409 : 400, { error: { code: isTimeout ? 'timeout' : 'import-failed', message: String(e?.message ?? e) } });
|
|
187
189
|
}
|
|
188
190
|
},
|
|
189
191
|
}), 'dsh-key-rotation: import route');
|
package/lib/ops-telemetry.js
CHANGED
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
SNAPSHOT_PATH,
|
|
16
16
|
} from './ops-paths.js';
|
|
17
17
|
import { findSecrets } from './keycheck.js';
|
|
18
|
+
import { mutateSettings, sectionOps } from './settings-write.js';
|
|
18
19
|
|
|
19
20
|
/**
|
|
20
21
|
* @param {object} ctx cordis context
|
|
@@ -96,12 +97,11 @@ ctx.effect(() => ctx.webServer.register({
|
|
|
96
97
|
if (!settings) { json(res, 503, { error: { code: 'settings-rejected', message: 'dsh-key-rotation: no settings provider' } }); return; }
|
|
97
98
|
const desc = descriptorOf(ctx, NS);
|
|
98
99
|
if (desc === void 0) { json(res, 500, { error: { code: 'settings-rejected', message: 'dsh-key-rotation: namespace missing' } }); return; }
|
|
99
|
-
const cur = desc.value ?? {};
|
|
100
|
-
// empty token fields in the file keep the current values (never wipe a secret)
|
|
101
|
-
const merged = { ...cur, ...snap };
|
|
102
|
-
if (!snap.webhookActionToken) merged.webhookActionToken = cur.webhookActionToken ?? '';
|
|
103
100
|
try {
|
|
104
|
-
|
|
101
|
+
// Empty exported tokens mean omitted, not erased. Never reconstruct a
|
|
102
|
+
// complete section from the redacted describe response.
|
|
103
|
+
const ops = sectionOps(snap, { preserveEmptySecrets: true });
|
|
104
|
+
await mutateSettings(settings, NS, ops, body.expectedRevision ?? desc.revision);
|
|
105
105
|
const after = descriptorOf(ctx, NS);
|
|
106
106
|
json(res, 200, { ok: true, revision: after?.revision });
|
|
107
107
|
} catch (e) {
|
package/lib/pool-builder.js
CHANGED
|
@@ -23,9 +23,15 @@ export function buildPoolItem({
|
|
|
23
23
|
rpmLimit,
|
|
24
24
|
makeState,
|
|
25
25
|
}) {
|
|
26
|
-
const
|
|
26
|
+
const seen = new Set();
|
|
27
|
+
const indexed = (Array.isArray(keys) ? keys : []).map((key, index) => ({ ref: typeof key === 'string' ? key.trim() : '', index }))
|
|
28
|
+
.filter(({ ref }) => { if (!isValidRef(ref) || seen.has(ref)) return false; seen.add(ref); return true; });
|
|
29
|
+
const refs = indexed.map(item => item.ref);
|
|
27
30
|
if (refs.length === 0) return null;
|
|
28
|
-
const w =
|
|
31
|
+
const w = indexed.map(({ index }) => {
|
|
32
|
+
const value = Array.isArray(weights) ? weights[index] : undefined;
|
|
33
|
+
return Number.isFinite(value) && value > 0 ? Math.max(1, Math.min(1000, Math.floor(value))) : 1;
|
|
34
|
+
});
|
|
29
35
|
const weightedRefs = [];
|
|
30
36
|
for (let i = 0; i < refs.length; i++) {
|
|
31
37
|
const ww = typeof w[i] === 'number' && w[i] > 0 ? Math.floor(w[i]) : 1;
|
|
@@ -34,7 +40,7 @@ export function buildPoolItem({
|
|
|
34
40
|
const parsedExpiry = {};
|
|
35
41
|
if (Array.isArray(expiresAt)) {
|
|
36
42
|
for (let i = 0; i < refs.length; i++) {
|
|
37
|
-
const exp = parseExpiry(expiresAt[i]);
|
|
43
|
+
const exp = parseExpiry(expiresAt[indexed[i].index]);
|
|
38
44
|
if (exp !== undefined) parsedExpiry[refs[i]] = exp;
|
|
39
45
|
}
|
|
40
46
|
}
|
package/lib/pool.js
CHANGED
|
@@ -16,7 +16,7 @@ export function keyTail(key) {
|
|
|
16
16
|
export function isLoopbackAddress(ip) {
|
|
17
17
|
if (!ip || typeof ip !== 'string') return false;
|
|
18
18
|
if (ip === '127.0.0.1' || ip === '::1' || ip === '::ffff:127.0.0.1') return true;
|
|
19
|
-
if (ip.
|
|
19
|
+
if (/^127(?:\.(?:0|[1-9]\d{0,2})){3}$/.test(ip) && ip.split('.').every(part => Number(part) <= 255)) return true;
|
|
20
20
|
return false;
|
|
21
21
|
}
|
|
22
22
|
|
|
@@ -32,7 +32,8 @@ export function isTrustedBridgeRequest(req) {
|
|
|
32
32
|
try {
|
|
33
33
|
const originUrl = new URL(origin);
|
|
34
34
|
if (originUrl.host !== hostHeader) return false;
|
|
35
|
-
|
|
35
|
+
const hostname = originUrl.hostname.replace(/^\[|\]$/g, '');
|
|
36
|
+
return ['http:', 'https:'].includes(originUrl.protocol) && (isLoopbackAddress(hostname) || hostname === 'localhost');
|
|
36
37
|
} catch (_) {
|
|
37
38
|
return false;
|
|
38
39
|
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
// Settings writes from redacted browser views must never replace an entire
|
|
2
|
+
// namespace. Host-side mutate is atomic and rejects conflicts on both core eras.
|
|
3
|
+
export const SECRET_FIELDS = new Set(['webhookActionToken']);
|
|
4
|
+
const UNSAFE_FIELDS = new Set(['__proto__', 'prototype', 'constructor']);
|
|
5
|
+
|
|
6
|
+
export function plainObject(value) {
|
|
7
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) return false;
|
|
8
|
+
const proto = Object.getPrototypeOf(value);
|
|
9
|
+
return proto === Object.prototype || proto === null;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function assertJson(value, depth = 0) {
|
|
13
|
+
if (depth > 40) throw new TypeError('Configuration is nested too deeply');
|
|
14
|
+
if (value === null || typeof value === 'string' || typeof value === 'boolean') return;
|
|
15
|
+
if (typeof value === 'number' && Number.isFinite(value)) return;
|
|
16
|
+
if (Array.isArray(value)) { for (const item of value) assertJson(item, depth + 1); return; }
|
|
17
|
+
if (plainObject(value)) {
|
|
18
|
+
for (const [key, child] of Object.entries(value)) {
|
|
19
|
+
if (UNSAFE_FIELDS.has(key)) throw new TypeError('Unsafe configuration field');
|
|
20
|
+
assertJson(child, depth + 1);
|
|
21
|
+
}
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
throw new TypeError('Configuration must contain finite JSON-compatible values');
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function sectionOps(section, { preserveEmptySecrets = false } = {}) {
|
|
28
|
+
if (!plainObject(section)) throw new TypeError('Expected a configuration object');
|
|
29
|
+
assertJson(section);
|
|
30
|
+
if (Object.hasOwn(section, 'providers')) validateProviders(section.providers);
|
|
31
|
+
return Object.entries(section)
|
|
32
|
+
.filter(([key, value]) => !(preserveEmptySecrets && SECRET_FIELDS.has(key) && (value === '' || value === null)))
|
|
33
|
+
.map(([key, value]) => ({ op: 'set', path: [key], value }));
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function validateOps(ops) {
|
|
37
|
+
if (!Array.isArray(ops) || ops.length > 1000) throw new TypeError('Expected at most 1000 field operations');
|
|
38
|
+
for (const op of ops) {
|
|
39
|
+
if (!plainObject(op) || !['set', 'unset'].includes(op.op) || !Array.isArray(op.path)
|
|
40
|
+
|| !op.path.length || op.path.length > 40
|
|
41
|
+
|| op.path.some(part => typeof part !== 'string' || !part || UNSAFE_FIELDS.has(part))) {
|
|
42
|
+
throw new TypeError('Expected set/unset operations with non-empty, safe field paths');
|
|
43
|
+
}
|
|
44
|
+
if (op.op === 'set') {
|
|
45
|
+
assertJson(op.value);
|
|
46
|
+
if (op.path.length === 1 && op.path[0] === 'providers') validateProviders(op.value);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return ops;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function requireRevision(revision) {
|
|
53
|
+
if (!Number.isSafeInteger(revision) || revision < 0) throw new TypeError('A non-negative expectedRevision from the latest read is required');
|
|
54
|
+
return revision;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export async function mutateSettings(settings, ns, ops, expectedRevision) {
|
|
58
|
+
validateOps(ops);
|
|
59
|
+
requireRevision(expectedRevision);
|
|
60
|
+
if (typeof settings?.mutate !== 'function') throw new Error('This host does not provide atomic field mutation');
|
|
61
|
+
// Do not retry with replace or drop the revision fence after a refusal.
|
|
62
|
+
await settings.mutate(ns, ops, expectedRevision);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function validateProviders(providers) {
|
|
66
|
+
if (!Array.isArray(providers)) throw new TypeError('Expected an array of provider key pools');
|
|
67
|
+
assertJson(providers);
|
|
68
|
+
const names = new Set();
|
|
69
|
+
for (const provider of providers) {
|
|
70
|
+
if (!plainObject(provider) || typeof provider.provider !== 'string' || !provider.provider.trim()
|
|
71
|
+
|| names.has(provider.provider) || !Array.isArray(provider.keys) || new Set(provider.keys).size !== provider.keys.length || provider.keys.some(key => typeof key !== 'string' || !/^[A-Za-z_][A-Za-z0-9_]*$/.test(key))) {
|
|
72
|
+
throw new TypeError('Each provider must have a unique name and valid, unique credential reference names');
|
|
73
|
+
}
|
|
74
|
+
names.add(provider.provider);
|
|
75
|
+
}
|
|
76
|
+
return providers;
|
|
77
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@goodandready/dsh-key-rotation",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.22",
|
|
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": [
|
|
@@ -61,6 +61,12 @@
|
|
|
61
61
|
"@deepseek-ai/dsh-llm": "^0.1.0-rc.6"
|
|
62
62
|
},
|
|
63
63
|
"scripts": {
|
|
64
|
-
"test": "node
|
|
64
|
+
"test": "node tools/test.mjs",
|
|
65
|
+
"test:core": "node tools/test.mjs --core",
|
|
66
|
+
"check": "node tools/check.mjs"
|
|
67
|
+
},
|
|
68
|
+
"devDependencies": {
|
|
69
|
+
"react": "18.3.1",
|
|
70
|
+
"react-test-renderer": "18.3.1"
|
|
65
71
|
}
|
|
66
72
|
}
|