@goodandready/dsh-subscriptions 0.6.72 → 0.6.83

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.
Files changed (49) hide show
  1. package/README.md +8 -0
  2. package/README.ru.md +8 -0
  3. package/README.zh.md +8 -0
  4. package/lib/accounts-view.js +97 -5
  5. package/lib/accounts.js +137 -11
  6. package/lib/adapter.js +3 -0
  7. package/lib/alerts.js +35 -0
  8. package/lib/analytics.js +31 -2
  9. package/lib/billing.js +99 -0
  10. package/lib/blob.js +22 -0
  11. package/lib/capabilities.js +208 -0
  12. package/lib/client.js +344 -17
  13. package/lib/config-schema.js +50 -4
  14. package/lib/diagnostic-wizard.js +2 -5
  15. package/lib/diagnostics.js +2 -2
  16. package/lib/error-buffer.js +2 -2
  17. package/lib/forecast.js +56 -0
  18. package/lib/headless-auth.js +392 -0
  19. package/lib/history.js +36 -0
  20. package/lib/http.js +67 -4
  21. package/lib/import-auth.js +3 -6
  22. package/lib/index.js +99 -11
  23. package/lib/jwt.js +84 -17
  24. package/lib/loopback.js +3 -2
  25. package/lib/mask.js +16 -0
  26. package/lib/oauth-pending.js +12 -0
  27. package/lib/pricing.js +89 -0
  28. package/lib/refs.js +2 -0
  29. package/lib/reset-clock.js +55 -0
  30. package/lib/rotate.js +18 -4
  31. package/lib/routes/accounts.js +142 -7
  32. package/lib/routes/oauth.js +51 -2
  33. package/lib/routes/proxy.js +14 -0
  34. package/lib/scope-inspector.js +51 -0
  35. package/lib/stream-rotate.js +31 -10
  36. package/lib/subscriptions.js +24 -1
  37. package/lib/updater.js +21 -0
  38. package/lib/vault-sync.js +414 -0
  39. package/lib/vault.js +1 -1
  40. package/lib/vendor-factory.js +6 -2
  41. package/lib/vendors/antigravity.js +13 -4
  42. package/lib/vendors/claude.js +56 -8
  43. package/lib/vendors/codex.js +34 -6
  44. package/lib/vendors/copilot-cli.js +36 -5
  45. package/lib/vendors/ernie.js +9 -2
  46. package/lib/vendors/grok.js +66 -6
  47. package/lib/vendors/jetbrains.js +136 -13
  48. package/lib/vendors/perplexity.js +151 -17
  49. package/package.json +1 -1
@@ -1,9 +1,12 @@
1
1
  import { parseHeaders } from './ratelimit.js'
2
+ import { formatRelativeReset } from './relative-time.js'
2
3
 
3
4
  export const VENDOR_WINDOW_MS = {
4
5
  claude: 5 * 3600 * 1000,
5
6
  codex: 3 * 3600 * 1000,
6
7
  grok: 2 * 3600 * 1000,
8
+ copilot: 24 * 3600 * 1000,
9
+ antigravity: 24 * 3600 * 1000,
7
10
  }
8
11
 
