@goodandready/dsh-subscriptions 0.6.61 → 0.6.63

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.
@@ -0,0 +1,408 @@
1
+ // Guided Diagnostic Wizard for accounts, credentials, and network connectivity (#519).
2
+ // Enforces 0 Cyrillic characters.
3
+
4
+ import { maskEmail, maskText, cleanErrorMessage } from './mask.js'
5
+ import { getVendor } from './vendors/index.js'
6
+
7
+ /**
8
+ * Mask sensitive credentials inside diagnostic reports.
9
+ * @param {any} val
10
+ * @returns {any}
11
+ */
12
+ export function sanitizeDiagnosticData(val) {
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]')
18
+ }
19
+ if (Array.isArray(val)) {
20
+ return val.map(sanitizeDiagnosticData)
21
+ }
22
+ if (val && typeof val === 'object') {
23
+ const out = {}
24
+ for (const [k, v] of Object.entries(val)) {
25
+ if (/secret|token|password|auth|refresh|access_token|key/i.test(k) && typeof v === 'string') {
26
+ out[k] = v.length > 8 ? v.slice(0, 4) + '...' + v.slice(-4) : '••••••'
27
+ } else {
28
+ out[k] = sanitizeDiagnosticData(v)
29
+ }
30
+ }
31
+ return out
32
+ }
33
+ return val
34
+ }
35
+
36
+ /**
37
+ * Run diagnostic checks on a specific subscription account.
38
+ *
39
+ * @param {Object} options
40
+ * @param {Object} options.account
41
+ * @param {string} [options.rawBlob]
42
+ * @param {Object} [options.store]
43
+ * @param {Function} [options.live]
44
+ * @param {boolean} [options.includeNetwork]
45
+ * @param {typeof fetch} [options.fetchImpl]
46
+ * @param {number} [options.timeoutMs]
47
+ * @returns {Promise<Object>}
48
+ */
49
+ export async function runDiagnosticWizard(options = {}) {
50
+ const account = options.account || {}
51
+ const ref = account.ref || account.id || 'unknown'
52
+ const provider = account.provider || 'unknown'
53
+ const includeNetwork = Boolean(options.includeNetwork)
54
+ const fetchImpl = options.fetchImpl || globalThis.fetch || fetch
55
+ const timeoutMs = options.timeoutMs || 8000
56
+ const now = Date.now()
57
+
58
+ let raw = options.rawBlob || null
59
+ if (!raw && options.store && typeof options.store.resolveRaw === 'function') {
60
+ try {
61
+ raw = await options.store.resolveRaw(ref)
62
+ } catch { /* ignore resolve error */ }
63
+ }
64
+
65
+ const steps = []
66
+ const recommendations = []
67
+
68
+ // -------------------------------------------------------------
69
+ // STEP 1: Credentials & Expiry Check (Passive)
70
+ // -------------------------------------------------------------
71
+ let blob = null
72
+ let credStatus = 'pass'
73
+ let credCode = 'CREDENTIAL_OK'
74
+ let credMsg = 'Credentials present and formatted correctly'
75
+ let expiresAt = null
76
+
77
+ if (!raw) {
78
+ credStatus = 'fail'
79
+ credCode = 'MISSING_CREDENTIAL'
80
+ credMsg = 'No credential blob stored for this account'
81
+ recommendations.push('Connect this account using OAuth login or provide an API key.')
82
+ } else {
83
+ try {
84
+ blob = JSON.parse(raw)
85
+ } catch {
86
+ credStatus = 'fail'
87
+ credCode = 'CORRUPT_CREDENTIAL_DATA'
88
+ credMsg = 'Stored credential payload could not be parsed as JSON'
89
+ recommendations.push('Re-authenticate or re-import this account to fix corrupt data.')
90
+ }
91
+
92
+ if (blob) {
93
+ expiresAt = blob.expiresAt || blob.expires_at || null
94
+ const hasToken = Boolean(
95
+ blob.access_token || blob.accessToken || blob.token || blob.apiKey || blob.key || blob.sessionToken
96
+ )
97
+ const hasRefreshToken = Boolean(blob.refresh_token || blob.refreshToken)
98
+
99
+ if (!hasToken && !hasRefreshToken) {
100
+ credStatus = 'fail'
101
+ credCode = 'NO_TOKEN'
102
+ credMsg = 'Credential data exists but contains no access or refresh token'
103
+ recommendations.push('Re-authenticate this account to obtain an active access token.')
104
+ } else if (expiresAt && typeof expiresAt === 'number') {
105
+ const remainingMs = expiresAt - now
106
+ if (remainingMs <= 0) {
107
+ if (hasRefreshToken) {
108
+ credStatus = 'warn'
109
+ credCode = 'EXPIRED_ACCESS_TOKEN'
110
+ credMsg = `Access token expired ${Math.round(Math.abs(remainingMs) / 60000)}m ago; refresh token available`
111
+ recommendations.push('Run token refresh or reconnect account.')
112
+ } else {
113
+ credStatus = 'fail'
114
+ credCode = 'TOKEN_EXPIRED_NO_REFRESH'
115
+ credMsg = 'Access token is expired and no refresh token is present'
116
+ recommendations.push('Reconnect this account to obtain fresh credentials.')
117
+ }
118
+ } else if (remainingMs < 10 * 60 * 1000) {
119
+ credStatus = 'warn'
120
+ credCode = 'EXPIRING_SOON'
121
+ credMsg = `Access token expires in ${Math.round(remainingMs / 60000)}m`
122
+ }
123
+ }
124
+
125
+ if (blob.validationUrl) {
126
+ credStatus = 'warn'
127
+ credCode = 'VALIDATION_REQUIRED'
128
+ credMsg = blob.validationMessage ? cleanErrorMessage(blob.validationMessage) : 'Provider requires account verification'
129
+ recommendations.push(`Visit provider validation URL: ${blob.validationUrl}`)
130
+ }
131
+ }
132
+ }
133
+
134
+ steps.push({
135
+ step: 'credentials',
136
+ name: 'Credentials & Expiry',
137
+ status: credStatus,
138
+ code: credCode,
139
+ message: credMsg,
140
+ expiresAt,
141
+ })
142
+
143
+ // -------------------------------------------------------------
144
+ // STEP 2: Network & Proxy Verification
145
+ // -------------------------------------------------------------
146
+ const proxyUrl = account.proxyUrl || (options.live && options.live() && options.live().proxyUrl) || null
147
+ let proxyStatus = 'pass'
148
+ let proxyCode = 'DIRECT_CONNECTION'
149
+ let proxyMsg = 'Direct network connection configured'
150
+ let proxyLatencyMs = null
151
+
152
+ if (proxyUrl) {
153
+ let parsed = null
154
+ try {
155
+ parsed = new URL(proxyUrl)
156
+ if (!['http:', 'https:', 'socks5:', 'socks5h:'].includes(parsed.protocol)) {
157
+ proxyStatus = 'fail'
158
+ proxyCode = 'UNSUPPORTED_PROXY_PROTOCOL'
159
+ proxyMsg = `Unsupported proxy protocol: ${parsed.protocol}`
160
+ recommendations.push('Use http://, https://, or socks5:// for proxy URL.')
161
+ } else {
162
+ proxyCode = 'PROXY_CONFIG_VALID'
163
+ proxyMsg = `Proxy configured: ${parsed.protocol}//${parsed.host}`
164
+ }
165
+ } catch {
166
+ proxyStatus = 'fail'
167
+ proxyCode = 'INVALID_PROXY_URL'
168
+ proxyMsg = 'Proxy URL syntax is invalid'
169
+ recommendations.push('Check the proxyUrl setting for formatting errors.')
170
+ }
171
+
172
+ if (proxyStatus === 'pass' && includeNetwork) {
173
+ const t0 = Date.now()
174
+ try {
175
+ const controller = new AbortController()
176
+ const timer = setTimeout(() => controller.abort(), timeoutMs)
177
+ // Light probe: connect to target or HEAD request
178
+ await fetchImpl(proxyUrl, {
179
+ method: 'HEAD',
180
+ signal: controller.signal,
181
+ }).catch((e) => {
182
+ // If proxy accepts HTTP or rejects HEAD with 405/404, TCP connection succeeded
183
+ if (e && e.name !== 'AbortError' && e.code !== 'ECONNREFUSED' && e.code !== 'ENOTFOUND') {
184
+ return { ok: true }
185
+ }
186
+ throw e
187
+ })
188
+ clearTimeout(timer)
189
+ proxyLatencyMs = Date.now() - t0
190
+ proxyCode = 'PROXY_REACHABLE'
191
+ proxyMsg = `Proxy reachable (${proxyLatencyMs}ms)`
192
+ } catch (e) {
193
+ proxyStatus = 'fail'
194
+ proxyCode = 'PROXY_UNREACHABLE'
195
+ proxyMsg = `Proxy connection failed: ${cleanErrorMessage(e && e.message ? e.message : e)}`
196
+ recommendations.push('Verify that proxy server is running and accessible.')
197
+ }
198
+ }
199
+ } else {
200
+ proxyStatus = 'skipped'
201
+ }
202
+
203
+ steps.push({
204
+ step: 'proxy',
205
+ name: 'Proxy & Gateway',
206
+ status: proxyStatus,
207
+ code: proxyCode,
208
+ message: proxyMsg,
209
+ latencyMs: proxyLatencyMs,
210
+ })
211
+
212
+ // -------------------------------------------------------------
213
+ // STEP 3: Token Validation & Freshness
214
+ // -------------------------------------------------------------
215
+ let tokenStatus = 'skipped'
216
+ let tokenCode = 'NETWORK_CHECK_SKIPPED'
217
+ let tokenMsg = 'Token validation skipped (enable network checks to run)'
218
+ let tokenLatencyMs = null
219
+
220
+ if (!includeNetwork) {
221
+ // Passive: based on expiry from Step 1
222
+ if (credStatus === 'pass') {
223
+ tokenStatus = 'pass'
224
+ tokenCode = 'TOKEN_VALID_PASSIVE'
225
+ tokenMsg = 'Access token appears valid locally'
226
+ } else {
227
+ tokenStatus = credStatus
228
+ tokenCode = credCode
229
+ tokenMsg = credMsg
230
+ }
231
+ } else if (blob) {
232
+ const t0 = Date.now()
233
+ const remainingMs = expiresAt ? expiresAt - now : null
234
+ const isFresh = remainingMs !== null && remainingMs > 15 * 60 * 1000
235
+
236
+ if (isFresh && !blob.validationUrl) {
237
+ tokenStatus = 'pass'
238
+ tokenCode = 'TOKEN_FRESH'
239
+ tokenMsg = `Access token valid for another ${Math.round(remainingMs / 60000)}m; refresh not required`
240
+ } else {
241
+ // Attempt safe singleflight token refresh if vendor supports it
242
+ let vendor = null
243
+ try { vendor = getVendor(provider) } catch { /* unknown provider */ }
244
+ if (vendor && typeof vendor.refresh === 'function' && blob.refresh_token) {
245
+ try {
246
+ const controller = new AbortController()
247
+ const timer = setTimeout(() => controller.abort(), timeoutMs)
248
+ const refreshed = await vendor.refresh(blob, {
249
+ fetchImpl,
250
+ signal: controller.signal,
251
+ config: options.live ? options.live() : {},
252
+ })
253
+ clearTimeout(timer)
254
+ tokenLatencyMs = Date.now() - t0
255
+ if (refreshed && (refreshed.access_token || refreshed.token)) {
256
+ tokenStatus = 'pass'
257
+ tokenCode = 'TOKEN_REFRESH_OK'
258
+ tokenMsg = `Token refreshed successfully (${tokenLatencyMs}ms)`
259
+ } else {
260
+ tokenStatus = 'warn'
261
+ tokenCode = 'TOKEN_REFRESH_PARTIAL'
262
+ tokenMsg = 'Token refresh completed without new access_token'
263
+ }
264
+ } catch (e) {
265
+ const errStatus = Number(e && (e.status || e.statusCode) || 0)
266
+ tokenLatencyMs = Date.now() - t0
267
+ if (errStatus === 401 || errStatus === 403 || (e && e.code === 'INVALID_GRANT')) {
268
+ tokenStatus = 'fail'
269
+ tokenCode = 'TOKEN_REVOKED'
270
+ tokenMsg = 'Token refresh rejected by provider (invalid_grant or revoked)'
271
+ recommendations.push('Log in again with this provider to reauthorize.')
272
+ } else if (errStatus === 429) {
273
+ tokenStatus = 'warn'
274
+ tokenCode = 'RATE_LIMITED'
275
+ tokenMsg = 'Token refresh rate limited (429)'
276
+ recommendations.push('Wait before requesting further token refreshes.')
277
+ } else {
278
+ tokenStatus = 'fail'
279
+ tokenCode = 'REFRESH_ERROR'
280
+ tokenMsg = `Token refresh failed: ${cleanErrorMessage(e && e.message ? e.message : e)}`
281
+ recommendations.push('Verify network connection to provider authentication servers.')
282
+ }
283
+ }
284
+ } else {
285
+ tokenStatus = 'pass'
286
+ tokenCode = 'STATIC_CREDENTIAL_READY'
287
+ tokenMsg = 'Static credentials ready for use'
288
+ }
289
+ }
290
+ }
291
+
292
+ steps.push({
293
+ step: 'token_validation',
294
+ name: 'Token Validation',
295
+ status: tokenStatus,
296
+ code: tokenCode,
297
+ message: tokenMsg,
298
+ latencyMs: tokenLatencyMs,
299
+ })
300
+
301
+ // -------------------------------------------------------------
302
+ // STEP 4: Model Catalog Discovery (Passive or Active listModels)
303
+ // -------------------------------------------------------------
304
+ let catalogStatus = 'skipped'
305
+ let catalogCode = 'NETWORK_CHECK_SKIPPED'
306
+ let catalogMsg = 'Model catalog reachability check skipped'
307
+ let catalogLatencyMs = null
308
+ let modelCount = 0
309
+
310
+ if (!includeNetwork) {
311
+ catalogStatus = 'skipped'
312
+ } else {
313
+ const t0 = Date.now()
314
+ const vendor = getVendor(provider)
315
+ if (vendor && typeof vendor.listModels === 'function') {
316
+ try {
317
+ const controller = new AbortController()
318
+ const timer = setTimeout(() => controller.abort(), timeoutMs)
319
+ const models = await vendor.listModels(account, {
320
+ fetchImpl,
321
+ signal: controller.signal,
322
+ config: options.live ? options.live() : {},
323
+ })
324
+ clearTimeout(timer)
325
+ catalogLatencyMs = Date.now() - t0
326
+ modelCount = Array.isArray(models) ? models.length : 0
327
+ if (modelCount > 0) {
328
+ catalogStatus = 'pass'
329
+ catalogCode = 'CATALOG_OK'
330
+ catalogMsg = `${modelCount} models reachable and verified (${catalogLatencyMs}ms)`
331
+ } else {
332
+ catalogStatus = 'warn'
333
+ catalogCode = 'CATALOG_EMPTY'
334
+ catalogMsg = 'Catalog request succeeded but 0 models returned'
335
+ recommendations.push('Check provider plan features and permissions.')
336
+ }
337
+ } catch (e) {
338
+ catalogLatencyMs = Date.now() - t0
339
+ const status = Number(e && (e.status || e.statusCode) || 0)
340
+ if (status === 401 || status === 403) {
341
+ catalogStatus = 'fail'
342
+ catalogCode = 'CATALOG_UNAUTHORIZED'
343
+ catalogMsg = 'Provider rejected catalog request with 401/403'
344
+ recommendations.push('Account credentials do not have access to models catalog.')
345
+ } else {
346
+ catalogStatus = 'fail'
347
+ catalogCode = 'CATALOG_UNREACHABLE'
348
+ catalogMsg = `Failed to fetch model catalog: ${cleanErrorMessage(e && e.message ? e.message : e)}`
349
+ recommendations.push('Verify provider API endpoint reachability.')
350
+ }
351
+ }
352
+ } else {
353
+ catalogStatus = 'pass'
354
+ catalogCode = 'STATIC_CATALOG'
355
+ catalogMsg = 'Standard static catalog available'
356
+ }
357
+ }
358
+
359
+ steps.push({
360
+ step: 'models_catalog',
361
+ name: 'Model Catalog Reachability',
362
+ status: catalogStatus,
363
+ code: catalogCode,
364
+ message: catalogMsg,
365
+ latencyMs: catalogLatencyMs,
366
+ modelCount,
367
+ })
368
+
369
+ // -------------------------------------------------------------
370
+ // Overall Summary & Sanitization
371
+ // -------------------------------------------------------------
372
+ let passed = 0
373
+ let warnings = 0
374
+ let failed = 0
375
+ let skipped = 0
376
+
377
+ for (const s of steps) {
378
+ if (s.status === 'pass') passed++
379
+ else if (s.status === 'warn') warnings++
380
+ else if (s.status === 'fail') failed++
381
+ else skipped++
382
+ }
383
+
384
+ const overallStatus = failed > 0 ? 'failing' : (warnings > 0 ? 'degraded' : 'healthy')
385
+
386
+ const report = {
387
+ account: {
388
+ ref,
389
+ provider,
390
+ label: account.label ? maskEmail(account.label) : ref,
391
+ index: account.index || 1,
392
+ },
393
+ generatedAt: new Date(now).toISOString(),
394
+ networkChecksIncluded: includeNetwork,
395
+ overallStatus,
396
+ summary: {
397
+ totalSteps: steps.length,
398
+ passed,
399
+ warnings,
400
+ failed,
401
+ skipped,
402
+ },
403
+ steps,
404
+ recommendations,
405
+ }
406
+
407
+ return sanitizeDiagnosticData(report)
408
+ }
@@ -1,3 +1,6 @@
1
+ import { defaultConcurrencyQueue } from './concurrency-queue.js'
2
+ import { calculateCacheMetrics } from './analytics.js'
3
+ import { getSessionPinStats } from './session-pin.js'
1
4
  import { maskText } from './mask.js'
