@goodandready/dsh-subscriptions 0.7.5 → 0.7.7

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
@@ -1,5 +1,6 @@
1
1
  import { readFileSync } from 'node:fs'
2
2
  import { createHmac } from 'node:crypto'
3
+ import { validateWebhookUrl } from './webhook-url.js'
3
4
 
4
5
  let pkgVersion = ''
5
6
  try {
@@ -13,6 +14,16 @@ try {
13
14
  // and webhook dispatch (e.g. Telegram/Discord or external monitoring).
14
15
 
15
16
  const MAX_ALERTS = 100
17
+ let defaultWebhookSecret = ''
18
+ let defaultAllowPrivate = false
19
+
20
+ export function setWebhookSecret(secret) {
21
+ defaultWebhookSecret = String(secret || '')
22
+ }
23
+
24
+ export function setWebhookAllowPrivate(allow) {
25
+ defaultAllowPrivate = Boolean(allow)
26
+ }
16
27
  const DEDUP_WINDOW_MS = 10 * 60 * 1000 // 10 minutes cooldown per (ref + type)
17
28
 
18
29
  const alertBuffer = []
@@ -62,9 +73,7 @@ export function getRecentAlerts(limit = 50) {
62
73
 
63
74
  // #696: signed, retrying webhook delivery plus a digest endpoint payload.
64
75
  export async function dispatchWebhookAlert(alert, webhookUrl, fetchImpl = fetch, options = {}) {
65
- if (!alert || !webhookUrl || typeof webhookUrl !== 'string' || !webhookUrl.startsWith('http')) {
66
- return false
67
- }
76
+ if (!alert || !webhookUrl) return false
68
77
  return dispatchNotification({ event: 'subscription_alert', alert }, webhookUrl, fetchImpl, options)
69
78
  }
70
79
 
@@ -93,7 +102,13 @@ export async function dispatchNotificationDigest(alerts, webhookUrl, fetchImpl =
93
102
  }
94
103
 
95
104
  async function dispatchNotification(body, webhookUrl, fetchImpl, options) {
96
- const { secret = '', retries = 1, timeoutMs = 5000, sleep } = options || {}
105
+ const opts = options || {}
106
+ const { retries = 1, timeoutMs = 5000, sleep } = opts
107
+ const secret = opts.secret == null ? defaultWebhookSecret : opts.secret
108
+ const allowPrivate = opts.allowPrivate == null ? defaultAllowPrivate : Boolean(opts.allowPrivate)
109
+ const checked = validateWebhookUrl(webhookUrl, { allowPrivate })
110
+ if (!checked.ok) return false
111
+ webhookUrl = checked.url
97
112
  const payload = { ...body, source: 'dsh-subscriptions', sentAt: new Date().toISOString() }
98
113
  const raw = JSON.stringify(payload)
99
114
  const headers = {
package/lib/client.js CHANGED
@@ -40,7 +40,7 @@ window.__ModuleLoader__.load({
40
40
 
41
41
 
42
42
  const CSS =
43
- ".dsub-card{list-style:none;border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-3);border-radius:12px;transition:border-color .16s,background .16s}.dsub-card:hover{border-color:var(--dsw-alias-label-dimmed)}.dsub-cardOpen{background:var(--dsw-alias-bg-layer-2);border-color:var(--dsw-alias-label-dimmed)}.dsub-header{appearance:none;width:100%;font:inherit;color:inherit;text-align:left;cursor:pointer;background:0 0;border:0;border-radius:12px;align-items:center;gap:12px;padding:14px 16px;display:flex}.dsub-header:focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:-2px}.dsub-headText{flex-direction:column;flex:1;gap:4px;min-width:0;display:flex}.dsub-name{color:var(--dsw-alias-label-primary);font-size:15px;font-weight:600;line-height:1.4}.dsub-description{color:var(--dsw-alias-label-secondary);font-size:13px}.dsub-chev{color:var(--dsw-alias-label-tertiary);flex:none;transition:transform .16s}.dsub-chevOpen{transform:rotate(180deg)}.dsub-body{border-top:1px solid var(--dsw-alias-border-l2);margin:0 16px;padding-bottom:12px}.dsub-block{margin-top:10px}.dsub-group{margin-top:12px;border:1px solid var(--dsw-alias-border-l2);border-radius:10px;padding:0 14px}.dsub-group[open]{padding-bottom:6px}.dsub-groupHead{cursor:pointer;font-size:13px;font-weight:600;color:var(--dsw-alias-label-primary);padding:10px 0;list-style:none}.dsub-groupHead::-webkit-details-marker{display:none}.dsub-row{display:flex;align-items:center;flex-wrap:wrap;gap:8px;margin-top:8px}.dsub-grow{flex:1 1 180px;min-width:140px}.dsub-helpBox{margin-top:8px;padding:12px 14px;border-radius:8px;background:var(--dsw-alias-bg-layer-1);border:1px solid var(--dsw-alias-border-l1);font-size:12px;line-height:1.6}.dsub-helpHead{display:flex;align-items:center;justify-content:space-between;font-weight:600;margin-bottom:8px;color:var(--dsw-alias-brand-primary)}.dsub-helpStep{margin-bottom:6px;color:var(--dsw-alias-label-primary)}.dsub-btnActive{background:var(--dsw-alias-brand-primary);color:var(--dsw-alias-label-primary-foreground);border-color:var(--dsw-alias-brand-primary)}.dsub-h{font-size:13.5px;font-weight:600;color:var(--dsw-alias-label-primary)}.dsub-sub{font-size:12px;color:var(--dsw-alias-label-secondary)}.dsub-dim{font-size:11.5px;color:var(--dsw-alias-label-tertiary)}.dsub-mini{appearance:none;font:inherit;font-size:12px;cursor:pointer;border:1px solid var(--dsw-alias-border-l2);border-radius:6px;padding:3px 8px;background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-primary);transition:all .15s}.dsub-mini:hover{background:var(--dsw-alias-bg-layer-1);border-color:var(--dsw-alias-label-dimmed)}.dsub-ok{font-size:12px;color:var(--dsw-alias-state-success-primary)}.dsub-bad{font-size:12px;color:var(--dsw-alias-state-error-primary);overflow-wrap:anywhere;word-break:break-word;max-width:100%}.dsub-hint{overflow-wrap:anywhere;word-break:break-word;max-width:100%}.dsub-btn{appearance:none;font:inherit;cursor:pointer;border:1px solid var(--dsw-alias-border-l2);border-radius:8px;padding:5px 12px;font-size:12.5px;font-weight:500;background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-primary);transition:all .15s}.dsub-btn:hover{background:var(--dsw-alias-bg-layer-1);border-color:var(--dsw-alias-label-dimmed)}.dsub-inp{height:30px;border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-3);color:var(--dsw-alias-label-primary);border-radius:6px;padding:0 8px;font-size:12px;min-width:180px}.dsub-inp:focus{outline:none;border-color:var(--dsw-alias-brand-primary)}.dsub-box{border:1px solid var(--dsw-alias-border-l2);border-radius:8px;padding:10px;margin-top:8px;background:var(--dsw-alias-bg-layer-2)}.dsub-cooldown{display:inline-block;padding:2px 6px;border-radius:4px;background:var(--dsw-alias-state-warn-primary);color:var(--dsw-alias-bg-layer-1);font-size:11px;font-weight:600}.dsub-bar{position:relative;height:6px;border-radius:3px;background:var(--dsw-alias-bg-layer-1);overflow:hidden;flex:1;min-width:80px}.dsub-barFill{height:100%;border-radius:3px;background:var(--dsw-alias-state-success-primary);transition:width .2s}.dsub-barWarn .dsub-barFill{background:var(--dsw-alias-state-warn-primary)}.dsub-barFull .dsub-barFill{background:var(--dsw-alias-state-error-primary)}.dsub-barRow{display:flex;align-items:center;gap:8px;font-size:12px;color:var(--dsw-alias-label-secondary)}.dsub-manual{display:flex;flex-direction:column;gap:6px;margin-top:4px;padding-top:10px;border-top:1px solid var(--dsw-alias-border-l2)}.dsub-verify{font-size:12px;color:var(--dsw-alias-state-warn-primary)}.dsub-verify a{color:var(--dsw-alias-brand-primary)}.dsub-pill{display:inline-flex;align-items:center;gap:7px;height:28px;padding:0 11px;border:1px solid var(--dsw-alias-border-l2);border-radius:999px;background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-primary);font-size:12px;font-weight:600;cursor:pointer;box-shadow:0 1px 3px color-mix(in srgb,black 15%,transparent),inset 0 1px 0 color-mix(in srgb,white 6%,transparent);transition:all .16s ease;user-select:none}.dsub-pill:hover{background:var(--dsw-alias-bg-layer-3);border-color:var(--dsw-alias-label-tertiary);transform:translateY(-1px);box-shadow:0 3px 8px color-mix(in srgb,black 25%,transparent),inset 0 1px 0 color-mix(in srgb,white 10%,transparent)}.dsub-pill:active{transform:translateY(0)}.dsub-pillTag{padding:1px 5px;border-radius:4px;font-size:10.5px;font-weight:700;letter-spacing:.02em;background:var(--dsw-alias-bg-layer-1);color:var(--dsw-alias-label-secondary)}.dsub-led{width:7px;height:7px;border-radius:50%;flex:none;transition:all .2s ease}.dsub-ledOk{background:var(--dsw-alias-state-success-primary);box-shadow:0 0 6px color-mix(in srgb,var(--dsw-alias-state-success-primary) 70%,transparent)}.dsub-ledWarn{background:var(--dsw-alias-state-warn-primary);box-shadow:0 0 6px color-mix(in srgb,var(--dsw-alias-state-warn-primary) 70%,transparent)}.dsub-ledBad{background:var(--dsw-alias-state-error-primary);box-shadow:0 0 6px color-mix(in srgb,var(--dsw-alias-state-error-primary) 70%,transparent)}.dsub-ledOff{background:var(--dsw-alias-label-tertiary)}.dsub-toast-container{position:fixed;top:16px;right:16px;z-index:10030;display:flex;flex-direction:column;gap:8px;pointer-events:none}.dsub-toast{pointer-events:auto;min-width:280px;max-width:400px;padding:10px 14px;border-radius:10px;background:var(--dsw-alias-bg-layer-3);color:var(--dsw-alias-label-primary);border:1px solid var(--dsw-alias-border-l1);box-shadow:0 8px 24px color-mix(in srgb,black 40%,transparent);display:flex;align-items:center;justify-content:space-between;gap:10px;font-size:12px;line-height:1.4;animation:dsubFadeIn .2s ease-out}.dsub-toast-warn{border-color:var(--dsw-alias-state-warn-primary)}.dsub-toast-bad{border-color:var(--dsw-alias-state-error-primary)}.dsub-toast-ok{border-color:var(--dsw-alias-state-success-primary)}.dsub-toast-close{background:none;border:none;color:var(--dsw-alias-label-tertiary);cursor:pointer;font-size:14px;padding:0 2px}.dsub-toast-close:hover{color:var(--dsw-alias-label-primary)}.dsub-modalWrap{position:fixed;inset:0;z-index:10020;display:grid;place-items:center;background:color-mix(in srgb,black 65%,transparent);backdrop-filter:blur(10px);animation:dsubFadeIn .16s ease-out}.dsub-modal{width:min(520px,94vw);max-height:min(85vh,720px);overflow-y:auto;border:1px solid var(--dsw-alias-border-l1);border-radius:16px;background:var(--dsw-alias-bg-layer-3);color:var(--dsw-alias-label-primary);box-shadow:0 24px 64px color-mix(in srgb,black 60%,transparent),0 0 0 1px color-mix(in srgb,white 7%,transparent);padding:20px;animation:dsubScaleIn .18s cubic-bezier(.16,1,.3,1)}@keyframes dsubFadeIn{from{opacity:0}to{opacity:1}}@keyframes dsubScaleIn{from{opacity:0;transform:scale(.96)}to{opacity:1;transform:scale(1)}}.dsub-modalHead{display:flex;align-items:center;justify-content:space-between;gap:12px;padding-bottom:14px;border-bottom:1px solid var(--dsw-alias-border-l2)}.dsub-modalTitleWrap{display:flex;align-items:center;gap:10px}.dsub-modalIcon{width:30px;height:30px;border-radius:8px;background:var(--dsw-alias-bg-layer-1);display:grid;place-items:center;color:var(--dsw-alias-brand-primary);font-size:14px}.dsub-modalBadge{font-size:11px;font-weight:600;padding:2px 7px;border-radius:10px;background:var(--dsw-alias-bg-layer-1);color:var(--dsw-alias-label-secondary)}.dsub-heroCard{margin-top:14px;padding:14px 16px;border-radius:12px;background:linear-gradient(135deg,var(--dsw-alias-bg-layer-2) 0%,var(--dsw-alias-bg-layer-3) 100%);border:1px solid var(--dsw-alias-border-l1);box-shadow:inset 0 1px 0 color-mix(in srgb,white 5%,transparent)}.dsub-heroHead{display:flex;align-items:center;justify-content:space-between;margin-bottom:10px}.dsub-heroTitle{font-size:14px;font-weight:700;display:flex;align-items:center;gap:6px}.dsub-heroModel{font-size:11.5px;color:var(--dsw-alias-label-secondary);font-family:ui-monospace,monospace}.dsub-select{appearance:none;height:30px;border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-3);color:var(--dsw-alias-label-primary);border-radius:6px;padding:0 24px 0 8px;font-size:12px;cursor:pointer}.dsub-details{margin-top:12px;border:1px solid var(--dsw-alias-border-l2);border-radius:8px;padding:8px 12px;background:var(--dsw-alias-bg-layer-2)}.dsub-summary{cursor:pointer;font-weight:600;font-size:12.5px;color:var(--dsw-alias-label-secondary);user-select:none}.dsub-summary:hover{color:var(--dsw-alias-label-primary)}.dsub-header-bar{display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:8px;padding:8px 0 12px;margin-bottom:8px;border-bottom:1px solid var(--dsw-alias-border-l2)}.dsub-badge{font-size:12px;padding:3px 10px;border-radius:999px;border:1px solid var(--dsw-alias-border-l2);display:inline-flex;align-items:center;gap:5px;font-weight:500}.dsub-badge-ok{border-color:var(--dsw-alias-state-success-primary);color:var(--dsw-alias-state-success-primary);background:color-mix(in srgb,var(--dsw-alias-state-success-primary) 10%,transparent)}.dsub-badge-warn{border-color:var(--dsw-alias-state-warn-primary);color:var(--dsw-alias-state-warn-primary);background:color-mix(in srgb,var(--dsw-alias-state-warn-primary) 10%,transparent)}.dsub-badge-bad{border-color:var(--dsw-alias-state-error-primary);color:var(--dsw-alias-state-error-primary);background:color-mix(in srgb,var(--dsw-alias-state-error-primary) 10%,transparent)}.dsub-badge-neutral{border-color:var(--dsw-alias-border-l2);color:var(--dsw-alias-label-secondary);background:var(--dsw-alias-bg-layer-2)}.dsub-section-card{border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-3);border-radius:12px;padding:14px 16px;display:flex;flex-direction:column;gap:10px;margin-top:12px}.dsub-section-title{font-size:14.5px;font-weight:600;color:var(--dsw-alias-label-primary);display:flex;align-items:center;justify-content:space-between;gap:8px}.dsub-section-desc{font-size:12px;color:var(--dsw-alias-label-secondary);margin-top:-4px;line-height:1.4}.dsub-grid-2{display:grid;grid-template-columns:repeat(auto-fit,minmax(200px,1fr));gap:10px}.dsub-stat-box{padding:10px 12px;border:1px solid var(--dsw-alias-border-l2);border-radius:8px;background:var(--dsw-alias-bg-layer-2);display:flex;flex-direction:column;gap:3px}.dsub-stat-val{font-size:16.5px;font-weight:700;color:var(--dsw-alias-label-primary)}.dsub-stat-lbl{font-size:11.5px;color:var(--dsw-alias-label-secondary)}.dsub-chart-wrap{display:flex;align-items:flex-end;gap:3px;height:52px;padding:6px 0;margin-top:8px;border-bottom:1px solid var(--dsw-alias-border-l3)}.dsub-chart-bar{flex:1;background:var(--dsw-alias-brand-primary);border-radius:2px 2px 0 0;min-height:3px;transition:height .2s ease;opacity:0.85;cursor:pointer}.dsub-chart-bar:hover{opacity:1;background:var(--dsw-alias-state-success-primary)}.dsub-chart-labels{display:flex;justify-content:space-between;font-size:10px;color:var(--dsw-alias-label-secondary);margin-top:3px}.dsub-alert-ok{padding:8px 12px;border-radius:8px;background:color-mix(in srgb,var(--dsw-alias-state-success-primary) 10%,transparent);color:var(--dsw-alias-state-success-primary);font-size:12.5px;border:1px solid color-mix(in srgb,var(--dsw-alias-state-success-primary) 25%,transparent)}.dsub-alert-bad{padding:8px 12px;border-radius:8px;background:color-mix(in srgb,var(--dsw-alias-state-error-primary) 10%,transparent);color:var(--dsw-alias-state-error-primary);font-size:12.5px;border:1px solid color-mix(in srgb,var(--dsw-alias-state-error-primary) 25%,transparent)}.dsub-btn-primary{background:var(--dsw-alias-label-primary) !important;color:var(--dsw-alias-bg-layer-3) !important;border-color:transparent !important;font-weight:600}.dsub-btn-primary:hover:not(:disabled){opacity:0.9}.dsub-model-grid{display:flex;flex-wrap:wrap;gap:6px;margin-top:6px}.dsub-model-chip{display:inline-flex;align-items:center;gap:6px;padding:3px 8px;border-radius:6px;background:var(--dsw-alias-bg-layer-1);border:1px solid var(--dsw-alias-border-l2);font-size:11.5px;color:var(--dsw-alias-label-primary)}.dsub-model-chip code{font-family:ui-monospace,monospace;font-weight:600}.dsub-model-tag{font-size:10px;padding:1px 4px;border-radius:3px;background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-secondary);font-weight:600}.dsub-trace-box{padding:8px 12px;border-radius:8px;background:var(--dsw-alias-bg-layer-2);border:1px solid var(--dsw-alias-border-l2);margin-top:8px;font-size:12px}.dsub-trace-row{display:flex;align-items:center;justify-content:space-between;gap:8px;padding:4px 0;border-bottom:1px solid var(--dsw-alias-border-l3)}.dsub-trace-row:last-child{border-bottom:none}.dsub-model-status-available{color:var(--dsw-alias-state-success-primary);background:color-mix(in srgb,var(--dsw-alias-state-success-primary) 12%,transparent)}.dsub-model-status-degraded{color:var(--dsw-alias-state-warn-primary);background:color-mix(in srgb,var(--dsw-alias-state-warn-primary) 12%,transparent)}.dsub-model-status-quarantined{color:var(--dsw-alias-state-error-primary);background:color-mix(in srgb,var(--dsw-alias-state-error-primary) 12%,transparent)}.dsub-model-status-cooldown{color:var(--dsw-alias-state-business-primary);background:color-mix(in srgb,var(--dsw-alias-state-business-primary) 12%,transparent)}.dsub-model-status-exhausted{color:var(--dsw-alias-state-error-primary);background:color-mix(in srgb,var(--dsw-alias-state-error-primary) 12%,transparent)}.dsub-model-status-unconfigured{color:var(--dsw-alias-label-tertiary);background:var(--dsw-alias-bg-layer-3)}.dsub-heroBars{display:flex;flex-direction:column;gap:8px;margin-top:10px}.dsub-barLabelRow{display:flex;justify-content:space-between;font-size:11.5px;color:var(--dsw-alias-label-secondary);margin-bottom:3px}.dsub-barTrack{height:6px;border-radius:3px;background:var(--dsw-alias-bg-layer-1);overflow:hidden}.dsub-barFillGrad{height:100%;border-radius:3px;transition:width .3s ease}.dsub-panel-box{margin-top:10px;padding:12px 14px;border-radius:10px;border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-2);display:flex;flex-direction:column;gap:8px}.dsub-panel-warn{border-color:color-mix(in srgb,var(--dsw-alias-state-warn-primary) 40%,transparent);background:color-mix(in srgb,var(--dsw-alias-state-warn-primary) 5%,transparent)}.dsub-panel-title{font-size:13px;font-weight:600;color:var(--dsw-alias-label-primary);display:flex;align-items:center;justify-content:space-between}.dsub-panel-desc{font-size:12px;color:var(--dsw-alias-label-secondary);line-height:1.4}.dsub-usage-grid{display:flex;flex-direction:column;gap:8px;margin-top:4px}.dsub-usage-card{display:flex;flex-direction:column;gap:5px;padding:8px 10px;border:1px solid var(--dsw-alias-border-l2);border-radius:8px;background:var(--dsw-alias-bg-layer-1)}.dsub-usage-head{display:flex;justify-content:space-between;align-items:center;font-size:12px;font-weight:500;color:var(--dsw-alias-label-primary)}.dsub-usage-track{width:100%;height:8px;border-radius:999px;background:var(--dsw-alias-bg-layer-3);border:1px solid var(--dsw-alias-border-l2);overflow:hidden}.dsub-usage-fill{height:100%;border-radius:999px;transition:width .3s ease}.dsub-usage-meta{display:flex;justify-content:space-between;align-items:center;font-size:11px;color:var(--dsw-alias-label-secondary)}.dsub-tag-ok{border-color:color-mix(in srgb,var(--dsw-alias-state-success-primary) 30%,transparent);color:var(--dsw-alias-state-success-primary);background:color-mix(in srgb,var(--dsw-alias-state-success-primary) 8%,transparent);padding:1px 7px;border-radius:999px;font-size:11px;font-weight:600;border-style:solid;border-width:1px}.dsub-tag-warn{border-color:color-mix(in srgb,var(--dsw-alias-state-warn-primary) 40%,transparent);color:var(--dsw-alias-state-warn-primary);background:color-mix(in srgb,var(--dsw-alias-state-warn-primary) 8%,transparent);padding:1px 7px;border-radius:999px;font-size:11px;font-weight:600;border-style:solid;border-width:1px}.dsub-tag-bad{border-color:color-mix(in srgb,var(--dsw-alias-state-error-primary) 40%,transparent);color:var(--dsw-alias-state-error-primary);background:color-mix(in srgb,var(--dsw-alias-state-error-primary) 8%,transparent);padding:1px 7px;border-radius:999px;font-size:11px;font-weight:600;border-style:solid;border-width:1px}.dsub-slot-meta-row{display:flex;flex-wrap:wrap;align-items:center;gap:10px;margin-top:12px;padding-top:10px;border-top:1px solid var(--dsw-alias-border-l2);font-size:12px}.dsub-ref-tag{display:inline-flex;align-items:center;gap:5px;font-size:11.5px;color:var(--dsw-alias-label-secondary)}.dsub-ref-tag code{padding:2px 6px;border-radius:4px;background:var(--dsw-alias-bg-layer-1);border:1px solid var(--dsw-alias-border-l2);font-family:ui-monospace,monospace;color:var(--dsw-alias-label-primary);font-size:11px}.dsub-speed-tag{display:inline-flex;align-items:center;gap:4px;font-size:11.5px;color:var(--dsw-alias-brand-primary);background:color-mix(in srgb,var(--dsw-alias-brand-primary) 8%,transparent);padding:2px 8px;border-radius:6px;font-weight:500;border:1px solid color-mix(in srgb,var(--dsw-alias-brand-primary) 20%,transparent)}.dsub-btn-danger{border-color:color-mix(in srgb,var(--dsw-alias-state-error-primary) 40%,transparent)!important;color:var(--dsw-alias-state-error-primary)!important}.dsub-btn-danger:hover:not(:disabled){background:color-mix(in srgb,var(--dsw-alias-state-error-primary) 12%,transparent)!important}.dsub-poolTitle{margin-top:16px;margin-bottom:8px;font-size:11.5px;font-weight:700;color:var(--dsw-alias-label-secondary);text-transform:uppercase;letter-spacing:.05em}.dsub-accountCard{display:flex;align-items:center;gap:12px;padding:10px 14px;border:1px solid var(--dsw-alias-border-l2);border-radius:10px;background:var(--dsw-alias-bg-layer-2);margin-top:6px;transition:border-color .15s,background .15s}.dsub-accountCard:hover{background:var(--dsw-alias-bg-layer-3);border-color:var(--dsw-alias-label-tertiary)}.dsub-brandBadge{width:32px;height:32px;border-radius:8px;display:grid;place-items:center;font-size:12px;font-weight:700;flex-shrink:0}.dsub-brandCodex{background:color-mix(in srgb,var(--dsw-alias-state-success-primary) 15%,transparent);color:var(--dsw-alias-state-success-primary);border:1px solid color-mix(in srgb,var(--dsw-alias-state-success-primary) 30%,transparent)}.dsub-brandClaude{background:color-mix(in srgb,var(--dsw-alias-state-warn-primary) 15%,transparent);color:var(--dsw-alias-state-warn-primary);border:1px solid color-mix(in srgb,var(--dsw-alias-state-warn-primary) 30%,transparent)}.dsub-brandGrok{background:color-mix(in srgb,var(--dsw-alias-state-business-primary,var(--dsw-alias-brand-primary)) 15%,transparent);color:var(--dsw-alias-state-business-primary,var(--dsw-alias-brand-primary));border:1px solid color-mix(in srgb,var(--dsw-alias-state-business-primary,var(--dsw-alias-brand-primary)) 30%,transparent)}.dsub-brandAgy{background:color-mix(in srgb,var(--dsw-alias-brand-primary) 15%,transparent);color:var(--dsw-alias-brand-primary);border:1px solid color-mix(in srgb,var(--dsw-alias-brand-primary) 30%,transparent)}.dsub-brandOllama{background:color-mix(in srgb,var(--dsw-alias-label-tertiary) 15%,transparent);color:var(--dsw-alias-label-secondary);border:1px solid color-mix(in srgb,var(--dsw-alias-label-tertiary) 30%,transparent)}.dsub-brandKimi{background:color-mix(in srgb,var(--dsw-alias-state-warn-primary) 15%,transparent);color:var(--dsw-alias-state-warn-primary);border:1px solid color-mix(in srgb,var(--dsw-alias-state-warn-primary) 30%,transparent)}.dsub-brandGlm{background:color-mix(in srgb,var(--dsw-alias-state-business-primary,var(--dsw-alias-brand-primary)) 15%,transparent);color:var(--dsw-alias-state-business-primary,var(--dsw-alias-brand-primary));border:1px solid color-mix(in srgb,var(--dsw-alias-state-business-primary,var(--dsw-alias-brand-primary)) 30%,transparent)}.dsub-planBadge{display:inline-flex;align-items:center;padding:1px 6px;border-radius:4px;font-size:10px;font-weight:600;text-transform:uppercase;letter-spacing:0.5px;background:color-mix(in srgb,var(--dsw-alias-brand-primary) 15%,transparent);color:var(--dsw-alias-brand-primary);border:1px solid color-mix(in srgb,var(--dsw-alias-brand-primary) 30%,transparent);margin-left:6px}.dsub-accInfo{flex:1;min-width:0;display:flex;flex-direction:column;gap:3px}.dsub-accNameRow{display:flex;align-items:center;justify-content:space-between;gap:8px}.dsub-accName{font-size:13px;font-weight:600}.dsub-accStatusTag{font-size:10.5px;font-weight:600;padding:1px 6px;border-radius:4px}.dsub-statusOk{background:color-mix(in srgb,var(--dsw-alias-state-success-primary) 15%,transparent);color:var(--dsw-alias-state-success-primary)}.dsub-statusWarn{background:color-mix(in srgb,var(--dsw-alias-state-warn-primary) 15%,transparent);color:var(--dsw-alias-state-warn-primary)}.dsub-statusBad{background:color-mix(in srgb,var(--dsw-alias-state-error-primary) 15%,transparent);color:var(--dsw-alias-state-error-primary)}.dsub-statusOff{background:var(--dsw-alias-bg-layer-1);color:var(--dsw-alias-label-tertiary)}.dsub-modalFoot{display:flex;align-items:center;justify-content:space-between;margin-top:16px;padding-top:12px;border-top:1px solid var(--dsw-alias-border-l2)}.dsub-btnPrimary{display:inline-flex;align-items:center;gap:6px;padding:6px 12px;border-radius:8px;font-size:12px;font-weight:600;background:var(--dsw-alias-brand-primary);color:var(--dsw-alias-label-primary-foreground);border:0;cursor:pointer;transition:opacity .15s}.dsub-btnPrimary:hover{opacity:.9}.dsub-btnSec{display:inline-flex;align-items:center;gap:5px;padding:5px 10px;border-radius:7px;font-size:11.5px;font-weight:500;background:var(--dsw-alias-bg-layer-1);color:var(--dsw-alias-label-secondary);border:1px solid var(--dsw-alias-border-l2);cursor:pointer;text-decoration:none;transition:all .15s}.dsub-btnSec:hover{background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-primary);border-color:var(--dsw-alias-label-tertiary)}.dsub-cq{display:inline-flex;align-items:center;gap:6px;font-size:12px;color:var(--dsw-alias-label-secondary);padding:0 6px}.dsub-cqBar{position:relative;width:40px;height:5px;border-radius:3px;background:var(--dsw-alias-bg-layer-1);overflow:hidden}.dsub-cqBarFill{height:100%;border-radius:3px;background:var(--dsw-alias-state-success-primary)}.dsub-cqBarWarn .dsub-cqBarFill{background:var(--dsw-alias-state-warn-primary)}.dsub-cqBarFull .dsub-cqBarFill{background:var(--dsw-alias-state-error-primary)}.dsub-cqB{font-variant-numeric:tabular-nums;font-weight:600;color:var(--dsw-alias-label-primary)}.dsub-qw{position:fixed;z-index:10010;display:flex;align-items:center;gap:6px;height:30px;padding:0 10px;border-radius:999px;border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-primary);box-shadow:0 4px 14px color-mix(in srgb,black 28%,transparent);cursor:grab;user-select:none;font-size:11.5px;font-weight:600;touch-action:none}.dsub-qw:active{cursor:grabbing}.dsub-qwDocked{right:16px !important;bottom:16px !important;cursor:default}.dsub-qwPct{font-variant-numeric:tabular-nums}.dsub-qwBar{width:28px;height:4px;border-radius:2px;background:var(--dsw-alias-bg-layer-1);overflow:hidden}.dsub-qwBarFill{height:100%;border-radius:2px;background:var(--dsw-alias-state-success-primary)}.dsub-qwWarn .dsub-qwBarFill{background:var(--dsw-alias-state-warn-primary)}.dsub-qwFull .dsub-qwBarFill{background:var(--dsw-alias-state-error-primary)}.dsub-diag{font:11px/1.5 ui-monospace,SFMono-Regular,monospace;max-height:260px;overflow:auto;white-space:pre-wrap;word-break:break-word;border:1px solid var(--dsw-alias-border-l2);border-radius:8px;padding:10px;background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-secondary)}";
43
+ ".dsub-card{list-style:none;border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-3);border-radius:12px;transition:border-color .16s,background .16s}.dsub-card:hover{border-color:var(--dsw-alias-label-dimmed)}.dsub-cardOpen{background:var(--dsw-alias-bg-layer-2);border-color:var(--dsw-alias-label-dimmed)}.dsub-header{appearance:none;width:100%;font:inherit;color:inherit;text-align:left;cursor:pointer;background:0 0;border:0;border-radius:12px;align-items:center;gap:12px;padding:14px 16px;display:flex}.dsub-header:focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:-2px}.dsub-headText{flex-direction:column;flex:1;gap:4px;min-width:0;display:flex}.dsub-name{color:var(--dsw-alias-label-primary);font-size:15px;font-weight:600;line-height:1.4}.dsub-description{color:var(--dsw-alias-label-secondary);font-size:13px}.dsub-chev{color:var(--dsw-alias-label-tertiary);flex:none;transition:transform .16s}.dsub-chevOpen{transform:rotate(180deg)}.dsub-body{border-top:1px solid var(--dsw-alias-border-l2);margin:0 16px;padding-bottom:12px}.dsub-block{margin-top:10px}.dsub-group{margin-top:12px;border:1px solid var(--dsw-alias-border-l2);border-radius:10px;padding:0 14px}.dsub-group[open]{padding-bottom:6px}.dsub-groupHead{cursor:pointer;font-size:13px;font-weight:600;color:var(--dsw-alias-label-primary);padding:10px 0;list-style:none}.dsub-groupHead::-webkit-details-marker{display:none}.dsub-row{display:flex;align-items:center;flex-wrap:wrap;gap:8px;margin-top:8px}.dsub-grow{flex:1 1 180px;min-width:140px}.dsub-helpBox{margin-top:8px;padding:12px 14px;border-radius:8px;background:var(--dsw-alias-bg-layer-1);border:1px solid var(--dsw-alias-border-l1);font-size:12px;line-height:1.6}.dsub-helpHead{display:flex;align-items:center;justify-content:space-between;font-weight:600;margin-bottom:8px;color:var(--dsw-alias-brand-primary)}.dsub-helpStep{margin-bottom:6px;color:var(--dsw-alias-label-primary)}.dsub-btnActive{background:var(--dsw-alias-brand-primary);color:var(--dsw-alias-label-primary-foreground);border-color:var(--dsw-alias-brand-primary)}.dsub-h{font-size:13.5px;font-weight:600;color:var(--dsw-alias-label-primary)}.dsub-sub{font-size:12px;color:var(--dsw-alias-label-secondary)}.dsub-dim{font-size:11.5px;color:var(--dsw-alias-label-tertiary)}.dsub-mini{appearance:none;font:inherit;font-size:12px;cursor:pointer;border:1px solid var(--dsw-alias-border-l2);border-radius:6px;padding:3px 8px;background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-primary);transition:all .15s}.dsub-mini:hover{background:var(--dsw-alias-bg-layer-1);border-color:var(--dsw-alias-label-dimmed)}.dsub-ok{font-size:12px;color:var(--dsw-alias-state-success-primary)}.dsub-bad{font-size:12px;color:var(--dsw-alias-state-error-primary);overflow-wrap:anywhere;word-break:break-word;max-width:100%}.dsub-hint{overflow-wrap:anywhere;word-break:break-word;max-width:100%}.dsub-btn{appearance:none;font:inherit;cursor:pointer;border:1px solid var(--dsw-alias-border-l2);border-radius:8px;padding:5px 12px;font-size:12.5px;font-weight:500;background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-primary);transition:all .15s}.dsub-btn:hover{background:var(--dsw-alias-bg-layer-1);border-color:var(--dsw-alias-label-dimmed)}.dsub-inp{height:30px;border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-3);color:var(--dsw-alias-label-primary);border-radius:6px;padding:0 8px;font-size:12px;min-width:180px}.dsub-inp:focus{outline:none;border-color:var(--dsw-alias-brand-primary)}.dsub-box{border:1px solid var(--dsw-alias-border-l2);border-radius:8px;padding:10px;margin-top:8px;background:var(--dsw-alias-bg-layer-2)}.dsub-cooldown{display:inline-block;padding:2px 6px;border-radius:4px;background:var(--dsw-alias-state-warn-primary);color:var(--dsw-alias-bg-layer-1);font-size:11px;font-weight:600}.dsub-bar{position:relative;height:6px;border-radius:3px;background:var(--dsw-alias-bg-layer-1);overflow:hidden;flex:1;min-width:80px}.dsub-barFill{height:100%;border-radius:3px;background:var(--dsw-alias-state-success-primary);transition:width .2s}.dsub-barWarn .dsub-barFill{background:var(--dsw-alias-state-warn-primary)}.dsub-barFull .dsub-barFill{background:var(--dsw-alias-state-error-primary)}.dsub-barRow{display:flex;align-items:center;gap:8px;font-size:12px;color:var(--dsw-alias-label-secondary)}.dsub-manual{display:flex;flex-direction:column;gap:6px;margin-top:4px;padding-top:10px;border-top:1px solid var(--dsw-alias-border-l2)}.dsub-verify{font-size:12px;color:var(--dsw-alias-state-warn-primary)}.dsub-verify a{color:var(--dsw-alias-brand-primary)}.dsub-pill{display:inline-flex;align-items:center;gap:7px;height:28px;padding:0 11px;border:1px solid var(--dsw-alias-border-l2);border-radius:999px;background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-primary);font-size:12px;font-weight:600;cursor:pointer;box-shadow:0 1px 3px color-mix(in srgb,black 15%,transparent),inset 0 1px 0 color-mix(in srgb,white 6%,transparent);transition:all .16s ease;user-select:none}.dsub-pill:hover{background:var(--dsw-alias-bg-layer-3);border-color:var(--dsw-alias-label-tertiary);transform:translateY(-1px);box-shadow:0 3px 8px color-mix(in srgb,black 25%,transparent),inset 0 1px 0 color-mix(in srgb,white 10%,transparent)}.dsub-pill:active{transform:translateY(0)}.dsub-pillTag{padding:1px 5px;border-radius:4px;font-size:10.5px;font-weight:700;letter-spacing:.02em;background:var(--dsw-alias-bg-layer-1);color:var(--dsw-alias-label-secondary)}.dsub-led{width:7px;height:7px;border-radius:50%;flex:none;transition:all .2s ease}.dsub-ledOk{background:var(--dsw-alias-state-success-primary);box-shadow:0 0 6px color-mix(in srgb,var(--dsw-alias-state-success-primary) 70%,transparent)}.dsub-ledWarn{background:var(--dsw-alias-state-warn-primary);box-shadow:0 0 6px color-mix(in srgb,var(--dsw-alias-state-warn-primary) 70%,transparent)}.dsub-ledBad{background:var(--dsw-alias-state-error-primary);box-shadow:0 0 6px color-mix(in srgb,var(--dsw-alias-state-error-primary) 70%,transparent)}.dsub-ledOff{background:var(--dsw-alias-label-tertiary)}.dsub-toast-container{position:fixed;top:16px;right:16px;z-index:10030;display:flex;flex-direction:column;gap:8px;pointer-events:none}.dsub-toast{pointer-events:auto;min-width:280px;max-width:400px;padding:10px 14px;border-radius:10px;background:var(--dsw-alias-bg-layer-3);color:var(--dsw-alias-label-primary);border:1px solid var(--dsw-alias-border-l1);box-shadow:0 8px 24px color-mix(in srgb,black 40%,transparent);display:flex;align-items:center;justify-content:space-between;gap:10px;font-size:12px;line-height:1.4;animation:dsubFadeIn .2s ease-out}.dsub-toast-warn{border-color:var(--dsw-alias-state-warn-primary)}.dsub-toast-bad{border-color:var(--dsw-alias-state-error-primary)}.dsub-toast-ok{border-color:var(--dsw-alias-state-success-primary)}.dsub-toast-close{background:none;border:none;color:var(--dsw-alias-label-tertiary);cursor:pointer;font-size:14px;padding:0 2px}.dsub-toast-close:hover{color:var(--dsw-alias-label-primary)}.dsub-modalWrap{position:fixed;inset:0;z-index:10020;display:grid;place-items:center;background:color-mix(in srgb,black 65%,transparent);backdrop-filter:blur(10px);animation:dsubFadeIn .16s ease-out}.dsub-modal{width:min(520px,94vw);max-height:min(85vh,720px);overflow-y:auto;border:1px solid var(--dsw-alias-border-l1);border-radius:16px;background:var(--dsw-alias-bg-layer-3);color:var(--dsw-alias-label-primary);box-shadow:0 24px 64px color-mix(in srgb,black 60%,transparent),0 0 0 1px color-mix(in srgb,white 7%,transparent);padding:20px;animation:dsubScaleIn .18s cubic-bezier(.16,1,.3,1)}@keyframes dsubFadeIn{from{opacity:0}to{opacity:1}}@keyframes dsubScaleIn{from{opacity:0;transform:scale(.96)}to{opacity:1;transform:scale(1)}}.dsub-modalHead{display:flex;align-items:center;justify-content:space-between;gap:12px;padding-bottom:14px;border-bottom:1px solid var(--dsw-alias-border-l2)}.dsub-modalTitleWrap{display:flex;align-items:center;gap:10px}.dsub-modalIcon{width:30px;height:30px;border-radius:8px;background:var(--dsw-alias-bg-layer-1);display:grid;place-items:center;color:var(--dsw-alias-brand-primary);font-size:14px}.dsub-modalBadge{font-size:11px;font-weight:600;padding:2px 7px;border-radius:10px;background:var(--dsw-alias-bg-layer-1);color:var(--dsw-alias-label-secondary)}.dsub-heroCard{margin-top:14px;padding:14px 16px;border-radius:12px;background:linear-gradient(135deg,var(--dsw-alias-bg-layer-2) 0%,var(--dsw-alias-bg-layer-3) 100%);border:1px solid var(--dsw-alias-border-l1);box-shadow:inset 0 1px 0 color-mix(in srgb,white 5%,transparent)}.dsub-heroHead{display:flex;align-items:center;justify-content:space-between;margin-bottom:10px}.dsub-heroTitle{font-size:14px;font-weight:700;display:flex;align-items:center;gap:6px}.dsub-heroModel{font-size:11.5px;color:var(--dsw-alias-label-secondary);font-family:ui-monospace,monospace}.dsub-select{appearance:none;height:30px;border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-3);color:var(--dsw-alias-label-primary);border-radius:6px;padding:0 24px 0 8px;font-size:12px;cursor:pointer}.dsub-details{margin-top:12px;border:1px solid var(--dsw-alias-border-l2);border-radius:8px;padding:8px 12px;background:var(--dsw-alias-bg-layer-2)}.dsub-summary{cursor:pointer;font-weight:600;font-size:12.5px;color:var(--dsw-alias-label-secondary);user-select:none}.dsub-summary:hover{color:var(--dsw-alias-label-primary)}.dsub-header-bar{display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:8px;padding:8px 0 12px;margin-bottom:8px;border-bottom:1px solid var(--dsw-alias-border-l2)}.dsub-badge{font-size:12px;padding:3px 10px;border-radius:999px;border:1px solid var(--dsw-alias-border-l2);display:inline-flex;align-items:center;gap:5px;font-weight:500}.dsub-badge-ok{border-color:var(--dsw-alias-state-success-primary);color:var(--dsw-alias-state-success-primary);background:color-mix(in srgb,var(--dsw-alias-state-success-primary) 10%,transparent)}.dsub-badge-warn{border-color:var(--dsw-alias-state-warn-primary);color:var(--dsw-alias-state-warn-primary);background:color-mix(in srgb,var(--dsw-alias-state-warn-primary) 10%,transparent)}.dsub-badge-bad{border-color:var(--dsw-alias-state-error-primary);color:var(--dsw-alias-state-error-primary);background:color-mix(in srgb,var(--dsw-alias-state-error-primary) 10%,transparent)}.dsub-badge-neutral{border-color:var(--dsw-alias-border-l2);color:var(--dsw-alias-label-secondary);background:var(--dsw-alias-bg-layer-2)}.dsub-section-card{border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-3);border-radius:12px;padding:14px 16px;display:flex;flex-direction:column;gap:10px;margin-top:12px}.dsub-section-title{font-size:14.5px;font-weight:600;color:var(--dsw-alias-label-primary);display:flex;align-items:center;justify-content:space-between;gap:8px}.dsub-section-desc{font-size:12px;color:var(--dsw-alias-label-secondary);margin-top:-4px;line-height:1.4}.dsub-grid-2{display:grid;grid-template-columns:repeat(auto-fit,minmax(200px,1fr));gap:10px}.dsub-stat-box{padding:10px 12px;border:1px solid var(--dsw-alias-border-l2);border-radius:8px;background:var(--dsw-alias-bg-layer-2);display:flex;flex-direction:column;gap:3px}.dsub-stat-val{font-size:16.5px;font-weight:700;color:var(--dsw-alias-label-primary)}.dsub-stat-lbl{font-size:11.5px;color:var(--dsw-alias-label-secondary)}.dsub-chart-wrap{display:flex;align-items:flex-end;gap:3px;height:52px;padding:6px 0;margin-top:8px;border-bottom:1px solid var(--dsw-alias-border-l3)}.dsub-chart-bar{flex:1;background:var(--dsw-alias-brand-primary);border-radius:2px 2px 0 0;min-height:3px;transition:height .2s ease;opacity:0.85;cursor:pointer}.dsub-chart-bar:hover{opacity:1;background:var(--dsw-alias-state-success-primary)}.dsub-chart-labels{display:flex;justify-content:space-between;font-size:10px;color:var(--dsw-alias-label-secondary);margin-top:3px}.dsub-alert-ok{padding:8px 12px;border-radius:8px;background:color-mix(in srgb,var(--dsw-alias-state-success-primary) 10%,transparent);color:var(--dsw-alias-state-success-primary);font-size:12.5px;border:1px solid color-mix(in srgb,var(--dsw-alias-state-success-primary) 25%,transparent)}.dsub-alert-bad{padding:8px 12px;border-radius:8px;background:color-mix(in srgb,var(--dsw-alias-state-error-primary) 10%,transparent);color:var(--dsw-alias-state-error-primary);font-size:12.5px;border:1px solid color-mix(in srgb,var(--dsw-alias-state-error-primary) 25%,transparent)}.dsub-btn-primary{background:var(--dsw-alias-label-primary) !important;color:var(--dsw-alias-bg-layer-3) !important;border-color:transparent !important;font-weight:600}.dsub-btn-primary:hover:not(:disabled){opacity:0.9}.dsub-model-grid{display:flex;flex-wrap:wrap;gap:6px;margin-top:6px}.dsub-model-chip{display:inline-flex;align-items:center;gap:6px;padding:3px 8px;border-radius:6px;background:var(--dsw-alias-bg-layer-1);border:1px solid var(--dsw-alias-border-l2);font-size:11.5px;color:var(--dsw-alias-label-primary)}.dsub-model-chip code{font-family:ui-monospace,monospace;font-weight:600}.dsub-model-tag{font-size:10px;padding:1px 4px;border-radius:3px;background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-secondary);font-weight:600}.dsub-trace-box{padding:8px 12px;border-radius:8px;background:var(--dsw-alias-bg-layer-2);border:1px solid var(--dsw-alias-border-l2);margin-top:8px;font-size:12px}.dsub-trace-row{display:flex;align-items:center;justify-content:space-between;gap:8px;padding:4px 0;border-bottom:1px solid var(--dsw-alias-border-l3)}.dsub-trace-row:last-child{border-bottom:none}.dsub-model-status-available{color:var(--dsw-alias-state-success-primary);background:color-mix(in srgb,var(--dsw-alias-state-success-primary) 12%,transparent)}.dsub-model-status-degraded{color:var(--dsw-alias-state-warn-primary);background:color-mix(in srgb,var(--dsw-alias-state-warn-primary) 12%,transparent)}.dsub-model-status-quarantined{color:var(--dsw-alias-state-error-primary);background:color-mix(in srgb,var(--dsw-alias-state-error-primary) 12%,transparent)}.dsub-model-status-cooldown{color:var(--dsw-alias-state-business-primary);background:color-mix(in srgb,var(--dsw-alias-state-business-primary) 12%,transparent)}.dsub-model-status-exhausted{color:var(--dsw-alias-state-error-primary);background:color-mix(in srgb,var(--dsw-alias-state-error-primary) 12%,transparent)}.dsub-model-status-unconfigured{color:var(--dsw-alias-label-tertiary);background:var(--dsw-alias-bg-layer-3)}.dsub-heroBars{display:flex;flex-direction:column;gap:8px;margin-top:10px}.dsub-barLabelRow{display:flex;justify-content:space-between;font-size:11.5px;color:var(--dsw-alias-label-secondary);margin-bottom:3px}.dsub-barTrack{height:6px;border-radius:3px;background:var(--dsw-alias-bg-layer-1);overflow:hidden}.dsub-barFillGrad{height:100%;border-radius:3px;transition:width .3s ease}.dsub-panel-box{margin-top:10px;padding:12px 14px;border-radius:10px;border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-2);display:flex;flex-direction:column;gap:8px}.dsub-panel-warn{border-color:color-mix(in srgb,var(--dsw-alias-state-warn-primary) 40%,transparent);background:color-mix(in srgb,var(--dsw-alias-state-warn-primary) 5%,transparent)}.dsub-panel-title{font-size:13px;font-weight:600;color:var(--dsw-alias-label-primary);display:flex;align-items:center;justify-content:space-between}.dsub-panel-desc{font-size:12px;color:var(--dsw-alias-label-secondary);line-height:1.4}.dsub-usage-grid{display:flex;flex-direction:column;gap:8px;margin-top:4px}.dsub-usage-card{display:flex;flex-direction:column;gap:5px;padding:8px 10px;border:1px solid var(--dsw-alias-border-l2);border-radius:8px;background:var(--dsw-alias-bg-layer-1)}.dsub-usage-head{display:flex;justify-content:space-between;align-items:center;font-size:12px;font-weight:500;color:var(--dsw-alias-label-primary)}.dsub-usage-track{width:100%;height:8px;border-radius:999px;background:var(--dsw-alias-bg-layer-3);border:1px solid var(--dsw-alias-border-l2);overflow:hidden}.dsub-usage-fill{height:100%;border-radius:999px;transition:width .3s ease}.dsub-usage-meta{display:flex;justify-content:space-between;align-items:center;font-size:11px;color:var(--dsw-alias-label-secondary)}.dsub-tag-ok{border-color:color-mix(in srgb,var(--dsw-alias-state-success-primary) 30%,transparent);color:var(--dsw-alias-state-success-primary);background:color-mix(in srgb,var(--dsw-alias-state-success-primary) 8%,transparent);padding:1px 7px;border-radius:999px;font-size:11px;font-weight:600;border-style:solid;border-width:1px}.dsub-tag-warn{border-color:color-mix(in srgb,var(--dsw-alias-state-warn-primary) 40%,transparent);color:var(--dsw-alias-state-warn-primary);background:color-mix(in srgb,var(--dsw-alias-state-warn-primary) 8%,transparent);padding:1px 7px;border-radius:999px;font-size:11px;font-weight:600;border-style:solid;border-width:1px}.dsub-tag-bad{border-color:color-mix(in srgb,var(--dsw-alias-state-error-primary) 40%,transparent);color:var(--dsw-alias-state-error-primary);background:color-mix(in srgb,var(--dsw-alias-state-error-primary) 8%,transparent);padding:1px 7px;border-radius:999px;font-size:11px;font-weight:600;border-style:solid;border-width:1px}.dsub-slot-meta-row{display:flex;flex-wrap:wrap;align-items:center;gap:10px;margin-top:12px;padding-top:10px;border-top:1px solid var(--dsw-alias-border-l2);font-size:12px}.dsub-ref-tag{display:inline-flex;align-items:center;gap:5px;font-size:11.5px;color:var(--dsw-alias-label-secondary)}.dsub-ref-tag code{padding:2px 6px;border-radius:4px;background:var(--dsw-alias-bg-layer-1);border:1px solid var(--dsw-alias-border-l2);font-family:ui-monospace,monospace;color:var(--dsw-alias-label-primary);font-size:11px}.dsub-speed-tag{display:inline-flex;align-items:center;gap:4px;font-size:11.5px;color:var(--dsw-alias-brand-primary);background:color-mix(in srgb,var(--dsw-alias-brand-primary) 8%,transparent);padding:2px 8px;border-radius:6px;font-weight:500;border:1px solid color-mix(in srgb,var(--dsw-alias-brand-primary) 20%,transparent)}.dsub-btn-danger{border-color:color-mix(in srgb,var(--dsw-alias-state-error-primary) 40%,transparent)!important;color:var(--dsw-alias-state-error-primary)!important}.dsub-btn-danger:hover:not(:disabled){background:color-mix(in srgb,var(--dsw-alias-state-error-primary) 12%,transparent)!important}.dsub-poolTitle{margin-top:16px;margin-bottom:8px;font-size:11.5px;font-weight:700;color:var(--dsw-alias-label-secondary);text-transform:uppercase;letter-spacing:.05em}.dsub-accountCard{display:flex;align-items:center;gap:12px;padding:10px 14px;border:1px solid var(--dsw-alias-border-l2);border-radius:10px;background:var(--dsw-alias-bg-layer-2);margin-top:6px;transition:border-color .15s,background .15s}.dsub-accountCard:hover{background:var(--dsw-alias-bg-layer-3);border-color:var(--dsw-alias-label-tertiary)}.dsub-brandBadge{width:32px;height:32px;border-radius:8px;display:grid;place-items:center;font-size:12px;font-weight:700;flex-shrink:0}.dsub-brandCodex{background:color-mix(in srgb,var(--dsw-alias-state-success-primary) 15%,transparent);color:var(--dsw-alias-state-success-primary);border:1px solid color-mix(in srgb,var(--dsw-alias-state-success-primary) 30%,transparent)}.dsub-brandClaude{background:color-mix(in srgb,var(--dsw-alias-state-warn-primary) 15%,transparent);color:var(--dsw-alias-state-warn-primary);border:1px solid color-mix(in srgb,var(--dsw-alias-state-warn-primary) 30%,transparent)}.dsub-brandGrok{background:color-mix(in srgb,var(--dsw-alias-state-business-primary,var(--dsw-alias-brand-primary)) 15%,transparent);color:var(--dsw-alias-state-business-primary,var(--dsw-alias-brand-primary));border:1px solid color-mix(in srgb,var(--dsw-alias-state-business-primary,var(--dsw-alias-brand-primary)) 30%,transparent)}.dsub-brandAgy{background:color-mix(in srgb,var(--dsw-alias-brand-primary) 15%,transparent);color:var(--dsw-alias-brand-primary);border:1px solid color-mix(in srgb,var(--dsw-alias-brand-primary) 30%,transparent)}.dsub-brandOllama{background:color-mix(in srgb,var(--dsw-alias-label-tertiary) 15%,transparent);color:var(--dsw-alias-label-secondary);border:1px solid color-mix(in srgb,var(--dsw-alias-label-tertiary) 30%,transparent)}.dsub-brandKimi{background:color-mix(in srgb,var(--dsw-alias-state-warn-primary) 15%,transparent);color:var(--dsw-alias-state-warn-primary);border:1px solid color-mix(in srgb,var(--dsw-alias-state-warn-primary) 30%,transparent)}.dsub-brandGlm{background:color-mix(in srgb,var(--dsw-alias-state-business-primary,var(--dsw-alias-brand-primary)) 15%,transparent);color:var(--dsw-alias-state-business-primary,var(--dsw-alias-brand-primary));border:1px solid color-mix(in srgb,var(--dsw-alias-state-business-primary,var(--dsw-alias-brand-primary)) 30%,transparent)}.dsub-planBadge{display:inline-flex;align-items:center;padding:1px 6px;border-radius:4px;font-size:10px;font-weight:600;text-transform:uppercase;letter-spacing:0.5px;background:color-mix(in srgb,var(--dsw-alias-brand-primary) 15%,transparent);color:var(--dsw-alias-brand-primary);border:1px solid color-mix(in srgb,var(--dsw-alias-brand-primary) 30%,transparent);margin-left:6px}.dsub-accInfo{flex:1;min-width:0;display:flex;flex-direction:column;gap:3px}.dsub-accNameRow{display:flex;align-items:center;justify-content:space-between;gap:8px}.dsub-accName{font-size:13px;font-weight:600}.dsub-accStatusTag{font-size:10.5px;font-weight:600;padding:1px 6px;border-radius:4px}.dsub-statusOk{background:color-mix(in srgb,var(--dsw-alias-state-success-primary) 15%,transparent);color:var(--dsw-alias-state-success-primary)}.dsub-statusWarn{background:color-mix(in srgb,var(--dsw-alias-state-warn-primary) 15%,transparent);color:var(--dsw-alias-state-warn-primary)}.dsub-statusBad{background:color-mix(in srgb,var(--dsw-alias-state-error-primary) 15%,transparent);color:var(--dsw-alias-state-error-primary)}.dsub-statusOff{background:var(--dsw-alias-bg-layer-1);color:var(--dsw-alias-label-tertiary)}.dsub-modalFoot{display:flex;align-items:center;justify-content:space-between;margin-top:16px;padding-top:12px;border-top:1px solid var(--dsw-alias-border-l2)}.dsub-btnPrimary{display:inline-flex;align-items:center;gap:6px;padding:6px 12px;border-radius:8px;font-size:12px;font-weight:600;background:var(--dsw-alias-brand-primary);color:var(--dsw-alias-label-primary-foreground);border:0;cursor:pointer;transition:opacity .15s}.dsub-btnPrimary:hover{opacity:.9}.dsub-btnSec{display:inline-flex;align-items:center;gap:5px;padding:5px 10px;border-radius:7px;font-size:11.5px;font-weight:500;background:var(--dsw-alias-bg-layer-1);color:var(--dsw-alias-label-secondary);border:1px solid var(--dsw-alias-border-l2);cursor:pointer;text-decoration:none;transition:all .15s}.dsub-btnSec:hover{background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-primary);border-color:var(--dsw-alias-label-tertiary)}.dsub-cq{display:inline-flex;align-items:center;gap:6px;font-size:12px;color:var(--dsw-alias-label-secondary);padding:0 6px}.dsub-cqBar{position:relative;width:40px;height:5px;border-radius:3px;background:var(--dsw-alias-bg-layer-1);overflow:hidden}.dsub-cqBarFill{height:100%;border-radius:3px;background:var(--dsw-alias-state-success-primary)}.dsub-cqBarWarn .dsub-cqBarFill{background:var(--dsw-alias-state-warn-primary)}.dsub-cqBarFull .dsub-cqBarFill{background:var(--dsw-alias-state-error-primary)}.dsub-cqB{font-variant-numeric:tabular-nums;font-weight:600;color:var(--dsw-alias-label-primary)}.dsub-qw{position:fixed;z-index:10010;display:flex;align-items:center;gap:6px;height:30px;padding:0 10px;border-radius:999px;border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-primary);box-shadow:0 4px 14px color-mix(in srgb,black 28%,transparent);cursor:grab;user-select:none;font-size:11.5px;font-weight:600;touch-action:none}.dsub-qw:active{cursor:grabbing}.dsub-qwDocked{right:16px !important;bottom:16px !important;cursor:default}.dsub-qwPct{font-variant-numeric:tabular-nums}.dsub-qwBar{width:28px;height:4px;border-radius:2px;background:var(--dsw-alias-bg-layer-1);overflow:hidden}.dsub-qwBarFill{height:100%;border-radius:2px;background:var(--dsw-alias-state-success-primary)}.dsub-qwPop{position:fixed;z-index:10011;width:280px;display:flex;flex-direction:column;gap:6px;padding:10px 12px;border-radius:10px;border:1px solid var(--dsw-alias-border-l1);background:var(--dsw-alias-bg-layer-3);box-shadow:0 12px 32px color-mix(in srgb,black 40%,transparent);font-size:12px}.dsub-qwWarn .dsub-qwBarFill{background:var(--dsw-alias-state-warn-primary)}.dsub-qwFull .dsub-qwBarFill{background:var(--dsw-alias-state-error-primary)}.dsub-diag{font:11px/1.5 ui-monospace,SFMono-Regular,monospace;max-height:260px;overflow:auto;white-space:pre-wrap;word-break:break-word;border:1px solid var(--dsw-alias-border-l2);border-radius:8px;padding:10px;background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-secondary)}";
44
44
  const cssId = 'dsh-subscriptions/settings.module.css'
45
45
  if (typeof document !== 'undefined' && !document.querySelector('style[data-plugin-css="' + cssId + '"]')) {
46
46
  const tag = document.createElement('style')
@@ -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,41 @@ 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.'),
1910
+ h(WebhookTestButton),
1875
1911
  ),
1876
1912
  h('details', { className: 'dsub-group', open: true },
1877
1913
  h('summary', { className: 'dsub-groupHead' }, '🚦 ' + t('groupQueue')),
@@ -3655,6 +3691,98 @@ window.__ModuleLoader__.load({
3655
3691
  )
3656
3692
  }
3657
3693
 
3694
+ function WebhookTestButton() {
3695
+ const [state, setState] = React.useState({ busy: false, message: '', error: null })
3696
+ const send = () => {
3697
+ setState({ busy: true, message: '', error: null })
3698
+ fetch('/dsh-subscriptions/webhook/test', { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{}' })
3699
+ .then(async (res) => {
3700
+ const data = await res.json().catch(() => ({}))
3701
+ if (!res.ok || !data.delivered) throw new Error((data.error && data.error.message) || ('HTTP ' + res.status))
3702
+ setState({ busy: false, message: 'delivered', error: null })
3703
+ })
3704
+ .catch((err) => setState({ busy: false, message: '', error: String(err && err.message || err) }))
3705
+ }
3706
+ return h('div', { className: 'dsub-row' },
3707
+ h('button', { type: 'button', className: 'dsub-mini', disabled: state.busy, onClick: send }, state.busy ? '…' : 'Send test'),
3708
+ state.message ? h('span', { className: 'dsub-ok' }, state.message) : null,
3709
+ state.error ? h('span', { className: 'dsub-bad' }, state.error) : null,
3710
+ )
3711
+ }
3712
+
3713
+ // #724: provider pack manager. Displays ids/models only; headers are masked.
3714
+ function ProviderPacksPanel() {
3715
+ const [state, setState] = React.useState({ packs: [], text: '', message: '', error: null, busy: false })
3716
+ const publicPack = (pack) => {
3717
+ const copy = Object.assign({}, pack)
3718
+ if (copy.clientSecret) copy.clientSecret = '••••'
3719
+ if (copy.headers && typeof copy.headers === 'object') {
3720
+ copy.headers = Object.fromEntries(Object.keys(copy.headers).map((key) => [key, '••••']))
3721
+ }
3722
+ return copy
3723
+ }
3724
+ const pull = () => {
3725
+ fetch('/dsh-subscriptions/config', { cache: 'no-store' })
3726
+ .then((res) => res.json())
3727
+ .then((cfg) => setState((st) => Object.assign({}, st, { packs: (cfg.config && cfg.config.providerPacks) || [] })))
3728
+ .catch((err) => setState((st) => Object.assign({}, st, { error: String(err && err.message || err) })))
3729
+ }
3730
+ React.useEffect(() => { pull() }, [])
3731
+ const parse = () => {
3732
+ const raw = JSON.parse(state.text || '{}')
3733
+ return raw && raw.pack ? raw.pack : raw
3734
+ }
3735
+ const validate = (pack) => fetch('/dsh-subscriptions/provider-packs/validate', {
3736
+ method: 'POST',
3737
+ headers: { 'content-type': 'application/json' },
3738
+ body: JSON.stringify({ pack }),
3739
+ }).then(async (res) => {
3740
+ const data = await res.json()
3741
+ if (!res.ok || !data.ok) throw new Error((data.error && data.error.message) || ('HTTP ' + res.status))
3742
+ return data.pack
3743
+ })
3744
+ const save = () => {
3745
+ setState((st) => Object.assign({}, st, { busy: true, message: '', error: null }))
3746
+ Promise.resolve().then(parse)
3747
+ .then(async (pack) => {
3748
+ const checked = await validate(pack)
3749
+ const cfg = await fetch('/dsh-subscriptions/config', { cache: 'no-store' }).then((res) => res.json())
3750
+ const packs = ((cfg.config && cfg.config.providerPacks) || []).filter((p) => p && p.id !== pack.id)
3751
+ packs.push(pack)
3752
+ const res = await fetch('/dsh-subscriptions/config', {
3753
+ method: 'PUT',
3754
+ headers: { 'content-type': 'application/json' },
3755
+ body: JSON.stringify({ config: Object.assign({}, cfg.config, { providerPacks: packs }), revision: cfg.revision }),
3756
+ })
3757
+ if (!res.ok) throw new Error('HTTP ' + res.status)
3758
+ setState((st) => Object.assign({}, st, { packs, busy: false, message: 'saved ' + checked.id, text: '' }))
3759
+ })
3760
+ .catch((err) => setState((st) => Object.assign({}, st, { busy: false, error: String(err && err.message || err) })))
3761
+ }
3762
+ const exportPacks = () => setState((st) => Object.assign({}, st, { text: JSON.stringify(st.packs.map(publicPack), null, 2), message: 'exported' }))
3763
+ return h('div', { className: 'dsub-section-card' },
3764
+ h('div', { className: 'dsub-section-title' }, 'Provider packs', h('button', { type: 'button', className: 'dsub-mini', onClick: pull }, '↻')),
3765
+ h('div', { className: 'dsub-section-desc' }, 'Add an OpenAI-Responses-compatible provider as a JSON manifest. Headers are masked in the editor.'),
3766
+ state.packs.length ? state.packs.map((pack) => h('div', { className: 'dsub-row', key: pack.id },
3767
+ h('span', { className: 'dsub-h' }, pack.id),
3768
+ h('span', { className: 'dsub-dim' }, (pack.displayName || '') + ' · ' + ((pack.models || []).length) + ' models'),
3769
+ )) : h('div', { className: 'dsub-dim' }, 'No packs yet.'),
3770
+ h('textarea', {
3771
+ className: 'dsub-inp',
3772
+ style: { width: '100%', minHeight: 120, fontFamily: 'ui-monospace,monospace', padding: 8 },
3773
+ value: state.text,
3774
+ placeholder: '{"id":"acme","displayName":"Acme AI","authUrl":"https://…","tokenUrl":"https://…","baseUrl":"https://…","clientId":"…","models":[{"id":"acme-1"}]}',
3775
+ onChange: (e) => setState((st) => Object.assign({}, st, { text: e.target.value, message: '', error: null })),
3776
+ }),
3777
+ h('div', { className: 'dsub-row' },
3778
+ h('button', { type: 'button', className: 'dsub-mini', disabled: state.busy, onClick: save }, state.busy ? '…' : 'Validate & save'),
3779
+ h('button', { type: 'button', className: 'dsub-mini', onClick: exportPacks }, 'Export'),
3780
+ ),
3781
+ state.message ? h('div', { className: 'dsub-ok' }, state.message) : null,
3782
+ state.error ? h('div', { className: 'dsub-bad' }, state.error) : null,
3783
+ )
3784
+ }
3785
+
3658
3786
  // #713: read-only source chain panel. Never renders key values.
3659
3787
  function SourcesPanel() {
3660
3788
  const [state, setState] = React.useState({ loading: true, data: null, error: null })
@@ -3692,8 +3820,10 @@ window.__ModuleLoader__.load({
3692
3820
  // #688: floating/docked quota capsule. Draggable unless docked; opens settings.
3693
3821
  function QuotaCapsule(props) {
3694
3822
  const t = (props && props.t) || ((k) => k)
3695
- const [state, setState] = React.useState({ mode: 'off', active: null, remaining: null, source: null })
3823
+ const [state, setState] = React.useState({ mode: 'off', active: null, remaining: null, source: null, candidates: [] })
3824
+ const [open, setOpen] = React.useState(false)
3696
3825
  const [pos, setPos] = React.useState({ right: 16, bottom: 16 })
3826
+ const popRef = React.useRef(null)
3697
3827
  const posRef = React.useRef({ right: 16, bottom: 16 })
3698
3828
  const drag = React.useRef(null)
3699
3829
  const suppressClick = React.useRef(false)
@@ -3721,7 +3851,7 @@ window.__ModuleLoader__.load({
3721
3851
  refreshLoggedIn().then(async (r) => {
3722
3852
  if (!alive) return
3723
3853
  const mode = r.quotaWidget || 'docked'
3724
- if (mode === 'off') { setState({ mode, active: null, remaining: null, source: null }); return }
3854
+ if (mode === 'off') { setState({ mode, active: null, remaining: null, source: null, candidates: [] }); return }
3725
3855
  const sources = await fetch('/dsh-subscriptions/sources', { cache: 'no-store' }).then((res) => res.json()).catch(() => null)
3726
3856
  let a = r.active
3727
3857
  const current = currentModelProvider(props)
@@ -3730,9 +3860,10 @@ window.__ModuleLoader__.load({
3730
3860
  const rows = (cfg && cfg.accounts) || []
3731
3861
  a = rows.find((row) => row && row.provider === current && row.usagePercent != null) || null
3732
3862
  }
3733
- if (!a || a.provider === 'ollama' || a.usagePercent == null) { setState({ mode, active: null, remaining: null, source: null }); return }
3863
+ if (!a || a.provider === 'ollama' || a.usagePercent == null) { setState({ mode, active: null, remaining: null, source: null, candidates: [] }); return }
3734
3864
  const sourceRow = (sources && Array.isArray(sources.providers) ? sources.providers : []).find((row) => row && row.provider === a.provider)
3735
3865
  const source = sourceRow ? sourceRow.selected : null
3866
+ const candidates = sourceRow && Array.isArray(sourceRow.candidates) ? sourceRow.candidates : []
3736
3867
  if (r.quotaWidgetPosition && typeof window !== 'undefined') {
3737
3868
  const clamp = (v, max) => Math.max(4, Math.min(max, Number(v) || 0))
3738
3869
  const next = {
@@ -3742,7 +3873,7 @@ window.__ModuleLoader__.load({
3742
3873
  posRef.current = next
3743
3874
  setPos(next)
3744
3875
  }
3745
- setState({ mode, active: a, remaining: Math.max(0, 100 - a.usagePercent), source })
3876
+ setState({ mode, active: a, remaining: Math.max(0, 100 - a.usagePercent), source, candidates })
3746
3877
  }).catch(() => {})
3747
3878
  }
3748
3879
  pull()
@@ -3763,6 +3894,16 @@ window.__ModuleLoader__.load({
3763
3894
  if (layout && typeof layout.selectPanel === 'function') layout.selectPanel('plugins')
3764
3895
  } catch { /* bestEffort */ }
3765
3896
  }
3897
+ React.useEffect(() => {
3898
+ if (!open) return undefined
3899
+ const onDoc = (e) => {
3900
+ if (popRef.current && !popRef.current.contains(e.target) && !(e.target.closest && e.target.closest('.dsub-qw'))) setOpen(false)
3901
+ }
3902
+ const onKey = (e) => { if (e.key === 'Escape') setOpen(false) }
3903
+ document.addEventListener('pointerdown', onDoc)
3904
+ document.addEventListener('keydown', onKey)
3905
+ return () => { document.removeEventListener('pointerdown', onDoc); document.removeEventListener('keydown', onKey) }
3906
+ }, [open])
3766
3907
  const onPointerDown = (e) => {
3767
3908
  if (docked) return
3768
3909
  drag.current = { x: e.clientX, y: e.clientY, right: pos.right, bottom: pos.bottom, moved: false }
@@ -3786,7 +3927,27 @@ window.__ModuleLoader__.load({
3786
3927
  drag.current = null
3787
3928
  if (!docked && wasDrag && wasDrag.moved) savePosition(posRef.current)
3788
3929
  }
3789
- return h('div', {
3930
+ const popover = open ? h('div', {
3931
+ ref: popRef,
3932
+ className: 'dsub-qwPop',
3933
+ style: { right: pos.right + 'px', bottom: (pos.bottom + 38) + 'px' },
3934
+ role: 'dialog',
3935
+ 'aria-label': 'Quota sources',
3936
+ },
3937
+ h('div', { className: 'dsub-h' }, String(state.active.provider || '').toUpperCase() + ' sources'),
3938
+ (state.candidates || []).map((c, i) => {
3939
+ const label = c.source === 'api-key' ? 'API key' : (c.source === 'oauth' ? 'OAuth' : 'Ollama')
3940
+ const cd = c.detail && Array.isArray(c.detail.keys) ? Math.max(0, ...c.detail.keys.map((k) => Number(k.cooldownMsLeft) || 0)) : 0
3941
+ return h('div', { className: 'dsub-row', key: i },
3942
+ h('span', { className: 'dsub-h' }, label),
3943
+ h('span', { className: c.available ? 'dsub-ok' : 'dsub-dim' }, c.available ? 'available' : 'unavailable'),
3944
+ cd > 0 ? h('span', { className: 'dsub-dim' }, 'cooldown ' + Math.ceil(cd / 1000) + 's') : null,
3945
+ c.detail && c.detail.healthyKeys != null ? h('span', { className: 'dsub-dim' }, c.detail.healthyKeys + '/' + c.detail.totalKeys + ' keys') : null,
3946
+ )
3947
+ }),
3948
+ h('button', { type: 'button', className: 'dsub-mini', onClick: () => { setOpen(false); openSettings() } }, 'Open settings'),
3949
+ ) : null
3950
+ return h(React.Fragment, null, h('div', {
3790
3951
  className: 'dsub-qw' + (docked ? ' dsub-qwDocked' : '') + tone,
3791
3952
  style: { right: pos.right + 'px', bottom: pos.bottom + 'px' },
3792
3953
  role: 'button',
@@ -3797,15 +3958,15 @@ window.__ModuleLoader__.load({
3797
3958
  onPointerMove,
3798
3959
  onPointerUp,
3799
3960
  onPointerCancel: () => { drag.current = null },
3800
- onClick: () => { if (suppressClick.current) { suppressClick.current = false; return } openSettings() },
3801
- onKeyDown: (e) => { if (e.key === 'Enter' || e.key === ' ') openSettings() },
3961
+ onClick: () => { if (suppressClick.current) { suppressClick.current = false; return } setOpen((o) => !o) },
3962
+ onKeyDown: (e) => { if (e.key === 'Enter' || e.key === ' ') setOpen((o) => !o) },
3802
3963
  },
3803
3964
  h('span', { className: 'dsub-led ' + ledClass(state.active.usagePercent) }),
3804
3965
  h('span', { className: 'dsub-qwBrand' }, brandBadge(state.active.provider).icon + ' ' + String(state.active.provider || '').toUpperCase()),
3805
3966
  state.source ? h('span', { className: 'dsub-pillTag' }, state.source === 'api-key' ? 'KEY' : (state.source === 'oauth' ? 'OAuth' : 'OLLAMA')) : null,
3806
3967
  h('span', { className: 'dsub-qwBar' }, h('span', { className: 'dsub-qwBarFill', style: { width: state.remaining + '%', display: 'block' } })),
3807
3968
  h('span', { className: 'dsub-qwPct' }, Math.round(state.remaining) + '%'),
3808
- )
3969
+ ), popover)
3809
3970
  }
3810
3971
 
3811
3972
  function registerSubsPill(ctx) {
@@ -157,6 +157,12 @@ 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
+ webhookAllowPrivate: z.boolean().volatile().default(false)
163
+ .description('#729 Allow webhook URLs that resolve to private or loopback hosts.'),
164
+ digestIntervalHours: z.number().volatile().default(0)
165
+ .description('#720 Send an alert digest every N hours (0 disables digests).'),
160
166
  alertThresholds: z.array(z.number()).volatile().default([80, 90, 95])
161
167
  .description('#351 Quota usage percentage thresholds for firing alerts.'),
162
168
  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, setWebhookAllowPrivate } 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,11 @@ 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
+ setWebhookAllowPrivate(cachedConfig.webhookAllowPrivate)
98
+ return cachedConfig
99
+ }
95
100
 
96
101
  function syncCustomVendors() {
97
102
  const profiles = validateProfiles(live().customVendors)
@@ -604,6 +609,22 @@ export function apply(ctx, rawConfig) {
604
609
  }
605
610
  }, 'dsh-subscriptions: vault-sync-loop')
606
611
 
612
+ // #720: interval digest for busy webhook channels.
613
+ ctx.effect(() => {
614
+ let lastDigestAt = 0
615
+ const tick = () => {
616
+ const cfg = live()
617
+ const alerts = getRecentAlerts(100)
618
+ if (!isDigestDue({ lastDigestAt, nowMs: Date.now(), intervalHours: cfg.digestIntervalHours, alertCount: alerts.length })) return
619
+ dispatchNotificationDigest(alerts, cfg.webhookUrl, fetch, { secret: cfg.webhookSecret, allowPrivate: cfg.webhookAllowPrivate })
620
+ .then((ok) => { if (ok) lastDigestAt = Date.now() })
621
+ .catch(() => {})
622
+ }
623
+ tick()
624
+ const id = setInterval(tick, 60 * 60 * 1000)
625
+ return () => clearInterval(id)
626
+ }, 'dsh-subscriptions: notification digest')
627
+
607
628
  registerRoutes(ctx, {
608
629
  NS,
609
630
  live,
@@ -41,19 +41,50 @@ 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
57
88
  }
58
89
  }
59
90
 
@@ -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
+ }
@@ -0,0 +1,47 @@
1
+ import { dispatchWebhookAlert } from '../alerts.js'
2
+ import { isTrustedSettingsRequest, safeJsonHandler, writeJson } from '../http.js'
3
+ import { validateWebhookUrl } from '../webhook-url.js'
4
+
5
+ export function registerWebhookRoutes(ctx, state) {
6
+ const { live } = state
7
+ ctx.effect(() => ctx.webServer.register({
8
+ kind: 'exact',
9
+ path: '/dsh-subscriptions/webhook/test',
10
+ handler: safeJsonHandler(async (req, res) => {
11
+ if (req.method !== 'POST') {
12
+ writeJson(res, 405, { ok: false, error: { code: 'method', message: 'POST only' } })
13
+ return
14
+ }
15
+ if (!isTrustedSettingsRequest(req, ctx)) {
16
+ writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'same-origin only' } })
17
+ return
18
+ }
19
+ const cfg = live()
20
+ if (!cfg.webhookUrl) {
21
+ writeJson(res, 400, { ok: false, delivered: false, error: { code: 'webhook', message: 'webhookUrl is not configured' } })
22
+ return
23
+ }
24
+ const checked = validateWebhookUrl(cfg.webhookUrl, { allowPrivate: cfg.webhookAllowPrivate })
25
+ if (!checked.ok) {
26
+ writeJson(res, 400, { ok: false, delivered: false, error: { code: 'webhook', message: checked.reason } })
27
+ return
28
+ }
29
+ const alert = {
30
+ id: 'wh_test_' + Date.now().toString(36),
31
+ type: 'webhook_test',
32
+ ref: null,
33
+ provider: 'dsh-subscriptions',
34
+ message: 'DSH Subscriptions webhook test',
35
+ severity: 'info',
36
+ details: null,
37
+ timestamp: Date.now(),
38
+ }
39
+ const delivered = await dispatchWebhookAlert(alert, cfg.webhookUrl, fetch, {
40
+ secret: cfg.webhookSecret,
41
+ allowPrivate: cfg.webhookAllowPrivate,
42
+ retries: 1,
43
+ })
44
+ writeJson(res, delivered ? 200 : 502, { ok: delivered, delivered })
45
+ }),
46
+ }), 'dsh-subscriptions: /webhook/test')
47
+ }
package/lib/routes.js CHANGED
@@ -6,6 +6,8 @@ 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'
10
+ import { registerWebhookRoutes } from './routes/webhook.js'
9
11
  import { registerPluginUpdater } from './updater.js'
10
12
 
11
13
  export function registerRoutes(ctx, state) {
@@ -111,4 +113,6 @@ function esc(x){return String(x==null?'':x).replace(/&/g,'&amp;').replace(/</g,'
111
113
  registerAccountsRoutes(ctx, state)
112
114
  registerProxyRoutes(ctx, state)
113
115
  registerSourcesRoutes(ctx, state)
116
+ registerPacksRoutes(ctx)
117
+ registerWebhookRoutes(ctx, state)
114
118
  }
@@ -0,0 +1,27 @@
1
+ // #729: webhook URLs are user-controlled and fetched by the host, so reject
2
+ // obvious SSRF targets unless the operator explicitly opts into private hosts.
3
+ export function isPrivateHostname(hostname) {
4
+ const host = String(hostname || '').toLowerCase().replace(/^\[|\]$/g, '')
5
+ if (!host) return true
6
+ if (host === 'localhost' || host.endsWith('.localhost')) return true
7
+ if (host === '::1' || host === '0:0:0:0:0:0:0:1') return true
8
+ if (/^(fc|fd|fe8|fe9|fea|feb)/.test(host)) return true
9
+ const m = host.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/)
10
+ if (!m) return false
11
+ const parts = m.slice(1).map(Number)
12
+ if (parts.some((n) => n > 255)) return true
13
+ const [a, b] = parts
14
+ return a === 0 || a === 10 || a === 127 || (a === 169 && b === 254)
15
+ || (a === 172 && b >= 16 && b <= 31) || (a === 192 && b === 168)
16
+ || (a === 100 && b >= 64 && b <= 127)
17
+ }
18
+
19
+ export function validateWebhookUrl(raw, { allowPrivate = false } = {}) {
20
+ if (typeof raw !== 'string' || !raw.trim()) return { ok: false, reason: 'webhook URL is empty' }
21
+ let url
22
+ try { url = new URL(raw.trim()) } catch { return { ok: false, reason: 'webhook URL is invalid' } }
23
+ if (url.protocol !== 'https:' && url.protocol !== 'http:') return { ok: false, reason: 'webhook URL must use http(s)' }
24
+ if (url.username || url.password) return { ok: false, reason: 'webhook URL must not contain userinfo' }
25
+ if (!allowPrivate && isPrivateHostname(url.hostname)) return { ok: false, reason: 'webhook URL points to a private/loopback host' }
26
+ return { ok: true, url: url.toString() }
27
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-subscriptions",
3
- "version": "0.7.5",
3
+ "version": "0.7.7",
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",