@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
@@ -1,9 +1,12 @@
1
1
  import { parseHeaders } from './ratelimit.js'
2
+ import { formatRelativeReset } from './relative-time.js'
2
3
 
3
4
  export const VENDOR_WINDOW_MS = {
4
5
  claude: 5 * 3600 * 1000,
5
6
  codex: 3 * 3600 * 1000,
6
7
  grok: 2 * 3600 * 1000,
8
+ copilot: 24 * 3600 * 1000,
9
+ antigravity: 24 * 3600 * 1000,
7
10
  }
8
11
 
9
12
  export function parseResetFromText(message, nowMs = Date.now()) {
@@ -85,3 +88,55 @@ export function resolveAccurateReset(err, account, provider, nowMs = Date.now(),
85
88
 
86
89
  return null
87
90
  }
91
+
92
+ export function formatRollingCountdown(resetAt, lang = 'en', now = Date.now()) {
93
+ const ts = Number(resetAt)
94
+ if (!Number.isFinite(ts) || ts <= 0) return ''
95
+ const d = new Date(ts)
96
+ const pad = (n) => String(n).padStart(2, '0')
97
+ const timeStr = `${pad(d.getHours())}:${pad(d.getMinutes())}`
98
+ const relStr = formatRelativeReset(ts, lang, now)
99
+ if (lang === 'zh') {
100
+ return `额度重置于 ${timeStr} (${relStr})`
101
+ }
102
+ return `Window resets at ${timeStr} (${relStr})`
103
+ }
104
+
105
+ export function computeEffectiveResetAt(account, provider, now = Date.now()) {
106
+ if (!account) return null
107
+ const nowMs = Number(now) || Date.now()
108
+ const candidates = []
109
+
110
+ if (account.quota && account.quota.resetAt) {
111
+ const r = Number(account.quota.resetAt)
112
+ if (Number.isFinite(r) && r > nowMs) candidates.push(r)
113
+ }
114
+ if (account.cooldownUntil) {
115
+ const c = Number(account.cooldownUntil)
116
+ if (Number.isFinite(c) && c > nowMs) candidates.push(c)
117
+ }
118
+ if (account.quarantineUntil) {
119
+ const q = Number(account.quarantineUntil)
120
+ if (Number.isFinite(q) && q > nowMs) candidates.push(q)
121
+ }
122
+ if (Array.isArray(account.usage)) {
123
+ for (const w of account.usage) {
124
+ if (w && w.resetAt) {
125
+ const wr = Number(w.resetAt)
126
+ if (Number.isFinite(wr) && wr > nowMs) candidates.push(wr)
127
+ }
128
+ }
129
+ }
130
+
131
+ if (candidates.length > 0) {
132
+ return Math.min(...candidates)
133
+ }
134
+
135
+ const p = String(provider || (account && account.provider) || '').toLowerCase()
136
+ const windowDefault = VENDOR_WINDOW_MS[p]
137
+ if (windowDefault && account.usagePercent != null && Number(account.usagePercent) >= 100) {
138
+ return nowMs + windowDefault
139
+ }
140
+
141
+ return null
142
+ }
package/lib/rotate.js CHANGED
@@ -17,14 +17,28 @@ export function isRegionError(err) {
17
17
  return status === 400 && (/FAILED_PRECONDITION/i.test(msg) || /User location is not supported/i.test(msg))
18
18
  }
19
19
 
20
+ export function isAuthError(err) {
21
+ if (!err || typeof err !== "object") return false
22
+ const status = Number(err.status || err.statusCode) || 0
23
+ if (status === 401) return true
24
+ const code = String(err.code || (err.failure && err.failure.code) || "").toUpperCase()
25
+ if (code === "AUTH" || code === "TOKEN_REVOKED" || code === "UNAUTHORIZED" || code === "INVALID_TOKEN" || code === "SESSION_EXPIRED") return true
26
+ const msg = String(err.message || "")
27
+ if (/invalid_grant/i.test(msg)) return true
28
+ if (/token (?:has been )?revoked/i.test(msg)) return true
29
+ if (/session (?:expired|revoked|invalid)/i.test(msg)) return true
30
+ if (/unauthorized/i.test(msg) && (status === 0 || status === 400 || status === 401)) return true
31
+ return false
32
+ }
33
+
20
34
  export function isSwitchableError(err) {
21
- if (!err || typeof err !== 'object') return false
22
- if (err.name === 'TimeoutError' || err.code === 'TIMEOUT' || err.code === 'EMPTY_RESPONSE' || err.code === 'SOFT_FAILURE' || err.isSoftFailure) return true
23
- if (err.name === 'AbortError' || err.code === 'ABORT_ERR' || err.code === 'ERR_ABORTED' || (err.message && /aborted/i.test(err.message))) return false
35
+ if (!err || typeof err !== "object") return false
36
+ if (err.name === "TimeoutError" || err.code === "TIMEOUT" || err.code === "EMPTY_RESPONSE" || err.code === "SOFT_FAILURE" || err.isSoftFailure) return true
37
+ if (err.name === "AbortError" || err.code === "ABORT_ERR" || err.code === "ERR_ABORTED" || (err.message && /aborted/i.test(err.message))) return false
24
38
  const code = err.code || (err.failure && err.failure.code)
25
39
  if (SWITCH_CODES.has(code)) return true
26
40
  if (isRegionError(err)) return true
27
- const status = err.status || err.statusCode
41
+ const status = Number(err.status || err.statusCode) || 0
28
42
  return status === 401 || status === 403 || status === 429 || status === 500 || status === 502 || status === 503
29
43
  }
30
44
 
@@ -1,4 +1,6 @@
1
+ import { diagnoseVendorError } from '../doctor.js'
1
2
  import { exportVault, importVault } from '../vault.js'
3
+ import { syncVaultPush, syncVaultPull, getVaultSyncStatus } from '../vault-sync.js'
2
4
  import { normalizeSlots, vendorConfig } from '../accounts.js'
3
5
  import { getVendor } from '../vendors/index.js'
4
6
  import { notifyQuarantineEntered, notifyQuarantineReleased } from '../alerts.js'
@@ -445,6 +447,140 @@ export function registerAccountsRoutes(ctx, state) {
445
447
  }),
