diff --git a/__tests__/unit/config/hosted-sites.test.ts b/__tests__/unit/config/hosted-sites.test.ts new file mode 100644 index 000000000..50c9fd467 --- /dev/null +++ b/__tests__/unit/config/hosted-sites.test.ts @@ -0,0 +1,215 @@ +/** + * 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 { HOSTED_SITES, siteBySlug, siteCanonicalHost, siteForHost, siteHref } from '@/config/sites'; +import { + pageRendersOwnHeader, + sitePageAt, + sitePagesFor, + siteChromeFor, +} from '@/config/site-content'; +import { getRouteSurface } from '@/config/routes'; +import { COMPANY, MANDATE_CURVES, MATERIALS } from '@/config/substrata'; +import { CHOKEPOINTS, COVERAGE, coverageProgress } from '@/config/substrata-coverage'; + +const site = siteBySlug('substrata'); + +describe('hosted sites — host resolution', () => { + it('resolves the free subdomain, with or without www and port', () => { + expect(siteForHost('substrata.orangecat.ch')?.slug).toBe('substrata'); + expect(siteForHost('www.substrata.orangecat.ch')?.slug).toBe('substrata'); + expect(siteForHost('Substrata.OrangeCat.ch:443')?.slug).toBe('substrata'); + }); + + it('resolves the local development host, so the rewrite is testable without DNS', () => { + expect(siteForHost('substrata.localhost:3020')?.slug).toBe('substrata'); + }); + + 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', + 'notsubstrata.orangecat.ch', + 'substrata.orangecat.ch.evil.example', + '', + null, + undefined, + ]) { + expect(siteForHost(host)).toBeNull(); + } + }); + + it('advertises the custom domain once one is set, and the subdomain until then', () => { + for (const site of HOSTED_SITES) { + const expected = site.customDomain ?? `${site.subdomain}.orangecat.ch`; + expect(siteCanonicalHost(site)).toBe(expected); + } + }); +}); + +describe('hosted sites — links', () => { + it('always emits the path form, which resolves on every host', () => { + expect(site).not.toBeNull(); + expect(siteHref(site!)).toBe('/sites/substrata'); + expect(siteHref(site!, 'map')).toBe('/sites/substrata/map'); + expect(siteHref(site!, '/map')).toBe('/sites/substrata/map'); + expect(siteHref(site!, '/')).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(HOSTED_SITES.map(site => [site.slug, site] as const))( + '%s has chrome, a home page, and a nav where every entry resolves', + (_slug, site) => { + const chrome = siteChromeFor(site); + const pages = sitePagesFor(site); + + 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(site, 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(site!, 'not-a-page')).toBeNull(); + }); +}); + +describe('substrata.orangecat.ch — the site is the profile, not a copy of it', () => { + const pages = sitePagesFor(site!); + 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(site!, '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(site!, '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(site!, '')!; + expect(home.sections[0].kind).toBe('hero'); + expect(pageRendersOwnHeader(home)).toBe(true); + + // Inner pages take the standard header instead. + expect(pageRendersOwnHeader(sitePageAt(site!, 'map')!)).toBe(false); + }); + + it('has no desk page, because there is no desk', () => { + expect(sitePageAt(site!, 'desk')).toBeNull(); + const navLabels = sitePagesFor(site!).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(site!, '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!)); + 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/substrata-acting.test.ts b/__tests__/unit/config/substrata-acting.test.ts new file mode 100644 index 000000000..ed41bd1ef --- /dev/null +++ b/__tests__/unit/config/substrata-acting.test.ts @@ -0,0 +1,127 @@ +/** + * 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 { siteBySlug } from '@/config/sites'; +import { sitePageAt, sitePagesFor } from '@/config/site-content'; + +const site = siteBySlug('substrata')!; +const actingPage = sitePageAt(site, 'acting'); +const everything = JSON.stringify(sitePagesFor(site)); + +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(site, '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 new file mode 100644 index 000000000..7835deaa5 --- /dev/null +++ b/__tests__/unit/config/substrata-participants.test.ts @@ -0,0 +1,127 @@ +/** + * 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 { siteBySlug } from '@/config/sites'; +import { sitePageAt } from '@/config/site-content'; + +const site = siteBySlug('substrata')!; +const page = sitePageAt(site, '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 new file mode 100644 index 000000000..3a2de57ab --- /dev/null +++ b/__tests__/unit/config/substrata.test.ts @@ -0,0 +1,274 @@ +/** + * 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 { GOVERNANCE_PRESETS } from '@/config/governance-presets'; +import { isReservedUsername } from '@/config/usernames'; + +// Mirrors of the live CHECK constraints on public.user_products / public.groups. +const PRODUCT_CURRENCIES = ['USD', 'EUR', 'CHF', 'BTC', 'GBP']; +const PRODUCT_TYPES = ['physical', 'digital', 'service']; +const FULFILLMENT_TYPES = ['manual', 'automatic', 'digital']; +const PRODUCT_STATUSES = ['draft', 'active', 'paused', 'sold_out']; +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', () => { + expect(GROUP_FEATURE_KEYS).toEqual([]); + }); + + 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/domain-search.test.ts b/__tests__/unit/services/domain-search.test.ts new file mode 100644 index 000000000..2e00f673f --- /dev/null +++ b/__tests__/unit/services/domain-search.test.ts @@ -0,0 +1,179 @@ +/** + * Domain search has exactly one way to be dangerous: telling somebody a name + * is free when it is not. They act on that — they plan a brand around it, or + * they stop looking. + * + * The naive version of this feature (ask a redirector, treat 404 as available) + * fails precisely that way, and it fails on this project's own domain: .ch + * runs no public RDAP service, so orangecat.ch — registered, in production, + * serving the app these tests belong to — comes back 404. So does .io, and + * so does .co. + * + * These tests hold the rule that prevents it: `unregistered` is reachable ONLY + * for a TLD in IANA's RDAP bootstrap that answered 404. Every other path — + * unsupported TLD, timeout, transport error, odd status, missing bootstrap — + * is `unknown`. Nothing here talks to the network. + */ + +import { + checkDomain, + checkDomains, + parseDomain, + resetDomainCaches, +} from '@/services/domains/availability'; +import { suggestDomains, toSeed } from '@/services/domains/suggest'; +import { CANDIDATE_TLDS, MAX_CANDIDATES } from '@/config/domain-search'; + +const RDAP_TLDS = new Set(['com', 'ai', 'dev', 'org', 'net', 'xyz']); + +const originalFetch = global.fetch; + +function mockFetch(impl: (url: string) => Promise> | Partial) { + global.fetch = jest.fn(async (input: RequestInfo | URL) => + impl(String(input)) + ) as unknown as typeof fetch; +} + +beforeEach(() => { + resetDomainCaches(); +}); + +afterEach(() => { + global.fetch = originalFetch; + jest.restoreAllMocks(); +}); + +describe('parseDomain', () => { + it('accepts a plain domain and normalises it', () => { + expect(parseDomain('SubstrataIntel.COM')).toEqual({ name: 'substrataintel', tld: 'com' }); + expect(parseDomain(' https://substrataintel.com/path ')).toEqual({ + name: 'substrataintel', + tld: 'com', + }); + expect(parseDomain('substrataintel.com.')).toEqual({ name: 'substrataintel', tld: 'com' }); + }); + + it('rejects things that are not domains', () => { + for (const input of ['substrataintel', '', ' ', '-bad.com', 'bad-.com', 'x.c', 'a b.com']) { + expect(parseDomain(input)).toBeNull(); + } + }); +}); + +describe('availability — the rule that stops a false “available”', () => { + it('reports unregistered only when a supported registry answers 404', async () => { + mockFetch(() => ({ status: 404, ok: false })); + const result = await checkDomain('substrataintel.com', RDAP_TLDS); + + expect(result.status).toBe('unregistered'); + expect(result.rdapSupported).toBe(true); + }); + + it('reports registered when the registry returns a record', async () => { + mockFetch(() => ({ status: 200, ok: true })); + const result = await checkDomain('google.com', RDAP_TLDS); + expect(result.status).toBe('registered'); + }); + + it.each(['ch', 'io', 'co'])( + 'never claims a .%s domain is free — that registry publishes no RDAP', + async tld => { + // A redirector 404s for these exactly as it does for a free name. If this + // test ever goes green on 'unregistered', the feature is lying. + mockFetch(() => ({ status: 404, ok: false })); + const result = await checkDomain(`orangecat.${tld}`, RDAP_TLDS); + + expect(result.status).toBe('unknown'); + expect(result.rdapSupported).toBe(false); + expect(result.reason).toContain('no RDAP service'); + } + ); + + it('treats a timeout as unresolved, never as free', async () => { + mockFetch(() => { + throw new Error('TimeoutError'); + }); + const result = await checkDomain('substrataintel.com', RDAP_TLDS); + expect(result.status).toBe('unknown'); + }); + + it('treats an unexpected registry status as unresolved', async () => { + mockFetch(() => ({ status: 500, ok: false })); + const result = await checkDomain('substrataintel.com', RDAP_TLDS); + expect(result.status).toBe('unknown'); + }); + + it('treats a missing bootstrap as unresolved rather than assuming anything', async () => { + mockFetch(() => ({ status: 404, ok: false })); + const result = await checkDomain('substrataintel.com', null); + expect(result.status).toBe('unknown'); + expect(result.reason).toContain('could not be loaded'); + }); + + it('rejects a malformed query without calling any registry', async () => { + const fetchSpy = jest.fn(); + global.fetch = fetchSpy as unknown as typeof fetch; + + const result = await checkDomain('not a domain', RDAP_TLDS); + expect(result.status).toBe('unknown'); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + it('caches a result so a repeated lookup does not hit the registry twice', async () => { + const fetchSpy = jest.fn(async () => ({ status: 404, ok: false }) as Partial); + global.fetch = fetchSpy as unknown as typeof fetch; + + await checkDomain('substrataintel.com', RDAP_TLDS); + await checkDomain('substrataintel.com', RDAP_TLDS); + + expect(fetchSpy).toHaveBeenCalledTimes(1); + }); +}); + +describe('availability — batches', () => { + it('checks every candidate and preserves order', async () => { + mockFetch(url => + url.includes('data.iana.org') + ? { ok: true, status: 200, json: async () => ({ services: [[['com', 'ai'], ['x']]] }) } + : { status: 404, ok: false } + ); + + const results = await checkDomains(['a.com', 'b.ai', 'c.ch']); + expect(results.map(r => r.domain)).toEqual(['a.com', 'b.ai', 'c.ch']); + expect(results.map(r => r.status)).toEqual(['unregistered', 'unregistered', 'unknown']); + }); +}); + +describe('suggestions', () => { + it('reduces a phrase to a usable label', () => { + expect(toSeed('Substrata Intel')).toBe('substrataintel'); + expect(toSeed(' Café Ltd. ')).toBe('cafeltd'); + expect(toSeed('!!!')).toBe(''); + }); + + it('leads with the exact domain when the user typed one', () => { + const candidates = suggestDomains({ query: 'substrataintel.com' }); + expect(candidates[0]).toBe('substrataintel.com'); + }); + + it('offers the bare name across every TLD before any invented variant', () => { + const candidates = suggestDomains({ query: 'substrate' }); + const bare = CANDIDATE_TLDS.map(tld => `substrate.${tld}`); + expect(candidates.slice(0, bare.length)).toEqual(bare); + }); + + it('honours a caller-supplied TLD list, which is how FleetCrown narrows it', () => { + const candidates = suggestDomains({ query: 'substrate', tlds: ['.ch', 'COM'] }); + expect(candidates.slice(0, 2)).toEqual(['substrate.ch', 'substrate.com']); + }); + + it('never fans out past the cap, and never repeats a candidate', () => { + const candidates = suggestDomains({ query: 'substrate' }); + expect(candidates.length).toBeLessThanOrEqual(MAX_CANDIDATES); + expect(new Set(candidates).size).toBe(candidates.length); + }); + + it('returns nothing usable for a query with no letters or digits', () => { + expect(suggestDomains({ query: '???' })).toEqual([]); + }); +}); diff --git a/scripts/seed-substrata.ts b/scripts/seed-substrata.ts new file mode 100644 index 000000000..cad95d6c5 --- /dev/null +++ b/scripts/seed-substrata.ts @@ -0,0 +1,196 @@ +/** + * 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 { + 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.'); +} + +const admin: SupabaseClient = createClient(SUPABASE_URL, SERVICE_ROLE_KEY, { + auth: { persistSession: false, autoRefreshToken: false }, +}); + +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 as string, user_id: data.user_id as string }; +} + +/** 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 = { + 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 as string; + } + + 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 as string; +} + +/** + * 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 = { + 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 as string; + } + + 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 as string; +} + +/** 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); + const groupActorId = 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/(public)/domains/page.tsx b/src/app/(public)/domains/page.tsx index 032ecdf42..37b34056c 100644 --- a/src/app/(public)/domains/page.tsx +++ b/src/app/(public)/domains/page.tsx @@ -3,6 +3,7 @@ import Link from 'next/link'; import { Metadata } from 'next'; import { Globe, ShieldCheck, Server } from 'lucide-react'; import Button from '@/components/ui/Button'; +import { DomainSearch } from '@/components/domains/DomainSearch'; import { ROUTES } from '@/config/routes'; import { DOMAINS_SERVICE_URL, @@ -32,6 +33,15 @@ export default function DomainsPage() { to your own domain when you're ready, and everything keeps working.

+ + {/* The rung before everything else on this page: you cannot host a + domain you have not found yet. */} +
+

+ Don't have a name yet? Check one against the registries. +

+ +
diff --git a/src/app/api/v1/domains/route.ts b/src/app/api/v1/domains/route.ts new file mode 100644 index 000000000..0d439537e --- /dev/null +++ b/src/app/api/v1/domains/route.ts @@ -0,0 +1,64 @@ +/** + * GET /api/v1/domains?q=...&tlds=com,ch — is this name free anywhere? + * + * The rung before the two services /domains sells: somebody with no domain + * cannot buy hosting for one. Public and keyless because registry RDAP records + * are public — the same reasoning as /api/v1/demand and /api/v1/search. + * + * Shared surface by design. OrangeCat's /domains page and FleetCrown both call + * this, so the honesty rules (a .ch "not found" is never reported as available) + * are enforced once, server-side, instead of being re-implemented per client. + */ +import { NextRequest } from 'next/server'; +import { apiSuccess, apiError } from '@/lib/api/standardResponse'; +import { CANDIDATE_TLDS, DOMAIN_SEARCH_DISCLAIMER } from '@/config/domain-search'; +import { checkDomains } from '@/services/domains/availability'; +import { suggestDomains } from '@/services/domains/suggest'; +import { logger } from '@/utils/logger'; + +/** Registry lookups are slow and cached; let a CDN hold the answer briefly. */ +const CACHE = 'public, s-maxage=300, stale-while-revalidate=600'; + +export async function GET(request: NextRequest) { + try { + const url = new URL(request.url); + const q = (url.searchParams.get('q') ?? '').trim(); + if (q.length < 2) { + return apiError('Query "q" (min 2 chars) is required', 'BAD_REQUEST', 400); + } + + const tldParam = url.searchParams.get('tlds'); + const tlds = tldParam + ? tldParam + .split(',') + .map(tld => tld.trim().replace(/^\./, '').toLowerCase()) + .filter(tld => /^[a-z]{2,63}$/.test(tld)) + : undefined; + + const candidates = suggestDomains({ query: q, tlds }); + if (candidates.length === 0) { + return apiError('Query contains no usable domain label', 'BAD_REQUEST', 400); + } + + const results = await checkDomains(candidates); + + const response = apiSuccess({ + query: q, + tlds: tlds ?? CANDIDATE_TLDS, + // Callers that want only the confident answers can filter on this + // without re-deriving the rule. + counts: { + unregistered: results.filter(r => r.status === 'unregistered').length, + registered: results.filter(r => r.status === 'registered').length, + unknown: results.filter(r => r.status === 'unknown').length, + }, + results, + disclaimer: DOMAIN_SEARCH_DISCLAIMER, + }); + response.headers.set('Cache-Control', CACHE); + return response; + } catch (err) { + logger.error('GET /api/v1/domains failed', { err }, 'DomainSearch'); + return apiError('Domain search failed', 'INTERNAL_ERROR', 500); + } +} diff --git a/src/app/sites/[site]/[[...path]]/page.tsx b/src/app/sites/[site]/[[...path]]/page.tsx new file mode 100644 index 000000000..182b055e4 --- /dev/null +++ b/src/app/sites/[site]/[[...path]]/page.tsx @@ -0,0 +1,112 @@ +/** + * The hosted-site renderer — one route for every page of every hosted site. + * + * A visitor on substrate.orangecat.ch never sees this path: middleware rewrote + * their request here while their URL bar kept saying substrate.orangecat.ch. + * The path form stays reachable on any host so a site can be previewed before + * its DNS exists, which is also how the tests and screenshots reach it. + * + * A catch-all rather than one file per page, because the pages are data + * (src/config/site-content.ts). Adding a page to a hosted site is adding an + * entry to that site's builder — never a new route file. + */ + +import React from 'react'; +import { notFound } from 'next/navigation'; +import type { Metadata } from 'next'; +import { HOSTED_SITES, siteBySlug, siteCanonicalHost } from '@/config/sites'; +import { + pageRendersOwnHeader, + siteChromeFor, + sitePageAt, + sitePagesFor, +} from '@/config/site-content'; +import { SiteFooter, SiteMasthead } from '@/components/sites/SiteChrome'; +import { SiteSections } from '@/components/sites/SiteSections'; + +interface RouteParams { + params: Promise<{ site: string; path?: string[] }>; +} + +/** Pre-render every page of every hosted site — they are static by nature. */ +export function generateStaticParams(): Array<{ site: string; path?: string[] }> { + return HOSTED_SITES.flatMap(site => + sitePagesFor(site).map(page => ({ + site: site.slug, + path: page.path ? [page.path] : [], + })) + ); +} + +export async function generateMetadata({ params }: RouteParams): Promise { + const { site: slug, path } = await params; + const site = siteBySlug(slug); + if (!site) { + return {}; + } + const page = sitePageAt(site, (path ?? []).join('/')); + if (!page) { + return {}; + } + const title = page.path ? `${page.title} — ${site.title}` : site.title; + return { + // `absolute` breaks the root layout's "%s | OrangeCat" template. On + // substrate.orangecat.ch the browser tab has no business advertising the + // host — the visitor is on Substrate's site, not on ours. + title: { absolute: title }, + description: page.intro, + openGraph: { + title, + description: page.intro, + siteName: site.title, + url: `https://${siteCanonicalHost(site)}${page.path ? `/${page.path}` : ''}`, + }, + }; +} + +export default async function HostedSitePage({ params }: RouteParams) { + const { site: slug, path } = await params; + const site = siteBySlug(slug); + if (!site) { + notFound(); + } + + const chrome = siteChromeFor(site); + const pages = sitePagesFor(site); + const currentPath = (path ?? []).join('/'); + const page = sitePageAt(site, currentPath); + + if (!chrome || !page) { + notFound(); + } + + return ( +
+ + +
+
+ {/* A page either opens with its own hero or gets the standard title + block — never both. The rule lives in site-content.ts so it holds + for every hosted site, not just this one. */} + {!pageRendersOwnHeader(page) && ( +
+

+ {page.title} +

+ {page.intro && ( +

+ {page.intro} +

+ )} +
+ )} + + +
+
+ + +
+ ); +} diff --git a/src/components/domains/DomainSearch.tsx b/src/components/domains/DomainSearch.tsx new file mode 100644 index 000000000..143fbe974 --- /dev/null +++ b/src/components/domains/DomainSearch.tsx @@ -0,0 +1,137 @@ +'use client'; + +/** + * Domain availability search for /domains. + * + * A thin client over /api/v1/domains — deliberately thin, because FleetCrown + * calls that same endpoint and any rule implemented here instead of there + * would apply to one of the two products and not the other. In particular the + * "a .ch not-found proves nothing" rule is server-side; this component only + * renders what it is told. + */ + +import React, { useCallback, useState } from 'react'; +import { Loader2, Search } from 'lucide-react'; +import Button from '@/components/ui/Button'; +import { DOMAIN_STATUS_COPY, type DomainStatus } from '@/config/domain-search'; + +interface DomainResult { + domain: string; + status: DomainStatus; + reason: string; + rdapSupported: boolean; +} + +const STATUS_CLASS: Record = { + unregistered: 'bg-status-positive-subtle text-status-positive', + registered: 'bg-surface-raised text-fg-tertiary', + unknown: 'bg-status-warning-subtle text-status-warning', +}; + +/** Free names first — they are the answer. Then unresolved, then taken. */ +const STATUS_ORDER: Record = { unregistered: 0, unknown: 1, registered: 2 }; + +export function DomainSearch() { + const [query, setQuery] = useState(''); + const [results, setResults] = useState(null); + const [disclaimer, setDisclaimer] = useState(''); + const [isSearching, setIsSearching] = useState(false); + const [error, setError] = useState(null); + + const search = useCallback( + async (event: React.FormEvent) => { + event.preventDefault(); + const trimmed = query.trim(); + if (trimmed.length < 2) { + setError('Type at least two characters.'); + return; + } + + setIsSearching(true); + setError(null); + try { + const response = await fetch(`/api/v1/domains?q=${encodeURIComponent(trimmed)}`); + const body = await response.json(); + if (!response.ok) { + setError(body?.error?.message ?? 'Search failed. Try again in a moment.'); + setResults(null); + return; + } + const payload = body.data ?? body; + setResults( + [...(payload.results ?? [])].sort( + (a: DomainResult, b: DomainResult) => STATUS_ORDER[a.status] - STATUS_ORDER[b.status] + ) + ); + setDisclaimer(payload.disclaimer ?? ''); + } catch { + setError('Could not reach the registry lookup. Try again in a moment.'); + setResults(null); + } finally { + setIsSearching(false); + } + }, + [query] + ); + + return ( +
+
+ + setQuery(event.target.value)} + placeholder="yourname" + autoComplete="off" + className="min-h-11 flex-1 rounded-lg border border-subtle bg-surface-base px-4 py-2.5 text-fg-primary placeholder:text-fg-muted focus-visible:border-interactive focus-visible:outline-none" + /> + +
+ + {error &&

{error}

} + + {results && results.length === 0 && ( +

+ Nothing to check — that query has no usable domain label. +

+ )} + + {results && results.length > 0 && ( +
+
    + {results.map(result => ( +
  • + {result.domain} + + + {result.reason} + + + {DOMAIN_STATUS_COPY[result.status].label} + + +
  • + ))} +
+ {disclaimer &&

{disclaimer}

} +
+ )} +
+ ); +} diff --git a/src/components/layout/AppShell.tsx b/src/components/layout/AppShell.tsx index 4e50127b8..d0f0cbdc1 100644 --- a/src/components/layout/AppShell.tsx +++ b/src/components/layout/AppShell.tsx @@ -43,6 +43,10 @@ export function AppShell({ children }: AppShellProps) { // search are noise on a sign-in screen and dilute the focus from the // form. Stripe/Linear/Notion all do this. const isAuthSurface = surface === 'auth'; + // A hosted site is a different company's website that happens to run here. + // It brings its own header and footer; ours would be someone else's branding + // on their domain. + const isSiteSurface = surface === 'site'; // Wait for auth hydration to prevent sidebar flash // During hydration, user is null even if authenticated - this prevents the sidebar from flickering @@ -108,6 +112,17 @@ export function AppShell({ children }: AppShellProps) { // Get filtered sections based on auth state const filteredSections = getFilteredSections(); + // Hosted site: no OrangeCat chrome whatsoever, and no message-sync manager + // either — a visitor to substrata.orangecat.ch has no OrangeCat session and + // no reason for one. The site's own layout supplies everything. + if (isSiteSurface) { + return ( +
+
{children}
+
+ ); + } + // Auth surface: no global chrome at all. The /auth page is its own // self-contained universe with its own minimal back-link. if (isAuthSurface) { diff --git a/src/components/sites/SiteChrome.tsx b/src/components/sites/SiteChrome.tsx new file mode 100644 index 000000000..3320ecd91 --- /dev/null +++ b/src/components/sites/SiteChrome.tsx @@ -0,0 +1,75 @@ +/** + * Masthead and footer for a hosted site. + * + * The visitor is on the site owner's domain, so the chrome is theirs: their + * name, their nav, their footer. The only OrangeCat mark is one quiet line at + * the bottom saying where this runs and linking to the profile behind it — + * the honest disclosure a hosted site owes, and the only distribution + * OrangeCat takes for hosting it. + * + * Design notes: the masthead is a rule, not a bar. It sticks so the section + * nav stays reachable on a long research page, but it carries no shadow, no + * fill beyond the page's own surface, and no logo — a hairline and a wordmark + * are enough, and anything heavier competes with the document. + */ + +import React from 'react'; +import Link from 'next/link'; +import { ROUTES } from '@/config/routes'; +import { siteCanonicalHost, siteHref, type HostedSite } from '@/config/sites'; +import type { SiteChrome as SiteChromeSpec, SitePage } from '@/config/site-content'; +import { SiteNav } from './SiteNav'; + +interface Props { + site: HostedSite; + chrome: SiteChromeSpec; + pages: SitePage[]; + currentPath: string; +} + +export function SiteMasthead({ site, chrome, pages, currentPath }: Props) { + return ( +
+
+ {/* One row at every width. Wrapping the nav onto a second line made a + sticky masthead eat a third of a phone screen, so on narrow + viewports the nav scrolls sideways instead. */} +
+ + + {chrome.name} + + + + +
+
+
+ ); +} + +export function SiteFooter({ site, chrome }: { site: HostedSite; chrome: SiteChromeSpec }) { + return ( +
+
+

{chrome.footerNote}

+
+ {siteCanonicalHost(site)} + + Hosted on{' '} + + OrangeCat + + {' · '} + + profile + + +
+
+
+ ); +} diff --git a/src/components/sites/SiteNav.tsx b/src/components/sites/SiteNav.tsx new file mode 100644 index 000000000..6e8460a22 --- /dev/null +++ b/src/components/sites/SiteNav.tsx @@ -0,0 +1,64 @@ +'use client'; + +/** + * The masthead's section nav. + * + * A client component for one reason: with eight sections the nav scrolls + * sideways on a phone, and the active item is frequently off-screen at rest — + * so a visitor arriving on a deep page sees a row of links with no indication + * that any of them is the page they are on. Scrolling it into view on mount + * fixes that, and there is no CSS-only way to do it. + * + * Everything else about the masthead stays server-rendered. + */ + +import React, { useEffect, useRef } from 'react'; +import Link from 'next/link'; +import { siteHref, type HostedSite } from '@/config/sites'; +import type { SitePage } from '@/config/site-content'; + +interface Props { + site: HostedSite; + pages: SitePage[]; + currentPath: string; +} + +export function SiteNav({ site, pages, currentPath }: Props) { + const navRef = useRef(null); + + useEffect(() => { + const active = navRef.current?.querySelector('[aria-current="page"]'); + // `nearest` keeps the page itself still — `center` would scroll the whole + // document to the top of the masthead on every navigation. + active?.scrollIntoView({ inline: 'nearest', block: 'nearest' }); + }, [currentPath]); + + return ( + + ); +} diff --git a/src/components/sites/SiteSections.tsx b/src/components/sites/SiteSections.tsx new file mode 100644 index 000000000..d41681014 --- /dev/null +++ b/src/components/sites/SiteSections.tsx @@ -0,0 +1,57 @@ +/** + * Dispatches a page's sections to their renderers, and numbers them. + * + * One renderer for every hosted site is the point: it is what stops fifty + * customer websites becoming fifty stylesheets, and it is why a site inherits + * typography, dark mode and reading measure without its owner thinking about + * any of them. Section shapes are the closed set in `src/config/site-content.ts`. + * + * Numbering counts only sections that carry a heading, so an unheaded lead + * paragraph or a bare table does not consume a number and leave a gap in the + * sequence a reader can see. + */ + +import React from 'react'; +import type { SiteSection } from '@/config/site-content'; +import { HeroSection } from './sections/HeroSection'; +import { MeterSection, StatsSection } from './sections/FigureSections'; +import { CardsSection, DefinitionsSection, ProseSection } from './sections/ProseSections'; +import { IndexSection, TableSection } from './sections/DataSections'; + +/** True when a section shows a heading, and therefore takes the next number. */ +function isNumbered(section: SiteSection): boolean { + return section.kind !== 'hero' && section.kind !== 'table' && Boolean(section.heading); +} + +export function SiteSections({ sections }: { sections: SiteSection[] }) { + let counter = 0; + + return ( +
+ {sections.map((section, key) => { + const index = isNumbered(section) ? ++counter : undefined; + + switch (section.kind) { + case 'hero': + return ; + case 'prose': + return ; + case 'stats': + return ; + case 'meter': + return ; + case 'cards': + return ; + case 'definitions': + return ; + case 'index': + return ; + case 'table': + return ; + default: + return null; + } + })} +
+ ); +} diff --git a/src/components/sites/sections/DataSections.tsx b/src/components/sites/sections/DataSections.tsx new file mode 100644 index 000000000..27ab76873 --- /dev/null +++ b/src/components/sites/sections/DataSections.tsx @@ -0,0 +1,141 @@ +/** + * Sections that present the research itself: the jump index and the tables. + * + * These are the instrument. Codes, counts and statuses are set in mono so a + * reader can scan a column; the status cell carries a dot because "Unverified + * lead" and "Sourced" are the two words on this site a reader must never + * skim past. + */ + +import React from 'react'; +import { Blurb, SectionBody, SectionHeading } from './Primitives'; +import type { SiteSection } from '@/config/site-content'; + +/** Status keyword → dot colour. Anything unrecognised stays neutral. */ +const STATUS_DOT: Record = { + sourced: 'bg-status-positive', + 'unverified lead': 'bg-status-warning', + taken: 'bg-fg-muted', + done: 'bg-status-positive', + 'in progress': 'bg-status-warning', + 'not started': 'bg-fg-muted', + // Scarcity grades. Red reads as "constrained", green as "no constraint here" — + // the opposite of a risk dashboard, and correct for this page. + chokepoint: 'bg-status-negative', + concentrated: 'bg-status-warning', + competitive: 'bg-status-positive', +}; + +export function IndexSection({ + section, + index, +}: { + section: Extract; + index?: number; +}) { + return ( +
+ {section.heading && {section.heading}} + {section.blurb &&
{{section.blurb}}
} + +
    + {section.entries.map((entry, i) => ( +
  1. + + + {String(i + 1).padStart(2, '0')} + + + {entry.label} + + {entry.meta && ( + {entry.meta} + )} + +
  2. + ))} +
+
+
+ ); +} + +export function TableSection({ section }: { section: Extract }) { + const mono = new Set(section.monoColumns ?? []); + + // Fixed layout with declared widths, because this page stacks fifteen tables + // with the same four columns. Auto layout sizes each one to its own longest + // cell, so "Malaysia Smelting Corporation" in one table shifts every column + // out of line with the table above it. Aligned columns are what make the set + // read as one instrument rather than fifteen separate exhibits. The first + // column carries the names and takes a third; the rest divide the remainder. + const restWidth = section.columns.length > 1 ? 66 / (section.columns.length - 1) : 100; + + return ( +
+ {section.heading && ( +

{section.heading}

+ )} + {section.blurb && ( +

+ {section.blurb} +

+ )} +
+ + + {section.columns.map((column, i) => ( + + ))} + + + + {section.columns.map(column => ( + + ))} + + + + {section.rows.map((row, rowIndex) => ( + + {row.map((cell, cellIndex) => { + const isStatus = cellIndex === section.statusColumn; + const dot = isStatus ? STATUS_DOT[cell.toLowerCase()] : undefined; + return ( + + ); + })} + + ))} + +
+ {column} +
+ {dot ? ( + + + {cell} + + ) : ( + cell + )} +
+
+ {section.note &&

{section.note}

} +
+ ); +} diff --git a/src/components/sites/sections/FigureSections.tsx b/src/components/sites/sections/FigureSections.tsx new file mode 100644 index 000000000..0519bf9c3 --- /dev/null +++ b/src/components/sites/sections/FigureSections.tsx @@ -0,0 +1,87 @@ +/** + * Sections that render a number. + * + * Both use tabular mono figures so digits line up column to column — the + * difference between a page that looks measured and one that looks typed. + */ + +import React from 'react'; +import { SectionBody, SectionHeading } from './Primitives'; +import type { SiteSection } from '@/config/site-content'; + +export function StatsSection({ + section, + index, +}: { + section: Extract; + index?: number; +}) { + return ( +
+ {section.heading && {section.heading}} +
+ {section.stats.map(stat => ( +
+
+ {stat.label} +
+
+ {stat.value} +
+ {stat.note &&

{stat.note}

} +
+ ))} +
+
+ ); +} + +/** + * One number, drawn. + * + * The bar is intentionally literal: when nothing is sourced it renders empty, + * because an empty bar is the honest picture of an unfinished research phase. + * Anything that dressed a 0% up as progress would be the page lying. + */ +export function MeterSection({ + section, + index, +}: { + section: Extract; + index?: number; +}) { + const percent = section.of > 0 ? Math.round((section.value / section.of) * 100) : 0; + + return ( +
+ {section.heading && {section.heading}} + +
+ + {section.label} + + {percent}% +
+

+ {section.value} + / {section.of} +

+
+
+
+ {section.caption && ( +

+ {section.caption} +

+ )} + +
+ ); +} diff --git a/src/components/sites/sections/HeroSection.tsx b/src/components/sites/sections/HeroSection.tsx new file mode 100644 index 000000000..591507171 --- /dev/null +++ b/src/components/sites/sections/HeroSection.tsx @@ -0,0 +1,32 @@ +/** + * The opening of a page that has one. + * + * An eyebrow, one display statement, and the lead — the shape a serious + * publication uses, and deliberately not a marketing hero: no gradient, no + * illustration, no button. The claim is the design. + */ + +import React from 'react'; +import type { SiteSection } from '@/config/site-content'; + +export function HeroSection({ section }: { section: Extract }) { + return ( +
+ {section.eyebrow && ( +

+ {section.eyebrow} +

+ )} +

+ {section.statement} +

+
+ {section.lead.map((paragraph, index) => ( +

+ {paragraph} +

+ ))} +
+
+ ); +} diff --git a/src/components/sites/sections/Primitives.tsx b/src/components/sites/sections/Primitives.tsx new file mode 100644 index 000000000..c1be1a92b --- /dev/null +++ b/src/components/sites/sections/Primitives.tsx @@ -0,0 +1,46 @@ +/** + * Shared type primitives for hosted-site sections. + * + * The whole visual system of a hosted site lives in these three components plus + * the section files beside them. Typography does the work: Space Grotesk for + * display, IBM Plex Mono for anything a reader might compare (codes, counts, + * prices, statuses), Inter for prose at a reading measure. Colour is reserved — + * monochrome surfaces, the warm accent only for the current position and one + * meter fill, status colours only on actual status. + */ + +import React from 'react'; + +/** + * A numbered section heading. Research houses number their sections; it makes a + * long page navigable and signals that the document has a structure rather than + * being a stack of blocks. + */ +export function SectionHeading({ index, children }: { index?: number; children: React.ReactNode }) { + return ( +
+ {index !== undefined && ( + + {String(index).padStart(2, '0')} + + )} +

+ {children} +

+
+ ); +} + +/** Standfirst under a heading. Kept at measure — it is prose, not layout. */ +export function Blurb({ children }: { children: React.ReactNode }) { + return

{children}

; +} + +/** + * Indents a section's body to sit under the heading text rather than under its + * number. Small thing; it is what makes the numbering read as a margin note + * instead of a bullet. + */ +export function SectionBody({ children }: { children: React.ReactNode }) { + return
{children}
; +} diff --git a/src/components/sites/sections/ProseSections.tsx b/src/components/sites/sections/ProseSections.tsx new file mode 100644 index 000000000..91156f057 --- /dev/null +++ b/src/components/sites/sections/ProseSections.tsx @@ -0,0 +1,90 @@ +/** + * Sections made of words: running prose, top-ruled cards, and definitions. + * + * Cards are ruled rather than boxed. A grid of bordered boxes reads as a + * product page; a grid of hairline-topped columns reads as a document, which + * is what a research firm is publishing. + */ + +import React from 'react'; +import { Blurb, SectionBody, SectionHeading } from './Primitives'; +import type { SiteSection } from '@/config/site-content'; + +export function ProseSection({ + section, + index, +}: { + section: Extract; + index?: number; +}) { + return ( +
+ {section.heading && {section.heading}} +
+
+ {section.paragraphs.map((paragraph, i) => ( +

+ {paragraph} +

+ ))} +
+
+
+ ); +} + +export function CardsSection({ + section, + index, +}: { + section: Extract; + index?: number; +}) { + const columns = section.columns === 3 ? 'lg:grid-cols-3' : 'lg:grid-cols-2'; + return ( +
+ {section.heading && {section.heading}} + {section.blurb &&
{{section.blurb}}
} + +
+ {section.cards.map(card => ( +
+

{card.title}

+

{card.body}

+ {card.meta && ( +

{card.meta}

+ )} +
+ ))} +
+
+
+ ); +} + +export function DefinitionsSection({ + section, + index, +}: { + section: Extract; + index?: number; +}) { + return ( +
+ {section.heading && {section.heading}} + {section.blurb &&
{{section.blurb}}
} + +
+ {section.items.map(item => ( +
+
{item.term}
+
+ {item.detail} +
+
+ ))} +
+
+
+ ); +} diff --git a/src/config/domain-search.ts b/src/config/domain-search.ts new file mode 100644 index 000000000..dea66d165 --- /dev/null +++ b/src/config/domain-search.ts @@ -0,0 +1,107 @@ +/** + * Domain search — SSOT for the availability check /domains offers. + * + * Finding a name is the rung BEFORE the two services /domains already sells + * (build me a site, host it at yourname.orangecat.ch). Somebody who does not + * yet have a domain cannot buy hosting for one, so this closes the front of + * that funnel — and it is the same capability FleetCrown needs when it stands + * up a customer, which is why the check lives behind a public v1 endpoint + * rather than inside a React component. + * + * WHY THIS IS BUILT ON THE IANA BOOTSTRAP, NOT ON "404 MEANS FREE" + * + * RDAP (RFC 7482) is the registries' own structured successor to WHOIS: free, + * keyless, authoritative for the registries that run it. The naive + * implementation asks rdap.org for a domain and calls a 404 "available". + * That implementation would have told this project that ORANGECAT.CH — its own + * production domain — was free, because .ch operates no public RDAP service + * and a redirector cannot distinguish "no such domain" from "no such registry". + * The same false positive applies to .io and .co. + * + * So a TLD earns a definitive answer only by appearing in IANA's RDAP + * bootstrap. Everything else returns `unknown`, and the UI says so. A search + * tool that guesses is worse than one that admits the gap, because the guess + * is what someone acts on. + * + * Created: 2026-08-26 + */ + +/** IANA's registry of which TLDs actually run RDAP. The gate for a real answer. */ +export const RDAP_BOOTSTRAP_URL = 'https://data.iana.org/rdap/dns.json'; + +/** Redirector that forwards a domain query to the registry's own RDAP server. */ +export const RDAP_QUERY_BASE = 'https://rdap.org/domain'; + +/** Bootstrap is republished rarely; an hour of staleness costs nothing. */ +export const RDAP_BOOTSTRAP_TTL_MS = 60 * 60 * 1000; + +/** Per-domain result cache. Registrations do not change minute to minute. */ +export const DOMAIN_RESULT_TTL_MS = 10 * 60 * 1000; + +/** One lookup's ceiling. A slow registry must not hold the whole search open. */ +export const RDAP_TIMEOUT_MS = 8000; + +/** Concurrent RDAP requests. Polite to the registries, fast enough for a page. */ +export const RDAP_CONCURRENCY = 6; + +/** Hard cap on candidates per search, so one query cannot fan out unbounded. */ +export const MAX_CANDIDATES = 24; + +/** + * TLDs offered by default, best-first. + * + * `.ch` leads despite having no RDAP because this is a Swiss platform and it + * is the right domain for most of its users — it simply comes back as + * "check manually" rather than as a confident yes. + */ +export const CANDIDATE_TLDS: readonly string[] = [ + 'com', + 'ch', + 'ai', + 'io', + 'dev', + 'org', + 'net', + 'xyz', +]; + +/** + * Name shapes tried around a seed word. Deliberately short: a wall of + * machine-generated names is noise, and the good name is usually the seed + * itself or the seed plus one honest qualifier. + */ +export const NAME_PATTERNS: readonly { id: string; render: (seed: string) => string }[] = [ + { id: 'bare', render: seed => seed }, + { id: 'intel', render: seed => `${seed}intel` }, + { id: 'research', render: seed => `${seed}research` }, + { id: 'labs', render: seed => `${seed}labs` }, + { id: 'group', render: seed => `${seed}group` }, + { id: 'get', render: seed => `get${seed}` }, +]; + +/** What a lookup can conclude. `unknown` is a first-class answer, not a failure. */ +export type DomainStatus = 'registered' | 'unregistered' | 'unknown'; + +export const DOMAIN_STATUS_COPY: Record = { + registered: { + label: 'Taken', + detail: 'The registry holds a registration record for this name.', + }, + unregistered: { + label: 'No registration found', + detail: + 'The registry reports no record. Premium pricing, registry reservations and ' + + 'trademark conflicts are not visible here — confirm at a registrar before you count on it.', + }, + unknown: { + label: 'Check manually', + detail: + 'This registry runs no public RDAP service, so nothing can be concluded either way. ' + + 'A registrar lookup is the only reliable answer for this TLD.', + }, +}; + +/** Labels a domain the platform already knows about, so search never mis-sells one. */ +export const DOMAIN_SEARCH_DISCLAIMER = + 'Availability is reported from registry RDAP records, not from a registrar. ' + + 'It is not a reservation, a price, or a guarantee that a name can be registered.'; diff --git a/src/config/public-api.ts b/src/config/public-api.ts index a16b9988e..d91aa9ec3 100644 --- a/src/config/public-api.ts +++ b/src/config/public-api.ts @@ -100,6 +100,14 @@ export const PUBLIC_API_INTEGRATION_ENDPOINTS = [ methods: ['GET'] as const, endpoint: `${PUBLIC_API_BASE}/demand`, }, + { + // Domain availability. Public and keyless — registry RDAP records are + // public. Shared with FleetCrown so the "a .ch not-found proves nothing" + // rule is enforced server-side once, not re-implemented per client. + name: 'domains', + methods: ['GET'] as const, + endpoint: `${PUBLIC_API_BASE}/domains`, + }, ] as const; /** diff --git a/src/config/routes.ts b/src/config/routes.ts index 8352e4d59..4538fd7bc 100644 --- a/src/config/routes.ts +++ b/src/config/routes.ts @@ -111,7 +111,7 @@ export type RouteContext = 'authenticated' | 'public' | 'universal' | 'auth' | ' // the matching list below. Both layers exist so source-tree organisation // and runtime classification can never silently drift. -export type RouteSurface = 'app' | 'public' | 'auth'; +export type RouteSurface = 'app' | 'public' | 'auth' | 'site'; const APP_SURFACES = [ '/dashboard', @@ -152,6 +152,14 @@ const APP_SURFACES = [ const AUTH_SURFACES = ['/auth'] as const; +// A hosted site is somebody else's website that happens to run on our +// infrastructure (see src/config/sites.ts). It carries none of OrangeCat's +// chrome — no header, no sidebar, no footer, no marketing nav — because on +// substrate.orangecat.ch the visitor is not on OrangeCat, they are on +// Substrate. This is a fourth surface rather than a chrome override precisely +// so that nothing can accidentally opt it back into the app shell. +const SITE_SURFACES = ['/sites'] as const; + /** O(n) prefix-match against a sorted list. Pathname like `/discover/123` * matches `'/discover'`. Exact `/` matches only `'/'`. */ @@ -169,6 +177,9 @@ function matchesPrefix(pathname: string, routes: readonly string[]): boolean { * header variant, and footer must all derive from this. */ export function getRouteSurface(pathname: string): RouteSurface { + if (matchesPrefix(pathname, SITE_SURFACES)) { + return 'site'; + } if (matchesPrefix(pathname, AUTH_SURFACES)) { return 'auth'; } diff --git a/src/config/site-content.ts b/src/config/site-content.ts new file mode 100644 index 000000000..2427f4128 --- /dev/null +++ b/src/config/site-content.ts @@ -0,0 +1,153 @@ +/** + * Hosted-site content model. + * + * A hosted site is a list of pages, and a page is a list of sections drawn + * from a small closed set of shapes. That constraint is the design: it means a + * profile can produce a whole website out of the structured data it already + * has, without anybody hand-writing JSX per customer, and it means every + * hosted site inherits typography, spacing and dark mode from one renderer + * instead of drifting into fifty bespoke stylesheets. + * + * Adding a site is therefore: an entry in `sites.ts`, and a function that + * returns `SitePage[]`. Substrata's lives in `site-substrata.ts` and is built + * entirely from the config the profile already needed — the mandate, the + * desks, the catalogue, the coverage universe. Nothing on the website is + * authored twice. + * + * Created: 2026-08-26 + */ + +import type { HostedSite } from './sites'; +import { substrataSiteChrome, substrataSitePages } from './site-substrata'; + +// ===================================================================== +// SECTIONS +// ===================================================================== + +/** A stat worth putting at the top of a page. */ +export interface SiteStat { + label: string; + value: string; + note?: string; +} + +export interface SiteCard { + title: string; + body: string; + /** Short trailing line — a price, a status, a jurisdiction. */ + meta?: string; +} + +export interface SiteDefinition { + term: string; + detail: string; +} + +export interface SiteIndexEntry { + label: string; + /** Small trailing figure — a count, a desk name. Rendered in mono. */ + meta?: string; + /** Fragment this entry jumps to; must match a section's `anchor`. */ + anchor: string; +} + +export type SiteSection = + /** + * Opens a page in place of the standard title block. An eyebrow, one display + * statement, and the lead. Only ever the FIRST section of a page — the shell + * checks for it and suppresses its own header so the two cannot both render. + */ + | { kind: 'hero'; eyebrow?: string; statement: string; lead: string[] } + | { kind: 'prose'; heading?: string; paragraphs: string[] } + | { kind: 'stats'; heading?: string; stats: SiteStat[] } + /** + * One number that deserves a picture. Used for research coverage, where the + * gap between `value` and `of` IS the message — an empty bar is the honest + * rendering of "nothing sourced yet", and it should look empty. + */ + | { kind: 'meter'; heading?: string; label: string; value: number; of: number; caption?: string } + | { kind: 'cards'; heading?: string; blurb?: string; columns?: 2 | 3; cards: SiteCard[] } + | { kind: 'definitions'; heading?: string; blurb?: string; items: SiteDefinition[] } + /** Jump list for a long page. Without one, fifteen tables is a scroll, not a document. */ + | { kind: 'index'; heading?: string; blurb?: string; entries: SiteIndexEntry[] } + | { + kind: 'table'; + heading?: string; + blurb?: string; + /** Fragment id, so an `index` entry can link straight here. */ + anchor?: string; + columns: string[]; + rows: string[][]; + /** Column indices rendered in mono — codes, figures, statuses. */ + monoColumns?: number[]; + /** Column index whose cell text is also a status keyword to dot-colour. */ + statusColumn?: number; + note?: string; + }; + +// ===================================================================== +// PAGES +// ===================================================================== + +export interface SitePage { + /** '' is the home page; otherwise a single path segment, e.g. 'map'. */ + path: string; + /** Label in the site's own navigation. Omit to keep a page out of the nav. */ + navLabel?: string; + /** and the page's h1. */ + title: string; + /** One line under the h1. */ + intro?: string; + sections: SiteSection[]; +} + +/** Everything the site chrome needs: the masthead, the nav, the footer. */ +export interface SiteChrome { + name: string; + tagline: string; + /** Line in the footer — who runs this and under what rules. */ + footerNote: string; +} + +// ===================================================================== +// DISPATCH +// ===================================================================== + +/** + * With one hosted site this is a switch, and it should stay a switch until + * there are three — at which point the shape of the third will show whether + * the right abstraction is a registry of builders or something else. Guessing + * now would be inventing a CMS for a customer base of one. + */ +export function sitePagesFor(site: HostedSite): SitePage[] { + switch (site.slug) { + case 'substrata': + return substrataSitePages(); + default: + return []; + } +} + +export function siteChromeFor(site: HostedSite): SiteChrome | null { + switch (site.slug) { + case 'substrata': + return substrataSiteChrome(); + default: + return null; + } +} + +/** + * True when the page opens with its own hero, in which case the shell must not + * also print a title block. One rule, checked in one place, so a page can never + * render two competing headers. + */ +export function pageRendersOwnHeader(page: SitePage): boolean { + return page.sections[0]?.kind === 'hero'; +} + +/** @returns the page at this path within the site, or null. */ +export function sitePageAt(site: HostedSite, path: string): SitePage | null { + const normalised = path.replace(/^\/+|\/+$/g, ''); + return sitePagesFor(site).find(page => page.path === normalised) ?? null; +} diff --git a/src/config/site-substrata.ts b/src/config/site-substrata.ts new file mode 100644 index 000000000..acee8736b --- /dev/null +++ b/src/config/site-substrata.ts @@ -0,0 +1,662 @@ +/** + * Substrata's website, generated from its OrangeCat profile. + * + * Every word and number below comes from `substrata.ts` and + * `substrata-coverage.ts` — the same objects the group profile, the product + * catalogue and the Cat read. There is no second copy of the mandate, no + * duplicated price list, and no separately-maintained "about" text. Change the + * profile and the website changes with it, which is the whole claim /domains + * is making when it says a profile can spin up a working site. + * + * That constraint is also why the site is honest: the coverage page cannot + * quietly present unsourced research leads as findings, because the only data + * it has is the same data the tests hold to `source: null`. + * + * Created: 2026-08-26 + */ + +import { + CHOKEPOINT_TEST, + COMPANY, + DISCLOSURE, + EXCLUSION_RULE, + LISTING_COPY, + MANDATE_CURVES, + MATERIALS, + NODE_TYPES, + PHASES, + SCOPE, + areaFor, +} from './substrata'; +import { + ACTING_LIMITS, + ACTION_ROUTES, + INVESTMENT_THESIS, + READINESS, + READINESS_STATUS_LABEL, + readinessProgress, +} from './substrata-acting'; +import { + CHAIN_LAYERS, + SCARCITY_DETAIL, + SCARCITY_LABEL, + bindingParticipants, + participantProgress, + participantsInLayer, +} from './substrata-participants'; +import { + CHOKEPOINTS, + COVERAGE, + NODE_TYPE_LABEL, + PRODUCER_ROLES, + chokepointProgress, + coverageProgress, +} from './substrata-coverage'; +import type { SiteChrome, SitePage, SiteSection } from './site-content'; + +const ROLE_LABEL: Record<string, string> = Object.fromEntries( + PRODUCER_ROLES.map(role => [role.id, role.label]) +); + +export function substrataSiteChrome(): SiteChrome { + return { + name: COMPANY.name, + tagline: COMPANY.tagline, + footerNote: + `${COMPANY.name} publishes research. It does not trade, broker or quote, holds no ` + + 'position in anything it covers, and nothing here is an offer or investment advice. ' + + 'Rows marked unverified are research leads, not findings.', + }; +} + +// ===================================================================== +// HOME +// ===================================================================== + +function homePage(): SitePage { + const progress = coverageProgress(); + const activePhases = PHASES.filter(phase => phase.status === 'active'); + const companies = new Set(COVERAGE.flatMap(entry => entry.producers.map(p => p.name))).size; + + return { + path: '', + navLabel: 'Home', + title: COMPANY.name, + sections: [ + { + kind: 'hero', + eyebrow: 'Open-source research · Materials desk', + // The proposition, not the company name. A visitor who reads one line + // should know what this firm does and what it refuses to do. + statement: COMPANY.tagline, + // The first two paragraphs only: the proposition and the rule that + // bounds it. The remaining two — which phase is running, and how + // quotes work — are said properly by the phase block below and by the + // desk page, and a lead that repeats them is a lead nobody finishes. + lead: LISTING_COPY.body.slice(0, 2), + }, + { + kind: 'stats', + heading: 'Where the research stands', + stats: [ + { + label: 'Materials covered', + value: String(MATERIALS.length), + note: 'Each one a chokepoint, not a commodity.', + }, + { + label: 'Producers identified', + value: String(progress.total), + note: `Across ${companies} distinct companies.`, + }, + { + label: 'Non-material chokepoints', + value: String(CHOKEPOINTS.length), + note: 'Tools, capacity, queues and know-how that gate the same curves.', + }, + ], + }, + { + kind: 'meter', + heading: 'Coverage', + label: 'Producer rows confirmed against a primary source', + value: progress.sourced, + of: progress.total, + caption: + 'Phase 1 completes when these match. The bar is drawn from the same data the ' + + 'map is drawn from, so it cannot flatter the work — an unfinished phase looks ' + + 'unfinished here.', + }, + { + kind: 'cards', + heading: 'The two tests', + blurb: + 'A node enters coverage, or the book, only if it passes both. Failing either is a ' + + 'decline — and we decline every week.', + columns: 3, + cards: MANDATE_CURVES.map(curve => ({ + title: curve.label, + body: curve.detail, + meta: curve.test, + })), + }, + { + kind: 'definitions', + heading: 'The chokepoint screen', + blurb: EXCLUSION_RULE.rule, + items: CHOKEPOINT_TEST.map(factor => ({ + term: factor.question, + detail: factor.detail, + })), + }, + { + kind: 'definitions', + heading: 'Running now', + blurb: + 'Two phases at once. A desk is not one of them — see Disclosure for what this ' + + 'firm does and does not do.', + items: activePhases.map(phase => ({ term: phase.label, detail: phase.detail })), + }, + ], + }; +} + +// ===================================================================== +// MANDATE +// ===================================================================== + +function mandatePage(): SitePage { + return { + path: 'mandate', + navLabel: 'Mandate', + title: 'Mandate', + intro: EXCLUSION_RULE.rule, + sections: [ + { kind: 'prose', paragraphs: [EXCLUSION_RULE.explainer] }, + { + kind: 'cards', + heading: 'Three curves', + columns: 3, + cards: MANDATE_CURVES.map(curve => ({ + title: curve.label, + body: curve.detail, + meta: curve.test, + })), + }, + { + kind: 'definitions', + heading: 'The chokepoint screen', + blurb: + 'Being on a curve is not enough. Most of a supply chain is substitutable, and ' + + 'therefore uninteresting. A node earns coverage when it gates a curve.', + items: CHOKEPOINT_TEST.map(factor => ({ term: factor.question, detail: factor.detail })), + }, + { + kind: 'cards', + heading: 'What counts as a node', + blurb: + 'The unit of coverage is a chokepoint, not an asset class. This is how the universe ' + + 'reaches robotics, AI hardware and additive manufacturing without becoming ' + + '“everything”: you arrive at them by tracing a chain you were already mapping.', + columns: 3, + cards: NODE_TYPES.map(node => ({ title: node.label, body: node.detail })), + }, + { + kind: 'definitions', + heading: 'Sequence', + blurb: + 'Research first, because it costs nothing but attention and it is the sourcing work the desk needs anyway.', + items: PHASES.map(phase => ({ + term: `${phase.label}${phase.status === 'active' ? ' — active' : ''}`, + detail: phase.detail, + })), + }, + ], + }; +} + +// ===================================================================== +// THE MAP +// ===================================================================== + +/** Stable fragment id for a material, so the index can link into the tables. */ +function materialAnchor(material: string): string { + return material + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-|-$/g, ''); +} + +function mapPage(): SitePage { + const progress = coverageProgress(); + + // The material's own research — why it gates, and which grade actually ships + // — used to live on the desk page. With no desk there is no desk page, and + // that content belongs here anyway: it is research, not a product listing. + const materialById = new Map(MATERIALS.map(material => [material.title, material])); + + const materialTables: SiteSection[] = COVERAGE.map(entry => { + const material = materialById.get(entry.material); + return { + kind: 'table' as const, + heading: entry.material, + anchor: materialAnchor(entry.material), + blurb: material + ? `${entry.thesis} Traded grade: ${material.spec} · ${areaFor(material).name}` + : entry.thesis, + columns: ['Company', 'Jurisdiction', 'Step in the chain', 'Status'], + // Jurisdiction codes and statuses are scanned down a column, not read + // across a row — mono keeps them aligned and comparable. + monoColumns: [1], + statusColumn: 3, + rows: entry.producers.map(producer => [ + producer.name, + producer.jurisdictions.join(' '), + ROLE_LABEL[producer.role] ?? producer.role, + producer.source ? 'Sourced' : 'Unverified lead', + ]), + }; + }); + + return { + path: 'map', + navLabel: 'The map', + title: 'The map', + intro: 'Every qualified producer of the fifteen materials on the desk.', + sections: [ + { + kind: 'prose', + paragraphs: [ + 'This is Phase 1, published as it is built rather than when it is finished. Each row ' + + 'asserts three things and no more: a company’s name, where it operates, and which ' + + 'step of the chain it occupies. There is no column for capacity, market share or ' + + 'revenue, because this firm has not sourced those numbers — and a table that ' + + 'implies otherwise is worse than an empty one.', + 'A row marked “unverified lead” is exactly that: a research lead we believe is right ' + + 'and have not yet confirmed against a primary source. It is not a finding. When an ' + + 'analyst attaches the source, the row flips to “sourced” and the meter below moves. ' + + 'That meter is the honest measure of how far along this is.', + 'Corrections are the reason this is public. If you work in one of these chains and a ' + + 'row is wrong, telling us makes the map better for everyone who reads it next.', + ], + }, + { + kind: 'meter', + heading: 'Coverage', + label: 'Producer rows confirmed against a primary source', + value: progress.sourced, + of: progress.total, + caption: `${COVERAGE.length} materials, ${progress.total} producer rows. Phase 1 completes when every row carries a source.`, + }, + { + kind: 'index', + heading: 'Materials', + blurb: 'Fifteen chokepoints. The number beside each is how many producers are mapped.', + entries: COVERAGE.map(entry => ({ + label: entry.material, + meta: String(entry.producers.length), + anchor: materialAnchor(entry.material), + })), + }, + ...materialTables, + ], + }; +} + +// ===================================================================== +// CHOKEPOINTS BEYOND MATERIALS +// ===================================================================== + +function chokepointsPage(): SitePage { + const progress = chokepointProgress(); + + // Cards, not a table. A table of names and country codes scans nicely and + // says nothing — the claim IS the reason it gates, and a research page that + // hides its reasoning behind a tidy grid has published a list, not research. + const byCurve = MANDATE_CURVES.map(curve => ({ + kind: 'cards' as const, + heading: curve.label, + blurb: curve.detail, + columns: 2 as const, + cards: CHOKEPOINTS.filter(point => point.curve === curve.id).map(point => ({ + title: point.name, + body: point.why, + meta: [ + NODE_TYPE_LABEL[point.type], + point.jurisdictions.length ? point.jurisdictions.join(' ') : 'Not geographic', + point.source ? 'Sourced' : 'Unverified lead', + ].join(' · '), + })), + })); + + return { + path: 'chokepoints', + navLabel: 'Chokepoints', + title: 'Chokepoints', + intro: 'The constraints that are not materials — and often are the tighter ones.', + sections: [ + { + kind: 'prose', + paragraphs: [ + 'A material is only one kind of chokepoint. For the compute and power curves it ' + + 'is frequently not the binding one: a lithography tool with a single supplier, ' + + 'packaging capacity allocated years before it is used, a transformer order ' + + 'book, an interconnection queue, and process knowledge that does not transfer ' + + 'when a competitor buys the same equipment all gate the same three curves — ' + + 'and none of them appears on a periodic table.', + 'They enter coverage on exactly the tests a material does: does it move a curve, ' + + 'and does it genuinely gate it. The unit of coverage was never the substance. ' + + 'It is the constraint.', + 'Each row claims what the node is and why it gates, and nothing about capacity, ' + + 'market share or price — the same rule as the producer map, for the same ' + + 'reason. Rows read “unverified lead” until an analyst attaches a primary source.', + ], + }, + { + kind: 'stats', + heading: 'The universe so far', + stats: [ + { label: 'Materials', value: String(MATERIALS.length) }, + { label: 'Non-material chokepoints', value: String(progress.total) }, + { + label: 'Kinds of node', + value: String(new Set(CHOKEPOINTS.map(point => point.type)).size), + note: 'Machines, processes, companies and people.', + }, + ], + }, + { + kind: 'meter', + heading: 'Verification', + label: 'Chokepoint rows confirmed against a primary source', + value: progress.sourced, + of: progress.total, + caption: + 'Newer than the producer map and further from finished. Published anyway, ' + + 'because a lead somebody can correct is worth more than a note nobody sees.', + }, + ...byCurve, + ], + }; +} + +// ===================================================================== +// THESIS +// ===================================================================== + +function thesisPage(): SitePage { + return { + path: 'thesis', + navLabel: 'Thesis', + title: 'Thesis', + intro: 'What we think follows from the map — stated so it can be scored, not admired.', + sections: [ + { + kind: 'prose', + paragraphs: [ + 'A research house is supposed to have a view and be judged on it. Each claim ' + + 'below carries a falsifier: the thing that, if observed, would show it to be ' + + 'wrong. A thesis without one is a slogan, and a slogan cannot be scored.', + 'This is a general view published to whoever reads it. It is not advice, it is ' + + 'not addressed to anyone in particular, and it takes no account of your ' + + 'circumstances — see Acting on it for what that does and does not permit.', + ], + }, + ...INVESTMENT_THESIS.map(claim => ({ + kind: 'definitions' as const, + heading: claim.claim, + blurb: claim.detail, + items: [{ term: 'What would prove this wrong', detail: claim.falsifier }], + })), + ], + }; +} + +// ===================================================================== +// PARTICIPANTS +// ===================================================================== + +function participantsPage(): SitePage { + const progress = participantProgress(); + const binding = bindingParticipants(); + + const layerTables: SiteSection[] = CHAIN_LAYERS.flatMap(layer => { + const rows = participantsInLayer(layer.id); + if (rows.length === 0) { + return []; + } + return [ + { + kind: 'table' as const, + heading: layer.name, + anchor: layer.id, + blurb: layer.detail, + columns: ['Participant', 'Role in the chain', 'Where', 'Scarcity'], + monoColumns: [2], + statusColumn: 3, + rows: rows.map(item => [ + item.name, + item.role, + item.jurisdictions.join(' '), + SCARCITY_LABEL[item.scarcity], + ]), + }, + ]; + }); + + return { + path: 'participants', + navLabel: 'Participants', + title: 'Participants', + intro: 'Everyone in the chain from ore to buyer, graded by how hard they are to replace.', + sections: [ + { + kind: 'prose', + paragraphs: [ + 'A directory of everyone in a supply chain is a phone book. What makes this ' + + 'research is the last column: for each participant, whether it is a chokepoint, ' + + 'merely concentrated, or genuinely competitive.', + 'Grading participants “competitive” is not filler. It is the thing that makes ' + + '“chokepoint” mean anything — a map on which every node is critical is a map ' + + 'nobody has actually read. Several well-known names in this list are here ' + + 'precisely because they are not constraints.', + 'On the demand side the grade reads the other way round: when a handful of firms ' + + 'account for most of the world’s orders, that concentration is a scarcity fact ' + + 'about the chain too, pointing upstream instead of down.', + ], + }, + { + kind: 'stats', + stats: [ + { + label: 'Participants mapped', + value: String(progress.total), + note: `Across ${CHAIN_LAYERS.length} layers and ${progress.jurisdictions} jurisdictions.`, + }, + { + label: 'Graded a chokepoint', + value: String(progress.chokepoints), + note: 'One or very few qualified suppliers, and no substitute that arrives in time.', + }, + { + label: 'Graded competitive', + value: String(progress.competitive), + note: 'In the chain, and not a constraint on it. Saying so is the point.', + }, + ], + }, + { + kind: 'definitions', + heading: 'How the grades are assigned', + blurb: + 'The mandate’s own screen — concentration, substitutability, lead time, demand ' + + 'inelasticity — applied one participant at a time.', + items: (Object.keys(SCARCITY_DETAIL) as Array<keyof typeof SCARCITY_DETAIL>).map(grade => ({ + term: SCARCITY_LABEL[grade], + detail: SCARCITY_DETAIL[grade], + })), + }, + { + kind: 'cards', + heading: 'Where the chain actually binds', + blurb: + `${binding.length} of ${progress.total} participants are graded a hard constraint. ` + + 'These are the ones worth knowing by name.', + columns: 2, + cards: binding.map(item => ({ + title: item.name, + body: item.why, + meta: `${item.role} · ${item.jurisdictions.join(' ')}`, + })), + }, + { + kind: 'index', + heading: 'The chain, layer by layer', + blurb: 'Ordered upstream to downstream. The number is how many participants are mapped.', + entries: CHAIN_LAYERS.map(layer => ({ + label: layer.name, + meta: String(participantsInLayer(layer.id).length), + anchor: layer.id, + })), + }, + ...layerTables, + ], + }; +} + +// ===================================================================== +// ACTING ON IT +// ===================================================================== + +function actingPage(): SitePage { + const progress = readinessProgress(); + + return { + path: 'acting', + navLabel: 'Acting on it', + title: 'Acting on it', + intro: 'What you can do with this research, and what we are not allowed to do for you.', + sections: [ + { + kind: 'definitions', + heading: 'Read this first', + blurb: + 'People read research and want to act on it — that is why it is published. But ' + + 'the line between publishing a view and advising a person is a legal one, and ' + + 'this firm sits firmly on the publishing side of it.', + items: ACTING_LIMITS.map((limit, index) => ({ + term: `Limit ${index + 1}`, + detail: limit, + })), + }, + ...ACTION_ROUTES.map(route => ({ + kind: 'definitions' as const, + heading: route.name, + blurb: route.gives, + items: [ + { term: 'Who provides it', detail: route.providedBy }, + { term: 'What it does not give you', detail: route.doesNotGive }, + { term: 'Worth asking', detail: route.ask.join(' · ') }, + ], + })), + { + kind: 'prose', + heading: 'What we can do if you get in touch', + paragraphs: [ + 'We can answer questions about the research: why a node is in the universe, ' + + 'what a grade designation means, who else makes something, what we have and ' + + 'have not verified. We are glad to be told a row is wrong, and that is the ' + + 'most useful message anyone sends us.', + 'We cannot tell you what to buy, how much, or when. Not because of caution but ' + + 'because doing so would be a licensed activity we are not licensed for, and ' + + 'a firm that quietly crosses that line has told you exactly how much its ' + + 'other statements are worth.', + ], + }, + { + kind: 'meter', + heading: 'Becoming the investor', + label: 'Requirements met to manage money rather than only publish', + value: progress.done, + of: progress.total, + caption: + `${progress.done} done, ${progress.inProgress} in progress. Managing third-party ` + + 'money is licensed activity everywhere that matters, and the gap below is real ' + + 'rather than paperwork. It is published for the same reason the coverage meter ' + + 'is: a plan with statuses is a plan, and everything else is a feeling.', + }, + { + // Definitions, not a table. Three columns where one holds a paragraph + // is a table that scrolls sideways on a phone and is read by nobody; + // the status belongs next to the requirement, not in a column of its own. + kind: 'definitions', + heading: 'The ledger', + items: READINESS.map(item => ({ + term: `${item.requirement} — ${READINESS_STATUS_LABEL[item.status]}`, + detail: item.detail, + })), + }, + ], + }; +} + +// ===================================================================== +// DISCLOSURE +// ===================================================================== + +function disclosurePage(): SitePage { + return { + path: 'disclosure', + navLabel: 'Disclosure', + title: 'Disclosure', + intro: 'What this firm does, what it does not do, and what it holds.', + sections: [ + { + kind: 'definitions', + heading: 'Today', + blurb: DISCLOSURE.today, + items: SCOPE.today.map((rule, index) => ({ term: `Position ${index + 1}`, detail: rule })), + }, + { + kind: 'prose', + heading: 'On the desk that does not exist', + paragraphs: [ + 'Acting on this map rather than only publishing it would mean a regulated book: ' + + 'licensing, compliance, capital and counterparty onboarding, none of it quick ' + + 'and none of it started. It is an intention. Until it is real, no page here ' + + 'carries a price, a lot size or an invitation to deal, because a firm that ' + + 'advertises a capability it does not have has already told you how much its ' + + 'other claims are worth.', + 'The rules below are written now, in advance, for the reason a disclosure policy ' + + 'is only ever credible before it is needed. Written after the first position, ' + + 'every clause reads as a response to something.', + ], + }, + { + kind: 'definitions', + heading: 'The rules, when there is something to disclose', + items: DISCLOSURE.rules.map((rule, index) => ({ + term: `Rule ${index + 1}`, + detail: rule, + })), + }, + { kind: 'prose', heading: 'Why it is free', paragraphs: [DISCLOSURE.openByDefault] }, + { kind: 'prose', heading: 'Out of scope', paragraphs: [SCOPE.outOfScope] }, + ], + }; +} + +// ===================================================================== + +export function substrataSitePages(): SitePage[] { + return [ + homePage(), + mandatePage(), + thesisPage(), + mapPage(), + chokepointsPage(), + participantsPage(), + actingPage(), + disclosurePage(), + ]; +} diff --git a/src/config/sites.ts b/src/config/sites.ts new file mode 100644 index 000000000..093ea66ae --- /dev/null +++ b/src/config/sites.ts @@ -0,0 +1,136 @@ +/** + * Hosted sites — SSOT for "a profile spins up a whole website". + * + * /domains sells this: "a working site, hosted and managed at + * yourname.orangecat.ch — free. Move to your own domain when you are ready." + * This file is the mechanism behind that sentence. It maps a HOSTNAME to an + * OrangeCat profile, and `src/middleware.ts` rewrites the request onto + * `/sites/<slug>`, where a standalone website renders from that profile's own + * structured data. + * + * The point is that the website is not separately authored. Substrate's + * mandate, desks, catalogue and coverage universe already exist as config + * because the profile needed them; the site is those same objects rendered for + * a different audience on a different domain. Adding a second site is an entry + * in HOSTED_SITES plus a content builder — not a new codebase. + * + * Three ways a request reaches a site, all resolved by `siteForHost`: + * substrata.orangecat.ch — the free subdomain every hosted site starts on + * substrata.example — a custom domain, once the owner points DNS + * substrata.localhost:3020 — local development, so the rewrite is testable + * + * The path form `/sites/substrata` always works too, on any host. That is what + * makes the site previewable before DNS exists, and it is the URL the + * screenshots and the E2E tests use. + * + * Created: 2026-08-26 + */ + +/** The domain every hosted site gets a free subdomain on. */ +export const SITES_BASE_DOMAIN = 'orangecat.ch'; + +/** URL prefix the middleware rewrites a matched host onto. */ +export const SITES_PATH_PREFIX = '/sites'; + +export interface HostedSite { + /** Path segment and registry key: /sites/<slug>. */ + slug: string; + /** Free subdomain: <subdomain>.orangecat.ch. */ + subdomain: string; + /** Custom domain once DNS is pointed here, else null. Canonical when set. */ + customDomain: string | null; + /** + * Extra hostnames this site also answers on. Wired ahead of ownership on + * purpose: a host only ever reaches this check if somebody has already + * pointed DNS at our box, so listing a domain we do not yet hold costs + * nothing and means the site works the hour it is bought, with no deploy. + * Never canonical — `siteCanonicalHost` ignores these. + */ + aliasHosts?: readonly string[]; + /** Browser tab title / OG title for the whole site. */ + title: string; + /** The OrangeCat profile this site renders. */ + profile: { + kind: 'group'; + /** `groups.slug` — the profile at /groups/<slug>. */ + slug: string; + }; +} + +export const HOSTED_SITES: readonly HostedSite[] = [ + { + slug: 'substrata', + subdomain: 'substrata', + customDomain: null, + // substrata.ch is the intended home and appears free — .ch publishes no + // RDAP, so that has to be confirmed at a registrar rather than by us. + // substrataintel.com is the .com fallback, since substrata.com is taken. + aliasHosts: ['substrata.ch', 'substrataintel.com'], + title: 'Substrata', + profile: { kind: 'group', slug: 'substrata' }, + }, +]; + +/** Strip the port and lowercase, so `Substrate.localhost:3020` matches. */ +function normaliseHost(host: string): string { + return host.trim().toLowerCase().split(':')[0]; +} + +/** + * Resolve a request Host header to a hosted site. + * + * Deliberately pure and allocation-light: this runs in edge middleware on + * every request, so it may not touch the database. A site that exists in the + * database but not in this list simply does not resolve — which is the safe + * direction, since the alternative is a DB read in the hot path of every + * request to the main app. + */ +export function siteForHost(host: string | null | undefined): HostedSite | null { + if (!host) { + return null; + } + const hostname = normaliseHost(host); + const bare = hostname.startsWith('www.') ? hostname.slice(4) : hostname; + + for (const site of HOSTED_SITES) { + if (bare === `${site.subdomain}.${SITES_BASE_DOMAIN}`) { + return site; + } + // Local development: substrate.localhost resolves to 127.0.0.1 in every + // major browser, which makes the rewrite testable without touching DNS. + if (bare === `${site.subdomain}.localhost`) { + return site; + } + if (site.customDomain && bare === normaliseHost(site.customDomain)) { + return site; + } + if (site.aliasHosts?.some(alias => bare === normaliseHost(alias))) { + return site; + } + } + return null; +} + +/** @returns the hosted site with this slug, or null. */ +export function siteBySlug(slug: string): HostedSite | null { + return HOSTED_SITES.find(site => site.slug === slug) ?? null; +} + +/** + * Build an in-site link. + * + * Always emits the `/sites/<slug>/...` path form rather than an absolute URL on + * the site's own domain. On the custom domain the middleware rewrite means this + * path resolves to the same page, and on orangecat.ch it is the only form that + * works at all — so one form is correct everywhere and no link needs to know + * which host it was rendered on. + */ +export function siteHref(site: HostedSite, path = ''): string { + const suffix = path && path !== '/' ? `/${path.replace(/^\/+/, '')}` : ''; + return `${SITES_PATH_PREFIX}/${site.slug}${suffix}`; +} + +/** The public address the site advertises as its own. */ +export function siteCanonicalHost(site: HostedSite): string { + return site.customDomain ?? `${site.subdomain}.${SITES_BASE_DOMAIN}`; +} diff --git a/src/config/substrata-acting.ts b/src/config/substrata-acting.ts new file mode 100644 index 000000000..76cff794c --- /dev/null +++ b/src/config/substrata-acting.ts @@ -0,0 +1,361 @@ +/** + * Substrata — the investment thesis, how a reader can act on it, and the + * ledger of what standing up a fund would actually require. + * + * THE REGULATORY LINE THIS FILE EXISTS TO HOLD + * + * People will read this research and want to do something with it. That is the + * point of publishing it. But Substrata is not registered, licensed or + * supervised anywhere, and three things follow that no amount of product + * ambition may erode: + * + * 1. NO ADVICE. Publishing an impersonal, generally-circulated view is + * research. Telling a particular person what to buy is advice, and advice + * is licensed almost everywhere. Nothing here may be personalised. + * 2. NO EXECUTION. Substrata does not hold client money or assets, does not + * receive or transmit orders, and does not arrange deals. + * 3. NOBODY PAYS TO BE HERE. The participant directory and the routes below + * are research output, not placements. Keeping them unpaid is what makes + * them worth reading — a directory somebody bought their way into is an + * advertisement wearing a table's clothes. + * + * So this file holds a stated view, an honest description of the routes by + * which anyone acts in these markets, the questions worth asking, and a public + * record of how far the firm is from being able to act on its own behalf. + * + * Created: 2026-08-26 + */ + +// ===================================================================== +// THE HARD LIMITS — stated on the page, not just in a footer +// ===================================================================== + +export const ACTING_LIMITS: readonly string[] = [ + 'Substrata publishes research. It does not manage money, hold client assets, ' + + 'execute orders, or take a position in anything it covers.', + 'Everything here is a general view, published to whoever reads it. It is not ' + + 'addressed to you, and it takes no account of your circumstances.', + 'Substrata is paid nothing by any participant in this directory. If that ever ' + + 'changes it will be written here before the arrangement starts.', +]; + +// ===================================================================== +// INVESTMENT THESIS +// +// A view, held in public, impersonal and generally circulated. This is the +// thing a research house is supposed to have and be judged on — and stating it +// plainly is also what makes a track record possible later, since a thesis +// nobody wrote down cannot be scored. +// ===================================================================== + +export interface ThesisClaim { + id: string; + claim: string; + detail: string; + /** What would show this to be wrong. A thesis without one is a slogan. */ + falsifier: string; +} + +export const INVESTMENT_THESIS: readonly ThesisClaim[] = [ + { + id: 'bottleneck-migrates', + claim: 'The bottleneck is rarely where the attention is.', + detail: + 'Attention, and therefore price, concentrates on the visible layer — the model, ' + + 'the accelerator, the hyperscaler. The binding constraint usually sits one or two ' + + 'layers below it, in a company nobody writes about. The gap between where a chain ' + + 'is priced and where it actually binds is the whole reason to map it.', + falsifier: + 'If constraints resolved at the visible layer — if compute output tracked chip ' + + 'design announcements rather than packaging, power and tooling — the map would be ' + + 'describing a chain that no longer gates anything.', + }, + { + id: 'chokepoints-are-not-commodities', + claim: 'A chokepoint does not price like a commodity.', + detail: + 'When both demand and supply are inelastic, price is set by scarcity rather than ' + + 'by cost of production, and it moves in jumps. Cost-plus intuition — the instinct ' + + 'that says a material cannot be worth many times its input cost — systematically ' + + 'misprices exactly the nodes this firm covers.', + falsifier: + 'Sustained periods where chokepoint prices track production cost, with substitution ' + + 'or new entry arriving fast enough to cap them.', + }, + { + id: 'energy-binds-first', + claim: 'This decade, energy binds before silicon does.', + detail: + 'Compute is being announced faster than it can be energised. Transformer order ' + + 'books, interconnection queues and turbine slots clear on a slower clock than fab ' + + 'construction, and none of them can be accelerated with capital alone.', + falsifier: + 'Interconnection queues and transformer lead times shortening while announced ' + + 'datacentre capacity keeps rising — energy ceasing to be the thing that slips.', + }, + { + id: 'substitution-is-slow', + claim: '“There is an alternative” is usually false on the horizon that matters.', + detail: + 'Qualification is measured in years: a second-source material, tool or resist has ' + + 'to be proven per process, per fab, per application. A substitute that exists in a ' + + 'laboratory and a substitute that is qualified are different facts, and only the ' + + 'second one relieves a constraint.', + falsifier: + 'Qualification cycles compressing materially — second sources reaching production ' + + 'in quarters rather than years.', + }, + { + id: 'concentration-is-political', + claim: 'Supply concentration is now a policy variable, in both directions.', + detail: + 'Export controls made geography a first-order term in these chains. That cuts both ' + + 'ways: restriction raises the value of what is restricted, and subsidised ' + + 're-shoring can destroy the scarcity that made a node interesting in the first place.', + falsifier: + 'A durable de-escalation in which controls are lifted and re-shoring programmes ' + + 'deliver qualified capacity at scale.', + }, + { + id: 'the-map-compounds', + claim: 'The map compounds. A position does not.', + detail: + 'Any single view can be wrong and is eventually closed. Knowing every qualified ' + + 'producer of a material, and being told when a row is wrong by someone who works ' + + 'in that chain, is an asset that accumulates. That is why the research is ' + + 'published rather than sold, and why it comes before any book.', + falsifier: + 'The map failing to attract corrections — no practitioner engagement — which would ' + + 'mean it is not compounding, only ageing.', + }, +]; + +// ===================================================================== +// ROUTES — how anyone acts in these markets +// +// Descriptions of market structure, not recommendations, and deliberately +// written to include what each route does NOT give you. A reader who takes a +// commodity ETF for exposure to seven-nines tin has been misled by omission, +// and omission is the failure mode a page like this actually has. +// ===================================================================== + +export interface ActionRoute { + id: string; + name: string; + /** Who provides it — a category of firm, never a named one. */ + providedBy: string; + gives: string; + /** The mismatch between this route and what the research is actually about. */ + doesNotGive: string; + /** Questions worth putting to any provider before committing. */ + ask: string[]; +} + +export const ACTION_ROUTES: readonly ActionRoute[] = [ + { + id: 'listed-equity', + name: 'Listed equity in the chain', + providedBy: 'Any regulated broker or bank offering international market access.', + gives: + 'Exposure to the listed producers, tool makers and refiners that appear in the map — ' + + 'the most accessible route, and the only one most readers will need.', + doesNotGive: + 'Many of the most concentrated nodes are private, family-held, or listed only ' + + 'domestically in markets a retail account cannot reach. And a listed company is ' + + 'rarely a pure play on the chokepoint you care about; the interesting segment is ' + + 'often a small share of its revenue.', + ask: [ + 'Which exchanges can this account actually reach, and at what cost?', + 'What share of this company’s revenue comes from the segment the research is about?', + 'Is there a foreign-ownership limit or a withholding treatment I should know about?', + ], + }, + { + id: 'commodity-derivatives', + name: 'Exchange-traded commodity exposure', + providedBy: 'Brokers offering futures, or issuers of exchange-traded commodities.', + gives: 'Liquid, transparent exposure to the benchmark grade of a traded metal.', + doesNotGive: + 'The benchmark grade is almost never the grade this research is about. Exchange ' + + 'tin is not seven-nines tin qualified for an EUV source, and the premium between ' + + 'them is the part that reflects the chokepoint. Rolling a futures position also ' + + 'carries a cost that has nothing to do with the thesis.', + ask: [ + 'What exactly does this contract deliver — which grade, which warehouse?', + 'What has the roll cost been over a horizon like mine?', + 'Is the premium I actually care about visible in this price at all?', + ], + }, + { + id: 'physical', + name: 'Physical metal', + providedBy: 'Specialist metals dealers and vaulting or custody providers.', + gives: + 'The only route that touches the actual grade — and, for an industrial buyer, ' + + 'the one that also solves a supply problem rather than expressing a view.', + doesNotGive: + 'Liquidity. Assay, storage, insurance, minimum lot sizes and a bid-offer that ' + + 'reflects a genuinely thin market. Several of these materials are export-controlled, ' + + 'which is a licensing question before it is a price question.', + ask: [ + 'What assay and certificate of analysis comes with the lot, and from whom?', + 'Where is it stored, insured by whom, and what does it cost to hold per year?', + 'Who will buy this back from me, and at what discount to today’s price?', + ], + }, + { + id: 'private', + name: 'Private markets', + providedBy: 'Licensed placement agents, private funds, and eligibility-gated platforms.', + gives: + 'Access to the part of the universe that is not listed anywhere — which, for ' + + 'several chokepoints in the map, is the only part there is.', + doesNotGive: + 'Liquidity, price discovery, or entry on your timetable. Eligibility rules ' + + '(qualified, professional or accredited investor, depending on jurisdiction) ' + + 'exclude most people by law, not by preference.', + ask: [ + 'Am I eligible for this under my own jurisdiction’s rules — and who verified that?', + 'Is the party introducing this licensed to do so, and how are they paid?', + 'What is the realistic holding period before any liquidity event?', + ], + }, + { + id: 'funds', + name: 'Thematic funds', + providedBy: 'Fund managers and the platforms that distribute them.', + gives: 'Diversified, professionally managed exposure without needing to pick nodes.', + doesNotGive: + 'Precision. A fund named for a theme frequently holds the visible layer — the ' + + 'large, liquid, widely-owned names — rather than the constrained one. The holdings ' + + 'list settles this in about five minutes, and it is worth the five minutes.', + ask: [ + 'What are the actual top holdings, and do they own the chokepoint or sit next to it?', + 'What is the total cost of ownership, including anything not in the headline fee?', + ], + }, + { + id: 'procurement', + name: 'Securing supply instead of buying exposure', + providedBy: 'Your own procurement function, and the producers in the map directly.', + gives: + 'For a company that consumes any of this, the highest-return action is usually not ' + + 'an investment at all: qualify a second source, lengthen a contract, or hold ' + + 'strategic stock before the constraint binds. The map is a supplier list as much ' + + 'as it is a research product.', + doesNotGive: + 'Anything financial. This is an operational decision with operational costs — ' + + 'qualification time, working capital, obsolescence risk.', + ask: [ + 'Which of these producers is already qualified for our process, and which is not?', + 'What would a second source cost in time, not just in price?', + ], + }, +]; + +// ===================================================================== +// READINESS — the honest distance to becoming the investor +// +// The same discipline as the coverage meter: a checklist that flatters nobody. +// "We are getting everything ready" is a feeling until it is a list with +// statuses, and then it is a plan. +// ===================================================================== + +export type ReadinessStatus = 'done' | 'in-progress' | 'not-started'; + +export interface ReadinessItem { + id: string; + requirement: string; + status: ReadinessStatus; + detail: string; +} + +export const READINESS: readonly ReadinessItem[] = [ + { + id: 'thesis', + requirement: 'A written, public investment thesis', + status: 'done', + detail: + 'Stated above, with a falsifier against each claim, so it can be scored rather ' + + 'than admired.', + }, + { + id: 'process', + requirement: 'A documented research process and coverage universe', + status: 'in-progress', + detail: + 'Two tests, a defined universe, and a verification standard that separates leads ' + + 'from findings. Phase 1 is not finished; the meter on the map says by how much.', + }, + { + id: 'conflicts', + requirement: 'Conflicts, personal-dealing and disclosure policy', + status: 'in-progress', + detail: + 'The disclosure rules are written and public. Personal-dealing rules for staff, ' + + 'and the compliance function to enforce them, are not.', + }, + { + id: 'track-record', + requirement: 'A timestamped, independently checkable track record', + status: 'not-started', + detail: + 'The single hardest one to fake and the slowest to acquire. It starts accruing the ' + + 'day published calls carry dates nobody can quietly edit.', + }, + { + id: 'entity', + requirement: 'Legal entity, domicile and governance', + status: 'not-started', + detail: 'Structure follows the regulatory pathway, so it waits on the next line.', + }, + { + id: 'licence', + requirement: 'Regulatory pathway chosen and licence obtained', + status: 'not-started', + detail: + 'Managing third-party money is licensed activity. The options — an asset-manager ' + + 'licence, operating under a licensed manager, or a structure that stays below a ' + + 'threshold — have materially different costs and timelines, and none is quick.', + }, + { + id: 'onboarding', + requirement: 'Investor eligibility, KYC and AML onboarding', + status: 'not-started', + detail: + 'Eligibility is a legal test, not a checkbox. This is also where the portal stops ' + + 'being an account and starts being a regulated record.', + }, + { + id: 'custody', + requirement: 'Custody, execution and administration relationships', + status: 'not-started', + detail: 'Somebody independent has to hold the assets and strike the valuations.', + }, + { + id: 'reporting', + requirement: 'Valuation and investor reporting policy', + status: 'not-started', + detail: 'How positions are marked, how often, and by whom — agreed before the first one.', + }, +]; + +export interface ReadinessProgress { + total: number; + done: number; + inProgress: number; +} + +export function readinessProgress(): ReadinessProgress { + return { + total: READINESS.length, + done: READINESS.filter(item => item.status === 'done').length, + inProgress: READINESS.filter(item => item.status === 'in-progress').length, + }; +} + +export const READINESS_STATUS_LABEL: Record<ReadinessStatus, string> = { + done: 'Done', + 'in-progress': 'In progress', + 'not-started': 'Not started', +}; diff --git a/src/config/substrata-coverage.ts b/src/config/substrata-coverage.ts new file mode 100644 index 000000000..1e235508e --- /dev/null +++ b/src/config/substrata-coverage.ts @@ -0,0 +1,504 @@ +/** + * Substrate — Phase 1 coverage universe: the producers of the fifteen. + * + * The firm's first research product. For every material on the desk + * (`substrata.ts` → CATALOGUE), the set of companies that mine, refine, + * convert or recycle it. Mostly private, mostly uncovered: there is a great + * deal of published research on chip designers and almost none on who fires + * crucible-grade quartz or upgrades tin to seven nines. + * + * WHAT THIS FILE CLAIMS, AND WHAT IT DELIBERATELY DOES NOT + * + * Each entry asserts three things only: a company's name, where it operates, + * and which step of the chain it occupies. Those are stable, widely documented + * facts. It asserts NOTHING about capacity, market share, revenue, cost + * position, or quality — the claims that go stale within a quarter, that move + * markets when wrong, and that this firm has not yet sourced. There is no + * field for them on purpose: the structure is the discipline, and the research + * phase is what adds those numbers, each with a citation. + * + * Consequently every entry starts at `source: null`, which reads as UNVERIFIED + * — a research lead, not a finding. An analyst clears a row by attaching the + * primary source that confirms the company's role in that material. Coverage + * progress is measured by how many rows have a source, not by how many rows + * exist, and `coverageProgress()` below is what reports it. + * + * This is the mandate's own rule turned on the firm: an unsourced claim is + * marked unverified rather than stated, however confident the analyst is. + * + * Created: 2026-08-26 + */ + +import { MATERIALS, type CurveId, type NodeType } from './substrata'; + +// ===================================================================== +// SHAPE +// ===================================================================== + +/** + * Where a company sits in the chain. A single firm can appear on more than one + * material, and at different steps on each — that overlap is precisely the + * structure the map exists to expose. + */ +export const PRODUCER_ROLES = [ + { + id: 'mine', + label: 'Mine / extract', + detail: 'Primary extraction, or recovery as a by-product.', + }, + { id: 'refine', label: 'Refine', detail: 'Purification to the grade the application needs.' }, + { + id: 'convert', + label: 'Convert', + detail: 'Into the form that ships: ingot, wafer, tape, coil, target.', + }, + { id: 'recycle', label: 'Recycle', detail: 'Secondary supply — often the only elastic source.' }, +] as const; + +export type ProducerRole = (typeof PRODUCER_ROLES)[number]['id']; + +export interface Producer { + /** Company name as it trades. */ + name: string; + /** ISO 3166-1 alpha-2 of the operating jurisdiction(s) for this material. */ + jurisdictions: string[]; + role: ProducerRole; + /** + * Primary source confirming this company's role in this material. + * `null` = unverified research lead. Never present an unsourced row as a + * finding — see the header. + */ + source: string | null; +} + +export interface MaterialCoverage { + /** Must exactly match a CATALOGUE title in `substrata.ts`. */ + material: string; + /** What makes this material a chokepoint, in one line — the research thesis. */ + thesis: string; + producers: Producer[]; +} + +/** Shorthand: every row starts unverified, because every row starts unsourced. */ +function lead(name: string, jurisdictions: string[], role: ProducerRole): Producer { + return { name, jurisdictions, role, source: null }; +} + +// ===================================================================== +// THE UNIVERSE +// ===================================================================== + +export const COVERAGE: readonly MaterialCoverage[] = [ + // ---------- Lithography & Optics ---------- + { + material: 'High-purity tin, EUV droplet grade', + thesis: + 'Tin metal is not scarce; tin at seven nines, qualified for an EUV source, is. The chokepoint is the upgrading step, not the mine.', + producers: [ + lead('Yunnan Tin', ['CN'], 'refine'), + lead('Minsur', ['PE'], 'refine'), + lead('PT Timah', ['ID'], 'refine'), + lead('Malaysia Smelting Corporation', ['MY'], 'refine'), + lead('Indium Corporation', ['US'], 'convert'), + lead('5N Plus', ['CA', 'DE'], 'convert'), + lead('Aurubis', ['DE'], 'recycle'), + ], + }, + { + material: 'Neon, excimer laser grade', + thesis: + 'Neon is separated from air, but economically only alongside large-scale air separation attached to steelmaking — which is why an industrial gas map and a war map turned out to be the same map in 2022.', + producers: [ + lead('Linde', ['GB', 'US', 'DE'], 'refine'), + lead('Air Liquide', ['FR'], 'refine'), + lead('Messer', ['DE'], 'refine'), + lead('Iceblick', ['UA'], 'refine'), + lead('Cryoin Engineering', ['UA'], 'refine'), + lead('Baosteel Gases', ['CN'], 'refine'), + ], + }, + { + material: 'Ruthenium, sputtering and ALD grade', + thesis: + 'Ruthenium is a by-product of PGM mining, so supply is set by platinum and palladium economics rather than by demand for ruthenium. Refining and target fabrication are separately concentrated.', + producers: [ + lead('Sibanye-Stillwater', ['ZA'], 'mine'), + lead('Impala Platinum', ['ZA'], 'mine'), + lead('Nornickel', ['RU'], 'mine'), + lead('Heraeus', ['DE'], 'refine'), + lead('Johnson Matthey', ['GB'], 'refine'), + lead('Umicore', ['BE'], 'refine'), + lead('Furuya Metal', ['JP'], 'convert'), + lead('Tanaka Kikinzoku', ['JP'], 'convert'), + ], + }, + + // ---------- Semiconductor Feedstock ---------- + { + material: 'Electronic-grade polysilicon', + thesis: + 'Solar-grade polysilicon has many producers; electronic-grade has very few, and the gap between the two is measured in orders of magnitude of impurity, not in price.', + producers: [ + lead('Wacker Chemie', ['DE', 'US'], 'refine'), + lead('Hemlock Semiconductor', ['US'], 'refine'), + lead('Tokuyama', ['JP', 'MY'], 'refine'), + lead('OCI', ['KR', 'MY'], 'refine'), + lead('Mitsubishi Materials', ['JP'], 'refine'), + lead('REC Silicon', ['US', 'NO'], 'refine'), + ], + }, + { + material: '300 mm prime silicon wafers', + thesis: + 'Five firms supply essentially all prime 300 mm wafer capacity. Qualification at a leading-edge fab takes years, so the barrier is certification history rather than capital.', + producers: [ + lead('Shin-Etsu Handotai', ['JP'], 'convert'), + lead('SUMCO', ['JP'], 'convert'), + lead('GlobalWafers', ['TW'], 'convert'), + lead('Siltronic', ['DE'], 'convert'), + lead('SK Siltron', ['KR'], 'convert'), + ], + }, + { + material: 'Crucible-grade high-purity quartz sand', + thesis: + 'The clearest single point of failure in the entire chain: inner-layer crucible quartz comes, in practice, from a very small number of deposits, and every Czochralski puller on earth needs it.', + producers: [ + lead('The Quartz Corp', ['NO', 'US'], 'mine'), + lead('Sibelco', ['BE', 'US'], 'mine'), + lead('Russian Quartz', ['RU'], 'mine'), + lead('Jiangsu Pacific Quartz', ['CN'], 'refine'), + lead('Momentive Technologies', ['US'], 'convert'), + lead('Shin-Etsu Quartz', ['JP'], 'convert'), + lead('Ferrotec', ['JP', 'CN'], 'convert'), + ], + }, + { + material: 'Gallium, refined', + thesis: + 'A by-product of alumina refining, so primary supply cannot respond to price. Concentrated in one jurisdiction and under export control since 2023 — the textbook case for why the map matters.', + producers: [ + lead('Chinalco', ['CN'], 'refine'), + lead('East Hope Group', ['CN'], 'refine'), + lead('Zhuhai Fangyuan', ['CN'], 'refine'), + lead('Rio Tinto', ['CA'], 'refine'), + lead('Nyrstar', ['AU'], 'refine'), + lead('5N Plus', ['CA'], 'convert'), + ], + }, + + // ---------- Thermal & Packaging ---------- + { + material: 'CVD synthetic diamond heat spreader', + thesis: + 'Reactor time, not raw material, is the constraint. Growing optical-grade polycrystalline diamond is slow, and the qualified capacity is held by a handful of firms.', + producers: [ + lead('Element Six', ['GB', 'IE'], 'convert'), + lead('Coherent', ['US'], 'convert'), + lead('Diamond Materials', ['DE'], 'convert'), + lead('Applied Diamond', ['US'], 'convert'), + lead('Sumitomo Electric', ['JP'], 'convert'), + ], + }, + { + material: 'Silicon carbide substrate, 200 mm semi-insulating', + thesis: + 'The 150 mm to 200 mm transition resets everyone’s yield curve at once. Semi-insulating grade is a much smaller field than conductive SiC, and defect density is the gate.', + producers: [ + lead('Wolfspeed', ['US'], 'convert'), + lead('Coherent', ['US'], 'convert'), + lead('SK Siltron CSS', ['KR', 'US'], 'convert'), + lead('Resonac', ['JP'], 'convert'), + lead('SICC', ['CN'], 'convert'), + lead('TankeBlue', ['CN'], 'convert'), + ], + }, + { + material: 'Two-phase dielectric immersion coolant', + thesis: + 'A chokepoint created by regulation rather than geology: PFAS restriction is withdrawing the incumbent fluorinated chemistry precisely as immersion cooling starts to scale.', + producers: [ + lead('3M', ['US'], 'refine'), + lead('Chemours', ['US'], 'refine'), + lead('Syensqo', ['BE'], 'refine'), + lead('AGC', ['JP'], 'refine'), + lead('Engineered Fluids', ['US'], 'convert'), + ], + }, + + // ---------- Power, Grid & Superconductors ---------- + { + material: 'Grain-oriented electrical steel (GOES)', + thesis: + 'The binding constraint on datacentre energisation. Large power transformers queue for years, and the core steel behind them is made on a small number of qualified lines.', + producers: [ + lead('Nippon Steel', ['JP'], 'convert'), + lead('JFE Steel', ['JP'], 'convert'), + lead('POSCO', ['KR'], 'convert'), + lead('ThyssenKrupp Electrical Steel', ['DE'], 'convert'), + lead('Cleveland-Cliffs', ['US'], 'convert'), + lead('Baosteel', ['CN'], 'convert'), + lead('Stalprodukt', ['PL'], 'convert'), + ], + }, + { + material: 'REBCO superconducting tape, 12 mm', + thesis: + 'A single high-field fusion magnet consumes tape by the kilometre. Annual world output is small enough that one programme’s order book moves the whole market.', + producers: [ + lead('Fujikura', ['JP'], 'convert'), + lead('Faraday Factory Japan', ['JP'], 'convert'), + lead('SuperPower', ['US'], 'convert'), + lead('MetOx', ['US'], 'convert'), + lead('THEVA', ['DE'], 'convert'), + lead('Shanghai Superconductor', ['CN'], 'convert'), + lead('AMSC', ['US'], 'convert'), + ], + }, + { + material: 'Liquid helium (He-4)', + thesis: + 'Helium is produced only as a by-product of a few natural gas fields with unusual composition, so supply is set by unrelated gas economics and by a handful of political jurisdictions.', + producers: [ + lead('QatarEnergy', ['QA'], 'mine'), + lead('ExxonMobil', ['US'], 'mine'), + lead('Gazprom', ['RU'], 'mine'), + lead('Air Products', ['US'], 'refine'), + lead('Linde', ['US', 'GB'], 'refine'), + lead('Air Liquide', ['FR'], 'refine'), + ], + }, + + // ---------- Actuation & Robotics ---------- + { + material: 'Didymium (Nd-Pr) metal, magnet feed', + thesis: + 'Mining is diversifying; separation and metal-making have not. The chokepoint moved downstream of the mine, which is where most published coverage still is not looking.', + producers: [ + lead('China Northern Rare Earth', ['CN'], 'refine'), + lead('Shenghe Resources', ['CN'], 'refine'), + lead('Lynas Rare Earths', ['AU', 'MY'], 'refine'), + lead('MP Materials', ['US'], 'mine'), + lead('Neo Performance Materials', ['CA', 'EE'], 'convert'), + lead('Solvay', ['FR'], 'refine'), + ], + }, + { + material: 'Dysprosium metal', + thesis: + 'Heavy rare earths are a far narrower chain than light ones, feedstock included. Small volumes, no substitute for hot-running magnets, and separation concentrated in effectively one jurisdiction.', + producers: [ + lead('China Rare Earth Group', ['CN'], 'refine'), + lead('Shenghe Resources', ['CN'], 'refine'), + lead('Lynas Rare Earths', ['AU', 'MY'], 'refine'), + lead('Neo Performance Materials', ['CA', 'EE'], 'convert'), + lead('Less Common Metals', ['GB'], 'convert'), + ], + }, +]; + +// ===================================================================== +// PROGRESS +// ===================================================================== + +export interface CoverageProgress { + /** Research leads in the universe. */ + total: number; + /** Rows with a primary source attached — the only ones that count as covered. */ + sourced: number; + /** Materials on the desk with no coverage entry at all. */ + uncoveredMaterials: string[]; +} + +/** + * What Phase 1 completion actually means. Rows without a source are leads, so + * a universe of 90 unsourced entries is 0% covered, not 100% mapped. + */ +export function coverageProgress(): CoverageProgress { + const rows = COVERAGE.flatMap(entry => entry.producers); + const covered = new Set(COVERAGE.map(entry => entry.material)); + return { + total: rows.length, + sourced: rows.filter(producer => producer.source !== null).length, + uncoveredMaterials: MATERIALS.map(material => material.title).filter( + title => !covered.has(title) + ), + }; +} + +/** @returns every material a company appears on — the overlaps are the point. */ +export function materialsFor(companyName: string): string[] { + return COVERAGE.filter(entry => + entry.producers.some(producer => producer.name === companyName) + ).map(entry => entry.material); +} + +// ===================================================================== +// CHOKEPOINTS THAT ARE NOT MATERIALS +// +// A material is only one kind of chokepoint, and for the compute and power +// curves it is often not the tightest one. A tool with a single supplier, +// packaging capacity allocated years ahead, a transformer order book, an +// interconnection queue and a process that lives in people rather than in +// equipment all gate the same curves — and none of them appears on a periodic +// table. The two tests do not care what a node is made of, so these enter the +// universe the same way and carry the same verification discipline: each row +// claims what the node is and why it gates, and nothing about capacity, +// share or price, because those are the numbers this firm has not sourced. +// ===================================================================== + +export interface Chokepoint { + name: string; + type: NodeType; + curve: CurveId; + /** ISO 3166-1 alpha-2 codes where the constraint physically sits, if narrow. */ + jurisdictions: string[]; + /** Why this gates a curve. The research claim, and the whole of it. */ + why: string; + /** Primary source. `null` = unverified research lead, exactly as above. */ + source: string | null; +} + +function node( + name: string, + type: NodeType, + curve: CurveId, + jurisdictions: string[], + why: string +): Chokepoint { + return { name, type, curve, jurisdictions, why, source: null }; +} + +/** Display label per node type, so the site never prints a raw enum. */ +export const NODE_TYPE_LABEL: Record<NodeType, string> = { + material: 'Material', + company: 'Company', + person: 'People', + machine: 'Machine', + process: 'Process', +}; + +export const CHOKEPOINTS: readonly Chokepoint[] = [ + // ---------- Compute per joule ---------- + node( + 'EUV lithography scanners', + 'machine', + 'compute-per-joule', + ['NL'], + 'One company on earth builds them, the queue is measured in years, and no second source is in progress. Every leading-edge wafer in the world is downstream of one factory.' + ), + node( + 'EUV projection optics', + 'process', + 'compute-per-joule', + ['DE'], + 'The mirror systems inside the scanner are polished to a tolerance one supplier has ever achieved. It is a chokepoint inside a chokepoint, and the constraint is know-how, not capacity.' + ), + node( + 'Advanced packaging capacity', + 'process', + 'compute-per-joule', + ['TW', 'KR', 'US'], + 'Accelerator output is gated by how many dies can be packaged onto an interposer, not by wafer starts. Capacity is allocated years ahead, which makes the allocation itself the scarce good.' + ), + node( + 'High-bandwidth memory stacking yield', + 'process', + 'compute-per-joule', + ['KR', 'US'], + 'Three suppliers, and the yield on stacking and bonding is knowledge that does not transfer when someone else buys the same equipment.' + ), + node( + 'Leading-edge foundry capacity', + 'company', + 'compute-per-joule', + ['TW', 'KR', 'US'], + 'A handful of fabs can run the newest node at volume. New capacity is a multi-year, multi-billion commitment, so the supply curve cannot answer a demand shock.' + ), + node( + 'Photoresist formulation', + 'process', + 'compute-per-joule', + ['JP'], + 'The chemistry is qualified per process per fab and is overwhelmingly Japanese. Substituting a resist is a re-qualification programme, not a purchase.' + ), + node( + 'Semiconductor process engineers', + 'person', + 'compute-per-joule', + [], + 'The constraint nobody can buy. A fab ramp moves at the speed of people who have done it before, which is why capacity announcements and capacity slip on different clocks.' + ), + + // ---------- Joules delivered ---------- + node( + 'Large power transformer slots', + 'machine', + 'joules-delivered', + ['KR', 'DE', 'JP', 'US'], + 'Lead times run to several years, and a datacentre cannot be energised without one. This gates more announced compute today than chip supply does.' + ), + node( + 'Grid interconnection queues', + 'process', + 'joules-delivered', + [], + 'Not a material, not a machine, and frequently the binding constraint: a multi-year administrative queue between a signed site and a live megawatt.' + ), + node( + 'Heavy-duty gas turbine order books', + 'machine', + 'joules-delivered', + ['US', 'DE'], + 'The fastest route to firm power at scale, and the order books are effectively sold out. A slot is worth more than the turbine price implies.' + ), + node( + 'High-voltage cable and switchgear', + 'machine', + 'joules-delivered', + ['DE', 'IT', 'KR'], + 'The unglamorous half of energisation. Same multi-year lead times as transformers, same inability to respond quickly to a demand shock.' + ), + + // ---------- Actuation ---------- + node( + 'Rare-earth magnet sintering', + 'process', + 'actuation', + ['CN'], + 'Even where the metal is mined elsewhere, sintering and grain-boundary diffusion are concentrated in one jurisdiction. The chokepoint sits downstream of the mine, where most coverage is not looking.' + ), + node( + 'Precision reduction drives', + 'company', + 'actuation', + ['JP'], + 'Harmonic and cycloidal drives set what a robot joint can do. Few qualified suppliers, and the tolerances are decades of accumulated manufacturing practice.' + ), + node( + 'Robot-grade encoders and force sensors', + 'company', + 'actuation', + ['JP', 'DE'], + 'Closing the loop is what separates a manipulator from an arm. Narrow supply, and qualification is per-application.' + ), +]; + +export interface ChokepointProgress { + total: number; + sourced: number; + byCurve: Record<string, number>; +} + +/** Same honesty as the producer map: a row counts only once it has a source. */ +export function chokepointProgress(): ChokepointProgress { + const byCurve: Record<string, number> = {}; + for (const point of CHOKEPOINTS) { + byCurve[point.curve] = (byCurve[point.curve] ?? 0) + 1; + } + return { + total: CHOKEPOINTS.length, + sourced: CHOKEPOINTS.filter(point => point.source !== null).length, + byCurve, + }; +} diff --git a/src/config/substrata-participants.ts b/src/config/substrata-participants.ts new file mode 100644 index 000000000..228ce8c25 --- /dev/null +++ b/src/config/substrata-participants.ts @@ -0,0 +1,1045 @@ +/** + * Substrata — the participants of the singularity chain, graded by scarcity. + * + * The producer map answers "who makes this material". This answers the bigger + * question: who is in the chain at all, from the ore to the buyer, and which of + * them are actually hard to replace. + * + * THE SCARCITY GRADE IS THE PRODUCT + * + * A directory of everyone in a supply chain is a phone book. What makes this + * research is the third column: for each participant, whether it is a + * chokepoint, merely concentrated, or genuinely competitive. Grading some + * participants "competitive" is not filler — it is the thing that makes + * "chokepoint" mean something. A map where every node is critical is a map + * that has not been read. + * + * The grades are the mandate's own screen (concentration, substitutability, + * lead time, demand inelasticity) applied one participant at a time: + * + * chokepoint — one or a very few qualified suppliers, and no substitute + * arrives on a horizon that matters. Removing it stops things. + * concentrated — a handful of credible suppliers. Substitution is possible + * but slow, costly, or requires re-qualification. + * competitive — many credible suppliers. Present in the chain, and not a + * constraint on it. + * + * On the demand side the same grade reads as concentration of BUYERS: when a + * handful of firms account for most of the world's orders, that is a scarcity + * fact about the chain too, pointing the other way. + * + * Same verification discipline as everywhere else: each row claims a name, a + * layer, a jurisdiction, a role and a scarcity judgement — and nothing about + * revenue, capacity or share. Every row starts unsourced, which reads as a + * research lead rather than a finding. + * + * Created: 2026-08-26 + */ + +import type { CurveId } from './substrata'; + +// ===================================================================== +// LAYERS — ore to buyer +// ===================================================================== + +export type ChainLayer = + | 'extraction' + | 'refining' + | 'conversion' + | 'equipment' + | 'fabrication' + | 'packaging' + | 'systems' + | 'energy' + | 'actuation' + | 'deployment'; + +export interface ChainLayerSpec { + id: ChainLayer; + name: string; + curve: CurveId; + detail: string; +} + +/** Ordered upstream to downstream. The order is the chain. */ +export const CHAIN_LAYERS: readonly ChainLayerSpec[] = [ + { + id: 'extraction', + name: 'Extraction', + curve: 'compute-per-joule', + detail: 'Ore, gas fields and the by-product streams that most of these elements come from.', + }, + { + id: 'refining', + name: 'Refining & separation', + curve: 'compute-per-joule', + detail: + 'Purification to the grade an application needs. For most of this chain the chokepoint moved here long ago, downstream of the mine and out of view.', + }, + { + id: 'conversion', + name: 'Conversion', + curve: 'compute-per-joule', + detail: 'Into the form that ships: wafer, crucible, tape, coil, target, magnet.', + }, + { + id: 'equipment', + name: 'Equipment & consumables', + curve: 'compute-per-joule', + detail: + 'The tools that print, etch, deposit and measure — and the chemistry they consume doing it.', + }, + { + id: 'fabrication', + name: 'Fabrication', + curve: 'compute-per-joule', + detail: 'The fabs. Where a design becomes a die, at a node and a yield.', + }, + { + id: 'packaging', + name: 'Packaging & memory', + curve: 'compute-per-joule', + detail: + 'Where dies become an accelerator. Increasingly the step that gates output rather than wafer starts.', + }, + { + id: 'systems', + name: 'Systems & silicon', + curve: 'compute-per-joule', + detail: 'Accelerators, interconnect and the machines they are built into.', + }, + { + id: 'energy', + name: 'Energy & grid', + curve: 'joules-delivered', + detail: + 'Transformers, turbines, cable and switchgear. The layer that decides whether announced compute is ever energised.', + }, + { + id: 'actuation', + name: 'Actuation & robotics', + curve: 'actuation', + detail: 'Drives, motors, encoders and the robots they add up to.', + }, + { + id: 'deployment', + name: 'Deployment & demand', + curve: 'compute-per-joule', + detail: + 'Who is actually buying. Concentrated demand is a scarcity fact about a chain in its own right.', + }, +]; + +// ===================================================================== +// SCARCITY +// ===================================================================== + +export type ScarcityGrade = 'chokepoint' | 'concentrated' | 'competitive'; + +export const SCARCITY_LABEL: Record<ScarcityGrade, string> = { + chokepoint: 'Chokepoint', + concentrated: 'Concentrated', + competitive: 'Competitive', +}; + +export const SCARCITY_DETAIL: Record<ScarcityGrade, string> = { + chokepoint: + 'One or very few qualified suppliers, and no substitute on a horizon that matters. Removing it stops things.', + concentrated: + 'A handful of credible suppliers. Substitution is possible but slow, costly, or needs re-qualification.', + competitive: + 'Many credible suppliers. In the chain, but not a constraint on it — and saying so is what makes the other two grades mean something.', +}; + +export interface Participant { + name: string; + layer: ChainLayer; + jurisdictions: string[]; + /** What they do in the chain, in one line. */ + role: string; + scarcity: ScarcityGrade; + /** Why the grade — the research claim, and the whole of it. */ + why: string; + /** Primary source. `null` = unverified research lead, as everywhere else. */ + source: string | null; +} + +function p( + name: string, + layer: ChainLayer, + jurisdictions: string[], + role: string, + scarcity: ScarcityGrade, + why: string +): Participant { + return { name, layer, jurisdictions, role, scarcity, why, source: null }; +} + +// ===================================================================== +// THE DIRECTORY +// ===================================================================== + +export const PARTICIPANTS: readonly Participant[] = [ + // ---------------- Extraction ---------------- + p( + 'The Quartz Corp', + 'extraction', + ['NO', 'US'], + 'High-purity quartz sand', + 'chokepoint', + 'Inner-layer crucible quartz comes in practice from a very small number of deposits, and every Czochralski puller on earth needs it.' + ), + p( + 'Sibelco', + 'extraction', + ['BE', 'US'], + 'High-purity quartz sand', + 'chokepoint', + 'The other holder of the same rare deposit quality. Two names deep is the whole of the upstream for this input.' + ), + p( + 'Sibanye-Stillwater', + 'extraction', + ['ZA'], + 'PGM mining incl. ruthenium', + 'concentrated', + 'Ruthenium is a by-product, so its supply is set by platinum economics rather than by demand for it.' + ), + p( + 'Impala Platinum', + 'extraction', + ['ZA'], + 'PGM mining', + 'concentrated', + 'Same by-product logic, same narrow geography.' + ), + p( + 'Nornickel', + 'extraction', + ['RU'], + 'Nickel and PGM mining', + 'concentrated', + 'Material share of world PGM and nickel, with sanctions risk layered on top of geology.' + ), + p( + 'MP Materials', + 'extraction', + ['US'], + 'Rare-earth ore', + 'concentrated', + 'The main non-Chinese light rare-earth mine. Mining diversified before separation did, which is the gap the map cares about.' + ), + p( + 'Lynas Rare Earths', + 'extraction', + ['AU', 'MY'], + 'Rare-earth mining and separation', + 'concentrated', + 'The most complete non-Chinese rare-earth chain, and still small against the incumbent.' + ), + p( + 'Yunnan Tin', + 'extraction', + ['CN'], + 'Tin mining and smelting', + 'concentrated', + 'Large in tin metal; the EUV-grade constraint sits downstream of it.' + ), + p( + 'Minsur', + 'extraction', + ['PE'], + 'Tin mining and smelting', + 'competitive', + 'Tin metal has several credible producers. It is purity, not tonnage, that binds here.' + ), + p( + 'PT Timah', + 'extraction', + ['ID'], + 'Tin mining and smelting', + 'competitive', + 'Same: the scarcity in this chain is an upgrading step, not an ore body.' + ), + p( + 'QatarEnergy', + 'extraction', + ['QA'], + 'Helium from LNG', + 'concentrated', + 'Helium exists commercially only as a by-product of a few gas fields with unusual composition.' + ), + p( + 'ExxonMobil', + 'extraction', + ['US'], + 'Helium from natural gas', + 'concentrated', + 'One of a very small number of fields worldwide rich enough to justify extraction.' + ), + + // ---------------- Refining & separation ---------------- + p( + 'Wacker Chemie', + 'refining', + ['DE', 'US'], + 'Electronic-grade polysilicon', + 'chokepoint', + 'Solar-grade polysilicon has many producers; electronic-grade has very few, and the gap is orders of magnitude of impurity.' + ), + p( + 'Hemlock Semiconductor', + 'refining', + ['US'], + 'Electronic-grade polysilicon', + 'chokepoint', + 'One of a handful of qualified suppliers of the first material in the entire chain.' + ), + p( + 'Tokuyama', + 'refining', + ['JP', 'MY'], + 'Electronic-grade polysilicon', + 'chokepoint', + 'Same short list, different geography — which is most of why it matters.' + ), + p( + 'OCI', + 'refining', + ['KR', 'MY'], + 'Polysilicon', + 'concentrated', + 'Credible at scale, with electronic-grade a narrower qualification than volume implies.' + ), + p( + 'China Northern Rare Earth', + 'refining', + ['CN'], + 'Rare-earth separation', + 'chokepoint', + 'Separation, not mining, is where the rare-earth chain actually narrows, and it narrows here.' + ), + p( + 'Shenghe Resources', + 'refining', + ['CN'], + 'Rare-earth separation and trading', + 'chokepoint', + 'Processes feedstock from mines all over the world, including ones marketed as diversification.' + ), + p( + 'Chinalco', + 'refining', + ['CN'], + 'Gallium from alumina refining', + 'chokepoint', + 'Gallium is an alumina by-product, so supply cannot answer price — and it is export-controlled.' + ), + p( + 'Heraeus', + 'refining', + ['DE'], + 'Precious-metal refining', + 'concentrated', + 'One of the few refiners able to deliver PGMs at semiconductor purity.' + ), + p( + 'Johnson Matthey', + 'refining', + ['GB'], + 'PGM refining', + 'concentrated', + 'Long-established PGM chemistry; a short list of peers worldwide.' + ), + p( + 'Umicore', + 'refining', + ['BE'], + 'PGM refining and recycling', + 'concentrated', + 'Secondary supply is often the only elastic source in these metals.' + ), + p( + 'Linde', + 'refining', + ['GB', 'US', 'DE'], + 'Industrial and electronic gases', + 'concentrated', + 'Noble gases come from air separation attached to heavy industry, which limits where they can come from at all.' + ), + p( + 'Air Liquide', + 'refining', + ['FR'], + 'Industrial and electronic gases', + 'concentrated', + 'One of three global gas majors; the fab-qualified end is narrower than the industrial one.' + ), + p( + 'Air Products', + 'refining', + ['US'], + 'Industrial gases and helium', + 'concentrated', + 'Helium distribution is a small club with long-dated source contracts.' + ), + p( + 'Iceblick', + 'refining', + ['UA'], + 'Neon and rare gases', + 'concentrated', + 'The 2022 squeeze made the point: an industrial-gas map and a war map turned out to be the same map.' + ), + p( + '5N Plus', + 'refining', + ['CA', 'DE'], + 'High-purity specialty metals', + 'concentrated', + 'Upgrading to five nines and beyond is a different business from producing the metal.' + ), + + // ---------------- Conversion ---------------- + p( + 'Shin-Etsu Handotai', + 'conversion', + ['JP'], + '300 mm prime silicon wafers', + 'chokepoint', + 'Five firms supply essentially all prime 300 mm capacity, and qualification at a leading-edge fab takes years.' + ), + p( + 'SUMCO', + 'conversion', + ['JP'], + '300 mm prime silicon wafers', + 'chokepoint', + 'The other half of a duopoly at the top of the wafer market.' + ), + p( + 'GlobalWafers', + 'conversion', + ['TW'], + 'Silicon wafers', + 'concentrated', + 'Third of the big five, with a genuine multi-region footprint.' + ), + p( + 'Siltronic', + 'conversion', + ['DE'], + 'Silicon wafers', + 'concentrated', + 'European supply of an input with almost no European alternative.' + ), + p( + 'SK Siltron', + 'conversion', + ['KR'], + 'Silicon and SiC wafers', + 'concentrated', + 'Captive-adjacent to Korean memory, and one of few SiC entrants at scale.' + ), + p( + 'Momentive Technologies', + 'conversion', + ['US'], + 'Fused quartz crucibles', + 'chokepoint', + 'Turning rare sand into a crucible that survives a pull is knowledge held in very few places.' + ), + p( + 'Shin-Etsu Quartz', + 'conversion', + ['JP'], + 'Fused quartz components', + 'chokepoint', + 'Same step, same shortness of the list.' + ), + p( + 'Ferrotec', + 'conversion', + ['JP', 'CN'], + 'Quartz and fab consumables', + 'concentrated', + 'Broad consumables base spanning both sides of an export-control line.' + ), + p( + 'Element Six', + 'conversion', + ['GB', 'IE'], + 'CVD synthetic diamond', + 'concentrated', + 'Reactor time, not raw material, is the constraint on optical-grade diamond.' + ), + p( + 'Coherent', + 'conversion', + ['US'], + 'SiC substrates, diamond, photonics', + 'concentrated', + 'One of the few firms present in several of this chain’s narrow materials at once.' + ), + p( + 'Wolfspeed', + 'conversion', + ['US'], + 'Silicon carbide substrates', + 'concentrated', + 'The 150 to 200 mm transition resets everyone’s yield curve, which is where the scarcity currently lives.' + ), + p( + 'Resonac', + 'conversion', + ['JP'], + 'SiC epitaxy and fab materials', + 'concentrated', + 'Deep in the materials nobody outside the industry can name.' + ), + p( + 'Fujikura', + 'conversion', + ['JP'], + 'REBCO superconducting tape', + 'concentrated', + 'A single high-field magnet consumes tape by the kilometre against a small world output.' + ), + p( + 'Faraday Factory Japan', + 'conversion', + ['JP'], + 'REBCO superconducting tape', + 'concentrated', + 'One of the very few able to ship fusion-programme quantities at all.' + ), + p( + 'Neo Performance Materials', + 'conversion', + ['CA', 'EE'], + 'Rare-earth magnets and materials', + 'concentrated', + 'The main non-Chinese magnet-making capacity outside Japan, and small against demand.' + ), + p( + 'Less Common Metals', + 'conversion', + ['GB'], + 'Rare-earth alloys and strip', + 'chokepoint', + 'A tiny specialist standing between Western separated oxide and a finished magnet.' + ), + p( + 'Nippon Steel', + 'conversion', + ['JP'], + 'Grain-oriented electrical steel', + 'concentrated', + 'Transformer cores are made on a small number of qualified lines worldwide.' + ), + p( + 'POSCO', + 'conversion', + ['KR'], + 'Grain-oriented electrical steel', + 'concentrated', + 'Same short list, and the same multi-year lead times downstream.' + ), + p( + 'Indium Corporation', + 'conversion', + ['US'], + 'High-purity metals and solders', + 'concentrated', + 'Seven-nines upgrading is a specialist step with few qualified providers.' + ), + + // ---------------- Equipment & consumables ---------------- + p( + 'ASML', + 'equipment', + ['NL'], + 'EUV and DUV lithography systems', + 'chokepoint', + 'One company on earth builds EUV, the queue runs to years, and no second source is in progress.' + ), + p( + 'Carl Zeiss SMT', + 'equipment', + ['DE'], + 'EUV projection optics', + 'chokepoint', + 'A chokepoint inside a chokepoint: the mirrors are polished to a tolerance one supplier has ever achieved.' + ), + p( + 'Trumpf', + 'equipment', + ['DE'], + 'EUV plasma-source lasers', + 'chokepoint', + 'The drive laser is as single-sourced as the scanner it sits inside.' + ), + p( + 'Applied Materials', + 'equipment', + ['US'], + 'Deposition, etch and process tools', + 'concentrated', + 'Broadest tool portfolio, with several steps where it is effectively the only qualified option.' + ), + p( + 'Lam Research', + 'equipment', + ['US'], + 'Etch and deposition', + 'concentrated', + 'High-aspect-ratio etch for 3D memory is a narrow specialism.' + ), + p( + 'Tokyo Electron', + 'equipment', + ['JP'], + 'Coaters, developers, etch', + 'concentrated', + 'Track systems pair with lithography and are qualified alongside it.' + ), + p( + 'KLA', + 'equipment', + ['US'], + 'Process control and metrology', + 'concentrated', + 'You cannot yield what you cannot measure, and few can measure at this scale.' + ), + p( + 'ASM International', + 'equipment', + ['NL'], + 'Atomic layer deposition', + 'concentrated', + 'ALD became unavoidable as devices went vertical, on a short supplier list.' + ), + p( + 'JSR', + 'equipment', + ['JP'], + 'Photoresists', + 'chokepoint', + 'Resist chemistry is qualified per process per fab; substituting one is a programme, not a purchase.' + ), + p( + 'Tokyo Ohka Kogyo', + 'equipment', + ['JP'], + 'Photoresists and process chemicals', + 'chokepoint', + 'The same Japanese concentration that made resist an export-control talking point.' + ), + p( + 'Shin-Etsu Chemical', + 'equipment', + ['JP'], + 'Photoresists, masks and silicones', + 'chokepoint', + 'Present at several narrow points of this chain simultaneously.' + ), + p( + 'Nikon', + 'equipment', + ['JP'], + 'Lithography systems', + 'competitive', + 'Credible in mature-node lithography, and not a factor at the leading edge — which is what "competitive" means here.' + ), + p( + 'Canon', + 'equipment', + ['JP'], + 'Lithography and nanoimprint', + 'competitive', + 'An alternative path that has not yet displaced anything at volume.' + ), + + // ---------------- Fabrication ---------------- + p( + 'TSMC', + 'fabrication', + ['TW'], + 'Leading-edge foundry', + 'chokepoint', + 'A handful of fabs can run the newest node at volume, and one of them runs most of it.' + ), + p( + 'Samsung Foundry', + 'fabrication', + ['KR'], + 'Leading-edge foundry and memory', + 'concentrated', + 'The only other merchant foundry credibly at the leading edge.' + ), + p( + 'Intel Foundry', + 'fabrication', + ['US', 'IE', 'IL'], + 'Leading-edge foundry', + 'concentrated', + 'The main non-Asian leading-edge option, and the reason several policy programmes exist.' + ), + p( + 'SMIC', + 'fabrication', + ['CN'], + 'Foundry', + 'concentrated', + 'Domestic Chinese capacity operating under equipment restrictions — the constraint is imported, not technical.' + ), + p( + 'GlobalFoundries', + 'fabrication', + ['US', 'DE', 'SG'], + 'Mature and specialty nodes', + 'competitive', + 'Mature-node capacity is genuinely contested, which is exactly why it is not where the chain binds.' + ), + p( + 'UMC', + 'fabrication', + ['TW'], + 'Mature-node foundry', + 'competitive', + 'Same: plenty of credible suppliers at these nodes.' + ), + + // ---------------- Packaging & memory ---------------- + p( + 'TSMC Advanced Packaging', + 'packaging', + ['TW'], + 'CoWoS-class packaging', + 'chokepoint', + 'Accelerator output is gated by packaging slots, not wafer starts, and they are allocated years ahead.' + ), + p( + 'ASE Technology', + 'packaging', + ['TW'], + 'Assembly and test', + 'concentrated', + 'The largest OSAT, moving up into advanced packaging as demand overflows.' + ), + p( + 'Amkor', + 'packaging', + ['US', 'KR'], + 'Assembly and test', + 'concentrated', + 'The main non-Taiwanese OSAT of scale, and a policy favourite for that reason.' + ), + p( + 'SK hynix', + 'packaging', + ['KR'], + 'High-bandwidth memory', + 'chokepoint', + 'HBM stacking yield is knowledge that does not transfer when a competitor buys the same equipment.' + ), + p( + 'Micron', + 'packaging', + ['US', 'JP', 'SG'], + 'High-bandwidth memory', + 'concentrated', + 'One of three, and the only one headquartered outside Korea.' + ), + p( + 'Samsung Memory', + 'packaging', + ['KR'], + 'High-bandwidth memory', + 'concentrated', + 'Enormous capacity, with qualification at the top of the HBM range a separate question from volume.' + ), + + // ---------------- Systems & silicon ---------------- + p( + 'NVIDIA', + 'systems', + ['US'], + 'Accelerators and interconnect', + 'chokepoint', + 'The constraint is not only silicon: the software estate around it is what makes substitution slow even where alternatives exist.' + ), + p( + 'AMD', + 'systems', + ['US'], + 'Accelerators and CPUs', + 'concentrated', + 'The credible merchant alternative, gated by the same packaging and memory as everyone else.' + ), + p( + 'Broadcom', + 'systems', + ['US'], + 'Custom accelerators and networking silicon', + 'concentrated', + 'Most large in-house accelerator programmes run through a very short list of design partners.' + ), + p( + 'Marvell', + 'systems', + ['US'], + 'Custom silicon, optics and interconnect', + 'concentrated', + 'The other name on that short list.' + ), + p( + 'Vertiv', + 'systems', + ['US'], + 'Datacentre power and thermal systems', + 'concentrated', + 'Rack-level power and cooling became a constraint the moment density outran air.' + ), + p( + 'Arista Networks', + 'systems', + ['US'], + 'Datacentre networking', + 'competitive', + 'Several credible suppliers of high-speed switching, and merchant silicon underneath most of them.' + ), + p( + 'Supermicro', + 'systems', + ['US', 'TW'], + 'Server systems integration', + 'competitive', + 'Integration capacity is contested; the parts going into it are not.' + ), + + // ---------------- Energy & grid ---------------- + p( + 'Hitachi Energy', + 'energy', + ['CH', 'JP'], + 'Transformers, HVDC, grid equipment', + 'chokepoint', + 'Large power transformers run to multi-year lead times, and a datacentre cannot be energised without one.' + ), + p( + 'Siemens Energy', + 'energy', + ['DE'], + 'Grid equipment and turbines', + 'chokepoint', + 'Order books for both halves of the energisation problem are effectively spoken for.' + ), + p( + 'GE Vernova', + 'energy', + ['US'], + 'Gas turbines and grid equipment', + 'chokepoint', + 'The fastest route to firm power at scale, sold out well into the future.' + ), + p( + 'Prysmian', + 'energy', + ['IT'], + 'High-voltage cable', + 'concentrated', + 'The unglamorous half of energisation, with the same inability to answer a demand shock quickly.' + ), + p( + 'NKT', + 'energy', + ['DK'], + 'High-voltage cable', + 'concentrated', + 'A short list of firms able to make and lay HV cable at all.' + ), + p( + 'Schneider Electric', + 'energy', + ['FR'], + 'Electrical distribution and datacentre power', + 'concentrated', + 'Switchgear and distribution have deepened into a constraint alongside transformers.' + ), + p( + 'ABB', + 'energy', + ['CH'], + 'Electrification and drives', + 'concentrated', + 'Present on both the power and the motion side of this chain.' + ), + p( + 'Mitsubishi Electric', + 'energy', + ['JP'], + 'Transformers and power electronics', + 'concentrated', + 'One of the few transformer makers with capacity outside Europe and the US.' + ), + + // ---------------- Actuation & robotics ---------------- + p( + 'Harmonic Drive Systems', + 'actuation', + ['JP'], + 'Strain-wave reduction gears', + 'chokepoint', + 'Precision drives set what a robot joint can do, and the tolerances are decades of accumulated practice.' + ), + p( + 'Nabtesco', + 'actuation', + ['JP'], + 'Cycloidal reduction gears', + 'chokepoint', + 'The other half of a duopoly that quietly gates humanoid and industrial robotics alike.' + ), + p( + 'FANUC', + 'actuation', + ['JP'], + 'Industrial robots and CNC', + 'concentrated', + 'Vertically integrated down to its own drives and controls, which is itself the moat.' + ), + p( + 'Yaskawa', + 'actuation', + ['JP'], + 'Servo motors and robots', + 'concentrated', + 'Servo and drive expertise that new entrants consistently underestimate.' + ), + p( + 'ABB Robotics', + 'actuation', + ['CH', 'SE'], + 'Industrial robots', + 'concentrated', + 'One of a small number of full-line robot makers worldwide.' + ), + p( + 'Renishaw', + 'actuation', + ['GB'], + 'Encoders and metrology', + 'concentrated', + 'Closing the control loop precisely is a narrow specialism.' + ), + p( + 'KUKA', + 'actuation', + ['DE', 'CN'], + 'Industrial robots', + 'competitive', + 'Robot assembly is contested; the drives inside are where the scarcity sits.' + ), + + // ---------------- Deployment & demand ---------------- + p( + 'Microsoft', + 'deployment', + ['US'], + 'Hyperscale compute buyer', + 'concentrated', + 'On the demand side the grade reads the other way: a handful of buyers account for most of the world’s accelerator orders.' + ), + p( + 'Amazon Web Services', + 'deployment', + ['US'], + 'Hyperscale compute buyer and custom silicon', + 'concentrated', + 'Buys at a scale that moves supply, and designs around it where it can.' + ), + p( + 'Google', + 'deployment', + ['US'], + 'Hyperscale compute buyer and custom silicon', + 'concentrated', + 'The longest-running in-house accelerator programme, and still bound by the same packaging.' + ), + p( + 'Meta', + 'deployment', + ['US'], + 'Hyperscale compute buyer', + 'concentrated', + 'Among the largest single sources of demand for everything upstream of it.' + ), + p( + 'OpenAI', + 'deployment', + ['US'], + 'Frontier model developer', + 'concentrated', + 'Demand large enough to be a planning input for several layers above it in this list.' + ), + p( + 'Anthropic', + 'deployment', + ['US'], + 'Frontier model developer', + 'concentrated', + 'Same: frontier training demand is concentrated in very few organisations.' + ), + p( + 'xAI', + 'deployment', + ['US'], + 'Frontier model developer', + 'concentrated', + 'Notable for building its own power and datacentre capacity to get around the queues.' + ), + p( + 'CoreWeave', + 'deployment', + ['US'], + 'Specialist compute provider', + 'competitive', + 'Neocloud capacity is contested and growing — the constraint is what they buy, not what they sell.' + ), +]; + +// ===================================================================== +// VIEWS +// ===================================================================== + +export interface ParticipantProgress { + total: number; + sourced: number; + chokepoints: number; + concentrated: number; + competitive: number; + jurisdictions: number; +} + +export function participantProgress(): ParticipantProgress { + const grade = (g: ScarcityGrade) => PARTICIPANTS.filter(item => item.scarcity === g).length; + return { + total: PARTICIPANTS.length, + sourced: PARTICIPANTS.filter(item => item.source !== null).length, + chokepoints: grade('chokepoint'), + concentrated: grade('concentrated'), + competitive: grade('competitive'), + jurisdictions: new Set(PARTICIPANTS.flatMap(item => item.jurisdictions)).size, + }; +} + +/** Participants in one layer, in the order they were written. */ +export function participantsInLayer(layer: ChainLayer): Participant[] { + return PARTICIPANTS.filter(item => item.layer === layer); +} + +/** Every participant graded a hard constraint — the point of the exercise. */ +export function bindingParticipants(): Participant[] { + return PARTICIPANTS.filter(item => item.scarcity === 'chokepoint'); +} diff --git a/src/config/substrata.ts b/src/config/substrata.ts new file mode 100644 index 000000000..c6edd7955 --- /dev/null +++ b/src/config/substrata.ts @@ -0,0 +1,583 @@ +/** + * "Substrata" — OrangeCat-side SSOT + * + * An open-source research firm covering the chokepoints between here and a + * technological singularity. Intelligence is not made of software. It is made + * of purified tin, neon and rare-earth metal — and also of EUV scanners nobody + * else can build, packaging capacity allocated years ahead, transformer slots, + * grid queues, and process knowledge that does not transfer with a purchase + * order. Behind each is a lead time and a dependency almost nobody has written + * down in public. + * + * THE PRODUCT IS THE MAP. There is NO TRADING DESK, and this file must not + * imply one: standing a regulated commodities book up is a long road through + * licensing, and until it is walked, publishing prices or inviting enquiries to + * deal would advertise a capability that does not exist. Research, data and intel are + * the whole of the business today. When a desk exists it will be added here + * with the disclosure rules that already sit below, written in advance + * precisely so they cannot look like a reaction later. + * + * This file is the single source of truth for the firm's identity, its mandate + * (the tests that make "focused" a rule rather than a slogan), its phases, its + * desks, its listed catalogue, and its compliance and disclosure stance. The + * Phase 1 coverage universe lives next door in `substrata-coverage.ts`. The + * seed that registers the firm on-platform (scripts/seed-substrata.ts) reads + * these two files and nothing else. + * + * On-platform shape: a `group` with label 'company' (public, so the research + * and the book are both readable), its own `actors` row of actor_type 'group', + * and a catalogue of `user_products` owned by that group actor. Ownership + * follows the Revive My Old Ride convention — the founder's `mao` actor + * creates it; swap to a dedicated actor by changing FOUNDER_ACTOR_SLUG. + * + * Created: 2026-08-26 + */ + +// ===================================================================== +// OWNERSHIP +// ===================================================================== + +/** Actor slug of the user who founds the firm (created_by + founder seat). */ +export const FOUNDER_ACTOR_SLUG = 'mao'; + +// ===================================================================== +// IDENTITY +// ===================================================================== + +export const COMPANY = { + name: 'Substrata', + /** Also the actor slug and the public path: /groups/substrata */ + slug: 'substrata', + tagline: 'The chokepoints between here and the singularity, written down in public.', +} as const; + +// ===================================================================== +// THE MANDATE — first test: which curve does it move? +// ===================================================================== + +/** + * Coverage and trading both start here. A node enters the universe only if it + * sits on the critical path of one of three curves. Everything else — however + * interesting, however profitable — is out of scope. + */ +export const MANDATE_CURVES = [ + { + id: 'compute-per-joule', + label: 'Compute per joule', + test: 'Does this make a thought cheaper to have?', + detail: + 'Feedstock, lithography consumables, thermal materials, and the firms ' + + 'that make them — what decides how much computation a watt can buy.', + }, + { + id: 'joules-delivered', + label: 'Joules delivered', + test: 'Does this get power to where the compute is?', + detail: + 'Transformer steel, conductors, superconducting tape, the cryogens that ' + + 'keep them cold, and the interconnect queue. A datacentre that cannot ' + + 'be energised is a shed.', + }, + { + id: 'actuation', + label: 'Actuation', + test: 'Does this give intelligence hands?', + detail: + 'Permanent-magnet feed, precision drives, additive manufacturing — the ' + + 'step where a model stops advising and starts doing physical work.', + }, +] as const; + +export type CurveId = (typeof MANDATE_CURVES)[number]['id']; + +// ===================================================================== +// THE MANDATE — second test: is it actually a chokepoint? +// ===================================================================== + +/** + * Being on a curve is not enough; most of a supply chain is substitutable and + * therefore uninteresting. A node earns coverage when it GATES a curve. These + * four factors are the screen, and they are why the universe stays countable + * as the firm expands from materials into robotics, compute and manufacturing. + */ +export const CHOKEPOINT_TEST = [ + { + id: 'concentration', + question: 'How few suppliers actually qualify?', + detail: 'Qualified is not the same as capable — a producer nobody has certified is not supply.', + }, + { + id: 'substitutability', + question: 'What happens if it disappears — a workaround, or a stop?', + detail: 'A material with a drop-in replacement is a price story, not a chokepoint.', + }, + { + id: 'lead-time', + question: 'How long from order to delivery, and from decision to new capacity?', + detail: 'Large power transformers gate more datacentres today than chip supply does.', + }, + { + id: 'demand-inelasticity', + question: 'Can the buyer walk away at any price?', + detail: 'If the machine does not exist without it, the demand curve is a wall.', + }, +] as const; + +/** + * The exclusion rule, stated plainly because it is the harder half of focus. + * A firm that will cover anything has no edge, in the same way that an analyst + * with an opinion on everything has none worth reading. + */ +export const EXCLUSION_RULE = { + rule: 'On a curve, and a chokepoint. Fail either test and it does not enter coverage.', + explainer: + 'We decline business and coverage every week. Not because either is ' + + 'unprofitable, but because a universe that drifts into general commodities ' + + 'and general tech loses the only edge a specialist has: knowing, for a ' + + 'countable number of nodes, every qualified producer, every grade that ' + + 'actually ships, and every lead time that is real rather than quoted.', +} as const; + +// ===================================================================== +// NODE TYPES — how the universe grows without becoming "everything" +// ===================================================================== + +/** + * The unit of coverage is a chokepoint NODE, not an asset class. A node can be + * a material, a company, a person, a machine or a process — the tests above + * apply identically to all of them. This is what lets the firm move from rare + * earths into robotics, AI hardware and additive manufacturing without a + * change of strategy: you do not decide to cover robotics, you ARRIVE at it by + * tracing dysprosium downstream. The graph grows by traversal, not by ambition. + */ +export const NODE_TYPES = [ + { + id: 'material', + label: 'Material', + detail: 'A substance with a grade, a purity and a producer.', + }, + { + id: 'company', + label: 'Company', + detail: 'Public or private. Most of the interesting ones are private.', + }, + { id: 'person', label: 'Person', detail: 'Where the process knowledge actually lives.' }, + { + id: 'machine', + label: 'Machine', + detail: 'Tools with single-digit annual output and multi-year queues.', + }, + { + id: 'process', + label: 'Process', + detail: 'Know-how that does not transfer with a purchase order.', + }, +] as const; + +export type NodeType = (typeof NODE_TYPES)[number]['id']; + +// ===================================================================== +// PHASES — research first, trade second, integrate third +// ===================================================================== + +/** + * Research leads because it costs nothing but attention, because it IS the + * sourcing work the desk needs anyway, and because publishing is the + * distribution engine: put the map out, the people who work in it correct it, + * and you become the place people check. The research is given away; what is + * monetised is the position it buys — deal flow, counterparty access, and a + * view of where the chain is thin. + */ +export const PHASES = [ + { + id: 'producers-of-the-fifteen', + label: 'Phase 1 — the producers of the fifteen', + status: 'active', + detail: + 'Map every qualified producer of the fifteen materials under coverage. ' + + 'Mostly private, mostly uncovered: the sell side writes about chip ' + + 'designers, not about who fires crucible-grade quartz.', + }, + { + id: 'beyond-materials', + label: 'Phase 2 — the chokepoints that are not materials', + status: 'active', + detail: + 'A material is only one kind of chokepoint. A tool with one supplier, ' + + 'packaging capacity allocated years ahead, a transformer order book, an ' + + 'interconnection queue and a process that lives in people rather than ' + + 'equipment all gate the same curves. These enter coverage on the same ' + + 'two tests, and the universe has already started admitting them.', + }, + { + id: 'one-hop-out', + label: 'Phase 3 — one hop out', + status: 'planned', + detail: + 'From each node, one hop upstream and one downstream. This is where ' + + 'robotics, AI hardware and additive manufacturing enter on their own — ' + + 'as counterparties in a chain already being mapped, not as a new vertical.', + }, + { + id: 'desk', + label: 'Later — a desk, if and when it is licensed', + status: 'not-started', + detail: + 'Acting on the map rather than only publishing it means a regulated ' + + 'book: licensing, compliance, capital and counterparty onboarding, none ' + + 'of it quick. It is an intention, not a service, and nothing on this ' + + 'site should be read as an offer to trade.', + }, +] as const; + +// ===================================================================== +// COVERAGE AREAS +// +// Named for segments of the chain, not for trading desks — there is no desk. +// ===================================================================== + +export type AreaId = 'lithography' | 'feedstock' | 'thermal' | 'power' | 'actuation'; + +export interface CoverageArea { + id: AreaId; + name: string; + curve: CurveId; + covers: string; +} + +export const COVERAGE_AREAS: readonly CoverageArea[] = [ + { + id: 'lithography', + name: 'Lithography & Optics', + curve: 'compute-per-joule', + covers: + 'Consumables the leading-edge fab burns to expose a wafer: EUV droplet ' + + 'tin, excimer and source gases, capping-layer platinum-group metals, ' + + 'fused silica and calcium fluoride optical blanks.', + }, + { + id: 'feedstock', + name: 'Semiconductor Feedstock', + curve: 'compute-per-joule', + covers: + 'What a wafer is made of before anything is printed on it: ' + + 'electronic-grade polysilicon, prime wafers, crucible-grade quartz, and ' + + 'the compound-semiconductor metals — gallium, germanium, indium.', + }, + { + id: 'thermal', + name: 'Thermal & Packaging', + curve: 'compute-per-joule', + covers: + 'The materials that carry heat away from a die, which is what actually ' + + 'caps rack density: CVD diamond and SiC spreaders, wide-bandgap ' + + 'substrates, two-phase dielectric coolants.', + }, + { + id: 'power', + name: 'Power, Grid & Superconductors', + curve: 'joules-delivered', + covers: + 'Grain-oriented electrical steel for transformers, Grade A copper, ' + + 'REBCO superconducting tape, and the helium that keeps superconductors ' + + 'superconducting.', + }, + { + id: 'actuation', + name: 'Actuation & Robotics', + curve: 'actuation', + covers: + 'Permanent-magnet feed — didymium and the heavy rare earths that hold ' + + 'coercivity hot — plus the cobalt and precision-drive alloys behind ' + + 'every robot joint.', + }, +] as const; + +// ===================================================================== +// MATERIALS UNDER COVERAGE +// +// Research subjects, not a price list. There are deliberately no prices, no +// units and no lot sizes here: this firm does not trade, and a page carrying +// indicative levels reads as an invitation to deal whatever the small print +// says. What is kept is the part that is research — why the material gates a +// curve, and which grade actually ships, since "tin" and "seven-nines tin +// qualified for an EUV source" are different markets. +// +// This list is also the Phase 1 work queue: `substrata-coverage.ts` owes a +// producer map to every title here, and a test enforces the correspondence. +// ===================================================================== + +export interface MaterialListing { + /** Display name, and the key `substrata-coverage.ts` maps producers onto. */ + title: string; + area: AreaId; + /** Why this material gates a curve — the research claim. */ + why: string; + /** The grade that actually ships. Naming it is most of the specialism. */ + spec: string; + tags: string[]; +} + +export const MATERIALS: readonly MaterialListing[] = [ + // ---------- Lithography & Optics ---------- + { + title: 'High-purity tin, EUV droplet grade', + area: 'lithography', + why: 'Every EUV photon in production today starts as a tin droplet hit by a CO₂ laser. Purity, not tonnage, is the constraint.', + spec: '7N (99.99999%) tin, shot or ingot, certificate of analysis per lot.', + tags: ['euv', 'lithography', 'tin', 'high-purity'], + }, + { + title: 'Neon, excimer laser grade', + area: 'lithography', + why: 'DUV excimer sources run on neon mixtures. The 2022 squeeze showed how thin and how geographically concentrated that supply is.', + spec: '≥99.999% neon, cylinder or ISO container, blended mixes to order.', + tags: ['neon', 'noble-gas', 'duv', 'lithography'], + }, + { + title: 'Ruthenium, sputtering and ALD grade', + area: 'lithography', + why: 'Caps EUV multilayer mirrors and lines advanced interconnect. Annual world supply is a few dozen tonnes, almost all a by-product of other mining.', + spec: '4N ruthenium, targets or precursor feed, PGM-refiner traceable.', + tags: ['ruthenium', 'pgm', 'euv', 'interconnect'], + }, + + // ---------- Semiconductor Feedstock ---------- + { + title: 'Electronic-grade polysilicon', + area: 'feedstock', + why: 'The first material in the chain. Solar-grade will not do: one part per billion of boron changes the device.', + spec: '11N (99.999999999%) polysilicon chunk or rod, Siemens process.', + tags: ['polysilicon', 'feedstock', 'wafer', 'high-purity'], + }, + { + title: '300 mm prime silicon wafers', + area: 'feedstock', + why: 'The unit of account for all leading-edge capacity. Every fab expansion is ultimately a wafer-start number.', + spec: 'Prime polished 300 mm, p-type or n-type, epi to specification.', + tags: ['wafer', '300mm', 'silicon', 'feedstock'], + }, + { + title: 'Crucible-grade high-purity quartz sand', + area: 'feedstock', + why: 'Czochralski crucibles need a quartz purity that comes, in practice, from a very small number of deposits. A genuine single point of failure for the whole industry.', + spec: 'Inner-layer crucible grade, ≤ 20 ppm total impurities.', + tags: ['quartz', 'crucible', 'czochralski', 'feedstock'], + }, + { + title: 'Gallium, refined', + area: 'feedstock', + why: 'GaN power stages and RF front-ends. A by-product of alumina refining, so supply cannot respond quickly to demand — and it is export-controlled.', + spec: '4N–7N gallium metal. Export-licence and end-use documentation required.', + tags: ['gallium', 'gan', 'compound-semiconductor', 'export-controlled'], + }, + + // ---------- Thermal & Packaging ---------- + { + title: 'CVD synthetic diamond heat spreader', + area: 'thermal', + why: 'The highest thermal conductivity available at any price. Where the die is hot enough that copper has stopped being an answer.', + spec: 'Polycrystalline CVD diamond, 10 × 10 mm, metallised to specification.', + tags: ['diamond', 'thermal', 'packaging', 'cvd'], + }, + { + title: 'Silicon carbide substrate, 200 mm semi-insulating', + area: 'thermal', + why: 'Wide-bandgap power conversion is how a datacentre stops wasting a tenth of its intake as heat in the power train.', + spec: '200 mm semi-insulating 4H-SiC, micropipe density to specification.', + tags: ['sic', 'wide-bandgap', 'power', 'substrate'], + }, + { + title: 'Two-phase dielectric immersion coolant', + area: 'thermal', + why: 'Air cooling ends somewhere around 50 kW a rack. Immersion is what the next order of magnitude of density runs on.', + spec: 'Engineered fluid, boiling point matched to the target die temperature.', + tags: ['immersion', 'cooling', 'datacenter', 'dielectric'], + }, + + // ---------- Power, Grid & Superconductors ---------- + { + title: 'Grain-oriented electrical steel (GOES)', + area: 'power', + why: 'Every megawatt reaching a GPU passes through transformer cores. Lead times on large power transformers, not chip supply, are the binding constraint on many buildouts.', + spec: 'M3-class grain-oriented silicon steel, coil, coated.', + tags: ['goes', 'transformer', 'grid', 'electrical-steel'], + }, + { + title: 'REBCO superconducting tape, 12 mm', + area: 'power', + why: 'High-field magnets for fusion and for compact motors. The kilometre-per-machine numbers make tape output an industry-level bottleneck.', + spec: '12 mm REBCO tape, critical current specified at 77 K, self-field.', + tags: ['rebco', 'superconductor', 'fusion', 'magnets'], + }, + { + title: 'Liquid helium (He-4)', + area: 'power', + why: 'Nothing else reaches 4 K at scale. Superconducting magnets and every dilution refrigerator in quantum computing depend on a supply tied to a handful of gas fields.', + spec: '5N liquid helium, dewar or ISO container, boil-off terms per contract.', + tags: ['helium', 'cryogenics', 'superconductor', 'quantum'], + }, + + // ---------- Actuation & Robotics ---------- + { + title: 'Didymium (Nd-Pr) metal, magnet feed', + area: 'actuation', + why: 'The bulk of every NdFeB magnet, and therefore of every robot joint, traction motor and hard-drive actuator.', + spec: 'Nd-Pr metal ingot, 75/25 nominal, ≥99% RE.', + tags: ['rare-earth', 'ndfeb', 'magnets', 'robotics'], + }, + { + title: 'Dysprosium metal', + area: 'actuation', + why: 'The heavy rare earth that keeps a magnet coercive when the motor gets hot. Small quantities, no substitute, single-country refining.', + spec: '≥99% dysprosium metal. Export-licence and end-use documentation required.', + tags: ['dysprosium', 'rare-earth', 'magnets', 'export-controlled'], + }, +]; + +// ===================================================================== +// SCOPE — what this firm does not do +// ===================================================================== + +/** + * Several materials under coverage are dual-use and export-controlled + * (gallium, germanium, the heavy rare earths). That is a fact ABOUT them and a + * reason to cover them carefully — it is not an operating obligation here, + * because nothing is bought, sold, brokered or moved. Saying so plainly + * matters more than a compliance section would: an export-licence policy on a + * firm with no shipments is theatre, and theatre is what makes the honest + * parts of a page harder to believe. + */ +export const SCOPE = { + today: [ + 'We publish research. We do not trade, broker, quote, or arrange the ' + + 'movement of any material under coverage.', + 'Nothing on this site is an offer, a solicitation, an inducement to deal, ' + + 'or investment advice.', + 'We hold no position in anything we cover. When that changes it will be ' + + 'disclosed before the note, not after.', + ], + outOfScope: + 'Nothing on the weapons or nuclear-fuel-cycle path is covered, and no ' + + 'coverage is written to help anyone acquire a controlled material. The ' + + 'three curves are compute, energy and actuation.', +} as const; + +// ===================================================================== +// DISCLOSURE — written before there is anything to disclose +// ===================================================================== + +/** + * Today the disclosure is short, because the firm holds nothing: no book, no + * positions, no counterparties. The rules below are kept anyway, in advance of + * the desk that may one day exist, for the reason a disclosure policy is only + * ever credible before it is needed. Written after the first position, every + * clause reads as a response to something. + */ +export const DISCLOSURE = { + today: + 'Substrata holds no position in anything it covers, trades nothing, and ' + + 'is paid by nobody it writes about. There is currently nothing to declare, ' + + 'and that itself is the declaration.', + rules: [ + 'Every published note states the firm’s position in what it covers — long, ' + + 'short, flat, or none — at time of publication.', + 'Research is never withheld, delayed or softened because of a position. If ' + + 'the two conflict, the position is the thing that moves.', + 'Nothing is published to move a price the firm is about to act on. Notes go ' + + 'out on a schedule.', + 'Sources are named. An unsourced claim is marked unverified rather than ' + + 'stated, however confident the analyst is.', + ], + openByDefault: + 'The research is free and public. What it buys is standing — being the ' + + 'place people check, and being told when a row is wrong by someone who ' + + 'works in that chain.', +} as const; + +// ===================================================================== +// PUBLIC LISTING COPY +// ===================================================================== + +export const LISTING_COPY = { + headline: COMPANY.name, + subhead: COMPANY.tagline, + body: [ + 'Substrata is an open-source research firm covering the physical ' + + 'chokepoints between here and a technological singularity. The research ' + + 'is free and the map is the product. There is no trading desk and no ' + + 'position in anything covered here.', + 'A node enters coverage only if it passes two tests: it moves one of ' + + 'three curves — compute per joule, joules delivered, or actuation — and ' + + 'it genuinely gates that curve, on concentration, substitutability, ' + + 'lead time and demand inelasticity. A node can be a material, a machine, ' + + 'a company, a process or a person; the tests do not care, and a tool ' + + 'with one supplier gates a curve as hard as an element does.', + 'Two phases run at once. Every qualified producer of the fifteen ' + + 'materials under coverage, and the chokepoints that are not materials ' + + 'at all — packaging capacity, transformer order books, interconnection ' + + 'queues, process knowledge that does not transfer with equipment.', + ], + cta: 'Read the map', +} as const; + +// ===================================================================== +// GROUP PROFILE PAYLOAD (maps 1:1 to the live `groups` table) +// ===================================================================== + +export interface CompanyGroupPayload { + name: string; + slug: string; + description: string; + label: string; + tags: string[]; + is_public: boolean; + visibility: 'public' | 'members_only' | 'private'; + governance_preset: string; +} + +export const GROUP_PAYLOAD: CompanyGroupPayload = { + name: COMPANY.name, + slug: COMPANY.slug, + description: [LISTING_COPY.subhead, '', ...LISTING_COPY.body].join('\n'), + label: 'company', + tags: [ + 'research', + 'open-source-research', + 'supply-chain', + 'chokepoints', + 'semiconductors', + 'rare-earths', + 'energy', + 'robotics', + 'singularity', + ], + // The 'company' label defaults to members_only. This firm publishes its + // research and expects a reader to check it before sending any + // enquiry, so the research has to be readable without an account. + is_public: true, + visibility: 'public', + governance_preset: 'hierarchical', +} as const; + +/** + * No features. `marketplace` was enabled to list a catalogue that no longer + * exists — a research firm with nothing to sell should not advertise a shop — + * and `treasury` needs a `bitcoin_address` the group does not have. Both get + * enabled by the commit that gives them something true to point at. + */ +export const GROUP_FEATURE_KEYS: readonly string[] = []; + +// ===================================================================== +// LOOKUPS +// ===================================================================== + +const AREA_BY_ID: Record<AreaId, CoverageArea> = COVERAGE_AREAS.reduce( + (acc, area) => ({ ...acc, [area.id]: area }), + {} as Record<AreaId, CoverageArea> +); + +/** @returns the coverage area a material belongs to. */ +export function areaFor(material: MaterialListing): CoverageArea { + return AREA_BY_ID[material.area]; +} diff --git a/src/middleware.ts b/src/middleware.ts index 092b9fba4..731bd102c 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -2,6 +2,7 @@ import { NextResponse } from 'next/server'; import type { NextRequest } from 'next/server'; import { createServerClient } from '@supabase/ssr'; import { getRouteSurface, ROUTES } from '@/config/routes'; +import { SITES_PATH_PREFIX, siteForHost } from '@/config/sites'; // Edge middleware route classification reads from the SAME SSOT used by // AppShell / MobileBottomNav / Footer / Header (src/config/routes.ts). @@ -35,6 +36,22 @@ export async function middleware(request: NextRequest) { return NextResponse.next(); } + // Hosted sites (src/config/sites.ts): a request arriving on a site's own + // host is somebody else's website, so rewrite it onto /sites/<slug> and let + // that standalone layout answer. Rewrite, not redirect — the visitor's URL + // bar must keep saying substrata.orangecat.ch, which is the entire point of + // what /domains sells. Requests already on /sites/... pass through, so the + // path form stays previewable from any host. + const hostedSite = siteForHost(request.headers.get('host')); + if (hostedSite && !pathname.startsWith(`${SITES_PATH_PREFIX}/`)) { + const target = request.nextUrl.clone(); + target.pathname = `${SITES_PATH_PREFIX}/${hostedSite.slug}${pathname === '/' ? '' : pathname}`; + const rewritten = NextResponse.rewrite(target); + rewritten.headers.set('x-pathname', target.pathname); + rewritten.headers.set('x-hosted-site', hostedSite.slug); + return rewritten; + } + const response = NextResponse.next({ request: { headers: request.headers, diff --git a/src/services/domains/availability.ts b/src/services/domains/availability.ts new file mode 100644 index 000000000..575766125 --- /dev/null +++ b/src/services/domains/availability.ts @@ -0,0 +1,206 @@ +/** + * Domain availability via registry RDAP. + * + * The contract this module keeps: it never reports `unregistered` unless the + * TLD appears in IANA's RDAP bootstrap AND the registry answered 404. Any + * other outcome — TLD with no RDAP service, timeout, transport failure, + * unexpected status — is `unknown`. See `src/config/domain-search.ts` for the + * false positive (orangecat.ch) that this rule exists to prevent. + * + * Created: 2026-08-26 + */ + +import { + DOMAIN_RESULT_TTL_MS, + RDAP_BOOTSTRAP_TTL_MS, + RDAP_BOOTSTRAP_URL, + RDAP_CONCURRENCY, + RDAP_QUERY_BASE, + RDAP_TIMEOUT_MS, + type DomainStatus, +} from '@/config/domain-search'; +import { logger } from '@/utils/logger'; + +export interface DomainResult { + /** Full domain, lowercased: 'substrataintel.com'. */ + domain: string; + /** Label part: 'substrataintel'. */ + name: string; + tld: string; + status: DomainStatus; + /** Why the status is what it is — surfaced so nobody has to guess. */ + reason: string; + /** True when this registry publishes RDAP, i.e. a definite answer was possible. */ + rdapSupported: boolean; +} + +// --------------------------------------------------------------------------- +// Bootstrap: which TLDs can answer at all +// --------------------------------------------------------------------------- + +let bootstrapCache: { tlds: Set<string>; fetchedAt: number } | null = null; + +/** + * TLDs that operate a public RDAP service, per IANA. + * + * On failure this returns null rather than an empty set — an empty set would + * be indistinguishable from "no TLD supports RDAP" and would silently turn + * every lookup into `unknown` without saying why. + */ +export async function loadRdapTlds(now: number = Date.now()): Promise<Set<string> | null> { + if (bootstrapCache && now - bootstrapCache.fetchedAt < RDAP_BOOTSTRAP_TTL_MS) { + return bootstrapCache.tlds; + } + try { + const response = await fetch(RDAP_BOOTSTRAP_URL, { + signal: AbortSignal.timeout(RDAP_TIMEOUT_MS), + headers: { accept: 'application/json' }, + }); + if (!response.ok) { + logger.warn('RDAP bootstrap fetch failed', { status: response.status }, 'DomainSearch'); + return bootstrapCache?.tlds ?? null; + } + const body = (await response.json()) as { services?: Array<[string[], string[]]> }; + const tlds = new Set<string>(); + for (const service of body.services ?? []) { + for (const tld of service[0] ?? []) { + tlds.add(tld.toLowerCase()); + } + } + if (tlds.size === 0) { + return bootstrapCache?.tlds ?? null; + } + bootstrapCache = { tlds, fetchedAt: now }; + return tlds; + } catch (error) { + logger.warn('RDAP bootstrap unreachable', { error: String(error) }, 'DomainSearch'); + // A stale set beats no answer; null means we have never had one. + return bootstrapCache?.tlds ?? null; + } +} + +/** Test seam — resets both caches. */ +export function resetDomainCaches(): void { + bootstrapCache = null; + resultCache.clear(); +} + +// --------------------------------------------------------------------------- +// Per-domain lookup +// --------------------------------------------------------------------------- + +const resultCache = new Map<string, { result: DomainResult; fetchedAt: number }>(); + +/** Split 'substrataintel.com' into its label and TLD. Null if it isn't a domain. */ +export function parseDomain(input: string): { name: string; tld: string } | null { + const cleaned = input + .trim() + .toLowerCase() + .replace(/^https?:\/\//, '') + .replace(/\/.*$/, '') + .replace(/\.$/, ''); + const match = /^([a-z0-9-]+(?:\.[a-z0-9-]+)*)\.([a-z]{2,63})$/.exec(cleaned); + if (!match) { + return null; + } + const [, name, tld] = match; + if (name.startsWith('-') || name.endsWith('-')) { + return null; + } + return { name, tld }; +} + +function unknown(name: string, tld: string, reason: string, rdapSupported: boolean): DomainResult { + return { domain: `${name}.${tld}`, name, tld, status: 'unknown', reason, rdapSupported }; +} + +/** + * Look one domain up. + * + * @param rdapTlds the bootstrap set, or null when it could not be loaded. + */ +export async function checkDomain( + input: string, + rdapTlds: Set<string> | null, + now: number = Date.now() +): Promise<DomainResult> { + const parsed = parseDomain(input); + if (!parsed) { + return unknown(input, '', 'Not a valid domain name.', false); + } + const { name, tld } = parsed; + const domain = `${name}.${tld}`; + + const cached = resultCache.get(domain); + if (cached && now - cached.fetchedAt < DOMAIN_RESULT_TTL_MS) { + return cached.result; + } + + if (!rdapTlds) { + return unknown(name, tld, 'The RDAP registry list could not be loaded.', false); + } + if (!rdapTlds.has(tld)) { + // The important branch. .ch, .io and .co land here — and a 404 from a + // redirector for one of them means nothing at all. + return unknown( + name, + tld, + `The .${tld} registry publishes no RDAP service, so availability cannot be confirmed here.`, + false + ); + } + + let result: DomainResult; + try { + const response = await fetch(`${RDAP_QUERY_BASE}/${encodeURIComponent(domain)}`, { + signal: AbortSignal.timeout(RDAP_TIMEOUT_MS), + headers: { accept: 'application/rdap+json, application/json' }, + redirect: 'follow', + }); + + if (response.status === 404) { + result = { + domain, + name, + tld, + status: 'unregistered', + reason: 'The registry reports no registration record for this name.', + rdapSupported: true, + }; + } else if (response.ok) { + result = { + domain, + name, + tld, + status: 'registered', + reason: 'The registry returned a registration record.', + rdapSupported: true, + }; + } else { + result = unknown( + name, + tld, + `The registry answered ${response.status}; treat as unresolved.`, + true + ); + } + } catch (error) { + result = unknown(name, tld, 'The registry did not answer in time.', true); + logger.warn('RDAP lookup failed', { domain, error: String(error) }, 'DomainSearch'); + } + + resultCache.set(domain, { result, fetchedAt: now }); + return result; +} + +/** Look several domains up, a few at a time so no registry is hammered. */ +export async function checkDomains(domains: string[]): Promise<DomainResult[]> { + const rdapTlds = await loadRdapTlds(); + const results: DomainResult[] = []; + + for (let index = 0; index < domains.length; index += RDAP_CONCURRENCY) { + const batch = domains.slice(index, index + RDAP_CONCURRENCY); + results.push(...(await Promise.all(batch.map(domain => checkDomain(domain, rdapTlds))))); + } + return results; +} diff --git a/src/services/domains/suggest.ts b/src/services/domains/suggest.ts new file mode 100644 index 000000000..f42a5d597 --- /dev/null +++ b/src/services/domains/suggest.ts @@ -0,0 +1,82 @@ +/** + * Turn what somebody typed into a short list of domains worth checking. + * + * Short on purpose. A hundred generated names is a wall nobody reads; the name + * a person actually registers is almost always the seed itself, or the seed + * plus one honest qualifier. So this generates the bare name across the + * offered TLDs first — that is the answer most searches want — and only then + * the patterned variants. + * + * Created: 2026-08-26 + */ + +import { CANDIDATE_TLDS, MAX_CANDIDATES, NAME_PATTERNS } from '@/config/domain-search'; +import { parseDomain } from './availability'; + +export interface SuggestionInput { + /** Whatever the user typed — a word, a phrase, or a full domain. */ + query: string; + /** Override the offered TLDs (FleetCrown passes its customer's preference). */ + tlds?: readonly string[]; +} + +/** Strip a query down to a usable domain label: 'Substrate Intel' → 'substrateintel'. */ +export function toSeed(query: string): string { + return query + .trim() + .toLowerCase() + .normalize('NFD') + .replace(/[̀-ͯ]/g, '') + .replace(/[^a-z0-9]+/g, '') + .slice(0, 63); +} + +/** + * Candidate domains for a query, best-first and de-duplicated. + * + * If the query already names a domain ('substrataintel.com'), that exact + * domain leads the list — someone who typed a full domain is asking about + * that domain, and burying it under generated alternatives would be rude. + */ +export function suggestDomains(input: SuggestionInput): string[] { + const tlds = (input.tlds?.length ? input.tlds : CANDIDATE_TLDS).map(tld => + tld.replace(/^\./, '').toLowerCase() + ); + + const candidates: string[] = []; + const seen = new Set<string>(); + + const push = (domain: string) => { + if (!seen.has(domain) && candidates.length < MAX_CANDIDATES) { + seen.add(domain); + candidates.push(domain); + } + }; + + const exact = parseDomain(input.query); + if (exact) { + push(`${exact.name}.${exact.tld}`); + } + + const seed = toSeed(exact ? exact.name : input.query); + if (!seed) { + return candidates; + } + + // The bare name across every offered TLD, before any invented variant. + for (const tld of tlds) { + push(`${seed}.${tld}`); + } + + for (const pattern of NAME_PATTERNS) { + if (pattern.id === 'bare') { + continue; + } + const rendered = pattern.render(seed); + for (const tld of tlds.slice(0, 3)) { + push(`${rendered}.${tld}`); + } + } + + return candidates; +}