@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.
@@ -39,6 +39,7 @@ async function discoverLocalCliSessionsInner() {
39
39
  const tok = codex.tokens || codex
40
40
  detected.codex = {
41
41
  provider: 'codex',
42
+ source: 'file',
42
43
  path: homeFile('.codex', 'auth.json'),
43
44
  email: codex.email || codex.account || '',
44
45
  hasRefreshToken: !!(tok.refresh_token || tok.refreshToken),
@@ -54,6 +55,7 @@ async function discoverLocalCliSessionsInner() {
54
55
  if (grok && (grok.access_token || grok.token || (grok.tokens && grok.tokens.access_token))) {
55
56
  detected.grok = {
56
57
  provider: 'grok',
58
+ source: 'file',
57
59
  path: p,
58
60
  email: grok.email || grok.account || '',
59
61
  hasRefreshToken: !!(grok.refresh_token || (grok.tokens && grok.tokens.refresh_token)),
@@ -73,6 +75,7 @@ async function discoverLocalCliSessionsInner() {
73
75
  if (tok && (tok.access_token || tok.accessToken)) {
74
76
  detected.antigravity = {
75
77
  provider: 'antigravity',
78
+ source: 'file',
76
79
  path: p,
77
80
  email: agy.email || tok.account || '',
78
81
  hasRefreshToken: !!(tok.refresh_token || tok.refreshToken),
@@ -86,6 +89,7 @@ async function discoverLocalCliSessionsInner() {
86
89
  if (kimi && (kimi.access_token || kimi.token)) {
87
90
  detected.kimi = {
88
91
  provider: 'kimi',
92
+ source: 'file',
89
93
  path: homeFile('.kimi-code', 'credentials', 'kimi-code.json'),
90
94
  email: kimi.email || kimi.account || '',
91
95
  hasRefreshToken: !!(kimi.refresh_token || kimi.refreshToken),
@@ -103,6 +107,7 @@ async function discoverLocalCliSessionsInner() {
103
107
  if (glm && (glm.apiKey || glm.api_key || (glm.provider && glm.provider.apiKey))) {
104
108
  detected.glm = {
105
109
  provider: 'glm',
110
+ source: 'file',
106
111
  path: p,
107
112
  email: 'ZCode CLI Account',
108
113
  hasRefreshToken: false,
@@ -111,20 +116,31 @@ async function discoverLocalCliSessionsInner() {
111
116
  }
112
117
  }
113
118
 
114
- // 6. Cursor Token (env, CLI config or IDE state)
119
+ // 6. Cursor Token (CLI config, auth.json, or env)
115
120
  const cursorCliConfig = await readJson(homeFile('.cursor', 'cli-config.json'))
116
- if (process.env.CURSOR_ACCESS_TOKEN) {
121
+ const cursorAuthConfig = await readJson(homeFile('.cursor', 'auth.json'))
122
+ if (cursorCliConfig && (cursorCliConfig.authInfo || cursorCliConfig.serverConfigCache)) {
117
123
  detected.cursor = {
118
124
  provider: 'cursor',
119
- path: 'CURSOR_ACCESS_TOKEN env',
120
- email: 'Cursor IDE User',
125
+ source: 'file',
126
+ path: homeFile('.cursor', 'cli-config.json'),
127
+ email: (cursorCliConfig.authInfo && cursorCliConfig.authInfo.email) || 'Cursor CLI User',
121
128
  hasRefreshToken: false,
122
129
  }
123
- } else if (cursorCliConfig && (cursorCliConfig.authInfo || cursorCliConfig.serverConfigCache)) {
130
+ } else if (cursorAuthConfig && (cursorAuthConfig.accessToken || cursorAuthConfig.access_token || (cursorAuthConfig.authInfo && cursorAuthConfig.authInfo.authId))) {
124
131
  detected.cursor = {
125
132
  provider: 'cursor',
126
- path: homeFile('.cursor', 'cli-config.json'),
127
- email: (cursorCliConfig.authInfo && cursorCliConfig.authInfo.email) || 'Cursor CLI User',
133
+ source: 'file',
134
+ path: homeFile('.cursor', 'auth.json'),
135
+ email: (cursorAuthConfig.authInfo && cursorAuthConfig.authInfo.email) || cursorAuthConfig.email || 'Cursor User',
136
+ hasRefreshToken: false,
137
+ }
138
+ } else if (process.env.CURSOR_ACCESS_TOKEN) {
139
+ detected.cursor = {
140
+ provider: 'cursor',
141
+ source: 'env',
142
+ path: 'CURSOR_ACCESS_TOKEN',
143
+ email: 'Cursor IDE User',
128
144
  hasRefreshToken: false,
129
145
  }
130
146
  }
@@ -139,6 +155,7 @@ async function discoverLocalCliSessionsInner() {
139
155
  if (k && (k.accessToken || k.access_token || k.token)) {
140
156
  detected.kiro = {
141
157
  provider: 'kiro',
158
+ source: 'file',
142
159
  path: p,
143
160
  email: k.email || k.account || 'AWS Kiro User',
144
161
  hasRefreshToken: !!(k.refreshToken || k.refresh_token),
@@ -149,7 +166,8 @@ async function discoverLocalCliSessionsInner() {
149
166
  if (!detected.kiro && process.env.KIRO_API_KEY) {
150
167
  detected.kiro = {
151
168
  provider: 'kiro',
152
- path: 'KIRO_API_KEY env',
169
+ source: 'env',
170
+ path: 'KIRO_API_KEY',
153
171
  email: 'AWS Kiro API Key',
154
172
  hasRefreshToken: false,
155
173
  }
@@ -158,12 +176,15 @@ async function discoverLocalCliSessionsInner() {
158
176
  // 8. Claude CLI Direct Import
159
177
  const claudeCreds = await readClaudeCredentials(activeHome)
160
178
  if (claudeCreds) {
161
- detected['claude-cli'] = {
179
+ const claudeEntry = {
162
180
  provider: 'claude',
181
+ source: 'file',
163
182
  path: homeFile('.claude', 'credentials.json'),
164
- email: 'Claude Code CLI User',
183
+ email: claudeCreds.email || 'Claude Code CLI User',
165
184
  hasRefreshToken: !!claudeCreds.refreshToken,
166
185
  }
186
+ detected.claude = claudeEntry
187
+ detected['claude-cli'] = claudeEntry
167
188
  }
168
189
 
169
190
  // 9. GitHub Copilot token
@@ -179,6 +200,7 @@ async function discoverLocalCliSessionsInner() {
179
200
  if (tok) {
180
201
  detected.copilot = {
181
202
  provider: 'copilot',
203
+ source: 'file',
182
204
  path: p,
183
205
  email: gh.user || 'GitHub Copilot User',
184
206
  hasRefreshToken: false,
@@ -190,37 +212,37 @@ async function discoverLocalCliSessionsInner() {
190
212
  if (!detected.copilot && (process.env.GITHUB_COPILOT_TOKEN || process.env.GH_TOKEN)) {
191
213
  detected.copilot = {
192
214
  provider: 'copilot',
193
- path: 'GITHUB_COPILOT_TOKEN env',
215
+ source: 'env',
216
+ path: process.env.GITHUB_COPILOT_TOKEN ? 'GITHUB_COPILOT_TOKEN' : 'GH_TOKEN',
194
217
  email: 'GitHub Copilot User',
195
218
  hasRefreshToken: false,
196
219
  }
197
220
  }
198
221
 
199
-
200
-
201
222
  return detected
202
223
  }
203
224
 
204
225
  export async function loadLocalCliBlob(provider, options = {}) {
226
+ const canonical = (provider === 'claude-cli') ? 'claude' : provider
205
227
  const discovered = await discoverLocalCliSessions(options)
206
- const info = discovered[provider]
228
+ const info = discovered[canonical] || discovered[provider]
207
229
  if (!info) throw new Error(`no local CLI session found for ${provider}`)
208
230
 
209
- if (provider === 'claude-cli' || (provider === 'claude' && info.provider === 'claude')) {
231
+ if (canonical === 'claude') {
210
232
  const creds = await readClaudeCredentials(options && options.home)
211
233
  if (!creds) throw new Error('Claude CLI credentials not found')
212
234
  return {
213
235
  accessToken: creds.accessToken,
214
- refreshToken: creds.refreshToken,
236
+ refreshToken: creds.refreshToken || '',
215
237
  expiresAt: creds.expiresAt,
216
- email: 'Claude Code CLI User'
238
+ email: creds.email || 'Claude Code CLI User',
217
239
  }
218
240
  }
219
241
 
220
- if (provider === 'copilot') {
242
+ if (canonical === 'copilot') {
221
243
  let tok = process.env.GITHUB_COPILOT_TOKEN || process.env.GH_TOKEN || ''
222
244
  let email = 'GitHub Copilot User'
223
- if (info.path && info.path !== 'GITHUB_COPILOT_TOKEN env') {
245
+ if (info.source === 'file' && info.path) {
224
246
  const cp = await readJson(info.path)
225
247
  if (cp) {
226
248
  const gh = cp['github.com'] || cp
@@ -228,6 +250,7 @@ export async function loadLocalCliBlob(provider, options = {}) {
228
250
  email = gh.user || email
229
251
  }
230
252
  }
253
+ if (!tok) throw new Error('no Copilot token found')
231
254
  return {
232
255
  accessToken: tok,
233
256
  refreshToken: tok,
@@ -238,10 +261,54 @@ export async function loadLocalCliBlob(provider, options = {}) {
238
261
  }
239
262
  }
240
263
 
264
+ if (canonical === 'cursor') {
265
+ if (info.source === 'env' || !info.path || info.path.includes('env') || info.path === 'CURSOR_ACCESS_TOKEN') {
266
+ const token = process.env.CURSOR_ACCESS_TOKEN
267
+ if (!token) throw new Error('CURSOR_ACCESS_TOKEN environment variable not set')
268
+ return {
269
+ accessToken: token,
270
+ refreshToken: '',
271
+ expiresAt: Date.now() + 30 * 86400 * 1000,
272
+ email: 'Cursor IDE User',
273
+ }
274
+ }
275
+ const raw = await readJson(info.path)
276
+ if (!raw) throw new Error(`failed to read local CLI file: ${info.path}`)
277
+ const token = (raw && (raw.accessToken || raw.access_token || (raw.authInfo && raw.authInfo.authId))) || process.env.CURSOR_ACCESS_TOKEN || ''
278
+ return {
279
+ accessToken: token,
280
+ refreshToken: '',
281
+ expiresAt: Date.now() + 30 * 86400 * 1000,
282
+ email: (raw && raw.authInfo && raw.authInfo.email) || raw.email || 'Cursor User',
283
+ }
284
+ }
285
+
286
+ if (canonical === 'kiro') {
287
+ if (info.source === 'env' || !info.path || info.path.includes('env') || info.path === 'KIRO_API_KEY') {
288
+ const token = process.env.KIRO_API_KEY
289
+ if (!token) throw new Error('KIRO_API_KEY environment variable not set')
290
+ return {
291
+ accessToken: token,
292
+ refreshToken: '',
293
+ expiresAt: Date.now() + 30 * 86400 * 1000,
294
+ email: 'AWS Kiro API Key',
295
+ }
296
+ }
297
+ const raw = await readJson(info.path)
298
+ if (!raw) throw new Error(`failed to read local CLI file: ${info.path}`)
299
+ const token = (raw && (raw.accessToken || raw.access_token || raw.token)) || process.env.KIRO_API_KEY || ''
300
+ return {
301
+ accessToken: token,
302
+ refreshToken: (raw && (raw.refreshToken || raw.refresh_token)) || '',
303
+ expiresAt: (raw && raw.expiresAt) || (Date.now() + 30 * 86400 * 1000),
304
+ email: (raw && raw.email) || 'AWS Kiro User',
305
+ }
306
+ }
307
+
241
308
  const raw = await readJson(info.path)
242
309
  if (!raw) throw new Error(`failed to read local CLI file: ${info.path}`)
243
310
 
244
- if (provider === 'codex') {
311
+ if (canonical === 'codex') {
245
312
  const tok = raw.tokens || raw
246
313
  return {
247
314
  accessToken: tok.access_token || tok.accessToken || '',
@@ -252,7 +319,7 @@ export async function loadLocalCliBlob(provider, options = {}) {
252
319
  }
253
320
  }
254
321
 
255
- if (provider === 'grok') {
322
+ if (canonical === 'grok') {
256
323
  const tok = raw.tokens || raw
257
324
  return {
258
325
  accessToken: tok.access_token || tok.token || '',
@@ -262,7 +329,7 @@ export async function loadLocalCliBlob(provider, options = {}) {
262
329
  }
263
330
  }
264
331
 
265
- if (provider === 'antigravity') {
332
+ if (canonical === 'antigravity') {
266
333
  const tok = raw.token || raw
267
334
  return {
268
335
  accessToken: tok.access_token || tok.accessToken || '',
@@ -273,7 +340,7 @@ export async function loadLocalCliBlob(provider, options = {}) {
273
340
  }
274
341
  }
275
342
 
276
- if (provider === 'kimi') {
343
+ if (canonical === 'kimi') {
277
344
  return {
278
345
  accessToken: raw.access_token || raw.token || '',
279
346
  refreshToken: raw.refresh_token || '',
@@ -282,7 +349,7 @@ export async function loadLocalCliBlob(provider, options = {}) {
282
349
  }
283
350
  }
284
351
 
285
- if (provider === 'glm') {
352
+ if (canonical === 'glm') {
286
353
  const apiKey = raw.apiKey || raw.api_key || (raw.provider && raw.provider.apiKey) || ''
287
354
  return {
288
355
  accessToken: apiKey,
@@ -292,25 +359,5 @@ export async function loadLocalCliBlob(provider, options = {}) {
292
359
  }
293
360
  }
294
361
 
295
- if (provider === 'cursor') {
296
- const token = process.env.CURSOR_ACCESS_TOKEN || (raw && (raw.accessToken || raw.access_token || (raw.authInfo && raw.authInfo.authId))) || ''
297
- return {
298
- accessToken: token,
299
- refreshToken: '',
300
- expiresAt: Date.now() + 30 * 86400 * 1000,
301
- email: (raw && raw.authInfo && raw.authInfo.email) || 'Cursor User',
302
- }
303
- }
304
-
305
- if (provider === 'kiro') {
306
- const token = process.env.KIRO_API_KEY || (raw && (raw.accessToken || raw.access_token || raw.token)) || ''
307
- return {
308
- accessToken: token,
309
- refreshToken: (raw && (raw.refreshToken || raw.refresh_token)) || '',
310
- expiresAt: (raw && raw.expiresAt) || (Date.now() + 30 * 86400 * 1000),
311
- email: (raw && raw.email) || 'AWS Kiro User',
312
- }
313
- }
314
-
315
362
  throw new Error(`unsupported local CLI import for provider ${provider}`)
316
363
  }
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,
package/lib/oauth.js CHANGED
@@ -1,3 +1,15 @@
1
+ /**
2
+ * @param {{
3
+ * authUrl: string,
4
+ * clientId: string,
5
+ * redirectUri: string,
6
+ * challenge: string,
7
+ * state: string,
8
+ * extra?: Record<string, any>,
9
+ * scope?: string,
10
+ * }} params
11
+ * @returns {string}
12
+ */
1
13
  export function buildAuthorizeUrl({
2
14
  authUrl,
3
15
  clientId,
@@ -143,7 +143,9 @@ export function registerAccountsRoutes(ctx, state) {
143
143
  } catch {
144
144
  body = null
145
145
  }
146
- if (!body || !body.provider || !isProvider(body.provider)) {
146
+ const rawProv = body && body.provider
147
+ const prov = (rawProv === 'claude-cli') ? 'claude' : rawProv
148
+ if (!prov || !isProvider(prov)) {
147
149
  writeJson(res, 400, { ok: false, error: { code: 'bad_request', message: 'unknown or missing provider' } })
148
150
  return
149
151
  }
@@ -153,9 +155,8 @@ export function registerAccountsRoutes(ctx, state) {
153
155
  return
154
156
  }
155
157
  try {
156
- const blob = await loadLocalCliBlob(body.provider)
157
- const prov = body.provider
158
- const idx = Number(body.index) || 1
158
+ const blob = await loadLocalCliBlob(prov)
159
+ const idx = idxNum
159
160
  const curSlots = Array.isArray(live().slots) ? live().slots.slice() : []
160
161
  const exists = curSlots.some((s) => s && s.provider === prov && Number(s.index) === idx)
161
162
  if (!exists && getSettingsApi()) {
@@ -211,7 +212,7 @@ export function registerAccountsRoutes(ctx, state) {
211
212
  writeJson(res, 400, { ok: false, error: { code: 'passphrase', message: 'passphrase must be at least 12 characters' } })
212
213
  return
213
214
  }
214
- 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')
215
216
  try {
216
217
  const accounts = []
217
218
  for (const slot of normalizeSlots(live().slots)) {
@@ -1,8 +1,9 @@
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'
5
- import { isTrustedSettingsRequest, queryOf, readBody, safeJsonHandler, writeJson } from '../http.js'
6
+ import { isTrustedSettingsRequest, queryOf, readBody, safeJsonHandler, writeJson, fetchWithTimeout, isTimeoutError } from '../http.js'
6
7
  import { quotaSnapshot } from '../ratelimit.js'
7
8
  import { PROVIDERS, displayName, droppedCredentialRefs, isProvider, oauthRef } from '../refs.js'
8
9
  import { getVendor } from '../vendors/index.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
@@ -257,10 +297,8 @@ export function registerStatusRoutes(ctx, state) {
257
297
  }
258
298
  let capturedQuota = null
259
299
  const probeFetch = async (url, init = {}) => {
260
- const timeoutSignal = AbortSignal.timeout(15_000)
261
- const signal = init.signal ? AbortSignal.any([init.signal, timeoutSignal]) : timeoutSignal
262
300
  const doFetch = (typeof fetchForRef === "function" && fetchForRef(ref)) || fetch
263
- const res2 = await doFetch(url, { ...init, signal })
301
+ const res2 = await fetchWithTimeout(doFetch, url, init, { timeoutMs: 15_000 })
264
302
  try {
265
303
  const snap = quotaSnapshot(provider, res2.headers, null, Date.now())
266
304
  if (snap) { capturedQuota = snap; store.rememberQuota(ref, snap) }
@@ -272,12 +310,14 @@ export function registerStatusRoutes(ctx, state) {
272
310
  const latencyMs = Date.now() - t0;
273
311
  writeJson(res, 200, { ok: true, provider, index: index, ref, latencyMs, email: pmE(blob.email), label: pmL(blob.label), expiresAt: blob.expiresAt || null, quota: capturedQuota || info.quota || null, usagePercent: info.usagePercent ?? null })
274
312
  } catch (e) {
313
+ const isTimeout = isTimeoutError(e)
275
314
  writeJson(res, 200, {
276
315
  ok: false, provider, index: index, ref,
277
316
  latencyMs: Date.now() - t0,
278
317
  email: pmE(blob.email), label: pmL(blob.label), expiresAt: blob.expiresAt || null,
279
318
  quota: capturedQuota || info.quota || null,
280
- error: { code: e && e.code ? e.code : 'VENDOR', message: String(e && e.message || e).slice(0, 300) },
319
+ isTimeout,
320
+ error: { code: isTimeout ? 'TIMEOUT' : (e && e.code ? e.code : 'VENDOR'), message: String(e && e.message || e).slice(0, 300) },
281
321
  })
282
322
  }
283
323
  }),
@@ -384,10 +424,8 @@ export function registerStatusRoutes(ctx, state) {
384
424
  if (typeof vendor.check === 'function') {
385
425
  let capturedQuota = null
386
426
  const probeFetch = async (url, init = {}) => {
387
- const timeoutSignal = AbortSignal.timeout(15_000)
388
- const signal = init.signal ? AbortSignal.any([init.signal, timeoutSignal]) : timeoutSignal
389
427
  const doFetch = (typeof fetchForRef === "function" && fetchForRef(ref)) || fetch
390
- const res2 = await doFetch(url, { ...init, signal })
428
+ const res2 = await fetchWithTimeout(doFetch, url, init, { timeoutMs: 15_000 })
391
429
  try {
392
430
  const snap = quotaSnapshot(provider, res2.headers, null, Date.now())
393
431
  if (snap) { capturedQuota = snap; store.rememberQuota(ref, snap) }
@@ -68,6 +68,11 @@ export class RunawayDetector {
68
68
  }
69
69
  }
70
70
 
71
+ /**
72
+ * @param {AsyncIterable<any>} stream
73
+ * @param {Record<string, any>} [options]
74
+ * @returns {AsyncGenerator<any, void, unknown>}
75
+ */
71
76
  export async function* withRunawayGuard(stream, options = {}) {
72
77
  if (options && options.enabled === false) {
73
78
  yield* stream
@@ -2,10 +2,8 @@ const sessionPins = new Map()
2
2
  const DEFAULT_TTL_MS = 30 * 60 * 1000 // 30 mins
3
3
 
4
4
  /**
5
- * Bind a session id to an account ref for ttlMs (default 30 minutes).
6
- * @param {string|undefined} sessionId
7
- * @param {string|undefined} accountRef
8
- * @param {number} [ttlMs]
5
+ * Prune expired session pin entries.
6
+ * @param {number} [now]
9
7
  * @returns {void}
10
8
  */
11
9
  export function pruneSessionPins(now = Date.now()) {
@@ -16,6 +14,13 @@ export function pruneSessionPins(now = Date.now()) {
16
14
  }
17
15
  }
18
16
 
17
+ /**
18
+ * Bind a session id to an account ref for ttlMs (default 30 minutes).
19
+ * @param {string|undefined} sessionId
20
+ * @param {string|undefined} accountRef
21
+ * @param {number} [ttlMs]
22
+ * @returns {void}
23
+ */
19
24
  export function pinSession(sessionId, accountRef, ttlMs = DEFAULT_TTL_MS) {
20
25
  if (!sessionId || !accountRef) return
21
26
  if (sessionPins.size >= 50) {
package/lib/sse.js CHANGED
@@ -1,3 +1,8 @@
1
+ /**
2
+ * @param {any} body
3
+ * @param {{ idleTimeoutMs?: number, signal?: AbortSignal }} [options]
4
+ * @returns {AsyncGenerator<string, void, unknown>}
5
+ */
1
6
  export async function* iterateSse(body, { idleTimeoutMs = 60000, signal } = {}) {
2
7
  const reader = body && typeof body.getReader === 'function' ? body.getReader() : null
3
8
  const decoder = new TextDecoder()
@@ -38,7 +43,7 @@ export async function* iterateSse(body, { idleTimeoutMs = 60000, signal } = {})
38
43
  const timeoutPromise = new Promise((_, reject) => {
39
44
  if (!idleTimeoutMs || idleTimeoutMs <= 0) return
40
45
  timer = setTimeout(() => {
41
- const err = new Error(`stream idle timeout: no data received for ${Math.round(idleTimeoutMs / 1000)}s`)
46
+ const err = /** @type {Error & { code?: string }} */ (new Error(`stream idle timeout: no data received for ${Math.round(idleTimeoutMs / 1000)}s`))
42
47
  err.code = 'TIMEOUT'
43
48
  reject(err)
44
49
  }, idleTimeoutMs)
@@ -41,7 +41,7 @@ export async function* streamWithRotation({
41
41
  return
42
42
  }
43
43
  if (lastError) throw lastError
44
- const err = new Error('no usable subscription account for this provider')
44
+ const err = /** @type {Error & { code?: string }} */ (new Error('no usable subscription account for this provider'))
45
45
  err.code = 'RATE_LIMIT'
46
46
  throw err
47
47
  }
@@ -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')