9
12
  export function parseResetFromText(message, nowMs = Date.now()) {
@@ -85,3 +88,55 @@ export function resolveAccurateReset(err, account, provider, nowMs = Date.now(),
85
88
 
86
89
  return null
87
90
  }
91
+
92
+ export function formatRollingCountdown(resetAt, lang = 'en', now = Date.now()) {
93
+ const ts = Number(resetAt)
94
+ if (!Number.isFinite(ts) || ts <= 0) return ''
95
+ const d = new Date(ts)
96
+ const pad = (n) => String(n).padStart(2, '0')
97
+ const timeStr = `${pad(d.getHours())}:${pad(d.getMinutes())}`
98
+ const relStr = formatRelativeReset(ts, lang, now)
99
+ if (lang === 'zh') {
100
+ return `额度重置于 ${timeStr} (${relStr})`
101
+ }
102
+ return `Window resets at ${timeStr} (${relStr})`
103
+ }
104
+
105
+ export function computeEffectiveResetAt(account, provider, now = Date.now()) {
106
+ if (!account) return null
107
+ const nowMs = Number(now) || Date.now()
108
+ const candidates = []
109
+
110
+ if (account.quota && account.quota.resetAt) {
111
+ const r = Number(account.quota.resetAt)
112
+ if (Number.isFinite(r) && r > nowMs) candidates.push(r)
113
+ }
114
+ if (account.cooldownUntil) {
115
+ const c = Number(account.cooldownUntil)
116
+ if (Number.isFinite(c) && c > nowMs) candidates.push(c)
117
+ }
118
+ if (account.quarantineUntil) {
119
+ const q = Number(account.quarantineUntil)
120
+ if (Number.isFinite(q) && q > nowMs) candidates.push(q)
121
+ }
122
+ if (Array.isArray(account.usage)) {
123
+ for (const w of account.usage) {
124
+ if (w && w.resetAt) {
125
+ const wr = Number(w.resetAt)
126
+ if (Number.isFinite(wr) && wr > nowMs) candidates.push(wr)
127
+ }
128
+ }
129
+ }
130
+
131
+ if (candidates.length > 0) {
132
+ return Math.min(...candidates)
133
+ }
134
+
135
+ const p = String(provider || (account && account.provider) || '').toLowerCase()
136
+ const windowDefault = VENDOR_WINDOW_MS[p]
137
+ if (windowDefault && account.usagePercent != null && Number(account.usagePercent) >= 100) {
138
+ return nowMs + windowDefault
139
+ }
140
+
141
+ return null
142
+ }
package/lib/rotate.js CHANGED
@@ -17,14 +17,28 @@ export function isRegionError(err) {
17
17
  return status === 400 && (/FAILED_PRECONDITION/i.test(msg) || /User location is not supported/i.test(msg))
18
18
  }
19
19
 
20
+ export function isAuthError(err) {
21
+ if (!err || typeof err !== "object") return false
22
+ const status = Number(err.status || err.statusCode) || 0
23
+ if (status === 401) return true
24
+ const code = String(err.code || (err.failure && err.failure.code) || "").toUpperCase()
25
+ if (code === "AUTH" || code === "TOKEN_REVOKED" || code === "UNAUTHORIZED" || code === "INVALID_TOKEN" || code === "SESSION_EXPIRED") return true
26
+ const msg = String(err.message || "")
27
+ if (/invalid_grant/i.test(msg)) return true
28
+ if (/token (?:has been )?revoked/i.test(msg)) return true
29
+ if (/session (?:expired|revoked|invalid)/i.test(msg)) return true
30
+ if (/unauthorized/i.test(msg) && (status === 0 || status === 400 || status === 401)) return true
31
+ return false
32
+ }
33
+
20
34
  export function isSwitchableError(err) {
21
- if (!err || typeof err !== 'object') return false
22
- if (err.name === 'TimeoutError' || err.code === 'TIMEOUT' || err.code === 'EMPTY_RESPONSE' || err.code === 'SOFT_FAILURE' || err.isSoftFailure) return true
23
- if (err.name === 'AbortError' || err.code === 'ABORT_ERR' || err.code === 'ERR_ABORTED' || (err.message && /aborted/i.test(err.message))) return false
35
+ if (!err || typeof err !== "object") return false
36
+ if (err.name === "TimeoutError" || err.code === "TIMEOUT" || err.code === "EMPTY_RESPONSE" || err.code === "SOFT_FAILURE" || err.isSoftFailure) return true
37
+ if (err.name === "AbortError" || err.code === "ABORT_ERR" || err.code === "ERR_ABORTED" || (err.message && /aborted/i.test(err.message))) return false
24
38
  const code = err.code || (err.failure && err.failure.code)
25
39
  if (SWITCH_CODES.has(code)) return true
26
40
  if (isRegionError(err)) return true
27
- const status = err.status || err.statusCode
41
+ const status = Number(err.status || err.statusCode) || 0
28
42
  return status === 401 || status === 403 || status === 429 || status === 500 || status === 502 || status === 503
29
43
  }
30
44
 
@@ -1,12 +1,14 @@
1
1
  import { diagnoseVendorError } from '../doctor.js'
2
2
  import { exportVault, importVault } from '../vault.js'
3
+ import { syncVaultPush, syncVaultPull, getVaultSyncStatus } from '../vault-sync.js'
3
4
  import { normalizeSlots, vendorConfig } from '../accounts.js'
4
5
  import { getVendor } from '../vendors/index.js'
5
6
  import { notifyQuarantineEntered, notifyQuarantineReleased } from '../alerts.js'
6
- import { Config, plainConfig, publicConfig } from '../config-schema.js'
7
+ import { Config, plainConfig, publicConfig, revisionOf } from '../config-schema.js'
7
8
  import { decryptWithPassphrase, encryptWithPassphrase } from '../crypto.js'
8
9
  import { isTrustedSettingsRequest, queryOf, readBody, safeJsonHandler, writeJson } from '../http.js'
9
10
  import { discoverLocalCliSessions, loadLocalCliBlob } from '../import-auth.js'
11
+ import { validateCredentialBlob } from '../blob.js'
10
12
  import { isProvider, oauthRef, parseOauthRef } from '../refs.js'
11
13
 
12
14
  export function registerAccountsRoutes(ctx, state) {
@@ -275,18 +277,20 @@ export function registerAccountsRoutes(ctx, state) {
275
277
  const curSlots = Array.isArray(live().slots) ? live().slots.slice() : []
276
278
  const exists = curSlots.some((s) => s && s.provider === prov && Number(s.index) === idx)
277
279
  if (!exists && getSettingsApi()) {
280
+ const settings = getSettingsApi()
281
+ const revision = revisionOf(settings)
278
282
  const nextSlots = curSlots.concat([{
279
283
  provider: prov,
280
284
  index: idx,
281
285
  label: blob.email || '',
282
286
  }])
283
287
  const parsed = plainConfig(Config({ ...live(), slots: stripLegacySlots(nextSlots) }))
284
- await getSettingsApi().replace(parsed)
288
+ await settings.replace(parsed, revision)
285
289
  syncCustomVendors()
286
290
  }
287
291
  const slot = normalizeSlots(live().slots).find((s) => s.provider === prov && s.index === idx)
288
292
  const ref = slot ? slot.ref : oauthRef(prov, idx)
289
- await store.saveBlob(ref, blob)
293
+ await store.saveBlob(ref, validateCredentialBlob(blob))
290
294
  await syncAdapter()
291
295
  refreshModels().catch(() => {})
292
296
  writeJson(res, 200, {
@@ -388,7 +392,7 @@ export function registerAccountsRoutes(ctx, state) {
388
392
  for (const row of bundle.accounts) {
389
393
  try {
390
394
  if (!row || typeof row.ref !== 'string' || !parseOauthRef(row.ref) || !row.blob) continue
391
- await store.saveBlob(row.ref, row.blob)
395
+ await store.saveBlob(row.ref, validateCredentialBlob(row.blob))
392
396
  imported++
393
397
  } catch { /* skip broken */ }
394
398
  }
@@ -436,7 +440,7 @@ export function registerAccountsRoutes(ctx, state) {
436
440
  const ref = oauthRef(provider, index)
437
441
  const blob = { accessToken: apiKey || refreshToken, refreshToken: refreshToken || apiKey }
438
442
  if (apiKey) { blob.apiKey = apiKey; blob.apiKeyOnly = true }
439
- await store.saveBlob(ref, blob)
443
+ await store.saveBlob(ref, validateCredentialBlob(blob))
440
444
  await syncAdapter()
441
445
  refreshModels().catch(() => {})
442
446
  writeJson(res, 200, { ok: true, ref, accounts: await accountsView() })
@@ -518,6 +522,69 @@ export function registerAccountsRoutes(ctx, state) {
518
522
 
519
523
 
520
524
 
525
+ // Issue #549: Switch active organization/workspace for an account slot
526
+ ctx.effect(() => ctx.webServer.register({
527
+ kind: 'exact',
528
+ path: '/dsh-subscriptions/accounts/switch-organization',
529
+ handler: safeJsonHandler(async (req, res) => {
530
+ if (req.method !== 'POST') {
531
+ writeJson(res, 405, { ok: false, error: { code: 'method', message: 'POST only' } })
532
+ return
533
+ }
534
+ if (!isTrustedSettingsRequest(req, ctx)) {
535
+ writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'same-origin only' } })
536
+ return
537
+ }
538
+ let payload
539
+ try { payload = JSON.parse((await readBody(req, 8 * 1024)).toString('utf8') || '{}') } catch {
540
+ writeJson(res, 400, { ok: false, error: { code: 'json', message: 'invalid json' } })
541
+ return
542
+ }
543
+ const provider = String(payload.provider || '')
544
+ const index = Number(payload.index || 1)
545
+ const organizationId = String(payload.organizationId || payload.selectedOrgId || '').trim()
546
+ if (!isProvider(provider)) {
547
+ writeJson(res, 400, { ok: false, error: { code: 'provider', message: 'unknown provider' } })
548
+ return
549
+ }
550
+ const ref = refForSlot(provider, index)
551
+ if (!ref) {
552
+ writeJson(res, 404, { ok: false, error: { code: 'slot', message: 'slot not found' } })
553
+ return
554
+ }
555
+ let blob
556
+ try { blob = await store.loadBlob(ref) } catch {
557
+ writeJson(res, 404, { ok: false, error: { code: 'blob', message: 'credentials not configured' } })
558
+ return
559
+ }
560
+
561
+ blob.selectedOrgId = organizationId
562
+ if (provider === 'codex') blob.accountId = organizationId
563
+ if (provider === 'antigravity') blob.projectId = organizationId
564
+ if (provider === 'claude') blob.organizationId = organizationId
565
+ if (provider === 'copilot') blob.organization = organizationId
566
+
567
+ if (Array.isArray(blob.organizations)) {
568
+ const found = blob.organizations.find((o) => o && o.id === organizationId)
569
+ if (found) blob.selectedOrgName = found.name
570
+ else if (organizationId) blob.selectedOrgName = organizationId
571
+ } else if (organizationId) {
572
+ blob.selectedOrgName = organizationId
573
+ }
574
+
575
+ await store.saveBlob(ref, blob)
576
+ await syncAdapter()
577
+
578
+ writeJson(res, 200, {
579
+ ok: true,
580
+ ref,
581
+ selectedOrgId: organizationId,
582
+ selectedOrgName: blob.selectedOrgName || organizationId,
583
+ accounts: await accountsView(),
584
+ })
585
+ }),
586
+ }), 'dsh-subscriptions: /accounts/switch-organization')
587
+
521
588
  ctx.effect(() => ctx.webServer.register({
522
589
  kind: 'exact',
523
590
  path: '/dsh-subscriptions/logout',
@@ -610,6 +677,7 @@ export function registerAccountsRoutes(ctx, state) {
610
677
  try {
611
678
  const result = importVault(vault, passphrase)
612
679
  const settings = getSettingsApi()
680
+ const revision = revisionOf(settings)
613
681
  let parsed
614
682
  if (result.slots.length) {
615
683
  if (!settings) throw new Error('settings are unavailable for vault import')
@@ -626,11 +694,11 @@ export function registerAccountsRoutes(ctx, state) {
626
694
  }
627
695
  let blobCount = 0
628
696
  for (const [ref, blob] of Object.entries(result.blobs)) {
629
- await store.saveBlob(ref, blob)
697
+ await store.saveBlob(ref, validateCredentialBlob(blob))
630
698
  blobCount++
631
699
  }
632
700
  if (parsed) {
633
- await settings.replace(parsed)
701
+ await settings.replace(parsed, revision)
634
702
  syncCustomVendors()
635
703
  }
636
704
  await syncAdapter()
@@ -660,4 +728,71 @@ export function registerAccountsRoutes(ctx, state) {
660
728
  handler: handleVaultImport,
661
729
  }), 'dsh-subscriptions: /import-backup')
662
730
 
731
+
732
+ // #562: Remote Vault Sync Push
733
+ ctx.effect(() => ctx.webServer.register({
734
+ kind: 'exact',
735
+ path: '/dsh-subscriptions/vault/sync/push',
736
+ handler: safeJsonHandler(async (req, res) => {
737
+ if (req.method !== 'POST') {
738
+ writeJson(res, 405, { ok: false, error: { code: 'method', message: 'POST only' } })
739
+ return
740
+ }
741
+ if (!isTrustedSettingsRequest(req, ctx)) {
742
+ writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'same-origin only' } })
743
+ return
744
+ }
745
+ try {
746
+ const result = await syncVaultPush({ live: live(), store })
747
+ writeJson(res, 200, { ok: true, result, status: getVaultSyncStatus() })
748
+ } catch (e) {
749
+ writeJson(res, 400, { ok: false, error: { code: 'vault_sync_push', message: String(e && e.message || e) } })
750
+ }
751
+ }),
752
+ }), 'dsh-subscriptions: /vault/sync/push')
753
+
754
+ // #562: Remote Vault Sync Pull
755
+ ctx.effect(() => ctx.webServer.register({
756
+ kind: 'exact',
757
+ path: '/dsh-subscriptions/vault/sync/pull',
758
+ handler: safeJsonHandler(async (req, res) => {
759
+ if (req.method !== 'POST') {
760
+ writeJson(res, 405, { ok: false, error: { code: 'method', message: 'POST only' } })
761
+ return
762
+ }
763
+ if (!isTrustedSettingsRequest(req, ctx)) {
764
+ writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'same-origin only' } })
765
+ return
766
+ }
767
+ try {
768
+ const result = await syncVaultPull({
769
+ live: live(),
770
+ store,
771
+ settingsApi: getSettingsApi(),
772
+ syncAdapter,
773
+ refreshModels,
774
+ })
775
+ writeJson(res, 200, { ok: true, result, status: getVaultSyncStatus(), accounts: await accountsView() })
776
+ } catch (e) {
777
+ writeJson(res, 400, { ok: false, error: { code: 'vault_sync_pull', message: String(e && e.message || e) } })
778
+ }
779
+ }),
780
+ }), 'dsh-subscriptions: /vault/sync/pull')
781
+
782
+ // #562: Remote Vault Sync Status
783
+ ctx.effect(() => ctx.webServer.register({
784
+ kind: 'exact',
785
+ path: '/dsh-subscriptions/vault/sync/status',
786
+ handler: safeJsonHandler(async (req, res) => {
787
+ if (req.method !== 'GET') {
788
+ writeJson(res, 405, { ok: false, error: { code: 'method', message: 'GET only' } })
789
+ return
790
+ }
791
+ if (!isTrustedSettingsRequest(req, ctx)) {
792
+ writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'same-origin only' } })
793
+ return
794
+ }
795
+ writeJson(res, 200, { ok: true, status: getVaultSyncStatus() })
796
+ }),
797
+ }), 'dsh-subscriptions: /vault/sync/status')
663
798
  }
