@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/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.effect(() => ctx.webServer.register({
885
- kind: 'exact',
886
- path: '/dsh-subscriptions/oauth/start',
887
- handler: async (req, res) => {
888
- if (req.method !== 'GET') {
889
- writeJson(res, 405, { ok: false, error: { code: 'method', message: 'GET only' } })
890
- return
891
- }
892
- const q = queryOf(req)
893
- const provider = q.get('provider') || ''
894
- const index = Number(q.get('index') || '1')
895
- if (!isProvider(provider)) {
896
- writeJson(res, 400, { ok: false, error: { code: 'provider', message: 'unknown provider' } })
897
- return
898
- }
899
- const origin = requestOrigin(req)
900
- const redirectUri = redirectFor(provider, live(), origin)
901
- const pkce = await createPkce()
902
- pending.set(pkce.state, {
903
- provider,
904
- index,
905
- verifier: pkce.verifier,
906
- challenge: pkce.challenge,
907
- state: pkce.state,
908
- redirectUri,
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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;')}
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, '&amp;')
1640
- .replace(/</g, '&lt;')
1641
- .replace(/>/g, '&gt;')
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
  }