@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.
@@ -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
+ }