@flamingo-stack/openframe-frontend-core 0.0.217 → 0.0.218

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 (93) hide show
  1. package/dist/{chunk-L6IBKPVM.js → chunk-EKBM4FHK.js} +2 -2
  2. package/dist/{chunk-SWZUZYWR.js → chunk-EWA2NFUR.js} +2 -2
  3. package/dist/{chunk-TYIBMDUZ.cjs → chunk-FZZBCRID.cjs} +7 -7
  4. package/dist/{chunk-TYIBMDUZ.cjs.map → chunk-FZZBCRID.cjs.map} +1 -1
  5. package/dist/{chunk-G2HHSZ3S.cjs → chunk-GE64T3JT.cjs} +9 -9
  6. package/dist/{chunk-G2HHSZ3S.cjs.map → chunk-GE64T3JT.cjs.map} +1 -1
  7. package/dist/{chunk-YWDC5BXM.cjs → chunk-L5RSJE2I.cjs} +1940 -915
  8. package/dist/chunk-L5RSJE2I.cjs.map +1 -0
  9. package/dist/{chunk-BVFRD34B.js → chunk-OHOUSDAY.js} +2 -2
  10. package/dist/{chunk-MVQ3OODK.cjs → chunk-S4SVD5JI.cjs} +9 -9
  11. package/dist/{chunk-MVQ3OODK.cjs.map → chunk-S4SVD5JI.cjs.map} +1 -1
  12. package/dist/{chunk-N5IKPYRL.js → chunk-SWIR5EB2.js} +2 -2
  13. package/dist/{chunk-6DCKL73F.cjs → chunk-TCJ5B2ZD.cjs} +24 -24
  14. package/dist/{chunk-6DCKL73F.cjs.map → chunk-TCJ5B2ZD.cjs.map} +1 -1
  15. package/dist/{chunk-ENBGG2K2.js → chunk-V5JY5RSY.js} +2954 -1929
  16. package/dist/chunk-V5JY5RSY.js.map +1 -0
  17. package/dist/components/chat/embeddable-chat.d.ts +13 -0
  18. package/dist/components/chat/embeddable-chat.d.ts.map +1 -1
  19. package/dist/components/chat/hooks/use-nats-chat-adapter.d.ts +104 -10
  20. package/dist/components/chat/hooks/use-nats-chat-adapter.d.ts.map +1 -1
  21. package/dist/components/chat/hooks/use-slash-commands.d.ts +6 -0
  22. package/dist/components/chat/hooks/use-slash-commands.d.ts.map +1 -1
  23. package/dist/components/chat/hooks/use-sse-chat-adapter.d.ts.map +1 -1
  24. package/dist/components/chat/hooks/use-unified-chat.d.ts.map +1 -1
  25. package/dist/components/chat/index.cjs +2 -2
  26. package/dist/components/chat/index.js +1 -1
  27. package/dist/components/chat/types/unified-chat-state.types.d.ts +81 -0
  28. package/dist/components/chat/types/unified-chat-state.types.d.ts.map +1 -1
  29. package/dist/components/contact/index.cjs +3 -3
  30. package/dist/components/contact/index.js +2 -2
  31. package/dist/components/features/index.cjs +2 -2
  32. package/dist/components/features/index.js +1 -1
  33. package/dist/components/index.cjs +73 -51
  34. package/dist/components/index.cjs.map +1 -1
  35. package/dist/components/index.js +26 -4
  36. package/dist/components/index.js.map +1 -1
  37. package/dist/components/navigation/app-header.d.ts +7 -0
  38. package/dist/components/navigation/app-header.d.ts.map +1 -1
  39. package/dist/components/navigation/app-layout-drawer.d.ts +65 -0
  40. package/dist/components/navigation/app-layout-drawer.d.ts.map +1 -0
  41. package/dist/components/navigation/app-layout.d.ts +9 -1
  42. package/dist/components/navigation/app-layout.d.ts.map +1 -1
  43. package/dist/components/navigation/header-mingo-button.d.ts +21 -0
  44. package/dist/components/navigation/header-mingo-button.d.ts.map +1 -0
  45. package/dist/components/navigation/index.cjs +24 -2
  46. package/dist/components/navigation/index.cjs.map +1 -1
  47. package/dist/components/navigation/index.d.ts +5 -1
  48. package/dist/components/navigation/index.d.ts.map +1 -1
  49. package/dist/components/navigation/index.js +23 -1
  50. package/dist/components/onboarding-guides/index.cjs +18 -18
  51. package/dist/components/onboarding-guides/index.js +3 -3
  52. package/dist/components/tickets/hooks/use-ticket-engagements.d.ts.map +1 -1
  53. package/dist/components/tickets/index.cjs +80 -66
  54. package/dist/components/tickets/index.cjs.map +1 -1
  55. package/dist/components/tickets/index.js +20 -6
  56. package/dist/components/tickets/index.js.map +1 -1
  57. package/dist/components/ui/index.cjs +2 -2
  58. package/dist/components/ui/index.js +1 -1
  59. package/dist/index.cjs +26 -2
  60. package/dist/index.cjs.map +1 -1
  61. package/dist/index.js +25 -1
  62. package/dist/utils/embed-authed-fetch.d.ts +80 -0
  63. package/dist/utils/embed-authed-fetch.d.ts.map +1 -1
  64. package/dist/utils/index.cjs +70 -5
  65. package/dist/utils/index.cjs.map +1 -1
  66. package/dist/utils/index.d.ts +1 -1
  67. package/dist/utils/index.d.ts.map +1 -1
  68. package/dist/utils/index.js +70 -6
  69. package/dist/utils/index.js.map +1 -1
  70. package/package.json +2 -2
  71. package/src/components/chat/embeddable-chat.tsx +154 -37
  72. package/src/components/chat/hooks/use-nats-chat-adapter.ts +601 -23
  73. package/src/components/chat/hooks/use-slash-commands.ts +10 -1
  74. package/src/components/chat/hooks/use-sse-chat-adapter.ts +45 -0
  75. package/src/components/chat/hooks/use-unified-chat.ts +59 -0
  76. package/src/components/chat/types/unified-chat-state.types.ts +116 -0
  77. package/src/components/navigation/app-header.tsx +23 -0
  78. package/src/components/navigation/app-layout-drawer.tsx +620 -0
  79. package/src/components/navigation/app-layout.tsx +65 -26
  80. package/src/components/navigation/header-mingo-button.tsx +58 -0
  81. package/src/components/navigation/index.ts +17 -1
  82. package/src/components/tickets/hooks/use-ticket-engagements.ts +24 -4
  83. package/src/stories/AppLayoutDrawer.stories.tsx +228 -0
  84. package/src/utils/.embed-authed-fetch.md +7 -0
  85. package/src/utils/__tests__/embed-authed-fetch.test.ts +103 -1
  86. package/src/utils/embed-authed-fetch.ts +247 -7
  87. package/src/utils/index.ts +5 -1
  88. package/dist/chunk-ENBGG2K2.js.map +0 -1
  89. package/dist/chunk-YWDC5BXM.cjs.map +0 -1
  90. /package/dist/{chunk-L6IBKPVM.js.map → chunk-EKBM4FHK.js.map} +0 -0
  91. /package/dist/{chunk-SWZUZYWR.js.map → chunk-EWA2NFUR.js.map} +0 -0
  92. /package/dist/{chunk-BVFRD34B.js.map → chunk-OHOUSDAY.js.map} +0 -0
  93. /package/dist/{chunk-N5IKPYRL.js.map → chunk-SWIR5EB2.js.map} +0 -0
