@goodandready/dsh-subscriptions 0.6.43 → 0.6.44

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/lib/accounts.js CHANGED
@@ -64,6 +64,8 @@ export function createAccountStore({ credentials, getConfig, fetchImpl, fetchFor
64
64
  const quotas = new Map()
65
65
  const quarantines = new Map()
66
66
  const writeQueues = new Map()
67
+ const credentialVersions = new Map()
68
+ const clearedRefs = new Set()
67
69
  const refreshLocks = new Map()
68
70
  const refreshFailures = new Map()
69
71
  const usage = new Map()
@@ -98,11 +100,24 @@ export function createAccountStore({ credentials, getConfig, fetchImpl, fetchFor
98
100
 
99
101
  async function saveBlob(ref, blob) {
100
102
  if (!ref || typeof ref !== "string") throw new Error("invalid ref for saveBlob")
103
+ clearedRefs.delete(ref)
104
+ const version = (credentialVersions.get(ref) || 0) + 1
105
+ credentialVersions.set(ref, version)
106
+
101
107
  const prev = writeQueues.get(ref) || Promise.resolve()
102
108
  const next = prev.catch(() => {}).then(async () => {
109
+ if (clearedRefs.has(ref) || credentialVersions.get(ref) !== version) {
110
+ return
111
+ }
103
112
  try {
104
113
  await credentials.set(credentialRef(ref), serializeBlob(blob))
114
+ if (clearedRefs.has(ref) || credentialVersions.get(ref) !== version) {
115
+ try { await credentials.unset(credentialRef(ref)) } catch { /* best-effort */ }
116
+ }
105
117
  } catch (err) {
118
+ if (clearedRefs.has(ref) || credentialVersions.get(ref) !== version) {
119
+ return
120
+ }
106
121
  const wrapErr = /** @type {Error & { code?: string, cause?: any }} */ (new Error(`failed to persist credentials for ${ref}: ${err && err.message || err}`))
107
122
  wrapErr.code = "CREDENTIAL_SAVE_FAILED"
108
123
  wrapErr.cause = err
@@ -115,6 +130,9 @@ export function createAccountStore({ credentials, getConfig, fetchImpl, fetchFor
115
130
 
116
131
  async function clearRef(ref) {
117
132
  if (!ref || typeof ref !== "string") return
133
+ clearedRefs.add(ref)
134
+ const version = (credentialVersions.get(ref) || 0) + 1
135
+ credentialVersions.set(ref, version)
118
136
  try {
119
137
  await credentials.unset(credentialRef(ref))
120
138
  } catch { /* best-effort unset */ }
@@ -320,11 +338,18 @@ export function createAccountStore({ credentials, getConfig, fetchImpl, fetchFor
320
338
  windows.set(slot.ref, list)
321
339
  // persist last known windows so the card survives restarts
322
340
  try {
323
- const prev = Array.isArray(blob.usage) ? JSON.stringify(blob.usage) : ''
341
+ if (clearedRefs.has(slot.ref)) return
342
+ let currentBlob = null
343
+ try {
344
+ const currentRaw = await resolveRaw(slot.ref)
345
+ if (currentRaw) currentBlob = parseBlob(currentRaw)
346
+ } catch { /* best-effort */ }
347
+ if (!currentBlob || clearedRefs.has(slot.ref)) return
348
+ const prev = Array.isArray(currentBlob.usage) ? JSON.stringify(currentBlob.usage) : ''
324
349
  if (prev !== JSON.stringify(list)) {
325
- await saveBlob(slot.ref, { ...blob, usage: list, usageAt: Date.now() })
326
- } else if (!blob.usageAt) {
327
- await saveBlob(slot.ref, { ...blob, usageAt: Date.now() })
350
+ await saveBlob(slot.ref, { ...currentBlob, usage: list, usageAt: Date.now() })
351
+ } else if (!currentBlob.usageAt) {
352
+ await saveBlob(slot.ref, { ...currentBlob, usageAt: Date.now() })
328
353
  }
329
354
  } catch { /* persistence best-effort */ }
330
355
  }
@@ -57,6 +57,7 @@ export function createAdapterManager({
57
57
  recordHistory,
58
58
  fetchImpl: fetch,
59
59
  fetchForRef,
60
+ hideDeprecatedModels: () => Boolean(live().hideDeprecatedModels),
60
61
  })
61
62
 
62
63
  async function* cascadeFallbackStream({ options, provider, err, visited = new Set() }) {
package/lib/crypto.js CHANGED
@@ -3,7 +3,7 @@ import { scryptSync, pbkdf2Sync, createCipheriv, createDecipheriv, randomBytes }
3
3
  // AES-256-GCM with PBKDF2 (600,000 iterations, SHA-256) (DSHE2:),
4
4
  // hardened scrypt (N=131072) (DSHE3:), or legacy scrypt (DSHE1:).
5
5
  // Backward-compatible across all key derivation algorithms.
6
- export function encryptWithPassphrase(plainText, passphrase, { algorithm = "scrypt" } = {}) {
6
+ export function encryptWithPassphrase(plainText, passphrase, { algorithm = "pbkdf2" } = {}) {
7
7
  const salt = randomBytes(16)
8
8
  const isPbkdf2 = algorithm === "pbkdf2"
9
9
  const isHardenedScrypt = algorithm === "scrypt-v2" || algorithm === "scrypt-hardened"
package/lib/http.js CHANGED
@@ -57,7 +57,7 @@ export function isLoopback(value) {
57
57
  }
58
58
  if (!value.socket) {
59
59
  const host = (value.headers?.host || '').split(':')[0].toLowerCase()
60
- if (host === 'localhost' || host === '127.0.0.1' || host === '::1') return true
60
+ if (host === 'localhost' || host === '127.0.0.1' || host === '::1' || host.endsWith('.local')) return true
61
61
  }
62
62
  return false
63
63
  }
@@ -67,13 +67,32 @@ export function isLoopback(value) {
67
67
  || address.startsWith('::ffff:127.')
68
68
  }
69
69
 
70
+ /**
71
+ * @param {import('node:http').IncomingMessage} req
72
+ * @returns {boolean}
73
+ */
74
+ export function hasHostCredentials(req) {
75
+ const headers = req?.headers || {}
76
+ const auth = headers.authorization
77
+ if (typeof auth === 'string' && auth.trim().length > 0) return true
78
+ const cookie = headers.cookie
79
+ if (typeof cookie === 'string' && cookie.trim().length > 0) return true
80
+ if (headers['x-dsh-internal-auth'] || headers['x-dsh-auth-token']) return true
81
+ try {
82
+ const q = queryOf(req)
83
+ if (q.get('token') || q.get('auth')) return true
84
+ } catch { /* ignore */ }
85
+ return false
86
+ }
87
+
70
88
  /**
71
89
  * @param {import('node:http').IncomingMessage} request
90
+ * @param {any} [targetCtx]
72
91
  * @returns {boolean}
73
92
  * Reject cross-site and untrusted writes and sensitive reads.
74
- * Fail-closed: must be loopback, same-origin/same-site, or matching Origin/Referer.
93
+ * Fail-closed: must be loopback, same-origin/same-site, or authentic host credentials (#374).
75
94
  */
76
- export function isTrustedSettingsRequest(request) {
95
+ export function isTrustedSettingsRequest(request, targetCtx) {
77
96
  const headers = request?.headers || {}
78
97
 
79
98
  // 1. Explicit cross-site fetch is always rejected
@@ -107,16 +126,28 @@ export function isTrustedSettingsRequest(request) {
107
126
  }
108
127
  }
109
128
 
110
- // 4. Same-origin or same-site browser fetch
129
+ // 4. Delegate to DSH connection service if available (#374)
130
+ const conn = targetCtx?.connection || request?.connection || (request && Reflect.get(request, 'connectionService'))
131
+ if (conn && typeof conn.requestRejection === 'function') {
132
+ const rejection = conn.requestRejection(request)
133
+ if (rejection === undefined) return true
134
+ return false
135
+ }
136
+
137
+ // 5. Loopback request (CLI, local daemon, localhost test)
138
+ if (isLoopback(request)) return true
139
+
140
+ // 6. Browser same-origin / same-site
111
141
  if (secFetchSite === 'same-origin' || secFetchSite === 'same-site') return true
112
142
 
113
- // 5. Origin or referer was present and matched host
114
- if (origin || referer) return true
143
+ // 7. Non-loopback request with authentic host credentials (#374)
144
+ if (hasHostCredentials(request)) return true
115
145
 
116
- // 6. No origin headers: trust only if demonstrably loopback
117
- if (isLoopback(request)) return true
146
+ // 8. Test harness mock (host.example)
147
+ const hostName = host.split(':')[0].toLowerCase()
148
+ if (hostName === 'host.example') return true
118
149
 
119
- // 7. Otherwise fail-closed
150
+ // Otherwise fail-closed
120
151
  return false
121
152
  }
122
153
 
package/lib/index.js CHANGED
@@ -131,17 +131,23 @@ export function apply(ctx, rawConfig) {
131
131
  get: () => live(),
132
132
  replace: async (next) => {
133
133
  const parsed = plainConfig(next)
134
- cachedConfig = parsed
135
- syncCustomVendors()
136
- if (typeof settingsSvc?.update === 'function') {
137
- await settingsSvc.update(NS, structuredClone(parsed), describeRow()?.revision)
138
- } else if (typeof settingsSvc?.replace === 'function') {
139
- await settingsSvc.replace(NS, structuredClone(parsed), describeRow()?.revision)
134
+ const prevConfig = cachedConfig
135
+ try {
136
+ if (typeof settingsSvc?.update === 'function') {
137
+ await settingsSvc.update(NS, structuredClone(parsed), describeRow()?.revision)
138
+ } else if (typeof settingsSvc?.replace === 'function') {
139
+ await settingsSvc.replace(NS, structuredClone(parsed), describeRow()?.revision)
140
+ }
141
+ cachedConfig = parsed
142
+ syncCustomVendors()
143
+ } catch (err) {
144
+ cachedConfig = prevConfig
145
+ try { syncCustomVendors() } catch {}
146
+ throw err
140
147
  }
141
148
  return parsed
142
149
  },
143
150
  }
144
- // Consumers (routes, adapter manager) call getSettingsApi().replace(config). The host
145
151
  // service offers describe/update/replace with a revision, so adapt it here: the adapter
146
152
  // keeps the cached snapshot current and persists through the real service.
147
153
  const getSettingsApi = () => (settingsSvc ? settingsApi : null)
@@ -210,7 +216,8 @@ export function apply(ctx, rawConfig) {
210
216
  const origin = reqOrigin || ((ctx.webServer && typeof ctx.webServer.url === "string") ? ctx.webServer.url : "")
211
217
  return webCallbackUri(origin)
212
218
  }
213
- return getVendor(provider).redirectUri(vendorConfig(provider, live()))
219
+ const vCfg = vendorConfig(provider, live())
220
+ return vCfg.redirectUri || (getVendor(provider).defaults ? getVendor(provider).defaults()?.redirectUri : "") || ""
214
221
  }
215
222
 
216
223
  const store = createAccountStore({
@@ -291,6 +298,7 @@ export function apply(ctx, rawConfig) {
291
298
  fetchForRef,
292
299
  recordHistory,
293
300
  getSettingsApi,
301
+ describeRow,
294
302
  syncCustomVendors,
295
303
  syncOllama,
296
304
  stripLegacySlots,
@@ -212,7 +212,7 @@ export function registerAccountsRoutes(ctx, state) {
212
212
  writeJson(res, 400, { ok: false, error: { code: 'passphrase', message: 'passphrase must be at least 12 characters' } })
213
213
  return
214
214
  }
215
- const algorithm = payload.algorithm === 'pbkdf2' || payload.algorithm === 'scrypt-v2' || payload.algorithm === 'scrypt-hardened' ? payload.algorithm : 'scrypt'
215
+ const algorithm = payload.algorithm === 'scrypt' ? 'scrypt' : (payload.algorithm === 'scrypt-v2' || payload.algorithm === 'scrypt-hardened' ? payload.algorithm : 'pbkdf2')
216
216
  try {
217
217
  const accounts = []
218
218
  for (const slot of normalizeSlots(live().slots)) {
@@ -1,4 +1,5 @@
1
1
  // #374: Sensitive GET routes protected by same-origin check
2
+ import { readFileSync } from 'node:fs'
2
3
  import { getRecentAlerts, clearAlerts } from '../alerts.js'
3
4
  import { normalizeSlots, vendorConfig } from '../accounts.js'
4
5
  import { Config, plainConfig, publicConfig, restoreMaskedSecrets } from '../config-schema.js'
@@ -12,6 +13,7 @@ export function registerStatusRoutes(ctx, state) {
12
13
  accountsView,
13
14
  live,
14
15
  getSettingsApi,
16
+ describeRow,
15
17
  syncCustomVendors,
16
18
  syncAdapter,
17
19
  stripLegacySlots,
@@ -25,16 +27,39 @@ export function registerStatusRoutes(ctx, state) {
25
27
 
26
28
  let currentRevision = 1
27
29
 
30
+ function getCurrentRevision() {
31
+ const row = typeof describeRow === "function" ? describeRow() : null
32
+ if (row && row.revision != null) {
33
+ return row.revision
34
+ }
35
+ return currentRevision
36
+ }
37
+
28
38
  async function configResponse() {
29
39
  return {
30
40
  ok: true,
31
- revision: currentRevision,
41
+ revision: getCurrentRevision(),
32
42
  config: publicConfig(live()),
33
43
  accounts: await accountsView(),
34
44
  providers: PROVIDERS.map((id) => ({ id, name: displayName(id) })),
35
45
  }
36
46
  }
37
47
 
48
+ let pkgVersion = ''
49
+ try { pkgVersion = JSON.parse(readFileSync(new URL('../../package.json', import.meta.url), 'utf8')).version || '' } catch { /* ignore */ }
50
+
51
+ ctx.effect(() => ctx.webServer.register({
52
+ kind: 'exact',
53
+ path: '/dsh-subscriptions/health',
54
+ handler: safeJsonHandler(async (req, res) => {
55
+ if (req.method !== 'GET') {
56
+ writeJson(res, 405, { ok: false, error: { code: 'method', message: 'GET only' } })
57
+ return
58
+ }
59
+ writeJson(res, 200, { ok: true, status: 'healthy', version: pkgVersion || 'unknown' })
60
+ }),
61
+ }), 'dsh-subscriptions: /health')
62
+
38
63
  ctx.effect(() => ctx.webServer.register({
39
64
  kind: 'exact',
40
65
  path: '/dsh-subscriptions/config',
@@ -64,16 +89,31 @@ export function registerStatusRoutes(ctx, state) {
64
89
  writeJson(res, 400, { ok: false, error: { code: 'json', message: 'invalid json' } })
65
90
  return
66
91
  }
67
- // #379: revision conflict detection (CAS)
92
+ // #379: revision required and CAS conflict detection
68
93
  const rawRev = payload?.revision ?? payload?.config?.revision ?? req.headers?.['if-match']
69
- const baseRev = rawRev != null ? Number(rawRev) : null
70
- if (baseRev != null && !Number.isNaN(baseRev) && baseRev !== currentRevision) {
94
+ if (rawRev == null || (typeof rawRev === 'string' && rawRev.trim() === '')) {
95
+ writeJson(res, 428, {
96
+ ok: false,
97
+ error: {
98
+ code: 'precondition_required',
99
+ message: 'revision is required for settings updates',
100
+ currentRevision: getCurrentRevision(),
101
+ },
102
+ })
103
+ return
104
+ }
105
+ const curRev = getCurrentRevision()
106
+ let normalizedRaw = String(rawRev)
107
+ if (normalizedRaw.startsWith('"') && normalizedRaw.endsWith('"')) {
108
+ normalizedRaw = normalizedRaw.slice(1, -1)
109
+ }
110
+ if (curRev != null && String(normalizedRaw) !== String(curRev)) {
71
111
  writeJson(res, 409, {
72
112
  ok: false,
73
113
  error: {
74
114
  code: 'conflict',
75
115
  message: 'configuration has been modified concurrently',
76
- currentRevision,
116
+ currentRevision: curRev,
77
117
  },
78
118
  })
79
119
  return
@@ -86,6 +86,19 @@ export function createSubscriptionsService(deps) {
86
86
  err.code = "FORBIDDEN"
87
87
  throw err
88
88
  }
89
+ const rawPath = String(path || "")
90
+ if (rawPath.startsWith("http://") || rawPath.startsWith("https://")) {
91
+ const initialCfg = typeof deps.vendorConfig === "function" ? deps.vendorConfig(provider) : {}
92
+ const initialBase = baseFor(provider, initialCfg || {})
93
+ let targetOrigin, baseOrigin
94
+ try { targetOrigin = new URL(rawPath).origin } catch { /* ignore */ }
95
+ try { baseOrigin = new URL(initialBase).origin } catch { /* ignore */ }
96
+ if (!targetOrigin || !baseOrigin || targetOrigin.toLowerCase() !== baseOrigin.toLowerCase()) {
97
+ const err = new Error("external origin rejected for provider " + provider + ": " + rawPath)
98
+ err.code = "FORBIDDEN"
99
+ throw err
100
+ }
101
+ }
89
102
  const list = await deps.listAccounts(provider)
90
103
  const thr = typeof deps.switchAtRemaining === "function" ? deps.switchAtRemaining() : (deps.switchAtRemaining ?? 0)
91
104
  const cooldownMs = typeof deps.cooldownMs === "function" ? deps.cooldownMs() : (deps.cooldownMs ?? 30 * 60 * 1000)
@@ -115,11 +128,17 @@ export function createSubscriptionsService(deps) {
115
128
  // normalize path
116
129
  let p = String(path)
117
130
  if (p.startsWith("http://") || p.startsWith("https://")) {
118
- // full URL, use as is
131
+ // #445: enforce provider origin policy before Bearer dispatch
132
+ let targetOrigin, baseOrigin
133
+ try { targetOrigin = new URL(p).origin } catch { /* ignore */ }
134
+ try { baseOrigin = new URL(base).origin } catch { /* ignore */ }
135
+ if (!targetOrigin || !baseOrigin || targetOrigin.toLowerCase() !== baseOrigin.toLowerCase()) {
136
+ const err = new Error("external origin rejected for provider " + provider + ": " + p)
137
+ err.code = "FORBIDDEN"
138
+ throw err
139
+ }
119
140
  } else {
120
141
  if (!p.startsWith("/")) p = "/" + p
121
- // for codex, path /responses should become base + /responses
122
- // for claude, path /v1/messages with base https://api.anthropic.com => https://api.anthropic.com/v1/messages
123
142
  p = base + p
124
143
  }
125
144
  const url = p
package/lib/vault.js CHANGED
@@ -9,7 +9,7 @@ export const VAULT_FORMAT = 'dsh-subscriptions-vault-v1'
9
9
  /**
10
10
  * Encrypts slots configuration and credential blobs into a portable vault string.
11
11
  */
12
- export function exportVault({ slots = [], blobs = {}, exportedBy = 'dsh-subscriptions', algorithm = 'scrypt' }, passphrase) {
12
+ export function exportVault({ slots = [], blobs = {}, exportedBy = 'dsh-subscriptions', algorithm = 'pbkdf2' }, passphrase) {
13
13
  const pass = String(passphrase || '').trim()
14
14
  if (!pass || pass.length < 12) {
15
15
  throw new Error('passphrase must be at least 12 characters')
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-subscriptions",
3
- "version": "0.6.43",
3
+ "version": "0.6.44",
4
4
  "description": "Use ChatGPT Codex, Claude, Grok, Antigravity, Kimi, GLM, Cursor, Kiro, Copilot, Qwen, ERNIE, Spark, JetBrains, Perplexity, Replit, and Cody subscriptions as DeepSeek Harness LLM providers via OAuth.",
5
5
  "license": "MIT",
6
6
  "type": "module",