@@ -5,6 +5,8 @@ import { parseCallbackInput, requestOrigin } from '../oauth.js'
5
5
  import { createPkce } from '../pkce.js'
6
6
  import { displayName, isProvider, oauthRef } from '../refs.js'
7
7
  import { getVendor } from '../vendors/index.js'
8
+ import { getHeadlessAuthInfo, handleHeadlessRedirect } from '../headless-auth.js'
9
+ import { syncVaultPush } from '../vault-sync.js'
8
10
 
9
11
  export function registerOauthRoutes(ctx, state) {
10
12
  const {
@@ -44,6 +46,7 @@ export function registerOauthRoutes(ctx, state) {
44
46
  }
45
47
  const origin = requestOrigin(req)
46
48
  const redirectUri = redirectFor(provider, `${provider}:${index}`, origin)
49
+ sweepPending(Date.now())
47
50
  const pkce = await createPkce()
48
51
  pending.set(pkce.state, {
49
52
  provider,
@@ -89,7 +92,10 @@ export function registerOauthRoutes(ctx, state) {
89
92
  }
90
93
  } catch { autoCatch = false }
91
94
  }
92
- writeJson(res, 200, { ok: true, url, state: pkce.state, redirectUri, autoCatch })
95
+ const host = (req.headers && req.headers.host) || ''
96
+ const user = process.env.USER || process.env.USERNAME || 'user'
97
+ const headless = getHeadlessAuthInfo({ provider, redirectUri, authUrl: url, host, user })
98
+ writeJson(res, 200, { ok: true, url, state: pkce.state, redirectUri, autoCatch, headless })
93
99
  }),
