diff --git a/openframe-frontend-core/README.md b/openframe-frontend-core/README.md index df8cad7904..4b7750365b 100644 --- a/openframe-frontend-core/README.md +++ b/openframe-frontend-core/README.md @@ -27,7 +27,7 @@ import { useDebounce, useMediaQuery, useToast } from '@flamingo-stack/openframe- ### Utilities ```tsx -import { cn, formatDate, formatPrice, getBaseUrl } from '@flamingo-stack/openframe-frontend-core/utils' +import { cn, formatDate, formatPrice, getPlatformUrl } from '@flamingo-stack/openframe-frontend-core/utils' ``` ### Styles @@ -65,7 +65,7 @@ export default { | `./components/navigation` | Navigation components (Header, StickySectionNav) | | `./components/toast` | Toast notification system | | `./hooks` | React hooks (useDebounce, useMediaQuery, useToast, etc.) | -| `./utils` | Utilities (cn, formatDate, formatPrice, getBaseUrl, platform-config) | +| `./utils` | Utilities (cn, formatDate, formatPrice, getPlatformUrl, platform-config) | | `./types` | TypeScript type definitions | | `./styles` | CSS styles and ODS design tokens | | `./nats` | NATS WebSocket utilities | diff --git a/openframe-frontend-core/src/__tests__/platform-domains.test.ts b/openframe-frontend-core/src/__tests__/platform-domains.test.ts index 66289e3d60..2417163d87 100644 --- a/openframe-frontend-core/src/__tests__/platform-domains.test.ts +++ b/openframe-frontend-core/src/__tests__/platform-domains.test.ts @@ -3,6 +3,11 @@ import { PLATFORM_DOMAINS, byKey, getPlatformProductionUrl, + getPlatformUrl, + getDeploymentUrl, + getRequestOrigin, + isLocalUrl, + resolveRedirectTarget, getPlatformByHostname, getAllPlatformBaseDomains, hostOf, @@ -156,6 +161,125 @@ describe('env override path', () => { }); }); +describe('getPlatformUrl — the one platform-URL resolver', () => { + it('is the registry URL in a production build, with no trailing slash', () => { + vi.stubEnv('NODE_ENV', 'production'); + expect(getPlatformUrl('flamingo')).toBe('https://www.flamingo.run'); + expect(getPlatformUrl('openframe')).toBe(getPlatformProductionUrl('openframe').replace(/\/+$/, '')); + }); + + it("never reads Vercel's first-listed domain", () => { + vi.stubEnv('NODE_ENV', 'production'); + vi.stubEnv('VERCEL_PROJECT_PRODUCTION_URL', 'flamingo.cx'); + expect(getPlatformUrl('flamingo')).toBe('https://www.flamingo.run'); + }); + + it('is the local dev URL outside a production build, unless production is asked for', () => { + vi.stubEnv('NODE_ENV', 'development'); + vi.stubEnv('NEXT_PUBLIC_DEV_URL', ''); + expect(getPlatformUrl('openmsp')).toBe('http://localhost:3000'); + expect(getPlatformUrl('openmsp', { environment: 'production' })).toBe('https://www.openmsp.ai'); + vi.stubEnv('NEXT_PUBLIC_DEV_URL', 'https://my-tunnel.example/'); + expect(getPlatformUrl('openmsp')).toBe('https://my-tunnel.example'); + }); +}); + +describe('getDeploymentUrl — the one app-origin resolver', () => { + // Server-side cases: jsdom defines `window`, which is the browser branch. + const onServer = () => vi.stubGlobal('window', undefined); + + it("is a Vercel preview's own immutable URL", () => { + onServer(); + vi.stubEnv('VERCEL_ENV', 'preview'); + vi.stubEnv('VERCEL_URL', 'flamingo-abc123-flamingocx.vercel.app'); + expect(getDeploymentUrl({ platform: 'flamingo' })).toBe('https://flamingo-abc123-flamingocx.vercel.app'); + }); + + it("is the platform's registry URL in production, whatever Vercel's first domain is", () => { + onServer(); + vi.stubEnv('VERCEL_ENV', 'production'); + vi.stubEnv('VERCEL_PROJECT_PRODUCTION_URL', 'flamingo.cx'); + expect(getDeploymentUrl({ platform: 'flamingo' })).toBe('https://www.flamingo.run'); + }); + + it('uses a runtime-configured app URL (a self-hosted install) over every default', () => { + onServer(); + vi.stubEnv('VERCEL_ENV', 'production'); + expect(getDeploymentUrl({ platform: 'openframe-dashboard', configuredUrl: 'frame.acme-msp.example/' })).toBe( + 'https://frame.acme-msp.example', + ); + expect(getDeploymentUrl({ platform: 'openframe-dashboard', configuredUrl: ' ' })).toBe( + getPlatformUrl('openframe-dashboard', { environment: 'production' }), + ); + }); + + it('is localhost on the running port in development', () => { + onServer(); + vi.stubEnv('VERCEL_ENV', ''); + vi.stubEnv('NODE_ENV', 'development'); + vi.stubEnv('NEXT_PUBLIC_DEV_URL', ''); + vi.stubEnv('PORT', '4000'); + expect(getDeploymentUrl({ platform: 'openframe-dashboard' })).toBe('http://localhost:4000'); + }); + + it("is the page's origin in the browser", () => { + vi.stubGlobal('window', { location: { origin: 'https://tenant.openframe.ai' } }); + expect(getDeploymentUrl({ platform: 'openframe-dashboard', configuredUrl: 'https://ignored.example' })).toBe( + 'https://tenant.openframe.ai', + ); + }); +}); + +describe('getRequestOrigin / isLocalUrl / resolveRedirectTarget', () => { + const headers = (values: Record) => ({ get: (name: string) => values[name] ?? null }); + + it('reads the host the request arrived on, https unless local, ignoring x-forwarded-host, else the app origin', () => { + expect(getRequestOrigin(headers({ host: 'www.flamingo.run' }), { platform: 'flamingo' })).toBe( + 'https://www.flamingo.run', + ); + expect(getRequestOrigin(headers({ host: 'localhost:3000' }), { platform: 'flamingo' })).toBe( + 'http://localhost:3000', + ); + expect(getRequestOrigin(headers({ host: '[::1]:3000' }), { platform: 'flamingo' })).toBe('http://[::1]:3000'); + expect( + getRequestOrigin(headers({ host: 'x.vercel.app', 'x-forwarded-proto': 'https' }), { platform: 'flamingo' }), + ).toBe('https://x.vercel.app'); + expect( + getRequestOrigin(headers({ host: 'www.flamingo.run', 'x-forwarded-host': 'evil.example' }), { + platform: 'flamingo', + }), + ).toBe('https://www.flamingo.run'); + vi.stubGlobal('window', undefined); + vi.stubEnv('NEXT_PUBLIC_DEV_URL', 'http://localhost:4000'); + expect(getRequestOrigin(headers({}), { platform: 'flamingo' })).toBe('http://localhost:4000'); + }); + + it.each([ + ['http://localhost:3000', true], + ['http://127.0.0.1:3000/x', true], + ['http://0.0.0.0', true], + ['http://[::1]:3000', true], + ['https://www.flamingo.run', false], + ['https://localhost.example.com', false], + ['http://127.evil.example', false], + ['not a url', false], + ])('isLocalUrl(%s) → %s', (url, local) => expect(isLocalUrl(url)).toBe(local)); + + it('resolves same-origin paths and passes explicit absolute URLs through', () => { + const origin = 'https://flamingo-abc123-flamingocx.vercel.app'; + expect(resolveRedirectTarget(origin, '/admin/x?y=1').href).toBe(`${origin}/admin/x?y=1`); + expect(resolveRedirectTarget(origin, 'auth/callback-client').href).toBe(`${origin}/auth/callback-client`); + expect(resolveRedirectTarget(origin, 'https://www.flamingo.run/blog').href).toBe('https://www.flamingo.run/blog'); + }); + + it.each(['//evil.example/path', '/\\evil.example/path', '\\\\evil.example', '\\/evil.example'])( + 'refuses %s, which would leave the origin', + target => { + expect(() => resolveRedirectTarget('https://www.flamingo.run', target)).toThrow('is not a same-origin path'); + }, + ); +}); + describe('registry integrity', () => { it('byKey resolves every key and openframe-dashboard is pseudo', () => { expect(byKey('openframe-dashboard')?.pseudo).toBe(true); diff --git a/openframe-frontend-core/src/components/chat/embeddable-chat.tsx b/openframe-frontend-core/src/components/chat/embeddable-chat.tsx index 19b92ecfd4..51733f1fd7 100644 --- a/openframe-frontend-core/src/components/chat/embeddable-chat.tsx +++ b/openframe-frontend-core/src/components/chat/embeddable-chat.tsx @@ -154,7 +154,7 @@ export interface EmbeddableChatProps { baseRoute?: string; /** When the embedder doesn't host a `[...path]` route to render markdown * chips against, set this to a platform that does. Chips with - * `externalUrl: null` resolve to `getBaseUrl(chipBasePlatform) + + * `externalUrl: null` resolve to `getPlatformUrl(chipBasePlatform) + * '/knowledge-base/' + path` and open in a new tab. */ chipBasePlatform?: string; /** DB-driven list of enabled RAG table ids (chip catalog filter). @@ -1439,7 +1439,7 @@ function EmbeddableChatInner({ // doesn't host an in-app doc viewer should NOT pass an empty baseRoute (that just // falls back to the platform default here) — instead it sets a truthy baseRoute + // `chipBasePlatform` so doc chips with no externalUrl resolve cross-platform to that - // platform's public knowledge hub (`getBaseUrl(chipBasePlatform)/knowledge-base/…`), + // platform's public knowledge hub (`getPlatformUrl(chipBasePlatform)/knowledge-base/…`), // exactly like the hub's openframe config (baseRoute:'/', chipBasePlatform:'openframe'). const resolvedBaseRoute = baseRoute || (source === 'flamingo' ? '/knowledge-base' : '/data-room'); diff --git a/openframe-frontend-core/src/components/chat/utils/__tests__/source-row-cta.test.ts b/openframe-frontend-core/src/components/chat/utils/__tests__/source-row-cta.test.ts index 60bdd237d3..49d4f60721 100644 --- a/openframe-frontend-core/src/components/chat/utils/__tests__/source-row-cta.test.ts +++ b/openframe-frontend-core/src/components/chat/utils/__tests__/source-row-cta.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { getBaseUrl } from '../../../../utils/cn'; +import { getPlatformUrl } from '../../../../platform-domains'; import { resolveSourceRowCTA } from '../source-row-cta'; /** @@ -28,7 +28,7 @@ describe('resolveSourceRowCTA — doc-chip platform routing', () => { }; const expectHref = (platform: string, base: string, path: string) => - new URL(path, `${getBaseUrl(platform)}/${base}/`).toString(); + new URL(path, `${getPlatformUrl(platform)}/${base}/`).toString(); it('routes EACH doc source to its own platform from ONE shared context (mixed sources)', () => { const md = resolveSourceRowCTA(markdownRow, { docPlatformTargets }); diff --git a/openframe-frontend-core/src/components/chat/utils/source-row-cta.ts b/openframe-frontend-core/src/components/chat/utils/source-row-cta.ts index 8ff3a2b159..79a5a30d00 100644 --- a/openframe-frontend-core/src/components/chat/utils/source-row-cta.ts +++ b/openframe-frontend-core/src/components/chat/utils/source-row-cta.ts @@ -23,7 +23,7 @@ import { FileText } from 'lucide-react'; import type React from 'react'; -import { getBaseUrl } from '../../../utils/cn'; +import { getPlatformUrl } from '../../../platform-domains'; import type { ComposeContentUrl } from '../../../utils/content-href'; import { canonicalContentRefType } from '../../../utils/list-url'; import type { ChatRef } from '../chat-ref.types'; @@ -93,7 +93,7 @@ export interface SourceRowContext { * replacement for the single `chipBasePlatform`. Maps a doc-table documentType * (`'markdown'`, `'data_room_doc'`, …) → the platform whose PUBLIC doc viewer * hosts it + that viewer's base path. A doc chip with no `externalUrl` resolves - * to `getBaseUrl(platform)//` PER ROW — so a chat mixing several + * to `getPlatformUrl(platform)//` PER ROW — so a chat mixing several * doc sources sends EACH to its own home (markdown→flamingo/knowledge-base, * data_room_doc→company-hub/data-room) instead of one static fallback for all. * Wins over `chipBasePlatform` when a row's documentType has an entry. @@ -212,11 +212,11 @@ export function resolveSourceRowCTA(row: SourceRowInput, ctx: SourceRowContext = // slash-stripping regex `/^\/+|\/+$/g` tripped CodeQL's js/polynomial-redos (high) // since `\/+$` backtracks on inputs with many '/'. split/filter/join is linear. const seg = docTarget.basePath.split('/').filter(Boolean).join('/'); - const base = `${getBaseUrl(docTarget.platform)}${seg ? `/${seg}` : ''}/`; + const base = `${getPlatformUrl(docTarget.platform)}${seg ? `/${seg}` : ''}/`; href = safeHref(new URL(safePath, base).toString()) ?? null; targetPlatform = docTarget.platform; } else if (ctx.chipBasePlatform) { - const base = `${getBaseUrl(ctx.chipBasePlatform)}/knowledge-base/`; + const base = `${getPlatformUrl(ctx.chipBasePlatform)}/knowledge-base/`; href = safeHref(new URL(safePath, base).toString()) ?? null; targetPlatform = ctx.chipBasePlatform; } else if (ctx.baseRoute) { diff --git a/openframe-frontend-core/src/components/made-with-love.tsx b/openframe-frontend-core/src/components/made-with-love.tsx index 1cf0024649..f5770ae40e 100644 --- a/openframe-frontend-core/src/components/made-with-love.tsx +++ b/openframe-frontend-core/src/components/made-with-love.tsx @@ -2,7 +2,7 @@ import type React from 'react'; import { useState, useEffect } from 'react'; -import { getBaseUrl } from '../utils'; +import { getPlatformUrl } from '../platform-domains'; import { FlamingoLogo } from './flamingo-logo'; interface MadeWithLoveProps { @@ -52,7 +52,7 @@ export function MadeWithLove({ className = '', size = 'md', showOnMobile = true }; const config = sizeConfig[size]; - const flamingoUrl = getBaseUrl('flamingo'); + const flamingoUrl = getPlatformUrl('flamingo'); // Container styles using primitive CSS const containerStyle: React.CSSProperties = { diff --git a/openframe-frontend-core/src/contexts/chat-runtime-context.tsx b/openframe-frontend-core/src/contexts/chat-runtime-context.tsx index 1f8e0397a0..ab4c4ce09f 100644 --- a/openframe-frontend-core/src/contexts/chat-runtime-context.tsx +++ b/openframe-frontend-core/src/contexts/chat-runtime-context.tsx @@ -246,7 +246,7 @@ export interface ChatRuntime { * the single `chipBasePlatform` prop. Maps a doc-table documentType * (`'markdown'`, `'data_room_doc'`, …) → `{ platform, basePath }` for the PUBLIC * doc viewer that hosts it. Doc chips with no `externalUrl` resolve PER ROW to - * `getBaseUrl(platform)//`, so a chat mixing several doc sources + * `getPlatformUrl(platform)//`, so a chat mixing several doc sources * sends EACH to its own home (markdown→flamingo/knowledge-base, * data_room_doc→company-hub/data-room) instead of one static fallback. The hub * may keep using `chipBasePlatform` (one doc source per platform); embedders that diff --git a/openframe-frontend-core/src/platform-domains.ts b/openframe-frontend-core/src/platform-domains.ts index 00730b6ef1..3e98d70c93 100644 --- a/openframe-frontend-core/src/platform-domains.ts +++ b/openframe-frontend-core/src/platform-domains.ts @@ -115,9 +115,8 @@ function envOverrideFor(key: string): string | null { * base-domain derivation, CSP) receives a parseable URL. Full-URL inputs (the registry * `defaultUrl`s, any scheme'd override) pass through unchanged. * - * EXPORTED as the single owner of the scheme-normalization rule (next.config.mjs keeps a - * byte-identical local copy ONLY because Next evaluates its config outside the TS module - * graph and cannot import this — see the comment there). + * EXPORTED as the single owner of the scheme-normalization rule. This subpath is pure ESM, + * so a consumer's next.config.mjs imports it directly rather than keeping a copy. * * Handles a (theoretical) protocol-relative `//host` too: strips the leading slashes so it * doesn't become `https:////host` (empty-host → hostOf null → silent platform drop). @@ -133,7 +132,7 @@ export function ensureScheme(url: string): string { * NEVER throws / undefined — the default guarantees a host (this is what keeps the * cookie base-domains, the reverse map, and CSP intact even with every override unset). * The result ALWAYS carries a scheme (`ensureScheme`), so the scheme-less env overrides - * resolve to valid URLs. Unknown-key fallback preserves cn.ts's flamingo.run default. + * resolve to valid URLs. Unknown-key fallback is flamingo's URL. */ export function getPlatformProductionUrl(platform: string): string { const resolved = @@ -141,6 +140,102 @@ export function getPlatformProductionUrl(platform: string): string { return ensureScheme(resolved); } +/** + * THE URL of a platform, for the environment the code is running in, no trailing slash. + * The one platform-URL resolver: consumers build every link to a platform from this. + * + * - `environment: 'current'` (default): the local dev URL (`NEXT_PUBLIC_DEV_URL`, else + * `http://localhost:3000`) outside a production build, the registry's production URL + * in any production build (a preview included — a preview never links to itself as + * canonical). + * - `environment: 'production'`: the registry's production URL everywhere, for links that + * leave the machine. + * + * `platform` is REQUIRED. The former `getBaseUrl()` accepted none and then returned + * `VERCEL_PROJECT_PRODUCTION_URL` — whichever domain Vercel lists first for a project + * (`flamingo.cx` on flamingo, a 308), and production on previews. A deployment's own + * origin is not a platform URL; consumers resolve it themselves. + */ +export function getPlatformUrl(platform: string, options: { environment?: 'current' | 'production' } = {}): string { + const url = + options.environment !== 'production' && process.env.NODE_ENV !== 'production' + ? process.env.NEXT_PUBLIC_DEV_URL || 'http://localhost:3000' + : getPlatformProductionUrl(platform); + return url.replace(/\/+$/, ''); +} + +/** + * Where THIS app is reachable, no trailing slash: what anything outside the app must + * fetch from the code that is running (rendered images, provider callbacks, self-calls). + * The one app-origin resolver; each app only supplies its own platform and config. + * + * In order: + * 1. In the browser: the page's origin. + * 2. `configuredUrl`: an app whose URL is configured at runtime (a self-hosted OpenFrame + * install's `NEXT_PUBLIC_APP_URL`) — that install's address wins over any default. + * 3. On a Vercel preview: the deployment's immutable `VERCEL_URL` (not the branch alias, + * which moves to the next commit and would serve other code). + * 4. In any production build: the platform's registry URL. + * 5. In development: `NEXT_PUBLIC_DEV_URL`, else localhost on the running port. + */ +export function getDeploymentUrl(options: { platform: string; configuredUrl?: string | null }): string { + if (typeof window !== 'undefined') return window.location.origin; + const configured = options.configuredUrl?.trim(); + if (configured) return ensureScheme(configured).replace(/\/+$/, ''); + if (process.env.VERCEL_ENV === 'preview' && process.env.VERCEL_URL) { + return ensureScheme(process.env.VERCEL_URL).replace(/\/+$/, ''); + } + if (process.env.VERCEL_ENV === 'production' || process.env.NODE_ENV === 'production') { + return getPlatformUrl(options.platform, { environment: 'production' }); + } + return (process.env.NEXT_PUBLIC_DEV_URL || `http://localhost:${process.env.PORT ?? 3000}`).replace(/\/+$/, ''); +} + +/** A hostname on the local machine, matched WHOLE: `localhost.example.com` and + * `127.evil.example` are public hosts. */ +const LOCAL_HOSTNAME = /^(localhost|127(?:\.\d{1,3}){3}|0\.0\.0\.0|\[::1\])$/i; + +/** Is this URL on the local machine, where no external system (a webhook sender, an + * OAuth provider) can reach it? False for anything that is not a URL. */ +export function isLocalUrl(url: string): boolean { + try { + return LOCAL_HOSTNAME.test(new URL(url).hostname); + } catch { + return false; + } +} + +/** + * The origin a server request arrived on, from its `host` header only (what the edge set + * for this request — a client-writable `x-forwarded-host` never chooses where a flow + * returns). `http` for a local host unless the proxy says otherwise, else `https`. + * With no Host header, the app's own origin (`getDeploymentUrl` for `platform`). + */ +export function getRequestOrigin( + headers: { get(name: string): string | null }, + options: { platform: string; configuredUrl?: string | null }, +): string { + const host = headers.get('host'); + if (!host) return getDeploymentUrl(options); + const local = LOCAL_HOSTNAME.test(host.replace(/:\d+$/, '')); + const proto = headers.get('x-forwarded-proto')?.split(',')[0].trim() || (local ? 'http' : 'https'); + return `${proto}://${host}`; +} + +/** + * Where a redirect goes: a path resolves against `origin`; an explicit absolute + * `http(s)://` URL is used as is. THROWS when a relative target resolves to another + * origin (`//evil.example`, `/\\evil.example`), so a shared redirect helper built on it + * can never be an open redirect. + */ +export function resolveRedirectTarget(origin: string, target: string): URL { + const url = new URL(target, origin); + if (!/^https?:\/\//i.test(target) && url.origin !== new URL(origin).origin) { + throw new Error(`resolveRedirectTarget: "${target}" is not a same-origin path`); + } + return url; +} + // ── Single-owner host primitives ── /** Canonical URL→host parser: `.hostname` (PORT-STRIPPED, lowercased), null on parse failure. */ diff --git a/openframe-frontend-core/src/utils/.cn.md b/openframe-frontend-core/src/utils/.cn.md index d60f8e0d5a..f1a8403d76 100644 --- a/openframe-frontend-core/src/utils/.cn.md +++ b/openframe-frontend-core/src/utils/.cn.md @@ -1,12 +1,11 @@ -Utility module providing two core helpers: a Tailwind-aware class name merger (`cn`) that correctly handles custom ODS typography utilities, and an environment-aware base URL resolver (`getBaseUrl`) that delegates platform canonical URLs to the SSOT in `platform-domains.ts`. +Utility module providing a Tailwind-aware class name merger (`cn`) that correctly handles custom ODS typography utilities. (Platform URLs are resolved by `getPlatformUrl` in `platform-domains.ts`, not here.) ## Key Components | Export | Type | Description | |--------|------|-------------| | `cn` | `function` | Combines class names using `clsx` + a custom `tailwind-merge` instance | -| `getBaseUrl` | `function` | Returns the application base URL for the current environment | | `twMerge` | internal | Extended `tailwind-merge` instance with the `ods-typography` class group | ### `ods-typography` Class Group @@ -22,7 +21,7 @@ Without this registration, `cn('text-badge', 'text-ods-text-on-accent')` would d ## Usage Example ```typescript -import { cn, getBaseUrl } from '@/lib/cn' +import { cn } from '@/lib/cn' // Merge conditional classes safely — typography and colour both survive const className = cn( @@ -31,25 +30,6 @@ const className = cn( isActive && 'bg-accent', ) -// Current deployment URL (dev → localhost, prod → Vercel/canonical domain) -const currentUrl = getBaseUrl() - -// Specific platform URL (respects env override, falls back to canonical) -const flamingoUrl = getBaseUrl('flamingo') // https://www.flamingo.run -const openMspUrl = getBaseUrl('openmsp') // https://www.openmsp.ai -``` - -## `getBaseUrl` Resolution Order - -```mermaid -graph TD - A[getBaseUrl called] --> B{NODE_ENV !== production?} - B -->|Yes| C[NEXT_PUBLIC_DEV_URL or localhost:3000] - B -->|No| D{platform arg provided?} - D -->|Yes| E[getPlatformProductionUrl from SSOT] - D -->|No| F{VERCEL_PROJECT_PRODUCTION_URL set?} - F -->|Yes| G[https://vercel-domain] - F -->|No| H[getPlatformProductionUrl via NEXT_PUBLIC_APP_TYPE] ``` -> Platform canonical URLs and their `NEXT_PUBLIC_*_URL` environment overrides are defined exclusively in [`src/platform-domains.ts`](https://github.com/flamingo-stack/openframe-oss-lib/blob/main/src/platform-domains.ts). \ No newline at end of file +> Platform URLs are resolved by `getPlatformUrl` (with the canonical URLs and their `NEXT_PUBLIC_*_URL` overrides) exclusively in [`src/platform-domains.ts`](https://github.com/flamingo-stack/openframe-oss-lib/blob/main/src/platform-domains.ts). \ No newline at end of file diff --git a/openframe-frontend-core/src/utils/cn.ts b/openframe-frontend-core/src/utils/cn.ts index c1a901af14..df63f12225 100644 --- a/openframe-frontend-core/src/utils/cn.ts +++ b/openframe-frontend-core/src/utils/cn.ts @@ -1,10 +1,5 @@ import { clsx, type ClassValue } from 'clsx'; import { extendTailwindMerge } from 'tailwind-merge'; -// Platform→domain resolution moved to the SSOT module `src/platform-domains.ts`. -// `getPlatformProductionUrl` / `getAllPlatformBaseDomains` now live there (re-exported -// via the utils barrel for existing callers); `getBaseUrl` stays here because it owns the -// dev-localhost + Vercel-self-origin branches, and delegates its platform branch. -import { getPlatformProductionUrl } from '../platform-domains'; /** * EVERY custom `text-*` utility we add in `tailwind.config.ts` MUST be listed here. @@ -37,45 +32,3 @@ const twMerge = extendTailwindMerge<'ods-typography'>({ export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)); } - -/** - * Get the application base URL for the current environment - * - * @param platform - Optional platform name (openmsp, flamingo, tmcg, openframe, etc.) - * @returns The base URL with protocol (https:// or http://) - * - * Priority order: - * 1. Development (http://localhost:3000) - * 2. Platform-specific URL via the SSOT (if `platform` provided) — env override ?? default - * 3. VERCEL_PROJECT_PRODUCTION_URL (Vercel production domain) - * 4. Production fallback: the DEPLOYING platform's canonical URL (NEXT_PUBLIC_APP_TYPE), - * openmsp if unset — sourced from the SSOT, no hardcoded literal. - * - * The per-platform canonical URLs + their `NEXT_PUBLIC_*_URL` overrides are the single - * source of truth in `src/platform-domains.ts` (`PLATFORM_DOMAINS`). - * - * @example - * getBaseUrl() // Current deployment's URL - * getBaseUrl('flamingo') // https://www.flamingo.run (prod) or http://localhost:3000 (dev) - */ -export function getBaseUrl(platform?: string): string { - // In development, always use localhost (regardless of platform) - if (process.env.NODE_ENV !== 'production') { - return process.env.NEXT_PUBLIC_DEV_URL || 'http://localhost:3000'; - } - - // If platform is specified, return its production URL (env override ?? default) - if (platform) { - return getPlatformProductionUrl(platform); - } - - // Production: Use Vercel domain if available - if (process.env.VERCEL_PROJECT_PRODUCTION_URL) { - return `https://${process.env.VERCEL_PROJECT_PRODUCTION_URL}`; - } - - // Production fallback: the deploying platform's canonical www domain (avoids Google - // "Page with redirect"). Derived from the SSOT for the current app type (openmsp when - // unset → 'https://www.openmsp.ai', byte-identical to the old hardcoded fallback). - return getPlatformProductionUrl(process.env.NEXT_PUBLIC_APP_TYPE || 'openmsp'); -} diff --git a/openframe-frontend-core/src/utils/index.ts b/openframe-frontend-core/src/utils/index.ts index 78d8c24214..2fa111fc19 100644 --- a/openframe-frontend-core/src/utils/index.ts +++ b/openframe-frontend-core/src/utils/index.ts @@ -4,7 +4,15 @@ export { cn } from './cn'; // here so existing `/utils` callers keep working; the new resolver/helpers // (getPlatformByHostname/hostOf/expandWwwApex/…) are exposed via the `/platform-domains` // subpath ONLY (one import surface for the new API). -export { getPlatformProductionUrl, getAllPlatformBaseDomains } from '../platform-domains'; +export { + getPlatformUrl, + getPlatformProductionUrl, + getAllPlatformBaseDomains, + getDeploymentUrl, + getRequestOrigin, + isLocalUrl, + resolveRedirectTarget, +} from '../platform-domains'; // Number / currency / byte / date formatters live in `./format` (single // source of truth). Re-exported here so existing callers that pull from // the barrel keep working without changing imports. @@ -40,7 +48,6 @@ export { pick, NO_CLIENT_CACHE, } from './common'; -export { getBaseUrl } from '../utils/cn'; // SEO title length budget — server-safe constant (SSOT). Consumed by the hub // (prompt guidance + DB check value) and by SEOEditorPreview (input maxLength). export { SEO_TITLE_MAX_LENGTH } from './seo-title'; diff --git a/react-embedding-example/README.md b/react-embedding-example/README.md index 51c5fff0a2..699806507a 100644 --- a/react-embedding-example/README.md +++ b/react-embedding-example/README.md @@ -221,7 +221,7 @@ docPlatformTargets: { markdown: { platform: 'flamingo', basePath: 'knowledge-base' }, data_room_doc: { platform: 'company-hub', basePath: 'data-room' }, }, -// markdown chip → getBaseUrl('flamingo')/knowledge-base/, opened in a new tab. +// markdown chip → getPlatformUrl('flamingo')/knowledge-base/, opened in a new tab. ``` ### 4. Shared-page chrome — `DevSectionPage` props diff --git a/react-embedding-example/src/providers/content-runtime.ts b/react-embedding-example/src/providers/content-runtime.ts index 176b1c3a6c..3faef7dc8e 100644 --- a/react-embedding-example/src/providers/content-runtime.ts +++ b/react-embedding-example/src/providers/content-runtime.ts @@ -109,7 +109,7 @@ export function buildChatRuntime(): Omit { }), // Per-documentType doc-viewer targets. Doc chips with NO public externalUrl // resolve here when their documentType has an entry — the lib emits - // `getBaseUrl(platform)//` and opens it in a NEW TAB. + // `getPlatformUrl(platform)//` and opens it in a NEW TAB. // // `markdown` is intentionally OMITTED — this embedder now mounts its OWN // `` at /knowledge-base (see app-routes.tsx + pages/knowledge-base.tsx),