@goodandready/dsh-subscriptions 0.2.11 → 0.3.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/accounts.js +41 -1
- package/lib/adapter.js +1 -0
- package/lib/client.js +95 -13
- package/lib/crypto.js +28 -0
- package/lib/index.js +209 -2
- package/lib/rotate.js +10 -2
- package/package.json +1 -1
package/lib/accounts.js
CHANGED
|
@@ -48,12 +48,14 @@ export function vendorConfig(provider, cfg) {
|
|
|
48
48
|
}
|
|
49
49
|
}
|
|
50
50
|
|
|
51
|
-
export function createAccountStore({ credentials, getConfig, fetchImpl }) {
|
|
51
|
+
export function createAccountStore({ credentials, getConfig, fetchImpl, onLimitNotice }) {
|
|
52
52
|
const cooldowns = new Map()
|
|
53
53
|
const quotas = new Map()
|
|
54
54
|
const refreshLocks = new Map()
|
|
55
55
|
const refreshFailures = new Map()
|
|
56
56
|
const usage = new Map()
|
|
57
|
+
const notifyThresholds = new Map()
|
|
58
|
+
const requestCounts = new Map()
|
|
57
59
|
const windows = new Map()
|
|
58
60
|
const usageFetched = new Map()
|
|
59
61
|
const doFetch = fetchImpl || fetch
|
|
@@ -85,6 +87,8 @@ export function createAccountStore({ credentials, getConfig, fetchImpl }) {
|
|
|
85
87
|
await credentials.unset(credentialRef(ref))
|
|
86
88
|
cooldowns.delete(ref)
|
|
87
89
|
quotas.delete(ref)
|
|
90
|
+
for (const k of [...notifyThresholds.keys()]) { if (k.startsWith(ref + ':')) notifyThresholds.delete(k) }
|
|
91
|
+
for (const k of [...notifyThresholds.keys()]) { if (k.startsWith(ref + ':')) notifyThresholds.delete(k) }
|
|
88
92
|
refreshFailures.delete(ref)
|
|
89
93
|
usage.delete(ref)
|
|
90
94
|
usageFetched.delete(ref)
|
|
@@ -201,6 +205,40 @@ export function createAccountStore({ credentials, getConfig, fetchImpl }) {
|
|
|
201
205
|
if (Number.isFinite(Number(snap.usedPercent))) {
|
|
202
206
|
usage.set(slot.ref, Number(snap.usedPercent))
|
|
203
207
|
}
|
|
208
|
+
// Notification thresholds: 70/90/100% — fire once per window until reset.
|
|
209
|
+
if (Array.isArray(snap.windows)) {
|
|
210
|
+
for (const win of snap.windows) {
|
|
211
|
+
if (!win || !Number.isFinite(Number(win.usedPercent))) continue
|
|
212
|
+
const pct = Number(win.usedPercent)
|
|
213
|
+
const key = slot.ref + ':' + win.id
|
|
214
|
+
const prev = notifyThresholds.get(key) || 0
|
|
215
|
+
const THRESHOLDS = [70, 90, 100]
|
|
216
|
+
for (const th of THRESHOLDS) {
|
|
217
|
+
if (pct >= th && prev < th) {
|
|
218
|
+
notifyThresholds.set(key, th)
|
|
219
|
+
try { onLimitNotice && onLimitNotice(slot.provider, slot.ref, win, th) } catch {}
|
|
220
|
+
break
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
// Notification thresholds: 70/90/100% — fire once per window until reset.
|
|
226
|
+
if (Array.isArray(snap.windows)) {
|
|
227
|
+
for (const win of snap.windows) {
|
|
228
|
+
if (!win || !Number.isFinite(Number(win.usedPercent))) continue
|
|
229
|
+
const pct = Number(win.usedPercent)
|
|
230
|
+
const key = slot.ref + ':' + win.id
|
|
231
|
+
const prev = notifyThresholds.get(key) || 0
|
|
232
|
+
const THRESHOLDS = [70, 90, 100]
|
|
233
|
+
for (const th of THRESHOLDS) {
|
|
234
|
+
if (pct >= th && prev < th) {
|
|
235
|
+
notifyThresholds.set(key, th)
|
|
236
|
+
try { onLimitNotice && onLimitNotice(slot.provider, slot.ref, win, th) } catch {}
|
|
237
|
+
break
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
}
|
|
204
242
|
const list = Array.isArray(snap.windows) ? snap.windows : null
|
|
205
243
|
if (list) {
|
|
206
244
|
windows.set(slot.ref, list)
|
|
@@ -235,5 +273,7 @@ export function createAccountStore({ credentials, getConfig, fetchImpl }) {
|
|
|
235
273
|
shouldSkipRefresh(ref, now, retryMs) { const f = refreshFailures.get(ref); if (!f) return false; return (Number(f.at) + Number(retryMs)) > Number(now) },
|
|
236
274
|
getQuota(ref) { return quotas.get(ref) || null },
|
|
237
275
|
rememberUsage(ref, percent) { usage.set(ref, percent) },
|
|
276
|
+
rememberRequest(ref) { requestCounts.set(ref, (requestCounts.get(ref) || 0) + 1) },
|
|
277
|
+
getRequestCount(ref) { return requestCounts.get(ref) || 0 },
|
|
238
278
|
}
|
|
239
279
|
}
|
package/lib/adapter.js
CHANGED
package/lib/client.js
CHANGED
|
@@ -37,7 +37,12 @@ window.__ModuleLoader__.load({
|
|
|
37
37
|
'.dsub-badge-on{color:var(--dsw-alias-state-success-primary);border-color:currentColor}' +
|
|
38
38
|
'.dsub-badge-warn{color:var(--dsw-alias-state-warning-primary);border-color:currentColor}' +
|
|
39
39
|
'.dsub-link{background:none;border:none;color:var(--dsw-alias-label-secondary);cursor:pointer;font-size:12px;padding:0}' +
|
|
40
|
-
'.dsub-
|
|
40
|
+
'.dsub-bar{position:relative;height:6px;border-radius:3px;background:var(--dsw-alias-bg-layer-1);overflow:hidden;flex:1;min-width:80px}' +
|
|
41
|
+
'.dsub-barFill{height:100%;border-radius:3px;background:var(--dsw-alias-state-success-primary);transition:width .2s}' +
|
|
42
|
+
'.dsub-barWarn .dsub-barFill{background:var(--dsw-alias-state-warning-primary)}' +
|
|
43
|
+
'.dsub-barFull .dsub-barFill{background:var(--dsw-alias-state-error-primary)}' +
|
|
44
|
+
'.dsub-barRow{display:flex;align-items:center;gap:8px;font-size:12px;color:var(--dsw-alias-label-secondary)}' +
|
|
45
|
+
'.dsub-verify{font-size:12px;color:var(--dsw-alias-state-warning-primary)}' + +
|
|
41
46
|
'.dsub-verify a{color:var(--dsw-alias-brand-primary)}'
|
|
42
47
|
|
|
43
48
|
const cssId = 'dsh-subscriptions/settings.module.css'
|
|
@@ -82,6 +87,9 @@ const cssId = 'dsh-subscriptions/settings.module.css'
|
|
|
82
87
|
'accountLabel': 'Account',
|
|
83
88
|
'plan': 'Plan',
|
|
84
89
|
'storedAs': 'Stored as',
|
|
90
|
+
'switchLabel': 'Provider',
|
|
91
|
+
'none': 'none',
|
|
92
|
+
'forecast': '≈',
|
|
85
93
|
}
|
|
86
94
|
const ru = {
|
|
87
95
|
'notConnected': 'Не подключено',
|
|
@@ -112,6 +120,9 @@ const cssId = 'dsh-subscriptions/settings.module.css'
|
|
|
112
120
|
'accountLabel': 'Аккаунт',
|
|
113
121
|
'plan': 'Тариф',
|
|
114
122
|
'storedAs': 'Хранится как',
|
|
123
|
+
'switchLabel': 'Провайдер',
|
|
124
|
+
'none': 'нет',
|
|
125
|
+
'forecast': '≈',
|
|
115
126
|
}
|
|
116
127
|
|
|
117
128
|
function badgeFor(account, t) {
|
|
@@ -358,25 +369,36 @@ function PluginCard(props) {
|
|
|
358
369
|
(function(){
|
|
359
370
|
var parts=[]
|
|
360
371
|
var wins=account.usage
|
|
372
|
+
function bar(label,pct){
|
|
373
|
+
var cls='dsub-bar'+(pct>=100?' dsub-barFull':(pct>=70?' dsub-barWarn':''))
|
|
374
|
+
return React.createElement('div',{className:'dsub-barRow',key:label},
|
|
375
|
+
React.createElement('span',null,label),
|
|
376
|
+
React.createElement('div',{className:cls},
|
|
377
|
+
React.createElement('div',{className:'dsub-barFill',style:{width:Math.min(100,Math.max(0,pct))+'%'}})
|
|
378
|
+
),
|
|
379
|
+
React.createElement('span',null,Math.round(pct)+'%'),
|
|
380
|
+
)
|
|
381
|
+
}
|
|
361
382
|
if(Array.isArray(wins)&&wins.length){
|
|
362
383
|
wins.forEach(function(w){
|
|
363
|
-
if(w&&w.usedPercent!=null)parts.push(
|
|
384
|
+
if(w&&w.usedPercent!=null)parts.push(bar((w.ru||w.id),w.usedPercent))
|
|
364
385
|
})
|
|
365
386
|
}
|
|
366
387
|
var q=account.quota
|
|
367
|
-
if(q)
|
|
368
|
-
|
|
369
|
-
if(q.remaining!=null&&q.limit!=null)qa.push(q.remaining+'/'+q.limit)
|
|
370
|
-
else if(q.remaining!=null)qa.push(String(q.remaining))
|
|
371
|
-
if(q.usedPercent!=null)qa.push(Math.round(q.usedPercent)+'%')
|
|
372
|
-
if(q.resetAt){var d=new Date(q.resetAt);qa.push(d.toLocaleTimeString())}
|
|
373
|
-
if(qa.length)parts.push(t('quota')+': '+qa.join(' / '))
|
|
374
|
-
}
|
|
375
|
-
if(!wins&&!q&&account.usagePercent!=null)parts.push(Math.round(account.usagePercent)+'/100%')
|
|
388
|
+
if(q&&q.usedPercent!=null)parts.push(bar(t('quota'),q.usedPercent))
|
|
389
|
+
if(!wins&&!q&&account.usagePercent!=null)parts.push(bar(t('quota'),account.usagePercent))
|
|
376
390
|
var at=(q&&q.measuredAt)||(account.usageAt||0)
|
|
377
|
-
if(at){var m=Math.round((Date.now()-at)/60000);if(m>=1)parts.push(m+'m')}
|
|
391
|
+
if(at){var m=Math.round((Date.now()-at)/60000);if(m>=1)parts.push(React.createElement('span',{className:'dsub-sub',key:'ago'},m+'m'))}
|
|
392
|
+
if(Array.isArray(wins)&&wins.length&&account.requests){
|
|
393
|
+
var w0=wins[0]
|
|
394
|
+
if(w0&&w0.usedPercent!=null&&w0.usedPercent<100){
|
|
395
|
+
var remaining=100-w0.usedPercent
|
|
396
|
+
var est=Math.floor(remaining/(w0.usedPercent/account.requests))
|
|
397
|
+
if(est>0)parts.push(React.createElement('span',{className:'dsub-sub',key:'fc'},t('forecast')+' '+est+' ('+(w0.ru||w0.id)+')'))
|
|
398
|
+
}
|
|
399
|
+
}
|
|
378
400
|
if(!parts.length)return null
|
|
379
|
-
return React.createElement('
|
|
401
|
+
return React.createElement('div',{className:'dsub-block'},parts)
|
|
380
402
|
})(),
|
|
381
403
|
|
|
382
404
|
account.paidTierName ? React.createElement('span', { className: 'dsub-sub' }, t('plan') + ': ' + account.paidTierName) : null,
|
|
@@ -488,9 +510,69 @@ function PluginCard(props) {
|
|
|
488
510
|
))
|
|
489
511
|
}
|
|
490
512
|
|
|
513
|
+
// Горячий переключатель провайдера в строке композера: клик циклично
|
|
514
|
+
// меняет активного провайдера подписки для следующего запроса.
|
|
515
|
+
let activeIdx = 0
|
|
516
|
+
let labelEl = null
|
|
517
|
+
const ORDER = ['codex', 'claude', 'grok', 'antigravity']
|
|
518
|
+
|
|
519
|
+
async function refreshLoggedIn() {
|
|
520
|
+
try {
|
|
521
|
+
const res = await fetch('/dsh-subscriptions/status', { cache: 'no-store' })
|
|
522
|
+
const data = await res.json().catch(() => ({}))
|
|
523
|
+
const loggedIn = (data && data.loggedIn) || {}
|
|
524
|
+
return ORDER.filter((p) => loggedIn[p])
|
|
525
|
+
} catch { return [] }
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
function registerComposerSwitch(ctx) {
|
|
529
|
+
ctx.effect(() => {
|
|
530
|
+
const render = () => {
|
|
531
|
+
if (!labelEl) return
|
|
532
|
+
refreshLoggedIn().then((logged) => {
|
|
533
|
+
if (!labelEl) return
|
|
534
|
+
const current = logged.length ? logged[activeIdx % logged.length] : null
|
|
535
|
+
labelEl.textContent = t('switchLabel') + ': ' + (current || t('none'))
|
|
536
|
+
}).catch(() => {})
|
|
537
|
+
}
|
|
538
|
+
ctx.slots.inject('composer.action', () =>
|
|
539
|
+
ctx.slots.register(
|
|
540
|
+
{
|
|
541
|
+
name: 'composer.action',
|
|
542
|
+
id: 'dsh-subscriptions-provider-switch',
|
|
543
|
+
order: 31,
|
|
544
|
+
label: () => {
|
|
545
|
+
// label используется ядром до монтирования; держим актуальным
|
|
546
|
+
setTimeout(render, 0)
|
|
547
|
+
return t('switchLabel')
|
|
548
|
+
},
|
|
549
|
+
},
|
|
550
|
+
(props) => {
|
|
551
|
+
const el = React.createElement('button', {
|
|
552
|
+
onClick: async () => {
|
|
553
|
+
const logged = await refreshLoggedIn()
|
|
554
|
+
if (!logged.length) return
|
|
555
|
+
activeIdx = (activeIdx + 1) % logged.length
|
|
556
|
+
render()
|
|
557
|
+
},
|
|
558
|
+
}, '')
|
|
559
|
+
// Обновить текст в смонтированном элементе
|
|
560
|
+
setTimeout(() => {
|
|
561
|
+
labelEl = el
|
|
562
|
+
render()
|
|
563
|
+
}, 0)
|
|
564
|
+
return el
|
|
565
|
+
},
|
|
566
|
+
),
|
|
567
|
+
)
|
|
568
|
+
render()
|
|
569
|
+
}, 'dsh-subscriptions: composer provider switch')
|
|
570
|
+
}
|
|
571
|
+
|
|
491
572
|
exports.inject = ['slots', 'locale']
|
|
492
573
|
exports.apply = function apply(ctx) {
|
|
493
574
|
registerSettings(ctx)
|
|
575
|
+
registerComposerSwitch(ctx)
|
|
494
576
|
}
|
|
495
577
|
return module.exports
|
|
496
578
|
},
|
package/lib/crypto.js
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { scryptSync, createCipheriv, createDecipheriv, randomBytes } from "node:crypto"
|
|
2
|
+
|
|
3
|
+
// AES-256-GCM с ключом из passphrase (scrypt, без хранения соли отдельно —
|
|
4
|
+
// соль в начале блока).
|
|
5
|
+
export function encryptWithPassphrase(plainText, passphrase) {
|
|
6
|
+
const salt = randomBytes(16)
|
|
7
|
+
const key = scryptSync(String(passphrase), salt, 32)
|
|
8
|
+
const iv = randomBytes(12)
|
|
9
|
+
const cipher = createCipheriv("aes-256-gcm", key, iv)
|
|
10
|
+
const encrypted = Buffer.concat([cipher.update(String(plainText), "utf8"), cipher.final()])
|
|
11
|
+
const tag = cipher.getAuthTag()
|
|
12
|
+
// формат: magic + salt + iv + tag + data
|
|
13
|
+
return "DSHE1:" + Buffer.concat([salt, iv, tag, encrypted]).toString("base64")
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function decryptWithPassphrase(payload, passphrase) {
|
|
17
|
+
const raw = String(payload || "")
|
|
18
|
+
if (!raw.startsWith("DSHE1:")) throw new Error("неизвестный формат экспорта")
|
|
19
|
+
const buf = Buffer.from(raw.slice(6), "base64")
|
|
20
|
+
const salt = buf.subarray(0, 16)
|
|
21
|
+
const iv = buf.subarray(16, 28)
|
|
22
|
+
const tag = buf.subarray(28, 44)
|
|
23
|
+
const data = buf.subarray(44)
|
|
24
|
+
const key = scryptSync(String(passphrase), salt, 32)
|
|
25
|
+
const decipher = createDecipheriv("aes-256-gcm", key, iv)
|
|
26
|
+
decipher.setAuthTag(tag)
|
|
27
|
+
return Buffer.concat([decipher.update(data), decipher.final()]).toString("utf8")
|
|
28
|
+
}
|
package/lib/index.js
CHANGED
|
@@ -8,6 +8,7 @@ import { SubscriptionAdapter } from './adapter.js'
|
|
|
8
8
|
import { generateOnce, SIZES as IMAGE_SIZES } from './images.js'
|
|
9
9
|
import { parseBlob } from './blob.js'
|
|
10
10
|
import { createSubscriptionsService } from './subscriptions.js'
|
|
11
|
+
import { encryptWithPassphrase, decryptWithPassphrase } from './crypto.js'
|
|
11
12
|
import { quotaSnapshot } from './ratelimit.js'
|
|
12
13
|
import { createAccountStore, normalizeSlots, vendorConfig } from './accounts.js'
|
|
13
14
|
import {
|
|
@@ -36,12 +37,16 @@ const defaultSlots = PROVIDERS.map((provider) => ({ provider, index: 1, label: '
|
|
|
36
37
|
export const Config = z.object({
|
|
37
38
|
cooldownMs: z.number().default(30 * 60 * 1000)
|
|
38
39
|
.description('After RATE_LIMIT/QUOTA/429, skip that account for this many milliseconds.'),
|
|
39
|
-
switchAtRemaining: z.number().default(0)
|
|
40
|
+
switchAtRemaining: z.number().default(0.01)
|
|
40
41
|
.description('If remaining <= this (absolute or <1 fraction), treat as exhausted before request. 0 disables.'),
|
|
41
42
|
refreshAheadMs: z.number().default(5 * 60 * 1000)
|
|
42
43
|
.description('Background refresh when expiry within this many ms.'),
|
|
43
44
|
refreshRetryMs: z.number().default(10 * 60 * 1000)
|
|
44
45
|
.description('Do not retry background refresh more often than this after failure.'),
|
|
46
|
+
probeIntervalMin: z.number().default(15)
|
|
47
|
+
.description('Background health-check interval in minutes. 0 disables.'),
|
|
48
|
+
notifyLimits: z.boolean().default(true)
|
|
49
|
+
.description('Emit log notices when usage crosses 70/90/100% of a window.'),
|
|
45
50
|
slots: z.array(Slot).default(defaultSlots)
|
|
46
51
|
.description('Account slots. Secrets are not stored here; only the credential ref names.'),
|
|
47
52
|
useWebCallback: z.boolean().default(false)
|
|
@@ -86,7 +91,17 @@ export function apply(ctx, config) {
|
|
|
86
91
|
})
|
|
87
92
|
|
|
88
93
|
const live = () => Config(structuredClone(getConfig() ?? {})) ?? config
|
|
89
|
-
const store = createAccountStore({
|
|
94
|
+
const store = createAccountStore({
|
|
95
|
+
credentials: ctx.credentials,
|
|
96
|
+
getConfig: live,
|
|
97
|
+
fetchImpl: fetch,
|
|
98
|
+
onLimitNotice: (provider, ref, win, threshold) => {
|
|
99
|
+
if (!live().notifyLimits) return
|
|
100
|
+
const label = win.ru || win.en || win.id
|
|
101
|
+
try { ctx.log && ctx.log.warn && ctx.log.warn(`[dsh-subscriptions] ${provider} ${ref}: лимит ${label} на ${threshold}%`) } catch {}
|
|
102
|
+
try { ctx.emit && ctx.emit('subscriptions.limit-notice', { provider, ref, window: win.id, usedPercent: win.usedPercent, threshold }) } catch {}
|
|
103
|
+
},
|
|
104
|
+
})
|
|
90
105
|
const subscriptions = createSubscriptionsService({
|
|
91
106
|
listAccounts: (provider) => store.listAccounts(provider),
|
|
92
107
|
loadBlob: (ref) => store.loadBlob(ref),
|
|
@@ -96,6 +111,8 @@ export function apply(ctx, config) {
|
|
|
96
111
|
switchAtRemaining: () => live().switchAtRemaining,
|
|
97
112
|
rememberCooldown: (ref, until) => store.rememberCooldown(ref, until),
|
|
98
113
|
rememberQuota: (ref, snap) => store.rememberQuota(ref, snap),
|
|
114
|
+
rememberRequest: (ref) => store.rememberRequest(ref),
|
|
115
|
+
getRequestCount: (ref) => store.getRequestCount(ref),
|
|
99
116
|
fetchImpl: fetch,
|
|
100
117
|
})
|
|
101
118
|
|
|
@@ -274,6 +291,7 @@ export function apply(ctx, config) {
|
|
|
274
291
|
usagePercent: info.usagePercent,
|
|
275
292
|
quota: info.quota || null,
|
|
276
293
|
usage: info.usage || null,
|
|
294
|
+
requests: store.getRequestCount(slot.ref) || null,
|
|
277
295
|
refreshError: info.refreshError || '',
|
|
278
296
|
validationUrl: info.validationUrl || '',
|
|
279
297
|
validationMessage: info.validationMessage || '',
|
|
@@ -335,6 +353,50 @@ export function apply(ctx, config) {
|
|
|
335
353
|
return () => clear(id)
|
|
336
354
|
}, 'dsh-subscriptions: refresh ahead')
|
|
337
355
|
|
|
356
|
+
// Фоновый health-check: раз в N минут прогоняет дешёвый check по всем
|
|
357
|
+
// подключённым аккаунтам. Мёртвые помечаются через describeRef, cooldown
|
|
358
|
+
// не ставится (как в /check).
|
|
359
|
+
ctx.effect(() => {
|
|
360
|
+
const tick = async () => {
|
|
361
|
+
const cfg = live()
|
|
362
|
+
const mins = Number(cfg.probeIntervalMin)
|
|
363
|
+
if (!Number.isFinite(mins) || mins <= 0) return
|
|
364
|
+
const now = Date.now()
|
|
365
|
+
for (const slot of normalizeSlots(cfg.slots)) {
|
|
366
|
+
const ref = slot.ref
|
|
367
|
+
try {
|
|
368
|
+
const info = await store.describeRef(ref)
|
|
369
|
+
if (!info.configured) continue
|
|
370
|
+
const raw = await store.resolveRaw(ref)
|
|
371
|
+
if (!raw) continue
|
|
372
|
+
const blob = await store.loadBlob(ref).catch(() => null)
|
|
373
|
+
if (!blob || !blob.refreshToken) continue
|
|
374
|
+
const fresh = await store.ensureFresh(slot.provider, blob, ref).catch(() => null)
|
|
375
|
+
if (!fresh) continue
|
|
376
|
+
const vendor = getVendor(slot.provider)
|
|
377
|
+
if (typeof vendor.check !== 'function') continue
|
|
378
|
+
const cfg2 = vendorConfig(slot.provider, live())
|
|
379
|
+
const probeFetch = async (u, i) => fetch(u, i)
|
|
380
|
+
await vendor.check(fresh, cfg2, probeFetch).catch((e) => {
|
|
381
|
+
try { ctx.log && ctx.log.warn && ctx.log.warn("[dsh-subscriptions] probe " + ref + ": " + String(e && e.message || e).slice(0, 200)) } catch {}
|
|
382
|
+
})
|
|
383
|
+
} catch {}
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
let lastProbeAt = 0
|
|
387
|
+
const wrapped = async () => {
|
|
388
|
+
const cfg = live()
|
|
389
|
+
const mins = Number(cfg.probeIntervalMin)
|
|
390
|
+
if (!Number.isFinite(mins) || mins <= 0) return
|
|
391
|
+
if (Date.now() - lastProbeAt < mins * 60 * 1000) return
|
|
392
|
+
lastProbeAt = Date.now()
|
|
393
|
+
await tick()
|
|
394
|
+
}
|
|
395
|
+
wrapped().catch(() => {})
|
|
396
|
+
const timer = setInterval(() => { wrapped().catch(() => {}) }, 60 * 1000)
|
|
397
|
+
return () => clearInterval(timer)
|
|
398
|
+
}, 'dsh-subscriptions: probe loop')
|
|
399
|
+
|
|
338
400
|
|
|
339
401
|
ctx.effect(() => ctx.webServer.register({
|
|
340
402
|
kind: 'exact',
|
|
@@ -559,6 +621,151 @@ export function apply(ctx, config) {
|
|
|
559
621
|
},
|
|
560
622
|
}), 'dsh-subscriptions: /check')
|
|
561
623
|
|
|
624
|
+
// HTTP-прокси к API провайдера через subscriptions.request.
|
|
625
|
+
// Same-origin only, allowlist путей, ротация и квота как у моделей.
|
|
626
|
+
// Токен наружу не отдаётся — наружу только ответ провайдера.
|
|
627
|
+
ctx.effect(() => ctx.webServer.register({
|
|
628
|
+
kind: 'prefix',
|
|
629
|
+
path: '/dsh-subscriptions/proxy',
|
|
630
|
+
handler: async (req, res) => {
|
|
631
|
+
if (req.method !== 'POST' && req.method !== 'GET') {
|
|
632
|
+
writeJson(res, 405, { ok: false, error: { code: 'method', message: 'GET or POST' } })
|
|
633
|
+
return
|
|
634
|
+
}
|
|
635
|
+
if (!isTrustedSettingsRequest(req)) {
|
|
636
|
+
writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'same-origin only' } })
|
|
637
|
+
return
|
|
638
|
+
}
|
|
639
|
+
const url = new URL(req.url || '/', 'http://localhost')
|
|
640
|
+
const parts = url.pathname.replace(/^\/dsh-subscriptions\/proxy\//, '').split('/').filter(Boolean)
|
|
641
|
+
const provider = parts[0]
|
|
642
|
+
const restPath = '/' + parts.slice(1).join('/')
|
|
643
|
+
if (!isProvider(provider)) {
|
|
644
|
+
writeJson(res, 404, { ok: false, error: { code: 'provider', message: 'unknown provider' } })
|
|
645
|
+
return
|
|
646
|
+
}
|
|
647
|
+
let body
|
|
648
|
+
if (req.method === 'POST') {
|
|
649
|
+
try {
|
|
650
|
+
body = JSON.parse((await readBody(req, 1024 * 1024)).toString('utf8'))
|
|
651
|
+
} catch {
|
|
652
|
+
writeJson(res, 400, { ok: false, error: { code: 'json', message: 'invalid json' } })
|
|
653
|
+
return
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
try {
|
|
657
|
+
const out = await subscriptions.request({
|
|
658
|
+
provider,
|
|
659
|
+
path: restPath,
|
|
660
|
+
method: req.method === 'POST' ? 'POST' : 'GET',
|
|
661
|
+
body,
|
|
662
|
+
headers: {},
|
|
663
|
+
})
|
|
664
|
+
const text = await out.text()
|
|
665
|
+
try {
|
|
666
|
+
const json = JSON.parse(text)
|
|
667
|
+
writeJson(res, out.status || 200, json)
|
|
668
|
+
} catch {
|
|
669
|
+
res.writeHead(out.status || 200, { 'Content-Type': 'application/json' })
|
|
670
|
+
res.end(text)
|
|
671
|
+
}
|
|
672
|
+
} catch (e) {
|
|
673
|
+
const status = e && e.status ? e.status : (e && e.code === 'FORBIDDEN' ? 403 : (e && e.code === 'AUTH' ? 401 : 502))
|
|
674
|
+
writeJson(res, status, { ok: false, error: { code: e && e.code || 'VENDOR', message: String(e && e.message || e).slice(0, 300) } })
|
|
675
|
+
}
|
|
676
|
+
},
|
|
677
|
+
}), 'dsh-subscriptions: proxy')
|
|
678
|
+
|
|
679
|
+
// Экспорт зашифрованного бандла токенов. Токены не логгируются.
|
|
680
|
+
ctx.effect(() => ctx.webServer.register({
|
|
681
|
+
kind: 'exact',
|
|
682
|
+
path: '/dsh-subscriptions/export',
|
|
683
|
+
handler: async (req, res) => {
|
|
684
|
+
if (req.method !== 'POST') {
|
|
685
|
+
writeJson(res, 405, { ok: false, error: { code: 'method', message: 'POST only' } })
|
|
686
|
+
return
|
|
687
|
+
}
|
|
688
|
+
if (!isTrustedSettingsRequest(req)) {
|
|
689
|
+
writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'same-origin only' } })
|
|
690
|
+
return
|
|
691
|
+
}
|
|
692
|
+
let payload
|
|
693
|
+
try { payload = JSON.parse((await readBody(req, 8 * 1024)).toString('utf8') || '{}') } catch {
|
|
694
|
+
writeJson(res, 400, { ok: false, error: { code: 'json', message: 'invalid json' } })
|
|
695
|
+
return
|
|
696
|
+
}
|
|
697
|
+
const passphrase = payload.passphrase
|
|
698
|
+
if (!passphrase || typeof passphrase !== 'string') {
|
|
699
|
+
writeJson(res, 400, { ok: false, error: { code: 'passphrase', message: 'нужен passphrase' } })
|
|
700
|
+
return
|
|
701
|
+
}
|
|
702
|
+
try {
|
|
703
|
+
const accounts = []
|
|
704
|
+
for (const slot of normalizeSlots(live().slots)) {
|
|
705
|
+
try {
|
|
706
|
+
const blob = await store.loadBlob(slot.ref)
|
|
707
|
+
accounts.push({ ref: slot.ref, provider: slot.provider, index: slot.index, label: slot.label || blob.label || '', blob })
|
|
708
|
+
} catch { /* skip missing */ }
|
|
709
|
+
}
|
|
710
|
+
if (!accounts.length) {
|
|
711
|
+
writeJson(res, 200, { ok: false, error: { code: 'empty', message: 'нет подключённых аккаунтов' } })
|
|
712
|
+
return
|
|
713
|
+
}
|
|
714
|
+
const bundle = JSON.stringify({ v: 1, exportedAt: Date.now(), accounts })
|
|
715
|
+
const encrypted = encryptWithPassphrase(bundle, passphrase)
|
|
716
|
+
writeJson(res, 200, { ok: true, payload: encrypted, count: accounts.length })
|
|
717
|
+
} catch (e) {
|
|
718
|
+
writeJson(res, 500, { ok: false, error: { code: 'export', message: String(e && e.message || e) } })
|
|
719
|
+
}
|
|
720
|
+
},
|
|
721
|
+
}), 'dsh-subscriptions: /export')
|
|
722
|
+
|
|
723
|
+
// Импорт зашифрованного бандла.
|
|
724
|
+
ctx.effect(() => ctx.webServer.register({
|
|
725
|
+
kind: 'exact',
|
|
726
|
+
path: '/dsh-subscriptions/import',
|
|
727
|
+
handler: async (req, res) => {
|
|
728
|
+
if (req.method !== 'POST') {
|
|
729
|
+
writeJson(res, 405, { ok: false, error: { code: 'method', message: 'POST only' } })
|
|
730
|
+
return
|
|
731
|
+
}
|
|
732
|
+
if (!isTrustedSettingsRequest(req)) {
|
|
733
|
+
writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'same-origin only' } })
|
|
734
|
+
return
|
|
735
|
+
}
|
|
736
|
+
let payload
|
|
737
|
+
try { payload = JSON.parse((await readBody(req, 1024 * 1024)).toString('utf8') || '{}') } catch {
|
|
738
|
+
writeJson(res, 400, { ok: false, error: { code: 'json', message: 'invalid json' } })
|
|
739
|
+
return
|
|
740
|
+
}
|
|
741
|
+
const { passphrase, payload: encrypted } = payload
|
|
742
|
+
if (!passphrase || !encrypted) {
|
|
743
|
+
writeJson(res, 400, { ok: false, error: { code: 'params', message: 'нужны passphrase и payload' } })
|
|
744
|
+
return
|
|
745
|
+
}
|
|
746
|
+
let bundle
|
|
747
|
+
try {
|
|
748
|
+
bundle = JSON.parse(decryptWithPassphrase(encrypted, passphrase))
|
|
749
|
+
} catch (e) {
|
|
750
|
+
writeJson(res, 400, { ok: false, error: { code: 'decrypt', message: 'неверный passphrase или повреждённый бандл' } })
|
|
751
|
+
return
|
|
752
|
+
}
|
|
753
|
+
if (!bundle || !Array.isArray(bundle.accounts)) {
|
|
754
|
+
writeJson(res, 400, { ok: false, error: { code: 'format', message: 'неверный формат бандла' } })
|
|
755
|
+
return
|
|
756
|
+
}
|
|
757
|
+
let imported = 0
|
|
758
|
+
for (const row of bundle.accounts) {
|
|
759
|
+
try {
|
|
760
|
+
await store.saveBlob(row.ref, row.blob)
|
|
761
|
+
imported++
|
|
762
|
+
} catch { /* skip broken */ }
|
|
763
|
+
}
|
|
764
|
+
await syncAdapter()
|
|
765
|
+
writeJson(res, 200, { ok: true, imported, total: bundle.accounts.length, accounts: await accountsView() })
|
|
766
|
+
},
|
|
767
|
+
}), 'dsh-subscriptions: /import')
|
|
768
|
+
|
|
562
769
|
ctx.effect(() => ctx.webServer.register({
|
|
563
770
|
kind: 'exact',
|
|
564
771
|
path: '/dsh-subscriptions/logout',
|
package/lib/rotate.js
CHANGED
|
@@ -23,11 +23,19 @@ export function pickAccount(accounts, nowMs, opts) {
|
|
|
23
23
|
if (!q) return false
|
|
24
24
|
if (q.resetAt && Number(q.resetAt) <= now) return false
|
|
25
25
|
if (q.remaining == null) return false
|
|
26
|
+
// Если до сброса меньше минуты — переключаемся, не тратя остаток,
|
|
27
|
+
// независимо от порога (аккаунт всё равно скоро обнулится).
|
|
28
|
+
if (q.resetAt && Number(q.resetAt) - now < 60000) return true
|
|
29
|
+
// Если остаток меньше порога — считаем исчерпанным, чтобы не тратить
|
|
30
|
+
// последние проценты перед отказом.
|
|
31
|
+
let below = false
|
|
26
32
|
if (q.limit != null && q.limit > 0 && thr > 0 && thr < 1) {
|
|
27
33
|
const frac = q.remaining / q.limit
|
|
28
|
-
|
|
34
|
+
below = frac <= thr
|
|
35
|
+
} else {
|
|
36
|
+
below = q.remaining <= thr
|
|
29
37
|
}
|
|
30
|
-
return
|
|
38
|
+
return below
|
|
31
39
|
}
|
|
32
40
|
function isUsageExhausted(acc) {
|
|
33
41
|
if (acc.usagePercent == null || Number(acc.usagePercent) < 100) return false
|
package/package.json
CHANGED