@goodandready/dsh-subscriptions 0.6.53 → 0.6.55

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.
@@ -42,6 +42,8 @@ export function createAdapterManager({
42
42
  vendorConfig: (provider) => vendorConfig(provider, live()),
43
43
  cooldownMs: () => live().cooldownMs,
44
44
  autoPacing: () => live().autoPacing,
45
+ drainBeforeReset: () => live().drainBeforeReset,
46
+ drainSafetyPercent: () => live().drainSafetyPercent,
45
47
  switchAtRemaining: () => live().switchAtRemaining,
46
48
  rememberCooldown: (ref, until, families) => store.rememberCooldown(ref, until, families),
47
49
  rememberQuarantine: (ref, reason, until, attempts) => store.rememberQuarantine(ref, reason, until, attempts),
package/lib/adapter.js CHANGED
@@ -121,7 +121,18 @@ export class SubscriptionAdapter extends LlmAdapter {
121
121
  const effectiveAutoPacing = options.autoPacing !== undefined
122
122
  ? options.autoPacing
123
123
  : (typeof deps.autoPacing === 'function' ? deps.autoPacing() : (deps.autoPacing ?? true))
124
- const streamOptions = { ...options, autoPacing: effectiveAutoPacing }
124
+ const effectiveDrainBeforeReset = options.drainBeforeReset !== undefined
125
+ ? options.drainBeforeReset
126
+ : (typeof deps.drainBeforeReset === 'function' ? deps.drainBeforeReset() : (deps.drainBeforeReset ?? false))
127
+ const effectiveDrainSafetyPercent = options.drainSafetyPercent !== undefined
128
+ ? options.drainSafetyPercent
129
+ : (typeof deps.drainSafetyPercent === 'function' ? deps.drainSafetyPercent() : (deps.drainSafetyPercent ?? 5))
130
+ const streamOptions = {
131
+ ...options,
132
+ autoPacing: effectiveAutoPacing,
133
+ drainBeforeReset: effectiveDrainBeforeReset,
134
+ drainSafetyPercent: effectiveDrainSafetyPercent,
135
+ }
125
136
  for await (const event of streamWithRotation({
126
137
  accounts: await deps.listAccounts(provider),
127
138
  nowMs: () => Date.now(),
@@ -94,6 +94,10 @@ export const Config = z.object({
94
94
  .description('#351 Quota usage percentage thresholds for firing alerts.'),
95
95
  autoPacing: z.boolean().volatile().default(true)
96
96
  .description('#352 Predictive burn-rate load balancing between accounts.'),
97
+ drainBeforeReset: z.boolean().volatile().default(false)
98
+ .description('#485 Prioritize accounts with closest reset window to burn expiring quota first.'),
99
+ drainSafetyPercent: z.number().volatile().default(5)
100
+ .description('#485 Safety margin percent: avoid draining accounts below this threshold when alternatives exist.'),
97
101
  customVendors: z.array(z.any()).volatile().default([])
98
102
  .description('Declarative OpenAI-Responses-compatible providers. See README.'),
99
103
  })
package/lib/forecast.js CHANGED
@@ -125,3 +125,19 @@ export function computePacingRisk(remainingPercent, pacePerHour, resetsAt, now =
125
125
  pacingRisk: hoursUntilExhaustion < 1 ? 50 : 0,
126
126
  }
127
127
  }
128
+
129
+ // #485: Smart Quota Drainer urgency calculation
130
+ export function calculateBurnUrgency(remainingPercent, resetAt, nowMs = Date.now(), safetyFloor = 5) {
131
+ const rem = Number(remainingPercent)
132
+ if (!Number.isFinite(rem) || rem <= 0) return 0
133
+ const floor = Number(safetyFloor) || 0
134
+ if (rem <= floor) return 0
135
+
136
+ const reset = Number(resetAt)
137
+ if (!Number.isFinite(reset) || reset <= nowMs) return 0
138
+
139
+ const hoursUntilReset = (reset - nowMs) / HOUR_MS
140
+ // Denominator clamped to minimum 0.05 hours (3 mins) to avoid infinity
141
+ const clampedHours = Math.max(hoursUntilReset, 0.05)
142
+ return rem / clampedHours
143
+ }
package/lib/http.js CHANGED
@@ -1,3 +1,5 @@
1
+ import { timingSafeEqual } from 'node:crypto'
2
+
1
3
  export function writeJson(res, code, body) {
2
4
  try {
3
5
  res.writeHead(code, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' })
@@ -12,6 +14,14 @@ export function writeHtml(res, code, html) {
12
14
  } catch { /* socket closed */ }
13
15
  }
14
16
 
17
+ export function timingSafeCompare(a, b) {
18
+ if (typeof a !== 'string' || typeof b !== 'string') return false
19
+ const bufA = Buffer.from(a, 'utf8')
20
+ const bufB = Buffer.from(b, 'utf8')
21
+ if (bufA.length !== bufB.length) return false
22
+ return timingSafeEqual(bufA, bufB)
23
+ }
24
+
15
25
  export function readBody(req, maxBytes = 64 * 1024) {
16
26
  return new Promise((resolve, reject) => {
17
27
  const chunks = []
@@ -126,11 +136,11 @@ export function isTrustedSettingsRequest(request, targetCtx) {
126
136
  // 5. Loopback request (CLI, local daemon, localhost test)
127
137
  if (isLoopback(request)) return true
128
138
 
129
- // 6. Verified server secret token with exact cookie matching (#374)
139
+ // 6. Verified server secret token with exact cookie matching (#374, #492)
130
140
  const expectedToken = process.env.DSH_AUTH_TOKEN
131
141
  if (expectedToken) {
132
142
  const auth = headers.authorization
133
- if (typeof auth === 'string' && auth.startsWith('Bearer ') && auth.slice(7).trim() === expectedToken) {
143
+ if (typeof auth === 'string' && auth.startsWith('Bearer ') && timingSafeCompare(auth.slice(7).trim(), expectedToken)) {
134
144
  return true
135
145
  }
136
146
  const cookieHeader = headers.cookie
@@ -142,7 +152,7 @@ export function isTrustedSettingsRequest(request, targetCtx) {
142
152
  cookies[part.slice(0, eq).trim()] = part.slice(eq + 1).trim()
143
153
  }
144
154
  }
145
- if (cookies.token === expectedToken || cookies.dsh_token === expectedToken) {
155
+ if (timingSafeCompare(cookies.token, expectedToken) || timingSafeCompare(cookies.dsh_token, expectedToken)) {
146
156
  return true
147
157
  }
148
158
  }
@@ -261,10 +271,12 @@ export async function fetchWithTimeout(fetchImpl, url, init = {}, { timeoutMs =
261
271
  clearTimeout(timer)
262
272
  return res
263
273
  }
274
+ if (typeof timer?.unref === 'function') timer.unref()
264
275
  const origJson = typeof res.json === 'function' ? res.json.bind(res) : null
265
276
  const origText = typeof res.text === 'function' ? res.text.bind(res) : null
266
277
  if (origJson) {
267
278
  res.json = () => {
279
+ if (typeof timer?.ref === 'function') timer.ref()
268
280
  if (controller.signal.aborted) {
269
281
  return Promise.reject(new DOMException('The operation was aborted due to timeout', 'TimeoutError'))
270
282
  }
@@ -292,6 +304,7 @@ export async function fetchWithTimeout(fetchImpl, url, init = {}, { timeoutMs =
292
304
  }
293
305
  if (origText) {
294
306
  res.text = () => {
307
+ if (typeof timer?.ref === 'function') timer.ref()
295
308
  if (controller.signal.aborted) {
296
309
  return Promise.reject(new DOMException('The operation was aborted due to timeout', 'TimeoutError'))
297
310
  }
package/lib/index.js CHANGED
@@ -314,6 +314,8 @@ export function apply(ctx, rawConfig) {
314
314
  ensureFresh: (provider, blob, ref) => store.ensureFresh(provider, blob, ref),
315
315
  vendorConfig: (provider) => vendorConfig(provider, live()),
316
316
  cooldownMs: () => live().cooldownMs,
317
+ drainBeforeReset: () => live().drainBeforeReset,
318
+ drainSafetyPercent: () => live().drainSafetyPercent,
317
319
  switchAtRemaining: () => live().switchAtRemaining,
318
320
  rememberCooldown: (ref, until, families) => store.rememberCooldown(ref, until, families),
319
321
  rememberQuarantine: (ref, reason, until, attempts) => store.rememberQuarantine(ref, reason, until, attempts),
@@ -348,7 +350,11 @@ export function apply(ctx, rawConfig) {
348
350
  throw new Error(`unknown subscription provider: ${provider}`)
349
351
  }
350
352
  const accounts = await store.listAccounts(provider)
351
- const eligible = pickAccount(accounts || [], Date.now(), { family: provider === 'codex' ? 'gpt' : 'grok' })
353
+ const eligible = pickAccount(accounts || [], Date.now(), {
354
+ family: provider === 'codex' ? 'gpt' : 'grok',
355
+ drainBeforeReset: live().drainBeforeReset,
356
+ drainSafetyPercent: live().drainSafetyPercent,
357
+ })
352
358
  const slot = eligible || (accounts || []).find((row) => row && row.ref && row.hasToken)
353
359
  if (!slot) throw new Error(`not logged in to ${provider}: sign in in the Subscriptions section`)
354
360
  const raw = await store.resolveRaw(slot.ref)
@@ -436,10 +442,8 @@ export function apply(ctx, rawConfig) {
436
442
  }
437
443
  }
438
444
  tick().catch(() => {})
439
- const timer = setInterval
440
- const clear = clearInterval
441
- const id = timer(() => { tick().catch(() => {}) }, 60 * 1000)
442
- return () => clear(id)
445
+ const id = setInterval(() => { tick().catch(() => {}) }, 60 * 1000)
446
+ return () => clearInterval(id)
443
447
  }, 'dsh-subscriptions: refresh ahead')
444
448
 
445
449
  // Background health-check probe loop
package/lib/rotate.js CHANGED
@@ -1,4 +1,4 @@
1
- import { computePacingRisk, calculateBurnRatePerHour } from "./forecast.js"
1
+ import { computePacingRisk, calculateBurnRatePerHour, calculateBurnUrgency } from "./forecast.js"
2
2
  import { isQuarantined } from './quarantine.js'
3
3
  import { getPinnedAccountRef, pinSession } from './session-pin.js'
4
4
 
@@ -41,6 +41,28 @@ export function cooldownBlocks(acc, family) {
41
41
  return fams.includes(family)
42
42
  }
43
43
 
44
+
45
+ export function getAccountRemainingPercent(acc) {
46
+ if (!acc) return null
47
+ if (acc.quota?.remainingPercent != null) {
48
+ const val = Number(acc.quota.remainingPercent)
49
+ return Number.isFinite(val) ? val : null
50
+ }
51
+ if (acc.quota?.usedPercent != null) {
52
+ const val = 100 - Number(acc.quota.usedPercent)
53
+ return Number.isFinite(val) ? val : null
54
+ }
55
+ if (acc.usagePercent != null) {
56
+ const val = 100 - Number(acc.usagePercent)
57
+ return Number.isFinite(val) ? val : null
58
+ }
59
+ if (acc.quota?.remaining != null && acc.quota?.limit != null && Number(acc.quota.limit) > 0) {
60
+ const val = (Number(acc.quota.remaining) / Number(acc.quota.limit)) * 100
61
+ return Number.isFinite(val) ? val : null
62
+ }
63
+ return null
64
+ }
65
+
44
66
  export function pickAccount(accounts, nowMs, opts) {
45
67
  const list = Array.isArray(accounts) ? accounts : []
46
68
  const now = Number(nowMs) || 0
@@ -111,6 +133,15 @@ export function pickAccount(accounts, nowMs, opts) {
111
133
  }
112
134
  }
113
135
 
136
+ if (opts && opts.drainBeforeReset) {
137
+ if (acc.burnUrgency === undefined) {
138
+ const rem = getAccountRemainingPercent(acc)
139
+ const resetAt = acc.quota?.resetAt || 0
140
+ const safetyFloor = opts.drainSafetyPercent != null ? Number(opts.drainSafetyPercent) : 5
141
+ acc.burnUrgency = calculateBurnUrgency(rem, resetAt, now, safetyFloor)
142
+ }
143
+ }
144
+
114
145
  // VIP Slot Reservation (#175)
115
146
  if (acc.vipOnly && !isVipRequest) continue
116
147
 
@@ -142,6 +173,15 @@ export function pickAccount(accounts, nowMs, opts) {
142
173
  for (const tier of tiers) {
143
174
  if (tier.length) {
144
175
  tier.sort((a, b) => {
176
+ // #485: Smart Quota Drainer - prioritize higher burn urgency first
177
+ if (opts && opts.drainBeforeReset) {
178
+ const aUrg = Number(a.burnUrgency) || 0
179
+ const bUrg = Number(b.burnUrgency) || 0
180
+ if (Math.abs(aUrg - bUrg) > 0.01) {
181
+ return bUrg - aUrg
182
+ }
183
+ }
184
+
145
185
  // 0. Auto-Pacing: lower pacing risk preferred (#352)
146
186
  const aRisk = Number(a.pacingRisk) || 0
147
187
  const bRisk = Number(b.pacingRisk) || 0
@@ -31,6 +31,10 @@ export function registerOauthRoutes(ctx, state) {
31
31
  writeJson(res, 405, { ok: false, error: { code: 'method', message: 'GET only' } })
32
32
  return
33
33
  }
34
+ if (!isTrustedSettingsRequest(req, ctx)) {
35
+ writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'same-origin only' } })
36
+ return
37
+ }
34
38
  const q = queryOf(req)
35
39
  const provider = q.get('provider') || ''
36
40
  const index = Number(q.get('index') || '1')
@@ -354,7 +354,7 @@ export function registerStatusRoutes(ctx, state) {
354
354
  writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'localhost only' } })
355
355
  return
356
356
  }
357
- const limit = Math.min(Number(queryOf(req).get('limit') || '10'), 100)
357
+ const limit = Math.max(1, Math.min(Number(queryOf(req).get('limit') || '10'), 100))
358
358
  writeJson(res, 200, { ok: true, total: history.size(), items: history.recent(limit) })
359
359
  }),
360
360
  }), 'dsh-subscriptions: /history')
@@ -502,7 +502,7 @@ export function registerStatusRoutes(ctx, state) {
502
502
  writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'same-origin only' } })
503
503
  return
504
504
  }
505
- const limit = Math.min(Number(queryOf(req).get('limit') || '50'), 100)
505
+ const limit = Math.max(1, Math.min(Number(queryOf(req).get('limit') || '50'), 100))
506
506
  writeJson(res, 200, { ok: true, alerts: getRecentAlerts(limit) })
507
507
  }),
508
508
  }), 'dsh-subscriptions: /alerts')
@@ -28,6 +28,8 @@ export async function* streamWithRotation({
28
28
  tag: options && options.tag,
29
29
  vip: options && options.vip,
30
30
  autoPacing: options && (options.autoPacing !== undefined ? options.autoPacing : (options.config && options.config.autoPacing)),
31
+ drainBeforeReset: options && (options.drainBeforeReset !== undefined ? options.drainBeforeReset : (options.config && options.config.drainBeforeReset)),
32
+ drainSafetyPercent: options && (options.drainSafetyPercent !== undefined ? options.drainSafetyPercent : (options.config && options.config.drainSafetyPercent)),
31
33
  })
32
34
 
33
35
  if (!account || tried.has(account.ref || account.id)) {
@@ -18,9 +18,16 @@ export function isAllowed(provider, path) {
18
18
  let p = String(path || "")
19
19
  // strip query and hash
20
20
  p = p.split("?")[0].split("#")[0]
21
+ if (p.startsWith("//")) return false
21
22
  try {
22
- if (p.startsWith("http://") || p.startsWith("https://")) p = new URL(p).pathname
23
- } catch { /* ignore subscriptions error */ }
23
+ if (p.startsWith("http://") || p.startsWith("https://")) {
24
+ p = new URL(p).pathname
25
+ } else {
26
+ p = new URL(p.startsWith("/") ? p : "/" + p, "http://localhost").pathname
27
+ }
28
+ } catch {
29
+ return false
30
+ }
24
31
  if (!p.startsWith("/")) p = "/" + p
25
32
  for (const allowed of list) {
26
33
  const a = allowed.split("?")[0]
@@ -88,6 +95,11 @@ export function createSubscriptionsService(deps) {
88
95
  throw err
89
96
  }
90
97
  const rawPath = String(path || "")
98
+ if (rawPath.startsWith("//")) {
99
+ const err = new Error("protocol-relative URL rejected: " + rawPath)
100
+ err.code = "FORBIDDEN"
101
+ throw err
102
+ }
91
103
  if (rawPath.startsWith("http://") || rawPath.startsWith("https://")) {
92
104
  const initialCfg = typeof deps.vendorConfig === "function" ? deps.vendorConfig(provider) : {}
93
105
  const initialBase = baseFor(provider, initialCfg || {})
@@ -108,7 +120,12 @@ export function createSubscriptionsService(deps) {
108
120
  const tried = new Set()
109
121
  let lastError = null
110
122
  while (true) {
111
- const account = pickAccount(pool, Date.now(), { switchAtRemaining: thr, family: modelFamily(provider, body && body.model) })
123
+ const account = pickAccount(pool, Date.now(), {
124
+ switchAtRemaining: thr,
125
+ family: modelFamily(provider, body && body.model),
126
+ drainBeforeReset: typeof deps.drainBeforeReset === 'function' ? deps.drainBeforeReset() : deps.drainBeforeReset,
127
+ drainSafetyPercent: typeof deps.drainSafetyPercent === 'function' ? deps.drainSafetyPercent() : deps.drainSafetyPercent,
128
+ })
112
129
  if (!account) {
113
130
  if (lastError) throw lastError
114
131
  const err = new Error("no usable subscription account for this provider")
@@ -126,23 +143,57 @@ export function createSubscriptionsService(deps) {
126
143
  const blob = await deps.ensureFresh(provider, await deps.loadBlob(account.ref), account.ref)
127
144
  const cfg = deps.vendorConfig(provider)
128
145
  const base = baseFor(provider, cfg)
129
- // normalize path
130
- let p = String(path)
131
- if (p.startsWith("http://") || p.startsWith("https://")) {
132
- // #445: enforce provider origin policy before Bearer dispatch
133
- let targetOrigin, baseOrigin
134
- try { targetOrigin = new URL(p).origin } catch { /* ignore */ }
135
- try { baseOrigin = new URL(base).origin } catch { /* ignore */ }
136
- if (!targetOrigin || !baseOrigin || targetOrigin.toLowerCase() !== baseOrigin.toLowerCase()) {
137
- const err = new Error("external origin rejected for provider " + provider + ": " + p)
146
+
147
+ // normalize path and resolve target URL (#487)
148
+ let targetUrl
149
+ let baseOrigin = ""
150
+ let basePath = ""
151
+ try {
152
+ const bUrl = new URL(base)
153
+ baseOrigin = bUrl.origin
154
+ basePath = bUrl.pathname.replace(/\/$/, "")
155
+ } catch { /* ignore */ }
156
+
157
+ const isAbsolute = rawPath.startsWith("http://") || rawPath.startsWith("https://")
158
+ try {
159
+ if (isAbsolute) {
160
+ targetUrl = new URL(rawPath)
161
+ } else {
162
+ const cleanRel = rawPath.startsWith("/") ? rawPath : "/" + rawPath
163
+ if (basePath && (cleanRel === basePath || cleanRel.startsWith(basePath + "/"))) {
164
+ targetUrl = new URL(baseOrigin + cleanRel)
165
+ } else {
166
+ const cleanBase = base.replace(/\/$/, "")
167
+ targetUrl = new URL(cleanBase + cleanRel)
168
+ }
169
+ }
170
+ } catch {
171
+ const err = new Error("invalid URL for " + provider + ": " + rawPath)
172
+ err.code = "FORBIDDEN"
173
+ throw err
174
+ }
175
+
176
+ if (!baseOrigin || targetUrl.origin.toLowerCase() !== baseOrigin.toLowerCase()) {
177
+ const err = new Error("external origin rejected for provider " + provider + ": " + rawPath)
178
+ err.code = "FORBIDDEN"
179
+ throw err
180
+ }
181
+
182
+ if (!isAbsolute) {
183
+ if (basePath && targetUrl.pathname !== basePath && !targetUrl.pathname.startsWith(basePath + "/")) {
184
+ const err = new Error("path traversal rejected for provider " + provider + ": " + rawPath)
138
185
  err.code = "FORBIDDEN"
139
186
  throw err
140
187
  }
141
- } else {
142
- if (!p.startsWith("/")) p = "/" + p
143
- p = base + p
144
188
  }
145
- const url = p
189
+
190
+ if (!isAllowed(provider, targetUrl.pathname)) {
191
+ const err = new Error("path not allowed for " + provider + ": " + targetUrl.pathname)
192
+ err.code = "FORBIDDEN"
193
+ throw err
194
+ }
195
+
196
+ const url = targetUrl.toString()
146
197
  const extraHeaders = headersFor(provider, blob, cfg)
147
198
  const fetchImpl = pickFetch(deps, account.ref)
148
199
  const requestTimeoutMs = Number(timeoutMs) > 0 ? Number(timeoutMs) : 30000
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-subscriptions",
3
- "version": "0.6.53",
3
+ "version": "0.6.55",
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",