@goodandready/dsh-subscriptions 0.7.10 → 0.7.12
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/dsh-subscriptions.mjs +78 -3
- package/lib/alerts.js +34 -1
- package/lib/client.js +147 -50
- package/lib/config-schema.js +8 -0
- package/lib/device-identity.js +41 -0
- package/lib/http.js +2 -5
- package/lib/index.js +3 -2
- package/lib/mask.js +1 -0
- package/lib/ollama-health.js +25 -0
- package/lib/routes/oauth.js +17 -0
- package/lib/routes/sources.js +2 -2
- package/lib/routes/status.js +5 -3
- package/lib/routes/vault.js +7 -3
- package/lib/vault-snapshots.js +2 -0
- package/lib/vault-sync.js +101 -41
- package/lib/vendors/grok.js +1 -0
- package/lib/vendors/index.js +1 -0
- package/package.json +2 -2
|
@@ -6,11 +6,12 @@ import { readFile } from 'node:fs/promises'
|
|
|
6
6
|
export function parseArgs(argv) {
|
|
7
7
|
const args = Array.isArray(argv) ? argv.slice() : []
|
|
8
8
|
const command = args.shift() || 'status'
|
|
9
|
-
const options = { command, csv: false, json: false, type: 'accounts', sessionId: '', mode: 'fast', help: false, setKey: '', setValue: '', packAction: '', packFile: '', vaultAction: '', vaultArg: '' }
|
|
9
|
+
const options = { command, csv: false, json: false, once: false, type: 'accounts', sessionId: '', mode: 'fast', help: false, setKey: '', setValue: '', packAction: '', packFile: '', vaultAction: '', vaultArg: '' }
|
|
10
10
|
for (let i = 0; i < args.length; i += 1) {
|
|
11
11
|
const arg = args[i]
|
|
12
12
|
if (arg === '--csv') options.csv = true
|
|
13
13
|
else if (arg === '--json') options.json = true
|
|
14
|
+
else if (arg === '--once') options.once = true
|
|
14
15
|
else if (arg === '--help' || arg === '-h') options.help = true
|
|
15
16
|
else if (arg === '--type') options.type = args[++i] || 'accounts'
|
|
16
17
|
else if (arg === '--session') options.sessionId = args[++i] || ''
|
|
@@ -125,6 +126,36 @@ export function formatPacks(packs) {
|
|
|
125
126
|
return JSON.stringify((Array.isArray(packs) ? packs : []).map(sanitizePack), null, 2) + '\\n'
|
|
126
127
|
}
|
|
127
128
|
|
|
129
|
+
export function renderTui(data, { color = false } = {}) {
|
|
130
|
+
const health = (data && data.health) || {}
|
|
131
|
+
const checks = health.checks || {}
|
|
132
|
+
const sources = (data && data.sources) || {}
|
|
133
|
+
const snapshots = (data && data.snapshots && data.snapshots.stats) || {}
|
|
134
|
+
const accounts = Array.isArray(data && data.accounts) ? data.accounts : []
|
|
135
|
+
const dim = (text) => color ? '\x1b[2m' + text + '\x1b[0m' : text
|
|
136
|
+
const green = (text) => color ? '\x1b[32m' + text + '\x1b[0m' : text
|
|
137
|
+
const red = (text) => color ? '\x1b[31m' + text + '\x1b[0m' : text
|
|
138
|
+
const lines = []
|
|
139
|
+
lines.push('DSH Subscriptions' + (health.version ? ' ' + health.version : '') + ' ' + (health.status === 'healthy' ? green('healthy') : red(health.status || 'unknown')))
|
|
140
|
+
lines.push(dim('accounts: ' + (checks.connectedAccounts || 0) + ' configured · key-rotation: ' + (checks.keyRotation ? 'yes' : 'no') + ' · ollama: ' + (checks.ollama ? 'yes' : 'no') + ' · webhook: ' + (checks.webhook ? 'yes' : 'no')))
|
|
141
|
+
lines.push('')
|
|
142
|
+
lines.push('ACCOUNTS')
|
|
143
|
+
for (const account of accounts) {
|
|
144
|
+
if (!account || !account.provider) continue
|
|
145
|
+
const usage = account.usagePercent == null ? '-' : Math.round(account.usagePercent) + '%'
|
|
146
|
+
lines.push(' ' + String(account.provider).padEnd(14) + '#' + (account.index || 1) + ' ' + (account.configured ? green('ok') : dim('off')) + ' ' + usage)
|
|
147
|
+
}
|
|
148
|
+
lines.push('')
|
|
149
|
+
lines.push('SOURCES')
|
|
150
|
+
for (const row of (sources.providers || [])) lines.push(' ' + String(row.provider).padEnd(14) + (row.selected || dim('none')))
|
|
151
|
+
lines.push('')
|
|
152
|
+
lines.push('VAULT')
|
|
153
|
+
lines.push(' snapshots: ' + (snapshots.count || 0) + ' · ' + (snapshots.bytes || 0) + ' bytes')
|
|
154
|
+
lines.push('')
|
|
155
|
+
lines.push(dim('r refresh · q quit'))
|
|
156
|
+
return lines.join('\n')
|
|
157
|
+
}
|
|
158
|
+
|
|
128
159
|
export function formatSnapshotStatus(payload) {
|
|
129
160
|
const stats = (payload && payload.stats) || {}
|
|
130
161
|
return 'snapshots: ' + (stats.count || 0) + ' bytes: ' + (stats.bytes || 0) + ' path: ' + (stats.path || '-')
|
|
@@ -176,6 +207,21 @@ export function buildSetRequest(options, configPayload, env = process.env) {
|
|
|
176
207
|
export async function run(argv = process.argv.slice(2), env = process.env, fetchImpl = fetch) {
|
|
177
208
|
const options = parseArgs(argv)
|
|
178
209
|
if (options.help) return { help: true }
|
|
210
|
+
if (options.command === 'tui') {
|
|
211
|
+
const base = String(env.DSH_SUBS_URL || 'http://127.0.0.1:3080/dsh-subscriptions').replace(/\/$/, '')
|
|
212
|
+
const headers = { Accept: 'application/json' }
|
|
213
|
+
if (env.DSH_AUTH_TOKEN) headers.Authorization = 'Bearer ' + env.DSH_AUTH_TOKEN
|
|
214
|
+
const get = (path) => fetchImpl(base + path, { method: 'GET', headers }).then(async (res) => {
|
|
215
|
+
const text = await res.text()
|
|
216
|
+
if (!res.ok) throw new Error('HTTP ' + res.status + ': ' + text.slice(0, 300))
|
|
217
|
+
return JSON.parse(text || '{}')
|
|
218
|
+
})
|
|
219
|
+
const [health, sources, snapshots, config] = await Promise.all([
|
|
220
|
+
get('/health'), get('/sources'), get('/vault/snapshots'), get('/config'),
|
|
221
|
+
])
|
|
222
|
+
const data = { health, sources, snapshots, accounts: (config && config.accounts) || [] }
|
|
223
|
+
return { text: renderTui(data, { color: false }), tuiData: data, contentType: 'text/plain' }
|
|
224
|
+
}
|
|
179
225
|
if (options.command === 'doctor') {
|
|
180
226
|
const diagReq = buildRequest({ command: 'diagnostics' }, env)
|
|
181
227
|
const srcReq = buildRequest({ command: 'sources' }, env)
|
|
@@ -242,12 +288,41 @@ export async function run(argv = process.argv.slice(2), env = process.env, fetch
|
|
|
242
288
|
return { text, contentType: res.headers.get('content-type') || '' }
|
|
243
289
|
}
|
|
244
290
|
|
|
291
|
+
async function runTui(initial) {
|
|
292
|
+
let cleanup = () => {}
|
|
293
|
+
const paint = (data) => process.stdout.write('\x1b[2J\x1b[H' + renderTui(data, { color: true }) + '\n')
|
|
294
|
+
paint(initial)
|
|
295
|
+
const onKey = async (buf) => {
|
|
296
|
+
const key = String(buf)
|
|
297
|
+
if (key === 'q' || key === '\u0003') { cleanup(); process.exit(0) }
|
|
298
|
+
if (key === 'r') {
|
|
299
|
+
try {
|
|
300
|
+
const next = await run(['tui', '--once'])
|
|
301
|
+
if (next.tuiData) paint(next.tuiData)
|
|
302
|
+
} catch (err) { process.stdout.write('\n' + String(err && err.message || err) + '\n') }
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
if (process.stdin.isTTY && typeof process.stdin.setRawMode === 'function') {
|
|
306
|
+
process.stdin.setRawMode(true)
|
|
307
|
+
process.stdin.resume()
|
|
308
|
+
process.stdin.on('data', onKey)
|
|
309
|
+
cleanup = () => {
|
|
310
|
+
process.stdin.removeListener('data', onKey)
|
|
311
|
+
process.stdin.setRawMode(false)
|
|
312
|
+
process.stdin.pause()
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
245
317
|
async function main() {
|
|
246
|
-
const
|
|
318
|
+
const argv = process.argv.slice(2)
|
|
319
|
+
const options = parseArgs(argv)
|
|
320
|
+
const result = await run(argv)
|
|
247
321
|
if (result.help) {
|
|
248
|
-
process.stdout.write('Usage: dsh-subscriptions <status|usage|fast|config|set|sources|packs|vault|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 vault snapshots | vault snapshot [label] | vault restore <id> | vault status\n doctor [--json]\n')
|
|
322
|
+
process.stdout.write('Usage: dsh-subscriptions <status|usage|fast|config|set|sources|packs|vault|tui|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 vault snapshots | vault snapshot [label] | vault restore <id> | vault status\n tui [--once]\n doctor [--json]\n')
|
|
249
323
|
return
|
|
250
324
|
}
|
|
325
|
+
if (result.tuiData && !options.once) { await runTui(result.tuiData); return }
|
|
251
326
|
process.stdout.write(result.text.endsWith('\n') ? result.text : result.text + '\n')
|
|
252
327
|
if (result.code) process.exitCode = result.code
|
|
253
328
|
}
|
package/lib/alerts.js
CHANGED
|
@@ -16,6 +16,7 @@ try {
|
|
|
16
16
|
const MAX_ALERTS = 100
|
|
17
17
|
let defaultWebhookSecret = ''
|
|
18
18
|
let defaultAllowPrivate = false
|
|
19
|
+
let defaultWebhookFormat = 'native'
|
|
19
20
|
|
|
20
21
|
export function setWebhookSecret(secret) {
|
|
21
22
|
defaultWebhookSecret = String(secret || '')
|
|
@@ -24,6 +25,35 @@ export function setWebhookSecret(secret) {
|
|
|
24
25
|
export function setWebhookAllowPrivate(allow) {
|
|
25
26
|
defaultAllowPrivate = Boolean(allow)
|
|
26
27
|
}
|
|
28
|
+
|
|
29
|
+
export function setWebhookFormat(format) {
|
|
30
|
+
defaultWebhookFormat = format === 'notify' ? 'notify' : 'native'
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function toNotifyPayload(body) {
|
|
34
|
+
const sentAt = new Date().toISOString()
|
|
35
|
+
if (body && body.alert) {
|
|
36
|
+
return {
|
|
37
|
+
kind: body.alert.severity === 'info' ? 'task_done' : 'error',
|
|
38
|
+
title: 'DSH Subscriptions',
|
|
39
|
+
sessionId: 'dsh-subscriptions',
|
|
40
|
+
summary: body.alert.message || body.alert.type || 'subscription alert',
|
|
41
|
+
reason: body.alert.type || null,
|
|
42
|
+
source: 'dsh-subscriptions',
|
|
43
|
+
sentAt,
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
const digest = (body && body.digest) || { total: 0 }
|
|
47
|
+
return {
|
|
48
|
+
kind: digest.total > 0 ? 'error' : 'task_done',
|
|
49
|
+
title: 'DSH Subscriptions digest',
|
|
50
|
+
sessionId: 'dsh-subscriptions',
|
|
51
|
+
summary: String(digest.total || 0) + ' alerts in the digest',
|
|
52
|
+
reason: 'digest',
|
|
53
|
+
source: 'dsh-subscriptions',
|
|
54
|
+
sentAt,
|
|
55
|
+
}
|
|
56
|
+
}
|
|
27
57
|
const DEDUP_WINDOW_MS = 10 * 60 * 1000 // 10 minutes cooldown per (ref + type)
|
|
28
58
|
|
|
29
59
|
const alertBuffer = []
|
|
@@ -106,10 +136,13 @@ async function dispatchNotification(body, webhookUrl, fetchImpl, options) {
|
|
|
106
136
|
const { retries = 1, timeoutMs = 5000, sleep } = opts
|
|
107
137
|
const secret = opts.secret == null ? defaultWebhookSecret : opts.secret
|
|
108
138
|
const allowPrivate = opts.allowPrivate == null ? defaultAllowPrivate : Boolean(opts.allowPrivate)
|
|
139
|
+
const format = opts.format == null ? defaultWebhookFormat : opts.format
|
|
109
140
|
const checked = validateWebhookUrl(webhookUrl, { allowPrivate })
|
|
110
141
|
if (!checked.ok) return false
|
|
111
142
|
webhookUrl = checked.url
|
|
112
|
-
const payload =
|
|
143
|
+
const payload = format === 'notify'
|
|
144
|
+
? toNotifyPayload(body)
|
|
145
|
+
: { ...body, source: 'dsh-subscriptions', sentAt: new Date().toISOString() }
|
|
113
146
|
const raw = JSON.stringify(payload)
|
|
114
147
|
const headers = {
|
|
115
148
|
'content-type': 'application/json',
|
package/lib/client.js
CHANGED
|
@@ -390,6 +390,47 @@ window.__ModuleLoader__.load({
|
|
|
390
390
|
"cachePinnedBadge": "Cache Pinned",
|
|
391
391
|
"reasoningEffortTitle": "Reasoning Effort Control",
|
|
392
392
|
"reasoningEffortDesc": "Configure reasoning depth for models with extended thinking capabilities.",
|
|
393
|
+
"quotaWidget": "Quota widget",
|
|
394
|
+
"quotaWidgetOff": "Off",
|
|
395
|
+
"quotaWidgetDocked": "Docked",
|
|
396
|
+
"quotaWidgetFloating": "Floating (draggable)",
|
|
397
|
+
"quotaWidgetHint": "Floating: draggable; docked.",
|
|
398
|
+
"webhookUrl": "Webhook URL",
|
|
399
|
+
"webhookUrlHint": "Signed POST target for alerts.",
|
|
400
|
+
"webhookSecret": "Webhook secret",
|
|
401
|
+
"webhookFormat": "Webhook format",
|
|
402
|
+
"webhookFormatNative": "Native",
|
|
403
|
+
"webhookFormatNotify": "dsh-plugin-notify compatible",
|
|
404
|
+
"digestIntervalHours": "Digest interval (hours)",
|
|
405
|
+
"digestHint": "0 disables digests; needs a URL.",
|
|
406
|
+
"sendTest": "Send test",
|
|
407
|
+
"delivered": "delivered",
|
|
408
|
+
"vaultSnapshots": "Vault snapshots",
|
|
409
|
+
"vaultSnapshotsHint": "Encrypted slots; auto pre-pull.",
|
|
410
|
+
"createSnapshot": "Create snapshot",
|
|
411
|
+
"restoreSnapshot": "Restore",
|
|
412
|
+
"noSnapshots": "No snapshots yet.",
|
|
413
|
+
"created": "created",
|
|
414
|
+
"restoredSlots": "restored slots:",
|
|
415
|
+
"providerPacks": "Provider packs",
|
|
416
|
+
"providerPacksHint": "Add a Responses provider as JSON.",
|
|
417
|
+
"noPacks": "No packs yet.",
|
|
418
|
+
"validateAndSave": "Validate & save",
|
|
419
|
+
"exportPacks": "Export",
|
|
420
|
+
"exported": "exported",
|
|
421
|
+
"saved": "saved",
|
|
422
|
+
"sources": "Sources",
|
|
423
|
+
"sourcesHint": "Subscription → API key → Ollama.",
|
|
424
|
+
"keyRotationOffline": "key-rotation not installed/offline",
|
|
425
|
+
"keyRotationHealth": "key-rotation health",
|
|
426
|
+
"apiKeySource": "API key",
|
|
427
|
+
"oauthSource": "OAuth",
|
|
428
|
+
"ollamaSource": "Ollama",
|
|
429
|
+
"sourceNone": "none",
|
|
430
|
+
"openSettings": "Open settings",
|
|
431
|
+
"sourceAvailable": "available",
|
|
432
|
+
"sourceUnavailable": "unavailable",
|
|
433
|
+
"cooldownLabel": "cooldown",
|
|
393
434
|
"reasoningLevelOff": "Off"
|
|
394
435
|
};
|
|
395
436
|
|
|
@@ -704,6 +745,47 @@ window.__ModuleLoader__.load({
|
|
|
704
745
|
"cachePinnedBadge": "缓存粘性",
|
|
705
746
|
"reasoningEffortTitle": "推理强度控制",
|
|
706
747
|
"reasoningEffortDesc": "为具备扩展思考能力的模型配置推理深度与思考预算。",
|
|
748
|
+
"quotaWidget": "额度组件",
|
|
749
|
+
"quotaWidgetOff": "关闭",
|
|
750
|
+
"quotaWidgetDocked": "停靠",
|
|
751
|
+
"quotaWidgetFloating": "浮动(可拖动)",
|
|
752
|
+
"quotaWidgetHint": "浮动可拖动;停靠右下。",
|
|
753
|
+
"webhookUrl": "Webhook 地址",
|
|
754
|
+
"webhookUrlHint": "警报的签名 POST 目标。",
|
|
755
|
+
"webhookSecret": "Webhook 密钥",
|
|
756
|
+
"webhookFormat": "Webhook 格式",
|
|
757
|
+
"webhookFormatNative": "原生",
|
|
758
|
+
"webhookFormatNotify": "兼容 dsh-plugin-notify",
|
|
759
|
+
"digestIntervalHours": "摘要间隔(小时)",
|
|
760
|
+
"digestHint": "0 关闭摘要;需要地址。",
|
|
761
|
+
"sendTest": "发送测试",
|
|
762
|
+
"delivered": "已送达",
|
|
763
|
+
"vaultSnapshots": "Vault 快照",
|
|
764
|
+
"vaultSnapshotsHint": "加密 slots 快照。",
|
|
765
|
+
"createSnapshot": "创建快照",
|
|
766
|
+
"restoreSnapshot": "恢复",
|
|
767
|
+
"noSnapshots": "尚无快照。",
|
|
768
|
+
"created": "已创建",
|
|
769
|
+
"restoredSlots": "已恢复 slots:",
|
|
770
|
+
"providerPacks": "供应商包",
|
|
771
|
+
"providerPacksHint": "JSON 添加供应商。",
|
|
772
|
+
"noPacks": "尚无供应商包。",
|
|
773
|
+
"validateAndSave": "验证并保存",
|
|
774
|
+
"exportPacks": "导出",
|
|
775
|
+
"exported": "已导出",
|
|
776
|
+
"saved": "已保存",
|
|
777
|
+
"sources": "来源",
|
|
778
|
+
"sourcesHint": "订阅 → 密钥 → Ollama。",
|
|
779
|
+
"keyRotationOffline": "key-rotation 未安装或离线",
|
|
780
|
+
"keyRotationHealth": "key-rotation 健康度",
|
|
781
|
+
"apiKeySource": "API 密钥",
|
|
782
|
+
"oauthSource": "OAuth",
|
|
783
|
+
"ollamaSource": "Ollama",
|
|
784
|
+
"sourceNone": "无",
|
|
785
|
+
"openSettings": "打开设置",
|
|
786
|
+
"sourceAvailable": "可用",
|
|
787
|
+
"sourceUnavailable": "不可用",
|
|
788
|
+
"cooldownLabel": "冷却",
|
|
707
789
|
"reasoningLevelOff": "关闭"
|
|
708
790
|
};
|
|
709
791
|
// #51: live countdown to the quota window reset (account.quota.resetAt).
|
|
@@ -1829,21 +1911,21 @@ window.__ModuleLoader__.load({
|
|
|
1829
1911
|
),
|
|
1830
1912
|
),
|
|
1831
1913
|
h('div', { className: 'dsub-sub' }, t('composerQuotaDisplayHint')), h('div', { className: 'dsub-row' },
|
|
1832
|
-
h('span', { className: 'dsub-h' }, '
|
|
1914
|
+
h('span', { className: 'dsub-h' }, t('quotaWidget')),
|
|
1833
1915
|
h('select', {
|
|
1834
1916
|
className: 'dsub-select',
|
|
1835
1917
|
value: draft.quotaWidget || 'docked',
|
|
1836
1918
|
onChange: (e) => setDraft((d) => Object.assign({}, d, { quotaWidget: e.target.value })),
|
|
1837
1919
|
},
|
|
1838
|
-
h('option', { value: 'off' }, '
|
|
1839
|
-
h('option', { value: 'docked' }, '
|
|
1840
|
-
h('option', { value: 'floating' },
|
|
1920
|
+
h('option', { value: 'off' }, t('quotaWidgetOff')),
|
|
1921
|
+
h('option', { value: 'docked' }, t('quotaWidgetDocked')),
|
|
1922
|
+
h('option', { value: 'floating' }, t('quotaWidgetFloating')),
|
|
1841
1923
|
),
|
|
1842
1924
|
),
|
|
1843
|
-
h('div', { className: 'dsub-sub' }, '
|
|
1925
|
+
h('div', { className: 'dsub-sub' }, t('quotaWidgetHint')),
|
|
1844
1926
|
h(SourcesPanel, { t }),
|
|
1845
|
-
h(ProviderPacksPanel),
|
|
1846
|
-
h(VaultSnapshotsPanel),
|
|
1927
|
+
h(ProviderPacksPanel, { t }),
|
|
1928
|
+
h(VaultSnapshotsPanel, { t }),
|
|
1847
1929
|
|
|
1848
1930
|
h('div', { className: 'dsub-row' },
|
|
1849
1931
|
h('span', { className: 'dsub-h' }, t('composerQuota')),
|
|
@@ -1875,7 +1957,7 @@ window.__ModuleLoader__.load({
|
|
|
1875
1957
|
h('div', { className: 'dsub-sub' },
|
|
1876
1958
|
t('expiryNotifyDaysHint')),
|
|
1877
1959
|
h('div', { className: 'dsub-row' },
|
|
1878
|
-
h('span', { className: 'dsub-h' }, '
|
|
1960
|
+
h('span', { className: 'dsub-h' }, t('webhookUrl')),
|
|
1879
1961
|
h('input', {
|
|
1880
1962
|
type: 'url',
|
|
1881
1963
|
className: 'dsub-inp',
|
|
@@ -1884,9 +1966,20 @@ window.__ModuleLoader__.load({
|
|
|
1884
1966
|
onChange: (e) => setDraft((d) => Object.assign({}, d, { webhookUrl: e.target.value })),
|
|
1885
1967
|
}),
|
|
1886
1968
|
),
|
|
1887
|
-
h('div', { className: 'dsub-sub' }, '
|
|
1969
|
+
h('div', { className: 'dsub-sub' }, t('webhookUrlHint')),
|
|
1888
1970
|
h('div', { className: 'dsub-row' },
|
|
1889
|
-
h('span', { className: 'dsub-h' }, '
|
|
1971
|
+
h('span', { className: 'dsub-h' }, t('webhookFormat')),
|
|
1972
|
+
h('select', {
|
|
1973
|
+
className: 'dsub-select',
|
|
1974
|
+
value: draft.webhookFormat || 'native',
|
|
1975
|
+
onChange: (e) => setDraft((d) => Object.assign({}, d, { webhookFormat: e.target.value })),
|
|
1976
|
+
},
|
|
1977
|
+
h('option', { value: 'native' }, t('webhookFormatNative')),
|
|
1978
|
+
h('option', { value: 'notify' }, t('webhookFormatNotify')),
|
|
1979
|
+
),
|
|
1980
|
+
),
|
|
1981
|
+
h('div', { className: 'dsub-row' },
|
|
1982
|
+
h('span', { className: 'dsub-h' }, t('webhookSecret')),
|
|
1890
1983
|
h('input', {
|
|
1891
1984
|
type: 'password',
|
|
1892
1985
|
className: 'dsub-inp',
|
|
@@ -1896,7 +1989,7 @@ window.__ModuleLoader__.load({
|
|
|
1896
1989
|
}),
|
|
1897
1990
|
),
|
|
1898
1991
|
h('div', { className: 'dsub-row' },
|
|
1899
|
-
h('span', { className: 'dsub-h' },
|
|
1992
|
+
h('span', { className: 'dsub-h' }, t('digestIntervalHours')),
|
|
1900
1993
|
h('input', {
|
|
1901
1994
|
type: 'number',
|
|
1902
1995
|
className: 'dsub-inp',
|
|
@@ -1907,8 +2000,8 @@ window.__ModuleLoader__.load({
|
|
|
1907
2000
|
onChange: (e) => setDraft((d) => Object.assign({}, d, { digestIntervalHours: Number(e.target.value) || 0 })),
|
|
1908
2001
|
}),
|
|
1909
2002
|
),
|
|
1910
|
-
h('div', { className: 'dsub-sub' }, '
|
|
1911
|
-
h(WebhookTestButton),
|
|
2003
|
+
h('div', { className: 'dsub-sub' }, t('digestHint')),
|
|
2004
|
+
h(WebhookTestButton, { t }),
|
|
1912
2005
|
),
|
|
1913
2006
|
h('details', { className: 'dsub-group', open: true },
|
|
1914
2007
|
h('summary', { className: 'dsub-groupHead' }, '🚦 ' + t('groupQueue')),
|
|
@@ -2049,19 +2142,19 @@ window.__ModuleLoader__.load({
|
|
|
2049
2142
|
h('div', { className: 'dsub-sub', style: { marginTop: '4px' } }, t('modelsCatalogDesc')),
|
|
2050
2143
|
h('div', { className: 'dsub-model-grid' },
|
|
2051
2144
|
(Array.isArray(modelsCatalog) && modelsCatalog.length ? modelsCatalog : [
|
|
2052
|
-
{ id: 'gpt-4o', contextLabel: '128k', capabilities: ['vision'], pool: { status: '
|
|
2053
|
-
{ id: 'o1 / o3-mini', contextLabel: '200k', capabilities: ['reasoning'], pool: { status: '
|
|
2054
|
-
{ id: 'claude-3-7-sonnet', contextLabel: '200k', capabilities: ['reasoning'], pool: { status: '
|
|
2055
|
-
{ id: 'claude-3-5-sonnet', contextLabel: '200k', capabilities: ['coding'], pool: { status: '
|
|
2056
|
-
{ id: 'grok-beta', contextLabel: '128k', capabilities: ['fast'], pool: { status: '
|
|
2057
|
-
{ id: 'gemini-2.5-pro', contextLabel: '1M', capabilities: ['vision'], pool: { status: '
|
|
2058
|
-
{ id: 'kimi-k1.5', contextLabel: '128k', capabilities: ['math'], pool: { status: '
|
|
2059
|
-
{ id: 'glm-4-plus', contextLabel: '128k', capabilities: ['chinese'], pool: { status: '
|
|
2145
|
+
{ id: 'gpt-4o', contextLabel: '128k', capabilities: ['vision'], pool: { status: t('sourceAvailable') } },
|
|
2146
|
+
{ id: 'o1 / o3-mini', contextLabel: '200k', capabilities: ['reasoning'], pool: { status: t('sourceAvailable') } },
|
|
2147
|
+
{ id: 'claude-3-7-sonnet', contextLabel: '200k', capabilities: ['reasoning'], pool: { status: t('sourceAvailable') } },
|
|
2148
|
+
{ id: 'claude-3-5-sonnet', contextLabel: '200k', capabilities: ['coding'], pool: { status: t('sourceAvailable') } },
|
|
2149
|
+
{ id: 'grok-beta', contextLabel: '128k', capabilities: ['fast'], pool: { status: t('sourceAvailable') } },
|
|
2150
|
+
{ id: 'gemini-2.5-pro', contextLabel: '1M', capabilities: ['vision'], pool: { status: t('sourceAvailable') } },
|
|
2151
|
+
{ id: 'kimi-k1.5', contextLabel: '128k', capabilities: ['math'], pool: { status: t('sourceAvailable') } },
|
|
2152
|
+
{ id: 'glm-4-plus', contextLabel: '128k', capabilities: ['chinese'], pool: { status: t('sourceAvailable') } },
|
|
2060
2153
|
]).map((m, idx) => {
|
|
2061
2154
|
const poolStatus = m.pool ? m.pool.status : 'unconfigured'
|
|
2062
2155
|
const statusClass = 'dsub-model-status-' + poolStatus
|
|
2063
2156
|
let poolLabel = ''
|
|
2064
|
-
if (poolStatus === '
|
|
2157
|
+
if (poolStatus === t('sourceAvailable')) {
|
|
2065
2158
|
poolLabel = '🟢 ' + (m.pool && m.pool.remainingPercent != null ? (m.pool.remainingPercent + '%') : t('modelPoolAvailable'))
|
|
2066
2159
|
} else if (poolStatus === 'degraded') {
|
|
2067
2160
|
poolLabel = '🟡 ' + t('modelPoolDegraded') + (m.pool ? ' (' + m.pool.healthyAccounts + '/' + m.pool.totalAccounts + ')' : '')
|
|
@@ -3692,7 +3785,8 @@ window.__ModuleLoader__.load({
|
|
|
3692
3785
|
)
|
|
3693
3786
|
}
|
|
3694
3787
|
|
|
3695
|
-
function WebhookTestButton() {
|
|
3788
|
+
function WebhookTestButton(props) {
|
|
3789
|
+
const t = (props && props.t) || ((k) => k)
|
|
3696
3790
|
const [state, setState] = React.useState({ busy: false, message: '', error: null })
|
|
3697
3791
|
const send = () => {
|
|
3698
3792
|
setState({ busy: true, message: '', error: null })
|
|
@@ -3700,19 +3794,20 @@ window.__ModuleLoader__.load({
|
|
|
3700
3794
|
.then(async (res) => {
|
|
3701
3795
|
const data = await res.json().catch(() => ({}))
|
|
3702
3796
|
if (!res.ok || !data.delivered) throw new Error((data.error && data.error.message) || ('HTTP ' + res.status))
|
|
3703
|
-
setState({ busy: false, message: 'delivered', error: null })
|
|
3797
|
+
setState({ busy: false, message: t('delivered'), error: null })
|
|
3704
3798
|
})
|
|
3705
3799
|
.catch((err) => setState({ busy: false, message: '', error: String(err && err.message || err) }))
|
|
3706
3800
|
}
|
|
3707
3801
|
return h('div', { className: 'dsub-row' },
|
|
3708
|
-
h('button', { type: 'button', className: 'dsub-mini', disabled: state.busy, onClick: send }, state.busy ? '…' : '
|
|
3802
|
+
h('button', { type: 'button', className: 'dsub-mini', disabled: state.busy, onClick: send }, state.busy ? '…' : t('sendTest')),
|
|
3709
3803
|
state.message ? h('span', { className: 'dsub-ok' }, state.message) : null,
|
|
3710
3804
|
state.error ? h('span', { className: 'dsub-bad' }, state.error) : null,
|
|
3711
3805
|
)
|
|
3712
3806
|
}
|
|
3713
3807
|
|
|
3714
3808
|
// #736: vault snapshot list with create/restore. Payload stays server-side.
|
|
3715
|
-
function VaultSnapshotsPanel() {
|
|
3809
|
+
function VaultSnapshotsPanel(props) {
|
|
3810
|
+
const t = (props && props.t) || ((k) => k)
|
|
3716
3811
|
const [state, setState] = React.useState({ snapshots: [], stats: null, message: '', error: null, busy: false })
|
|
3717
3812
|
const pull = () => {
|
|
3718
3813
|
fetch('/dsh-subscriptions/vault/snapshots', { cache: 'no-store' })
|
|
@@ -3733,31 +3828,32 @@ window.__ModuleLoader__.load({
|
|
|
3733
3828
|
const create = () => {
|
|
3734
3829
|
setState((st) => Object.assign({}, st, { busy: true, message: '', error: null }))
|
|
3735
3830
|
call('/dsh-subscriptions/vault/snapshots', { label: 'manual' })
|
|
3736
|
-
.then((data) => { setState((st) => Object.assign({}, st, { busy: false, message: 'created ' + data.snapshot.id })); pull() })
|
|
3831
|
+
.then((data) => { setState((st) => Object.assign({}, st, { busy: false, message: t('created') + ' ' + data.snapshot.id })); pull() })
|
|
3737
3832
|
.catch((err) => setState((st) => Object.assign({}, st, { busy: false, error: String(err && err.message || err) })))
|
|
3738
3833
|
}
|
|
3739
3834
|
const restore = (id) => {
|
|
3740
3835
|
setState((st) => Object.assign({}, st, { busy: true, message: '', error: null }))
|
|
3741
3836
|
call('/dsh-subscriptions/vault/snapshots/restore', { id })
|
|
3742
|
-
.then((data) => { setState((st) => Object.assign({}, st, { busy: false, message: '
|
|
3837
|
+
.then((data) => { setState((st) => Object.assign({}, st, { busy: false, message: t('restoredSlots') + ' ' + data.slotCount })); pull() })
|
|
3743
3838
|
.catch((err) => setState((st) => Object.assign({}, st, { busy: false, error: String(err && err.message || err) })))
|
|
3744
3839
|
}
|
|
3745
3840
|
return h('div', { className: 'dsub-section-card' },
|
|
3746
|
-
h('div', { className: 'dsub-section-title' }, '
|
|
3747
|
-
h('div', { className: 'dsub-section-desc' }, '
|
|
3841
|
+
h('div', { className: 'dsub-section-title' }, t('vaultSnapshots'), h('button', { type: 'button', className: 'dsub-mini', onClick: create, disabled: state.busy }, state.busy ? '…' : t('createSnapshot'))),
|
|
3842
|
+
h('div', { className: 'dsub-section-desc' }, t('vaultSnapshotsHint')),
|
|
3748
3843
|
state.stats ? h('div', { className: 'dsub-dim' }, state.stats.count + ' snapshots · ' + state.stats.bytes + ' bytes · ' + state.stats.path) : null,
|
|
3749
3844
|
state.snapshots.length ? state.snapshots.map((snap) => h('div', { className: 'dsub-row', key: snap.id },
|
|
3750
3845
|
h('span', { className: 'dsub-h' }, snap.id),
|
|
3751
3846
|
h('span', { className: 'dsub-dim' }, (snap.label || '-') + ' · ' + snap.slotCount + ' slots · ' + snap.createdAt),
|
|
3752
|
-
h('button', { type: 'button', className: 'dsub-mini', disabled: state.busy, onClick: () => restore(snap.id) }, '
|
|
3753
|
-
)) : h('div', { className: 'dsub-dim' }, '
|
|
3847
|
+
h('button', { type: 'button', className: 'dsub-mini', disabled: state.busy, onClick: () => restore(snap.id) }, t('restoreSnapshot')),
|
|
3848
|
+
)) : h('div', { className: 'dsub-dim' }, t('noSnapshots')),
|
|
3754
3849
|
state.message ? h('div', { className: 'dsub-ok' }, state.message) : null,
|
|
3755
3850
|
state.error ? h('div', { className: 'dsub-bad' }, state.error) : null,
|
|
3756
3851
|
)
|
|
3757
3852
|
}
|
|
3758
3853
|
|
|
3759
3854
|
// #724: provider pack manager. Displays ids/models only; headers are masked.
|
|
3760
|
-
function ProviderPacksPanel() {
|
|
3855
|
+
function ProviderPacksPanel(props) {
|
|
3856
|
+
const t = (props && props.t) || ((k) => k)
|
|
3761
3857
|
const [state, setState] = React.useState({ packs: [], text: '', message: '', error: null, busy: false })
|
|
3762
3858
|
const publicPack = (pack) => {
|
|
3763
3859
|
const copy = Object.assign({}, pack)
|
|
@@ -3801,18 +3897,18 @@ window.__ModuleLoader__.load({
|
|
|
3801
3897
|
body: JSON.stringify({ config: Object.assign({}, cfg.config, { providerPacks: packs }), revision: cfg.revision }),
|
|
3802
3898
|
})
|
|
3803
3899
|
if (!res.ok) throw new Error('HTTP ' + res.status)
|
|
3804
|
-
setState((st) => Object.assign({}, st, { packs, busy: false, message: 'saved ' + checked.id, text: '' }))
|
|
3900
|
+
setState((st) => Object.assign({}, st, { packs, busy: false, message: t('saved') + ' ' + checked.id, text: '' }))
|
|
3805
3901
|
})
|
|
3806
3902
|
.catch((err) => setState((st) => Object.assign({}, st, { busy: false, error: String(err && err.message || err) })))
|
|
3807
3903
|
}
|
|
3808
|
-
const exportPacks = () => setState((st) => Object.assign({}, st, { text: JSON.stringify(st.packs.map(publicPack), null, 2), message: 'exported' }))
|
|
3904
|
+
const exportPacks = () => setState((st) => Object.assign({}, st, { text: JSON.stringify(st.packs.map(publicPack), null, 2), message: t('exported') }))
|
|
3809
3905
|
return h('div', { className: 'dsub-section-card' },
|
|
3810
|
-
h('div', { className: 'dsub-section-title' }, '
|
|
3811
|
-
h('div', { className: 'dsub-section-desc' }, '
|
|
3906
|
+
h('div', { className: 'dsub-section-title' }, t('providerPacks'), h('button', { type: 'button', className: 'dsub-mini', onClick: pull }, '↻')),
|
|
3907
|
+
h('div', { className: 'dsub-section-desc' }, t('providerPacksHint')),
|
|
3812
3908
|
state.packs.length ? state.packs.map((pack) => h('div', { className: 'dsub-row', key: pack.id },
|
|
3813
3909
|
h('span', { className: 'dsub-h' }, pack.id),
|
|
3814
3910
|
h('span', { className: 'dsub-dim' }, (pack.displayName || '') + ' · ' + ((pack.models || []).length) + ' models'),
|
|
3815
|
-
)) : h('div', { className: 'dsub-dim' }, '
|
|
3911
|
+
)) : h('div', { className: 'dsub-dim' }, t('noPacks')),
|
|
3816
3912
|
h('textarea', {
|
|
3817
3913
|
className: 'dsub-inp',
|
|
3818
3914
|
style: { width: '100%', minHeight: 120, fontFamily: 'ui-monospace,monospace', padding: 8 },
|
|
@@ -3821,8 +3917,8 @@ window.__ModuleLoader__.load({
|
|
|
3821
3917
|
onChange: (e) => setState((st) => Object.assign({}, st, { text: e.target.value, message: '', error: null })),
|
|
3822
3918
|
}),
|
|
3823
3919
|
h('div', { className: 'dsub-row' },
|
|
3824
|
-
h('button', { type: 'button', className: 'dsub-mini', disabled: state.busy, onClick: save }, state.busy ? '…' : '
|
|
3825
|
-
h('button', { type: 'button', className: 'dsub-mini', onClick: exportPacks }, '
|
|
3920
|
+
h('button', { type: 'button', className: 'dsub-mini', disabled: state.busy, onClick: save }, state.busy ? '…' : t('validateAndSave')),
|
|
3921
|
+
h('button', { type: 'button', className: 'dsub-mini', onClick: exportPacks }, t('exportPacks')),
|
|
3826
3922
|
),
|
|
3827
3923
|
state.message ? h('div', { className: 'dsub-ok' }, state.message) : null,
|
|
3828
3924
|
state.error ? h('div', { className: 'dsub-bad' }, state.error) : null,
|
|
@@ -3830,7 +3926,8 @@ window.__ModuleLoader__.load({
|
|
|
3830
3926
|
}
|
|
3831
3927
|
|
|
3832
3928
|
// #713: read-only source chain panel. Never renders key values.
|
|
3833
|
-
function SourcesPanel() {
|
|
3929
|
+
function SourcesPanel(props) {
|
|
3930
|
+
const t = (props && props.t) || ((k) => k)
|
|
3834
3931
|
const [state, setState] = React.useState({ loading: true, data: null, error: null })
|
|
3835
3932
|
const pull = () => {
|
|
3836
3933
|
fetch('/dsh-subscriptions/sources', { cache: 'no-store' })
|
|
@@ -3843,16 +3940,16 @@ window.__ModuleLoader__.load({
|
|
|
3843
3940
|
const id = setInterval(pull, 30 * 1000)
|
|
3844
3941
|
return () => clearInterval(id)
|
|
3845
3942
|
}, [])
|
|
3846
|
-
const sourceLabel = (source) => source === 'api-key' ? '
|
|
3943
|
+
const sourceLabel = (source) => source === 'api-key' ? t('apiKeySource') : (source === 'oauth' ? t('oauthSource') : (source === 'ollama' ? t('ollamaSource') : t('sourceNone')))
|
|
3847
3944
|
return h('div', { className: 'dsub-section-card' },
|
|
3848
|
-
h('div', { className: 'dsub-section-title' }, '
|
|
3849
|
-
h('div', { className: 'dsub-section-desc' }, '
|
|
3945
|
+
h('div', { className: 'dsub-section-title' }, t('sources'), h('button', { type: 'button', className: 'dsub-mini', onClick: pull }, state.loading ? '…' : '↻')),
|
|
3946
|
+
h('div', { className: 'dsub-section-desc' }, t('sourcesHint')),
|
|
3850
3947
|
state.error ? h('div', { className: 'dsub-bad' }, state.error) : null,
|
|
3851
3948
|
state.data && state.data.keyRotation && !state.data.keyRotation.available
|
|
3852
|
-
? h('div', { className: 'dsub-dim' }, '
|
|
3949
|
+
? h('div', { className: 'dsub-dim' }, t('keyRotationOffline') + (state.data.keyRotation.reason ? ': ' + state.data.keyRotation.reason : ''))
|
|
3853
3950
|
: null,
|
|
3854
3951
|
state.data && state.data.keyRotation && state.data.keyRotation.available
|
|
3855
|
-
? h('div', { className: 'dsub-dim' }, '
|
|
3952
|
+
? h('div', { className: 'dsub-dim' }, t('keyRotationHealth') + ': ' + state.data.keyRotation.healthyKeys + '/' + state.data.keyRotation.totalKeys + ' healthy keys')
|
|
3856
3953
|
: null,
|
|
3857
3954
|
(state.data && state.data.providers ? state.data.providers : []).map((row) => h('div', { className: 'dsub-row', key: row.provider },
|
|
3858
3955
|
h('span', { className: 'dsub-h' }, row.provider),
|
|
@@ -3982,16 +4079,16 @@ window.__ModuleLoader__.load({
|
|
|
3982
4079
|
},
|
|
3983
4080
|
h('div', { className: 'dsub-h' }, String(state.active.provider || '').toUpperCase() + ' sources'),
|
|
3984
4081
|
(state.candidates || []).map((c, i) => {
|
|
3985
|
-
const label = c.source === 'api-key' ? '
|
|
4082
|
+
const label = c.source === 'api-key' ? t('apiKeySource') : (c.source === 'oauth' ? t('oauthSource') : t('ollamaSource'))
|
|
3986
4083
|
const cd = c.detail && Array.isArray(c.detail.keys) ? Math.max(0, ...c.detail.keys.map((k) => Number(k.cooldownMsLeft) || 0)) : 0
|
|
3987
4084
|
return h('div', { className: 'dsub-row', key: i },
|
|
3988
4085
|
h('span', { className: 'dsub-h' }, label),
|
|
3989
|
-
h('span', { className: c.available ? 'dsub-ok' : 'dsub-dim' }, c.available ? '
|
|
3990
|
-
cd > 0 ? h('span', { className: 'dsub-dim' }, '
|
|
4086
|
+
h('span', { className: c.available ? 'dsub-ok' : 'dsub-dim' }, c.available ? t('sourceAvailable') : t('sourceUnavailable')),
|
|
4087
|
+
cd > 0 ? h('span', { className: 'dsub-dim' }, t('cooldownLabel') + ' ' + Math.ceil(cd / 1000) + 's') : null,
|
|
3991
4088
|
c.detail && c.detail.healthyKeys != null ? h('span', { className: 'dsub-dim' }, c.detail.healthyKeys + '/' + c.detail.totalKeys + ' keys') : null,
|
|
3992
4089
|
)
|
|
3993
4090
|
}),
|
|
3994
|
-
h('button', { type: 'button', className: 'dsub-mini', onClick: () => { setOpen(false); openSettings() } }, '
|
|
4091
|
+
h('button', { type: 'button', className: 'dsub-mini', onClick: () => { setOpen(false); openSettings() } }, t('openSettings')),
|
|
3995
4092
|
) : null
|
|
3996
4093
|
return h(React.Fragment, null, h('div', {
|
|
3997
4094
|
className: 'dsub-qw' + (docked ? ' dsub-qwDocked' : '') + tone,
|
package/lib/config-schema.js
CHANGED
|
@@ -161,6 +161,8 @@ export const Config = z.object({
|
|
|
161
161
|
.description('#720 HMAC-SHA256 secret used to sign webhook payloads.'),
|
|
162
162
|
webhookAllowPrivate: z.boolean().volatile().default(false)
|
|
163
163
|
.description('#729 Allow webhook URLs that resolve to private or loopback hosts.'),
|
|
164
|
+
webhookFormat: z.string().volatile().default('native')
|
|
165
|
+
.description('#751 Webhook payload format: native or notify (dsh-plugin-notify compatible).'),
|
|
164
166
|
digestIntervalHours: z.number().volatile().default(0)
|
|
165
167
|
.description('#720 Send an alert digest every N hours (0 disables digests).'),
|
|
166
168
|
alertThresholds: z.array(z.number()).volatile().default([80, 90, 95])
|
|
@@ -189,6 +191,12 @@ export const Config = z.object({
|
|
|
189
191
|
.description('#562 Remote storage backend: webdav, gist, or s3.'),
|
|
190
192
|
vaultSyncPassphrase: z.string().role('secret').default('')
|
|
191
193
|
.description('#562 Passphrase used to encrypt/decrypt the remote vault archive.'),
|
|
194
|
+
vaultDeviceId: z.string().volatile().default('')
|
|
195
|
+
.description('#749 Stable per-device vault id. Empty derives one from the host.'),
|
|
196
|
+
vaultDeviceLabel: z.string().volatile().default('')
|
|
197
|
+
.description('#749 Human-readable device label for vault metadata.'),
|
|
198
|
+
vaultPerDeviceKeys: z.boolean().volatile().default(true)
|
|
199
|
+
.description('#749 Derive a per-device passphrase and remote object; legacy shared vault stays readable.'),
|
|
192
200
|
vaultSyncProviders: z.array(z.string()).volatile().default([])
|
|
193
201
|
.description('#691 Optional provider allowlist for selective vault sync. Empty means all providers.'),
|
|
194
202
|
vaultSyncIntervalHours: z.number().volatile().default(24)
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// #749: stable per-device identity. The passphrase is derived per device so
|
|
2
|
+
// one device cannot decrypt another device's remote object with only the base
|
|
3
|
+
// passphrase; the legacy shared object remains readable as a fallback.
|
|
4
|
+
import { createHash } from 'node:crypto'
|
|
5
|
+
import { hostname, platform, arch } from 'node:os'
|
|
6
|
+
|
|
7
|
+
export function resolveDeviceIdentity({ id = '', label = '' } = {}) {
|
|
8
|
+
const host = hostname()
|
|
9
|
+
const derived = createHash('sha256').update(host + '|' + platform() + '|' + arch()).digest('hex').slice(0, 12)
|
|
10
|
+
const clean = String(id || derived).replace(/[^a-zA-Z0-9_-]/g, '').slice(0, 32)
|
|
11
|
+
return { id: clean || derived, label: String(label || host) }
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function devicePassphrase(passphrase, deviceId) {
|
|
15
|
+
return deviceId ? String(passphrase) + '#' + String(deviceId) : String(passphrase)
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function withDeviceSuffix(name, deviceId) {
|
|
19
|
+
if (!deviceId) return name
|
|
20
|
+
const match = String(name).match(/^(.*?)(.[^./]+)?$/)
|
|
21
|
+
return (match && match[1] ? match[1] : String(name)) + '.' + deviceId + (match && match[2] ? match[2] : '')
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function deviceWebdavUrl(url, deviceId) {
|
|
25
|
+
if (!url || !deviceId) return url
|
|
26
|
+
try {
|
|
27
|
+
const parsed = new URL(url)
|
|
28
|
+
parsed.pathname = withDeviceSuffix(parsed.pathname, deviceId)
|
|
29
|
+
return parsed.toString()
|
|
30
|
+
} catch {
|
|
31
|
+
return url
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function deviceGistName(deviceId) {
|
|
36
|
+
return withDeviceSuffix('dsh-subscriptions-vault.enc', deviceId)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function deviceS3Key(key, deviceId) {
|
|
40
|
+
return withDeviceSuffix(key || 'dsh-subscriptions-vault.enc', deviceId)
|
|
41
|
+
}
|
package/lib/http.js
CHANGED
|
@@ -104,14 +104,11 @@ function configuredTrustedHosts(targetCtx) {
|
|
|
104
104
|
}
|
|
105
105
|
|
|
106
106
|
/**
|
|
107
|
-
*
|
|
108
|
-
*
|
|
109
|
-
* @returns {boolean}/**
|
|
107
|
+
* Reject cross-site and untrusted writes and sensitive reads.
|
|
108
|
+
* Fail-closed: must be loopback, same-origin/same-site, or authentic host credentials (#374).
|
|
110
109
|
* @param {import('node:http').IncomingMessage} request
|
|
111
110
|
* @param {any} [targetCtx]
|
|
112
111
|
* @returns {boolean}
|
|
113
|
-
* Reject cross-site and untrusted writes and sensitive reads.
|
|
114
|
-
* Fail-closed: must be loopback, same-origin/same-site, or authentic host credentials (#374).
|
|
115
112
|
*/
|
|
116
113
|
export function isTrustedSettingsRequest(request, targetCtx) {
|
|
117
114
|
const headers = request?.headers || {}
|
package/lib/index.js
CHANGED
|
@@ -28,7 +28,7 @@ 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, getRecentAlerts, dispatchNotificationDigest, setWebhookSecret, setWebhookAllowPrivate } from './alerts.js'
|
|
31
|
+
import { notifySessionExpired, getRecentAlerts, dispatchNotificationDigest, setWebhookSecret, setWebhookAllowPrivate, setWebhookFormat } from './alerts.js'
|
|
32
32
|
import { isDigestDue } from './notification-scheduler.js'
|
|
33
33
|
|
|
34
34
|
export { observeForecast, estimateForecast } from './forecast.js'
|
|
@@ -95,6 +95,7 @@ export function apply(ctx, rawConfig) {
|
|
|
95
95
|
const live = () => {
|
|
96
96
|
setWebhookSecret(cachedConfig.webhookSecret)
|
|
97
97
|
setWebhookAllowPrivate(cachedConfig.webhookAllowPrivate)
|
|
98
|
+
setWebhookFormat(cachedConfig.webhookFormat)
|
|
98
99
|
return cachedConfig
|
|
99
100
|
}
|
|
100
101
|
|
|
@@ -616,7 +617,7 @@ export function apply(ctx, rawConfig) {
|
|
|
616
617
|
const cfg = live()
|
|
617
618
|
const alerts = getRecentAlerts(100)
|
|
618
619
|
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
|
+
dispatchNotificationDigest(alerts, cfg.webhookUrl, fetch, { secret: cfg.webhookSecret, allowPrivate: cfg.webhookAllowPrivate, format: cfg.webhookFormat })
|
|
620
621
|
.then((ok) => { if (ok) lastDigestAt = Date.now() })
|
|
621
622
|
.catch(() => {})
|
|
622
623
|
}
|
package/lib/mask.js
CHANGED
|
@@ -20,6 +20,7 @@ export function maskText(value) {
|
|
|
20
20
|
return String(value || '').replace(EMAIL_RE, (m) => maskEmail(m))
|
|
21
21
|
}
|
|
22
22
|
|
|
23
|
+
/** @type {Array<[RegExp, string]>} */
|
|
23
24
|
const SECRET_PATTERNS = [
|
|
24
25
|
[/Bearer\s+[A-Za-z0-9._~+/-]+=*/gi, 'Bearer [REDACTED]'],
|
|
25
26
|
[/\b(?:sk|ghp|gho|ghu|ghs|ghr|github_pat|xox[baprs])[-_][A-Za-z0-9._-]{8,}/g, '[REDACTED]'],
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
// #755: health must probe the actual Ollama endpoint, not the fallback flag.
|
|
2
|
+
let cache = { base: '', at: 0, value: false }
|
|
3
|
+
|
|
4
|
+
export function clearOllamaHealthCache() {
|
|
5
|
+
cache = { base: '', at: 0, value: false }
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export async function probeOllama(baseUrl, { fetchImpl = fetch, timeoutMs = 800, ttlMs = 10000, now = Date.now } = {}) {
|
|
9
|
+
if (!baseUrl) return false
|
|
10
|
+
const base = String(baseUrl).replace(/\/$/, '')
|
|
11
|
+
if (cache.base === base && (now() - cache.at) < ttlMs) return cache.value
|
|
12
|
+
let value = false
|
|
13
|
+
try {
|
|
14
|
+
const init = { method: 'GET' }
|
|
15
|
+
if (timeoutMs > 0 && typeof AbortSignal !== 'undefined' && typeof AbortSignal.timeout === 'function') {
|
|
16
|
+
init.signal = AbortSignal.timeout(timeoutMs)
|
|
17
|
+
}
|
|
18
|
+
const res = await fetchImpl(base + '/api/tags', init)
|
|
19
|
+
value = Boolean(res && res.ok)
|
|
20
|
+
} catch {
|
|
21
|
+
value = false
|
|
22
|
+
}
|
|
23
|
+
cache = { base, at: now(), value }
|
|
24
|
+
return value
|
|
25
|
+
}
|
package/lib/routes/oauth.js
CHANGED
|
@@ -292,10 +292,27 @@ export function registerOauthRoutes(ctx, state) {
|
|
|
292
292
|
writeJson(res, 405, { ok: false, error: { code: 'method', message: 'GET only' } })
|
|
293
293
|
return
|
|
294
294
|
}
|
|
295
|
+
if (!isTrustedSettingsRequest(req, ctx)) {
|
|
296
|
+
writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'same-origin or loopback only' } })
|
|
297
|
+
return
|
|
298
|
+
}
|
|
295
299
|
const q = queryOf(req)
|
|
296
300
|
const provider = q.get('provider') || ''
|
|
297
301
|
const redirectUri = q.get('redirectUri') || ''
|
|
298
302
|
const authUrl = q.get('authUrl') || ''
|
|
303
|
+
for (const [name, value] of [['redirectUri', redirectUri], ['authUrl', authUrl]]) {
|
|
304
|
+
if (!value) continue
|
|
305
|
+
try {
|
|
306
|
+
const parsed = new URL(value)
|
|
307
|
+
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
|
308
|
+
writeJson(res, 400, { ok: false, error: { code: 'url', message: name + ' must use http(s)' } })
|
|
309
|
+
return
|
|
310
|
+
}
|
|
311
|
+
} catch {
|
|
312
|
+
writeJson(res, 400, { ok: false, error: { code: 'url', message: name + ' is invalid' } })
|
|
313
|
+
return
|
|
314
|
+
}
|
|
315
|
+
}
|
|
299
316
|
const host = (req.headers && req.headers.host) || ''
|
|
300
317
|
const user = process.env.USER || process.env.USERNAME || 'user'
|
|
301
318
|
const info = getHeadlessAuthInfo({ provider, redirectUri, authUrl, host, user })
|
package/lib/routes/sources.js
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
import { isTrustedSettingsRequest, safeJsonHandler, writeJson } from '../http.js'
|
|
2
2
|
import { buildSourceChain, readKeyRotationStatus, subscriptionCandidate } from '../key-rotation-bridge.js'
|
|
3
3
|
|
|
4
|
-
export async function collectSourceChain(state, port) {
|
|
4
|
+
export async function collectSourceChain(state, port, { accounts } = {}) {
|
|
5
5
|
const { accountsView, live } = state
|
|
6
6
|
let subscriptions = []
|
|
7
7
|
try {
|
|
8
|
-
const rows = await accountsView()
|
|
8
|
+
const rows = accounts || await accountsView()
|
|
9
9
|
subscriptions = (rows || []).map(subscriptionCandidate)
|
|
10
10
|
} catch { /* keep the chain available */ }
|
|
11
11
|
const keyRotation = port
|
package/lib/routes/status.js
CHANGED
|
@@ -27,6 +27,7 @@ import { PROVIDERS, displayName, droppedCredentialRefs, isProvider, oauthRef } f
|
|
|
27
27
|
import { getVendor } from '../vendors/index.js'
|
|
28
28
|
import { defaultErrorBuffer } from '../error-buffer.js'
|
|
29
29
|
import { assessHealth } from '../health-report.js'
|
|
30
|
+
import { probeOllama } from '../ollama-health.js'
|
|
30
31
|
import { defaultSnapshotStore } from '../vault-snapshots.js'
|
|
31
32
|
import { collectSourceChain } from './sources.js'
|
|
32
33
|
|
|
@@ -85,14 +86,15 @@ export function registerStatusRoutes(ctx, state) {
|
|
|
85
86
|
writeJson(res, 405, { ok: false, error: { code: 'method', message: 'GET only' } })
|
|
86
87
|
return
|
|
87
88
|
}
|
|
88
|
-
const
|
|
89
|
-
const
|
|
89
|
+
const logged = typeof store.loggedInProviders === 'function' ? await store.loggedInProviders().catch(() => []) : []
|
|
90
|
+
const accounts = logged.map((provider) => ({ provider, configured: true }))
|
|
91
|
+
const sources = await collectSourceChain(state, req.socket && req.socket.localPort, { accounts })
|
|
90
92
|
const health = assessHealth({
|
|
91
93
|
accounts,
|
|
92
94
|
sources,
|
|
93
95
|
snapshots: defaultSnapshotStore.stats(),
|
|
94
96
|
webhookUrl: live().webhookUrl,
|
|
95
|
-
ollama: live().
|
|
97
|
+
ollama: await probeOllama(live().ollamaBaseUrl),
|
|
96
98
|
})
|
|
97
99
|
writeJson(res, 200, { ok: true, status: health.status, version: pkgVersion || 'unknown', checks: health.checks })
|
|
98
100
|
}),
|
package/lib/routes/vault.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { Config, plainConfig, revisionOf } from '../config-schema.js'
|
|
2
2
|
import { isTrustedSettingsRequest, readBody, safeJsonHandler, writeJson } from '../http.js'
|
|
3
|
-
import { capturePrePullSnapshot,
|
|
3
|
+
import { capturePrePullSnapshot, restoreSnapshotWithConfig, resolveVaultKey } from '../vault-sync.js'
|
|
4
4
|
import { defaultSnapshotStore } from '../vault-snapshots.js'
|
|
5
5
|
|
|
6
6
|
export function registerVaultRoutes(ctx, state) {
|
|
@@ -33,7 +33,11 @@ export function registerVaultRoutes(ctx, state) {
|
|
|
33
33
|
return
|
|
34
34
|
}
|
|
35
35
|
try {
|
|
36
|
-
const
|
|
36
|
+
const key = resolveVaultKey(live(), passphrase)
|
|
37
|
+
const snapshot = capturePrePullSnapshot(live().slots || [], key.passphrase, String(body.label || 'manual'), {
|
|
38
|
+
keyMode: key.keyMode,
|
|
39
|
+
deviceId: key.deviceId,
|
|
40
|
+
})
|
|
37
41
|
writeJson(res, 200, { ok: true, snapshot })
|
|
38
42
|
} catch (err) {
|
|
39
43
|
writeJson(res, 400, { ok: false, error: { code: 'vault', message: String(err && err.message || err) } })
|
|
@@ -74,7 +78,7 @@ export function registerVaultRoutes(ctx, state) {
|
|
|
74
78
|
return
|
|
75
79
|
}
|
|
76
80
|
try {
|
|
77
|
-
const restored =
|
|
81
|
+
const restored = restoreSnapshotWithConfig(snapshot, live())
|
|
78
82
|
const cfg = live()
|
|
79
83
|
const slots = stripLegacySlots(restored.slots || [])
|
|
80
84
|
await api.replace(plainConfig(Config({ ...cfg, slots })), revisionOf(api))
|
package/lib/vault-snapshots.js
CHANGED
package/lib/vault-sync.js
CHANGED
|
@@ -7,6 +7,7 @@ import { normalizeSlots } from './accounts.js'
|
|
|
7
7
|
import { Config, plainConfig, revisionOf } from './config-schema.js'
|
|
8
8
|
import { validateCredentialBlob } from './blob.js'
|
|
9
9
|
import { defaultSnapshotStore } from './vault-snapshots.js'
|
|
10
|
+
import { resolveDeviceIdentity, devicePassphrase, deviceWebdavUrl, deviceGistName, deviceS3Key } from './device-identity.js'
|
|
10
11
|
|
|
11
12
|
let syncStatus = {
|
|
12
13
|
enabled: false,
|
|
@@ -75,7 +76,7 @@ export async function pullFromWebdav({ url, username, password, fetchFn = fetch
|
|
|
75
76
|
|
|
76
77
|
// --- Driver: Gist (GitHub / Gitea) ---
|
|
77
78
|
|
|
78
|
-
export async function pushToGist({ baseUrl = 'https://api.github.com', gistId, token, encryptedData, fetchFn = fetch }) {
|
|
79
|
+
export async function pushToGist({ baseUrl = 'https://api.github.com', gistId, token, encryptedData, fileName = 'dsh-subscriptions-vault.enc', fetchFn = fetch }) {
|
|
79
80
|
if (!token) throw new Error('Gist access token is required')
|
|
80
81
|
const cleanBase = baseUrl.replace(/\/+$/, '')
|
|
81
82
|
const headers = {
|
|
@@ -86,7 +87,7 @@ export async function pushToGist({ baseUrl = 'https://api.github.com', gistId, t
|
|
|
86
87
|
}
|
|
87
88
|
|
|
88
89
|
const files = {
|
|
89
|
-
|
|
90
|
+
[fileName]: { content: encryptedData },
|
|
90
91
|
}
|
|
91
92
|
|
|
92
93
|
if (gistId) {
|
|
@@ -118,7 +119,7 @@ export async function pushToGist({ baseUrl = 'https://api.github.com', gistId, t
|
|
|
118
119
|
return { ok: true, gistId: json.id }
|
|
119
120
|
}
|
|
120
121
|
|
|
121
|
-
export async function pullFromGist({ baseUrl = 'https://api.github.com', gistId, token, fetchFn = fetch }) {
|
|
122
|
+
export async function pullFromGist({ baseUrl = 'https://api.github.com', gistId, token, fileName = 'dsh-subscriptions-vault.enc', fetchFn = fetch }) {
|
|
122
123
|
if (!gistId) throw new Error('Gist ID is required')
|
|
123
124
|
const cleanBase = baseUrl.replace(/\/+$/, '')
|
|
124
125
|
const headers = {
|
|
@@ -133,9 +134,9 @@ export async function pullFromGist({ baseUrl = 'https://api.github.com', gistId,
|
|
|
133
134
|
})
|
|
134
135
|
if (!res.ok) throw new Error(`Gist pull failed: HTTP ${res.status} ${res.statusText}`)
|
|
135
136
|
const json = await res.json()
|
|
136
|
-
const file = json.files && json.files[
|
|
137
|
+
const file = json.files && json.files[fileName]
|
|
137
138
|
if (!file || !file.content) {
|
|
138
|
-
throw new Error('
|
|
139
|
+
throw new Error(fileName + ' not found in Gist')
|
|
139
140
|
}
|
|
140
141
|
return file.content
|
|
141
142
|
}
|
|
@@ -268,14 +269,44 @@ export function mergeVaultSlots(localSlots, remoteSlots) {
|
|
|
268
269
|
return { slots: out, conflicts }
|
|
269
270
|
}
|
|
270
271
|
|
|
271
|
-
export function createVaultSnapshot({ slots, blobs = {} }, passphrase, label = '') {
|
|
272
|
+
export function createVaultSnapshot({ slots, blobs = {} }, passphrase, label = '', meta = {}) {
|
|
272
273
|
if (!passphrase) throw new Error('passphrase is required for a vault snapshot')
|
|
273
274
|
const { vault, slotCount, blobCount } = exportVault({ slots: slots || [], blobs }, passphrase)
|
|
274
|
-
return { version: 2, label: String(label || ''), createdAt: new Date().toISOString(), vault, slotCount, blobCount }
|
|
275
|
+
return { version: 2, label: String(label || ''), createdAt: new Date().toISOString(), vault, slotCount, blobCount, ...meta }
|
|
275
276
|
}
|
|
276
277
|
|
|
277
|
-
export function capturePrePullSnapshot(slots, passphrase, label = 'pre-pull') {
|
|
278
|
-
return defaultSnapshotStore.add(createVaultSnapshot({ slots: slots || [], blobs: {} }, passphrase, label))
|
|
278
|
+
export function capturePrePullSnapshot(slots, passphrase, label = 'pre-pull', meta = {}) {
|
|
279
|
+
return defaultSnapshotStore.add(createVaultSnapshot({ slots: slots || [], blobs: {} }, passphrase, label, meta))
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
export function resolveVaultKey(cfg, passphrase) {
|
|
283
|
+
const perDevice = cfg && cfg.vaultPerDeviceKeys !== false
|
|
284
|
+
const device = resolveDeviceIdentity({ id: cfg && cfg.vaultDeviceId, label: cfg && cfg.vaultDeviceLabel })
|
|
285
|
+
return {
|
|
286
|
+
passphrase: perDevice ? devicePassphrase(passphrase, device.id) : passphrase,
|
|
287
|
+
keyMode: perDevice ? 'device' : 'base',
|
|
288
|
+
deviceId: perDevice ? device.id : '',
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
export function restoreSnapshotWithConfig(snapshot, cfg) {
|
|
293
|
+
const base = String((cfg && cfg.vaultSyncPassphrase) || '').trim()
|
|
294
|
+
if (!base) throw new Error('vaultSyncPassphrase is required to restore a snapshot')
|
|
295
|
+
const attempts = []
|
|
296
|
+
if (snapshot && snapshot.keyMode === 'device') {
|
|
297
|
+
attempts.push(devicePassphrase(base, snapshot.deviceId || resolveVaultKey(cfg, base).deviceId))
|
|
298
|
+
} else if (snapshot && snapshot.keyMode === 'base') {
|
|
299
|
+
attempts.push(base)
|
|
300
|
+
} else {
|
|
301
|
+
// Snapshots written before #749/#754 have no keyMode. Try the legacy
|
|
302
|
+
// base key first, then the current device key.
|
|
303
|
+
attempts.push(base, resolveVaultKey(cfg, base).passphrase)
|
|
304
|
+
}
|
|
305
|
+
let lastError = null
|
|
306
|
+
for (const pass of attempts) {
|
|
307
|
+
try { return restoreVaultSnapshot(snapshot, pass) } catch (err) { lastError = err }
|
|
308
|
+
}
|
|
309
|
+
throw lastError || new Error('unable to restore snapshot')
|
|
279
310
|
}
|
|
280
311
|
|
|
281
312
|
export function restoreVaultSnapshot(snapshot, passphrase) {
|
|
@@ -308,12 +339,15 @@ export async function syncVaultPush({ live, store, fetchFn = fetch }) {
|
|
|
308
339
|
} catch { /* ignore */ }
|
|
309
340
|
}
|
|
310
341
|
|
|
311
|
-
const
|
|
342
|
+
const device = resolveDeviceIdentity({ id: cfg.vaultDeviceId, label: cfg.vaultDeviceLabel })
|
|
343
|
+
const perDevice = cfg.vaultPerDeviceKeys !== false
|
|
344
|
+
const activePass = resolveVaultKey(cfg, passphrase).passphrase
|
|
345
|
+
const { vault, slotCount, blobCount } = exportVault({ slots, blobs }, activePass)
|
|
312
346
|
const backend = cfg.vaultSyncBackend || 'webdav'
|
|
313
347
|
|
|
314
348
|
if (backend === 'webdav') {
|
|
315
349
|
await pushToWebdav({
|
|
316
|
-
url: cfg.vaultSyncWebdavUrl,
|
|
350
|
+
url: perDevice ? deviceWebdavUrl(cfg.vaultSyncWebdavUrl, device.id) : cfg.vaultSyncWebdavUrl,
|
|
317
351
|
username: cfg.vaultSyncWebdavUsername,
|
|
318
352
|
password: cfg.vaultSyncWebdavPassword,
|
|
319
353
|
encryptedData: vault,
|
|
@@ -325,6 +359,7 @@ export async function syncVaultPush({ live, store, fetchFn = fetch }) {
|
|
|
325
359
|
gistId: cfg.vaultSyncGistId,
|
|
326
360
|
token: cfg.vaultSyncGistToken,
|
|
327
361
|
encryptedData: vault,
|
|
362
|
+
fileName: perDevice ? deviceGistName(device.id) : undefined,
|
|
328
363
|
fetchFn,
|
|
329
364
|
})
|
|
330
365
|
if (res.gistId && !cfg.vaultSyncGistId) {
|
|
@@ -334,7 +369,7 @@ export async function syncVaultPush({ live, store, fetchFn = fetch }) {
|
|
|
334
369
|
await pushToS3({
|
|
335
370
|
endpoint: cfg.vaultSyncS3Endpoint,
|
|
336
371
|
bucket: cfg.vaultSyncS3Bucket,
|
|
337
|
-
key: cfg.vaultSyncS3Key || 'dsh-subscriptions-vault.enc',
|
|
372
|
+
key: perDevice ? deviceS3Key(cfg.vaultSyncS3Key || 'dsh-subscriptions-vault.enc', device.id) : (cfg.vaultSyncS3Key || 'dsh-subscriptions-vault.enc'),
|
|
338
373
|
region: cfg.vaultSyncS3Region || 'us-east-1',
|
|
339
374
|
accessKey: cfg.vaultSyncS3AccessKey,
|
|
340
375
|
secretKey: cfg.vaultSyncS3SecretKey,
|
|
@@ -376,38 +411,60 @@ export async function syncVaultPull({ live, store, settingsApi, syncAdapter, ref
|
|
|
376
411
|
|
|
377
412
|
try {
|
|
378
413
|
const backend = cfg.vaultSyncBackend || 'webdav'
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
414
|
+
const device = resolveDeviceIdentity({ id: cfg.vaultDeviceId, label: cfg.vaultDeviceLabel })
|
|
415
|
+
const perDevice = cfg.vaultPerDeviceKeys !== false
|
|
416
|
+
let activePass = perDevice ? devicePassphrase(passphrase, device.id) : passphrase
|
|
417
|
+
let snapshotKeyMode = perDevice ? 'device' : 'base'
|
|
418
|
+
let usedLegacy = false
|
|
419
|
+
const load = async (deviceMode) => {
|
|
420
|
+
if (backend === 'webdav') {
|
|
421
|
+
return pullFromWebdav({
|
|
422
|
+
url: deviceMode ? deviceWebdavUrl(cfg.vaultSyncWebdavUrl, device.id) : cfg.vaultSyncWebdavUrl,
|
|
423
|
+
username: cfg.vaultSyncWebdavUsername,
|
|
424
|
+
password: cfg.vaultSyncWebdavPassword,
|
|
425
|
+
fetchFn,
|
|
426
|
+
})
|
|
427
|
+
}
|
|
428
|
+
if (backend === 'gist') {
|
|
429
|
+
return pullFromGist({
|
|
430
|
+
baseUrl: cfg.vaultSyncGistBaseUrl || 'https://api.github.com',
|
|
431
|
+
gistId: cfg.vaultSyncGistId,
|
|
432
|
+
token: cfg.vaultSyncGistToken,
|
|
433
|
+
fileName: deviceMode ? deviceGistName(device.id) : undefined,
|
|
434
|
+
fetchFn,
|
|
435
|
+
})
|
|
436
|
+
}
|
|
437
|
+
if (backend === 's3') {
|
|
438
|
+
return pullFromS3({
|
|
439
|
+
endpoint: cfg.vaultSyncS3Endpoint,
|
|
440
|
+
bucket: cfg.vaultSyncS3Bucket,
|
|
441
|
+
key: deviceMode ? deviceS3Key(cfg.vaultSyncS3Key || 'dsh-subscriptions-vault.enc', device.id) : (cfg.vaultSyncS3Key || 'dsh-subscriptions-vault.enc'),
|
|
442
|
+
region: cfg.vaultSyncS3Region || 'us-east-1',
|
|
443
|
+
accessKey: cfg.vaultSyncS3AccessKey,
|
|
444
|
+
secretKey: cfg.vaultSyncS3SecretKey,
|
|
445
|
+
fetchFn,
|
|
446
|
+
})
|
|
447
|
+
}
|
|
406
448
|
throw new Error(`unsupported vault sync backend: ${backend}`)
|
|
407
449
|
}
|
|
450
|
+
let encryptedData
|
|
451
|
+
try {
|
|
452
|
+
encryptedData = await load(perDevice)
|
|
453
|
+
} catch (err) {
|
|
454
|
+
if (!perDevice) throw err
|
|
455
|
+
encryptedData = await load(false)
|
|
456
|
+
activePass = passphrase
|
|
457
|
+
snapshotKeyMode = 'base'
|
|
458
|
+
usedLegacy = true
|
|
459
|
+
}
|
|
408
460
|
|
|
409
|
-
try {
|
|
410
|
-
|
|
461
|
+
try {
|
|
462
|
+
capturePrePullSnapshot(cfg.slots || [], activePass, 'pre-pull', {
|
|
463
|
+
keyMode: snapshotKeyMode,
|
|
464
|
+
deviceId: snapshotKeyMode === 'device' ? device.id : '',
|
|
465
|
+
})
|
|
466
|
+
} catch { /* snapshot is best-effort */ }
|
|
467
|
+
const result = importVault(encryptedData, activePass)
|
|
411
468
|
const providerFilter = new Set((cfg.vaultSyncProviders || []).map((p) => String(p)))
|
|
412
469
|
const remoteSlots = providerFilter.size
|
|
413
470
|
? result.slots.filter((slot) => providerFilter.has(String(slot && slot.provider)))
|
|
@@ -447,6 +504,9 @@ export async function syncVaultPull({ live, store, settingsApi, syncAdapter, ref
|
|
|
447
504
|
slotCount: result.slots.length,
|
|
448
505
|
blobCount,
|
|
449
506
|
conflicts: conflicts.length,
|
|
507
|
+
deviceId: device.id,
|
|
508
|
+
perDevice,
|
|
509
|
+
legacy: usedLegacy,
|
|
450
510
|
exportedAt: result.exportedAt,
|
|
451
511
|
timestamp: syncStatus.lastPullAt,
|
|
452
512
|
}
|
package/lib/vendors/grok.js
CHANGED
|
@@ -132,6 +132,7 @@ function streamHeaders(blob, config, extra) {
|
|
|
132
132
|
hostname = ''
|
|
133
133
|
}
|
|
134
134
|
if (!isTrustedAuthHost(hostname)) {
|
|
135
|
+
/** @type {Error & { code?: string }} */
|
|
135
136
|
const err = new Error(`Untrusted Grok baseUrl host: ${hostname || base}`)
|
|
136
137
|
err.code = 'GROK_UNTRUSTED_HOST'
|
|
137
138
|
throw err
|
package/lib/vendors/index.js
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@goodandready/dsh-subscriptions",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.12",
|
|
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",
|
|
@@ -39,7 +39,7 @@
|
|
|
39
39
|
"url": "https://github.com/GooDAnDReaDY/dsh-subscriptions/issues"
|
|
40
40
|
},
|
|
41
41
|
"scripts": {
|
|
42
|
-
"test": "eslint lib/ test/ && node --import ./test/_setup.mjs --test test/*.test.mjs",
|
|
42
|
+
"test": "eslint lib/ test/ && npm run typecheck && node --import ./test/_setup.mjs --test test/*.test.mjs",
|
|
43
43
|
"typecheck": "tsc -p tsconfig.checkjs.json"
|
|
44
44
|
},
|
|
45
45
|
"dsh": {
|