@flamingo-stack/openframe-frontend-core 0.0.557 → 0.0.558-1830.4885.1

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 (120) hide show
  1. package/dist/{chunk-A6TAVIBX.cjs → chunk-3TKG7SW6.cjs} +42 -42
  2. package/dist/{chunk-A6TAVIBX.cjs.map → chunk-3TKG7SW6.cjs.map} +1 -1
  3. package/dist/{chunk-SBKX2VSG.js → chunk-4VLGJEKQ.js} +2 -2
  4. package/dist/{chunk-V5EYGEX7.cjs → chunk-53GNFRVQ.cjs} +7 -7
  5. package/dist/{chunk-V5EYGEX7.cjs.map → chunk-53GNFRVQ.cjs.map} +1 -1
  6. package/dist/{chunk-FXVMRZIQ.js → chunk-5JHJX7OX.js} +5 -5
  7. package/dist/{chunk-6YXEZCVG.cjs → chunk-5WQPOH27.cjs} +5 -5
  8. package/dist/{chunk-6YXEZCVG.cjs.map → chunk-5WQPOH27.cjs.map} +1 -1
  9. package/dist/{chunk-RPCY2FAC.js → chunk-C5HQCF4R.js} +6 -6
  10. package/dist/{chunk-4WREPWRL.cjs → chunk-CWQSUYVV.cjs} +3 -3
  11. package/dist/{chunk-4WREPWRL.cjs.map → chunk-CWQSUYVV.cjs.map} +1 -1
  12. package/dist/{chunk-33FOBAWS.cjs → chunk-DHSIAIFG.cjs} +18 -18
  13. package/dist/{chunk-33FOBAWS.cjs.map → chunk-DHSIAIFG.cjs.map} +1 -1
  14. package/dist/{chunk-VJL46NHW.js → chunk-DNPQ2U7P.js} +4 -4
  15. package/dist/{chunk-G7ROZAY5.js → chunk-DV4CSNZD.js} +3 -3
  16. package/dist/{chunk-25YLMIWU.js → chunk-E26KVZJM.js} +3 -3
  17. package/dist/{chunk-YHRE4JBF.cjs → chunk-FCCSHC3H.cjs} +32 -32
  18. package/dist/{chunk-YHRE4JBF.cjs.map → chunk-FCCSHC3H.cjs.map} +1 -1
  19. package/dist/{chunk-DRNSIK3V.js → chunk-FVHFBEXH.js} +2 -2
  20. package/dist/{chunk-6ZOVSD4M.cjs → chunk-GYQHUJWR.cjs} +14 -14
  21. package/dist/{chunk-6ZOVSD4M.cjs.map → chunk-GYQHUJWR.cjs.map} +1 -1
  22. package/dist/{chunk-G3NBTL4I.js → chunk-IHXUZBBV.js} +6 -4
  23. package/dist/{chunk-G3NBTL4I.js.map → chunk-IHXUZBBV.js.map} +1 -1
  24. package/dist/{chunk-WBQ3NOH7.js → chunk-KL6ZDLDF.js} +2 -2
  25. package/dist/{chunk-Y6TXTBIQ.js → chunk-LM6NDYGD.js} +2 -2
  26. package/dist/{chunk-5HNZTPAB.cjs → chunk-N3OGB543.cjs} +12 -12
  27. package/dist/{chunk-5HNZTPAB.cjs.map → chunk-N3OGB543.cjs.map} +1 -1
  28. package/dist/{chunk-VJTD2DGF.cjs → chunk-NK3M6F5A.cjs} +39 -30
  29. package/dist/chunk-NK3M6F5A.cjs.map +1 -0
  30. package/dist/{chunk-DFJ7TWH6.cjs → chunk-NKEOD2AD.cjs} +3 -3
  31. package/dist/{chunk-DFJ7TWH6.cjs.map → chunk-NKEOD2AD.cjs.map} +1 -1
  32. package/dist/{chunk-5JF6H7OJ.cjs → chunk-R3OCP4A7.cjs} +76 -76
  33. package/dist/{chunk-5JF6H7OJ.cjs.map → chunk-R3OCP4A7.cjs.map} +1 -1
  34. package/dist/{chunk-IPO24G7U.cjs → chunk-R3TXAMMB.cjs} +143 -141
  35. package/dist/{chunk-IPO24G7U.cjs.map → chunk-R3TXAMMB.cjs.map} +1 -1
  36. package/dist/{chunk-LJWTXODF.js → chunk-S3NZZKNU.js} +3 -3
  37. package/dist/{chunk-BE6TRUKY.cjs → chunk-SI64DC72.cjs} +40 -40
  38. package/dist/{chunk-BE6TRUKY.cjs.map → chunk-SI64DC72.cjs.map} +1 -1
  39. package/dist/{chunk-NNGQ6ONX.js → chunk-UVGFICZT.js} +8 -8
  40. package/dist/{chunk-4EGNYOZA.cjs → chunk-VNWGXQJD.cjs} +23 -23
  41. package/dist/{chunk-4EGNYOZA.cjs.map → chunk-VNWGXQJD.cjs.map} +1 -1
  42. package/dist/{chunk-QCBBX6DO.cjs → chunk-XDMRN3SG.cjs} +97 -97
  43. package/dist/{chunk-QCBBX6DO.cjs.map → chunk-XDMRN3SG.cjs.map} +1 -1
  44. package/dist/{chunk-64HHDGRN.cjs → chunk-XL2G2HHJ.cjs} +4 -4
  45. package/dist/{chunk-64HHDGRN.cjs.map → chunk-XL2G2HHJ.cjs.map} +1 -1
  46. package/dist/{chunk-UTEQHK3H.js → chunk-YUVCH2LX.js} +6 -6
  47. package/dist/{chunk-26HAJBSQ.js → chunk-Z5FN5VTC.js} +2 -2
  48. package/dist/{chunk-VRX6Y6A7.js → chunk-ZNEVGXMU.js} +23 -14
  49. package/dist/chunk-ZNEVGXMU.js.map +1 -0
  50. package/dist/{chunk-RRWWAH37.js → chunk-ZSZ24UY6.js} +2 -2
  51. package/dist/components/case-studies/index.cjs +9 -9
  52. package/dist/components/case-studies/index.js +3 -3
  53. package/dist/components/chat/index.cjs +3 -3
  54. package/dist/components/chat/index.js +2 -2
  55. package/dist/components/contact/index.cjs +4 -4
  56. package/dist/components/contact/index.js +3 -3
  57. package/dist/components/docs/index.cjs +6 -6
  58. package/dist/components/docs/index.js +5 -5
  59. package/dist/components/embeds/index.cjs +4 -4
  60. package/dist/components/embeds/index.js +3 -3
  61. package/dist/components/faq/index.cjs +5 -5
  62. package/dist/components/faq/index.js +4 -4
  63. package/dist/components/features/index.cjs +3 -3
  64. package/dist/components/features/index.js +2 -2
  65. package/dist/components/features/video.d.ts.map +1 -1
  66. package/dist/components/help-center-pages/index.cjs +24 -24
  67. package/dist/components/help-center-pages/index.js +15 -15
  68. package/dist/components/index.cjs +137 -137
  69. package/dist/components/index.js +13 -13
  70. package/dist/components/meeting-scheduler/index.cjs +34 -34
  71. package/dist/components/meeting-scheduler/index.js +4 -4
  72. package/dist/components/navigation/index.cjs +3 -3
  73. package/dist/components/navigation/index.js +2 -2
  74. package/dist/components/onboarding-guides/index.cjs +7 -7
  75. package/dist/components/onboarding-guides/index.js +6 -6
  76. package/dist/components/related-content/index.cjs +5 -5
  77. package/dist/components/related-content/index.js +4 -4
  78. package/dist/components/tickets/index.cjs +7 -7
  79. package/dist/components/tickets/index.js +6 -6
  80. package/dist/components/ui/index.cjs +3 -3
  81. package/dist/components/ui/index.js +2 -2
  82. package/dist/hooks/index.cjs +6 -2
  83. package/dist/hooks/index.cjs.map +1 -1
  84. package/dist/hooks/index.d.ts +1 -0
  85. package/dist/hooks/index.d.ts.map +1 -1
  86. package/dist/hooks/index.js +5 -1
  87. package/dist/hooks/use-authed-asset-src.d.ts +16 -0
  88. package/dist/hooks/use-authed-asset-src.d.ts.map +1 -0
  89. package/dist/hooks/use-authed-image-src.d.ts +15 -6
  90. package/dist/hooks/use-authed-image-src.d.ts.map +1 -1
  91. package/dist/index.cjs +7 -3
  92. package/dist/index.cjs.map +1 -1
  93. package/dist/index.js +6 -2
  94. package/dist/utils/embed-authed-fetch.d.ts +24 -10
  95. package/dist/utils/embed-authed-fetch.d.ts.map +1 -1
  96. package/dist/utils/index.cjs.map +1 -1
  97. package/dist/utils/index.js.map +1 -1
  98. package/package.json +1 -1
  99. package/src/components/features/video.tsx +13 -2
  100. package/src/hooks/index.ts +1 -0
  101. package/src/hooks/use-authed-asset-src.ts +98 -0
  102. package/src/hooks/use-authed-image-src.ts +15 -84
  103. package/src/utils/__tests__/embed-authed-fetch.test.ts +50 -1
  104. package/src/utils/embed-authed-fetch.ts +31 -14
  105. package/dist/chunk-VJTD2DGF.cjs.map +0 -1
  106. package/dist/chunk-VRX6Y6A7.js.map +0 -1
  107. /package/dist/{chunk-SBKX2VSG.js.map → chunk-4VLGJEKQ.js.map} +0 -0
  108. /package/dist/{chunk-FXVMRZIQ.js.map → chunk-5JHJX7OX.js.map} +0 -0
  109. /package/dist/{chunk-RPCY2FAC.js.map → chunk-C5HQCF4R.js.map} +0 -0
  110. /package/dist/{chunk-VJL46NHW.js.map → chunk-DNPQ2U7P.js.map} +0 -0
  111. /package/dist/{chunk-G7ROZAY5.js.map → chunk-DV4CSNZD.js.map} +0 -0
  112. /package/dist/{chunk-25YLMIWU.js.map → chunk-E26KVZJM.js.map} +0 -0
  113. /package/dist/{chunk-DRNSIK3V.js.map → chunk-FVHFBEXH.js.map} +0 -0
  114. /package/dist/{chunk-WBQ3NOH7.js.map → chunk-KL6ZDLDF.js.map} +0 -0
  115. /package/dist/{chunk-Y6TXTBIQ.js.map → chunk-LM6NDYGD.js.map} +0 -0
  116. /package/dist/{chunk-LJWTXODF.js.map → chunk-S3NZZKNU.js.map} +0 -0
  117. /package/dist/{chunk-NNGQ6ONX.js.map → chunk-UVGFICZT.js.map} +0 -0
  118. /package/dist/{chunk-UTEQHK3H.js.map → chunk-YUVCH2LX.js.map} +0 -0
  119. /package/dist/{chunk-26HAJBSQ.js.map → chunk-Z5FN5VTC.js.map} +0 -0
  120. /package/dist/{chunk-RRWWAH37.js.map → chunk-ZSZ24UY6.js.map} +0 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@flamingo-stack/openframe-frontend-core",
