@goodandready/dsh-subscriptions 0.6.0 → 0.6.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +6 -0
- package/README.ru.md +6 -0
- package/README.zh.md +6 -0
- package/lib/accounts.js +1 -1
- package/lib/adapter.js +2 -2
- package/lib/client.js +43 -167
- package/lib/crypto.js +4 -4
- package/lib/google-validation.js +1 -1
- package/lib/health.js +2 -2
- package/lib/history.js +5 -5
- package/lib/http.js +7 -0
- package/lib/images.js +21 -21
- package/lib/index.js +19 -19
- package/lib/loopback.js +10 -10
- package/lib/ratelimit.js +2 -2
- package/lib/refs.js +9 -1
- package/lib/routes/accounts.js +343 -0
- package/lib/routes/oauth.js +248 -0
- package/lib/routes/proxy.js +147 -0
- package/lib/routes/status.js +257 -0
- package/lib/routes.js +12 -916
- package/lib/subscriptions.js +1 -1
- package/lib/ui-styles.js +12 -12
- package/lib/usage.js +1 -1
- package/lib/vendor-factory.js +13 -13
- package/package.json +1 -1
- package/lib/ui-i18n.js +0 -256
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
import { normalizeSlots, vendorConfig } from '../accounts.js'
|
|
2
|
+
import { escapeHtml, isTrustedSettingsRequest, queryOf, readBody, safeJsonHandler, writeHtml, writeJson } from '../http.js'
|
|
3
|
+
import { startLoopback } from '../loopback.js'
|
|
4
|
+
import { parseCallbackInput, requestOrigin } from '../oauth.js'
|
|
5
|
+
import { createPkce } from '../pkce.js'
|
|
6
|
+
import { displayName, isProvider, oauthRef } from '../refs.js'
|
|
7
|
+
import { getVendor } from '../vendors/index.js'
|
|
8
|
+
|
|
9
|
+
export function registerOauthRoutes(ctx, state) {
|
|
10
|
+
const {
|
|
11
|
+
live,
|
|
12
|
+
accountsView,
|
|
13
|
+
syncAdapter,
|
|
14
|
+
store,
|
|
15
|
+
redirectFor,
|
|
16
|
+
OK_HTML,
|
|
17
|
+
refreshModels,
|
|
18
|
+
pending,
|
|
19
|
+
completeOAuth,
|
|
20
|
+
fetchForRef,
|
|
21
|
+
sweepPending,
|
|
22
|
+
subscriptions,
|
|
23
|
+
pmL,
|
|
24
|
+
} = state
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
ctx.effect(() => ctx.webServer.register({
|
|
28
|
+
kind: 'exact',
|
|
29
|
+
path: '/dsh-subscriptions/oauth/start',
|
|
30
|
+
handler: safeJsonHandler(async (req, res) => {
|
|
31
|
+
if (req.method !== 'GET') {
|
|
32
|
+
writeJson(res, 405, { ok: false, error: { code: 'method', message: 'GET only' } })
|
|
33
|
+
return
|
|
34
|
+
}
|
|
35
|
+
const q = queryOf(req)
|
|
36
|
+
const provider = q.get('provider') || ''
|
|
37
|
+
const index = Number(q.get('index') || '1')
|
|
38
|
+
if (!isProvider(provider)) {
|
|
39
|
+
writeJson(res, 400, { ok: false, error: { code: 'provider', message: 'unknown provider' } })
|
|
40
|
+
return
|
|
41
|
+
}
|
|
42
|
+
const origin = requestOrigin(req)
|
|
43
|
+
const redirectUri = redirectFor(provider, live(), origin)
|
|
44
|
+
const pkce = await createPkce()
|
|
45
|
+
pending.set(pkce.state, {
|
|
46
|
+
provider,
|
|
47
|
+
index,
|
|
48
|
+
verifier: pkce.verifier,
|
|
49
|
+
challenge: pkce.challenge,
|
|
50
|
+
state: pkce.state,
|
|
51
|
+
redirectUri,
|
|
52
|
+
createdAt: Date.now(),
|
|
53
|
+
})
|
|
54
|
+
const cfg = { ...vendorConfig(provider, live()), redirectUri }
|
|
55
|
+
const url = getVendor(provider).authorizeUrl(cfg, pkce)
|
|
56
|
+
// #89: if redirect_uri is loopback - start a temporary catch server.
|
|
57
|
+
let autoCatch = false
|
|
58
|
+
if (live().autoLoopback) {
|
|
59
|
+
try {
|
|
60
|
+
const cb = new URL(redirectUri)
|
|
61
|
+
if (cb.hostname === 'localhost' || cb.hostname === '127.0.0.1') {
|
|
62
|
+
autoCatch = true
|
|
63
|
+
startLoopback({
|
|
64
|
+
redirectUri,
|
|
65
|
+
onCode: async (params) => {
|
|
66
|
+
const code = params.get('code') || ''
|
|
67
|
+
const state = params.get('state') || ''
|
|
68
|
+
if (!code) throw new Error('no code')
|
|
69
|
+
await completeOAuth({ provider, index, code, state })
|
|
70
|
+
return OK_HTML
|
|
71
|
+
},
|
|
72
|
+
}).catch(() => {})
|
|
73
|
+
}
|
|
74
|
+
} catch { autoCatch = false }
|
|
75
|
+
}
|
|
76
|
+
writeJson(res, 200, { ok: true, url, state: pkce.state, redirectUri, autoCatch })
|
|
77
|
+
}),
|
|
78
|
+
}), 'dsh-subscriptions: /oauth/start')
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
// #90: Device-code login (headless/VPS). Codex-only: auth.openai.com mints an
|
|
82
|
+
// authorization_code + code_verifier server-side; we finish with the normal
|
|
83
|
+
// PKCE exchange using the device redirect URI. Device code stays server-side
|
|
84
|
+
// in the pending map (same lifetime as PKCE pending rows).
|
|
85
|
+
ctx.effect(() => ctx.webServer.register({
|
|
86
|
+
kind: 'exact',
|
|
87
|
+
path: '/dsh-subscriptions/oauth/device/start',
|
|
88
|
+
handler: safeJsonHandler(async (req, res) => {
|
|
89
|
+
if (req.method !== 'POST') {
|
|
90
|
+
writeJson(res, 405, { ok: false, error: { code: 'method', message: 'POST only' } })
|
|
91
|
+
return
|
|
92
|
+
}
|
|
93
|
+
if (!isTrustedSettingsRequest(req)) {
|
|
94
|
+
writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'same-origin only' } })
|
|
95
|
+
return
|
|
96
|
+
}
|
|
97
|
+
let body
|
|
98
|
+
try {
|
|
99
|
+
body = JSON.parse((await readBody(req, 16 * 1024)).toString('utf8'))
|
|
100
|
+
} catch {
|
|
101
|
+
writeJson(res, 400, { ok: false, error: { code: 'json', message: 'invalid json' } })
|
|
102
|
+
return
|
|
103
|
+
}
|
|
104
|
+
const provider = String(body.provider || '')
|
|
105
|
+
const index = Number(body.index || 1)
|
|
106
|
+
if (!isProvider(provider)) {
|
|
107
|
+
writeJson(res, 400, { ok: false, error: { code: 'provider', message: 'unknown provider' } })
|
|
108
|
+
return
|
|
109
|
+
}
|
|
110
|
+
const vendor = getVendor(provider)
|
|
111
|
+
if (typeof vendor.deviceStart !== 'function') {
|
|
112
|
+
writeJson(res, 400, { ok: false, error: { code: 'device', message: 'device login not supported for ' + provider } })
|
|
113
|
+
return
|
|
114
|
+
}
|
|
115
|
+
try {
|
|
116
|
+
const cfg = vendorConfig(provider, live())
|
|
117
|
+
const start = await vendor.deviceStart(cfg, (fetchForRef(oauthRef(provider, index)) || fetch))
|
|
118
|
+
const state = 'dev-' + Date.now() + '-' + Math.random().toString(36).slice(2, 10)
|
|
119
|
+
sweepPending(Date.now())
|
|
120
|
+
pending.set(state, {
|
|
121
|
+
kind: 'device',
|
|
122
|
+
provider,
|
|
123
|
+
index,
|
|
124
|
+
ref: oauthRef(provider, index),
|
|
125
|
+
deviceAuthId: start.deviceAuthId,
|
|
126
|
+
userCode: start.userCode,
|
|
127
|
+
intervalMs: start.intervalMs,
|
|
128
|
+
createdAt: Date.now(),
|
|
129
|
+
})
|
|
130
|
+
writeJson(res, 200, { ok: true, state, userCode: start.userCode, authUrl: start.authUrl, intervalMs: start.intervalMs })
|
|
131
|
+
} catch (e) {
|
|
132
|
+
writeJson(res, 502, { ok: false, error: { code: e && e.code || 'DEVICE', message: String(e && e.message || e).slice(0, 300) } })
|
|
133
|
+
}
|
|
134
|
+
}),
|
|
135
|
+
}), 'dsh-subscriptions: /oauth/device/start')
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
ctx.effect(() => ctx.webServer.register({
|
|
139
|
+
kind: 'exact',
|
|
140
|
+
path: '/dsh-subscriptions/oauth/device/poll',
|
|
141
|
+
handler: safeJsonHandler(async (req, res) => {
|
|
142
|
+
if (req.method !== 'POST') {
|
|
143
|
+
writeJson(res, 405, { ok: false, error: { code: 'method', message: 'POST only' } })
|
|
144
|
+
return
|
|
145
|
+
}
|
|
146
|
+
if (!isTrustedSettingsRequest(req)) {
|
|
147
|
+
writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'same-origin only' } })
|
|
148
|
+
return
|
|
149
|
+
}
|
|
150
|
+
let body
|
|
151
|
+
try {
|
|
152
|
+
body = JSON.parse((await readBody(req, 16 * 1024)).toString('utf8'))
|
|
153
|
+
} catch {
|
|
154
|
+
writeJson(res, 400, { ok: false, error: { code: 'json', message: 'invalid json' } })
|
|
155
|
+
return
|
|
156
|
+
}
|
|
157
|
+
const state = String(body.state || '')
|
|
158
|
+
sweepPending(Date.now())
|
|
159
|
+
const row = pending.get(state)
|
|
160
|
+
if (!row || row.kind !== 'device') {
|
|
161
|
+
writeJson(res, 404, { ok: false, error: { code: 'expired', message: 'device login session expired; start again' } })
|
|
162
|
+
return
|
|
163
|
+
}
|
|
164
|
+
const vendor = getVendor(row.provider)
|
|
165
|
+
try {
|
|
166
|
+
const cfg = vendorConfig(row.provider, live())
|
|
167
|
+
const out = await vendor.devicePoll(cfg, { deviceAuthId: row.deviceAuthId, userCode: row.userCode }, (fetchForRef(row.ref) || fetch))
|
|
168
|
+
if (out.status !== 'authorized') {
|
|
169
|
+
writeJson(res, 200, { ok: true, status: out.status })
|
|
170
|
+
return
|
|
171
|
+
}
|
|
172
|
+
const slots = normalizeSlots(live().slots)
|
|
173
|
+
const slot = slots.find((s) => s.ref === row.ref)
|
|
174
|
+
const blob = out.blob
|
|
175
|
+
if (slot && slot.label) blob.label = slot.label
|
|
176
|
+
await store.saveBlob(row.ref, blob)
|
|
177
|
+
pending.delete(state)
|
|
178
|
+
await syncAdapter()
|
|
179
|
+
refreshModels().catch(() => {})
|
|
180
|
+
writeJson(res, 200, { ok: true, status: 'authorized', ref: row.ref, label: pmL(blob.label || blob.email) || displayName(row.provider) })
|
|
181
|
+
} catch (e) {
|
|
182
|
+
writeJson(res, 502, { ok: false, error: { code: e && e.code || 'DEVICE', message: String(e && e.message || e).slice(0, 300) } })
|
|
183
|
+
}
|
|
184
|
+
}),
|
|
185
|
+
}), 'dsh-subscriptions: /oauth/device/poll')
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
ctx.effect(() => ctx.webServer.register({
|
|
189
|
+
kind: 'exact',
|
|
190
|
+
path: '/dsh-subscriptions/oauth/callback',
|
|
191
|
+
handler: async (req, res) => {
|
|
192
|
+
if (req.method !== 'GET') {
|
|
193
|
+
writeJson(res, 405, { ok: false, error: { code: 'method', message: 'GET only' } })
|
|
194
|
+
return
|
|
195
|
+
}
|
|
196
|
+
const q = queryOf(req)
|
|
197
|
+
const code = q.get('code') || ''
|
|
198
|
+
const state = q.get('state') || ''
|
|
199
|
+
const row = pending.get(state)
|
|
200
|
+
if (!code || !row) {
|
|
201
|
+
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>')
|
|
202
|
+
return
|
|
203
|
+
}
|
|
204
|
+
try {
|
|
205
|
+
await completeOAuth({ provider: row.provider, index: row.index, code, state })
|
|
206
|
+
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>')
|
|
207
|
+
} catch (e) {
|
|
208
|
+
writeHtml(res, 400, `<!doctype html><meta charset="utf-8"><title>Subscriptions</title><p>${escapeHtml(String(e && e.message || e))}</p>`)
|
|
209
|
+
}
|
|
210
|
+
},
|
|
211
|
+
}), 'dsh-subscriptions: /oauth/callback')
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
ctx.effect(() => ctx.webServer.register({
|
|
215
|
+
kind: 'exact',
|
|
216
|
+
path: '/dsh-subscriptions/oauth/complete',
|
|
217
|
+
handler: safeJsonHandler(async (req, res) => {
|
|
218
|
+
if (req.method !== 'POST') {
|
|
219
|
+
writeJson(res, 405, { ok: false, error: { code: 'method', message: 'POST only' } })
|
|
220
|
+
return
|
|
221
|
+
}
|
|
222
|
+
if (!isTrustedSettingsRequest(req)) {
|
|
223
|
+
writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'same-origin only' } })
|
|
224
|
+
return
|
|
225
|
+
}
|
|
226
|
+
let payload
|
|
227
|
+
try { payload = JSON.parse((await readBody(req, 16 * 1024)).toString('utf8') || '{}') } catch {
|
|
228
|
+
writeJson(res, 400, { ok: false, error: { code: 'json', message: 'invalid json' } })
|
|
229
|
+
return
|
|
230
|
+
}
|
|
231
|
+
const parsed = parseCallbackInput(payload.url || payload.code || '')
|
|
232
|
+
const provider = payload.provider
|
|
233
|
+
const index = payload.index
|
|
234
|
+
const code = parsed.code
|
|
235
|
+
const state = parsed.state || payload.state || ''
|
|
236
|
+
if (!code) {
|
|
237
|
+
writeJson(res, 400, { ok: false, error: { code: 'code', message: 'paste the redirected URL or the code' } })
|
|
238
|
+
return
|
|
239
|
+
}
|
|
240
|
+
try {
|
|
241
|
+
const result = await completeOAuth({ provider, index, code, state })
|
|
242
|
+
writeJson(res, 200, { ok: true, ...result, accounts: await accountsView() })
|
|
243
|
+
} catch (e) {
|
|
244
|
+
writeJson(res, 400, { ok: false, error: { code: 'oauth', message: String(e && e.message || e) } })
|
|
245
|
+
}
|
|
246
|
+
}),
|
|
247
|
+
}), 'dsh-subscriptions: /oauth/complete')
|
|
248
|
+
}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { normalizeSlots, vendorConfig } from '../accounts.js'
|
|
2
|
+
import { analyzeSessionEvents } from '../analyze-session.js'
|
|
3
|
+
import { isTrustedSettingsRequest, readBody, safeJsonHandler, writeJson } from '../http.js'
|
|
4
|
+
import { proxyFetch } from '../proxy.js'
|
|
5
|
+
import { isProvider } from '../refs.js'
|
|
6
|
+
|
|
7
|
+
export function registerProxyRoutes(ctx, state) {
|
|
8
|
+
const {
|
|
9
|
+
live,
|
|
10
|
+
subscriptions,
|
|
11
|
+
} = state
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
ctx.effect(() => ctx.webServer.register({
|
|
15
|
+
kind: 'exact',
|
|
16
|
+
path: '/dsh-subscriptions/analyze-session',
|
|
17
|
+
handler: safeJsonHandler(async (req, res) => {
|
|
18
|
+
if (req.method !== 'POST') {
|
|
19
|
+
writeJson(res, 405, { ok: false, error: { code: 'method', message: 'POST only' } })
|
|
20
|
+
return
|
|
21
|
+
}
|
|
22
|
+
const raw = await readBody(req, 128 * 1024).catch(() => Buffer.alloc(0))
|
|
23
|
+
let body = {}
|
|
24
|
+
try { body = JSON.parse(raw.toString('utf8') || '{}') } catch {}
|
|
25
|
+
const events = Array.isArray(body && body.events) ? body.events : []
|
|
26
|
+
const analysis = analyzeSessionEvents(events)
|
|
27
|
+
writeJson(res, 200, { ok: true, analysis })
|
|
28
|
+
}),
|
|
29
|
+
}), 'dsh-subscriptions: /analyze-session')
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
// HTTP proxy to the provider API through subscriptions.request.
|
|
35
|
+
// Same-origin only, path allowlist, rotation and quota as with models.
|
|
36
|
+
// The token never leaves the process - only the provider's response goes out.
|
|
37
|
+
ctx.effect(() => ctx.webServer.register({
|
|
38
|
+
kind: 'prefix',
|
|
39
|
+
path: '/dsh-subscriptions/proxy',
|
|
40
|
+
handler: safeJsonHandler(async (req, res) => {
|
|
41
|
+
if (req.method !== 'POST' && req.method !== 'GET') {
|
|
42
|
+
writeJson(res, 405, { ok: false, error: { code: 'method', message: 'GET or POST' } })
|
|
43
|
+
return
|
|
44
|
+
}
|
|
45
|
+
if (!isTrustedSettingsRequest(req)) {
|
|
46
|
+
writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'same-origin only' } })
|
|
47
|
+
return
|
|
48
|
+
}
|
|
49
|
+
const url = new URL(req.url || '/', 'http://localhost')
|
|
50
|
+
const parts = url.pathname.replace(/^\/dsh-subscriptions\/proxy\//, '').split('/').filter(Boolean)
|
|
51
|
+
const provider = parts[0]
|
|
52
|
+
const restPath = '/' + parts.slice(1).join('/')
|
|
53
|
+
if (!isProvider(provider)) {
|
|
54
|
+
writeJson(res, 404, { ok: false, error: { code: 'provider', message: 'unknown provider' } })
|
|
55
|
+
return
|
|
56
|
+
}
|
|
57
|
+
let body
|
|
58
|
+
if (req.method === 'POST') {
|
|
59
|
+
try {
|
|
60
|
+
body = JSON.parse((await readBody(req, 1024 * 1024)).toString('utf8'))
|
|
61
|
+
} catch {
|
|
62
|
+
writeJson(res, 400, { ok: false, error: { code: 'json', message: 'invalid json' } })
|
|
63
|
+
return
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
try {
|
|
67
|
+
const out = await subscriptions.request({
|
|
68
|
+
provider,
|
|
69
|
+
path: restPath,
|
|
70
|
+
method: req.method === 'POST' ? 'POST' : 'GET',
|
|
71
|
+
body,
|
|
72
|
+
headers: {},
|
|
73
|
+
})
|
|
74
|
+
const text = await out.text()
|
|
75
|
+
try {
|
|
76
|
+
const json = JSON.parse(text)
|
|
77
|
+
writeJson(res, out.status || 200, json)
|
|
78
|
+
} catch {
|
|
79
|
+
res.writeHead(out.status || 200, { 'Content-Type': 'application/json' })
|
|
80
|
+
res.end(text)
|
|
81
|
+
}
|
|
82
|
+
} catch (e) {
|
|
83
|
+
const status = e && e.status ? e.status : (e && e.code === 'FORBIDDEN' ? 403 : (e && e.code === 'AUTH' ? 401 : 502))
|
|
84
|
+
writeJson(res, status, { ok: false, error: { code: e && e.code || 'VENDOR', message: String(e && e.message || e).slice(0, 300) } })
|
|
85
|
+
}
|
|
86
|
+
}),
|
|
87
|
+
}), 'dsh-subscriptions: proxy')
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
// #88: account proxy check - a real request to the provider endpoint with latency measurement.
|
|
91
|
+
ctx.effect(() => ctx.webServer.register({
|
|
92
|
+
kind: 'exact',
|
|
93
|
+
path: '/dsh-subscriptions/proxy-check',
|
|
94
|
+
handler: safeJsonHandler(async (req, res) => {
|
|
95
|
+
if (req.method !== 'POST') {
|
|
96
|
+
writeJson(res, 405, { ok: false, error: { code: 'method', message: 'POST only' } })
|
|
97
|
+
return
|
|
98
|
+
}
|
|
99
|
+
if (!isTrustedSettingsRequest(req)) {
|
|
100
|
+
writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'same-origin only' } })
|
|
101
|
+
return
|
|
102
|
+
}
|
|
103
|
+
let body
|
|
104
|
+
try {
|
|
105
|
+
body = JSON.parse((await readBody(req, 16 * 1024)).toString('utf8'))
|
|
106
|
+
} catch {
|
|
107
|
+
writeJson(res, 400, { ok: false, error: { code: 'json', message: 'invalid json' } })
|
|
108
|
+
return
|
|
109
|
+
}
|
|
110
|
+
const provider = String(body.provider || '')
|
|
111
|
+
const index = Number(body.index || 1)
|
|
112
|
+
if (!isProvider(provider)) {
|
|
113
|
+
writeJson(res, 400, { ok: false, error: { code: 'provider', message: 'unknown provider' } })
|
|
114
|
+
return
|
|
115
|
+
}
|
|
116
|
+
const slots = normalizeSlots(live().slots)
|
|
117
|
+
const slot = slots.find((s) => s.provider === provider && s.index === index)
|
|
118
|
+
const proxyUrl = (slot && slot.proxyUrl) || ''
|
|
119
|
+
const DEFAULT_BASE = {
|
|
120
|
+
codex: 'https://chatgpt.com/backend-api/codex',
|
|
121
|
+
claude: 'https://api.anthropic.com',
|
|
122
|
+
grok: 'https://api.x.ai/v1',
|
|
123
|
+
antigravity: 'https://cloudcode-pa.googleapis.com',
|
|
124
|
+
}
|
|
125
|
+
const base = String((vendorConfig(provider, live()) || {}).baseUrl || DEFAULT_BASE[provider] || '').replace(/\/$/, '')
|
|
126
|
+
const started = Date.now()
|
|
127
|
+
try {
|
|
128
|
+
const impl = (proxyUrl && proxyFetch(proxyUrl)) || fetch
|
|
129
|
+
if (proxyUrl && impl === fetch) throw new Error('invalid proxy URL')
|
|
130
|
+
const out = await impl(base + '/models', {
|
|
131
|
+
method: 'GET',
|
|
132
|
+
headers: { Accept: 'application/json' },
|
|
133
|
+
signal: AbortSignal.timeout(10000),
|
|
134
|
+
})
|
|
135
|
+
// Any HTTP response (including 401/403) means the proxy and endpoint are reachable.
|
|
136
|
+
writeJson(res, 200, { ok: true, status: out.status, latencyMs: Date.now() - started, viaProxy: !!proxyUrl })
|
|
137
|
+
} catch (e) {
|
|
138
|
+
writeJson(res, 200, {
|
|
139
|
+
ok: false,
|
|
140
|
+
latencyMs: Date.now() - started,
|
|
141
|
+
viaProxy: !!proxyUrl,
|
|
142
|
+
error: { code: (e && e.code) || 'NETWORK', message: String((e && e.message) || e).slice(0, 200) },
|
|
143
|
+
})
|
|
144
|
+
}
|
|
145
|
+
}),
|
|
146
|
+
}), 'dsh-subscriptions: proxy-check')
|
|
147
|
+
}
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
import { normalizeSlots, vendorConfig } from '../accounts.js'
|
|
2
|
+
import { Config, publicConfig } from '../config-schema.js'
|
|
3
|
+
import { isTrustedSettingsRequest, queryOf, readBody, safeJsonHandler, writeJson } from '../http.js'
|
|
4
|
+
import { quotaSnapshot } from '../ratelimit.js'
|
|
5
|
+
import { PROVIDERS, displayName, droppedCredentialRefs, isProvider, oauthRef } from '../refs.js'
|
|
6
|
+
import { getVendor } from '../vendors/index.js'
|
|
7
|
+
|
|
8
|
+
export function registerStatusRoutes(ctx, state) {
|
|
9
|
+
const {
|
|
10
|
+
accountsView,
|
|
11
|
+
live,
|
|
12
|
+
getSettingsApi,
|
|
13
|
+
syncCustomVendors,
|
|
14
|
+
syncAdapter,
|
|
15
|
+
stripLegacySlots,
|
|
16
|
+
store,
|
|
17
|
+
history,
|
|
18
|
+
diagnosticsReport,
|
|
19
|
+
subscriptions,
|
|
20
|
+
pmL,
|
|
21
|
+
pmE,
|
|
22
|
+
} = state
|
|
23
|
+
|
|
24
|
+
async function configResponse() {
|
|
25
|
+
return {
|
|
26
|
+
ok: true,
|
|
27
|
+
config: publicConfig(live()),
|
|
28
|
+
accounts: await accountsView(),
|
|
29
|
+
providers: PROVIDERS.map((id) => ({ id, name: displayName(id) })),
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
ctx.effect(() => ctx.webServer.register({
|
|
34
|
+
kind: 'exact',
|
|
35
|
+
path: '/dsh-subscriptions/config',
|
|
36
|
+
handler: async (req, res) => {
|
|
37
|
+
if (req.method === 'GET') {
|
|
38
|
+
writeJson(res, 200, await configResponse())
|
|
39
|
+
return
|
|
40
|
+
}
|
|
41
|
+
if (req.method !== 'PUT') {
|
|
42
|
+
writeJson(res, 405, { ok: false, error: { code: 'method', message: 'GET or PUT' } })
|
|
43
|
+
return
|
|
44
|
+
}
|
|
45
|
+
if (!isTrustedSettingsRequest(req)) {
|
|
46
|
+
writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'settings writes are same-origin only' } })
|
|
47
|
+
return
|
|
48
|
+
}
|
|
49
|
+
if (!getSettingsApi()) {
|
|
50
|
+
writeJson(res, 503, { ok: false, error: { code: 'settings', message: 'settings not ready' } })
|
|
51
|
+
return
|
|
52
|
+
}
|
|
53
|
+
let payload
|
|
54
|
+
try { payload = JSON.parse((await readBody(req, 256 * 1024)).toString('utf8') || '{}') } catch {
|
|
55
|
+
writeJson(res, 400, { ok: false, error: { code: 'json', message: 'invalid json' } })
|
|
56
|
+
return
|
|
57
|
+
}
|
|
58
|
+
if (payload && typeof payload.config === 'object') payload = payload.config
|
|
59
|
+
try {
|
|
60
|
+
if (Array.isArray(payload.slots)) payload.slots = stripLegacySlots(payload.slots)
|
|
61
|
+
const parsed = Config(payload)
|
|
62
|
+
const dropped = droppedCredentialRefs(live().slots, parsed.slots)
|
|
63
|
+
await getSettingsApi().replace(parsed)
|
|
64
|
+
syncCustomVendors()
|
|
65
|
+
for (const ref of dropped) await store.clearRef(ref)
|
|
66
|
+
await syncAdapter()
|
|
67
|
+
writeJson(res, 200, await configResponse())
|
|
68
|
+
} catch (e) {
|
|
69
|
+
writeJson(res, 400, { ok: false, error: { code: 'save', message: String(e && e.message || e) } })
|
|
70
|
+
}
|
|
71
|
+
},
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
}), 'dsh-subscriptions: /config')
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
ctx.effect(() => ctx.webServer.register({
|
|
79
|
+
kind: 'exact',
|
|
80
|
+
path: '/dsh-subscriptions/status',
|
|
81
|
+
handler: safeJsonHandler(async (req, res) => {
|
|
82
|
+
if (req.method !== 'GET') {
|
|
83
|
+
writeJson(res, 405, { ok: false, error: { code: 'method', message: 'GET only' } })
|
|
84
|
+
return
|
|
85
|
+
}
|
|
86
|
+
const logged = await store.loggedInProviders()
|
|
87
|
+
// #66/#67: usagePercent (max across accounts) and expiresAt for the chip.
|
|
88
|
+
const usage = {}
|
|
89
|
+
const expires = {}
|
|
90
|
+
const labels = {}
|
|
91
|
+
for (const slot of normalizeSlots(live().slots)) {
|
|
92
|
+
try {
|
|
93
|
+
const info = await store.describeRef(slot.ref)
|
|
94
|
+
if (info.usagePercent != null) {
|
|
95
|
+
usage[slot.provider] = Math.max(usage[slot.provider] || 0, info.usagePercent)
|
|
96
|
+
}
|
|
97
|
+
// #67: subscription expiry date comes from the slot (entered in settings).
|
|
98
|
+
if (slot.expiresAt) {
|
|
99
|
+
expires[slot.provider] = Math.max(expires[slot.provider] || 0, slot.expiresAt)
|
|
100
|
+
labels[slot.provider] = pmL(slot.label || info.label || slot.provider)
|
|
101
|
+
}
|
|
102
|
+
} catch {}
|
|
103
|
+
}
|
|
104
|
+
// #69/#72: active subscription = last successful request (newest first).
|
|
105
|
+
let active = null
|
|
106
|
+
const last = history.recent(1)
|
|
107
|
+
if (last.length) {
|
|
108
|
+
const lastRow = last[0]
|
|
109
|
+
const accts = await store.listAccounts(lastRow.provider)
|
|
110
|
+
const acct = accts.find((a) => a.ref === lastRow.ref) || accts[0]
|
|
111
|
+
let plan = ''
|
|
112
|
+
let index = acct && acct.ref ? Number(String(acct.ref).split('_').pop()) || null : null
|
|
113
|
+
let status = 'ok'
|
|
114
|
+
let windows = []
|
|
115
|
+
try {
|
|
116
|
+
const info = await store.describeRef(lastRow.ref)
|
|
117
|
+
plan = info.paidTierName || ''
|
|
118
|
+
if (info.validationUrl) status = 'verify'
|
|
119
|
+
else if (info.cooldownUntil && info.cooldownUntil > Date.now()) status = 'cooldown'
|
|
120
|
+
if (Array.isArray(info.usage)) {
|
|
121
|
+
windows = info.usage
|
|
122
|
+
.filter((w) => w && typeof w.usedPercent === 'number')
|
|
123
|
+
.map((w) => ({ id: w.id || w.en || w.ru, label: w.en || w.ru || w.id, usedPercent: w.usedPercent }))
|
|
124
|
+
}
|
|
125
|
+
} catch {}
|
|
126
|
+
active = {
|
|
127
|
+
provider: lastRow.provider,
|
|
128
|
+
index,
|
|
129
|
+
model: lastRow.model || null,
|
|
130
|
+
path: lastRow.path || null,
|
|
131
|
+
plan,
|
|
132
|
+
windows,
|
|
133
|
+
usagePercent: usage[lastRow.provider] != null ? usage[lastRow.provider] : null,
|
|
134
|
+
status,
|
|
135
|
+
at: lastRow.ts,
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
writeJson(res, 200, {
|
|
139
|
+
ok: true,
|
|
140
|
+
loggedIn: Object.fromEntries(PROVIDERS.map((id) => [id, logged.includes(id)])),
|
|
141
|
+
usagePercent: usage,
|
|
142
|
+
expiresAt: expires,
|
|
143
|
+
labels,
|
|
144
|
+
expiryNotifyDays: live().expiryNotifyDays,
|
|
145
|
+
fastMode: !!live().codexFastMode,
|
|
146
|
+
composerQuota: String(live().composerQuota || 'off'),
|
|
147
|
+
active,
|
|
148
|
+
})
|
|
149
|
+
}),
|
|
150
|
+
}), 'dsh-subscriptions: /status')
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
ctx.effect(() => ctx.webServer.register({
|
|
154
|
+
kind: 'exact',
|
|
155
|
+
path: '/dsh-subscriptions/diagnostics',
|
|
156
|
+
handler: safeJsonHandler(async (req, res) => {
|
|
157
|
+
if (req.method !== 'GET') {
|
|
158
|
+
writeJson(res, 405, { ok: false, error: { code: 'method', message: 'GET only' } })
|
|
159
|
+
return
|
|
160
|
+
}
|
|
161
|
+
writeJson(res, 200, { ok: true, report: await diagnosticsReport() })
|
|
162
|
+
}),
|
|
163
|
+
}), 'dsh-subscriptions: /diagnostics')
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
// ponytail: cheap per-vendor probe; never sets cooldown, never returns tokens
|
|
167
|
+
ctx.effect(() => ctx.webServer.register({
|
|
168
|
+
kind: 'exact',
|
|
169
|
+
path: '/dsh-subscriptions/check',
|
|
170
|
+
handler: safeJsonHandler(async (req, res) => {
|
|
171
|
+
if (req.method !== 'POST') {
|
|
172
|
+
writeJson(res, 405, { ok: false, error: { code: 'method', message: 'POST only' } })
|
|
173
|
+
return
|
|
174
|
+
}
|
|
175
|
+
if (!isTrustedSettingsRequest(req)) {
|
|
176
|
+
writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'same-origin only' } })
|
|
177
|
+
return
|
|
178
|
+
}
|
|
179
|
+
let payload
|
|
180
|
+
try { payload = JSON.parse((await readBody(req, 8 * 1024)).toString('utf8') || '{}') } catch {
|
|
181
|
+
writeJson(res, 400, { ok: false, error: { code: 'json', message: 'invalid json' } })
|
|
182
|
+
return
|
|
183
|
+
}
|
|
184
|
+
const provider = payload.provider
|
|
185
|
+
if (!isProvider(provider)) {
|
|
186
|
+
writeJson(res, 200, { ok: false, provider, error: { code: 'provider', message: 'unknown provider' } })
|
|
187
|
+
return
|
|
188
|
+
}
|
|
189
|
+
const ref = oauthRef(provider, payload.index)
|
|
190
|
+
const info = await store.describeRef(ref)
|
|
191
|
+
if (!info.configured) {
|
|
192
|
+
writeJson(res, 200, { ok: false, provider, index: payload.index, ref, error: { code: 'not_connected', message: 'not connected' } })
|
|
193
|
+
return
|
|
194
|
+
}
|
|
195
|
+
let blob
|
|
196
|
+
try { blob = await store.loadBlob(ref) } catch (e) {
|
|
197
|
+
writeJson(res, 200, { ok: false, provider, index: payload.index, ref, error: { code: 'auth', message: String(e && e.message || e) } })
|
|
198
|
+
return
|
|
199
|
+
}
|
|
200
|
+
try { blob = await store.ensureFresh(provider, blob, ref) } catch (e) {
|
|
201
|
+
writeJson(res, 200, {
|
|
202
|
+
ok: false, provider, index: payload.index, ref,
|
|
203
|
+
email: pmE(blob.email), label: pmL(blob.label), expiresAt: blob.expiresAt || null,
|
|
204
|
+
quota: info.quota || null,
|
|
205
|
+
error: { code: 'refresh', message: String(e && e.message || e) },
|
|
206
|
+
})
|
|
207
|
+
return
|
|
208
|
+
}
|
|
209
|
+
const vendor = getVendor(provider)
|
|
210
|
+
if (typeof vendor.check !== 'function') {
|
|
211
|
+
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 })
|
|
212
|
+
return
|
|
213
|
+
}
|
|
214
|
+
let capturedQuota = null
|
|
215
|
+
const probeFetch = async (url, init) => {
|
|
216
|
+
const res2 = await fetch(url, init)
|
|
217
|
+
try {
|
|
218
|
+
const snap = quotaSnapshot(provider, res2.headers, null, Date.now())
|
|
219
|
+
if (snap) { capturedQuota = snap; store.rememberQuota(ref, snap) }
|
|
220
|
+
} catch {}
|
|
221
|
+
return res2
|
|
222
|
+
}
|
|
223
|
+
try {
|
|
224
|
+
await vendor.check(blob, vendorConfig(provider, live()), probeFetch)
|
|
225
|
+
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 })
|
|
226
|
+
} catch (e) {
|
|
227
|
+
writeJson(res, 200, {
|
|
228
|
+
ok: false, provider, index: payload.index, ref,
|
|
229
|
+
email: pmE(blob.email), label: pmL(blob.label), expiresAt: blob.expiresAt || null,
|
|
230
|
+
quota: capturedQuota || info.quota || null,
|
|
231
|
+
error: { code: e && e.code ? e.code : 'VENDOR', message: String(e && e.message || e).slice(0, 300) },
|
|
232
|
+
})
|
|
233
|
+
}
|
|
234
|
+
}),
|
|
235
|
+
}), 'dsh-subscriptions: /check')
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
// #50: summary page /subscriptions (localhost-only).
|
|
239
|
+
// #65: request and cost history (JSON).
|
|
240
|
+
ctx.effect(() => ctx.webServer.register({
|
|
241
|
+
kind: 'exact',
|
|
242
|
+
path: '/dsh-subscriptions/history',
|
|
243
|
+
handler: async (req, res) => {
|
|
244
|
+
if (req.method !== 'GET') {
|
|
245
|
+
writeJson(res, 405, { ok: false, error: { code: 'method', message: 'GET only' } })
|
|
246
|
+
return
|
|
247
|
+
}
|
|
248
|
+
const host = (req.headers.host || '').split(':')[0]
|
|
249
|
+
if (host !== 'localhost' && host !== '127.0.0.1') {
|
|
250
|
+
writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'localhost only' } })
|
|
251
|
+
return
|
|
252
|
+
}
|
|
253
|
+
const limit = Math.min(Number(queryOf(req).get('limit') || '10'), 100)
|
|
254
|
+
writeJson(res, 200, { ok: true, total: history.size(), items: history.recent(limit) })
|
|
255
|
+
},
|
|
256
|
+
}), 'dsh-subscriptions: /history')
|
|
257
|
+
}
|