@goodandready/dsh-subscriptions 0.7.9 → 0.7.11

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] || ''
@@ -105,6 +106,8 @@ export function formatDoctor(payload) {
105
106
  lines.push('doctor: ' + (doctorFailed(payload) ? 'degraded' : 'ok'))
106
107
  lines.push('errors: ' + errors + ' conflicts: ' + conflicts)
107
108
  lines.push('key-rotation: ' + (kr.available ? (kr.healthyKeys + '/' + kr.totalKeys + ' healthy keys') : ('unavailable' + (kr.reason ? ' (' + kr.reason + ')' : ''))))
109
+ const snapshots = diag.vaultSnapshots || {}
110
+ lines.push('snapshots: ' + (snapshots.count || 0) + ' (' + (snapshots.bytes || 0) + ' bytes)')
108
111
  for (const row of (sources.providers || [])) lines.push(row.provider + ': ' + (row.selected || 'none'))
109
112
  return lines.join('\n')
110
113
  }
@@ -123,6 +126,36 @@ export function formatPacks(packs) {
123
126
  return JSON.stringify((Array.isArray(packs) ? packs : []).map(sanitizePack), null, 2) + '\\n'
124
127
  }
125
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
+
126
159
  export function formatSnapshotStatus(payload) {
127
160
  const stats = (payload && payload.stats) || {}
128
161
  return 'snapshots: ' + (stats.count || 0) + ' bytes: ' + (stats.bytes || 0) + ' path: ' + (stats.path || '-')
@@ -174,6 +207,21 @@ export function buildSetRequest(options, configPayload, env = process.env) {
174
207
  export async function run(argv = process.argv.slice(2), env = process.env, fetchImpl = fetch) {
175
208
  const options = parseArgs(argv)
176
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
+ }
177
225
  if (options.command === 'doctor') {
178
226
  const diagReq = buildRequest({ command: 'diagnostics' }, env)
179
227
  const srcReq = buildRequest({ command: 'sources' }, env)
@@ -240,12 +288,41 @@ export async function run(argv = process.argv.slice(2), env = process.env, fetch
240
288
  return { text, contentType: res.headers.get('content-type') || '' }
241
289
  }
242
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
+
243
317
  async function main() {
244
- const result = await run()
318
+ const argv = process.argv.slice(2)
319
+ const options = parseArgs(argv)
320
+ const result = await run(argv)
245
321
  if (result.help) {
246
- 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')
247
323
  return
248
324
  }
325
+ if (result.tuiData && !options.once) { await runTui(result.tuiData); return }
249
326
  process.stdout.write(result.text.endsWith('\n') ? result.text : result.text + '\n')
250
327
  if (result.code) process.exitCode = result.code
251
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
@@ -1885,6 +1885,17 @@ window.__ModuleLoader__.load({
1885
1885
  }),
1886
1886
  ),
1887
1887
  h('div', { className: 'dsub-sub' }, 'Signed POST target for quota, session and billing alerts.'),
1888
+ h('div', { className: 'dsub-row' },
1889
+ h('span', { className: 'dsub-h' }, 'Webhook format'),
1890
+ h('select', {
1891
+ className: 'dsub-select',
1892
+ value: draft.webhookFormat || 'native',
1893
+ onChange: (e) => setDraft((d) => Object.assign({}, d, { webhookFormat: e.target.value })),
1894
+ },
1895
+ h('option', { value: 'native' }, 'Native'),
1896
+ h('option', { value: 'notify' }, 'dsh-plugin-notify compatible'),
1897
+ ),
1898
+ ),
1888
1899
  h('div', { className: 'dsub-row' },
1889
1900
  h('span', { className: 'dsub-h' }, 'Webhook secret'),
1890
1901
  h('input', {
@@ -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
+ }
@@ -0,0 +1,21 @@
1
+ // #745: health is about having a usable source, not about uptime alone.
2
+ export function assessHealth({ accounts = [], sources = null, snapshots = null, webhookUrl = '', ollama = false } = {}) {
3
+ const list = Array.isArray(accounts) ? accounts : []
4
+ const connectedAccounts = list.filter((a) => a && a.configured).length
5
+ const sourceRows = sources && Array.isArray(sources.providers) ? sources.providers : []
6
+ const selectedSources = sourceRows.filter((row) => row && row.selected).length
7
+ const keyRotation = Boolean(sources && sources.keyRotation && sources.keyRotation.available)
8
+ const ok = connectedAccounts > 0 || keyRotation || Boolean(ollama)
9
+ return {
10
+ ok,
11
+ status: ok ? 'healthy' : 'degraded',
12
+ checks: {
13
+ connectedAccounts,
14
+ keyRotation,
15
+ ollama: Boolean(ollama),
16
+ webhook: Boolean(webhookUrl),
17
+ snapshots: snapshots ? Number(snapshots.count) || 0 : 0,
18
+ selectedSources,
19
+ },
20
+ }
21
+ }
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/metrics.js ADDED
@@ -0,0 +1,62 @@
1
+ // #744: Prometheus text exposition. No secrets, labels escaped, deterministic order.
2
+ function label(value) {
3
+ return String(value == null ? '' : value).replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, '\\n')
4
+ }
5
+
6
+ function add(lines, name, help, type, samples) {
7
+ lines.push('# HELP ' + name + ' ' + help)
8
+ lines.push('# TYPE ' + name + ' ' + type)
9
+ for (const sample of samples) lines.push(name + (sample.labels ? '{' + sample.labels + '}' : '') + ' ' + sample.value)
10
+ }
11
+
12
+ export function formatMetrics({ accounts = [], rows = [], alerts = [], sources = null } = {}) {
13
+ const lines = []
14
+ const accountRows = Array.isArray(accounts) ? accounts : []
15
+ add(lines, 'dsh_subscriptions_connected_accounts', 'Accounts with a configured credential.', 'gauge', [
16
+ { value: accountRows.filter((a) => a && a.configured).length },
17
+ ])
18
+ const configured = []
19
+ const usage = []
20
+ const cooldown = []
21
+ for (const account of accountRows) {
22
+ if (!account || !account.provider) continue
23
+ const labels = 'provider="' + label(account.provider) + '",index="' + label(account.index || 1) + '"'
24
+ configured.push({ labels, value: account.configured ? 1 : 0 })
25
+ if (account.usagePercent != null) usage.push({ labels, value: Number(account.usagePercent) })
26
+ const until = Number(account.cooldownUntil) || 0
27
+ cooldown.push({ labels, value: until > Date.now() ? Math.round((until - Date.now()) / 1000) : 0 })
28
+ }
29
+ add(lines, 'dsh_subscriptions_account_configured', '1 when the slot has a credential.', 'gauge', configured)
30
+ add(lines, 'dsh_subscriptions_account_usage_percent', 'Last observed quota usage.', 'gauge', usage)
31
+ add(lines, 'dsh_subscriptions_account_cooldown_seconds', 'Remaining cooldown.', 'gauge', cooldown)
32
+
33
+ const selected = []
34
+ for (const row of (sources && Array.isArray(sources.providers) ? sources.providers : [])) {
35
+ if (!row || !row.selected) continue
36
+ selected.push({ labels: 'provider="' + label(row.provider) + '",source="' + label(row.selected) + '"', value: 1 })
37
+ }
38
+ add(lines, 'dsh_subscriptions_source_selected', 'Selected source for a provider.', 'gauge', selected)
39
+
40
+ const byRequest = new Map()
41
+ for (const row of (Array.isArray(rows) ? rows : [])) {
42
+ const provider = row && row.provider ? row.provider : 'unknown'
43
+ const status = row && row.status != null ? row.status : 'unknown'
44
+ const key = provider + '|' + status
45
+ byRequest.set(key, (byRequest.get(key) || 0) + 1)
46
+ }
47
+ add(lines, 'dsh_subscriptions_requests_total', 'Recorded requests by provider and status.', 'counter',
48
+ [...byRequest.entries()].sort().map(([key, value]) => {
49
+ const [provider, status] = key.split('|')
50
+ return { labels: 'provider="' + label(provider) + '",status="' + label(status) + '"', value }
51
+ }))
52
+
53
+ const bySeverity = new Map()
54
+ for (const alert of (Array.isArray(alerts) ? alerts : [])) {
55
+ const severity = alert && alert.severity ? alert.severity : 'warn'
56
+ bySeverity.set(severity, (bySeverity.get(severity) || 0) + 1)
57
+ }
58
+ add(lines, 'dsh_subscriptions_alerts_total', 'Recent alerts by severity.', 'counter',
59
+ [...bySeverity.entries()].sort().map(([severity, value]) => ({ labels: 'severity="' + label(severity) + '"', value })))
60
+
61
+ return lines.join('\n') + '\n'
62
+ }
@@ -0,0 +1,33 @@
1
+ import { getRecentAlerts } from '../alerts.js'
2
+ import { isLoopback, isTrustedSettingsRequest } from '../http.js'
3
+ import { formatMetrics } from '../metrics.js'
4
+ import { collectSourceChain } from './sources.js'
5
+
6
+ export function registerMetricsRoutes(ctx, state) {
7
+ ctx.effect(() => ctx.webServer.register({
8
+ kind: 'exact',
9
+ path: '/dsh-subscriptions/metrics',
10
+ handler: async (req, res) => {
11
+ if (req.method !== 'GET') {
12
+ res.writeHead(405, { 'content-type': 'text/plain; charset=utf-8' })
13
+ res.end('method not allowed\n')
14
+ return
15
+ }
16
+ if (!isLoopback(req) && !isTrustedSettingsRequest(req, ctx)) {
17
+ res.writeHead(403, { 'content-type': 'text/plain; charset=utf-8' })
18
+ res.end('forbidden\n')
19
+ return
20
+ }
21
+ const accounts = await state.accountsView().catch(() => [])
22
+ const sources = await collectSourceChain(state, req.socket && req.socket.localPort)
23
+ const text = formatMetrics({
24
+ accounts,
25
+ rows: typeof state.history?.all === 'function' ? state.history.all() : [],
26
+ alerts: getRecentAlerts(1000),
27
+ sources,
28
+ })
29
+ res.writeHead(200, { 'content-type': 'text/plain; version=0.0.4; charset=utf-8' })
30
+ res.end(text)
31
+ },
32
+ }), 'dsh-subscriptions: /metrics')
33
+ }
@@ -1,8 +1,33 @@
1
1
  import { isTrustedSettingsRequest, safeJsonHandler, writeJson } from '../http.js'
2
2
  import { buildSourceChain, readKeyRotationStatus, subscriptionCandidate } from '../key-rotation-bridge.js'
3
3
 
4
- export function registerSourcesRoutes(ctx, state) {
4
+ export async function collectSourceChain(state, port) {
5
5
  const { accountsView, live } = state
6
+ let subscriptions = []
7
+ try {
8
+ const rows = await accountsView()
9
+ subscriptions = (rows || []).map(subscriptionCandidate)
10
+ } catch { /* keep the chain available */ }
11
+ const keyRotation = port
12
+ ? await readKeyRotationStatus({ baseUrl: 'http://127.0.0.1:' + port })
13
+ : { available: false, reason: 'no local port', providers: [], totalKeys: 0, healthyKeys: 0 }
14
+ const chain = buildSourceChain({
15
+ subscriptions,
16
+ keyRotation,
17
+ ollama: { available: live().ollamaFallback !== false },
18
+ })
19
+ return {
20
+ keyRotation: {
21
+ available: keyRotation.available,
22
+ reason: keyRotation.reason || null,
23
+ totalKeys: keyRotation.totalKeys,
24
+ healthyKeys: keyRotation.healthyKeys,
25
+ },
26
+ ...chain,
27
+ }
28
+ }
29
+
30
+ export function registerSourcesRoutes(ctx, state) {
6
31
 
7
32
  ctx.effect(() => ctx.webServer.register({
8
33
  kind: 'exact',
@@ -16,30 +41,8 @@ export function registerSourcesRoutes(ctx, state) {
16
41
  writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'same-origin only' } })
17
42
  return
18
43
  }
19
- let subscriptions = []
20
- try {
21
- const rows = await accountsView()
22
- subscriptions = (rows || []).map(subscriptionCandidate)
23
- } catch { /* keep the chain available */ }
24
- const port = req.socket && req.socket.localPort
25
- const keyRotation = port
26
- ? await readKeyRotationStatus({ baseUrl: 'http://127.0.0.1:' + port })
27
- : { available: false, reason: 'no local port', providers: [], totalKeys: 0, healthyKeys: 0 }
28
- const chain = buildSourceChain({
29
- subscriptions,
30
- keyRotation,
31
- ollama: { available: live().ollamaFallback !== false },
32
- })
33
- writeJson(res, 200, {
34
- ok: true,
35
- keyRotation: {
36
- available: keyRotation.available,
37
- reason: keyRotation.reason || null,
38
- totalKeys: keyRotation.totalKeys,
39
- healthyKeys: keyRotation.healthyKeys,
40
- },
41
- ...chain,
42
- })
44
+ const payload = await collectSourceChain(state, req.socket && req.socket.localPort)
45
+ writeJson(res, 200, { ok: true, ...payload })
43
46
  }),
44
47
  }), 'dsh-subscriptions: /sources')
45
48
  }
