@goodandready/dsh-subscriptions 0.6.41 → 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
@@ -1,5 +1,5 @@
1
1
  import { needsWarmupProbe, enterProbing, resolveWarmupSuccess, resolveWarmupFailure, STATUS_QUARANTINE, STATUS_PROBING } from "./quarantine.js"
2
- import { fetchWithTimeout } from "./http.js"
2
+ import { fetchWithTimeout, isTimeoutError } from "./http.js"
3
3
  import { credentialRef } from '@deepseek-ai/dsh-credentials'
4
4
  import { oauthRef, isProvider } from './refs.js'
5
5
  import { parseBlob, serializeBlob } from './blob.js'
@@ -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()
@@ -75,7 +77,7 @@ export function createAccountStore({ credentials, getConfig, fetchImpl, fetchFor
75
77
  const doFetch = fetchImpl || fetch
76
78
  // #88: per-account proxy fetch for token refresh / usage (falls back to doFetch).
77
79
  const rawFetchFor = (ref) => (typeof fetchForRef === 'function' && fetchForRef(ref)) || doFetch
78
- const fetchFor = (ref) => (url, init, opts) => fetchWithTimeout(rawFetchFor(ref), url, init, { timeoutMs: 25000, ...(opts || {}) })
80
+ const fetchFor = (ref, defaultTimeoutMs = 25000) => (url, init, opts) => fetchWithTimeout(rawFetchFor(ref), url, init, { timeoutMs: defaultTimeoutMs, ...(opts || {}) })
79
81
 
80
82
  async function resolveRaw(ref) {
81
83
  try {
@@ -89,7 +91,7 @@ export function createAccountStore({ credentials, getConfig, fetchImpl, fetchFor
89
91
  async function loadBlob(ref) {
90
92
  const raw = await resolveRaw(ref)
91
93
  if (!raw) {
92
- const err = new Error(`no token for ${ref}`)
94
+ const err = /** @type {Error & { code?: string }} */ (new Error(`no token for ${ref}`))
93
95
  err.code = 'AUTH'
94
96
  throw err
95
97
  }
@@ -98,12 +100,25 @@ 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) {
106
- const wrapErr = new Error(`failed to persist credentials for ${ref}: ${err && err.message || err}`)
118
+ if (clearedRefs.has(ref) || credentialVersions.get(ref) !== version) {
119
+ return
120
+ }
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
109
124
  throw wrapErr
@@ -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 */ }
@@ -171,6 +189,7 @@ export function createAccountStore({ credentials, getConfig, fetchImpl, fetchFor
171
189
  healthBadge: healthBadge(computeHealthScore(health.get(ref) || null)),
172
190
  usagePercent: usage.has(ref) ? usage.get(ref) : null,
173
191
  refreshError: refreshFailures.get(ref)?.error || '',
192
+ isTimeout: !!refreshFailures.get(ref)?.isTimeout,
174
193
  validationUrl: base.validationUrl || '',
175
194
  validationMessage: base.validationMessage || '',
176
195
  accountNotice: base.accountNotice || '',
@@ -227,7 +246,7 @@ export function createAccountStore({ credentials, getConfig, fetchImpl, fetchFor
227
246
  const curConfig = getConfig()
228
247
  const cfg = vendorConfig(provider, curConfig)
229
248
  const next = await executeWithRetry(
230
- () => getVendor(provider).refresh(cfg, blob, fetchFor(ref)),
249
+ () => getVendor(provider).refresh(cfg, blob, fetchFor(ref, 30000)),
231
250
  {
232
251
  maxRetries: 2,
233
252
  initialDelayMs: 300,
@@ -250,7 +269,11 @@ export function createAccountStore({ credentials, getConfig, fetchImpl, fetchFor
250
269
  if (ref) refreshFailures.delete(ref)
251
270
  return merged
252
271
  } catch (e) {
253
- if (ref) refreshFailures.set(ref, { at: Date.now(), error: String(e && e.message || e) })
272
+ const isTimeout = isTimeoutError(e)
273
+ const errMsg = isTimeout
274
+ ? `Network timeout refreshing token: ${e.message || 'timed out'}`
275
+ : String(e && e.message || e)
276
+ if (ref) refreshFailures.set(ref, { at: Date.now(), error: errMsg, isTimeout })
254
277
  throw e
255
278
  } finally {
256
279
  refreshLocks.delete(lockKey)
@@ -287,7 +310,8 @@ export function createAccountStore({ credentials, getConfig, fetchImpl, fetchFor
287
310
  try {
288
311
  usageFetched.set(slot.ref, Date.now())
289
312
  const blob = await ensureFresh(provider, parseBlob(raw), slot.ref)
290
- const snap = await vendor.usage(blob, cfg, fetchFor(slot.ref))
313
+ const usageTimeout = Number(curConfig?.usageTimeoutMs) || 15000
314
+ const snap = await vendor.usage(blob, cfg, fetchFor(slot.ref, usageTimeout))
291
315
  if (!snap) return
292
316
  if (Number.isFinite(Number(snap.usedPercent))) {
293
317
  usage.set(slot.ref, Number(snap.usedPercent))
@@ -314,16 +338,27 @@ export function createAccountStore({ credentials, getConfig, fetchImpl, fetchFor
314
338
  windows.set(slot.ref, list)
315
339
  // persist last known windows so the card survives restarts
316
340
  try {
317
- 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) : ''
318
349
  if (prev !== JSON.stringify(list)) {
319
- await saveBlob(slot.ref, { ...blob, usage: list, usageAt: Date.now() })
320
- } else if (!blob.usageAt) {
321
- 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() })
322
353
  }
323
354
  } catch { /* persistence best-effort */ }
324
355
  }
325
- } catch {
356
+ } catch (err) {
326
357
  usageFetched.set(slot.ref, Date.now())
358
+ const isTimeout = isTimeoutError(err)
359
+ if (isTimeout && !refreshFailures.has(slot.ref)) {
360
+ refreshFailures.set(slot.ref, { at: Date.now(), error: `Network timeout fetching usage: ${err.message || 'timed out'}`, isTimeout: true })
361
+ }
327
362
  } finally {
328
363
  slotUsageLocks.delete(slot.ref)
329
364
  }
@@ -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/adapter.js CHANGED
@@ -53,14 +53,16 @@ export class SubscriptionAdapter extends LlmAdapter {
53
53
  let models = null
54
54
  if (targetAccount) {
55
55
  const cfg = this.deps.vendorConfig(provider)
56
- const fetchImpl = pickFetch(this.deps, targetAccount.ref)
56
+ const baseFetch = pickFetch(this.deps, targetAccount.ref)
57
+ const listModelsFetch = (url, init, opts) =>
58
+ fetchWithTimeout(baseFetch, url, init, { timeoutMs: 15000, ...(opts || {}) })
57
59
  try {
58
60
  const blob = await this.deps.ensureFresh(
59
61
  provider,
60
62
  await this.deps.loadBlob(targetAccount.ref),
61
63
  targetAccount.ref,
62
64
  )
63
- const live = await getVendor(provider).listModels(blob, cfg, fetchImpl)
65
+ const live = await getVendor(provider).listModels(blob, cfg, listModelsFetch)
64
66
  if (Array.isArray(live) && live.length) models = live
65
67
  } catch { /* use built-in catalog */ }
66
68
  }
@@ -200,7 +202,7 @@ export class SubscriptionAdapter extends LlmAdapter {
200
202
  // ponytail: capture x-ratelimit headers without touching vendors
201
203
  const baseFetch = pickFetch(deps, account.ref)
202
204
  const quotaFetch = async (url, init) => {
203
- const res = await fetchWithTimeout(baseFetch, url, init, { timeoutMs: 120000 })
205
+ const res = await fetchWithTimeout(baseFetch, url, init, { timeoutMs: 60000, stream: true })
204
206
  try {
205
207
  const snap = quotaSnapshot(provider, res.headers, null, Date.now())
206
208
  if (snap && typeof deps.rememberQuota === "function") {
@@ -221,7 +223,9 @@ export class SubscriptionAdapter extends LlmAdapter {
221
223
  } catch { /* ignore quota capture failure */ }
222
224
  if (!res.ok && res.clone) {
223
225
  try {
224
- const txt = await res.clone().text()
226
+ const textPromise = res.clone().text()
227
+ const timeoutPromise = new Promise((_, reject) => setTimeout(() => reject(new Error("timeout")), 5000))
228
+ const txt = await Promise.race([textPromise, timeoutPromise])
225
229
  let j=null; try { j = JSON.parse(txt) } catch { j = null }
226
230
  if (j) {
227
231
  const b = parseBody(provider, j, Date.now())
package/lib/backoff.js CHANGED
@@ -1,9 +1,21 @@
1
+ /**
2
+ * @template T
3
+ * @param {() => Promise<T>} fn
4
+ * @param {{
5
+ * maxRetries?: number,
6
+ * initialDelayMs?: number,
7
+ * maxDelayMs?: number,
8
+ * factor?: number,
9
+ * isRetryable?: (err: any) => boolean,
10
+ * }} [options]
11
+ * @returns {Promise<T>}
12
+ */
1
13
  export async function executeWithRetry(fn, {
2
14
  maxRetries = 3,
3
15
  initialDelayMs = 200,
4
16
  maxDelayMs = 3000,
5
17
  factor = 2,
6
- isRetryable = () => true
18
+ isRetryable = (_err) => true
7
19
  } = {}) {
8
20
  let attempt = 0
9
21
  let delay = initialDelayMs
@@ -204,6 +204,7 @@ export function streamEnvelope({ projectId, model, request, userAgent, sessionId
204
204
  const session = sessionId || randomUUID()
205
205
  const body = { ...(request || {}) }
206
206
  if (!body.session_id) body.session_id = session
207
+ /** @type {Record<string, any>} */
207
208
  const envelope = {
208
209
  ...(projectId ? { project: projectId } : {}),
209
210
  model,
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"
@@ -98,7 +98,7 @@ export function validationFromHttpError(status, bodyText) {
98
98
  }
99
99
 
100
100
  export function validationRequiredError(details) {
101
- const err = new Error(`${details.message} Verify at ${details.validationUrl}`)
101
+ const err = /** @type {Error & { code?: string, validationUrl?: string, learnMoreUrl?: string }} */ (new Error(`${details.message} Verify at ${details.validationUrl}`))
102
102
  err.code = 'VALIDATION_REQUIRED'
103
103
  err.validationUrl = details.validationUrl
104
104
  if (details.learnMoreUrl) err.learnMoreUrl = details.learnMoreUrl
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
@@ -81,7 +100,7 @@ export function isTrustedSettingsRequest(request) {
81
100
  if (secFetchSite === 'cross-site') return false
82
101
 
83
102
  // 2. If origin header is present, it must match host
84
- const host = headers.host || headers['x-forwarded-host']
103
+ const host = String(Array.isArray(headers.host) ? headers.host[0] : (headers.host || headers['x-forwarded-host'] || ''))
85
104
  const origin = headers.origin
86
105
  if (origin) {
87
106
  try {
@@ -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
 
@@ -167,17 +198,36 @@ export function sanitizeRequestHeaders(url, init = {}, options = {}) {
167
198
  delete headers.Cookie
168
199
  }
169
200
  }
170
- } catch {}
201
+ } catch (err) {
202
+ void err
203
+ return headers
204
+ }
171
205
  return headers
172
206
  }
173
207
 
174
- export async function fetchWithTimeout(fetchImpl, url, init = {}, { timeoutMs = 30000, allowedOrigins } = {}) {
208
+ export function isTimeoutError(err) {
209
+ if (!err) return false
210
+ return err.name === 'TimeoutError'
211
+ || err.code === 'ETIMEDOUT'
212
+ || (err.name === 'AbortError' && String(err.message || '').toLowerCase().includes('timeout'))
213
+ || String(err.message || '').toLowerCase().includes('aborted due to timeout')
214
+ || String(err.message || '').toLowerCase().includes('timed out')
215
+ || String(err.message || '').toLowerCase().includes('operation was aborted')
216
+ }
217
+
218
+ /**
219
+ * @param {any} fetchImpl
220
+ * @param {string} url
221
+ * @param {any} [init]
222
+ * @param {{ timeoutMs?: number, allowedOrigins?: string[], stream?: boolean }} [options]
223
+ */
224
+ export async function fetchWithTimeout(fetchImpl, url, init = {}, { timeoutMs = 30000, allowedOrigins, stream = false } = {}) {
175
225
  const safeHeaders = sanitizeRequestHeaders(url, init, { allowedOrigins })
176
226
  init = { ...init, headers: safeHeaders }
177
227
  const impl = fetchImpl || fetch
178
228
  if (!timeoutMs || timeoutMs <= 0) return impl(url, init)
179
229
  const controller = new AbortController()
180
- const timer = setTimeout(() => {
230
+ let timer = setTimeout(() => {
181
231
  controller.abort(new DOMException('The operation was aborted due to timeout', 'TimeoutError'))
182
232
  }, timeoutMs)
183
233
 
@@ -186,9 +236,74 @@ export async function fetchWithTimeout(fetchImpl, url, init = {}, { timeoutMs =
186
236
  : controller.signal
187
237
 
188
238
  try {
189
- return await impl(url, { ...init, signal })
190
- } finally {
239
+ const res = await impl(url, { ...init, signal })
240
+ if (stream) {
241
+ clearTimeout(timer)
242
+ return res
243
+ }
244
+ const origJson = typeof res.json === 'function' ? res.json.bind(res) : null
245
+ const origText = typeof res.text === 'function' ? res.text.bind(res) : null
246
+ if (origJson) {
247
+ res.json = () => {
248
+ if (controller.signal.aborted) {
249
+ return Promise.reject(new DOMException('The operation was aborted due to timeout', 'TimeoutError'))
250
+ }
251
+ return new Promise((resolve, reject) => {
252
+ const onAbort = () => reject(new DOMException('The operation was aborted due to timeout', 'TimeoutError'))
253
+ controller.signal.addEventListener('abort', onAbort, { once: true })
254
+ origJson().then(
255
+ (val) => {
256
+ controller.signal.removeEventListener('abort', onAbort)
257
+ clearTimeout(timer)
258
+ resolve(val)
259
+ },
260
+ (err) => {
261
+ controller.signal.removeEventListener('abort', onAbort)
262
+ clearTimeout(timer)
263
+ if (controller.signal.aborted) {
264
+ reject(new DOMException('The operation was aborted due to timeout', 'TimeoutError'))
265
+ } else {
266
+ reject(err)
267
+ }
268
+ }
269
+ )
270
+ })
271
+ }
272
+ }
273
+ if (origText) {
274
+ res.text = () => {
275
+ if (controller.signal.aborted) {
276
+ return Promise.reject(new DOMException('The operation was aborted due to timeout', 'TimeoutError'))
277
+ }
278
+ return new Promise((resolve, reject) => {
279
+ const onAbort = () => reject(new DOMException('The operation was aborted due to timeout', 'TimeoutError'))
280
+ controller.signal.addEventListener('abort', onAbort, { once: true })
281
+ origText().then(
282
+ (val) => {
283
+ controller.signal.removeEventListener('abort', onAbort)
284
+ clearTimeout(timer)
285
+ resolve(val)
286
+ },
287
+ (err) => {
288
+ controller.signal.removeEventListener('abort', onAbort)
289
+ clearTimeout(timer)
290
+ if (controller.signal.aborted) {
291
+ reject(new DOMException('The operation was aborted due to timeout', 'TimeoutError'))
292
+ } else {
293
+ reject(err)
294
+ }
295
+ }
296
+ )
297
+ })
298
+ }
299
+ }
300
+ return res
301
+ } catch (err) {
191
302
  clearTimeout(timer)
303
+ if (controller.signal.aborted && !isTimeoutError(err)) {
304
+ throw new DOMException('The operation was aborted due to timeout', 'TimeoutError')
305
+ }
306
+ throw err
192
307
  }
193
308
  }
194
309