@@ -22,6 +22,117 @@
22
22
 
23
23
  import { applyProxyAuth } from './embed-proxy-auth-storage'
24
24
 
25
+ // =============================================================================
26
+ // Host-supplied auth adapter (opt-in)
27
+ // =============================================================================
28
+
29
+ /**
30
+ * Hosts that have their own auth model (cookie sessions, app-specific
31
+ * JWT in localStorage, OAuth access tokens, …) can register an adapter
32
+ * to override the lib's default `embedProxyAuth` flow. When set, the
33
+ * adapter's `getHeaders()` result is merged onto every `embedAuthedFetch`
34
+ * call AFTER the default proxy-auth header step (so adapter headers
35
+ * win over both caller and proxy values), and `credentials` overrides
36
+ * the default `'same-origin'` behaviour.
37
+ *
38
+ * Default (no adapter): MPH-style proxy-impersonation — bearer + act-as
39
+ * read from localStorage, `credentials: 'same-origin'`. No consumer
40
+ * needs to touch this unless they want a different auth model.
41
+ *
42
+ * Use cases:
43
+ * - openframe-frontend has its own JWT in `localStorage.of_access_token`
44
+ * and cookie-based session; register an adapter to attach the JWT
45
+ * and request `credentials: 'include'` so cookies travel cross-origin
46
+ * to the openframe gateway.
47
+ * - Future embed hosts with OAuth access tokens, signed URLs, etc.
48
+ *
49
+ * Lifetime: setter is module-level (intentionally — `embedAuthedFetch`
50
+ * is a plain utility, not a hook, so it can't read React context). Host
51
+ * runtime providers should call `setEmbedAuthAdapter(...)` on mount and
52
+ * `setEmbedAuthAdapter(null)` on unmount. Multiple hosts registering at
53
+ * once is a programming error (one chat panel per app).
54
+ */
55
+ export interface EmbedAuthAdapter {
56
+ /** Headers merged onto every embedded-fetch call. Return `{}` to add
57
+ * nothing. Called per-request so reactive token refresh sees the latest
58
+ * value from your auth store / storage. Values typed as
59
+ * `string | undefined` so the common narrowed shape
60
+ * `{ Authorization: token ? 'Bearer …' : undefined }` (or a conditional
61
+ * `token ? { Authorization: … } : {}`) assigns cleanly — `undefined`
62
+ * values are filtered before being merged into the request headers. */
63
+ getHeaders?: () => Record<string, string | undefined>
64
+ /** `RequestInit.credentials` mode. Default when no adapter: callers'
65
+ * `init.credentials` or `'same-origin'`. Use `'include'` for cookie
66
+ * auth against a different origin (CORS + `SameSite=None` required). */
67
+ credentials?: RequestCredentials
68
+ /**
69
+ * Optional 401 self-heal. When a request comes back `401`,
70
+ * `embedAuthedFetch` calls this once, and — if it resolves `true` —
71
+ * retries the SAME request exactly once with freshly-recomputed
72
+ * headers (so a rotated bearer from `getHeaders()` is picked up).
73
+ * Resolve `false` to surface the 401 to the caller unchanged.
74
+ *
75
+ * This is the capability the openframe `apiClient` has had all along
76
+ * (refresh-the-access-token-then-retry); registering it here gives the
77
+ * embedded chat/ticket surfaces the same self-healing auth instead of
78
+ * dying on an expired token. Concurrent 401s are de-duplicated by the
79
+ * wrapper, so this fires at most once per refresh cycle even when a
80
+ * stampede of chat requests all expire together — your implementation
81
+ * does NOT need its own in-flight guard (though a token-refresh manager
82
+ * that already dedups is harmless).
83
+ *
84
+ * Keep it idempotent and side-effect-light: on failure the wrapper just
85
+ * returns the original 401 — logout/redirect decisions belong to the
86
+ * host's own auth layer, not to this fetch wrapper.
87
+ */
88
+ refresh?: () => Promise<boolean>
89
+ }
90
+
91
+ /**
92
+ * The registered adapter is parked on `globalThis`, NOT in a module-private
93
+ * `let`. Reason: this lib ships multiple entry points (`/utils`,
94
+ * `/components/chat`, …) and a consumer's bundler can inline this file into
95
+ * more than one chunk — giving each chunk its OWN module scope. If the host
96
+ * calls `setEmbedAuthAdapter` from the `/utils` copy while the chat's
97
+ * `embedAuthedFetch` runs from the `/components/chat` copy, a module-local
98
+ * `let` would be set on one copy and read as `null` on the other (the exact
99
+ * "credentials: same-origin, no Bearer, no refresh" symptom). A single
100
+ * `globalThis` slot is shared across every copy, so registration always
101
+ * reaches the fetch path.
102
+ */
103
+ const ADAPTER_GLOBAL_KEY = '__embedAuthedFetchAdapter__'
104
+
105
+ function getRegisteredAuthAdapter(): EmbedAuthAdapter | null {
106
+ if (typeof globalThis === 'undefined') return null
107
+ return (globalThis as Record<string, unknown>)[ADAPTER_GLOBAL_KEY] as EmbedAuthAdapter | null ?? null
108
+ }
109
+
110
+ function storeRegisteredAuthAdapter(adapter: EmbedAuthAdapter | null): void {
111
+ if (typeof globalThis === 'undefined') return
112
+ ;(globalThis as Record<string, unknown>)[ADAPTER_GLOBAL_KEY] = adapter
113
+ }
114
+
115
+ /**
116
+ * Register a host-owned auth adapter for `embedAuthedFetch`. Pass `null`
117
+ * to clear (typically on provider unmount).
118
+ *
119
+ * Module-level state — there is one chat panel per app, so a single
120
+ * registration is sufficient. Calling this twice with different non-null
121
+ * adapters replaces the previous one (the most recent registration wins);
122
+ * a `console.warn` flags the overwrite so duplicate-provider mounts get
123
+ * caught in dev.
124
+ */
125
+ export function setEmbedAuthAdapter(adapter: EmbedAuthAdapter | null): void {
126
+ if (adapter && getRegisteredAuthAdapter() && process.env.NODE_ENV !== 'production') {
127
+ console.warn(
128
+ '[setEmbedAuthAdapter] overwriting a previously-registered auth ' +
129
+ 'adapter. Two chat-runtime providers should not coexist — verify ' +
130
+ 'mount order and pass `null` from the unmounting provider.',
131
+ )
132
+ }
133
+ storeRegisteredAuthAdapter(adapter)
134
+ }
135
+
25
136
  /**
26
137
  * `fetch` wrapper that attaches embed-proxy bearer headers (when
27
138
  * present in sessionStorage) and forces `credentials: 'same-origin'`
@@ -40,13 +151,26 @@ import { applyProxyAuth } from './embed-proxy-auth-storage'
40
151
  * the current window's origin; cross-origin URLs throw before the bearer
41
152
  * leaves the page. This is a defense-in-depth guard for future call sites
42
153
  * — there is no legitimate cross-origin use of this fetch wrapper.
154
+ *
155
+ * **401 self-heal:** when a registered adapter supplies `refresh`, a `401`
156
+ * response triggers a single token refresh + retry of the same request
157
+ * (see `EmbedAuthAdapter.refresh`). This is the openframe `apiClient`'s
158
+ * refresh-then-retry behaviour, lifted into the lib so embedded surfaces
159
+ * no longer need a host-side `window.fetch` monkey-patch to survive an
160
+ * expired access token mid-chat. With no adapter (or no `refresh`), the
161
+ * 401 passes straight through unchanged.
43
162
  */