@@ -26,6 +26,9 @@ import { quotaSnapshot } from '../ratelimit.js'
26
26
  import { PROVIDERS, displayName, droppedCredentialRefs, isProvider, oauthRef } from '../refs.js'
27
27
  import { getVendor } from '../vendors/index.js'
28
28
  import { defaultErrorBuffer } from '../error-buffer.js'
29
+ import { assessHealth } from '../health-report.js'
30
+ import { defaultSnapshotStore } from '../vault-snapshots.js'
31
+ import { collectSourceChain } from './sources.js'
29
32
 
30
33
  export function registerStatusRoutes(ctx, state) {
31
34
  const {
@@ -82,7 +85,16 @@ export function registerStatusRoutes(ctx, state) {
82
85
  writeJson(res, 405, { ok: false, error: { code: 'method', message: 'GET only' } })
83
86
  return
84
87
  }
85
- writeJson(res, 200, { ok: true, status: 'healthy', version: pkgVersion || 'unknown' })
88
+ const accounts = await accountsView().catch(() => [])
89
+ const sources = await collectSourceChain(state, req.socket && req.socket.localPort)
90
+ const health = assessHealth({
91
+ accounts,
92
+ sources,
93
+ snapshots: defaultSnapshotStore.stats(),
94
+ webhookUrl: live().webhookUrl,
95
+ ollama: live().ollamaFallback !== false,
96
+ })
97
+ writeJson(res, 200, { ok: true, status: health.status, version: pkgVersion || 'unknown', checks: health.checks })
86
98
  }),