446
448
  }), 'dsh-subscriptions: /import-token')
447
449
 
450
+ // Issue #541: Dynamic model catalog discovery & sync
451
+ ctx.effect(() => ctx.webServer.register({
452
+ kind: 'exact',
453
+ path: '/dsh-subscriptions/accounts/sync-models',
454
+ handler: safeJsonHandler(async (req, res) => {
455
+ if (req.method !== 'POST') {
456
+ writeJson(res, 405, { ok: false, error: { code: 'method', message: 'POST only' } })
457
+ return
458
+ }
459
+ if (!isTrustedSettingsRequest(req, ctx)) {
460
+ writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'same-origin only' } })
461
+ return
462
+ }
463
+ let payload = {}
464
+ try { payload = JSON.parse((await readBody(req, 64 * 1024)).toString('utf8') || '{}') } catch { /* ignore */ }
465
+ const provider = payload.provider
466
+ const index = payload.index || 1
467
+ if (!provider || !isProvider(provider)) {
468
+ writeJson(res, 400, { ok: false, error: { code: 'invalid_provider', message: 'Invalid or missing provider' } })
469
+ return
470
+ }
471
+ const ref = oauthRef(provider, index)
472
+ const info = await store.describeRef(ref)
473
+ if (!info.configured) {
474
+ writeJson(res, 400, { ok: false, error: { code: 'not_configured', message: 'Account is not configured' } })
475
+ return
476
+ }
477
+ try {
478
+ let blob = await store.loadBlob(ref)
479
+ blob = await store.ensureFresh(provider, blob, ref)
480
+ const vendor = getVendor(provider)
481
+ const cfg = vendorConfig(provider, live())
482
+ const doFetch = (typeof state.fetchForRef === 'function' && state.fetchForRef(ref)) || fetch
483
+ let models = []
484
+ if (typeof vendor.listModels === 'function') {
485
+ const cat = await vendor.listModels(blob, cfg, doFetch)
486
+ if (Array.isArray(cat)) {
487
+ models = cat.map((m) => (typeof m === 'string' ? m : (m.id || m.slug || m.name))).filter(Boolean)
488
+ } else if (cat && Array.isArray(cat.models)) {
489
+ models = cat.models.map((m) => (typeof m === 'string' ? m : (m.id || m.slug || m.name))).filter(Boolean)
490
+ }
491
+ }
492
+ if (!models.length && vendor.defaults) {
493
+ models = (vendor.defaults().models || []).map((m) => (typeof m === 'string' ? m : (m.id || m.slug || m.name))).filter(Boolean)
494
+ }
495
+ if (typeof store.rememberDynamicModels === 'function') {
496
+ store.rememberDynamicModels(ref, models)
497
+ }
498
+ writeJson(res, 200, {
499
+ ok: true,
500
+ provider,
501
+ index,
502
+ ref,
503
+ count: models.length,
504
+ models,
505
+ })
506
+ } catch (err) {
507
+ const doc = diagnoseVendorError(err, provider)
508
+ writeJson(res, 502, {
509
+ ok: false,
510
+ provider,
511
+ index,
512
+ ref,
513
+ doctor: doc,
514
+ error: { code: 'sync_failed', message: String(err && err.message || err) },
515
+ })
516
+ }
517
+ }),
518
+ }), 'dsh-subscriptions: /accounts/sync-models')
519
+
520
+
521
+
522
+ // Issue #549: Switch active organization/workspace for an account slot
523
+ ctx.effect(() => ctx.webServer.register({
524
+ kind: 'exact',
525
+ path: '/dsh-subscriptions/accounts/switch-organization',
526
+ handler: safeJsonHandler(async (req, res) => {
527
+ if (req.method !== 'POST') {
528
+ writeJson(res, 405, { ok: false, error: { code: 'method', message: 'POST only' } })
529
+ return
530
+ }
531
+ if (!isTrustedSettingsRequest(req, ctx)) {
532
+ writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'same-origin only' } })
533
+ return
534
+ }
535
+ let payload
536
+ try { payload = JSON.parse((await readBody(req, 8 * 1024)).toString('utf8') || '{}') } catch {
537
+ writeJson(res, 400, { ok: false, error: { code: 'json', message: 'invalid json' } })
538
+ return
539
+ }
540
+ const provider = String(payload.provider || '')
541
+ const index = Number(payload.index || 1)
542
+ const organizationId = String(payload.organizationId || payload.selectedOrgId || '').trim()
543
+ if (!isProvider(provider)) {
544
+ writeJson(res, 400, { ok: false, error: { code: 'provider', message: 'unknown provider' } })
545
+ return
546
+ }
547
+ const ref = refForSlot(provider, index)
548
+ if (!ref) {
549
+ writeJson(res, 404, { ok: false, error: { code: 'slot', message: 'slot not found' } })
550
+ return
551
+ }
552
+ let blob
553
+ try { blob = await store.loadBlob(ref) } catch {
554
+ writeJson(res, 404, { ok: false, error: { code: 'blob', message: 'credentials not configured' } })
555
+ return
556
+ }
557
+
558
+ blob.selectedOrgId = organizationId
559
+ if (provider === 'codex') blob.accountId = organizationId
560
+ if (provider === 'antigravity') blob.projectId = organizationId
561
+ if (provider === 'claude') blob.organizationId = organizationId
562
+ if (provider === 'copilot') blob.organization = organizationId
563
+
564
+ if (Array.isArray(blob.organizations)) {
565
+ const found = blob.organizations.find((o) => o && o.id === organizationId)
566
+ if (found) blob.selectedOrgName = found.name
567
+ else if (organizationId) blob.selectedOrgName = organizationId
568
+ } else if (organizationId) {
569
+ blob.selectedOrgName = organizationId
570
+ }
571
+
572
+ await store.saveBlob(ref, blob)
573
+ await syncAdapter()
574
+
575
+ writeJson(res, 200, {
576
+ ok: true,
577
+ ref,
578
+ selectedOrgId: organizationId,
579
+ selectedOrgName: blob.selectedOrgName || organizationId,
580
+ accounts: await accountsView(),
581
+ })
582
+ }),
583
+ }), 'dsh-subscriptions: /accounts/switch-organization')
448
584
 