3
- "version": "0.0.557",
3
+ "version": "0.0.558-1830.4885.1",
4
4
  "description": "Shared design system and components for all Flamingo platforms",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -36,6 +36,7 @@ import { VideoPlayBadge, VideoUnmuteGlyph } from './video-center-badge';
36
36
  import { fetchPriorityProp } from '../../utils/fetch-priority';
37
37
  import { useIosNativeVideoFullscreen } from './use-ios-native-video-fullscreen';
38
38
  import { saveDataEnabled } from './use-video-warmup';
39
+ import { useAuthedAssetSrc } from '../../hooks/use-authed-asset-src';
39
40
 
40
41
  // =============================================================================
41
42
  // Dev-only hover→playing latency instrumentation gate. Always on in dev
@@ -647,6 +648,16 @@ function FilePlayer({
647
648
  // an early manifest fetch is at most one bounded manifest+segment — the
648
649
  // Save-Data verification gate covers it (see plan A2.4).
649
650
  const effectivePreload = preload ?? (saveDataEnabled() ? 'none' : 'metadata');
651
+ // THE captions seam. `<track src>` is a browser subresource: it carries no
652
+ // custom headers, so it authenticates by cookie only. Fine on the cookie-auth
653
+ // web, a guaranteed 401 on every HEADER-auth host — native shells and
654
+ // dev-ticket web — whose caption route sits behind the same proxied
655
+ // `/content/api/captions` path as every other embedded endpoint. Resolving it
656
+ // through `useAuthedAssetSrc` puts the track on the SAME single auth knob as
657
+ // every embedded `fetch` (adapter bearer + 401-refresh-retry), so a host
658
+ // configures `endpoints.captionsUrlPrefix` and nothing else — no per-surface,
659
+ // per-host caption plumbing. Cookie-auth hosts get the URL back untouched.
660
+ const resolvedCaptionsUrl = useAuthedAssetSrc(captionsUrl, 'text/vtt, */*');
650
661
  // True while hover playback is running MUTED because the browser's autoplay
651
662
  // policy blocked sound (no user activation yet). Drives the center unmute
652
663
  // control — the industry pattern (muted autoplay + explicit unmute button)
@@ -1030,10 +1041,10 @@ function FilePlayer({
1030
1041
  ...(fit === 'cover' ? ({ '--media-object-fit': 'cover' } as React.CSSProperties) : {}),
1031
1042
  }}
1032
1043
  >
1033
- {captionsUrl ? (
1044
+ {resolvedCaptionsUrl ? (
1034
1045
  <track
1035
1046
  kind="captions"
1036
- src={captionsUrl}
1047
+ src={resolvedCaptionsUrl}
1037
1048
  srcLang="en"
1038
1049
  label="English"
1039
1050
  default
@@ -8,6 +8,7 @@ export * from './use-copy-to-clipboard'
8
8
  // Image authentication hooks
9
9
  export * from './use-batch-images'
10
10
  export * from './use-authenticated-image'
11
+ export * from './use-authed-asset-src'
11
12
  export * from './use-authed-image-src'
12
13
 
13
14
  // URL State Management hooks
@@ -0,0 +1,98 @@
1
+ 'use client'
2
+
3
+ import { useEffect, useReducer } from 'react'
4
+ import { embedAuthedFetch, needsBearerAssetFetch } from '../utils/embed-authed-fetch'
5
+
6
+ /**
7
+ * Resolve ANY asset URL the browser would load natively — `<img src>`,
8
+ * `<track src>`, a font, a poster — for hosts whose auth rides in request
9
+ * HEADERS instead of cookies: native shells (`capacitor://`, `tauri://`) and
10
+ * dev-ticket web, both in bearer mode. A native subresource load can't carry
11
+ * an `Authorization` header, so a gateway URL that works on the cookie-auth
12
+ * web 401s there.
13
+ *
14
+ * When `needsBearerAssetFetch` says the URL is bearer-authed, this hook
15
+ * fetches it through `embedAuthedFetch` (bearer + deduped 401-refresh-retry +
16
+ * the adapter's cross-origin guard — the SAME single auth knob every embedded
17
+ * `fetch` in the lib rides) and returns a blob object-URL. Every other URL is
18
+ * returned unchanged, so on the cookie-auth web the hook is a pass-through.
19
+ *
20
+ * Returns `undefined` while the fetch is in flight OR after it fails, so
21
+ * callers show their existing placeholder / "no captions" branch instead of a
22
+ * broken asset; a failed fetch retries on the next mount.
23
+ *
24
+ * Cache: module-level, session-lifetime, keyed by full URL — so the two
25
+ * `<track>` URLs of one player, or the same avatar in twenty rows, cost one
26
+ * request. Gateway URLs carry a `?v=<content-hash>` cache-buster, so changed
27
+ * content is a new key; entries are never revoked (a VTT / avatar is a few KB
28
+ * — refcount churn isn't worth it) and are dropped wholesale at session end
29
+ * via `clearAuthedAssetCache`.
30
+ */
31
+
32
+ const resolvedCache = new Map<string, string>()
33
+ const inFlight = new Map<string, Promise<void>>()
34
+ let cacheGeneration = 0
35
+
36
+ /**
37
+ * Drop every cached blob and revoke its object URL. Hosts call this at
38
+ * session end (logout / forced re-login) so a follow-on login as a
39
+ * different identity cannot be served blobs fetched under the previous
40
+ * one's bearer. Fetches still in flight when the clear happens are fenced
41
+ * by a generation counter — their results are revoked instead of cached.
42
+ */
43
+ export function clearAuthedAssetCache(): void {
44
+ cacheGeneration++
45
+ for (const url of resolvedCache.values()) URL.revokeObjectURL(url)
46
+ resolvedCache.clear()
47
+ inFlight.clear()
48
+ }
49
+
50
+ function fetchAsBlobUrl(src: string, accept: string): Promise<void> {
51
+ let pending = inFlight.get(src)
52
+ if (!pending) {
53
+ const startedGeneration = cacheGeneration
54
+ pending = embedAuthedFetch(src, { headers: { Accept: accept } })
55
+ .then(async response => {
56
+ if (!response.ok) throw new Error(`asset fetch failed: ${response.status}`)
57
+ const blobUrl = URL.createObjectURL(await response.blob())
58
+ if (startedGeneration === cacheGeneration) {
59
+ resolvedCache.set(src, blobUrl)
60
+ } else {
61
+ URL.revokeObjectURL(blobUrl)
62
+ }
63
+ })
64
+ .finally(() => {
65
+ if (inFlight.get(src) === pending) inFlight.delete(src)
66
+ })
67
+ inFlight.set(src, pending)
68
+ }
69
+ return pending
70
+ }
71
+
72
+ /**
73
+ * @param src the asset URL (relative or absolute), or null/undefined
74
+ * @param accept `Accept` header for the authed fetch — narrow it to what the
75
+ * consumer renders (`image/*`, `text/vtt`); it also replaces
76
+ * `embedAuthedFetch`'s default JSON `Content-Type` on the GET.
77
+ */
78
+ export function useAuthedAssetSrc(src?: string | null, accept = '*/*'): string | undefined {
79
+ const bearerSrc = src && needsBearerAssetFetch(src) ? src : null
80
+ const [, rerender] = useReducer((c: number) => c + 1, 0)
81
+
82
+ useEffect(() => {
83
+ if (!bearerSrc || resolvedCache.has(bearerSrc)) return
84
+ let cancelled = false
85
+ fetchAsBlobUrl(bearerSrc, accept)
86
+ .catch(() => {})
87
+ .finally(() => {
88
+ if (!cancelled) rerender()
89
+ })
90
+ return () => {
91
+ cancelled = true
92
+ }
93
+ }, [bearerSrc, accept])
94
+
95
+ if (!src) return undefined
96
+ if (!bearerSrc) return src
97
+ return resolvedCache.get(bearerSrc)
98
+ }
@@ -1,93 +1,24 @@
1
1
  'use client'
2
2
 
3
- import { useEffect, useReducer } from 'react'
4
- import { embedAuthedFetch, needsBearerAssetFetch } from '../utils/embed-authed-fetch'
3
+ import { clearAuthedAssetCache, useAuthedAssetSrc } from './use-authed-asset-src'
5
4
 
6
5
  /**
7
- * Resolve an image URL for hosts whose auth rides in request headers
8
- * instead of cookies (native shells `capacitor://localhost`,
9
- * `tauri://localhost` running in bearer mode). A native `<img>` load
10
- * can't carry an `Authorization` header, so a gateway image URL that
11
- * works on the cookie-auth web 401s in a shell. When
12
- * `needsBearerAssetFetch` says the URL's origin is bearer-authed, this
13
- * hook fetches it through `embedAuthedFetch` (bearer + deduped
14
- * 401-refresh-retry + the adapter's cross-origin guard) and returns a
15
- * blob object-URL; every other URL is returned unchanged, so on the
16
- * cookie-auth web the hook is a pass-through.
6
+ * Image flavor of `useAuthedAssetSrc` see that hook for the full contract.
7
+ * Kept as its own export because it is the lib's most-used consumer (avatars,
8
+ * entity images, markdown `<img>`) and because hosts already import this name.
17
9
  *
18
- * Returns `undefined` while the fetch is in flight OR after it fails, so
19
- * callers show their existing initials/placeholder fallback instead of a
20
- * broken image; a failed fetch retries on the next mount.
21
- *
22
- * Cache: module-level, session-lifetime, keyed by full URL. Gateway image
23
- * URLs carry a `?v=<content-hash>` cache-buster, so a changed image is a
24
- * new key; entries are never revoked (avatars are a few KB — refcount
25
- * churn isn't worth it).
26
- *
27
- * Distinct from the legacy `useAuthenticatedImage`, which ALWAYS
28
- * blob-fetches with `credentials: 'include'` and its own global config.
29
- * This hook is pass-through-first and keys off the single auth knob the
30
- * lib already has: the registered `EmbedAuthAdapter`.
10
+ * Distinct from the legacy `useAuthenticatedImage`, which ALWAYS blob-fetches
11
+ * with `credentials: 'include'` and its own global config. This hook is
12
+ * pass-through-first and keys off the single auth knob the lib already has:
13
+ * the registered `EmbedAuthAdapter`.
31
14
  */
32
-
33
- const resolvedCache = new Map<string, string>()
34
- const inFlight = new Map<string, Promise<void>>()
35
- let cacheGeneration = 0
15
+ export function useAuthedImageSrc(src?: string | null): string | undefined {
16
+ return useAuthedAssetSrc(src, 'image/*')
17
+ }
36
18
 
37
19
  /**
38
- * Drop every cached blob and revoke its object URL. Hosts call this at
39
- * session end (logout / forced re-login) so a follow-on login as a
40
- * different identity cannot be served blobs fetched under the previous
41
- * one's bearer. Fetches still in flight when the clear happens are fenced
42
- * by a generation counter — their results are revoked instead of cached.
20
+ * Session-end cache drop. Now an alias of `clearAuthedAssetCache` one cache
21
+ * backs every authed asset (images AND caption tracks), so hosts keep calling
22
+ * this single function on logout / forced re-login.
43
23
  */
44
- export function clearAuthedImageCache(): void {
45
- cacheGeneration++
46
- for (const url of resolvedCache.values()) URL.revokeObjectURL(url)
47
- resolvedCache.clear()
48
- inFlight.clear()
49
- }
50
-
51
- function fetchAsBlobUrl(src: string): Promise<void> {
52
- let pending = inFlight.get(src)
53
- if (!pending) {
54
- const startedGeneration = cacheGeneration
55
- pending = embedAuthedFetch(src, { headers: { Accept: 'image/*' } })
56
- .then(async response => {
57
- if (!response.ok) throw new Error(`image fetch failed: ${response.status}`)
58
- const blobUrl = URL.createObjectURL(await response.blob())
59
- if (startedGeneration === cacheGeneration) {
60
- resolvedCache.set(src, blobUrl)
61
- } else {
62
- URL.revokeObjectURL(blobUrl)
63
- }
64
- })
65
- .finally(() => {
66
- if (inFlight.get(src) === pending) inFlight.delete(src)
67
- })
68
- inFlight.set(src, pending)
69
- }
70
- return pending
71
- }
72
-
73
- export function useAuthedImageSrc(src?: string | null): string | undefined {
74
- const bearerSrc = src && needsBearerAssetFetch(src) ? src : null
75
- const [, rerender] = useReducer((c: number) => c + 1, 0)
76
-
77
- useEffect(() => {
78
- if (!bearerSrc || resolvedCache.has(bearerSrc)) return
79
- let cancelled = false
80
- fetchAsBlobUrl(bearerSrc)
81
- .catch(() => {})
82
- .finally(() => {
83
- if (!cancelled) rerender()
84
- })
85
- return () => {
86
- cancelled = true
87
- }
88
- }, [bearerSrc])
89
-
90
- if (!src) return undefined
91
- if (!bearerSrc) return src
92
- return resolvedCache.get(bearerSrc)
93
- }
24
+ export const clearAuthedImageCache = clearAuthedAssetCache
@@ -1,5 +1,5 @@
1
1
  import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
2
- import { embedAuthedFetch, setEmbedAuthAdapter } from '../embed-authed-fetch'
2
+ import { embedAuthedFetch, needsBearerAssetFetch, setEmbedAuthAdapter } from '../embed-authed-fetch'
3
3
 
4
4
  /**
5
5
  * Tests focus on the same-origin guard rather than the proxy-auth
@@ -205,3 +205,52 @@ describe('embedAuthedFetch adapter.allowedOrigins (native-shell cross-origin san
205
205
  expect(() => embedAuthedFetch('javascript:alert(1)')).toThrow(/non-http\(s\)/i)
206
206
  })
207
207
  })
208
+
209
+ /**
210
+ * `needsBearerAssetFetch` decides whether a `<img>` / `<track>` URL has to be
211
+ * blob-fetched (see `useAuthedAssetSrc`). The signal is the adapter's
212
+ * `Authorization` header — i.e. the host authenticates by HEADER, so nothing
213
+ * the browser fetches on its own carries auth, same-origin included.
214
+ */
215
+ describe('needsBearerAssetFetch (native subresources that cannot carry a header)', () => {
216
+ const bearer = { getHeaders: () => ({ Authorization: 'Bearer t' }) }
217
+
218
+ afterEach(() => {
219
+ setEmbedAuthAdapter(null)
220
+ })
221
+
222
+ it('is false with no adapter registered (the hub — cookies or nothing)', () => {
223
+ expect(needsBearerAssetFetch('/content/api/captions/release/1')).toBe(false)
224
+ })
225
+
226
+ it('is false in cookie mode (adapter supplies no Authorization)', () => {
227
+ setEmbedAuthAdapter({ getHeaders: () => ({}) })
228
+ expect(needsBearerAssetFetch('/content/api/captions/release/1')).toBe(false)
229
+ })
230
+
231
+ it('is TRUE for a relative proxy path in header-auth mode (dev-ticket web)', () => {
232
+ setEmbedAuthAdapter(bearer)
233
+ expect(needsBearerAssetFetch('/content/api/captions/release/1')).toBe(true)
234
+ })
235
+
236
+ it('is TRUE for an absolute same-origin URL in header-auth mode', () => {
237
+ setEmbedAuthAdapter(bearer)
238
+ expect(needsBearerAssetFetch('http://localhost:3000/content/api/image-proxy?u=x')).toBe(true)
239
+ })
240
+
241
+ it('is TRUE for a cross-origin gateway the adapter sanctions (native shell)', () => {
242
+ setEmbedAuthAdapter({ ...bearer, allowedOrigins: ['https://gateway.example'] })
243
+ expect(needsBearerAssetFetch('https://gateway.example/content/api/captions/release/1')).toBe(true)
244
+ })
245
+
246
+ it('is false for a third-party origin the bearer does not belong to', () => {
247
+ setEmbedAuthAdapter({ ...bearer, allowedOrigins: ['https://gateway.example'] })
248
+ expect(needsBearerAssetFetch('https://cdn.example.com/pic.png')).toBe(false)
249
+ })
250
+
251
+ it('is false for non-http(s) schemes (data:/blob: assets load natively)', () => {
252
+ setEmbedAuthAdapter(bearer)
253
+ expect(needsBearerAssetFetch('data:image/png;base64,AAAA')).toBe(false)
254
+ expect(needsBearerAssetFetch('blob:http://localhost:3000/abc')).toBe(false)
255
+ })
256
+ })
@@ -158,32 +158,49 @@ export function hasEmbedAuthAdapter(): boolean {
158
158
 
159
159
  /**
160
160
  * Whether `url` is an asset the browser CANNOT load natively because its
161
- * auth rides in request headers: the registered adapter is currently
162
- * supplying an `Authorization` header AND the URL's origin is one the
163
- * adapter explicitly sanctions for that bearer (`allowedOrigins` — the
164
- * host's gateway, cross-origin by construction in native shells).
161
+ * auth rides in request headers.
165
162
  *
166
- * Native asset loads (`<img src>`, CSS `background-image`) can't carry
167
- * custom headers, so a URL this returns `true` for must go through
168
- * `embedAuthedFetch` → blob object-URL instead (see `useAuthedImageSrc`).
169
- * Everything else — cookie-auth web (no Authorization), relative /
170
- * same-origin URLs (cookies work), third-party origins the bearer does
171
- * NOT belong to (public images) loads natively, unchanged.
163
+ * Native asset loads (`<img src>`, `<track src>`, CSS `background-image`)
164
+ * can't carry custom headers, so a URL this returns `true` for must go
165
+ * through `embedAuthedFetch` → blob object-URL instead (see
166
+ * `useAuthedAssetSrc` / `useAuthedImageSrc`).
167
+ *
168
+ * The single signal is the registered adapter: when it supplies an
169
+ * `Authorization` header, the host authenticates by HEADER, so nothing the
170
+ * browser fetches on its own is authenticated. That holds for BOTH shapes a
171
+ * header-auth host takes:
172
+ *
173
+ * - a cross-origin gateway URL — sanctioned via `allowedOrigins` (native
174
+ * shells, whose page origin is `capacitor://` / `tauri://`);
175
+ * - a same-origin / relative reverse-proxy path (`/content/api/...`) —
176
+ * dev-ticket web, where the token lives in localStorage and there is NO
177
+ * session cookie to fall back on. This case used to be excluded on the
178
+ * assumption that same-origin implies "cookies work"; true for a cookie
179
+ * session, false for every header-auth host, which is exactly when this
180
+ * predicate is consulted at all.
181
+ *
182
+ * Everything else loads natively, unchanged: cookie-auth web (the adapter
183
+ * supplies no `Authorization`), no adapter at all (the hub), and third-party
184
+ * origins the bearer does NOT belong to (public images) — those stay out of
185
+ * reach of the token by the same origin rules `embedAuthedFetch` enforces.
172
186
  */
173
187
  export function needsBearerAssetFetch(url: string): boolean {
174
188
  if (typeof window === 'undefined') return false
175
189
  const adapter = getRegisteredAuthAdapter()
176
- if (!adapter?.allowedOrigins?.length || adapter.getHeaders?.().Authorization === undefined) {
177
- return false
178
- }
190
+ if (!adapter || adapter.getHeaders?.().Authorization === undefined) return false
179
191
  let target: URL
192
+ let pageOrigin: string
180
193
  try {
181
194
  target = new URL(url, window.location.href)
195
+ // Same reason `assertSameOrigin` derives it this way: test environments
196
+ // mock `window.location` as a plain object with no `origin` field.
197
+ pageOrigin = new URL(window.location.href).origin
182
198
  } catch {
183
199
  return false
184
200
  }
185
201
  if (target.protocol !== 'http:' && target.protocol !== 'https:') return false
186
- return adapter.allowedOrigins.includes(target.origin)
202
+ if (target.origin === pageOrigin) return true
203
+ return adapter.allowedOrigins?.includes(target.origin) ?? false
187
204
  }
188
205
 
189
206
  /**