import { headers } from 'next/headers'; /** * The storefront's public origin — the value that ends up in canonical tags, * sitemap.xml, robots.txt, JSON-LD, and the `Origin` header the backend * compares against the sales channel's configured domain. * * ⛔ NEVER hardcode this, and never reintroduce a literal default. The * scaffolder cannot know the answer: `npx create-brainerce-store` is very * often run by an AI builder (ChatGPT, Lovable, Bolt) that deploys the result * to a sandbox domain *it* owns, and the merchant moves it to their own domain * later. The old `.env.local` shipped `NEXT_PUBLIC_SITE_URL=http://localhost:3000` * as a confident wrong answer, which is worse than no answer — it silently * wrote `http://localhost:3000` into every sitemap and canonical tag of every * storefront not run from a laptop, and `https://example.com` into the ones * where that env file never travelled at all (it is gitignored). * * Two resolvers, split by what the caller actually needs: * * {@link getCanonicalSiteUrl} — the address the store should be KNOWN by. * Env first, because a canonical URL must be stable across preview deploys * and must not be forgeable by a request header. * * {@link getRequestOrigin} — the address a request actually ARRIVED on. * Request headers first — unless they name an internal host (localhost, * 127.0.0.1, a bind address) and an origin is configured, because an * internal host is how the platform's proxy addresses this server, not * where the request came from. Claiming the production domain from a * preview deployment on a real hostname would still pass a check that * ought to fail, so public hostnames keep winning over the env. */ const DEV_FALLBACK = 'http://localhost:3000'; /** * Hosts that can never be a storefront's public address: loopback names and * the 0.0.0.0 bind address. Some hosting proxies (OpenAI Sites among them) * forward requests to the app with `Host: localhost:` and no * `x-forwarded-host`, so seeing one of these in a request means "the proxy's * internal leg", not "the shopper is on localhost". */ function isInternalHost(host: string): boolean { const name = host.startsWith('[') ? host.slice(1, host.includes(']') ? host.indexOf(']') : host.length) : host.split(':')[0]; const lower = name.toLowerCase(); return lower === 'localhost' || lower === '127.0.0.1' || lower === '::1' || lower === '0.0.0.0'; } function isInternalOrigin(origin: string): boolean { try { return isInternalHost(new URL(origin).host); } catch { return false; } } /** * Coerce a host (`shop.example.com`) or a full URL (`https://shop.example.com/`) * into a bare origin. Returns null for anything unparseable, so a malformed env * var degrades to the next source instead of throwing. */ function toOrigin(raw: string | undefined): string | null { const trimmed = raw?.trim(); if (!trimmed) return null; const hasProtocol = /^https?:\/\//i.test(trimmed); const bare = trimmed.replace(/^\/\//, ''); const candidate = hasProtocol ? trimmed : `${isInternalHost(bare) ? 'http' : 'https'}://${bare}`; try { return new URL(candidate).origin; } catch { return null; } } /** * Explicitly configured origin. `SITE_URL` is preferred; `NEXT_PUBLIC_SITE_URL` * is accepted as a backwards-compatible alias for storefronts scaffolded before * this resolver existed. * * The `NEXT_PUBLIC_` prefix was always a misnomer here — nothing client-side * reads this value, every consumer is server-side — and the prefix implies a * build-time inline that does not apply to server reads. Prefer `SITE_URL`. */ function fromExplicitEnv(): string | null { return toOrigin(process.env.SITE_URL) ?? toOrigin(process.env.NEXT_PUBLIC_SITE_URL); } /** * Origins injected by the hosting platform. These are present during * `next build` as well as at runtime, so they answer correctly with no request * in hand. * * Production domains are listed before per-deployment domains: a preview build * must not stamp its throwaway URL into canonical tags. * * Netlify's `URL` is guarded behind `NETLIFY` — the bare name is too generic to * trust on its own, and a merchant's own `URL` variable must not be mistaken * for a site address. */ function fromPlatformEnv(): string | null { const env = process.env; const candidates = [ env.VERCEL_PROJECT_PRODUCTION_URL, env.VERCEL_URL, env.NETLIFY ? env.URL : undefined, env.NETLIFY ? env.DEPLOY_PRIME_URL : undefined, env.RENDER_EXTERNAL_URL, env.RAILWAY_PUBLIC_DOMAIN, env.CF_PAGES_URL, ]; for (const candidate of candidates) { const origin = toOrigin(candidate); if (origin) return origin; } return null; } /** * Origin derived from the incoming request. * * ⚠️ Caller-controlled. `curl -H 'Host: evil.example'` reaches this, so a * storefront relying on it can have a canonical tag poisoned by whoever asks. * That is an accepted trade for the AI-builder case — a sandbox injecting none * of the platform vars above would otherwise have no correct source at all — * and it disappears the moment `SITE_URL` is set, which is why every caller * prefers the env. */ function fromForwardedHeaders(h: Headers): string | null { // `x-forwarded-host` carries a comma-separated chain when several proxies // append to it; the first entry is the original client-facing host. const forwardedHost = h.get('x-forwarded-host')?.split(',')[0]?.trim(); const host = forwardedHost || h.get('host')?.trim(); if (!host) return null; const proto = h.get('x-forwarded-proto')?.split(',')[0]?.trim() || (isInternalHost(host) ? 'http' : 'https'); return toOrigin(`${proto}://${host}`); } let warnedMissingSiteUrl = false; /** * Alarm at the point of failure rather than fail open. Reaching the dev * fallback in production means every absolute URL this process emits is wrong, * and no downstream layer can detect that — a canonical tag pointing at * localhost looks perfectly well-formed to a crawler. */ function warnUnresolved(): void { if (warnedMissingSiteUrl || process.env.NODE_ENV !== 'production') return; warnedMissingSiteUrl = true; console.warn( [ `[brainerce] Could not determine this storefront's public URL; falling back to ${DEV_FALLBACK}.`, 'Sitemap, robots.txt, canonical tags and JSON-LD will all be wrong.', ' Fix: set SITE_URL to your public origin (e.g. SITE_URL=https://shop.example.com)', " in your hosting provider's environment variables.", ' Also check the channel: a LIVE sales channel only accepts requests whose Origin', ' matches the Domain configured on it. Add this host under Sales Channels -> your', ' channel -> Domain in the Brainerce dashboard, or storefront API calls are rejected', ' with 403 no matter what SITE_URL says.', ].join('\n') ); } /** * Env-only resolution, safe to call outside a request (module scope, build * scripts, `next.config.ts`). Returns null rather than guessing. */ export function getSiteUrlFromEnv(): string | null { return fromExplicitEnv() ?? fromPlatformEnv(); } /** * The origin this store should be known by — canonical tags, sitemap entries, * JSON-LD `url` fields, OpenGraph. Stable across deploys when `SITE_URL` is set. */ export async function getCanonicalSiteUrl(): Promise { const configured = getSiteUrlFromEnv(); if (configured) return configured; // Only reached when nothing is configured, so the `headers()` call — and the // dynamic rendering it forces — is only paid in that case. // // ⛔ Deliberately NOT wrapped in try/catch. During static generation // `headers()` throws Next's DynamicServerError, which is a *signal*, not a // failure: Next catches it upstream and re-renders the route dynamically. // Catching it here returns DEV_FALLBACK instead, and Next then bakes // `http://localhost:3000` into the prerendered sitemap.xml and robots.txt — // reproducing the exact bug this module exists to prevent. Verified: with // the catch in place the build emitted localhost into `sitemap.xml.body`. const fromRequest = fromForwardedHeaders(await headers()); if (fromRequest) return fromRequest; warnUnresolved(); return DEV_FALLBACK; } /** * The origin a request actually arrived on — used for the `Origin` header sent * to the Brainerce backend, which must match the channel's configured domain. * * Pass the live request headers where you have them (route handlers, the BFF * proxy); omit to read the ambient request context. */ export async function getRequestOrigin(requestHeaders?: Headers): Promise { // Same rule as above: no try/catch around the ambient `headers()` read, so // Next's DynamicServerError propagates and the route is re-rendered // dynamically rather than prerendered against a fallback origin. const h = requestHeaders ?? (await headers()); const fromRequest = fromForwardedHeaders(h); // A public hostname from the request wins outright — it is what this // request actually arrived on. if (fromRequest && !isInternalOrigin(fromRequest)) return fromRequest; // An internal host here does NOT mean the request arrived on localhost. It // means the platform's proxy addresses this server by its bind address // (`Host: localhost:3000`, no `x-forwarded-host`) — OpenAI Sites does // exactly this — and the request's true origin is invisible to us. A // configured origin is the only correct answer then. In plain local dev // nothing is configured, so the loopback host (with its real port) still // wins below. const configured = getSiteUrlFromEnv(); if (configured) return configured; if (fromRequest) return fromRequest; warnUnresolved(); return DEV_FALLBACK; }