@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/README.md +12 -1
- package/README.ru.md +11 -0
- package/README.zh.md +6 -0
- package/lib/accounts.js +1 -1
- package/lib/adapter.js +2 -2
- package/lib/client.js +220 -189
- 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 +5 -5
- package/lib/ui-i18n.js +0 -256
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
import { normalizeSlots } from '../accounts.js'
|
|
2
|
+
import { Config, publicConfig } from '../config-schema.js'
|
|
3
|
+
import { decryptWithPassphrase, encryptWithPassphrase } from '../crypto.js'
|
|
4
|
+
import { isTrustedSettingsRequest, queryOf, readBody, safeJsonHandler, writeJson } from '../http.js'
|
|
5
|
+
import { discoverLocalCliSessions, loadLocalCliBlob } from '../import-auth.js'
|
|
6
|
+
import { isProvider, oauthRef } from '../refs.js'
|
|
7
|
+
|
|
8
|
+
export function registerAccountsRoutes(ctx, state) {
|
|
9
|
+
const {
|
|
10
|
+
live,
|
|
11
|
+
accountsView,
|
|
12
|
+
getSettingsApi,
|
|
13
|
+
syncCustomVendors,
|
|
14
|
+
syncAdapter,
|
|
15
|
+
stripLegacySlots,
|
|
16
|
+
store,
|
|
17
|
+
refreshModels,
|
|
18
|
+
refForSlot,
|
|
19
|
+
resetCredits,
|
|
20
|
+
subscriptions,
|
|
21
|
+
} = state
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
ctx.effect(() => ctx.webServer.register({
|
|
25
|
+
kind: 'exact',
|
|
26
|
+
path: '/dsh-subscriptions/reset-credits',
|
|
27
|
+
handler: safeJsonHandler(async (req, res) => {
|
|
28
|
+
if (req.method !== 'GET') {
|
|
29
|
+
writeJson(res, 405, { ok: false, error: { code: 'method', message: 'GET only' } })
|
|
30
|
+
return
|
|
31
|
+
}
|
|
32
|
+
const q = queryOf(req)
|
|
33
|
+
const provider = q.get('provider') || ''
|
|
34
|
+
const index = Number(q.get('index') || '1')
|
|
35
|
+
const ref = refForSlot(provider, index)
|
|
36
|
+
if (!ref) { writeJson(res, 404, { ok: false, error: { code: 'slot', message: 'slot not found' } }); return }
|
|
37
|
+
try {
|
|
38
|
+
writeJson(res, 200, { ok: true, ...(await resetCredits.inspect(ref)) })
|
|
39
|
+
} catch (e) {
|
|
40
|
+
writeJson(res, 200, { ok: false, error: { code: 'reset', message: String(e && e.message || e) } })
|
|
41
|
+
}
|
|
42
|
+
}),
|
|
43
|
+
}), 'dsh-subscriptions: /reset-credits')
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
ctx.effect(() => ctx.webServer.register({
|
|
47
|
+
kind: 'exact',
|
|
48
|
+
path: '/dsh-subscriptions/reset-credits/prepare',
|
|
49
|
+
handler: safeJsonHandler(async (req, res) => {
|
|
50
|
+
if (req.method !== 'POST') {
|
|
51
|
+
writeJson(res, 405, { ok: false, error: { code: 'method', message: 'POST only' } })
|
|
52
|
+
return
|
|
53
|
+
}
|
|
54
|
+
if (!isTrustedSettingsRequest(req)) {
|
|
55
|
+
writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'same-origin only' } })
|
|
56
|
+
return
|
|
57
|
+
}
|
|
58
|
+
let payload
|
|
59
|
+
try { payload = JSON.parse((await readBody(req, 4096)).toString('utf8') || '{}') } catch {
|
|
60
|
+
writeJson(res, 400, { ok: false, error: { code: 'json', message: 'invalid json' } })
|
|
61
|
+
return
|
|
62
|
+
}
|
|
63
|
+
const ref = refForSlot(String(payload.provider || ''), Number(payload.index) || 1)
|
|
64
|
+
if (!ref) { writeJson(res, 404, { ok: false, error: { code: 'slot', message: 'slot not found' } }); return }
|
|
65
|
+
try {
|
|
66
|
+
writeJson(res, 200, { ok: true, ...(await resetCredits.prepare(ref)) })
|
|
67
|
+
} catch (e) {
|
|
68
|
+
writeJson(res, 200, { ok: false, error: { code: 'reset', message: String(e && e.message || e) } })
|
|
69
|
+
}
|
|
70
|
+
}),
|
|
71
|
+
}), 'dsh-subscriptions: /reset-credits/prepare')
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
ctx.effect(() => ctx.webServer.register({
|
|
75
|
+
kind: 'exact',
|
|
76
|
+
path: '/dsh-subscriptions/reset-credits/consume',
|
|
77
|
+
handler: safeJsonHandler(async (req, res) => {
|
|
78
|
+
if (req.method !== 'POST') {
|
|
79
|
+
writeJson(res, 405, { ok: false, error: { code: 'method', message: 'POST only' } })
|
|
80
|
+
return
|
|
81
|
+
}
|
|
82
|
+
if (!isTrustedSettingsRequest(req)) {
|
|
83
|
+
writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'same-origin only' } })
|
|
84
|
+
return
|
|
85
|
+
}
|
|
86
|
+
let payload
|
|
87
|
+
try { payload = JSON.parse((await readBody(req, 4096)).toString('utf8') || '{}') } catch {
|
|
88
|
+
writeJson(res, 400, { ok: false, error: { code: 'json', message: 'invalid json' } })
|
|
89
|
+
return
|
|
90
|
+
}
|
|
91
|
+
try {
|
|
92
|
+
const result = await resetCredits.consume({ challengeId: payload.challengeId, acknowledged: payload.acknowledged })
|
|
93
|
+
writeJson(res, 200, { ok: true, result })
|
|
94
|
+
refreshModels().catch(() => {})
|
|
95
|
+
} catch (e) {
|
|
96
|
+
writeJson(res, 200, { ok: false, error: { code: 'reset', message: String(e && e.message || e) } })
|
|
97
|
+
}
|
|
98
|
+
}),
|
|
99
|
+
}), 'dsh-subscriptions: /reset-credits/consume')
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
ctx.effect(() => ctx.webServer.register({
|
|
103
|
+
kind: 'exact',
|
|
104
|
+
path: '/dsh-subscriptions/discover-local',
|
|
105
|
+
handler: safeJsonHandler(async (req, res) => {
|
|
106
|
+
if (req.method !== 'GET') {
|
|
107
|
+
writeJson(res, 405, { ok: false, error: { code: 'method', message: 'GET only' } })
|
|
108
|
+
return
|
|
109
|
+
}
|
|
110
|
+
try {
|
|
111
|
+
const detected = await discoverLocalCliSessions()
|
|
112
|
+
writeJson(res, 200, { ok: true, detected })
|
|
113
|
+
} catch (e) {
|
|
114
|
+
writeJson(res, 500, { ok: false, error: { message: String(e && e.message || e) } })
|
|
115
|
+
}
|
|
116
|
+
}),
|
|
117
|
+
}), 'dsh-subscriptions: /discover-local')
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
ctx.effect(() => ctx.webServer.register({
|
|
121
|
+
kind: 'exact',
|
|
122
|
+
path: '/dsh-subscriptions/import-local',
|
|
123
|
+
handler: safeJsonHandler(async (req, res) => {
|
|
124
|
+
if (req.method !== 'POST') {
|
|
125
|
+
writeJson(res, 405, { ok: false, error: { code: 'method', message: 'POST only' } })
|
|
126
|
+
return
|
|
127
|
+
}
|
|
128
|
+
if (!isTrustedSettingsRequest(req)) {
|
|
129
|
+
writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'same-origin only' } })
|
|
130
|
+
return
|
|
131
|
+
}
|
|
132
|
+
let body
|
|
133
|
+
try {
|
|
134
|
+
body = JSON.parse((await readBody(req, 16 * 1024)).toString('utf8') || '{}')
|
|
135
|
+
} catch {
|
|
136
|
+
body = null
|
|
137
|
+
}
|
|
138
|
+
if (!body || !body.provider) {
|
|
139
|
+
writeJson(res, 400, { ok: false, error: { code: 'bad_request', message: 'missing provider' } })
|
|
140
|
+
return
|
|
141
|
+
}
|
|
142
|
+
try {
|
|
143
|
+
const blob = await loadLocalCliBlob(body.provider)
|
|
144
|
+
const prov = body.provider
|
|
145
|
+
const idx = Number(body.index) || 1
|
|
146
|
+
const curSlots = Array.isArray(live().slots) ? live().slots.slice() : []
|
|
147
|
+
const exists = curSlots.some((s) => s && s.provider === prov && Number(s.index) === idx)
|
|
148
|
+
if (!exists && getSettingsApi()) {
|
|
149
|
+
const nextSlots = curSlots.concat([{
|
|
150
|
+
provider: prov,
|
|
151
|
+
index: idx,
|
|
152
|
+
label: blob.email || '',
|
|
153
|
+
}])
|
|
154
|
+
const parsed = Config({ ...live(), slots: stripLegacySlots(nextSlots) })
|
|
155
|
+
await getSettingsApi().replace(parsed)
|
|
156
|
+
syncCustomVendors()
|
|
157
|
+
}
|
|
158
|
+
const slot = normalizeSlots(live().slots).find((s) => s.provider === prov && s.index === idx)
|
|
159
|
+
const ref = slot ? slot.ref : `${prov.toUpperCase()}_OAUTH_${idx}`
|
|
160
|
+
await store.saveBlob(ref, blob)
|
|
161
|
+
await syncAdapter()
|
|
162
|
+
refreshModels().catch(() => {})
|
|
163
|
+
writeJson(res, 200, {
|
|
164
|
+
ok: true,
|
|
165
|
+
ref,
|
|
166
|
+
provider: prov,
|
|
167
|
+
email: blob.email || '',
|
|
168
|
+
accounts: await accountsView(),
|
|
169
|
+
config: publicConfig(live()),
|
|
170
|
+
})
|
|
171
|
+
} catch (e) {
|
|
172
|
+
writeJson(res, 400, { ok: false, error: { message: String(e && e.message || e) } })
|
|
173
|
+
}
|
|
174
|
+
}),
|
|
175
|
+
}), 'dsh-subscriptions: /import-local')
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
// Export of the encrypted token bundle. Tokens are never logged.
|
|
179
|
+
ctx.effect(() => ctx.webServer.register({
|
|
180
|
+
kind: 'exact',
|
|
181
|
+
path: '/dsh-subscriptions/export',
|
|
182
|
+
handler: safeJsonHandler(async (req, res) => {
|
|
183
|
+
if (req.method !== 'POST') {
|
|
184
|
+
writeJson(res, 405, { ok: false, error: { code: 'method', message: 'POST only' } })
|
|
185
|
+
return
|
|
186
|
+
}
|
|
187
|
+
if (!isTrustedSettingsRequest(req)) {
|
|
188
|
+
writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'same-origin only' } })
|
|
189
|
+
return
|
|
190
|
+
}
|
|
191
|
+
let payload
|
|
192
|
+
try { payload = JSON.parse((await readBody(req, 8 * 1024)).toString('utf8') || '{}') } catch {
|
|
193
|
+
writeJson(res, 400, { ok: false, error: { code: 'json', message: 'invalid json' } })
|
|
194
|
+
return
|
|
195
|
+
}
|
|
196
|
+
const passphrase = payload.passphrase
|
|
197
|
+
if (!passphrase || typeof passphrase !== 'string') {
|
|
198
|
+
writeJson(res, 400, { ok: false, error: { code: 'passphrase', message: 'passphrase is required' } })
|
|
199
|
+
return
|
|
200
|
+
}
|
|
201
|
+
try {
|
|
202
|
+
const accounts = []
|
|
203
|
+
for (const slot of normalizeSlots(live().slots)) {
|
|
204
|
+
try {
|
|
205
|
+
const blob = await store.loadBlob(slot.ref)
|
|
206
|
+
accounts.push({ ref: slot.ref, provider: slot.provider, index: slot.index, label: slot.label || blob.label || '', blob })
|
|
207
|
+
} catch { /* skip missing */ }
|
|
208
|
+
}
|
|
209
|
+
if (!accounts.length) {
|
|
210
|
+
writeJson(res, 200, { ok: false, error: { code: 'empty', message: 'no connected accounts' } })
|
|
211
|
+
return
|
|
212
|
+
}
|
|
213
|
+
const bundle = JSON.stringify({ v: 1, exportedAt: Date.now(), accounts })
|
|
214
|
+
const encrypted = encryptWithPassphrase(bundle, passphrase)
|
|
215
|
+
writeJson(res, 200, { ok: true, payload: encrypted, count: accounts.length })
|
|
216
|
+
} catch (e) {
|
|
217
|
+
writeJson(res, 500, { ok: false, error: { code: 'export', message: String(e && e.message || e) } })
|
|
218
|
+
}
|
|
219
|
+
}),
|
|
220
|
+
}), 'dsh-subscriptions: /export')
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
// Import of the encrypted bundle.
|
|
224
|
+
ctx.effect(() => ctx.webServer.register({
|
|
225
|
+
kind: 'exact',
|
|
226
|
+
path: '/dsh-subscriptions/import',
|
|
227
|
+
handler: safeJsonHandler(async (req, res) => {
|
|
228
|
+
if (req.method !== 'POST') {
|
|
229
|
+
writeJson(res, 405, { ok: false, error: { code: 'method', message: 'POST only' } })
|
|
230
|
+
return
|
|
231
|
+
}
|
|
232
|
+
if (!isTrustedSettingsRequest(req)) {
|
|
233
|
+
writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'same-origin only' } })
|
|
234
|
+
return
|
|
235
|
+
}
|
|
236
|
+
let payload
|
|
237
|
+
try { payload = JSON.parse((await readBody(req, 1024 * 1024)).toString('utf8') || '{}') } catch {
|
|
238
|
+
writeJson(res, 400, { ok: false, error: { code: 'json', message: 'invalid json' } })
|
|
239
|
+
return
|
|
240
|
+
}
|
|
241
|
+
const { passphrase, payload: encrypted } = payload
|
|
242
|
+
if (!passphrase || !encrypted) {
|
|
243
|
+
writeJson(res, 400, { ok: false, error: { code: 'params', message: 'passphrase and payload are required' } })
|
|
244
|
+
return
|
|
245
|
+
}
|
|
246
|
+
let bundle
|
|
247
|
+
try {
|
|
248
|
+
bundle = JSON.parse(decryptWithPassphrase(encrypted, passphrase))
|
|
249
|
+
} catch (e) {
|
|
250
|
+
writeJson(res, 400, { ok: false, error: { code: 'decrypt', message: 'wrong passphrase or corrupted bundle' } })
|
|
251
|
+
return
|
|
252
|
+
}
|
|
253
|
+
if (!bundle || !Array.isArray(bundle.accounts)) {
|
|
254
|
+
writeJson(res, 400, { ok: false, error: { code: 'format', message: 'invalid bundle format' } })
|
|
255
|
+
return
|
|
256
|
+
}
|
|
257
|
+
let imported = 0
|
|
258
|
+
for (const row of bundle.accounts) {
|
|
259
|
+
try {
|
|
260
|
+
await store.saveBlob(row.ref, row.blob)
|
|
261
|
+
imported++
|
|
262
|
+
} catch { /* skip broken */ }
|
|
263
|
+
}
|
|
264
|
+
await syncAdapter()
|
|
265
|
+
writeJson(res, 200, { ok: true, imported, total: bundle.accounts.length, accounts: await accountsView() })
|
|
266
|
+
}),
|
|
267
|
+
}), 'dsh-subscriptions: /import')
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
// #45: import an existing refresh token / API key without an OAuth flow.
|
|
271
|
+
ctx.effect(() => ctx.webServer.register({
|
|
272
|
+
kind: 'exact',
|
|
273
|
+
path: '/dsh-subscriptions/import-token',
|
|
274
|
+
handler: safeJsonHandler(async (req, res) => {
|
|
275
|
+
if (req.method !== 'POST') {
|
|
276
|
+
writeJson(res, 405, { ok: false, error: { code: 'method', message: 'POST only' } })
|
|
277
|
+
return
|
|
278
|
+
}
|
|
279
|
+
if (!isTrustedSettingsRequest(req)) {
|
|
280
|
+
writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'same-origin only' } })
|
|
281
|
+
return
|
|
282
|
+
}
|
|
283
|
+
let payload
|
|
284
|
+
try { payload = JSON.parse((await readBody(req, 64 * 1024)).toString('utf8') || '{}') } catch {
|
|
285
|
+
writeJson(res, 400, { ok: false, error: { code: 'json', message: 'invalid json' } })
|
|
286
|
+
return
|
|
287
|
+
}
|
|
288
|
+
const provider = payload.provider
|
|
289
|
+
const index = Number(payload.index || '1')
|
|
290
|
+
const refreshToken = payload.refreshToken
|
|
291
|
+
const apiKey = payload.apiKey
|
|
292
|
+
if (!isProvider(provider)) {
|
|
293
|
+
writeJson(res, 400, { ok: false, error: { code: 'provider', message: 'unknown provider' } })
|
|
294
|
+
return
|
|
295
|
+
}
|
|
296
|
+
if (!refreshToken && !apiKey) {
|
|
297
|
+
writeJson(res, 400, { ok: false, error: { code: 'token', message: 'refreshToken or apiKey is required' } })
|
|
298
|
+
return
|
|
299
|
+
}
|
|
300
|
+
try {
|
|
301
|
+
const ref = oauthRef(provider, index)
|
|
302
|
+
const blob = { accessToken: apiKey || refreshToken, refreshToken: refreshToken || apiKey }
|
|
303
|
+
if (apiKey) { blob.apiKey = apiKey; blob.apiKeyOnly = true }
|
|
304
|
+
await store.saveBlob(ref, blob)
|
|
305
|
+
await syncAdapter()
|
|
306
|
+
refreshModels().catch(() => {})
|
|
307
|
+
writeJson(res, 200, { ok: true, ref, accounts: await accountsView() })
|
|
308
|
+
} catch (e) {
|
|
309
|
+
writeJson(res, 400, { ok: false, error: { code: 'import', message: String(e && e.message || e) } })
|
|
310
|
+
}
|
|
311
|
+
}),
|
|
312
|
+
}), 'dsh-subscriptions: /import-token')
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
ctx.effect(() => ctx.webServer.register({
|
|
316
|
+
kind: 'exact',
|
|
317
|
+
path: '/dsh-subscriptions/logout',
|
|
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 payload
|
|
328
|
+
try { payload = JSON.parse((await readBody(req, 8 * 1024)).toString('utf8') || '{}') } catch {
|
|
329
|
+
writeJson(res, 400, { ok: false, error: { code: 'json', message: 'invalid json' } })
|
|
330
|
+
return
|
|
331
|
+
}
|
|
332
|
+
try {
|
|
333
|
+
const ref = oauthRef(payload.provider, payload.index)
|
|
334
|
+
await store.clearRef(ref)
|
|
335
|
+
await syncAdapter()
|
|
336
|
+
refreshModels().catch(() => {})
|
|
337
|
+
writeJson(res, 200, { ok: true, ref, accounts: await accountsView() })
|
|
338
|
+
} catch (e) {
|
|
339
|
+
writeJson(res, 400, { ok: false, error: { code: 'logout', message: String(e && e.message || e) } })
|
|
340
|
+
}
|
|
341
|
+
}),
|
|
342
|
+
}), 'dsh-subscriptions: /logout')
|
|
343
|
+
}
|
|
@@ -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
|
+
}
|