@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.
- package/README.md +8 -0
- package/README.ru.md +8 -0
- package/README.zh.md +8 -0
- package/lib/accounts-view.js +97 -5
- package/lib/accounts.js +137 -11
- package/lib/adapter.js +3 -0
- package/lib/alerts.js +35 -0
- package/lib/analytics.js +31 -2
- package/lib/billing.js +99 -0
- package/lib/blob.js +22 -0
- package/lib/capabilities.js +208 -0
- package/lib/client.js +344 -17
- package/lib/config-schema.js +50 -4
- package/lib/diagnostic-wizard.js +2 -5
- package/lib/diagnostics.js +2 -2
- package/lib/error-buffer.js +2 -2
- package/lib/forecast.js +56 -0
- package/lib/headless-auth.js +392 -0
- package/lib/history.js +36 -0
- package/lib/http.js +67 -4
- package/lib/import-auth.js +3 -6
- package/lib/index.js +99 -11
- package/lib/jwt.js +84 -17
- package/lib/loopback.js +3 -2
- package/lib/mask.js +16 -0
- package/lib/oauth-pending.js +12 -0
- package/lib/pricing.js +89 -0
- package/lib/refs.js +2 -0
- package/lib/reset-clock.js +55 -0
- package/lib/rotate.js +18 -4
- package/lib/routes/accounts.js +142 -7
- package/lib/routes/oauth.js +51 -2
- package/lib/routes/proxy.js +14 -0
- package/lib/scope-inspector.js +51 -0
- package/lib/stream-rotate.js +31 -10
- package/lib/subscriptions.js +24 -1
- package/lib/updater.js +21 -0
- package/lib/vault-sync.js +414 -0
- package/lib/vault.js +1 -1
- package/lib/vendor-factory.js +6 -2
- package/lib/vendors/antigravity.js +13 -4
- package/lib/vendors/claude.js +56 -8
- package/lib/vendors/codex.js +34 -6
- package/lib/vendors/copilot-cli.js +36 -5
- package/lib/vendors/ernie.js +9 -2
- package/lib/vendors/grok.js +66 -6
- package/lib/vendors/jetbrains.js +136 -13
- package/lib/vendors/perplexity.js +151 -17
- package/package.json +1 -1
package/lib/reset-clock.js
CHANGED
|
@@ -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 !==
|
|
22
|
-
if (err.name ===
|
|
23
|
-
if (err.name ===
|
|
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
|
|
package/lib/routes/accounts.js
CHANGED
|
@@ -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
|
|
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
|
}
|
package/lib/routes/oauth.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
}
|
package/lib/routes/proxy.js
CHANGED
|
@@ -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
|
+
}
|
package/lib/stream-rotate.js
CHANGED
|
@@ -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:
|
|
115
|
-
|
|
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, { ...
|
|
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
|
-
|
|
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
|
package/lib/subscriptions.js
CHANGED
|
@@ -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
|