@goodandready/dsh-subscriptions 0.5.26 → 0.5.30

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/routes.js ADDED
@@ -0,0 +1,1016 @@
1
+ import { PROVIDERS, parseOauthRef, displayName, droppedCredentialRefs, oauthRef, isProvider } from './refs.js'
2
+ import { writeJson, writeHtml, readBody, isTrustedSettingsRequest, queryOf } 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'
16
+
17
+ export function registerRoutes(ctx, state) {
18
+ const {
19
+ NS,
20
+ live,
21
+ accountsView,
22
+ getSettingsApi,
23
+ syncCustomVendors,
24
+ syncAdapter,
25
+ stripLegacySlots,
26
+ store,
27
+ PENDING_TTL_MS,
28
+ redirectFor,
29
+ OK_HTML,
30
+ refreshModels,
31
+ history,
32
+ refForSlot,
33
+ resetCredits,
34
+ diagnosticsReport,
35
+ pending,
36
+ completeOAuth,
37
+ fetchForRef,
38
+ sweepPending,
39
+ subscriptions,
40
+ pmL,
41
+ pmE,
42
+ } = state
43
+
44
+ async function configResponse() {
45
+ return {
46
+ ok: true,
47
+ config: publicConfig(live()),
48
+ accounts: await accountsView(),
49
+ providers: PROVIDERS.map((id) => ({ id, name: displayName(id) })),
50
+ }
51
+ }
52
+
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: 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: 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: 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: 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: 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: 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: 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: 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: 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: 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: 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: 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: async (req, res) => {
625
+ if (req.method !== 'POST') {
626
+ writeJson(res, 405, { ok: false, error: { code: 'method', message: 'POST only' } })
627
+ return
628
+ }
629
+ try {
630
+ const body = await readBody(req).catch(() => ({}))
631
+ const events = Array.isArray(body && body.events) ? body.events : []
632
+ const analysis = analyzeSessionEvents(events)
633
+ writeJson(res, 200, { ok: true, analysis })
634
+ } catch (e) {
635
+ writeJson(res, 500, { ok: false, error: { message: String(e && e.message || e) } })
636
+ }
637
+ },
638
+ }), 'dsh-subscriptions: /analyze-session')
639
+
640
+
641
+
642
+ // HTTP-прокси к API провайдера через subscriptions.request.
643
+ // Same-origin only, allowlist путей, ротация и квота как у моделей.
644
+ // Токен наружу не отдаётся — наружу только ответ провайдера.
645
+ ctx.effect(() => ctx.webServer.register({
646
+ kind: 'prefix',
647
+ path: '/dsh-subscriptions/proxy',
648
+ handler: async (req, res) => {
649
+ if (req.method !== 'POST' && req.method !== 'GET') {
650
+ writeJson(res, 405, { ok: false, error: { code: 'method', message: 'GET or POST' } })
651
+ return
652
+ }
653
+ if (!isTrustedSettingsRequest(req)) {
654
+ writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'same-origin only' } })
655
+ return
656
+ }
657
+ const url = new URL(req.url || '/', 'http://localhost')
658
+ const parts = url.pathname.replace(/^\/dsh-subscriptions\/proxy\//, '').split('/').filter(Boolean)
659
+ const provider = parts[0]
660
+ const restPath = '/' + parts.slice(1).join('/')
661
+ if (!isProvider(provider)) {
662
+ writeJson(res, 404, { ok: false, error: { code: 'provider', message: 'unknown provider' } })
663
+ return
664
+ }
665
+ let body
666
+ if (req.method === 'POST') {
667
+ try {
668
+ body = JSON.parse((await readBody(req, 1024 * 1024)).toString('utf8'))
669
+ } catch {
670
+ writeJson(res, 400, { ok: false, error: { code: 'json', message: 'invalid json' } })
671
+ return
672
+ }
673
+ }
674
+ try {
675
+ const out = await subscriptions.request({
676
+ provider,
677
+ path: restPath,
678
+ method: req.method === 'POST' ? 'POST' : 'GET',
679
+ body,
680
+ headers: {},
681
+ })
682
+ const text = await out.text()
683
+ try {
684
+ const json = JSON.parse(text)
685
+ writeJson(res, out.status || 200, json)
686
+ } catch {
687
+ res.writeHead(out.status || 200, { 'Content-Type': 'application/json' })
688
+ res.end(text)
689
+ }
690
+ } catch (e) {
691
+ const status = e && e.status ? e.status : (e && e.code === 'FORBIDDEN' ? 403 : (e && e.code === 'AUTH' ? 401 : 502))
692
+ writeJson(res, status, { ok: false, error: { code: e && e.code || 'VENDOR', message: String(e && e.message || e).slice(0, 300) } })
693
+ }
694
+ },
695
+ }), 'dsh-subscriptions: proxy')
696
+
697
+ // #88: проверка прокси аккаунта — реальный запрос к эндпоинту провайдера с замером задержки.
698
+ ctx.effect(() => ctx.webServer.register({
699
+ kind: 'exact',
700
+ path: '/dsh-subscriptions/proxy-check',
701
+ handler: async (req, res) => {
702
+ if (req.method !== 'POST') {
703
+ writeJson(res, 405, { ok: false, error: { code: 'method', message: 'POST only' } })
704
+ return
705
+ }
706
+ if (!isTrustedSettingsRequest(req)) {
707
+ writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'same-origin only' } })
708
+ return
709
+ }
710
+ let body
711
+ try {
712
+ body = JSON.parse((await readBody(req, 16 * 1024)).toString('utf8'))
713
+ } catch {
714
+ writeJson(res, 400, { ok: false, error: { code: 'json', message: 'invalid json' } })
715
+ return
716
+ }
717
+ const provider = String(body.provider || '')
718
+ const index = Number(body.index || 1)
719
+ if (!isProvider(provider)) {
720
+ writeJson(res, 400, { ok: false, error: { code: 'provider', message: 'unknown provider' } })
721
+ return
722
+ }
723
+ const slots = normalizeSlots(live().slots)
724
+ const slot = slots.find((s) => s.provider === provider && s.index === index)
725
+ const proxyUrl = (slot && slot.proxyUrl) || ''
726
+ const DEFAULT_BASE = {
727
+ codex: 'https://chatgpt.com/backend-api/codex',
728
+ claude: 'https://api.anthropic.com',
729
+ grok: 'https://api.x.ai/v1',
730
+ antigravity: 'https://cloudcode-pa.googleapis.com',
731
+ }
732
+ const base = String((vendorConfig(provider, live()) || {}).baseUrl || DEFAULT_BASE[provider] || '').replace(/\/$/, '')
733
+ const started = Date.now()
734
+ try {
735
+ const impl = (proxyUrl && proxyFetch(proxyUrl)) || fetch
736
+ if (proxyUrl && impl === fetch) throw new Error('invalid proxy URL')
737
+ const out = await impl(base + '/models', {
738
+ method: 'GET',
739
+ headers: { Accept: 'application/json' },
740
+ signal: AbortSignal.timeout(10000),
741
+ })
742
+ // Любой HTTP-ответ (включая 401/403) = прокси и эндпоинт доступны.
743
+ writeJson(res, 200, { ok: true, status: out.status, latencyMs: Date.now() - started, viaProxy: !!proxyUrl })
744
+ } catch (e) {
745
+ writeJson(res, 200, {
746
+ ok: false,
747
+ latencyMs: Date.now() - started,
748
+ viaProxy: !!proxyUrl,
749
+ error: { code: (e && e.code) || 'NETWORK', message: String((e && e.message) || e).slice(0, 200) },
750
+ })
751
+ }
752
+ },
753
+ }), 'dsh-subscriptions: proxy-check')
754
+
755
+ // Экспорт зашифрованного бандла токенов. Токены не логгируются.
756
+ ctx.effect(() => ctx.webServer.register({
757
+ kind: 'exact',
758
+ path: '/dsh-subscriptions/export',
759
+ handler: async (req, res) => {
760
+ if (req.method !== 'POST') {
761
+ writeJson(res, 405, { ok: false, error: { code: 'method', message: 'POST only' } })
762
+ return
763
+ }
764
+ if (!isTrustedSettingsRequest(req)) {
765
+ writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'same-origin only' } })
766
+ return
767
+ }
768
+ let payload
769
+ try { payload = JSON.parse((await readBody(req, 8 * 1024)).toString('utf8') || '{}') } catch {
770
+ writeJson(res, 400, { ok: false, error: { code: 'json', message: 'invalid json' } })
771
+ return
772
+ }
773
+ const passphrase = payload.passphrase
774
+ if (!passphrase || typeof passphrase !== 'string') {
775
+ writeJson(res, 400, { ok: false, error: { code: 'passphrase', message: 'нужен passphrase' } })
776
+ return
777
+ }
778
+ try {
779
+ const accounts = []
780
+ for (const slot of normalizeSlots(live().slots)) {
781
+ try {
782
+ const blob = await store.loadBlob(slot.ref)
783
+ accounts.push({ ref: slot.ref, provider: slot.provider, index: slot.index, label: slot.label || blob.label || '', blob })
784
+ } catch { /* skip missing */ }
785
+ }
786
+ if (!accounts.length) {
787
+ writeJson(res, 200, { ok: false, error: { code: 'empty', message: 'нет подключённых аккаунтов' } })
788
+ return
789
+ }
790
+ const bundle = JSON.stringify({ v: 1, exportedAt: Date.now(), accounts })
791
+ const encrypted = encryptWithPassphrase(bundle, passphrase)
792
+ writeJson(res, 200, { ok: true, payload: encrypted, count: accounts.length })
793
+ } catch (e) {
794
+ writeJson(res, 500, { ok: false, error: { code: 'export', message: String(e && e.message || e) } })
795
+ }
796
+ },
797
+ }), 'dsh-subscriptions: /export')
798
+
799
+ // Импорт зашифрованного бандла.
800
+ ctx.effect(() => ctx.webServer.register({
801
+ kind: 'exact',
802
+ path: '/dsh-subscriptions/import',
803
+ handler: async (req, res) => {
804
+ if (req.method !== 'POST') {
805
+ writeJson(res, 405, { ok: false, error: { code: 'method', message: 'POST only' } })
806
+ return
807
+ }
808
+ if (!isTrustedSettingsRequest(req)) {
809
+ writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'same-origin only' } })
810
+ return
811
+ }
812
+ let payload
813
+ try { payload = JSON.parse((await readBody(req, 1024 * 1024)).toString('utf8') || '{}') } catch {
814
+ writeJson(res, 400, { ok: false, error: { code: 'json', message: 'invalid json' } })
815
+ return
816
+ }
817
+ const { passphrase, payload: encrypted } = payload
818
+ if (!passphrase || !encrypted) {
819
+ writeJson(res, 400, { ok: false, error: { code: 'params', message: 'нужны passphrase и payload' } })
820
+ return
821
+ }
822
+ let bundle
823
+ try {
824
+ bundle = JSON.parse(decryptWithPassphrase(encrypted, passphrase))
825
+ } catch (e) {
826
+ writeJson(res, 400, { ok: false, error: { code: 'decrypt', message: 'неверный passphrase или повреждённый бандл' } })
827
+ return
828
+ }
829
+ if (!bundle || !Array.isArray(bundle.accounts)) {
830
+ writeJson(res, 400, { ok: false, error: { code: 'format', message: 'неверный формат бандла' } })
831
+ return
832
+ }
833
+ let imported = 0
834
+ for (const row of bundle.accounts) {
835
+ try {
836
+ await store.saveBlob(row.ref, row.blob)
837
+ imported++
838
+ } catch { /* skip broken */ }
839
+ }
840
+ await syncAdapter()
841
+ writeJson(res, 200, { ok: true, imported, total: bundle.accounts.length, accounts: await accountsView() })
842
+ },
843
+ }), 'dsh-subscriptions: /import')
844
+
845
+ // #45: импорт существующего refresh token / API key без OAuth-флоу.
846
+ ctx.effect(() => ctx.webServer.register({
847
+ kind: 'exact',
848
+ path: '/dsh-subscriptions/import-token',
849
+ handler: async (req, res) => {
850
+ if (req.method !== 'POST') {
851
+ writeJson(res, 405, { ok: false, error: { code: 'method', message: 'POST only' } })
852
+ return
853
+ }
854
+ if (!isTrustedSettingsRequest(req)) {
855
+ writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'same-origin only' } })
856
+ return
857
+ }
858
+ let payload
859
+ try { payload = JSON.parse((await readBody(req, 64 * 1024)).toString('utf8') || '{}') } catch {
860
+ writeJson(res, 400, { ok: false, error: { code: 'json', message: 'invalid json' } })
861
+ return
862
+ }
863
+ const provider = payload.provider
864
+ const index = Number(payload.index || '1')
865
+ const refreshToken = payload.refreshToken
866
+ const apiKey = payload.apiKey
867
+ if (!isProvider(provider)) {
868
+ writeJson(res, 400, { ok: false, error: { code: 'provider', message: 'unknown provider' } })
869
+ return
870
+ }
871
+ if (!refreshToken && !apiKey) {
872
+ writeJson(res, 400, { ok: false, error: { code: 'token', message: 'нужен refreshToken или apiKey' } })
873
+ return
874
+ }
875
+ try {
876
+ const ref = oauthRef(provider, index)
877
+ const blob = { accessToken: apiKey || refreshToken, refreshToken: refreshToken || apiKey }
878
+ if (apiKey) { blob.apiKey = apiKey; blob.apiKeyOnly = true }
879
+ await store.saveBlob(ref, blob)
880
+ await syncAdapter()
881
+ refreshModels().catch(() => {})
882
+ writeJson(res, 200, { ok: true, ref, accounts: await accountsView() })
883
+ } catch (e) {
884
+ writeJson(res, 400, { ok: false, error: { code: 'import', message: String(e && e.message || e) } })
885
+ }
886
+ },
887
+ }), 'dsh-subscriptions: /import-token')
888
+
889
+ // #50: сводная страница /subscriptions (localhost-only).
890
+ // #65: история запросов и стоимости (JSON).
891
+ ctx.effect(() => ctx.webServer.register({
892
+ kind: 'exact',
893
+ path: '/dsh-subscriptions/history',
894
+ handler: async (req, res) => {
895
+ if (req.method !== 'GET') {
896
+ writeJson(res, 405, { ok: false, error: { code: 'method', message: 'GET only' } })
897
+ return
898
+ }
899
+ const host = (req.headers.host || '').split(':')[0]
900
+ if (host !== 'localhost' && host !== '127.0.0.1') {
901
+ writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'localhost only' } })
902
+ return
903
+ }
904
+ const limit = Math.min(Number(queryOf(req).get('limit') || '10'), 100)
905
+ writeJson(res, 200, { ok: true, total: history.size(), items: history.recent(limit) })
906
+ },
907
+ }), 'dsh-subscriptions: /history')
908
+
909
+ ctx.effect(() => ctx.webServer.register({
910
+ kind: 'exact',
911
+ path: '/dsh-subscriptions/subscriptions',
912
+ handler: async (req, res) => {
913
+ if (req.method !== 'GET') {
914
+ writeJson(res, 405, { ok: false, error: { code: 'method', message: 'GET only' } })
915
+ return
916
+ }
917
+ const host = (req.headers.host || '').split(':')[0]
918
+ if (host !== 'localhost' && host !== '127.0.0.1') {
919
+ writeHtml(res, 403, '<!doctype html><meta charset="utf-8"><p>Subscriptions overview is localhost-only.</p>')
920
+ return
921
+ }
922
+ let cfg, accounts
923
+ try {
924
+ const out = await configResponse()
925
+ cfg = out.config
926
+ accounts = out.accounts || []
927
+ } catch (e) {
928
+ writeHtml(res, 500, '<!doctype html><meta charset="utf-8"><p>Failed to load: ' + escapeHtml(String(e && e.message || e)) + '</p>')
929
+ return
930
+ }
931
+ const rows = accounts.map((a) => {
932
+ const pct = a.usagePercent != null ? a.usagePercent : (a.quota && a.quota.usedPercent) || null
933
+ const rem = a.quota && a.quota.remaining != null ? a.quota.remaining : null
934
+ const lim = a.quota && a.quota.limit != null ? a.quota.limit : null
935
+ const reset = a.quota && a.quota.resetAt ? new Date(a.quota.resetAt).toLocaleString() : ''
936
+ const status = a.validationUrl ? 'verify' : (a.cooldownUntil && a.cooldownUntil > Date.now() ? 'cooldown' : (a.configured ? 'ok' : 'none'))
937
+ return '<tr><td>' + escapeHtml(a.provider) + '</td><td>' + (a.index||1) + '</td>' +
938
+ '<td>' + escapeHtml(a.label || a.email || '') + '</td><td>' + status + '</td>' +
939
+ '<td>' + (pct != null ? Math.round(pct) + '%' : '—') + '</td>' +
940
+ '<td>' + (rem != null ? (rem + (lim != null ? '/' + lim : '')) : '—') + '</td>' +
941
+ '<td>' + escapeHtml(reset) + '</td><td>' + escapeHtml(a.refreshError || '') + '</td></tr>'
942
+ })
943
+ const body = rows.length
944
+ ? '<div class="grid">' + rows.join('') + '</div>'
945
+ : '<p class="empty">No accounts connected yet.</p>'
946
+ const slots = cfg.slots || []
947
+ const connected = accounts.filter((a) => a.configured).length
948
+ writeHtml(res, 200, `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Subscriptions</title>
949
+ <style>
950
+ body{font-family:system-ui,sans-serif;margin:0;padding:24px;background:#0d1117;color:#e6edf3}
951
+ h1{font-size:20px} .dim{color:#8b949e;font-size:13px}
952
+ .stats{display:flex;gap:24px;margin:16px 0;font-size:13px}
953
+ .grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(320px,1fr));gap:12px}
954
+ .card{border:1px solid #30363d;border-radius:10px;padding:14px;background:#161b22}
955
+ .card b{display:block;margin-bottom:4px}
956
+ .status{font-size:11px;padding:2px 8px;border-radius:999px;border:1px solid #30363d}
957
+ .status.ok{color:#3fb950;border-color:#238636} .status.none{color:#8b949e}
958
+ .status.cooldown{color:#d29922;border-color:#9e6a03} .status.verify{color:#d29922;border-color:#9e6a03}
959
+ .meta{color:#8b949e;font-size:12px}
960
+ </style></head><body>
961
+ <h1>Subscriptions</h1>
962
+ <div class="dim">/subscriptions — localhost only</div>
963
+ <div class="stats"><span><b>${connected}</b> connected</span><span><b>${accounts.length}</b> accounts</span><span><b>${slots.length}</b> slots</span></div>
964
+ ${body}
965
+ <h2>History</h2>
966
+ <div class="dim">last 10 · <a href="/dsh-subscriptions/history?limit=100">show 100</a></div>
967
+ <div class="hist" id="hist"></div>
968
+ <script>
969
+ fetch('/dsh-subscriptions/history?limit=10').then(r=>r.json()).then(d=>{
970
+ const el=document.getElementById('hist')
971
+ if(!d||!d.items||!d.items.length){el.textContent='No requests yet.';return}
972
+ el.innerHTML='<table class="grid"><tr><th>time</th><th>provider</th><th>model</th><th>path</th><th>status</th></tr>'+
973
+ d.items.map(i=>'<tr><td>'+new Date(i.ts).toLocaleString()+'</td><td>'+esc(i.provider)+'</td><td>'+esc(i.model||'')+'</td><td>'+esc(i.path)+'</td><td>'+i.status+'</td></tr>').join('')+'</table>'
974
+ }).catch(()=>{})
975
+ function esc(x){return String(x==null?'':x).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;')}
976
+ </script>
977
+ </body></html>`)
978
+ },
979
+ }), 'dsh-subscriptions: /subscriptions')
980
+
981
+ ctx.effect(() => ctx.webServer.register({
982
+ kind: 'exact',
983
+ path: '/dsh-subscriptions/logout',
984
+ handler: async (req, res) => {
985
+ if (req.method !== 'POST') {
986
+ writeJson(res, 405, { ok: false, error: { code: 'method', message: 'POST only' } })
987
+ return
988
+ }
989
+ if (!isTrustedSettingsRequest(req)) {
990
+ writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'same-origin only' } })
991
+ return
992
+ }
993
+ let payload
994
+ try { payload = JSON.parse((await readBody(req, 8 * 1024)).toString('utf8') || '{}') } catch {
995
+ writeJson(res, 400, { ok: false, error: { code: 'json', message: 'invalid json' } })
996
+ return
997
+ }
998
+ try {
999
+ const ref = oauthRef(payload.provider, payload.index)
1000
+ await store.clearRef(ref)
1001
+ await syncAdapter()
1002
+ refreshModels().catch(() => {})
1003
+ writeJson(res, 200, { ok: true, ref, accounts: await accountsView() })
1004
+ } catch (e) {
1005
+ writeJson(res, 400, { ok: false, error: { code: 'logout', message: String(e && e.message || e) } })
1006
+ }
1007
+ },
1008
+ }), 'dsh-subscriptions: /logout')
1009
+ }
1010
+
1011
+ export function escapeHtml(text) {
1012
+ return String(text)
1013
+ .replace(/&/g, '&amp;')
1014
+ .replace(/</g, '&lt;')
1015
+ .replace(/>/g, '&gt;')
1016
+ }