2
5
  import { normalizeSlots } from './accounts.js'
3
6
  import { PROVIDERS } from './refs.js'
@@ -46,6 +49,9 @@ export async function createDiagnosticsReport({ live, store, history, NS, pkgVer
46
49
  runtime: { node: process.version, platform: process.platform, arch: process.arch, uptimeSec: Math.round(process.uptime()) },
47
50
  providers,
48
51
  requests: { total: rows.length, byStatus, lastErrors },
52
+ cache: calculateCacheMetrics(rows),
53
+ queue: defaultConcurrencyQueue.stats(),
54
+ pins: getSessionPinStats(),
49
55
  decisions: {
50
56
  recent: rows.filter((r) => r && r.decisionTrace).slice(0, 10).map((r) => ({
51
57
  ts: r.ts,
package/lib/history.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { calculateAnalytics } from './analytics.js'
1
2
  import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'
2
3
  import { join } from 'node:path'
3
4
  import { homedir } from 'node:os'
@@ -298,4 +299,8 @@ export class HistoryStore {
298
299
  recentDecisions,
299
300
  }
300
301
  }
302
+
303
+ getAnalytics(accounts = [], nowMs = Date.now()) {
304
+ return calculateAnalytics(this.rows, accounts, nowMs)
305
+ }
301
306
  }
package/lib/rotate.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { computePacingRisk, calculateBurnRatePerHour, calculateBurnUrgency } from "./forecast.js"
2
2
  import { isQuarantined } from './quarantine.js'
3
- import { getPinnedAccountRef, pinSession } from './session-pin.js'
3
+ import { getPinnedAccountRef, pinSession, touchSessionPin, unpinSession, isCacheAwareProvider } from './session-pin.js'
4
4
 
5
5
  const SWITCH_CODES = new Set([
6
6
  'RATE_LIMIT',
@@ -98,9 +98,9 @@ export function pickAccountWithTrace(accounts, nowMs, opts) {
98
98
  return true
99
99
  }
100
100
 
101
- // Sticky Session Pinning (#170, #517)
101
+ // Sticky & Cache-Aware Session Pinning (#170, #517, #520)
102
102
  if (targetSession) {
103
- const pinnedRef = getPinnedAccountRef(targetSession)
103
+ const pinnedRef = getPinnedAccountRef(targetSession, now)
104
104
  if (pinnedRef) {
105
105
  const pinned = list.find((a) => (a.ref || a.id) === pinnedRef)
106
106
  if (pinned && pinned.hasToken && !isQuarantined(pinned, now)) {
@@ -110,42 +110,53 @@ export function pickAccountWithTrace(accounts, nowMs, opts) {
110
110
  (pinned.quota.remainingPercent != null && Number(pinned.quota.remainingPercent) <= 0)
111
111
  ) && (!pinned.quota.resetAt || Number(pinned.quota.resetAt) > now))
112
112
  if (!isCooldown && !isPinnedZero && !isQuotaExhausted(pinned) && !isUsageExhausted(pinned)) {
113
+ const cacheAware = isCacheAwareProvider(pinned.provider)
114
+ const decisionReason = cacheAware ? 'cache_pinned' : 'session_pinned'
115
+ touchSessionPin(targetSession, { provider: pinned.provider, model: opts && opts.model })
113
116
  evaluations.push({
114
117
  ref: pinnedRef,
115
118
  eligible: true,
116
119
  tier: 0,
117
- reason: 'session_pinned',
120
+ reason: decisionReason,
118
121
  pinned: true,
122
+ cacheAware,
119
123
  })
120
124
  return {
121
125
  account: pinned,
122
126
  trace: {
123
127
  chosenRef: pinnedRef,
124
- decisionReason: 'session_pinned',
128
+ decisionReason,
125
129
  tier: 0,
126
130
  evaluations,
127
131
  selectionMetrics: {
128
132
  pinned: true,
133
+ cacheAware,
129
134
  remainingPercent: getAccountRemainingPercent(pinned),
130
135
  healthScore: pinned.healthScore ?? 100,
131
136
  },
132
137
  },
133
138
  }
134
139
  } else {
140
+ const releaseReason = isCooldown ? 'pinned_cooldown' : isPinnedZero ? 'pinned_zero_quota' : 'pinned_exhausted'
141
+ unpinSession(targetSession, releaseReason)
135
142
  evaluations.push({
136
143
  ref: pinnedRef,
137
144
  eligible: false,
138
- reason: isCooldown ? 'pinned_cooldown' : isPinnedZero ? 'pinned_zero_quota' : 'pinned_exhausted',
145
+ reason: releaseReason,
139
146
  pinned: true,
140
147
  })
141
148
  }
142
149
  } else if (pinned) {
150
+ const releaseReason = !pinned.hasToken ? 'pinned_no_token' : 'pinned_quarantine'
151
+ unpinSession(targetSession, releaseReason)
143
152
  evaluations.push({
144
153
  ref: pinnedRef,
145
154
  eligible: false,
146
- reason: !pinned.hasToken ? 'pinned_no_token' : 'pinned_quarantine',
155
+ reason: releaseReason,
147
156
  pinned: true,
148
157
  })
158
+ } else {
159
+ unpinSession(targetSession, 'pinned_not_found')
149
160
  }
150
161
  }
151
162
  }
@@ -277,7 +288,11 @@ export function pickAccountWithTrace(accounts, nowMs, opts) {
277
288
  const chosen = tier[0]
278
289
  const chosenRef = chosen.ref || chosen.id
279
290
  if (targetSession && chosen) {
280
- pinSession(targetSession, chosenRef)
291
+ pinSession(targetSession, chosenRef, {
292
+ provider: chosen.provider,
293
+ model: opts && opts.model,
294
+ cacheAware: isCacheAwareProvider(chosen.provider),
295
+ })
281
296
  }
282
297
  return {
283
298
  account: chosen,
@@ -1,3 +1,6 @@
1
+ import { defaultConcurrencyQueue } from '../concurrency-queue.js'
2
+ import { calculateAnalytics } from '../analytics.js'
3
+ import { runDiagnosticWizard } from '../diagnostic-wizard.js'
1
4
  import { buildEnrichedCatalog } from '../models-catalog.js'
2
5
  // #374: Sensitive GET routes protected by same-origin check
3
6
  import { readFileSync } from 'node:fs'
@@ -49,6 +52,7 @@ export function registerStatusRoutes(ctx, state) {
49
52
  liveConfig: live(),
50
53
  hideDeprecated: !!live().hideDeprecatedModels,
51
54
  }),
55
+ queue: defaultConcurrencyQueue.stats(),
52
56
  }
53
57
  }
54
58
 
@@ -538,4 +542,76 @@ export function registerStatusRoutes(ctx, state) {
538
542
  }),
539
543
  }), 'dsh-subscriptions: /alerts')
