@goodandready/dsh-subscriptions 0.5.26 → 0.5.30
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/config-schema.js +80 -0
- package/lib/index.js +28 -1042
- package/lib/routes.js +1016 -0
- package/lib/sse.js +39 -9
- package/lib/ui-i18n.js +256 -0
- package/lib/ui-styles.js +119 -0
- package/package.json +1 -1
- package/lib/coalesce-stream.js +0 -22
- package/lib/cookie-bridge.js +0 -8
- package/lib/credit-detect.js +0 -22
- package/lib/encrypted-bundle.js +0 -46
- package/lib/latency-heatmap.js +0 -26
- package/lib/latency-score.js +0 -36
- package/lib/multi-tenant.js +0 -16
- package/lib/network-benchmark.js +0 -23
- package/lib/pkce-store.js +0 -28
- package/lib/plan.js +0 -42
- package/lib/prompt-cache-warmer.js +0 -44
- package/lib/reconnect-stream.js +0 -21
- package/lib/reset-toast.js +0 -38
- package/lib/savings-calculator.js +0 -14
- package/lib/state-recovery.js +0 -33
- package/lib/telemetry.js +0 -28
- package/lib/token-speedometer.js +0 -26
- package/lib/zero-trace-logger.js +0 -27
package/lib/index.js
CHANGED
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
export { Config, publicConfig, Slot, defaultSlots } from './config-schema.js'
|
|
2
|
+
import { Config, publicConfig } from './config-schema.js'
|
|
3
|
+
import { registerRoutes } from './routes.js'
|
|
1
4
|
import { analyzeSessionEvents } from './analyze-session.js'
|
|
2
5
|
import { discoverLocalCliSessions, loadLocalCliBlob } from './import-auth.js'
|
|
3
6
|
import { readFileSync } from 'node:fs'
|
|
@@ -38,86 +41,6 @@ let pkgVersion = ''
|
|
|
38
41
|
try { pkgVersion = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version || '' } catch {}
|
|
39
42
|
const PENDING_TTL_MS = 15 * 60 * 1000
|
|
40
43
|
|
|
41
|
-
const Slot = z.object({
|
|
42
|
-
provider: z.string().default('codex')
|
|
43
|
-
.description('One of: codex, claude, grok, antigravity.'),
|
|
44
|
-
index: z.number().default(1)
|
|
45
|
-
.description('Account slot number. Credential ref is <PROVIDER>_OAUTH_<index>.'),
|
|
46
|
-
label: z.string().default('')
|
|
47
|
-
.description('Optional display label. Empty uses the account email after login.'),
|
|
48
|
-
expiresAt: z.number().default(0)
|
|
49
|
-
.description('#67 Optional subscription expiry timestamp (ms). When set and within expiryNotifyDays, the header chip shows the account and expiry date.'),
|
|
50
|
-
proxyUrl: z.string().default('')
|
|
51
|
-
.description('#88 Per-account proxy URL (http://, https://, socks5://). All requests for this account route through it. Empty = direct connection.'),
|
|
52
|
-
})
|
|
53
|
-
|
|
54
|
-
const defaultSlots = PROVIDERS.map((provider) => ({ provider, index: 1, label: '' }))
|
|
55
|
-
|
|
56
|
-
export const Config = z.object({
|
|
57
|
-
cooldownMs: z.number().default(30 * 60 * 1000)
|
|
58
|
-
.description('After RATE_LIMIT/QUOTA/429, skip that account for this many milliseconds.'),
|
|
59
|
-
switchAtRemaining: z.number().default(0.01)
|
|
60
|
-
.description('If remaining <= this (absolute or <1 fraction), treat as exhausted before request. 0 disables.'),
|
|
61
|
-
refreshAheadMs: z.number().default(5 * 60 * 1000)
|
|
62
|
-
.description('Background refresh when expiry within this many ms.'),
|
|
63
|
-
refreshRetryMs: z.number().default(10 * 60 * 1000)
|
|
64
|
-
.description('Do not retry background refresh more often than this after failure.'),
|
|
65
|
-
probeIntervalMin: z.number().default(15)
|
|
66
|
-
.description('Background health-check interval in minutes. 0 disables.'),
|
|
67
|
-
notifyLimits: z.boolean().default(true)
|
|
68
|
-
.description('Emit log notices when usage crosses 70/90/100% of a window.'),
|
|
69
|
-
expiryNotifyDays: z.number().default(7)
|
|
70
|
-
.description('#67 Warn in the header chip this many days before a subscription expiry (expiresAt). 0 disables.'),
|
|
71
|
-
privacyMask: z.boolean().default(false)
|
|
72
|
-
.description('#98 Hide personal data in the UI: emails show as j***n@example.com.'),
|
|
73
|
-
slots: z.array(Slot).default(defaultSlots)
|
|
74
|
-
.description('Account slots. Secrets are not stored here; only the credential ref names.'),
|
|
75
|
-
useWebCallback: z.boolean().default(false)
|
|
76
|
-
.description('When on, redirect_uri is this Web UI origin + /dsh-subscriptions/oauth/callback. When off, the vendor CLI registered redirect is used and you paste the redirected URL.'),
|
|
77
|
-
autoLoopback: z.boolean().default(true)
|
|
78
|
-
.description('#89 When on and the vendor redirect_uri is a loopback address (codex :1455, grok :56121), a temporary local server catches the OAuth callback automatically - no paste needed. Paste fallback stays available.'),
|
|
79
|
-
ollamaBaseUrl: z.string().default('http://127.0.0.1:11434')
|
|
80
|
-
.description('#91 Local Ollama base URL. Served as the ollama provider in the native model picker when reachable.'),
|
|
81
|
-
ollamaFallback: z.boolean().default(true)
|
|
82
|
-
.description('#91 When every account of a provider is exhausted, continue the chat on local Ollama instead of failing.'),
|
|
83
|
-
ollamaFallbackModel: z.string().default('')
|
|
84
|
-
.description('#91 Ollama model used for the fallback (for example qwen2.5-coder). Empty = first model from /api/tags.'),
|
|
85
|
-
hideDeprecatedModels: z.boolean().default(false)
|
|
86
|
-
.description('#94 Hide test/preview/beta/legacy model ids from the native model picker.'),
|
|
87
|
-
codexClientId: z.string().default(''),
|
|
88
|
-
codexVerbosity: z.string().default('')
|
|
89
|
-
.description('#93 Response verbosity for Codex reasoning models: low, medium or high. Empty = protocol default.'),
|
|
90
|
-
codexFastMode: z.boolean().default(false)
|
|
91
|
-
.description('#92 Fast Mode for Codex: sends service_tier priority (1.5x speed billing tier) with every request.'),
|
|
92
|
-
composerQuota: z.string().default('off')
|
|
93
|
-
.description('#84 Composer quota indicator mode: off, percent, bar or forecast (predictive runway from a sliding window).'),
|
|
94
|
-
codexRedirectUri: z.string().default(''),
|
|
95
|
-
codexBaseUrl: z.string().default(''),
|
|
96
|
-
claudeClientId: z.string().default(''),
|
|
97
|
-
claudeRedirectUri: z.string().default(''),
|
|
98
|
-
grokClientId: z.string().default(''),
|
|
99
|
-
grokRedirectUri: z.string().default(''),
|
|
100
|
-
grokBaseUrl: z.string().default(''),
|
|
101
|
-
grokClientVersion: z.string().default('')
|
|
102
|
-
.description('Grok CLI identity version header. Empty uses the built-in default.'),
|
|
103
|
-
antigravityClientId: z.string().default(''),
|
|
104
|
-
antigravityClientSecret: z.string().default(''),
|
|
105
|
-
antigravityRedirectUri: z.string().default(''),
|
|
106
|
-
customVendors: z.array(z.any()).default([])
|
|
107
|
-
.description('Declarative OpenAI-Responses-compatible providers. See README.'),
|
|
108
|
-
|
|
109
|
-
})
|
|
110
|
-
|
|
111
|
-
function publicConfig(cfg) {
|
|
112
|
-
const clone = structuredClone(cfg)
|
|
113
|
-
// #242: redact all *ClientSecret fields before exposing config via GET /config.
|
|
114
|
-
// Secrets must never leave the server; the UI does not need them.
|
|
115
|
-
for (const key of Object.keys(clone)) {
|
|
116
|
-
if (key.endsWith('ClientSecret')) clone[key] = clone[key] ? '••••••' : ''
|
|
117
|
-
}
|
|
118
|
-
return clone
|
|
119
|
-
}
|
|
120
|
-
|
|
121
44
|
function redirectFor(provider, cfg, origin) {
|
|
122
45
|
const overlay = vendorConfig(provider, cfg)
|
|
123
46
|
if (cfg.useWebCallback) return webCallbackUri(origin)
|
|
@@ -676,967 +599,30 @@ export function apply(ctx, config) {
|
|
|
676
599
|
}, 'dsh-subscriptions: probe loop')
|
|
677
600
|
|
|
678
601
|
|
|
679
|
-
ctx.effect(() => ctx.webServer.register({
|
|
680
|
-
kind: 'exact',
|
|
681
|
-
path: '/dsh-subscriptions/config',
|
|
682
|
-
handler: async (req, res) => {
|
|
683
|
-
if (req.method === 'GET') {
|
|
684
|
-
writeJson(res, 200, await configResponse())
|
|
685
|
-
return
|
|
686
|
-
}
|
|
687
|
-
if (req.method !== 'PUT') {
|
|
688
|
-
writeJson(res, 405, { ok: false, error: { code: 'method', message: 'GET or PUT' } })
|
|
689
|
-
return
|
|
690
|
-
}
|
|
691
|
-
if (!isTrustedSettingsRequest(req)) {
|
|
692
|
-
writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'settings writes are same-origin only' } })
|
|
693
|
-
return
|
|
694
|
-
}
|
|
695
|
-
if (!settingsApi) {
|
|
696
|
-
writeJson(res, 503, { ok: false, error: { code: 'settings', message: 'settings not ready' } })
|
|
697
|
-
return
|
|
698
|
-
}
|
|
699
|
-
let payload
|
|
700
|
-
try { payload = JSON.parse((await readBody(req, 256 * 1024)).toString('utf8') || '{}') } catch {
|
|
701
|
-
writeJson(res, 400, { ok: false, error: { code: 'json', message: 'invalid json' } })
|
|
702
|
-
return
|
|
703
|
-
}
|
|
704
|
-
if (payload && typeof payload.config === 'object') payload = payload.config
|
|
705
|
-
try {
|
|
706
|
-
if (Array.isArray(payload.slots)) payload.slots = stripLegacySlots(payload.slots)
|
|
707
|
-
const parsed = Config(payload)
|
|
708
|
-
const dropped = droppedCredentialRefs(live().slots, parsed.slots)
|
|
709
|
-
await settingsApi.replace(parsed)
|
|
710
|
-
syncCustomVendors()
|
|
711
|
-
for (const ref of dropped) await store.clearRef(ref)
|
|
712
|
-
await syncAdapter()
|
|
713
|
-
writeJson(res, 200, await configResponse())
|
|
714
|
-
} catch (e) {
|
|
715
|
-
writeJson(res, 400, { ok: false, error: { code: 'save', message: String(e && e.message || e) } })
|
|
716
|
-
}
|
|
717
|
-
},
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
}), 'dsh-subscriptions: /config')
|
|
722
|
-
|
|
723
|
-
ctx.effect(() => ctx.webServer.register({
|
|
724
|
-
kind: 'exact',
|
|
725
|
-
path: '/dsh-subscriptions/status',
|
|
726
|
-
handler: async (req, res) => {
|
|
727
|
-
if (req.method !== 'GET') {
|
|
728
|
-
writeJson(res, 405, { ok: false, error: { code: 'method', message: 'GET only' } })
|
|
729
|
-
return
|
|
730
|
-
}
|
|
731
|
-
const logged = await store.loggedInProviders()
|
|
732
|
-
// #66/#67: usagePercent (максимум по аккаунтам) и expiresAt для чипа.
|
|
733
|
-
const usage = {}
|
|
734
|
-
const expires = {}
|
|
735
|
-
const labels = {}
|
|
736
|
-
for (const slot of normalizeSlots(live().slots)) {
|
|
737
|
-
try {
|
|
738
|
-
const info = await store.describeRef(slot.ref)
|
|
739
|
-
if (info.usagePercent != null) {
|
|
740
|
-
usage[slot.provider] = Math.max(usage[slot.provider] || 0, info.usagePercent)
|
|
741
|
-
}
|
|
742
|
-
// #67: дата окончания подписки берётся из слота (вводится в настройках).
|
|
743
|
-
if (slot.expiresAt) {
|
|
744
|
-
expires[slot.provider] = Math.max(expires[slot.provider] || 0, slot.expiresAt)
|
|
745
|
-
labels[slot.provider] = pmL(slot.label || info.label || slot.provider)
|
|
746
|
-
}
|
|
747
|
-
} catch {}
|
|
748
|
-
}
|
|
749
|
-
// #69/#72: активная подписка = последний успешный запрос (новые сверху).
|
|
750
|
-
let active = null
|
|
751
|
-
const last = history.recent(1)
|
|
752
|
-
if (last.length) {
|
|
753
|
-
const lastRow = last[0]
|
|
754
|
-
const accts = await store.listAccounts(lastRow.provider)
|
|
755
|
-
const acct = accts.find((a) => a.ref === lastRow.ref) || accts[0]
|
|
756
|
-
let plan = ''
|
|
757
|
-
let index = acct && acct.ref ? Number(String(acct.ref).split('_').pop()) || null : null
|
|
758
|
-
let status = 'ok'
|
|
759
|
-
let windows = []
|
|
760
|
-
try {
|
|
761
|
-
const info = await store.describeRef(lastRow.ref)
|
|
762
|
-
plan = info.paidTierName || ''
|
|
763
|
-
if (info.validationUrl) status = 'verify'
|
|
764
|
-
else if (info.cooldownUntil && info.cooldownUntil > Date.now()) status = 'cooldown'
|
|
765
|
-
if (Array.isArray(info.usage)) {
|
|
766
|
-
windows = info.usage
|
|
767
|
-
.filter((w) => w && typeof w.usedPercent === 'number')
|
|
768
|
-
.map((w) => ({ id: w.id || w.en || w.ru, label: w.en || w.ru || w.id, usedPercent: w.usedPercent }))
|
|
769
|
-
}
|
|
770
|
-
} catch {}
|
|
771
|
-
active = {
|
|
772
|
-
provider: lastRow.provider,
|
|
773
|
-
index,
|
|
774
|
-
model: lastRow.model || null,
|
|
775
|
-
path: lastRow.path || null,
|
|
776
|
-
plan,
|
|
777
|
-
windows,
|
|
778
|
-
usagePercent: usage[lastRow.provider] != null ? usage[lastRow.provider] : null,
|
|
779
|
-
status,
|
|
780
|
-
at: lastRow.ts,
|
|
781
|
-
}
|
|
782
|
-
}
|
|
783
|
-
writeJson(res, 200, {
|
|
784
|
-
ok: true,
|
|
785
|
-
loggedIn: Object.fromEntries(PROVIDERS.map((id) => [id, logged.includes(id)])),
|
|
786
|
-
usagePercent: usage,
|
|
787
|
-
expiresAt: expires,
|
|
788
|
-
labels,
|
|
789
|
-
expiryNotifyDays: live().expiryNotifyDays,
|
|
790
|
-
fastMode: !!live().codexFastMode,
|
|
791
|
-
composerQuota: String(live().composerQuota || 'off'),
|
|
792
|
-
active,
|
|
793
|
-
})
|
|
794
|
-
},
|
|
795
|
-
}), 'dsh-subscriptions: /status')
|
|
796
|
-
|
|
797
|
-
ctx.effect(() => ctx.webServer.register({
|
|
798
|
-
kind: 'exact',
|
|
799
|
-
path: '/dsh-subscriptions/reset-credits',
|
|
800
|
-
handler: async (req, res) => {
|
|
801
|
-
if (req.method !== 'GET') {
|
|
802
|
-
writeJson(res, 405, { ok: false, error: { code: 'method', message: 'GET only' } })
|
|
803
|
-
return
|
|
804
|
-
}
|
|
805
|
-
const q = queryOf(req)
|
|
806
|
-
const provider = q.get('provider') || ''
|
|
807
|
-
const index = Number(q.get('index') || '1')
|
|
808
|
-
const ref = refForSlot(provider, index)
|
|
809
|
-
if (!ref) { writeJson(res, 404, { ok: false, error: { code: 'slot', message: 'slot not found' } }); return }
|
|
810
|
-
try {
|
|
811
|
-
writeJson(res, 200, { ok: true, ...(await resetCredits.inspect(ref)) })
|
|
812
|
-
} catch (e) {
|
|
813
|
-
writeJson(res, 200, { ok: false, error: { code: 'reset', message: String(e && e.message || e) } })
|
|
814
|
-
}
|
|
815
|
-
},
|
|
816
|
-
}), 'dsh-subscriptions: /reset-credits')
|
|
817
|
-
|
|
818
|
-
ctx.effect(() => ctx.webServer.register({
|
|
819
|
-
kind: 'exact',
|
|
820
|
-
path: '/dsh-subscriptions/reset-credits/prepare',
|
|
821
|
-
handler: async (req, res) => {
|
|
822
|
-
if (req.method !== 'POST') {
|
|
823
|
-
writeJson(res, 405, { ok: false, error: { code: 'method', message: 'POST only' } })
|
|
824
|
-
return
|
|
825
|
-
}
|
|
826
|
-
if (!isTrustedSettingsRequest(req)) {
|
|
827
|
-
writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'same-origin only' } })
|
|
828
|
-
return
|
|
829
|
-
}
|
|
830
|
-
let payload
|
|
831
|
-
try { payload = JSON.parse((await readBody(req, 4096)).toString('utf8') || '{}') } catch {
|
|
832
|
-
writeJson(res, 400, { ok: false, error: { code: 'json', message: 'invalid json' } })
|
|
833
|
-
return
|
|
834
|
-
}
|
|
835
|
-
const ref = refForSlot(String(payload.provider || ''), Number(payload.index) || 1)
|
|
836
|
-
if (!ref) { writeJson(res, 404, { ok: false, error: { code: 'slot', message: 'slot not found' } }); return }
|
|
837
|
-
try {
|
|
838
|
-
writeJson(res, 200, { ok: true, ...(await resetCredits.prepare(ref)) })
|
|
839
|
-
} catch (e) {
|
|
840
|
-
writeJson(res, 200, { ok: false, error: { code: 'reset', message: String(e && e.message || e) } })
|
|
841
|
-
}
|
|
842
|
-
},
|
|
843
|
-
}), 'dsh-subscriptions: /reset-credits/prepare')
|
|
844
|
-
|
|
845
|
-
ctx.effect(() => ctx.webServer.register({
|
|
846
|
-
kind: 'exact',
|
|
847
|
-
path: '/dsh-subscriptions/reset-credits/consume',
|
|
848
|
-
handler: async (req, res) => {
|
|
849
|
-
if (req.method !== 'POST') {
|
|
850
|
-
writeJson(res, 405, { ok: false, error: { code: 'method', message: 'POST only' } })
|
|
851
|
-
return
|
|
852
|
-
}
|
|
853
|
-
if (!isTrustedSettingsRequest(req)) {
|
|
854
|
-
writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'same-origin only' } })
|
|
855
|
-
return
|
|
856
|
-
}
|
|
857
|
-
let payload
|
|
858
|
-
try { payload = JSON.parse((await readBody(req, 4096)).toString('utf8') || '{}') } catch {
|
|
859
|
-
writeJson(res, 400, { ok: false, error: { code: 'json', message: 'invalid json' } })
|
|
860
|
-
return
|
|
861
|
-
}
|
|
862
|
-
try {
|
|
863
|
-
const result = await resetCredits.consume({ challengeId: payload.challengeId, acknowledged: payload.acknowledged })
|
|
864
|
-
writeJson(res, 200, { ok: true, result })
|
|
865
|
-
refreshModels().catch(() => {})
|
|
866
|
-
} catch (e) {
|
|
867
|
-
writeJson(res, 200, { ok: false, error: { code: 'reset', message: String(e && e.message || e) } })
|
|
868
|
-
}
|
|
869
|
-
},
|
|
870
|
-
}), 'dsh-subscriptions: /reset-credits/consume')
|
|
871
|
-
|
|
872
|
-
ctx.effect(() => ctx.webServer.register({
|
|
873
|
-
kind: 'exact',
|
|
874
|
-
path: '/dsh-subscriptions/diagnostics',
|
|
875
|
-
handler: async (req, res) => {
|
|
876
|
-
if (req.method !== 'GET') {
|
|
877
|
-
writeJson(res, 405, { ok: false, error: { code: 'method', message: 'GET only' } })
|
|
878
|
-
return
|
|
879
|
-
}
|
|
880
|
-
writeJson(res, 200, { ok: true, report: await diagnosticsReport() })
|
|
881
|
-
},
|
|
882
|
-
}), 'dsh-subscriptions: /diagnostics')
|
|
883
602
|
|
|
884
|
-
ctx
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
createdAt: Date.now(),
|
|
910
|
-
})
|
|
911
|
-
const cfg = { ...vendorConfig(provider, live()), redirectUri }
|
|
912
|
-
const url = getVendor(provider).authorizeUrl(cfg, pkce)
|
|
913
|
-
// #89: если redirect_uri loopback — поднять временный сервер перехвата.
|
|
914
|
-
let autoCatch = false
|
|
915
|
-
if (live().autoLoopback) {
|
|
916
|
-
try {
|
|
917
|
-
const cb = new URL(redirectUri)
|
|
918
|
-
if (cb.hostname === 'localhost' || cb.hostname === '127.0.0.1') {
|
|
919
|
-
autoCatch = true
|
|
920
|
-
startLoopback({
|
|
921
|
-
redirectUri,
|
|
922
|
-
onCode: async (params) => {
|
|
923
|
-
const code = params.get('code') || ''
|
|
924
|
-
const state = params.get('state') || ''
|
|
925
|
-
if (!code) throw new Error('no code')
|
|
926
|
-
await completeOAuth({ provider, index, code, state })
|
|
927
|
-
return OK_HTML
|
|
928
|
-
},
|
|
929
|
-
}).catch(() => {})
|
|
930
|
-
}
|
|
931
|
-
} catch { autoCatch = false }
|
|
932
|
-
}
|
|
933
|
-
writeJson(res, 200, { ok: true, url, state: pkce.state, redirectUri, autoCatch })
|
|
934
|
-
},
|
|
935
|
-
}), 'dsh-subscriptions: /oauth/start')
|
|
936
|
-
|
|
937
|
-
// #90: Device-code login (headless/VPS). Codex-only: auth.openai.com mints an
|
|
938
|
-
// authorization_code + code_verifier server-side; we finish with the normal
|
|
939
|
-
// PKCE exchange using the device redirect URI. Device code stays server-side
|
|
940
|
-
// in the pending map (same lifetime as PKCE pending rows).
|
|
941
|
-
ctx.effect(() => ctx.webServer.register({
|
|
942
|
-
kind: 'exact',
|
|
943
|
-
path: '/dsh-subscriptions/oauth/device/start',
|
|
944
|
-
handler: async (req, res) => {
|
|
945
|
-
if (req.method !== 'POST') {
|
|
946
|
-
writeJson(res, 405, { ok: false, error: { code: 'method', message: 'POST only' } })
|
|
947
|
-
return
|
|
948
|
-
}
|
|
949
|
-
if (!isTrustedSettingsRequest(req)) {
|
|
950
|
-
writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'same-origin only' } })
|
|
951
|
-
return
|
|
952
|
-
}
|
|
953
|
-
let body
|
|
954
|
-
try {
|
|
955
|
-
body = JSON.parse((await readBody(req, 16 * 1024)).toString('utf8'))
|
|
956
|
-
} catch {
|
|
957
|
-
writeJson(res, 400, { ok: false, error: { code: 'json', message: 'invalid json' } })
|
|
958
|
-
return
|
|
959
|
-
}
|
|
960
|
-
const provider = String(body.provider || '')
|
|
961
|
-
const index = Number(body.index || 1)
|
|
962
|
-
if (!isProvider(provider)) {
|
|
963
|
-
writeJson(res, 400, { ok: false, error: { code: 'provider', message: 'unknown provider' } })
|
|
964
|
-
return
|
|
965
|
-
}
|
|
966
|
-
const vendor = getVendor(provider)
|
|
967
|
-
if (typeof vendor.deviceStart !== 'function') {
|
|
968
|
-
writeJson(res, 400, { ok: false, error: { code: 'device', message: 'device login not supported for ' + provider } })
|
|
969
|
-
return
|
|
970
|
-
}
|
|
971
|
-
try {
|
|
972
|
-
const cfg = vendorConfig(provider, live())
|
|
973
|
-
const start = await vendor.deviceStart(cfg, (fetchForRef(oauthRef(provider, index)) || fetch))
|
|
974
|
-
const state = 'dev-' + Date.now() + '-' + Math.random().toString(36).slice(2, 10)
|
|
975
|
-
sweepPending(Date.now())
|
|
976
|
-
pending.set(state, {
|
|
977
|
-
kind: 'device',
|
|
978
|
-
provider,
|
|
979
|
-
index,
|
|
980
|
-
ref: oauthRef(provider, index),
|
|
981
|
-
deviceAuthId: start.deviceAuthId,
|
|
982
|
-
userCode: start.userCode,
|
|
983
|
-
intervalMs: start.intervalMs,
|
|
984
|
-
createdAt: Date.now(),
|
|
985
|
-
})
|
|
986
|
-
writeJson(res, 200, { ok: true, state, userCode: start.userCode, authUrl: start.authUrl, intervalMs: start.intervalMs })
|
|
987
|
-
} catch (e) {
|
|
988
|
-
writeJson(res, 502, { ok: false, error: { code: e && e.code || 'DEVICE', message: String(e && e.message || e).slice(0, 300) } })
|
|
989
|
-
}
|
|
990
|
-
},
|
|
991
|
-
}), 'dsh-subscriptions: /oauth/device/start')
|
|
992
|
-
|
|
993
|
-
ctx.effect(() => ctx.webServer.register({
|
|
994
|
-
kind: 'exact',
|
|
995
|
-
path: '/dsh-subscriptions/oauth/device/poll',
|
|
996
|
-
handler: async (req, res) => {
|
|
997
|
-
if (req.method !== 'POST') {
|
|
998
|
-
writeJson(res, 405, { ok: false, error: { code: 'method', message: 'POST only' } })
|
|
999
|
-
return
|
|
1000
|
-
}
|
|
1001
|
-
if (!isTrustedSettingsRequest(req)) {
|
|
1002
|
-
writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'same-origin only' } })
|
|
1003
|
-
return
|
|
1004
|
-
}
|
|
1005
|
-
let body
|
|
1006
|
-
try {
|
|
1007
|
-
body = JSON.parse((await readBody(req, 16 * 1024)).toString('utf8'))
|
|
1008
|
-
} catch {
|
|
1009
|
-
writeJson(res, 400, { ok: false, error: { code: 'json', message: 'invalid json' } })
|
|
1010
|
-
return
|
|
1011
|
-
}
|
|
1012
|
-
const state = String(body.state || '')
|
|
1013
|
-
sweepPending(Date.now())
|
|
1014
|
-
const row = pending.get(state)
|
|
1015
|
-
if (!row || row.kind !== 'device') {
|
|
1016
|
-
writeJson(res, 404, { ok: false, error: { code: 'expired', message: 'device login session expired; start again' } })
|
|
1017
|
-
return
|
|
1018
|
-
}
|
|
1019
|
-
const vendor = getVendor(row.provider)
|
|
1020
|
-
try {
|
|
1021
|
-
const cfg = vendorConfig(row.provider, live())
|
|
1022
|
-
const out = await vendor.devicePoll(cfg, { deviceAuthId: row.deviceAuthId, userCode: row.userCode }, (fetchForRef(row.ref) || fetch))
|
|
1023
|
-
if (out.status !== 'authorized') {
|
|
1024
|
-
writeJson(res, 200, { ok: true, status: out.status })
|
|
1025
|
-
return
|
|
1026
|
-
}
|
|
1027
|
-
const slots = normalizeSlots(live().slots)
|
|
1028
|
-
const slot = slots.find((s) => s.ref === row.ref)
|
|
1029
|
-
const blob = out.blob
|
|
1030
|
-
if (slot && slot.label) blob.label = slot.label
|
|
1031
|
-
await store.saveBlob(row.ref, blob)
|
|
1032
|
-
pending.delete(state)
|
|
1033
|
-
await syncAdapter()
|
|
1034
|
-
refreshModels().catch(() => {})
|
|
1035
|
-
writeJson(res, 200, { ok: true, status: 'authorized', ref: row.ref, label: pmL(blob.label || blob.email) || displayName(row.provider) })
|
|
1036
|
-
} catch (e) {
|
|
1037
|
-
writeJson(res, 502, { ok: false, error: { code: e && e.code || 'DEVICE', message: String(e && e.message || e).slice(0, 300) } })
|
|
1038
|
-
}
|
|
1039
|
-
},
|
|
1040
|
-
}), 'dsh-subscriptions: /oauth/device/poll')
|
|
1041
|
-
|
|
1042
|
-
ctx.effect(() => ctx.webServer.register({
|
|
1043
|
-
kind: 'exact',
|
|
1044
|
-
path: '/dsh-subscriptions/oauth/callback',
|
|
1045
|
-
handler: async (req, res) => {
|
|
1046
|
-
if (req.method !== 'GET') {
|
|
1047
|
-
writeJson(res, 405, { ok: false, error: { code: 'method', message: 'GET only' } })
|
|
1048
|
-
return
|
|
1049
|
-
}
|
|
1050
|
-
const q = queryOf(req)
|
|
1051
|
-
const code = q.get('code') || ''
|
|
1052
|
-
const state = q.get('state') || ''
|
|
1053
|
-
const row = pending.get(state)
|
|
1054
|
-
if (!code || !row) {
|
|
1055
|
-
writeHtml(res, 400, '<!doctype html><meta charset="utf-8"><title>Subscriptions</title><p>Login session missing. Return to Settings and paste the redirected URL.</p>')
|
|
1056
|
-
return
|
|
1057
|
-
}
|
|
1058
|
-
try {
|
|
1059
|
-
await completeOAuth({ provider: row.provider, index: row.index, code, state })
|
|
1060
|
-
writeHtml(res, 200, '<!doctype html><meta charset="utf-8"><title>Subscriptions</title><p>Signed in. You can close this tab and return to Settings.</p>')
|
|
1061
|
-
} catch (e) {
|
|
1062
|
-
writeHtml(res, 400, `<!doctype html><meta charset="utf-8"><title>Subscriptions</title><p>${escapeHtml(String(e && e.message || e))}</p>`)
|
|
1063
|
-
}
|
|
1064
|
-
},
|
|
1065
|
-
}), 'dsh-subscriptions: /oauth/callback')
|
|
1066
|
-
|
|
1067
|
-
ctx.effect(() => ctx.webServer.register({
|
|
1068
|
-
kind: 'exact',
|
|
1069
|
-
path: '/dsh-subscriptions/oauth/complete',
|
|
1070
|
-
handler: async (req, res) => {
|
|
1071
|
-
if (req.method !== 'POST') {
|
|
1072
|
-
writeJson(res, 405, { ok: false, error: { code: 'method', message: 'POST only' } })
|
|
1073
|
-
return
|
|
1074
|
-
}
|
|
1075
|
-
if (!isTrustedSettingsRequest(req)) {
|
|
1076
|
-
writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'same-origin only' } })
|
|
1077
|
-
return
|
|
1078
|
-
}
|
|
1079
|
-
let payload
|
|
1080
|
-
try { payload = JSON.parse((await readBody(req, 16 * 1024)).toString('utf8') || '{}') } catch {
|
|
1081
|
-
writeJson(res, 400, { ok: false, error: { code: 'json', message: 'invalid json' } })
|
|
1082
|
-
return
|
|
1083
|
-
}
|
|
1084
|
-
const parsed = parseCallbackInput(payload.url || payload.code || '')
|
|
1085
|
-
const provider = payload.provider
|
|
1086
|
-
const index = payload.index
|
|
1087
|
-
const code = parsed.code
|
|
1088
|
-
const state = parsed.state || payload.state || ''
|
|
1089
|
-
if (!code) {
|
|
1090
|
-
writeJson(res, 400, { ok: false, error: { code: 'code', message: 'paste the redirected URL or the code' } })
|
|
1091
|
-
return
|
|
1092
|
-
}
|
|
1093
|
-
try {
|
|
1094
|
-
const result = await completeOAuth({ provider, index, code, state })
|
|
1095
|
-
writeJson(res, 200, { ok: true, ...result, accounts: await accountsView() })
|
|
1096
|
-
} catch (e) {
|
|
1097
|
-
writeJson(res, 400, { ok: false, error: { code: 'oauth', message: String(e && e.message || e) } })
|
|
1098
|
-
}
|
|
1099
|
-
},
|
|
1100
|
-
}), 'dsh-subscriptions: /oauth/complete')
|
|
1101
|
-
|
|
1102
|
-
// ponytail: cheap per-vendor probe; never sets cooldown, never returns tokens
|
|
1103
|
-
ctx.effect(() => ctx.webServer.register({
|
|
1104
|
-
kind: 'exact',
|
|
1105
|
-
path: '/dsh-subscriptions/check',
|
|
1106
|
-
handler: async (req, res) => {
|
|
1107
|
-
if (req.method !== 'POST') {
|
|
1108
|
-
writeJson(res, 405, { ok: false, error: { code: 'method', message: 'POST only' } })
|
|
1109
|
-
return
|
|
1110
|
-
}
|
|
1111
|
-
if (!isTrustedSettingsRequest(req)) {
|
|
1112
|
-
writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'same-origin only' } })
|
|
1113
|
-
return
|
|
1114
|
-
}
|
|
1115
|
-
let payload
|
|
1116
|
-
try { payload = JSON.parse((await readBody(req, 8 * 1024)).toString('utf8') || '{}') } catch {
|
|
1117
|
-
writeJson(res, 400, { ok: false, error: { code: 'json', message: 'invalid json' } })
|
|
1118
|
-
return
|
|
1119
|
-
}
|
|
1120
|
-
const provider = payload.provider
|
|
1121
|
-
if (!isProvider(provider)) {
|
|
1122
|
-
writeJson(res, 200, { ok: false, provider, error: { code: 'provider', message: 'unknown provider' } })
|
|
1123
|
-
return
|
|
1124
|
-
}
|
|
1125
|
-
const ref = oauthRef(provider, payload.index)
|
|
1126
|
-
const info = await store.describeRef(ref)
|
|
1127
|
-
if (!info.configured) {
|
|
1128
|
-
writeJson(res, 200, { ok: false, provider, index: payload.index, ref, error: { code: 'not_connected', message: 'not connected' } })
|
|
1129
|
-
return
|
|
1130
|
-
}
|
|
1131
|
-
let blob
|
|
1132
|
-
try { blob = await store.loadBlob(ref) } catch (e) {
|
|
1133
|
-
writeJson(res, 200, { ok: false, provider, index: payload.index, ref, error: { code: 'auth', message: String(e && e.message || e) } })
|
|
1134
|
-
return
|
|
1135
|
-
}
|
|
1136
|
-
try { blob = await store.ensureFresh(provider, blob, ref) } catch (e) {
|
|
1137
|
-
writeJson(res, 200, {
|
|
1138
|
-
ok: false, provider, index: payload.index, ref,
|
|
1139
|
-
email: pmE(blob.email), label: pmL(blob.label), expiresAt: blob.expiresAt || null,
|
|
1140
|
-
quota: info.quota || null,
|
|
1141
|
-
error: { code: 'refresh', message: String(e && e.message || e) },
|
|
1142
|
-
})
|
|
1143
|
-
return
|
|
1144
|
-
}
|
|
1145
|
-
const vendor = getVendor(provider)
|
|
1146
|
-
if (typeof vendor.check !== 'function') {
|
|
1147
|
-
writeJson(res, 200, { ok: true, provider, index: payload.index, ref, email: pmE(blob.email), label: pmL(blob.label), expiresAt: blob.expiresAt || null, quota: info.quota || null })
|
|
1148
|
-
return
|
|
1149
|
-
}
|
|
1150
|
-
let capturedQuota = null
|
|
1151
|
-
const probeFetch = async (url, init) => {
|
|
1152
|
-
const res2 = await fetch(url, init)
|
|
1153
|
-
try {
|
|
1154
|
-
const snap = quotaSnapshot(provider, res2.headers, null, Date.now())
|
|
1155
|
-
if (snap) { capturedQuota = snap; store.rememberQuota(ref, snap) }
|
|
1156
|
-
} catch {}
|
|
1157
|
-
return res2
|
|
1158
|
-
}
|
|
1159
|
-
try {
|
|
1160
|
-
await vendor.check(blob, vendorConfig(provider, live()), probeFetch)
|
|
1161
|
-
writeJson(res, 200, { ok: true, provider, index: payload.index, ref, email: pmE(blob.email), label: pmL(blob.label), expiresAt: blob.expiresAt || null, quota: capturedQuota || info.quota || null, usagePercent: info.usagePercent ?? null })
|
|
1162
|
-
} catch (e) {
|
|
1163
|
-
writeJson(res, 200, {
|
|
1164
|
-
ok: false, provider, index: payload.index, ref,
|
|
1165
|
-
email: pmE(blob.email), label: pmL(blob.label), expiresAt: blob.expiresAt || null,
|
|
1166
|
-
quota: capturedQuota || info.quota || null,
|
|
1167
|
-
error: { code: e && e.code ? e.code : 'VENDOR', message: String(e && e.message || e).slice(0, 300) },
|
|
1168
|
-
})
|
|
1169
|
-
}
|
|
1170
|
-
},
|
|
1171
|
-
}), 'dsh-subscriptions: /check')
|
|
1172
|
-
|
|
1173
|
-
ctx.effect(() => ctx.webServer.register({
|
|
1174
|
-
kind: 'exact',
|
|
1175
|
-
path: '/dsh-subscriptions/discover-local',
|
|
1176
|
-
handler: async (req, res) => {
|
|
1177
|
-
if (req.method !== 'GET') {
|
|
1178
|
-
writeJson(res, 405, { ok: false, error: { code: 'method', message: 'GET only' } })
|
|
1179
|
-
return
|
|
1180
|
-
}
|
|
1181
|
-
try {
|
|
1182
|
-
const detected = await discoverLocalCliSessions()
|
|
1183
|
-
writeJson(res, 200, { ok: true, detected })
|
|
1184
|
-
} catch (e) {
|
|
1185
|
-
writeJson(res, 500, { ok: false, error: { message: String(e && e.message || e) } })
|
|
1186
|
-
}
|
|
1187
|
-
},
|
|
1188
|
-
}), 'dsh-subscriptions: /discover-local')
|
|
1189
|
-
|
|
1190
|
-
ctx.effect(() => ctx.webServer.register({
|
|
1191
|
-
kind: 'exact',
|
|
1192
|
-
path: '/dsh-subscriptions/import-local',
|
|
1193
|
-
handler: async (req, res) => {
|
|
1194
|
-
if (req.method !== 'POST') {
|
|
1195
|
-
writeJson(res, 405, { ok: false, error: { code: 'method', message: 'POST only' } })
|
|
1196
|
-
return
|
|
1197
|
-
}
|
|
1198
|
-
if (!isTrustedSettingsRequest(req)) {
|
|
1199
|
-
writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'same-origin only' } })
|
|
1200
|
-
return
|
|
1201
|
-
}
|
|
1202
|
-
let body
|
|
1203
|
-
try {
|
|
1204
|
-
body = JSON.parse((await readBody(req, 16 * 1024)).toString('utf8') || '{}')
|
|
1205
|
-
} catch {
|
|
1206
|
-
body = null
|
|
1207
|
-
}
|
|
1208
|
-
if (!body || !body.provider) {
|
|
1209
|
-
writeJson(res, 400, { ok: false, error: { code: 'bad_request', message: 'missing provider' } })
|
|
1210
|
-
return
|
|
1211
|
-
}
|
|
1212
|
-
try {
|
|
1213
|
-
const blob = await loadLocalCliBlob(body.provider)
|
|
1214
|
-
const prov = body.provider
|
|
1215
|
-
const idx = Number(body.index) || 1
|
|
1216
|
-
const curSlots = Array.isArray(live().slots) ? live().slots.slice() : []
|
|
1217
|
-
const exists = curSlots.some((s) => s && s.provider === prov && Number(s.index) === idx)
|
|
1218
|
-
if (!exists && settingsApi) {
|
|
1219
|
-
const nextSlots = curSlots.concat([{
|
|
1220
|
-
provider: prov,
|
|
1221
|
-
index: idx,
|
|
1222
|
-
label: blob.email || '',
|
|
1223
|
-
}])
|
|
1224
|
-
const parsed = Config({ ...live(), slots: stripLegacySlots(nextSlots) })
|
|
1225
|
-
await settingsApi.replace(parsed)
|
|
1226
|
-
syncCustomVendors()
|
|
1227
|
-
}
|
|
1228
|
-
const slot = normalizeSlots(live().slots).find((s) => s.provider === prov && s.index === idx)
|
|
1229
|
-
const ref = slot ? slot.ref : `${prov.toUpperCase()}_OAUTH_${idx}`
|
|
1230
|
-
await store.saveBlob(ref, blob)
|
|
1231
|
-
await syncAdapter()
|
|
1232
|
-
refreshModels().catch(() => {})
|
|
1233
|
-
writeJson(res, 200, {
|
|
1234
|
-
ok: true,
|
|
1235
|
-
ref,
|
|
1236
|
-
provider: prov,
|
|
1237
|
-
email: blob.email || '',
|
|
1238
|
-
accounts: await accountsView(),
|
|
1239
|
-
config: publicConfig(live()),
|
|
1240
|
-
})
|
|
1241
|
-
} catch (e) {
|
|
1242
|
-
writeJson(res, 400, { ok: false, error: { message: String(e && e.message || e) } })
|
|
1243
|
-
}
|
|
1244
|
-
},
|
|
1245
|
-
}), 'dsh-subscriptions: /import-local')
|
|
1246
|
-
|
|
1247
|
-
ctx.effect(() => ctx.webServer.register({
|
|
1248
|
-
kind: 'exact',
|
|
1249
|
-
path: '/dsh-subscriptions/analyze-session',
|
|
1250
|
-
handler: async (req, res) => {
|
|
1251
|
-
if (req.method !== 'POST') {
|
|
1252
|
-
writeJson(res, 405, { ok: false, error: { code: 'method', message: 'POST only' } })
|
|
1253
|
-
return
|
|
1254
|
-
}
|
|
1255
|
-
try {
|
|
1256
|
-
const body = await readBody(req).catch(() => ({}))
|
|
1257
|
-
const events = Array.isArray(body && body.events) ? body.events : []
|
|
1258
|
-
const analysis = analyzeSessionEvents(events)
|
|
1259
|
-
writeJson(res, 200, { ok: true, analysis })
|
|
1260
|
-
} catch (e) {
|
|
1261
|
-
writeJson(res, 500, { ok: false, error: { message: String(e && e.message || e) } })
|
|
1262
|
-
}
|
|
1263
|
-
},
|
|
1264
|
-
}), 'dsh-subscriptions: /analyze-session')
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
// HTTP-прокси к API провайдера через subscriptions.request.
|
|
1269
|
-
// Same-origin only, allowlist путей, ротация и квота как у моделей.
|
|
1270
|
-
// Токен наружу не отдаётся — наружу только ответ провайдера.
|
|
1271
|
-
ctx.effect(() => ctx.webServer.register({
|
|
1272
|
-
kind: 'prefix',
|
|
1273
|
-
path: '/dsh-subscriptions/proxy',
|
|
1274
|
-
handler: async (req, res) => {
|
|
1275
|
-
if (req.method !== 'POST' && req.method !== 'GET') {
|
|
1276
|
-
writeJson(res, 405, { ok: false, error: { code: 'method', message: 'GET or POST' } })
|
|
1277
|
-
return
|
|
1278
|
-
}
|
|
1279
|
-
if (!isTrustedSettingsRequest(req)) {
|
|
1280
|
-
writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'same-origin only' } })
|
|
1281
|
-
return
|
|
1282
|
-
}
|
|
1283
|
-
const url = new URL(req.url || '/', 'http://localhost')
|
|
1284
|
-
const parts = url.pathname.replace(/^\/dsh-subscriptions\/proxy\//, '').split('/').filter(Boolean)
|
|
1285
|
-
const provider = parts[0]
|
|
1286
|
-
const restPath = '/' + parts.slice(1).join('/')
|
|
1287
|
-
if (!isProvider(provider)) {
|
|
1288
|
-
writeJson(res, 404, { ok: false, error: { code: 'provider', message: 'unknown provider' } })
|
|
1289
|
-
return
|
|
1290
|
-
}
|
|
1291
|
-
let body
|
|
1292
|
-
if (req.method === 'POST') {
|
|
1293
|
-
try {
|
|
1294
|
-
body = JSON.parse((await readBody(req, 1024 * 1024)).toString('utf8'))
|
|
1295
|
-
} catch {
|
|
1296
|
-
writeJson(res, 400, { ok: false, error: { code: 'json', message: 'invalid json' } })
|
|
1297
|
-
return
|
|
1298
|
-
}
|
|
1299
|
-
}
|
|
1300
|
-
try {
|
|
1301
|
-
const out = await subscriptions.request({
|
|
1302
|
-
provider,
|
|
1303
|
-
path: restPath,
|
|
1304
|
-
method: req.method === 'POST' ? 'POST' : 'GET',
|
|
1305
|
-
body,
|
|
1306
|
-
headers: {},
|
|
1307
|
-
})
|
|
1308
|
-
const text = await out.text()
|
|
1309
|
-
try {
|
|
1310
|
-
const json = JSON.parse(text)
|
|
1311
|
-
writeJson(res, out.status || 200, json)
|
|
1312
|
-
} catch {
|
|
1313
|
-
res.writeHead(out.status || 200, { 'Content-Type': 'application/json' })
|
|
1314
|
-
res.end(text)
|
|
1315
|
-
}
|
|
1316
|
-
} catch (e) {
|
|
1317
|
-
const status = e && e.status ? e.status : (e && e.code === 'FORBIDDEN' ? 403 : (e && e.code === 'AUTH' ? 401 : 502))
|
|
1318
|
-
writeJson(res, status, { ok: false, error: { code: e && e.code || 'VENDOR', message: String(e && e.message || e).slice(0, 300) } })
|
|
1319
|
-
}
|
|
1320
|
-
},
|
|
1321
|
-
}), 'dsh-subscriptions: proxy')
|
|
1322
|
-
|
|
1323
|
-
// #88: проверка прокси аккаунта — реальный запрос к эндпоинту провайдера с замером задержки.
|
|
1324
|
-
ctx.effect(() => ctx.webServer.register({
|
|
1325
|
-
kind: 'exact',
|
|
1326
|
-
path: '/dsh-subscriptions/proxy-check',
|
|
1327
|
-
handler: async (req, res) => {
|
|
1328
|
-
if (req.method !== 'POST') {
|
|
1329
|
-
writeJson(res, 405, { ok: false, error: { code: 'method', message: 'POST only' } })
|
|
1330
|
-
return
|
|
1331
|
-
}
|
|
1332
|
-
if (!isTrustedSettingsRequest(req)) {
|
|
1333
|
-
writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'same-origin only' } })
|
|
1334
|
-
return
|
|
1335
|
-
}
|
|
1336
|
-
let body
|
|
1337
|
-
try {
|
|
1338
|
-
body = JSON.parse((await readBody(req, 16 * 1024)).toString('utf8'))
|
|
1339
|
-
} catch {
|
|
1340
|
-
writeJson(res, 400, { ok: false, error: { code: 'json', message: 'invalid json' } })
|
|
1341
|
-
return
|
|
1342
|
-
}
|
|
1343
|
-
const provider = String(body.provider || '')
|
|
1344
|
-
const index = Number(body.index || 1)
|
|
1345
|
-
if (!isProvider(provider)) {
|
|
1346
|
-
writeJson(res, 400, { ok: false, error: { code: 'provider', message: 'unknown provider' } })
|
|
1347
|
-
return
|
|
1348
|
-
}
|
|
1349
|
-
const slots = normalizeSlots(live().slots)
|
|
1350
|
-
const slot = slots.find((s) => s.provider === provider && s.index === index)
|
|
1351
|
-
const proxyUrl = (slot && slot.proxyUrl) || ''
|
|
1352
|
-
const DEFAULT_BASE = {
|
|
1353
|
-
codex: 'https://chatgpt.com/backend-api/codex',
|
|
1354
|
-
claude: 'https://api.anthropic.com',
|
|
1355
|
-
grok: 'https://api.x.ai/v1',
|
|
1356
|
-
antigravity: 'https://cloudcode-pa.googleapis.com',
|
|
1357
|
-
}
|
|
1358
|
-
const base = String((vendorConfig(provider, live()) || {}).baseUrl || DEFAULT_BASE[provider] || '').replace(/\/$/, '')
|
|
1359
|
-
const started = Date.now()
|
|
1360
|
-
try {
|
|
1361
|
-
const impl = (proxyUrl && proxyFetch(proxyUrl)) || fetch
|
|
1362
|
-
if (proxyUrl && impl === fetch) throw new Error('invalid proxy URL')
|
|
1363
|
-
const out = await impl(base + '/models', {
|
|
1364
|
-
method: 'GET',
|
|
1365
|
-
headers: { Accept: 'application/json' },
|
|
1366
|
-
signal: AbortSignal.timeout(10000),
|
|
1367
|
-
})
|
|
1368
|
-
// Любой HTTP-ответ (включая 401/403) = прокси и эндпоинт доступны.
|
|
1369
|
-
writeJson(res, 200, { ok: true, status: out.status, latencyMs: Date.now() - started, viaProxy: !!proxyUrl })
|
|
1370
|
-
} catch (e) {
|
|
1371
|
-
writeJson(res, 200, {
|
|
1372
|
-
ok: false,
|
|
1373
|
-
latencyMs: Date.now() - started,
|
|
1374
|
-
viaProxy: !!proxyUrl,
|
|
1375
|
-
error: { code: (e && e.code) || 'NETWORK', message: String((e && e.message) || e).slice(0, 200) },
|
|
1376
|
-
})
|
|
1377
|
-
}
|
|
1378
|
-
},
|
|
1379
|
-
}), 'dsh-subscriptions: proxy-check')
|
|
1380
|
-
|
|
1381
|
-
// Экспорт зашифрованного бандла токенов. Токены не логгируются.
|
|
1382
|
-
ctx.effect(() => ctx.webServer.register({
|
|
1383
|
-
kind: 'exact',
|
|
1384
|
-
path: '/dsh-subscriptions/export',
|
|
1385
|
-
handler: async (req, res) => {
|
|
1386
|
-
if (req.method !== 'POST') {
|
|
1387
|
-
writeJson(res, 405, { ok: false, error: { code: 'method', message: 'POST only' } })
|
|
1388
|
-
return
|
|
1389
|
-
}
|
|
1390
|
-
if (!isTrustedSettingsRequest(req)) {
|
|
1391
|
-
writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'same-origin only' } })
|
|
1392
|
-
return
|
|
1393
|
-
}
|
|
1394
|
-
let payload
|
|
1395
|
-
try { payload = JSON.parse((await readBody(req, 8 * 1024)).toString('utf8') || '{}') } catch {
|
|
1396
|
-
writeJson(res, 400, { ok: false, error: { code: 'json', message: 'invalid json' } })
|
|
1397
|
-
return
|
|
1398
|
-
}
|
|
1399
|
-
const passphrase = payload.passphrase
|
|
1400
|
-
if (!passphrase || typeof passphrase !== 'string') {
|
|
1401
|
-
writeJson(res, 400, { ok: false, error: { code: 'passphrase', message: 'нужен passphrase' } })
|
|
1402
|
-
return
|
|
1403
|
-
}
|
|
1404
|
-
try {
|
|
1405
|
-
const accounts = []
|
|
1406
|
-
for (const slot of normalizeSlots(live().slots)) {
|
|
1407
|
-
try {
|
|
1408
|
-
const blob = await store.loadBlob(slot.ref)
|
|
1409
|
-
accounts.push({ ref: slot.ref, provider: slot.provider, index: slot.index, label: slot.label || blob.label || '', blob })
|
|
1410
|
-
} catch { /* skip missing */ }
|
|
1411
|
-
}
|
|
1412
|
-
if (!accounts.length) {
|
|
1413
|
-
writeJson(res, 200, { ok: false, error: { code: 'empty', message: 'нет подключённых аккаунтов' } })
|
|
1414
|
-
return
|
|
1415
|
-
}
|
|
1416
|
-
const bundle = JSON.stringify({ v: 1, exportedAt: Date.now(), accounts })
|
|
1417
|
-
const encrypted = encryptWithPassphrase(bundle, passphrase)
|
|
1418
|
-
writeJson(res, 200, { ok: true, payload: encrypted, count: accounts.length })
|
|
1419
|
-
} catch (e) {
|
|
1420
|
-
writeJson(res, 500, { ok: false, error: { code: 'export', message: String(e && e.message || e) } })
|
|
1421
|
-
}
|
|
1422
|
-
},
|
|
1423
|
-
}), 'dsh-subscriptions: /export')
|
|
1424
|
-
|
|
1425
|
-
// Импорт зашифрованного бандла.
|
|
1426
|
-
ctx.effect(() => ctx.webServer.register({
|
|
1427
|
-
kind: 'exact',
|
|
1428
|
-
path: '/dsh-subscriptions/import',
|
|
1429
|
-
handler: async (req, res) => {
|
|
1430
|
-
if (req.method !== 'POST') {
|
|
1431
|
-
writeJson(res, 405, { ok: false, error: { code: 'method', message: 'POST only' } })
|
|
1432
|
-
return
|
|
1433
|
-
}
|
|
1434
|
-
if (!isTrustedSettingsRequest(req)) {
|
|
1435
|
-
writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'same-origin only' } })
|
|
1436
|
-
return
|
|
1437
|
-
}
|
|
1438
|
-
let payload
|
|
1439
|
-
try { payload = JSON.parse((await readBody(req, 1024 * 1024)).toString('utf8') || '{}') } catch {
|
|
1440
|
-
writeJson(res, 400, { ok: false, error: { code: 'json', message: 'invalid json' } })
|
|
1441
|
-
return
|
|
1442
|
-
}
|
|
1443
|
-
const { passphrase, payload: encrypted } = payload
|
|
1444
|
-
if (!passphrase || !encrypted) {
|
|
1445
|
-
writeJson(res, 400, { ok: false, error: { code: 'params', message: 'нужны passphrase и payload' } })
|
|
1446
|
-
return
|
|
1447
|
-
}
|
|
1448
|
-
let bundle
|
|
1449
|
-
try {
|
|
1450
|
-
bundle = JSON.parse(decryptWithPassphrase(encrypted, passphrase))
|
|
1451
|
-
} catch (e) {
|
|
1452
|
-
writeJson(res, 400, { ok: false, error: { code: 'decrypt', message: 'неверный passphrase или повреждённый бандл' } })
|
|
1453
|
-
return
|
|
1454
|
-
}
|
|
1455
|
-
if (!bundle || !Array.isArray(bundle.accounts)) {
|
|
1456
|
-
writeJson(res, 400, { ok: false, error: { code: 'format', message: 'неверный формат бандла' } })
|
|
1457
|
-
return
|
|
1458
|
-
}
|
|
1459
|
-
let imported = 0
|
|
1460
|
-
for (const row of bundle.accounts) {
|
|
1461
|
-
try {
|
|
1462
|
-
await store.saveBlob(row.ref, row.blob)
|
|
1463
|
-
imported++
|
|
1464
|
-
} catch { /* skip broken */ }
|
|
1465
|
-
}
|
|
1466
|
-
await syncAdapter()
|
|
1467
|
-
writeJson(res, 200, { ok: true, imported, total: bundle.accounts.length, accounts: await accountsView() })
|
|
1468
|
-
},
|
|
1469
|
-
}), 'dsh-subscriptions: /import')
|
|
1470
|
-
|
|
1471
|
-
// #45: импорт существующего refresh token / API key без OAuth-флоу.
|
|
1472
|
-
ctx.effect(() => ctx.webServer.register({
|
|
1473
|
-
kind: 'exact',
|
|
1474
|
-
path: '/dsh-subscriptions/import-token',
|
|
1475
|
-
handler: async (req, res) => {
|
|
1476
|
-
if (req.method !== 'POST') {
|
|
1477
|
-
writeJson(res, 405, { ok: false, error: { code: 'method', message: 'POST only' } })
|
|
1478
|
-
return
|
|
1479
|
-
}
|
|
1480
|
-
if (!isTrustedSettingsRequest(req)) {
|
|
1481
|
-
writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'same-origin only' } })
|
|
1482
|
-
return
|
|
1483
|
-
}
|
|
1484
|
-
let payload
|
|
1485
|
-
try { payload = JSON.parse((await readBody(req, 64 * 1024)).toString('utf8') || '{}') } catch {
|
|
1486
|
-
writeJson(res, 400, { ok: false, error: { code: 'json', message: 'invalid json' } })
|
|
1487
|
-
return
|
|
1488
|
-
}
|
|
1489
|
-
const provider = payload.provider
|
|
1490
|
-
const index = Number(payload.index || '1')
|
|
1491
|
-
const refreshToken = payload.refreshToken
|
|
1492
|
-
const apiKey = payload.apiKey
|
|
1493
|
-
if (!isProvider(provider)) {
|
|
1494
|
-
writeJson(res, 400, { ok: false, error: { code: 'provider', message: 'unknown provider' } })
|
|
1495
|
-
return
|
|
1496
|
-
}
|
|
1497
|
-
if (!refreshToken && !apiKey) {
|
|
1498
|
-
writeJson(res, 400, { ok: false, error: { code: 'token', message: 'нужен refreshToken или apiKey' } })
|
|
1499
|
-
return
|
|
1500
|
-
}
|
|
1501
|
-
try {
|
|
1502
|
-
const ref = oauthRef(provider, index)
|
|
1503
|
-
const blob = { accessToken: apiKey || refreshToken, refreshToken: refreshToken || apiKey }
|
|
1504
|
-
if (apiKey) { blob.apiKey = apiKey; blob.apiKeyOnly = true }
|
|
1505
|
-
await store.saveBlob(ref, blob)
|
|
1506
|
-
await syncAdapter()
|
|
1507
|
-
refreshModels().catch(() => {})
|
|
1508
|
-
writeJson(res, 200, { ok: true, ref, accounts: await accountsView() })
|
|
1509
|
-
} catch (e) {
|
|
1510
|
-
writeJson(res, 400, { ok: false, error: { code: 'import', message: String(e && e.message || e) } })
|
|
1511
|
-
}
|
|
1512
|
-
},
|
|
1513
|
-
}), 'dsh-subscriptions: /import-token')
|
|
1514
|
-
|
|
1515
|
-
// #50: сводная страница /subscriptions (localhost-only).
|
|
1516
|
-
// #65: история запросов и стоимости (JSON).
|
|
1517
|
-
ctx.effect(() => ctx.webServer.register({
|
|
1518
|
-
kind: 'exact',
|
|
1519
|
-
path: '/dsh-subscriptions/history',
|
|
1520
|
-
handler: async (req, res) => {
|
|
1521
|
-
if (req.method !== 'GET') {
|
|
1522
|
-
writeJson(res, 405, { ok: false, error: { code: 'method', message: 'GET only' } })
|
|
1523
|
-
return
|
|
1524
|
-
}
|
|
1525
|
-
const host = (req.headers.host || '').split(':')[0]
|
|
1526
|
-
if (host !== 'localhost' && host !== '127.0.0.1') {
|
|
1527
|
-
writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'localhost only' } })
|
|
1528
|
-
return
|
|
1529
|
-
}
|
|
1530
|
-
const limit = Math.min(Number(queryOf(req).get('limit') || '10'), 100)
|
|
1531
|
-
writeJson(res, 200, { ok: true, total: history.size(), items: history.recent(limit) })
|
|
1532
|
-
},
|
|
1533
|
-
}), 'dsh-subscriptions: /history')
|
|
1534
|
-
|
|
1535
|
-
ctx.effect(() => ctx.webServer.register({
|
|
1536
|
-
kind: 'exact',
|
|
1537
|
-
path: '/dsh-subscriptions/subscriptions',
|
|
1538
|
-
handler: async (req, res) => {
|
|
1539
|
-
if (req.method !== 'GET') {
|
|
1540
|
-
writeJson(res, 405, { ok: false, error: { code: 'method', message: 'GET only' } })
|
|
1541
|
-
return
|
|
1542
|
-
}
|
|
1543
|
-
const host = (req.headers.host || '').split(':')[0]
|
|
1544
|
-
if (host !== 'localhost' && host !== '127.0.0.1') {
|
|
1545
|
-
writeHtml(res, 403, '<!doctype html><meta charset="utf-8"><p>Subscriptions overview is localhost-only.</p>')
|
|
1546
|
-
return
|
|
1547
|
-
}
|
|
1548
|
-
let cfg, accounts
|
|
1549
|
-
try {
|
|
1550
|
-
const out = await configResponse()
|
|
1551
|
-
cfg = out.config
|
|
1552
|
-
accounts = out.accounts || []
|
|
1553
|
-
} catch (e) {
|
|
1554
|
-
writeHtml(res, 500, '<!doctype html><meta charset="utf-8"><p>Failed to load: ' + escapeHtml(String(e && e.message || e)) + '</p>')
|
|
1555
|
-
return
|
|
1556
|
-
}
|
|
1557
|
-
const rows = accounts.map((a) => {
|
|
1558
|
-
const pct = a.usagePercent != null ? a.usagePercent : (a.quota && a.quota.usedPercent) || null
|
|
1559
|
-
const rem = a.quota && a.quota.remaining != null ? a.quota.remaining : null
|
|
1560
|
-
const lim = a.quota && a.quota.limit != null ? a.quota.limit : null
|
|
1561
|
-
const reset = a.quota && a.quota.resetAt ? new Date(a.quota.resetAt).toLocaleString() : ''
|
|
1562
|
-
const status = a.validationUrl ? 'verify' : (a.cooldownUntil && a.cooldownUntil > Date.now() ? 'cooldown' : (a.configured ? 'ok' : 'none'))
|
|
1563
|
-
return '<tr><td>' + escapeHtml(a.provider) + '</td><td>' + (a.index||1) + '</td>' +
|
|
1564
|
-
'<td>' + escapeHtml(a.label || a.email || '') + '</td><td>' + status + '</td>' +
|
|
1565
|
-
'<td>' + (pct != null ? Math.round(pct) + '%' : '—') + '</td>' +
|
|
1566
|
-
'<td>' + (rem != null ? (rem + (lim != null ? '/' + lim : '')) : '—') + '</td>' +
|
|
1567
|
-
'<td>' + escapeHtml(reset) + '</td><td>' + escapeHtml(a.refreshError || '') + '</td></tr>'
|
|
1568
|
-
})
|
|
1569
|
-
const body = rows.length
|
|
1570
|
-
? '<div class="grid">' + rows.join('') + '</div>'
|
|
1571
|
-
: '<p class="empty">No accounts connected yet.</p>'
|
|
1572
|
-
const slots = cfg.slots || []
|
|
1573
|
-
const connected = accounts.filter((a) => a.configured).length
|
|
1574
|
-
writeHtml(res, 200, `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Subscriptions</title>
|
|
1575
|
-
<style>
|
|
1576
|
-
body{font-family:system-ui,sans-serif;margin:0;padding:24px;background:#0d1117;color:#e6edf3}
|
|
1577
|
-
h1{font-size:20px} .dim{color:#8b949e;font-size:13px}
|
|
1578
|
-
.stats{display:flex;gap:24px;margin:16px 0;font-size:13px}
|
|
1579
|
-
.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(320px,1fr));gap:12px}
|
|
1580
|
-
.card{border:1px solid #30363d;border-radius:10px;padding:14px;background:#161b22}
|
|
1581
|
-
.card b{display:block;margin-bottom:4px}
|
|
1582
|
-
.status{font-size:11px;padding:2px 8px;border-radius:999px;border:1px solid #30363d}
|
|
1583
|
-
.status.ok{color:#3fb950;border-color:#238636} .status.none{color:#8b949e}
|
|
1584
|
-
.status.cooldown{color:#d29922;border-color:#9e6a03} .status.verify{color:#d29922;border-color:#9e6a03}
|
|
1585
|
-
.meta{color:#8b949e;font-size:12px}
|
|
1586
|
-
</style></head><body>
|
|
1587
|
-
<h1>Subscriptions</h1>
|
|
1588
|
-
<div class="dim">/subscriptions — localhost only</div>
|
|
1589
|
-
<div class="stats"><span><b>${connected}</b> connected</span><span><b>${accounts.length}</b> accounts</span><span><b>${slots.length}</b> slots</span></div>
|
|
1590
|
-
${body}
|
|
1591
|
-
<h2>History</h2>
|
|
1592
|
-
<div class="dim">last 10 · <a href="/dsh-subscriptions/history?limit=100">show 100</a></div>
|
|
1593
|
-
<div class="hist" id="hist"></div>
|
|
1594
|
-
<script>
|
|
1595
|
-
fetch('/dsh-subscriptions/history?limit=10').then(r=>r.json()).then(d=>{
|
|
1596
|
-
const el=document.getElementById('hist')
|
|
1597
|
-
if(!d||!d.items||!d.items.length){el.textContent='No requests yet.';return}
|
|
1598
|
-
el.innerHTML='<table class="grid"><tr><th>time</th><th>provider</th><th>model</th><th>path</th><th>status</th></tr>'+
|
|
1599
|
-
d.items.map(i=>'<tr><td>'+new Date(i.ts).toLocaleString()+'</td><td>'+esc(i.provider)+'</td><td>'+esc(i.model||'')+'</td><td>'+esc(i.path)+'</td><td>'+i.status+'</td></tr>').join('')+'</table>'
|
|
1600
|
-
}).catch(()=>{})
|
|
1601
|
-
function esc(x){return String(x==null?'':x).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>')}
|
|
1602
|
-
</script>
|
|
1603
|
-
</body></html>`)
|
|
1604
|
-
},
|
|
1605
|
-
}), 'dsh-subscriptions: /subscriptions')
|
|
1606
|
-
|
|
1607
|
-
ctx.effect(() => ctx.webServer.register({
|
|
1608
|
-
kind: 'exact',
|
|
1609
|
-
path: '/dsh-subscriptions/logout',
|
|
1610
|
-
handler: async (req, res) => {
|
|
1611
|
-
if (req.method !== 'POST') {
|
|
1612
|
-
writeJson(res, 405, { ok: false, error: { code: 'method', message: 'POST only' } })
|
|
1613
|
-
return
|
|
1614
|
-
}
|
|
1615
|
-
if (!isTrustedSettingsRequest(req)) {
|
|
1616
|
-
writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'same-origin only' } })
|
|
1617
|
-
return
|
|
1618
|
-
}
|
|
1619
|
-
let payload
|
|
1620
|
-
try { payload = JSON.parse((await readBody(req, 8 * 1024)).toString('utf8') || '{}') } catch {
|
|
1621
|
-
writeJson(res, 400, { ok: false, error: { code: 'json', message: 'invalid json' } })
|
|
1622
|
-
return
|
|
1623
|
-
}
|
|
1624
|
-
try {
|
|
1625
|
-
const ref = oauthRef(payload.provider, payload.index)
|
|
1626
|
-
await store.clearRef(ref)
|
|
1627
|
-
await syncAdapter()
|
|
1628
|
-
refreshModels().catch(() => {})
|
|
1629
|
-
writeJson(res, 200, { ok: true, ref, accounts: await accountsView() })
|
|
1630
|
-
} catch (e) {
|
|
1631
|
-
writeJson(res, 400, { ok: false, error: { code: 'logout', message: String(e && e.message || e) } })
|
|
1632
|
-
}
|
|
1633
|
-
},
|
|
1634
|
-
}), 'dsh-subscriptions: /logout')
|
|
1635
|
-
}
|
|
1636
|
-
|
|
1637
|
-
function escapeHtml(text) {
|
|
1638
|
-
return String(text)
|
|
1639
|
-
.replace(/&/g, '&')
|
|
1640
|
-
.replace(/</g, '<')
|
|
1641
|
-
.replace(/>/g, '>')
|
|
603
|
+
registerRoutes(ctx, {
|
|
604
|
+
NS,
|
|
605
|
+
live,
|
|
606
|
+
accountsView,
|
|
607
|
+
getSettingsApi: () => settingsApi,
|
|
608
|
+
syncCustomVendors,
|
|
609
|
+
syncAdapter,
|
|
610
|
+
stripLegacySlots,
|
|
611
|
+
store,
|
|
612
|
+
PENDING_TTL_MS,
|
|
613
|
+
redirectFor,
|
|
614
|
+
OK_HTML,
|
|
615
|
+
refreshModels,
|
|
616
|
+
history,
|
|
617
|
+
refForSlot,
|
|
618
|
+
resetCredits,
|
|
619
|
+
diagnosticsReport,
|
|
620
|
+
pending,
|
|
621
|
+
completeOAuth,
|
|
622
|
+
fetchForRef,
|
|
623
|
+
sweepPending,
|
|
624
|
+
subscriptions,
|
|
625
|
+
pmL,
|
|
626
|
+
pmE,
|
|
627
|
+
})
|
|
1642
628
|
}
|