@goodandready/dsh-subscriptions 0.6.72 → 0.6.83

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.
Files changed (49) hide show
  1. package/README.md +8 -0
  2. package/README.ru.md +8 -0
  3. package/README.zh.md +8 -0
  4. package/lib/accounts-view.js +97 -5
  5. package/lib/accounts.js +137 -11
  6. package/lib/adapter.js +3 -0
  7. package/lib/alerts.js +35 -0
  8. package/lib/analytics.js +31 -2
  9. package/lib/billing.js +99 -0
  10. package/lib/blob.js +22 -0
  11. package/lib/capabilities.js +208 -0
  12. package/lib/client.js +344 -17
  13. package/lib/config-schema.js +50 -4
  14. package/lib/diagnostic-wizard.js +2 -5
  15. package/lib/diagnostics.js +2 -2
  16. package/lib/error-buffer.js +2 -2
  17. package/lib/forecast.js +56 -0
  18. package/lib/headless-auth.js +392 -0
  19. package/lib/history.js +36 -0
  20. package/lib/http.js +67 -4
  21. package/lib/import-auth.js +3 -6
  22. package/lib/index.js +99 -11
  23. package/lib/jwt.js +84 -17
  24. package/lib/loopback.js +3 -2
  25. package/lib/mask.js +16 -0
  26. package/lib/oauth-pending.js +12 -0
  27. package/lib/pricing.js +89 -0
  28. package/lib/refs.js +2 -0
  29. package/lib/reset-clock.js +55 -0
  30. package/lib/rotate.js +18 -4
  31. package/lib/routes/accounts.js +142 -7
  32. package/lib/routes/oauth.js +51 -2
  33. package/lib/routes/proxy.js +14 -0
  34. package/lib/scope-inspector.js +51 -0
  35. package/lib/stream-rotate.js +31 -10
  36. package/lib/subscriptions.js +24 -1
  37. package/lib/updater.js +21 -0
  38. package/lib/vault-sync.js +414 -0
  39. package/lib/vault.js +1 -1
  40. package/lib/vendor-factory.js +6 -2
  41. package/lib/vendors/antigravity.js +13 -4
  42. package/lib/vendors/claude.js +56 -8
  43. package/lib/vendors/codex.js +34 -6
  44. package/lib/vendors/copilot-cli.js +36 -5
  45. package/lib/vendors/ernie.js +9 -2
  46. package/lib/vendors/grok.js +66 -6
  47. package/lib/vendors/jetbrains.js +136 -13
  48. package/lib/vendors/perplexity.js +151 -17
  49. package/package.json +1 -1
