@goodandready/dsh-subscriptions 0.6.28 → 0.6.30

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.
@@ -46,6 +46,8 @@ export function createAdapterManager({
46
46
  getQuota: (ref) => store.getQuota(ref),
47
47
  refreshUsage: (provider) => store.refreshUsage(provider),
48
48
  saveBlob: (ref, blob) => store.saveBlob(ref, blob),
49
+ alertThresholds: () => live().alertThresholds,
50
+ webhookUrl: () => live().webhookUrl,
49
51
  recordHistory,
50
52
  fetchImpl: fetch,
51
53
  fetchForRef,
package/lib/adapter.js CHANGED
@@ -4,6 +4,7 @@ import { displayName } from './refs.js'
4
4
  import { getVendor } from './vendors/index.js'
5
5
  import { modelCatalog } from './messages.js'
6
6
  import { streamWithRotation } from './stream-rotate.js'
7
+ import { checkQuotaThresholds } from './alerts.js'
7
8
  import { pickFetch } from './proxy.js'
8
9
  import { quotaSnapshot, parseBody } from './ratelimit.js'
9
10
  import { toTokenUsage } from './wire.js'
@@ -112,6 +113,8 @@ export class SubscriptionAdapter extends LlmAdapter {
112
113
  cooldownMs: deps.cooldownMs(),
113
114
  switchAtRemaining: typeof deps.switchAtRemaining === 'function' ? deps.switchAtRemaining() : (deps.switchAtRemaining ?? 0),
114
115
  options,
116
+ webhookUrl: typeof deps.webhookUrl === 'function' ? deps.webhookUrl() : deps.webhookUrl,
117
+ fetchImpl: deps.fetchImpl || fetch,
115
118
  onCooldown: (account) => {
116
119
  if (typeof deps.rememberCooldown === 'function') deps.rememberCooldown(account.ref, account.cooldownUntil, account.cooldownFamilies || null)
117
120
  if (typeof deps.recordSwitch === 'function') deps.recordSwitch(account.ref)
@@ -129,7 +132,21 @@ export class SubscriptionAdapter extends LlmAdapter {
129
132
  const res = await fetchWithTimeout(baseFetch, url, init, { timeoutMs: 120000 })
130
133
  try {
131
134
  const snap = quotaSnapshot(provider, res.headers, null, Date.now())
132
- if (snap && typeof deps.rememberQuota === "function") deps.rememberQuota(account.ref, snap)
135
+ if (snap && typeof deps.rememberQuota === "function") {
136
+ deps.rememberQuota(account.ref, snap)
137
+ if (snap.usedPercent != null) {
138
+ const th = typeof deps.alertThresholds === 'function' ? deps.alertThresholds() : deps.alertThresholds
139
+ const wh = typeof deps.webhookUrl === 'function' ? deps.webhookUrl() : deps.webhookUrl
140
+ checkQuotaThresholds({
141
+ ref: account.ref,
142
+ provider,
143
+ usedPercent: snap.usedPercent,
144
+ thresholds: th,
145
+ webhookUrl: wh,
146
+ fetchImpl: deps.fetchImpl || fetch,
147
+ })
148
+ }
149
+ }
133
150
  } catch { /* ignore quota capture failure */ }
134
151
  if (!res.ok && res.clone) {
135
152
  try {
@@ -139,7 +156,21 @@ export class SubscriptionAdapter extends LlmAdapter {
139
156
  const b = parseBody(provider, j, Date.now())
140
157
  if (b) {
141
158
  const snap2 = quotaSnapshot(provider, res.headers, j, Date.now())
142
- if (snap2) deps.rememberQuota(account.ref, snap2)
159
+ if (snap2) {
160
+ deps.rememberQuota(account.ref, snap2)
161
+ if (snap2.usedPercent != null) {
162
+ const th = typeof deps.alertThresholds === 'function' ? deps.alertThresholds() : deps.alertThresholds
163
+ const wh = typeof deps.webhookUrl === 'function' ? deps.webhookUrl() : deps.webhookUrl
164
+ checkQuotaThresholds({
165
+ ref: account.ref,
166
+ provider,
167
+ usedPercent: snap2.usedPercent,
168
+ thresholds: th,
169
+ webhookUrl: wh,
170
+ fetchImpl: deps.fetchImpl || fetch,
171
+ })
172
+ }
173
+ }
143
174
  }
144
175
  }
145
176
  } catch { /* ignore quota capture failure */ }
package/lib/alerts.js CHANGED
@@ -1,3 +1,12 @@
1
+ import { readFileSync } from 'node:fs'
2
+
3
+ let pkgVersion = ''
4
+ try {
5
+ pkgVersion = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version || ''
6
+ } catch {
7
+ // ignore package read errors
8
+ }
9
+
1
10
  // #351: Proactive Quota Alerts & Webhook Dispatcher
2
11
  // Manages quota threshold warnings, session expiry notifications,
3
12
  // and webhook dispatch (e.g. Telegram/Discord or external monitoring).
@@ -70,7 +79,7 @@ export async function dispatchWebhookAlert(alert, webhookUrl, fetchImpl = fetch)
70
79
  method: 'POST',
71
80
  headers: {
72
81
  'content-type': 'application/json',
73
- 'user-agent': 'dsh-subscriptions/0.6.17',
82
+ 'user-agent': pkgVersion ? `dsh-subscriptions/${pkgVersion}` : 'dsh-subscriptions',
74
83
  },
75
84
  body: JSON.stringify(payload),
76
85
  })
package/lib/client.js CHANGED
@@ -149,7 +149,7 @@ window.__ModuleLoader__.load({
149
149
  '.dsub-slot-meta-row{display:flex;flex-wrap:wrap;align-items:center;gap:10px;margin-top:12px;padding-top:10px;border-top:1px solid var(--dsw-alias-border-l2);font-size:12px}' +
150
150
  '.dsub-ref-tag{display:inline-flex;align-items:center;gap:5px;font-size:11.5px;color:var(--dsw-alias-label-secondary)}' +
151
151
  '.dsub-ref-tag code{padding:2px 6px;border-radius:4px;background:var(--dsw-alias-bg-layer-1);border:1px solid var(--dsw-alias-border-l2);font-family:ui-monospace,monospace;color:var(--dsw-alias-label-primary);font-size:11px}' +
152
- '.dsub-speed-tag{display:inline-flex;align-items:center;gap:4px;font-size:11.5px;color:var(--dsw-alias-brand-default,#3b82f6);background:color-mix(in srgb,var(--dsw-alias-brand-default,#3b82f6) 8%,transparent);padding:2px 8px;border-radius:6px;font-weight:500;border:1px solid color-mix(in srgb,var(--dsw-alias-brand-default,#3b82f6) 20%,transparent)}' +
152
+ '.dsub-speed-tag{display:inline-flex;align-items:center;gap:4px;font-size:11.5px;color:var(--dsw-alias-brand-default);background:color-mix(in srgb,var(--dsw-alias-brand-default) 8%,transparent);padding:2px 8px;border-radius:6px;font-weight:500;border:1px solid color-mix(in srgb,var(--dsw-alias-brand-default) 20%,transparent)}' +
153
153
  '.dsub-btn-danger{border-color:color-mix(in srgb,var(--dsw-alias-state-error-primary) 40%,transparent)!important;color:var(--dsw-alias-state-error-primary)!important}' +
154
154
  '.dsub-btn-danger:hover:not(:disabled){background:color-mix(in srgb,var(--dsw-alias-state-error-primary) 12%,transparent)!important}' +
155
155
  '.dsub-poolTitle{margin-top:16px;margin-bottom:8px;font-size:11.5px;font-weight:700;color:var(--dsw-alias-label-secondary);text-transform:uppercase;letter-spacing:.05em}' +
@@ -299,12 +299,12 @@ const cssId = 'dsh-subscriptions/settings.module.css'
299
299
  grok: {
300
300
  title: "xAI Grok",
301
301
  stepsEn: [
302
- "1. Run grok-cli or hermes login on server.",
302
+ "1. Run grok CLI login on the server.",
303
303
  "2. Click \"📥 From CLI\" (~/.grok/auth.json).",
304
304
  "3. Or paste your xAI API Key and click \"Save token\".",
305
305
  ],
306
306
  stepsZh: [
307
- "1. 在服务器上运行 grok-cli 或 hermes 登录。",
307
+ "1. 在服务器上运行 grok CLI 登录。",
308
308
  "2. 点击 \"📥 从 CLI 导入\" (~/.grok/auth.json)。",
309
309
  "3. 或将 xAI API 密钥粘贴到输入框并点击 \"保存令牌\"。",
310
310
  ],
@@ -477,6 +477,7 @@ const cssId = 'dsh-subscriptions/settings.module.css'
477
477
  "save": "Save",
478
478
  "saved": "Saved",
479
479
  "loading": "Loading…",
480
+ "unavailable": "Settings are unavailable",
480
481
  "accountLabel": "Account",
481
482
  "plan": "Plan",
482
483
  "storedAs": "Stored as",
@@ -528,8 +529,9 @@ const cssId = 'dsh-subscriptions/settings.module.css'
528
529
  "backupImportTitle": "Import Encrypted Backup",
529
530
  "backupExportAction": "Export",
530
531
  "backupImportAction": "Import",
531
- "backupPassphrase": "Vault Passphrase (min 4 chars)",
532
- "backupExportHint": "Encrypt connected subscription accounts and slots with AES-256-GCM and PBKDF2.",
532
+ "backupPassphrase": "Vault Passphrase (min 12 chars)",
533
+ "backupExportHint": "Encrypt connected subscription accounts and slots with AES-256-GCM. ⚠️ Contains sensitive OAuth refresh tokens; use a strong password (min 12 chars).",
534
+ "vaultTokenWarning": "⚠️ Contains sensitive OAuth refresh tokens",
533
535
  "backupImportHint": "Restore encrypted subscription accounts and credentials from a vault payload.",
534
536
  "backupPayloadPlaceholder": "Paste DSHE... encrypted string here",
535
537
  "backupExportOk": "Backup exported successfully",
@@ -674,6 +676,7 @@ const cssId = 'dsh-subscriptions/settings.module.css'
674
676
  "save": "保存",
675
677
  "saved": "已保存",
676
678
  "loading": "加载中…",
679
+ "unavailable": "设置不可用",
677
680
  "accountLabel": "账户",
678
681
  "plan": "套餐方案",
679
682
  "storedAs": "存储标识",
@@ -725,8 +728,9 @@ const cssId = 'dsh-subscriptions/settings.module.css'
725
728
  "backupImportTitle": "导入加密备份",
726
729
  "backupExportAction": "导出",
727
730
  "backupImportAction": "导入",
728
- "backupPassphrase": "凭据库密码(至少 4 位)",
729
- "backupExportHint": "使用 AES-256-GCM 与 PBKDF2 安全加密导出所有订阅账户与配置。",
731
+ "backupPassphrase": "凭据库密码(至少 12 位)",
732
+ "backupExportHint": "使用 AES-256-GCM 安全加密导出所有订阅账户与配置。⚠️ 包含敏感的 OAuth 刷新令牌,请使用至少 12 位的强密码保护。",
733
+ "vaultTokenWarning": "⚠️ 包含敏感的 OAuth 刷新令牌",
730
734
  "backupImportHint": "从已加密的备份内容中导入并恢复订阅账户与凭据。",
731
735
  "backupPayloadPlaceholder": "在此粘贴 DSHE... 加密内容",
732
736
  "backupExportOk": "备份导出成功",
@@ -1674,7 +1678,7 @@ const cssId = 'dsh-subscriptions/settings.module.css'
1674
1678
  backupModal === 'export' ? React.createElement('button', {
1675
1679
  type: 'button',
1676
1680
  className: 'dsub-mini',
1677
- disabled: backupLoading || !backupPass || backupPass.length < 4,
1681
+ disabled: backupLoading || !backupPass || backupPass.length < 12,
1678
1682
  onClick: doExportBackup,
1679
1683
  }, backupLoading ? '…' : t('backupExportAction')) : React.createElement('button', {
1680
1684
  type: 'button',
@@ -2074,6 +2078,33 @@ const cssId = 'dsh-subscriptions/settings.module.css'
2074
2078
  if (props && props.view === 'summary') {
2075
2079
  return React.createElement('div', { className: 'dsub-description' }, t('cardIntro'))
2076
2080
  }
2081
+ // Check host snapshot status (#408): loading or unavailable
2082
+ if (props && props.status === 'loading') {
2083
+ return React.createElement(page ? 'div' : 'li', {
2084
+ className: page ? 'dsub-page' : 'dsub-card',
2085
+ },
2086
+ React.createElement('div', { className: 'dsub-header', style: { cursor: 'default' } },
2087
+ React.createElement('div', { className: 'dsub-headText' },
2088
+ React.createElement('div', { className: 'dsub-name' }, t('title')),
2089
+ React.createElement('div', { className: 'dsub-description' }, t('loading') || t('settingsLoading') || 'Loading…'),
2090
+ ),
2091
+ )
2092
+ )
2093
+ }
2094
+ if (props && props.status === 'unavailable') {
2095
+ return React.createElement(page ? 'div' : 'li', {
2096
+ className: page ? 'dsub-page' : 'dsub-card',
2097
+ },
2098
+ React.createElement('div', { className: 'dsub-header', style: { cursor: 'default' } },
2099
+ React.createElement('div', { className: 'dsub-headText' },
2100
+ React.createElement('div', { className: 'dsub-name' }, t('title')),
2101
+ React.createElement('div', { className: 'dsub-description', style: { color: 'var(--dsw-alias-state-warning-primary)' } },
2102
+ t('unavailable') || t('settingsUnavailable') || 'Settings are unavailable',
2103
+ ),
2104
+ ),
2105
+ )
2106
+ )
2107
+ }
2077
2108
  return React.createElement(page ? 'div' : 'li', {
2078
2109
  className: page ? 'dsub-page' : 'dsub-card' + (open ? ' dsub-cardOpen' : ''),
2079
2110
  },
package/lib/crypto.js CHANGED
@@ -1,19 +1,31 @@
1
1
  import { scryptSync, pbkdf2Sync, createCipheriv, createDecipheriv, randomBytes } from "node:crypto"
2
2
 
3
- // AES-256-GCM with PBKDF2 (100,000 iterations, SHA-256) (DSHE2:) or scrypt (DSHE1:).
4
- // Backward-compatible across both key derivation algorithms.
3
+ // AES-256-GCM with PBKDF2 (600,000 iterations, SHA-256) (DSHE2:),
4
+ // hardened scrypt (N=131072) (DSHE3:), or legacy scrypt (DSHE1:).
5
+ // Backward-compatible across all key derivation algorithms.
5
6
  export function encryptWithPassphrase(plainText, passphrase, { algorithm = "scrypt" } = {}) {
6
7
  const salt = randomBytes(16)
7
8
  const isPbkdf2 = algorithm === "pbkdf2"
8
- const key = isPbkdf2
9
- ? pbkdf2Sync(String(passphrase), salt, 100000, 32, "sha256")
10
- : scryptSync(String(passphrase), salt, 32)
9
+ const isHardenedScrypt = algorithm === "scrypt-v2" || algorithm === "scrypt-hardened"
10
+ let key
11
+ let prefix
12
+
13
+ if (isPbkdf2) {
14
+ key = pbkdf2Sync(String(passphrase), salt, 600000, 32, "sha256")
15
+ prefix = "DSHE2:"
16
+ } else if (isHardenedScrypt) {
17
+ key = scryptSync(String(passphrase), salt, 32, { N: 131072, r: 8, p: 1, maxmem: 256 * 1024 * 1024 })
18
+ prefix = "DSHE3:"
19
+ } else {
20
+ key = scryptSync(String(passphrase), salt, 32)
21
+ prefix = "DSHE1:"
22
+ }
23
+
11
24
  const iv = randomBytes(12)
12
25
  const cipher = createCipheriv("aes-256-gcm", key, iv)
13
26
  const encrypted = Buffer.concat([cipher.update(String(plainText), "utf8"), cipher.final()])
14
27
  const tag = cipher.getAuthTag()
15
28
  // format: magic + salt + iv + tag + data
16
- const prefix = isPbkdf2 ? "DSHE2:" : "DSHE1:"
17
29
  return prefix + Buffer.concat([salt, iv, tag, encrypted]).toString("base64")
18
30
  }
19
31
 
@@ -24,13 +36,28 @@ export function decryptWithPassphrase(payload, passphrase) {
24
36
  let iv
25
37
  let tag
26
38
  let data
27
- if (raw.startsWith("DSHE2:")) {
39
+ if (raw.startsWith("DSHE3:")) {
40
+ const buf = Buffer.from(raw.slice(6), "base64")
41
+ salt = buf.subarray(0, 16)
42
+ iv = buf.subarray(16, 28)
43
+ tag = buf.subarray(28, 44)
44
+ data = buf.subarray(44)
45
+ key = scryptSync(String(passphrase), salt, 32, { N: 131072, r: 8, p: 1, maxmem: 256 * 1024 * 1024 })
46
+ } else if (raw.startsWith("DSHE2:")) {
28
47
  const buf = Buffer.from(raw.slice(6), "base64")
29
48
  salt = buf.subarray(0, 16)
30
49
  iv = buf.subarray(16, 28)
31
50
  tag = buf.subarray(28, 44)
32
51
  data = buf.subarray(44)
33
- key = pbkdf2Sync(String(passphrase), salt, 100000, 32, "sha256")
52
+ // Try 600,000 iterations first (#414), fallback to legacy 100,000 iterations
53
+ try {
54
+ key = pbkdf2Sync(String(passphrase), salt, 600000, 32, "sha256")
55
+ const decipher = createDecipheriv("aes-256-gcm", key, iv)
56
+ decipher.setAuthTag(tag)
57
+ return Buffer.concat([decipher.update(data), decipher.final()]).toString("utf8")
58
+ } catch {
59
+ key = pbkdf2Sync(String(passphrase), salt, 100000, 32, "sha256")
60
+ }
34
61
  } else if (raw.startsWith("DSHE1:")) {
35
62
  const buf = Buffer.from(raw.slice(6), "base64")
36
63
  salt = buf.subarray(0, 16)
package/lib/history.js CHANGED
@@ -6,15 +6,21 @@ import { homedir } from 'node:os'
6
6
  // ~/.dsh/storages/dsh-subscriptions/history.json (JSON array, newest first).
7
7
  // All IO is best-effort: a write failure must never crash the harness.
8
8
 
9
- export const DEFAULT_HISTORY_DIR = join(homedir(), '.dsh', 'storages', 'dsh-subscriptions')
9
+ export function resolveHistoryDir(customDir) {
10
+ if (customDir) return customDir
11
+ if (process.env.DSH_HISTORY_DIR) return process.env.DSH_HISTORY_DIR
12
+ if (process.env.DSH_STORAGE_DIR) return process.env.DSH_STORAGE_DIR
13
+ if (process.env.DSH_HOME) return join(process.env.DSH_HOME, 'storages', 'dsh-subscriptions')
14
+ return join(homedir(), '.dsh', 'storages', 'dsh-subscriptions')
15
+ }
10
16
 
11
17
  export class HistoryStore {
12
- constructor(dir = DEFAULT_HISTORY_DIR, ttlMs = 7 * 24 * 60 * 60 * 1000, debounceMs = 0, maxEntries = 1000) {
13
- this.dir = dir
18
+ constructor(dir = resolveHistoryDir(), ttlMs = 7 * 24 * 60 * 60 * 1000, debounceMs = 0, maxEntries = 1000) {
19
+ this.dir = resolveHistoryDir(dir)
14
20
  this.ttlMs = ttlMs
15
21
  this.debounceMs = debounceMs
16
22
  this.maxEntries = maxEntries
17
- this.path = join(dir, 'history.json')
23
+ this.path = join(this.dir, 'history.json')
18
24
  this.rows = []
19
25
  this._persistTimer = null
20
26
  this._load()
@@ -2,7 +2,6 @@ import { readFile } from 'node:fs/promises'
2
2
  import { homedir } from 'node:os'
3
3
  import { join } from 'node:path'
4
4
  import { readClaudeCredentials } from './vendors/claude-cli.js'
5
- import { discoverCodingAgentConfigs } from './importers/coding-agents.js'
6
5
 
7
6
  // Test seam: a fixture root overrides the real home for the duration of a
8
7
  // public call. Undefined (the default) keeps production behaviour.
@@ -46,10 +45,9 @@ async function discoverLocalCliSessionsInner() {
46
45
  }
47
46
  }
48
47
 
49
- // 2. Grok CLI / Hermes
48
+ // 2. Grok CLI
50
49
  const grokPaths = [
51
50
  homeFile('.grok', 'auth.json'),
52
- homeFile('.hermes', 'auth.json'),
53
51
  ]
54
52
  for (const p of grokPaths) {
55
53
  const grok = await readJson(p)
@@ -198,24 +196,7 @@ async function discoverLocalCliSessionsInner() {
198
196
  }
199
197
  }
200
198
 
201
- // 10. Coding Agents (Aider / Roo-Code)
202
- const agentConfigs = await discoverCodingAgentConfigs()
203
- if (agentConfigs.aider) {
204
- detected.aider = {
205
- provider: 'aider',
206
- path: agentConfigs.aider.path,
207
- email: 'Aider Configured Agent',
208
- hasRefreshToken: false,
209
- }
210
- }
211
- if (agentConfigs.roocode) {
212
- detected.roocode = {
213
- provider: 'roocode',
214
- path: agentConfigs.roocode.path,
215
- email: 'Roo-Code Configured Agent',
216
- hasRefreshToken: false,
217
- }
218
- }
199
+
219
200
 
220
201
  return detected
221
202
  }
package/lib/index.js CHANGED
@@ -16,7 +16,7 @@ import { OllamaAdapter, ollamaAlive, ollamaModels, ollamaBase } from './ollama.j
16
16
  import { createResetCreditService } from './reset-credits.js'
17
17
  import { maskEmail, maskLabel } from './mask.js'
18
18
  import { proxyFetch } from './proxy.js'
19
- import { HistoryStore, DEFAULT_HISTORY_DIR } from './history.js'
19
+ import { HistoryStore, resolveHistoryDir } from './history.js'
20
20
  import { createDiagnosticsReport } from './diagnostics.js'
21
21
  import { createAccountsView } from './accounts-view.js'
22
22
  import { createAdapterManager } from './adapter-manager.js'
@@ -185,7 +185,8 @@ export function apply(ctx, rawConfig) {
185
185
  bestEffort('emit limit-notice', () => ctx.emit && ctx.emit('subscriptions.limit-notice', { provider, ref, window: win.id, usedPercent: win.usedPercent, threshold }), undefined, logger)
186
186
  },
187
187
  })
188
- const history = new HistoryStore(DEFAULT_HISTORY_DIR, 7 * 24 * 60 * 60 * 1000, 1000)
188
+ const historyDir = ctx?.historyDir || ctx?.storageDir || resolveHistoryDir()
189
+ const history = new HistoryStore(historyDir, 7 * 24 * 60 * 60 * 1000, 1000)
189
190
  if (typeof ctx?.on === 'function') {
190
191
  ctx.on('dispose', () => history.dispose())
191
192
  }
package/lib/routes.js CHANGED
@@ -24,6 +24,10 @@ export function registerRoutes(ctx, state) {
24
24
 
25
25
  ctx.effect(() => registerPluginUpdater(ctx, {
26
26
  endpoint: '/dsh-subscriptions/update',
27
+ aliases: [
28
+ '/api/@goodandready/dsh-subscriptions/update',
29
+ '/api/dsh-subscriptions/update',
30
+ ],
27
31
  packageName: '@goodandready/dsh-subscriptions',
28
32
  manifestUrl: new URL('../package.json', import.meta.url),
29
33
  }), 'dsh-subscriptions: /update')
@@ -1,5 +1,6 @@
1
1
  import { pickAccount, markCooldown, isSwitchableError, isRegionError, modelFamily } from './rotate.js'
2
2
  import { putInQuarantine, REASON_RATE_LIMIT, REASON_HARD_LIMIT, REASON_REVOKED } from './quarantine.js'
3
+ import { notifySessionExpired } from './alerts.js'
3
4
  import { withRunawayGuard } from './runaway-guard.js'
4
5
 
5
6
  export async function* streamWithRotation({
@@ -12,6 +13,8 @@ export async function* streamWithRotation({
12
13
  onCooldown,
13
14
  cascadeFallback, // #349: optional cross-vendor cascading fallback generator
14
15
  offlineFallback, // #174: optional fallback generator if all accounts exhausted
16
+ webhookUrl,
17
+ fetchImpl = fetch,
15
18
  }) {
16
19
  const pool = (accounts || []).map((account) => ({ ...account }))
17
20
  let lastError = null
@@ -89,6 +92,19 @@ export async function* streamWithRotation({
89
92
  account.quarantineUntil = quarantined.quarantineUntil
90
93
  account.quarantineReason = quarantined.quarantineReason
91
94
 
95
+ if (reason === REASON_REVOKED) {
96
+ try {
97
+ notifySessionExpired({
98
+ ref: account.ref,
99
+ provider: options && options.provider,
100
+ message: err && err.message,
101
+ webhookUrl: webhookUrl || (options && options.webhookUrl),
102
+ fetchImpl,
103
+ nowMs: nowMs(),
104
+ })
105
+ } catch { /* fire-and-forget alert */ }
106
+ }
107
+
92
108
  if (onCooldown) onCooldown(account)
93
109
  // Break inner retry loop to continue to next account in outer while (true) loop
94
110
  break
package/lib/updater.js CHANGED
@@ -4,7 +4,7 @@ import { readFile } from 'node:fs/promises'
4
4
  import { homedir } from 'node:os'
5
5
  import { basename, dirname, isAbsolute, resolve } from 'node:path'
6
6
  import { fileURLToPath } from 'node:url'
7
- import { writeJson } from './http.js'
7
+ import { isTrustedSettingsRequest, writeJson } from './http.js'
8
8
 
9
9
  const UPDATE_HEADER = 'x-dsh-plugin-update'
10
10
  const UPDATE_TIMEOUT_MS = 10 * 60_000
@@ -25,24 +25,11 @@ export function isLoopback(value) {
25
25
 
26
26
  export function isTrustedUpdateRequest(request) {
27
27
  const customHeader = header(request, UPDATE_HEADER)
28
- if (customHeader !== '1') {
29
- // In browser fetch, allow if sec-fetch-site is same-origin or same-site
30
- const site = header(request, 'sec-fetch-site')
31
- if (site === 'cross-site') return false
28
+ if (customHeader === '1') {
29
+ const remote = request.socket?.remoteAddress
30
+ if (remote && isLoopback(remote)) return true
32
31
  }
33
- const remote = request.socket?.remoteAddress
34
- if (remote && !isLoopback(remote)) return false
35
- const origin = header(request, 'origin')
36
- const host = header(request, 'host')
37
- if (origin && host) {
38
- try {
39
- const url = new URL(origin)
40
- if (url.host !== host && !isLoopback(url.hostname)) return false
41
- } catch {
42
- return false
43
- }
44
- }
45
- return true
32
+ return isTrustedSettingsRequest(request)
46
33
  }
47
34
 
48
35
  function validProfileName(value) {
@@ -206,10 +193,11 @@ export async function installExactPackage(target, packageSpec, options) {
206
193
 
207
194
  export function registerPluginUpdater(ctx, options) {
208
195
  let installing = false
209
- return ctx.webServer.register({
210
- kind: 'exact',
211
- path: options.endpoint,
212
- handler: async (request, response) => {
196
+ const endpoints = Array.isArray(options.endpoint)
197
+ ? options.endpoint
198
+ : [options.endpoint, ...(Array.isArray(options.aliases) ? options.aliases : [])]
199
+
200
+ const handler = async (request, response) => {
213
201
  try {
214
202
  const target = getRuntime()
215
203
  if (request.method === 'GET' || request.method === 'HEAD') {
@@ -255,6 +243,17 @@ export function registerPluginUpdater(ctx, options) {
255
243
  ctx.logger?.warn?.(`plugin updater failed: ${String(error)}`)
256
244
  writeJson(response, 503, { ok: false, error: { code: 'update_failed', message: String(error?.message || error) } })
257
245
  }
258
- },
259
- })
246
+ }
247
+
248
+ const unregisters = endpoints.map((ep) => ctx.webServer.register({
249
+ kind: 'exact',
250
+ path: ep,
251
+ handler,
252
+ }))
253
+
254
+ return () => {
255
+ for (const un of unregisters) {
256
+ if (typeof un === 'function') un()
257
+ }
258
+ }
260
259
  }
package/lib/vault.js CHANGED
@@ -11,8 +11,8 @@ export const VAULT_FORMAT = 'dsh-subscriptions-vault-v1'
11
11
  */
12
12
  export function exportVault({ slots = [], blobs = {}, exportedBy = 'dsh-subscriptions', algorithm = 'scrypt' }, passphrase) {
13
13
  const pass = String(passphrase || '').trim()
14
- if (!pass || pass.length < 4) {
15
- throw new Error('passphrase must be at least 4 characters')
14
+ if (!pass || pass.length < 12) {
15
+ throw new Error('passphrase must be at least 12 characters')
16
16
  }
17
17
 
18
18
  const payload = {
@@ -43,7 +43,7 @@ export function importVault(encryptedPayload, passphrase) {
43
43
  }
44
44
 
45
45
  const raw = String(encryptedPayload || '').trim()
46
- if (!raw.startsWith('DSHE1:') && !raw.startsWith('DSHE2:')) {
46
+ if (!raw.startsWith('DSHE1:') && !raw.startsWith('DSHE2:') && !raw.startsWith('DSHE3:')) {
47
47
  throw new Error('invalid vault format: missing DSHE1 header')
48
48
  }
49
49
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-subscriptions",
3
- "version": "0.6.28",
3
+ "version": "0.6.30",
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",
@@ -37,7 +37,7 @@
37
37
  "url": "https://github.com/GooDAnDReaDY/dsh-subscriptions/issues"
38
38
  },
39
39
  "scripts": {
40
- "test": "eslint lib/ test/ && node --test test/*.test.mjs",
40
+ "test": "eslint lib/ test/ && node --import ./test/_setup.mjs --test test/*.test.mjs",
41
41
  "typecheck": "tsc -p tsconfig.checkjs.json"
42
42
  },
43
43
  "dsh": {
@@ -1,37 +0,0 @@
1
- import { readFile } from 'node:fs/promises'
2
- import { homedir } from 'node:os'
3
- import { join } from 'node:path'
4
-
5
- export async function discoverCodingAgentConfigs() {
6
- const configs = {}
7
-
8
- // 1. Aider (.aider.conf.yml)
9
- try {
10
- const p = join(homedir(), '.aider.conf.yml')
11
- const text = await readFile(p, 'utf8')
12
- configs.aider = {
13
- name: 'Aider Config',
14
- path: p,
15
- found: true,
16
- raw: text
17
- }
18
- } catch {
19
- // not present
20
- }
21
-
22
- // 2. Roo-Code / Roo-Cline settings
23
- try {
24
- const p = join(homedir(), '.roo-code', 'settings.json')
25
- const text = await readFile(p, 'utf8')
26
- configs.roocode = {
27
- name: 'Roo-Code Config',
28
- path: p,
29
- found: true,
30
- raw: text
31
- }
32
- } catch {
33
- // not present
34
- }
35
-
36
- return configs
37
- }