@goodandready/dsh-subscriptions 0.6.0 → 0.6.2

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/routes.js CHANGED
@@ -1,44 +1,17 @@
1
- import { PROVIDERS, parseOauthRef, displayName, droppedCredentialRefs, oauthRef, isProvider } from './refs.js'
2
- import { writeJson, writeHtml, readBody, isTrustedSettingsRequest, queryOf, safeJsonHandler } from './http.js'
3
- import { parseCallbackInput, requestOrigin, webCallbackUri } from './oauth.js'
4
- import { createPkce } from './pkce.js'
5
- import { startLoopback } from './loopback.js'
6
- import { maskEmail, maskLabel, maskText } from './mask.js'
7
- import { parseBlob } from './blob.js'
8
- import { encryptWithPassphrase, decryptWithPassphrase } from './crypto.js'
9
- import { analyzeSessionEvents } from './analyze-session.js'
10
- import { discoverLocalCliSessions, loadLocalCliBlob } from './import-auth.js'
11
- import { proxyFetch } from './proxy.js'
12
- import { Config, publicConfig } from './config-schema.js'
13
- import { quotaSnapshot } from './ratelimit.js'
14
- import { getVendor } from './vendors/index.js'
15
- import { vendorConfig, normalizeSlots } from './accounts.js'
1
+ import { publicConfig } from './config-schema.js'
2
+ import { PROVIDERS, displayName } from './refs.js'
3
+ import { writeHtml, writeJson } from './http.js'
4
+ import { registerStatusRoutes } from './routes/status.js'
5
+ import { registerOauthRoutes } from './routes/oauth.js'
6
+ import { registerAccountsRoutes } from './routes/accounts.js'
7
+ import { registerProxyRoutes } from './routes/proxy.js'
16
8
 
