'use client' /** * Client-side persistence for embed-surface proxy credentials (a * PLATFORM API KEY + impersonation email). Used by every embedded * surface — the chat widget AND the ticket center AND any future * embedded React component that needs to identify itself as the * impersonated customer. * * When set, the surface attaches the creds as * `Authorization: Bearer ` + `X-Chat-Act-As: ` * on every call to `/api/docs/chat`, `/api/chat/*`, and any other route * gated by `requireChatAuth` — proving to the server that this session * is acting on behalf of . The key is minted in the hub's * `/admin/api-keys` and is platform-bound (`fpk__…`): a * SERVICE key may act as any email; a PERSONAL key only as its owner. * * **Naming history:** the stored field is still named `secret` and the * wire-side header names are `X-Chat-*`. Those are client/server * contracts predating the per-platform key migration (the old shared * `CHAT_PROXY_SECRET` env is removed hub-side); renaming them would * break persisted state + the wire shape for zero behavior change. The * CLIENT-side helpers were renamed `Embed*` so non-chat surfaces (e.g. * ticket center) don't have to import a chat-prefixed symbol just to * send the same headers. * * Persists to **`localStorage`** so the bearer token + act-as identity * survive tab close, new-tab opens, and browser restarts — the * `/debug` paste-creds UI is an admin tool and re-pasting every tab * cycle was rejected as a dev-experience tradeoff that wasn't worth * the security gain. An XSS sink on this origin can read the value * indefinitely (vs only-this-tab with sessionStorage), but `/debug` * is admin-gated behind the platform's `askAI.enabled` flag, the key * is server-verified (hash-at-rest, platform-bound, revocable in * `/admin/api-keys`), and the act-as reach is bounded by the key's * tier. Explicit "Clear" button on the creds bar is the supported * logout path; closing the tab is no longer. * * Namespaced under `.chat.proxy-auth.v1` (the storage key is * unchanged from the old chat-prefixed helper — that's a storage * contract; renaming it would log everyone out). */ import { createLocalStorageAdapter } from './local-storage-adapter' import { getAppType } from './app-config' export interface EmbedProxyAuth { secret: string email: string /** Optional identity passthrough — empty/omitted = not sent. Server * parses these as `X-Chat-{First,Last}-Name` / `X-Chat-Avatar-Url` and * threads them through `resolveChatProxyIdentity`'s returned user. */ firstName?: string lastName?: string avatarUrl?: string } function isValidPersistedAuth(value: unknown): value is EmbedProxyAuth { if (!value || typeof value !== 'object') return false const v = value as Record if ( typeof v.secret !== 'string' || v.secret.trim().length === 0 || typeof v.email !== 'string' || v.email.trim().length === 0 ) return false // Optional fields: when present must be strings. Empty string is treated // as absent later (in `getEmbedProxyAuth`). if (v.firstName != null && typeof v.firstName !== 'string') return false if (v.lastName != null && typeof v.lastName !== 'string') return false if (v.avatarUrl != null && typeof v.avatarUrl !== 'string') return false return true } const adapter = createLocalStorageAdapter({ // Storage key unchanged from the legacy chat-prefixed helper. Renaming // it would silently log every existing admin out — the key is a // storage contract, not a code identifier. key: 'chat.proxy-auth.v1', namespace: () => getAppType(), validate: isValidPersistedAuth, logTag: '[embed-proxy-auth-storage]', // localStorage — survives tab close, new tabs, and browser restarts. // Admin re-pasting creds every tab cycle was the dev-experience // tradeoff prior `sessionStorage` setup demanded — rejected. See // file-level doc comment for the security tradeoff rationale. backend: 'local', }) /** Trim + null-coerce an optional identity field so consumers can do * `auth.firstName ?? ''` without worrying about whitespace-only strings. */ function normalizeOptional(value: string | undefined): string | undefined { if (!value) return undefined const trimmed = value.trim() return trimmed.length > 0 ? trimmed : undefined } /** * Returns full credentials (secret + email + optional identity passthrough) * when secret + email are available. Returns `null` when nothing is saved — * callers treat that as "fall back to cookie auth". */ export function getEmbedProxyAuth(): EmbedProxyAuth | null { const persisted = adapter.load() if (!persisted) return null return { secret: persisted.secret, email: persisted.email.trim().toLowerCase(), firstName: normalizeOptional(persisted.firstName), lastName: normalizeOptional(persisted.lastName), avatarUrl: normalizeOptional(persisted.avatarUrl), } } /** * Returns the LAST email the admin saved. The proxy creds bar reads * this to pre-fill the email field on mount. */ export function getPersistedProxyEmail(): string | null { const persisted = adapter.load() return persisted?.email.trim().toLowerCase() ?? null } /** Save the proxy creds. Secret + email are required; identity-passthrough * fields are persisted only when non-empty. */ export function setEmbedProxyAuth(value: EmbedProxyAuth): void { adapter.save({ secret: value.secret, email: value.email.trim().toLowerCase(), firstName: normalizeOptional(value.firstName), lastName: normalizeOptional(value.lastName), avatarUrl: normalizeOptional(value.avatarUrl), }) } /** Drop the persisted creds. */ export function clearEmbedProxyAuth(): void { adapter.clear() } /** * Apply the embed-proxy auth (Bearer + X-Chat-Act-As) to a fetch call's * URL + headers. Used by every embedded-surface route that needs to * identify itself as the proxied customer (chat stream, agent-* routes, * ticket-center actions). When proxy auth is absent (regular * cookie-session users), returns the inputs unchanged so the cookie-auth * path still works. * * `X-Chat-Act-As` header (vs a URL query param) keeps PII out of access * logs, Sentry breadcrumbs, browser history, and CDN analytics. */ export function applyProxyAuth( url: string, baseHeaders: Record = { 'Content-Type': 'application/json' }, ): { url: string; headers: Record } { const auth = getEmbedProxyAuth() const headers = { ...baseHeaders } if (auth?.secret) { headers.Authorization = `Bearer ${auth.secret}` } if (auth?.email) { headers['X-Chat-Act-As'] = auth.email } // Optional identity passthrough — only attached when present so the // server's "required vs optional" header shape stays exact. if (auth?.firstName) headers['X-Chat-First-Name'] = auth.firstName if (auth?.lastName) headers['X-Chat-Last-Name'] = auth.lastName if (auth?.avatarUrl) headers['X-Chat-Avatar-Url'] = auth.avatarUrl return { url, headers } }