@goodandready/dsh-subscriptions 0.2.10 → 0.3.0
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 +114 -17
- 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
|
@@ -15,8 +15,8 @@ window.__ModuleLoader__.load({
|
|
|
15
15
|
'.dsub-headText{flex-direction:column;flex:1;gap:4px;min-width:0;display:flex}' +
|
|
16
16
|
'.dsub-name{color:var(--dsw-alias-label-primary);font-size:15px;font-weight:600;line-height:1.4}' +
|
|
17
17
|
'.dsub-description{color:var(--dsw-alias-label-secondary);font-size:13px}' +
|
|
18
|
-
'.dsub-
|
|
19
|
-
'.dsub-
|
|
18
|
+
'.dsub-chev{color:var(--dsw-alias-label-tertiary);flex:none;transition:transform .16s}' +
|
|
19
|
+
'.dsub-chevOpen{transform:rotate(180deg)}' +
|
|
20
20
|
'.dsub-body{border-top:1px solid var(--dsw-alias-border-l2);margin:0 16px;padding-bottom:8px}' +
|
|
21
21
|
// existing dsub classes kept for SubsSection internals
|
|
22
22
|
'.dsub-wrap{display:flex;flex-direction:column;gap:22px;padding:4px 0;max-width:760px}' +
|
|
@@ -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) {
|
|
@@ -124,7 +135,22 @@ const cssId = 'dsh-subscriptions/settings.module.css'
|
|
|
124
135
|
|
|
125
136
|
// Карточка во вкладке «Плагины» рисует свой заголовок и сворачивание:
|
|
126
137
|
// ядро даёт только рамку списка.
|
|
127
|
-
|
|
138
|
+
// Ядровый значок раскрытия. Без защищённого require вместо IconChevronDownOutline14 может упасть вся клиентская половина.
|
|
139
|
+
let ChevronIcon = null
|
|
140
|
+
try {
|
|
141
|
+
const primitives = require('@deepseek-ai/dsh-client-ui-primitives')
|
|
142
|
+
ChevronIcon = primitives && primitives.IconChevronDownOutline14
|
|
143
|
+
} catch (noPrimitives) {
|
|
144
|
+
ChevronIcon = null
|
|
145
|
+
}
|
|
146
|
+
function FallbackChevron(props) {
|
|
147
|
+
return React.createElement('svg', { className: props.className, width: 14, height: 14, viewBox: '0 0 14 14', fill: 'none' },
|
|
148
|
+
React.createElement('path', { d: 'M3.5 5.25L7 8.75l3.5-3.5', stroke: 'currentColor', 'stroke-width': 1.5, 'stroke-linecap': 'round', 'stroke-linejoin': 'round' }),
|
|
149
|
+
)
|
|
150
|
+
}
|
|
151
|
+
const Chevron = ChevronIcon || FallbackChevron
|
|
152
|
+
|
|
153
|
+
function PluginCard(props) {
|
|
128
154
|
const t = (props && props.t) || ((key) => key)
|
|
129
155
|
const [open, setOpen] = React.useState(false)
|
|
130
156
|
return React.createElement('div', { className: 'dsub-block' },
|
|
@@ -343,25 +369,36 @@ const cssId = 'dsh-subscriptions/settings.module.css'
|
|
|
343
369
|
(function(){
|
|
344
370
|
var parts=[]
|
|
345
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
|
+
}
|
|
346
382
|
if(Array.isArray(wins)&&wins.length){
|
|
347
383
|
wins.forEach(function(w){
|
|
348
|
-
if(w&&w.usedPercent!=null)parts.push(
|
|
384
|
+
if(w&&w.usedPercent!=null)parts.push(bar((w.ru||w.id),w.usedPercent))
|
|
349
385
|
})
|
|
350
386
|
}
|
|
351
387
|
var q=account.quota
|
|
352
|
-
if(q)
|
|
353
|
-
|
|
354
|
-
if(q.remaining!=null&&q.limit!=null)qa.push(q.remaining+'/'+q.limit)
|
|
355
|
-
else if(q.remaining!=null)qa.push(String(q.remaining))
|
|
356
|
-
if(q.usedPercent!=null)qa.push(Math.round(q.usedPercent)+'%')
|
|
357
|
-
if(q.resetAt){var d=new Date(q.resetAt);qa.push(d.toLocaleTimeString())}
|
|
358
|
-
if(qa.length)parts.push(t('quota')+': '+qa.join(' / '))
|
|
359
|
-
}
|
|
360
|
-
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))
|
|
361
390
|
var at=(q&&q.measuredAt)||(account.usageAt||0)
|
|
362
|
-
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
|
+
}
|
|
363
400
|
if(!parts.length)return null
|
|
364
|
-
return React.createElement('
|
|
401
|
+
return React.createElement('div',{className:'dsub-block'},parts)
|
|
365
402
|
})(),
|
|
366
403
|
|
|
367
404
|
account.paidTierName ? React.createElement('span', { className: 'dsub-sub' }, t('plan') + ': ' + account.paidTierName) : null,
|
|
@@ -404,7 +441,7 @@ const cssId = 'dsh-subscriptions/settings.module.css'
|
|
|
404
441
|
React.createElement('div', { className: 'dsub-description' }, t('cardIntro')),
|
|
405
442
|
),
|
|
406
443
|
React.createElement('svg', {
|
|
407
|
-
className: 'dsub-
|
|
444
|
+
className: 'dsub-chev' + (open ? ' dsub-chevOpen' : ''),
|
|
408
445
|
width: 16, height: 16, viewBox: '0 0 16 16', fill: 'none',
|
|
409
446
|
},
|
|
410
447
|
React.createElement('path', {
|
|
@@ -473,9 +510,69 @@ const cssId = 'dsh-subscriptions/settings.module.css'
|
|
|
473
510
|
))
|
|
474
511
|
}
|
|
475
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
|
+
|
|
476
572
|
exports.inject = ['slots', 'locale']
|
|
477
573
|
exports.apply = function apply(ctx) {
|
|
478
574
|
registerSettings(ctx)
|
|
575
|
+
registerComposerSwitch(ctx)
|
|
479
576
|
}
|
|
480
577
|
return module.exports
|
|
481
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