87
99
  }), 'dsh-subscriptions: /health')
88
100
 
@@ -310,6 +322,8 @@ export function registerStatusRoutes(ctx, state) {
310
322
  report: await diagnosticsReport(),
311
323
  conflicts: getActiveConflicts(),
312
324
  route: buildRouteReport(last, accounts),
325
+ sources: await collectSourceChain(state, req.socket && req.socket.localPort),
326
+ vaultSnapshots: defaultSnapshotStore.stats(),
313
327
  })
314
328
  }),
315
329
  }), 'dsh-subscriptions: /diagnostics')
package/lib/routes.js CHANGED
@@ -9,6 +9,7 @@ import { registerSourcesRoutes } from './routes/sources.js'
9
9
  import { registerPacksRoutes } from './routes/packs.js'
10
10
  import { registerWebhookRoutes } from './routes/webhook.js'
11
11
  import { registerVaultRoutes } from './routes/vault.js'
12
+ import { registerMetricsRoutes } from './routes/metrics.js'
12
13
  import { registerPluginUpdater } from './updater.js'
13
14
 
14
15
  export function registerRoutes(ctx, state) {
@@ -117,4 +118,5 @@ function esc(x){return String(x==null?'':x).replace(/&/g,'&amp;').replace(/</g,'
117
118
  registerPacksRoutes(ctx)
118
119
  registerWebhookRoutes(ctx, state)
119
120
  registerVaultRoutes(ctx, state)
121
+ registerMetricsRoutes(ctx, state)
120
122
  }
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
  }
@@ -308,12 +309,15 @@ export async function syncVaultPush({ live, store, fetchFn = fetch }) {
308
309
  } catch { /* ignore */ }
309
310
  }
