@goodandready/dsh-subscriptions 0.6.43 → 0.6.45

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
@@ -1,3 +1,46 @@
1
+ import { readFileSync, writeFileSync, mkdirSync } from "node:fs"
2
+ import { homedir } from "node:os"
3
+ import { join } from "node:path"
4
+
5
+ function resolveQuarantineFilePath() {
6
+ const base = process.env.DSH_HOME || join(homedir(), '.dsh')
7
+ return join(base, 'subscriptions', 'quarantines.json')
8
+ }
9
+
10
+ function loadQuarantinesFromFile(map) {
11
+ try {
12
+ const p = resolveQuarantineFilePath()
13
+ const content = readFileSync(p, 'utf8')
14
+ const json = JSON.parse(content)
15
+ const now = Date.now()
16
+ if (json && typeof json === 'object') {
17
+ for (const [k, v] of Object.entries(json)) {
18
+ if (v && Number(v.until) > now) {
19
+ map.set(k, v)
20
+ }
21
+ }
22
+ }
23
+ } catch { /* best-effort */ }
24
+ }
25
+
26
+ function saveQuarantinesToFile(map) {
27
+ try {
28
+ const p = resolveQuarantineFilePath()
29
+ mkdirSync(join(p, '..'), { recursive: true })
30
+ const obj = {}
31
+ const now = Date.now()
32
+ for (const [k, v] of map.entries()) {
33
+ if (v && Number(v.until) > now) {
34
+ obj[k] = v
35
+ }
36
+ }
37
+ writeFileSync(p, JSON.stringify(obj), 'utf8')
38
+ } catch { /* best-effort */ }
39
+ }
40
+
41
+ const sharedQuarantines = new Map()
42
+ loadQuarantinesFromFile(sharedQuarantines)
43
+
1
44
  import { needsWarmupProbe, enterProbing, resolveWarmupSuccess, resolveWarmupFailure, STATUS_QUARANTINE, STATUS_PROBING } from "./quarantine.js"
2
45
  import { fetchWithTimeout, isTimeoutError } from "./http.js"
3
46
  import { credentialRef } from '@deepseek-ai/dsh-credentials'