94
100
  }), 'dsh-subscriptions: /oauth/start')
95
101
 
@@ -143,7 +149,13 @@ export function registerOauthRoutes(ctx, state) {
143
149
  intervalMs: start.intervalMs,
144
150
  createdAt: Date.now(),
145
151
  })
146
- writeJson(res, 200, { ok: true, state, userCode: start.userCode, authUrl: start.authUrl, intervalMs: start.intervalMs })
152
+ const host = (req.headers && req.headers.host) || ''
153
+ const user = process.env.USER || process.env.USERNAME || 'user'
154
+ const headless = getHeadlessAuthInfo({ provider, redirectUri: '', authUrl: start.authUrl, host, user })
155
+ if (headless.terminalQr) {
156
+ console.log('[dsh-subscriptions] Device OAuth initiated for ' + provider + ':\n' + headless.terminalQr)
157
+ }
158
+ writeJson(res, 200, { ok: true, state, userCode: start.userCode, authUrl: start.authUrl, intervalMs: start.intervalMs, headless })
147
159
  } catch (e) {
148
160
  writeJson(res, 502, { ok: false, error: { code: e && e.code || 'DEVICE', message: String(e && e.message || e).slice(0, 300) } })
149
161
  }
@@ -193,6 +205,9 @@ export function registerOauthRoutes(ctx, state) {
193
205
  pending.delete(state)
194
206
  await syncAdapter()
195
207
  refreshModels().catch(() => {})
208
+ if (live().vaultSyncEnabled && live().vaultSyncAutoPush) {
209
+ syncVaultPush({ live: live(), store }).catch(() => {})
210
+ }
196
211
  writeJson(res, 200, { ok: true, status: 'authorized', ref: row.ref, label: pmL(blob.label || blob.email) || displayName(row.provider) })
197
212
  } catch (e) {
198
213
  writeJson(res, 502, { ok: false, error: { code: e && e.code || 'DEVICE', message: String(e && e.message || e).slice(0, 300) } })
@@ -261,4 +276,38 @@ export function registerOauthRoutes(ctx, state) {
261
276
  }
262
277
  }),
