diff --git a/__tests__/unit/config/hosted-sites.test.ts b/__tests__/unit/config/hosted-sites.test.ts index 2f3989eac..faeed0be1 100644 --- a/__tests__/unit/config/hosted-sites.test.ts +++ b/__tests__/unit/config/hosted-sites.test.ts @@ -31,7 +31,7 @@ import { sitePagesFor, siteChromeFor, } from '@/config/site-content'; -import { getRouteSurface } from '@/config/routes'; +import { getRouteSurface, isHostedSiteRequest } from '@/config/routes'; import { COMPANY, MANDATE_CURVES, MATERIALS } from '@/config/substrata'; import { CHOKEPOINTS, COVERAGE, coverageProgress } from '@/config/substrata-coverage'; @@ -140,6 +140,55 @@ describe('hosted sites — the config a site owner may set', () => { }); }); +describe('hosted sites — the rewrite must not leak OrangeCat onto a customer domain', () => { + /** + * This is the case that shipped broken. + * + * A hosted site is served by a REWRITE, so the browser path stays "/" while + * `/sites/` renders. Everything that decided chrome from the visible + * path therefore classified a customer's website as OrangeCat's public + * marketing surface, and substrata.orangecat.ch came up with OrangeCat's + * header, "Sign In", our Google Analytics, our Organization schema and the + * internal FleetCrown feedback widget on it. + * + * The old tests all asked `getRouteSurface('/sites/substrata')` — the path + * form, which was never broken. None of them asked what happens when the path + * is "/" and only a header knows better. + */ + function headersOf(map: Record) { + return (name: string) => map[name] ?? null; + } + + it('recognises a rewritten request, whose visible path is only "/"', () => { + expect(getRouteSurface('/')).toBe('public'); + expect(isHostedSiteRequest(headersOf({ 'x-pathname': '/' }))).toBe(false); + + // The rewrite sets this. Without it the request is indistinguishable from + // a visit to orangecat.ch itself. + expect( + isHostedSiteRequest(headersOf({ 'x-pathname': '/', 'x-hosted-site': 'substrata' })) + ).toBe(true); + }); + + it('recognises a deep page on a hosted site', () => { + expect( + isHostedSiteRequest(headersOf({ 'x-pathname': '/map', 'x-hosted-site': 'substrata' })) + ).toBe(true); + }); + + it('recognises the preview form, which has no rewrite and no header', () => { + expect(isHostedSiteRequest(headersOf({ 'x-pathname': '/sites/substrata' }))).toBe(true); + expect(isHostedSiteRequest(headersOf({ 'x-pathname': '/sites/substrata/map' }))).toBe(true); + }); + + it('leaves ordinary OrangeCat requests alone, header absent', () => { + for (const path of ['/', '/dashboard', '/about', '/groups/substrata', '/auth']) { + expect(isHostedSiteRequest(headersOf({ 'x-pathname': path }))).toBe(false); + } + expect(isHostedSiteRequest(headersOf({}))).toBe(false); + }); +}); + describe('hosted sites — links', () => { it('always emits the path form, which resolves on every host', () => { expect(siteHref(site.slug)).toBe('/sites/substrata'); diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 0850a0ffd..35c3fc384 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -42,6 +42,8 @@ const ibmPlexMono = localFont({ }); import './globals.css'; import Script from 'next/script'; +import { headers } from 'next/headers'; +import { isHostedSiteRequest } from '@/config/routes'; import { AuthProvider } from '@/components/providers/AuthProvider'; import { QueryProvider } from '@/components/providers/QueryProvider'; import { ThemeProvider } from '@/components/providers/ThemeProvider'; @@ -103,8 +105,25 @@ export const metadata: Metadata = { }, }; -export default function RootLayout({ children }: { children: React.ReactNode }) { - const gaId = process.env.NEXT_PUBLIC_GA_MEASUREMENT_ID; +export default async function RootLayout({ children }: { children: React.ReactNode }) { + /** + * Is this request rendering somebody else's website? + * + * Decided ONCE, here, on the server, because four separate things below are + * OrangeCat's and must not appear on a customer's domain: the app shell, our + * Organization schema, our analytics, and the FleetCrown feedback widget. + * + * It cannot be decided from the path in a client component. A hosted site is + * served by a REWRITE — the visitor's URL bar keeps saying + * substrata.orangecat.ch, so `usePathname()` returns "/" and every one of + * those four leaked onto the customer's site. Middleware therefore forwards + * `x-hosted-site` on the request headers, and the path form is covered by the + * same `getRouteSurface` SSOT the rest of the app uses. + */ + const requestHeaders = await headers(); + const isHostedSite = isHostedSiteRequest(name => requestHeaders.get(name)); + + const gaId = isHostedSite ? undefined : process.env.NEXT_PUBLIC_GA_MEASUREMENT_ID; // Cache-only, never a network wait: rendering a page must not depend on a // third party answering. Whatever we last knew gets handed to the browser so // the first paint already speaks the visitor's currency. @@ -117,39 +136,42 @@ export default function RootLayout({ children }: { children: React.ReactNode }) suppressHydrationWarning > - {/* Structured data: Organization + WebSite */} -