@goodandready/dsh-subscriptions 0.7.4 → 0.7.6

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.
@@ -1,11 +1,12 @@
1
1
  #!/usr/bin/env node
2
2
  import process from 'node:process'
3
3
  import { pathToFileURL } from 'node:url'
4
+ import { readFile } from 'node:fs/promises'
4
5
 
5
6
  export function parseArgs(argv) {
6
7
  const args = Array.isArray(argv) ? argv.slice() : []
7
8
  const command = args.shift() || 'status'
8
- const options = { command, csv: false, json: false, type: 'accounts', sessionId: '', mode: 'fast', help: false, setKey: '', setValue: '' }
9
+ const options = { command, csv: false, json: false, type: 'accounts', sessionId: '', mode: 'fast', help: false, setKey: '', setValue: '', packAction: '', packFile: '' }
9
10
  for (let i = 0; i < args.length; i += 1) {
10
11
  const arg = args[i]
11
12
  if (arg === '--csv') options.csv = true
@@ -15,6 +16,8 @@ export function parseArgs(argv) {
15
16
  else if (arg === '--session') options.sessionId = args[++i] || ''
16
17
  else if (command === 'set' && !options.setKey) options.setKey = arg
17
18
  else if (command === 'set') options.setValue = arg
19
+ else if (command === 'packs' && arg === 'add') options.packAction = 'add'
20
+ else if (command === 'packs' && options.packAction === 'add' && !options.packFile) options.packFile = arg
18
21
  else if (command === 'fast' && !options.sessionId) options.sessionId = arg
19
22
  else if (command === 'fast') options.mode = arg === 'standard' || arg === 'off' ? 'standard' : 'fast'
20
23
  }
@@ -67,9 +70,60 @@ export function buildRequest(options, env = process.env) {
67
70
  }
68
71
  if (options.command === 'config') return { url: base + '/config', init: { method: 'GET', headers } }
69
72
  if (options.command === 'sources') return { url: base + '/sources', init: { method: 'GET', headers } }
73
+ if (options.command === 'packs') return { url: base + '/config', init: { method: 'GET', headers } }
74
+ if (options.command === 'diagnostics') return { url: base + '/diagnostics', init: { method: 'GET', headers } }
70
75
  throw new Error('unknown command: ' + options.command)
71
76
  }
72
77
 
78
+ export function doctorFailed(payload) {
79
+ const diag = (payload && payload.diagnostics) || {}
80
+ const report = diag.report || {}
81
+ const errors = Array.isArray(report.recentErrors) ? report.recentErrors.length : 0
82
+ const conflicts = Array.isArray(diag.conflicts) ? diag.conflicts.length : 0
83
+ return errors > 0 || conflicts > 0
84
+ }
85
+
86
+ export function formatDoctor(payload) {
87
+ const diag = (payload && payload.diagnostics) || {}
88
+ const report = diag.report || {}
89
+ const sources = (payload && payload.sources) || {}
90
+ const errors = Array.isArray(report.recentErrors) ? report.recentErrors.length : 0
91
+ const conflicts = Array.isArray(diag.conflicts) ? diag.conflicts.length : 0
92
+ const kr = sources.keyRotation || {}
93
+ const lines = []
94
+ lines.push('doctor: ' + (doctorFailed(payload) ? 'degraded' : 'ok'))
95
+ lines.push('errors: ' + errors + ' conflicts: ' + conflicts)
96
+ lines.push('key-rotation: ' + (kr.available ? (kr.healthyKeys + '/' + kr.totalKeys + ' healthy keys') : ('unavailable' + (kr.reason ? ' (' + kr.reason + ')' : ''))))
97
+ for (const row of (sources.providers || [])) lines.push(row.provider + ': ' + (row.selected || 'none'))
98
+ return lines.join('\n')
99
+ }
100
+
101
+ export function sanitizePack(pack) {
102
+ if (!pack || typeof pack !== 'object') return pack
103
+ const copy = JSON.parse(JSON.stringify(pack))
104
+ if (copy.clientSecret) copy.clientSecret = '••••••'
105
+ if (copy.headers && typeof copy.headers === 'object') {
106
+ copy.headers = Object.fromEntries(Object.keys(copy.headers).map((key) => [key, '••••••']))
107
+ }
108
+ return copy
109
+ }
110
+
111
+ export function formatPacks(packs) {
112
+ return JSON.stringify((Array.isArray(packs) ? packs : []).map(sanitizePack), null, 2) + '\\n'
113
+ }
114
+
115
+ export function mergeProviderPack(packs, pack) {
116
+ const list = (Array.isArray(packs) ? packs : []).filter((p) => p && p.id !== pack.id)
117
+ return [...list, pack]
118
+ }
119
+
120
+ export function buildValidatePackRequest(pack, env = process.env) {
121
+ const base = String(env.DSH_SUBS_URL || 'http://127.0.0.1:3080/dsh-subscriptions').replace(/\/$/, '')
122
+ const headers = { Accept: 'application/json', 'Content-Type': 'application/json' }
123
+ if (env.DSH_AUTH_TOKEN) headers.Authorization = 'Bearer ' + env.DSH_AUTH_TOKEN
124
+ return { url: base + '/provider-packs/validate', init: { method: 'POST', headers, body: JSON.stringify({ pack }) } }
125
+ }
126
+
73
127
  export function formatSources(payload) {
74
128
  const lines = []
75
129
  const kr = payload && payload.keyRotation
@@ -98,6 +152,41 @@ export function buildSetRequest(options, configPayload, env = process.env) {
98
152
  export async function run(argv = process.argv.slice(2), env = process.env, fetchImpl = fetch) {
99
153
  const options = parseArgs(argv)
100
154
  if (options.help) return { help: true }
155
+ if (options.command === 'doctor') {
156
+ const diagReq = buildRequest({ command: 'diagnostics' }, env)
157
+ const srcReq = buildRequest({ command: 'sources' }, env)
158
+ const [diagRes, srcRes] = await Promise.all([fetchImpl(diagReq.url, diagReq.init), fetchImpl(srcReq.url, srcReq.init)])
159
+ const diagText = await diagRes.text()
160
+ const srcText = await srcRes.text()
161
+ if (!diagRes.ok) throw new Error('HTTP ' + diagRes.status + ': ' + diagText.slice(0, 500))
162
+ if (!srcRes.ok) throw new Error('HTTP ' + srcRes.status + ': ' + srcText.slice(0, 500))
163
+ const payload = { diagnostics: JSON.parse(diagText || '{}'), sources: JSON.parse(srcText || '{}') }
164
+ return {
165
+ text: options.json ? JSON.stringify(payload, null, 2) + '\n' : formatDoctor(payload),
166
+ contentType: options.json ? 'application/json' : 'text/plain',
167
+ code: doctorFailed(payload) ? 1 : 0,
168
+ }
169
+ }
170
+ if (options.command === 'packs' && options.packAction === 'add') {
171
+ if (!options.packFile) throw new Error('packs add requires a JSON file')
172
+ const pack = JSON.parse(await readFile(options.packFile, 'utf8'))
173
+ const validate = buildValidatePackRequest(pack.pack || pack, env)
174
+ const checked = await fetchImpl(validate.url, validate.init)
175
+ const checkedText = await checked.text()
176
+ if (!checked.ok) throw new Error('invalid pack: ' + checkedText.slice(0, 500))
177
+ const current = await fetchImpl(buildRequest({ command: 'config' }, env).url, buildRequest({ command: 'config' }, env).init)
178
+ const currentText = await current.text()
179
+ if (!current.ok) throw new Error('HTTP ' + current.status + ': ' + currentText.slice(0, 500))
180
+ const payload = JSON.parse(currentText || '{}')
181
+ const nextConfig = Object.assign({}, payload.config, {
182
+ providerPacks: mergeProviderPack(payload.config && payload.config.providerPacks, pack.pack || pack),
183
+ })
184
+ const put = buildSetRequest({ setKey: 'providerPacks', setValue: JSON.stringify(nextConfig.providerPacks) }, { config: nextConfig, revision: payload.revision }, env)
185
+ const saved = await fetchImpl(put.url, put.init)
186
+ const savedText = await saved.text()
187
+ if (!saved.ok) throw new Error('HTTP ' + saved.status + ': ' + savedText.slice(0, 500))
188
+ return { text: 'saved provider pack ' + ((pack.pack && pack.pack.id) || pack.id || ''), contentType: 'text/plain' }
189
+ }
101
190
  let request = buildRequest(options.command === 'set' ? { ...options, command: 'config' } : options, env)
102
191
  if (options.command === 'set') {
103
192
  const current = await fetchImpl(request.url, request.init)
@@ -108,6 +197,12 @@ export async function run(argv = process.argv.slice(2), env = process.env, fetch
108
197
  const res = await fetchImpl(request.url, request.init)
109
198
  const text = await res.text()
110
199
  if (!res.ok) throw new Error('HTTP ' + res.status + ': ' + text.slice(0, 500))
200
+ if (options.command === 'packs' && options.packAction !== 'add') {
201
+ try {
202
+ const payload = JSON.parse(text || '{}')
203
+ return { text: formatPacks((payload.config && payload.config.providerPacks) || []), contentType: 'application/json' }
204
+ } catch { /* fall back to raw */ }
205
+ }
111
206
  if (options.command === 'sources' && !options.json) {
112
207
  try { return { text: formatSources(JSON.parse(text || '{}')), contentType: 'text/plain' } } catch { /* fall back to raw */ }
113
208
  }
@@ -117,10 +212,11 @@ export async function run(argv = process.argv.slice(2), env = process.env, fetch
117
212
  async function main() {
118
213
  const result = await run()
119
214
  if (result.help) {
120
- process.stdout.write('Usage: dsh-subscriptions <status|usage|fast|config|set|sources> [options]\n usage --csv --type accounts|calls\n fast <session> [fast|standard]\n config\n set <dot.path> <value>\n sources [--json]\n')
215
+ process.stdout.write('Usage: dsh-subscriptions <status|usage|fast|config|set|sources|packs|doctor> [options]\n usage --csv --type accounts|calls\n fast <session> [fast|standard]\n config\n set <dot.path> <value>\n sources [--json]\n packs\n packs add <file.json>\n doctor [--json]\n')
121
216
  return
122
217
  }
123
218
  process.stdout.write(result.text.endsWith('\n') ? result.text : result.text + '\n')
219
+ if (result.code) process.exitCode = result.code
124
220
  }
125
221
 
126
222
  if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
package/lib/alerts.js CHANGED
@@ -13,6 +13,11 @@ try {
13
13
  // and webhook dispatch (e.g. Telegram/Discord or external monitoring).
14
14
 
15
15
  const MAX_ALERTS = 100
16
+ let defaultWebhookSecret = ''
17
+
18
+ export function setWebhookSecret(secret) {
19
+ defaultWebhookSecret = String(secret || '')
20
+ }
16
21
  const DEDUP_WINDOW_MS = 10 * 60 * 1000 // 10 minutes cooldown per (ref + type)
17
22
 
18
23
  const alertBuffer = []
@@ -93,7 +98,9 @@ export async function dispatchNotificationDigest(alerts, webhookUrl, fetchImpl =
93
98
  }
94
99
 
95
100
  async function dispatchNotification(body, webhookUrl, fetchImpl, options) {
96
- const { secret = '', retries = 1, timeoutMs = 5000, sleep } = options || {}
101
+ const opts = options || {}
102
+ const { retries = 1, timeoutMs = 5000, sleep } = opts
103
+ const secret = opts.secret == null ? defaultWebhookSecret : opts.secret
97
104
  const payload = { ...body, source: 'dsh-subscriptions', sentAt: new Date().toISOString() }
98
105
  const raw = JSON.stringify(payload)
99
106
  const headers = {
package/lib/client.js CHANGED
@@ -1842,6 +1842,7 @@ window.__ModuleLoader__.load({
1842
1842
  ),
1843
1843
  h('div', { className: 'dsub-sub' }, 'Floating capsule can be dragged; docked stays bottom-right.'),
1844
1844
  h(SourcesPanel, { t }),
1845
+ h(ProviderPacksPanel),
1845
1846
 
1846
1847
  h('div', { className: 'dsub-row' },
1847
1848
  h('span', { className: 'dsub-h' }, t('composerQuota')),
@@ -1872,6 +1873,40 @@ window.__ModuleLoader__.load({
1872
1873
  ),
1873
1874
  h('div', { className: 'dsub-sub' },
1874
1875
  t('expiryNotifyDaysHint')),
1876
+ h('div', { className: 'dsub-row' },
1877
+ h('span', { className: 'dsub-h' }, 'Webhook URL'),
1878
+ h('input', {
1879
+ type: 'url',
1880
+ className: 'dsub-inp',
1881
+ placeholder: 'https://hooks.example/…',
1882
+ value: draft.webhookUrl || '',
1883
+ onChange: (e) => setDraft((d) => Object.assign({}, d, { webhookUrl: e.target.value })),
1884
+ }),
1885
+ ),
1886
+ h('div', { className: 'dsub-sub' }, 'Signed POST target for quota, session and billing alerts.'),
1887
+ h('div', { className: 'dsub-row' },
1888
+ h('span', { className: 'dsub-h' }, 'Webhook secret'),
1889
+ h('input', {
1890
+ type: 'password',
1891
+ className: 'dsub-inp',
1892
+ autoComplete: 'new-password',
1893
+ value: draft.webhookSecret || '',
1894
+ onChange: (e) => setDraft((d) => Object.assign({}, d, { webhookSecret: e.target.value })),
1895
+ }),
1896
+ ),
1897
+ h('div', { className: 'dsub-row' },
1898
+ h('span', { className: 'dsub-h' }, 'Digest interval (hours)'),
1899
+ h('input', {
1900
+ type: 'number',
1901
+ className: 'dsub-inp',
1902
+ style: { width: '80px', minWidth: '80px' },
1903
+ min: 0,
1904
+ max: 168,
1905
+ value: draft.digestIntervalHours != null ? draft.digestIntervalHours : 0,
1906
+ onChange: (e) => setDraft((d) => Object.assign({}, d, { digestIntervalHours: Number(e.target.value) || 0 })),
1907
+ }),
1908
+ ),
1909
+ h('div', { className: 'dsub-sub' }, '0 disables digests. Requires a Webhook URL.'),
1875
1910
  ),
1876
1911
  h('details', { className: 'dsub-group', open: true },
1877
1912
  h('summary', { className: 'dsub-groupHead' }, '🚦 ' + t('groupQueue')),
@@ -3655,6 +3690,79 @@ window.__ModuleLoader__.load({
3655
3690
  )
3656
3691
  }
3657
3692
 
3693
+ // #724: provider pack manager. Displays ids/models only; headers are masked.
3694
+ function ProviderPacksPanel() {
3695
+ const [state, setState] = React.useState({ packs: [], text: '', message: '', error: null, busy: false })
3696
+ const publicPack = (pack) => {
3697
+ const copy = Object.assign({}, pack)
3698
+ if (copy.clientSecret) copy.clientSecret = '••••'
3699
+ if (copy.headers && typeof copy.headers === 'object') {
3700
+ copy.headers = Object.fromEntries(Object.keys(copy.headers).map((key) => [key, '••••']))
3701
+ }
3702
+ return copy
3703
+ }
3704
+ const pull = () => {
3705
+ fetch('/dsh-subscriptions/config', { cache: 'no-store' })
3706
+ .then((res) => res.json())
3707
+ .then((cfg) => setState((st) => Object.assign({}, st, { packs: (cfg.config && cfg.config.providerPacks) || [] })))
3708
+ .catch((err) => setState((st) => Object.assign({}, st, { error: String(err && err.message || err) })))
3709
+ }
3710
+ React.useEffect(() => { pull() }, [])
3711
+ const parse = () => {
3712
+ const raw = JSON.parse(state.text || '{}')
3713
+ return raw && raw.pack ? raw.pack : raw
3714
+ }
3715
+ const validate = (pack) => fetch('/dsh-subscriptions/provider-packs/validate', {
3716
+ method: 'POST',
3717
+ headers: { 'content-type': 'application/json' },
3718
+ body: JSON.stringify({ pack }),
3719
+ }).then(async (res) => {
3720
+ const data = await res.json()
3721
+ if (!res.ok || !data.ok) throw new Error((data.error && data.error.message) || ('HTTP ' + res.status))
3722
+ return data.pack
3723
+ })
3724
+ const save = () => {
3725
+ setState((st) => Object.assign({}, st, { busy: true, message: '', error: null }))
3726
+ Promise.resolve().then(parse)
3727
+ .then(async (pack) => {
3728
+ const checked = await validate(pack)
3729
+ const cfg = await fetch('/dsh-subscriptions/config', { cache: 'no-store' }).then((res) => res.json())
3730
+ const packs = ((cfg.config && cfg.config.providerPacks) || []).filter((p) => p && p.id !== pack.id)
3731
+ packs.push(pack)
3732
+ const res = await fetch('/dsh-subscriptions/config', {
3733
+ method: 'PUT',
3734
+ headers: { 'content-type': 'application/json' },
3735
+ body: JSON.stringify({ config: Object.assign({}, cfg.config, { providerPacks: packs }), revision: cfg.revision }),
3736
+ })
3737
+ if (!res.ok) throw new Error('HTTP ' + res.status)
3738
+ setState((st) => Object.assign({}, st, { packs, busy: false, message: 'saved ' + checked.id, text: '' }))
3739
+ })
3740
+ .catch((err) => setState((st) => Object.assign({}, st, { busy: false, error: String(err && err.message || err) })))
3741
+ }
3742
+ const exportPacks = () => setState((st) => Object.assign({}, st, { text: JSON.stringify(st.packs.map(publicPack), null, 2), message: 'exported' }))
3743
+ return h('div', { className: 'dsub-section-card' },
3744
+ h('div', { className: 'dsub-section-title' }, 'Provider packs', h('button', { type: 'button', className: 'dsub-mini', onClick: pull }, '↻')),
3745
+ h('div', { className: 'dsub-section-desc' }, 'Add an OpenAI-Responses-compatible provider as a JSON manifest. Headers are masked in the editor.'),
3746
+ state.packs.length ? state.packs.map((pack) => h('div', { className: 'dsub-row', key: pack.id },
3747
+ h('span', { className: 'dsub-h' }, pack.id),
3748
+ h('span', { className: 'dsub-dim' }, (pack.displayName || '') + ' · ' + ((pack.models || []).length) + ' models'),
3749
+ )) : h('div', { className: 'dsub-dim' }, 'No packs yet.'),
3750
+ h('textarea', {
3751
+ className: 'dsub-inp',
3752
+ style: { width: '100%', minHeight: 120, fontFamily: 'ui-monospace,monospace', padding: 8 },
3753
+ value: state.text,
3754
+ placeholder: '{"id":"acme","displayName":"Acme AI","authUrl":"https://…","tokenUrl":"https://…","baseUrl":"https://…","clientId":"…","models":[{"id":"acme-1"}]}',
3755
+ onChange: (e) => setState((st) => Object.assign({}, st, { text: e.target.value, message: '', error: null })),
3756
+ }),
3757
+ h('div', { className: 'dsub-row' },
3758
+ h('button', { type: 'button', className: 'dsub-mini', disabled: state.busy, onClick: save }, state.busy ? '…' : 'Validate & save'),
3759
+ h('button', { type: 'button', className: 'dsub-mini', onClick: exportPacks }, 'Export'),
3760
+ ),
3761
+ state.message ? h('div', { className: 'dsub-ok' }, state.message) : null,
3762
+ state.error ? h('div', { className: 'dsub-bad' }, state.error) : null,
3763
+ )
3764
+ }
3765
+
3658
3766
  // #713: read-only source chain panel. Never renders key values.
3659
3767
  function SourcesPanel() {
3660
3768
  const [state, setState] = React.useState({ loading: true, data: null, error: null })
@@ -157,6 +157,10 @@ export const Config = z.object({
157
157
  .description('#349 Custom cross-vendor fallback chains (e.g. { claude: ["copilot", "cursor"] }).'),
158
158
  webhookUrl: z.string().volatile().default('')
159
159
  .description('#351 External webhook URL to receive quota and session alerts (JSON POST).'),
160
+ webhookSecret: z.string().role('secret').default('')
161
+ .description('#720 HMAC-SHA256 secret used to sign webhook payloads.'),
162
+ digestIntervalHours: z.number().volatile().default(0)
163
+ .description('#720 Send an alert digest every N hours (0 disables digests).'),
160
164
  alertThresholds: z.array(z.number()).volatile().default([80, 90, 95])
161
165
  .description('#351 Quota usage percentage thresholds for firing alerts.'),
162
166
  enableToasts: z.boolean().volatile().default(true)
package/lib/index.js CHANGED
@@ -28,7 +28,8 @@ import { createAccountsView } from './accounts-view.js'
28
28
  import { inspectScopes } from './scope-inspector.js'
29
29
  import { createAdapterManager } from './adapter-manager.js'
30
30
  import { pickAccount, isAuthError } from './rotate.js'
31
- import { notifySessionExpired } from './alerts.js'
31
+ import { notifySessionExpired, getRecentAlerts, dispatchNotificationDigest, setWebhookSecret } from './alerts.js'
32
+ import { isDigestDue } from './notification-scheduler.js'
32
33
 
33
34
  export { observeForecast, estimateForecast } from './forecast.js'
34
35
  export { formatRelativeReset } from './relative-time.js'
@@ -91,7 +92,10 @@ export function apply(ctx, rawConfig) {
91
92
  return plainConfig(Config(plainConfig(rawConfig || {})))
92
93
  }
93
94
  let cachedConfig = resolveEntry()
94
- const live = () => cachedConfig
95
+ const live = () => {
96
+ setWebhookSecret(cachedConfig.webhookSecret)
97
+ return cachedConfig
98
+ }
95
99
 
96
100
  function syncCustomVendors() {
97
101
  const profiles = validateProfiles(live().customVendors)
@@ -604,6 +608,22 @@ export function apply(ctx, rawConfig) {
604
608
  }
605
609
  }, 'dsh-subscriptions: vault-sync-loop')
606
610
 
611
+ // #720: interval digest for busy webhook channels.
612
+ ctx.effect(() => {
613
+ let lastDigestAt = 0
614
+ const tick = () => {
615
+ const cfg = live()
616
+ const alerts = getRecentAlerts(100)
617
+ if (!isDigestDue({ lastDigestAt, nowMs: Date.now(), intervalHours: cfg.digestIntervalHours, alertCount: alerts.length })) return
618
+ dispatchNotificationDigest(alerts, cfg.webhookUrl, fetch, { secret: cfg.webhookSecret })
619
+ .then((ok) => { if (ok) lastDigestAt = Date.now() })
620
+ .catch(() => {})
621
+ }
622
+ tick()
623
+ const id = setInterval(tick, 60 * 60 * 1000)
624
+ return () => clearInterval(id)
625
+ }, 'dsh-subscriptions: notification digest')
626
+
607
627
  registerRoutes(ctx, {
608
628
  NS,
609
629
  live,
@@ -41,19 +41,62 @@ export function summarizeKeyRotationStatus(raw) {
41
41
  return { available: true, providers, totalKeys, healthyKeys }
42
42
  }
43
43
 
44
- export async function readKeyRotationStatus({ baseUrl, path = KEY_ROTATION_STATUS_PATH, fetchImpl = fetch, timeoutMs = 2000 } = {}) {
44
+ // #726: a short TTL keeps the widget, settings panel and doctor from
45
+ // multiplying key-rotation status reads. Errors are cached for the same TTL.
46
+ let statusCache = { key: '', at: 0, value: null }
47
+ let inFlight = null
48
+
49
+ export function clearKeyRotationCache() {
50
+ statusCache = { key: '', at: 0, value: null }
51
+ inFlight = null
52
+ }
53
+
54
+ export async function readKeyRotationStatus({ baseUrl, path = KEY_ROTATION_STATUS_PATH, fetchImpl = fetch, timeoutMs = 2000, cacheTtlMs = 10000, bypassCache = false } = {}) {
45
55
  if (!baseUrl) return { available: false, reason: 'missing base url', providers: [], totalKeys: 0, healthyKeys: 0 }
56
+ const url = String(baseUrl).replace(/\/$/, '') + path
57
+ if (!bypassCache && statusCache.key === url && (Date.now() - statusCache.at) < Number(cacheTtlMs)) {
58
+ return statusCache.value
59
+ }
60
+ if (!bypassCache && inFlight && inFlight.key === url) return inFlight.promise
61
+ const promise = (async () => {
46
62
  try {
47
63
  const init = { method: 'GET', headers: { accept: 'application/json' } }
48
64
  if (timeoutMs > 0 && typeof AbortSignal !== 'undefined' && typeof AbortSignal.timeout === 'function') {
49
65
  init.signal = AbortSignal.timeout(timeoutMs)
50
66
  }
51
- const res = await fetchImpl(String(baseUrl).replace(/\/$/, '') + path, init)
52
- if (!res || !res.ok) return { available: false, reason: 'HTTP ' + (res && res.status), providers: [], totalKeys: 0, healthyKeys: 0 }
67
+ const res = await fetchImpl(url, init)
68
+ if (!res || !res.ok) {
69
+ const value = { available: false, reason: 'HTTP ' + (res && res.status), providers: [], totalKeys: 0, healthyKeys: 0 }
70
+ statusCache = { key: url, at: Date.now(), value }
71
+ return value
72
+ }
53
73
  const raw = await res.json()
54
- return summarizeKeyRotationStatus(raw)
74
+ const value = summarizeKeyRotationStatus(raw)
75
+ statusCache = { key: url, at: Date.now(), value }
76
+ return value
55
77
  } catch (err) {
56
- return { available: false, reason: String(err && err.message || err), providers: [], totalKeys: 0, healthyKeys: 0 }
78
+ const value = { available: false, reason: String(err && err.message || err), providers: [], totalKeys: 0, healthyKeys: 0 }
79
+ statusCache = { key: url, at: Date.now(), value }
80
+ return value
81
+ }
82
+ })()
83
+ inFlight = { key: url, promise }
84
+ try {
85
+ return await promise
86
+ } finally {
87
+ if (inFlight && inFlight.promise === promise) inFlight = null
88
+ }
89
+ }
90
+
91
+ export function subscriptionCandidate(row) {
92
+ const value = row && typeof row === 'object' ? row : {}
93
+ return {
94
+ provider: value.provider,
95
+ index: value.index,
96
+ label: value.label || null,
97
+ loggedIn: Boolean(value.configured ?? value.hasToken),
98
+ usagePercent: value.usagePercent == null ? null : Number(value.usagePercent),
99
+ cooldownUntil: value.cooldownUntil == null ? null : Number(value.cooldownUntil),
57
100
  }
58
101
  }
59
102
 
@@ -0,0 +1,7 @@
1
+ // #720: digest scheduling is a pure decision so it can be tested without timers.
2
+ export function isDigestDue({ lastDigestAt = 0, nowMs = Date.now(), intervalHours = 0, alertCount = 0 } = {}) {
3
+ if (!(Number(intervalHours) > 0)) return false
4
+ if (!(Number(alertCount) > 0)) return false
5
+ if (!lastDigestAt) return true
6
+ return Number(nowMs) - Number(lastDigestAt) >= Number(intervalHours) * 60 * 60 * 1000
7
+ }
@@ -0,0 +1,41 @@
1
+ import { isTrustedSettingsRequest, readBody, safeJsonHandler, writeJson } from '../http.js'
2
+ import { normalizeVendorPack } from '../vendor-pack.js'
3
+
4
+ export function registerPacksRoutes(ctx) {
5
+ ctx.effect(() => ctx.webServer.register({
6
+ kind: 'exact',
7
+ path: '/dsh-subscriptions/provider-packs/validate',
8
+ handler: safeJsonHandler(async (req, res) => {
9
+ if (req.method !== 'POST') {
10
+ writeJson(res, 405, { ok: false, error: { code: 'method', message: 'POST only' } })
11
+ return
12
+ }
13
+ if (!isTrustedSettingsRequest(req, ctx)) {
14
+ writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'same-origin only' } })
15
+ return
16
+ }
17
+ let body
18
+ try { body = JSON.parse((await readBody(req, 64 * 1024)).toString('utf8') || '{}') } catch {
19
+ writeJson(res, 400, { ok: false, error: { code: 'json', message: 'invalid json' } })
20
+ return
21
+ }
22
+ const raw = body && body.pack && typeof body.pack === 'object' ? body.pack : body
23
+ try {
24
+ const pack = normalizeVendorPack(raw)
25
+ writeJson(res, 200, {
26
+ ok: true,
27
+ pack: {
28
+ id: pack.id,
29
+ displayName: pack.displayName,
30
+ baseUrl: pack.baseUrl,
31
+ models: pack.models,
32
+ scope: pack.scope,
33
+ tokenStyle: pack.tokenStyle,
34
+ },
35
+ })
36
+ } catch (err) {
37
+ writeJson(res, 400, { ok: false, error: { code: 'pack', message: String(err && err.message || err) } })
38
+ }
39
+ }),
40
+ }), 'dsh-subscriptions: /provider-packs/validate')
41
+ }
@@ -1,5 +1,5 @@
1
1
  import { isTrustedSettingsRequest, safeJsonHandler, writeJson } from '../http.js'
2
- import { buildSourceChain, readKeyRotationStatus } from '../key-rotation-bridge.js'
2
+ import { buildSourceChain, readKeyRotationStatus, subscriptionCandidate } from '../key-rotation-bridge.js'
3
3
 
4
4
  export function registerSourcesRoutes(ctx, state) {
5
5
  const { accountsView, live } = state
@@ -19,14 +19,7 @@ export function registerSourcesRoutes(ctx, state) {
19
19
  let subscriptions = []
20
20
  try {
21
21
  const rows = await accountsView()
22
- subscriptions = (rows || []).map((row) => ({
23
- provider: row.provider,
24
- index: row.index,
25
- label: row.label,
26
- loggedIn: Boolean(row.hasToken),
27
- usagePercent: row.usagePercent,
28
- cooldownUntil: row.cooldownUntil,
29
- }))
22
+ subscriptions = (rows || []).map(subscriptionCandidate)
30
23
  } catch { /* keep the chain available */ }
31
24
  const port = req.socket && req.socket.localPort
32
25
  const keyRotation = port
package/lib/routes.js CHANGED
@@ -6,6 +6,7 @@ import { registerOauthRoutes } from './routes/oauth.js'
6
6
  import { registerAccountsRoutes } from './routes/accounts.js'
7
7
  import { registerProxyRoutes } from './routes/proxy.js'
8
8
  import { registerSourcesRoutes } from './routes/sources.js'
9
+ import { registerPacksRoutes } from './routes/packs.js'
9
10
  import { registerPluginUpdater } from './updater.js'
10
11
 
11
12
  export function registerRoutes(ctx, state) {
@@ -111,4 +112,5 @@ function esc(x){return String(x==null?'':x).replace(/&/g,'&amp;').replace(/</g,'
111
112
  registerAccountsRoutes(ctx, state)
112
113
  registerProxyRoutes(ctx, state)
113
114
  registerSourcesRoutes(ctx, state)
115
+ registerPacksRoutes(ctx)
114
116
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-subscriptions",
3
- "version": "0.7.4",
3
+ "version": "0.7.6",
4
4
  "description": "Use ChatGPT Codex, Claude, Grok, Antigravity, Kimi, GLM, Cursor, Kiro, Copilot, Qwen, ERNIE, Spark, JetBrains, Perplexity, Replit, and Cody subscriptions as DeepSeek Harness LLM providers via OAuth.",
5
5
  "license": "MIT",
6
6
  "type": "module",