449
585
  ctx.effect(() => ctx.webServer.register({
450
586
  kind: 'exact',
@@ -588,4 +724,71 @@ export function registerAccountsRoutes(ctx, state) {
588
724
  handler: handleVaultImport,
589
725
  }), 'dsh-subscriptions: /import-backup')
590
726
 
727
+
728
+ // #562: Remote Vault Sync Push
729
+ ctx.effect(() => ctx.webServer.register({
730
+ kind: 'exact',
731
+ path: '/dsh-subscriptions/vault/sync/push',
732
+ handler: safeJsonHandler(async (req, res) => {
733
+ if (req.method !== 'POST') {
734
+ writeJson(res, 405, { ok: false, error: { code: 'method', message: 'POST only' } })
735
+ return
736
+ }
737
+ if (!isTrustedSettingsRequest(req, ctx)) {
738
+ writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'same-origin only' } })
739
+ return
740
+ }
741
+ try {
742
+ const result = await syncVaultPush({ live: live(), store })
743
+ writeJson(res, 200, { ok: true, result, status: getVaultSyncStatus() })
744
+ } catch (e) {
745
+ writeJson(res, 400, { ok: false, error: { code: 'vault_sync_push', message: String(e && e.message || e) } })
746
+ }
747
+ }),
748
+ }), 'dsh-subscriptions: /vault/sync/push')
749
+
750
+ // #562: Remote Vault Sync Pull
751
+ ctx.effect(() => ctx.webServer.register({
752
+ kind: 'exact',
753
+ path: '/dsh-subscriptions/vault/sync/pull',
754
+ handler: safeJsonHandler(async (req, res) => {
755
+ if (req.method !== 'POST') {
756
+ writeJson(res, 405, { ok: false, error: { code: 'method', message: 'POST only' } })
757
+ return
758
+ }
759
+ if (!isTrustedSettingsRequest(req, ctx)) {
760
+ writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'same-origin only' } })
761
+ return
762
+ }
763
+ try {
764
+ const result = await syncVaultPull({
765
+ live: live(),
766
+ store,
767
+ settingsApi: getSettingsApi(),
768
+ syncAdapter,
769
+ refreshModels,
770
+ })
771
+ writeJson(res, 200, { ok: true, result, status: getVaultSyncStatus(), accounts: await accountsView() })
772
+ } catch (e) {
773
+ writeJson(res, 400, { ok: false, error: { code: 'vault_sync_pull', message: String(e && e.message || e) } })
774
+ }
775
+ }),
776
+ }), 'dsh-subscriptions: /vault/sync/pull')
777
+
778
+ // #562: Remote Vault Sync Status
779
+ ctx.effect(() => ctx.webServer.register({
780
+ kind: 'exact',
781
+ path: '/dsh-subscriptions/vault/sync/status',
782
+ handler: safeJsonHandler(async (req, res) => {
783
+ if (req.method !== 'GET') {
784
+ writeJson(res, 405, { ok: false, error: { code: 'method', message: 'GET only' } })
785
+ return
786
+ }
787
+ if (!isTrustedSettingsRequest(req, ctx)) {
788
+ writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'same-origin only' } })
789
+ return
790
+ }
791
+ writeJson(res, 200, { ok: true, status: getVaultSyncStatus() })
792
+ }),
793
+ }), 'dsh-subscriptions: /vault/sync/status')
591
794
  }
