@goodandready/dsh-subscriptions 0.6.2 → 0.6.4

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/accounts.js CHANGED
@@ -12,6 +12,8 @@ export function normalizeSlots(slots) {
12
12
  const out = []
13
13
  const seen = new Set()
14
14
  for (const slot of Array.isArray(slots) ? slots : []) {
15
+ // User-editable config: a null or non-object entry must be skipped, not crash the plugin.
16
+ if (!slot || typeof slot !== 'object') continue
15
17
  if (!isProvider(slot.provider)) continue
16
18
  const index = Number(slot.index)
17
19
  if (!Number.isInteger(index) || index < 1) continue
@@ -105,10 +107,14 @@ export function createAccountStore({ credentials, getConfig, fetchImpl, fetchFor
105
107
  quarantines.delete(ref)
106
108
  writeQueues.delete(ref)
107
109
  for (const k of [...notifyThresholds.keys()]) { if (k.startsWith(ref + ':')) notifyThresholds.delete(k) }
108
- for (const k of [...notifyThresholds.keys()]) { if (k.startsWith(ref + ':')) notifyThresholds.delete(k) }
109
110
  refreshFailures.delete(ref)
110
111
  usage.delete(ref)
111
112
  usageFetched.delete(ref)
113
+ // Counters and health must not outlive the account, otherwise a
114
+ // re-added slot inherits stale rotation statistics.
115
+ requestCounts.delete(ref)
116
+ health.delete(ref)
117
+ windows.delete(ref)
112
118
  }
113
119
 
114
120
  async function describeRef(ref) {
package/lib/http.js CHANGED
@@ -12,7 +12,7 @@ export function writeHtml(res, code, html) {
12
12
  } catch { /* socket closed */ }
13
13
  }
14
14
 
15
- export function readBody(req, maxBytes) {
15
+ export function readBody(req, maxBytes = 64 * 1024) {
16
16
  return new Promise((resolve, reject) => {
17
17
  const chunks = []
18
18
  let size = 0
@@ -4,8 +4,12 @@ import { join } from 'node:path'
4
4
  import { readClaudeCredentials } from './vendors/claude-cli.js'
5
5
  import { discoverCodingAgentConfigs } from './importers/coding-agents.js'
6
6
 
7
+ // Test seam: a fixture root overrides the real home for the duration of a
8
+ // public call. Undefined (the default) keeps production behaviour.
9
+ let activeHome = null
10
+
7
11
  function homeFile(...parts) {
8
- return join(homedir(), ...parts)
12
+ return join(activeHome || homedir(), ...parts)
9
13
  }
10
14
 
11
15
  async function readJson(path) {
@@ -17,7 +21,17 @@ async function readJson(path) {
17
21
  }
18
22
  }
19
23
 
20
- export async function discoverLocalCliSessions() {
24
+ export async function discoverLocalCliSessions(options = {}) {
25
+ const prevHome = activeHome
26
+ activeHome = options && options.home ? String(options.home) : null
27
+ try {
28
+ return await discoverLocalCliSessionsInner()
29
+ } finally {
30
+ activeHome = prevHome
31
+ }
32
+ }
33
+
34
+ async function discoverLocalCliSessionsInner() {
21
35
  const detected = {}
22
36
 
23
37
  // 1. Codex CLI
@@ -144,7 +158,7 @@ export async function discoverLocalCliSessions() {
144
158
  }
145
159
 
146
160
  // 8. Claude CLI Direct Import
147
- const claudeCreds = await readClaudeCredentials()
161
+ const claudeCreds = await readClaudeCredentials(activeHome)
148
162
  if (claudeCreds) {
149
163
  detected['claude-cli'] = {
150
164
  provider: 'claude',
@@ -186,13 +200,13 @@ export async function discoverLocalCliSessions() {
186
200
  return detected
187
201
  }
188
202
 
189
- export async function loadLocalCliBlob(provider) {
190
- const discovered = await discoverLocalCliSessions()
203
+ export async function loadLocalCliBlob(provider, options = {}) {
204
+ const discovered = await discoverLocalCliSessions(options)
191
205
  const info = discovered[provider]
192
206
  if (!info) throw new Error(`no local CLI session found for ${provider}`)
193
207
 
194
208
  if (provider === 'claude-cli' || (provider === 'claude' && info.provider === 'claude')) {
195
- const creds = await readClaudeCredentials()
209
+ const creds = await readClaudeCredentials(options && options.home)
196
210
  if (!creds) throw new Error('Claude CLI credentials not found')
197
211
  return {
198
212
  accessToken: creds.accessToken,
package/lib/index.js CHANGED
@@ -141,7 +141,7 @@ export function apply(ctx, config) {
141
141
  if (!cfg.ollamaFallback || !models.length) throw err
142
142
  const model = cfg.ollamaFallbackModel || models[0].id
143
143
  try { ctx.emit && ctx.emit('subscriptions.ollama-fallback', { provider, model, reason: err && err.code || 'EXHAUSTED' }) } catch {}
144
- try { ctx.log && ctx.log.warn && ctx.log.warn(`[dsh-subscriptions] ${provider}: all accounts exhausted (${err && err.code || 'EXHAUSTED'}), откат на ollama/${model}`) } catch {}
144
+ try { ctx.log && ctx.log.warn && ctx.log.warn(`[dsh-subscriptions] ${provider}: all accounts exhausted (${err && err.code || 'EXHAUSTED'}), falling back to ollama/${model}`) } catch {}
145
145
  try {
146
146
  recordHistory({
147
147
  provider: 'ollama',
@@ -17,7 +17,6 @@ export function registerAccountsRoutes(ctx, state) {
17
17
  refreshModels,
18
18
  refForSlot,
19
19
  resetCredits,
20
- subscriptions,
21
20
  } = state
22
21
 
23
22
 
@@ -19,7 +19,6 @@ export function registerOauthRoutes(ctx, state) {
19
19
  completeOAuth,
20
20
  fetchForRef,
21
21
  sweepPending,
22
- subscriptions,
23
22
  pmL,
24
23
  } = state
25
24
 
@@ -16,7 +16,6 @@ export function registerStatusRoutes(ctx, state) {
16
16
  store,
17
17
  history,
18
18
  diagnosticsReport,
19
- subscriptions,
20
19
  pmL,
21
20
  pmE,
22
21
  } = state
package/lib/routes.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { publicConfig } from './config-schema.js'
2
2
  import { PROVIDERS, displayName } from './refs.js'
3
- import { writeHtml, writeJson } from './http.js'
3
+ import { escapeHtml, writeHtml, writeJson } from './http.js'
4
4
  import { registerStatusRoutes } from './routes/status.js'
5
5
  import { registerOauthRoutes } from './routes/oauth.js'
6
6
  import { registerAccountsRoutes } from './routes/accounts.js'
@@ -11,7 +11,6 @@ export function registerRoutes(ctx, state) {
11
11
  accountsView,
12
12
  history,
13
13
  live,
14
- subscriptions,
15
14
  } = state
16
15
 
17
16
  async function configResponse() {
@@ -101,10 +100,3 @@ function esc(x){return String(x==null?'':x).replace(/&/g,'&amp;').replace(/</g,'
101
100
  registerAccountsRoutes(ctx, state)
102
101
  registerProxyRoutes(ctx, state)
103
102
  }
104
-
105
- export function escapeHtml(text) {
106
- return String(text)
107
- .replace(/&/g, '&amp;')
108
- .replace(/</g, '&lt;')
109
- .replace(/>/g, '&gt;')
110
- }
package/lib/ui-styles.js CHANGED
@@ -2,14 +2,14 @@ export const CSS =
2
2
  '.dsub-card{list-style:none;border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-3);border-radius:12px;transition:border-color .16s,background .16s}' +
3
3
  '.dsub-card:hover{border-color:var(--dsw-alias-label-dimmed)}' +
4
4
  '.dsub-cardOpen{background:var(--dsw-alias-bg-layer-2);border-color:var(--dsw-alias-label-dimmed)}' +
5
- '.dsub-header{appearance:none;width:100%;font:inherit;color:inherit;text-align:left;cursor:pointer;background:0 0;border:0;border-radius:12px;align-items:center;gap:12px;padding:14px 16px;display:flex}' +
5
+ '.dsub-header{appearance:none;width:100%;font:inherit;color:inherit;text-align:left;cursor:pointer;background:0 0;border:0;border-radius:12px;align-items:center;gap:12px;padding:18px 20px;display:flex}' +
6
6
  '.dsub-header:focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:-2px}' +
7
7
  '.dsub-headText{flex-direction:column;flex:1;gap:4px;min-width:0;display:flex}' +
8
- '.dsub-name{color:var(--dsw-alias-label-primary);font-size:15px;font-weight:600;line-height:1.4}' +
8
+ '.dsub-name{color:var(--dsw-alias-label-primary);font-size:16px;font-weight:600;line-height:1.4}' +
9
9
  '.dsub-description{color:var(--dsw-alias-label-secondary);font-size:13px}' +
10
10
  '.dsub-chev{color:var(--dsw-alias-label-tertiary);flex:none;transition:transform .16s}' +
11
11
  '.dsub-chevOpen{transform:rotate(180deg)}' +
12
- '.dsub-body{border-top:1px solid var(--dsw-alias-border-l2);margin:0 16px;padding-bottom:12px}' +
12
+ '.dsub-body{border-top:1px solid var(--dsw-alias-border-l2);margin:0 20px;padding-bottom:12px}' +
13
13
  '.dsub-block{margin-top:10px}' +
14
14
  '.dsub-row{display:flex;align-items:center;flex-wrap:wrap;gap:8px;margin-top:8px}' +
15
15
  '.dsub-grow{flex:1 1 180px;min-width:140px}' +
@@ -25,13 +25,13 @@ export const CSS =
25
25
  '.dsub-ok{font-size:12px;color:var(--dsw-alias-state-success-primary)}' +
26
26
  '.dsub-bad{font-size:12px;color:var(--dsw-alias-state-error-primary);overflow-wrap:anywhere;word-break:break-word;max-width:100%}' +
27
27
  '.dsub-hint{overflow-wrap:anywhere;word-break:break-word;max-width:100%}' +
28
- '.dsub-btn{appearance:none;font:inherit;cursor:pointer;border:1px solid var(--dsw-alias-border-l2);border-radius:8px;padding:5px 12px;font-size:12.5px;font-weight:500;background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-primary);transition:all .15s}' +
28
+ '.dsub-btn{appearance:none;font:inherit;cursor:pointer;border:1px solid var(--dsw-alias-border-l2);border-radius:8px;padding:7px 14px;font-size:13px;font-weight:500;background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-primary);transition:all .15s}' +
29
29
  '.dsub-btn:hover{background:var(--dsw-alias-bg-layer-1);border-color:var(--dsw-alias-label-dimmed)}' +
30
- '.dsub-inp{height:30px;border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-3);color:var(--dsw-alias-label-primary);border-radius:6px;padding:0 8px;font-size:12px;min-width:180px}' +
30
+ '.dsub-inp{height:36px;border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-primary);border-radius:8px;padding:0 12px;font-size:13px;min-width:180px}' +
31
31
  '.dsub-inp:focus{outline:none;border-color:var(--dsw-alias-brand-primary)}' +
32
- '.dsub-box{border:1px solid var(--dsw-alias-border-l2);border-radius:8px;padding:10px;margin-top:8px;background:var(--dsw-alias-bg-layer-2)}' +
32
+ '.dsub-box{border:1px solid var(--dsw-alias-border-l2);border-radius:8px;padding:12px 14px;margin-top:8px;background:var(--dsw-alias-bg-layer-2)}' +
33
33
  '.dsub-cooldown{display:inline-block;padding:2px 6px;border-radius:4px;background:var(--dsw-alias-state-warning-primary);color:#000;font-size:11px;font-weight:600}' +
34
- '.dsub-bar{position:relative;height:6px;border-radius:3px;background:var(--dsw-alias-bg-layer-1);overflow:hidden;flex:1;min-width:80px}' +
34
+ '.dsub-bar{position:relative;height:10px;border-radius:999px;background:var(--dsw-alias-bg-layer-1);overflow:hidden;flex:1;min-width:80px}' +
35
35
  '.dsub-barFill{height:100%;border-radius:3px;background:var(--dsw-alias-state-success-primary);transition:width .2s}' +
36
36
  '.dsub-barWarn .dsub-barFill{background:var(--dsw-alias-state-warning-primary)}' +
37
37
  '.dsub-barFull .dsub-barFill{background:var(--dsw-alias-state-error-primary)}' +
@@ -62,11 +62,11 @@ export const CSS =
62
62
  '.dsub-heroModel{font-size:11.5px;color:var(--dsw-alias-label-secondary);font-family:ui-monospace,monospace}' +
63
63
  '.dsub-heroBars{display:flex;flex-direction:column;gap:8px;margin-top:10px}' +
64
64
  '.dsub-barLabelRow{display:flex;justify-content:space-between;font-size:11.5px;color:var(--dsw-alias-label-secondary);margin-bottom:3px}' +
65
- '.dsub-barTrack{height:6px;border-radius:3px;background:var(--dsw-alias-bg-layer-1);overflow:hidden}' +
65
+ '.dsub-barTrack{height:10px;border-radius:999px;background:var(--dsw-alias-bg-layer-1);overflow:hidden}' +
66
66
  '.dsub-barFillGrad{height:100%;border-radius:3px;transition:width .3s ease}' +
67
- '.dsub-panel-box{margin-top:10px;padding:12px 14px;border-radius:10px;border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-2);display:flex;flex-direction:column;gap:8px}' +
67
+ '.dsub-panel-box{margin-top:10px;padding:14px 16px;border-radius:10px;border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-2);display:flex;flex-direction:column;gap:8px}' +
68
68
  '.dsub-panel-warn{border-color:color-mix(in srgb,var(--dsw-alias-state-warning-primary) 40%,transparent);background:color-mix(in srgb,var(--dsw-alias-state-warning-primary) 5%,transparent)}' +
69
- '.dsub-panel-title{font-size:13px;font-weight:600;color:var(--dsw-alias-label-primary);display:flex;align-items:center;justify-content:space-between}' +
69
+ '.dsub-panel-title{font-size:16px;font-weight:600;color:var(--dsw-alias-label-primary);display:flex;align-items:center;justify-content:space-between}' +
70
70
  '.dsub-panel-desc{font-size:12px;color:var(--dsw-alias-label-secondary);line-height:1.4}' +
71
71
  '.dsub-usage-grid{display:flex;flex-direction:column;gap:8px;margin-top:4px}' +
72
72
  '.dsub-usage-card{display:flex;flex-direction:column;gap:5px;padding:8px 10px;border:1px solid var(--dsw-alias-border-l2);border-radius:8px;background:var(--dsw-alias-bg-layer-1)}' +
@@ -74,9 +74,9 @@ export const CSS =
74
74
  '.dsub-usage-track{width:100%;height:8px;border-radius:999px;background:var(--dsw-alias-bg-layer-3);border:1px solid var(--dsw-alias-border-l2);overflow:hidden}' +
75
75
  '.dsub-usage-fill{height:100%;border-radius:999px;transition:width .3s ease}' +
76
76
  '.dsub-usage-meta{display:flex;justify-content:space-between;align-items:center;font-size:11px;color:var(--dsw-alias-label-secondary)}' +
77
- '.dsub-tag-ok{border-color:color-mix(in srgb,var(--dsw-alias-state-success-primary) 30%,transparent);color:var(--dsw-alias-state-success-primary);background:color-mix(in srgb,var(--dsw-alias-state-success-primary) 8%,transparent);padding:1px 7px;border-radius:999px;font-size:11px;font-weight:600;border-style:solid;border-width:1px}' +
78
- '.dsub-tag-warn{border-color:color-mix(in srgb,var(--dsw-alias-state-warning-primary) 40%,transparent);color:var(--dsw-alias-state-warning-primary);background:color-mix(in srgb,var(--dsw-alias-state-warning-primary) 8%,transparent);padding:1px 7px;border-radius:999px;font-size:11px;font-weight:600;border-style:solid;border-width:1px}' +
79
- '.dsub-tag-bad{border-color:color-mix(in srgb,var(--dsw-alias-state-error-primary) 40%,transparent);color:var(--dsw-alias-state-error-primary);background:color-mix(in srgb,var(--dsw-alias-state-error-primary) 8%,transparent);padding:1px 7px;border-radius:999px;font-size:11px;font-weight:600;border-style:solid;border-width:1px}' +
77
+ '.dsub-tag-ok{border-color:var(--dsw-alias-state-success-primary);color:var(--dsw-alias-state-success-primary);background:color-mix(in srgb,var(--dsw-alias-state-success-primary) 8%,transparent);padding:3px 10px;border-radius:999px;font-size:12px;font-weight:600;border-style:solid;border-width:1px}' +
78
+ '.dsub-tag-warn{border-color:var(--dsw-alias-state-warning-primary);color:var(--dsw-alias-state-warning-primary);background:color-mix(in srgb,var(--dsw-alias-state-warning-primary) 8%,transparent);padding:3px 10px;border-radius:999px;font-size:12px;font-weight:600;border-style:solid;border-width:1px}' +
79
+ '.dsub-tag-bad{border-color:var(--dsw-alias-state-error-primary);color:var(--dsw-alias-state-error-primary);background:color-mix(in srgb,var(--dsw-alias-state-error-primary) 8%,transparent);padding:3px 10px;border-radius:999px;font-size:12px;font-weight:600;border-style:solid;border-width:1px}' +
80
80
  '.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}' +
81
81
  '.dsub-ref-tag{display:inline-flex;align-items:center;gap:5px;font-size:11.5px;color:var(--dsw-alias-label-secondary)}' +
82
82
  '.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}' +
@@ -2,8 +2,12 @@ import { readFile } from 'node:fs/promises'
2
2
  import { homedir } from 'node:os'
3
3
  import { join } from 'node:path'
4
4
 
5
- export async function readClaudeCredentials() {
6
- const p = join(homedir(), '.claude', 'credentials.json')
5
+ /**
6
+ * Read Claude Code CLI credentials. `home` is a test seam: when omitted the
7
+ * real user home is used, so production behaviour is unchanged.
8
+ */
9
+ export async function readClaudeCredentials(home) {
10
+ const p = join(home || homedir(), '.claude', 'credentials.json')
7
11
  try {
8
12
  const text = await readFile(p, 'utf8')
9
13
  const json = JSON.parse(text)
@@ -16,6 +16,11 @@ export const CODY_MODELS = [
16
16
  }
17
17
  ]
18
18
 
19
+ /** Catalog id -> Sourcegraph model name. */
20
+ export const CODY_MODEL_ALIASES = {
21
+ 'cody-claude-3.5-sonnet': 'claude-3-5-sonnet',
22
+ }
23
+
19
24
  export function providerInfo() {
20
25
  return { id, name: 'Sourcegraph Cody' }
21
26
  }
@@ -50,8 +55,14 @@ export async function* streamOnce({ blob, options, fetchImpl, headers, config, s
50
55
  if (!token) throw new LlmError('Sourcegraph Cody not authenticated', 'AUTH')
51
56
 
52
57
  const url = `${(config && config.apiBase) || CODY_API_BASE}/chat/completions`
58
+ // Catalog ids carry a cody- prefix and dotted versions; the Sourcegraph
59
+ // API expects the bare dashed name (cody-claude-3.5-sonnet -> claude-3-5-sonnet).
60
+ const requested = String(options.model || '').trim()
61
+ const mapped = CODY_MODEL_ALIASES[requested]
62
+ || requested.replace(/^cody-/, '').replace(/\./g, '-')
63
+ || 'claude-3-5-sonnet'
53
64
  const body = {
54
- model: 'claude-3-5-sonnet',
65
+ model: mapped,
55
66
  messages: openaiMessages(options),
56
67
  stream: true
57
68
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-subscriptions",
3
- "version": "0.6.2",
3
+ "version": "0.6.4",
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",
@@ -34,6 +34,9 @@
34
34
  "bugs": {
35
35
  "url": "https://github.com/GooDAnDReaDY/dsh-subscriptions/issues"
36
36
  },
37
+ "scripts": {
38
+ "test": "eslint lib/ && node --test test/*.test.mjs"
39
+ },
37
40
  "dsh": {
38
41
  "bundle": {
39
42
  "patch": "./cordis.patch.yml"
@@ -60,8 +63,5 @@
60
63
  },
61
64
  "devDependencies": {
62
65
  "@deepseek-ai/dsh-llm": "0.1.0-rc.8"
63
- },
64
- "scripts": {
65
- "test": "eslint lib/ && node --test test/*.test.mjs"
66
66
  }
67
- }
67
+ }
@@ -1,35 +0,0 @@
1
- import { open, unlink } from 'node:fs/promises'
2
-
3
- export async function withFileLock(filePath, fn, timeoutMs = 5000) {
4
- const lockPath = `${filePath}.lock`
5
- const start = Date.now()
6
- let handle = null
7
-
8
- while (Date.now() - start < timeoutMs) {
9
- try {
10
- handle = await open(lockPath, 'wx')
11
- break
12
- } catch (err) {
13
- if (err.code === 'EEXIST') {
14
- await new Promise((r) => setTimeout(r, 40))
15
- continue
16
- }
17
- throw err
18
- }
19
- }
20
-
21
- if (!handle) {
22
- throw new Error(`Failed to acquire lock on ${lockPath} after ${timeoutMs}ms`)
23
- }
24
-
25
- try {
26
- return await fn()
27
- } finally {
28
- try {
29
- await handle.close()
30
- await unlink(lockPath)
31
- } catch {
32
- // ignore unlock errors
33
- }
34
- }
35
- }
@@ -1,23 +0,0 @@
1
- export function estimateTokens(text) {
2
- if (!text || typeof text !== 'string') return 0
3
- // Average English: ~4 chars per token; CJK/Code: ~1.5 - 2 chars per token
4
- const hasCjk = /[\u4e00-\u9fa5]/.test(text)
5
- const ratio = hasCjk ? 2 : 3.8
6
- return Math.max(1, Math.ceil(text.length / ratio))
7
- }
8
-
9
- export function validateContextLimit(messages, contextWindowLimit = 128000) {
10
- let totalChars = 0
11
- for (const m of messages || []) {
12
- const content = typeof m.content === 'string' ? m.content : JSON.stringify(m.content || '')
13
- totalChars += content.length
14
- }
15
- const estimated = estimateTokens(totalChars > 0 ? 'x'.repeat(totalChars) : '')
16
- const safe = estimated <= contextWindowLimit * 0.95
17
- return {
18
- estimatedTokens: estimated,
19
- limit: contextWindowLimit,
20
- isSafe: safe,
21
- marginTokens: Math.max(0, Math.floor(contextWindowLimit * 0.95 - estimated))
22
- }
23
- }
package/lib/revocation.js DELETED
@@ -1,25 +0,0 @@
1
- const REVOKED_ERRORS = new Set([
2
- 'invalid_grant',
3
- 'token_revoked',
4
- 'account_deactivated',
5
- 'session_expired'
6
- ])
7
-
8
- export function isTokenRevokedError(err) {
9
- if (!err) return false
10
- const msg = String(err.message || err.error || err.code || '').toLowerCase()
11
- for (const pattern of REVOKED_ERRORS) {
12
- if (msg.includes(pattern)) return true
13
- }
14
- return false
15
- }
16
-
17
- export function handleAccountRevocation(account) {
18
- return {
19
- ...account,
20
- hasToken: false,
21
- status: 'revoked',
22
- needsReauth: true,
23
- revokedAt: Date.now()
24
- }
25
- }