package/lib/http.js CHANGED
@@ -79,7 +79,34 @@ export function isLoopback(value) {
79
79
 
80
80
 
81
81
 
82
+ function hostNameOf(hostHeader) {
83
+ const raw = String(Array.isArray(hostHeader) ? (hostHeader[0] || '') : (hostHeader || '')).trim().toLowerCase()
84
+ if (!raw) return ''
85
+ if (raw === '::1') return '::1'
86
+ if (raw.startsWith('[')) {
87
+ const end = raw.indexOf(']')
88
+ return end > 0 ? raw.slice(0, end + 1) : raw
89
+ }
90
+ return raw.split(':')[0]
91
+ }
92
+
93
+ function configuredTrustedHosts(targetCtx) {
94
+ const out = new Set()
95
+ for (const part of String(process.env.DSH_TRUSTED_HOSTS || '').split(',')) {
96
+ const host = hostNameOf(part)
97
+ if (host) out.add(host)
98
+ }
99
+ try {
100
+ const url = targetCtx && targetCtx.webServer && targetCtx.webServer.url
101
+ if (url) out.add(hostNameOf(new URL(url).host))
102
+ } catch { /* ignore malformed web server url */ }
103
+ return out
104
+ }
105
+
82
106
  /**
107
+ * @param {import('node:http').IncomingMessage} request
108
+ * @param {any} [targetCtx]
109
+ * @returns {boolean}/**
83
110
  * @param {import('node:http').IncomingMessage} request
84
111
  * @param {any} [targetCtx]
85
112
  * @returns {boolean}
@@ -103,7 +130,17 @@ export function isTrustedSettingsRequest(request, targetCtx) {
103
130
  if (!host || u.host.toLowerCase() !== host.toLowerCase()) {
104
131
  return false
105
132
  }
106
- } else if (u.protocol !== 'dsh-app:') {
133
+ } else if (u.protocol === 'dsh-app:') {
134
+ const appHost = u.host.toLowerCase()
135
+ if (appHost && appHost !== 'app' && appHost !== 'localhost') {
136
+ return false
137
+ }
138
+ const remoteAddr = request?.socket?.remoteAddress || request?.connection?.remoteAddress
139
+ const hasRemote = Boolean(remoteAddr || request?.socket || request?.connection)
140
+ if (hasRemote && !isLoopback(request)) {
141
+ return false
142
+ }
143
+ } else {
107
144
  return false
108
145
  }
109
146
  } catch {
@@ -120,7 +157,17 @@ export function isTrustedSettingsRequest(request, targetCtx) {
120
157
  if (!host || u.host.toLowerCase() !== host.toLowerCase()) {
121
158
  return false
122
159
  }
123
- } else if (u.protocol !== 'dsh-app:') {
160
+ } else if (u.protocol === 'dsh-app:') {
161
+ const appHost = u.host.toLowerCase()
162
+ if (appHost && appHost !== 'app' && appHost !== 'localhost') {
163
+ return false
164
+ }
165
+ const remoteAddr = request?.socket?.remoteAddress || request?.connection?.remoteAddress
166
+ const hasRemote = Boolean(remoteAddr || request?.socket || request?.connection)
167
+ if (hasRemote && !isLoopback(request)) {
168
+ return false
169
+ }
170
+ } else {
124
171
  return false
125
172
  }
126
173
  } catch {
@@ -128,6 +175,20 @@ export function isTrustedSettingsRequest(request, targetCtx) {
128
175
  }
129
176
  }
130
177
 
178
+ // 4a. Loopback peers must present a loopback or configured Host.
179
+ // A loopback socket with a foreign Host is a DNS-rebinding attempt.
180
+ const peerAddress = request?.socket?.remoteAddress || request?.connection?.remoteAddress
181
+ if (peerAddress && isLoopback(peerAddress)) {
182
+ const host = hostNameOf(headers.host)
183
+ const loopbackHost = host === ''
184
+ || host === 'localhost'
185
+ || host === 'localhost.'
186
+ || host === '127.0.0.1'
187
+ || host === '::1'
188
+ || host === '[::1]'
189
+ if (!loopbackHost && !configuredTrustedHosts(targetCtx).has(host)) return false
190
+ }
191
+
131
192
  // 4. Delegate to DSH connection service if available (#374)
132
193
  let conn = null
133
194
  try {
@@ -177,7 +238,8 @@ export function isTrustedSettingsRequest(request, targetCtx) {
177
238
  if (u.protocol === 'http:' || u.protocol === 'https:') {
178
239
  if (u.host.toLowerCase() === host.toLowerCase()) return true
179
240
  } else if (u.protocol === 'dsh-app:') {
180
- return true
241
+ const appHost = u.host.toLowerCase()
242
+ if (!appHost || appHost === 'app' || appHost === 'localhost') return true
181
243
  }
182
244
  } catch { /* bestEffort */ }
183
245
  }
@@ -187,7 +249,8 @@ export function isTrustedSettingsRequest(request, targetCtx) {
187
249
  if (u.protocol === 'http:' || u.protocol === 'https:') {
188
250
  if (u.host.toLowerCase() === host.toLowerCase()) return true
189
251
  } else if (u.protocol === 'dsh-app:') {
190
- return true
252
+ const appHost = u.host.toLowerCase()
253
+ if (!appHost || appHost === 'app' || appHost === 'localhost') return true
191
254
  }
192
255
  } catch { /* bestEffort */ }
193
256
  }
@@ -6,7 +6,7 @@ import { readClaudeCredentials } from './vendors/claude-cli.js'
6
6
  import { readCopilotCredentials } from './vendors/copilot-cli.js'
7
7
  import { normalizeExpiresAt } from './blob.js'
8
8
  import { isGoogleClientId, extractGoogleClientIdFromJwt, emailFromToken } from './jwt.js'
9
- import { DEFAULT_CLI_CLIENT_ID, DEFAULT_CLI_CLIENT_SECRET } from './vendors/antigravity.js'
9
+
10
10
 
11
11
  // Test seam: a fixture root overrides the real home for the duration of a
12
12
  // public call. Undefined (the default) keeps production behaviour.
@@ -146,7 +146,8 @@ async function discoverLocalCliSessionsInner() {
146
146
  }
147
147
  }
148
148
 