@@ -5,6 +5,8 @@ import { parseCallbackInput, requestOrigin } from '../oauth.js'
5
5
  import { createPkce } from '../pkce.js'
6
6
  import { displayName, isProvider, oauthRef } from '../refs.js'
7
7
  import { getVendor } from '../vendors/index.js'
8
+ import { getHeadlessAuthInfo, handleHeadlessRedirect } from '../headless-auth.js'
9
+ import { syncVaultPush } from '../vault-sync.js'
8
10
 
9
11
  export function registerOauthRoutes(ctx, state) {
10
12
  const {
@@ -44,6 +46,7 @@ export function registerOauthRoutes(ctx, state) {
44
46
  }
45
47
  const origin = requestOrigin(req)
46
48
  const redirectUri = redirectFor(provider, `${provider}:${index}`, origin)
49
+ sweepPending(Date.now())
47
50
  const pkce = await createPkce()
48
51
  pending.set(pkce.state, {
49
52
  provider,
@@ -89,7 +92,10 @@ export function registerOauthRoutes(ctx, state) {
89
92
  }
90
93
  } catch { autoCatch = false }
91
94
  }
92
- writeJson(res, 200, { ok: true, url, state: pkce.state, redirectUri, autoCatch })
95
+ const host = (req.headers && req.headers.host) || ''
96
+ const user = process.env.USER || process.env.USERNAME || 'user'
97
+ const headless = getHeadlessAuthInfo({ provider, redirectUri, authUrl: url, host, user })
98
+ writeJson(res, 200, { ok: true, url, state: pkce.state, redirectUri, autoCatch, headless })
93
99
  }),
