@goodandready/dsh-subscriptions 0.7.11 → 0.7.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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,20 +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 format'),
1971
+ h('span', { className: 'dsub-h' }, t('webhookFormat')),
1890
1972
  h('select', {
1891
1973
  className: 'dsub-select',
1892
1974
  value: draft.webhookFormat || 'native',
1893
1975
  onChange: (e) => setDraft((d) => Object.assign({}, d, { webhookFormat: e.target.value })),
1894
1976
  },
1895
- h('option', { value: 'native' }, 'Native'),
1896
- h('option', { value: 'notify' }, 'dsh-plugin-notify compatible'),
1977
+ h('option', { value: 'native' }, t('webhookFormatNative')),
1978
+ h('option', { value: 'notify' }, t('webhookFormatNotify')),
1897
1979
  ),
1898
1980
  ),
1899
1981
  h('div', { className: 'dsub-row' },
1900
- h('span', { className: 'dsub-h' }, 'Webhook secret'),
1982
+ h('span', { className: 'dsub-h' }, t('webhookSecret')),
1901
1983
  h('input', {
1902
1984
  type: 'password',
1903
1985
  className: 'dsub-inp',
@@ -1907,7 +1989,7 @@ window.__ModuleLoader__.load({
1907
1989
  }),
1908
1990
  ),
1909
1991
  h('div', { className: 'dsub-row' },
1910
- h('span', { className: 'dsub-h' }, 'Digest interval (hours)'),
1992
+ h('span', { className: 'dsub-h' }, t('digestIntervalHours')),
1911
1993
  h('input', {
1912
1994
  type: 'number',
1913
1995
  className: 'dsub-inp',
@@ -1918,8 +2000,8 @@ window.__ModuleLoader__.load({
1918
2000
  onChange: (e) => setDraft((d) => Object.assign({}, d, { digestIntervalHours: Number(e.target.value) || 0 })),
1919
2001
  }),
1920
2002
  ),
1921
- h('div', { className: 'dsub-sub' }, '0 disables digests. Requires a Webhook URL.'),
1922
- h(WebhookTestButton),
2003
+ h('div', { className: 'dsub-sub' }, t('digestHint')),
2004
+ h(WebhookTestButton, { t }),
1923
2005
  ),
1924
2006
  h('details', { className: 'dsub-group', open: true },
1925
2007
  h('summary', { className: 'dsub-groupHead' }, '🚦 ' + t('groupQueue')),
@@ -2060,19 +2142,19 @@ window.__ModuleLoader__.load({
2060
2142
  h('div', { className: 'dsub-sub', style: { marginTop: '4px' } }, t('modelsCatalogDesc')),
2061
2143
  h('div', { className: 'dsub-model-grid' },
2062
2144
  (Array.isArray(modelsCatalog) && modelsCatalog.length ? modelsCatalog : [
2063
- { id: 'gpt-4o', contextLabel: '128k', capabilities: ['vision'], pool: { status: 'available' } },
2064
- { id: 'o1 / o3-mini', contextLabel: '200k', capabilities: ['reasoning'], pool: { status: 'available' } },
2065
- { id: 'claude-3-7-sonnet', contextLabel: '200k', capabilities: ['reasoning'], pool: { status: 'available' } },
2066
- { id: 'claude-3-5-sonnet', contextLabel: '200k', capabilities: ['coding'], pool: { status: 'available' } },
2067
- { id: 'grok-beta', contextLabel: '128k', capabilities: ['fast'], pool: { status: 'available' } },
2068
- { id: 'gemini-2.5-pro', contextLabel: '1M', capabilities: ['vision'], pool: { status: 'available' } },
2069
- { id: 'kimi-k1.5', contextLabel: '128k', capabilities: ['math'], pool: { status: 'available' } },
2070
- { 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') } },
2071
2153
  ]).map((m, idx) => {
2072
2154
  const poolStatus = m.pool ? m.pool.status : 'unconfigured'
2073
2155
  const statusClass = 'dsub-model-status-' + poolStatus
2074
2156
  let poolLabel = ''
2075
- if (poolStatus === 'available') {
2157
+ if (poolStatus === t('sourceAvailable')) {
2076
2158
  poolLabel = '🟢 ' + (m.pool && m.pool.remainingPercent != null ? (m.pool.remainingPercent + '%') : t('modelPoolAvailable'))
2077
2159
  } else if (poolStatus === 'degraded') {
2078
2160
  poolLabel = '🟡 ' + t('modelPoolDegraded') + (m.pool ? ' (' + m.pool.healthyAccounts + '/' + m.pool.totalAccounts + ')' : '')
@@ -3703,7 +3785,8 @@ window.__ModuleLoader__.load({
3703
3785
  )
3704
3786
  }
3705
3787
 
3706
- function WebhookTestButton() {
3788
+ function WebhookTestButton(props) {
3789
+ const t = (props && props.t) || ((k) => k)
3707
3790
  const [state, setState] = React.useState({ busy: false, message: '', error: null })
3708
3791
  const send = () => {
3709
3792
  setState({ busy: true, message: '', error: null })
@@ -3711,19 +3794,20 @@ window.__ModuleLoader__.load({
3711
3794
  .then(async (res) => {
3712
3795
  const data = await res.json().catch(() => ({}))
3713
3796
  if (!res.ok || !data.delivered) throw new Error((data.error && data.error.message) || ('HTTP ' + res.status))
3714
- setState({ busy: false, message: 'delivered', error: null })
3797
+ setState({ busy: false, message: t('delivered'), error: null })
3715
3798
  })
3716
3799
  .catch((err) => setState({ busy: false, message: '', error: String(err && err.message || err) }))
3717
3800
  }
3718
3801
  return h('div', { className: 'dsub-row' },
3719
- 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')),
3720
3803
  state.message ? h('span', { className: 'dsub-ok' }, state.message) : null,
3721
3804
  state.error ? h('span', { className: 'dsub-bad' }, state.error) : null,
3722
3805
  )
3723
3806
  }
3724
3807
 
3725
3808
  // #736: vault snapshot list with create/restore. Payload stays server-side.
3726
- function VaultSnapshotsPanel() {
3809
+ function VaultSnapshotsPanel(props) {
3810
+ const t = (props && props.t) || ((k) => k)
3727
3811
  const [state, setState] = React.useState({ snapshots: [], stats: null, message: '', error: null, busy: false })
3728
3812
  const pull = () => {
3729
3813
  fetch('/dsh-subscriptions/vault/snapshots', { cache: 'no-store' })
@@ -3744,31 +3828,32 @@ window.__ModuleLoader__.load({
3744
3828
  const create = () => {
3745
3829
  setState((st) => Object.assign({}, st, { busy: true, message: '', error: null }))
3746
3830
  call('/dsh-subscriptions/vault/snapshots', { label: 'manual' })
3747
- .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() })
3748
3832
  .catch((err) => setState((st) => Object.assign({}, st, { busy: false, error: String(err && err.message || err) })))
3749
3833
  }
3750
3834
  const restore = (id) => {
3751
3835
  setState((st) => Object.assign({}, st, { busy: true, message: '', error: null }))
3752
3836
  call('/dsh-subscriptions/vault/snapshots/restore', { id })
3753
- .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() })
3754
3838
  .catch((err) => setState((st) => Object.assign({}, st, { busy: false, error: String(err && err.message || err) })))
3755
3839
  }
3756
3840
  return h('div', { className: 'dsub-section-card' },
3757
- h('div', { className: 'dsub-section-title' }, 'Vault snapshots', h('button', { type: 'button', className: 'dsub-mini', onClick: create, disabled: state.busy }, state.busy ? '…' : 'Create snapshot')),
3758
- 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')),
3759
3843
  state.stats ? h('div', { className: 'dsub-dim' }, state.stats.count + ' snapshots · ' + state.stats.bytes + ' bytes · ' + state.stats.path) : null,
3760
3844
  state.snapshots.length ? state.snapshots.map((snap) => h('div', { className: 'dsub-row', key: snap.id },
3761
3845
  h('span', { className: 'dsub-h' }, snap.id),
3762
3846
  h('span', { className: 'dsub-dim' }, (snap.label || '-') + ' · ' + snap.slotCount + ' slots · ' + snap.createdAt),
3763
- h('button', { type: 'button', className: 'dsub-mini', disabled: state.busy, onClick: () => restore(snap.id) }, 'Restore'),
3764
- )) : 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')),
3765
3849
  state.message ? h('div', { className: 'dsub-ok' }, state.message) : null,
3766
3850
  state.error ? h('div', { className: 'dsub-bad' }, state.error) : null,
3767
3851
  )
3768
3852
  }
3769
3853
 
3770
3854
  // #724: provider pack manager. Displays ids/models only; headers are masked.
3771
- function ProviderPacksPanel() {
3855
+ function ProviderPacksPanel(props) {
3856
+ const t = (props && props.t) || ((k) => k)
3772
3857
  const [state, setState] = React.useState({ packs: [], text: '', message: '', error: null, busy: false })
3773
3858
  const publicPack = (pack) => {
3774
3859
  const copy = Object.assign({}, pack)
@@ -3812,18 +3897,18 @@ window.__ModuleLoader__.load({
3812
3897
  body: JSON.stringify({ config: Object.assign({}, cfg.config, { providerPacks: packs }), revision: cfg.revision }),
3813
3898
  })
3814
3899
  if (!res.ok) throw new Error('HTTP ' + res.status)
3815
- 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: '' }))
3816
3901
  })
3817
3902
  .catch((err) => setState((st) => Object.assign({}, st, { busy: false, error: String(err && err.message || err) })))
3818
3903
  }
3819
- 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') }))
3820
3905
  return h('div', { className: 'dsub-section-card' },
3821
- h('div', { className: 'dsub-section-title' }, 'Provider packs', h('button', { type: 'button', className: 'dsub-mini', onClick: pull }, '↻')),
3822
- 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')),
3823
3908
  state.packs.length ? state.packs.map((pack) => h('div', { className: 'dsub-row', key: pack.id },
3824
3909
  h('span', { className: 'dsub-h' }, pack.id),
3825
3910
  h('span', { className: 'dsub-dim' }, (pack.displayName || '') + ' · ' + ((pack.models || []).length) + ' models'),
3826
- )) : h('div', { className: 'dsub-dim' }, 'No packs yet.'),
3911
+ )) : h('div', { className: 'dsub-dim' }, t('noPacks')),
3827
3912
  h('textarea', {
3828
3913
  className: 'dsub-inp',
3829
3914
  style: { width: '100%', minHeight: 120, fontFamily: 'ui-monospace,monospace', padding: 8 },
@@ -3832,8 +3917,8 @@ window.__ModuleLoader__.load({
3832
3917
  onChange: (e) => setState((st) => Object.assign({}, st, { text: e.target.value, message: '', error: null })),
3833
3918
  }),
3834
3919
  h('div', { className: 'dsub-row' },
3835
- h('button', { type: 'button', className: 'dsub-mini', disabled: state.busy, onClick: save }, state.busy ? '…' : 'Validate & save'),
3836
- 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')),
3837
3922
  ),
3838
3923
  state.message ? h('div', { className: 'dsub-ok' }, state.message) : null,
3839
3924
  state.error ? h('div', { className: 'dsub-bad' }, state.error) : null,
@@ -3841,7 +3926,8 @@ window.__ModuleLoader__.load({
3841
3926
  }
3842
3927
 
3843
3928
  // #713: read-only source chain panel. Never renders key values.
3844
- function SourcesPanel() {
3929
+ function SourcesPanel(props) {
3930
+ const t = (props && props.t) || ((k) => k)
3845
3931
  const [state, setState] = React.useState({ loading: true, data: null, error: null })
3846
3932
  const pull = () => {
3847
3933
  fetch('/dsh-subscriptions/sources', { cache: 'no-store' })
@@ -3854,16 +3940,16 @@ window.__ModuleLoader__.load({
3854
3940
  const id = setInterval(pull, 30 * 1000)
3855
3941
  return () => clearInterval(id)
3856
3942
  }, [])
3857
- 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')))
3858
3944
  return h('div', { className: 'dsub-section-card' },
3859
- h('div', { className: 'dsub-section-title' }, 'Sources', h('button', { type: 'button', className: 'dsub-mini', onClick: pull }, state.loading ? '…' : '↻')),
3860
- 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')),
3861
3947
  state.error ? h('div', { className: 'dsub-bad' }, state.error) : null,
3862
3948
  state.data && state.data.keyRotation && !state.data.keyRotation.available
3863
- ? 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 : ''))
3864
3950
  : null,
3865
3951
  state.data && state.data.keyRotation && state.data.keyRotation.available
3866
- ? 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')
3867
3953
  : null,
3868
3954
  (state.data && state.data.providers ? state.data.providers : []).map((row) => h('div', { className: 'dsub-row', key: row.provider },
3869
3955
  h('span', { className: 'dsub-h' }, row.provider),
@@ -3993,16 +4079,16 @@ window.__ModuleLoader__.load({
3993
4079
  },
3994
4080
  h('div', { className: 'dsub-h' }, String(state.active.provider || '').toUpperCase() + ' sources'),
3995
4081
  (state.candidates || []).map((c, i) => {
3996
- 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'))
3997
4083
  const cd = c.detail && Array.isArray(c.detail.keys) ? Math.max(0, ...c.detail.keys.map((k) => Number(k.cooldownMsLeft) || 0)) : 0
3998
4084
  return h('div', { className: 'dsub-row', key: i },
3999
4085
  h('span', { className: 'dsub-h' }, label),
4000
- h('span', { className: c.available ? 'dsub-ok' : 'dsub-dim' }, c.available ? 'available' : 'unavailable'),
4001
- 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,
4002
4088
  c.detail && c.detail.healthyKeys != null ? h('span', { className: 'dsub-dim' }, c.detail.healthyKeys + '/' + c.detail.totalKeys + ' keys') : null,
4003
4089
  )
4004
4090
  }),
4005
- 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')),
4006
4092
  ) : null
4007
4093
  return h(React.Fragment, null, h('div', {
4008
4094
  className: 'dsub-qw' + (docked ? ' dsub-qwDocked' : '') + tone,
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/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
@@ -269,14 +269,44 @@ export function mergeVaultSlots(localSlots, remoteSlots) {
269
269
  return { slots: out, conflicts }
270
270
  }
271
271
 
272
- export function createVaultSnapshot({ slots, blobs = {} }, passphrase, label = '') {
272
+ export function createVaultSnapshot({ slots, blobs = {} }, passphrase, label = '', meta = {}) {
273
273
  if (!passphrase) throw new Error('passphrase is required for a vault snapshot')
274
274
  const { vault, slotCount, blobCount } = exportVault({ slots: slots || [], blobs }, passphrase)
275
- 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 }
276
276
  }
277
277
 
278
- export function capturePrePullSnapshot(slots, passphrase, label = 'pre-pull') {
279
- 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')
280
310
  }
281
311
 
282
312
  export function restoreVaultSnapshot(snapshot, passphrase) {
@@ -311,7 +341,7 @@ export async function syncVaultPush({ live, store, fetchFn = fetch }) {
311
341
 
312
342
  const device = resolveDeviceIdentity({ id: cfg.vaultDeviceId, label: cfg.vaultDeviceLabel })
313
343
  const perDevice = cfg.vaultPerDeviceKeys !== false
314
- const activePass = perDevice ? devicePassphrase(passphrase, device.id) : passphrase
344
+ const activePass = resolveVaultKey(cfg, passphrase).passphrase
315
345
  const { vault, slotCount, blobCount } = exportVault({ slots, blobs }, activePass)
316
346
  const backend = cfg.vaultSyncBackend || 'webdav'
317
347
 
@@ -384,6 +414,7 @@ export async function syncVaultPull({ live, store, settingsApi, syncAdapter, ref
384
414
  const device = resolveDeviceIdentity({ id: cfg.vaultDeviceId, label: cfg.vaultDeviceLabel })
385
415
  const perDevice = cfg.vaultPerDeviceKeys !== false
386
416
  let activePass = perDevice ? devicePassphrase(passphrase, device.id) : passphrase
417
+ let snapshotKeyMode = perDevice ? 'device' : 'base'
387
418
  let usedLegacy = false
388
419
  const load = async (deviceMode) => {
389
420
  if (backend === 'webdav') {
@@ -423,10 +454,16 @@ export async function syncVaultPull({ live, store, settingsApi, syncAdapter, ref
423
454
  if (!perDevice) throw err
424
455
  encryptedData = await load(false)
425
456
  activePass = passphrase
457
+ snapshotKeyMode = 'base'
426
458
  usedLegacy = true
427
459
  }
428
460
 
429
- try { capturePrePullSnapshot(cfg.slots || [], activePass) } catch { /* snapshot is best-effort */ }
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 */ }
430
467
  const result = importVault(encryptedData, activePass)
431
468
  const providerFilter = new Set((cfg.vaultSyncProviders || []).map((p) => String(p)))
432
469
  const remoteSlots = providerFilter.size
@@ -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.11",
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": {