@goodandready/dsh-subscriptions 0.4.15 → 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/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
- writeJson(res, 200, { ok: true, url, state: pkce.state, redirectUri })
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 || '', label: blob.label || '', expiresAt: blob.expiresAt || null,
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 || '', label: blob.label || '', expiresAt: blob.expiresAt || null, quota: info.quota || null })
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 || '', label: blob.label || '', expiresAt: blob.expiresAt || null, quota: capturedQuota || info.quota || null, usagePercent: info.usagePercent ?? null })
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 || '', label: blob.label || '', expiresAt: blob.expiresAt || null,
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',
@@ -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
+ }
@@ -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.fetchImpl || fetch
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 {}