17
9
  export function registerRoutes(ctx, state) {
18
10
  const {
19
- NS,
20
- live,
21
11
  accountsView,
22
- getSettingsApi,
23
- syncCustomVendors,
24
- syncAdapter,
25
- stripLegacySlots,
26
- store,
27
- PENDING_TTL_MS,
28
- redirectFor,
29
- OK_HTML,
30
- refreshModels,
31
12
  history,
32
- refForSlot,
33
- resetCredits,
34
- diagnosticsReport,
35
- pending,
36
- completeOAuth,
37
- fetchForRef,
38
- sweepPending,
13
+ live,
39
14
  subscriptions,
40
- pmL,
41
- pmE,
42
15
  } = state
43
16
 
44
17
  async function configResponse() {
@@ -50,859 +23,6 @@ export function registerRoutes(ctx, state) {
50
23
  }
51
24
  }
52
25
 
53
- ctx.effect(() => ctx.webServer.register({
54
- kind: 'exact',
55
- path: '/dsh-subscriptions/config',
56
- handler: async (req, res) => {
57
- if (req.method === 'GET') {
58
- writeJson(res, 200, await configResponse())
59
- return
60
- }
61
- if (req.method !== 'PUT') {
62
- writeJson(res, 405, { ok: false, error: { code: 'method', message: 'GET or PUT' } })
63
- return
64
- }
65
- if (!isTrustedSettingsRequest(req)) {
66
- writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'settings writes are same-origin only' } })
67
- return
68
- }
69
- if (!getSettingsApi()) {
70
- writeJson(res, 503, { ok: false, error: { code: 'settings', message: 'settings not ready' } })
71
- return
72
- }
73
- let payload
74
- try { payload = JSON.parse((await readBody(req, 256 * 1024)).toString('utf8') || '{}') } catch {
75
- writeJson(res, 400, { ok: false, error: { code: 'json', message: 'invalid json' } })
76
- return
77
- }
78
- if (payload && typeof payload.config === 'object') payload = payload.config
79
- try {
80
- if (Array.isArray(payload.slots)) payload.slots = stripLegacySlots(payload.slots)
81
- const parsed = Config(payload)
82
- const dropped = droppedCredentialRefs(live().slots, parsed.slots)
83
- await getSettingsApi().replace(parsed)
84
- syncCustomVendors()
85
- for (const ref of dropped) await store.clearRef(ref)
86
- await syncAdapter()
87
- writeJson(res, 200, await configResponse())
88
- } catch (e) {
89
- writeJson(res, 400, { ok: false, error: { code: 'save', message: String(e && e.message || e) } })
90
- }
91
- },
92
-
93
-
94
-
95
- }), 'dsh-subscriptions: /config')
96
-
97
- ctx.effect(() => ctx.webServer.register({
98
- kind: 'exact',
99
- path: '/dsh-subscriptions/status',
100
- handler: safeJsonHandler(async (req, res) => {
101
- if (req.method !== 'GET') {
102
- writeJson(res, 405, { ok: false, error: { code: 'method', message: 'GET only' } })
103
- return
104
- }
105
- const logged = await store.loggedInProviders()
106
- // #66/#67: usagePercent (максимум по аккаунтам) и expiresAt для чипа.
107
- const usage = {}
108
- const expires = {}
109
- const labels = {}
110
- for (const slot of normalizeSlots(live().slots)) {
111
- try {
112
- const info = await store.describeRef(slot.ref)
113
- if (info.usagePercent != null) {
114
- usage[slot.provider] = Math.max(usage[slot.provider] || 0, info.usagePercent)
115
- }
116
- // #67: дата окончания подписки берётся из слота (вводится в настройках).
117
- if (slot.expiresAt) {
118
- expires[slot.provider] = Math.max(expires[slot.provider] || 0, slot.expiresAt)
119
- labels[slot.provider] = pmL(slot.label || info.label || slot.provider)
120
- }
121
- } catch {}
122
- }
123
- // #69/#72: активная подписка = последний успешный запрос (новые сверху).
124
- let active = null
125
- const last = history.recent(1)
126
- if (last.length) {
127
- const lastRow = last[0]
128
- const accts = await store.listAccounts(lastRow.provider)
129
- const acct = accts.find((a) => a.ref === lastRow.ref) || accts[0]
130
- let plan = ''
131
- let index = acct && acct.ref ? Number(String(acct.ref).split('_').pop()) || null : null
132
- let status = 'ok'
133
- let windows = []
134
- try {
135
- const info = await store.describeRef(lastRow.ref)
136
- plan = info.paidTierName || ''
137
- if (info.validationUrl) status = 'verify'
138
- else if (info.cooldownUntil && info.cooldownUntil > Date.now()) status = 'cooldown'
139
- if (Array.isArray(info.usage)) {
140
- windows = info.usage
141
- .filter((w) => w && typeof w.usedPercent === 'number')
142
- .map((w) => ({ id: w.id || w.en || w.ru, label: w.en || w.ru || w.id, usedPercent: w.usedPercent }))
143
- }
144
- } catch {}
145
- active = {
146
- provider: lastRow.provider,
147
- index,
148
- model: lastRow.model || null,
149
- path: lastRow.path || null,
150
- plan,
151
- windows,
152
- usagePercent: usage[lastRow.provider] != null ? usage[lastRow.provider] : null,
153
- status,
154
- at: lastRow.ts,
155
- }
156
- }
157
- writeJson(res, 200, {
158
- ok: true,
159
- loggedIn: Object.fromEntries(PROVIDERS.map((id) => [id, logged.includes(id)])),
160
- usagePercent: usage,
161
- expiresAt: expires,
162
- labels,
163
- expiryNotifyDays: live().expiryNotifyDays,
164
- fastMode: !!live().codexFastMode,
165
- composerQuota: String(live().composerQuota || 'off'),
166
- active,
167
- })
168
- }),
169
- }), 'dsh-subscriptions: /status')
170
-
171
- ctx.effect(() => ctx.webServer.register({
172
- kind: 'exact',
173
- path: '/dsh-subscriptions/reset-credits',
174
- handler: safeJsonHandler(async (req, res) => {
175
- if (req.method !== 'GET') {
176
- writeJson(res, 405, { ok: false, error: { code: 'method', message: 'GET only' } })
177
- return
178
- }
179
- const q = queryOf(req)
180
- const provider = q.get('provider') || ''
181
- const index = Number(q.get('index') || '1')
182
- const ref = refForSlot(provider, index)
183
- if (!ref) { writeJson(res, 404, { ok: false, error: { code: 'slot', message: 'slot not found' } }); return }
184
- try {
185
- writeJson(res, 200, { ok: true, ...(await resetCredits.inspect(ref)) })
186
- } catch (e) {
187
- writeJson(res, 200, { ok: false, error: { code: 'reset', message: String(e && e.message || e) } })
188
- }
189
- }),
190
- }), 'dsh-subscriptions: /reset-credits')
191
-
192
- ctx.effect(() => ctx.webServer.register({
193
- kind: 'exact',
194
- path: '/dsh-subscriptions/reset-credits/prepare',
195
- handler: safeJsonHandler(async (req, res) => {
196
- if (req.method !== 'POST') {
197
- writeJson(res, 405, { ok: false, error: { code: 'method', message: 'POST only' } })
198
- return
199
- }
200
- if (!isTrustedSettingsRequest(req)) {
201
- writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'same-origin only' } })
202
- return
203
- }
204
- let payload
205
- try { payload = JSON.parse((await readBody(req, 4096)).toString('utf8') || '{}') } catch {
206
- writeJson(res, 400, { ok: false, error: { code: 'json', message: 'invalid json' } })
207
- return
208
- }
209
- const ref = refForSlot(String(payload.provider || ''), Number(payload.index) || 1)
210
- if (!ref) { writeJson(res, 404, { ok: false, error: { code: 'slot', message: 'slot not found' } }); return }
211
- try {
212
- writeJson(res, 200, { ok: true, ...(await resetCredits.prepare(ref)) })
213
- } catch (e) {
214
- writeJson(res, 200, { ok: false, error: { code: 'reset', message: String(e && e.message || e) } })
215
- }
216
- }),
217
- }), 'dsh-subscriptions: /reset-credits/prepare')
218
-
219
- ctx.effect(() => ctx.webServer.register({
220
- kind: 'exact',
221
- path: '/dsh-subscriptions/reset-credits/consume',
222
- handler: safeJsonHandler(async (req, res) => {
223
- if (req.method !== 'POST') {
224
- writeJson(res, 405, { ok: false, error: { code: 'method', message: 'POST only' } })
225
- return
226
- }
227
- if (!isTrustedSettingsRequest(req)) {
228
- writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'same-origin only' } })
229
- return
230
- }
231
- let payload
232
- try { payload = JSON.parse((await readBody(req, 4096)).toString('utf8') || '{}') } catch {
233
- writeJson(res, 400, { ok: false, error: { code: 'json', message: 'invalid json' } })
234
- return
235
- }
236
- try {
237
- const result = await resetCredits.consume({ challengeId: payload.challengeId, acknowledged: payload.acknowledged })
238
- writeJson(res, 200, { ok: true, result })
239
- refreshModels().catch(() => {})
240
- } catch (e) {
241
- writeJson(res, 200, { ok: false, error: { code: 'reset', message: String(e && e.message || e) } })
242
- }
243
- }),
244
- }), 'dsh-subscriptions: /reset-credits/consume')
245
-
246
- ctx.effect(() => ctx.webServer.register({
247
- kind: 'exact',
248
- path: '/dsh-subscriptions/diagnostics',
249
- handler: safeJsonHandler(async (req, res) => {
250
- if (req.method !== 'GET') {
251
- writeJson(res, 405, { ok: false, error: { code: 'method', message: 'GET only' } })
252
- return
253
- }
254
- writeJson(res, 200, { ok: true, report: await diagnosticsReport() })
255
- }),
256
- }), 'dsh-subscriptions: /diagnostics')
257
-
258
- ctx.effect(() => ctx.webServer.register({
259
- kind: 'exact',
260
- path: '/dsh-subscriptions/oauth/start',
261
- handler: safeJsonHandler(async (req, res) => {
262
- if (req.method !== 'GET') {
263
- writeJson(res, 405, { ok: false, error: { code: 'method', message: 'GET only' } })
264
- return
265
- }
266
- const q = queryOf(req)
267
- const provider = q.get('provider') || ''
268
- const index = Number(q.get('index') || '1')
269
- if (!isProvider(provider)) {
270
- writeJson(res, 400, { ok: false, error: { code: 'provider', message: 'unknown provider' } })
271
- return
272
- }
273
- const origin = requestOrigin(req)
274
- const redirectUri = redirectFor(provider, live(), origin)
275
- const pkce = await createPkce()
276
- pending.set(pkce.state, {
277
- provider,
278
- index,
279
- verifier: pkce.verifier,
280
- challenge: pkce.challenge,
281
- state: pkce.state,
282
- redirectUri,
283
- createdAt: Date.now(),
284
- })
285
- const cfg = { ...vendorConfig(provider, live()), redirectUri }
286
- const url = getVendor(provider).authorizeUrl(cfg, pkce)
287
- // #89: если redirect_uri loopback — поднять временный сервер перехвата.
288
- let autoCatch = false
289
- if (live().autoLoopback) {
290
- try {
291
- const cb = new URL(redirectUri)
292
- if (cb.hostname === 'localhost' || cb.hostname === '127.0.0.1') {
293
- autoCatch = true
294
- startLoopback({
295
- redirectUri,
296
- onCode: async (params) => {
297
- const code = params.get('code') || ''
298
- const state = params.get('state') || ''
299
- if (!code) throw new Error('no code')
300
- await completeOAuth({ provider, index, code, state })
301
- return OK_HTML
302
- },
303
- }).catch(() => {})
304
- }
305
- } catch { autoCatch = false }
306
- }
307
- writeJson(res, 200, { ok: true, url, state: pkce.state, redirectUri, autoCatch })
308
- }),
309
- }), 'dsh-subscriptions: /oauth/start')
310
-
311
- // #90: Device-code login (headless/VPS). Codex-only: auth.openai.com mints an
312
- // authorization_code + code_verifier server-side; we finish with the normal
313
- // PKCE exchange using the device redirect URI. Device code stays server-side
314
- // in the pending map (same lifetime as PKCE pending rows).
315
- ctx.effect(() => ctx.webServer.register({
316
- kind: 'exact',
317
- path: '/dsh-subscriptions/oauth/device/start',
318
- handler: safeJsonHandler(async (req, res) => {
319
- if (req.method !== 'POST') {
320
- writeJson(res, 405, { ok: false, error: { code: 'method', message: 'POST only' } })
321
- return
322
- }
323
- if (!isTrustedSettingsRequest(req)) {
324
- writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'same-origin only' } })
325
- return
326
- }
327
- let body
328
- try {
329
- body = JSON.parse((await readBody(req, 16 * 1024)).toString('utf8'))
330
- } catch {
331
- writeJson(res, 400, { ok: false, error: { code: 'json', message: 'invalid json' } })
332
- return
333
- }
334
- const provider = String(body.provider || '')
335
- const index = Number(body.index || 1)
336
- if (!isProvider(provider)) {
337
- writeJson(res, 400, { ok: false, error: { code: 'provider', message: 'unknown provider' } })
338
- return
339
- }
340
- const vendor = getVendor(provider)
341
- if (typeof vendor.deviceStart !== 'function') {
342
- writeJson(res, 400, { ok: false, error: { code: 'device', message: 'device login not supported for ' + provider } })
343
- return
344
- }
345
- try {
346
- const cfg = vendorConfig(provider, live())
347
- const start = await vendor.deviceStart(cfg, (fetchForRef(oauthRef(provider, index)) || fetch))
348
- const state = 'dev-' + Date.now() + '-' + Math.random().toString(36).slice(2, 10)
349
- sweepPending(Date.now())
350
- pending.set(state, {
351
- kind: 'device',
352
- provider,
353
- index,
354
- ref: oauthRef(provider, index),
355
- deviceAuthId: start.deviceAuthId,
356
- userCode: start.userCode,
357
- intervalMs: start.intervalMs,
358
- createdAt: Date.now(),
359
- })
360
- writeJson(res, 200, { ok: true, state, userCode: start.userCode, authUrl: start.authUrl, intervalMs: start.intervalMs })
361
- } catch (e) {
362
- writeJson(res, 502, { ok: false, error: { code: e && e.code || 'DEVICE', message: String(e && e.message || e).slice(0, 300) } })
363
- }
364
- }),
365
- }), 'dsh-subscriptions: /oauth/device/start')
366
-
367
- ctx.effect(() => ctx.webServer.register({
368
- kind: 'exact',
369
- path: '/dsh-subscriptions/oauth/device/poll',
370
- handler: safeJsonHandler(async (req, res) => {
371
- if (req.method !== 'POST') {
372
- writeJson(res, 405, { ok: false, error: { code: 'method', message: 'POST only' } })
373
- return
374
- }
375
- if (!isTrustedSettingsRequest(req)) {
376
- writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'same-origin only' } })
377
- return
378
- }
379
- let body
380
- try {
381
- body = JSON.parse((await readBody(req, 16 * 1024)).toString('utf8'))
382
- } catch {
383
- writeJson(res, 400, { ok: false, error: { code: 'json', message: 'invalid json' } })
384
- return
385
- }
386
- const state = String(body.state || '')
387
- sweepPending(Date.now())
388
- const row = pending.get(state)
389
- if (!row || row.kind !== 'device') {
390
- writeJson(res, 404, { ok: false, error: { code: 'expired', message: 'device login session expired; start again' } })
391
- return
392
- }
393
- const vendor = getVendor(row.provider)
394
- try {
395
- const cfg = vendorConfig(row.provider, live())
396
- const out = await vendor.devicePoll(cfg, { deviceAuthId: row.deviceAuthId, userCode: row.userCode }, (fetchForRef(row.ref) || fetch))
397
- if (out.status !== 'authorized') {
398
- writeJson(res, 200, { ok: true, status: out.status })
399
- return
400
- }
401
- const slots = normalizeSlots(live().slots)
402
- const slot = slots.find((s) => s.ref === row.ref)
403
- const blob = out.blob
404
- if (slot && slot.label) blob.label = slot.label
405
- await store.saveBlob(row.ref, blob)
406
- pending.delete(state)
407
- await syncAdapter()
408
- refreshModels().catch(() => {})
409
- writeJson(res, 200, { ok: true, status: 'authorized', ref: row.ref, label: pmL(blob.label || blob.email) || displayName(row.provider) })
410
- } catch (e) {
411
- writeJson(res, 502, { ok: false, error: { code: e && e.code || 'DEVICE', message: String(e && e.message || e).slice(0, 300) } })
412
- }
413
- }),
414
- }), 'dsh-subscriptions: /oauth/device/poll')
415
-
416
- ctx.effect(() => ctx.webServer.register({
417
- kind: 'exact',
418
- path: '/dsh-subscriptions/oauth/callback',
419
- handler: async (req, res) => {
420
- if (req.method !== 'GET') {
421
- writeJson(res, 405, { ok: false, error: { code: 'method', message: 'GET only' } })
422
- return
423
- }
424
- const q = queryOf(req)
425
- const code = q.get('code') || ''
426
- const state = q.get('state') || ''
427
- const row = pending.get(state)
428
- if (!code || !row) {
429
- 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>')
430
- return
431
- }
432
- try {
433
- await completeOAuth({ provider: row.provider, index: row.index, code, state })
434
- 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>')
435
- } catch (e) {
436
- writeHtml(res, 400, `<!doctype html><meta charset="utf-8"><title>Subscriptions</title><p>${escapeHtml(String(e && e.message || e))}</p>`)
437
- }
438
- },
439
- }), 'dsh-subscriptions: /oauth/callback')
440
-
441
- ctx.effect(() => ctx.webServer.register({
442
- kind: 'exact',
443
- path: '/dsh-subscriptions/oauth/complete',
444
- handler: safeJsonHandler(async (req, res) => {
445
- if (req.method !== 'POST') {
446
- writeJson(res, 405, { ok: false, error: { code: 'method', message: 'POST only' } })
447
- return
448
- }
449
- if (!isTrustedSettingsRequest(req)) {
450
- writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'same-origin only' } })
451
- return
452
- }
453
- let payload
454
- try { payload = JSON.parse((await readBody(req, 16 * 1024)).toString('utf8') || '{}') } catch {
455
- writeJson(res, 400, { ok: false, error: { code: 'json', message: 'invalid json' } })
456
- return
457
- }
458
- const parsed = parseCallbackInput(payload.url || payload.code || '')
459
- const provider = payload.provider
460
- const index = payload.index
461
- const code = parsed.code
462
- const state = parsed.state || payload.state || ''
463
- if (!code) {
464
- writeJson(res, 400, { ok: false, error: { code: 'code', message: 'paste the redirected URL or the code' } })
465
- return
466
- }
467
- try {
468
- const result = await completeOAuth({ provider, index, code, state })
469
- writeJson(res, 200, { ok: true, ...result, accounts: await accountsView() })
470
- } catch (e) {
471
- writeJson(res, 400, { ok: false, error: { code: 'oauth', message: String(e && e.message || e) } })
472
- }
473
- }),
474
- }), 'dsh-subscriptions: /oauth/complete')
475
-
476
- // ponytail: cheap per-vendor probe; never sets cooldown, never returns tokens
477
- ctx.effect(() => ctx.webServer.register({
478
- kind: 'exact',
479
- path: '/dsh-subscriptions/check',
480
- handler: safeJsonHandler(async (req, res) => {
481
- if (req.method !== 'POST') {
482
- writeJson(res, 405, { ok: false, error: { code: 'method', message: 'POST only' } })
483
- return
484
- }
485
- if (!isTrustedSettingsRequest(req)) {
486
- writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'same-origin only' } })
487
- return
488
- }
489
- let payload
490
- try { payload = JSON.parse((await readBody(req, 8 * 1024)).toString('utf8') || '{}') } catch {
491
- writeJson(res, 400, { ok: false, error: { code: 'json', message: 'invalid json' } })
492
- return
493
- }
494
- const provider = payload.provider
495
- if (!isProvider(provider)) {
496
- writeJson(res, 200, { ok: false, provider, error: { code: 'provider', message: 'unknown provider' } })
497
- return
498
- }
499
- const ref = oauthRef(provider, payload.index)
500
- const info = await store.describeRef(ref)
501
- if (!info.configured) {
502
- writeJson(res, 200, { ok: false, provider, index: payload.index, ref, error: { code: 'not_connected', message: 'not connected' } })
503
- return
504
- }
505
- let blob
506
- try { blob = await store.loadBlob(ref) } catch (e) {
507
- writeJson(res, 200, { ok: false, provider, index: payload.index, ref, error: { code: 'auth', message: String(e && e.message || e) } })
508
- return
509
- }
510
- try { blob = await store.ensureFresh(provider, blob, ref) } catch (e) {
511
- writeJson(res, 200, {
512
- ok: false, provider, index: payload.index, ref,
513
- email: pmE(blob.email), label: pmL(blob.label), expiresAt: blob.expiresAt || null,
514
- quota: info.quota || null,
515
- error: { code: 'refresh', message: String(e && e.message || e) },
516
- })
517
- return
518
- }
519
- const vendor = getVendor(provider)
520
- if (typeof vendor.check !== 'function') {
521
- 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 })
522
- return
523
- }
524
- let capturedQuota = null
525
- const probeFetch = async (url, init) => {
526
- const res2 = await fetch(url, init)
527
- try {
528
- const snap = quotaSnapshot(provider, res2.headers, null, Date.now())
529
- if (snap) { capturedQuota = snap; store.rememberQuota(ref, snap) }
530
- } catch {}
531
- return res2
532
- }
533
- try {
534
- await vendor.check(blob, vendorConfig(provider, live()), probeFetch)
535
- 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 })
536
- } catch (e) {
537
- writeJson(res, 200, {
538
- ok: false, provider, index: payload.index, ref,
539
- email: pmE(blob.email), label: pmL(blob.label), expiresAt: blob.expiresAt || null,
540
- quota: capturedQuota || info.quota || null,
541
- error: { code: e && e.code ? e.code : 'VENDOR', message: String(e && e.message || e).slice(0, 300) },
542
- })
543
- }
544
- }),
545
- }), 'dsh-subscriptions: /check')
546
-
547
- ctx.effect(() => ctx.webServer.register({
548
- kind: 'exact',
549
- path: '/dsh-subscriptions/discover-local',
550
- handler: safeJsonHandler(async (req, res) => {
551
- if (req.method !== 'GET') {
552
- writeJson(res, 405, { ok: false, error: { code: 'method', message: 'GET only' } })
553
- return
554
- }
555
- try {
556
- const detected = await discoverLocalCliSessions()
557
- writeJson(res, 200, { ok: true, detected })
558
- } catch (e) {
559
- writeJson(res, 500, { ok: false, error: { message: String(e && e.message || e) } })
560
- }
561
- }),
562
- }), 'dsh-subscriptions: /discover-local')
563
-
564
- ctx.effect(() => ctx.webServer.register({
565
- kind: 'exact',
566
- path: '/dsh-subscriptions/import-local',
567
- handler: safeJsonHandler(async (req, res) => {
568
- if (req.method !== 'POST') {
569
- writeJson(res, 405, { ok: false, error: { code: 'method', message: 'POST only' } })
570
- return
571
- }
572
- if (!isTrustedSettingsRequest(req)) {
573
- writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'same-origin only' } })
574
- return
575
- }
576
- let body
577
- try {
578
- body = JSON.parse((await readBody(req, 16 * 1024)).toString('utf8') || '{}')
579
- } catch {
580
- body = null
581
- }
582
- if (!body || !body.provider) {
583
- writeJson(res, 400, { ok: false, error: { code: 'bad_request', message: 'missing provider' } })
584
- return
585
- }
586
- try {
587
- const blob = await loadLocalCliBlob(body.provider)
588
- const prov = body.provider
589
- const idx = Number(body.index) || 1
590
- const curSlots = Array.isArray(live().slots) ? live().slots.slice() : []
591
- const exists = curSlots.some((s) => s && s.provider === prov && Number(s.index) === idx)
592
- if (!exists && getSettingsApi()) {
593
- const nextSlots = curSlots.concat([{
594
- provider: prov,
595
- index: idx,
596
- label: blob.email || '',
597
- }])
598
- const parsed = Config({ ...live(), slots: stripLegacySlots(nextSlots) })
599
- await getSettingsApi().replace(parsed)
600
- syncCustomVendors()
601
- }
602
- const slot = normalizeSlots(live().slots).find((s) => s.provider === prov && s.index === idx)
603
- const ref = slot ? slot.ref : `${prov.toUpperCase()}_OAUTH_${idx}`
604
- await store.saveBlob(ref, blob)
605
- await syncAdapter()
606
- refreshModels().catch(() => {})
607
- writeJson(res, 200, {
608
- ok: true,
609
- ref,
610
- provider: prov,
611
- email: blob.email || '',
612
- accounts: await accountsView(),
613
- config: publicConfig(live()),
614
- })
615
- } catch (e) {
616
- writeJson(res, 400, { ok: false, error: { message: String(e && e.message || e) } })
617
- }
618
- }),
619
- }), 'dsh-subscriptions: /import-local')
620
-
621
- ctx.effect(() => ctx.webServer.register({
622
- kind: 'exact',
623
- path: '/dsh-subscriptions/analyze-session',
624
- handler: safeJsonHandler(async (req, res) => {
625
- if (req.method !== 'POST') {
626
- writeJson(res, 405, { ok: false, error: { code: 'method', message: 'POST only' } })
627
- return
628
- }
629
- const raw = await readBody(req, 128 * 1024).catch(() => Buffer.alloc(0))
630
- let body = {}
631
- try { body = JSON.parse(raw.toString('utf8') || '{}') } catch {}
632
- const events = Array.isArray(body && body.events) ? body.events : []
633
- const analysis = analyzeSessionEvents(events)
634
- writeJson(res, 200, { ok: true, analysis })
635
- }),
636
- }), 'dsh-subscriptions: /analyze-session')
637
-
638
-
639
-
640
- // HTTP-прокси к API провайдера через subscriptions.request.
641
- // Same-origin only, allowlist путей, ротация и квота как у моделей.
642
- // Токен наружу не отдаётся — наружу только ответ провайдера.
643
- ctx.effect(() => ctx.webServer.register({
644
- kind: 'prefix',
645
- path: '/dsh-subscriptions/proxy',
646
- handler: safeJsonHandler(async (req, res) => {
647
- if (req.method !== 'POST' && req.method !== 'GET') {
648
- writeJson(res, 405, { ok: false, error: { code: 'method', message: 'GET or POST' } })
649
- return
650
- }
651
- if (!isTrustedSettingsRequest(req)) {
652
- writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'same-origin only' } })
653
- return
654
- }
655
- const url = new URL(req.url || '/', 'http://localhost')
656
- const parts = url.pathname.replace(/^\/dsh-subscriptions\/proxy\//, '').split('/').filter(Boolean)
657
- const provider = parts[0]
658
- const restPath = '/' + parts.slice(1).join('/')
659
- if (!isProvider(provider)) {
660
- writeJson(res, 404, { ok: false, error: { code: 'provider', message: 'unknown provider' } })
661
- return
662
- }
663
- let body
664
- if (req.method === 'POST') {
665
- try {
666
- body = JSON.parse((await readBody(req, 1024 * 1024)).toString('utf8'))
667
- } catch {
668
- writeJson(res, 400, { ok: false, error: { code: 'json', message: 'invalid json' } })
669
- return
670
- }
671
- }
672
- try {
673
- const out = await subscriptions.request({
674
- provider,
675
- path: restPath,
676
- method: req.method === 'POST' ? 'POST' : 'GET',
677
- body,
678
- headers: {},
679
- })
680
- const text = await out.text()
681
- try {
682
- const json = JSON.parse(text)
683
- writeJson(res, out.status || 200, json)
684
- } catch {
685
- res.writeHead(out.status || 200, { 'Content-Type': 'application/json' })
686
- res.end(text)
687
- }
688
- } catch (e) {
689
- const status = e && e.status ? e.status : (e && e.code === 'FORBIDDEN' ? 403 : (e && e.code === 'AUTH' ? 401 : 502))
690
- writeJson(res, status, { ok: false, error: { code: e && e.code || 'VENDOR', message: String(e && e.message || e).slice(0, 300) } })
691
- }
692
- }),
693
- }), 'dsh-subscriptions: proxy')
694
-
695
- // #88: проверка прокси аккаунта — реальный запрос к эндпоинту провайдера с замером задержки.
696
- ctx.effect(() => ctx.webServer.register({
697
- kind: 'exact',
698
- path: '/dsh-subscriptions/proxy-check',
699
- handler: safeJsonHandler(async (req, res) => {
700
- if (req.method !== 'POST') {
701
- writeJson(res, 405, { ok: false, error: { code: 'method', message: 'POST only' } })
702
- return
703
- }
704
- if (!isTrustedSettingsRequest(req)) {
705
- writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'same-origin only' } })
706
- return
707
- }
708
- let body
709
- try {
710
- body = JSON.parse((await readBody(req, 16 * 1024)).toString('utf8'))
711
- } catch {
712
- writeJson(res, 400, { ok: false, error: { code: 'json', message: 'invalid json' } })
713
- return
714
- }
715
- const provider = String(body.provider || '')
716
- const index = Number(body.index || 1)
717
- if (!isProvider(provider)) {
718
- writeJson(res, 400, { ok: false, error: { code: 'provider', message: 'unknown provider' } })
719
- return
720
- }
721
- const slots = normalizeSlots(live().slots)
722
- const slot = slots.find((s) => s.provider === provider && s.index === index)
723
- const proxyUrl = (slot && slot.proxyUrl) || ''
724
- const DEFAULT_BASE = {
725
- codex: 'https://chatgpt.com/backend-api/codex',
726
- claude: 'https://api.anthropic.com',
727
- grok: 'https://api.x.ai/v1',
728
- antigravity: 'https://cloudcode-pa.googleapis.com',
729
- }
730
- const base = String((vendorConfig(provider, live()) || {}).baseUrl || DEFAULT_BASE[provider] || '').replace(/\/$/, '')
731
- const started = Date.now()
732
- try {
733
- const impl = (proxyUrl && proxyFetch(proxyUrl)) || fetch
734
- if (proxyUrl && impl === fetch) throw new Error('invalid proxy URL')
735
- const out = await impl(base + '/models', {
736
- method: 'GET',
737
- headers: { Accept: 'application/json' },
738
- signal: AbortSignal.timeout(10000),
739
- })
740
- // Любой HTTP-ответ (включая 401/403) = прокси и эндпоинт доступны.
741
- writeJson(res, 200, { ok: true, status: out.status, latencyMs: Date.now() - started, viaProxy: !!proxyUrl })
742
- } catch (e) {
743
- writeJson(res, 200, {
744
- ok: false,
745
- latencyMs: Date.now() - started,
746
- viaProxy: !!proxyUrl,
747
- error: { code: (e && e.code) || 'NETWORK', message: String((e && e.message) || e).slice(0, 200) },
748
- })
749
- }
750
- }),
751
- }), 'dsh-subscriptions: proxy-check')
752
-
753
- // Экспорт зашифрованного бандла токенов. Токены не логгируются.
754
- ctx.effect(() => ctx.webServer.register({
755
- kind: 'exact',
756
- path: '/dsh-subscriptions/export',
757
- handler: safeJsonHandler(async (req, res) => {
758
- if (req.method !== 'POST') {
759
- writeJson(res, 405, { ok: false, error: { code: 'method', message: 'POST only' } })
760
- return
761
- }
762
- if (!isTrustedSettingsRequest(req)) {
763
- writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'same-origin only' } })
764
- return
765
- }
766
- let payload
767
- try { payload = JSON.parse((await readBody(req, 8 * 1024)).toString('utf8') || '{}') } catch {
768
- writeJson(res, 400, { ok: false, error: { code: 'json', message: 'invalid json' } })
769
- return
770
- }
771
- const passphrase = payload.passphrase
772
- if (!passphrase || typeof passphrase !== 'string') {
773
- writeJson(res, 400, { ok: false, error: { code: 'passphrase', message: 'нужен passphrase' } })
774
- return
775
- }
776
- try {
777
- const accounts = []
778
- for (const slot of normalizeSlots(live().slots)) {
779
- try {
780
- const blob = await store.loadBlob(slot.ref)
781
- accounts.push({ ref: slot.ref, provider: slot.provider, index: slot.index, label: slot.label || blob.label || '', blob })
782
- } catch { /* skip missing */ }
783
- }
784
- if (!accounts.length) {
785
- writeJson(res, 200, { ok: false, error: { code: 'empty', message: 'нет подключённых аккаунтов' } })
786
- return
787
- }
788
- const bundle = JSON.stringify({ v: 1, exportedAt: Date.now(), accounts })
789
- const encrypted = encryptWithPassphrase(bundle, passphrase)
790
- writeJson(res, 200, { ok: true, payload: encrypted, count: accounts.length })
791
- } catch (e) {
792
- writeJson(res, 500, { ok: false, error: { code: 'export', message: String(e && e.message || e) } })
793
- }
794
- }),
795
- }), 'dsh-subscriptions: /export')
796
-
797
- // Импорт зашифрованного бандла.
798
- ctx.effect(() => ctx.webServer.register({
799
- kind: 'exact',
800
- path: '/dsh-subscriptions/import',
801
- handler: safeJsonHandler(async (req, res) => {
802
- if (req.method !== 'POST') {
803
- writeJson(res, 405, { ok: false, error: { code: 'method', message: 'POST only' } })
804
- return
805
- }
806
- if (!isTrustedSettingsRequest(req)) {
807
- writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'same-origin only' } })
808
- return
809
- }
810
- let payload
811
- try { payload = JSON.parse((await readBody(req, 1024 * 1024)).toString('utf8') || '{}') } catch {
812
- writeJson(res, 400, { ok: false, error: { code: 'json', message: 'invalid json' } })
813
- return
814
- }
815
- const { passphrase, payload: encrypted } = payload
816
- if (!passphrase || !encrypted) {
817
- writeJson(res, 400, { ok: false, error: { code: 'params', message: 'нужны passphrase и payload' } })
818
- return
819
- }
820
- let bundle
821
- try {
822
- bundle = JSON.parse(decryptWithPassphrase(encrypted, passphrase))
823
- } catch (e) {
824
- writeJson(res, 400, { ok: false, error: { code: 'decrypt', message: 'неверный passphrase или повреждённый бандл' } })
825
- return
826
- }
827
- if (!bundle || !Array.isArray(bundle.accounts)) {
828
- writeJson(res, 400, { ok: false, error: { code: 'format', message: 'неверный формат бандла' } })
829
- return
830
- }
831
- let imported = 0
832
- for (const row of bundle.accounts) {
833
- try {
834
- await store.saveBlob(row.ref, row.blob)
835
- imported++
836
- } catch { /* skip broken */ }
837
- }
838
- await syncAdapter()
839
- writeJson(res, 200, { ok: true, imported, total: bundle.accounts.length, accounts: await accountsView() })
840
- }),
841
- }), 'dsh-subscriptions: /import')
842
-
843
- // #45: импорт существующего refresh token / API key без OAuth-флоу.
844
- ctx.effect(() => ctx.webServer.register({
845
- kind: 'exact',
846
- path: '/dsh-subscriptions/import-token',
847
- handler: safeJsonHandler(async (req, res) => {
848
- if (req.method !== 'POST') {
849
- writeJson(res, 405, { ok: false, error: { code: 'method', message: 'POST only' } })
850
- return
851
- }
852
- if (!isTrustedSettingsRequest(req)) {
853
- writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'same-origin only' } })
854
- return
855
- }
856
- let payload
857
- try { payload = JSON.parse((await readBody(req, 64 * 1024)).toString('utf8') || '{}') } catch {
858
- writeJson(res, 400, { ok: false, error: { code: 'json', message: 'invalid json' } })
859
- return
860
- }
861
- const provider = payload.provider
862
- const index = Number(payload.index || '1')
863
- const refreshToken = payload.refreshToken
864
- const apiKey = payload.apiKey
865
- if (!isProvider(provider)) {
866
- writeJson(res, 400, { ok: false, error: { code: 'provider', message: 'unknown provider' } })
867
- return
868
- }
869
- if (!refreshToken && !apiKey) {
870
- writeJson(res, 400, { ok: false, error: { code: 'token', message: 'нужен refreshToken или apiKey' } })
871
- return
872
- }
873
- try {
874
- const ref = oauthRef(provider, index)
875
- const blob = { accessToken: apiKey || refreshToken, refreshToken: refreshToken || apiKey }
876
- if (apiKey) { blob.apiKey = apiKey; blob.apiKeyOnly = true }
877
- await store.saveBlob(ref, blob)
878
- await syncAdapter()
879
- refreshModels().catch(() => {})
880
- writeJson(res, 200, { ok: true, ref, accounts: await accountsView() })
881
- } catch (e) {
882
- writeJson(res, 400, { ok: false, error: { code: 'import', message: String(e && e.message || e) } })
883
- }
884
- }),
885
- }), 'dsh-subscriptions: /import-token')
886
-
887
- // #50: сводная страница /subscriptions (localhost-only).
888
- // #65: история запросов и стоимости (JSON).
889
- ctx.effect(() => ctx.webServer.register({
890
- kind: 'exact',
891
- path: '/dsh-subscriptions/history',
892
- handler: async (req, res) => {
893
- if (req.method !== 'GET') {
894
- writeJson(res, 405, { ok: false, error: { code: 'method', message: 'GET only' } })
895
- return
896
- }
897
- const host = (req.headers.host || '').split(':')[0]
898
- if (host !== 'localhost' && host !== '127.0.0.1') {
899
- writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'localhost only' } })
900
- return
901
- }
902
- const limit = Math.min(Number(queryOf(req).get('limit') || '10'), 100)
903
- writeJson(res, 200, { ok: true, total: history.size(), items: history.recent(limit) })
904
- },
905
- }), 'dsh-subscriptions: /history')
906
26
 