94
100
  }), 'dsh-subscriptions: /oauth/start')
95
101
 
@@ -143,7 +149,13 @@ export function registerOauthRoutes(ctx, state) {
143
149
  intervalMs: start.intervalMs,
144
150
  createdAt: Date.now(),
145
151
  })
146
- writeJson(res, 200, { ok: true, state, userCode: start.userCode, authUrl: start.authUrl, intervalMs: start.intervalMs })
152
+ const host = (req.headers && req.headers.host) || ''
153
+ const user = process.env.USER || process.env.USERNAME || 'user'
154
+ const headless = getHeadlessAuthInfo({ provider, redirectUri: '', authUrl: start.authUrl, host, user })
155
+ if (headless.terminalQr) {
156
+ console.log('[dsh-subscriptions] Device OAuth initiated for ' + provider + ':\n' + headless.terminalQr)
157
+ }
158
+ writeJson(res, 200, { ok: true, state, userCode: start.userCode, authUrl: start.authUrl, intervalMs: start.intervalMs, headless })
147
159
  } catch (e) {
148
160
  writeJson(res, 502, { ok: false, error: { code: e && e.code || 'DEVICE', message: String(e && e.message || e).slice(0, 300) } })
149
161
  }
@@ -193,6 +205,9 @@ export function registerOauthRoutes(ctx, state) {
193
205
  pending.delete(state)
194
206
  await syncAdapter()
195
207
  refreshModels().catch(() => {})
208
+ if (live().vaultSyncEnabled && live().vaultSyncAutoPush) {
209
+ syncVaultPush({ live: live(), store }).catch(() => {})
210
+ }
196
211
  writeJson(res, 200, { ok: true, status: 'authorized', ref: row.ref, label: pmL(blob.label || blob.email) || displayName(row.provider) })
197
212
  } catch (e) {
198
213
  writeJson(res, 502, { ok: false, error: { code: e && e.code || 'DEVICE', message: String(e && e.message || e).slice(0, 300) } })
@@ -261,4 +276,31 @@ export function registerOauthRoutes(ctx, state) {
261
276
  }
262
277
  }),
263
278
  }), 'dsh-subscriptions: /oauth/complete')
279
+
280
+ // #557: Headless auth info helper endpoint
281
+ ctx.effect(() => ctx.webServer.register({
282
+ kind: 'exact',
283
+ path: '/dsh-subscriptions/oauth/headless-info',
284
+ handler: safeJsonHandler(async (req, res) => {
285
+ if (req.method !== 'GET') {
286
+ writeJson(res, 405, { ok: false, error: { code: 'method', message: 'GET only' } })
287
+ return
288
+ }
289
+ const q = queryOf(req)
290
+ const provider = q.get('provider') || ''
291
+ const redirectUri = q.get('redirectUri') || ''
292
+ const authUrl = q.get('authUrl') || ''
293
+ const host = (req.headers && req.headers.host) || ''
294
+ const user = process.env.USER || process.env.USERNAME || 'user'
295
+ const info = getHeadlessAuthInfo({ provider, redirectUri, authUrl, host, user })
296
+ writeJson(res, 200, { ok: true, ...info })
297
+ }),
298
+ }), 'dsh-subscriptions: /oauth/headless-info')
299
+
300
+ // #557: Headless re-auth callback redirector
301
+ ctx.effect(() => ctx.webServer.register({
302
+ kind: 'exact',
303
+ path: '/dsh-subscriptions/oauth/headless-redirect',
304
+ handler: (req, res) => handleHeadlessRedirect(req, res, { pending, completeOAuth, accountsView }),
305
+ }), 'dsh-subscriptions: /oauth/headless-redirect')
264
306
  }
