@goodandready/dsh-key-rotation 0.8.51 → 0.8.53

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,37 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.8.53 - 2026-10-09
4
+
5
+ ### Fixed
6
+ - **Official DSH Desktop Build Compatibility (#465, GitHub #22)**:
7
+ - Fixed issue where all mutating routes (POST, PUT, DELETE) answered `403 "forbidden"` inside the official DSH Desktop application (reported by @onech1cken in GitHub #22).
8
+ - The Desktop shell (`dsh-app://app/`) uses `forwardWebRequest` proxying which strips the `Origin` header before forwarding requests to the in-process server.
9
+ - `isTrustedBridgeRequest` now permits absent `Origin` headers (`origin === undefined`) for mutating requests when the remote peer address is verified loopback (`127.0.0.1`, `::1`, `::ffff:127.0.0.1`) and the `Host` header is verified loopback/localhost with no cross-site fetch metadata (`sec-fetch-site !== cross-site`). Real web browsers unconditionally attach `Origin` to cross-origin writes, preventing CSRF.
10
+ - Explicitly allowed `dsh-app://app` origin when forwarded by Desktop shell.
11
+ - Preserved strict fail-closed security for non-loopback clients, DNS rebinding, malformed origins, and cross-site requests.
12
+
13
+ ## 0.8.52 - 2026-10-07
14
+
15
+ ### Added
16
+ - **Key Pinning (`pinnedRef`) with Resilient Failover Bypass (#455)**:
17
+ - Added `pinnedRef` (alias `useKeyRef`) to provider settings schema and pool builder.
18
+ - In key selection (`lib/resolver.js` and `lib/rotate.js`), healthy pinned keys are prioritized first.
19
+ - When a pinned key enters cooldown, is paused/revoked, or runs out of token quota, requests automatically and seamlessly bypass the pin to rotate among remaining healthy keys, preventing agent stalls.
20
+ - **User-Defined Key Labels (#456)**:
21
+ - Added `labels` string array aligned with provider `keys` in settings schema and pool models.
22
+ - Exposed per-key in `GET /dsh-key-rotation/status` (`label` field) and provider overview.
23
+ - Displayed as visual tag badges in WebUI settings card.
24
+ - **Secure Plaintext Key Reveal Endpoint (#457)**:
25
+ - Added `GET /dsh-key-rotation/key?ref=...` in `lib/ops-keys.js` with loopback and same-origin validation (`isTrustedBridgeRequest`).
26
+ - Decrypts and returns `{ ref, plaintext, tail }` for operator auditing and copying without raw disk inspection.
27
+ - **In-Memory Client Diagnostics Ring Buffer (#458)**:
28
+ - Added bounded 200-entry diagnostics ring buffer and `/dsh-key-rotation/diag` route in `lib/ops-telemetry.js`.
29
+ - Ingests frontend runtime errors and fetch failures via `POST /diag` and retrieves log entries via `GET /diag`.
30
+
31
+ ### Changed
32
+ - **PeerDependencies Triplet SemVer Range (#459)**:
33
+ - Updated `@deepseek-ai/dsh-llm` peer dependency range to `^0.1.7-rc.2 || >=0.2.0-rc.1 <0.2.1-0` in `package.json` to prevent peer dependency warnings across DSH `0.2.0-rc.*` releases.
34
+
3
35
  ## 0.8.51 - 2026-10-06
4
36
 
5
37
  ### Fixed
package/lib/client.js CHANGED
@@ -1886,6 +1886,12 @@ window.__ModuleLoader__.load({
1886
1886
  h('span', { key: 'd', className: 'krot-dot', style: { background: st ? st.color : 'var(--dsw-alias-border-l2)' } }),
1887
1887
  h('span', { key: 's', className: 'krot-state' }, st ? st.text : ''),
1888
1888
  ];
1889
+ if (info && info.pinned) {
1890
+ meta.push(h('span', { key: 'pin', className: 'krot-state', title: 'Pinned key' }, '📌'));
1891
+ }
1892
+ if (info && info.label) {
1893
+ meta.push(h('span', { key: 'lbl', className: 'krot-tag', title: 'Key label' }, info.label));
1894
+ }
1889
1895
  if (fromEnv) {
1890
1896
  meta.push(h('span', { key: 'v', className: 'krot-tail', title: t('keyFromEnv') },
1891
1897
  info.tail ? '••••' + info.tail : t('keyFromEnv')));
package/lib/index.js CHANGED
@@ -118,6 +118,8 @@ const SettingsSchema = Schema.object({
118
118
  revoked: Schema.array(Schema.boolean()).default([]),
119
119
  expiresAt: Schema.array(Schema.union([Schema.number(), Schema.string()])).default([]),
120
120
  tags: Schema.array(Schema.string()).default([]),
121
+ labels: Schema.array(Schema.string()).default([]),
122
+ pinnedRef: Schema.string().default(''),
121
123
  costBudgetDaily: Schema.number(),
122
124
  costBudgetWeekly: Schema.number(),
123
125
  pauseOnBudget: Schema.boolean().default(false),
package/lib/ops-keys.js CHANGED
@@ -10,6 +10,7 @@ import {
10
10
  isTrustedBridgeRequest,
11
11
  isValidRef,
12
12
  keyTail,
13
+ envValue,
13
14
  } from './pool.js';
14
15
  import { looksLikeApiSecret } from './keycheck.js';
15
16
  import { mutateSettings, validateProviders } from './settings-write.js';
@@ -43,8 +44,8 @@ ctx.effect(() => ctx.webServer.register({
43
44
  kind: 'exact',
44
45
  path: KEY_PATH,
45
46
  handler: async (req, res) => {
46
- if (req.method !== 'PUT' && req.method !== 'DELETE') {
47
- json(res, 405, { error: { code: 'method', message: 'PUT or DELETE only' } });
47
+ if (req.method !== 'PUT' && req.method !== 'DELETE' && req.method !== 'GET') {
48
+ json(res, 405, { error: { code: 'method', message: 'GET, PUT or DELETE only' } });
48
49
  return;
49
50
  }
50
51
  if (!isTrustedBridgeRequest(req)) {
@@ -52,6 +53,30 @@ ctx.effect(() => ctx.webServer.register({
52
53
  return;
53
54
  }
54
55
  const credentialsService = ctx.get('credentials');
56
+ if (req.method === 'GET') {
57
+ const url = new URL(req.url, 'http://127.0.0.1');
58
+ const ref = url.searchParams.get('ref')?.trim();
59
+ if (!isValidRef(ref)) {
60
+ json(res, 400, { error: { code: 'bad-ref', message: 'dsh-key-rotation: ref must be an environment variable name' } });
61
+ return;
62
+ }
63
+ try {
64
+ let hit = await (credentialsService?.__dshKeyRotationOriginalResolve ?? credentialsService?.resolve)?.call(credentialsService, ref);
65
+ let raw = hit?.value;
66
+ if (!raw) {
67
+ raw = envValue(ref);
68
+ }
69
+ if (!raw) {
70
+ json(res, 404, { error: { code: 'not-found', message: `dsh-key-rotation: secret for "${ref}" not found` } });
71
+ return;
72
+ }
73
+ const plain = decryptSecret(raw);
74
+ json(res, 200, { ref, plaintext: plain, tail: keyTail(plain) });
75
+ } catch (err) {
76
+ json(res, 500, { error: { code: 'reveal-failed', message: String(err?.message ?? err) } });
77
+ }
78
+ return;
79
+ }
55
80
  if (!credentialsService || typeof credentialsService.set !== 'function') {
56
81
  json(res, 503, { error: { code: 'no-credentials', message: 'dsh-key-rotation: no credentials service is mounted' } });
57
82
  return;
package/lib/ops-paths.js CHANGED
@@ -10,3 +10,4 @@ export const TEST_PATH = '/dsh-key-rotation/test';
10
10
  export const SANDBOX_CACHE_PATH = '/dsh-key-rotation/sandbox-cache';
11
11
  export const WEBHOOK_ACTION_PATH = '/dsh-key-rotation/webhook-action';
12
12
  export const METRICS_PATH = '/dsh-key-rotation/metrics';
13
+ export const DIAG_PATH = '/dsh-key-rotation/diag';
package/lib/ops-status.js CHANGED
@@ -127,6 +127,8 @@ ctx.effect(() => ctx.webServer.register({
127
127
  providerName: pool.provider ?? providerOfBase(pool.base),
128
128
  basePool: basePool?.base ?? null,
129
129
  model: pool.model ?? null,
130
+ pinnedRef: pool.pinnedRef ?? null,
131
+ labels: pool.labels ?? {},
130
132
  quotasConfigured: hasModelQuotaConfig(pool),
131
133
  keys,
132
134
  tags: providerTags?.get?.(pool.base) ?? [],
@@ -13,6 +13,7 @@ import {
13
13
  import {
14
14
  USAGE_PATH,
15
15
  SNAPSHOT_PATH,
16
+ DIAG_PATH,
16
17
  } from './ops-paths.js';
17
18
  import { findSecrets } from './keycheck.js';
18
19
  import { mutateSettings, sectionOps } from './settings-write.js';
@@ -21,6 +22,24 @@ import { mutateSettings, sectionOps } from './settings-write.js';
21
22
  * @param {object} ctx cordis context
22
23
  * @param {object} deps live dependencies from apply()
23
24
  */
25
+
26
+ const DIAG_BUFFER_LIMIT = 200;
27
+ const diagBuffer = [];
28
+
29
+ export function pushDiag(entry) {
30
+ if (diagBuffer.length >= DIAG_BUFFER_LIMIT) {
31
+ diagBuffer.shift();
32
+ }
33
+ diagBuffer.push({
34
+ timestamp: Date.now(),
35
+ ...entry,
36
+ });
37
+ }
38
+
39
+ export function getDiagBuffer() {
40
+ return diagBuffer.slice();
41
+ }
42
+
24
43
  export function registerTelemetryRoutes(ctx, deps) {
25
44
  const {
26
45
  buildRuntime,
@@ -111,4 +130,34 @@ ctx.effect(() => ctx.webServer.register({
111
130
  },
112
131
  }), 'dsh-key-rotation: snapshot route');
113
132
 
133
+ ctx.effect(() => ctx.webServer.register({
134
+ kind: 'exact',
135
+ path: DIAG_PATH,
136
+ handler: async (req, res) => {
137
+ if (req.method !== 'GET' && req.method !== 'POST') {
138
+ json(res, 405, { error: { code: 'method', message: 'GET or POST only' } });
139
+ return;
140
+ }
141
+ if (!isTrustedBridgeRequest(req)) {
142
+ json(res, 403, { error: { code: 'forbidden', message: 'dsh-key-rotation: diag is local-only' } });
143
+ return;
144
+ }
145
+ if (req.method === 'POST') {
146
+ let body;
147
+ try { body = await readJson(req); } catch (e) {
148
+ json(res, 400, { error: { code: 'bad-request', message: String(e?.message ?? e) } });
149
+ return;
150
+ }
151
+ pushDiag({
152
+ level: typeof body?.level === 'string' ? body.level : 'info',
153
+ message: typeof body?.message === 'string' ? body.message.slice(0, 500) : 'unknown',
154
+ details: body?.details ?? null,
155
+ });
156
+ json(res, 200, { ok: true, count: diagBuffer.length });
157
+ return;
158
+ }
159
+ json(res, 200, { diag: getDiagBuffer() });
160
+ },
161
+ }), 'dsh-key-rotation: diag route');
162
+
114
163
  }
@@ -33,6 +33,9 @@ export function buildPoolItem({
33
33
  model = null,
34
34
  quotas = null,
35
35
  quotaResetWindow = null,
36
+ labels = null,
37
+ pinnedRef = null,
38
+ useKeyRef = null,
36
39
  }) {
37
40
  const seen = new Set();
38
41
  const indexed = (Array.isArray(keys) ? keys : []).map((key, index) => ({ ref: typeof key === 'string' ? key.trim() : '', index }))
@@ -84,6 +87,21 @@ export function buildPoolItem({
84
87
  for (let i = 0; i < refs.length; i++) {
85
88
  weightsMap[refs[i]] = typeof w[i] === 'number' && w[i] > 0 ? Math.floor(w[i]) : 1;
86
89
  }
90
+ const labelsMap = {};
91
+ if (Array.isArray(labels)) {
92
+ for (let i = 0; i < refs.length; i++) {
93
+ const lbl = labels[indexed[i].index];
94
+ if (typeof lbl === 'string' && lbl.trim()) labelsMap[refs[i]] = lbl.trim();
95
+ }
96
+ } else if (labels && typeof labels === 'object') {
97
+ for (let i = 0; i < refs.length; i++) {
98
+ const lbl = labels[refs[i]];
99
+ if (typeof lbl === 'string' && lbl.trim()) labelsMap[refs[i]] = lbl.trim();
100
+ }
101
+ }
102
+ const cleanPinned = typeof pinnedRef === 'string' && pinnedRef.trim()
103
+ ? pinnedRef.trim()
104
+ : (typeof useKeyRef === 'string' && useKeyRef.trim() ? useKeyRef.trim() : null);
87
105
  const normalizedQuotas = normalizeModelQuotas(quotas);
88
106
  return {
89
107
  base,
@@ -107,6 +125,8 @@ export function buildPoolItem({
107
125
  concurrencyLimit: typeof concurrencyLimit === 'number' && Number.isFinite(concurrencyLimit) && concurrencyLimit > 0 ? Math.floor(concurrencyLimit) : 0,
108
126
  routingStrategy: poolStrategy,
109
127
  proactiveRateLimitGuard: poolGuard,
128
+ labels: labelsMap,
129
+ pinnedRef: cleanPinned,
110
130
  };
111
131
  }
112
132
 
@@ -145,6 +165,8 @@ export function buildPools({ cfg = {}, poolState = new Map(), defaultState = ini
145
165
  const pool = buildPool(p.provider, p.keys, p.weights, p.paused, p.revoked, poolCooldown, poolMax, p.expiresAt, p.routingStrategy, p.proactiveRateLimitGuard, {
146
166
  provider: p.provider,
147
167
  model: null,
168
+ labels: p.labels,
169
+ pinnedRef: p.pinnedRef ?? p.useKeyRef,
148
170
  }, provRpm, provTpm, provConcurrency);
149
171
  if (pool) {
150
172
  for (const ref of pool.refs) {
@@ -171,6 +193,8 @@ export function buildPools({ cfg = {}, poolState = new Map(), defaultState = ini
171
193
  provider: p.provider,
172
194
  model,
173
195
  quotas: mp.quotas,
196
+ labels: mp.labels ?? p.labels,
197
+ pinnedRef: mp.pinnedRef ?? mp.useKeyRef ?? p.pinnedRef ?? p.useKeyRef,
174
198
  }, modelRpm, modelTpm, modelConcurrency);
175
199
  if (!mpool) continue;
176
200
  mpool.basePool = pool;
package/lib/pool.js CHANGED
@@ -42,12 +42,19 @@ export function isTrustedBridgeRequest(req) {
42
42
  if (!(isLoopbackAddress(hostName) || hostName === 'localhost')) return false;
43
43
 
44
44
  const origin = req?.headers?.origin;
45
- if (!origin) {
46
- const method = String(req?.method ?? 'GET').toUpperCase();
47
- return method === 'GET' || method === 'HEAD' || method === 'OPTIONS';
45
+ if (origin === undefined) {
46
+ // Official Desktop build forwards requests via forwardWebRequest which strips Origin (#465 / GitHub #22).
47
+ // Real browsers unconditionally send Origin on cross-origin mutations, so an absent Origin cannot originate
48
+ // from a malicious website. Peer address and Host header are already verified loopback above.
49
+ return true;
48
50
  }
51
+ if (!origin) return false; // Explicit empty string or null-like falsy
49
52
  try {
50
53
  const originUrl = new URL(origin);
54
+ // Official Desktop build shell origin
55
+ if (originUrl.protocol === 'dsh-app:' && (originUrl.host === 'app' || originUrl.hostname === 'app')) {
56
+ return true;
57
+ }
51
58
  if (originUrl.host !== hostHeader) return false;
52
59
  const hostname = originUrl.hostname.replace(/^\[|\]$/g, '');
53
60
  return ['http:', 'https:'].includes(originUrl.protocol) && (isLoopbackAddress(hostname) || hostname === 'localhost');
package/lib/resolver.js CHANGED
@@ -125,7 +125,16 @@ export function createResolver({
125
125
  // original credential must never be used as a bypass. Decided once, before
126
126
  // the loop, so it cannot be re-evaluated mid-iteration.
127
127
  const quotaManaged = hasModelQuotaConfig(pool) && isQuotaManagedRequest(runtime.index, ref, requested);
128
- const start = strategy === 'round-robin' ? (pool.state.pointer ?? 0) : 0;
128
+ let start = strategy === 'round-robin' ? (pool.state.pointer ?? 0) : 0;
129
+ if (pool.pinnedRef && list.includes(pool.pinnedRef)) {
130
+ const pIdx = list.indexOf(pool.pinnedRef);
131
+ if (pIdx > 0) {
132
+ list = list.slice();
133
+ list.splice(pIdx, 1);
134
+ list.unshift(pool.pinnedRef);
135
+ }
136
+ start = 0;
137
+ }
129
138
 
130
139
  for (let i = 0; i < list.length; i++) {
131
140
  const index = (start + i) % list.length;
package/lib/rotate.js CHANGED
@@ -89,6 +89,13 @@ export function createRotate(deps) {
89
89
  const quotaManaged = hasModelQuotaConfig(pool);
90
90
  let attemptList = (pool.weightedRefs ?? pool.refs).slice();
91
91
  attemptList = attemptList.filter((r) => !isKeyPaused(pool, r) && !isKeyRevoked(pool, r) && !isKeyBroken(pool, r, wallNow));
92
+ if (pool.pinnedRef && attemptList.includes(pool.pinnedRef)) {
93
+ const pIdx = attemptList.indexOf(pool.pinnedRef);
94
+ if (pIdx > 0) {
95
+ attemptList.splice(pIdx, 1);
96
+ attemptList.unshift(pool.pinnedRef);
97
+ }
98
+ }
92
99
  if (quotaManaged) {
93
100
  attemptList = attemptList.filter((ref) => isModelQuotaAvailable(pool, ref, wallNow));
94
101
  if (attemptList.length === 0) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-key-rotation",
3
- "version": "0.8.51",
3
+ "version": "0.8.53",
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": [
@@ -57,7 +57,7 @@
57
57
  },
58
58
  "peerDependencies": {
59
59
  "@deepseek-ai/cordis": "^4.0.1",
60
- "@deepseek-ai/dsh-llm": "^0.1.7-rc.2 || ^0.2.0-rc.1",
60
+ "@deepseek-ai/dsh-llm": "^0.1.7-rc.2 || >=0.2.0-rc.1 <0.2.1-0",
61
61
  "@deepseek-ai/schemastery": "^3.18.4"
62
62
  },
63
63
  "scripts": {