@goodandready/dsh-subscriptions 0.6.71 → 0.6.82

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 (50) hide show
  1. package/README.md +13 -0
  2. package/README.ru.md +13 -0
  3. package/README.zh.md +13 -0
  4. package/lib/accounts-view.js +101 -5
  5. package/lib/accounts.js +150 -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 +18 -0
  11. package/lib/capabilities.js +208 -0
  12. package/lib/client.js +944 -828
  13. package/lib/config-schema.js +46 -4
  14. package/lib/diagnostic-wizard.js +2 -5
  15. package/lib/diagnostics.js +2 -2
  16. package/lib/doctor.js +163 -0
  17. package/lib/error-buffer.js +2 -2
  18. package/lib/forecast.js +56 -0
  19. package/lib/headless-auth.js +379 -0
  20. package/lib/history.js +36 -0
  21. package/lib/http.js +26 -4
  22. package/lib/index.js +98 -11
  23. package/lib/instructions.js +138 -0
  24. package/lib/jwt.js +84 -17
  25. package/lib/loopback.js +3 -2
  26. package/lib/mask.js +16 -0
  27. package/lib/oauth-pending.js +12 -0
  28. package/lib/pricing.js +89 -0
  29. package/lib/refs.js +2 -0
  30. package/lib/reset-clock.js +55 -0
  31. package/lib/rotate.js +18 -4
  32. package/lib/routes/accounts.js +203 -0
  33. package/lib/routes/oauth.js +44 -2
  34. package/lib/routes/proxy.js +14 -0
  35. package/lib/routes/status.js +99 -5
  36. package/lib/scope-inspector.js +51 -0
  37. package/lib/stream-rotate.js +31 -10
  38. package/lib/updater.js +21 -0
  39. package/lib/vault-sync.js +412 -0
  40. package/lib/vault.js +1 -1
  41. package/lib/vendor-factory.js +6 -2
  42. package/lib/vendors/antigravity.js +11 -2
  43. package/lib/vendors/claude.js +56 -8
  44. package/lib/vendors/codex.js +34 -6
  45. package/lib/vendors/copilot-cli.js +8 -1
  46. package/lib/vendors/ernie.js +9 -2
  47. package/lib/vendors/grok.js +66 -6
  48. package/lib/vendors/jetbrains.js +133 -13
  49. package/lib/vendors/perplexity.js +148 -17
  50. package/package.json +1 -1
@@ -20,6 +20,8 @@ export const Slot = z.object({
20
20
  .description('#67 Optional subscription expiry timestamp (ms). When set and within expiryNotifyDays, the header chip shows the account and expiry date.'),
21
21
  proxyUrl: z.string().default('')
22
22
  .description('#88 Per-account proxy URL (http://, https://, socks5://). All requests for this account route through it. Empty = direct connection.'),
23
+ maxConcurrency: z.number().default(1)
24
+ .description('#555 Per-slot max concurrent requests before queuing. Defaults to 1 for subscription safety.'),
23
25
  })
24
26
 
25
27
  export const defaultSlots = PROVIDERS.map((provider) => ({ provider, index: 1, label: '' }))