@@ -58,6 +58,20 @@ export function registerProxyRoutes(ctx, state) {
58
58
  writeJson(res, 404, { ok: false, error: { code: 'provider', message: 'unknown provider' } })
59
59
  return
60
60
  }
61
+ const hasBackslashOrInvalid = parts.some((p) => {
62
+ if (p.includes("\\")) return true
63
+ try {
64
+ const dec = decodeURIComponent(p)
65
+ if (dec.includes("\\")) return true
66
+ } catch {
67
+ return true
68
+ }
69
+ return false
70
+ })
71
+ if (hasBackslashOrInvalid) {
72
+ writeJson(res, 400, { ok: false, error: { code: 'path', message: 'invalid path' } })
73
+ return
74
+ }
61
75
  let body
62
76
  if (req.method === 'POST') {
63
77
  try {
@@ -1,3 +1,5 @@
1
+ import { diagnoseVendorError } from '../doctor.js'
2
+ import { INSTRUCTIONS } from '../instructions.js'
1
3
  import {
2
4
  getModelReasoningCapabilities,
3
5
  setSessionReasoning,
@@ -505,9 +507,13 @@ export function registerStatusRoutes(ctx, state) {
505
507
  blob = await store.loadBlob(ref)
506
508
  blob = await store.ensureFresh(provider, blob, ref)
507
509
  } catch (e) {
508
- const latencyMs = Date.now() - t0
510
+ const latencyMs = Math.max(1, Date.now() - t0)
511
+ const latencyGrade = latencyMs < 250 ? 'good' : (latencyMs < 750 ? 'fair' : 'poor')
512
+ if (typeof store.rememberPing === "function") store.rememberPing(ref, { latencyMs, latencyGrade })
513
+ const docVerdict = diagnoseVendorError(e, provider, blob)
514
+ if (typeof store.rememberDoctorVerdict === "function") store.rememberDoctorVerdict(ref, docVerdict)
509
515
  history.add({ provider, ref, path: "/smoke", status: 401, ms: latencyMs })
510
- writeJson(res, 502, { ok: false, provider, index, ref, latencyMs, error: { code: 'auth_failed', message: String(e && e.message || e) } })
516
+ writeJson(res, 502, { ok: false, provider, index, ref, latencyMs, latencyGrade, doctor: docVerdict, error: { code: 'auth_failed', message: String(e && e.message || e) } })
511
517
  return
512
518
  }
513
519
 
@@ -525,7 +531,10 @@ export function registerStatusRoutes(ctx, state) {
525
531
  }
526
532
  try {
527
533
  await vendor.check(blob, vendorConfig(provider, live()), probeFetch)
528
- const latencyMs = Date.now() - t0
534
+ const latencyMs = Math.max(1, Date.now() - t0)
535
+ const latencyGrade = latencyMs < 250 ? 'good' : (latencyMs < 750 ? 'fair' : 'poor')
536
+ if (typeof store.rememberPing === "function") store.rememberPing(ref, { latencyMs, latencyGrade })
537
+ if (typeof store.rememberDoctorVerdict === "function") store.rememberDoctorVerdict(ref, null)
529
538
  history.add({ provider, ref, path: "/smoke", status: 200, ms: latencyMs })
530
539
  writeJson(res, 200, {
531
540
  ok: true,
@@ -533,6 +542,7 @@ export function registerStatusRoutes(ctx, state) {
533
542
  index,
534
543
  ref,
535
544
  latencyMs,
545
+ latencyGrade,
536
546
  label: pmL(blob.label),
537
547
  email: pmE(blob.email),
538
548
  quota: capturedQuota || info.quota || null,
@@ -540,7 +550,11 @@ export function registerStatusRoutes(ctx, state) {
540
550
  })
541
551
  return
542
552
  } catch (e) {
543
- const latencyMs = Date.now() - t0
553
+ const latencyMs = Math.max(1, Date.now() - t0)
554
+ const latencyGrade = latencyMs < 250 ? 'good' : (latencyMs < 750 ? 'fair' : 'poor')
555
+ if (typeof store.rememberPing === "function") store.rememberPing(ref, { latencyMs, latencyGrade })
556
+ const docVerdict = diagnoseVendorError(e, provider, blob)
557
+ if (typeof store.rememberDoctorVerdict === "function") store.rememberDoctorVerdict(ref, docVerdict)
544
558
  history.add({ provider, ref, path: "/smoke", status: 502, ms: latencyMs })
545
559
  writeJson(res, 502, {
546
560
  ok: false,
@@ -548,13 +562,18 @@ export function registerStatusRoutes(ctx, state) {
548
562
  index,
549
563
  ref,
550
564
  latencyMs,
565
+ latencyGrade,
566
+ doctor: docVerdict,
551
567
  error: { code: e && e.code ? e.code : 'VENDOR', message: String(e && e.message || e).slice(0, 300) },
552
568
  })
553
569
  return
554
570
  }
555
571
  }
556
572
 
557
- const latencyMs = Date.now() - t0
573
+ const latencyMs = Math.max(1, Date.now() - t0)
574
+ const latencyGrade = latencyMs < 250 ? 'good' : (latencyMs < 750 ? 'fair' : 'poor')
575
+ if (typeof store.rememberPing === "function") store.rememberPing(ref, { latencyMs, latencyGrade })
576
+ if (typeof store.rememberDoctorVerdict === "function") store.rememberDoctorVerdict(ref, null)
558
577
  history.add({ provider, ref, path: "/smoke", status: 200, ms: latencyMs })
559
578
  writeJson(res, 200, {
560
579
  ok: true,
@@ -562,6 +581,7 @@ export function registerStatusRoutes(ctx, state) {
562
581
  index,
563
582
  ref,
564
583
  latencyMs,
584
+ latencyGrade,
565
585
  label: pmL(blob.label),
566
586
  email: pmE(blob.email),
567
587
  quota: info.quota || null,
@@ -569,6 +589,80 @@ export function registerStatusRoutes(ctx, state) {
569
589
  }),
570
590
  }), 'dsh-subscriptions: /smoke')
571
591
 
592
+ // Issue #542: Latency RTT ping test endpoint
593
+ ctx.effect(() => ctx.webServer.register({
594
+ kind: 'exact',
595
+ path: '/dsh-subscriptions/ping',
596
+ handler: safeJsonHandler(async (req, res) => {
597
+ if (req.method !== 'POST') {
598
+ writeJson(res, 405, { ok: false, error: { code: 'method', message: 'POST only' } })
599
+ return
600
+ }
601
+ if (!isTrustedSettingsRequest(req, ctx)) {
602
+ writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'same-origin only' } })
603
+ return
604
+ }
605
+ let payload = {}
606
+ try { payload = JSON.parse((await readBody(req, 64 * 1024)).toString('utf8') || '{}') } catch { /* ignore ping payload */ }
607
+ const provider = payload.provider
608
+ const index = payload.index || 1
609
+ if (!provider || !isProvider(provider)) {
610
+ writeJson(res, 400, { ok: false, error: { code: 'invalid_provider', message: 'Provider required' } })
611
+ return
612
+ }
613
+ const ref = oauthRef(provider, index)
614
+ const info = await store.describeRef(ref)
615
+ if (!info.configured) {
616
+ writeJson(res, 400, { ok: false, error: { code: 'not_configured', message: 'Account is not configured' } })
617
+ return
618
+ }
619
+ const t0 = Date.now()
620
+ let latencyMs = 0
621
+ let latencyGrade = 'good'
622
+ try {
623
+ const blob = await store.loadBlob(ref)
624
+ const vendor = getVendor(provider)
625
+ const cfg = vendorConfig(provider, live())
626
+ const doFetch = (typeof fetchForRef === 'function' && fetchForRef(ref)) || fetch
627
+ if (typeof vendor.check === 'function') {
628
+ await vendor.check(blob, cfg, doFetch)
629
+ } else if (typeof vendor.listModels === 'function') {
630
+ await vendor.listModels(blob, cfg, doFetch)
631
+ }
632
+ latencyMs = Math.max(1, Date.now() - t0)
633
+ latencyGrade = latencyMs < 250 ? 'good' : (latencyMs < 750 ? 'fair' : 'poor')
634
+ if (typeof store.rememberPing === 'function') store.rememberPing(ref, { latencyMs, latencyGrade })
635
+ if (typeof store.rememberDoctorVerdict === 'function') store.rememberDoctorVerdict(ref, null)
636
+ writeJson(res, 200, { ok: true, provider, index, ref, latencyMs, latencyGrade })
637
+ } catch (err) {
638
+ latencyMs = Math.max(1, Date.now() - t0)
639
+ latencyGrade = 'poor'
640
+ const doc = diagnoseVendorError(err, provider)
641
+ if (typeof store.rememberPing === 'function') store.rememberPing(ref, { latencyMs, latencyGrade })
642
+ if (typeof store.rememberDoctorVerdict === 'function') store.rememberDoctorVerdict(ref, doc)
643
+ writeJson(res, 502, { ok: false, provider, index, ref, latencyMs, latencyGrade, doctor: doc, error: { message: String(err && err.message || err) } })
644
+ }
645
+ }),
646
+ }), 'dsh-subscriptions: /ping')
647
+
648
+ // Issue #540: Dynamic vendor instructions API
649
+ ctx.effect(() => ctx.webServer.register({
650
+ kind: 'exact',
651
+ path: '/dsh-subscriptions/instructions',
652
+ handler: safeJsonHandler(async (req, res) => {
653
+ if (req.method !== 'GET') {
654
+ writeJson(res, 405, { ok: false, error: { code: 'method', message: 'GET only' } })
655
+ return
656
+ }
657
+ if (!isTrustedSettingsRequest(req, ctx)) {
658
+ writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'same-origin only' } })
659
+ return
660
+ }
661
+ writeJson(res, 200, { ok: true, instructions: INSTRUCTIONS })
662
+ }),
663
+ }), 'dsh-subscriptions: /instructions')
664
+
665
+
572
666
  // #351: Quota and session alerts API
