From 69104767bb815aff5468e9c3c3d74f95d5075407 Mon Sep 17 00:00:00 2001 From: Mao Nakamoto Date: Thu, 27 Aug 2026 13:47:39 +0200 Subject: [PATCH] =?UTF-8?q?chore(sites):=20remove=20hosted=20sites=20?= =?UTF-8?q?=E2=80=94=20OrangeCat=20is=20not=20the=20host?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit I built this on a wrong premise: that .orangecat.ch means membership in OrangeCat. It does not. It is an address on a domain George happens to own, which is why petvity has a site and no profile, and why sinktattoo moved to its own domain without moving a line of code. Every site in this studio is its own repository from birth — aslan-tattoo, kivvi, vitareba, petvity, botsmann, all of them. That is not an accident of history, it is the property that makes a site handover-able, and a site that lives inside this codebase can never be handed to anyone. Substrata was the only violation, and it was mine. Removed: the group_features 'site' feature, the profile-site renderer, the positional host resolution and its reserved-subdomain list, the publish API, the tls-check endpoint, the catch-all Caddy block, and Substrata's configs. Substrata moves to its own repository, with its own design system — it currently renders in OrangeCat's tokens and OrangeCat's orange, which is the visible form of the same mistake. The RLS policy needs its own DROP: deleting a migration file does not undo a policy that already ran on production, and an anonymous SELECT grant is not something to leave behind because nothing writes that row any more. Kept, because they were never about hosted sites: - rateLimitDomainSearch — /api/v1/domains still fans one request out to 24 registry lookups for an anonymous caller - the bounded RDAP result cache, keyed by caller input in a process that runs for weeks - clientIp(), collapsing the third copy of the x-forwarded-for read - the CodeQL fix for the URL substring test - type-check:scripts, which is why scripts/ is type-checked at all verify green: 244 suites, 2406 tests. Co-Authored-By: Claude Opus 5 (1M context) --- __tests__/unit/api/tls-check-api.test.ts | 108 -- __tests__/unit/config/hosted-sites.test.ts | 342 ------ __tests__/unit/config/site-profile.test.ts | 101 -- .../unit/config/substrata-acting.test.ts | 127 -- .../config/substrata-participants.test.ts | 127 -- __tests__/unit/config/substrata.test.ts | 284 ----- __tests__/unit/services/site-publish.test.ts | 68 -- deployment/caddy/hosted-sites.caddy | 43 - deployment/reserved-hosts.txt | 34 - docs/operations/hosted-sites.md | 165 --- package.json | 4 +- scripts/ci/check-reserved-hosts.sh | 46 - scripts/ci/install-hosted-sites-caddy.sh | 64 - scripts/ci/sync-reserved-hosts.sh | 34 - scripts/seed-substrata.ts | 208 ---- src/app/api/groups/[slug]/site/route.ts | 149 --- src/app/api/internal/tls-check/route.ts | 76 -- src/app/layout.tsx | 92 +- src/app/sites/[site]/[[...path]]/page.tsx | 140 --- src/components/layout/AppShell.tsx | 37 +- src/components/sites/SiteChrome.tsx | 76 -- src/components/sites/SiteNav.tsx | 66 -- src/components/sites/SiteSections.tsx | 57 - .../sites/sections/DataSections.tsx | 141 --- .../sites/sections/FigureSections.tsx | 87 -- src/components/sites/sections/HeroSection.tsx | 32 - src/components/sites/sections/Primitives.tsx | 46 - .../sites/sections/ProseSections.tsx | 90 -- src/config/group-features.ts | 8 - src/config/hosted-site.ts | 178 --- src/config/routes.ts | 39 +- src/config/site-content.ts | 192 --- src/config/site-profile.ts | 135 --- src/config/site-substrata.ts | 662 ----------- src/config/sites.ts | 210 ---- src/config/substrata-acting.ts | 361 ------ src/config/substrata-coverage.ts | 504 -------- src/config/substrata-participants.ts | 1045 ----------------- src/config/substrata.ts | 591 ---------- src/middleware.ts | 43 +- src/services/sites/publish.ts | 130 -- src/services/sites/registry.ts | 194 --- ...0260827090000_hosted_sites_public_read.sql | 52 - ...0260827130000_drop_hosted_sites_policy.sql | 19 + 44 files changed, 61 insertions(+), 7146 deletions(-) delete mode 100644 __tests__/unit/api/tls-check-api.test.ts delete mode 100644 __tests__/unit/config/hosted-sites.test.ts delete mode 100644 __tests__/unit/config/site-profile.test.ts delete mode 100644 __tests__/unit/config/substrata-acting.test.ts delete mode 100644 __tests__/unit/config/substrata-participants.test.ts delete mode 100644 __tests__/unit/config/substrata.test.ts delete mode 100644 __tests__/unit/services/site-publish.test.ts delete mode 100644 deployment/caddy/hosted-sites.caddy delete mode 100644 deployment/reserved-hosts.txt delete mode 100644 docs/operations/hosted-sites.md delete mode 100755 scripts/ci/check-reserved-hosts.sh delete mode 100755 scripts/ci/install-hosted-sites-caddy.sh delete mode 100755 scripts/ci/sync-reserved-hosts.sh delete mode 100644 scripts/seed-substrata.ts delete mode 100644 src/app/api/groups/[slug]/site/route.ts delete mode 100644 src/app/api/internal/tls-check/route.ts delete mode 100644 src/app/sites/[site]/[[...path]]/page.tsx delete mode 100644 src/components/sites/SiteChrome.tsx delete mode 100644 src/components/sites/SiteNav.tsx delete mode 100644 src/components/sites/SiteSections.tsx delete mode 100644 src/components/sites/sections/DataSections.tsx delete mode 100644 src/components/sites/sections/FigureSections.tsx delete mode 100644 src/components/sites/sections/HeroSection.tsx delete mode 100644 src/components/sites/sections/Primitives.tsx delete mode 100644 src/components/sites/sections/ProseSections.tsx delete mode 100644 src/config/hosted-site.ts delete mode 100644 src/config/site-content.ts delete mode 100644 src/config/site-profile.ts delete mode 100644 src/config/site-substrata.ts delete mode 100644 src/config/sites.ts delete mode 100644 src/config/substrata-acting.ts delete mode 100644 src/config/substrata-coverage.ts delete mode 100644 src/config/substrata-participants.ts delete mode 100644 src/config/substrata.ts delete mode 100644 src/services/sites/publish.ts delete mode 100644 src/services/sites/registry.ts delete mode 100644 supabase/migrations/20260827090000_hosted_sites_public_read.sql create mode 100644 supabase/migrations/20260827130000_drop_hosted_sites_policy.sql diff --git a/__tests__/unit/api/tls-check-api.test.ts b/__tests__/unit/api/tls-check-api.test.ts deleted file mode 100644 index e0af550dd..000000000 --- a/__tests__/unit/api/tls-check-api.test.ts +++ /dev/null @@ -1,108 +0,0 @@ -/** - * @jest-environment node - * - * Node rather than jsdom: this route is plain web-standard Request/Response, - * which jsdom does not provide as globals. - */ -/** - * This endpoint decides whether Caddy orders a TLS certificate, which makes it - * the one place where being generous is expensive. - * - * Every 200 is an ACME order against our Let's Encrypt account, and that account - * is rate-limited on failures. Anyone who points a hostname at this box and - * requests it can spend that budget, so the rules being tested here are: only a - * site that is published RIGHT NOW, never a reserved subdomain, and — the one - * that is easy to get backwards — a denial rather than an approval when the - * lookup itself fails. - */ - -import { GET } from '@/app/api/internal/tls-check/route'; -import { siteByHost } from '@/services/sites/registry'; - -jest.mock('@/services/sites/registry', () => ({ siteByHost: jest.fn() })); -jest.mock('@/utils/logger', () => ({ - logger: { warn: jest.fn(), error: jest.fn(), info: jest.fn() }, -})); - -const mockSiteByHost = siteByHost as unknown as jest.Mock; - -function ask(domain?: string) { - const url = new URL('http://127.0.0.1:4003/api/internal/tls-check'); - if (domain !== undefined) { - url.searchParams.set('domain', domain); - } - return GET(new Request(url)); -} - -const publishedSite = { - site: { - slug: 'acme', - title: 'Acme', - customDomain: 'acme.example', - aliasHosts: [], - profile: { kind: 'group' as const, slug: 'acme' }, - builder: null, - }, - profile: null, -}; - -beforeEach(() => { - mockSiteByHost.mockReset(); -}); - -describe('tls-check — what earns a certificate', () => { - it('approves a hostname a published site answers on', async () => { - mockSiteByHost.mockResolvedValue(publishedSite); - expect((await ask('acme.orangecat.ch')).status).toBe(200); - }); - - it('refuses a hostname no site claims', async () => { - mockSiteByHost.mockResolvedValue(null); - expect((await ask('nobody.orangecat.ch')).status).toBe(403); - }); - - it('refuses without a domain, and refuses a malformed one', async () => { - expect((await ask()).status).toBe(403); - expect((await ask('')).status).toBe(403); - expect((await ask(`${'a'.repeat(300)}.example`)).status).toBe(403); - expect(mockSiteByHost).not.toHaveBeenCalled(); - }); -}); - -describe('tls-check — the refusals that matter', () => { - /** - * These already have certificates and their own Caddy blocks. Issuing a second - * one is waste at best; `security.orangecat.ch` under our certificate is a - * phish at worst. Refused BEFORE the lookup, so no database state can grant one. - */ - it('refuses reserved subdomains without ever asking the database', async () => { - mockSiteByHost.mockResolvedValue(publishedSite); - - for (const host of [ - 'supabase.orangecat.ch', - 'fleetcrown.orangecat.ch', - 'security.orangecat.ch', - 'www.orangecat.ch', - 'kivvi.orangecat.ch', - ]) { - expect((await ask(host)).status).toBe(403); - } - expect(mockSiteByHost).not.toHaveBeenCalled(); - }); - - it('refuses a multi-label subdomain, which no hosted site can have', async () => { - mockSiteByHost.mockResolvedValue(publishedSite); - expect((await ask('a.b.orangecat.ch')).status).toBe(403); - expect(mockSiteByHost).not.toHaveBeenCalled(); - }); - - /** - * The one that is easy to get backwards. A database blip must not turn this - * into an open certificate mint — an outage should cost new certificates, not - * hand them out. - */ - it('fails closed when the lookup throws', async () => { - mockSiteByHost.mockRejectedValue(new Error('database unreachable')); - expect((await ask('acme.example')).status).toBe(403); - }); -}); diff --git a/__tests__/unit/config/hosted-sites.test.ts b/__tests__/unit/config/hosted-sites.test.ts deleted file mode 100644 index faeed0be1..000000000 --- a/__tests__/unit/config/hosted-sites.test.ts +++ /dev/null @@ -1,342 +0,0 @@ -/** - * A hosted site is the claim /domains makes, turned into code: a profile's own - * data, served as a whole website on its own hostname. Three things have to - * hold for that claim to be true, and none of them is obvious from reading a - * component. - * - * 1. Host resolution has to work for the free subdomain, a custom domain and - * local development — and has to REFUSE everything else, because a - * too-eager match would silently swallow orangecat.ch itself. - * 2. The site surface has to be a surface, not a chrome override, or a hosted - * site quietly grows OrangeCat's header on somebody else's domain. - * 3. The pages have to be generated from the profile config rather than - * authored beside it, or the "spins up a website" claim is a copy-paste. - */ - -import { - RESERVED_SUBDOMAINS, - isReservedSubdomain, - siteHref, - siteSlugForHost, -} from '@/config/sites'; -import { - ALWAYS_PUBLISHED, - HOSTED_SITE_FALLBACKS, - siteCanonicalHost, - toHostedSite, -} from '@/config/hosted-site'; -import { - pageRendersOwnHeader, - sitePageAt, - sitePagesFor, - siteChromeFor, -} from '@/config/site-content'; -import { getRouteSurface, isHostedSiteRequest } from '@/config/routes'; -import { COMPANY, MANDATE_CURVES, MATERIALS } from '@/config/substrata'; -import { CHOKEPOINTS, COVERAGE, coverageProgress } from '@/config/substrata-coverage'; - -const site = HOSTED_SITE_FALLBACKS.substrata; - -describe('hosted sites — host resolution', () => { - it('resolves the free subdomain, with or without www and port', () => { - expect(siteSlugForHost('substrata.orangecat.ch')).toBe('substrata'); - expect(siteSlugForHost('www.substrata.orangecat.ch')).toBe('substrata'); - expect(siteSlugForHost('Substrata.OrangeCat.ch:443')).toBe('substrata'); - }); - - it('resolves the local development host, so the rewrite is testable without DNS', () => { - expect(siteSlugForHost('substrata.localhost:3020')).toBe('substrata'); - }); - - /** - * Resolution is now positional rather than an allowlist — that is what makes a - * new customer zero deploys. The safety therefore has to come from SHAPE, and - * these are the shapes that must never resolve. - */ - it('refuses everything else — a greedy match would swallow OrangeCat itself', () => { - for (const host of [ - 'orangecat.ch', - 'www.orangecat.ch', - 'localhost:3000', - 'substrata.evil.example', - 'substrata.orangecat.ch.evil.example', - 'a.b.orangecat.ch', - '.orangecat.ch', - 'sub_domain.orangecat.ch', - '-leading.orangecat.ch', - 'trailing-.orangecat.ch', - '', - null, - undefined, - ]) { - expect(siteSlugForHost(host)).toBeNull(); - } - }); - - /** - * An unclaimed slug MUST resolve, because the page is what knows whether a - * site exists. If this ever returned null the whole no-deploy path would be - * gone and every customer would need a code change again. - */ - it('resolves a slug no site has claimed, leaving existence to the page', () => { - expect(siteSlugForHost('a-brand-new-customer.orangecat.ch')).toBe('a-brand-new-customer'); - }); - - /** - * The dangerous half of a positional match. `security.orangecat.ch` under our - * own certificate is a phish; `supabase.orangecat.ch` is the database. Neither - * may ever be handed to a group that happens to pick that slug. - */ - it('refuses every reserved subdomain, infrastructure and impersonation alike', () => { - for (const { label } of RESERVED_SUBDOMAINS) { - expect(isReservedSubdomain(label)).toBe(true); - expect(siteSlugForHost(`${label}.orangecat.ch`)).toBeNull(); - expect(siteSlugForHost(`${label.toUpperCase()}.orangecat.ch`)).toBeNull(); - } - }); - - it('reserves the hosts that are actually live on the box', () => { - // Every one of these has its own Caddy block today. A site claiming one - // would be shadowed by it, or would shadow it. - for (const live of ['www', 'bridge', 'fleetcrown', 'evig', 'supabase']) { - expect(isReservedSubdomain(live)).toBe(true); - } - }); - - it('advertises the custom domain once one is set, and the subdomain until then', () => { - const bare = toHostedSite({ slug: 'acme', name: 'Acme' }, {}); - expect(siteCanonicalHost(bare)).toBe('acme.orangecat.ch'); - - const custom = toHostedSite({ slug: 'acme', name: 'Acme' }, { customDomain: 'acme.example' }); - expect(siteCanonicalHost(custom)).toBe('acme.example'); - }); -}); - -describe('hosted sites — the config a site owner may set', () => { - /** - * `group_features.config` is jsonb, which is `any` wearing a hat. Whatever a - * client wrote there reaches this function, so a bad field must cost that - * field and never the customer's whole website. - */ - it('falls back to the group name and drops malformed fields', () => { - const site = toHostedSite( - { slug: 'acme', name: 'Acme Corp' }, - { title: ' ', customDomain: 'not a hostname', aliasHosts: ['ok.example', 'nodot', ''] } - ); - expect(site.title).toBe('Acme Corp'); - expect(site.customDomain).toBeNull(); - expect(site.aliasHosts).toEqual(['ok.example']); - }); - - it('survives config that is not an object at all', () => { - for (const junk of [null, undefined, 42, 'string', []]) { - expect(toHostedSite({ slug: 'acme', name: 'Acme' }, junk).title).toBe('Acme'); - } - }); - - it('marks only repo-resident sites as bespoke, so everyone else renders from their profile', () => { - expect(toHostedSite({ slug: 'substrata', name: 'Substrata' }, {}).builder).toBe('substrata'); - expect(toHostedSite({ slug: 'acme', name: 'Acme' }, {}).builder).toBeNull(); - }); -}); - -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'); - expect(siteHref(site.slug, 'map')).toBe('/sites/substrata/map'); - expect(siteHref(site.slug, '/map')).toBe('/sites/substrata/map'); - expect(siteHref(site.slug, '/')).toBe('/sites/substrata'); - }); -}); - -describe('hosted sites — chrome isolation', () => { - it('classifies a hosted site as its own surface, not app or public', () => { - expect(getRouteSurface('/sites/substrata')).toBe('site'); - expect(getRouteSurface('/sites/substrata/map')).toBe('site'); - }); - - it('leaves the rest of the app classified as it was', () => { - expect(getRouteSurface('/dashboard')).toBe('app'); - expect(getRouteSurface('/about')).toBe('public'); - expect(getRouteSurface('/auth')).toBe('auth'); - expect(getRouteSurface('/groups/substrata')).toBe('app'); - }); -}); - -describe('hosted sites — every site renders', () => { - it.each(ALWAYS_PUBLISHED.map(slug => [slug, HOSTED_SITE_FALLBACKS[slug]] as const))( - '%s has chrome, a home page, and a nav where every entry resolves', - (_slug, site) => { - const chrome = siteChromeFor(site, null); - const pages = sitePagesFor(site, null); - - expect(chrome).not.toBeNull(); - expect(pages.length).toBeGreaterThan(0); - expect(pages.some(page => page.path === '')).toBe(true); - - // Unique paths, or two nav entries fight over one URL. - const paths = pages.map(page => page.path); - expect(new Set(paths).size).toBe(paths.length); - - for (const page of pages) { - // The builders return fresh objects per call, so compare by value. - expect(sitePageAt(sitePagesFor(site, null), page.path)).toEqual(page); - expect(page.title.length).toBeGreaterThan(0); - expect(page.sections.length).toBeGreaterThan(0); - } - } - ); - - it('returns null for a path no page claims, so the route can 404', () => { - expect(sitePageAt(sitePagesFor(site, null), 'not-a-page')).toBeNull(); - }); -}); - -describe('substrata.orangecat.ch — the site is the profile, not a copy of it', () => { - const pages = sitePagesFor(site, null); - const text = JSON.stringify(pages); - - it('takes its name and tagline from the profile config', () => { - const chrome = siteChromeFor(site); - expect(chrome?.name).toBe(COMPANY.name); - expect(chrome?.tagline).toBe(COMPANY.tagline); - }); - - it('renders every material under coverage, from the same config the profile uses', () => { - for (const material of MATERIALS) { - expect(text).toContain(material.title); - } - }); - - it('renders every producer in the coverage universe', () => { - for (const entry of COVERAGE) { - for (const producer of entry.producers) { - expect(text).toContain(producer.name); - } - } - }); - - it('states the mandate the profile states', () => { - for (const curve of MANDATE_CURVES) { - expect(text).toContain(curve.label); - } - }); - - it('reports coverage honestly — unsourced rows read as leads, not findings', () => { - const mapPage = sitePageAt(sitePagesFor(site, null), 'map'); - const rows = JSON.stringify(mapPage); - const { sourced, total } = coverageProgress(); - - // Every row the config has not sourced must be labelled as unverified on - // the public page. This is the test that stops the website becoming the - // place where the caveat quietly gets dropped. - if (sourced < total) { - expect(rows).toContain('Unverified lead'); - } - - // And the meter must carry the same two numbers, so the picture and the - // table can never disagree about how much is actually done. - const meter = mapPage!.sections.find(section => section.kind === 'meter'); - expect(meter).toMatchObject({ value: sourced, of: total }); - }); - - it('gives the map a jump index whose every anchor lands on a real table', () => { - const mapPage = sitePageAt(sitePagesFor(site, null), 'map')!; - const index = mapPage.sections.find(section => section.kind === 'index'); - expect(index).toBeDefined(); - - const anchors = new Set( - mapPage.sections.flatMap(section => - section.kind === 'table' && section.anchor ? [section.anchor] : [] - ) - ); - const entries = index!.kind === 'index' ? index.entries : []; - expect(entries.length).toBe(COVERAGE.length); - for (const entry of entries) { - expect(anchors.has(entry.anchor)).toBe(true); - } - }); - - it('opens the home page with a hero, and never doubles it with a title block', () => { - const home = sitePageAt(sitePagesFor(site, null), '')!; - expect(home.sections[0].kind).toBe('hero'); - expect(pageRendersOwnHeader(home)).toBe(true); - - // Inner pages take the standard header instead. - expect(pageRendersOwnHeader(sitePageAt(sitePagesFor(site, null), 'map')!)).toBe(false); - }); - - it('has no desk page, because there is no desk', () => { - expect(sitePageAt(sitePagesFor(site, null), 'desk')).toBeNull(); - const navLabels = sitePagesFor(site, null).map(page => page.navLabel); - expect(navLabels).not.toContain('The desk'); - }); - - it('carries the non-material chokepoints, so the site shows the whole universe', () => { - const page = sitePageAt(sitePagesFor(site, null), 'chokepoints'); - expect(page).not.toBeNull(); - const text = JSON.stringify(page); - for (const point of CHOKEPOINTS) { - expect(text).toContain(point.name); - } - }); - - it('says nowhere that the firm trades, quotes or takes a position', () => { - // The whole site, not one page: if a price or an invitation to deal ever - // reappears anywhere, this is what catches it before a reader does. - const everything = JSON.stringify(sitePagesFor(site, null)); - for (const phrase of ['RFQ', 'per kg', 'Indicative CHF', 'Settlement in Bitcoin']) { - expect(`${phrase}: ${everything.includes(phrase)}`).toBe(`${phrase}: false`); - } - expect(everything).toContain('no trading desk'); - }); -}); diff --git a/__tests__/unit/config/site-profile.test.ts b/__tests__/unit/config/site-profile.test.ts deleted file mode 100644 index 76b6bfade..000000000 --- a/__tests__/unit/config/site-profile.test.ts +++ /dev/null @@ -1,101 +0,0 @@ -/** - * The default website is the product claim, so these tests hold the claim - * rather than the implementation. - * - * The claim is: a group that filled in a profile already has a website, and - * turning it on costs one switch and no code. Two things have to be true for - * that to survive contact with real profiles — it must render something decent - * from the little a profile guarantees (a name), and it must never invent - * anything the group did not say. A default site that prints an empty "Support" - * heading is advertising a capability nobody set up. - */ - -import { profileSiteChrome, profileSitePages, type SiteProfile } from '@/config/site-profile'; -import { pageRendersOwnHeader, sitePageAt } from '@/config/site-content'; - -function profile(overrides: Partial = {}): SiteProfile { - return { - slug: 'acme', - name: 'Acme Cooperative', - description: null, - label: null, - tags: [], - bitcoinAddress: null, - lightningAddress: null, - canonicalHost: 'acme.orangecat.ch', - ...overrides, - }; -} - -describe('the default site renders from a profile alone', () => { - it('produces a home page from nothing but a name', () => { - const pages = profileSitePages(profile()); - - expect(pages).toHaveLength(1); - expect(pages[0].path).toBe(''); - expect(pages[0].title).toBe('Acme Cooperative'); - expect(pageRendersOwnHeader(pages[0])).toBe(true); - expect(sitePageAt(pages, '')).toBe(pages[0]); - }); - - it('splits the description into a hero lead and an About section', () => { - const pages = profileSitePages( - profile({ description: 'We repair things.\n\nFounded 2019.\n\nIn Zürich.' }) - ); - const [hero, about] = pages[0].sections; - - expect(hero).toMatchObject({ kind: 'hero', lead: ['We repair things.'] }); - expect(about).toMatchObject({ - kind: 'prose', - heading: 'About', - paragraphs: ['Founded 2019.', 'In Zürich.'], - }); - }); - - it('never prints the same paragraph in both the hero and the prose', () => { - const pages = profileSitePages(profile({ description: 'One line only.' })); - const kinds = pages[0].sections.map(section => section.kind); - - expect(kinds).toEqual(['hero']); - }); - - it('omits Support entirely when there is nothing to pay to', () => { - const sections = profileSitePages(profile()).flatMap(page => page.sections); - - expect(sections.some(section => 'heading' in section && section.heading === 'Support')).toBe( - false - ); - }); - - it('shows Support only for the addresses the group actually set', () => { - const sections = profileSitePages(profile({ lightningAddress: 'acme@getalby.com' })).flatMap( - page => page.sections - ); - const support = sections.find( - (section): section is Extract => - section.kind === 'definitions' - ); - - expect(support?.items).toEqual([{ term: 'Lightning', detail: 'acme@getalby.com' }]); - }); - - it('gives a one-page site no nav, because a single "Home" link is furniture', () => { - expect(profileSitePages(profile()).every(page => page.navLabel === undefined)).toBe(true); - }); - - it('takes a masthead tagline from the first sentence, not the whole description', () => { - const chrome = profileSiteChrome( - profile({ description: 'We repair things. We have since 2019. Ask us anything.' }) - ); - - expect(chrome.name).toBe('Acme Cooperative'); - expect(chrome.tagline).toBe('We repair things.'); - }); - - it('survives a profile with no description at all', () => { - const chrome = profileSiteChrome(profile()); - - expect(chrome.name).toBe('Acme Cooperative'); - expect(chrome.tagline).toBe(''); - }); -}); diff --git a/__tests__/unit/config/substrata-acting.test.ts b/__tests__/unit/config/substrata-acting.test.ts deleted file mode 100644 index e9b85ca07..000000000 --- a/__tests__/unit/config/substrata-acting.test.ts +++ /dev/null @@ -1,127 +0,0 @@ -/** - * The regulatory line, held by tests rather than by good intentions. - * - * This is the part of the site with real legal exposure. Substrata is not - * registered anywhere, so three things have to stay true, and all three are the - * kind that erode quietly: a helpful sentence gets personalised, a partner list - * appears because somebody offered a fee, or the page starts telling people - * what to do because that is what readers keep asking for. - * - * Nothing here checks taste. Each test corresponds to a specific way a - * publisher becomes a regulated intermediary without noticing. - */ - -import { - ACTING_LIMITS, - ACTION_ROUTES, - INVESTMENT_THESIS, - READINESS, - READINESS_STATUS_LABEL, - readinessProgress, -} from '@/config/substrata-acting'; -import { HOSTED_SITE_FALLBACKS } from '@/config/hosted-site'; -import { sitePageAt, sitePagesFor } from '@/config/site-content'; - -const site = HOSTED_SITE_FALLBACKS.substrata; -const actingPage = sitePageAt(sitePagesFor(site, null), 'acting'); -const everything = JSON.stringify(sitePagesFor(site, null)); - -describe('Substrata — the limits are stated, not assumed', () => { - it('says on the page what the firm does and does not do', () => { - const text = JSON.stringify(actingPage); - expect(text).toContain('does not manage money'); - expect(text).toContain('paid nothing by any participant'); - }); - - it('keeps the unpaid rule, which is what makes the directory worth reading', () => { - // A directory somebody bought their way into is an advertisement wearing a - // table's clothes. This sentence is the difference. - expect(ACTING_LIMITS.join(' ')).toContain('paid nothing by any participant'); - expect(ACTING_LIMITS.join(' ')).toContain('before the arrangement starts'); - }); -}); - -describe('Substrata — routes describe markets, they do not recommend', () => { - it('gives every route a provider category, a limitation, and questions to ask', () => { - for (const route of ACTION_ROUTES) { - expect(route.providedBy.length).toBeGreaterThan(0); - // The omission is the real risk on a page like this: a reader taking a - // commodity ETF as exposure to a seven-nines grade has been misled by - // what nobody said. - expect(route.doesNotGive.length).toBeGreaterThan(0); - expect(route.ask.length).toBeGreaterThan(0); - } - }); - - it('names no individual firm as a route provider', () => { - for (const route of ACTION_ROUTES) { - expect(route.providedBy).not.toMatch(/\b(Ltd|AG|GmbH|Inc|LLC|PLC|S\.A\.)\b/); - } - }); - - it('uses no directive or personalised language anywhere on the site', () => { - // Impersonal and general is what keeps publishing on the publishing side - // of the line. These phrasings are how that slips. - for (const phrase of [ - 'we recommend', - 'you should buy', - 'we advise', - 'best investment', - 'guaranteed', - 'risk-free', - ]) { - expect(`${phrase}: ${everything.toLowerCase().includes(phrase)}`).toBe(`${phrase}: false`); - } - }); -}); - -describe('Substrata — the thesis is scoreable', () => { - it('gives every claim a falsifier, or it is a slogan', () => { - expect(INVESTMENT_THESIS.length).toBeGreaterThanOrEqual(4); - for (const claim of INVESTMENT_THESIS) { - expect(claim.claim.length).toBeGreaterThan(0); - expect(claim.detail.length).toBeGreaterThan(0); - expect(claim.falsifier.length).toBeGreaterThan(0); - } - }); - - it('publishes every claim and its falsifier on the site', () => { - const thesis = JSON.stringify(sitePageAt(sitePagesFor(site, null), 'thesis')); - for (const claim of INVESTMENT_THESIS) { - expect(thesis).toContain(claim.claim); - expect(thesis).toContain(claim.falsifier); - } - }); -}); - -describe('Substrata — the readiness ledger cannot flatter itself', () => { - it('uses only known statuses, each with a label the site can print', () => { - for (const item of READINESS) { - expect(Object.keys(READINESS_STATUS_LABEL)).toContain(item.status); - expect(item.detail.length).toBeGreaterThan(0); - } - }); - - it('counts what is actually done, not what is planned', () => { - const progress = readinessProgress(); - expect(progress.total).toBe(READINESS.length); - expect(progress.done).toBe(READINESS.filter(item => item.status === 'done').length); - expect(progress.done).toBeLessThan(progress.total); - }); - - it('still lists the licence as outstanding — the whole reason for this page', () => { - const licence = READINESS.find(item => item.id === 'licence'); - expect(licence).toBeDefined(); - expect(licence!.status).not.toBe('done'); - }); - - it('shows the ledger and the meter on the site with matching numbers', () => { - const text = JSON.stringify(actingPage); - const { done, total } = readinessProgress(); - const meter = actingPage!.sections.find(section => section.kind === 'meter'); - expect(meter).toMatchObject({ value: done, of: total }); - for (const item of READINESS) { - expect(text).toContain(item.requirement); - } - }); -}); diff --git a/__tests__/unit/config/substrata-participants.test.ts b/__tests__/unit/config/substrata-participants.test.ts deleted file mode 100644 index caab5d3b8..000000000 --- a/__tests__/unit/config/substrata-participants.test.ts +++ /dev/null @@ -1,127 +0,0 @@ -/** - * The participant directory is only research because of its last column. - * - * A list of everyone in a supply chain is a phone book. The scarcity grade is - * what turns it into a claim — and the claim is only meaningful if the grades - * are actually discriminating. A directory on which everything is a chokepoint - * has graded nothing, and that failure mode is invisible by inspection once the - * list is a hundred rows long. These tests are what notice it. - */ - -import { - CHAIN_LAYERS, - PARTICIPANTS, - SCARCITY_DETAIL, - SCARCITY_LABEL, - bindingParticipants, - participantProgress, - participantsInLayer, -} from '@/config/substrata-participants'; -import { MANDATE_CURVES } from '@/config/substrata'; -import { HOSTED_SITE_FALLBACKS } from '@/config/hosted-site'; -import { sitePageAt, sitePagesFor } from '@/config/site-content'; - -const site = HOSTED_SITE_FALLBACKS.substrata; -const page = sitePageAt(sitePagesFor(site, null), 'participants'); - -describe('the directory is well formed', () => { - it('gives every participant a layer that exists and a curve behind it', () => { - const layerIds = CHAIN_LAYERS.map(layer => layer.id); - const curveIds = MANDATE_CURVES.map(curve => curve.id); - for (const item of PARTICIPANTS) { - expect(layerIds).toContain(item.layer); - expect(item.role.length).toBeGreaterThan(0); - expect(item.why.length).toBeGreaterThan(0); - expect(item.jurisdictions.length).toBeGreaterThan(0); - for (const code of item.jurisdictions) { - expect(code).toMatch(/^[A-Z]{2}$/); - } - } - for (const layer of CHAIN_LAYERS) { - expect(curveIds).toContain(layer.curve); - } - }); - - it('claims nothing it has not sourced — no revenue, capacity or share field', () => { - const allowed = ['jurisdictions', 'layer', 'name', 'role', 'scarcity', 'source', 'why'].sort(); - for (const item of PARTICIPANTS) { - expect(Object.keys(item).sort()).toEqual(allowed); - } - }); - - it('leaves no layer of the chain empty', () => { - for (const layer of CHAIN_LAYERS) { - expect(participantsInLayer(layer.id).length).toBeGreaterThan(0); - } - }); - - it('does not list the same participant twice in one layer', () => { - for (const layer of CHAIN_LAYERS) { - const names = participantsInLayer(layer.id).map(item => item.name); - expect(new Set(names).size).toBe(names.length); - } - }); -}); - -describe('the grades actually discriminate', () => { - it('uses all three grades, or it has graded nothing', () => { - const progress = participantProgress(); - expect(progress.chokepoints).toBeGreaterThan(0); - expect(progress.concentrated).toBeGreaterThan(0); - // The one people skip. Naming participants that are NOT constraints is - // what gives "chokepoint" its meaning; without it the map is flattery. - expect(progress.competitive).toBeGreaterThan(0); - }); - - it('keeps chokepoints a minority — a chain where everything binds binds nowhere', () => { - const progress = participantProgress(); - expect(progress.chokepoints).toBeLessThan(progress.total / 2); - }); - - it('adds up: every participant carries exactly one of the three grades', () => { - const progress = participantProgress(); - expect(progress.chokepoints + progress.concentrated + progress.competitive).toBe( - progress.total - ); - expect(bindingParticipants().length).toBe(progress.chokepoints); - }); - - it('gives every grade a label and an explanation the site can print', () => { - for (const grade of ['chokepoint', 'concentrated', 'competitive'] as const) { - expect(SCARCITY_LABEL[grade]).toBeTruthy(); - expect(SCARCITY_DETAIL[grade].length).toBeGreaterThan(0); - } - }); -}); - -describe('the site renders the whole directory', () => { - const text = JSON.stringify(page); - - it('publishes every participant', () => { - expect(page).not.toBeNull(); - for (const item of PARTICIPANTS) { - expect(text).toContain(item.name); - } - }); - - it('spells out why each binding participant binds', () => { - for (const item of bindingParticipants()) { - expect(text).toContain(item.why); - } - }); - - it('offers a layer index whose every anchor lands on a real table', () => { - const anchors = new Set( - page!.sections.flatMap(section => - section.kind === 'table' && section.anchor ? [section.anchor] : [] - ) - ); - const index = page!.sections.find(section => section.kind === 'index'); - expect(index).toBeDefined(); - const entries = index!.kind === 'index' ? index.entries : []; - expect(entries.length).toBe(CHAIN_LAYERS.length); - for (const entry of entries) { - expect(anchors.has(entry.anchor)).toBe(true); - } - }); -}); diff --git a/__tests__/unit/config/substrata.test.ts b/__tests__/unit/config/substrata.test.ts deleted file mode 100644 index eedc86e91..000000000 --- a/__tests__/unit/config/substrata.test.ts +++ /dev/null @@ -1,284 +0,0 @@ -/** - * The Substrate profile has to survive contact with the live schema, and the - * firm has to survive contact with its own mandate. - * - * The config is written once and pushed to production by a seed that talks - * straight to PostgREST, so a bad enum value or a price of zero doesn't fail in - * CI — it fails halfway through a seed run against the real database, with some - * rows written and some not. The first half of these tests is the cheap version - * of that discovery: the payloads held against the CHECK constraints in - * supabase/migrations/20240101000001_baseline_public_schema.sql and against the - * group label / feature / governance registries. - * - * The second half holds the firm to its own rules — every listing traces to a - * curve, every material on the desk has a coverage entry, and no producer row - * is presented as a finding before someone has sourced it. Those are the - * claims the profile makes in public; a test is what keeps them true. - */ - -import { readFileSync } from 'node:fs'; -import { join } from 'node:path'; -import { - CHOKEPOINT_TEST, - COMPANY, - COVERAGE_AREAS, - DISCLOSURE, - GROUP_FEATURE_KEYS, - GROUP_PAYLOAD, - MANDATE_CURVES, - MATERIALS, - NODE_TYPES, - PHASES, - SCOPE, - areaFor, -} from '@/config/substrata'; -import { - CHOKEPOINTS, - COVERAGE, - NODE_TYPE_LABEL, - PRODUCER_ROLES, - chokepointProgress, - coverageProgress, - materialsFor, -} from '@/config/substrata-coverage'; -import { GROUP_LABELS } from '@/config/group-labels'; -import { GROUP_FEATURES } from '@/config/group-features'; -import { SITE_FEATURE_KEY } from '@/config/hosted-site'; -import { GOVERNANCE_PRESETS } from '@/config/governance-presets'; -import { isReservedUsername } from '@/config/usernames'; - -// Mirror of the live CHECK constraint on public.groups. The user_products -// mirrors that used to sit here were dropped: Substrata sells nothing and the -// seed writes no product rows, so they asserted against a table this config -// never touches. -const GROUP_VISIBILITIES = ['public', 'members_only', 'private']; - -describe('Substrata — group profile payload', () => { - it('uses a label, governance preset and visibility the platform knows', () => { - expect(Object.keys(GROUP_LABELS)).toContain(GROUP_PAYLOAD.label); - expect(Object.keys(GOVERNANCE_PRESETS)).toContain(GROUP_PAYLOAD.governance_preset); - expect(GROUP_VISIBILITIES).toContain(GROUP_PAYLOAD.visibility); - }); - - it('is publicly readable — a research firm that gates its research is not one', () => { - expect(GROUP_PAYLOAD.is_public).toBe(true); - expect(GROUP_PAYLOAD.visibility).toBe('public'); - }); - - it('carries a slug that is URL-safe and not a reserved handle', () => { - expect(GROUP_PAYLOAD.slug).toBe(COMPANY.slug); - expect(COMPANY.slug).toMatch(/^[a-z0-9]+(?:-[a-z0-9]+)*$/); - expect(isReservedUsername(COMPANY.slug)).toBe(false); - }); - - it('enables only features that exist and whose required fields are supplied', () => { - for (const key of GROUP_FEATURE_KEYS) { - const feature = GROUP_FEATURES[key as keyof typeof GROUP_FEATURES]; - expect(feature).toBeDefined(); - - // treasury requires bitcoin_address; the payload has no wallet fields, so - // enabling it here would produce a group advertising a treasury it lacks. - const required: string[] = (feature as { requiresFields?: string[] }).requiresFields ?? []; - for (const field of required) { - expect(Object.keys(GROUP_PAYLOAD)).toContain(field); - } - } - }); -}); - -describe('Substrata — there is no trading desk, and nothing may imply one', () => { - // The firm publishes research and sells nothing. Standing a regulated book up - // is a long road through licensing, so every price, unit, lot size and - // invitation to deal was removed. These tests are what stop one coming back: - // a config that quietly regrows a price field is a config that puts an offer - // in front of a reader nobody is licensed to sell to. - const config = readFileSync(join('src', 'config', 'substrata.ts'), 'utf8'); - const siteBuilder = readFileSync(join('src', 'config', 'site-substrata.ts'), 'utf8'); - - it('carries no price, unit or lot-size field on a material', () => { - for (const material of MATERIALS) { - expect(Object.keys(material).sort()).toEqual(['area', 'spec', 'tags', 'title', 'why']); - } - }); - - it('never uses dealing language anywhere the reader can see it', () => { - for (const [name, source] of [ - ['substrata.ts', config], - ['site-substrata.ts', siteBuilder], - ] as const) { - for (const phrase of ['RFQ', 'indicativePrice', 'Settlement in', 'per wafer']) { - expect(`${name} contains "${phrase}": ${source.includes(phrase)}`).toBe( - `${name} contains "${phrase}": false` - ); - } - } - }); - - it('states plainly what the firm does not do', () => { - expect(SCOPE.today.length).toBeGreaterThanOrEqual(3); - expect(SCOPE.today.join(' ')).toContain('do not trade'); - expect(DISCLOSURE.today).toContain('holds no position'); - }); - - it('advertises no marketplace, because there is nothing to sell', () => { - // Asserted as "no COMMERCE feature", not "no features". The list was empty - // when this was written and the emptiness stood in for the rule; it does not - // any more, because publishing a website is a feature and sells nothing. - // What must never appear here is a way to take money for research. - for (const commerce of ['marketplace', 'treasury', 'shared_wallet']) { - expect(GROUP_FEATURE_KEYS).not.toContain(commerce); - } - }); - - it('publishes a website, which is the feature row substrata.orangecat.ch reads', () => { - expect(GROUP_FEATURE_KEYS).toContain(SITE_FEATURE_KEY); - }); - - it('keeps a desk only as a future phase, never as a running one', () => { - const desk = PHASES.find(phase => phase.id === 'desk'); - expect(desk).toBeDefined(); - expect(desk!.status).toBe('not-started'); - }); -}); - -describe('Substrata — the mandate is enforced, not just stated', () => { - it('every desk maps to one of the three curves', () => { - const curveIds = MANDATE_CURVES.map(curve => curve.id); - for (const area of COVERAGE_AREAS) { - expect(curveIds).toContain(area.curve); - } - }); - - it('every material sits in a coverage area, and so on a curve', () => { - for (const material of MATERIALS) { - const area = areaFor(material); - expect(area).toBeDefined(); - expect(area.id).toBe(material.area); - } - }); - - it('no coverage area is left empty — an empty area is scope creep on paper', () => { - for (const area of COVERAGE_AREAS) { - expect(MATERIALS.some(material => material.area === area.id)).toBe(true); - } - }); - - it('every non-material chokepoint has a known node type and a real curve', () => { - const curveIds = MANDATE_CURVES.map(curve => curve.id); - const nodeIds = NODE_TYPES.map(node => node.id); - for (const point of CHOKEPOINTS) { - expect(curveIds).toContain(point.curve); - expect(nodeIds).toContain(point.type); - expect(NODE_TYPE_LABEL[point.type]).toBeTruthy(); - expect(point.why.length).toBeGreaterThan(0); - } - }); - - it('proves the node taxonomy is used, not merely declared', () => { - // It sat in the config for a while with nothing but materials in the - // universe. If this ever drops back to one kind, "a node can be anything" - // has become a claim the coverage does not support. - expect(new Set(CHOKEPOINTS.map(point => point.type)).size).toBeGreaterThanOrEqual(3); - expect(new Set(CHOKEPOINTS.map(point => point.curve)).size).toBe(MANDATE_CURVES.length); - }); - - it('holds non-material chokepoints to the same claim limits as producers', () => { - const allowed = ['curve', 'jurisdictions', 'name', 'source', 'type', 'why'].sort(); - for (const point of CHOKEPOINTS) { - expect(Object.keys(point).sort()).toEqual(allowed); - } - const { sourced, total } = chokepointProgress(); - expect(total).toBe(CHOKEPOINTS.length); - expect(sourced).toBeLessThanOrEqual(total); - }); - - it('keeps a chokepoint screen and a node taxonomy, which is what makes it extensible', () => { - expect(CHOKEPOINT_TEST.length).toBeGreaterThanOrEqual(4); - // A universe that only admits materials cannot reach robotics by traversal. - expect(NODE_TYPES.map(node => node.id)).toEqual( - expect.arrayContaining(['material', 'company', 'person']) - ); - }); - - it('runs at least one phase, and never the desk', () => { - const active = PHASES.filter(phase => phase.status === 'active'); - expect(active.length).toBeGreaterThanOrEqual(1); - expect(active.some(phase => phase.id === 'desk')).toBe(false); - }); - - it('commits to disclosure before any position exists, which is the only time it is credible', () => { - expect(DISCLOSURE.rules.length).toBeGreaterThanOrEqual(3); - }); -}); - -describe('Substrata — Phase 1 coverage universe', () => { - it('owes a coverage entry to every material on the desk', () => { - expect(coverageProgress().uncoveredMaterials).toEqual([]); - }); - - it('covers no material outside the list — coverage follows the universe', () => { - const covered = MATERIALS.map(material => material.title); - for (const entry of COVERAGE) { - expect(covered).toContain(entry.material); - } - }); - - it('names more than one producer per material, or it is not a map', () => { - for (const entry of COVERAGE) { - expect(entry.producers.length).toBeGreaterThan(1); - expect(entry.thesis.length).toBeGreaterThan(0); - } - }); - - it('uses only known roles and plausible ISO-3166 jurisdictions', () => { - const roles = PRODUCER_ROLES.map(role => role.id); - for (const entry of COVERAGE) { - for (const producer of entry.producers) { - expect(roles).toContain(producer.role); - expect(producer.jurisdictions.length).toBeGreaterThan(0); - for (const jurisdiction of producer.jurisdictions) { - expect(jurisdiction).toMatch(/^[A-Z]{2}$/); - } - } - } - }); - - it('does not list the same company twice on one material', () => { - for (const entry of COVERAGE) { - const names = entry.producers.map(producer => producer.name); - expect(new Set(names).size).toBe(names.length); - } - }); - - it('presents unsourced rows as leads, never as findings', () => { - const { total, sourced } = coverageProgress(); - expect(total).toBeGreaterThan(0); - // Not an assertion that nothing is sourced — it is an assertion that the - // count is honest. Phase 1 is done when sourced === total, and this test - // is what makes that a number rather than a feeling. - expect(sourced).toBeLessThanOrEqual(total); - for (const entry of COVERAGE) { - for (const producer of entry.producers) { - expect(producer).toHaveProperty('source'); - } - } - }); - - it('carries no capacity, share or financial claim — there is no field for one', () => { - const allowed = ['name', 'jurisdictions', 'role', 'source'].sort(); - for (const entry of COVERAGE) { - for (const producer of entry.producers) { - expect(Object.keys(producer).sort()).toEqual(allowed); - } - } - }); - - it('surfaces the cross-material overlaps that make the map worth having', () => { - // A company appearing on several materials is the map earning its keep: - // one counterparty, several exposures. If this ever returns nothing for - // every company, the universe has been flattened into unrelated lists. - const everyName = COVERAGE.flatMap(entry => entry.producers.map(p => p.name)); - const overlapping = [...new Set(everyName)].filter(name => materialsFor(name).length > 1); - expect(overlapping.length).toBeGreaterThan(0); - }); -}); diff --git a/__tests__/unit/services/site-publish.test.ts b/__tests__/unit/services/site-publish.test.ts deleted file mode 100644 index 2401b6ecb..000000000 --- a/__tests__/unit/services/site-publish.test.ts +++ /dev/null @@ -1,68 +0,0 @@ -/** - * Publishing is one call, so the refusals have to be right in that one call. - * - * Every rule here prevents a site that would exist in the database and never - * answer on the internet — the worst failure mode for this feature, because the - * button said it worked. A slug that cannot be a DNS label, a slug that already - * belongs to infrastructure, and a private group whose site RLS would hide from - * every visitor. - */ - -import { publishRefusal, siteAddress, type SiteGroup } from '@/services/sites/publish'; - -function group(overrides: Partial = {}): SiteGroup { - return { id: 'uuid', name: 'Acme Cooperative', slug: 'acme', is_public: true, ...overrides }; -} - -describe('what may be published', () => { - it('publishes an ordinary public group', () => { - expect(publishRefusal(group())).toBeNull(); - }); - - it('refuses a slug that cannot be a DNS label', () => { - for (const slug of ['-acme', 'acme-', 'ac me', 'acme_co', '']) { - expect(publishRefusal(group({ slug }))).toMatch(/cannot be a hostname/); - } - }); - - /** - * The one that would be a real incident. `supabase.orangecat.ch` is the - * database; `security.orangecat.ch` under our own certificate is a phish. - */ - it('refuses a slug that already belongs to infrastructure or invites a phish', () => { - expect(publishRefusal(group({ slug: 'supabase' }))).toMatch(/reserved/); - expect(publishRefusal(group({ slug: 'security' }))).toMatch(/reserved/); - expect(publishRefusal(group({ slug: 'fleetcrown' }))).toMatch(/reserved/); - }); - - it('explains WHY a name is reserved, since the fix depends on it', () => { - expect(publishRefusal(group({ slug: 'supabase' }))).toContain('database'); - }); - - /** - * The RLS policy only exposes a site whose group is public. Publishing a - * private group would write a row that every visitor is denied. - */ - it('refuses a private group rather than publishing a site nobody can load', () => { - expect(publishRefusal(group({ is_public: false }))).toMatch(/private group/i); - }); -}); - -describe('where a site lives', () => { - it('gives the free subdomain, and a preview path that works without DNS', () => { - expect(siteAddress(group(), {})).toEqual({ - url: 'https://acme.orangecat.ch', - previewPath: '/sites/acme', - }); - }); - - it('prefers a custom domain once one is configured', () => { - expect(siteAddress(group(), { customDomain: 'acme.example' }).url).toBe('https://acme.example'); - }); - - it('ignores a malformed custom domain rather than advertising it', () => { - expect(siteAddress(group(), { customDomain: 'not a hostname' }).url).toBe( - 'https://acme.orangecat.ch' - ); - }); -}); diff --git a/deployment/caddy/hosted-sites.caddy b/deployment/caddy/hosted-sites.caddy deleted file mode 100644 index 210ce909d..000000000 --- a/deployment/caddy/hosted-sites.caddy +++ /dev/null @@ -1,43 +0,0 @@ -# Hosted sites — every customer website, on one block, forever. -# -# Installed to /etc/caddy/apps.d/hosted-sites.caddy by -# scripts/ci/install-hosted-sites-caddy.sh. Lives in the repo because it is -# configuration, and configuration that exists only on a box is configuration -# nobody can review. -# -# WHY A CATCH-ALL AND NOT A BLOCK PER SITE -# -# A block per site means every customer is an ssh session. This block is the -# last resort — Caddy matches the most specific host first, so all twenty-odd -# named blocks above still win — and it answers for everything else: -# -# .orangecat.ch the free subdomain every hosted site starts on -# anything.example a customer's own domain, the hour they point it here -# -# WHY ON-DEMAND TLS -# -# A wildcard certificate would need a DNS-01 challenge, an Infomaniak API token -# sitting on this box, and a Caddy DNS plugin — and it still would not cover a -# customer's own domain. On-demand needs none of that: Caddy asks OrangeCat -# whether a hostname is real before it orders a certificate, and OrangeCat -# already knows, because a hosted site is a row in its database. -# -# The `ask` endpoint is the safety. Every yes is an ACME order and Let's Encrypt -# rate-limits an account that fails them, so /api/internal/tls-check answers 403 -# for anything that is not a published site right now — and fails closed if it -# cannot reach the database. Without that endpoint answering 200, this block -# serves nothing at all, which is the correct behaviour for a misconfiguration. -# -# Requires in the global block of /etc/caddy/Caddyfile: -# on_demand_tls { -# ask http://127.0.0.1:4003/api/internal/tls-check -# } -https:// { - encode zstd gzip - tls { - on_demand - } - reverse_proxy 127.0.0.1:4003 { - flush_interval -1 - } -} diff --git a/deployment/reserved-hosts.txt b/deployment/reserved-hosts.txt deleted file mode 100644 index 72c7db6f9..000000000 --- a/deployment/reserved-hosts.txt +++ /dev/null @@ -1,34 +0,0 @@ -# Subdomain labels currently served on bitbaum under *.orangecat.ch. -# -# Generated from the box, committed so CI can check it WITHOUT ssh: -# npm run sync:reserved-hosts -# -# Every label here must appear in RESERVED_SUBDOMAINS (src/config/sites.ts), -# because host resolution for hosted sites is positional — any non-reserved -# label on orangecat.ch is a candidate site slug. A label serving an app AND -# claimable as a site is a collision waiting to happen, and on-demand TLS -# would issue a second certificate for a name that already has one. -# -# check:reserved-hosts fails the build when this file and the code disagree. -annushka -aoz-wohnen -aoz -botsmann -bridge -camille -datacat -evig -fleetcrown -kivvi -petvity -printcraft -reparaturbonus -revamp-info -revampit -sbb -sink -solon -supabase -surf-your-life -vitareba -www diff --git a/docs/operations/hosted-sites.md b/docs/operations/hosted-sites.md deleted file mode 100644 index 31aa1d8b4..000000000 --- a/docs/operations/hosted-sites.md +++ /dev/null @@ -1,165 +0,0 @@ ---- -created_date: 2026-08-27 -last_modified_date: 2026-08-27 -last_modified_summary: First version — how a group becomes a website, the publish API, and the two one-time infrastructure steps behind it. ---- - -# Hosted sites — turning a group into a website - -`/domains` sells one sentence: _"a working site, hosted and managed at -yourname.orangecat.ch — free. Move to your own domain when you are ready."_ -This is how that works, and what it costs. - -## Publishing a site - -**One call. No deploy, no ssh, no DNS.** - -```http -PUT /api/groups/acme/site → { published: true, url, previewPath } -DELETE /api/groups/acme/site → unpublish (keeps the configuration) -GET /api/groups/acme/site → status, address, and whether it is eligible -``` - -Admin/founder only. `PUT` is an upsert, so the same call publishes a new site -and reconfigures a live one. This is what FleetCrown and the group settings UI -call; neither knows anything about DNS or Caddy, because neither is involved. - -`GET` answers `url` even when unpublished, so the button can read _"Publish at -acme.orangecat.ch"_ rather than a bare _"Publish"_, and returns `eligible` + -`reason` so a group that can never be published (reserved slug, private group) -says so before anyone clicks. - -Underneath, that call is one row: - -```sql -INSERT INTO group_features (group_id, feature_key, enabled, enabled_by) -VALUES ('', 'site', true, ''); -``` - -The site is live at `.orangecat.ch` within a minute — that minute is -`SITE_CACHE_TTL_SECONDS` in `src/services/sites/registry.ts`. Setting -`enabled = false` unpublishes it just as fast. - -The pages are generated from the group's own profile (`src/config/site-profile.ts`): -its name, description, label, tags, and payment addresses if it has them. There -is no page builder and no second place to type the description, which is the -point — if the profile is good, the website is good. - -### Optional configuration - -`group_features.config` (jsonb), all fields optional: - -| Field | Default | Meaning | -| -------------- | ---------------- | ------------------------------------------------- | -| `title` | the group's name | browser tab / OG title | -| `customDomain` | `null` | canonical hostname once the owner points DNS here | -| `aliasHosts` | `[]` | extra hostnames answered but never advertised | - -Every field is validated with its own fallback, so one bad value costs that -field and never the site. - -## How a request gets there - -``` -Host: acme.orangecat.ch - │ - ├─ middleware.ts siteSlugForHost() — pure, no database. - │ "acme" is one non-reserved label → rewrite. - │ (Rewrite, not redirect: the URL bar keeps saying acme.) - │ - ├─ /sites/acme siteBySlug() — reads as ANON, so RLS decides - │ "published". No row → 404. - │ - └─ site-profile.ts the group's profile, rendered as pages. -``` - -The split matters: middleware runs on **every request to the whole app**, so it -may never query. It answers "is this shaped like a site?" An unclaimed slug -rewrites and 404s, which is the safe direction. - -`/sites/` also works on any host, which is how a site is previewed before -its DNS exists. - -## Reserved subdomains - -Host resolution is positional, so `RESERVED_SUBDOMAINS` (`src/config/sites.ts`) -is load-bearing twice: - -- **Infrastructure** — `supabase`, `fleetcrown`, `bridge`, and 19 others already - serve something on this box. -- **Impersonation** — `security.orangecat.ch` under our own certificate is a - phish, not a website. - -The infrastructure half is **generated, not remembered**. The first hand-written -version held 7 labels while the box was serving 22. - -```bash -npm run sync:reserved-hosts # regenerate from Caddy (needs ssh) -npm run check:reserved-hosts # in `verify`; fails if the two disagree -``` - -**Deploying a new app on bitbaum will fail the build until you run the sync.** -That is deliberate: the alternative is a hostname that serves an app _and_ is -claimable as a customer's site. - -## The two one-time infrastructure steps - -Both are done once, ever. After them no site needs infrastructure work again. - -### 1. Caddy — on-demand TLS - -```bash -bash scripts/ci/install-hosted-sites-caddy.sh -``` - -Installs `deployment/caddy/hosted-sites.caddy` as a catch-all block and adds -`on_demand_tls { ask ... }` to the global block. The script refuses to run until -`/api/internal/tls-check` answers 200, backs up the Caddyfile, validates before -reloading, and re-checks the neighbouring hosts afterwards — one bad Caddyfile -takes down every app on the box. - -A wildcard certificate was the alternative: DNS-01, an Infomaniak API token -living on the box, a Caddy DNS plugin — and it still would not have covered a -customer's own domain. - -### 2. DNS — the wildcard record - -At Infomaniak, on `orangecat.ch`: - -``` -* A 167.233.22.31 -``` - -**This is the only step that cannot be automated from a developer machine** — -no Infomaniak API credentials exist here. Existing named records keep -precedence, so nothing already live changes. - -A customer's own domain needs no record from us at all: they point it here, and -Caddy asks `/api/internal/tls-check` whether it is real. - -## Bespoke sites - -A site whose content is genuinely not profile-shaped gets a builder. Substrata -is the one example — a research corpus of tables, meters and a coverage ledger, -which no generic renderer should try to guess. - -Adding one means an entry in `BESPOKE_BUILDERS` and a function returning -`SitePage[]` from a closed set of section shapes (`src/config/site-content.ts`). -**This is the exception.** If you are reaching for it for an ordinary customer, -the profile builder is what should be improved instead — it costs zero lines per -customer, and a fix there reaches every site at once. - -## Verifying a site - -```bash -curl -sI https://acme.orangecat.ch/ | head -1 -curl -s https://acme.orangecat.ch/ | grep -o '[^<]*' - -# Would Caddy issue a certificate for this host? -curl -s -o /dev/null -w '%{http_code}\n' \ - 'http://127.0.0.1:4003/api/internal/tls-check?domain=acme.orangecat.ch' -``` - -`403` from the last one means no published site answers on that hostname — check -`group_features.enabled`, that the group is `is_public`, and that the label is -not reserved. diff --git a/package.json b/package.json index 9b6a66f26..f3ba6861a 100644 --- a/package.json +++ b/package.json @@ -39,7 +39,7 @@ "check:rpc-exists": "node scripts/check-rpc-exists.mjs", "check:ai-models": "node scripts/check-ai-models.mjs", "check:mdx": "node scripts/check-mdx.mjs", - "verify": "npm run ci:docs && npm run check:accent-ink && npm run type-check && npm run type-check:scripts && npm run check:sizes && npm run audit:routes && npm run lint && npm run check:duplication && npm run check:dead-fields && npm run check:migration-versions && npm run check:schema-columns && npm run check:currency-units && npm run check:reserved-hosts && npm run check:rpc-exists && npm run check:mdx && npm run test:unit -- --watchAll=false", + "verify": "npm run ci:docs && npm run check:accent-ink && npm run type-check && npm run type-check:scripts && npm run check:sizes && npm run audit:routes && npm run lint && npm run check:duplication && npm run check:dead-fields && npm run check:migration-versions && npm run check:schema-columns && npm run check:currency-units && npm run check:rpc-exists && npm run check:mdx && npm run test:unit -- --watchAll=false", "audit:schema": "node scripts/db/audit-schema-drift.mjs", "audit:routes": "node scripts/audit-routes.mjs", "gen:types": "bash scripts/db/gen-types.sh", @@ -107,8 +107,6 @@ "db:audit": "node scripts/db-audit.mjs", "eval:voice": "node scripts/eval-voice-routing.mjs", "check:migration-versions": "node scripts/check-migration-versions.mjs", - "check:reserved-hosts": "bash scripts/ci/check-reserved-hosts.sh", - "sync:reserved-hosts": "bash scripts/ci/sync-reserved-hosts.sh", "type-check:scripts": "tsc --noEmit --skipLibCheck -p tsconfig.scripts.json" }, "dependencies": { diff --git a/scripts/ci/check-reserved-hosts.sh b/scripts/ci/check-reserved-hosts.sh deleted file mode 100755 index 392341f07..000000000 --- a/scripts/ci/check-reserved-hosts.sh +++ /dev/null @@ -1,46 +0,0 @@ -#!/usr/bin/env bash -# -# Every subdomain already serving traffic on bitbaum must be reserved in code. -# -# WHY THIS GATE EXISTS -# -# Hosted-site resolution is positional: any non-reserved label on orangecat.ch -# is a candidate site slug (src/config/sites.ts). That is what makes a new -# customer cost zero deploys, and it is also what makes RESERVED_SUBDOMAINS -# load-bearing. A label that serves an app AND is claimable as a site is a -# collision — and with on-demand TLS it is also a second certificate order for a -# hostname that already has one. -# -# The first version of that list was written by hand and was missing fourteen of -# the twenty-two hosts that were already live. So it is no longer remembered: it -# is generated from the box (`npm run sync:reserved-hosts`), committed, and -# checked here. Nobody has to notice. -# -# Runs with no network and no ssh, which is why the manifest is committed. -set -euo pipefail - -cd "$(dirname "$0")/../.." - -MANIFEST="deployment/reserved-hosts.txt" -SOURCE="src/config/sites.ts" - -[ -f "$MANIFEST" ] || { echo "✗ missing $MANIFEST"; exit 1; } -[ -f "$SOURCE" ] || { echo "✗ missing $SOURCE"; exit 1; } - -# Labels the code reserves: the `label: '...'` field of each RESERVED_SUBDOMAINS entry. -reserved=$(grep -oE "label: '[a-z0-9-]+'" "$SOURCE" | sed "s/label: '//; s/'//" | sort -u) -live=$(grep -vE '^\s*(#|$)' "$MANIFEST" | tr -d ' \t' | sort -u) - -missing=$(comm -23 <(echo "$live") <(echo "$reserved")) - -if [ -n "$missing" ]; then - echo "✗ These hosts serve traffic on bitbaum but are NOT reserved in $SOURCE:" - echo "$missing" | sed 's/^/ /' - echo - echo " Each one is claimable as a hosted-site slug right now. Add them to" - echo " RESERVED_SUBDOMAINS, or if the host is gone, refresh the manifest:" - echo " npm run sync:reserved-hosts" - exit 1 -fi - -echo "✓ reserved subdomains cover all $(echo "$live" | wc -l | tr -d ' ') live hosts" diff --git a/scripts/ci/install-hosted-sites-caddy.sh b/scripts/ci/install-hosted-sites-caddy.sh deleted file mode 100755 index 69af3bf2a..000000000 --- a/scripts/ci/install-hosted-sites-caddy.sh +++ /dev/null @@ -1,64 +0,0 @@ -#!/usr/bin/env bash -# -# Install the hosted-sites Caddy block on bitbaum. Idempotent, and validates -# before it reloads — a bad Caddyfile takes down all twenty-odd apps on the box, -# so this never writes and hopes. -# -# Run AFTER the code carrying /api/internal/tls-check is deployed. Without that -# endpoint every certificate request is denied, which is safe but inert. -# -# bash scripts/ci/install-hosted-sites-caddy.sh -set -euo pipefail - -cd "$(dirname "$0")/../.." -BOX="${BITBAUM_SSH:-root@167.233.22.31}" -FRAGMENT="deployment/caddy/hosted-sites.caddy" -ASK_URL="http://127.0.0.1:4003/api/internal/tls-check" - -[ -f "$FRAGMENT" ] || { echo "✗ missing $FRAGMENT"; exit 1; } - -echo "→ checking the ask endpoint is live before enabling on-demand TLS" -if ! ssh -o BatchMode=yes "$BOX" "curl -fsS -o /dev/null -w '%{http_code}' '${ASK_URL}?domain=substrata.orangecat.ch'" | grep -q 200; then - echo "✗ ${ASK_URL} did not answer 200 for a known site." - echo " Deploy the code first — otherwise every certificate request is denied." - exit 1 -fi - -echo "→ installing" -scp -q "$FRAGMENT" "$BOX:/etc/caddy/apps.d/hosted-sites.caddy" - -ssh -o BatchMode=yes "$BOX" 'bash -s' <<'REMOTE' -set -euo pipefail -cd /etc/caddy -cp Caddyfile "Caddyfile.bak-$(date +%Y%m%d-%H%M%S)" - -# Add the global on_demand_tls option once. Global options must be in the main -# Caddyfile's first block; apps.d is imported at the end, so it cannot go there. -if ! grep -q 'on_demand_tls' Caddyfile; then - python3 - <<'PY' -import pathlib -p = pathlib.Path('/etc/caddy/Caddyfile'); s = p.read_text() -old = "\tservers {\n\t\tprotocols h1 h2\n\t}\n}" -new = ("\tservers {\n\t\tprotocols h1 h2\n\t}\n" - "\ton_demand_tls {\n" - "\t\task http://127.0.0.1:4003/api/internal/tls-check\n" - "\t}\n}") -assert old in s, "global block not in the expected shape — install by hand" -p.write_text(s.replace(old, new, 1)) -PY - echo " + on_demand_tls ask added to the global block" -else - echo " = on_demand_tls already configured" -fi - -caddy validate --config /etc/caddy/Caddyfile --adapter caddyfile >/dev/null 2>&1 \ - || { echo "✗ Caddyfile invalid — NOT reloading"; exit 1; } -systemctl reload caddy -echo " ✓ caddy reloaded" -REMOTE - -echo "→ verifying the existing hosts still answer" -for host in orangecat.ch fleetcrown.orangecat.ch supabase.orangecat.ch; do - code=$(curl -s -o /dev/null -w '%{http_code}' "https://$host/" || echo "000") - echo " $host → $code" -done diff --git a/scripts/ci/sync-reserved-hosts.sh b/scripts/ci/sync-reserved-hosts.sh deleted file mode 100755 index 08ea42e81..000000000 --- a/scripts/ci/sync-reserved-hosts.sh +++ /dev/null @@ -1,34 +0,0 @@ -#!/usr/bin/env bash -# -# Regenerate deployment/reserved-hosts.txt from what Caddy actually serves. -# -# Needs ssh to the box, which is why the RESULT is committed and the CHECK -# (check-reserved-hosts.sh) reads the committed file instead of running this. -# Run it after adding or removing an app on bitbaum. -set -euo pipefail - -cd "$(dirname "$0")/../.." -BOX="${BITBAUM_SSH:-root@167.233.22.31}" - -labels=$(ssh -o BatchMode=yes "$BOX" "caddy adapt --config /etc/caddy/Caddyfile 2>/dev/null" \ - | python3 -c " -import json,sys -d=json.load(sys.stdin); hosts=set() -for srv in d.get('apps',{}).get('http',{}).get('servers',{}).values(): - for route in srv.get('routes',[]): - for m in route.get('match',[]): - for h in m.get('host',[]): hosts.add(h) -suffix='.orangecat.ch' -for h in sorted(hosts): - if h.endswith(suffix): print(h[:-len(suffix)]) -") - -[ -n "$labels" ] || { echo "✗ Caddy returned no orangecat.ch hosts — refusing to write an empty manifest"; exit 1; } - -{ - sed -n '1,/^# check:reserved-hosts/p' deployment/reserved-hosts.txt - echo "$labels" -} > deployment/reserved-hosts.txt.new -mv deployment/reserved-hosts.txt.new deployment/reserved-hosts.txt - -echo "✓ manifest refreshed with $(echo "$labels" | wc -l | tr -d ' ') hosts" diff --git a/scripts/seed-substrata.ts b/scripts/seed-substrata.ts deleted file mode 100644 index 8dac08178..000000000 --- a/scripts/seed-substrata.ts +++ /dev/null @@ -1,208 +0,0 @@ -/** - * Seed "Substrata" — an open-source research firm covering the chokepoints - * between here and a technological singularity — as an OrangeCat group - * profile with its own actor. - * - * OrangeCat is the SSOT for economic entities. A company is a `group` (label - * 'company') that owns an `actors` row of actor_type 'group'; everything the - * company lists hangs off that actor, which is what makes a group's store work - * the same way a person's does. - * - * Idempotent: resolves the founder by actor slug at runtime, upserts the group - * by slug, the actor by group_id, and membership by its unique key. Never - * truncates. Safe to re-run. Owner-gated so it can't fire by accident. - * - * Runs from ANYWHERE that can reach supabase.orangecat.ch over HTTPS — it is - * PostgREST calls, not psql, so it needs no SSH and no tunnel. In practice - * that means a laptop with .env.local, or the box: - * - * ORANGECAT_OWNER_SEED=1 npx tsx scripts/seed-substrata.ts - * - * Requires in the environment (both already in .env.local): - * NEXT_PUBLIC_SUPABASE_URL — self-hosted Supabase URL - * SUPABASE_SERVICE_ROLE_KEY — service role (bypasses RLS for the seed) - * - * The service-role key bypasses RLS, so treat a machine that has it as - * privileged and do not paste it into a shell history you keep. - * - * Created: 2026-08-26 - */ - -import { config as loadEnv } from 'dotenv'; -import { createClient, type SupabaseClient } from '@supabase/supabase-js'; -import type { Database } from '../src/types/database'; -import { - COMPANY, - FOUNDER_ACTOR_SLUG, - GROUP_FEATURE_KEYS, - GROUP_PAYLOAD, -} from '../src/config/substrata'; - -loadEnv({ path: '.env.local' }); - -const SUPABASE_URL = process.env.NEXT_PUBLIC_SUPABASE_URL; -const SERVICE_ROLE_KEY = process.env.SUPABASE_SERVICE_ROLE_KEY; - -function die(message: string): never { - console.error(`✗ ${message}`); - process.exit(1); -} - -if (process.env.ORANGECAT_OWNER_SEED !== '1') { - die('Refusing to run without ORANGECAT_OWNER_SEED=1 (owner-gated).'); -} -if (!SUPABASE_URL || !SERVICE_ROLE_KEY) { - die('Missing NEXT_PUBLIC_SUPABASE_URL or SUPABASE_SERVICE_ROLE_KEY in the environment.'); -} - -/** - * Typed against the generated schema, not `any`. - * - * A seed is the one place where a column rename fails on the box rather than in - * CI — it runs by hand, months after the migration that broke it. Binding the - * client to `Database` moves that failure to `npm run type-check`, and it is why - * the row literals below need no casts. - */ -const admin: SupabaseClient = createClient(SUPABASE_URL, SERVICE_ROLE_KEY, { - auth: { persistSession: false, autoRefreshToken: false }, -}); - -type GroupInsert = Database['public']['Tables']['groups']['Insert']; -type ActorInsert = Database['public']['Tables']['actors']['Insert']; - -interface FounderRow { - id: string; - user_id: string; -} - -/** The user behind FOUNDER_ACTOR_SLUG — `groups.created_by` needs a real user. */ -async function resolveFounder(): Promise { - const { data, error } = await admin - .from('actors') - .select('id, user_id') - .eq('slug', FOUNDER_ACTOR_SLUG) - .maybeSingle(); - if (error) die(`Failed to resolve founder actor: ${error.message}`); - if (!data) { - die(`No actor with slug '${FOUNDER_ACTOR_SLUG}'. Create it or change FOUNDER_ACTOR_SLUG.`); - } - if (!data.user_id) die(`Actor '${FOUNDER_ACTOR_SLUG}' has no user_id; groups require one.`); - return { id: data.id, user_id: data.user_id }; -} - -/** Upsert the company group by its (unique) slug. Returns its id. */ -async function upsertGroup(founder: FounderRow): Promise { - const { data: existing, error: probeErr } = await admin - .from('groups') - .select('id') - .eq('slug', GROUP_PAYLOAD.slug) - .maybeSingle(); - if (probeErr) die(`Failed to look up group '${GROUP_PAYLOAD.slug}': ${probeErr.message}`); - - const row: GroupInsert = { - name: GROUP_PAYLOAD.name, - slug: GROUP_PAYLOAD.slug, - description: GROUP_PAYLOAD.description, - label: GROUP_PAYLOAD.label, - tags: GROUP_PAYLOAD.tags, - is_public: GROUP_PAYLOAD.is_public, - visibility: GROUP_PAYLOAD.visibility, - governance_preset: GROUP_PAYLOAD.governance_preset, - created_by: founder.user_id, - }; - - if (existing) { - const { error } = await admin.from('groups').update(row).eq('id', existing.id); - if (error) die(`Failed to update group: ${error.message}`); - console.log(`↻ updated group "${GROUP_PAYLOAD.name}" (${existing.id})`); - return existing.id; - } - - const { data, error } = await admin.from('groups').insert(row).select('id').single(); - if (error) die(`Failed to insert group: ${error.message}`); - console.log(`+ created group "${GROUP_PAYLOAD.name}" (${data.id})`); - return data.id; -} - -/** - * Upsert the group's actor. Nothing creates this automatically — `createGroup` - * in the app doesn't either — and without it the group can own nothing, since - * every entity table keys on actor_id. - */ -async function upsertGroupActor(groupId: string): Promise { - const { data: existing, error: probeErr } = await admin - .from('actors') - .select('id') - .eq('group_id', groupId) - .eq('actor_type', 'group') - .maybeSingle(); - if (probeErr) die(`Failed to look up group actor: ${probeErr.message}`); - - const row: ActorInsert = { - actor_type: 'group', - group_id: groupId, - user_id: null, - display_name: COMPANY.name, - slug: COMPANY.slug, - }; - - if (existing) { - const { error } = await admin.from('actors').update(row).eq('id', existing.id); - if (error) die(`Failed to update group actor: ${error.message}`); - console.log(`↻ group actor '${COMPANY.slug}' (${existing.id})`); - return existing.id; - } - - const { data, error } = await admin.from('actors').insert(row).select('id').single(); - if (error) die(`Failed to insert group actor: ${error.message}`); - console.log(`+ group actor '${COMPANY.slug}' (${data.id})`); - return data.id; -} - -/** The founder seat. Unique on (group_id, user_id), so ignore-on-conflict. */ -async function ensureFounderMembership(groupId: string, founder: FounderRow): Promise { - const { error } = await admin - .from('group_members') - .upsert( - { group_id: groupId, user_id: founder.user_id, role: 'founder' }, - { onConflict: 'group_id,user_id' } - ); - if (error) die(`Failed to seat the founder: ${error.message}`); - console.log(` ✓ founder seat for actor '${FOUNDER_ACTOR_SLUG}'`); -} - -/** Enable the features the company needs. Unique on (group_id, feature_key). */ -async function ensureFeatures(groupId: string, founder: FounderRow): Promise { - if (GROUP_FEATURE_KEYS.length === 0) { - return; - } - const rows = GROUP_FEATURE_KEYS.map(feature_key => ({ - group_id: groupId, - feature_key, - enabled: true, - enabled_by: founder.user_id, - })); - const { error } = await admin - .from('group_features') - .upsert(rows, { onConflict: 'group_id,feature_key' }); - if (error) die(`Failed to enable features: ${error.message}`); - console.log(` ✓ features: ${GROUP_FEATURE_KEYS.join(', ')}`); -} - -async function main(): Promise { - console.log(`Seeding "${COMPANY.name}" against ${SUPABASE_URL} …`); - const founder = await resolveFounder(); - console.log(`founder actor '${FOUNDER_ACTOR_SLUG}' = ${founder.id}`); - - const groupId = await upsertGroup(founder); - await upsertGroupActor(groupId); - await ensureFounderMembership(groupId, founder); - await ensureFeatures(groupId, founder); - - // Deliberately no product listings. Substrata publishes research and sells - // nothing, so seeding a catalogue would put a shop on the profile of a firm - // that has none. If a licensed desk ever exists, that commit adds them. - console.log(`✓ done. View at /groups/${COMPANY.slug}.`); -} - -main().catch(err => die(err instanceof Error ? err.message : String(err))); diff --git a/src/app/api/groups/[slug]/site/route.ts b/src/app/api/groups/[slug]/site/route.ts deleted file mode 100644 index 6cd6746dc..000000000 --- a/src/app/api/groups/[slug]/site/route.ts +++ /dev/null @@ -1,149 +0,0 @@ -/** - * Group Website API — publish a group as a website, and take it down again. - * - * GET /api/groups/[slug]/site — is this group published, and where? - * PUT /api/groups/[slug]/site — publish it (admin/founder only) - * DELETE /api/groups/[slug]/site — unpublish it (admin/founder only) - * - * This endpoint IS the "few clicks": everything underneath it works off one - * row, so turning a group into a website is this one call — no DNS, no Caddy, - * no deploy. PUT is an upsert, so it both publishes and reconfigures; two verbs - * would be two states where the database has one row. - * - * HTTP only. What may be published, and what publishing does, lives in - * `@/services/sites/publish`. - */ - -import { withAuth, type AuthenticatedRequest } from '@/lib/api/withAuth'; -import { revalidateTag } from 'next/cache'; -import { - apiSuccess, - apiBadRequest, - apiForbidden, - apiNotFound, - apiRateLimited, - apiValidationError, - handleApiError, -} from '@/lib/api/standardResponse'; -import { rateLimitWriteAsync, retryAfterSeconds } from '@/lib/rate-limit'; -import { logger } from '@/utils/logger'; -import { checkGroupAdmin } from '@/domain/groups/helpers.server'; -import { siteConfigSchema } from '@/config/hosted-site'; -import { HOSTED_SITES_TAG } from '@/services/sites/registry'; -import { - publishRefusal, - publishSite, - readSiteFeature, - resolveGroupForSite, - siteAddress, - unpublishSite, - type SiteGroup, -} from '@/services/sites/publish'; - -interface RouteContext { - params: Promise<{ slug: string }>; -} - -/** Resolve + authorise in one step; every verb here needs exactly this. */ -async function requireAdminGroup( - req: AuthenticatedRequest, - slug: string -): Promise<{ group: SiteGroup } | { error: ReturnType }> { - const group = await resolveGroupForSite(req.supabase, slug); - if (!group) { - return { error: apiNotFound('Group not found') }; - } - if (!(await checkGroupAdmin(req.supabase, group.id, req.user.id))) { - return { error: apiForbidden('Only group admins and founders can manage the website') }; - } - return { group }; -} - -export const GET = withAuth(async (req: AuthenticatedRequest, { params }: RouteContext) => { - const { slug } = await params; - try { - const resolved = await requireAdminGroup(req, slug); - if ('error' in resolved) { - return resolved.error; - } - const { group } = resolved; - const feature = await readSiteFeature(req.supabase, group.id); - const refusal = publishRefusal(group); - - return apiSuccess({ - published: feature.enabled, - // Returned even when unpublished, so the UI can offer "Publish at - // acme.orangecat.ch" rather than a bare "Publish". It costs nothing. - ...siteAddress(group, feature.config), - eligible: refusal === null, - reason: refusal, - config: feature.config, - }); - } catch (error) { - logger.error('Reading site settings failed', { slug, error: String(error) }, 'Sites'); - return handleApiError(error); - } -}); - -export const PUT = withAuth(async (req: AuthenticatedRequest, { params }: RouteContext) => { - const { slug } = await params; - try { - const rl = await rateLimitWriteAsync(req.user.id); - if (!rl.success) { - return apiRateLimited('Too many requests. Please slow down.', retryAfterSeconds(rl)); - } - - const resolved = await requireAdminGroup(req, slug); - if ('error' in resolved) { - return resolved.error; - } - const { group } = resolved; - - const refusal = publishRefusal(group); - if (refusal) { - return apiBadRequest(refusal); - } - - const body = await req.json().catch(() => ({})); - const parsed = siteConfigSchema.safeParse(body ?? {}); - if (!parsed.success) { - return apiValidationError('Invalid site configuration', parsed.error.flatten().fieldErrors); - } - - await publishSite(req.supabase, group, parsed.data, req.user.id); - // Without this the site is live but the resolver holds its old answer for up - // to a minute, and a publish button that takes a minute to visibly work - // reads as a publish button that failed. - revalidateTag(HOSTED_SITES_TAG, 'max'); - logger.info('Group website published', { group: group.slug }, 'Sites'); - - return apiSuccess({ published: true, ...siteAddress(group, parsed.data) }); - } catch (error) { - logger.error('Publishing a site failed', { slug, error: String(error) }, 'Sites'); - return handleApiError(error); - } -}); - -export const DELETE = withAuth(async (req: AuthenticatedRequest, { params }: RouteContext) => { - const { slug } = await params; - try { - const rl = await rateLimitWriteAsync(req.user.id); - if (!rl.success) { - return apiRateLimited('Too many requests. Please slow down.', retryAfterSeconds(rl)); - } - - const resolved = await requireAdminGroup(req, slug); - if ('error' in resolved) { - return resolved.error; - } - - await unpublishSite(req.supabase, resolved.group); - revalidateTag(HOSTED_SITES_TAG, 'max'); - logger.info('Group website unpublished', { group: slug }, 'Sites'); - - return apiSuccess({ published: false }); - } catch (error) { - logger.error('Unpublishing a site failed', { slug, error: String(error) }, 'Sites'); - return handleApiError(error); - } -}); diff --git a/src/app/api/internal/tls-check/route.ts b/src/app/api/internal/tls-check/route.ts deleted file mode 100644 index 4312f5cb0..000000000 --- a/src/app/api/internal/tls-check/route.ts +++ /dev/null @@ -1,76 +0,0 @@ -/** - * GET /api/internal/tls-check?domain= — may Caddy issue a certificate? - * - * This is the endpoint Caddy's `on_demand_tls { ask ... }` calls before it - * obtains a certificate for a hostname it has never seen. 200 means yes. - * - * WHY THIS EXISTS - * - * The alternative to on-demand TLS is a wildcard certificate, which needs a - * DNS-01 challenge, an Infomaniak API token on the box, and a Caddy plugin — and - * it still would not cover a customer's own domain. On-demand needs none of - * that, and it inverts the relationship in the right direction: instead of - * infrastructure being edited every time a customer appears, Caddy ASKS - * OrangeCat which hostnames are real, and OrangeCat already knows. - * - * THIS IS A GATE, NOT A LOOKUP - * - * Answering 200 too freely is a denial of service against ourselves: every yes - * is an ACME order, and Let's Encrypt rate-limits an account that fails them. - * Somebody who points `whatever.example` at this box and requests it can - * therefore burn our issuance budget. So the rule is strict — the hostname must - * resolve to a site that is published RIGHT NOW, reserved subdomains are - * refused before any query, and anything unrecognised is a flat 403. - * - * Internal by convention only: Caddy calls it over loopback, and it discloses - * nothing an ordinary visitor could not learn by loading the site itself. - */ - -import { isReservedSubdomain, normaliseHost, SITES_BASE_DOMAIN } from '@/config/sites'; -import { siteByHost } from '@/services/sites/registry'; -import { logger } from '@/utils/logger'; - -/** Caddy blocks on this call, so it must be quick and must never hang. */ -export const dynamic = 'force-dynamic'; - -// Plain `Response`, not `NextResponse`: this returns a status and a line of -// text, and nothing here needs Next's cookie or rewrite helpers. -function deny(reason: string): Response { - // Caddy treats any non-2xx as "do not issue". The body is for our logs. - return new Response(reason, { status: 403 }); -} - -export async function GET(request: Request): Promise { - const domain = new URL(request.url).searchParams.get('domain'); - if (!domain) { - return deny('missing domain'); - } - - const host = normaliseHost(domain); - if (!host || host.length > 253) { - return deny('malformed domain'); - } - - // Reserved names are refused before a query runs. They have their own Caddy - // blocks and their own certificates; issuing a second one here would at best - // be waste and at worst hand `security.orangecat.ch` to whoever asked. - const suffix = `.${SITES_BASE_DOMAIN}`; - if (host.endsWith(suffix)) { - const label = host.slice(0, -suffix.length); - if (!label || label.includes('.') || isReservedSubdomain(label)) { - return deny('reserved or malformed subdomain'); - } - } - - try { - const resolved = await siteByHost(host); - if (!resolved) { - return deny('no published site answers on this host'); - } - return new Response('ok', { status: 200 }); - } catch (error) { - // Fail CLOSED. A database blip must not become an open certificate mint. - logger.warn('TLS check failed', { host, error: String(error) }, 'Sites'); - return deny('lookup failed'); - } -} diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 35c3fc384..0850a0ffd 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -42,8 +42,6 @@ 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'; @@ -105,25 +103,8 @@ export const metadata: Metadata = { }, }; -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; +export default function RootLayout({ children }: { children: React.ReactNode }) { + const gaId = 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. @@ -136,42 +117,39 @@ export default async function RootLayout({ children }: { children: React.ReactNo suppressHydrationWarning > - {/* Structured data: Organization + WebSite. Never on a hosted site — - it would tell every crawler that the customer's domain IS OrangeCat. */} - {!isHostedSite && ( -