@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.
@@ -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 result = await run()
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 = { ...body, source: 'dsh-subscriptions', sentAt: new Date().toISOString() }
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' }, 'Quota widget'),
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' }, 'Off'),
1839
- h('option', { value: 'docked' }, 'Docked'),
1840
- h('option', { value: 'floating' }, 'Floating (draggable)'),
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' }, 'Floating capsule can be dragged; docked stays bottom-right.'),
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' }, 'Webhook URL'),
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' }, 'Signed POST target for quota, session and billing alerts.'),
1969
+ h('div', { className: 'dsub-sub' }, t('webhookUrlHint')),
1888
1970
  h('div', { className: 'dsub-row' },
1889
- h('span', { className: 'dsub-h' }, 'Webhook secret'),
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' }, 'Digest interval (hours)'),
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' }, '0 disables digests. Requires a Webhook URL.'),
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: 'available' } },
2053
- { id: 'o1 / o3-mini', contextLabel: '200k', capabilities: ['reasoning'], pool: { status: 'available' } },
2054
- { id: 'claude-3-7-sonnet', contextLabel: '200k', capabilities: ['reasoning'], pool: { status: 'available' } },
2055
- { id: 'claude-3-5-sonnet', contextLabel: '200k', capabilities: ['coding'], pool: { status: 'available' } },
2056
- { id: 'grok-beta', contextLabel: '128k', capabilities: ['fast'], pool: { status: 'available' } },
2057
- { id: 'gemini-2.5-pro', contextLabel: '1M', capabilities: ['vision'], pool: { status: 'available' } },
2058
- { id: 'kimi-k1.5', contextLabel: '128k', capabilities: ['math'], pool: { status: 'available' } },
2059
- { id: 'glm-4-plus', contextLabel: '128k', capabilities: ['chinese'], pool: { status: 'available' } },
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 === 'available') {
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 ? '…' : 'Send test'),
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: 'restored ' + data.slotCount + ' slots' })); pull() })
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' }, 'Vault snapshots', h('button', { type: 'button', className: 'dsub-mini', onClick: create, disabled: state.busy }, state.busy ? '…' : 'Create snapshot')),
3747
- h('div', { className: 'dsub-section-desc' }, 'Encrypted slots snapshots. A pre-pull snapshot is created automatically. Payload stays on the host.'),
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) }, 'Restore'),
3753
- )) : h('div', { className: 'dsub-dim' }, 'No snapshots yet.'),
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' }, 'Provider packs', h('button', { type: 'button', className: 'dsub-mini', onClick: pull }, '↻')),
3811
- h('div', { className: 'dsub-section-desc' }, 'Add an OpenAI-Responses-compatible provider as a JSON manifest. Headers are masked in the editor.'),
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' }, 'No packs yet.'),
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 ? '…' : 'Validate & save'),
3825
- h('button', { type: 'button', className: 'dsub-mini', onClick: exportPacks }, 'Export'),
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' ? 'API key' : (source === 'oauth' ? 'OAuth' : (source === 'ollama' ? 'Ollama' : 'none'))
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' }, 'Sources', h('button', { type: 'button', className: 'dsub-mini', onClick: pull }, state.loading ? '…' : '↻')),
3849
- h('div', { className: 'dsub-section-desc' }, 'Subscription → API key → Ollama. Read-only: key rotation owns its cooldowns.'),
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' }, 'key-rotation not installed/offline' + (state.data.keyRotation.reason ? ': ' + state.data.keyRotation.reason : ''))
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' }, 'key-rotation: ' + state.data.keyRotation.healthyKeys + '/' + state.data.keyRotation.totalKeys + ' healthy keys')
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' ? 'API key' : (c.source === 'oauth' ? 'OAuth' : 'Ollama')
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 ? 'available' : 'unavailable'),
3990
- cd > 0 ? h('span', { className: 'dsub-dim' }, 'cooldown ' + Math.ceil(cd / 1000) + 's') : null,
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() } }, 'Open settings'),
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,
@@ -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
- * @param {import('node:http').IncomingMessage} request
108
- * @param {any} [targetCtx]
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
+ }
@@ -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 })
@@ -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
@@ -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 accounts = await accountsView().catch(() => [])
89
- const sources = await collectSourceChain(state, req.socket && req.socket.localPort)
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().ollamaFallback !== false,
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
  }),
@@ -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, restoreVaultSnapshot } from '../vault-sync.js'
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 snapshot = capturePrePullSnapshot(live().slots || [], passphrase, String(body.label || 'manual'))
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 = restoreVaultSnapshot(snapshot, passphrase)
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))
@@ -28,6 +28,8 @@ function meta(snapshot) {
28
28
  version: snapshot.version || 2,
29
29
  slotCount: Number(snapshot.slotCount) || 0,
30
30
  blobCount: Number(snapshot.blobCount) || 0,
31
+ keyMode: snapshot.keyMode || 'base',
32
+ deviceId: snapshot.deviceId || '',
31
33
  }
32
34
  }
33
35
 
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
- 'dsh-subscriptions-vault.enc': { content: encryptedData },
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['dsh-subscriptions-vault.enc']
137
+ const file = json.files && json.files[fileName]
137
138
  if (!file || !file.content) {
138
- throw new Error('dsh-subscriptions-vault.enc not found in Gist')
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 { vault, slotCount, blobCount } = exportVault({ slots, blobs }, passphrase)
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
- let encryptedData = ''
380
-
381
- if (backend === 'webdav') {
382
- encryptedData = await pullFromWebdav({
383
- url: cfg.vaultSyncWebdavUrl,
384
- username: cfg.vaultSyncWebdavUsername,
385
- password: cfg.vaultSyncWebdavPassword,
386
- fetchFn,
387
- })
388
- } else if (backend === 'gist') {
389
- encryptedData = await pullFromGist({
390
- baseUrl: cfg.vaultSyncGistBaseUrl || 'https://api.github.com',
391
- gistId: cfg.vaultSyncGistId,
392
- token: cfg.vaultSyncGistToken,
393
- fetchFn,
394
- })
395
- } else if (backend === 's3') {
396
- encryptedData = await pullFromS3({
397
- endpoint: cfg.vaultSyncS3Endpoint,
398
- bucket: cfg.vaultSyncS3Bucket,
399
- key: cfg.vaultSyncS3Key || 'dsh-subscriptions-vault.enc',
400
- region: cfg.vaultSyncS3Region || 'us-east-1',
401
- accessKey: cfg.vaultSyncS3AccessKey,
402
- secretKey: cfg.vaultSyncS3SecretKey,
403
- fetchFn,
404
- })
405
- } else {
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 { capturePrePullSnapshot(cfg.slots || [], passphrase) } catch { /* snapshot is best-effort */ }
410
- const result = importVault(encryptedData, passphrase)
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
  }
@@ -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
@@ -51,6 +51,7 @@ export function clearCustomVendors() {
51
51
 
52
52
  const vendors = new Proxy({}, {
53
53
  get(_t, prop) {
54
+ if (typeof prop === 'symbol') return undefined
54
55
  if (prop in builtins) return builtins[prop]
55
56
  return customVendors.get(prop)
56
57
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-subscriptions",
3
- "version": "0.7.10",
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": {