573
667
  ctx.effect(() => ctx.webServer.register({
574
668
  kind: 'exact',
@@ -0,0 +1,51 @@
1
+ export const REQUIRED_SCOPES = {
2
+ antigravity: [
3
+ { scope: "https://www.googleapis.com/auth/cloud-platform", description: "Google Cloud Platform & Code Assist" },
4
+ ],
5
+ codex: [
6
+ { scope: "openid", description: "OpenID authentication" },
7
+ { scope: "https://api.openai.com/auth", description: "ChatGPT API authorization" },
8
+ ],
9
+ claude: [
10
+ { scope: "user:profile", description: "Anthropic user profile" },
11
+ ],
12
+ copilot: [
13
+ { scope: "read:user", description: "GitHub user read" },
14
+ ],
15
+ grok: [
16
+ { scope: "openid", description: "OpenID authentication" },
17
+ ],
18
+ }
19
+
20
+ /**
21
+ * Inspect scopes granted by provider against required scopes.
22
+ * @param {string} provider
23
+ * @param {string[]|string} rawScopes
24
+ * @returns {{ scopes: string[], valid: boolean, missing: string[], warning: string, scopeWarning: string }}
25
+ */
26
+ export function inspectScopes(provider, rawScopes) {
27
+ const scopes = Array.isArray(rawScopes)
28
+ ? rawScopes.map(String).filter(Boolean)
29
+ : String(rawScopes || "").split(/[\s,]+/).filter(Boolean)
30
+
31
+ const reqList = REQUIRED_SCOPES[provider] || []
32
+ if (reqList.length === 0 || scopes.length === 0) {
33
+ return { scopes, valid: true, missing: [], warning: "", scopeWarning: "" }
34
+ }
35
+
36
+ const missing = reqList.filter((r) =>
37
+ !scopes.some((s) => s.toLowerCase() === r.scope.toLowerCase())
38
+ )
39
+
40
+ const warning = missing.length > 0
41
+ ? `Missing required OAuth scope: ${missing.map((m) => m.description || m.scope).join(", ")}`
42
+ : ""
43
+
44
+ return {
45
+ scopes,
46
+ valid: missing.length === 0,
47
+ missing: missing.map((m) => m.scope),
48
+ warning,
49
+ scopeWarning: warning,
50
+ }
51
+ }