@goodandready/dsh-subscriptions 0.6.72 → 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 (47) hide show
  1. package/README.md +7 -0
  2. package/README.ru.md +7 -0
  3. package/README.zh.md +7 -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 +18 -0
  11. package/lib/capabilities.js +208 -0
  12. package/lib/client.js +344 -17
  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/error-buffer.js +2 -2
  17. package/lib/forecast.js +56 -0
  18. package/lib/headless-auth.js +379 -0
  19. package/lib/history.js +36 -0
  20. package/lib/http.js +26 -4
  21. package/lib/index.js +98 -11
  22. package/lib/jwt.js +84 -17
  23. package/lib/loopback.js +3 -2
  24. package/lib/mask.js +16 -0
  25. package/lib/oauth-pending.js +12 -0
  26. package/lib/pricing.js +89 -0
  27. package/lib/refs.js +2 -0
  28. package/lib/reset-clock.js +55 -0
  29. package/lib/rotate.js +18 -4
  30. package/lib/routes/accounts.js +131 -0
  31. package/lib/routes/oauth.js +44 -2
  32. package/lib/routes/proxy.js +14 -0
  33. package/lib/scope-inspector.js +51 -0
  34. package/lib/stream-rotate.js +31 -10
  35. package/lib/updater.js +21 -0
  36. package/lib/vault-sync.js +412 -0
  37. package/lib/vault.js +1 -1
  38. package/lib/vendor-factory.js +6 -2
  39. package/lib/vendors/antigravity.js +11 -2
  40. package/lib/vendors/claude.js +56 -8
  41. package/lib/vendors/codex.js +34 -6
  42. package/lib/vendors/copilot-cli.js +8 -1
  43. package/lib/vendors/ernie.js +9 -2
  44. package/lib/vendors/grok.js +66 -6
  45. package/lib/vendors/jetbrains.js +133 -13
  46. package/lib/vendors/perplexity.js +148 -17
  47. package/package.json +1 -1
@@ -1,5 +1,6 @@
1
1
  import { diagnoseVendorError } from '../doctor.js'
2
2
  import { exportVault, importVault } from '../vault.js'
3
+ import { syncVaultPush, syncVaultPull, getVaultSyncStatus } from '../vault-sync.js'
3
4
  import { normalizeSlots, vendorConfig } from '../accounts.js'
4
5
  import { getVendor } from '../vendors/index.js'
5
6
  import { notifyQuarantineEntered, notifyQuarantineReleased } from '../alerts.js'
