Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions openframe-frontend-core/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 |
Expand Down
124 changes: 124 additions & 0 deletions openframe-frontend-core/src/__tests__/platform-domains.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,11 @@ import {
PLATFORM_DOMAINS,
byKey,
getPlatformProductionUrl,
getPlatformUrl,
getDeploymentUrl,
getRequestOrigin,
isLocalUrl,
resolveRedirectTarget,
getPlatformByHostname,
getAllPlatformBaseDomains,
hostOf,
Expand Down Expand Up @@ -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<string, string>) => ({ 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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down Expand Up @@ -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');

Expand Down
Original file line number Diff line number Diff line change
@@ -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';

/**
Expand Down Expand Up @@ -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 });
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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)/<basePath>/<path>` PER ROW — so a chat mixing several
* to `getPlatformUrl(platform)/<basePath>/<path>` 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.
Expand Down Expand Up @@ -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) {
Expand Down
4 changes: 2 additions & 2 deletions openframe-frontend-core/src/components/made-with-love.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 = {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)/<basePath>/<path>`, so a chat mixing several doc sources
* `getPlatformUrl(platform)/<basePath>/<path>`, 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
Expand Down
103 changes: 99 additions & 4 deletions openframe-frontend-core/src/platform-domains.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand All @@ -133,14 +132,110 @@ 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 =
envOverrideFor(platform) ?? byKey(platform)?.defaultUrl ?? envOverrideFor('flamingo') ?? 'https://www.flamingo.run';
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. */
Expand Down
Loading
Loading