@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/lib/index.js CHANGED
@@ -61,8 +61,8 @@ export function apply(ctx, config) {
61
61
  })
62
62
  })
63
63
 
64
- // Регистрация кастомных вендоров из Config. Вызывается при старте и при
65
- // каждой смене конфига (replace) реестр пересобирается с нуля.
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}: лимит ${label} на ${threshold}%`) } catch {}
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}: все аккаунты исчерпаны (${err && err.code || 'EXHAUSTED'}), откат на ollama/${model}`) } catch {}
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(`неизвестный провайдер подписки: ${provider}`)
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(`нет входа в ${provider}: войдите в разделе «Подписки»`)
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(`нет входа в ${provider}: войдите в разделе «Подписки»`)
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 usage на старте, чтобы windows (5h/7d) появились в blob сразу
470
- // и активная подписка в чипе сразу показывала 5h/7d/..., а не ждала probeInterval.
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
- // Фоновый health-check: раз в N минут прогоняет дешёвый check по всем
520
- // подключённым аккаунтам. Мёртвые помечаются через describeRef, cooldown
521
- // не ставится (как в /check).
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: временный loopback-сервер для перехвата OAuth callback.
4
- // Слушает на зарегистрированном у провайдера redirect_uri (порт фиксирован
5
- // вендором: codex localhost:1455, grok 127.0.0.1:56121). Принимает GET с
6
- // code/state, отдаёт HTML-страницу и завершается после первого запроса.
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
- * Поднять loopback-сервер и дождаться callback.
12
+ * Start the loopback server and wait for the callback.
13
13
  * @param {object} opts
14
- * @param {string} opts.redirectUri зарегистрированный redirect_uri (localhost/127.0.0.1)
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
- * вызывается с query callback-запроса; возвращает HTML для браузера.
18
- * Бросок исключения = ошибка авторизации (отдаётся ERR_HTML).
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
- // Провайдер может редиректить на путь с suffix (например /auth/callback/extra)
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("осталось "+q.remaining)
216
- if (q.resetAt) parts.push("сброс "+new Date(q.resetAt).toLocaleString())
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
- // Встроенные провайдеры + динамические из Config.customVendors.
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
+ }