310
311
 
311
- const { vault, slotCount, blobCount } = exportVault({ slots, blobs }, passphrase)
312
+ const device = resolveDeviceIdentity({ id: cfg.vaultDeviceId, label: cfg.vaultDeviceLabel })
313
+ const perDevice = cfg.vaultPerDeviceKeys !== false
314
+ const activePass = perDevice ? devicePassphrase(passphrase, device.id) : passphrase
315
+ const { vault, slotCount, blobCount } = exportVault({ slots, blobs }, activePass)
312
316
  const backend = cfg.vaultSyncBackend || 'webdav'
313
317
 
314
318
  if (backend === 'webdav') {
315
319
  await pushToWebdav({
316
- url: cfg.vaultSyncWebdavUrl,
320
+ url: perDevice ? deviceWebdavUrl(cfg.vaultSyncWebdavUrl, device.id) : cfg.vaultSyncWebdavUrl,
317
321
  username: cfg.vaultSyncWebdavUsername,
318
322
  password: cfg.vaultSyncWebdavPassword,
319
323
  encryptedData: vault,
@@ -325,6 +329,7 @@ export async function syncVaultPush({ live, store, fetchFn = fetch }) {
325
329
  gistId: cfg.vaultSyncGistId,
326
330
  token: cfg.vaultSyncGistToken,
327
331
  encryptedData: vault,
332
+ fileName: perDevice ? deviceGistName(device.id) : undefined,
328
333
  fetchFn,
329
334
  })
330
335
  if (res.gistId && !cfg.vaultSyncGistId) {
@@ -334,7 +339,7 @@ export async function syncVaultPush({ live, store, fetchFn = fetch }) {
334
339
  await pushToS3({
335
340
  endpoint: cfg.vaultSyncS3Endpoint,
336
341
  bucket: cfg.vaultSyncS3Bucket,
337
- key: cfg.vaultSyncS3Key || 'dsh-subscriptions-vault.enc',
342
+ key: perDevice ? deviceS3Key(cfg.vaultSyncS3Key || 'dsh-subscriptions-vault.enc', device.id) : (cfg.vaultSyncS3Key || 'dsh-subscriptions-vault.enc'),
338
343
  region: cfg.vaultSyncS3Region || 'us-east-1',
339
344
  accessKey: cfg.vaultSyncS3AccessKey,
340
345
  secretKey: cfg.vaultSyncS3SecretKey,
@@ -376,38 +381,53 @@ export async function syncVaultPull({ live, store, settingsApi, syncAdapter, ref
376
381
 
377
382
  try {
378
383
  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 {
384
+ const device = resolveDeviceIdentity({ id: cfg.vaultDeviceId, label: cfg.vaultDeviceLabel })
385
+ const perDevice = cfg.vaultPerDeviceKeys !== false
386
+ let activePass = perDevice ? devicePassphrase(passphrase, device.id) : passphrase
387
+ let usedLegacy = false
388
+ const load = async (deviceMode) => {
389
+ if (backend === 'webdav') {
390
+ return pullFromWebdav({
391
+ url: deviceMode ? deviceWebdavUrl(cfg.vaultSyncWebdavUrl, device.id) : cfg.vaultSyncWebdavUrl,
392
+ username: cfg.vaultSyncWebdavUsername,
393
+ password: cfg.vaultSyncWebdavPassword,
394
+ fetchFn,
395
+ })
396
+ }
397
+ if (backend === 'gist') {
398
+ return pullFromGist({
399
+ baseUrl: cfg.vaultSyncGistBaseUrl || 'https://api.github.com',
400
+ gistId: cfg.vaultSyncGistId,
401
+ token: cfg.vaultSyncGistToken,
402
+ fileName: deviceMode ? deviceGistName(device.id) : undefined,
403
+ fetchFn,
404
+ })
405
+ }
406
+ if (backend === 's3') {
407
+ return pullFromS3({
408
+ endpoint: cfg.vaultSyncS3Endpoint,
409
+ bucket: cfg.vaultSyncS3Bucket,
410
+ key: deviceMode ? deviceS3Key(cfg.vaultSyncS3Key || 'dsh-subscriptions-vault.enc', device.id) : (cfg.vaultSyncS3Key || 'dsh-subscriptions-vault.enc'),
411
+ region: cfg.vaultSyncS3Region || 'us-east-1',
412
+ accessKey: cfg.vaultSyncS3AccessKey,
413
+ secretKey: cfg.vaultSyncS3SecretKey,
414
+ fetchFn,
415
+ })
416
+ }
406
417
  throw new Error(`unsupported vault sync backend: ${backend}`)
407
418
  }
419
+ let encryptedData
420
+ try {
421
+ encryptedData = await load(perDevice)
422
+ } catch (err) {
423
+ if (!perDevice) throw err
424
+ encryptedData = await load(false)
425
+ activePass = passphrase
426
+ usedLegacy = true
427
+ }
408
428
 
409
- try { capturePrePullSnapshot(cfg.slots || [], passphrase) } catch { /* snapshot is best-effort */ }
410
- const result = importVault(encryptedData, passphrase)
429
+ try { capturePrePullSnapshot(cfg.slots || [], activePass) } catch { /* snapshot is best-effort */ }
430
+ const result = importVault(encryptedData, activePass)
411
431
  const providerFilter = new Set((cfg.vaultSyncProviders || []).map((p) => String(p)))
412
432
  const remoteSlots = providerFilter.size
413
433
  ? result.slots.filter((slot) => providerFilter.has(String(slot && slot.provider)))
@@ -447,6 +467,9 @@ export async function syncVaultPull({ live, store, settingsApi, syncAdapter, ref
447
467
  slotCount: result.slots.length,
448
468
  blobCount,
449
469
  conflicts: conflicts.length,
470
+ deviceId: device.id,
471
+ perDevice,
472
+ legacy: usedLegacy,
450
473
  exportedAt: result.exportedAt,
451
474
  timestamp: syncStatus.lastPullAt,
452
475
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-subscriptions",
3
- "version": "0.7.9",
3
+ "version": "0.7.11",
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",