263
278
  }), 'dsh-subscriptions: /oauth/complete')
279
+
280
+ // #557: Headless auth info helper endpoint
281
+ ctx.effect(() => ctx.webServer.register({
282
+ kind: 'exact',
283
+ path: '/dsh-subscriptions/oauth/headless-info',
284
+ handler: safeJsonHandler(async (req, res) => {
285
+ if (req.method !== 'GET') {
286
+ writeJson(res, 405, { ok: false, error: { code: 'method', message: 'GET only' } })
287
+ return
288
+ }
289
+ const q = queryOf(req)
290
+ const provider = q.get('provider') || ''
291
+ const redirectUri = q.get('redirectUri') || ''
292
+ const authUrl = q.get('authUrl') || ''
293
+ const host = (req.headers && req.headers.host) || ''
294
+ const user = process.env.USER || process.env.USERNAME || 'user'
295
+ const info = getHeadlessAuthInfo({ provider, redirectUri, authUrl, host, user })
296
+ writeJson(res, 200, { ok: true, ...info })
297
+ }),
298
+ }), 'dsh-subscriptions: /oauth/headless-info')
299
+
300
+ // #557: Headless re-auth callback redirector
301
+ ctx.effect(() => ctx.webServer.register({
302
+ kind: 'exact',
303
+ path: '/dsh-subscriptions/oauth/headless-redirect',
304
+ handler: (req, res) => {
305
+ if (!isTrustedSettingsRequest(req, ctx)) {
306
+ res.writeHead(403, { 'Content-Type': 'text/html; charset=utf-8' })
307
+ res.end('<!doctype html><meta charset="utf-8"><p>Forbidden: same-origin or loopback requests only.</p>')
308
+ return
309
+ }
310
+ return handleHeadlessRedirect(req, res, { pending, completeOAuth, accountsView })
311
+ },
312
+ }), 'dsh-subscriptions: /oauth/headless-redirect')
264
313
  }
