@flamingo-stack/openframe-frontend-core 0.0.568 → 0.0.569

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 (176) hide show
  1. package/dist/{chunk-P7QGTNEW.cjs → chunk-2DSDGWWU.cjs} +101 -101
  2. package/dist/{chunk-P7QGTNEW.cjs.map → chunk-2DSDGWWU.cjs.map} +1 -1
  3. package/dist/{chunk-ZARYIFCI.js → chunk-3UPQAOES.js} +5 -3
  4. package/dist/{chunk-ZARYIFCI.js.map → chunk-3UPQAOES.js.map} +1 -1
  5. package/dist/{chunk-KL6ZDLDF.js → chunk-4NN7V443.js} +2 -2
  6. package/dist/{chunk-G2PAUMV6.js → chunk-6STF67EF.js} +4 -4
  7. package/dist/{chunk-6RSKK4E7.cjs → chunk-74DB5H5T.cjs} +1258 -1162
  8. package/dist/chunk-74DB5H5T.cjs.map +1 -0
  9. package/dist/{chunk-6TDWCBQM.js → chunk-A2T266F3.js} +7 -7
  10. package/dist/{chunk-HKG7BZON.cjs → chunk-AAROYQ4O.cjs} +14 -14
  11. package/dist/{chunk-HKG7BZON.cjs.map → chunk-AAROYQ4O.cjs.map} +1 -1
  12. package/dist/{chunk-CEAAOVCV.cjs → chunk-AJPGQCZ2.cjs} +1 -1
  13. package/dist/chunk-AJPGQCZ2.cjs.map +1 -0
  14. package/dist/{chunk-ZXASQL3E.js → chunk-C5GKBUT7.js} +4 -4
  15. package/dist/{chunk-RBYMJF2V.cjs → chunk-D6SBYELJ.cjs} +35 -35
  16. package/dist/{chunk-RBYMJF2V.cjs.map → chunk-D6SBYELJ.cjs.map} +1 -1
  17. package/dist/{chunk-LNUXUOPN.cjs → chunk-EBE22KOQ.cjs} +4 -4
  18. package/dist/{chunk-LNUXUOPN.cjs.map → chunk-EBE22KOQ.cjs.map} +1 -1
  19. package/dist/{chunk-EYXZF46V.js → chunk-FE5QR7PT.js} +995 -899
  20. package/dist/chunk-FE5QR7PT.js.map +1 -0
  21. package/dist/{chunk-6PWUUWCO.cjs → chunk-FMWIA5IH.cjs} +42 -42
  22. package/dist/{chunk-6PWUUWCO.cjs.map → chunk-FMWIA5IH.cjs.map} +1 -1
  23. package/dist/{chunk-DHTT77AG.cjs → chunk-GY22CIEF.cjs} +78 -78
  24. package/dist/{chunk-DHTT77AG.cjs.map → chunk-GY22CIEF.cjs.map} +1 -1
  25. package/dist/{chunk-O3JCKEVG.js → chunk-HOMYI5RR.js} +2 -2
  26. package/dist/{chunk-BZ7B255K.js → chunk-KCAGMTJ6.js} +9 -9
  27. package/dist/{chunk-DZ2Z7HRF.js → chunk-KO333Q2B.js} +3 -3
  28. package/dist/{chunk-MQQM4NCN.js → chunk-LJBNHO6B.js} +3 -3
  29. package/dist/{chunk-KZS2DFC2.cjs → chunk-ME346OJF.cjs} +42 -42
  30. package/dist/{chunk-KZS2DFC2.cjs.map → chunk-ME346OJF.cjs.map} +1 -1
  31. package/dist/{chunk-UWY722RH.js → chunk-MR6RRXSD.js} +1 -1
  32. package/dist/chunk-MR6RRXSD.js.map +1 -0
  33. package/dist/{chunk-ZIZJDXUI.js → chunk-MZG3QQUR.js} +2 -2
  34. package/dist/{chunk-HB3IG2MC.js → chunk-OS5RFMW5.js} +3 -3
  35. package/dist/{chunk-VEXTUGUA.js → chunk-OWCN3BM6.js} +7 -7
  36. package/dist/{chunk-QVQLNKFS.js → chunk-P7ZENKIA.js} +2 -2
  37. package/dist/{chunk-T7AE7CKV.cjs → chunk-PCL6WYYT.cjs} +7 -7
  38. package/dist/{chunk-T7AE7CKV.cjs.map → chunk-PCL6WYYT.cjs.map} +1 -1
  39. package/dist/{chunk-2LKVYI7H.js → chunk-PLJAOZYZ.js} +6 -6
  40. package/dist/{chunk-L22CSZZU.cjs → chunk-QY3VTOB6.cjs} +18 -18
  41. package/dist/{chunk-L22CSZZU.cjs.map → chunk-QY3VTOB6.cjs.map} +1 -1
  42. package/dist/{chunk-Y2BYHTEN.cjs → chunk-SMW5Z4NW.cjs} +23 -23
  43. package/dist/{chunk-Y2BYHTEN.cjs.map → chunk-SMW5Z4NW.cjs.map} +1 -1
  44. package/dist/{chunk-LXWQLUBR.cjs → chunk-SW3K3JSP.cjs} +9 -7
  45. package/dist/chunk-SW3K3JSP.cjs.map +1 -0
  46. package/dist/{chunk-NKEOD2AD.cjs → chunk-TDYTAED2.cjs} +3 -3
  47. package/dist/{chunk-NKEOD2AD.cjs.map → chunk-TDYTAED2.cjs.map} +1 -1
  48. package/dist/{chunk-ZNEVGXMU.js → chunk-TXEI4PES.js} +112 -4
  49. package/dist/chunk-TXEI4PES.js.map +1 -0
  50. package/dist/{chunk-KZ3PIUCH.cjs → chunk-VNE2EHCD.cjs} +14 -14
  51. package/dist/{chunk-KZ3PIUCH.cjs.map → chunk-VNE2EHCD.cjs.map} +1 -1
  52. package/dist/{chunk-6I76UK2Z.cjs → chunk-XMECOGLY.cjs} +3 -3
  53. package/dist/{chunk-6I76UK2Z.cjs.map → chunk-XMECOGLY.cjs.map} +1 -1
  54. package/dist/{chunk-NK3M6F5A.cjs → chunk-Z3SRMLMK.cjs} +121 -13
  55. package/dist/chunk-Z3SRMLMK.cjs.map +1 -0
  56. package/dist/components/case-studies/index.cjs +10 -10
  57. package/dist/components/case-studies/index.js +4 -4
  58. package/dist/components/chat/index.cjs +4 -17
  59. package/dist/components/chat/index.cjs.map +1 -1
  60. package/dist/components/chat/index.js +3 -16
  61. package/dist/components/chat/utils/index.d.ts +0 -2
  62. package/dist/components/chat/utils/index.d.ts.map +1 -1
  63. package/dist/components/contact/index.cjs +5 -5
  64. package/dist/components/contact/index.js +4 -4
  65. package/dist/components/docs/index.cjs +7 -7
  66. package/dist/components/docs/index.js +6 -6
  67. package/dist/components/embeds/claude-embed.d.ts +15 -8
  68. package/dist/components/embeds/claude-embed.d.ts.map +1 -1
  69. package/dist/components/embeds/embed-viewer-frame.d.ts +8 -1
  70. package/dist/components/embeds/embed-viewer-frame.d.ts.map +1 -1
  71. package/dist/components/embeds/index.cjs +5 -5
  72. package/dist/components/embeds/index.js +4 -4
  73. package/dist/components/faq/index.cjs +6 -6
  74. package/dist/components/faq/index.js +5 -5
  75. package/dist/components/features/index.cjs +4 -4
  76. package/dist/components/features/index.js +3 -3
  77. package/dist/components/help-center-pages/index.cjs +25 -25
  78. package/dist/components/help-center-pages/index.js +16 -16
  79. package/dist/components/index.cjs +142 -154
  80. package/dist/components/index.cjs.map +1 -1
  81. package/dist/components/index.js +14 -26
  82. package/dist/components/index.js.map +1 -1
  83. package/dist/components/meeting-scheduler/index.cjs +36 -36
  84. package/dist/components/meeting-scheduler/index.js +5 -5
  85. package/dist/components/navigation/index.cjs +4 -4
  86. package/dist/components/navigation/index.js +3 -3
  87. package/dist/components/onboarding-guides/index.cjs +8 -8
  88. package/dist/components/onboarding-guides/index.js +7 -7
  89. package/dist/components/related-content/index.cjs +6 -6
  90. package/dist/components/related-content/index.js +5 -5
  91. package/dist/components/tickets/index.cjs +8 -8
  92. package/dist/components/tickets/index.js +7 -7
  93. package/dist/components/ui/index.cjs +4 -16
  94. package/dist/components/ui/index.cjs.map +1 -1
  95. package/dist/components/ui/index.js +3 -15
  96. package/dist/components/ui/markdown/sanitize.d.ts.map +1 -1
  97. package/dist/components/ui/table/table.d.ts +1 -1
  98. package/dist/components/ui/table/table.d.ts.map +1 -1
  99. package/dist/components/ui/table/types.d.ts +9 -0
  100. package/dist/components/ui/table/types.d.ts.map +1 -1
  101. package/dist/contexts/endpoints-runtime-context.d.ts +6 -0
  102. package/dist/contexts/endpoints-runtime-context.d.ts.map +1 -1
  103. package/dist/contexts/index.cjs +2 -2
  104. package/dist/contexts/index.js +1 -1
  105. package/dist/hooks/index.cjs +5 -3
  106. package/dist/hooks/index.cjs.map +1 -1
  107. package/dist/hooks/index.d.ts +1 -0
  108. package/dist/hooks/index.d.ts.map +1 -1
  109. package/dist/hooks/index.js +4 -2
  110. package/dist/hooks/use-claude-mirror-src.d.ts +17 -0
  111. package/dist/hooks/use-claude-mirror-src.d.ts.map +1 -0
  112. package/dist/index.cjs +10 -8
  113. package/dist/index.cjs.map +1 -1
  114. package/dist/index.js +15 -13
  115. package/dist/utils/embed-proxy-auth-storage.d.ts.map +1 -1
  116. package/dist/utils/embed-url-converters.d.ts +20 -0
  117. package/dist/utils/embed-url-converters.d.ts.map +1 -1
  118. package/dist/utils/fullscreen-switch.d.ts +85 -0
  119. package/dist/utils/fullscreen-switch.d.ts.map +1 -0
  120. package/dist/utils/index.cjs +114 -0
  121. package/dist/utils/index.cjs.map +1 -1
  122. package/dist/utils/index.d.ts +1 -0
  123. package/dist/utils/index.d.ts.map +1 -1
  124. package/dist/utils/index.js +113 -1
  125. package/dist/utils/index.js.map +1 -1
  126. package/package.json +1 -1
  127. package/src/components/chat/hooks/use-chat-attachments.ts +3 -3
  128. package/src/components/chat/hooks/use-chat-history-hydration.ts +2 -2
  129. package/src/components/chat/hooks/use-chat-identity.ts +5 -5
  130. package/src/components/chat/hooks/use-sse-chat-adapter.ts +3 -3
  131. package/src/components/chat/utils/.index.md +1 -2
  132. package/src/components/chat/utils/chat-conversation-storage.ts +2 -2
  133. package/src/components/chat/utils/index.ts +3 -10
  134. package/src/components/embeds/claude-embed.tsx +41 -14
  135. package/src/components/embeds/embed-viewer-frame.tsx +77 -4
  136. package/src/components/ui/markdown/__tests__/sanitize-invariant.test.ts +30 -0
  137. package/src/components/ui/markdown/rich/__tests__/shortcodes.test.ts +8 -0
  138. package/src/components/ui/markdown/sanitize.ts +4 -0
  139. package/src/components/ui/table/table.tsx +7 -2
  140. package/src/components/ui/table/types.ts +9 -0
  141. package/src/contexts/endpoints-runtime-context.tsx +6 -0
  142. package/src/hooks/index.ts +4 -0
  143. package/src/hooks/use-claude-mirror-src.ts +43 -0
  144. package/src/utils/.embed-proxy-auth-storage.md +2 -2
  145. package/src/utils/embed-proxy-auth-storage.ts +19 -14
  146. package/src/utils/embed-url-converters.ts +45 -0
  147. package/src/utils/fullscreen-switch.ts +180 -0
  148. package/src/utils/index.ts +8 -6
  149. package/dist/chunk-6RSKK4E7.cjs.map +0 -1
  150. package/dist/chunk-CEAAOVCV.cjs.map +0 -1
  151. package/dist/chunk-EYXZF46V.js.map +0 -1
  152. package/dist/chunk-LXWQLUBR.cjs.map +0 -1
  153. package/dist/chunk-NK3M6F5A.cjs.map +0 -1
  154. package/dist/chunk-UWY722RH.js.map +0 -1
  155. package/dist/chunk-ZNEVGXMU.js.map +0 -1
  156. package/dist/components/chat/utils/chat-authed-fetch.d.ts +0 -15
  157. package/dist/components/chat/utils/chat-authed-fetch.d.ts.map +0 -1
  158. package/dist/components/chat/utils/chat-proxy-auth-storage.d.ts +0 -16
  159. package/dist/components/chat/utils/chat-proxy-auth-storage.d.ts.map +0 -1
  160. package/src/components/chat/utils/.chat-authed-fetch.md +0 -25
  161. package/src/components/chat/utils/.chat-proxy-auth-storage.md +0 -38
  162. package/src/components/chat/utils/chat-authed-fetch.ts +0 -17
  163. package/src/components/chat/utils/chat-proxy-auth-storage.ts +0 -25
  164. /package/dist/{chunk-KL6ZDLDF.js.map → chunk-4NN7V443.js.map} +0 -0
  165. /package/dist/{chunk-G2PAUMV6.js.map → chunk-6STF67EF.js.map} +0 -0
  166. /package/dist/{chunk-6TDWCBQM.js.map → chunk-A2T266F3.js.map} +0 -0
  167. /package/dist/{chunk-ZXASQL3E.js.map → chunk-C5GKBUT7.js.map} +0 -0
  168. /package/dist/{chunk-O3JCKEVG.js.map → chunk-HOMYI5RR.js.map} +0 -0
  169. /package/dist/{chunk-BZ7B255K.js.map → chunk-KCAGMTJ6.js.map} +0 -0
  170. /package/dist/{chunk-DZ2Z7HRF.js.map → chunk-KO333Q2B.js.map} +0 -0
  171. /package/dist/{chunk-MQQM4NCN.js.map → chunk-LJBNHO6B.js.map} +0 -0
  172. /package/dist/{chunk-ZIZJDXUI.js.map → chunk-MZG3QQUR.js.map} +0 -0
  173. /package/dist/{chunk-HB3IG2MC.js.map → chunk-OS5RFMW5.js.map} +0 -0
  174. /package/dist/{chunk-VEXTUGUA.js.map → chunk-OWCN3BM6.js.map} +0 -0
  175. /package/dist/{chunk-QVQLNKFS.js.map → chunk-P7ZENKIA.js.map} +0 -0
  176. /package/dist/{chunk-2LKVYI7H.js.map → chunk-PLJAOZYZ.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.568",
3
+ "version": "0.0.569",
4
4
  "description": "Shared design system and components for all Flamingo platforms",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -9,7 +9,7 @@
9
9
  * `AbortController` for clean remove-mid-upload.
10
10
  *
11
11
  * Auth propagation:
12
- * - `chatAuthedFetch` carries bearer-act-as headers + Supabase
12
+ * - `embedAuthedFetch` carries bearer-act-as headers + Supabase
13
13
  * session cookies — used both for minting the upload URL and as
14
14
  * the auth path before falling through to the signed PUT.
15
15
  * - The byte-PUT to the signed URL uses an inline `uploadWithProgress`
@@ -26,7 +26,7 @@
26
26
  import { useCallback, useMemo, useRef, useState } from 'react'
27
27
  import { fileTypeFromBlob } from 'file-type'
28
28
  import { useRequiredChatRuntime } from '../../../contexts/chat-runtime-context'
29
- import { chatAuthedFetch } from '../utils/chat-authed-fetch'
29
+ import { embedAuthedFetch } from '../../../utils/embed-authed-fetch'
30
30
  import {
31
31
  CHAT_ATTACHMENT_MIME_TYPES,
32
32
  CHAT_ATTACHMENT_CONCURRENT_UPLOADS_PER_USER,
@@ -243,7 +243,7 @@ export function useChatAttachments(): UseChatAttachmentsApi {
243
243
  // bearer-act-as headers (if present).
244
244
  updateOne(att.id, { status: 'uploading', progress: 5 })
245
245
 
246
- const urlResp = await chatAuthedFetch(uploadUrlEndpoint, {
246
+ const urlResp = await embedAuthedFetch(uploadUrlEndpoint, {
247
247
  method: 'POST',
248
248
  headers: { 'Content-Type': 'application/json' },
249
249
  signal: ctrl?.signal,
@@ -19,7 +19,7 @@
19
19
 
20
20
  import { useEffect, useRef, useState, type MutableRefObject } from 'react'
21
21
  import type { Message } from './use-chat'
22
- import { chatAuthedFetch } from '../utils/chat-authed-fetch'
22
+ import { embedAuthedFetch } from '../../../utils/embed-authed-fetch'
23
23
  import { AUTO_CONTINUATION_DIRECTIVE_PREFIX } from '../utils/auto-continuation-directive'
24
24
 
25
25
  export interface UseChatHistoryHydrationArgs {
@@ -79,7 +79,7 @@ export function useChatHistoryHydration({
79
79
  setIsHydratingHistory(true)
80
80
  ;(async () => {
81
81
  try {
82
- const res = await chatAuthedFetch(
82
+ const res = await embedAuthedFetch(
83
83
  `${historyUrl}?conversationId=${encodeURIComponent(conversationId)}`,
84
84
  { method: 'GET' },
85
85
  )
@@ -43,8 +43,8 @@
43
43
 
44
44
  import { createContext, createElement, useContext, useEffect, useState, type ReactNode } from 'react'
45
45
  import { useRequiredChatRuntime } from '../../../contexts/chat-runtime-context'
46
- import { chatAuthedFetch } from '../utils/chat-authed-fetch'
47
- import { getChatProxyAuth } from '../utils/chat-proxy-auth-storage'
46
+ import { embedAuthedFetch } from '../../../utils/embed-authed-fetch'
47
+ import { getEmbedProxyAuth } from '../../../utils/embed-proxy-auth-storage'
48
48
 
49
49
  /**
50
50
  * Wire-shape for the identity route response. Mirrors the hub's
@@ -109,10 +109,10 @@ const ANON_DEFAULTS: ChatIdentityResponse = {
109
109
  function useResolveChatIdentity(enabled: boolean): ChatIdentitySurface {
110
110
  const runtime = useRequiredChatRuntime()
111
111
  const url = runtime.endpoints.identityUrl
112
- // `getChatProxyAuth()` reads localStorage every render. If the user
112
+ // `getEmbedProxyAuth()` reads localStorage every render. If the user
113
113
  // pastes bearer creds mid-session (via the `/debug` creds bar),
114
114
  // their email arrives here and the effect's dep changes → refetch.
115
- const proxyEmail = getChatProxyAuth()?.email ?? null
115
+ const proxyEmail = getEmbedProxyAuth()?.email ?? null
116
116
 
117
117
  const [data, setData] = useState<ChatIdentityResponse>(ANON_DEFAULTS)
118
118
  // Only "loading" if THIS instance will actually fetch. A disabled
@@ -126,7 +126,7 @@ function useResolveChatIdentity(enabled: boolean): ChatIdentitySurface {
126
126
  const ctrl = new AbortController()
127
127
 
128
128
  setIsLoading(true)
129
- chatAuthedFetch(url, { signal: ctrl.signal })
129
+ embedAuthedFetch(url, { signal: ctrl.signal })
130
130
  .then(async (resp) => {
131
131
  if (!resp.ok) return ANON_DEFAULTS
132
132
  return (await resp.json()) as ChatIdentityResponse
@@ -57,7 +57,7 @@ import type { ChatRef } from '../chat-ref.types'
57
57
  import type { Message } from '../types/message.types'
58
58
  import type { MessageSegment } from '../types/message.types'
59
59
  import { useSlashCommandRegistry, type SlashCommandSummary } from './use-slash-commands'
60
- import { chatAuthedFetch } from '../utils/chat-authed-fetch'
60
+ import { embedAuthedFetch } from '../../../utils/embed-authed-fetch'
61
61
  import type { ScrollAnchor } from '../utils/scroll-anchor'
62
62
  import { buildConfirmToolBody, readServerErrorMessage } from '../../../chat-protocol/confirm-tool'
63
63
  import { createSseFrameDecoder } from '../../../chat-protocol/decode'
@@ -458,10 +458,10 @@ export function useSseChatAdapter(
458
458
  abortControllerRef.current = ctrl
459
459
 
460
460
  try {
461
- // `chatAuthedFetch` carries the bearer-act-as headers (+ Supabase
461
+ // `embedAuthedFetch` carries the bearer-act-as headers (+ Supabase
462
462
  // session cookies) — same wrapper `use-chat-attachments` and
463
463
  // `use-chat-identity` use.
464
- const response = await chatAuthedFetch(targetPath, {
464
+ const response = await embedAuthedFetch(targetPath, {
465
465
  method: 'POST',
466
466
  body: JSON.stringify(requestBody),
467
467
  signal: ctrl.signal,
@@ -8,7 +8,7 @@ Barrel export module that consolidates and re-exports all message processing, ch
8
8
  | **Message Accumulation** | `MessageSegmentAccumulator`, `createMessageSegmentAccumulator`, `AccumulatorCallbacks` |
9
9
  | **Historical Messages** | `processHistoricalMessages`, `extractErrorMessages`, `processHistoricalMessagesWithErrors` |
10
10
  | **Chat Navigation** | `handleChatNavClick`, `resolveExternalNavigation`, `decideNewTab`, `computeIsNewTab`, `buildAnchorProps` |
11
- | **Proxy Auth** | `getChatProxyAuth`, `setChatProxyAuth`, `clearChatProxyAuth`, `applyProxyAuth`, `chatAuthedFetch` |
11
+ | **Proxy Auth** | moved to `utils/` — `getEmbedProxyAuth`, `setEmbedProxyAuth`, `clearEmbedProxyAuth`, `applyProxyAuth`, `embedAuthedFetch` |
12
12
  | **Attachments** | `formatChatAttachmentMarkdownForBubble`, `stripChatAttachmentMarkdown`, `buildChatAttachmentViewUrl` |
13
13
  | **UI / Styling** | `chatChipClass`, compact card class constants (`COMPACT_CARD_*`), `getStatusColorScheme` |
14
14
  | **Icon Registry** | `ICON_REGISTRY`, `getIconComponent`, `normalizeIconKey`, `getDynamicIcon` |
@@ -21,7 +21,6 @@ Barrel export module that consolidates and re-exports all message processing, ch
21
21
  import {
22
22
  createMessageSegmentAccumulator,
23
23
  handleChatNavClick,
24
- getChatProxyAuth,
25
24
  getIconComponent,
26
25
  formatChatAttachmentMarkdownForBubble,
27
26
  decideNewTab,
@@ -19,7 +19,7 @@ import {
19
19
  createLocalStorageAdapter,
20
20
  type LocalStorageAdapter,
21
21
  } from '../../../utils/local-storage-adapter'
22
- import { getChatProxyAuth } from './chat-proxy-auth-storage'
22
+ import { getEmbedProxyAuth } from '../../../utils/embed-proxy-auth-storage'
23
23
 
24
24
  export interface PersistedChatConversation {
25
25
  conversationId: string
@@ -29,7 +29,7 @@ const STORAGE_KEY = 'conversation'
29
29
 
30
30
  const namespaceFor = (source: string): string => {
31
31
  const base = `mingo-chat-${source}`
32
- const auth = getChatProxyAuth()
32
+ const auth = getEmbedProxyAuth()
33
33
  return auth?.email ? `${base}-u-${encodeURIComponent(auth.email.toLowerCase())}` : base
34
34
  }
35
35
 
@@ -89,16 +89,9 @@ export {
89
89
  type RuntimeMode,
90
90
  } from './decide-new-tab'
91
91
 
92
- // Chat-proxy auth (localStorage adapter + fetch wrapper)
93
- export {
94
- getChatProxyAuth,
95
- getPersistedProxyEmail,
96
- setChatProxyAuth,
97
- clearChatProxyAuth,
98
- applyProxyAuth,
99
- type ChatProxyAuth,
100
- } from './chat-proxy-auth-storage'
101
- export { chatAuthedFetch } from './chat-authed-fetch'
92
+ // Embed-surface auth moved to `utils/` (embed-proxy-auth-storage /
93
+ // embed-authed-fetch) — import the Embed* names from
94
+ // `@flamingo-stack/openframe-frontend-core/utils`.
102
95
 
103
96
  // Pure content helpers
104
97
  export { flattenAssistantContent } from './flatten-assistant-content'
@@ -4,6 +4,7 @@ import { ExternalLink } from 'lucide-react'
4
4
  import { Button } from '../ui/button/button'
5
5
  import { ClaudeIcon } from '../icons/claude-icon'
6
6
  import { EmbedViewerFrame } from './embed-viewer-frame'
7
+ import { useClaudeMirrorSrc } from '../../hooks/use-claude-mirror-src'
7
8
  import { toClaudeEmbedUrl } from '../../utils/embed-url-converters'
8
9
 
9
10
  export type ClaudeEmbedKind = 'artifact' | 'design'
@@ -33,18 +34,29 @@ const KIND_HEADING: Record<ClaudeEmbedKind, string> = {
33
34
  * `{{claude-artifact:URL}}` / `{{claude-design:URL}}` shortcodes, so it looks
34
35
  * identical wherever markdown renders — a spec body, a comment, a links rail.
35
36
  *
36
- * A PUBLISHED artifact frames through its `/embed` route (`toClaudeEmbedUrl`).
37
- * Everything else has no embeddable route at all, and then `src` is null and
38
- * the shared frame shows its own empty state — the action stays either way, so
39
- * the artifact is always one click away.
37
+ * FRAME SOURCE fully under the hood, in priority order:
38
+ * 1. The host's SELF-HOSTED MIRROR: the url's artifact id derives the
39
+ * storage-view proxy path (`toClaudeMirrorPath`), probed with a
40
+ * 1-byte ranged fetch. When the host has ingested a copy, the frame
41
+ * shows it — claude.ai frame-locks CODE artifacts entirely, so this
42
+ * is the only way those ever render inline.
43
+ * 2. claude.ai's own `/embed` route for a PUBLISHED artifact
44
+ * (`toClaudeEmbedUrl`).
45
+ * 3. Neither → the shared frame's empty state.
46
+ * The consumer passes only the claude URL; "Open in Claude" always
47
+ * targets it, so commenting stays on claude.ai.
40
48
  *
41
- * What defeats the frame: a Claude CODE url (no Embed settings exist for it,
42
- * and its `/embed` path answers `frame-ancestors 'self'`), a Claude Design url
43
- * (no embed route), an artifact whose author has not allow-listed this host,
44
- * or an http host (mixed content — it will not paint on `http://localhost`).
49
+ * What defeats the claude.ai leg: a Claude CODE url (no Embed settings
50
+ * exist for it, and its `/embed` path answers `frame-ancestors 'self'`),
51
+ * a Claude Design url (no embed route), an artifact whose author has not
52
+ * allow-listed this host, or an http host (mixed content).
45
53
  */
46
54
  export function ClaudeEmbed({ url, kind = 'artifact', title, height, loading = 'lazy' }: ClaudeEmbedProps) {
47
- const embedUrl = toClaudeEmbedUrl(url)
55
+ // Mirror detection lives in `useClaudeMirrorSrc` (derive from
56
+ // EndpointsRuntime + 1-byte ranged probe) — this component stays
57
+ // presentational: mirror when the hook found one, claude.ai otherwise.
58
+ const mirrorSrc = useClaudeMirrorSrc(url)
59
+ const embedUrl = mirrorSrc ?? toClaudeEmbedUrl(url)
48
60
  return (
49
61
  <EmbedViewerFrame
50
62
  className="my-6 space-y-3"
@@ -65,6 +77,10 @@ export function ClaudeEmbed({ url, kind = 'artifact', title, height, loading = '
65
77
  </Button>
66
78
  }
67
79
  src={embedUrl}
80
+ // The framed artifact has no internal fullscreen button of its own
81
+ // (unlike Figma's player) — the shell provides the toggle for both
82
+ // the claude.ai `/embed` route and a mirror.
83
+ fullscreenControl
68
84
  loading={loading}
69
85
  height={height}
70
86
  // Same frame contract Figma gets — an artifact is a real app: it copies
@@ -72,11 +88,22 @@ export function ClaudeEmbed({ url, kind = 'artifact', title, height, loading = '
72
88
  allow="clipboard-write; clipboard-read; fullscreen"
73
89
  // …but UNLIKE Figma, an artifact is USER-AUTHORED HTML and JS, so it is
74
90
  // sandboxed. Omitting `allow-top-navigation` is the point: an artifact
75
- // cannot navigate the page it is embedded in. `allow-same-origin` beside
76
- // `allow-scripts` is what Anthropic's own embed snippet uses and is safe
77
- // on a CROSS-ORIGIN frame it grants the frame claude.ai's origin, never
78
- // ours.
79
- sandbox="allow-scripts allow-same-origin allow-popups allow-forms"
91
+ // cannot navigate the page it is embedded in. The `allow-same-origin`
92
+ // token is CONDITIONAL on where the frame points:
93
+ // - claude.ai `/embed` (no mirror): Anthropic's own embed snippet
94
+ // uses it, and on a CROSS-ORIGIN frame it grants the frame
95
+ // claude.ai's origin never ours. The artifact runtime needs
96
+ // it (storage, postMessage handshake).
97
+ // - the derived MIRROR is served from the HOST's OWN origin —
98
+ // `allow-same-origin` + `allow-scripts` there would be a no-op
99
+ // sandbox handing the artifact first-party cookies and
100
+ // `window.parent`. A static mirror renders fine from an opaque
101
+ // origin, so the token is dropped.
102
+ sandbox={
103
+ mirrorSrc
104
+ ? 'allow-scripts allow-popups allow-forms'
105
+ : 'allow-scripts allow-same-origin allow-popups allow-forms'
106
+ }
80
107
  allowFullScreen
81
108
  emptyIcon={<ClaudeIcon className="w-16 h-16 mb-4" />}
82
109
  emptyMessage="Open this one in Claude · it has no embeddable view"
@@ -1,7 +1,10 @@
1
1
  'use client'
2
2
 
3
- import React from 'react'
3
+ import React, { useEffect, useRef, useState } from 'react'
4
+ import { Maximize2, Minimize2 } from 'lucide-react'
4
5
  import { cn } from '../../utils/cn'
6
+ import { FullscreenSwitchController } from '../../utils/fullscreen-switch'
7
+ import { Button } from '../ui/button/button'
5
8
  import { EmbedIframe } from './embed-iframe'
6
9
 
7
10
  /**
@@ -47,6 +50,13 @@ export interface EmbedViewerFrameProps {
47
50
  sandbox?: string
48
51
  /** Wrapper classes; defaults to the sheets/pdf `space-y-4`. */
49
52
  className?: string
53
+ /** Opt-in fullscreen toggle in the header row (rendered after `actions`,
54
+ * only when there is a `src`). Fullscreens the WHOLE frame — header
55
+ * included — so the exit control stays reachable (Esc works too).
56
+ * Exists because some embedded contents ship their own fullscreen
57
+ * button (the Figma player) and some cannot (a self-hosted Claude
58
+ * artifact mirror) — the shell provides the affordance uniformly. */
59
+ fullscreenControl?: boolean
50
60
  }
51
61
 
52
62
  export function EmbedViewerFrame({
@@ -63,9 +73,45 @@ export function EmbedViewerFrame({
63
73
  loading,
64
74
  sandbox,
65
75
  className,
76
+ fullscreenControl,
66
77
  }: EmbedViewerFrameProps) {
78
+ const frameRef = useRef<HTMLDivElement>(null)
79
+ const [isFullscreen, setIsFullscreen] = useState(false)
80
+
81
+ // ONE fullscreen implementation across the library and its hosts:
82
+ // `FullscreenSwitchController` (shared with the hub's deck) owns the
83
+ // Fullscreen API calls, webkit fallbacks, rejection safety, and the
84
+ // change-event-driven state — Esc, F11, buttons and browser chrome all
85
+ // converge on its listener, so the icon can never desync. Here it runs
86
+ // element-level (`target` = this frame; no mask classes — the frame
87
+ // styles off its own state).
88
+ const controllerRef = useRef<FullscreenSwitchController | null>(null)
89
+ useEffect(() => {
90
+ if (!fullscreenControl) return
91
+ const controller = new FullscreenSwitchController({
92
+ target: () => frameRef.current,
93
+ onFullscreenChange: setIsFullscreen,
94
+ })
95
+ controllerRef.current = controller
96
+ controller.attach()
97
+ return () => {
98
+ controllerRef.current = null
99
+ controller.detach()
100
+ }
101
+ }, [fullscreenControl])
102
+ const toggleFullscreen = () => controllerRef.current?.toggle()
103
+
67
104
  return (
68
- <div className={cn('space-y-4', className)}>
105
+ <div
106
+ ref={frameRef}
107
+ className={cn(
108
+ 'space-y-[var(--spacing-system-mf)]',
109
+ className,
110
+ // Fullscreen paints its own ground (the fullscreened element
111
+ // otherwise sits on the UA's black backdrop) and scrolls itself.
112
+ isFullscreen && 'overflow-auto bg-ods-bg p-[var(--spacing-system-mf)]',
113
+ )}
114
+ >
69
115
  <div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
70
116
  <div className="flex items-center gap-2 min-w-0">
71
117
  {icon}
@@ -75,13 +121,40 @@ export function EmbedViewerFrame({
75
121
  <span className="text-h6 font-semibold text-ods-text-primary truncate">{title}</span>
76
122
  )}
77
123
  </div>
78
- {actions}
124
+ {/* Same responsive action-row idiom as the PDF viewer's button pair
125
+ and every viewer's own action: full-width stacked on mobile,
126
+ inline row from `sm:` up, SAME Button idiom (outline /
127
+ small-legacy / w-4 icon / w-full sm:w-auto). Without the
128
+ fullscreen toggle, `actions` renders EXACTLY as before — the
129
+ wrapper exists only when the toggle joins it, so pre-existing
130
+ viewers (figma/pdf/sheets) are byte-identical. */}
131
+ {fullscreenControl && src ? (
132
+ <div className="flex flex-col gap-[var(--spacing-system-xsf)] sm:flex-row sm:items-center">
133
+ {actions}
134
+ <Button
135
+ variant="outline"
136
+ size="small-legacy"
137
+ onClick={toggleFullscreen}
138
+ aria-label={isFullscreen ? 'Exit fullscreen' : 'Enter fullscreen'}
139
+ leftIcon={isFullscreen ? <Minimize2 className="w-4 h-4" /> : <Maximize2 className="w-4 h-4" />}
140
+ className="w-full sm:w-auto"
141
+ >
142
+ {isFullscreen ? 'Exit fullscreen' : 'Fullscreen'}
143
+ </Button>
144
+ </div>
145
+ ) : (
146
+ actions
147
+ )}
79
148
  </div>
80
149
  {src ? (
81
150
  <EmbedIframe
82
151
  src={src}
83
152
  title={title}
84
- height={height}
153
+ // 96px = the fullscreen wrapper's own chrome above the iframe:
154
+ // header row (~40px incl. its gap) + the wrapper's mf padding
155
+ // (16px × 2) + the mf space-y gap (16px), rounded up so the
156
+ // frame never forces a scrollbar. Recheck if that chrome changes.
157
+ height={isFullscreen ? 'calc(100vh - 96px)' : height}
85
158
  allow={allow}
86
159
  allowFullScreen={allowFullScreen}
87
160
  loading={loading}
@@ -16,6 +16,7 @@ import {
16
16
  buildEffectiveTagSet,
17
17
  buildSanitizeSchema,
18
18
  escapeUnknownHtmlTags,
19
+ rehypeStripUnsafe,
19
20
  SAFE_HTML_TAGS,
20
21
  SVG_TAGS,
21
22
  } from '../sanitize'
@@ -352,3 +353,32 @@ describe('link-definition shelters blank in every shape (round 21)', () => {
352
353
  expect(masked).toContain('## after heading')
353
354
  })
354
355
  })
356
+
357
+ describe('URL_ATTRS covers hast PROPERTY keys, not attribute names', () => {
358
+ it('strips a javascript: data-url via its camelized hast key', () => {
359
+ // property-information camelizes `data-url` to `dataUrl` BEFORE the
360
+ // strip pass runs - the URL_ATTRS entry must match the lowercased
361
+ // PROPERTY key or the scheme check silently never fires.
362
+ const tree = {
363
+ type: 'root',
364
+ children: [
365
+ {
366
+ type: 'element',
367
+ tagName: 'div',
368
+ properties: {
369
+ className: ['claude-embed'],
370
+ dataUrl: 'javascript:alert(1)',
371
+ dataKind: 'artifact',
372
+ },
373
+ children: [],
374
+ },
375
+ ],
376
+ }
377
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
378
+ ;(rehypeStripUnsafe() as (t: unknown) => void)(tree as any)
379
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
380
+ const props = (tree.children[0] as any).properties
381
+ expect(props.dataUrl).toBeUndefined()
382
+ expect(props.dataKind).toBe('artifact')
383
+ })
384
+ })
@@ -91,6 +91,14 @@ describe('processShortcodes design-doc embeds', () => {
91
91
  expect(design).toContain('data-kind="design"');
92
92
  });
93
93
 
94
+ it('threads the optional pipe title into data-title', () => {
95
+ const titled = processShortcodes('{{claude-artifact:https://claude.ai/a|Brief doc}}');
96
+ expect(titled).toContain('data-title="Brief doc"');
97
+
98
+ const untitled = processShortcodes('{{claude-artifact:https://claude.ai/a}}');
99
+ expect(untitled).not.toContain('data-title');
100
+ });
101
+
94
102
  it('escapes the url attribute so a crafted link cannot break out of the block', () => {
95
103
  const output = processShortcodes('{{claude-artifact:https://claude.ai/a"><script>alert(1)</script>}}');
96
104
  expect(output).not.toContain('<script>');
@@ -395,6 +395,10 @@ const URL_ATTRS = new Set([
395
395
  'data',
396
396
  'action',
397
397
  'background',
398
+ // The embed shortcodes' primary url (`data-url` → `dataUrl`) — it
399
+ // becomes an anchor href ("Open in Claude") and a link-preview fetch
400
+ // target, so a `javascript:`/`data:` scheme there is a live sink too.
401
+ 'dataurl',
398
402
  ])
399
403
 
400
404
  /**
@@ -21,6 +21,7 @@ function injectSyntheticColumns<T>(
21
21
  rowActions?: RowAction<T>[],
22
22
  renderRowActions?: (item: T) => ReactNode,
23
23
  rowHref?: (item: T) => string | null | undefined,
24
+ actionsColumnWidth?: string,
24
25
  ): TableColumn<T>[] {
25
26
  const hasActions = Boolean(rowActions?.length) || Boolean(renderRowActions)
26
27
  const result = [...columns]
@@ -29,7 +30,10 @@ function injectSyntheticColumns<T>(
29
30
  const actionsColumn: TableColumn<T> = {
30
31
  key: '__actions__',
31
32
  label: '',
32
- width: 'min-w-[100px] w-auto shrink-0 flex-none',
33
+ // A caller-fixed width keeps the header and every row on ONE grid
34
+ // (see `actionsColumnWidth` in types.ts); the default stays
35
+ // content-sized for back-compat.
36
+ width: actionsColumnWidth ?? 'min-w-[100px] w-auto shrink-0 flex-none',
33
37
  align: 'right',
34
38
  renderCell: (item: T) => (
35
39
  <div className="flex gap-2 items-center justify-end pointer-events-auto" data-no-row-click>
@@ -120,11 +124,12 @@ export function Table<T = any>({
120
124
  stickyHeader,
121
125
  stickyHeaderOffset,
122
126
  animateRowReorder,
127
+ actionsColumnWidth,
123
128
  }: TableProps<T>) {
124
129
  // Opt-in row-reorder animation: framer-motion is loaded lazily (its own chunk)
125
130
  // ONLY when `animateRowReorder` is set, so the default table stays motion-free.
126
131
  const tableMotion = useTableMotion(Boolean(animateRowReorder))
127
- const columnsWithActions = injectSyntheticColumns(columns, rowActions, renderRowActions, rowHref)
132
+ const columnsWithActions = injectSyntheticColumns(columns, rowActions, renderRowActions, rowHref, actionsColumnWidth)
128
133
  const getRowHref = (item: T): string | undefined => {
129
134
  if (onRowClick || !rowHref) return undefined
130
135
  return rowHref(item) ?? undefined
@@ -110,6 +110,15 @@ export interface TableProps<T = any> {
110
110
  rowActions?: RowAction<T>[]
111
111
  // Custom renderer for the actions area (e.g., kebab/dots menu)
112
112
  renderRowActions?: (item: T) => ReactNode
113
+ /**
114
+ * Width class(es) for the synthetic actions column. The default is
115
+ * content-sized (`w-auto`), which mis-aligns every OTHER column between
116
+ * the header and the rows whenever the row actions are wider than the
117
+ * header's "Showing N results" counter (each side sizes to its own
118
+ * content). Pass a FIXED width (e.g. `w-60`) when the actions are wide
119
+ * text buttons so the header and rows share one grid.
120
+ */
121
+ actionsColumnWidth?: string
113
122
 
114
123
  /**
115
124
  * When provided, renders a right-pointing chevron link at the end of every row
@@ -34,6 +34,12 @@ export interface EndpointsRuntime {
34
34
  }
35
35
  /** POST contact-form submission. */
36
36
  contactUrl: string
37
+ /** GET base of the host's private-storage view proxy
38
+ * (`<base>/<bucket>/<object>`). `ClaudeEmbed` derives artifact mirror
39
+ * urls under it (`<base>/design-briefs/<uuid>.html`). OPTIONAL: a host
40
+ * without the proxy simply omits it and claude embeds fall back to
41
+ * claude.ai — no probing happens. */
42
+ storageViewBaseUrl?: string
37
43
  }
38
44
 
39
45
  export const EndpointsRuntimeContext = createContext<EndpointsRuntime | null>(null)
@@ -29,6 +29,10 @@ export * from './use-access-code-integration'
29
29
  // THE og-placeholder URL hook (endpoints-driven; no injected builder)
30
30
  export * from './use-og-placeholder-url'
31
31
 
32
+ // Claude artifact mirror detection (endpoints-driven; ClaudeEmbed's
33
+ // transparent mirror leg)
34
+ export * from './use-claude-mirror-src'
35
+
32
36
  // Deep-link "scroll to URL hash" after data loads. Pairs with URL
33
37
  // composers that emit `?<filter>=<id>#<prefix>-<id>` — the filter
34
38
  // narrows the list, the hash scrolls the matching DOM id.
@@ -0,0 +1,43 @@
1
+ 'use client'
2
+
3
+ import { useEffect, useState } from 'react'
4
+ import { useEndpointsRuntime } from '../contexts/endpoints-runtime-context'
5
+ import { toClaudeMirrorPath } from '../utils/embed-url-converters'
6
+
7
+ /**
8
+ * The host's SELF-HOSTED MIRROR src for a claude artifact url, or `null`
9
+ * while none is known — THE hook behind `ClaudeEmbed`'s transparent
10
+ * mirror leg, extracted so the component stays presentational.
11
+ *
12
+ * Fully under the hood: derives the candidate mirror path from the url's
13
+ * artifact id under the host's configured storage-view proxy base
14
+ * (`EndpointsRuntime.storageViewBaseUrl` — the SAME proxy-path mechanism
15
+ * as every other lib→API call) and probes it with a 1-byte ranged GET
16
+ * (the proxy forwards Range, so the probe costs nothing). Exists → the
17
+ * mirror path; missing / no proxy / network blip → `null`, and the
18
+ * caller's claude.ai fallback is exactly the pre-mirror behavior. A host
19
+ * that mounts no EndpointsRuntime provider (or omits the entry) skips
20
+ * the mirror leg entirely.
21
+ */
22
+ export function useClaudeMirrorSrc(url: string): string | null {
23
+ const endpoints = useEndpointsRuntime()
24
+ const mirrorPath = toClaudeMirrorPath(url, endpoints?.storageViewBaseUrl)
25
+ const [mirrorSrc, setMirrorSrc] = useState<string | null>(null)
26
+ useEffect(() => {
27
+ setMirrorSrc(null)
28
+ if (!mirrorPath) return
29
+ let cancelled = false
30
+ fetch(mirrorPath, { headers: { Range: 'bytes=0-0' } })
31
+ .then(res => {
32
+ if (!cancelled && (res.ok || res.status === 206)) setMirrorSrc(mirrorPath)
33
+ })
34
+ .catch(() => {
35
+ // No proxy on this host / network blip — the caller's claude.ai
36
+ // fallback stands, same as before mirrors existed.
37
+ })
38
+ return () => {
39
+ cancelled = true
40
+ }
41
+ }, [mirrorPath])
42
+ return mirrorSrc
43
+ }
@@ -1,5 +1,5 @@
1
1
  <!-- source-hash: 4f6996d4ad0ba284b64e13807891eb62 -->
2
- Manages client-side persistence of embed-surface proxy credentials (`CHAT_PROXY_SECRET` + impersonation email) in `localStorage`, enabling embedded surfaces (chat widget, ticket center, etc.) to authenticate as a proxied customer across sessions.
2
+ Manages client-side persistence of embed-surface proxy credentials (a platform API key + impersonation email) in `localStorage`, enabling embedded surfaces (chat widget, ticket center, etc.) to authenticate as a proxied customer across sessions.
3
3
 
4
4
  ## Key Components
5
5
 
@@ -35,4 +35,4 @@ const response = await fetch(url, { method: 'POST', headers })
35
35
  clearEmbedProxyAuth()
36
36
  ```
37
37
 
38
- > **Security note:** Credentials persist in `localStorage` (survives tab close and browser restart). This is intentional — re-pasting on every tab was rejected as a dev-experience tradeoff. The `/debug` entry point is admin-gated, and all headers are server-validated against `CHAT_PROXY_SECRET`.
38
+ > **Security note:** Credentials persist in `localStorage` (survives tab close and browser restart). This is intentional — re-pasting on every tab was rejected as a dev-experience tradeoff. The `/debug` entry point is admin-gated, and all headers are server-validated against the hash-at-rest platform API key store (revocable in `/admin/api-keys`).
@@ -1,24 +1,28 @@
1
1
  'use client'
2
2
 
3
3
  /**
4
- * Client-side persistence for embed-surface proxy credentials
5
- * (`CHAT_PROXY_SECRET` + impersonation email). Used by every embedded
4
+ * Client-side persistence for embed-surface proxy credentials (a
5
+ * PLATFORM API KEY + impersonation email). Used by every embedded
6
6
  * surface — the chat widget AND the ticket center AND any future
7
7
  * embedded React component that needs to identify itself as the
8
8
  * impersonated customer.
9
9
  *
10
10
  * When set, the surface attaches the creds as
11
- * `Authorization: Bearer <secret>` + `X-Chat-Act-As: <email>`
11
+ * `Authorization: Bearer <platform API key>` + `X-Chat-Act-As: <email>`
12
12
  * on every call to `/api/docs/chat`, `/api/chat/*`, and any other route
13
13
  * gated by `requireChatAuth` — proving to the server that this session
14
- * is acting on behalf of <email>.
14
+ * is acting on behalf of <email>. The key is minted in the hub's
15
+ * `/admin/api-keys` and is platform-bound (`fpk_<platform>_…`): a
16
+ * SERVICE key may act as any email; a PERSONAL key only as its owner.
15
17
  *
16
- * **Naming history:** the wire-side header names are still `X-Chat-*`
17
- * and the env var is `CHAT_PROXY_SECRET`. Those are server contracts;
18
- * renaming them would require a coordinated deploy + customer-side
19
- * env-var migration. The CLIENT-side helpers were renamed `Embed*` so
20
- * non-chat surfaces (e.g. ticket center) don't have to import a
21
- * chat-prefixed symbol just to send the same headers.
18
+ * **Naming history:** the stored field is still named `secret` and the
19
+ * wire-side header names are `X-Chat-*`. Those are client/server
20
+ * contracts predating the per-platform key migration (the old shared
21
+ * `CHAT_PROXY_SECRET` env is removed hub-side); renaming them would
22
+ * break persisted state + the wire shape for zero behavior change. The
23
+ * CLIENT-side helpers were renamed `Embed*` so non-chat surfaces (e.g.
24
+ * ticket center) don't have to import a chat-prefixed symbol just to
25
+ * send the same headers.
22
26
  *
23
27
  * Persists to **`localStorage`** so the bearer token + act-as identity
24
28
  * survive tab close, new-tab opens, and browser restarts — the
@@ -26,10 +30,11 @@
26
30
  * cycle was rejected as a dev-experience tradeoff that wasn't worth
27
31
  * the security gain. An XSS sink on this origin can read the value
28
32
  * indefinitely (vs only-this-tab with sessionStorage), but `/debug`
29
- * is admin-gated behind the platform's `askAI.enabled` flag and the
30
- * impersonation header it sets is server-validated against
31
- * `CHAT_PROXY_SECRET` anyway. Explicit "Clear" button on the creds
32
- * bar is the supported logout path; closing the tab is no longer.
33
+ * is admin-gated behind the platform's `askAI.enabled` flag, the key
34
+ * is server-verified (hash-at-rest, platform-bound, revocable in
35
+ * `/admin/api-keys`), and the act-as reach is bounded by the key's
36
+ * tier. Explicit "Clear" button on the creds bar is the supported
37
+ * logout path; closing the tab is no longer.
33
38
  *
34
39
  * Namespaced under `<platform>.chat.proxy-auth.v1` (the storage key is
35
40
  * unchanged from the old chat-prefixed helper — that's a storage