907
27
  ctx.effect(() => ctx.webServer.register({
908
28
  kind: 'exact',
@@ -976,34 +96,10 @@ function esc(x){return String(x==null?'':x).replace(/&/g,'&amp;').replace(/</g,'
976
96
  },
977
97
  }), 'dsh-subscriptions: /subscriptions')
978
98
 
979
- ctx.effect(() => ctx.webServer.register({
980
- kind: 'exact',
981
- path: '/dsh-subscriptions/logout',
982
- handler: safeJsonHandler(async (req, res) => {
983
- if (req.method !== 'POST') {
984
- writeJson(res, 405, { ok: false, error: { code: 'method', message: 'POST only' } })
985
- return
986
- }
987
- if (!isTrustedSettingsRequest(req)) {
988
- writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'same-origin only' } })
989
- return
990
- }
991
- let payload
992
- try { payload = JSON.parse((await readBody(req, 8 * 1024)).toString('utf8') || '{}') } catch {
993
- writeJson(res, 400, { ok: false, error: { code: 'json', message: 'invalid json' } })
994
- return
995
- }
996
- try {
997
- const ref = oauthRef(payload.provider, payload.index)
998
- await store.clearRef(ref)
999
- await syncAdapter()
1000
- refreshModels().catch(() => {})
1001
- writeJson(res, 200, { ok: true, ref, accounts: await accountsView() })
1002
- } catch (e) {
1003
- writeJson(res, 400, { ok: false, error: { code: 'logout', message: String(e && e.message || e) } })
1004
- }
1005
- }),
1006
- }), 'dsh-subscriptions: /logout')
99
+ registerStatusRoutes(ctx, state)
100
+ registerOauthRoutes(ctx, state)
101
+ registerAccountsRoutes(ctx, state)
102
+ registerProxyRoutes(ctx, state)
1007
103
  }
1008
104
 
1009
105
  export function escapeHtml(text) {