@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
package/lib/index.js
CHANGED
|
@@ -61,8 +61,8 @@ export function apply(ctx, config) {
|
|
|
61
61
|
})
|
|
62
62
|
})
|
|
63
63
|
|
|
64
|
-
//
|
|
65
|
-
//
|
|
64
|
+
// Registering custom vendors from Config. Runs at startup and on
|
|
65
|
+
// every config change (replace) - the registry is rebuilt from scratch.
|
|
66
66
|
function syncCustomVendors() {
|
|
67
67
|
let profiles
|
|
68
68
|
try {
|
|
@@ -104,7 +104,7 @@ export function apply(ctx, config) {
|
|
|
104
104
|
onLimitNotice: (provider, ref, win, threshold) => {
|
|
105
105
|
if (!live().notifyLimits) return
|
|
106
106
|
const label = win.ru || win.en || win.id
|
|
107
|
-
try { ctx.log && ctx.log.warn && ctx.log.warn(`[dsh-subscriptions] ${provider} ${ref}:
|
|
107
|
+
try { ctx.log && ctx.log.warn && ctx.log.warn(`[dsh-subscriptions] ${provider} ${ref}: limit ${label} at ${threshold}%`) } catch {}
|
|
108
108
|
try { ctx.emit && ctx.emit('subscriptions.limit-notice', { provider, ref, window: win.id, usedPercent: win.usedPercent, threshold }) } catch {}
|
|
109
109
|
},
|
|
110
110
|
})
|
|
@@ -141,7 +141,7 @@ export function apply(ctx, config) {
|
|
|
141
141
|
if (!cfg.ollamaFallback || !models.length) throw err
|
|
142
142
|
const model = cfg.ollamaFallbackModel || models[0].id
|
|
143
143
|
try { ctx.emit && ctx.emit('subscriptions.ollama-fallback', { provider, model, reason: err && err.code || 'EXHAUSTED' }) } catch {}
|
|
144
|
-
try { ctx.log && ctx.log.warn && ctx.log.warn(`[dsh-subscriptions] ${provider}:
|
|
144
|
+
try { ctx.log && ctx.log.warn && ctx.log.warn(`[dsh-subscriptions] ${provider}: all accounts exhausted (${err && err.code || 'EXHAUSTED'}), откат на ollama/${model}`) } catch {}
|
|
145
145
|
try {
|
|
146
146
|
recordHistory({
|
|
147
147
|
provider: 'ollama',
|
|
@@ -181,15 +181,15 @@ export function apply(ctx, config) {
|
|
|
181
181
|
hideDeprecatedModels: () => !!live().hideDeprecatedModels,
|
|
182
182
|
})
|
|
183
183
|
|
|
184
|
-
//
|
|
184
|
+
// Subscription-backed image generation service.
|
|
185
185
|
//
|
|
186
|
-
//
|
|
187
|
-
//
|
|
188
|
-
//
|
|
189
|
-
//
|
|
186
|
+
// An action is exposed, not a token: a network route would hand a live
|
|
187
|
+
// access key to anyone reaching the harness, while the service lives inside
|
|
188
|
+
// the process and is visible only to sibling plugins. Token refresh stays
|
|
189
|
+
// with a single owner - this plugin.
|
|
190
190
|
ctx.effect(() => ctx.provide('subscriptions', subscriptions), 'dsh-subscriptions: subscriptions service')
|
|
191
191
|
ctx.effect(() => ctx.provide('subscriptionImages', {
|
|
192
|
-
/**
|
|
192
|
+
/** Providers with an active session right now. */
|
|
193
193
|
async available() {
|
|
194
194
|
const logged = await store.loggedInProviders()
|
|
195
195
|
return ['codex', 'grok'].filter((name) => logged && logged[name])
|
|
@@ -202,13 +202,13 @@ export function apply(ctx, config) {
|
|
|
202
202
|
async generate(request) {
|
|
203
203
|
const provider = request && request.provider
|
|
204
204
|
if (provider !== 'codex' && provider !== 'grok') {
|
|
205
|
-
throw new Error(
|
|
205
|
+
throw new Error(`unknown subscription provider: ${provider}`)
|
|
206
206
|
}
|
|
207
207
|
const accounts = await store.listAccounts(provider)
|
|
208
208
|
const slot = (accounts || []).find((row) => row && row.ref)
|
|
209
|
-
if (!slot) throw new Error(
|
|
209
|
+
if (!slot) throw new Error(`not logged in to ${provider}: sign in in the Subscriptions section`)
|
|
210
210
|
const raw = await store.resolveRaw(slot.ref)
|
|
211
|
-
if (!raw) throw new Error(
|
|
211
|
+
if (!raw) throw new Error(`not logged in to ${provider}: sign in in the Subscriptions section`)
|
|
212
212
|
const session = await store.ensureFresh(provider, parseBlob(raw), slot.ref)
|
|
213
213
|
return generateOnce({
|
|
214
214
|
provider,
|
|
@@ -220,7 +220,7 @@ export function apply(ctx, config) {
|
|
|
220
220
|
signal: request.signal,
|
|
221
221
|
})
|
|
222
222
|
},
|
|
223
|
-
}), 'dsh-subscriptions:
|
|
223
|
+
}), 'dsh-subscriptions: image generation service')
|
|
224
224
|
const pending = new Map()
|
|
225
225
|
const adapter = new SubscriptionAdapter({
|
|
226
226
|
listAccounts: (provider) => store.listAccounts(provider),
|
|
@@ -466,8 +466,8 @@ export function apply(ctx, config) {
|
|
|
466
466
|
ctx.effect(() => {
|
|
467
467
|
syncAdapter().catch(() => { /* first paint */ })
|
|
468
468
|
syncOllama().catch(() => { /* first paint */ })
|
|
469
|
-
// #75: eager refresh
|
|
470
|
-
//
|
|
469
|
+
// #75: eager usage refresh at startup so the windows (5h/7d) land in the blob immediately
|
|
470
|
+
// and the active subscription chip shows 5h/7d/... right away instead of waiting for probeInterval.
|
|
471
471
|
const eager = async () => {
|
|
472
472
|
try {
|
|
473
473
|
for (const slot of normalizeSlots(live().slots)) {
|
|
@@ -516,9 +516,9 @@ export function apply(ctx, config) {
|
|
|
516
516
|
return () => clear(id)
|
|
517
517
|
}, 'dsh-subscriptions: refresh ahead')
|
|
518
518
|
|
|
519
|
-
//
|
|
520
|
-
//
|
|
521
|
-
//
|
|
519
|
+
// Background health-check: every N minutes runs a cheap check across all
|
|
520
|
+
// connected accounts. Dead ones are flagged via describeRef, no cooldown
|
|
521
|
+
// is set (same as /check).
|
|
522
522
|
ctx.effect(() => {
|
|
523
523
|
const tick = async () => {
|
|
524
524
|
const cfg = live()
|
package/lib/loopback.js
CHANGED
|
@@ -1,21 +1,21 @@
|
|
|
1
1
|
import http from 'node:http'
|
|
2
2
|
|
|
3
|
-
// #89:
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
// code/state,
|
|
3
|
+
// #89: temporary loopback server to catch the OAuth callback.
|
|
4
|
+
// Listens on the redirect_uri registered with the provider (vendor-fixed
|
|
5
|
+
// port: codex localhost:1455, grok 127.0.0.1:56121). Accepts a GET with
|
|
6
|
+
// code/state, serves an HTML page and shuts down after the first request.
|
|
7
7
|
|
|
8
8
|
const OK_HTML = '<!doctype html><meta charset="utf-8"><title>Subscriptions</title><p>Signed in. You can close this tab and return to Settings.</p>'
|
|
9
9
|
const ERR_HTML = '<!doctype html><meta charset="utf-8"><title>Subscriptions</title><p>Login failed. Return to Settings and paste the redirected URL.</p>'
|
|
10
10
|
|
|
11
11
|
/**
|
|
12
|
-
*
|
|
12
|
+
* Start the loopback server and wait for the callback.
|
|
13
13
|
* @param {object} opts
|
|
14
|
-
* @param {string} opts.redirectUri
|
|
15
|
-
* @param {number} opts.timeoutMs
|
|
14
|
+
* @param {string} opts.redirectUri registered redirect_uri (localhost/127.0.0.1)
|
|
15
|
+
* @param {number} opts.timeoutMs server lifetime
|
|
16
16
|
* @param {(query: URLSearchParams) => Promise<string>} opts.onCode
|
|
17
|
-
*
|
|
18
|
-
*
|
|
17
|
+
* called with the callback request query; returns HTML for the browser.
|
|
18
|
+
* Throwing means an authorization error (ERR_HTML is served).
|
|
19
19
|
* @returns {Promise<{url: string, close: () => void}>}
|
|
20
20
|
*/
|
|
21
21
|
export function startLoopback({ redirectUri, timeoutMs = 10 * 60 * 1000, onCode }) {
|
|
@@ -30,7 +30,7 @@ export function startLoopback({ redirectUri, timeoutMs = 10 * 60 * 1000, onCode
|
|
|
30
30
|
const promise = new Promise((resolve, reject) => {
|
|
31
31
|
const server = http.createServer((req, res) => {
|
|
32
32
|
const url = new URL(req.url || '/', `http://127.0.0.1:${port}`)
|
|
33
|
-
//
|
|
33
|
+
// The provider may redirect to a suffixed path (e.g. /auth/callback/extra)
|
|
34
34
|
if (!url.pathname.startsWith(path)) {
|
|
35
35
|
res.writeHead(404, { 'Content-Type': 'text/html; charset=utf-8' })
|
|
36
36
|
res.end(ERR_HTML)
|
package/lib/ratelimit.js
CHANGED
|
@@ -212,7 +212,7 @@ export function formatQuota(q) {
|
|
|
212
212
|
if (!q) return ""
|
|
213
213
|
const parts = []
|
|
214
214
|
if (q.remaining!=null && q.limit!=null) parts.push(q.remaining+"/"+q.limit)
|
|
215
|
-
else if (q.remaining!=null) parts.push("
|
|
216
|
-
if (q.resetAt) parts.push("
|
|
215
|
+
else if (q.remaining!=null) parts.push("left "+q.remaining)
|
|
216
|
+
if (q.resetAt) parts.push("reset "+new Date(q.resetAt).toLocaleString())
|
|
217
217
|
return parts.join(" · ")
|
|
218
218
|
}
|
package/lib/refs.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
//
|
|
1
|
+
// Built-in providers + dynamic ones from Config.customVendors.
|
|
2
2
|
export const BUILTIN_PROVIDERS = Object.freeze([
|
|
3
3
|
'codex',
|
|
4
4
|
'claude',
|
|
@@ -8,6 +8,14 @@ export const BUILTIN_PROVIDERS = Object.freeze([
|
|
|
8
8
|
'glm',
|
|
9
9
|
'cursor',
|
|
10
10
|
'kiro',
|
|
11
|
+
'copilot',
|
|
12
|
+
'qwen',
|
|
13
|
+
'ernie',
|
|
14
|
+
'spark',
|
|
15
|
+
'jetbrains',
|
|
16
|
+
'perplexity',
|
|
17
|
+
'replit',
|
|
18
|
+
'cody',
|
|
11
19
|
])
|
|
12
20
|
|
|
13
21
|
const dynamicIds = new Set()
|
|
@@ -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
|
+
}
|