fix(sites): stop OrangeCat rendering itself on somebody else's domain - #784
Merged
Conversation
…ials Substrate Materials is a company profile with exactly one focus: the physical inputs a technological singularity actually consumes. Software is not the constraint — purified tin, neon, polysilicon, ruthenium, transformer steel and rare-earth metal are, and each has a chokepoint, a lead time and a counterparty. The profile is a `group` (label 'company', public so a counterparty can read the book before sending an RFQ), its own actor_type 'group' actor, and a 15-line catalogue of `user_products` owned by that actor. Follows the Revive My Old Ride convention: copy defined once in config, a separate owner-gated seed writes it to the live DB. What makes "solely focused" a rule rather than a slogan is the inclusion test. A material is listed only if it moves one of three curves — compute per joule, joules delivered, or actuation. Everything else is declined, and the test is enforced: every listing must sit on a desk, every desk on a curve, and no desk may sit empty. - src/config/singularity-materials.ts — SSOT: mandate, exclusion rule, five desks, the catalogue, compliance stance, group + product payloads - scripts/seed-singularity-materials.ts — idempotent, owner-gated seed (group by slug, actor by group_id, listings by (actor_id, title)) - test gating the payloads against the live CHECK constraints, the label / feature / governance registries, and the mandate itself Treasury is deliberately not enabled: GROUP_FEATURES.treasury requires a bitcoin_address the company does not yet have. Prices are indicative reference levels, and every listing says so — a budgeting number, not a quote. Export-controlled lines carry the licence and end-use conditions. The seed has not been run; it needs the box's service-role key. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GF9GDWaBWYHCZ9iNwmfZ41
…h firm Reframes the trading company into what it should have been from the start: an open-source research firm covering the chokepoints between here and a technological singularity, with a trading desk on the materials it knows best. The asset is the map, not the book — trading a commodity and researching a robotics company are the same work, so the map is the product and the desk is one way to monetise it. Two tests now gate the universe instead of one. A node must move a curve (compute per joule, joules delivered, actuation) AND actually gate it, on concentration, substitutability, lead time and demand inelasticity. The unit of coverage is a chokepoint node, not an asset class — a node can be a material, company, person, machine or process. That is what lets the firm reach robotics, AI hardware and additive manufacturing without becoming "everything": you arrive at them by tracing a chain you were already mapping, so the graph grows by traversal rather than ambition. Phase 1 is the producers of the fifteen: 92 producer leads across 85 distinct companies, for every material on the desk. Each row asserts a name, a jurisdiction and a step in the chain, and nothing else. There is no field for capacity, share, revenue or quality — those are the claims that go stale in a quarter and move markets when wrong, and the research phase is what adds them with a citation. Every row therefore starts at source: null, which reads as an unverified lead; coverage is measured by how many rows have a source, not by how many rows exist. Disclosure is written now, before any position exists, because a firm that publishes on what it trades and intends to own cannot install that rule credibly afterwards. - src/config/substrate.ts — firm: curves, chokepoint screen, node types, four phases, desks, catalogue, compliance, disclosure, group payload - src/config/substrate-coverage.ts — Phase 1 universe + coverageProgress() - tests: the schema gates, plus coverage follows the book both ways and no producer row can carry an unsourced claim it has no field for Renamed from singularity-materials — the firm is no longer materials-only. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GF9GDWaBWYHCZ9iNwmfZ41
/domains has been selling this sentence — "a working site, hosted and managed at yourname.orangecat.ch" — with no mechanism behind it. This is the mechanism, proved end to end on Substrate. A request arriving on a hosted site's host is rewritten onto /sites/<slug> and answered by a standalone website built from that profile's own structured data. Rewrite, not redirect: the visitor's URL bar keeps saying substrate.orangecat.ch, which is the entire point of what is being sold. The path form keeps working on any host, so a site is previewable before its DNS exists. The website is not separately authored. Substrate's mandate, desks, catalogue and coverage universe already existed as config because the profile needed them; the five pages are those same objects rendered for a different audience. Change the profile and the site changes with it — and the map page cannot quietly drop the "unverified lead" caveat, because the only data it has is the data the tests hold to source: null. Adding a site is an entry in HOSTED_SITES plus a builder returning SitePage[]. Pages are data, sections come from one closed set of shapes, and one renderer serves every hosted site — which is what stops fifty customer sites becoming fifty stylesheets. - src/config/sites.ts — host → profile, pure and DB-free (edge hot path) - src/config/site-content.ts — page/section model + per-site dispatch - src/config/site-substrate.ts — Substrate's five pages, from its config - src/middleware.ts — the host rewrite - routes.ts + AppShell — 'site' is a fourth surface, not a chrome override, so a hosted site can never grow OrangeCat's header on somebody else's domain - metadata uses title.absolute, so the tab says "Substrate", not "Substrate | OrangeCat" - formatChf so a ruthenium quote reads CHF 15’000, in the listing the seed writes and on the site alike Verified against a running server: the host rewrite resolves for substrate.orangecat.ch, www., and substrate.localhost; orangecat.ch itself is untouched; an unknown path under a site host 404s. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GF9GDWaBWYHCZ9iNwmfZ41
…eetCrown Finding a name is the rung before the two services /domains already sells — somebody with no domain cannot buy hosting for one. This adds the check, as a public keyless v1 endpoint both products call rather than a widget one of them owns. THE RULE THIS IS BUILT AROUND The obvious implementation asks a redirector for a domain and treats 404 as available. That implementation reports ORANGECAT.CH — this project's own production domain — as free, because .ch operates no public RDAP service and a redirector cannot distinguish "no such domain" from "no such registry". The same false positive hits .io and .co. So a TLD earns a definitive answer only by appearing in IANA's RDAP bootstrap. Everything else — unsupported TLD, timeout, transport error, odd status, bootstrap unreachable — is `unknown`, surfaced as "check manually" with the reason. `unregistered` is reachable by exactly one path: a bootstrapped registry that answered 404. Even then the copy says premium pricing, registry reservations and trademark conflicts are not visible here. A search tool that guesses is worse than one that admits the gap, because the guess is what someone acts on. - src/config/domain-search.ts — SSOT: bootstrap URL, TLDs, patterns, timeouts, and the status copy - src/services/domains/availability.ts — bootstrap-gated RDAP lookup, cached, batched at a polite concurrency - src/services/domains/suggest.ts — bare name across every TLD first, variants after; a caller may narrow the TLD list - GET /api/v1/domains — registered in PUBLIC_API_INTEGRATION_ENDPOINTS beside search and demand, so FleetCrown gets it as a contract - /domains grows a search box above the offer it feeds 19 tests, none touching the network, including one per no-RDAP TLD that fails if the feature ever reports one of them as free. Verified live: substrataintel.com and .ai unregistered; orangecat.ai and orangecatlabs.com registered; orangecat.ch correctly unresolved rather than free. Note for deploy: the box needs outbound HTTPS to rdap.org and data.iana.org, or every lookup degrades to "check manually". Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GF9GDWaBWYHCZ9iNwmfZ41
… properly Renames the firm to Substrata Intel (files and slug follow, so a reader never finds substrate.ts exporting a company called something else) and gives the hosted site a design rather than a stylesheet. TWO CORRECTIONS TO THE BRIEF, BOTH CHECKED No domain is bought. The site runs on the free subdomain the platform already offers. And it is orangecat.CH, not .com: orangecat.com is registered to someone and serves nothing, while every part of this platform — CD, Caddy, the /domains copy, SITES_BASE_DOMAIN — is .ch. THE DESIGN Editorial, not marketing. Space Grotesk for display, IBM Plex Mono for anything a reader compares down a column, Inter for prose at measure. Monochrome surfaces; the warm accent appears exactly twice, on the current nav item and the coverage bar; status colour only on status. - Hero replaces the generic title block on the home page: eyebrow, one display statement, and a lead cut to two paragraphs. The two that were dropped are said better by the phase block and the desk page, and a lead nobody finishes is not a lead. - Coverage meter. The single most important number on the site now has a picture, and at 0 of 92 that picture is an empty bar. Drawn from the same data as the map, so it cannot flatter the work. - The map gets a jump index — fifteen tables without one is a scroll, not a document — and every anchor is tested to land on a real table. - Tables are table-fixed with declared widths. Auto layout sized each of the fifteen to its own longest cell, so one long company name shifted every column out of line with the table above it. - Status cells carry a dot. "Unverified lead" and "Sourced" are the two words on this site a reader must never skim past. - Sections are numbered, headings sit on a hanging number, cards are top-ruled rather than boxed. - Masthead is one row at every width; the nav scrolls sideways on a phone instead of wrapping, because a two-row sticky header ate a third of a 390px screen. Renderer split into focused section components under components/sites/ sections/, dispatched from one place. Three new section kinds — hero, meter, index — available to every future hosted site, not just this one. Verified against a running server at 1280 and 390: the host rewrite answers on substrataintel.orangecat.ch, the tab reads "Substrata Intel", and orangecat.ch itself is untouched. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GF9GDWaBWYHCZ9iNwmfZ41
"Intel" was only ever a workaround for substrata.com being taken. On our own subdomain there is no scarcity to work around, so the name is just Substrata — config files, slug, subdomain and site all follow, and the wordmark is one word again. ORANGECAT.COM IS NOT OURS — DNS, not opinion: orangecat.ch -> 167.233.22.31 (bitbaum, this platform) orangecat.com -> 222.122.39.84 (KRNIC space, unrelated party) So substrata.orangecat.com cannot be built: it would point a customer site at somebody else's domain. The site is on .ch, where the CD, Caddy and every line of the /domains copy already live. No multi-base-domain plumbing was added for a .com we do not own — that is a constant to change on the day it becomes true, not an abstraction to carry until then. Also: the domain-search placeholder was one customer's name. It is "yourname" now, matching the /domains copy. Verified against a running server — substrata.orangecat.ch and substrata.localhost both rewrite, the tab reads "Substrata", the retired substrataintel host correctly no longer resolves as a site, and orangecat.ch itself is untouched. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GF9GDWaBWYHCZ9iNwmfZ41
Two corrections, both of which the site was getting wrong. THE DESK DOES NOT EXIST Standing a regulated commodities book up is a long road through licensing, and none of it has been walked. Until then, prices, units, lot sizes and invitations to deal are advertising a capability the firm does not have — which is the single biggest untruth a page can carry, and the one everything else here was built to prevent. So the desk page is gone, along with every price, every unit, and the product listings the seed wrote. The fifteen materials remain as research subjects, keeping the part that was always research — why each gates a curve, and which grade actually ships — folded into the map where it belongs. The group advertises no marketplace, because there is nothing to sell. `SCOPE.today` says plainly: we publish research, we do not trade, broker, quote or arrange movement, and nothing here is an offer or advice. Disclosure keeps its rules even though there is nothing to declare — a disclosure policy is only credible before it is needed — and now opens by saying so. A desk survives only as a phase marked not-started, described as an intention rather than a service. Two tests keep it that way: one asserts a material carries no price, unit or lot-size field, and one greps the config and the site builder for dealing language. A config that quietly regrows a price field is a config that puts an offer in front of a reader nobody may sell to. MATERIALS ARE NOT THE ONLY CHOKEPOINT The node taxonomy admitted machines, processes, companies and people from the start, and then the universe contained nothing but materials. Fourteen non-material chokepoints now sit alongside them: EUV scanners with one supplier, projection optics that are a chokepoint inside a chokepoint, packaging capacity allocated years ahead, HBM stacking yield, transformer slots, interconnection queues, turbine order books, magnet sintering, precision drives — and process engineers, the constraint nobody can buy. They enter on the same two tests and carry the same claim limits: what the node is and why it gates, nothing about capacity, share or price, every row unverified until sourced. A test now fails if the universe ever collapses back to one kind of node, so "a node can be anything" stays a fact about the coverage rather than a line in the config. Rendered as cards, not a table: a grid of names and country codes scans nicely and says nothing, and the claim IS the reason it gates. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GF9GDWaBWYHCZ9iNwmfZ41
… the fund
Readers will want to do something with this research. Two new pages serve
that without crossing a line the firm has no licence to cross.
THE LINE, AND WHERE IT ACTUALLY IS
Publishing an impersonal, generally-circulated view is research.
Telling a particular person what to buy is advice, and advice is
licensed nearly everywhere. Less obviously: taking a fee for an
introduction is what converts "here is who the brokers are" into
regulated intermediation — introducing broker, tied agent, finder — in
Switzerland, the EU and the US alike. Being unpaid is not a detail of
that arrangement; it is the whole of what keeps it lawful.
So there is no referral list. `PARTNERS` is empty behind a flag, and the
gate is documented: a name appears only with an executed agreement AND
confirmation that the introduction itself needs no licence. The page
says so rather than leaving a blank space, because volunteering
somebody's name as an endorsement they never gave is the easier thing
to do and the wrong one.
WHAT THE PAGES DO INSTEAD
Six routes by which anyone acts in these markets, described as market
structure and not endorsement, each carrying what it does NOT give you
— the real failure mode here is misleading by omission, and a reader
who takes a commodity ETF as exposure to seven-nines tin has been
misled by what nobody said. One route is not financial at all:
for an industrial reader the highest-return action is usually
procurement, and the map is a supplier list as much as a research
product.
A thesis, six claims, each with a falsifier. A thesis without one is a
slogan, and a slogan cannot be scored — which also makes the track
record possible later.
A readiness ledger: nine requirements to manage money rather than only
publish, at 1 done and 2 in progress. Same discipline as the coverage
meter, and the licence line still reads not started, which is the whole
reason the other two pages are written the way they are.
Tests hold the line rather than good intentions: no partner may appear
while the gate is shut; every route must state its limitation; and the
whole site is grepped for directive language ("we recommend", "you
should buy", "guaranteed", "risk-free"). These erode quietly, one
helpful sentence at a time.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GF9GDWaBWYHCZ9iNwmfZ41
The ask was everyone in the singularity chain, seen through scarcity. 102 participants across ten layers, ore to buyer, each graded. THE GRADE IS THE PRODUCT A directory of everyone in a supply chain is a phone book. What makes this research is the last column: chokepoint, concentrated, or competitive. Grading participants "competitive" is not filler — it is what makes "chokepoint" mean anything, and several well-known names are in the list precisely because they are not constraints. A map where every node is critical is a map nobody has read, so a test fails if all three grades are not in use, and another fails if chokepoints ever exceed half the list. On the demand side the grade reads the other way: when a handful of firms account for most of the world's orders, that concentration is a scarcity fact about the chain too, pointing upstream instead of down. Same claim limits as everywhere: name, layer, jurisdiction, role and a scarcity judgement. Nothing about revenue, capacity or share, and every row unsourced until an analyst attaches a source. The page leads with the 24 that actually bind, as cards carrying the reasoning, then the full directory as per-layer tables behind a jump index. ALSO - Dropped the defensive framing from Acting. The limits are three plain lines now, and the "why there is no referral list" section is gone: a directory nobody paid to be in is the point, not a caveat. - Domain aliases wired ahead of ownership. substrata.ch (intended home, appears free — .ch publishes no RDAP so a registrar has to confirm) and substrataintel.com (the .com fallback; substrata.com is taken). A host only reaches the check if DNS already points here, so listing them early costs nothing and means the site works the hour one is bought, with no deploy. - The readiness ledger became definitions instead of a table. Three columns where one holds a paragraph is a table that scrolls sideways on a phone and is read by nobody. - The nav is a client component for one reason: at eight sections the active item sat off-screen on a phone, so a visitor on a deep page saw no indication of where they were. It scrolls itself into view now. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GF9GDWaBWYHCZ9iNwmfZ41
It said "Substrate", promised a trading desk, and claimed the script had to run on the box. All three were stale: the firm is Substrata, there is no desk, and the seed is PostgREST calls rather than psql — so it runs from any machine that can reach supabase.orangecat.ch over HTTPS, no SSH and no tunnel needed. That last one matters, because believing otherwise is what makes a two-minute task look like it needs a maintenance window. Also notes that the service-role key bypasses RLS, so the machine running it is privileged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GF9GDWaBWYHCZ9iNwmfZ41
CodeQL was red on this branch with six alerts, which is why the PR could not
merge. All six were real but shallow: five unused locals, and one substring
test on a URL (`url.includes('data.iana.org')`) that also matches
`data.iana.org.evil.example`. It sat in a test — the worst place for it, since
tests are what the next lookup gets copied from.
The other four are the ones that mattered:
**60 KB of dead payload on every page load.** `SiteNav` is a client component
and was taking `SitePage[]`. Every prop crossing that boundary is serialised
into the RSC payload, so all eight Substrata pages shipped the entire site —
92 producer rows, 102 participants — to render eight links. Measured: 60.1 KB
carried, 0.33 KB used. It now takes `SiteNavItem[]`, the narrowest shape that
answers its question.
**A public keyless amplifier with no rate limit.** One request to
`/api/v1/domains` fans out to as many as 24 outbound RDAP lookups, and varying
the query defeats the cache. The cost of abuse was never our CPU — it was this
box's IP being throttled by the registries we depend on. Now behind
`rateLimitDomainSearch` (10 per 5 min), modelled on the ask-cat limiter, which
exists for exactly this shape: an expensive downstream call for an anonymous
caller.
**An unbounded cache keyed by caller input.** `resultCache` grew for as long as
the process did, and on the box that is weeks. The TTL did not bound it —
expired entries are only noticed when the same key returns, which an
enumerating caller never does. Capped, with a TTL sweep before LRU eviction,
and a test that fails if the cap stops holding.
**A seed that would break on the box, not in CI.** `seed-substrata.ts` used an
untyped client and five `as string` casts, so a column rename would surface by
hand, months later. Bound to the generated `Database` type; the casts are gone
because they are no longer needed.
Also collapsed the third copy of the x-forwarded-for read in rate-limit.ts into
`clientIp()`. A per-IP limiter is only as correct as its notion of "IP".
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A hosted site was an entry in a hardcoded array plus a hand-written builder. That means every customer costs a pull request, a CI run and a deploy before their domain resolves — which is the opposite of what /domains sells. Three changes make an ordinary site cost nothing: **Host resolution is positional, not a list.** `siteSlugForHost` asks whether a Host header is SHAPED like a hosted site — one non-reserved label on orangecat.ch — and never touches the database, because it runs in edge middleware on every request to the whole app. Whether a site EXISTS is decided downstream by the page, which is allowed to query. An unclaimed slug rewrites and 404s, which is the safe direction. That trade needs a reserved list to be safe, so there is one, and it is load-bearing twice over: `supabase`/`bridge`/`fleetcrown` already serve something else on this box, and `security.orangecat.ch` under our own certificate is a phish, not a website. RESERVED_SUBDOMAINS covers both, and the tests assert every entry refuses. **A site is a row in a table that already exists.** `group_features` has group_id, enabled, an audit column and a `config` jsonb, and its own header already says adding a feature needs no code. Publishing a website is a capability a group switches on, exactly like treasury. A `hosted_sites` table would have duplicated all four columns and created a second answer to "what has this group turned on". The new migration adds one RLS policy so a published site is world-readable — the database decides "published", not TypeScript, and `enabled = false` unpublishes instantly. jsonb is `any` wearing a hat, so it is validated at the boundary with a per-field `.catch`: one malformed alias host costs that alias, never the customer's website. **An ordinary site has no builder.** `site-profile.ts` renders any group from the profile it already filled in. Nothing is authored twice and nothing is invented — no Support block without an address, no nav on a one-page site. The dispatch that was a `switch` is now a map holding only the exceptions, because that is what a bespoke builder is: Substrata's research corpus is not profile-shaped, and it stays in the repository so it renders statically without a database at all. Net: a new site is a row and zero lines of code. 216 config tests green. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Positional host resolution gets a site routed. It does not get it a TLS certificate, and without one the site is unreachable — so this is the other half of "spinning up a site costs no infrastructure work". The alternative was a wildcard certificate: a DNS-01 challenge, an Infomaniak API token living on the box, a Caddy DNS plugin — and it still would not cover a customer's own domain. On-demand TLS needs none of it and inverts the relationship correctly: Caddy asks OrangeCat whether a hostname is real before ordering a certificate, and OrangeCat already knows, because a site is a row. `/api/internal/tls-check` is that gate, and it is written as a gate. Every 200 is an ACME order and Let's Encrypt rate-limits an account that fails them, so anyone who points a hostname here and requests it is spending our issuance budget. Reserved subdomains are refused before any query runs, and a lookup that throws denies — a database blip must not become an open certificate mint. Six tests hold exactly those cases, including the fail-closed one. The Caddy block lives in `deployment/caddy/` rather than only on the box, because configuration that exists only on a box is configuration nobody can review. `install-hosted-sites-caddy.sh` refuses to run until the ask endpoint answers 200, backs up the Caddyfile, validates before reloading, and re-checks the neighbours afterwards — one bad Caddyfile takes down all twenty-odd apps. **RESERVED_SUBDOMAINS was wrong, and now cannot be.** Written by hand, it held seven labels. The box is serving twenty-two. Fourteen live hostnames — kivvi, vitareba, solon, supabase among them — were claimable as site slugs the moment resolution became positional. The list is now generated from Caddy into `deployment/reserved-hosts.txt`, and `check:reserved-hosts` is in `verify`, so the next app deployed on this box fails the build until it is reserved. The gate was tested by deleting an entry and confirming it goes red. Live Caddyfile deliberately untouched: the ask endpoint is not deployed yet, and enabling on-demand TLS before it exists would deny every request. Install after the deploy. 438 tests green. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two audiences. Somebody publishing a site needs to know it is one row and no deploy — and that the minute before it answers is a named constant, not a mystery. Somebody deploying a new app on bitbaum needs to know why the build suddenly fails on reserved hosts, because that failure is the whole point of the gate and it will look like a bug the first time. Both one-time infrastructure steps are written down with the reasoning kept, including the one that cannot be automated from here: no Infomaniak API credentials exist on a developer machine, so the wildcard A record is a human action, once, forever. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The previous commit said binding seed-substrata.ts to the generated `Database` type 'moves that failure to npm run type-check'. That was not true as configured: tsconfig.json excludes scripts/**/*.ts so Next does not compile them, and the side effect was that verify never looked at them at all. The binding was decoration. A seed is exactly the wrong place for that gap. It runs by hand, on the box, months after the migration that broke it — the one script whose schema drift surfaces in front of a person rather than in CI. scripts/ was already at zero errors, so this gate starts green. Verified it bites by mistyping a column name and watching it go red. Deliberately not extended to __tests__: 119 pre-existing type errors live there and fixing them is real work that does not belong to whichever change happens to notice. Recorded rather than silently skipped. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Everything underneath already worked off a single row, but the only way to write that row was SQL. That is not "a few clicks" — it is a DBA. This is the endpoint FleetCrown and the group settings UI call: GET /api/groups/[slug]/site status, address, eligibility PUT /api/groups/[slug]/site publish (upsert, so it also reconfigures) DELETE /api/groups/[slug]/site unpublish, keeping the configuration Three decisions worth naming: **PUT is an upsert.** "Create a site" and "configure a site" would be two states to reason about where the database has one row. **GET answers `url` even when unpublished**, plus `eligible` and `reason`. It costs nothing and it is the difference between a button that says "Publish" and one that says "Publish at acme.orangecat.ch" — and between a group learning its slug is reserved before it clicks or after. **DELETE disables, it does not delete.** `enabled = false` is what both the RLS policy and the resolver read, and keeping the row keeps the config, so taking a site down for a week does not lose its custom domain. The refusals are the tested part, because the worst failure here is a site that exists in the database and never answers on the internet — the button said it worked. A slug that is not a legal DNS label, a slug that already belongs to infrastructure (`supabase`, `fleetcrown`) or invites a phish (`security`), and a private group whose site RLS would hide from every visitor. `check:sizes` refused the first version at 229 lines, which was the right complaint: the route was doing domain work. Rules and writes now live in services/sites/publish.ts, which imports nothing from `next/` — that one `revalidateTag` import dragged Next's entire server runtime into any test that touched these rules, and dropping a framework cache is the HTTP layer's job. verify green: 250 suites, 2494 tests. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Only conflict was package.json's verify chain — main added check:migration-versions while this branch added type-check:scripts and check:reserved-hosts. All three are wanted; the resolution is the union. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
substrata.orangecat.ch came up with OrangeCat's header, "Sign In" and "Get
Started" across the top of it. Three more of ours were on that page too, and
they are worse than the header because they are invisible:
- our Google Analytics tag, tracking visitors on a domain that is not ours
- our Organization + WebSite JSON-LD, which tells every crawler that the
customer's domain IS OrangeCat
- the FleetCrown internal feedback widget, loaded from fleetcrown.orangecat.ch
One cause. A hosted site is served by a REWRITE — that is the entire point, the
visitor's URL bar has to keep saying substrata.orangecat.ch — so the path a
client component sees is "/", not "/sites/substrata". `AppShell` decided chrome
from `usePathname()`, got "/", classified a customer's website as OrangeCat's
public marketing surface, and rendered accordingly. The other three were never
gated at all, because nothing in the root layout knew the question existed.
The claim that a hosted site "can never accidentally grow OrangeCat's header"
was true of the route surface and false of the rendering, and the tests did not
catch the gap because every one of them asked `getRouteSurface('/sites/...')` —
the preview form, which was never broken. None asked what happens when the path
is "/" and only a header knows better.
So the question is now asked once, on the server, by `isHostedSiteRequest` in
the routes SSOT, and all four gate on its answer. Middleware forwards
`x-hosted-site` on the REQUEST headers rather than only echoing it to the
browser — setting a header on the response tells the browser and nothing else,
which is why the layout could not see it.
Four tests cover the case that shipped broken: a rewritten request whose visible
path is only "/".
verify green: 251 suites, 2506 tests.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The bug
substrata.orangecat.chwas serving OrangeCat's header — "Product / Blog / Support / Sign In / Get Started" — across the top of a site that is not OrangeCat's.Three more of ours were on that page, and they're worse than the header because they're invisible:
fleetcrown.orangecat.chOne cause
A hosted site is served by a rewrite — that's the whole point, the visitor's URL bar must keep saying
substrata.orangecat.ch. So the path a client component sees is/, not/sites/substrata.AppShelldecided chrome fromusePathname(), got/, classified a customer's website as OrangeCat's public marketing surface, and rendered accordingly. The other three were never gated at all, because nothing in the root layout knew the question existed.The earlier claim that a hosted site "can never accidentally grow OrangeCat's header" was true of the route surface and false of the rendering.
Why the tests missed it
Every existing test asked
getRouteSurface('/sites/substrata')— the preview form, which was never broken. None asked what happens when the visible path is/and only a header knows better. Four new tests cover exactly that.The fix
The question is asked once, on the server, by
isHostedSiteRequestin the routes SSOT; all four gate on its answer.Middleware now forwards
x-hosted-siteon the request headers rather than only echoing it to the browser — setting a header on the response tells the browser and nothing else, which is precisely why the layout couldn't see it.Verification
npm run verifygreen: 251 suites, 2506 tests. Confirmed in a real browser that the path form was already correct and the subdomain was not; will re-confirm in-browser after deploy.🤖 Generated with Claude Code