@@ -518,6 +519,69 @@ export function registerAccountsRoutes(ctx, state) {
518
519
 
519
520
 
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')
584
+
521
585
  ctx.effect(() => ctx.webServer.register({
522
586
  kind: 'exact',
523
587
  path: '/dsh-subscriptions/logout',
@@ -660,4 +724,71 @@ export function registerAccountsRoutes(ctx, state) {
660
724
  handler: handleVaultImport,
661
725
  }), 'dsh-subscriptions: /import-backup')
662
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')
663
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 {
@@ -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
+ }
@@ -1,6 +1,7 @@
1
+ import { inspectAccountCapabilities, normalizeCapabilitiesRequest } from './capabilities.js'
1
2
  import { defaultConcurrencyQueue } from './concurrency-queue.js'
2
3
  import { recordSessionCacheUsage } from './session-pin.js'
3
- import { pickAccountWithTrace, markCooldown, isSwitchableError, isRegionError, modelFamily } from './rotate.js'
4
+ import { pickAccountWithTrace, markCooldown, isSwitchableError, isRegionError, isAuthError, modelFamily } from './rotate.js'
4
5
  import { putInQuarantine, REASON_RATE_LIMIT, REASON_HARD_LIMIT, REASON_REVOKED, REASON_DEGRADED } from './quarantine.js'
5
6
  import { notifySessionExpired, notifyQuarantineEntered } from './alerts.js'
6
7
  import { withRunawayGuard } from './runaway-guard.js'
@@ -10,7 +11,7 @@ import { resolveAccurateReset } from './reset-clock.js'
10
11
 
11
12
  export async function* streamWithRotation({
12
13
  accounts,
13
- nowMs,
14
+ nowMs = () => Date.now(),
14
15
  cooldownMs,
15
16
  switchAtRemaining,
16
17
  streamOnce,
@@ -111,8 +112,20 @@ export async function* streamWithRotation({
111
112
  const queueConfig = {
112
113
  signal: options && options.signal,
113
114
  timeoutMs: options && (options.queueTimeoutMs !== undefined ? options.queueTimeoutMs : (options.config && options.config.queueTimeoutMs)),
114
- maxConcurrency: options && (options.accountMaxConcurrency !== undefined ? options.accountMaxConcurrency : (options.config && options.config.accountMaxConcurrency)),
115
- maxQueueSize: options && (options.maxQueueSize !== undefined ? options.maxQueueSize : (options.config && options.config.maxQueueSize)),
115
+ maxConcurrency: account.maxConcurrency !== undefined
116
+ ? account.maxConcurrency
117
+ : (options && options.accountMaxConcurrency !== undefined
118
+ ? options.accountMaxConcurrency
119
+ : (options && options.config && options.config.accountMaxConcurrency !== undefined
120
+ ? options.config.accountMaxConcurrency
121
+ : 1)),
122
+ maxQueueSize: account.maxQueueSize !== undefined
123
+ ? account.maxQueueSize
124
+ : (options && options.maxQueueSize !== undefined
125
+ ? options.maxQueueSize
126
+ : (options && options.config && options.config.maxQueueSize !== undefined
127
+ ? options.config.maxQueueSize
128
+ : 20)),
116
129
  enabled: options && (options.enableQueue !== undefined ? options.enableQueue : (options.config && options.config.enableQueue !== undefined ? options.config.enableQueue : true)),
117
130
  }
118
131
 
@@ -146,15 +159,19 @@ export async function* streamWithRotation({
146
159
  runawayState.reason = reason
147
160
  },
148
161
  }
162
+ const accountCaps = account.capabilities || inspectAccountCapabilities(account.provider || options?.provider, account, account)
163
+ const adaptedOptions = normalizeCapabilitiesRequest(options, accountCaps)
149
164
  const guardedStream = withRunawayGuard(
150
- streamOnce(account, { ...options, _runawayState: runawayState }),
165
+ streamOnce(account, { ...adaptedOptions, _runawayState: runawayState }),
151
166
  runawayOpts
152
167
  )
153
168
  const sentineledStream = withSoftFailureSentinel(guardedStream, {
154
169
  enabled: (options?.enableSoftFailureSentinel !== false) && (options?.config?.enableSoftFailureSentinel !== false),
155
170
  })
156
171
  for await (const chunk of sentineledStream) {
157
- firstChunkDelivered = true
172
+ if (chunk && (chunk.type === 'text-delta' || chunk.type === 'delta' || chunk.text || chunk.content)) {
173
+ firstChunkDelivered = true
174
+ }
158
175
  const cachedTokens = Number(
159
176
  (chunk && chunk.usage && (chunk.usage.cacheReadTokens || chunk.usage.cachedTokens)) ||
160
177
  (chunk && chunk.tokens && (chunk.tokens.cacheRead || chunk.tokens.cached)) ||
@@ -191,7 +208,7 @@ export async function* streamWithRotation({
191
208
  continue
192
209
  }
193
210
 
194
- if (!isSwitchableError(err)) {
211
+ if (!isSwitchableError(err) && !isAuthError(err)) {
195
212
  currentAttempt.status = 'fatal_error'
196
213
  currentAttempt.error = err ? (err.message || String(err)) : 'unknown'
197
214
  syncTrace('fatal_error')
@@ -203,9 +220,10 @@ export async function* streamWithRotation({
203
220
 
204
221
  const accurateUntil = resolveAccurateReset(err, account, options && options.provider, nowMs())
205
222
  const isSoft = Boolean(err && (err.code === 'SOFT_FAILURE' || err.isSoftFailure))
223
+ const isAuth = isAuthError(err)
206
224
 
207
225
  // Move slot to cooldown and quarantine (#172, #538)
208
- const effectiveCooldownMs = isSoft ? Math.min(cooldownMs, 5 * 60 * 1000) : cooldownMs
226
+ const effectiveCooldownMs = isSoft ? Math.min(cooldownMs, 5 * 60 * 1000) : (isAuth ? 86400000 : cooldownMs)
209
227
  const cooled = markCooldown(account, nowMs(), effectiveCooldownMs, modelFamily(options && options.provider, options && options.model), accurateUntil)
210
228
  account.cooldownUntil = cooled.cooldownUntil
211
229
  if (cooled.cooldownFamilies) account.cooldownFamilies = cooled.cooldownFamilies
@@ -214,7 +232,7 @@ export async function* streamWithRotation({
214
232
  const code = String(err && err.code || '')
215
233
  const reason = isSoft
216
234
  ? REASON_DEGRADED
217
- : (status === 401 || code === 'AUTH' || code === 'TOKEN_REVOKED')
235
+ : (isAuth || status === 401 || code === 'AUTH' || code === 'TOKEN_REVOKED')
218
236
  ? REASON_REVOKED
219
237
  : (status === 403 || code === 'HARD_LIMIT' || code === 'LICENSE_REQUIRED')
220
238
  ? REASON_HARD_LIMIT
@@ -226,11 +244,14 @@ export async function* streamWithRotation({
226
244
  account.quarantineAttempts = err.warmupAccount.quarantineAttempts
227
245
  account.status = err.warmupAccount.status
228
246
  } else {
229
- const quarantined = putInQuarantine(account, reason, nowMs(), accurateUntil)
247
+ const quarantined = putInQuarantine(account, reason, nowMs(), accurateUntil || (nowMs() + (isAuth ? 86400000 : 3600000)))
230
248
  account.quarantineUntil = quarantined.quarantineUntil
231
249
  account.quarantineReason = quarantined.quarantineReason
232
250
  account.quarantineAttempts = (account.quarantineAttempts || 0) + 1
233
251
  }
252
+ if (isAuth || reason === REASON_REVOKED) {
253
+ account.isRevoked = true
254
+ }
234
255
 
235
256
  currentAttempt.quarantineReason = account.quarantineReason || reason
236
257
  currentAttempt.quarantineUntil = account.quarantineUntil
package/lib/updater.js CHANGED
@@ -24,6 +24,27 @@ export function isLoopback(value) {
24
24
  }
25
25
 
26
26
  export function isTrustedUpdateRequest(request, targetCtx) {
27
+ const secFetchSite = String(header(request, 'sec-fetch-site') || '').toLowerCase()
28
+ if (secFetchSite === 'cross-site') return false
29
+
30
+ const origin = header(request, 'origin')
31
+ const host = header(request, 'host')
32
+ if (origin && host) {
33
+ try {
34
+ const u = new URL(origin)
35
+ if (u.protocol === 'http:' || u.protocol === 'https:') {
36
+ if (u.host.toLowerCase() !== host.toLowerCase()) return false
37
+ } else if (u.protocol === 'dsh-app:') {
38
+ const appHost = u.host.toLowerCase()
39
+ if (appHost && appHost !== 'app' && appHost !== 'localhost') return false
40
+ } else {
41
+ return false
42
+ }
43
+ } catch {
44
+ return false
45
+ }
46
+ }
47
+
27
48
  const customHeader = header(request, UPDATE_HEADER)
28
49
  if (customHeader === '1') {
29
50
  const remote = request.socket?.remoteAddress