@@ -62,8 +105,10 @@ export function vendorConfig(provider, cfg) {
62
105
  export function createAccountStore({ credentials, getConfig, fetchImpl, fetchForRef, onLimitNotice }) {
63
106
  const cooldowns = new Map()
64
107
  const quotas = new Map()
65
- const quarantines = new Map()
108
+ const quarantines = sharedQuarantines
66
109
  const writeQueues = new Map()
110
+ const credentialVersions = new Map()
111
+ const clearedRefs = new Set()
67
112
  const refreshLocks = new Map()
68
113
  const refreshFailures = new Map()
69
114
  const usage = new Map()
@@ -98,11 +143,24 @@ export function createAccountStore({ credentials, getConfig, fetchImpl, fetchFor
98
143
 
99
144
  async function saveBlob(ref, blob) {
100
145
  if (!ref || typeof ref !== "string") throw new Error("invalid ref for saveBlob")
146
+ clearedRefs.delete(ref)
147
+ const version = (credentialVersions.get(ref) || 0) + 1
148
+ credentialVersions.set(ref, version)
149
+
101
150
  const prev = writeQueues.get(ref) || Promise.resolve()
102
151
  const next = prev.catch(() => {}).then(async () => {
152
+ if (clearedRefs.has(ref) || credentialVersions.get(ref) !== version) {
153
+ return
154
+ }
103
155
  try {
104
156
  await credentials.set(credentialRef(ref), serializeBlob(blob))
157
+ if (clearedRefs.has(ref) || credentialVersions.get(ref) !== version) {
158
+ try { await credentials.unset(credentialRef(ref)) } catch { /* best-effort */ }
159
+ }
105
160
  } catch (err) {
161
+ if (clearedRefs.has(ref) || credentialVersions.get(ref) !== version) {
162
+ return
163
+ }
106
164
  const wrapErr = /** @type {Error & { code?: string, cause?: any }} */ (new Error(`failed to persist credentials for ${ref}: ${err && err.message || err}`))
107
165
  wrapErr.code = "CREDENTIAL_SAVE_FAILED"
108
166
  wrapErr.cause = err
@@ -115,6 +173,9 @@ export function createAccountStore({ credentials, getConfig, fetchImpl, fetchFor
115
173
 
116
174
  async function clearRef(ref) {
117
175
  if (!ref || typeof ref !== "string") return
176
+ clearedRefs.add(ref)
177
+ const version = (credentialVersions.get(ref) || 0) + 1
178
+ credentialVersions.set(ref, version)
118
179
  try {
119
180
  await credentials.unset(credentialRef(ref))
120
181
  } catch { /* best-effort unset */ }
@@ -165,6 +226,7 @@ export function createAccountStore({ credentials, getConfig, fetchImpl, fetchFor
165
226
  quarantineReason: quarantines.get(ref) ? quarantines.get(ref).reason : null,
166
227
  cooldownFamilies: cooldowns.get(ref) && cooldowns.get(ref).families ? cooldowns.get(ref).families : null,
167
228
  quota: quotas.get(ref) || null,
229
+ pacePerHour: quotas.get(ref)?.pacePerHour || 0,
168
230
  usage: windows.get(ref) || base.usage || null,
169
231
  health: health.get(ref) || null,
170
232
  healthScore: computeHealthScore(health.get(ref) || null),
@@ -200,6 +262,7 @@ export function createAccountStore({ credentials, getConfig, fetchImpl, fetchFor
200
262
  quarantineUntil: inQ ? q.until : 0,
201
263
  quarantineReason: inQ ? q.reason : null,
202
264
  label: slot.label || info.label,
265
+ pacePerHour: info.pacePerHour || 0,
203
266
  })
204
267
  }
205
268
  return out
@@ -320,11 +383,18 @@ export function createAccountStore({ credentials, getConfig, fetchImpl, fetchFor
320
383
  windows.set(slot.ref, list)
321
384
  // persist last known windows so the card survives restarts
322
385
  try {
323
- const prev = Array.isArray(blob.usage) ? JSON.stringify(blob.usage) : ''
386
+ if (clearedRefs.has(slot.ref)) return
387
+ let currentBlob = null
388
+ try {
389
+ const currentRaw = await resolveRaw(slot.ref)
390
+ if (currentRaw) currentBlob = parseBlob(currentRaw)
391
+ } catch { /* best-effort */ }
392
+ if (!currentBlob || clearedRefs.has(slot.ref)) return
393
+ const prev = Array.isArray(currentBlob.usage) ? JSON.stringify(currentBlob.usage) : ''
324
394
  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() })
395
+ await saveBlob(slot.ref, { ...currentBlob, usage: list, usageAt: Date.now() })
396
+ } else if (!currentBlob.usageAt) {
397
+ await saveBlob(slot.ref, { ...currentBlob, usageAt: Date.now() })
328
398
  }
329
399
  } catch { /* persistence best-effort */ }
330
400
  }
@@ -392,6 +462,7 @@ export function createAccountStore({ credentials, getConfig, fetchImpl, fetchFor
392
462
  attempts: Number(attempts) || 1,
393
463
  status: STATUS_QUARANTINE,
394
464
  })
465
+ saveQuarantinesToFile(quarantines)
395
466
  },
396
467
  getQuarantine(ref, now = Date.now()) {
397
468
  const q = quarantines.get(ref)
@@ -431,6 +502,7 @@ export function createAccountStore({ credentials, getConfig, fetchImpl, fetchFor
431
502
  },
432
503
  releaseQuarantine(ref) {
433
504
  quarantines.delete(ref)
505
+ saveQuarantinesToFile(quarantines)
434
506
  },
435
507
  rememberRequest(ref) { requestCounts.set(ref, (requestCounts.get(ref) || 0) + 1) },
436
508
  getRequestCount(ref) { return requestCounts.get(ref) || 0 },
@@ -18,6 +18,7 @@ export function createAdapterManager({
18
18
  syncCustomVendors,
19
19
  syncOllama,
20
20
  stripLegacySlots,
21
+ ollamaFallback,
21
22
  }) {
22
23
  let handle
23
24
  let adapterDisposed = false
@@ -57,6 +58,9 @@ export function createAdapterManager({
57
58
  recordHistory,
58
59
  fetchImpl: fetch,
59
60
  fetchForRef,
61
+ hideDeprecatedModels: () => Boolean(live().hideDeprecatedModels),
62
+ cascadingFallback: (...args) => cascadeFallbackStream(...args),
63
+ ollamaFallback: typeof ollamaFallback === 'function' ? ollamaFallback : async function* ({ err }) { throw err },
60
64
  })
61
65
 
62
66
  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,9 +298,11 @@ 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,
305
+ ollamaFallback: ollamaFallbackStream,
297
306
  })
298
307
  const { syncAdapter, reconcileSlots, refreshModels, cascadeFallbackStream } = adapterManager
299
308
  adapterManager.registerAdapterLifecycle()
package/lib/rotate.js CHANGED
@@ -81,7 +81,11 @@ export function pickAccount(accounts, nowMs, opts) {
81
81
  const pinned = list.find((a) => (a.ref || a.id) === pinnedRef)
82
82
  if (pinned && pinned.hasToken && !isQuarantined(pinned, now)) {
83
83
  const isCooldown = pinned.cooldownUntil && Number(pinned.cooldownUntil) > now && cooldownBlocks(pinned, opts && opts.family)
84
- if (!isCooldown && !isQuotaExhausted(pinned) && !isUsageExhausted(pinned)) {
84
+ const isPinnedZero = Boolean(pinned.quota && (
85
+ (pinned.quota.remaining != null && Number(pinned.quota.remaining) <= 0) ||
86
+ (pinned.quota.remainingPercent != null && Number(pinned.quota.remainingPercent) <= 0)
87
+ ) && (!pinned.quota.resetAt || Number(pinned.quota.resetAt) > now))
88
+ if (!isCooldown && !isPinnedZero && !isQuotaExhausted(pinned) && !isUsageExhausted(pinned)) {
85
89
  return pinned
86
90
  }
87
91
  }
@@ -116,8 +120,12 @@ export function pickAccount(accounts, nowMs, opts) {
116
120
  const isCooldown = acc.cooldownUntil && Number(acc.cooldownUntil) > now && cooldownBlocks(acc, opts && opts.family)
117
121
  const inQuarantine = isQuarantined(acc, now)
118
122
  const isHardExhausted = (acc.usagePercent != null && Number(acc.usagePercent) >= 100 && (!acc.quota || !acc.quota.resetAt || Number(acc.quota.resetAt) > now))
123
+ const isZeroQuota = Boolean(acc.quota && (
124
+ (acc.quota.remaining != null && Number(acc.quota.remaining) <= 0) ||
125
+ (acc.quota.remainingPercent != null && Number(acc.quota.remainingPercent) <= 0)
126
+ ) && (!acc.quota.resetAt || Number(acc.quota.resetAt) > now))
119
127
 
120
- if (isCooldown || inQuarantine || isHardExhausted) {
128
+ if (isCooldown || inQuarantine || isHardExhausted || isZeroQuota) {
121
129
  continue
122
130
  }
123
131
 
@@ -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
@@ -27,6 +27,7 @@ export async function* streamWithRotation({
27
27
  sessionId: options && options.sessionId,
28
28
  tag: options && options.tag,
29
29
  vip: options && options.vip,
30
+ autoPacing: options && (options.autoPacing !== undefined ? options.autoPacing : (options.config && options.config.autoPacing)),
30
31
  })
31
32
 
32
33
  if (!account || tried.has(account.ref || account.id)) {
@@ -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.45",
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",