{"version":3,"sources":["/home/runner/work/openframe-oss-lib/openframe-oss-lib/openframe-frontend-core/dist/chunk-EHAYNUOL.cjs","../src/hooks/ui/use-image-edge-color.ts","../src/utils/platform-identity.ts","../src/utils/app-config.ts","../src/utils/local-storage-adapter.ts","../src/utils/embed-proxy-auth-storage.ts","../src/utils/embed-authed-fetch.ts","../src/hooks/use-authed-asset-src.ts","../src/hooks/use-authed-image-src.ts","../src/utils/og-placeholder.ts","../src/hooks/use-og-placeholder-url.ts"],"names":["r","g","b","adapter"],"mappings":"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AA;AACG,QAAA;AACX,MAAA;AACF,IAAA;AAEc,IAAA;AACR,MAAA;AACK,MAAA;AACX,IAAA;AAEU,IAAA;AAEG,IAAA;AACC,MAAA;AACd,IAAA;AACY,EAAA;AAEP,EAAA;AACT;ADhDmB;AACA;AEpEN;AACF,EAAA;AACE,EAAA;AACD,EAAA;AACV,EAAA;AACiB,EAAA;AACF,EAAA;AACA,EAAA;AACD,EAAA;AACC,EAAA;AACT,EAAA;AACD,EAAA;AACM,EAAA;AACb;AAMa;AACN,EAAA;AACP;AAEa;AAET,EAAA;AACS,EAAA;AAET,EAAA;AACF,EAAA;AACM,EAAA;AACD,EAAA;AACM,EAAA;AACb;AAEa;AACF,EAAA;AACE,EAAA;AACD,EAAA;AACV,EAAA;AACM,EAAA;AACD,EAAA;AACM,EAAA;AACb;AAEa;AACF,EAAA;AACE,EAAA;AACD,EAAA;AACC,EAAA;AACX,EAAA;AACiB,EAAA;AACF,EAAA;AACA,EAAA;AACD,EAAA;AACC,EAAA;AACT,EAAA;AACD,EAAA;AACP;AAEgB;AACP,EAAA;AACT;AAGgB;AACP,EAAA;AACT;AAEgB;AACP,EAAA;AACT;AAEgB;AACP,EAAA;AACT;AAEgB;AACP,EAAA;AACT;AAYa;AACM,EAAA;AACA,EAAA;AACF,EAAA;AACN,EAAA;AACA,EAAA;AACF,EAAA;AACP,EAAA;AACF;AAOa;AAEQ,EAAA;AACT,IAAA;AACF,IAAA;AACI,IAAA;AACI,IAAA;AACd,EAAA;AACiB,EAAA;AACT,IAAA;AACF,IAAA;AACI,IAAA;AACI,IAAA;AACd,EAAA;AACe,EAAA;AACP,IAAA;AACF,IAAA;AACI,IAAA;AACI,IAAA;AACd,EAAA;AACS,EAAA;AACD,IAAA;AACF,IAAA;AACI,IAAA;AACI,IAAA;AACd,EAAA;AACS,EAAA;AACD,IAAA;AACF,IAAA;AACI,IAAA;AACI,IAAA;AACd,EAAA;AACO,EAAA;AACC,IAAA;AACF,IAAA;AACI,IAAA;AACI,IAAA;AACd,EAAA;AACA,EAAA;AACQ,IAAA;AACF,IAAA;AACI,IAAA;AACI,IAAA;AACd,E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AAC1C;AAES;AACS,EAAA;AAKAA,EAAAA;AACF,EAAA;AACV,EAAA;AACC,EAAA;AAIH,IAAA;AAII,MAAA;AACD,IAAA;AACH,IAAA;AACF,EAAA;AACO,EAAA;AACT;AAQe;AAUA,EAAA;AAOG,EAAA;AACH,EAAA;AAII,IAAA;AACH,MAAA;AACZ,IAAA;AACF,EAAA;AACM,EAAA;AAEW,EAAA;AACZ,IAAA;AACH,IAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAMA,IAAA;AACD,EAAA;AAKY,EAAA;AACL,IAAA;AACS,IAAA;AACN,MAAA;AACT,IAAA;AACF,EAAA;AAEO,EAAA;AACT;AAqBS;AACI,EAAA;AACP,EAAA;AACA,EAAA;AACA,EAAA;AACW,IAAA;AAKA,IAAA;AACP,EAAA;AACI,IAAA;AACZ,EAAA;AACW,EAAA;AACC,IAAA;AACR,MAAA;AACF,IAAA;AACF,EAAA;AACW,EAAA;AAML,IAAA;AACF,MAAA;AACF,IAAA;AASY,IAAA;AACF,MAAA;AACN,QAAA;AAGF,MAAA;AACA,MAAA;AACF,IAAA;AACU,IAAA;AACR,MAAA;AACF,IAAA;AACF,EAAA;AACF;AN8GmB;AACA;AO7iBV;AA6BH;AACW;AACb;AASY;AACd,EAAA;AACW,EAAA;AACG,EAAA;AACC,EAAA;AACjB;AAES;AACO,EAAA;AACA,EAAA;AACN,IAAA;AACI,IAAA;AAED,MAAA;AACC,MAAA;AACF,MAAA;AACF,QAAA;AACK,MAAA;AACD,QAAA;AACN,MAAA;AAEO,IAAA;AACM,MAAA;AACd,IAAA;AACU,IAAA;AACf,EAAA;AACO,EAAA;AACT;AAQgB;AACR,EAAA;AACW,EAAA;AAED,EAAA;AACT,IAAA;AACD,IAAA;AACW,IAAA;AAEJ,IAAA;AACF,MAAA;AACN,IAAA;AACU,IAAA;AACC,MAAA;AACd,IAAA;AACa,EAAA;AAEE,EAAA;AACD,EAAA;AACT,EAAA;AACT;AP8fmB;AACA;AQllBH;AACP,EAAA;AACT;AAOa;AR8kBM;AACA;ASjjBb;AAIG;AACQ,EAAA;AACT,EAAA;AACU,EAAA;AAIR,IAAA;AACF,IAAA;AACN,EAAA;AACO,EAAA;AACT;AAYgB;AAKD,EAAA;AACE,EAAA;AACF,EAAA;AACE,EAAA;AAEJ,EAAA;AACO,EAAA;AAKJ,EAAA;AACC,EAAA;AAGJ,EAAA;AACA,EAAA;AAEG,EAAA;AAChB;ATshBmB;AACA;AU7nBV;AA+BO;AACd,EAAA;AACW,EAAA;AACF,EAAA;AACC,EAAA;AAC+B;AACnC,EAAA;AAES,EAAA;AACR,IAAA;AACE,IAAA;AACM,EAAA;AACjB;AVgmBmB;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA","file":"/home/runner/work/openframe-oss-lib/openframe-oss-lib/openframe-frontend-core/dist/chunk-EHAYNUOL.cjs","sourcesContent":[null,"'use client';\n\nimport { useState, useEffect } from 'react';\n\n/**\n * Extract the dominant edge color from an image using color bucketing.\n * Samples pixels along left and right edges (the visible letterbox areas),\n * groups them into color buckets, and returns the most common bucket.\n * This avoids muddy averages when edges have mixed colors (e.g., sky + sand).\n */\nfunction extractEdgeColor(img: HTMLImageElement): string {\n  const canvas = document.createElement('canvas');\n  const ctx = canvas.getContext('2d');\n  if (!ctx) return '#000000';\n\n  const maxSize = 100;\n  const scale = Math.min(maxSize / img.naturalWidth, maxSize / img.naturalHeight);\n  const w = Math.round(img.naturalWidth * scale);\n  const h = Math.round(img.naturalHeight * scale);\n  canvas.width = w;\n  canvas.height = h;\n\n  ctx.drawImage(img, 0, 0, w, h);\n\n  const data = ctx.getImageData(0, 0, w, h).data;\n\n  // Sample left edge, right edge, top edge, bottom edge (15% band)\n  const edgeW = Math.max(2, Math.round(w * 0.15));\n  const edgeH = Math.max(2, Math.round(h * 0.15));\n\n  // Color bucketing: quantize to 32-step buckets for grouping similar colors\n  const bucketSize = 32;\n  const buckets = new Map<string, { r: number; g: number; b: number; count: number }>();\n\n  for (let y = 0; y < h; y++) {\n    for (let x = 0; x < w; x++) {\n      const isEdge = x < edgeW || x >= w - edgeW || y < edgeH || y >= h - edgeH;\n\n      if (!isEdge) continue;\n\n      const i = (y * w + x) * 4;\n      const a = data[i + 3];\n      if (a < 128) continue;\n\n      const r = data[i];\n      const g = data[i + 1];\n      const b = data[i + 2];\n\n      // Quantize to bucket\n      const br = Math.floor(r / bucketSize) * bucketSize;\n      const bg = Math.floor(g / bucketSize) * bucketSize;\n      const bb = Math.floor(b / bucketSize) * bucketSize;\n      const key = `${br},${bg},${bb}`;\n\n      const existing = buckets.get(key);\n      if (existing) {\n        existing.r += r;\n        existing.g += g;\n        existing.b += b;\n        existing.count++;\n      } else {\n        buckets.set(key, { r, g, b, count: 1 });\n      }\n    }\n  }\n\n  if (buckets.size === 0) return '#000000';\n\n  // Find the most common color bucket\n  let bestBucket: { r: number; g: number; b: number; count: number } | null = null;\n  for (const bucket of buckets.values()) {\n    if (!bestBucket || bucket.count > bestBucket.count) {\n      bestBucket = bucket;\n    }\n  }\n\n  if (!bestBucket || bestBucket.count === 0) return '#000000';\n\n  // Return average color within the winning bucket\n  const r = Math.round(bestBucket.r / bestBucket.count);\n  const g = Math.round(bestBucket.g / bestBucket.count);\n  const b = Math.round(bestBucket.b / bestBucket.count);\n\n  return `rgb(${r}, ${g}, ${b})`;\n}\n\n/**\n * Hook that extracts the dominant edge color from an image URL.\n * Returns a CSS color string for use as a background behind object-contain images.\n *\n * Always sets crossOrigin='anonymous' — required for canvas pixel access.\n * Most CDNs (Supabase, Cloudflare, our image proxy) return CORS headers.\n * If the server doesn't support CORS, onerror fires and we use the fallback.\n *\n * @param imageUrl - URL of the image to analyze\n * @param fallback - Fallback color if extraction fails (default: '#000000')\n * @returns CSS color string (e.g., 'rgb(34, 28, 22)')\n */\nexport function useImageEdgeColor(imageUrl: string | undefined | null, fallback = '#000000'): string {\n  const [color, setColor] = useState(fallback);\n\n  // Losing the url (or being given a different fallback) is a prop-driven\n  // reset, so it happens while rendering — React's documented alternative to a\n  // sync-from-props effect. From the effect it would commit one frame with the\n  // departed image's colour still painted behind an image that is already gone.\n  const [resetFor, setResetFor] = useState({ imageUrl, fallback });\n  if (resetFor.imageUrl !== imageUrl || resetFor.fallback !== fallback) {\n    setResetFor({ imageUrl, fallback });\n    if (!imageUrl) setColor(fallback);\n  }\n\n  useEffect(() => {\n    if (!imageUrl) return undefined;\n\n    let cancelled = false;\n    const img = new Image();\n    img.crossOrigin = 'anonymous';\n\n    img.onload = () => {\n      if (cancelled) return;\n      try {\n        setColor(extractEdgeColor(img));\n      } catch {\n        setColor(fallback);\n      }\n    };\n\n    img.onerror = () => {\n      if (cancelled) return;\n      setColor(fallback);\n    };\n\n    img.src = imageUrl;\n\n    return () => {\n      cancelled = true;\n    };\n  }, [imageUrl, fallback]);\n\n  return color;\n}\n","/**\n * Platform identity + brand SSOT (single source of truth).\n *\n * ONE module owns every per-platform STRING (display/short/long name, description,\n * slogan, icon name) and every per-platform COLOUR DECISION (the accent/secondary\n * \"stem\", the theme, the surface). Everything downstream DERIVES from here:\n * `platform-config.tsx`'s icon + colour maps, `PlatformBadge`, `getPlatformConfig`,\n * the hub's `AppConfig.brandColors`, the hub's manifest generator and `theme-color`.\n *\n * JSX-FREE + zero-dependency ON PURPOSE: it carries its own tsup entry and\n * `exports` subpath (`@flamingo-stack/openframe-frontend-core/utils/platform-identity`)\n * so hub SCRIPTS and `server-only` modules can import it without dragging React,\n * the icon components, or the utils barrel into their graph.\n *\n * TWO accepted by-construction copies of the colour decision remain, both LOCKED\n * by `src/__tests__/mlg-platform-leaves.test.ts` in this package:\n *   1. `platformHexColors` below (hand-typed hex mirror of each accent token).\n *   2. The `[data-app-type='…']` blocks in `src/styles/ods-colors.css`.\n */\n\nimport type { PlatformName } from '../types/platform';\n\n// ── Strings ─────────────────────────────────────────────────────────────────\n\n/** Human-readable platform names. THE naming source for lib-side presentation. */\nexport const platformDisplayNames = {\n  openmsp: 'OpenMSP',\n  openframe: 'OpenFrame',\n  flamingo: 'Flamingo',\n  'flamingo-teaser': 'Flamingo Teaser',\n  'marketing-hub': 'Flamingo Marketing Hub',\n  'product-hub': 'Flamingo Product Hub',\n  'revenue-hub': 'Flamingo Revenue Hub',\n  'people-hub': 'Flamingo People Hub',\n  'company-hub': 'Flamingo Company Hub',\n  tmcg: 'TMCG',\n  mlg: 'Major League GitHub',\n  universal: 'Universal',\n};\n\n/**\n * Short-name OVERRIDES only. A platform without a row falls back to its display\n * name, so this map never lists a name that equals `platformDisplayNames`.\n */\nexport const platformShortNames: Partial<Record<PlatformName, string>> = {\n  mlg: 'MLG',\n};\n\nexport const platformDescriptions = {\n  openmsp:\n    'Comprehensive directory and comparison platform for managed service providers (MSPs) and technology vendors. Reduce vendor costs and discover open-source alternatives.',\n  openframe: 'AI-driven open-source security operations center (SOC) and endpoint detection platform for MSPs.',\n  flamingo:\n    'AI-driven open-source OS for MSPs. Swap bloated vendor tools for open ones. Automate the boring crap. Take your margin back.',\n  'flamingo-teaser': 'Preview of Flamingo - the AI-driven open-source OS for MSPs.',\n  tmcg: 'The Miami Cyber Gang - A cybersecurity community focused on education and collaboration.',\n  mlg: 'Scouting report for US open-source talent. Rank GitHub contributors by programming language, city, state, region, and nearest Major League Soccer club.',\n  universal: 'Cross-platform universal content.',\n};\n\nexport const platformSlogans = {\n  openmsp: 'Find Your Perfect MSP Partner',\n  openframe: 'Open-Source Security Operations',\n  flamingo: 'Open-Source OS for MSPs',\n  'flamingo-teaser': 'Coming Soon: Open-Source OS for MSPs',\n  tmcg: 'Miami Cyber Community',\n  mlg: 'GitHub Scouting Report: Major League Edition',\n  universal: 'Universal Platform',\n};\n\nexport const platformIconNames = {\n  openmsp: 'openmsp-logo',\n  openframe: 'openframe-logo',\n  flamingo: 'flamingo-logo',\n  universal: 'globe',\n  'flamingo-teaser': 'flamingo-logo',\n  'marketing-hub': 'flamingo-logo',\n  'product-hub': 'flamingo-logo',\n  'revenue-hub': 'flamingo-logo',\n  'people-hub': 'flamingo-logo',\n  'company-hub': 'flamingo-logo',\n  tmcg: 'tmcg-logo',\n  mlg: 'mlg-logo',\n};\n\nexport function getPlatformDisplayName(platformName: string): string {\n  return platformDisplayNames[platformName as keyof typeof platformDisplayNames] || platformName;\n}\n\n/** Compact label (nav chips, manifest `short_name`, JSON-LD `alternateName`). */\nexport function getPlatformShortName(platformName: string): string {\n  return platformShortNames[platformName as PlatformName] ?? getPlatformDisplayName(platformName);\n}\n\nexport function getPlatformDescription(platformName: string): string {\n  return platformDescriptions[platformName as keyof typeof platformDescriptions] || platformName;\n}\n\nexport function getPlatformSlogan(platformName: string): string {\n  return platformSlogans[platformName as keyof typeof platformSlogans] || platformName;\n}\n\nexport function getDefaultIconForPlatform(platformName: string): string {\n  return platformIconNames[platformName as keyof typeof platformIconNames] || platformIconNames.universal;\n}\n\n// ── Colour stems ────────────────────────────────────────────────────────────\n\n/**\n * The six brand/attention colours (plus the neutral) a platform may adopt, named\n * by STEM rather than by token so the token spelling has exactly one home below.\n */\nexport type OdsColorStem =\n  'flamingo-pink' | 'flamingo-cyan' | 'open-yellow' | 'success' | 'warning' | 'error' | 'text-secondary';\n\n/** Stem → ODS token name (without the `--ods-` prefix). THE token spelling. */\nexport const ODS_STEM_TOKENS: Record<OdsColorStem, string> = {\n  'flamingo-pink': 'flamingo-pink-base',\n  'flamingo-cyan': 'flamingo-cyan-base',\n  'open-yellow': 'open-yellow-base',\n  success: 'attention-green-success',\n  warning: 'attention-yellow-warning',\n  error: 'attention-red-error',\n  'text-secondary': 'system-greys-grey',\n};\n\n/**\n * Stem → Tailwind class strings. STATIC LITERALS on purpose: the Tailwind\n * scanner reads source text, so a template-built class would be purged.\n * This is the ONLY place a stem-to-class literal exists.\n */\nexport const ODS_STEM_CLASSES: Record<OdsColorStem, { text: string; bg: string; bgSoft: string; borderSoft: string }> =\n  {\n    'flamingo-pink': {\n      text: 'text-ods-flamingo-pink',\n      bg: 'bg-ods-flamingo-pink',\n      bgSoft: 'bg-ods-flamingo-pink/10',\n      borderSoft: 'border-ods-flamingo-pink/30',\n    },\n    'flamingo-cyan': {\n      text: 'text-ods-flamingo-cyan',\n      bg: 'bg-ods-flamingo-cyan',\n      bgSoft: 'bg-ods-flamingo-cyan/10',\n      borderSoft: 'border-ods-flamingo-cyan/30',\n    },\n    'open-yellow': {\n      text: 'text-ods-open-yellow',\n      bg: 'bg-ods-open-yellow',\n      bgSoft: 'bg-ods-open-yellow/10',\n      borderSoft: 'border-ods-open-yellow/30',\n    },\n    success: {\n      text: 'text-ods-success',\n      bg: 'bg-ods-success',\n      bgSoft: 'bg-ods-success/10',\n      borderSoft: 'border-ods-success/30',\n    },\n    warning: {\n      text: 'text-ods-warning',\n      bg: 'bg-ods-warning',\n      bgSoft: 'bg-ods-warning/10',\n      borderSoft: 'border-ods-warning/30',\n    },\n    error: {\n      text: 'text-ods-error',\n      bg: 'bg-ods-error',\n      bgSoft: 'bg-ods-error/10',\n      borderSoft: 'border-ods-error/30',\n    },\n    'text-secondary': {\n      text: 'text-ods-text-secondary',\n      bg: 'bg-ods-text-secondary',\n      bgSoft: 'bg-ods-text-secondary/10',\n      borderSoft: 'border-ods-text-secondary/30',\n    },\n  };\n\n// ── Theme + surface ─────────────────────────────────────────────────────────\n\n/**\n * Every platform ships the dark theme today (the hub's flamingo config says so\n * explicitly, and every `[data-app-type]` block inherits the dark `:root`).\n * ONE constant instead of a per-platform field: reintroduce the field only when\n * a platform actually differs.\n */\nexport const PLATFORM_THEME = 'dark' as const;\n\n/** The dark surface's tokens, consumed by config `brandColors` and the manifest. */\nexport const PLATFORM_SURFACE = {\n  background: 'system-greys-background',\n  text: 'system-greys-white',\n} as const;\n\nexport const getPlatformBackgroundVarName = (): string => `--ods-${PLATFORM_SURFACE.background}`;\nexport const getPlatformTextVarName = (): string => `--ods-${PLATFORM_SURFACE.text}`;\n\n// ── Brand record ────────────────────────────────────────────────────────────\n\nexport interface PlatformBrand {\n  /** Primary brand colour (`--color-accent-primary` in the platform's CSS block). */\n  accentStem: OdsColorStem;\n  /** Link colour (`--color-link`). Equals `accentStem` for most platforms. */\n  secondaryStem: OdsColorStem;\n}\n\n/**\n * THE accent/link table. Every row is verified against that platform's\n * `[data-app-type='…']` block in `ods-colors.css` by the hub's parity test.\n */\nexport const PLATFORM_BRAND: Record<PlatformName, PlatformBrand> = {\n  openframe: { accentStem: 'open-yellow', secondaryStem: 'flamingo-cyan' },\n  openmsp: { accentStem: 'open-yellow', secondaryStem: 'open-yellow' },\n  flamingo: { accentStem: 'flamingo-pink', secondaryStem: 'flamingo-pink' },\n  'flamingo-teaser': { accentStem: 'flamingo-pink', secondaryStem: 'flamingo-pink' },\n  tmcg: { accentStem: 'flamingo-pink', secondaryStem: 'flamingo-pink' },\n  'marketing-hub': { accentStem: 'flamingo-pink', secondaryStem: 'flamingo-pink' },\n  'product-hub': { accentStem: 'success', secondaryStem: 'success' },\n  'revenue-hub': { accentStem: 'warning', secondaryStem: 'warning' },\n  'people-hub': { accentStem: 'flamingo-cyan', secondaryStem: 'flamingo-cyan' },\n  'company-hub': { accentStem: 'error', secondaryStem: 'error' },\n  mlg: { accentStem: 'flamingo-pink', secondaryStem: 'flamingo-cyan' },\n  universal: { accentStem: 'text-secondary', secondaryStem: 'text-secondary' },\n};\n\n/** The brand row for a platform, falling back to `universal` for anything unknown. */\nexport function getPlatformBrand(platformName: string): PlatformBrand {\n  return PLATFORM_BRAND[platformName as PlatformName] ?? PLATFORM_BRAND.universal;\n}\n\nexport function platformAccentToken(platformName: string): string {\n  return ODS_STEM_TOKENS[getPlatformBrand(platformName).accentStem];\n}\n\nexport function platformSecondaryToken(platformName: string): string {\n  return ODS_STEM_TOKENS[getPlatformBrand(platformName).secondaryStem];\n}\n\n/** `--ods-…` CSS variable NAME for a platform's accent (input to `resolveOdsColor`). */\nexport function platformAccentVarName(platformName: string): string {\n  return `--ods-${platformAccentToken(platformName)}`;\n}\n\nexport function platformSecondaryVarName(platformName: string): string {\n  return `--ods-${platformSecondaryToken(platformName)}`;\n}\n\n/** `var(--ods-…)` VALUE for a platform's accent (config `brandColors` data). */\nexport function getPlatformAccentColor(platformName: string): string {\n  return `var(${platformAccentVarName(platformName)})`;\n}\n\nexport function getPlatformSecondaryColor(platformName: string): string {\n  return `var(${platformSecondaryVarName(platformName)})`;\n}\n\n/** Tailwind classes for a platform, DERIVED from its stems. */\nexport function getPlatformBrandClasses(platformName: string): {\n  accentText: string;\n  accentBg: string;\n  accentBgSoft: string;\n  accentBorderSoft: string;\n  secondaryText: string;\n} {\n  const brand = getPlatformBrand(platformName);\n  const accent = ODS_STEM_CLASSES[brand.accentStem];\n  const secondary = ODS_STEM_CLASSES[brand.secondaryStem];\n  return {\n    accentText: accent.text,\n    accentBg: accent.bg,\n    accentBgSoft: accent.bgSoft,\n    accentBorderSoft: accent.borderSoft,\n    secondaryText: secondary.text,\n  };\n}\n\n/** Platform background classes, DERIVED (was a hand-typed hex map). */\nexport const platformColors: Record<PlatformName, string> = Object.fromEntries(\n  (Object.keys(PLATFORM_BRAND) as PlatformName[]).map(p => [p, getPlatformBrandClasses(p).accentBg]),\n) as Record<PlatformName, string>;\n\nexport function getPlatformColor(platformName: string) {\n  return platformColors[platformName as PlatformName] || platformColors.universal;\n}\n\n/**\n * Hand-typed hex mirror of each platform's accent token (the DB `default_color`\n * and other JS-value consumers). Accepted copy #1 — locked by\n * `src/__tests__/mlg-platform-leaves.test.ts` in this package, which asserts\n * every row against the `[data-app-type]` blocks in `ods-colors.css`.\n * (The hub has its OWN parity test, but it holds different copies: the asset\n * generator's map and the generated manifests.)\n */\nexport const platformHexColors: Record<PlatformName, string> = {\n  openmsp: '#ffc008',\n  openframe: '#ffc008',\n  flamingo: '#f357bb',\n  universal: '#888888',\n  'flamingo-teaser': '#f357bb',\n  'marketing-hub': '#f357bb',\n  'product-hub': '#5ea62e',\n  'revenue-hub': '#e1b32f',\n  'people-hub': '#5efaf0',\n  'company-hub': '#f36666',\n  tmcg: '#f357bb',\n  mlg: '#f357bb',\n};\n\nexport function getDefaultColorForPlatform(platformName: string): string {\n  return platformHexColors[platformName as PlatformName] || platformHexColors.universal;\n}\n","// Stub app config\nexport const APP_CONFIG = {\n  app: {\n    type: 'openmsp',\n    name: 'OpenMSP',\n    domain: 'openmsp.ai',\n  },\n  features: {\n    announcements: true,\n    notifications: true,\n  },\n} as const;\n\nexport function getAppConfig() {\n  return APP_CONFIG;\n}\n\nexport function getAppType() {\n  // The exact `process.env.NEXT_PUBLIC_APP_TYPE` member expression is kept so\n  // Next/webpack can statically inline it. The try/catch makes the call safe\n  // in non-Next React hosts (Vite/CRA embeds) where `process` does not exist\n  // at runtime — those hosts get the 'openmsp' fallback, which is fine for\n  // embeds: dismissal cookies are domain-scoped and an embed domain serves\n  // one platform's announcements.\n  try {\n    return process.env.NEXT_PUBLIC_APP_TYPE || 'openmsp';\n  } catch {\n    return 'openmsp';\n  }\n}\n","/**\n * Thin JSON-typed Web-Storage adapter (localStorage or sessionStorage).\n *\n * Centralizes the SSR-guard + try/catch + silent quota-failure pattern\n * that every per-feature storage util would otherwise re-implement.\n *\n * Optional `namespace` prefix supports platform / user partitioning —\n * the resolver runs lazily at call time so the namespace can vary across\n * the lifetime of the page (e.g. proxy-auth switches user, which switches\n * the key suffix).\n *\n * Backend selection (`backend: 'local' | 'session'`):\n *   - `'local'` (default): persists across browser sessions. Use for\n *     UI state, chat history metadata, feature-flag opt-ins.\n *   - `'session'`: cleared when the tab closes. Use for ANY auth-adjacent\n *     value (bearer tokens, act-as identity, proxy credentials). Reduces\n *     the XSS-exfiltration attack window from \"indefinite\" to \"until tab\n *     close\" without losing per-session ergonomics.\n */\n\nexport type WebStorageBackend = 'local' | 'session';\n\nexport interface LocalStorageAdapter<T> {\n  load(): T | null;\n  save(value: T): void;\n  clear(): void;\n  /** Resolved storage key for the current call. Useful for tests. */\n  resolveKey(): string;\n}\n\nexport interface LocalStorageAdapterOptions<T> {\n  /** Base storage key. Combined with `namespace()` when provided. */\n  key: string;\n  /** Optional dynamic namespace prefix appended via `.` separator.\n   *  Called on EVERY read/write so the key can vary across the page\n   *  lifetime (e.g. when the platform or user identity changes). */\n  namespace?: () => string | null | undefined;\n  /** Runtime shape check. Falsey return → `load()` yields null. */\n  validate?: (parsed: unknown) => parsed is T;\n  /** Diagnostic prefix written to `console.warn` on parse / write\n   *  failures. Defaults to `'[local-storage]'`. */\n  logTag?: string;\n  /** Which Web-Storage backend to use. Defaults to `'local'`. Pass\n   *  `'session'` for anything auth-adjacent so the value evaporates\n   *  when the tab closes. */\n  backend?: WebStorageBackend;\n}\n\nfunction getStorage(backend: WebStorageBackend): Storage | null {\n  if (typeof window === 'undefined') return null;\n  try {\n    return backend === 'session' ? window.sessionStorage : window.localStorage;\n  } catch {\n    // Some sandboxed contexts (Safari private mode older versions,\n    // strict CSP) throw on storage access — treat as unavailable.\n    return null;\n  }\n}\n\nexport function createLocalStorageAdapter<T>(options: LocalStorageAdapterOptions<T>): LocalStorageAdapter<T> {\n  const tag = options.logTag ?? '[local-storage]';\n  const backend: WebStorageBackend = options.backend ?? 'local';\n  const resolveKey = (): string => {\n    const ns = options.namespace?.();\n    return ns ? `${ns}.${options.key}` : options.key;\n  };\n\n  return {\n    resolveKey,\n    load() {\n      const storage = getStorage(backend);\n      if (!storage) return null;\n      try {\n        const raw = storage.getItem(resolveKey());\n        if (!raw) return null;\n        const parsed = JSON.parse(raw) as unknown;\n        if (options.validate && !options.validate(parsed)) return null;\n        return parsed as T;\n      } catch (err) {\n        console.warn(`${tag} parse failed for key ${resolveKey()}:`, err);\n        return null;\n      }\n    },\n    save(value: T) {\n      const storage = getStorage(backend);\n      if (!storage) return;\n      try {\n        storage.setItem(resolveKey(), JSON.stringify(value));\n      } catch (err) {\n        console.warn(`${tag} write failed for key ${resolveKey()}:`, err);\n      }\n    },\n    clear() {\n      const storage = getStorage(backend);\n      if (!storage) return;\n      try {\n        storage.removeItem(resolveKey());\n      } catch (err) {\n        console.warn(`${tag} clear failed for key ${resolveKey()}:`, err);\n      }\n    },\n  };\n}\n","'use client';\n\n/**\n * Client-side persistence for embed-surface proxy credentials (a\n * PLATFORM API KEY + impersonation email). Used by every embedded\n * surface — the chat widget AND the ticket center AND any future\n * embedded React component that needs to identify itself as the\n * impersonated customer.\n *\n * When set, the surface attaches the creds as\n *   `Authorization: Bearer <platform API key>` + `X-Chat-Act-As: <email>`\n * on every call to `/api/docs/chat`, `/api/chat/*`, and any other route\n * gated by `requireChatAuth` — proving to the server that this session\n * is acting on behalf of <email>. The key is minted in the hub's\n * `/admin/api-keys` and is platform-bound (`fpk_<platform>_…`): a\n * SERVICE key may act as any email; a PERSONAL key only as its owner.\n *\n * **Naming history:** the stored field is still named `secret` and the\n * wire-side header names are `X-Chat-*`. Those are client/server\n * contracts predating the per-platform key migration (the old shared\n * `CHAT_PROXY_SECRET` env is removed hub-side); renaming them would\n * break persisted state + the wire shape for zero behavior change. The\n * CLIENT-side helpers were renamed `Embed*` so non-chat surfaces (e.g.\n * ticket center) don't have to import a chat-prefixed symbol just to\n * send the same headers.\n *\n * Persists to **`localStorage`** so the bearer token + act-as identity\n * survive tab close, new-tab opens, and browser restarts — the\n * `/debug` paste-creds UI is an admin tool and re-pasting every tab\n * cycle was rejected as a dev-experience tradeoff that wasn't worth\n * the security gain. An XSS sink on this origin can read the value\n * indefinitely (vs only-this-tab with sessionStorage), but `/debug`\n * is admin-gated behind the platform's `askAI.enabled` flag, the key\n * is server-verified (hash-at-rest, platform-bound, revocable in\n * `/admin/api-keys`), and the act-as reach is bounded by the key's\n * tier. Explicit \"Clear\" button on the creds bar is the supported\n * logout path; closing the tab is no longer.\n *\n * Namespaced under `<platform>.chat.proxy-auth.v1` (the storage key is\n * unchanged from the old chat-prefixed helper — that's a storage\n * contract; renaming it would log everyone out).\n */\n\nimport { getAppType } from './app-config';\nimport { createLocalStorageAdapter } from './local-storage-adapter';\n\nexport interface EmbedProxyAuth {\n  secret: string;\n  email: string;\n  /** Optional identity passthrough — empty/omitted = not sent. Server\n   *  parses these as `X-Chat-{First,Last}-Name` / `X-Chat-Avatar-Url` and\n   *  threads them through `resolveChatProxyIdentity`'s returned user. */\n  firstName?: string;\n  lastName?: string;\n  avatarUrl?: string;\n}\n\nfunction isValidPersistedAuth(value: unknown): value is EmbedProxyAuth {\n  if (!value || typeof value !== 'object') return false;\n  const v = value as Record<string, unknown>;\n  if (\n    typeof v.secret !== 'string' ||\n    v.secret.trim().length === 0 ||\n    typeof v.email !== 'string' ||\n    v.email.trim().length === 0\n  )\n    return false;\n  // Optional fields: when present must be strings. Empty string is treated\n  // as absent later (in `getEmbedProxyAuth`).\n  if (v.firstName != null && typeof v.firstName !== 'string') return false;\n  if (v.lastName != null && typeof v.lastName !== 'string') return false;\n  if (v.avatarUrl != null && typeof v.avatarUrl !== 'string') return false;\n  return true;\n}\n\nconst adapter = createLocalStorageAdapter<EmbedProxyAuth>({\n  // Storage key unchanged from the legacy chat-prefixed helper. Renaming\n  // it would silently log every existing admin out — the key is a\n  // storage contract, not a code identifier.\n  key: 'chat.proxy-auth.v1',\n  namespace: () => getAppType(),\n  validate: isValidPersistedAuth,\n  logTag: '[embed-proxy-auth-storage]',\n  // localStorage — survives tab close, new tabs, and browser restarts.\n  // Admin re-pasting creds every tab cycle was the dev-experience\n  // tradeoff prior `sessionStorage` setup demanded — rejected. See\n  // file-level doc comment for the security tradeoff rationale.\n  backend: 'local',\n});\n\n/** Trim + null-coerce an optional identity field so consumers can do\n *  `auth.firstName ?? ''` without worrying about whitespace-only strings. */\nfunction normalizeOptional(value: string | undefined): string | undefined {\n  if (!value) return undefined;\n  const trimmed = value.trim();\n  return trimmed.length > 0 ? trimmed : undefined;\n}\n\n/**\n * Returns full credentials (secret + email + optional identity passthrough)\n * when secret + email are available. Returns `null` when nothing is saved —\n * callers treat that as \"fall back to cookie auth\".\n */\nexport function getEmbedProxyAuth(): EmbedProxyAuth | null {\n  const persisted = adapter.load();\n  if (!persisted) return null;\n  return {\n    secret: persisted.secret,\n    email: persisted.email.trim().toLowerCase(),\n    firstName: normalizeOptional(persisted.firstName),\n    lastName: normalizeOptional(persisted.lastName),\n    avatarUrl: normalizeOptional(persisted.avatarUrl),\n  };\n}\n\n/**\n * Returns the LAST email the admin saved. The proxy creds bar reads\n * this to pre-fill the email field on mount.\n */\nexport function getPersistedProxyEmail(): string | null {\n  const persisted = adapter.load();\n  return persisted?.email.trim().toLowerCase() ?? null;\n}\n\n/** Save the proxy creds. Secret + email are required; identity-passthrough\n *  fields are persisted only when non-empty. */\nexport function setEmbedProxyAuth(value: EmbedProxyAuth): void {\n  adapter.save({\n    secret: value.secret,\n    email: value.email.trim().toLowerCase(),\n    firstName: normalizeOptional(value.firstName),\n    lastName: normalizeOptional(value.lastName),\n    avatarUrl: normalizeOptional(value.avatarUrl),\n  });\n}\n\n/** Drop the persisted creds. */\nexport function clearEmbedProxyAuth(): void {\n  adapter.clear();\n}\n\n/**\n * Apply the embed-proxy auth (Bearer + X-Chat-Act-As) to a fetch call's\n * URL + headers. Used by every embedded-surface route that needs to\n * identify itself as the proxied customer (chat stream, agent-* routes,\n * ticket-center actions). When proxy auth is absent (regular\n * cookie-session users), returns the inputs unchanged so the cookie-auth\n * path still works.\n *\n * `X-Chat-Act-As` header (vs a URL query param) keeps PII out of access\n * logs, Sentry breadcrumbs, browser history, and CDN analytics.\n */\nexport function applyProxyAuth(\n  url: string,\n  baseHeaders: Record<string, string> = { 'Content-Type': 'application/json' },\n): { url: string; headers: Record<string, string> } {\n  const auth = getEmbedProxyAuth();\n  const headers = { ...baseHeaders };\n  if (auth?.secret) {\n    headers.Authorization = `Bearer ${auth.secret}`;\n  }\n  if (auth?.email) {\n    headers['X-Chat-Act-As'] = auth.email;\n  }\n  // Optional identity passthrough — only attached when present so the\n  // server's \"required vs optional\" header shape stays exact.\n  if (auth?.firstName) headers['X-Chat-First-Name'] = auth.firstName;\n  if (auth?.lastName) headers['X-Chat-Last-Name'] = auth.lastName;\n  if (auth?.avatarUrl) headers['X-Chat-Avatar-Url'] = auth.avatarUrl;\n  return { url, headers };\n}\n","'use client';\n\n/**\n * Shared `fetch` wrapper for any embedded surface (chat, ticket center,\n * future widgets) that needs to carry the bearer-act-as identity\n * (proxy `Authorization` + `X-Chat-Act-As` headers from\n * `embed-proxy-auth-storage.ts`).\n *\n * Wire header names are `X-Chat-*` for historical reasons — that's a\n * server contract, not a UI namespace. The wrapper itself is generic.\n *\n * Drop-in replacement for `fetch()` — `Authorization` / `X-Chat-Act-As`\n * are merged into `init.headers` when proxy creds are stashed in\n * sessionStorage, otherwise the call falls through to the cookie-auth\n * path unchanged.\n *\n * Use this for any client-side fetch hitting `/api/chat/*`, `/api/docs/chat/*`,\n * or `/api/storage/generate-upload-url` (chat-attachment surface — shared\n * with the ticket center). Routes that do NOT need bearer-act-as\n * (e.g. `/api/profile/me`) keep using vanilla `fetch`.\n */\n\nimport { applyProxyAuth } from './embed-proxy-auth-storage';\n\n// =============================================================================\n// Host-supplied auth adapter (opt-in)\n// =============================================================================\n\n/**\n * Hosts that have their own auth model (cookie sessions, app-specific\n * JWT in localStorage, OAuth access tokens, …) can register an adapter\n * to override the lib's default `embedProxyAuth` flow. When set, the\n * adapter's `getHeaders()` result is merged onto every `embedAuthedFetch`\n * call AFTER the default proxy-auth header step (so adapter headers\n * win over both caller and proxy values), and `credentials` overrides\n * the default `'same-origin'` behaviour.\n *\n * Default (no adapter): MPH-style proxy-impersonation — bearer + act-as\n * read from localStorage, `credentials: 'same-origin'`. No consumer\n * needs to touch this unless they want a different auth model.\n *\n * Use cases:\n *   - openframe-frontend has its own JWT in `localStorage.of_access_token`\n *     and cookie-based session; register an adapter to attach the JWT\n *     and request `credentials: 'include'` so cookies travel cross-origin\n *     to the openframe gateway.\n *   - Future embed hosts with OAuth access tokens, signed URLs, etc.\n *\n * Lifetime: setter is module-level (intentionally — `embedAuthedFetch`\n * is a plain utility, not a hook, so it can't read React context). Host\n * runtime providers should call `setEmbedAuthAdapter(...)` on mount and\n * `setEmbedAuthAdapter(null)` on unmount. Multiple hosts registering at\n * once is a programming error (one chat panel per app).\n */\nexport interface EmbedAuthAdapter {\n  /** Headers merged onto every embedded-fetch call. Return `{}` to add\n   *  nothing. Called per-request so reactive token refresh sees the latest\n   *  value from your auth store / storage. Values typed as\n   *  `string | undefined` so the common narrowed shape\n   *  `{ Authorization: token ? 'Bearer …' : undefined }` (or a conditional\n   *  `token ? { Authorization: … } : {}`) assigns cleanly — `undefined`\n   *  values are filtered before being merged into the request headers. */\n  getHeaders?: () => Record<string, string | undefined>;\n  /** `RequestInit.credentials` mode. Default when no adapter: callers'\n   *  `init.credentials` or `'same-origin'`. Use `'include'` for cookie\n   *  auth against a different origin (CORS + `SameSite=None` required). */\n  credentials?: RequestCredentials;\n  /**\n   * Optional 401 self-heal. When a request comes back `401`,\n   * `embedAuthedFetch` calls this once, and — if it resolves `true` —\n   * retries the SAME request exactly once with freshly-recomputed\n   * headers (so a rotated bearer from `getHeaders()` is picked up).\n   * Resolve `false` to surface the 401 to the caller unchanged.\n   *\n   * This is the capability the openframe `apiClient` has had all along\n   * (refresh-the-access-token-then-retry); registering it here gives the\n   * embedded chat/ticket surfaces the same self-healing auth instead of\n   * dying on an expired token. Concurrent 401s are de-duplicated by the\n   * wrapper, so this fires at most once per refresh cycle even when a\n   * stampede of chat requests all expire together — your implementation\n   * does NOT need its own in-flight guard (though a token-refresh manager\n   * that already dedups is harmless).\n   *\n   * Keep it idempotent and side-effect-light: on failure the wrapper just\n   * returns the original 401 — logout/redirect decisions belong to the\n   * host's own auth layer, not to this fetch wrapper.\n   */\n  refresh?: () => Promise<boolean>;\n  /**\n   * Exact extra origins (`scheme://host[:port]`, as produced by `URL.origin`)\n   * the same-origin guard additionally accepts. The guard normally rejects\n   * every cross-origin URL in production — but native-shell hosts serve the\n   * page from a local pseudo-origin (`capacitor://localhost`,\n   * `tauri://localhost`) with NO server behind it, so the gateway their\n   * bearer already belongs to is unavoidably cross-origin. Listing it here\n   * is the host's explicit sanction to send the adapter's credentials there.\n   * Compared AFTER the http(s)-protocol check, so non-http(s) schemes can\n   * never be allowlisted. Omit (or leave empty) everywhere else — same-origin\n   * remains the rule, this is the narrow exception.\n   */\n  allowedOrigins?: string[];\n}\n\n/**\n * The registered adapter is parked on `globalThis`, NOT in a module-private\n * `let`. Reason: this lib ships multiple entry points (`/utils`,\n * `/components/chat`, …) and a consumer's bundler can inline this file into\n * more than one chunk — giving each chunk its OWN module scope. If the host\n * calls `setEmbedAuthAdapter` from the `/utils` copy while the chat's\n * `embedAuthedFetch` runs from the `/components/chat` copy, a module-local\n * `let` would be set on one copy and read as `null` on the other (the exact\n * \"credentials: same-origin, no Bearer, no refresh\" symptom). A single\n * `globalThis` slot is shared across every copy, so registration always\n * reaches the fetch path.\n */\nconst ADAPTER_GLOBAL_KEY = '__embedAuthedFetchAdapter__';\n\nfunction getRegisteredAuthAdapter(): EmbedAuthAdapter | null {\n  if (typeof globalThis === 'undefined') return null;\n  return ((globalThis as Record<string, unknown>)[ADAPTER_GLOBAL_KEY] as EmbedAuthAdapter | null) ?? null;\n}\n\nfunction storeRegisteredAuthAdapter(adapter: EmbedAuthAdapter | null): void {\n  if (typeof globalThis === 'undefined') return;\n  (globalThis as Record<string, unknown>)[ADAPTER_GLOBAL_KEY] = adapter;\n}\n\n/**\n * Register a host-owned auth adapter for `embedAuthedFetch`. Pass `null`\n * to clear (typically on provider unmount).\n *\n * Module-level state — there is one chat panel per app, so a single\n * registration is sufficient. Calling this twice with different non-null\n * adapters replaces the previous one (the most recent registration wins);\n * a `console.warn` flags the overwrite so duplicate-provider mounts get\n * caught in dev.\n */\nexport function setEmbedAuthAdapter(adapter: EmbedAuthAdapter | null): void {\n  if (adapter && getRegisteredAuthAdapter() && process.env.NODE_ENV !== 'production') {\n    console.warn(\n      '[setEmbedAuthAdapter] overwriting a previously-registered auth ' +\n        'adapter. Two chat-runtime providers should not coexist — verify ' +\n        'mount order and pass `null` from the unmounting provider.',\n    );\n  }\n  storeRegisteredAuthAdapter(adapter);\n}\n\n/**\n * Whether a host auth adapter is currently registered. Lets sibling helpers\n * (e.g. `contentFetch`) route through `embedAuthedFetch` ONLY when a host has\n * opted into embedded auth, and stay a plain `fetch` otherwise — so there's a\n * single auth knob (the adapter), not a second content-fetch registration.\n */\nexport function hasEmbedAuthAdapter(): boolean {\n  return getRegisteredAuthAdapter() !== null;\n}\n\n/**\n * Whether `url` is an asset the browser CANNOT load natively because its\n * auth rides in request headers.\n *\n * Native asset loads (`<img src>`, `<track src>`, CSS `background-image`)\n * can't carry custom headers, so a URL this returns `true` for must go\n * through `embedAuthedFetch` → blob object-URL instead (see\n * `useAuthedAssetSrc` / `useAuthedImageSrc`).\n *\n * The single signal is the registered adapter: when it supplies an\n * `Authorization` header, the host authenticates by HEADER, so nothing the\n * browser fetches on its own is authenticated. That holds for BOTH shapes a\n * header-auth host takes:\n *\n *   - a cross-origin gateway URL — sanctioned via `allowedOrigins` (native\n *     shells, whose page origin is `capacitor://` / `tauri://`);\n *   - a same-origin / relative reverse-proxy path (`/content/api/...`) —\n *     dev-ticket web, where the token lives in localStorage and there is NO\n *     session cookie to fall back on. This case used to be excluded on the\n *     assumption that same-origin implies \"cookies work\"; true for a cookie\n *     session, false for every header-auth host, which is exactly when this\n *     predicate is consulted at all.\n *\n * Everything else loads natively, unchanged: cookie-auth web (the adapter\n * supplies no `Authorization`), no adapter at all (the hub), and third-party\n * origins the bearer does NOT belong to (public images) — those stay out of\n * reach of the token by the same origin rules `embedAuthedFetch` enforces.\n */\nexport function needsBearerAssetFetch(url: string): boolean {\n  if (typeof window === 'undefined') return false;\n  const adapter = getRegisteredAuthAdapter();\n  if (!adapter || adapter.getHeaders?.().Authorization === undefined) return false;\n  let target: URL;\n  let pageOrigin: string;\n  try {\n    target = new URL(url, window.location.href);\n    // Same reason `assertSameOrigin` derives it this way: test environments\n    // mock `window.location` as a plain object with no `origin` field.\n    pageOrigin = new URL(window.location.href).origin;\n  } catch {\n    return false;\n  }\n  if (target.protocol !== 'http:' && target.protocol !== 'https:') return false;\n  if (target.origin === pageOrigin) return true;\n  return adapter.allowedOrigins?.includes(target.origin) ?? false;\n}\n\n/**\n * `fetch` wrapper that attaches embed-proxy bearer headers (when\n * present in sessionStorage) and forces `credentials: 'same-origin'`\n * so Supabase auth cookies travel too.\n *\n * **Header merge direction (proxy WINS over caller):** the implementation\n * spreads `baseHeaders` first inside `applyProxyAuth`, then sets the\n * `Authorization` / `X-Chat-*` keys — so the proxy values take precedence\n * over anything the caller passed. The motivation is that the bearer +\n * act-as identity is the source of truth for embedded auth; a caller\n * accidentally passing a stale `Authorization` header should NOT override\n * the live proxy creds.\n *\n * **Cross-origin defense:** the wrapper assumes a same-origin `/api/…`\n * relative URL. Absolute URLs are accepted only when their origin matches\n * the current window's origin — or appears in the registered adapter's\n * `allowedOrigins` (the native-shell hatch; see that field's doc) — and\n * cross-origin URLs otherwise throw before the bearer leaves the page.\n * This is a defense-in-depth guard for future call sites — outside the\n * allowlisted-shell case there is no legitimate cross-origin use of this\n * fetch wrapper.\n *\n * **401 self-heal:** when a registered adapter supplies `refresh`, a `401`\n * response triggers a single token refresh + retry of the same request\n * (see `EmbedAuthAdapter.refresh`). This is the openframe `apiClient`'s\n * refresh-then-retry behaviour, lifted into the lib so embedded surfaces\n * no longer need a host-side `window.fetch` monkey-patch to survive an\n * expired access token mid-chat. With no adapter (or no `refresh`), the\n * 401 passes straight through unchanged.\n */\nexport function embedAuthedFetch(url: string, init: RequestInit = {}): Promise<Response> {\n  // Same-origin guard runs SYNCHRONOUSLY (not awaited inside the async\n  // helper below) so a bearer-leaking cross-origin URL throws before any\n  // promise is created — callers and tests rely on the synchronous throw.\n  assertSameOrigin(url);\n\n  // `applyProxyAuth` accepts `Record<string, string>`; normalize the\n  // caller's headers to that shape ONCE, up front. RequestInit accepts\n  // `HeadersInit` which is broader (Headers instance OR array of tuples).\n  // We re-derive the per-request headers from this base on every attempt\n  // (initial + post-refresh retry) so a rotated bearer is picked up.\n  //\n  // When the caller passes no headers, fall back to the same default\n  // `applyProxyAuth` uses internally — `Content-Type: application/json` —\n  // so JSON POSTs keep their content-type when only `embedAuthedFetch(url)`\n  // is used at the call site. GET callers that explicitly want no body\n  // headers can pass `init.headers = {}` to opt out.\n  let baseHeaders: Record<string, string>;\n  if (init.headers === undefined) {\n    baseHeaders = { 'Content-Type': 'application/json' };\n  } else {\n    baseHeaders = {};\n    if (init.headers instanceof Headers) {\n      init.headers.forEach((v, k) => {\n        baseHeaders[k] = v;\n      });\n    } else if (Array.isArray(init.headers)) {\n      for (const [k, v] of init.headers) baseHeaders[k] = v;\n    } else {\n      Object.assign(baseHeaders, init.headers);\n    }\n  }\n\n  return fetchWithRefresh(url, init, baseHeaders, false);\n}\n\n/**\n * Single in-flight refresh shared across all concurrent `embedAuthedFetch`\n * callers. A stampede of chat requests that all 401 at the same moment must\n * trigger the adapter's `refresh()` ONCE, not N times — otherwise an\n * expiring session fires a thundering herd of refresh calls at the auth\n * server. Resets to `null` once settled so the next genuine expiry can\n * refresh again.\n */\n// Stored on `globalThis` rather than a module-local so the \"single refresh\"\n// guarantee survives module duplication. Bundlers can ship more than one copy\n// of this module (e.g. across chunks or a host + embedded build); a per-module\n// variable would let each copy run its own refresh cycle, re-creating the\n// thundering-herd this dedupe exists to prevent.\nconst IN_FLIGHT_REFRESH_GLOBAL_KEY = '__embedAuthedFetchInFlightRefresh__';\n\nfunction getInFlightRefresh(): Promise<boolean> | null {\n  if (typeof globalThis === 'undefined') return null;\n  return (\n    ((globalThis as Record<string, unknown>)[IN_FLIGHT_REFRESH_GLOBAL_KEY] as Promise<boolean> | null | undefined) ??\n    null\n  );\n}\n\nfunction setInFlightRefresh(refresh: Promise<boolean> | null): void {\n  if (typeof globalThis === 'undefined') return;\n  (globalThis as Record<string, unknown>)[IN_FLIGHT_REFRESH_GLOBAL_KEY] = refresh;\n}\n\nfunction dedupedRefresh(): Promise<boolean> {\n  const adapter = getRegisteredAuthAdapter();\n  // Bound and captured BEFORE the closure below. `refresh` is an optional\n  // property, so TypeScript drops the `if (!adapter?.refresh)` narrowing inside\n  // the `.then()` callback — the property could in principle be reassigned\n  // between the guard and the call. `bind` keeps the adapter as the receiver.\n  const refresh = adapter?.refresh?.bind(adapter);\n  if (!refresh) return Promise.resolve(false);\n  let inFlightRefresh = getInFlightRefresh();\n  if (!inFlightRefresh) {\n    // Wrap in `Promise.resolve` so an adapter that throws synchronously\n    // (rather than rejecting) still funnels through the shared slot and\n    // clears it. A rejected refresh is treated as \"could not refresh\".\n    inFlightRefresh = Promise.resolve()\n      .then(() => refresh())\n      .catch(() => false)\n      .finally(() => {\n        setInFlightRefresh(null);\n      });\n    setInFlightRefresh(inFlightRefresh);\n  }\n  return inFlightRefresh;\n}\n\n/**\n * Core fetch path: merge proxy-auth + adapter headers, issue the request,\n * and — on a `401` with a refresh-capable adapter — refresh once and retry\n * the identical request a single time. Mirrors the openframe `apiClient`'s\n * refresh-then-retry contract (`isRetry` guards against infinite loops).\n */\nasync function fetchWithRefresh(\n  url: string,\n  init: RequestInit,\n  baseHeaders: Record<string, string>,\n  isRetry: boolean,\n): Promise<Response> {\n  // Re-run the merge each attempt: `applyProxyAuth` reads the latest stored\n  // proxy creds and `getHeaders()` reads the latest bearer, so a retry after\n  // refresh carries the rotated token rather than the stale one. `{...baseHeaders}`\n  // keeps the caller's normalized headers immutable across attempts.\n  const { url: authedUrl, headers } = applyProxyAuth(url, { ...baseHeaders });\n\n  // Host-supplied auth adapter layer. Runs AFTER the proxy-auth merge so\n  // adapter headers override both caller and proxy values — the adapter\n  // is the host's explicit \"this is my auth model\" override, intentionally\n  // last-writer-wins. When no adapter is registered, this is a zero-cost\n  // no-op (object spread of `{}`).\n  const adapter = getRegisteredAuthAdapter();\n  if (adapter?.getHeaders) {\n    // Filter `undefined` values — the adapter type allows them so consumers\n    // don't have to narrow `{ Authorization: token ? '…' : undefined }`-shaped\n    // returns, but `fetch` headers must be strings.\n    for (const [k, v] of Object.entries(adapter.getHeaders())) {\n      if (v !== undefined) headers[k] = v;\n    }\n  }\n  const credentials = adapter?.credentials ?? init.credentials ?? 'same-origin';\n\n  const response = await fetch(authedUrl, {\n    ...init,\n    headers,\n    // Default `same-origin` carries Supabase cookies for the MPH proxy-\n    // auth model. Hosts on different origins (openframe-frontend ↔\n    // openframe gateway) register `credentials: 'include'` via the\n    // adapter to make their own cookies travel cross-origin (CORS +\n    // `SameSite=None` must be configured server-side for that to work).\n    credentials,\n  });\n\n  // 401 self-heal: refresh the token once and retry. Only when an adapter\n  // opted into `refresh`, and only on the first attempt — a 401 on the\n  // retry means the fresh token is also unauthorized, so surface it.\n  if (response.status === 401 && !isRetry && adapter?.refresh) {\n    const refreshed = await dedupedRefresh();\n    if (refreshed) {\n      return fetchWithRefresh(url, init, baseHeaders, true);\n    }\n  }\n\n  return response;\n}\n\n/**\n * Reject any URL that resolves to a cross-origin destination or to a\n * non-http(s) scheme. Every input is resolved against\n * `window.location.href` so the same rule covers path-only\n * (`/api/...`), absolute (`https://...`), protocol-relative\n * (`//host/...`), AND whitespace-prefixed forms (`\\t//evil.com/...`) —\n * the WHATWG fetch spec strips leading ASCII whitespace before\n * parsing, so any regex-based \"skip relative\" shortcut is bypassable\n * with a leading `\\t`/`\\n`/`\\r`/space. We resolve unconditionally\n * instead and compare origins.\n *\n * Also blocks `javascript:` / `data:` / `blob:` etc. — only `http(s):`\n * is allowed. This is explicit allowlisting rather than relying on\n * `origin === 'null'` to fall out wrong.\n *\n * Server-side rendering: when `typeof window === 'undefined'` we skip\n * the check — the bearer comes from sessionStorage which doesn't exist\n * on the server, so there's nothing to leak anyway.\n */\nfunction assertSameOrigin(url: string): void {\n  if (typeof window === 'undefined') return;\n  let target: URL;\n  let pageOrigin: string;\n  try {\n    target = new URL(url, window.location.href);\n    // Derive the page origin from `href` rather than reading\n    // `window.location.origin` directly so the check works in test\n    // environments that mock `window.location` to a plain object\n    // without an `origin` field (jsdom setups do this).\n    pageOrigin = new URL(window.location.href).origin;\n  } catch {\n    throw new Error(`embedAuthedFetch: refusing to fetch malformed URL (${JSON.stringify(url)})`);\n  }\n  if (target.protocol !== 'http:' && target.protocol !== 'https:') {\n    throw new Error(\n      `embedAuthedFetch: refusing non-http(s) URL (${target.protocol}) — pass a relative /api/* path instead`,\n    );\n  }\n  if (target.origin !== pageOrigin) {\n    // Host-sanctioned cross-origin target (`EmbedAuthAdapter.allowedOrigins`).\n    // Native shells serve the page from a local pseudo-origin with no server\n    // behind it, so their gateway — the same backend the host already trusts\n    // with this bearer — is unavoidably cross-origin. Exact `URL.origin`\n    // match, and only reachable for http(s) targets (checked above).\n    if (getRegisteredAuthAdapter()?.allowedOrigins?.includes(target.origin)) {\n      return;\n    }\n    // Dev-mode escape hatch — embedded apps (e.g. openframe-frontend)\n    // run on a different origin from their gateway during local dev,\n    // and forcing a Next.js `rewrites()` workaround is more error-prone\n    // than relaxing the guard for the dev build. In production\n    // (`NODE_ENV === 'production'`) the guard stays absolute — same\n    // defense-in-depth bearer-leak protection as before. The check is\n    // baked at build time by Next/webpack/Turbopack so prod bundles\n    // contain only the throwing branch (no dev string in the artifact).\n    if (process.env.NODE_ENV !== 'production') {\n      console.warn(\n        `[embedAuthedFetch] cross-origin fetch to ${target.origin} ` +\n          `allowed in dev (NODE_ENV !== 'production'). Production builds ` +\n          `will reject this — wire a same-origin proxy before shipping.`,\n      );\n      return;\n    }\n    throw new Error(\n      `embedAuthedFetch: refusing cross-origin fetch to ${target.origin} — pass a relative /api/* path instead`,\n    );\n  }\n}\n","'use client';\n\nimport { useEffect, useReducer } from 'react';\nimport { embedAuthedFetch, needsBearerAssetFetch } from '../utils/embed-authed-fetch';\n\n/**\n * Resolve ANY asset URL the browser would load natively — `<img src>`,\n * `<track src>`, a font, a poster — for hosts whose auth rides in request\n * HEADERS instead of cookies: native shells (`capacitor://`, `tauri://`) and\n * dev-ticket web, both in bearer mode. A native subresource load can't carry\n * an `Authorization` header, so a gateway URL that works on the cookie-auth\n * web 401s there.\n *\n * When `needsBearerAssetFetch` says the URL is bearer-authed, this hook\n * fetches it through `embedAuthedFetch` (bearer + deduped 401-refresh-retry +\n * the adapter's cross-origin guard — the SAME single auth knob every embedded\n * `fetch` in the lib rides) and returns a blob object-URL. Every other URL is\n * returned unchanged, so on the cookie-auth web the hook is a pass-through.\n *\n * Returns `undefined` while the fetch is in flight OR after it fails, so\n * callers show their existing placeholder / \"no captions\" branch instead of a\n * broken asset; a failed fetch retries on the next mount.\n *\n * Cache: module-level, session-lifetime, keyed by full URL — so the two\n * `<track>` URLs of one player, or the same avatar in twenty rows, cost one\n * request. Gateway URLs carry a `?v=<content-hash>` cache-buster, so changed\n * content is a new key; entries are never revoked (a VTT / avatar is a few KB\n * — refcount churn isn't worth it) and are dropped wholesale at session end\n * via `clearAuthedAssetCache`.\n */\n\nconst resolvedCache = new Map<string, string>();\nconst inFlight = new Map<string, Promise<void>>();\nlet cacheGeneration = 0;\n\n/**\n * Drop every cached blob and revoke its object URL. Hosts call this at\n * session end (logout / forced re-login) so a follow-on login as a\n * different identity cannot be served blobs fetched under the previous\n * one's bearer. Fetches still in flight when the clear happens are fenced\n * by a generation counter — their results are revoked instead of cached.\n */\nexport function clearAuthedAssetCache(): void {\n  cacheGeneration++;\n  for (const url of resolvedCache.values()) URL.revokeObjectURL(url);\n  resolvedCache.clear();\n  inFlight.clear();\n}\n\nfunction fetchAsBlobUrl(src: string, accept: string): Promise<void> {\n  let pending = inFlight.get(src);\n  if (!pending) {\n    const startedGeneration = cacheGeneration;\n    pending = embedAuthedFetch(src, { headers: { Accept: accept } })\n      .then(async response => {\n        if (!response.ok) throw new Error(`asset fetch failed: ${response.status}`);\n        const blobUrl = URL.createObjectURL(await response.blob());\n        if (startedGeneration === cacheGeneration) {\n          resolvedCache.set(src, blobUrl);\n        } else {\n          URL.revokeObjectURL(blobUrl);\n        }\n      })\n      .finally(() => {\n        if (inFlight.get(src) === pending) inFlight.delete(src);\n      });\n    inFlight.set(src, pending);\n  }\n  return pending;\n}\n\n/**\n * @param src    the asset URL (relative or absolute), or null/undefined\n * @param accept `Accept` header for the authed fetch — narrow it to what the\n *               consumer renders (`image/*`, `text/vtt`); it also replaces\n *               `embedAuthedFetch`'s default JSON `Content-Type` on the GET.\n */\nexport function useAuthedAssetSrc(src?: string | null, accept = '*/*'): string | undefined {\n  const bearerSrc = src && needsBearerAssetFetch(src) ? src : null;\n  const [, rerender] = useReducer((c: number) => c + 1, 0);\n\n  useEffect(() => {\n    if (!bearerSrc || resolvedCache.has(bearerSrc)) return undefined;\n    let cancelled = false;\n    fetchAsBlobUrl(bearerSrc, accept)\n      .catch(() => {})\n      .finally(() => {\n        if (!cancelled) rerender();\n      });\n    return () => {\n      cancelled = true;\n    };\n  }, [bearerSrc, accept]);\n\n  if (!src) return undefined;\n  if (!bearerSrc) return src;\n  return resolvedCache.get(bearerSrc);\n}\n","'use client';\n\nimport { clearAuthedAssetCache, useAuthedAssetSrc } from './use-authed-asset-src';\n\n/**\n * Image flavor of `useAuthedAssetSrc` — see that hook for the full contract.\n * Kept as its own export because it is the lib's most-used consumer (avatars,\n * entity images, markdown `<img>`) and because hosts already import this name.\n *\n * Distinct from the legacy `useAuthenticatedImage`, which ALWAYS blob-fetches\n * with `credentials: 'include'` and its own global config. This hook is\n * pass-through-first and keys off the single auth knob the lib already has:\n * the registered `EmbedAuthAdapter`.\n */\nexport function useAuthedImageSrc(src?: string | null): string | undefined {\n  return useAuthedAssetSrc(src, 'image/*');\n}\n\n/**\n * Session-end cache drop. Now an alias of `clearAuthedAssetCache` — one cache\n * backs every authed asset (images AND caption tracks), so hosts keep calling\n * this single function on logout / forced re-login.\n */\nexport const clearAuthedImageCache = clearAuthedAssetCache;\n","/**\n * Branded OG-placeholder URL construction — the DEFAULT cover-image fallback\n * for entity cards (onboarding guides, blog/case-study/release/etc.) that have\n * no image.\n *\n * ALL of the logic lives here, in the lib. A consumer hands over its runtime\n * `endpoints` object and NOTHING else — base resolution AND the `?title=…`\n * concatenation happen inside `buildOgPlaceholderUrl`. For the entity-card +\n * onboarding-detail surfaces this is the single entry point — no card builds an\n * og-placeholder URL itself (that was the bug this replaced: each embedder\n * wired a per-surface callback that concatenated the URL, and a host that\n * forgot it rendered a blank slot with no request). (Other server-side OG paths\n * such as the hub's blog og:image generator construct their own URLs and are\n * out of this module's scope.)\n *\n * The base API URL is taken from the endpoints the host already configures:\n *   1. explicit `endpoints.ogPlaceholderUrl`\n *   2. derived from the sibling `endpoints.imageProxyUrlPrefix` (same API base)\n *   3. same-origin relative `/api/og-placeholder`\n * The base may already carry pre-existing query params; they're preserved and\n * `title` (+ dimensions) are layered on top. Per-platform brand colors are NOT\n * baked into this URL — the `/api/og-placeholder` route resolves them\n * server-side from the platform. Most hosts leave `ogPlaceholderUrl` unset and\n * let the base derive from `imageProxyUrlPrefix`.\n */\n\n/** The slice of `ChatRuntime.endpoints` this module needs. */\nexport interface OgPlaceholderEndpoints {\n  /** Explicit base URL for the og-placeholder route. May already carry query\n   *  params — they're preserved. Per-platform colors are NOT baked here; the\n   *  route resolves them server-side from the platform. */\n  ogPlaceholderUrl?: string;\n  /** Sibling image route under the SAME API base. When `ogPlaceholderUrl` is\n   *  unset, the base is derived from this by swapping the trailing\n   *  `/image-proxy` segment for `/og-placeholder` — so a host that already\n   *  proxies images gets the placeholder for free, with zero extra wiring. */\n  imageProxyUrlPrefix?: string;\n}\n\nexport interface BuildOgPlaceholderOptions {\n  /** Site name shown under the title. Skipped when empty. */\n  site?: string;\n  /** `'wide'` (1200×630, the route default — no `w`/`h` emitted) or\n   *  `'square'` (1024×1024, for compact 56×56 chat-inline slots). */\n  aspect?: 'wide' | 'square';\n  /** Explicit pixel overrides (win over `aspect`). */\n  width?: number;\n  height?: number;\n}\n\n/** Same-origin default — for hosts that serve the route themselves (the hub).\n *  Cross-origin embedders override via `ogPlaceholderUrl` or inherit it from\n *  `imageProxyUrlPrefix`. */\nconst DEFAULT_OG_PLACEHOLDER_PATH = '/api/og-placeholder';\n\n/** Resolve the og-placeholder route base from the host's endpoints.\n *  Internal — callers go through `buildOgPlaceholderUrl(endpoints, …)`. */\nfunction resolveOgPlaceholderBase(endpoints?: OgPlaceholderEndpoints | null): string {\n  if (endpoints?.ogPlaceholderUrl) return endpoints.ogPlaceholderUrl;\n  const imageProxy = endpoints?.imageProxyUrlPrefix;\n  if (imageProxy) {\n    // `/image-proxy` and `/og-placeholder` are sibling API routes under one\n    // base. Anchor to a path-segment boundary so we only rewrite the route\n    // name, never an incidental substring.\n    const derived = imageProxy.replace(/\\/image-proxy(?=$|[?/])/, '/og-placeholder');\n    if (derived !== imageProxy) return derived;\n  }\n  return DEFAULT_OG_PLACEHOLDER_PATH;\n}\n\n/**\n * Build the branded og-placeholder image URL from the host's `endpoints` + a\n * title. This is the single entry point for entity-card + onboarding-detail\n * cover fallbacks: it resolves the route base from `endpoints` AND concatenates\n * `title` (+ dimensions), so those consumers never construct a URL themselves.\n *\n * Pure string construction — SSR- and browser-safe. Always returns a usable\n * URL (relative default at worst), so a missing/unknown image degrades\n * gracefully via the `<img onError>` recovery in the card components.\n */\nexport function buildOgPlaceholderUrl(\n  endpoints: OgPlaceholderEndpoints | null | undefined,\n  title: string,\n  options: BuildOgPlaceholderOptions = {},\n): string {\n  const base = resolveOgPlaceholderBase(endpoints);\n  const qIndex = base.indexOf('?');\n  const path = qIndex === -1 ? base : base.slice(0, qIndex);\n  const params = new URLSearchParams(qIndex === -1 ? '' : base.slice(qIndex + 1));\n\n  params.set('title', title);\n  if (options.site) params.set('site', options.site);\n\n  // Square aspect → request a 1024×1024 image so `object-cover` doesn't crop\n  // the title off in compact slots. Wide leaves dimensions to the route\n  // default (1200×630). Explicit width/height always win.\n  const width = options.width ?? (options.aspect === 'square' ? 1024 : undefined);\n  const height = options.height ?? (options.aspect === 'square' ? 1024 : undefined);\n  // `Number.isFinite` does not coerce, so it already rejects `undefined` — no\n  // separate `typeof === 'number'` guard needed.\n  if (Number.isFinite(width)) params.set('w', String(width));\n  if (Number.isFinite(height)) params.set('h', String(height));\n\n  return `${path}?${params.toString()}`;\n}\n","'use client';\n\nimport { useMemo } from 'react';\n\nimport { useChatRuntime } from '../contexts/chat-runtime-context';\nimport { buildOgPlaceholderUrl } from '../utils/og-placeholder';\n\n/**\n * Resolve a branded og-placeholder image URL for a title, driven entirely by\n * the runtime `endpoints` (no injected builder).\n *\n * THE one og-placeholder hook. It reads the host's `endpoints` from\n * `ChatRuntime` and hands them to `buildOgPlaceholderUrl`, which resolves the\n * route base (explicit `ogPlaceholderUrl` → derived from `imageProxyUrlPrefix`\n * → same-origin `/api/og-placeholder`) and appends `?title=…`. Per-platform\n * brand colors are resolved SERVER-SIDE by the route — nothing is baked here.\n *\n * Replaces the old builder-injection `useOgPlaceholder(buildUrl, …)`: callers\n * no longer pass a URL builder. `useEntityCardPlaceholder` delegates here too,\n * so every surface shares one memo + one code path.\n */\nexport interface UseOgPlaceholderUrlArgs {\n  /** Text to display on the placeholder. */\n  title: string | undefined | null;\n  /** Site name shown below the title (optional). */\n  siteName?: string;\n  /** `'wide'` (1200×630 social-card; default) or `'square'` (1024×1024 — for\n   *  compact chat-inline slots so `object-cover` doesn't crop the title off). */\n  aspect?: 'wide' | 'square';\n  /** When `false`, returns `null` instead of a URL. */\n  enabled?: boolean;\n}\n\nexport function useOgPlaceholderUrl({\n  title,\n  siteName = '',\n  aspect = 'wide',\n  enabled = true,\n}: UseOgPlaceholderUrlArgs): string | null {\n  const endpoints = useChatRuntime()?.endpoints;\n\n  return useMemo(() => {\n    if (!enabled || !title) return null;\n    return buildOgPlaceholderUrl(endpoints, title, { site: siteName || undefined, aspect });\n  }, [endpoints, title, siteName, aspect, enabled]);\n}\n"]}