@@ -58,6 +58,20 @@ export function registerProxyRoutes(ctx, state) {
58
58
  writeJson(res, 404, { ok: false, error: { code: 'provider', message: 'unknown provider' } })
59
59
  return
60
60
  }
61
+ const hasBackslashOrInvalid = parts.some((p) => {
62
+ if (p.includes("\\")) return true
63
+ try {
64
+ const dec = decodeURIComponent(p)
65
+ if (dec.includes("\\")) return true
66
+ } catch {
67
+ return true
68
+ }
69
+ return false
70
+ })
71
+ if (hasBackslashOrInvalid) {
72
+ writeJson(res, 400, { ok: false, error: { code: 'path', message: 'invalid path' } })
73
+ return
74
+ }
61
75
  let body
62
76
  if (req.method === 'POST') {
63
77
  try {
@@ -0,0 +1,51 @@
1
+ export const REQUIRED_SCOPES = {
2
+ antigravity: [
3
+ { scope: "https://www.googleapis.com/auth/cloud-platform", description: "Google Cloud Platform & Code Assist" },
4
+ ],
5
+ codex: [
6
+ { scope: "openid", description: "OpenID authentication" },
7
+ { scope: "https://api.openai.com/auth", description: "ChatGPT API authorization" },
8
+ ],
9
+ claude: [
10
+ { scope: "user:profile", description: "Anthropic user profile" },
11
+ ],
12
+ copilot: [
13
+ { scope: "read:user", description: "GitHub user read" },
14
+ ],
15
+ grok: [
16
+ { scope: "openid", description: "OpenID authentication" },
17
+ ],
18
+ }
19
+
20
+ /**
21
+ * Inspect scopes granted by provider against required scopes.
22
+ * @param {string} provider
23
+ * @param {string[]|string} rawScopes
24
+ * @returns {{ scopes: string[], valid: boolean, missing: string[], warning: string, scopeWarning: string }}
25
+ */
26
+ export function inspectScopes(provider, rawScopes) {
27
+ const scopes = Array.isArray(rawScopes)
28
+ ? rawScopes.map(String).filter(Boolean)
29
+ : String(rawScopes || "").split(/[\s,]+/).filter(Boolean)
30
+
31
+ const reqList = REQUIRED_SCOPES[provider] || []
32
+ if (reqList.length === 0 || scopes.length === 0) {
33
+ return { scopes, valid: true, missing: [], warning: "", scopeWarning: "" }
34
+ }
35
+
36
+ const missing = reqList.filter((r) =>
37
+ !scopes.some((s) => s.toLowerCase() === r.scope.toLowerCase())
38
+ )
39
+
40
+ const warning = missing.length > 0
41
+ ? `Missing required OAuth scope: ${missing.map((m) => m.description || m.scope).join(", ")}`
42
+ : ""
43
+
44
+ return {
45
+ scopes,
46
+ valid: missing.length === 0,
47
+ missing: missing.map((m) => m.scope),
48
+ warning,
49
+ scopeWarning: warning,
50
+ }
51
+ }
@@ -1,6 +1,7 @@
1
+ import { inspectAccountCapabilities, normalizeCapabilitiesRequest } from './capabilities.js'
1
2
  import { defaultConcurrencyQueue } from './concurrency-queue.js'
2
3
  import { recordSessionCacheUsage } from './session-pin.js'
3
- import { pickAccountWithTrace, markCooldown, isSwitchableError, isRegionError, modelFamily } from './rotate.js'
4
+ import { pickAccountWithTrace, markCooldown, isSwitchableError, isRegionError, isAuthError, modelFamily } from './rotate.js'
4
5
  import { putInQuarantine, REASON_RATE_LIMIT, REASON_HARD_LIMIT, REASON_REVOKED, REASON_DEGRADED } from './quarantine.js'
5
6
  import { notifySessionExpired, notifyQuarantineEntered } from './alerts.js'
6
7
  import { withRunawayGuard } from './runaway-guard.js'
@@ -10,7 +11,7 @@ import { resolveAccurateReset } from './reset-clock.js'
10
11
 
11
12
  export async function* streamWithRotation({
12
13
  accounts,
13
- nowMs,
14
+ nowMs = () => Date.now(),
14
15
  cooldownMs,
15
16
  switchAtRemaining,
16
17
  streamOnce,
@@ -111,8 +112,20 @@ export async function* streamWithRotation({
111
112
  const queueConfig = {
112
113
  signal: options && options.signal,
113
114
  timeoutMs: options && (options.queueTimeoutMs !== undefined ? options.queueTimeoutMs : (options.config && options.config.queueTimeoutMs)),
114
- maxConcurrency: options && (options.accountMaxConcurrency !== undefined ? options.accountMaxConcurrency : (options.config && options.config.accountMaxConcurrency)),
115
- maxQueueSize: options && (options.maxQueueSize !== undefined ? options.maxQueueSize : (options.config && options.config.maxQueueSize)),
115
+ maxConcurrency: account.maxConcurrency !== undefined
116
+ ? account.maxConcurrency
117
+ : (options && options.accountMaxConcurrency !== undefined
118
+ ? options.accountMaxConcurrency
119
+ : (options && options.config && options.config.accountMaxConcurrency !== undefined
120
+ ? options.config.accountMaxConcurrency
121
+ : 1)),
122
+ maxQueueSize: account.maxQueueSize !== undefined
123
+ ? account.maxQueueSize
124
+ : (options && options.maxQueueSize !== undefined
125
+ ? options.maxQueueSize
126
+ : (options && options.config && options.config.maxQueueSize !== undefined
127
+ ? options.config.maxQueueSize
128
+ : 20)),
116
129
  enabled: options && (options.enableQueue !== undefined ? options.enableQueue : (options.config && options.config.enableQueue !== undefined ? options.config.enableQueue : true)),
117
130
  }
118
131
 
@@ -146,15 +159,19 @@ export async function* streamWithRotation({
146
159
  runawayState.reason = reason
147
160
  },
148
161
  }
162
+ const accountCaps = account.capabilities || inspectAccountCapabilities(account.provider || options?.provider, account, account)
163
+ const adaptedOptions = normalizeCapabilitiesRequest(options, accountCaps)
149
164
  const guardedStream = withRunawayGuard(
150
- streamOnce(account, { ...options, _runawayState: runawayState }),
165
+ streamOnce(account, { ...adaptedOptions, _runawayState: runawayState }),
151
166
  runawayOpts
152
167
  )
153
168
  const sentineledStream = withSoftFailureSentinel(guardedStream, {
154
169
  enabled: (options?.enableSoftFailureSentinel !== false) && (options?.config?.enableSoftFailureSentinel !== false),
155
170
  })
156
171
  for await (const chunk of sentineledStream) {
157
- firstChunkDelivered = true
172
+ if (chunk && (chunk.type === 'text-delta' || chunk.type === 'delta' || chunk.text || chunk.content)) {
173
+ firstChunkDelivered = true
174
+ }
158
175
  const cachedTokens = Number(
159
176
  (chunk && chunk.usage && (chunk.usage.cacheReadTokens || chunk.usage.cachedTokens)) ||
160
177
  (chunk && chunk.tokens && (chunk.tokens.cacheRead || chunk.tokens.cached)) ||
@@ -191,7 +208,7 @@ export async function* streamWithRotation({
191
208
  continue
192
209
  }
193
210
 
194
- if (!isSwitchableError(err)) {
211
+ if (!isSwitchableError(err) && !isAuthError(err)) {
195
212
  currentAttempt.status = 'fatal_error'
196
213
  currentAttempt.error = err ? (err.message || String(err)) : 'unknown'
197
214
  syncTrace('fatal_error')
@@ -203,9 +220,10 @@ export async function* streamWithRotation({
203
220
 
204
221
  const accurateUntil = resolveAccurateReset(err, account, options && options.provider, nowMs())
205
222
  const isSoft = Boolean(err && (err.code === 'SOFT_FAILURE' || err.isSoftFailure))
223
+ const isAuth = isAuthError(err)
206
224
 
207
225
  // Move slot to cooldown and quarantine (#172, #538)
208
- const effectiveCooldownMs = isSoft ? Math.min(cooldownMs, 5 * 60 * 1000) : cooldownMs
226
+ const effectiveCooldownMs = isSoft ? Math.min(cooldownMs, 5 * 60 * 1000) : (isAuth ? 86400000 : cooldownMs)
209
227
  const cooled = markCooldown(account, nowMs(), effectiveCooldownMs, modelFamily(options && options.provider, options && options.model), accurateUntil)
210
228
  account.cooldownUntil = cooled.cooldownUntil
211
229
  if (cooled.cooldownFamilies) account.cooldownFamilies = cooled.cooldownFamilies
@@ -214,7 +232,7 @@ export async function* streamWithRotation({
214
232
  const code = String(err && err.code || '')
215
233
  const reason = isSoft
216
234
  ? REASON_DEGRADED
217
- : (status === 401 || code === 'AUTH' || code === 'TOKEN_REVOKED')
235
+ : (isAuth || status === 401 || code === 'AUTH' || code === 'TOKEN_REVOKED')
218
236
  ? REASON_REVOKED
219
237
  : (status === 403 || code === 'HARD_LIMIT' || code === 'LICENSE_REQUIRED')
220
238
  ? REASON_HARD_LIMIT
@@ -226,11 +244,14 @@ export async function* streamWithRotation({
226
244
  account.quarantineAttempts = err.warmupAccount.quarantineAttempts
227
245
  account.status = err.warmupAccount.status
228
246
  } else {
229
- const quarantined = putInQuarantine(account, reason, nowMs(), accurateUntil)
247
+ const quarantined = putInQuarantine(account, reason, nowMs(), accurateUntil || (nowMs() + (isAuth ? 86400000 : 3600000)))
230
248
  account.quarantineUntil = quarantined.quarantineUntil
231
249
  account.quarantineReason = quarantined.quarantineReason
232
250
  account.quarantineAttempts = (account.quarantineAttempts || 0) + 1
233
251
  }
252
+ if (isAuth || reason === REASON_REVOKED) {
253
+ account.isRevoked = true
254
+ }
234
255
 
235
256
  currentAttempt.quarantineReason = account.quarantineReason || reason
236
257
  currentAttempt.quarantineUntil = account.quarantineUntil
@@ -5,16 +5,39 @@ import { pickFetch } from "./proxy.js"
5
5
  import { fetchWithTimeout } from "./http.js"
6
6
 
7
7
  // ponytail: allowlist per provider — add path to extend without touching request logic
8
+ const OPENAI_COMPAT = [
9
+ "/models",
10
+ "/v1/models",
11
+ "/chat/completions",
12
+ "/v1/chat/completions",
13
+ "/responses",
14
+ "/v1/responses",
15
+ "/images/generations",
16
+ "/v1/images/generations",
17
+ ]
18
+
8
19
  export const ALLOWLIST = {
9
20
  codex: ["/responses", "/models", "/images/generations", "/backend-api/codex/responses", "/backend-api/codex/models", "/backend-api/codex/images/generations"],
10
21
  claude: ["/v1/messages", "/api/oauth/profile", "/api/oauth/usage", "/v1/models"],
11
22
  grok: ["/v1/models", "/responses", "/v1/responses", "/v1/billing", "/v1/chat/completions", "/images/generations", "/v1/images/generations"],
12
23
  antigravity: ["/v1/models", "/v1/generateContent", "/v1/streamGenerateContent", "/v1/loadCodeAssist", "/v1/fetchAvailableModels", "/v1/countTokens", "/v1/internal:loadCodeAssist"],
24
+ kimi: OPENAI_COMPAT,
25
+ glm: [...OPENAI_COMPAT, "/api/paas/v4/chat/completions", "/api/paas/v4/models"],
26
+ cursor: OPENAI_COMPAT,
27
+ kiro: OPENAI_COMPAT,
28
+ copilot: OPENAI_COMPAT,
29
+ qwen: [...OPENAI_COMPAT, "/compatible-mode/v1/chat/completions", "/compatible-mode/v1/models"],
30
+ ernie: [...OPENAI_COMPAT, "/rpc/2.0/ai_custom/v1/wenxinworkshop/chat", "/oauth/2.0/token"],
31
+ spark: OPENAI_COMPAT,
32
+ jetbrains: OPENAI_COMPAT,
33
+ perplexity: [...OPENAI_COMPAT, "/api/v1/user/profile", "/api/v1/user/usage"],
34
+ replit: [...OPENAI_COMPAT, "/api/v1/ai"],
35
+ cody: OPENAI_COMPAT,
13
36
  }
14
37
 
15
38
  export function isAllowed(provider, path) {
16
39
  if (!isProvider(provider)) return false
17
- const list = ALLOWLIST[provider] || []
40
+ const list = ALLOWLIST[provider] || OPENAI_COMPAT
18
41
  let p = String(path || "")
19
42
  // strip query and hash
20
43
  p = p.split("?")[0].split("#")[0]
package/lib/updater.js CHANGED
@@ -24,6 +24,27 @@ export function isLoopback(value) {
24
24
  }
25
25
 
26
26
  export function isTrustedUpdateRequest(request, targetCtx) {
27
+ const secFetchSite = String(header(request, 'sec-fetch-site') || '').toLowerCase()
28
+ if (secFetchSite === 'cross-site') return false
29
+
30
+ const origin = header(request, 'origin')
31
+ const host = header(request, 'host')
32
+ if (origin && host) {
33
+ try {
34
+ const u = new URL(origin)
35
+ if (u.protocol === 'http:' || u.protocol === 'https:') {
36
+ if (u.host.toLowerCase() !== host.toLowerCase()) return false
37
+ } else if (u.protocol === 'dsh-app:') {
38
+ const appHost = u.host.toLowerCase()
39
+ if (appHost && appHost !== 'app' && appHost !== 'localhost') return false
40
+ } else {
41
+ return false
42
+ }
43
+ } catch {
44
+ return false
45
+ }
46
+ }
47
+
27
48
  const customHeader = header(request, UPDATE_HEADER)
28
49
  if (customHeader === '1') {
29
50
  const remote = request.socket?.remoteAddress