@goodandready/dsh-subscriptions 0.4.14 → 0.4.16
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +157 -213
- package/README.ru.md +174 -0
- package/README.zh.md +109 -0
- package/lib/accounts.js +5 -3
- package/lib/adapter.js +3 -1
- package/lib/client.js +150 -0
- package/lib/index.js +294 -9
- package/lib/loopback.js +66 -0
- package/lib/mask.js +21 -0
- package/lib/proxy.js +79 -0
- package/lib/subscriptions.js +4 -1
- package/lib/vendors/codex.js +45 -0
- package/package.json +6 -2
package/lib/index.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs'
|
|
1
2
|
import z from '@deepseek-ai/schemastery'
|
|
2
|
-
import { PROVIDERS, oauthRef, isProvider, displayName, droppedCredentialRefs } from './refs.js'
|
|
3
|
+
import { PROVIDERS, oauthRef, parseOauthRef, isProvider, displayName, droppedCredentialRefs } from './refs.js'
|
|
3
4
|
import { createPkce } from './pkce.js'
|
|
4
5
|
import { parseCallbackInput, requestOrigin, webCallbackUri } from './oauth.js'
|
|
5
6
|
import { writeJson, writeHtml, readBody, isTrustedSettingsRequest, queryOf } from './http.js'
|
|
@@ -13,6 +14,9 @@ import { createSubscriptionsService } from './subscriptions.js'
|
|
|
13
14
|
import { encryptWithPassphrase, decryptWithPassphrase } from './crypto.js'
|
|
14
15
|
import { quotaSnapshot } from './ratelimit.js'
|
|
15
16
|
import { createAccountStore, normalizeSlots, vendorConfig } from './accounts.js'
|
|
17
|
+
import { startLoopback } from './loopback.js'
|
|
18
|
+
import { maskEmail, maskLabel, maskText } from './mask.js'
|
|
19
|
+
import { proxyFetch, pickFetch } from './proxy.js'
|
|
16
20
|
import { HistoryStore } from './history.js'
|
|
17
21
|
import {
|
|
18
22
|
inspectGoogleAccount,
|
|
@@ -24,6 +28,9 @@ export const name = 'dsh-subscriptions'
|
|
|
24
28
|
export const inject = ['llm', 'credentials', 'webServer', 'settings']
|
|
25
29
|
|
|
26
30
|
const NS = 'dsh-subscriptions'
|
|
31
|
+
|
|
32
|
+
let pkgVersion = ''
|
|
33
|
+
try { pkgVersion = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version || '' } catch {}
|
|
27
34
|
const PENDING_TTL_MS = 15 * 60 * 1000
|
|
28
35
|
|
|
29
36
|
const Slot = z.object({
|
|
@@ -35,6 +42,8 @@ const Slot = z.object({
|
|
|
35
42
|
.description('Optional display label. Empty uses the account email after login.'),
|
|
36
43
|
expiresAt: z.number().default(0)
|
|
37
44
|
.description('#67 Optional subscription expiry timestamp (ms). When set and within expiryNotifyDays, the header chip shows the account and expiry date.'),
|
|
45
|
+
proxyUrl: z.string().default('')
|
|
46
|
+
.description('#88 Per-account proxy URL (http://, https://, socks5://). All requests for this account route through it. Empty = direct connection.'),
|
|
38
47
|
})
|
|
39
48
|
|
|
40
49
|
const defaultSlots = PROVIDERS.map((provider) => ({ provider, index: 1, label: '' }))
|
|
@@ -54,10 +63,14 @@ export const Config = z.object({
|
|
|
54
63
|
.description('Emit log notices when usage crosses 70/90/100% of a window.'),
|
|
55
64
|
expiryNotifyDays: z.number().default(7)
|
|
56
65
|
.description('#67 Warn in the header chip this many days before a subscription expiry (expiresAt). 0 disables.'),
|
|
66
|
+
privacyMask: z.boolean().default(false)
|
|
67
|
+
.description('#98 Hide personal data in the UI: emails show as j***n@example.com.'),
|
|
57
68
|
slots: z.array(Slot).default(defaultSlots)
|
|
58
69
|
.description('Account slots. Secrets are not stored here; only the credential ref names.'),
|
|
59
70
|
useWebCallback: z.boolean().default(false)
|
|
60
71
|
.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.'),
|
|
72
|
+
autoLoopback: z.boolean().default(true)
|
|
73
|
+
.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.'),
|
|
61
74
|
codexClientId: z.string().default(''),
|
|
62
75
|
codexRedirectUri: z.string().default(''),
|
|
63
76
|
codexBaseUrl: z.string().default(''),
|
|
@@ -87,6 +100,8 @@ function redirectFor(provider, cfg, origin) {
|
|
|
87
100
|
return overlay.redirectUri || webCallbackUri(origin)
|
|
88
101
|
}
|
|
89
102
|
|
|
103
|
+
const OK_HTML = '<!doctype html><meta charset="utf-8"><title>Subscriptions</title><p>Signed in. You can close this tab and return to Settings.</p>'
|
|
104
|
+
|
|
90
105
|
export function apply(ctx, config) {
|
|
91
106
|
let getConfig = () => config
|
|
92
107
|
let settingsApi
|
|
@@ -126,10 +141,20 @@ export function apply(ctx, config) {
|
|
|
126
141
|
const live = () => Config(structuredClone(getConfig() ?? {})) ?? config
|
|
127
142
|
syncCustomVendors()
|
|
128
143
|
|
|
144
|
+
// #88: fetch bound to the per-account proxy (slot.proxyUrl), or null for direct.
|
|
145
|
+
const fetchForRef = (ref) => {
|
|
146
|
+
const parsed = parseOauthRef(ref)
|
|
147
|
+
if (!parsed) return null
|
|
148
|
+
const slot = normalizeSlots(live().slots).find((s) => s.provider === parsed.provider && s.index === parsed.index)
|
|
149
|
+
if (!slot || !slot.proxyUrl) return null
|
|
150
|
+
return proxyFetch(slot.proxyUrl)
|
|
151
|
+
}
|
|
152
|
+
|
|
129
153
|
const store = createAccountStore({
|
|
130
154
|
credentials: ctx.credentials,
|
|
131
155
|
getConfig: live,
|
|
132
156
|
fetchImpl: fetch,
|
|
157
|
+
fetchForRef,
|
|
133
158
|
onLimitNotice: (provider, ref, win, threshold) => {
|
|
134
159
|
if (!live().notifyLimits) return
|
|
135
160
|
const label = win.ru || win.en || win.id
|
|
@@ -159,6 +184,7 @@ export function apply(ctx, config) {
|
|
|
159
184
|
getRequestCount: (ref) => store.getRequestCount(ref),
|
|
160
185
|
recordHistory,
|
|
161
186
|
fetchImpl: fetch,
|
|
187
|
+
fetchForRef,
|
|
162
188
|
})
|
|
163
189
|
|
|
164
190
|
// Служба генерации картинок на подписке.
|
|
@@ -216,6 +242,7 @@ export function apply(ctx, config) {
|
|
|
216
242
|
saveBlob: (ref, blob) => store.saveBlob(ref, blob),
|
|
217
243
|
recordHistory,
|
|
218
244
|
fetchImpl: fetch,
|
|
245
|
+
fetchForRef,
|
|
219
246
|
})
|
|
220
247
|
|
|
221
248
|
let handle
|
|
@@ -291,7 +318,7 @@ export function apply(ctx, config) {
|
|
|
291
318
|
pending.delete(row.state)
|
|
292
319
|
await syncAdapter()
|
|
293
320
|
refreshModels().catch(() => {})
|
|
294
|
-
return { ref, label: blob.label || blob.email || displayName(provider) }
|
|
321
|
+
return { ref, label: pmL(blob.label || blob.email) || displayName(provider) }
|
|
295
322
|
}
|
|
296
323
|
|
|
297
324
|
async function enrichAntigravityAccount(slot, info) {
|
|
@@ -343,6 +370,69 @@ export function apply(ctx, config) {
|
|
|
343
370
|
return (slots || []).filter((slot) => isProvider(slot.provider))
|
|
344
371
|
}
|
|
345
372
|
|
|
373
|
+
// #98: privacy masking. pmE for raw emails, pmL for display labels (only email-looking ones masked).
|
|
374
|
+
const privacyOn = () => !!live().privacyMask
|
|
375
|
+
const pmE = (s) => privacyOn() ? maskEmail(s) : String(s || '')
|
|
376
|
+
const pmL = (s) => privacyOn() ? maskLabel(s) : String(s || '')
|
|
377
|
+
|
|
378
|
+
// #99: anonymized diagnostics report. No tokens, emails, refs, proxy URLs.
|
|
379
|
+
function scrubReport(v) {
|
|
380
|
+
if (typeof v === 'string') return maskText(v)
|
|
381
|
+
if (Array.isArray(v)) return v.map(scrubReport)
|
|
382
|
+
if (v && typeof v === 'object') {
|
|
383
|
+
const o = {}
|
|
384
|
+
for (const k of Object.keys(v)) o[k] = scrubReport(v[k])
|
|
385
|
+
return o
|
|
386
|
+
}
|
|
387
|
+
return v
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
async function diagnosticsReport() {
|
|
391
|
+
const cfg = live()
|
|
392
|
+
const slots = normalizeSlots(cfg.slots)
|
|
393
|
+
const mk = () => ({ loggedIn: false, slots: 0, configured: 0, cooldown: 0, proxy: 0, maxUsagePercent: null })
|
|
394
|
+
const providers = {}
|
|
395
|
+
for (const p of PROVIDERS) providers[p] = mk()
|
|
396
|
+
const logged = await store.loggedInProviders()
|
|
397
|
+
for (const slot of slots) {
|
|
398
|
+
const pv = providers[slot.provider] || (providers[slot.provider] = mk())
|
|
399
|
+
pv.slots++
|
|
400
|
+
if (slot.proxyUrl) pv.proxy++
|
|
401
|
+
try {
|
|
402
|
+
const info = await store.describeRef(slot.ref)
|
|
403
|
+
if (info.configured) pv.configured++
|
|
404
|
+
if (info.cooldownUntil && info.cooldownUntil > Date.now()) pv.cooldown++
|
|
405
|
+
if (info.usagePercent != null) pv.maxUsagePercent = Math.max(pv.maxUsagePercent || 0, Math.round(info.usagePercent))
|
|
406
|
+
} catch {}
|
|
407
|
+
}
|
|
408
|
+
for (const p of Object.keys(providers)) providers[p].loggedIn = logged.includes(p)
|
|
409
|
+
const rows = history.all()
|
|
410
|
+
const byStatus = {}
|
|
411
|
+
for (const r of rows) {
|
|
412
|
+
const k = (r.provider || '?') + ':' + (r.status || '?')
|
|
413
|
+
byStatus[k] = (byStatus[k] || 0) + 1
|
|
414
|
+
}
|
|
415
|
+
const lastErrors = rows.filter((r) => r.status && r.status >= 400).slice(0, 10)
|
|
416
|
+
.map((r) => ({ ts: r.ts, provider: r.provider, status: r.status, kind: r.kind || 'request', ms: r.ms || null }))
|
|
417
|
+
return scrubReport({
|
|
418
|
+
generatedAt: new Date().toISOString(),
|
|
419
|
+
plugin: NS + (pkgVersion ? ' v' + pkgVersion : ''),
|
|
420
|
+
runtime: { node: process.version, platform: process.platform, arch: process.arch, uptimeSec: Math.round(process.uptime()) },
|
|
421
|
+
providers,
|
|
422
|
+
requests: { total: rows.length, byStatus, lastErrors },
|
|
423
|
+
settings: {
|
|
424
|
+
cooldownMs: cfg.cooldownMs,
|
|
425
|
+
switchAtRemaining: cfg.switchAtRemaining,
|
|
426
|
+
probeIntervalMin: cfg.probeIntervalMin,
|
|
427
|
+
useWebCallback: !!cfg.useWebCallback,
|
|
428
|
+
autoLoopback: !!cfg.autoLoopback,
|
|
429
|
+
privacyMask: !!cfg.privacyMask,
|
|
430
|
+
customVendors: Array.isArray(cfg.customVendors) ? cfg.customVendors.length : 0,
|
|
431
|
+
proxySlots: slots.filter((s) => s.proxyUrl).length,
|
|
432
|
+
},
|
|
433
|
+
})
|
|
434
|
+
}
|
|
435
|
+
|
|
346
436
|
async function accountsView() {
|
|
347
437
|
const out = []
|
|
348
438
|
for (const slot of normalizeSlots(live().slots)) {
|
|
@@ -351,7 +441,7 @@ export function apply(ctx, config) {
|
|
|
351
441
|
provider: slot.provider,
|
|
352
442
|
index: slot.index,
|
|
353
443
|
ref: slot.ref,
|
|
354
|
-
label: slot.label || info.label,
|
|
444
|
+
label: pmL(slot.label || info.label),
|
|
355
445
|
configured: info.configured,
|
|
356
446
|
writable: info.writable,
|
|
357
447
|
cooldownUntil: info.cooldownUntil,
|
|
@@ -541,7 +631,7 @@ export function apply(ctx, config) {
|
|
|
541
631
|
// #67: дата окончания подписки берётся из слота (вводится в настройках).
|
|
542
632
|
if (slot.expiresAt) {
|
|
543
633
|
expires[slot.provider] = Math.max(expires[slot.provider] || 0, slot.expiresAt)
|
|
544
|
-
labels[slot.provider] = slot.label || info.label || slot.provider
|
|
634
|
+
labels[slot.provider] = pmL(slot.label || info.label || slot.provider)
|
|
545
635
|
}
|
|
546
636
|
} catch {}
|
|
547
637
|
}
|
|
@@ -591,6 +681,18 @@ export function apply(ctx, config) {
|
|
|
591
681
|
},
|
|
592
682
|
}), 'dsh-subscriptions: /status')
|
|
593
683
|
|
|
684
|
+
ctx.effect(() => ctx.webServer.register({
|
|
685
|
+
kind: 'exact',
|
|
686
|
+
path: '/dsh-subscriptions/diagnostics',
|
|
687
|
+
handler: async (req, res) => {
|
|
688
|
+
if (req.method !== 'GET') {
|
|
689
|
+
writeJson(res, 405, { ok: false, error: { code: 'method', message: 'GET only' } })
|
|
690
|
+
return
|
|
691
|
+
}
|
|
692
|
+
writeJson(res, 200, { ok: true, report: await diagnosticsReport() })
|
|
693
|
+
},
|
|
694
|
+
}), 'dsh-subscriptions: /diagnostics')
|
|
695
|
+
|
|
594
696
|
ctx.effect(() => ctx.webServer.register({
|
|
595
697
|
kind: 'exact',
|
|
596
698
|
path: '/dsh-subscriptions/oauth/start',
|
|
@@ -620,10 +722,135 @@ export function apply(ctx, config) {
|
|
|
620
722
|
})
|
|
621
723
|
const cfg = { ...vendorConfig(provider, live()), redirectUri }
|
|
622
724
|
const url = getVendor(provider).authorizeUrl(cfg, pkce)
|
|
623
|
-
|
|
725
|
+
// #89: если redirect_uri loopback — поднять временный сервер перехвата.
|
|
726
|
+
let autoCatch = false
|
|
727
|
+
if (live().autoLoopback) {
|
|
728
|
+
try {
|
|
729
|
+
const cb = new URL(redirectUri)
|
|
730
|
+
if (cb.hostname === 'localhost' || cb.hostname === '127.0.0.1') {
|
|
731
|
+
autoCatch = true
|
|
732
|
+
startLoopback({
|
|
733
|
+
redirectUri,
|
|
734
|
+
onCode: async (params) => {
|
|
735
|
+
const code = params.get('code') || ''
|
|
736
|
+
const state = params.get('state') || ''
|
|
737
|
+
if (!code) throw new Error('no code')
|
|
738
|
+
await completeOAuth({ provider, index, code, state })
|
|
739
|
+
return OK_HTML
|
|
740
|
+
},
|
|
741
|
+
}).catch(() => {})
|
|
742
|
+
}
|
|
743
|
+
} catch { autoCatch = false }
|
|
744
|
+
}
|
|
745
|
+
writeJson(res, 200, { ok: true, url, state: pkce.state, redirectUri, autoCatch })
|
|
624
746
|
},
|
|
625
747
|
}), 'dsh-subscriptions: /oauth/start')
|
|
626
748
|
|
|
749
|
+
// #90: Device-code login (headless/VPS). Codex-only: auth.openai.com mints an
|
|
750
|
+
// authorization_code + code_verifier server-side; we finish with the normal
|
|
751
|
+
// PKCE exchange using the device redirect URI. Device code stays server-side
|
|
752
|
+
// in the pending map (same lifetime as PKCE pending rows).
|
|
753
|
+
ctx.effect(() => ctx.webServer.register({
|
|
754
|
+
kind: 'exact',
|
|
755
|
+
path: '/dsh-subscriptions/oauth/device/start',
|
|
756
|
+
handler: async (req, res) => {
|
|
757
|
+
if (req.method !== 'POST') {
|
|
758
|
+
writeJson(res, 405, { ok: false, error: { code: 'method', message: 'POST only' } })
|
|
759
|
+
return
|
|
760
|
+
}
|
|
761
|
+
if (!isTrustedSettingsRequest(req)) {
|
|
762
|
+
writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'same-origin only' } })
|
|
763
|
+
return
|
|
764
|
+
}
|
|
765
|
+
let body
|
|
766
|
+
try {
|
|
767
|
+
body = JSON.parse((await readBody(req, 16 * 1024)).toString('utf8'))
|
|
768
|
+
} catch {
|
|
769
|
+
writeJson(res, 400, { ok: false, error: { code: 'json', message: 'invalid json' } })
|
|
770
|
+
return
|
|
771
|
+
}
|
|
772
|
+
const provider = String(body.provider || '')
|
|
773
|
+
const index = Number(body.index || 1)
|
|
774
|
+
if (!isProvider(provider)) {
|
|
775
|
+
writeJson(res, 400, { ok: false, error: { code: 'provider', message: 'unknown provider' } })
|
|
776
|
+
return
|
|
777
|
+
}
|
|
778
|
+
const vendor = getVendor(provider)
|
|
779
|
+
if (typeof vendor.deviceStart !== 'function') {
|
|
780
|
+
writeJson(res, 400, { ok: false, error: { code: 'device', message: 'device login not supported for ' + provider } })
|
|
781
|
+
return
|
|
782
|
+
}
|
|
783
|
+
try {
|
|
784
|
+
const cfg = vendorConfig(provider, live())
|
|
785
|
+
const start = await vendor.deviceStart(cfg, fetch)
|
|
786
|
+
const state = 'dev-' + Date.now() + '-' + Math.random().toString(36).slice(2, 10)
|
|
787
|
+
sweepPending(Date.now())
|
|
788
|
+
pending.set(state, {
|
|
789
|
+
kind: 'device',
|
|
790
|
+
provider,
|
|
791
|
+
index,
|
|
792
|
+
ref: oauthRef(provider, index),
|
|
793
|
+
deviceAuthId: start.deviceAuthId,
|
|
794
|
+
userCode: start.userCode,
|
|
795
|
+
intervalMs: start.intervalMs,
|
|
796
|
+
createdAt: Date.now(),
|
|
797
|
+
})
|
|
798
|
+
writeJson(res, 200, { ok: true, state, userCode: start.userCode, authUrl: start.authUrl, intervalMs: start.intervalMs })
|
|
799
|
+
} catch (e) {
|
|
800
|
+
writeJson(res, 502, { ok: false, error: { code: e && e.code || 'DEVICE', message: String(e && e.message || e).slice(0, 300) } })
|
|
801
|
+
}
|
|
802
|
+
},
|
|
803
|
+
}), 'dsh-subscriptions: /oauth/device/start')
|
|
804
|
+
|
|
805
|
+
ctx.effect(() => ctx.webServer.register({
|
|
806
|
+
kind: 'exact',
|
|
807
|
+
path: '/dsh-subscriptions/oauth/device/poll',
|
|
808
|
+
handler: async (req, res) => {
|
|
809
|
+
if (req.method !== 'POST') {
|
|
810
|
+
writeJson(res, 405, { ok: false, error: { code: 'method', message: 'POST only' } })
|
|
811
|
+
return
|
|
812
|
+
}
|
|
813
|
+
if (!isTrustedSettingsRequest(req)) {
|
|
814
|
+
writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'same-origin only' } })
|
|
815
|
+
return
|
|
816
|
+
}
|
|
817
|
+
let body
|
|
818
|
+
try {
|
|
819
|
+
body = JSON.parse((await readBody(req, 16 * 1024)).toString('utf8'))
|
|
820
|
+
} catch {
|
|
821
|
+
writeJson(res, 400, { ok: false, error: { code: 'json', message: 'invalid json' } })
|
|
822
|
+
return
|
|
823
|
+
}
|
|
824
|
+
const state = String(body.state || '')
|
|
825
|
+
sweepPending(Date.now())
|
|
826
|
+
const row = pending.get(state)
|
|
827
|
+
if (!row || row.kind !== 'device') {
|
|
828
|
+
writeJson(res, 404, { ok: false, error: { code: 'expired', message: 'device login session expired; start again' } })
|
|
829
|
+
return
|
|
830
|
+
}
|
|
831
|
+
const vendor = getVendor(row.provider)
|
|
832
|
+
try {
|
|
833
|
+
const cfg = vendorConfig(row.provider, live())
|
|
834
|
+
const out = await vendor.devicePoll(cfg, { deviceAuthId: row.deviceAuthId, userCode: row.userCode }, fetch)
|
|
835
|
+
if (out.status !== 'authorized') {
|
|
836
|
+
writeJson(res, 200, { ok: true, status: out.status })
|
|
837
|
+
return
|
|
838
|
+
}
|
|
839
|
+
const slots = normalizeSlots(live().slots)
|
|
840
|
+
const slot = slots.find((s) => s.ref === row.ref)
|
|
841
|
+
const blob = out.blob
|
|
842
|
+
if (slot && slot.label) blob.label = slot.label
|
|
843
|
+
await store.saveBlob(row.ref, blob)
|
|
844
|
+
pending.delete(state)
|
|
845
|
+
await syncAdapter()
|
|
846
|
+
refreshModels().catch(() => {})
|
|
847
|
+
writeJson(res, 200, { ok: true, status: 'authorized', ref: row.ref, label: pmL(blob.label || blob.email) || displayName(row.provider) })
|
|
848
|
+
} catch (e) {
|
|
849
|
+
writeJson(res, 502, { ok: false, error: { code: e && e.code || 'DEVICE', message: String(e && e.message || e).slice(0, 300) } })
|
|
850
|
+
}
|
|
851
|
+
},
|
|
852
|
+
}), 'dsh-subscriptions: /oauth/device/poll')
|
|
853
|
+
|
|
627
854
|
ctx.effect(() => ctx.webServer.register({
|
|
628
855
|
kind: 'exact',
|
|
629
856
|
path: '/dsh-subscriptions/oauth/callback',
|
|
@@ -721,7 +948,7 @@ export function apply(ctx, config) {
|
|
|
721
948
|
try { blob = await store.ensureFresh(provider, blob, ref) } catch (e) {
|
|
722
949
|
writeJson(res, 200, {
|
|
723
950
|
ok: false, provider, index: payload.index, ref,
|
|
724
|
-
email: blob.email
|
|
951
|
+
email: pmE(blob.email), label: pmL(blob.label), expiresAt: blob.expiresAt || null,
|
|
725
952
|
quota: info.quota || null,
|
|
726
953
|
error: { code: 'refresh', message: String(e && e.message || e) },
|
|
727
954
|
})
|
|
@@ -729,7 +956,7 @@ export function apply(ctx, config) {
|
|
|
729
956
|
}
|
|
730
957
|
const vendor = getVendor(provider)
|
|
731
958
|
if (typeof vendor.check !== 'function') {
|
|
732
|
-
writeJson(res, 200, { ok: true, provider, index: payload.index, ref, email: blob.email
|
|
959
|
+
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 })
|
|
733
960
|
return
|
|
734
961
|
}
|
|
735
962
|
let capturedQuota = null
|
|
@@ -743,11 +970,11 @@ export function apply(ctx, config) {
|
|
|
743
970
|
}
|
|
744
971
|
try {
|
|
745
972
|
await vendor.check(blob, vendorConfig(provider, live()), probeFetch)
|
|
746
|
-
writeJson(res, 200, { ok: true, provider, index: payload.index, ref, email: blob.email
|
|
973
|
+
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 })
|
|
747
974
|
} catch (e) {
|
|
748
975
|
writeJson(res, 200, {
|
|
749
976
|
ok: false, provider, index: payload.index, ref,
|
|
750
|
-
email: blob.email
|
|
977
|
+
email: pmE(blob.email), label: pmL(blob.label), expiresAt: blob.expiresAt || null,
|
|
751
978
|
quota: capturedQuota || info.quota || null,
|
|
752
979
|
error: { code: e && e.code ? e.code : 'VENDOR', message: String(e && e.message || e).slice(0, 300) },
|
|
753
980
|
})
|
|
@@ -810,6 +1037,64 @@ export function apply(ctx, config) {
|
|
|
810
1037
|
},
|
|
811
1038
|
}), 'dsh-subscriptions: proxy')
|
|
812
1039
|
|
|
1040
|
+
// #88: проверка прокси аккаунта — реальный запрос к эндпоинту провайдера с замером задержки.
|
|
1041
|
+
ctx.effect(() => ctx.webServer.register({
|
|
1042
|
+
kind: 'exact',
|
|
1043
|
+
path: '/dsh-subscriptions/proxy-check',
|
|
1044
|
+
handler: async (req, res) => {
|
|
1045
|
+
if (req.method !== 'POST') {
|
|
1046
|
+
writeJson(res, 405, { ok: false, error: { code: 'method', message: 'POST only' } })
|
|
1047
|
+
return
|
|
1048
|
+
}
|
|
1049
|
+
if (!isTrustedSettingsRequest(req)) {
|
|
1050
|
+
writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'same-origin only' } })
|
|
1051
|
+
return
|
|
1052
|
+
}
|
|
1053
|
+
let body
|
|
1054
|
+
try {
|
|
1055
|
+
body = JSON.parse((await readBody(req, 16 * 1024)).toString('utf8'))
|
|
1056
|
+
} catch {
|
|
1057
|
+
writeJson(res, 400, { ok: false, error: { code: 'json', message: 'invalid json' } })
|
|
1058
|
+
return
|
|
1059
|
+
}
|
|
1060
|
+
const provider = String(body.provider || '')
|
|
1061
|
+
const index = Number(body.index || 1)
|
|
1062
|
+
if (!isProvider(provider)) {
|
|
1063
|
+
writeJson(res, 400, { ok: false, error: { code: 'provider', message: 'unknown provider' } })
|
|
1064
|
+
return
|
|
1065
|
+
}
|
|
1066
|
+
const slots = normalizeSlots(live().slots)
|
|
1067
|
+
const slot = slots.find((s) => s.provider === provider && s.index === index)
|
|
1068
|
+
const proxyUrl = (slot && slot.proxyUrl) || ''
|
|
1069
|
+
const DEFAULT_BASE = {
|
|
1070
|
+
codex: 'https://chatgpt.com/backend-api/codex',
|
|
1071
|
+
claude: 'https://api.anthropic.com',
|
|
1072
|
+
grok: 'https://api.x.ai/v1',
|
|
1073
|
+
antigravity: 'https://cloudcode-pa.googleapis.com',
|
|
1074
|
+
}
|
|
1075
|
+
const base = String((vendorConfig(provider, live()) || {}).baseUrl || DEFAULT_BASE[provider] || '').replace(/\/$/, '')
|
|
1076
|
+
const started = Date.now()
|
|
1077
|
+
try {
|
|
1078
|
+
const impl = (proxyUrl && proxyFetch(proxyUrl)) || fetch
|
|
1079
|
+
if (proxyUrl && impl === fetch) throw new Error('invalid proxy URL')
|
|
1080
|
+
const out = await impl(base + '/models', {
|
|
1081
|
+
method: 'GET',
|
|
1082
|
+
headers: { Accept: 'application/json' },
|
|
1083
|
+
signal: AbortSignal.timeout(10000),
|
|
1084
|
+
})
|
|
1085
|
+
// Любой HTTP-ответ (включая 401/403) = прокси и эндпоинт доступны.
|
|
1086
|
+
writeJson(res, 200, { ok: true, status: out.status, latencyMs: Date.now() - started, viaProxy: !!proxyUrl })
|
|
1087
|
+
} catch (e) {
|
|
1088
|
+
writeJson(res, 200, {
|
|
1089
|
+
ok: false,
|
|
1090
|
+
latencyMs: Date.now() - started,
|
|
1091
|
+
viaProxy: !!proxyUrl,
|
|
1092
|
+
error: { code: (e && e.code) || 'NETWORK', message: String((e && e.message) || e).slice(0, 200) },
|
|
1093
|
+
})
|
|
1094
|
+
}
|
|
1095
|
+
},
|
|
1096
|
+
}), 'dsh-subscriptions: proxy-check')
|
|
1097
|
+
|
|
813
1098
|
// Экспорт зашифрованного бандла токенов. Токены не логгируются.
|
|
814
1099
|
ctx.effect(() => ctx.webServer.register({
|
|
815
1100
|
kind: 'exact',
|
package/lib/loopback.js
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import http from 'node:http'
|
|
2
|
+
|
|
3
|
+
// #89: временный loopback-сервер для перехвата OAuth callback.
|
|
4
|
+
// Слушает на зарегистрированном у провайдера redirect_uri (порт фиксирован
|
|
5
|
+
// вендором: codex localhost:1455, grok 127.0.0.1:56121). Принимает GET с
|
|
6
|
+
// code/state, отдаёт HTML-страницу и завершается после первого запроса.
|
|
7
|
+
|
|
8
|
+
const OK_HTML = '<!doctype html><meta charset="utf-8"><title>Subscriptions</title><p>Signed in. You can close this tab and return to Settings.</p>'
|
|
9
|
+
const ERR_HTML = '<!doctype html><meta charset="utf-8"><title>Subscriptions</title><p>Login failed. Return to Settings and paste the redirected URL.</p>'
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Поднять loopback-сервер и дождаться callback.
|
|
13
|
+
* @param {object} opts
|
|
14
|
+
* @param {string} opts.redirectUri зарегистрированный redirect_uri (localhost/127.0.0.1)
|
|
15
|
+
* @param {number} opts.timeoutMs время жизни сервера
|
|
16
|
+
* @param {(query: URLSearchParams) => Promise<string>} opts.onCode
|
|
17
|
+
* вызывается с query callback-запроса; возвращает HTML для браузера.
|
|
18
|
+
* Бросок исключения = ошибка авторизации (отдаётся ERR_HTML).
|
|
19
|
+
* @returns {Promise<{url: string, close: () => void}>}
|
|
20
|
+
*/
|
|
21
|
+
export function startLoopback({ redirectUri, timeoutMs = 10 * 60 * 1000, onCode }) {
|
|
22
|
+
const parsed = new URL(redirectUri)
|
|
23
|
+
if (parsed.hostname !== 'localhost' && parsed.hostname !== '127.0.0.1') {
|
|
24
|
+
throw new Error(`loopback redirect requires localhost, got ${parsed.hostname}`)
|
|
25
|
+
}
|
|
26
|
+
const port = Number(parsed.port) || 80
|
|
27
|
+
const path = parsed.pathname
|
|
28
|
+
|
|
29
|
+
const state = { server: null, timer: null, done: false }
|
|
30
|
+
const promise = new Promise((resolve, reject) => {
|
|
31
|
+
const server = http.createServer((req, res) => {
|
|
32
|
+
const url = new URL(req.url || '/', `http://127.0.0.1:${port}`)
|
|
33
|
+
// Провайдер может редиректить на путь с suffix (например /auth/callback/extra)
|
|
34
|
+
if (!url.pathname.startsWith(path)) {
|
|
35
|
+
res.writeHead(404, { 'Content-Type': 'text/html; charset=utf-8' })
|
|
36
|
+
res.end(ERR_HTML)
|
|
37
|
+
return
|
|
38
|
+
}
|
|
39
|
+
if (state.done) return
|
|
40
|
+
state.done = true
|
|
41
|
+
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' })
|
|
42
|
+
Promise.resolve(onCode(url.searchParams))
|
|
43
|
+
.then((html) => res.end(html || OK_HTML))
|
|
44
|
+
.catch(() => res.end(ERR_HTML))
|
|
45
|
+
.finally(() => {
|
|
46
|
+
clearTimeout(state.timer)
|
|
47
|
+
server.close()
|
|
48
|
+
resolve({ ok: true })
|
|
49
|
+
})
|
|
50
|
+
})
|
|
51
|
+
server.on('error', (err) => {
|
|
52
|
+
state.done = true
|
|
53
|
+
clearTimeout(state.timer)
|
|
54
|
+
reject(err)
|
|
55
|
+
})
|
|
56
|
+
state.server = server
|
|
57
|
+
server.listen(port, parsed.hostname, () => {})
|
|
58
|
+
state.timer = setTimeout(() => {
|
|
59
|
+
if (state.done) return
|
|
60
|
+
state.done = true
|
|
61
|
+
server.close()
|
|
62
|
+
reject(new Error('loopback timeout: no callback received'))
|
|
63
|
+
}, timeoutMs)
|
|
64
|
+
})
|
|
65
|
+
return promise
|
|
66
|
+
}
|
package/lib/mask.js
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// #98: privacy masking. Emails -> j***n@example.com. Non-email labels pass through.
|
|
2
|
+
export function maskEmail(value) {
|
|
3
|
+
const s = String(value || '')
|
|
4
|
+
const at = s.indexOf('@')
|
|
5
|
+
if (at <= 0) return s
|
|
6
|
+
const local = s.slice(0, at)
|
|
7
|
+
const tail = local.length > 2 ? local[local.length - 1] : ''
|
|
8
|
+
return local[0] + '***' + tail + s.slice(at)
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function maskLabel(value) {
|
|
12
|
+
const s = String(value || '')
|
|
13
|
+
return s.includes('@') ? maskEmail(s) : s
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const EMAIL_RE = /[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/g
|
|
17
|
+
|
|
18
|
+
// #99: scrub free-text (error messages, notes): mask embedded emails only.
|
|
19
|
+
export function maskText(value) {
|
|
20
|
+
return String(value || '').replace(EMAIL_RE, (m) => maskEmail(m))
|
|
21
|
+
}
|
package/lib/proxy.js
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
// #88: per-account proxy support.
|
|
2
|
+
// http/https proxies -> undici ProxyAgent; socks5 -> undici Agent with a socks connector.
|
|
3
|
+
import { ProxyAgent, Agent, fetch as undiciFetch } from 'undici'
|
|
4
|
+
import tls from 'node:tls'
|
|
5
|
+
import { SocksClient } from 'socks'
|
|
6
|
+
|
|
7
|
+
const cache = new Map()
|
|
8
|
+
|
|
9
|
+
export function parseProxyUrl(raw) {
|
|
10
|
+
const s = String(raw || '').trim()
|
|
11
|
+
if (!s) return null
|
|
12
|
+
let u
|
|
13
|
+
try { u = new URL(s) } catch { return null }
|
|
14
|
+
const scheme = u.protocol.replace(':', '')
|
|
15
|
+
if (scheme !== 'http' && scheme !== 'https' && scheme !== 'socks5') return null
|
|
16
|
+
if (!u.hostname) return null
|
|
17
|
+
const port = Number(u.port) || (scheme === 'socks5' ? 1080 : (scheme === 'https' ? 443 : 80))
|
|
18
|
+
const auth = u.username
|
|
19
|
+
? { username: decodeURIComponent(u.username), password: decodeURIComponent(u.password || '') }
|
|
20
|
+
: null
|
|
21
|
+
return { href: s, scheme, host: u.hostname, port, auth }
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function socksDispatcher(p) {
|
|
25
|
+
return new Agent({
|
|
26
|
+
connect: (opts, callback) => {
|
|
27
|
+
SocksClient.createConnection({
|
|
28
|
+
proxy: {
|
|
29
|
+
host: p.host,
|
|
30
|
+
port: p.port,
|
|
31
|
+
type: 5,
|
|
32
|
+
...(p.auth ? { userId: p.auth.username, password: p.auth.password } : {}),
|
|
33
|
+
},
|
|
34
|
+
command: 'connect',
|
|
35
|
+
destination: { host: opts.hostname || opts.host, port: Number(opts.port) || 443 },
|
|
36
|
+
}).then(({ socket }) => {
|
|
37
|
+
if (String(opts.protocol) !== 'https:') {
|
|
38
|
+
callback(null, socket)
|
|
39
|
+
return
|
|
40
|
+
}
|
|
41
|
+
const tlsSocket = tls.connect({
|
|
42
|
+
socket,
|
|
43
|
+
servername: opts.servername || opts.hostname || opts.host,
|
|
44
|
+
})
|
|
45
|
+
tlsSocket.once('secureConnect', () => callback(null, tlsSocket))
|
|
46
|
+
tlsSocket.once('error', (e) => callback(e))
|
|
47
|
+
}).catch(callback)
|
|
48
|
+
},
|
|
49
|
+
})
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function httpDispatcher(p) {
|
|
53
|
+
return new ProxyAgent({
|
|
54
|
+
uri: p.href,
|
|
55
|
+
...(p.auth ? { token: 'Basic ' + Buffer.from(p.auth.username + ':' + p.auth.password).toString('base64') } : {}),
|
|
56
|
+
})
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Returns a fetch bound to the proxy dispatcher, or null for empty/invalid proxy. Dispatchers are cached per URL. */
|
|
60
|
+
export function proxyFetch(raw) {
|
|
61
|
+
const p = parseProxyUrl(raw)
|
|
62
|
+
if (!p) return null
|
|
63
|
+
let f = cache.get(p.href)
|
|
64
|
+
if (!f) {
|
|
65
|
+
const d = p.scheme === 'socks5' ? socksDispatcher(p) : httpDispatcher(p)
|
|
66
|
+
f = (url, init = {}) => undiciFetch(url, { ...init, dispatcher: d })
|
|
67
|
+
cache.set(p.href, f)
|
|
68
|
+
}
|
|
69
|
+
return f
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Pick the fetch for an account ref: per-slot proxy -> deps.fetchImpl -> global fetch. */
|
|
73
|
+
export function pickFetch(deps, ref) {
|
|
74
|
+
if (typeof deps?.fetchForRef === 'function') {
|
|
75
|
+
const f = deps.fetchForRef(ref)
|
|
76
|
+
if (f) return f
|
|
77
|
+
}
|
|
78
|
+
return deps?.fetchImpl || fetch
|
|
79
|
+
}
|
package/lib/subscriptions.js
CHANGED
|
@@ -2,6 +2,7 @@ import { isProvider } from "./refs.js"
|
|
|
2
2
|
import { pickAccount, markCooldown, isSwitchableError } from "./rotate.js"
|
|
3
3
|
import { quotaSnapshot } from "./ratelimit.js"
|
|
4
4
|
import { getVendor } from "./vendors/index.js"
|
|
5
|
+
import { pickFetch } from "./proxy.js"
|
|
5
6
|
|
|
6
7
|
// ponytail: allowlist per provider — add path to extend without touching request logic
|
|
7
8
|
export const ALLOWLIST = {
|
|
@@ -121,7 +122,8 @@ export function createSubscriptionsService(deps) {
|
|
|
121
122
|
}
|
|
122
123
|
const url = p
|
|
123
124
|
const extraHeaders = headersFor(provider, blob, cfg)
|
|
124
|
-
const fetchImpl = deps.
|
|
125
|
+
const fetchImpl = pickFetch(deps, account.ref)
|
|
126
|
+
const t0 = Date.now()
|
|
125
127
|
const res = await fetchImpl(url, {
|
|
126
128
|
method,
|
|
127
129
|
headers: {
|
|
@@ -169,6 +171,7 @@ export function createSubscriptionsService(deps) {
|
|
|
169
171
|
path,
|
|
170
172
|
method,
|
|
171
173
|
status: res.status || 200,
|
|
174
|
+
ms: Date.now() - t0,
|
|
172
175
|
kind: "request",
|
|
173
176
|
})
|
|
174
177
|
} catch {}
|