@goodandready/dsh-key-rotation 0.8.20 → 0.8.21

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.
@@ -0,0 +1,42 @@
1
+ // Decode modern volatile Config references while retaining stable identities for
2
+ // the request hot path. Legacy cores pass ordinary resolved configuration.
3
+ export function createConfigReader(config) {
4
+ let previous;
5
+ const read = (value, held) => {
6
+ if (value && typeof value.get === 'function') value = value.get();
7
+ if (!value || typeof value !== 'object') return value;
8
+ const keys = Object.keys(value);
9
+ const next = Array.isArray(value) ? [] : {};
10
+ let same = held && typeof held === 'object' && Array.isArray(value) === Array.isArray(held)
11
+ && keys.length === Object.keys(held).length;
12
+ for (const key of keys) {
13
+ Object.defineProperty(next, key, { value: read(value[key], held?.[key]), writable: true, enumerable: true, configurable: true });
14
+ if (!held || !Object.hasOwn(held, key) || !Object.is(next[key], held[key])) same = false;
15
+ }
16
+ return same ? held : next;
17
+ };
18
+ return () => (previous = read(config, previous));
19
+ }
20
+
21
+ // Modern settings owns descriptors rather than the removed get(namespace) API.
22
+ // Cache by document invalidation; clone-producing describe() must not rebuild
23
+ // every key pool on every request.
24
+ export function createProviderProfilesReader(ctx, namespace) {
25
+ let dirty = true, held = null, owner;
26
+ ctx.effect(() => ctx.on('settings/document-updated', () => { dirty = true; }));
27
+ return () => {
28
+ const settings = ctx.get('settings');
29
+ if (settings !== owner) { owner = settings; dirty = true; }
30
+ if (!settings) return null;
31
+ if (typeof settings.get === 'function') {
32
+ try { return settings.get(namespace)?.providers ?? null; } catch (_) { return null; }
33
+ }
34
+ if (dirty) {
35
+ try {
36
+ held = settings.describe({ redactSecrets: true }).find(row => row.ns === namespace)?.value?.providers ?? null;
37
+ dirty = false;
38
+ } catch (_) { held = null; }
39
+ }
40
+ return held;
41
+ };
42
+ }
@@ -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 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);
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
- request.on('error', reject);
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
- await settings.replace(ns, section, expectedRevision);
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.writeHead(403);
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
- if (typeof body !== 'object' || body === null || typeof body.section !== 'object' || body.section === null || Array.isArray(body.section)) {
135
- json(res, 400, { error: { code: 'settings-rejected', message: 'dsh-key-rotation: PUT requires {"section": {...}}' } });
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 = scanForLiveSecrets(section);
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
- export const Config = Schema.object({
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
- getConfig = () => config;
160
+ const readEntryConfig = createConfigReader(config);
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 = ctx.get('settings')?.get(PIAI_NS)?.providers ?? null;
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 = ctx.get('settings')?.get(PIAI_NS)?.providers ?? {};
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,11 @@ export function apply(ctx, config = {}) {
536
548
 
537
549
  ctx.inject(['settings'], (sctx) => {
538
550
  const settingsSvc = sctx.get('settings');
539
- if (!settingsSvc || typeof settingsSvc.register !== 'function') {
540
- logger.warn('[dsh-key-rotation] settings service unavailable — config scope not registered');
541
- return;
542
- }
543
- const scope = settingsSvc.register(NS, Config, { base: config });
544
- getConfig = () => scope.get() ?? config;
545
- sctx.effect(() => () => {
546
- getConfig = () => config;
547
- });
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
+ sctx.effect(() => () => { getConfig = readEntryConfig; });
548
557
  });
549
558
  }
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
- const cfg = getConfig();
10
- if (!cfg || cfg.selfHealCooldown === false) return () => {};
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
- const cfg = getConfig();
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 timer = setInterval(async () => {
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
- }, intervalMs);
59
- if (typeof timer.unref === 'function') timer.unref();
60
- return () => { disposed = true; clearInterval(timer); };
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
- if (!Array.isArray(data)) { json(res, 400, { error: { code: 'bad-format', message: 'dsh-key-rotation: expected JSON array of providers' } }); return; }
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
- const cur = desc?.value?.providers ?? [];
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.replace(NS, { ...(desc?.value ?? {}), providers: mergedArr }, desc?.revision);
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');
@@ -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
- await settings.replace(NS, merged, desc.revision);
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) {
@@ -23,9 +23,15 @@ export function buildPoolItem({
23
23
  rpmLimit,
24
24
  makeState,
25
25
  }) {
26
- const refs = (keys ?? []).filter((ref) => typeof ref === 'string' && isValidRef(ref.trim()));
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 = Array.isArray(weights) ? weights : [];
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.startsWith('127.')) return true;
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
- return isLoopbackAddress(originUrl.hostname) || originUrl.hostname === 'localhost';
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.20",
3
+ "version": "0.8.21",
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 --test --test-timeout=10000 test/*.test.js test/*.test.mjs"
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
  }