@@ -86,6 +88,10 @@ export const Config = z.object({
86
88
  antigravityClientSecret: z.string().role('secret').default('')
87
89
  .description('Google OAuth client secret for custom confidential clients or CLI tokens requiring secret refresh.'),
88
90
  antigravityRedirectUri: z.string().volatile().default(''),
91
+ perplexityClientId: z.string().volatile().default(''),
92
+ perplexityRedirectUri: z.string().volatile().default(''),
93
+ jetbrainsClientId: z.string().volatile().default(''),
94
+ jetbrainsRedirectUri: z.string().volatile().default(''),
89
95
  antigravityModels: z.array(z.any()).volatile().default([])
90
96
  .description('Custom model catalog for Antigravity.'),
91
97
  codexOriginator: z.string().volatile().default('')
@@ -130,12 +136,48 @@ export const Config = z.object({
130
136
  .description('#485 Safety margin percent: avoid draining accounts below this threshold when alternatives exist.'),
131
137
  enableQueue: z.boolean().volatile().default(true)
132
138
  .description('#518 Bounded concurrency queue to prevent bursts of 429 errors.'),
133
- accountMaxConcurrency: z.number().volatile().default(2)
134
- .description('#518 Max concurrent requests per account before queuing.'),
139
+ accountMaxConcurrency: z.number().volatile().default(1)
140
+ .description('#518 #555 Default max concurrent requests per account before queuing (1 for OAuth subscription safety).'),
135
141
  maxQueueSize: z.number().volatile().default(20)
136
142
  .description('#518 Max requests waiting in queue before reject.'),
137
143
  queueTimeoutMs: z.number().volatile().default(30000)
138
144
  .description('#518 Max queue wait time in ms before timeout.'),
145
+ masterKey: z.string().role('secret').default('')
146
+ .description('#550 Optional master passphrase for encrypting OAuth credentials at rest (defaults to DSH_SUBSCRIPTIONS_MASTER_KEY env).'),
147
+ vaultSyncEnabled: z.boolean().volatile().default(false)
148
+ .description('#562 Enable automated encrypted vault sync to remote storage.'),
149
+ vaultSyncBackend: z.string().volatile().default('webdav')
150
+ .description('#562 Remote storage backend: webdav, gist, or s3.'),
151
+ vaultSyncPassphrase: z.string().role('secret').default('')
152
+ .description('#562 Passphrase used to encrypt/decrypt the remote vault archive.'),
153
+ vaultSyncIntervalHours: z.number().volatile().default(24)
154
+ .description('#562 Automated periodic sync interval in hours (0 disables periodic pull).'),
155
+ vaultSyncAutoPush: z.boolean().volatile().default(true)
156
+ .description('#562 Automatically push encrypted vault after successful OAuth login/refresh.'),
157
+ vaultSyncWebdavUrl: z.string().volatile().default('')
158
+ .description('#562 WebDAV target URL.'),
159
+ vaultSyncWebdavUsername: z.string().volatile().default('')
160
+ .description('#562 WebDAV username.'),
161
+ vaultSyncWebdavPassword: z.string().role('secret').default('')
162
+ .description('#562 WebDAV password or app token.'),
163
+ vaultSyncGistId: z.string().volatile().default('')
164
+ .description('#562 GitHub or Gitea Gist ID.'),
165
+ vaultSyncGistToken: z.string().role('secret').default('')
166
+ .description('#562 Personal access token with gist permission.'),
167
+ vaultSyncGistBaseUrl: z.string().volatile().default('https://api.github.com')
168
+ .description('#562 Gist API base URL.'),
169
+ vaultSyncS3Endpoint: z.string().volatile().default('')
170
+ .description('#562 S3 endpoint URL.'),
171
+ vaultSyncS3Bucket: z.string().volatile().default('')
172
+ .description('#562 S3 bucket name.'),
173
+ vaultSyncS3Key: z.string().volatile().default('dsh-subscriptions-vault.enc')
174
+ .description('#562 S3 object key.'),
175
+ vaultSyncS3Region: z.string().volatile().default('us-east-1')
176
+ .description('#562 S3 region.'),
177
+ vaultSyncS3AccessKey: z.string().volatile().default('')
178
+ .description('#562 S3 Access Key ID.'),
179
+ vaultSyncS3SecretKey: z.string().role('secret').default('')
180
+ .description('#562 S3 Secret Access Key.'),
139
181
  customVendors: z.array(z.any()).volatile().default([])
140
182
  .description('Declarative OpenAI-Responses-compatible providers. See README.'),
141
183
  })
@@ -145,7 +187,7 @@ export function publicConfig(cfg) {
145
187
  const clone = { ...cfg }
146
188
  // #242, #252: redact secret fields before exposing config via GET /config.
147
189
  for (const key of Object.keys(clone)) {
148
- if (key.endsWith("ClientSecret") || key.endsWith("Secret")) {
190
+ if (key.endsWith("ClientSecret") || key.endsWith("Secret") || key.endsWith("SecretKey") || key.endsWith("Password") || key.endsWith("Token") || key.endsWith("Passphrase") || key === "masterKey") {
149
191
  clone[key] = clone[key] ? "••••••" : ""
150
192
  }
151
193
  }
@@ -199,7 +241,7 @@ export function restoreMaskedSecrets(incoming, existing) {
199
241
  const out = { ...incoming }
200
242
 
201
243
  for (const key of Object.keys(out)) {
202
- if ((key.endsWith("ClientSecret") || key.endsWith("Secret")) && typeof out[key] === "string") {
244
+ if ((key.endsWith("ClientSecret") || key.endsWith("Secret") || key.endsWith("SecretKey") || key.endsWith("Password") || key.endsWith("Token") || key.endsWith("Passphrase") || key === "masterKey") && typeof out[key] === "string") {
203
245
  if (out[key].includes("••••")) {
204
246
  out[key] = existing[key] || ""
205
247
  }
@@ -1,7 +1,7 @@
1
1
  // Guided Diagnostic Wizard for accounts, credentials, and network connectivity (#519).
2
2
  // Enforces 0 Cyrillic characters.
3
3
 
4
- import { maskEmail, maskText, cleanErrorMessage } from './mask.js'
4
+ import { maskEmail, maskSecrets, cleanErrorMessage } from './mask.js'
5
5
  import { getVendor } from './vendors/index.js'
6
6
 
7
7
  /**
@@ -11,10 +11,7 @@ import { getVendor } from './vendors/index.js'
11
11
  */
12
12
  export function sanitizeDiagnosticData(val) {
13
13
  if (typeof val === 'string') {
14
- return maskText(val)
15
- .replace(/Bearer\s+[A-Za-z0-9._~+/-]+=*/gi, 'Bearer [REDACTED]')
16
- .replace(/key=[A-Za-z0-9._~+/-]+/gi, 'key=[REDACTED]')
17
- .replace(/token=[A-Za-z0-9._~+/-]+/gi, 'token=[REDACTED]')
14
+ return maskSecrets(val)
18
15
  }
19
16
  if (Array.isArray(val)) {
20
17
  return val.map(sanitizeDiagnosticData)
@@ -3,13 +3,13 @@ import { getSessionReasoningStats } from './reasoning-effort.js'
3
3
  import { defaultConcurrencyQueue } from './concurrency-queue.js'
4
4
  import { calculateCacheMetrics } from './analytics.js'
5
5
  import { getSessionPinStats } from './session-pin.js'
6
- import { maskText } from './mask.js'
6
+ import { maskSecrets } from './mask.js'
7
7
  import { normalizeSlots } from './accounts.js'
8
8
  import { PROVIDERS } from './refs.js'
9
9
  import { defaultErrorBuffer } from './error-buffer.js'
10
10
 
11
11
  function scrubReport(v) {
12
- if (typeof v === 'string') return maskText(v)
12
+ if (typeof v === 'string') return maskSecrets(v)
13
13
  if (Array.isArray(v)) return v.map(scrubReport)
14
14
  if (v && typeof v === 'object') {
15
15
  const o = {}
package/lib/doctor.js ADDED
@@ -0,0 +1,163 @@
1
+ /**
2
+ * Vendor Auth Doctor: Diagnose authentication, authorization, quota, and network errors
3
+ * into structured, human-understandable recommendations.
4
+ */
5
+
6
+ export const DOCTOR_CODES = {
7
+ GEO_BLOCKED: 'GEO_BLOCKED',
8
+ TOKEN_EXPIRED: 'TOKEN_EXPIRED',
9
+ TOKEN_REVOKED: 'TOKEN_REVOKED',
10
+ MISSING_SCOPE: 'MISSING_SCOPE',
11
+ QUOTA_EXHAUSTED: 'QUOTA_EXHAUSTED',
12
+ RATE_LIMIT: 'RATE_LIMIT',
13
+ PROXY_ERROR: 'PROXY_ERROR',
14
+ UNKNOWN: 'UNKNOWN',
15
+ }
16
+
17
+ /**
18
+ * Classifies vendor errors and generates actionable recommendations.
19
+ * @param {any} err
20
+ * @param {string} [provider]
21
+ * @param {any} [_account]
22
+ * @returns {{ code: string, title: string, details: string, recommendation: string } | null}
23
+ */
24
+ export function diagnoseVendorError(err, provider = '', _account = null) {
25
+ if (!err) return null
26
+
27
+ const msg = String(err.message || err.error || err || '').toLowerCase()
28
+ const code = String(err.code || err.status || err.statusCode || '').toUpperCase()
29
+ const p = String(provider || '').toLowerCase()
30
+
31
+ // 1. Geo-blocking / Cloudflare Firewall (403, 1020, region forbidden)
32
+ if (
33
+ msg.includes('country') ||
34
+ msg.includes('location') ||
35
+ msg.includes('region') ||
36
+ msg.includes('not supported in your country') ||
37
+ msg.includes('cloudflare') ||
38
+ msg.includes('cf-ray') ||
39
+ msg.includes('error 1020') ||
40
+ msg.includes('access denied') ||
41
+ (code === '403' && (msg.includes('forbidden') || msg.includes('blocked') || msg.includes('geoblock')))
42
+ ) {
43
+ return {
44
+ code: DOCTOR_CODES.GEO_BLOCKED,
45
+ title: 'Region or IP Blocked by Vendor Firewall',
46
+ details: `The vendor (${p || 'provider'}) returned a geo-restriction or Cloudflare block.`,
47
+ recommendation: 'Configure a dedicated SOCKS5 or HTTP proxy located in a supported region (US, EU, JP) in proxy settings.',
48
+ }
49
+ }
50
+
51
+ // 2. Token Expired / Session Expired
52
+ if (
53
+ msg.includes('token_expired') ||
54
+ msg.includes('jwt expired') ||
55
+ msg.includes('invalid_grant') ||
56
+ msg.includes('session expired') ||
57
+ msg.includes('refresh token expired') ||
58
+ msg.includes('token has expired') ||
59
+ msg.includes('login_required')
60
+ ) {
61
+ return {
62
+ code: DOCTOR_CODES.TOKEN_EXPIRED,
63
+ title: 'OAuth Access or Refresh Token Expired',
64
+ details: 'The stored OAuth credentials have expired and could not be refreshed automatically.',
65
+ recommendation: 'Click "Connect" or "Device Login" to re-authorize the account and establish a fresh session.',
66
+ }
67
+ }
68
+
69
+ // 3. Token Revoked / Bad Credentials
70
+ if (
71
+ msg.includes('token_revoked') ||
72
+ msg.includes('revoked') ||
73
+ msg.includes('account_deactivated') ||
74
+ msg.includes('invalid_token') ||
75
+ msg.includes('bad_credentials') ||
76
+ code === 'TOKEN_REVOKED' ||
77
+ (code === '401' && (msg.includes('unauthorized') || msg.includes('invalid api key') || msg.includes('incorrect api key')))
78
+ ) {
79
+ return {
80
+ code: DOCTOR_CODES.TOKEN_REVOKED,
81
+ title: 'Token Invalid or Revoked',
82
+ details: 'The vendor rejected the API key or OAuth token as invalid or revoked.',
83
+ recommendation: 'Re-issue a fresh session token, API key, or CLI credentials in the vendor dashboard and save it.',
84
+ }
85
+ }
86
+
87
+ // 4. Missing Scope / Permissions
88
+ if (
89
+ msg.includes('insufficient_scope') ||
90
+ msg.includes('missing_scope') ||
91
+ msg.includes('scope required') ||
92
+ msg.includes('permission_denied') ||
93
+ msg.includes('model_not_found') ||
94
+ msg.includes('not have access') ||
95
+ msg.includes('license_required')
96
+ ) {
97
+ return {
98
+ code: DOCTOR_CODES.MISSING_SCOPE,
99
+ title: 'Missing Scope or Model Permissions',
100
+ details: 'The account does not have sufficient entitlements or required scope permissions for this model.',
101
+ recommendation: 'Check your organization role or subscription tier to ensure permissions and access to requested models is granted.',
102
+ }
103
+ }
104
+
105
+ // 5. Quota Exhausted / Balance Zero
106
+ if (
107
+ msg.includes('insufficient_quota') ||
108
+ msg.includes('exceeded your current quota') ||
109
+ msg.includes('credit_balance_zero') ||
110
+ msg.includes('billing_not_active') ||
111
+ msg.includes('quota exceeded') ||
112
+ msg.includes('run out of credits') ||
113
+ code === 'QUOTA'
114
+ ) {
115
+ return {
116
+ code: DOCTOR_CODES.QUOTA_EXHAUSTED,
117
+ title: 'Subscription Quota or Credits Exhausted',
118
+ details: 'The account has consumed 100% of its current billing cycle or token usage quota.',
119
+ recommendation: 'Check your subscription billing dashboard or wait until the quota window resets.',
120
+ }
121
+ }
122
+
123
+ // 6. Rate Limit / 429
124
+ if (
125
+ code === '429' ||
126
+ code === 'RATE_LIMIT' ||
127
+ msg.includes('too many requests') ||
128
+ msg.includes('rate_limit') ||
129
+ msg.includes('rate limit')
130
+ ) {
131
+ return {
132
+ code: DOCTOR_CODES.RATE_LIMIT,
133
+ title: 'Temporary Rate Limit Reached',
134
+ details: 'The vendor is throttling requests (RPM/TPM limit reached) for this account.',
135
+ recommendation: 'Account is placed in temporary cooldown. Requests will automatically route to another account.',
136
+ }
137
+ }
138
+
139
+ // 7. Network / Proxy Connectivity
140
+ if (
141
+ msg.includes('econnrefused') ||
142
+ msg.includes('etimedout') ||
143
+ msg.includes('enotfound') ||
144
+ msg.includes('fetch failed') ||
145
+ msg.includes('socks') ||
146
+ code === 'ENOTFOUND' ||
147
+ code === 'ETIMEDOUT'
148
+ ) {
149
+ return {
150
+ code: DOCTOR_CODES.PROXY_ERROR,
151
+ title: 'Network or Proxy Connection Failure',
152
+ details: 'Failed to establish TCP/TLS connection with the provider API or configured proxy server.',
153
+ recommendation: 'Check server network access, DNS resolution, and verify the configured SOCKS/HTTP proxy is online.',
154
+ }
155
+ }
156
+
157
+ return {
158
+ code: DOCTOR_CODES.UNKNOWN,
159
+ title: 'Unexpected Vendor Error',
160
+ details: msg.slice(0, 200) || 'Unknown error occurred during vendor request.',
161
+ recommendation: 'Review vendor API status or re-check account credentials.',
162
+ }
163
+ }
@@ -1,4 +1,4 @@
1
- import { cleanErrorMessage, maskText } from './mask.js'
1
+ import { cleanErrorMessage, maskSecrets } from './mask.js'
2
2
 
3
3
  /**
4
4
  * Ring buffer holding the most recent runtime errors across vendors and streams.
@@ -19,7 +19,7 @@ export class ErrorRingBuffer {
19
19
  * @param {{ provider?: string, ref?: string, model?: string, status?: number|null, code?: string, message?: string }} [param0]
20
20
  */
21
21
  record({ provider = 'unknown', ref = '', model = '', status = null, code = '', message = '' } = {}) {
22
- const rawMsg = cleanErrorMessage(maskText(String(message || '')))
22
+ const rawMsg = cleanErrorMessage(maskSecrets(message))
23
23
  const entry = {
24
24
  id: String(this._nextId++),
25
25
  ts: new Date().toISOString(),
package/lib/forecast.js CHANGED
@@ -141,3 +141,59 @@ export function calculateBurnUrgency(remainingPercent, resetAt, nowMs = Date.now
141
141
  const clampedHours = Math.max(hoursUntilReset, 0.05)
142
142
  return rem / clampedHours
143
143
  }
144
+
145
+ /**
146
+ * #559: Calculates remaining runway duration and pacing status based on burn rate.
147
+ * @param {number|null} remainingPercent
148
+ * @param {number} pacePerHour
149
+ * @param {number|null} [_resetAt]
150
+ * @param {number} [_nowMs]
151
+ * @returns {{ runwayMs: number|null, runwayHours: number|null, runwayText: string, pacingStatus: string }}
152
+ */
153
+ export function calculatePacingRunway(remainingPercent, pacePerHour, _resetAt = null, _nowMs = Date.now()) {
154
+ if (remainingPercent === null || remainingPercent === undefined) {
155
+ return { runwayMs: null, runwayHours: null, runwayText: '', pacingStatus: 'unknown' }
156
+ }
157
+ const rem = Number(remainingPercent)
158
+ if (!Number.isFinite(rem)) {
159
+ return { runwayMs: null, runwayHours: null, runwayText: '', pacingStatus: 'unknown' }
160
+ }
161
+ if (rem <= 0) {
162
+ return { runwayMs: 0, runwayHours: 0, runwayText: '0m (exhausted)', pacingStatus: 'critical' }
163
+ }
164
+
165
+ const pace = Number(pacePerHour) || 0
166
+ if (pace <= 0.02) {
167
+ return { runwayMs: Infinity, runwayHours: Infinity, runwayText: 'Sustainable', pacingStatus: 'sustainable' }
168
+ }
169
+
170
+ const runwayHours = rem / pace
171
+ const runwayMs = Math.round(runwayHours * HOUR_MS)
172
+
173
+ let runwayText = ''
174
+ if (runwayHours >= 24) {
175
+ const days = Math.round(runwayHours / 24)
176
+ runwayText = `~${days}d`
177
+ } else if (runwayHours >= 1) {
178
+ const h = Math.floor(runwayHours)
179
+ const m = Math.round((runwayHours % 1) * 60)
180
+ runwayText = m > 0 ? `~${h}h ${m}m` : `~${h}h`
181
+ } else {
182
+ const m = Math.max(1, Math.round(runwayHours * 60))
183
+ runwayText = m <= 15 ? '< 15m' : `~${m}m`
184
+ }
185
+
186
+ let pacingStatus = 'sustainable'
187
+ if (runwayHours < 0.5) {
188
+ pacingStatus = 'critical'
189
+ } else if (runwayHours <= 2.5) {
190
+ pacingStatus = 'elevated'
191
+ }
192
+
193
+ return {
194
+ runwayMs,
195
+ runwayHours: Math.round(runwayHours * 10) / 10,
196
+ runwayText,
197
+ pacingStatus,
198
+ }
199
+ }