44
163
  export function embedAuthedFetch(url: string, init: RequestInit = {}): Promise<Response> {
164
+ // Same-origin guard runs SYNCHRONOUSLY (not awaited inside the async
165
+ // helper below) so a bearer-leaking cross-origin URL throws before any
166
+ // promise is created — callers and tests rely on the synchronous throw.
45
167
  assertSameOrigin(url)
46
168
 
47
169
  // `applyProxyAuth` accepts `Record<string, string>`; normalize the
48
- // caller's headers to that shape. RequestInit accepts `HeadersInit`
49
- // which is broader (Headers instance OR array of tuples).
170
+ // caller's headers to that shape ONCE, up front. RequestInit accepts
171
+ // `HeadersInit` which is broader (Headers instance OR array of tuples).
172
+ // We re-derive the per-request headers from this base on every attempt
173
+ // (initial + post-refresh retry) so a rotated bearer is picked up.
50
174
  //
51
175
  // When the caller passes no headers, fall back to the same default
52
176
  // `applyProxyAuth` uses internally — `Content-Type: application/json` —
@@ -69,14 +193,114 @@ export function embedAuthedFetch(url: string, init: RequestInit = {}): Promise<R
69
193
  }
70
194
  }
71
195
 
72
- const { url: authedUrl, headers } = applyProxyAuth(url, baseHeaders)
73
- return fetch(authedUrl, {
196
+ return fetchWithRefresh(url, init, baseHeaders, false)
197
+ }
198
+
199
+ /**
200
+ * Single in-flight refresh shared across all concurrent `embedAuthedFetch`
201
+ * callers. A stampede of chat requests that all 401 at the same moment must
202
+ * trigger the adapter's `refresh()` ONCE, not N times — otherwise an
203
+ * expiring session fires a thundering herd of refresh calls at the auth
204
+ * server. Resets to `null` once settled so the next genuine expiry can
205
+ * refresh again.
206
+ */
207
+ // Stored on `globalThis` rather than a module-local so the "single refresh"
208
+ // guarantee survives module duplication. Bundlers can ship more than one copy
209
+ // of this module (e.g. across chunks or a host + embedded build); a per-module
210
+ // variable would let each copy run its own refresh cycle, re-creating the
211
+ // thundering-herd this dedupe exists to prevent.
212
+ const IN_FLIGHT_REFRESH_GLOBAL_KEY = '__embedAuthedFetchInFlightRefresh__'
213
+
214
+ function getInFlightRefresh(): Promise<boolean> | null {
215
+ if (typeof globalThis === 'undefined') return null
216
+ return (
217
+ ((globalThis as Record<string, unknown>)[IN_FLIGHT_REFRESH_GLOBAL_KEY] as
218
+ | Promise<boolean>
219
+ | null
220
+ | undefined) ?? null
221
+ )
222
+ }
223
+
224
+ function setInFlightRefresh(refresh: Promise<boolean> | null): void {
225
+ if (typeof globalThis === 'undefined') return
226
+ ;(globalThis as Record<string, unknown>)[IN_FLIGHT_REFRESH_GLOBAL_KEY] = refresh
227
+ }
228
+
229
+ function dedupedRefresh(): Promise<boolean> {
230
+ const adapter = getRegisteredAuthAdapter()
231
+ if (!adapter?.refresh) return Promise.resolve(false)
232
+ let inFlightRefresh = getInFlightRefresh()
233
+ if (!inFlightRefresh) {
234
+ // Wrap in `Promise.resolve` so an adapter that throws synchronously
235
+ // (rather than rejecting) still funnels through the shared slot and
236
+ // clears it. A rejected refresh is treated as "could not refresh".
237
+ inFlightRefresh = Promise.resolve()
238
+ .then(() => adapter.refresh!())
239
+ .catch(() => false)
240
+ .finally(() => {
241
+ setInFlightRefresh(null)
242
+ })
243
+ setInFlightRefresh(inFlightRefresh)
244
+ }
245
+ return inFlightRefresh
246
+ }
247
+
248
+ /**
249
+ * Core fetch path: merge proxy-auth + adapter headers, issue the request,
250
+ * and — on a `401` with a refresh-capable adapter — refresh once and retry
251
+ * the identical request a single time. Mirrors the openframe `apiClient`'s
252
+ * refresh-then-retry contract (`isRetry` guards against infinite loops).
253
+ */
254
+ async function fetchWithRefresh(
255
+ url: string,
256
+ init: RequestInit,
257
+ baseHeaders: Record<string, string>,
258
+ isRetry: boolean,
259
+ ): Promise<Response> {
260
+ // Re-run the merge each attempt: `applyProxyAuth` reads the latest stored
261
+ // proxy creds and `getHeaders()` reads the latest bearer, so a retry after
262
+ // refresh carries the rotated token rather than the stale one. `{...baseHeaders}`
263
+ // keeps the caller's normalized headers immutable across attempts.
264
+ const { url: authedUrl, headers } = applyProxyAuth(url, { ...baseHeaders })
265
+
266
+ // Host-supplied auth adapter layer. Runs AFTER the proxy-auth merge so
267
+ // adapter headers override both caller and proxy values — the adapter
268
+ // is the host's explicit "this is my auth model" override, intentionally
269
+ // last-writer-wins. When no adapter is registered, this is a zero-cost
270
+ // no-op (object spread of `{}`).
271
+ const adapter = getRegisteredAuthAdapter()
272
+ if (adapter?.getHeaders) {
273
+ // Filter `undefined` values — the adapter type allows them so consumers
274
+ // don't have to narrow `{ Authorization: token ? '…' : undefined }`-shaped
275
+ // returns, but `fetch` headers must be strings.
276
+ for (const [k, v] of Object.entries(adapter.getHeaders())) {
277
+ if (v !== undefined) headers[k] = v
278
+ }
279
+ }
280
+ const credentials = adapter?.credentials ?? init.credentials ?? 'same-origin'
281
+
282
+ const response = await fetch(authedUrl, {
74
283
  ...init,
75
284
  headers,
76
- // Always include Supabase auth cookies. `applyProxyAuth` handles
77
- // the bearer header layer; cookies are the session-tier carrier.
78
- credentials: init.credentials ?? 'same-origin',
285
+ // Default `same-origin` carries Supabase cookies for the MPH proxy-
286
+ // auth model. Hosts on different origins (openframe-frontend
287
+ // openframe gateway) register `credentials: 'include'` via the
288
+ // adapter to make their own cookies travel cross-origin (CORS +
289
+ // `SameSite=None` must be configured server-side for that to work).
290
+ credentials,
79
291
  })
292
+
293
+ // 401 self-heal: refresh the token once and retry. Only when an adapter
294
+ // opted into `refresh`, and only on the first attempt — a 401 on the
295
+ // retry means the fresh token is also unauthorized, so surface it.
296
+ if (response.status === 401 && !isRetry && adapter?.refresh) {
297
+ const refreshed = await dedupedRefresh()
298
+ if (refreshed) {
299
+ return fetchWithRefresh(url, init, baseHeaders, true)
300
+ }
301
+ }
302
+
303
+ return response
80
304
  }
81
305
 
82
306
  /**
@@ -118,6 +342,22 @@ function assertSameOrigin(url: string): void {
118
342
  )
119
343
  }
120
344
  if (target.origin !== pageOrigin) {
345
+ // Dev-mode escape hatch — embedded apps (e.g. openframe-frontend)
346
+ // run on a different origin from their gateway during local dev,
347
+ // and forcing a Next.js `rewrites()` workaround is more error-prone
348
+ // than relaxing the guard for the dev build. In production
349
+ // (`NODE_ENV === 'production'`) the guard stays absolute — same
350
+ // defense-in-depth bearer-leak protection as before. The check is
351
+ // baked at build time by Next/webpack/Turbopack so prod bundles
352
+ // contain only the throwing branch (no dev string in the artifact).
353
+ if (process.env.NODE_ENV !== 'production') {
354
+ console.warn(
355
+ `[embedAuthedFetch] cross-origin fetch to ${target.origin} ` +
356
+ `allowed in dev (NODE_ENV !== 'production'). Production builds ` +
357
+ `will reject this — wire a same-origin proxy before shipping.`,
358
+ )
359
+ return
360
+ }
121
361
  throw new Error(
122
362
  `embedAuthedFetch: refusing cross-origin fetch to ${target.origin} — pass a relative /api/* path instead`,
123
363
  )
@@ -120,7 +120,11 @@ export {
120
120
  // surfaces from importing a chat-prefixed symbol. Old chat-prefixed
121
121
  // aliases are kept as @deprecated re-exports at
122
122
  // `components/chat/utils/chat-authed-fetch.ts` + `chat-proxy-auth-storage.ts`.
123
- export { embedAuthedFetch } from './embed-authed-fetch'
123
+ export {
124
+ embedAuthedFetch,
125
+ setEmbedAuthAdapter,
126
+ type EmbedAuthAdapter,
127
+ } from './embed-authed-fetch'
124
128
  export {
125
129
  type EmbedProxyAuth,
126
130
  getEmbedProxyAuth,