540
544
 
545
+
546
+ // #511: Detailed cache and subscription analytics endpoint
547
+ ctx.effect(() => ctx.webServer.register({
548
+ kind: 'exact',
549
+ path: '/dsh-subscriptions/analytics',
550
+ handler: safeJsonHandler(async (req, res) => {
551
+ if (req.method !== 'GET') {
552
+ writeJson(res, 405, { ok: false, error: { code: 'method', message: 'GET only' } })
553
+ return
554
+ }
555
+ if (!isTrustedSettingsRequest(req, ctx)) {
556
+ writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'same-origin only' } })
557
+ return
558
+ }
559
+ const accounts = await accountsView()
560
+ const analytics = typeof history.getAnalytics === 'function'
561
+ ? history.getAnalytics(accounts)
562
+ : calculateAnalytics(history.all(), accounts)
563
+ writeJson(res, 200, { ok: true, analytics })
564
+ }),
565
+ }), 'dsh-subscriptions: /analytics')
566
+
567
+ // #518: Concurrency and request queue status
568
+ ctx.effect(() => ctx.webServer.register({
569
+ kind: 'exact',
570
+ path: '/dsh-subscriptions/queue',
571
+ handler: safeJsonHandler(async (req, res) => {
572
+ if (req.method !== 'GET') {
573
+ writeJson(res, 405, { ok: false, error: { code: 'method', message: 'GET only' } })
574
+ return
575
+ }
576
+ if (!isTrustedSettingsRequest(req, ctx)) {
577
+ writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'same-origin only' } })
578
+ return
579
+ }
580
+ const queueStats = defaultConcurrencyQueue.stats()
581
+ writeJson(res, 200, { ok: true, queue: queueStats })
582
+ }),
583
+ }), 'dsh-subscriptions: /queue')
584
+
585
+ // #519: Interactive Diagnostic Wizard for accounts and connectivity
586
+ ctx.effect(() => ctx.webServer.register({
587
+ kind: 'exact',
588
+ path: '/dsh-subscriptions/diagnostic-wizard',
589
+ handler: safeJsonHandler(async (req, res) => {
590
+ if (req.method !== 'POST') {
591
+ writeJson(res, 405, { ok: false, error: { code: 'method', message: 'POST only' } })
592
+ return
593
+ }
594
+ if (!isTrustedSettingsRequest(req, ctx)) {
595
+ writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'same-origin only' } })
596
+ return
597
+ }
598
+ let body = {}
599
+ try { body = JSON.parse((await readBody(req, 64 * 1024)).toString('utf8') || '{}') } catch { /* ignore */ }
600
+ const accounts = await accountsView()
601
+ const targetAccount = accounts.find((a) => {
602
+ if (body.ref && (a.ref === body.ref || a.id === body.ref)) return true
603
+ if (body.provider && body.index !== undefined && a.provider === body.provider && a.index === Number(body.index)) return true
604
+ return false
605
+ }) || (body.provider ? { provider: body.provider, index: Number(body.index) || 1 } : (accounts[0] || {}))
606
+
607
+ const report = await runDiagnosticWizard({
608
+ account: targetAccount,
609
+ store,
610
+ live,
611
+ includeNetwork: Boolean(body.includeNetwork),
612
+ fetchImpl: fetch,
613
+ })
614
+ writeJson(res, 200, { ok: true, report })
615
+ }),
616
+ }), 'dsh-subscriptions: /diagnostic-wizard')
541
617
  }