@goodandready/dsh-subscriptions 0.7.11 → 0.7.13

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": "Drag/dock.",
398
+ "webhookUrl": "Webhook URL",
399
+ "webhookUrlHint": "Signed target.",
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 = off.",
406
+ "sendTest": "Send test",
407
+ "delivered": "delivered",
408
+ "vaultSnapshots": "Vault snapshots",
409
+ "vaultSnapshotsHint": "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 provider JSON.",
417
+ "noPacks": "No packs yet.",
418
+ "validateAndSave": "Validate & save",
419
+ "exportPacks": "Export",
420
+ "exported": "exported",
421
+ "saved": "saved",
422
+ "sources": "Sources",
423
+ "sourcesHint": "OAuth → 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": "签名目标。",
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": "自动预拉取",
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).
@@ -855,6 +937,7 @@ window.__ModuleLoader__.load({
855
937
  // The translator comes from the slot because its registration declares locale.
856
938
  const t = (props && props.t) || ((key) => key)
857
939
  const [draft, setDraft] = React.useState(null)
940
+ const [newProviders, setNewProviders] = React.useState([])
858
941
  const [accounts, setAccounts] = React.useState([])
859
942
  const [providers, setProviders] = React.useState([])
860
943
  const [modelsCatalog, setModelsCatalog] = React.useState([])
@@ -1560,6 +1643,7 @@ window.__ModuleLoader__.load({
1560
1643
  const used = slots.filter((s) => s.provider === provider).map((s) => s.index)
1561
1644
  let index = 1
1562
1645
  while (used.indexOf(index) >= 0) index++
1646
+ setNewProviders((prev) => prev.includes(provider) ? prev : prev.concat([provider]))
1563
1647
  setSlots((prev) => prev.concat([{ provider: provider, index: index, label: '' }]))
1564
1648
  }
1565
1649
 
@@ -1829,21 +1913,21 @@ window.__ModuleLoader__.load({
1829
1913
  ),
1830
1914
  ),
1831
1915
  h('div', { className: 'dsub-sub' }, t('composerQuotaDisplayHint')), h('div', { className: 'dsub-row' },
1832
- h('span', { className: 'dsub-h' }, 'Quota widget'),
1916
+ h('span', { className: 'dsub-h' }, t('quotaWidget')),
1833
1917
  h('select', {
1834
1918
  className: 'dsub-select',
1835
1919
  value: draft.quotaWidget || 'docked',
1836
1920
  onChange: (e) => setDraft((d) => Object.assign({}, d, { quotaWidget: e.target.value })),
1837
1921
  },
1838
- h('option', { value: 'off' }, 'Off'),
1839
- h('option', { value: 'docked' }, 'Docked'),
1840
- h('option', { value: 'floating' }, 'Floating (draggable)'),
1922
+ h('option', { value: 'off' }, t('quotaWidgetOff')),
1923
+ h('option', { value: 'docked' }, t('quotaWidgetDocked')),
1924
+ h('option', { value: 'floating' }, t('quotaWidgetFloating')),
1841
1925
  ),
1842
1926
  ),
1843
- h('div', { className: 'dsub-sub' }, 'Floating capsule can be dragged; docked stays bottom-right.'),
1927
+ h('div', { className: 'dsub-sub' }, t('quotaWidgetHint')),
1844
1928
  h(SourcesPanel, { t }),
1845
- h(ProviderPacksPanel),
1846
- h(VaultSnapshotsPanel),
1929
+ h(ProviderPacksPanel, { t }),
1930
+ h(VaultSnapshotsPanel, { t }),
1847
1931
 
1848
1932
  h('div', { className: 'dsub-row' },
1849
1933
  h('span', { className: 'dsub-h' }, t('composerQuota')),
@@ -1875,7 +1959,7 @@ window.__ModuleLoader__.load({
1875
1959
  h('div', { className: 'dsub-sub' },
1876
1960
  t('expiryNotifyDaysHint')),
1877
1961
  h('div', { className: 'dsub-row' },
1878
- h('span', { className: 'dsub-h' }, 'Webhook URL'),
1962
+ h('span', { className: 'dsub-h' }, t('webhookUrl')),
1879
1963
  h('input', {
1880
1964
  type: 'url',
1881
1965
  className: 'dsub-inp',
@@ -1884,20 +1968,20 @@ window.__ModuleLoader__.load({
1884
1968
  onChange: (e) => setDraft((d) => Object.assign({}, d, { webhookUrl: e.target.value })),
1885
1969
  }),
1886
1970
  ),
1887
- h('div', { className: 'dsub-sub' }, 'Signed POST target for quota, session and billing alerts.'),
1971
+ h('div', { className: 'dsub-sub' }, t('webhookUrlHint')),
1888
1972
  h('div', { className: 'dsub-row' },
1889
- h('span', { className: 'dsub-h' }, 'Webhook format'),
1973
+ h('span', { className: 'dsub-h' }, t('webhookFormat')),
1890
1974
  h('select', {
1891
1975
  className: 'dsub-select',
1892
1976
  value: draft.webhookFormat || 'native',
1893
1977
  onChange: (e) => setDraft((d) => Object.assign({}, d, { webhookFormat: e.target.value })),
1894
1978
  },
1895
- h('option', { value: 'native' }, 'Native'),
1896
- h('option', { value: 'notify' }, 'dsh-plugin-notify compatible'),
1979
+ h('option', { value: 'native' }, t('webhookFormatNative')),
1980
+ h('option', { value: 'notify' }, t('webhookFormatNotify')),
1897
1981
  ),
1898
1982
  ),
1899
1983
  h('div', { className: 'dsub-row' },
1900
- h('span', { className: 'dsub-h' }, 'Webhook secret'),
1984
+ h('span', { className: 'dsub-h' }, t('webhookSecret')),
1901
1985
  h('input', {
1902
1986
  type: 'password',
1903
1987
  className: 'dsub-inp',
@@ -1907,7 +1991,7 @@ window.__ModuleLoader__.load({
1907
1991
  }),
1908
1992
  ),
1909
1993
  h('div', { className: 'dsub-row' },
1910
- h('span', { className: 'dsub-h' }, 'Digest interval (hours)'),
1994
+ h('span', { className: 'dsub-h' }, t('digestIntervalHours')),
1911
1995
  h('input', {
1912
1996
  type: 'number',
1913
1997
  className: 'dsub-inp',
@@ -1918,8 +2002,8 @@ window.__ModuleLoader__.load({
1918
2002
  onChange: (e) => setDraft((d) => Object.assign({}, d, { digestIntervalHours: Number(e.target.value) || 0 })),
1919
2003
  }),
1920
2004
  ),
1921
- h('div', { className: 'dsub-sub' }, '0 disables digests. Requires a Webhook URL.'),
1922
- h(WebhookTestButton),
2005
+ h('div', { className: 'dsub-sub' }, t('digestHint')),
2006
+ h(WebhookTestButton, { t }),
1923
2007
  ),
1924
2008
  h('details', { className: 'dsub-group', open: true },
1925
2009
  h('summary', { className: 'dsub-groupHead' }, '🚦 ' + t('groupQueue')),
@@ -2060,19 +2144,19 @@ window.__ModuleLoader__.load({
2060
2144
  h('div', { className: 'dsub-sub', style: { marginTop: '4px' } }, t('modelsCatalogDesc')),
2061
2145
  h('div', { className: 'dsub-model-grid' },
2062
2146
  (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' } },
2147
+ { id: 'gpt-4o', contextLabel: '128k', capabilities: ['vision'], pool: { status: t('sourceAvailable') } },
2148
+ { id: 'o1 / o3-mini', contextLabel: '200k', capabilities: ['reasoning'], pool: { status: t('sourceAvailable') } },
2149
+ { id: 'claude-3-7-sonnet', contextLabel: '200k', capabilities: ['reasoning'], pool: { status: t('sourceAvailable') } },
2150
+ { id: 'claude-3-5-sonnet', contextLabel: '200k', capabilities: ['coding'], pool: { status: t('sourceAvailable') } },
2151
+ { id: 'grok-beta', contextLabel: '128k', capabilities: ['fast'], pool: { status: t('sourceAvailable') } },
2152
+ { id: 'gemini-2.5-pro', contextLabel: '1M', capabilities: ['vision'], pool: { status: t('sourceAvailable') } },
2153
+ { id: 'kimi-k1.5', contextLabel: '128k', capabilities: ['math'], pool: { status: t('sourceAvailable') } },
2154
+ { id: 'glm-4-plus', contextLabel: '128k', capabilities: ['chinese'], pool: { status: t('sourceAvailable') } },
2071
2155
  ]).map((m, idx) => {
2072
2156
  const poolStatus = m.pool ? m.pool.status : 'unconfigured'
2073
2157
  const statusClass = 'dsub-model-status-' + poolStatus
2074
2158
  let poolLabel = ''
2075
- if (poolStatus === 'available') {
2159
+ if (poolStatus === t('sourceAvailable')) {
2076
2160
  poolLabel = '🟢 ' + (m.pool && m.pool.remainingPercent != null ? (m.pool.remainingPercent + '%') : t('modelPoolAvailable'))
2077
2161
  } else if (poolStatus === 'degraded') {
2078
2162
  poolLabel = '🟡 ' + t('modelPoolDegraded') + (m.pool ? ' (' + m.pool.healthyAccounts + '/' + m.pool.totalAccounts + ')' : '')
@@ -2460,7 +2544,8 @@ window.__ModuleLoader__.load({
2460
2544
  || slot.label || slot.proxyUrl || slot.expiresAt
2461
2545
  || (Array.isArray(slot.tags) && slot.tags.length) || slot.vipOnly || slot.isPro || slot.weight)
2462
2546
  }
2463
- const visibleNames = names.filter((prov) => slots.some((slot) => slot.provider === prov.id && configuredSlot(slot)))
2547
+ const visibleNames = names.filter((prov) => newProviders.includes(prov.id)
2548
+ || slots.some((slot) => slot.provider === prov.id && configuredSlot(slot)))
2464
2549
  const hiddenNames = names.filter((prov) => visibleNames.indexOf(prov) < 0)
2465
2550
  return h(React.Fragment, null,
2466
2551
  visibleNames.map((prov) => {
@@ -3703,7 +3788,8 @@ window.__ModuleLoader__.load({
3703
3788
  )
3704
3789
  }
3705
3790
 
3706
- function WebhookTestButton() {
3791
+ function WebhookTestButton(props) {
3792
+ const t = (props && props.t) || ((k) => k)
3707
3793
  const [state, setState] = React.useState({ busy: false, message: '', error: null })
3708
3794
  const send = () => {
3709
3795
  setState({ busy: true, message: '', error: null })
@@ -3711,19 +3797,20 @@ window.__ModuleLoader__.load({
3711
3797
  .then(async (res) => {
3712
3798
  const data = await res.json().catch(() => ({}))
3713
3799
  if (!res.ok || !data.delivered) throw new Error((data.error && data.error.message) || ('HTTP ' + res.status))
3714
- setState({ busy: false, message: 'delivered', error: null })
3800
+ setState({ busy: false, message: t('delivered'), error: null })
3715
3801
  })
3716
3802
  .catch((err) => setState({ busy: false, message: '', error: String(err && err.message || err) }))
3717
3803
  }
3718
3804
  return h('div', { className: 'dsub-row' },
3719
- h('button', { type: 'button', className: 'dsub-mini', disabled: state.busy, onClick: send }, state.busy ? '…' : 'Send test'),
3805
+ h('button', { type: 'button', className: 'dsub-mini', disabled: state.busy, onClick: send }, state.busy ? '…' : t('sendTest')),
3720
3806
  state.message ? h('span', { className: 'dsub-ok' }, state.message) : null,
3721
3807
  state.error ? h('span', { className: 'dsub-bad' }, state.error) : null,
3722
3808
  )
3723
3809
  }
3724
3810
 
3725
3811
  // #736: vault snapshot list with create/restore. Payload stays server-side.
3726
- function VaultSnapshotsPanel() {
3812
+ function VaultSnapshotsPanel(props) {
3813
+ const t = (props && props.t) || ((k) => k)
3727
3814
  const [state, setState] = React.useState({ snapshots: [], stats: null, message: '', error: null, busy: false })
3728
3815
  const pull = () => {
3729
3816
  fetch('/dsh-subscriptions/vault/snapshots', { cache: 'no-store' })
@@ -3744,31 +3831,32 @@ window.__ModuleLoader__.load({
3744
3831
  const create = () => {
3745
3832
  setState((st) => Object.assign({}, st, { busy: true, message: '', error: null }))
3746
3833
  call('/dsh-subscriptions/vault/snapshots', { label: 'manual' })
3747
- .then((data) => { setState((st) => Object.assign({}, st, { busy: false, message: 'created ' + data.snapshot.id })); pull() })
3834
+ .then((data) => { setState((st) => Object.assign({}, st, { busy: false, message: t('created') + ' ' + data.snapshot.id })); pull() })
3748
3835
  .catch((err) => setState((st) => Object.assign({}, st, { busy: false, error: String(err && err.message || err) })))
3749
3836
  }
3750
3837
  const restore = (id) => {
3751
3838
  setState((st) => Object.assign({}, st, { busy: true, message: '', error: null }))
3752
3839
  call('/dsh-subscriptions/vault/snapshots/restore', { id })
3753
- .then((data) => { setState((st) => Object.assign({}, st, { busy: false, message: 'restored ' + data.slotCount + ' slots' })); pull() })
3840
+ .then((data) => { setState((st) => Object.assign({}, st, { busy: false, message: t('restoredSlots') + ' ' + data.slotCount })); pull() })
3754
3841
  .catch((err) => setState((st) => Object.assign({}, st, { busy: false, error: String(err && err.message || err) })))
3755
3842
  }
3756
3843
  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.'),
3844
+ h('div', { className: 'dsub-section-title' }, t('vaultSnapshots'), h('button', { type: 'button', className: 'dsub-mini', onClick: create, disabled: state.busy }, state.busy ? '…' : t('createSnapshot'))),
3845
+ h('div', { className: 'dsub-section-desc' }, t('vaultSnapshotsHint')),
3759
3846
  state.stats ? h('div', { className: 'dsub-dim' }, state.stats.count + ' snapshots · ' + state.stats.bytes + ' bytes · ' + state.stats.path) : null,
3760
3847
  state.snapshots.length ? state.snapshots.map((snap) => h('div', { className: 'dsub-row', key: snap.id },
3761
3848
  h('span', { className: 'dsub-h' }, snap.id),
3762
3849
  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.'),
3850
+ h('button', { type: 'button', className: 'dsub-mini', disabled: state.busy, onClick: () => restore(snap.id) }, t('restoreSnapshot')),
3851
+ )) : h('div', { className: 'dsub-dim' }, t('noSnapshots')),
3765
3852
  state.message ? h('div', { className: 'dsub-ok' }, state.message) : null,
3766
3853
  state.error ? h('div', { className: 'dsub-bad' }, state.error) : null,
3767
3854
  )
3768
3855
  }
3769
3856
 
3770
3857
  // #724: provider pack manager. Displays ids/models only; headers are masked.
3771
- function ProviderPacksPanel() {
3858
+ function ProviderPacksPanel(props) {
3859
+ const t = (props && props.t) || ((k) => k)
3772
3860
  const [state, setState] = React.useState({ packs: [], text: '', message: '', error: null, busy: false })
3773
3861
  const publicPack = (pack) => {
3774
3862
  const copy = Object.assign({}, pack)
@@ -3812,18 +3900,18 @@ window.__ModuleLoader__.load({
3812
3900
  body: JSON.stringify({ config: Object.assign({}, cfg.config, { providerPacks: packs }), revision: cfg.revision }),
3813
3901
  })
3814
3902
  if (!res.ok) throw new Error('HTTP ' + res.status)
3815
- setState((st) => Object.assign({}, st, { packs, busy: false, message: 'saved ' + checked.id, text: '' }))
3903
+ setState((st) => Object.assign({}, st, { packs, busy: false, message: t('saved') + ' ' + checked.id, text: '' }))
3816
3904
  })
3817
3905
  .catch((err) => setState((st) => Object.assign({}, st, { busy: false, error: String(err && err.message || err) })))
3818
3906
  }
3819
- const exportPacks = () => setState((st) => Object.assign({}, st, { text: JSON.stringify(st.packs.map(publicPack), null, 2), message: 'exported' }))
3907
+ const exportPacks = () => setState((st) => Object.assign({}, st, { text: JSON.stringify(st.packs.map(publicPack), null, 2), message: t('exported') }))
3820
3908
  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.'),
3909
+ h('div', { className: 'dsub-section-title' }, t('providerPacks'), h('button', { type: 'button', className: 'dsub-mini', onClick: pull }, '↻')),
3910
+ h('div', { className: 'dsub-section-desc' }, t('providerPacksHint')),
3823
3911
  state.packs.length ? state.packs.map((pack) => h('div', { className: 'dsub-row', key: pack.id },
3824
3912
  h('span', { className: 'dsub-h' }, pack.id),
3825
3913
  h('span', { className: 'dsub-dim' }, (pack.displayName || '') + ' · ' + ((pack.models || []).length) + ' models'),
3826
- )) : h('div', { className: 'dsub-dim' }, 'No packs yet.'),
3914
+ )) : h('div', { className: 'dsub-dim' }, t('noPacks')),
3827
3915
  h('textarea', {
3828
3916
  className: 'dsub-inp',
3829
3917
  style: { width: '100%', minHeight: 120, fontFamily: 'ui-monospace,monospace', padding: 8 },
@@ -3832,8 +3920,8 @@ window.__ModuleLoader__.load({
3832
3920
  onChange: (e) => setState((st) => Object.assign({}, st, { text: e.target.value, message: '', error: null })),
3833
3921
  }),
3834
3922
  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'),
3923
+ h('button', { type: 'button', className: 'dsub-mini', disabled: state.busy, onClick: save }, state.busy ? '…' : t('validateAndSave')),
3924
+ h('button', { type: 'button', className: 'dsub-mini', onClick: exportPacks }, t('exportPacks')),
3837
3925
  ),
3838
3926
  state.message ? h('div', { className: 'dsub-ok' }, state.message) : null,
3839
3927
  state.error ? h('div', { className: 'dsub-bad' }, state.error) : null,
@@ -3841,7 +3929,8 @@ window.__ModuleLoader__.load({
3841
3929
  }
3842
3930
 
3843
3931
  // #713: read-only source chain panel. Never renders key values.
3844
- function SourcesPanel() {
3932
+ function SourcesPanel(props) {
3933
+ const t = (props && props.t) || ((k) => k)
3845
3934
  const [state, setState] = React.useState({ loading: true, data: null, error: null })
3846
3935
  const pull = () => {
3847
3936
  fetch('/dsh-subscriptions/sources', { cache: 'no-store' })
@@ -3854,16 +3943,16 @@ window.__ModuleLoader__.load({
3854
3943
  const id = setInterval(pull, 30 * 1000)
3855
3944
  return () => clearInterval(id)
3856
3945
  }, [])
3857
- const sourceLabel = (source) => source === 'api-key' ? 'API key' : (source === 'oauth' ? 'OAuth' : (source === 'ollama' ? 'Ollama' : 'none'))
3946
+ const sourceLabel = (source) => source === 'api-key' ? t('apiKeySource') : (source === 'oauth' ? t('oauthSource') : (source === 'ollama' ? t('ollamaSource') : t('sourceNone')))
3858
3947
  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.'),
3948
+ h('div', { className: 'dsub-section-title' }, t('sources'), h('button', { type: 'button', className: 'dsub-mini', onClick: pull }, state.loading ? '…' : '↻')),
3949
+ h('div', { className: 'dsub-section-desc' }, t('sourcesHint')),
3861
3950
  state.error ? h('div', { className: 'dsub-bad' }, state.error) : null,
3862
3951
  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 : ''))
3952
+ ? h('div', { className: 'dsub-dim' }, t('keyRotationOffline') + (state.data.keyRotation.reason ? ': ' + state.data.keyRotation.reason : ''))
3864
3953
  : null,
3865
3954
  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')
3955
+ ? h('div', { className: 'dsub-dim' }, t('keyRotationHealth') + ': ' + state.data.keyRotation.healthyKeys + '/' + state.data.keyRotation.totalKeys + ' healthy keys')
3867
3956
  : null,
3868
3957
  (state.data && state.data.providers ? state.data.providers : []).map((row) => h('div', { className: 'dsub-row', key: row.provider },
3869
3958
  h('span', { className: 'dsub-h' }, row.provider),
@@ -3993,16 +4082,16 @@ window.__ModuleLoader__.load({
3993
4082
  },
3994
4083
  h('div', { className: 'dsub-h' }, String(state.active.provider || '').toUpperCase() + ' sources'),
3995
4084
  (state.candidates || []).map((c, i) => {
3996
- const label = c.source === 'api-key' ? 'API key' : (c.source === 'oauth' ? 'OAuth' : 'Ollama')
4085
+ const label = c.source === 'api-key' ? t('apiKeySource') : (c.source === 'oauth' ? t('oauthSource') : t('ollamaSource'))
3997
4086
  const cd = c.detail && Array.isArray(c.detail.keys) ? Math.max(0, ...c.detail.keys.map((k) => Number(k.cooldownMsLeft) || 0)) : 0
3998
4087
  return h('div', { className: 'dsub-row', key: i },
3999
4088
  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,
4089
+ h('span', { className: c.available ? 'dsub-ok' : 'dsub-dim' }, c.available ? t('sourceAvailable') : t('sourceUnavailable')),
4090
+ cd > 0 ? h('span', { className: 'dsub-dim' }, t('cooldownLabel') + ' ' + Math.ceil(cd / 1000) + 's') : null,
4002
4091
  c.detail && c.detail.healthyKeys != null ? h('span', { className: 'dsub-dim' }, c.detail.healthyKeys + '/' + c.detail.totalKeys + ' keys') : null,
4003
4092
  )
4004
4093
  }),
4005
- h('button', { type: 'button', className: 'dsub-mini', onClick: () => { setOpen(false); openSettings() } }, 'Open settings'),
4094
+ h('button', { type: 'button', className: 'dsub-mini', onClick: () => { setOpen(false); openSettings() } }, t('openSettings')),
4006
4095
  ) : null
4007
4096
  return h(React.Fragment, null, h('div', {
4008
4097
  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.13",
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": {