149
- if (!detected.antigravity && (process.platform === 'win32' || activeWindowsCredentialReader)) {
149
+ const canReadWindowsCredentialManager = Boolean(activeWindowsCredentialReader) || (!activeHome && process.platform === 'win32')
150
+ if (!detected.antigravity && canReadWindowsCredentialManager) {
150
151
  const winCred = readWindowsAntigravityCredential()
151
152
  const tok = winCred && winCred.token ? winCred.token : winCred
152
153
  if (tok && (tok.access_token || tok.accessToken)) {
@@ -453,10 +454,6 @@ export async function loadLocalCliBlob(provider, options = {}) {
453
454
  raw.installed?.client_secret || raw.web?.client_secret || ''
454
455
  let clientSecret = typeof directSecret === 'string' ? directSecret.trim() : ''
455
456
 
456
- if (!clientSecret && clientId === DEFAULT_CLI_CLIENT_ID) {
457
- clientSecret = DEFAULT_CLI_CLIENT_SECRET
458
- }
459
-
460
457
  const rawExpiry = tok.expiry ?? tok.expires_at ?? tok.expiresAt ?? tok.expiry_date ?? raw.expiry_date ?? raw.expiry ?? (tok.expires_in ? Date.now() + Number(tok.expires_in) * 1000 : 0)
461
458
  const expiresAt = normalizeExpiresAt(rawExpiry)
462
459
 
package/lib/index.js CHANGED
@@ -2,6 +2,7 @@ import { plainConfig } from './config-schema.js'
2
2
  export { plainConfig } from './config-schema.js'
3
3
  import { readFileSync } from 'node:fs'
4
4
  import { bestEffort } from './utils.js'
5
+ import { sweepExpiredPending } from './oauth-pending.js'
5
6
  export { Config, publicConfig, Slot, defaultSlots } from './config-schema.js'
6
7
  import { Config } from './config-schema.js'
7
8
  import { registerRoutes } from './routes.js'
@@ -21,8 +22,10 @@ import { proxyFetch } from './proxy.js'
21
22
  import { HistoryStore, resolveHistoryDir } from './history.js'
22
23
  import { createDiagnosticsReport } from './diagnostics.js'
23
24
  import { createAccountsView } from './accounts-view.js'
25
+ import { inspectScopes } from './scope-inspector.js'
24
26
  import { createAdapterManager } from './adapter-manager.js'
25
- import { pickAccount } from './rotate.js'
27
+ import { pickAccount, isAuthError } from './rotate.js'
28
+ import { notifySessionExpired } from './alerts.js'
26
29
 
27
30
  export { observeForecast, estimateForecast } from './forecast.js'
28
31
  export { formatRelativeReset } from './relative-time.js'
@@ -31,6 +34,9 @@ export { exportVault, importVault } from './vault.js'
31
34
  export { recordAlert, getRecentAlerts, checkQuotaThresholds, notifySessionExpired } from './alerts.js'
32
35
  export { calculateBurnRatePerHour, computePacingRisk } from './forecast.js'
33
36
  export { needsWarmupProbe, enterProbing, resolveWarmupSuccess, resolveWarmupFailure } from './quarantine.js'
37
+ export { encodeQrMatrix, renderAsciiQr, renderSvgQr, renderSvgDataUrl, generateSshTunnelCommand, getHeadlessAuthInfo, handleHeadlessRedirect } from './headless-auth.js'
38
+ export { pushToWebdav, pullFromWebdav, pushToGist, pullFromGist, buildS3SigV4Headers, pushToS3, pullFromS3, syncVaultPush, syncVaultPull, getVaultSyncStatus, resetVaultSyncStatus } from './vault-sync.js'
39
+ import { syncVaultPush, syncVaultPull, getVaultSyncStatus } from './vault-sync.js'
34
40
 
35
41
  export const name = '@goodandready/dsh-subscriptions'
36
42
  export const inject = ['llm', 'credentials', 'webServer', 'settings']
@@ -129,6 +135,7 @@ export function apply(ctx, rawConfig) {
129
135
 
130
136
  const settingsApi = {
131
137
  get: () => live(),
138
+ revision: () => describeRow()?.revision,
132
139
  replace: async (next, expectedRevision) => {
133
140
  const parsed = plainConfig(next)
134
141
  const prevConfig = cachedConfig
@@ -380,11 +387,15 @@ export function apply(ctx, rawConfig) {
380
387
  const pending = new Map()
381
388
 
382
389
  function sweepPending(now) {
383
- for (const [stateKey, row] of pending) {
384
- if (now - row.createdAt > PENDING_TTL_MS) pending.delete(stateKey)
385
- }
390
+ sweepExpiredPending(pending, now, PENDING_TTL_MS)
386
391
  }
387
392
 
393
+ ctx.effect(() => {
394
+ const id = setInterval(() => sweepPending(Date.now()), 60 * 1000)
395
+ if (id && typeof id.unref === 'function') id.unref()
396
+ return () => clearInterval(id)
397
+ }, 'dsh-subscriptions: pending oauth sweep')
398
+
388
399
  async function completeOAuth({ provider, index, code, state }) {
389
400
  if (!state) throw new Error('missing oauth state')
390
401
  sweepPending(Date.now())
@@ -401,14 +412,26 @@ export function apply(ctx, rawConfig) {
401
412
  challenge: row.challenge,
402
413
  state: row.state,
403
414
  }, code, fetchForRef(ref) || fetch)
415
+ const inspection = inspectScopes(actualProvider, blob.scopes)
416
+ blob.scopes = inspection.scopes
417
+ blob.scopeWarning = inspection.scopeWarning || ""
404
418
  const slots = normalizeSlots(live().slots)
405
419
  const slot = slots.find((s) => s.ref === ref)
406
420
  if (slot && slot.label) blob.label = slot.label
407
- await store.saveBlob(ref, blob)
421
+ let existing = {}
422
+ try { existing = await store.loadBlob(ref) } catch { /* no existing blob on fresh auth */ }
423
+ const merged = { ...existing, ...blob }
424
+ await store.saveBlob(ref, merged)
425
+ store.recordSuccess(ref)
426
+ store.clearRefreshFailure(ref)
427
+ store.clearCooldown(ref)
408
428
  pending.delete(row.state)
409
429
  await syncAdapter()
410
430
  refreshModels().catch(() => {})
411
- return { ref, label: pmL(blob.label || blob.email) || displayName(actualProvider) }
431
+ if (live().vaultSyncEnabled && live().vaultSyncAutoPush) {
432
+ syncVaultPush({ live: live(), store }).catch(() => {})
433
+ }
434
+ return { ref, label: pmL(merged.label || merged.email) || displayName(actualProvider) }
412
435
  }
413
436
 
414
437
  // Privacy masking
@@ -449,7 +472,7 @@ export function apply(ctx, rawConfig) {
449
472
  }
450
473
 
451
474
  const diagnosticsReport = () => createDiagnosticsReport({ live, store, history, NS, pkgVersion, heartbeatStats })
452
- const accountsView = () => createAccountsView({ live, store, fetch, pmL })
475
+ const accountsView = () => createAccountsView({ live, store, fetch, pmL, pmE, history })
453
476
 
454
477
  // Ponytail: background refresh ahead of expiry, single timer
455
478
  ctx.effect(() => {
@@ -473,15 +496,51 @@ export function apply(ctx, rawConfig) {
473
496
  if (!raw) continue
474
497
  const blob = await store.loadBlob(ref).catch(() => null)
475
498
  if (!blob || !blob.refreshToken) continue
476
- const fresh = await store.ensureFresh(slot.provider, blob, ref).catch(() => null)
499
+ let fresh
500
+ try {
501
+ fresh = await store.ensureFresh(slot.provider, blob, ref)
502
+ } catch (refreshErr) {
503
+ if (isAuthError(refreshErr)) {
504
+ store.recordBroken(ref)
505
+ store.rememberQuarantine(ref, "SESSION_REVOKED", Date.now() + 86400000, 1)
506
+ try {
507
+ notifySessionExpired({
508
+ ref,
509
+ provider: slot.provider,
510
+ message: refreshErr && refreshErr.message,
511
+ webhookUrl: cfg.webhookUrl,
512
+ fetchImpl: fetch,
513
+ nowMs: Date.now(),
514
+ })
515
+ } catch { /* alert best-effort */ }
516
+ }
517
+ continue
518
+ }
477
519
  if (!fresh) continue
478
520
  const vendor = getVendor(slot.provider)
479
521
  if (typeof vendor.check !== 'function') continue
480
522
  const cfg2 = vendorConfig(slot.provider, live())
481
523
  const probeFetch = async (u, i) => ((typeof fetchForRef === 'function' && fetchForRef(ref)) || fetch)(u, i)
482
- await vendor.check(fresh, cfg2, probeFetch).catch((e) => {
483
- if (logger && logger.warn) logger.warn('[dsh-subscriptions] probe ' + ref + ': ' + String(e && e.message || e).slice(0, 200))
484
- })
524
+ try {
525
+ await vendor.check(fresh, cfg2, probeFetch)
526
+ if (typeof store.rememberDoctorVerdict === 'function') store.rememberDoctorVerdict(ref, null)
527
+ } catch (checkErr) {
528
+ if (isAuthError(checkErr)) {
529
+ store.recordBroken(ref)
530
+ store.rememberQuarantine(ref, "SESSION_REVOKED", Date.now() + 86400000, 1)
531
+ try {
532
+ notifySessionExpired({
533
+ ref,
534
+ provider: slot.provider,
535
+ message: checkErr && checkErr.message,
536
+ webhookUrl: cfg.webhookUrl,
537
+ fetchImpl: fetch,
538
+ nowMs: Date.now(),
539
+ })
540
+ } catch { /* alert best-effort */ }
541
+ }
542
+ if (logger && logger.warn) logger.warn('[dsh-subscriptions] probe ' + ref + ': ' + String(checkErr && checkErr.message || checkErr).slice(0, 200))
543
+ }
485
544
  } catch { /* best-effort */ }
486
545
  }
487
546
  }
@@ -499,6 +558,35 @@ export function apply(ctx, rawConfig) {
499
558
  return () => clearInterval(timer)
500
559
  }, 'dsh-subscriptions: probe loop')
501
560
 
561
+ // #562: Background automated remote vault sync loop
562
+ ctx.effect(() => {
563
+ const checkSync = async () => {
564
+ const cfg = live()
565
+ if (!cfg.vaultSyncEnabled || !cfg.vaultSyncIntervalHours || cfg.vaultSyncIntervalHours <= 0) return
566
+ const status = getVaultSyncStatus()
567
+ const now = Date.now()
568
+ const intervalMs = cfg.vaultSyncIntervalHours * 3600 * 1000
569
+ const lastPull = status.lastPullAt ? new Date(status.lastPullAt).getTime() : 0
570
+ if (now - lastPull >= intervalMs) {
571
+ try {
572
+ await syncVaultPull({
573
+ live: cfg,
574
+ store,
575
+ settingsApi: getSettingsApi(),
576
+ syncAdapter,
577
+ refreshModels,
578
+ })
579
+ } catch { /* status captures error */ }
580
+ }
581
+ }
582
+ const intervalId = setInterval(() => { checkSync().catch(() => {}) }, 60 * 60 * 1000)
583
+ const initialTimer = setTimeout(() => { checkSync().catch(() => {}) }, 30 * 1000)
584
+ return () => {
585
+ clearInterval(intervalId)
586
+ clearTimeout(initialTimer)
587
+ }
588
+ }, 'dsh-subscriptions: vault-sync-loop')
589
+
502
590
  registerRoutes(ctx, {
503
591
  NS,
504
592
  live,
package/lib/jwt.js CHANGED
@@ -1,9 +1,9 @@
1
- function decodeJwtPayload(token) {
2
- const raw = String(token || '')
3
- const parts = raw.split('.')
1
+ export function decodeJwtPayload(token) {
2
+ const raw = String(token || "")
3
+ const parts = raw.split(".")
4
4
  if (parts.length < 2) return null
5
5
  try {
6
- const json = Buffer.from(parts[1], 'base64url').toString('utf8')
6
+ const json = Buffer.from(parts[1], "base64url").toString("utf8")
7
7
  return JSON.parse(json)
8
8
  } catch {
9
9
  return null
@@ -12,42 +12,84 @@ function decodeJwtPayload(token) {
12
12
 
13
13
  export function chatgptAccountId(token) {
14
14
  const payload = decodeJwtPayload(token)
15
- if (!payload || typeof payload !== 'object') return ''
15
+ if (!payload || typeof payload !== "object") return ""
16
16
  if (payload.chatgpt_account_id) return String(payload.chatgpt_account_id)
17
- const nested = payload['https://api.openai.com/auth']
17
+ const nested = payload["https://api.openai.com/auth"]
18
18
  if (nested && nested.chatgpt_account_id) return String(nested.chatgpt_account_id)
19
+ if (nested && nested.user_id) return String(nested.user_id)
19
20
  const orgs = payload.organizations
20
21
  if (Array.isArray(orgs) && orgs[0] && orgs[0].id) return String(orgs[0].id)
21
- return ''
22
+ if (payload.sub) return String(payload.sub)
23
+ return ""
22
24
  }
23
25
 
24
26
  export function emailFromToken(token) {
25
27
  const payload = decodeJwtPayload(token)
26
- if (!payload) return ''
27
- return String(payload.email || payload.preferred_username || '')
28
+ if (!payload || typeof payload !== "object") return ""
29
+ if (payload.email) return String(payload.email)
30
+ const openaiProf = payload["https://api.openai.com/profile"]
31
+ if (openaiProf && openaiProf.email) return String(openaiProf.email)
32
+ if (payload.preferred_username) return String(payload.preferred_username)
33
+ if (payload.upn) return String(payload.upn)
34
+ return ""
35
+ }
36
+
37
+ export function planFromToken(token) {
38
+ const payload = decodeJwtPayload(token)
39
+ if (!payload || typeof payload !== "object") return ""
40
+ const nested = payload["https://api.openai.com/auth"]
41
+ if (nested && nested.chatgpt_plan_type) return String(nested.chatgpt_plan_type)
42
+ const anthropic = payload["https://api.anthropic.com"]
43
+ if (anthropic && anthropic.plan) return String(anthropic.plan)
44
+ if (payload.plan_type) return String(payload.plan_type)
45
+ if (payload.plan) return typeof payload.plan === "string" ? payload.plan : (payload.plan.name || "")
46
+ if (payload.subscription && payload.subscription.tier) return String(payload.subscription.tier)
47
+ if (payload.subscription_tier) return String(payload.subscription_tier)
48
+ if (payload.paidTierName) return String(payload.paidTierName)
49
+ return ""
50
+ }
51
+
52
+ export function avatarFromToken(token) {
53
+ const payload = decodeJwtPayload(token)
54
+ if (!payload || typeof payload !== "object") return ""
55
+ if (payload.picture && typeof payload.picture === "string") return payload.picture
56
+ if (payload.avatar_url && typeof payload.avatar_url === "string") return payload.avatar_url
57
+ if (payload.avatar && typeof payload.avatar === "string") return payload.avatar
58
+ const openaiProf = payload["https://api.openai.com/profile"]
59
+ if (openaiProf && openaiProf.picture && typeof openaiProf.picture === "string") return openaiProf.picture
60
+ return ""
61
+ }
62
+
63
+ export function scopesFromToken(token) {
64
+ const payload = decodeJwtPayload(token)
65
+ if (!payload || typeof payload !== "object") return []
66
+ const raw = payload.scope || payload.scp || ""
67
+ if (Array.isArray(raw)) return raw.map(String).filter(Boolean)
68
+ if (typeof raw === "string") return raw.split(/[\s,]+/).filter(Boolean)
69
+ return []
28
70
  }
29
71
 
30
72
  export function isGoogleClientId(str) {
31
- if (typeof str !== 'string') return false
73
+ if (typeof str !== "string") return false
32
74
  const trimmed = str.trim()
33
75
  return /^[0-9]+-[a-zA-Z0-9_-]+\.apps\.googleusercontent\.com$/.test(trimmed)
34
76
  }
35
77
 
36
78
  export function extractGoogleClientIdFromJwt(token) {
37
79
  const payload = decodeJwtPayload(token)
38
- if (!payload || typeof payload !== 'object') return ''
80
+ if (!payload || typeof payload !== "object") return ""
39
81
 
40
- const azp = typeof payload.azp === 'string' ? payload.azp.trim() : ''
82
+ const azp = typeof payload.azp === "string" ? payload.azp.trim() : ""
41
83
  const rawAud = payload.aud
42
84
 
43
85
  const audiences = Array.isArray(rawAud)
44
- ? rawAud.filter(a => typeof a === 'string').map(a => a.trim())
45
- : (typeof rawAud === 'string' && rawAud.trim() ? [rawAud.trim()] : [])
86
+ ? rawAud.filter(a => typeof a === "string").map(a => a.trim())
87
+ : (typeof rawAud === "string" && rawAud.trim() ? [rawAud.trim()] : [])
46
88
 
47
89
  if (azp) {
48
- if (!isGoogleClientId(azp)) return ''
90
+ if (!isGoogleClientId(azp)) return ""
49
91
  if (audiences.length > 0 && !audiences.includes(azp)) {
50
- return ''
92
+ return ""
51
93
  }
52
94
  return azp
53
95
  }
@@ -57,5 +99,30 @@ export function extractGoogleClientIdFromJwt(token) {
57
99
  return validAuds[0]
58
100
  }
59
101
 
60
- return ''
102
+ return ""
103
+ }
104
+
105
+ export function organizationsFromToken(token) {
106
+ const payload = decodeJwtPayload(token)
107
+ if (!payload || typeof payload !== "object") return []
108
+ const raw = payload.organizations ||
109
+ (payload["https://api.openai.com/auth"] && payload["https://api.openai.com/auth"].organizations) ||
110
+ payload.orgs ||
111
+ (payload["https://api.anthropic.com/auth"] && payload["https://api.anthropic.com/auth"].organizations) ||
112
+ []
113
+ if (!Array.isArray(raw)) return []
114
+ const out = []
115
+ for (const item of raw) {
116
+ if (typeof item === "string" && item.trim()) {
117
+ out.push({ id: item.trim(), name: item.trim() })
118
+ } else if (item && typeof item === "object") {
119
+ const id = String(item.id || item.org_id || item.uuid || "").trim()
120
+ const name = String(item.title || item.name || item.org_name || id).trim()
121
+ const role = item.role ? String(item.role).trim() : undefined
122
+ if (id) {
123
+ out.push({ id, name: name || id, ...(role ? { role } : {}) })
124
+ }
125
+ }
126
+ }
127
+ return out
61
128
  }
package/lib/loopback.js CHANGED
@@ -30,8 +30,9 @@ export function startLoopback({ redirectUri, timeoutMs = 10 * 60 * 1000, onCode
30
30
  const promise = new Promise((resolve, reject) => {
31
31
  const server = http.createServer((req, res) => {
32
32
  const url = new URL(req.url || '/', `http://127.0.0.1:${port}`)
33
- // The provider may redirect to a suffixed path (e.g. /auth/callback/extra)
34
- if (!url.pathname.startsWith(path)) {
33
+ // The provider may redirect to a suffixed path (e.g. /auth/callback/extra) (#545)
34
+ const matchesPath = url.pathname === path || (path.endsWith("/") ? url.pathname.startsWith(path) : url.pathname.startsWith(path + "/"));
35
+ if (!matchesPath) {
35
36
  res.writeHead(404, { 'Content-Type': 'text/html; charset=utf-8' })
36
37
  res.end(ERR_HTML)
37
38
  return
package/lib/mask.js CHANGED
@@ -20,6 +20,22 @@ export function maskText(value) {
20
20
  return String(value || '').replace(EMAIL_RE, (m) => maskEmail(m))
21
21
  }
22
22
 
23
+ const SECRET_PATTERNS = [
24
+ [/Bearer\s+[A-Za-z0-9._~+/-]+=*/gi, 'Bearer [REDACTED]'],
25
+ [/\b(?:sk|ghp|gho|ghu|ghs|ghr|github_pat|xox[baprs])[-_][A-Za-z0-9._-]{8,}/g, '[REDACTED]'],
26
+ [/\beyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+/g, '[REDACTED]'],
27
+ [/\b(access_token|refresh_token|token|key|secret|password)=([^&\s]+)/gi, '$1=[REDACTED]'],
28
+ ]
29
+
30
+ // #578: redact credentials as well as emails from free-text diagnostics.
31
+ export function maskSecrets(value) {
32
+ let text = maskText(value)
33
+ for (const [pattern, replacement] of SECRET_PATTERNS) {
34
+ text = text.replace(pattern, replacement)
35
+ }
36
+ return text
37
+ }
38
+
23
39
  export function cleanErrorMessage(raw) {
24
40
  if (!raw) return ''
25
41
  let text = typeof raw === 'string' ? raw : (raw.message || String(raw))
@@ -0,0 +1,12 @@
1
+ // #580: shared expiry sweep for in-memory OAuth pending rows.
2
+ export function sweepExpiredPending(pending, nowMs, ttlMs) {
3
+ let removed = 0
4
+ for (const [stateKey, row] of pending) {
5
+ const createdAt = Number(row && row.createdAt)
6
+ if (!Number.isFinite(createdAt) || Number(nowMs) - createdAt > Number(ttlMs)) {
7
+ pending.delete(stateKey)
8
+ removed += 1
9
+ }
10
+ }
11
+ return removed
12
+ }
package/lib/pricing.js ADDED
@@ -0,0 +1,89 @@
1
+ // Subscription Value Delivered & API Cost Equivalent Counter (#561)
2
+ // Translates raw prompt, completion and cache tokens into equivalent official API pricing,
3
+ // comparing delivered value against monthly subscription fee ($20/mo standard).
4
+
5
+ export const DEFAULT_SUBSCRIPTION_MONTHLY_COST = 20.0
6
+
7
+ export const TOKEN_RATES = {
8
+ 'claude-3-7-sonnet': { prompt: 3.0, completion: 15.0, cacheRead: 0.3 },
9
+ 'claude-3-5-sonnet': { prompt: 3.0, completion: 15.0, cacheRead: 0.3 },
10
+ 'claude-3-5-haiku': { prompt: 0.8, completion: 4.0, cacheRead: 0.08 },
11
+ 'claude-3-opus': { prompt: 15.0, completion: 75.0, cacheRead: 1.5 },
12
+ 'gpt-4o': { prompt: 2.5, completion: 10.0, cacheRead: 1.25 },
13
+ 'gpt-4o-mini': { prompt: 0.15, completion: 0.6, cacheRead: 0.075 },
14
+ 'o1': { prompt: 15.0, completion: 60.0, cacheRead: 7.5 },
15
+ 'o3-mini': { prompt: 1.1, completion: 4.4, cacheRead: 0.55 },
16
+ 'grok-2': { prompt: 2.0, completion: 10.0, cacheRead: 0.2 },
17
+ 'sonar-reasoning': { prompt: 5.0, completion: 25.0, cacheRead: 0.5 },
18
+ 'sonar-pro': { prompt: 3.0, completion: 15.0, cacheRead: 0.3 },
19
+ 'sonar': { prompt: 1.0, completion: 5.0, cacheRead: 0.1 },
20
+ 'gemini-1.5-pro': { prompt: 1.25, completion: 5.0, cacheRead: 0.3 },
21
+ 'gemini-2.0-flash': { prompt: 0.1, completion: 0.4, cacheRead: 0.025 },
22
+ 'default': { prompt: 2.5, completion: 10.0, cacheRead: 0.5 },
23
+ }
24
+
25
+ /**
26
+ * Resolves standard token rates per 1M tokens for a given model identifier.
27
+ * @param {string} [modelId]
28
+ * @returns {{ prompt: number, completion: number, cacheRead: number }}
29
+ */
30
+ export function lookupTokenRate(modelId = '') {
31
+ const m = String(modelId || '').toLowerCase()
32
+ for (const [key, rates] of Object.entries(TOKEN_RATES)) {
33
+ if (key !== 'default' && m.includes(key)) {
34
+ return rates
35
+ }
36
+ }
37
+ return TOKEN_RATES.default
38
+ }
39
+
40
+ /**
41
+ * Calculates dollar cost equivalent for the given token quantities.
42
+ * @param {number} promptTokens
43
+ * @param {number} completionTokens
44
+ * @param {number} [cacheReadTokens]
45
+ * @param {string} [modelId]
46
+ * @returns {number}
47
+ */
48
+ export function calculateEquivalentCost(promptTokens = 0, completionTokens = 0, cacheReadTokens = 0, modelId = 'default') {
49
+ const rates = lookupTokenRate(modelId)
50
+ const p = (Number(promptTokens) || 0) * (rates.prompt / 1_000_000)
51
+ const c = (Number(completionTokens) || 0) * (rates.completion / 1_000_000)
52
+ const cr = (Number(cacheReadTokens) || 0) * (rates.cacheRead / 1_000_000)
53
+ return Math.round((p + c + cr) * 100) / 100
54
+ }
55
+
56
+ /**
57
+ * Computes value delivered metrics and ROI ratio for an account.
58
+ * @param {object} params
59
+ * @param {number} [params.promptTokens]
60
+ * @param {number} [params.completionTokens]
61
+ * @param {number} [params.cacheReadTokens]
62
+ * @param {string} [params.modelId]
63
+ * @param {number} [params.subscriptionCost]
64
+ * @returns {object}
65
+ */
66
+ export function calculateValueDelivered({
67
+ promptTokens = 0,
68
+ completionTokens = 0,
69
+ cacheReadTokens = 0,
70
+ modelId = 'default',
71
+ subscriptionCost = DEFAULT_SUBSCRIPTION_MONTHLY_COST,
72
+ } = {}) {
73
+ const equivalentCost = calculateEquivalentCost(promptTokens, completionTokens, cacheReadTokens, modelId)
74
+ const subCost = Number(subscriptionCost) > 0 ? Number(subscriptionCost) : DEFAULT_SUBSCRIPTION_MONTHLY_COST
75
+ const roiMultiplier = Math.round((equivalentCost / subCost) * 10) / 10
76
+ const roiLabel = `${roiMultiplier}x`
77
+
78
+ return {
79
+ equivalentCost,
80
+ formattedCost: `$${equivalentCost.toFixed(2)}`,
81
+ subscriptionCost: subCost,
82
+ formattedSubscriptionCost: `$${subCost.toFixed(2)}`,
83
+ roiMultiplier,
84
+ roiLabel,
85
+ promptTokens: Number(promptTokens) || 0,
86
+ completionTokens: Number(completionTokens) || 0,
87
+ cacheReadTokens: Number(cacheReadTokens) || 0,
88
+ }
89
+ }
package/lib/refs.js CHANGED
@@ -56,6 +56,8 @@ const DISPLAY = {
56
56
  cursor: 'Cursor',
57
57
  kiro: 'AWS Kiro',
58
58
  copilot: 'GitHub Copilot',
59
+ perplexity: 'Perplexity Pro',
60
+ jetbrains: 'JetBrains AI',
59
61
  }
60
62
 
61
63
  export function displayName(provider) {