Skip to content

feat(sites): make publishing a website a row, not a deploy - #783

Merged
github-actions[bot] merged 17 commits into
mainfrom
feat/hosted-sites-routine
Aug 27, 2026
Merged

feat(sites): make publishing a website a row, not a deploy#783
github-actions[bot] merged 17 commits into
mainfrom
feat/hosted-sites-routine

Conversation

@catomean

Copy link
Copy Markdown
Collaborator

Description

Stacked on #782. That PR built the hosted-site mechanism and Substrata as its first customer; this one closes the defects an architecture audit found in it, and then removes the part that would not have scaled: a new site was a code change.

Why this is stacked rather than folded into #782

#782 is green apart from CodeQL and belongs to the session that wrote it. This branch takes it as a base so its diff stays reviewable on its own. Merge #782 first; GitHub will retarget this to main.

What it does

1. The five defects from the audit (first commit)

Finding Fix
CodeQL 6 alerts, PR unmergeable 5 dead locals; url.includes('data.iana.org') → exact-host parse
Perf 60.1 KB serialised per page load, 0.33 KB used SiteNav is a client component and took SitePage[] — every prop crosses into the RSC payload. Now takes SiteNavItem[]
Security /api/v1/domains public, keyless, 1 request → 24 outbound RDAP lookups rateLimitDomainSearch, 10/5 min, modelled on the ask-cat limiter
Stability resultCache unbounded, keyed by caller input, process lives for weeks Capped, TTL sweep then LRU, with a test
Correctness seed-substrata.ts untyped + 5 as string Bound to generated Database; casts gone

2. A site stops being a code change (second commit)

  • Host resolution is positional. siteSlugForHost matches by shape, never touches the database (it runs in edge middleware on every request to the whole app). Existence is decided by the page, which may query. An unclaimed slug rewrites and 404s.
  • A site is a group_features row with feature_key='site'. No new table: that table already has group_id, enabled, an audit column and a config jsonb, and its own header already says adding a feature needs no code. One RLS policy makes a published site world-readable, so the database decides "published" and enabled=false unpublishes instantly. jsonb is validated at the boundary with per-field .catch — a malformed alias host costs that alias, not the customer's website.
  • An ordinary site has no builder. site-profile.ts renders any group from the profile it already filled in. The switch became a map holding only exceptions; Substrata stays bespoke because a research corpus is not profile-shaped, and stays in the repo so it renders statically with no database at all.

3. A certificate without ssh (third commit)

/api/internal/tls-check is what Caddy's on_demand_tls { ask } calls. A wildcard cert would have needed DNS-01, an Infomaniak token on the box and a Caddy plugin — and still would not cover a customer's own domain. Written as a gate: every 200 is an ACME order, so reserved subdomains are refused before any query and a lookup that throws denies.

The bug this surfaced

RESERVED_SUBDOMAINS, written by hand, held 7 labels. The box serves 22. Fourteen live hostnames — kivvi, vitareba, solon, supabase — became claimable as site slugs the moment resolution went 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 bitbaum fails the build until it is reserved. The gate was verified by deleting an entry and confirming it goes red.

Verification

npm run verify green: 249 suites, 2486 tests, plus type-check, lint, route audit, duplication, dead-fields, schema-columns, currency-units, the new reserved-hosts gate, RPC-exists and MDX.

Caddy config validated in place on bitbaum against the real apps.d imports — every one of the 21 existing hosts still routes to its own port, catch-all lands on 4003. The live Caddyfile is deliberately untouched: the ask endpoint is not deployed yet, and enabling on-demand TLS before it exists would deny every request.

Deploying the first site

  1. Merge → CD ships the ask endpoint.
  2. bash scripts/ci/install-hosted-sites-caddy.sh (refuses to run until the endpoint answers 200; backs up, validates, reloads, re-checks neighbours).
  3. One manual step, once, forever: a wildcard *.orangecat.ch A 167.233.22.31 at Infomaniak. No API credentials exist on this machine, so it cannot be automated from here. After it, no site ever needs DNS work again.

🤖 Generated with Claude Code

claude and others added 13 commits August 26, 2026 15:49
…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>
@catomean
catomean changed the base branch from claude/singularity-materials-profile-gzunby to main August 27, 2026 08:02
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>
@catomean catomean closed this Aug 27, 2026
@catomean catomean reopened this Aug 27, 2026
Mao Nakamoto and others added 3 commits August 27, 2026 10:13
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>
@github-actions
github-actions Bot merged commit 370ccce into main Aug 27, 2026
9 checks passed
@github-actions
github-actions Bot deleted the feat/hosted-sites-routine branch August 27, 2026 08:42
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants