Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
527fa78
feat(groups): a trading company whose whole book is singularity mater…
claude Aug 26, 2026
254a4d5
feat(groups): make the map the product — Substrate as an open researc…
claude Aug 26, 2026
e9bf570
feat(domains): a profile spins up a whole website on its own hostname
claude Aug 26, 2026
af88465
feat(domains): domain availability search, shared by OrangeCat and Fl…
claude Aug 26, 2026
6b6fa8f
feat(sites): Substrata Intel at substrataintel.orangecat.ch, designed…
claude Aug 26, 2026
f91ec2d
refactor(sites): the firm is Substrata, at substrata.orangecat.ch
claude Aug 26, 2026
3b57257
feat(substrata): no trading desk, and chokepoints that are not materials
claude Aug 26, 2026
35995b7
feat(substrata): thesis, how to act on it, and the ledger to becoming…
claude Aug 27, 2026
b88f592
feat(substrata): the participant directory, graded by scarcity
claude Aug 27, 2026
b0db807
docs(seed): the seed header still described a firm that no longer exists
claude Aug 27, 2026
cb02bce
fix(sites): close the five defects the architecture audit found
Aug 27, 2026
7aecbae
feat(sites): publishing a website stops being a code change
Aug 27, 2026
0d50293
feat(sites): a certificate for any customer domain, with no ssh
Aug 27, 2026
9d1a745
docs(sites): the runbook for turning a group into a website
Aug 27, 2026
65a07e4
fix(verify): type-check the scripts, so the seed's types mean something
Aug 27, 2026
6d0b517
feat(sites): publish a website with one call, not one INSERT
Aug 27, 2026
21ee262
merge: main into feat/hosted-sites-routine
Aug 27, 2026
3709899
Merge remote-tracking branch 'origin/main' into feat/hosted-sites-rou…
catomean Aug 27, 2026
dd3ec15
fix(sites): stop OrangeCat rendering itself on somebody else's domain
Aug 27, 2026
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
51 changes: 50 additions & 1 deletion __tests__/unit/config/hosted-sites.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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/<slug>` 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<string, string>) {
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');
Expand Down
92 changes: 57 additions & 35 deletions src/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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.
Expand All @@ -117,39 +136,42 @@ export default function RootLayout({ children }: { children: React.ReactNode })
suppressHydrationWarning
>
<body className="antialiased">
{/* Structured data: Organization + WebSite */}
<script
type="application/ld+json"
dangerouslySetInnerHTML={{
__html: JSON.stringify({
'@context': 'https://schema.org',
'@graph': [
{
'@type': 'Organization',
name: APP_NAME,
url: SITE_URL,
logo: `${SITE_URL}/images/orange-cat-logo.svg`,
description:
'Fund, lend, invest, trade, and govern with any identity, settled in Bitcoin.',
sameAs: [],
},
{
'@type': 'WebSite',
name: APP_NAME,
url: SITE_URL,
potentialAction: {
'@type': 'SearchAction',
target: {
'@type': 'EntryPoint',
urlTemplate: `${SITE_URL}/discover?q={search_term_string}`,
{/* Structured data: Organization + WebSite. Never on a hosted site —
it would tell every crawler that the customer's domain IS OrangeCat. */}
{!isHostedSite && (
<script
type="application/ld+json"
dangerouslySetInnerHTML={{
__html: JSON.stringify({
'@context': 'https://schema.org',
'@graph': [
{
'@type': 'Organization',
name: APP_NAME,
url: SITE_URL,
logo: `${SITE_URL}/images/orange-cat-logo.svg`,
description:
'Fund, lend, invest, trade, and govern with any identity, settled in Bitcoin.',
sameAs: [],
},
{
'@type': 'WebSite',
name: APP_NAME,
url: SITE_URL,
potentialAction: {
'@type': 'SearchAction',
target: {
'@type': 'EntryPoint',
urlTemplate: `${SITE_URL}/discover?q={search_term_string}`,
},
'query-input': 'required name=search_term_string',
},
'query-input': 'required name=search_term_string',
},
},
],
}),
}}
/>
],
}),
}}
/>
)}
<a
href="#main-content"
className="sr-only focus:not-sr-only focus:fixed focus:top-2 focus:left-2 focus:z-[9999] focus:px-4 focus:py-2 focus:bg-fg-primary focus:text-fg-inverted focus:rounded-lg focus:outline-none"
Expand All @@ -169,7 +191,7 @@ export default function RootLayout({ children }: { children: React.ReactNode })
inner <Suspense> around the slow part, which is what the
Next.js docs recommend so the existence check still runs
before the response commits). Enforced by audit-routes. */}
<AppShell>{children}</AppShell>
<AppShell forceSurface={isHostedSite ? 'site' : undefined}>{children}</AppShell>
</AuthProvider>
</QueryProvider>
</CurrencyRatesProvider>
Expand All @@ -183,7 +205,7 @@ export default function RootLayout({ children }: { children: React.ReactNode })
polls) can keep the browser from going idle, so lazyOnload left the
FAB unmounted for minutes — a feedback button that isn't there when
something breaks is the one time it's needed. */}
{process.env.FLEETCROWN_FEEDBACK_TOKEN && (
{!isHostedSite && process.env.FLEETCROWN_FEEDBACK_TOKEN && (
<Script
src="https://fleetcrown.orangecat.ch/widget.js"
strategy="afterInteractive"
Expand Down
22 changes: 18 additions & 4 deletions src/components/layout/AppShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
import { ReactNode, useEffect, useRef, useState } from 'react';
import { usePathname } from 'next/navigation';
import { useAuth } from '@/hooks/useAuth';
import { getRouteChrome, getRouteSurface, isCatHubPath } from '@/config/routes';
import { getRouteChrome, getRouteSurface, isCatHubPath, type RouteSurface } from '@/config/routes';
import GlobalCatLauncher from '@/components/ai-chat/GlobalCatLauncher';
import { STORAGE_KEYS } from '@/config/storage-keys';
import { Header } from './Header';
Expand All @@ -16,6 +16,14 @@ import { MessageSyncManagerInitializer } from '@/components/MessageSyncManagerIn

interface AppShellProps {
children: ReactNode;
/**
* Override the surface derived from the browser path.
*
* Set by the root layout for hosted sites only, where a rewrite means the
* path a client component can see is not the path being rendered. Leave it
* undefined everywhere else.
*/
forceSurface?: RouteSurface;
}

/**
Expand All @@ -32,12 +40,18 @@ interface AppShellProps {
* Last Modified: 2025-12-17
* Last Modified Summary: Fixed hydration race condition - wait for auth hydration before rendering sidebar
*/
export function AppShell({ children }: AppShellProps) {
export function AppShell({ children, forceSurface }: AppShellProps) {
const pathname = usePathname();
const { user, profile, hydrated, isLoading } = useAuth();
// SSOT: every shell-related visibility decision derives from
// getRouteSurface (src/config/routes.ts). Do not branch on pathname here.
const surface = getRouteSurface(pathname ?? '/');
//
// `forceSurface` exists for exactly one case, and it is not a style override:
// a hosted site is served by a REWRITE, so the browser path stays "/" and
// `usePathname()` cannot see `/sites/<slug>`. The server knows from the
// request headers and says so. Without it, OrangeCat's header and sidebar
// render on a customer's own domain.
const surface = forceSurface ?? getRouteSurface(pathname ?? '/');
const isAppSurface = surface === 'app';
// Auth pages render their own minimal chrome — global header + nav +
// search are noise on a sign-in screen and dilute the focus from the
Expand All @@ -64,7 +78,7 @@ export function AppShell({ children }: AppShellProps) {
getFilteredSections,
} = useNavigation(sidebarSections);

const routeChrome = getRouteChrome(pathname ?? '/');
const routeChrome = getRouteChrome(forceSurface === 'site' ? '/sites' : (pathname ?? '/'));
// Mirrors MobileBottomNav visibility — when the bottom nav renders, reserve
// space below the main scroll area so its content isn't covered.
const showsMobileBottomNav = isAppSurface && !routeChrome.hideMobileBottomNav;
Expand Down
26 changes: 26 additions & 0 deletions src/config/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,32 @@ function matchesPrefix(pathname: string, routes: readonly string[]): boolean {
* Single SSOT for shell-related route classification. Sidebar, mobile nav,
* header variant, and footer must all derive from this.
*/
/**
* Is this request rendering a hosted site — somebody else's website?
*
* Takes a header lookup rather than a `Headers`, so it is callable from a
* layout, a route handler and a test without any of them constructing a request.
*
* WHY A HEADER AND NOT THE PATH
*
* A hosted site is served by a REWRITE: the visitor stays on
* substrata.orangecat.ch while `/sites/substrata` renders. So the path visible
* to the browser — and to any client component calling `usePathname()` — is
* "/", which classifies as the public marketing surface. Deciding from that
* put OrangeCat's header, analytics, Organization schema and internal feedback
* widget on a customer's own domain. Middleware sets `x-hosted-site` on the
* request precisely so the server can answer this without guessing.
*
* `x-pathname` covers the other way in: `/sites/<slug>` requested directly,
* which is the preview form and has no rewrite.
*/
export function isHostedSiteRequest(getHeader: (name: string) => string | null): boolean {
if (getHeader('x-hosted-site')) {
return true;
}
return getRouteSurface(getHeader('x-pathname') ?? '/') === 'site';
}

export function getRouteSurface(pathname: string): RouteSurface {
if (matchesPrefix(pathname, SITE_SURFACES)) {
return 'site';
Expand Down
23 changes: 21 additions & 2 deletions src/middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,15 +51,34 @@ export async function middleware(request: NextRequest) {
if (siteSlug && !pathname.startsWith(`${SITES_PATH_PREFIX}/`)) {
const target = request.nextUrl.clone();
target.pathname = `${SITES_PATH_PREFIX}/${siteSlug}${pathname === '/' ? '' : pathname}`;
const rewritten = NextResponse.rewrite(target);

// These go on the REQUEST headers, not just the response.
//
// A rewrite keeps the visitor's URL bar saying substrata.orangecat.ch,
// which means `usePathname()` in the app says "/" — so the layout cannot
// tell it is rendering somebody else's website by looking at the path. It
// has to be told. Setting these only on the response (which is what this
// did first) tells the browser and nothing else, and the result was
// OrangeCat's header, analytics and Organization schema all rendering on a
// customer's domain.
const forwarded = new Headers(request.headers);
forwarded.set('x-pathname', target.pathname);
forwarded.set('x-hosted-site', siteSlug);

const rewritten = NextResponse.rewrite(target, { request: { headers: forwarded } });
rewritten.headers.set('x-pathname', target.pathname);
rewritten.headers.set('x-hosted-site', siteSlug);
return rewritten;
}

// Same reasoning as the rewrite above: the layout reads `x-pathname` from the
// REQUEST, so it has to be set there and not only echoed to the browser.
const forwardedHeaders = new Headers(request.headers);
forwardedHeaders.set('x-pathname', pathname);

const response = NextResponse.next({
request: {
headers: request.headers,
headers: forwardedHeaders,
},
});

Expand Down
Loading