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 && ( -