From 527fa78a0f9604cd8970e3731c84b4f736e82d5a Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 26 Aug 2026 15:49:14 +0000 Subject: [PATCH 01/17] feat(groups): a trading company whose whole book is singularity materials MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Substrate Materials is a company profile with exactly one focus: the physical inputs a technological singularity actually consumes. Software is not the constraint — purified tin, neon, polysilicon, ruthenium, transformer steel and rare-earth metal are, and each has a chokepoint, a lead time and a counterparty. The profile is a `group` (label 'company', public so a counterparty can read the book before sending an RFQ), its own actor_type 'group' actor, and a 15-line catalogue of `user_products` owned by that actor. Follows the Revive My Old Ride convention: copy defined once in config, a separate owner-gated seed writes it to the live DB. What makes "solely focused" a rule rather than a slogan is the inclusion test. A material is listed only if it moves one of three curves — compute per joule, joules delivered, or actuation. Everything else is declined, and the test is enforced: every listing must sit on a desk, every desk on a curve, and no desk may sit empty. - src/config/singularity-materials.ts — SSOT: mandate, exclusion rule, five desks, the catalogue, compliance stance, group + product payloads - scripts/seed-singularity-materials.ts — idempotent, owner-gated seed (group by slug, actor by group_id, listings by (actor_id, title)) - test gating the payloads against the live CHECK constraints, the label / feature / governance registries, and the mandate itself Treasury is deliberately not enabled: GROUP_FEATURES.treasury requires a bitcoin_address the company does not yet have. Prices are indicative reference levels, and every listing says so — a budgeting number, not a quote. Export-controlled lines carry the licence and end-use conditions. The seed has not been run; it needs the box's service-role key. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01GF9GDWaBWYHCZ9iNwmfZ41 --- .../unit/config/singularity-materials.test.ts | 137 +++++ scripts/seed-singularity-materials.ts | 242 +++++++++ src/config/singularity-materials.ts | 477 ++++++++++++++++++ 3 files changed, 856 insertions(+) create mode 100644 __tests__/unit/config/singularity-materials.test.ts create mode 100644 scripts/seed-singularity-materials.ts create mode 100644 src/config/singularity-materials.ts diff --git a/__tests__/unit/config/singularity-materials.test.ts b/__tests__/unit/config/singularity-materials.test.ts new file mode 100644 index 000000000..fd5c170d5 --- /dev/null +++ b/__tests__/unit/config/singularity-materials.test.ts @@ -0,0 +1,137 @@ +/** + * The Substrate Materials profile has to survive contact with the live schema. + * + * The company 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. These tests are the cheap version of + * that discovery: they hold the payloads against the CHECK constraints in + * supabase/migrations/20240101000001_baseline_public_schema.sql and against the + * group label / feature / governance registries. + * + * They also hold the company to its own mandate — every listing has to trace to + * one of the three curves, which is what "solely focused" means here. + */ + +import { + CATALOGUE, + COMPANY, + DESKS, + GROUP_FEATURE_KEYS, + GROUP_PAYLOAD, + MANDATE_CURVES, + PRODUCT_PAYLOADS, + deskFor, +} from '@/config/singularity-materials'; +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('Substrate Materials — 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, because a counterparty reads before it quotes', () => { + 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('Substrate Materials — catalogue', () => { + it('lists something', () => { + expect(PRODUCT_PAYLOADS.length).toBe(CATALOGUE.length); + expect(PRODUCT_PAYLOADS.length).toBeGreaterThan(0); + }); + + it('has unique titles, since the seed keys idempotency on (actor_id, title)', () => { + const titles = PRODUCT_PAYLOADS.map(p => p.title); + expect(new Set(titles).size).toBe(titles.length); + }); + + it.each(PRODUCT_PAYLOADS.map(p => [p.title, p] as const))( + '%s satisfies every user_products CHECK constraint', + (_title, payload) => { + expect(PRODUCT_CURRENCIES).toContain(payload.currency); + expect(PRODUCT_TYPES).toContain(payload.product_type); + expect(FULFILLMENT_TYPES).toContain(payload.fulfillment_type); + expect(PRODUCT_STATUSES).toContain(payload.status); + expect(payload.price).toBeGreaterThan(0); + } + ); + + it('prices fit numeric(20,8) — no value the column would silently round', () => { + for (const payload of PRODUCT_PAYLOADS) { + const decimals = (String(payload.price).split('.')[1] ?? '').length; + expect(decimals).toBeLessThanOrEqual(8); + expect(String(Math.trunc(payload.price)).length).toBeLessThanOrEqual(12); + } + }); + + it('says the price is indicative, so no listing reads as a firm quote', () => { + for (const payload of PRODUCT_PAYLOADS) { + expect(payload.description).toContain('Indicative reference'); + expect(payload.description).toContain('not a quote'); + } + }); +}); + +describe('Substrate Materials — 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 desk of DESKS) { + expect(curveIds).toContain(desk.curve); + } + }); + + it('every listed material sits on a desk, and so on a curve', () => { + for (const listing of CATALOGUE) { + const desk = deskFor(listing); + expect(desk).toBeDefined(); + expect(desk.id).toBe(listing.desk); + } + }); + + it('every listing is filed under its desk name, which is the product category', () => { + const deskNames = DESKS.map(desk => desk.name); + for (const payload of PRODUCT_PAYLOADS) { + expect(deskNames).toContain(payload.category); + } + }); + + it('no desk is left without a listing — an empty desk is scope creep on paper', () => { + for (const desk of DESKS) { + expect(CATALOGUE.some(listing => listing.desk === desk.id)).toBe(true); + } + }); +}); diff --git a/scripts/seed-singularity-materials.ts b/scripts/seed-singularity-materials.ts new file mode 100644 index 000000000..d5b916325 --- /dev/null +++ b/scripts/seed-singularity-materials.ts @@ -0,0 +1,242 @@ +/** + * Seed "Substrate Materials" — a trading company whose entire book is the + * materials a technological singularity consumes — as an OrangeCat group + * profile with its own actor and catalogue. + * + * 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, membership and features by their unique keys, + * and each listing by (actor_id, title). Never truncates. Safe to re-run. + * Owner-gated so it can't fire by accident. + * + * Run against the LIVE self-hosted DB (supabase.orangecat.ch) from the box: + * ORANGECAT_OWNER_SEED=1 npx tsx scripts/seed-singularity-materials.ts + * + * Requires in the environment (already in .env.local on the box): + * NEXT_PUBLIC_SUPABASE_URL — self-hosted Supabase URL + * SUPABASE_SERVICE_ROLE_KEY — service role (bypasses RLS for the seed) + * + * 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, + PRODUCT_PAYLOADS, + type MaterialProductPayload, +} from '../src/config/singularity-materials'; + +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(', ')}`); +} + +/** Idempotently write one catalogue listing, matched on (actor_id, title). */ +async function upsertListing( + groupId: string, + groupActorId: string, + founder: FounderRow, + listing: MaterialProductPayload +): Promise { + const { data: existing, error: probeErr } = await admin + .from('user_products') + .select('id') + .eq('actor_id', groupActorId) + .eq('title', listing.title) + .maybeSingle(); + if (probeErr) die(`Failed to look up listing '${listing.title}': ${probeErr.message}`); + + const row = { + // user_products.user_id is NOT NULL and records who acts; ownership for + // every read path is actor_id, which points at the group. + user_id: founder.user_id, + actor_id: groupActorId, + group_id: groupId, + title: listing.title, + description: listing.description, + price: listing.price, + currency: listing.currency, + product_type: listing.product_type, + fulfillment_type: listing.fulfillment_type, + category: listing.category, + status: listing.status, + tags: listing.tags, + inventory_count: listing.inventory_count, + show_on_profile: listing.show_on_profile, + is_test: false, + }; + + if (existing) { + const { error } = await admin.from('user_products').update(row).eq('id', existing.id); + if (error) die(`Failed to update listing '${listing.title}': ${error.message}`); + console.log(` ↻ ${listing.title}`); + return; + } + + const { error } = await admin.from('user_products').insert(row); + if (error) die(`Failed to insert listing '${listing.title}': ${error.message}`); + console.log(` + ${listing.title}`); +} + +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); + + console.log(`catalogue (${PRODUCT_PAYLOADS.length} listings):`); + for (const listing of PRODUCT_PAYLOADS) { + await upsertListing(groupId, groupActorId, founder, listing); + } + + console.log(`✓ done. View at /groups/${COMPANY.slug}.`); +} + +main().catch(err => die(err instanceof Error ? err.message : String(err))); diff --git a/src/config/singularity-materials.ts b/src/config/singularity-materials.ts new file mode 100644 index 000000000..f483cf861 --- /dev/null +++ b/src/config/singularity-materials.ts @@ -0,0 +1,477 @@ +/** + * "Substrate Materials" — OrangeCat-side SSOT + * + * A trading company with exactly one focus: the physical inputs that a + * technological singularity actually consumes. Intelligence is not made of + * software. It is made of purified tin, neon, polysilicon, ruthenium, + * electrical steel and rare-earth metal — and every one of those has a + * chokepoint, a lead time and a counterparty. + * + * This file is the single source of truth for the company's identity, its + * mandate (the inclusion test that makes "solely focused" a rule rather than a + * slogan), its desks, its listed catalogue, and its compliance stance. The seed + * that registers it on-platform (scripts/seed-singularity-materials.ts) reads + * this file and nothing else, so the copy is written ONCE here and reused by + * the seed today and by any /groups rendering or Cat context later. + * + * On-platform shape: a `group` with label 'company' (public, so the profile is + * readable by anyone), its own `actors` row of actor_type 'group', and a + * catalogue of `user_products` owned by that group actor. Ownership follows the + * same convention as Revive My Old Ride — the founder's `mao` actor creates it; + * swap to a dedicated actor later by changing FOUNDER_ACTOR_SLUG. + * + * Created: 2026-08-26 + */ + +// ===================================================================== +// OWNERSHIP +// ===================================================================== + +/** Actor slug of the user who founds the company (created_by + founder seat). */ +export const FOUNDER_ACTOR_SLUG = 'mao'; + +// ===================================================================== +// IDENTITY +// ===================================================================== + +export const COMPANY = { + name: 'Substrate Materials', + /** Also the actor slug and the public path: /groups/substrate-materials */ + slug: 'substrate-materials', + tagline: 'The materials intelligence is made of.', +} as const; + +// ===================================================================== +// THE MANDATE — why this company is "solely focused" +// ===================================================================== + +/** + * The inclusion test. A material is traded only if it sits on the critical path + * of one of three curves. Everything else — however profitable — is declined. + * This is the whole of the company's strategy; the desks below are just the + * test applied to five parts of the supply chain. + */ +export const MANDATE_CURVES = [ + { + id: 'compute-per-joule', + label: 'Compute per joule', + test: 'Does this material make a thought cheaper to have?', + detail: + 'Feedstock, lithography consumables and thermal materials — the inputs ' + + 'that decide how much computation a watt can buy.', + }, + { + id: 'joules-delivered', + label: 'Joules delivered', + test: 'Does this material get power to where the compute is?', + detail: + 'Transformer steel, conductors, superconducting tape and the cryogens ' + + 'that keep them cold. A datacentre that cannot be energised is a shed.', + }, + { + id: 'actuation', + label: 'Actuation', + test: 'Does this material give intelligence hands?', + detail: + 'Permanent-magnet feed and the metals behind precision drives — the ' + + 'step where a model stops advising and starts doing physical work.', + }, +] as const; + +/** + * The exclusion rule, stated plainly because it is the harder half of focus. + * A trading desk that will quote anything is a trading desk with no thesis. + */ +export const EXCLUSION_RULE = { + rule: 'If a material moves none of the three curves, we do not quote it.', + explainer: + 'We decline business every week. Not because the margin is bad, but ' + + 'because a book that drifts into general commodities loses the only edge ' + + 'a specialist has: knowing, for fifteen materials, every qualified ' + + 'producer, every purity grade that actually ships, and every lead time ' + + 'that is real rather than quoted.', +} as const; + +// ===================================================================== +// DESKS +// ===================================================================== + +export type DeskId = 'lithography' | 'feedstock' | 'thermal' | 'power' | 'actuation'; + +export interface Desk { + id: DeskId; + /** Also the `category` written onto every listing on this desk. */ + name: string; + curve: (typeof MANDATE_CURVES)[number]['id']; + covers: string; +} + +export const DESKS: readonly Desk[] = [ + { + 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; + +// ===================================================================== +// LISTED CATALOGUE +// +// Prices are INDICATIVE reference levels in CHF for the stated unit — the +// number a counterparty should use to size a budget, not a quote. Real pricing +// is by RFQ against grade, lot size, origin and delivery window, because every +// one of these markets prices that way. The unit is carried in the copy, since +// `user_products` has no unit column. +// ===================================================================== + +export interface MaterialListing { + /** Product title as it appears on the profile. */ + title: string; + desk: DeskId; + /** Unit the indicative price refers to, e.g. 'kg', 'wafer', 'metre'. */ + unit: string; + /** Indicative reference price in CHF per `unit`. Must be > 0 (DB CHECK). */ + indicativePriceChf: number; + /** Why this material is on the critical path — the reason it is listed. */ + why: string; + /** Grade / form actually traded. */ + spec: string; + tags: string[]; +} + +export const CATALOGUE: readonly MaterialListing[] = [ + // ---------- Lithography & Optics ---------- + { + title: 'High-purity tin, EUV droplet grade', + desk: 'lithography', + unit: 'kg', + indicativePriceChf: 240, + 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', + desk: 'lithography', + unit: 'm³', + indicativePriceChf: 120, + 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', + desk: 'lithography', + unit: 'kg', + indicativePriceChf: 15000, + 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', + desk: 'feedstock', + unit: 'kg', + indicativePriceChf: 45, + 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', + desk: 'feedstock', + unit: 'wafer', + indicativePriceChf: 110, + 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', + desk: 'feedstock', + unit: 'kg', + indicativePriceChf: 18, + 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', + desk: 'feedstock', + unit: 'kg', + indicativePriceChf: 620, + 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', + desk: 'thermal', + unit: 'piece', + indicativePriceChf: 450, + 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', + desk: 'thermal', + unit: 'wafer', + indicativePriceChf: 1400, + 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', + desk: 'thermal', + unit: 'litre', + indicativePriceChf: 95, + 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)', + desk: 'power', + unit: 'kg', + indicativePriceChf: 3.2, + 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', + desk: 'power', + unit: 'metre', + indicativePriceChf: 85, + 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)', + desk: 'power', + unit: 'litre', + indicativePriceChf: 48, + 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', + desk: 'actuation', + unit: 'kg', + indicativePriceChf: 95, + 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', + desk: 'actuation', + unit: 'kg', + indicativePriceChf: 400, + 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'], + }, +] as const; + +// ===================================================================== +// COMPLIANCE — the reason a focused book stays a legal one +// ===================================================================== + +/** + * Several of these materials are dual-use and export-controlled (gallium, + * germanium, the heavy rare earths, some high-purity metals). A specialist + * desk cannot be casual about this, and the focus itself is the first control: + * the mandate admits compute, energy and actuation, and nothing else. + */ +export const COMPLIANCE = { + screening: [ + 'Counterparty and end-user screening against applicable sanctions and ' + + 'denied-party lists before any quote is issued.', + 'End-use and end-user documentation on every export-controlled line; no ' + + 'shipment moves on an unverified end use.', + 'Licence-first: where an export licence is required, it is obtained ' + + 'before the material is committed, not after.', + ], + outOfScope: + 'Nothing on the weapons or nuclear-fuel-cycle path is traded, quoted or ' + + 'brokered — that is outside the mandate as well as outside the law we ' + + 'operate under. The three curves are compute, energy and actuation.', +} as const; + +// ===================================================================== +// PUBLIC LISTING COPY +// ===================================================================== + +export const LISTING_COPY = { + headline: COMPANY.name, + subhead: COMPANY.tagline, + body: [ + 'Substrate Materials is a trading company with one book: the physical ' + + 'inputs a technological singularity consumes. Not "tech commodities" ' + + 'broadly — the fifteen or so materials that sit on the critical path ' + + 'between a design and a working machine.', + 'A material is listed only if it moves one of three curves: compute per ' + + 'joule, joules delivered, or actuation. Everything else we decline, ' + + 'including business we could profitably do. Focus is the product — for ' + + 'fifteen materials we know every qualified producer, every purity grade ' + + 'that actually ships, and every lead time that is real rather than quoted.', + 'Quotes are by RFQ against grade, lot size, origin and delivery window. ' + + 'Listed prices are indicative reference levels for budgeting. ' + + 'Settlement in Bitcoin or in francs, counterparty’s choice.', + ], + cta: 'Send us an RFQ', +} 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: [ + 'trading', + 'materials', + 'semiconductors', + 'rare-earths', + 'energy', + 'robotics', + 'singularity', + ], + // The 'company' label defaults to members_only; a trading counterparty has to + // be able to read the book before it can send an RFQ, so this one is public. + is_public: true, + visibility: 'public', + governance_preset: 'hierarchical', +} as const; + +/** + * Features enabled on creation. `marketplace` is what lets the group list the + * catalogue. `treasury` is deliberately NOT enabled: GROUP_FEATURES.treasury + * requires a `bitcoin_address` on the group, and there is no wallet for this + * company yet. Enable it in the same commit that adds the address. + */ +export const GROUP_FEATURE_KEYS: readonly string[] = ['marketplace']; + +// ===================================================================== +// PRODUCT PAYLOADS (map 1:1 to the live `user_products` table) +// ===================================================================== + +export interface MaterialProductPayload { + title: string; + description: string; + price: number; + currency: 'CHF'; + product_type: 'physical'; + fulfillment_type: 'manual'; + category: string; + status: 'active'; + tags: string[]; + /** -1 = not inventory-tracked; these are brokered lots, not stock on a shelf. */ + inventory_count: number; + show_on_profile: boolean; +} + +const DESK_BY_ID: Record = DESKS.reduce( + (acc, desk) => ({ ...acc, [desk.id]: desk }), + {} as Record +); + +/** @returns the desk a listing belongs to. */ +export function deskFor(listing: MaterialListing): Desk { + return DESK_BY_ID[listing.desk]; +} + +/** Renders one listing into the row shape `user_products` expects. */ +export function toProductPayload(listing: MaterialListing): MaterialProductPayload { + const desk = deskFor(listing); + return { + title: listing.title, + description: [ + listing.why, + '', + `Traded as: ${listing.spec}`, + `Desk: ${desk.name}`, + `Indicative reference: CHF ${listing.indicativePriceChf} per ${listing.unit} — ` + + 'a budgeting level, not a quote. Firm pricing by RFQ against grade, lot ' + + 'size, origin and delivery window.', + ].join('\n'), + price: listing.indicativePriceChf, + currency: 'CHF', + product_type: 'physical', + fulfillment_type: 'manual', + category: desk.name, + status: 'active', + tags: listing.tags, + inventory_count: -1, + show_on_profile: true, + }; +} + +export const PRODUCT_PAYLOADS: readonly MaterialProductPayload[] = CATALOGUE.map(toProductPayload); From 254a4d54a2ccebd5aeab5e9b1beb0edd6ee200de Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 26 Aug 2026 16:04:08 +0000 Subject: [PATCH 02/17] =?UTF-8?q?feat(groups):=20make=20the=20map=20the=20?= =?UTF-8?q?product=20=E2=80=94=20Substrate=20as=20an=20open=20research=20f?= =?UTF-8?q?irm?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reframes the trading company into what it should have been from the start: an open-source research firm covering the chokepoints between here and a technological singularity, with a trading desk on the materials it knows best. The asset is the map, not the book — trading a commodity and researching a robotics company are the same work, so the map is the product and the desk is one way to monetise it. Two tests now gate the universe instead of one. A node must move a curve (compute per joule, joules delivered, actuation) AND actually gate it, on concentration, substitutability, lead time and demand inelasticity. The unit of coverage is a chokepoint node, not an asset class — a node can be a material, company, person, machine or process. That is what lets the firm reach robotics, AI hardware and additive manufacturing without becoming "everything": you arrive at them by tracing a chain you were already mapping, so the graph grows by traversal rather than ambition. Phase 1 is the producers of the fifteen: 92 producer leads across 85 distinct companies, for every material on the desk. Each row asserts a name, a jurisdiction and a step in the chain, and nothing else. There is no field for capacity, share, revenue or quality — those are the claims that go stale in a quarter and move markets when wrong, and the research phase is what adds them with a citation. Every row therefore starts at source: null, which reads as an unverified lead; coverage is measured by how many rows have a source, not by how many rows exist. Disclosure is written now, before any position exists, because a firm that publishes on what it trades and intends to own cannot install that rule credibly afterwards. - src/config/substrate.ts — firm: curves, chokepoint screen, node types, four phases, desks, catalogue, compliance, disclosure, group payload - src/config/substrate-coverage.ts — Phase 1 universe + coverageProgress() - tests: the schema gates, plus coverage follows the book both ways and no producer row can carry an unsourced claim it has no field for Renamed from singularity-materials — the firm is no longer materials-only. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01GF9GDWaBWYHCZ9iNwmfZ41 --- .../unit/config/singularity-materials.test.ts | 137 ------- __tests__/unit/config/substrate.test.ts | 238 +++++++++++++ ...ularity-materials.ts => seed-substrate.ts} | 11 +- src/config/substrate-coverage.ts | 334 ++++++++++++++++++ ...{singularity-materials.ts => substrate.ts} | 291 +++++++++++---- 5 files changed, 810 insertions(+), 201 deletions(-) delete mode 100644 __tests__/unit/config/singularity-materials.test.ts create mode 100644 __tests__/unit/config/substrate.test.ts rename scripts/{seed-singularity-materials.ts => seed-substrate.ts} (96%) create mode 100644 src/config/substrate-coverage.ts rename src/config/{singularity-materials.ts => substrate.ts} (57%) diff --git a/__tests__/unit/config/singularity-materials.test.ts b/__tests__/unit/config/singularity-materials.test.ts deleted file mode 100644 index fd5c170d5..000000000 --- a/__tests__/unit/config/singularity-materials.test.ts +++ /dev/null @@ -1,137 +0,0 @@ -/** - * The Substrate Materials profile has to survive contact with the live schema. - * - * The company 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. These tests are the cheap version of - * that discovery: they hold the payloads against the CHECK constraints in - * supabase/migrations/20240101000001_baseline_public_schema.sql and against the - * group label / feature / governance registries. - * - * They also hold the company to its own mandate — every listing has to trace to - * one of the three curves, which is what "solely focused" means here. - */ - -import { - CATALOGUE, - COMPANY, - DESKS, - GROUP_FEATURE_KEYS, - GROUP_PAYLOAD, - MANDATE_CURVES, - PRODUCT_PAYLOADS, - deskFor, -} from '@/config/singularity-materials'; -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('Substrate Materials — 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, because a counterparty reads before it quotes', () => { - 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('Substrate Materials — catalogue', () => { - it('lists something', () => { - expect(PRODUCT_PAYLOADS.length).toBe(CATALOGUE.length); - expect(PRODUCT_PAYLOADS.length).toBeGreaterThan(0); - }); - - it('has unique titles, since the seed keys idempotency on (actor_id, title)', () => { - const titles = PRODUCT_PAYLOADS.map(p => p.title); - expect(new Set(titles).size).toBe(titles.length); - }); - - it.each(PRODUCT_PAYLOADS.map(p => [p.title, p] as const))( - '%s satisfies every user_products CHECK constraint', - (_title, payload) => { - expect(PRODUCT_CURRENCIES).toContain(payload.currency); - expect(PRODUCT_TYPES).toContain(payload.product_type); - expect(FULFILLMENT_TYPES).toContain(payload.fulfillment_type); - expect(PRODUCT_STATUSES).toContain(payload.status); - expect(payload.price).toBeGreaterThan(0); - } - ); - - it('prices fit numeric(20,8) — no value the column would silently round', () => { - for (const payload of PRODUCT_PAYLOADS) { - const decimals = (String(payload.price).split('.')[1] ?? '').length; - expect(decimals).toBeLessThanOrEqual(8); - expect(String(Math.trunc(payload.price)).length).toBeLessThanOrEqual(12); - } - }); - - it('says the price is indicative, so no listing reads as a firm quote', () => { - for (const payload of PRODUCT_PAYLOADS) { - expect(payload.description).toContain('Indicative reference'); - expect(payload.description).toContain('not a quote'); - } - }); -}); - -describe('Substrate Materials — 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 desk of DESKS) { - expect(curveIds).toContain(desk.curve); - } - }); - - it('every listed material sits on a desk, and so on a curve', () => { - for (const listing of CATALOGUE) { - const desk = deskFor(listing); - expect(desk).toBeDefined(); - expect(desk.id).toBe(listing.desk); - } - }); - - it('every listing is filed under its desk name, which is the product category', () => { - const deskNames = DESKS.map(desk => desk.name); - for (const payload of PRODUCT_PAYLOADS) { - expect(deskNames).toContain(payload.category); - } - }); - - it('no desk is left without a listing — an empty desk is scope creep on paper', () => { - for (const desk of DESKS) { - expect(CATALOGUE.some(listing => listing.desk === desk.id)).toBe(true); - } - }); -}); diff --git a/__tests__/unit/config/substrate.test.ts b/__tests__/unit/config/substrate.test.ts new file mode 100644 index 000000000..70061dd2a --- /dev/null +++ b/__tests__/unit/config/substrate.test.ts @@ -0,0 +1,238 @@ +/** + * 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 { + CATALOGUE, + CHOKEPOINT_TEST, + COMPANY, + DESKS, + DISCLOSURE, + GROUP_FEATURE_KEYS, + GROUP_PAYLOAD, + MANDATE_CURVES, + NODE_TYPES, + PHASES, + PRODUCT_PAYLOADS, + deskFor, +} from '@/config/substrate'; +import { + COVERAGE, + PRODUCER_ROLES, + coverageProgress, + materialsFor, +} from '@/config/substrate-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('Substrate — 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('Substrate — the desk', () => { + it('lists something', () => { + expect(PRODUCT_PAYLOADS.length).toBe(CATALOGUE.length); + expect(PRODUCT_PAYLOADS.length).toBeGreaterThan(0); + }); + + it('has unique titles, since the seed keys idempotency on (actor_id, title)', () => { + const titles = PRODUCT_PAYLOADS.map(p => p.title); + expect(new Set(titles).size).toBe(titles.length); + }); + + it.each(PRODUCT_PAYLOADS.map(p => [p.title, p] as const))( + '%s satisfies every user_products CHECK constraint', + (_title, payload) => { + expect(PRODUCT_CURRENCIES).toContain(payload.currency); + expect(PRODUCT_TYPES).toContain(payload.product_type); + expect(FULFILLMENT_TYPES).toContain(payload.fulfillment_type); + expect(PRODUCT_STATUSES).toContain(payload.status); + expect(payload.price).toBeGreaterThan(0); + } + ); + + it('prices fit numeric(20,8) — no value the column would silently round', () => { + for (const payload of PRODUCT_PAYLOADS) { + const decimals = (String(payload.price).split('.')[1] ?? '').length; + expect(decimals).toBeLessThanOrEqual(8); + expect(String(Math.trunc(payload.price)).length).toBeLessThanOrEqual(12); + } + }); + + it('says the price is indicative, so no listing reads as a firm quote', () => { + for (const payload of PRODUCT_PAYLOADS) { + expect(payload.description).toContain('Indicative reference'); + expect(payload.description).toContain('not a quote'); + } + }); +}); + +describe('Substrate — 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 desk of DESKS) { + expect(curveIds).toContain(desk.curve); + } + }); + + it('every listed material sits on a desk, and so on a curve', () => { + for (const listing of CATALOGUE) { + const desk = deskFor(listing); + expect(desk).toBeDefined(); + expect(desk.id).toBe(listing.desk); + } + }); + + it('every listing is filed under its desk name, which is the product category', () => { + const deskNames = DESKS.map(desk => desk.name); + for (const payload of PRODUCT_PAYLOADS) { + expect(deskNames).toContain(payload.category); + } + }); + + it('no desk is left without a listing — an empty desk is scope creep on paper', () => { + for (const desk of DESKS) { + expect(CATALOGUE.some(listing => listing.desk === desk.id)).toBe(true); + } + }); + + 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 exactly one phase at a time — sequencing is the whole point', () => { + expect(PHASES.filter(phase => phase.status === 'active')).toHaveLength(1); + }); + + it('commits to disclosure before any position exists, which is the only time it is credible', () => { + expect(DISCLOSURE.rules.length).toBeGreaterThanOrEqual(3); + }); +}); + +describe('Substrate — Phase 1 coverage universe', () => { + it('owes a coverage entry to every material on the desk', () => { + expect(coverageProgress().uncoveredMaterials).toEqual([]); + }); + + it('covers no material the desk does not trade — coverage follows the book', () => { + const traded = CATALOGUE.map(listing => listing.title); + for (const entry of COVERAGE) { + expect(traded).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/scripts/seed-singularity-materials.ts b/scripts/seed-substrate.ts similarity index 96% rename from scripts/seed-singularity-materials.ts rename to scripts/seed-substrate.ts index d5b916325..d44de93f9 100644 --- a/scripts/seed-singularity-materials.ts +++ b/scripts/seed-substrate.ts @@ -1,7 +1,8 @@ /** - * Seed "Substrate Materials" — a trading company whose entire book is the - * materials a technological singularity consumes — as an OrangeCat group - * profile with its own actor and catalogue. + * Seed "Substrate" — an open-source research firm covering the chokepoints + * between here and a technological singularity, with a trading desk on the + * materials it knows best — as an OrangeCat group profile with its own actor + * and catalogue. * * 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 @@ -14,7 +15,7 @@ * Owner-gated so it can't fire by accident. * * Run against the LIVE self-hosted DB (supabase.orangecat.ch) from the box: - * ORANGECAT_OWNER_SEED=1 npx tsx scripts/seed-singularity-materials.ts + * ORANGECAT_OWNER_SEED=1 npx tsx scripts/seed-substrate.ts * * Requires in the environment (already in .env.local on the box): * NEXT_PUBLIC_SUPABASE_URL — self-hosted Supabase URL @@ -32,7 +33,7 @@ import { GROUP_PAYLOAD, PRODUCT_PAYLOADS, type MaterialProductPayload, -} from '../src/config/singularity-materials'; +} from '../src/config/substrate'; loadEnv({ path: '.env.local' }); diff --git a/src/config/substrate-coverage.ts b/src/config/substrate-coverage.ts new file mode 100644 index 000000000..f256ddd57 --- /dev/null +++ b/src/config/substrate-coverage.ts @@ -0,0 +1,334 @@ +/** + * Substrate — Phase 1 coverage universe: the producers of the fifteen. + * + * The firm's first research product. For every material on the desk + * (`substrate.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 { CATALOGUE } from './substrate'; + +// ===================================================================== +// 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 `substrate.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: CATALOGUE.map(listing => listing.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); +} diff --git a/src/config/singularity-materials.ts b/src/config/substrate.ts similarity index 57% rename from src/config/singularity-materials.ts rename to src/config/substrate.ts index f483cf861..f45dd25af 100644 --- a/src/config/singularity-materials.ts +++ b/src/config/substrate.ts @@ -1,24 +1,31 @@ /** - * "Substrate Materials" — OrangeCat-side SSOT + * "Substrate" — OrangeCat-side SSOT * - * A trading company with exactly one focus: the physical inputs that a - * technological singularity actually consumes. Intelligence is not made of - * software. It is made of purified tin, neon, polysilicon, ruthenium, - * electrical steel and rare-earth metal — and every one of those has a - * chokepoint, a lead time and a counterparty. + * An open-source research firm covering the chokepoints between here and a + * technological singularity, with a trading desk on the materials it knows + * best. Intelligence is not made of software. It is made of purified tin, + * neon, polysilicon, ruthenium, transformer steel and rare-earth metal — and + * behind each of those is a producer, a lead time and a counterparty that + * almost nobody has written down in public. * - * This file is the single source of truth for the company's identity, its - * mandate (the inclusion test that makes "solely focused" a rule rather than a - * slogan), its desks, its listed catalogue, and its compliance stance. The seed - * that registers it on-platform (scripts/seed-singularity-materials.ts) reads - * this file and nothing else, so the copy is written ONCE here and reused by - * the seed today and by any /groups rendering or Cat context later. + * THE ASSET IS THE MAP, NOT THE BOOK. Trading a commodity and researching a + * robotics company are the same work: knowing who makes what, at what grade, + * on what lead time, and who is dependent on them. So the map is the product; + * the desk is one way to monetise it, and eventually the chain the firm + * intends to integrate is the chain it already mapped. * - * On-platform shape: a `group` with label 'company' (public, so the profile is - * readable by anyone), its own `actors` row of actor_type 'group', and a - * catalogue of `user_products` owned by that group actor. Ownership follows the - * same convention as Revive My Old Ride — the founder's `mao` actor creates it; - * swap to a dedicated actor later by changing FOUNDER_ACTOR_SLUG. + * 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 `substrate-coverage.ts`. The + * seed that registers the firm on-platform (scripts/seed-substrate.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 */ @@ -27,7 +34,7 @@ // OWNERSHIP // ===================================================================== -/** Actor slug of the user who founds the company (created_by + founder seat). */ +/** Actor slug of the user who founds the firm (created_by + founder seat). */ export const FOUNDER_ACTOR_SLUG = 'mao'; // ===================================================================== @@ -35,63 +42,189 @@ export const FOUNDER_ACTOR_SLUG = 'mao'; // ===================================================================== export const COMPANY = { - name: 'Substrate Materials', - /** Also the actor slug and the public path: /groups/substrate-materials */ - slug: 'substrate-materials', - tagline: 'The materials intelligence is made of.', + name: 'Substrate', + /** Also the actor slug and the public path: /groups/substrate */ + slug: 'substrate', + tagline: 'The chokepoints between here and the singularity, written down in public.', } as const; // ===================================================================== -// THE MANDATE — why this company is "solely focused" +// THE MANDATE — first test: which curve does it move? // ===================================================================== /** - * The inclusion test. A material is traded only if it sits on the critical path - * of one of three curves. Everything else — however profitable — is declined. - * This is the whole of the company's strategy; the desks below are just the - * test applied to five parts of the supply chain. + * 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 material make a thought cheaper to have?', + test: 'Does this make a thought cheaper to have?', detail: - 'Feedstock, lithography consumables and thermal materials — the inputs ' + - 'that decide how much computation a watt can buy.', + '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 material get power to where the compute is?', + test: 'Does this get power to where the compute is?', detail: - 'Transformer steel, conductors, superconducting tape and the cryogens ' + - 'that keep them cold. A datacentre that cannot be energised is a shed.', + '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 material give intelligence hands?', + test: 'Does this give intelligence hands?', detail: - 'Permanent-magnet feed and the metals behind precision drives — the ' + + '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 trading desk that will quote anything is a trading desk with no thesis. + * A firm that will cover anything has no edge, and a desk that will quote + * anything has no thesis. */ export const EXCLUSION_RULE = { - rule: 'If a material moves none of the three curves, we do not quote it.', + rule: 'On a curve, and a chokepoint. Fail either test and we neither cover it nor quote it.', explainer: - 'We decline business every week. Not because the margin is bad, but ' + - 'because a book that drifts into general commodities loses the only edge ' + - 'a specialist has: knowing, for fifteen materials, every qualified ' + - 'producer, every purity grade that actually ships, and every lead time ' + - 'that is real rather than quoted.', + '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 already on the ' + + 'desk. Mostly private, mostly uncovered: the sell side writes about ' + + 'chip designers, not about who fires crucible-grade quartz. Every ' + + 'profile is simultaneously research and a counterparty for the desk.', + }, + { + id: 'one-hop-out', + label: 'Phase 2 — one hop out', + status: 'planned', + detail: + 'From each producer, one hop upstream (their inputs) and one downstream ' + + '(their buyers). This is where robotics, AI hardware and additive ' + + 'manufacturing enter the universe on their own — as counterparties in a ' + + 'chain already being mapped, not as a new vertical.', + }, + { + id: 'desk-beyond-commodities', + label: 'Phase 3 — the desk beyond commodities', + status: 'planned', + detail: + 'Apply the same map to equities, private positions and offtake. The ' + + 'research does not change; only the instrument does.', + }, + { + id: 'integration', + label: 'Phase 4 — integration', + status: 'planned', + detail: + 'Take positions in the chain, upstream and downstream. By construction ' + + 'the acquisition pipeline is the coverage universe — you buy into what ' + + 'you already understand better than the seller does.', + }, +] as const; + // ===================================================================== // DESKS // ===================================================================== @@ -102,7 +235,7 @@ export interface Desk { id: DeskId; /** Also the `category` written onto every listing on this desk. */ name: string; - curve: (typeof MANDATE_CURVES)[number]['id']; + curve: CurveId; covers: string; } @@ -162,10 +295,13 @@ export const DESKS: readonly Desk[] = [ // is by RFQ against grade, lot size, origin and delivery window, because every // one of these markets prices that way. The unit is carried in the copy, since // `user_products` has no unit column. +// +// This list is also the Phase 1 work queue: `substrate-coverage.ts` owes a +// producer map to every title here, and a test enforces the correspondence. // ===================================================================== export interface MaterialListing { - /** Product title as it appears on the profile. */ + /** Product title as it appears on the profile, and the coverage key. */ title: string; desk: DeskId; /** Unit the indicative price refers to, e.g. 'kg', 'wafer', 'metre'. */ @@ -324,7 +460,7 @@ export const CATALOGUE: readonly MaterialListing[] = [ spec: '≥99% dysprosium metal. Export-licence and end-use documentation required.', tags: ['dysprosium', 'rare-earth', 'magnets', 'export-controlled'], }, -] as const; +]; // ===================================================================== // COMPLIANCE — the reason a focused book stays a legal one @@ -351,6 +487,34 @@ export const COMPLIANCE = { 'operate under. The three curves are compute, energy and actuation.', } as const; +// ===================================================================== +// DISCLOSURE — installed now because it cannot be retrofitted +// ===================================================================== + +/** + * A firm that publishes research, trades the same materials, and intends to + * eventually own parts of the chain is publishing on its own positions. That + * is a workable model, but only with a disclosure rule written before the + * first position exists — afterwards, every rule looks like a response to + * something. So it is written here, in the same file as the mandate. + */ +export const DISCLOSURE = { + rules: [ + 'Every published note states the firm’s position in what it covers — long, ' + + 'short, flat, brokering, or in negotiation — at time of publication.', + 'Research is never withheld, delayed or softened because the desk holds a ' + + 'position. If those two conflict, the position is the thing that moves.', + 'Nothing is published to move a price the desk is about to trade against. ' + + 'Notes go out on a schedule, not on a fill.', + '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 is monetised is the position the ' + + 'research buys — counterparty access, deal flow, and knowing where the ' + + 'chain is thin — not the research itself.', +} as const; + // ===================================================================== // PUBLIC LISTING COPY // ===================================================================== @@ -359,20 +523,26 @@ export const LISTING_COPY = { headline: COMPANY.name, subhead: COMPANY.tagline, body: [ - 'Substrate Materials is a trading company with one book: the physical ' + - 'inputs a technological singularity consumes. Not "tech commodities" ' + - 'broadly — the fifteen or so materials that sit on the critical path ' + - 'between a design and a working machine.', - 'A material is listed only if it moves one of three curves: compute per ' + - 'joule, joules delivered, or actuation. Everything else we decline, ' + - 'including business we could profitably do. Focus is the product — for ' + - 'fifteen materials we know every qualified producer, every purity grade ' + - 'that actually ships, and every lead time that is real rather than quoted.', + 'Substrate is an open-source research firm covering the physical ' + + 'chokepoints between here and a technological singularity — and a ' + + 'trading desk on the materials it knows best. The research is free. ' + + 'The map is the product.', + '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 ' + + 'company, a person, a machine or a process; the tests do not care. That ' + + 'is how the universe reaches robotics and additive manufacturing ' + + 'without ever becoming "everything": you arrive at them by tracing a ' + + 'chain you were already mapping.', + 'First phase: every qualified producer of the fifteen materials on the ' + + 'desk. The sell side writes about chip designers. Almost nobody writes ' + + 'about who fires crucible-grade quartz, and that is the gap.', 'Quotes are by RFQ against grade, lot size, origin and delivery window. ' + 'Listed prices are indicative reference levels for budgeting. ' + 'Settlement in Bitcoin or in francs, counterparty’s choice.', ], - cta: 'Send us an RFQ', + cta: 'Read the map, or send us an RFQ', } as const; // ===================================================================== @@ -396,16 +566,19 @@ export const GROUP_PAYLOAD: CompanyGroupPayload = { description: [LISTING_COPY.subhead, '', ...LISTING_COPY.body].join('\n'), label: 'company', tags: [ + 'research', + 'open-source-research', + 'supply-chain', 'trading', - 'materials', 'semiconductors', 'rare-earths', 'energy', 'robotics', 'singularity', ], - // The 'company' label defaults to members_only; a trading counterparty has to - // be able to read the book before it can send an RFQ, so this one is public. + // The 'company' label defaults to members_only. This firm publishes its + // research and expects counterparties to read the book before sending an + // RFQ, so both halves have to be readable without an account. is_public: true, visibility: 'public', governance_preset: 'hierarchical', @@ -415,7 +588,7 @@ export const GROUP_PAYLOAD: CompanyGroupPayload = { * Features enabled on creation. `marketplace` is what lets the group list the * catalogue. `treasury` is deliberately NOT enabled: GROUP_FEATURES.treasury * requires a `bitcoin_address` on the group, and there is no wallet for this - * company yet. Enable it in the same commit that adds the address. + * firm yet. Enable it in the same commit that adds the address. */ export const GROUP_FEATURE_KEYS: readonly string[] = ['marketplace']; From e9bf570ad04aab05682b1773f3495c2dd850d825 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 26 Aug 2026 16:29:22 +0000 Subject: [PATCH 03/17] feat(domains): a profile spins up a whole website on its own hostname MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /domains has been selling this sentence — "a working site, hosted and managed at yourname.orangecat.ch" — with no mechanism behind it. This is the mechanism, proved end to end on Substrate. A request arriving on a hosted site's host is rewritten onto /sites/ and answered by a standalone website built from that profile's own structured data. Rewrite, not redirect: the visitor's URL bar keeps saying substrate.orangecat.ch, which is the entire point of what is being sold. The path form keeps working on any host, so a site is previewable before its DNS exists. The website is not separately authored. Substrate's mandate, desks, catalogue and coverage universe already existed as config because the profile needed them; the five pages are those same objects rendered for a different audience. Change the profile and the site changes with it — and the map page cannot quietly drop the "unverified lead" caveat, because the only data it has is the data the tests hold to source: null. Adding a site is an entry in HOSTED_SITES plus a builder returning SitePage[]. Pages are data, sections come from one closed set of shapes, and one renderer serves every hosted site — which is what stops fifty customer sites becoming fifty stylesheets. - src/config/sites.ts — host → profile, pure and DB-free (edge hot path) - src/config/site-content.ts — page/section model + per-site dispatch - src/config/site-substrate.ts — Substrate's five pages, from its config - src/middleware.ts — the host rewrite - routes.ts + AppShell — 'site' is a fourth surface, not a chrome override, so a hosted site can never grow OrangeCat's header on somebody else's domain - metadata uses title.absolute, so the tab says "Substrate", not "Substrate | OrangeCat" - formatChf so a ruthenium quote reads CHF 15’000, in the listing the seed writes and on the site alike Verified against a running server: the host rewrite resolves for substrate.orangecat.ch, www., and substrate.localhost; orangecat.ch itself is untouched; an unknown path under a site host 404s. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01GF9GDWaBWYHCZ9iNwmfZ41 --- __tests__/unit/config/hosted-sites.test.ts | 160 ++++++++++ __tests__/unit/config/substrate.test.ts | 20 ++ src/app/sites/[site]/[[...path]]/page.tsx | 102 +++++++ src/components/layout/AppShell.tsx | 15 + src/components/sites/SiteChrome.tsx | 88 ++++++ src/components/sites/SiteSections.tsx | 178 +++++++++++ src/config/routes.ts | 13 +- src/config/site-content.ts | 116 ++++++++ src/config/site-substrate.ts | 328 +++++++++++++++++++++ src/config/sites.ts | 121 ++++++++ src/config/substrate.ts | 16 +- src/middleware.ts | 17 ++ 12 files changed, 1172 insertions(+), 2 deletions(-) create mode 100644 __tests__/unit/config/hosted-sites.test.ts create mode 100644 src/app/sites/[site]/[[...path]]/page.tsx create mode 100644 src/components/sites/SiteChrome.tsx create mode 100644 src/components/sites/SiteSections.tsx create mode 100644 src/config/site-content.ts create mode 100644 src/config/site-substrate.ts create mode 100644 src/config/sites.ts diff --git a/__tests__/unit/config/hosted-sites.test.ts b/__tests__/unit/config/hosted-sites.test.ts new file mode 100644 index 000000000..3d972ce91 --- /dev/null +++ b/__tests__/unit/config/hosted-sites.test.ts @@ -0,0 +1,160 @@ +/** + * 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 { sitePageAt, sitePagesFor, siteChromeFor } from '@/config/site-content'; +import { getRouteSurface } from '@/config/routes'; +import { CATALOGUE, COMPANY, MANDATE_CURVES } from '@/config/substrate'; +import { COVERAGE, coverageProgress } from '@/config/substrate-coverage'; + +const substrate = siteBySlug('substrate'); + +describe('hosted sites — host resolution', () => { + it('resolves the free subdomain, with or without www and port', () => { + expect(siteForHost('substrate.orangecat.ch')?.slug).toBe('substrate'); + expect(siteForHost('www.substrate.orangecat.ch')?.slug).toBe('substrate'); + expect(siteForHost('Substrate.OrangeCat.ch:443')?.slug).toBe('substrate'); + }); + + it('resolves the local development host, so the rewrite is testable without DNS', () => { + expect(siteForHost('substrate.localhost:3020')?.slug).toBe('substrate'); + }); + + it('refuses everything else — a greedy match would swallow OrangeCat itself', () => { + for (const host of [ + 'orangecat.ch', + 'www.orangecat.ch', + 'localhost:3000', + 'substrate.evil.example', + 'notsubstrate.orangecat.ch', + 'substrate.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(substrate).not.toBeNull(); + expect(siteHref(substrate!)).toBe('/sites/substrate'); + expect(siteHref(substrate!, 'map')).toBe('/sites/substrate/map'); + expect(siteHref(substrate!, '/map')).toBe('/sites/substrate/map'); + expect(siteHref(substrate!, '/')).toBe('/sites/substrate'); + }); +}); + +describe('hosted sites — chrome isolation', () => { + it('classifies a hosted site as its own surface, not app or public', () => { + expect(getRouteSurface('/sites/substrate')).toBe('site'); + expect(getRouteSurface('/sites/substrate/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/substrate')).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(substrate!, 'not-a-page')).toBeNull(); + }); +}); + +describe('substrate.orangecat.ch — the site is the profile, not a copy of it', () => { + const pages = sitePagesFor(substrate!); + const text = JSON.stringify(pages); + + it('takes its name and tagline from the profile config', () => { + const chrome = siteChromeFor(substrate!); + expect(chrome?.name).toBe(COMPANY.name); + expect(chrome?.tagline).toBe(COMPANY.tagline); + }); + + it('renders every material on the desk, from the same catalogue the profile lists', () => { + for (const listing of CATALOGUE) { + expect(text).toContain(listing.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(substrate!, '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'); + } + expect(rows).toContain(`${sourced} of ${total}`); + }); + + it('says on the desk page that prices are indicative rather than quotes', () => { + const deskPage = sitePageAt(substrate!, 'desk'); + expect(JSON.stringify(deskPage)).toContain('not a quote'); + }); +}); diff --git a/__tests__/unit/config/substrate.test.ts b/__tests__/unit/config/substrate.test.ts index 70061dd2a..151f1b9fa 100644 --- a/__tests__/unit/config/substrate.test.ts +++ b/__tests__/unit/config/substrate.test.ts @@ -29,6 +29,7 @@ import { PHASES, PRODUCT_PAYLOADS, deskFor, + formatChf, } from '@/config/substrate'; import { COVERAGE, @@ -236,3 +237,22 @@ describe('Substrate — Phase 1 coverage universe', () => { expect(overlapping.length).toBeGreaterThan(0); }); }); + +describe('Substrate — price formatting', () => { + it('groups thousands, so a five-figure quote cannot be misread', () => { + expect(formatChf(15000)).toBe('15’000'); + expect(formatChf(1400)).toBe('1’400'); + expect(formatChf(3.2)).toBe('3.2'); + expect(formatChf(95)).toBe('95'); + expect(formatChf(1234567)).toBe('1’234’567'); + }); + + it('formats every listed price the same way wherever it is shown', () => { + for (const listing of CATALOGUE) { + const payload = PRODUCT_PAYLOADS.find(p => p.title === listing.title); + expect(payload?.description).toContain( + `CHF ${formatChf(listing.indicativePriceChf)} per ${listing.unit}` + ); + } + }); +}); diff --git a/src/app/sites/[site]/[[...path]]/page.tsx b/src/app/sites/[site]/[[...path]]/page.tsx new file mode 100644 index 000000000..50a0b42ec --- /dev/null +++ b/src/app/sites/[site]/[[...path]]/page.tsx @@ -0,0 +1,102 @@ +/** + * 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 { 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 ( +
+ + +
+
+
+

+ {page.title} +

+ {page.intro && ( +

+ {page.intro} +

+ )} +
+ + +
+
+ + +
+ ); +} diff --git a/src/components/layout/AppShell.tsx b/src/components/layout/AppShell.tsx index 4e50127b8..e3a0681fa 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 substrate.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..5a23ff02e --- /dev/null +++ b/src/components/sites/SiteChrome.tsx @@ -0,0 +1,88 @@ +/** + * Masthead and footer for a hosted site. + * + * The visitor is on the site owner's domain, so the chrome is theirs: their + * name in the masthead, 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 — which is the honest disclosure a hosted site owes, and + * also the only distribution OrangeCat gets out of hosting it. + */ + +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'; + +interface Props { + site: HostedSite; + chrome: SiteChromeSpec; + pages: SitePage[]; + currentPath: string; +} + +export function SiteMasthead({ site, chrome, pages, currentPath }: Props) { + const navPages = pages.filter(page => page.navLabel); + + return ( +
+
+
+ + + {chrome.name} + + {chrome.tagline} + + + +
+
+
+ ); +} + +export function SiteFooter({ site, chrome }: { site: HostedSite; chrome: SiteChromeSpec }) { + return ( +
+
+

{chrome.footerNote}

+
+ {siteCanonicalHost(site)} + + Hosted on{' '} + + OrangeCat + + {' · '} + + profile + + +
+
+
+ ); +} diff --git a/src/components/sites/SiteSections.tsx b/src/components/sites/SiteSections.tsx new file mode 100644 index 000000000..b409208a3 --- /dev/null +++ b/src/components/sites/SiteSections.tsx @@ -0,0 +1,178 @@ +/** + * Renders the closed set of section shapes a hosted site is built from. + * + * One renderer for every hosted site is deliberate: it is what stops fifty + * customer websites from becoming fifty stylesheets, and it is why a site + * inherits dark mode, spacing and readable measure without its owner thinking + * about any of them. Section shapes live in `src/config/site-content.ts`. + */ + +import React from 'react'; +import type { SiteSection } from '@/config/site-content'; + +function Heading({ children }: { children: React.ReactNode }) { + return ( +

+ {children} +

+ ); +} + +function Blurb({ children }: { children: React.ReactNode }) { + return

{children}

; +} + +function ProseSection({ section }: { section: Extract }) { + return ( +
+ {section.heading && {section.heading}} + {section.paragraphs.map((paragraph, index) => ( +

+ {paragraph} +

+ ))} +
+ ); +} + +function StatsSection({ section }: { section: Extract }) { + return ( +
+ {section.heading && {section.heading}} +
+ {section.stats.map(stat => ( +
+
+ {stat.label} +
+
+ {stat.value} +
+ {stat.note &&

{stat.note}

} +
+ ))} +
+
+ ); +} + +function CardsSection({ section }: { section: Extract }) { + 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} +

+ )} +
+ ))} +
+
+ ); +} + +function DefinitionsSection({ + section, +}: { + section: Extract; +}) { + return ( +
+ {section.heading && {section.heading}} + {section.blurb && {section.blurb}} +
+ {section.items.map(item => ( +
+
{item.term}
+
+ {item.detail} +
+
+ ))} +
+
+ ); +} + +function TableSection({ section }: { section: Extract }) { + return ( +
+ {section.heading && ( +

{section.heading}

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

{section.blurb}

+ )} +
+ + + + {section.columns.map(column => ( + + ))} + + + + {section.rows.map((row, rowIndex) => ( + + {row.map((cell, cellIndex) => ( + + ))} + + ))} + +
+ {column} +
+ {cell} +
+
+ {section.note &&

{section.note}

} +
+ ); +} + +export function SiteSections({ sections }: { sections: SiteSection[] }) { + return ( +
+ {sections.map((section, index) => { + switch (section.kind) { + case 'prose': + return ; + case 'stats': + return ; + case 'cards': + return ; + case 'definitions': + return ; + case 'table': + return ; + default: + return null; + } + })} +
+ ); +} 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..b6c95b195 --- /dev/null +++ b/src/config/site-content.ts @@ -0,0 +1,116 @@ +/** + * 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[]`. Substrate's lives in `site-substrate.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 { substrateSiteChrome, substrateSitePages } from './site-substrate'; + +// ===================================================================== +// 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 type SiteSection = + | { kind: 'prose'; heading?: string; paragraphs: string[] } + | { kind: 'stats'; heading?: string; stats: SiteStat[] } + | { kind: 'cards'; heading?: string; blurb?: string; columns?: 2 | 3; cards: SiteCard[] } + | { kind: 'definitions'; heading?: string; blurb?: string; items: SiteDefinition[] } + | { + kind: 'table'; + heading?: string; + blurb?: string; + columns: string[]; + rows: string[][]; + 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 'substrate': + return substrateSitePages(); + default: + return []; + } +} + +export function siteChromeFor(site: HostedSite): SiteChrome | null { + switch (site.slug) { + case 'substrate': + return substrateSiteChrome(); + default: + return null; + } +} + +/** @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-substrate.ts b/src/config/site-substrate.ts new file mode 100644 index 000000000..1ae15e42a --- /dev/null +++ b/src/config/site-substrate.ts @@ -0,0 +1,328 @@ +/** + * Substrate's website, generated from Substrate's OrangeCat profile. + * + * Every word and number below comes from `substrate.ts` and + * `substrate-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 { + CATALOGUE, + CHOKEPOINT_TEST, + COMPANY, + COMPLIANCE, + DESKS, + DISCLOSURE, + EXCLUSION_RULE, + LISTING_COPY, + MANDATE_CURVES, + NODE_TYPES, + PHASES, + formatChf, +} from './substrate'; +import { COVERAGE, PRODUCER_ROLES, coverageProgress } from './substrate-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 substrateSiteChrome(): SiteChrome { + return { + name: COMPANY.name, + tagline: COMPANY.tagline, + footerNote: + `${COMPANY.name} publishes its research openly and trades the materials it covers. ` + + 'Every note states the firm’s position at time of publication. Listed prices are ' + + 'indicative reference levels, not quotes.', + }; +} + +// ===================================================================== +// HOME +// ===================================================================== + +function homePage(): SitePage { + const progress = coverageProgress(); + const activePhase = PHASES.find(phase => phase.status === 'active'); + + return { + path: '', + navLabel: 'Home', + title: COMPANY.name, + intro: COMPANY.tagline, + sections: [ + { kind: 'prose', paragraphs: [...LISTING_COPY.body] }, + { + kind: 'stats', + heading: 'Where the research stands', + stats: [ + { + label: 'Materials on the desk', + value: String(CATALOGUE.length), + note: 'Each one a chokepoint, not a commodity.', + }, + { + label: 'Producers identified', + value: String(progress.total), + note: `Across ${new Set(COVERAGE.flatMap(e => e.producers.map(p => p.name))).size} distinct companies.`, + }, + { + label: 'Producers sourced', + value: `${progress.sourced} of ${progress.total}`, + note: 'A row counts only once a primary source is attached.', + }, + ], + }, + { + 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', + blurb: EXCLUSION_RULE.rule, + items: CHOKEPOINT_TEST.map(factor => ({ + term: factor.question, + detail: factor.detail, + })), + }, + { + kind: 'prose', + heading: activePhase ? activePhase.label : 'Current phase', + paragraphs: activePhase ? [activePhase.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 +// ===================================================================== + +function mapPage(): SitePage { + const progress = coverageProgress(); + + const materialTables: SiteSection[] = COVERAGE.map(entry => ({ + kind: 'table' as const, + heading: entry.material, + blurb: entry.thesis, + columns: ['Company', 'Jurisdiction', 'Step in the chain', 'Status'], + 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 count below moves. ' + + 'That count 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: 'stats', + stats: [ + { label: 'Materials covered', value: String(COVERAGE.length) }, + { label: 'Producer rows', value: String(progress.total) }, + { + label: 'Confirmed against a source', + value: `${progress.sourced} of ${progress.total}`, + note: 'Phase 1 completes when these match.', + }, + ], + }, + ...materialTables, + ], + }; +} + +// ===================================================================== +// THE DESK +// ===================================================================== + +function deskPage(): SitePage { + const deskSections: SiteSection[] = DESKS.map(desk => ({ + kind: 'cards' as const, + heading: desk.name, + blurb: desk.covers, + columns: 2 as const, + cards: CATALOGUE.filter(listing => listing.desk === desk.id).map(listing => ({ + title: listing.title, + body: listing.why, + meta: `${listing.spec} · Indicative CHF ${formatChf(listing.indicativePriceChf)} per ${listing.unit}`, + })), + })); + + return { + path: 'desk', + navLabel: 'The desk', + title: 'The desk', + intro: 'The materials we trade, and what we will tell you about them before you ask.', + sections: [ + { + kind: 'prose', + paragraphs: [ + 'Fifteen materials, five desks, one book. Every line here passed the same two tests ' + + 'the research universe uses, which is why the desk and the map cover exactly the ' + + 'same ground — the sourcing work and the research work are the same work.', + 'Prices shown are indicative reference levels in Swiss francs for the stated unit: ' + + 'the number to size a budget with, not a quote. Firm pricing is by RFQ against ' + + 'grade, lot size, origin and delivery window, because every one of these markets ' + + 'prices that way. Settlement in Bitcoin or in francs, your choice.', + ], + }, + ...deskSections, + { + kind: 'definitions', + heading: 'Before you send an RFQ', + items: COMPLIANCE.screening.map((rule, index) => ({ + term: `Screening ${index + 1}`, + detail: rule, + })), + }, + { kind: 'prose', paragraphs: [COMPLIANCE.outOfScope] }, + ], + }; +} + +// ===================================================================== +// DISCLOSURE +// ===================================================================== + +function disclosurePage(): SitePage { + return { + path: 'disclosure', + navLabel: 'Disclosure', + title: 'Disclosure', + intro: 'We publish research on materials we trade. Here is how that is kept straight.', + sections: [ + { + kind: 'prose', + paragraphs: [ + 'A firm that publishes research, trades the same materials, and intends to eventually ' + + 'own parts of the chain is publishing on its own positions. That is a workable ' + + 'model, but only with the rule written before the first position exists — ' + + 'afterwards, every rule looks like a response to something. So it was written on ' + + 'day one, in the same file as the mandate.', + DISCLOSURE.openByDefault, + ], + }, + { + kind: 'definitions', + heading: 'The rules', + items: DISCLOSURE.rules.map((rule, index) => ({ + term: `Rule ${index + 1}`, + detail: rule, + })), + }, + { + kind: 'definitions', + heading: 'Trade compliance', + blurb: + 'Several of these materials are dual-use and export-controlled. The focus itself is ' + + 'the first control: the mandate admits compute, energy and actuation, and nothing else.', + items: COMPLIANCE.screening.map((rule, index) => ({ + term: `Control ${index + 1}`, + detail: rule, + })), + }, + { kind: 'prose', heading: 'Out of scope', paragraphs: [COMPLIANCE.outOfScope] }, + ], + }; +} + +// ===================================================================== + +export function substrateSitePages(): SitePage[] { + return [homePage(), mandatePage(), mapPage(), deskPage(), disclosurePage()]; +} diff --git a/src/config/sites.ts b/src/config/sites.ts new file mode 100644 index 000000000..ea521b710 --- /dev/null +++ b/src/config/sites.ts @@ -0,0 +1,121 @@ +/** + * 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`: + * substrate.orangecat.ch — the free subdomain every hosted site starts on + * substrate.example — a custom domain, once the owner points DNS + * substrate.localhost:3020 — local development, so the rewrite is testable + * + * The path form `/sites/substrate` 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. */ + customDomain: string | null; + /** 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: 'substrate', + subdomain: 'substrate', + customDomain: null, + title: 'Substrate', + profile: { kind: 'group', slug: 'substrate' }, + }, +]; + +/** 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; + } + } + 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/substrate.ts b/src/config/substrate.ts index f45dd25af..9b77c5467 100644 --- a/src/config/substrate.ts +++ b/src/config/substrate.ts @@ -611,6 +611,20 @@ export interface MaterialProductPayload { show_on_profile: boolean; } +/** + * Group thousands with the Swiss apostrophe, so a ruthenium quote reads + * CHF 15’000 rather than CHF 15000 — at these magnitudes an unseparated + * number is a misreading waiting to happen. Done by hand rather than with + * Intl, because this string is asserted in tests and baked into rows the seed + * writes to the database: it must not vary with the ICU data of whatever + * machine happens to run the seed. + */ +export function formatChf(amount: number): string { + const [whole, fraction] = String(amount).split('.'); + const grouped = whole.replace(/\B(?=(\d{3})+(?!\d))/g, '’'); + return fraction ? `${grouped}.${fraction}` : grouped; +} + const DESK_BY_ID: Record<DeskId, Desk> = DESKS.reduce( (acc, desk) => ({ ...acc, [desk.id]: desk }), {} as Record<DeskId, Desk> @@ -631,7 +645,7 @@ export function toProductPayload(listing: MaterialListing): MaterialProductPaylo '', `Traded as: ${listing.spec}`, `Desk: ${desk.name}`, - `Indicative reference: CHF ${listing.indicativePriceChf} per ${listing.unit} — ` + + `Indicative reference: CHF ${formatChf(listing.indicativePriceChf)} per ${listing.unit} — ` + 'a budgeting level, not a quote. Firm pricing by RFQ against grade, lot ' + 'size, origin and delivery window.', ].join('\n'), diff --git a/src/middleware.ts b/src/middleware.ts index 092b9fba4..65d0f3f7f 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 substrate.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, From af88465c452a1bd141f637ab8d23e72d0163c112 Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Wed, 26 Aug 2026 17:03:56 +0000 Subject: [PATCH 04/17] feat(domains): domain availability search, shared by OrangeCat and FleetCrown MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Finding a name is the rung before the two services /domains already sells — somebody with no domain cannot buy hosting for one. This adds the check, as a public keyless v1 endpoint both products call rather than a widget one of them owns. THE RULE THIS IS BUILT AROUND The obvious implementation asks a redirector for a domain and treats 404 as available. That implementation reports ORANGECAT.CH — this project's own production domain — as free, because .ch operates no public RDAP service and a redirector cannot distinguish "no such domain" from "no such registry". The same false positive hits .io and .co. So a TLD earns a definitive answer only by appearing in IANA's RDAP bootstrap. Everything else — unsupported TLD, timeout, transport error, odd status, bootstrap unreachable — is `unknown`, surfaced as "check manually" with the reason. `unregistered` is reachable by exactly one path: a bootstrapped registry that answered 404. Even then the copy says premium pricing, registry reservations and trademark conflicts are not visible here. A search tool that guesses is worse than one that admits the gap, because the guess is what someone acts on. - src/config/domain-search.ts — SSOT: bootstrap URL, TLDs, patterns, timeouts, and the status copy - src/services/domains/availability.ts — bootstrap-gated RDAP lookup, cached, batched at a polite concurrency - src/services/domains/suggest.ts — bare name across every TLD first, variants after; a caller may narrow the TLD list - GET /api/v1/domains — registered in PUBLIC_API_INTEGRATION_ENDPOINTS beside search and demand, so FleetCrown gets it as a contract - /domains grows a search box above the offer it feeds 19 tests, none touching the network, including one per no-RDAP TLD that fails if the feature ever reports one of them as free. Verified live: substrataintel.com and .ai unregistered; orangecat.ai and orangecatlabs.com registered; orangecat.ch correctly unresolved rather than free. Note for deploy: the box needs outbound HTTPS to rdap.org and data.iana.org, or every lookup degrades to "check manually". Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GF9GDWaBWYHCZ9iNwmfZ41 --- __tests__/unit/services/domain-search.test.ts | 179 +++++++++++++++ src/app/(public)/domains/page.tsx | 10 + src/app/api/v1/domains/route.ts | 64 ++++++ src/components/domains/DomainSearch.tsx | 137 ++++++++++++ src/config/domain-search.ts | 107 +++++++++ src/config/public-api.ts | 8 + src/services/domains/availability.ts | 206 ++++++++++++++++++ src/services/domains/suggest.ts | 82 +++++++ 8 files changed, 793 insertions(+) create mode 100644 __tests__/unit/services/domain-search.test.ts create mode 100644 src/app/api/v1/domains/route.ts create mode 100644 src/components/domains/DomainSearch.tsx create mode 100644 src/config/domain-search.ts create mode 100644 src/services/domains/availability.ts create mode 100644 src/services/domains/suggest.ts 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<Response>> | Partial<Response>) { + 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<Response>); + 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/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. </p> </div> + + {/* The rung before everything else on this page: you cannot host a + domain you have not found yet. */} + <div className="mt-10"> + <p className="mb-4 text-center text-sm text-fg-tertiary"> + Don't have a name yet? Check one against the registries. + </p> + <DomainSearch /> + </div> </div> </div> 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/components/domains/DomainSearch.tsx b/src/components/domains/DomainSearch.tsx new file mode 100644 index 000000000..cb28baa75 --- /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<DomainStatus, string> = { + 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<DomainStatus, number> = { unregistered: 0, unknown: 1, registered: 2 }; + +export function DomainSearch() { + const [query, setQuery] = useState(''); + const [results, setResults] = useState<DomainResult[] | null>(null); + const [disclaimer, setDisclaimer] = useState(''); + const [isSearching, setIsSearching] = useState(false); + const [error, setError] = useState<string | null>(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 ( + <div className="mx-auto max-w-3xl"> + <form onSubmit={search} className="flex flex-col gap-3 sm:flex-row"> + <label htmlFor="domain-query" className="sr-only"> + Name to check + </label> + <input + id="domain-query" + type="text" + value={query} + onChange={event => setQuery(event.target.value)} + placeholder="substrataintel" + 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" + /> + <Button type="submit" variant="accent" disabled={isSearching} className="min-h-11"> + {isSearching ? ( + <Loader2 className="mr-2 h-4 w-4 animate-spin" /> + ) : ( + <Search className="mr-2 h-4 w-4" /> + )} + {isSearching ? 'Checking registries' : 'Check availability'} + </Button> + </form> + + {error && <p className="mt-3 text-sm text-status-negative">{error}</p>} + + {results && results.length === 0 && ( + <p className="mt-6 text-sm text-fg-secondary"> + Nothing to check — that query has no usable domain label. + </p> + )} + + {results && results.length > 0 && ( + <div className="mt-6"> + <ul className="divide-y divide-subtle overflow-hidden rounded-lg border border-subtle bg-surface-base"> + {results.map(result => ( + <li + key={result.domain} + className="flex flex-col gap-1 px-4 py-3 sm:flex-row sm:items-center sm:justify-between sm:gap-4" + > + <span className="font-mono text-sm text-fg-primary">{result.domain}</span> + <span className="flex items-center gap-3"> + <span className="text-xs text-fg-muted sm:max-w-md sm:text-right"> + {result.reason} + </span> + <span + className={`shrink-0 rounded-full px-2.5 py-1 text-xs font-medium ${STATUS_CLASS[result.status]}`} + > + {DOMAIN_STATUS_COPY[result.status].label} + </span> + </span> + </li> + ))} + </ul> + {disclaimer && <p className="mt-3 text-xs leading-relaxed text-fg-muted">{disclaimer}</p>} + </div> + )} + </div> + ); +} 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<DomainStatus, { label: string; detail: string }> = { + 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/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; +} From 6b6fa8ff5bd32f51fe4270786d83de2f7b5cb76d Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Wed, 26 Aug 2026 17:50:19 +0000 Subject: [PATCH 05/17] feat(sites): Substrata Intel at substrataintel.orangecat.ch, designed properly MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Renames the firm to Substrata Intel (files and slug follow, so a reader never finds substrate.ts exporting a company called something else) and gives the hosted site a design rather than a stylesheet. TWO CORRECTIONS TO THE BRIEF, BOTH CHECKED No domain is bought. The site runs on the free subdomain the platform already offers. And it is orangecat.CH, not .com: orangecat.com is registered to someone and serves nothing, while every part of this platform — CD, Caddy, the /domains copy, SITES_BASE_DOMAIN — is .ch. THE DESIGN Editorial, not marketing. Space Grotesk for display, IBM Plex Mono for anything a reader compares down a column, Inter for prose at measure. Monochrome surfaces; the warm accent appears exactly twice, on the current nav item and the coverage bar; status colour only on status. - Hero replaces the generic title block on the home page: eyebrow, one display statement, and a lead cut to two paragraphs. The two that were dropped are said better by the phase block and the desk page, and a lead nobody finishes is not a lead. - Coverage meter. The single most important number on the site now has a picture, and at 0 of 92 that picture is an empty bar. Drawn from the same data as the map, so it cannot flatter the work. - The map gets a jump index — fifteen tables without one is a scroll, not a document — and every anchor is tested to land on a real table. - Tables are table-fixed with declared widths. Auto layout sized each of the fifteen to its own longest cell, so one long company name shifted every column out of line with the table above it. - Status cells carry a dot. "Unverified lead" and "Sourced" are the two words on this site a reader must never skim past. - Sections are numbered, headings sit on a hanging number, cards are top-ruled rather than boxed. - Masthead is one row at every width; the nav scrolls sideways on a phone instead of wrapping, because a two-row sticky header ate a third of a 390px screen. Renderer split into focused section components under components/sites/ sections/, dispatched from one place. Three new section kinds — hero, meter, index — available to every future hosted site, not just this one. Verified against a running server at 1280 and 390: the host rewrite answers on substrataintel.orangecat.ch, the tab reads "Substrata Intel", and orangecat.ch itself is untouched. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GF9GDWaBWYHCZ9iNwmfZ41 --- __tests__/unit/config/hosted-sites.test.ts | 87 +++++--- ...strate.test.ts => substrata-intel.test.ts} | 4 +- ...d-substrate.ts => seed-substrata-intel.ts} | 4 +- src/app/sites/[site]/[[...path]]/page.tsx | 34 ++-- src/components/layout/AppShell.tsx | 2 +- src/components/sites/SiteChrome.tsx | 54 ++--- src/components/sites/SiteSections.tsx | 185 +++--------------- .../sites/sections/DataSections.tsx | 133 +++++++++++++ .../sites/sections/FigureSections.tsx | 87 ++++++++ src/components/sites/sections/HeroSection.tsx | 32 +++ src/components/sites/sections/Primitives.tsx | 46 +++++ .../sites/sections/ProseSections.tsx | 90 +++++++++ src/config/site-content.ts | 49 ++++- ...e-substrate.ts => site-substrata-intel.ts} | 96 ++++++--- src/config/sites.ts | 16 +- ...overage.ts => substrata-intel-coverage.ts} | 6 +- .../{substrate.ts => substrata-intel.ts} | 16 +- src/middleware.ts | 2 +- 18 files changed, 672 insertions(+), 271 deletions(-) rename __tests__/unit/config/{substrate.test.ts => substrata-intel.test.ts} (99%) rename scripts/{seed-substrate.ts => seed-substrata-intel.ts} (98%) create mode 100644 src/components/sites/sections/DataSections.tsx create mode 100644 src/components/sites/sections/FigureSections.tsx create mode 100644 src/components/sites/sections/HeroSection.tsx create mode 100644 src/components/sites/sections/Primitives.tsx create mode 100644 src/components/sites/sections/ProseSections.tsx rename src/config/{site-substrate.ts => site-substrata-intel.ts} (76%) rename src/config/{substrate-coverage.ts => substrata-intel-coverage.ts} (98%) rename src/config/{substrate.ts => substrata-intel.ts} (98%) diff --git a/__tests__/unit/config/hosted-sites.test.ts b/__tests__/unit/config/hosted-sites.test.ts index 3d972ce91..1c9e6c1fd 100644 --- a/__tests__/unit/config/hosted-sites.test.ts +++ b/__tests__/unit/config/hosted-sites.test.ts @@ -14,22 +14,27 @@ */ import { HOSTED_SITES, siteBySlug, siteCanonicalHost, siteForHost, siteHref } from '@/config/sites'; -import { sitePageAt, sitePagesFor, siteChromeFor } from '@/config/site-content'; +import { + pageRendersOwnHeader, + sitePageAt, + sitePagesFor, + siteChromeFor, +} from '@/config/site-content'; import { getRouteSurface } from '@/config/routes'; -import { CATALOGUE, COMPANY, MANDATE_CURVES } from '@/config/substrate'; -import { COVERAGE, coverageProgress } from '@/config/substrate-coverage'; +import { CATALOGUE, COMPANY, MANDATE_CURVES } from '@/config/substrata-intel'; +import { COVERAGE, coverageProgress } from '@/config/substrata-intel-coverage'; -const substrate = siteBySlug('substrate'); +const site = siteBySlug('substrataintel'); describe('hosted sites — host resolution', () => { it('resolves the free subdomain, with or without www and port', () => { - expect(siteForHost('substrate.orangecat.ch')?.slug).toBe('substrate'); - expect(siteForHost('www.substrate.orangecat.ch')?.slug).toBe('substrate'); - expect(siteForHost('Substrate.OrangeCat.ch:443')?.slug).toBe('substrate'); + expect(siteForHost('substrataintel.orangecat.ch')?.slug).toBe('substrataintel'); + expect(siteForHost('www.substrataintel.orangecat.ch')?.slug).toBe('substrataintel'); + expect(siteForHost('SubstrataIntel.OrangeCat.ch:443')?.slug).toBe('substrataintel'); }); it('resolves the local development host, so the rewrite is testable without DNS', () => { - expect(siteForHost('substrate.localhost:3020')?.slug).toBe('substrate'); + expect(siteForHost('substrataintel.localhost:3020')?.slug).toBe('substrataintel'); }); it('refuses everything else — a greedy match would swallow OrangeCat itself', () => { @@ -37,9 +42,9 @@ describe('hosted sites — host resolution', () => { 'orangecat.ch', 'www.orangecat.ch', 'localhost:3000', - 'substrate.evil.example', - 'notsubstrate.orangecat.ch', - 'substrate.orangecat.ch.evil.example', + 'substrataintel.evil.example', + 'notsubstrataintel.orangecat.ch', + 'substrataintel.orangecat.ch.evil.example', '', null, undefined, @@ -58,25 +63,25 @@ describe('hosted sites — host resolution', () => { describe('hosted sites — links', () => { it('always emits the path form, which resolves on every host', () => { - expect(substrate).not.toBeNull(); - expect(siteHref(substrate!)).toBe('/sites/substrate'); - expect(siteHref(substrate!, 'map')).toBe('/sites/substrate/map'); - expect(siteHref(substrate!, '/map')).toBe('/sites/substrate/map'); - expect(siteHref(substrate!, '/')).toBe('/sites/substrate'); + expect(site).not.toBeNull(); + expect(siteHref(site!)).toBe('/sites/substrataintel'); + expect(siteHref(site!, 'map')).toBe('/sites/substrataintel/map'); + expect(siteHref(site!, '/map')).toBe('/sites/substrataintel/map'); + expect(siteHref(site!, '/')).toBe('/sites/substrataintel'); }); }); describe('hosted sites — chrome isolation', () => { it('classifies a hosted site as its own surface, not app or public', () => { - expect(getRouteSurface('/sites/substrate')).toBe('site'); - expect(getRouteSurface('/sites/substrate/map')).toBe('site'); + expect(getRouteSurface('/sites/substrataintel')).toBe('site'); + expect(getRouteSurface('/sites/substrataintel/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/substrate')).toBe('app'); + expect(getRouteSurface('/groups/substrataintel')).toBe('app'); }); }); @@ -105,16 +110,16 @@ describe('hosted sites — every site renders', () => { ); it('returns null for a path no page claims, so the route can 404', () => { - expect(sitePageAt(substrate!, 'not-a-page')).toBeNull(); + expect(sitePageAt(site!, 'not-a-page')).toBeNull(); }); }); -describe('substrate.orangecat.ch — the site is the profile, not a copy of it', () => { - const pages = sitePagesFor(substrate!); +describe('substrataintel.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(substrate!); + const chrome = siteChromeFor(site!); expect(chrome?.name).toBe(COMPANY.name); expect(chrome?.tagline).toBe(COMPANY.tagline); }); @@ -140,7 +145,7 @@ describe('substrate.orangecat.ch — the site is the profile, not a copy of it', }); it('reports coverage honestly — unsourced rows read as leads, not findings', () => { - const mapPage = sitePageAt(substrate!, 'map'); + const mapPage = sitePageAt(site!, 'map'); const rows = JSON.stringify(mapPage); const { sourced, total } = coverageProgress(); @@ -150,11 +155,41 @@ describe('substrate.orangecat.ch — the site is the profile, not a copy of it', if (sourced < total) { expect(rows).toContain('Unverified lead'); } - expect(rows).toContain(`${sourced} of ${total}`); + + // 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('says on the desk page that prices are indicative rather than quotes', () => { - const deskPage = sitePageAt(substrate!, 'desk'); + const deskPage = sitePageAt(site!, 'desk'); expect(JSON.stringify(deskPage)).toContain('not a quote'); }); }); diff --git a/__tests__/unit/config/substrate.test.ts b/__tests__/unit/config/substrata-intel.test.ts similarity index 99% rename from __tests__/unit/config/substrate.test.ts rename to __tests__/unit/config/substrata-intel.test.ts index 151f1b9fa..3fb39fc3f 100644 --- a/__tests__/unit/config/substrate.test.ts +++ b/__tests__/unit/config/substrata-intel.test.ts @@ -30,13 +30,13 @@ import { PRODUCT_PAYLOADS, deskFor, formatChf, -} from '@/config/substrate'; +} from '@/config/substrata-intel'; import { COVERAGE, PRODUCER_ROLES, coverageProgress, materialsFor, -} from '@/config/substrate-coverage'; +} from '@/config/substrata-intel-coverage'; import { GROUP_LABELS } from '@/config/group-labels'; import { GROUP_FEATURES } from '@/config/group-features'; import { GOVERNANCE_PRESETS } from '@/config/governance-presets'; diff --git a/scripts/seed-substrate.ts b/scripts/seed-substrata-intel.ts similarity index 98% rename from scripts/seed-substrate.ts rename to scripts/seed-substrata-intel.ts index d44de93f9..89a4d599e 100644 --- a/scripts/seed-substrate.ts +++ b/scripts/seed-substrata-intel.ts @@ -15,7 +15,7 @@ * Owner-gated so it can't fire by accident. * * Run against the LIVE self-hosted DB (supabase.orangecat.ch) from the box: - * ORANGECAT_OWNER_SEED=1 npx tsx scripts/seed-substrate.ts + * ORANGECAT_OWNER_SEED=1 npx tsx scripts/seed-substrata-intel.ts * * Requires in the environment (already in .env.local on the box): * NEXT_PUBLIC_SUPABASE_URL — self-hosted Supabase URL @@ -33,7 +33,7 @@ import { GROUP_PAYLOAD, PRODUCT_PAYLOADS, type MaterialProductPayload, -} from '../src/config/substrate'; +} from '../src/config/substrata-intel'; loadEnv({ path: '.env.local' }); diff --git a/src/app/sites/[site]/[[...path]]/page.tsx b/src/app/sites/[site]/[[...path]]/page.tsx index 50a0b42ec..182b055e4 100644 --- a/src/app/sites/[site]/[[...path]]/page.tsx +++ b/src/app/sites/[site]/[[...path]]/page.tsx @@ -15,7 +15,12 @@ import React from 'react'; import { notFound } from 'next/navigation'; import type { Metadata } from 'next'; import { HOSTED_SITES, siteBySlug, siteCanonicalHost } from '@/config/sites'; -import { siteChromeFor, sitePageAt, sitePagesFor } from '@/config/site-content'; +import { + pageRendersOwnHeader, + siteChromeFor, + sitePageAt, + sitePagesFor, +} from '@/config/site-content'; import { SiteFooter, SiteMasthead } from '@/components/sites/SiteChrome'; import { SiteSections } from '@/components/sites/SiteSections'; @@ -80,17 +85,22 @@ export default async function HostedSitePage({ params }: RouteParams) { <SiteMasthead site={site} chrome={chrome} pages={pages} currentPath={currentPath} /> <main className="flex-1"> - <div className="mx-auto max-w-shell px-4 py-12 sm:px-6 lg:px-8"> - <header className="mb-10"> - <h1 className="font-heading tracking-display text-3xl font-bold text-fg-primary sm:text-4xl"> - {page.title} - </h1> - {page.intro && ( - <p className="mt-3 max-w-3xl text-lg leading-relaxed text-fg-secondary"> - {page.intro} - </p> - )} - </header> + <div className="mx-auto max-w-shell px-4 py-14 sm:px-6 sm:py-20 lg:px-8"> + {/* 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) && ( + <header className="mb-14 border-b border-subtle pb-10"> + <h1 className="font-heading text-3xl font-semibold tracking-display text-fg-primary sm:text-5xl"> + {page.title} + </h1> + {page.intro && ( + <p className="mt-4 max-w-prose text-lg leading-relaxed text-fg-secondary"> + {page.intro} + </p> + )} + </header> + )} <SiteSections sections={page.sections} /> </div> diff --git a/src/components/layout/AppShell.tsx b/src/components/layout/AppShell.tsx index e3a0681fa..eb3c3faaa 100644 --- a/src/components/layout/AppShell.tsx +++ b/src/components/layout/AppShell.tsx @@ -113,7 +113,7 @@ export function AppShell({ children }: AppShellProps) { const filteredSections = getFilteredSections(); // Hosted site: no OrangeCat chrome whatsoever, and no message-sync manager - // either — a visitor to substrate.orangecat.ch has no OrangeCat session and + // either — a visitor to substrataintel.orangecat.ch has no OrangeCat session and // no reason for one. The site's own layout supplies everything. if (isSiteSurface) { return ( diff --git a/src/components/sites/SiteChrome.tsx b/src/components/sites/SiteChrome.tsx index 5a23ff02e..345e5d414 100644 --- a/src/components/sites/SiteChrome.tsx +++ b/src/components/sites/SiteChrome.tsx @@ -2,10 +2,15 @@ * Masthead and footer for a hosted site. * * The visitor is on the site owner's domain, so the chrome is theirs: their - * name in the masthead, 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 — which is the honest disclosure a hosted site owes, and - * also the only distribution OrangeCat gets out of hosting it. + * 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'; @@ -25,30 +30,35 @@ export function SiteMasthead({ site, chrome, pages, currentPath }: Props) { const navPages = pages.filter(page => page.navLabel); return ( - <header className="border-b border-subtle bg-surface-base"> + <header className="sticky top-0 z-30 border-b border-subtle bg-surface-page/85 backdrop-blur"> <div className="mx-auto max-w-shell px-4 sm:px-6 lg:px-8"> - <div className="flex flex-col gap-3 py-5 sm:flex-row sm:items-baseline sm:justify-between"> - <Link href={siteHref(site)} className="group"> - <span className="font-heading tracking-display text-xl font-semibold text-fg-primary"> + {/* 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. */} + <div className="flex items-center justify-between gap-6 py-4"> + <Link href={siteHref(site)} className="shrink-0"> + <span className="font-heading text-lg font-semibold tracking-display text-fg-primary"> {chrome.name} </span> - <span className="ml-3 hidden text-sm text-fg-tertiary sm:inline">{chrome.tagline}</span> </Link> - <nav aria-label="Site" className="flex flex-wrap items-center gap-x-5 gap-y-2"> + <nav + aria-label="Sections" + className="scrollbar-hide -mx-1 flex flex-nowrap items-center gap-x-1 overflow-x-auto" + > {navPages.map(page => { - const href = siteHref(site, page.path); const isCurrent = page.path === currentPath; return ( <Link key={page.path || 'home'} - href={href} + href={siteHref(site, page.path)} aria-current={isCurrent ? 'page' : undefined} - className={ + className={[ + 'shrink-0 rounded px-2 py-1 font-mono text-xs uppercase tracking-caps transition-colors', isCurrent - ? 'text-sm font-semibold text-fg-primary underline decoration-accent-warm decoration-2 underline-offset-8' - : 'text-sm text-fg-secondary transition-colors hover:text-fg-primary' - } + ? 'text-fg-primary underline decoration-accent-warm decoration-2 underline-offset-8' + : 'text-fg-tertiary hover:text-fg-primary', + ].join(' ')} > {page.navLabel} </Link> @@ -63,12 +73,12 @@ export function SiteMasthead({ site, chrome, pages, currentPath }: Props) { export function SiteFooter({ site, chrome }: { site: HostedSite; chrome: SiteChromeSpec }) { return ( - <footer className="mt-16 border-t border-subtle bg-surface-base"> - <div className="mx-auto max-w-shell px-4 py-10 sm:px-6 lg:px-8"> - <p className="max-w-3xl text-sm leading-relaxed text-fg-secondary">{chrome.footerNote}</p> - <div className="mt-6 flex flex-col gap-2 border-t border-subtle pt-6 text-xs text-fg-muted sm:flex-row sm:items-center sm:justify-between"> - <span className="font-mono">{siteCanonicalHost(site)}</span> - <span> + <footer className="mt-24 border-t border-subtle"> + <div className="mx-auto max-w-shell px-4 py-12 sm:px-6 lg:px-8"> + <p className="max-w-prose text-sm leading-relaxed text-fg-secondary">{chrome.footerNote}</p> + <div className="mt-8 flex flex-col gap-2 border-t border-subtle pt-6 font-mono text-xs uppercase tracking-caps text-fg-muted sm:flex-row sm:items-center sm:justify-between"> + <span>{siteCanonicalHost(site)}</span> + <span className="normal-case tracking-normal"> Hosted on{' '} <Link href={ROUTES.HOME} className="underline underline-offset-2 hover:text-fg-primary"> OrangeCat diff --git a/src/components/sites/SiteSections.tsx b/src/components/sites/SiteSections.tsx index b409208a3..d41681014 100644 --- a/src/components/sites/SiteSections.tsx +++ b/src/components/sites/SiteSections.tsx @@ -1,174 +1,53 @@ /** - * Renders the closed set of section shapes a hosted site is built from. + * Dispatches a page's sections to their renderers, and numbers them. * - * One renderer for every hosted site is deliberate: it is what stops fifty - * customer websites from becoming fifty stylesheets, and it is why a site - * inherits dark mode, spacing and readable measure without its owner thinking - * about any of them. Section shapes live in `src/config/site-content.ts`. + * 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'; -function Heading({ children }: { children: React.ReactNode }) { - return ( - <h2 className="font-heading tracking-display text-2xl font-semibold text-fg-primary sm:text-3xl"> - {children} - </h2> - ); -} - -function Blurb({ children }: { children: React.ReactNode }) { - return <p className="mt-3 max-w-3xl text-base leading-relaxed text-fg-secondary">{children}</p>; -} - -function ProseSection({ section }: { section: Extract<SiteSection, { kind: 'prose' }> }) { - return ( - <section className="space-y-4"> - {section.heading && <Heading>{section.heading}</Heading>} - {section.paragraphs.map((paragraph, index) => ( - <p key={index} className="max-w-3xl text-base leading-relaxed text-fg-secondary"> - {paragraph} - </p> - ))} - </section> - ); -} - -function StatsSection({ section }: { section: Extract<SiteSection, { kind: 'stats' }> }) { - return ( - <section> - {section.heading && <Heading>{section.heading}</Heading>} - <dl className="mt-6 grid grid-cols-1 gap-px overflow-hidden rounded-lg border border-subtle bg-subtle sm:grid-cols-3"> - {section.stats.map(stat => ( - <div key={stat.label} className="bg-surface-base p-6"> - <dt className="text-sm font-medium uppercase tracking-caps text-fg-tertiary"> - {stat.label} - </dt> - <dd className="mt-2 font-heading text-3xl font-semibold tabular-nums text-fg-primary"> - {stat.value} - </dd> - {stat.note && <p className="mt-2 text-sm leading-relaxed text-fg-muted">{stat.note}</p>} - </div> - ))} - </dl> - </section> - ); +/** 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); } -function CardsSection({ section }: { section: Extract<SiteSection, { kind: 'cards' }> }) { - const columns = section.columns === 3 ? 'lg:grid-cols-3' : 'lg:grid-cols-2'; - return ( - <section> - {section.heading && <Heading>{section.heading}</Heading>} - {section.blurb && <Blurb>{section.blurb}</Blurb>} - <div className={`mt-6 grid grid-cols-1 gap-4 sm:grid-cols-2 ${columns}`}> - {section.cards.map(card => ( - <article - key={card.title} - className="flex h-full flex-col rounded-lg border border-subtle bg-surface-base p-5" - > - <h3 className="text-base font-semibold text-fg-primary">{card.title}</h3> - <p className="mt-2 flex-1 text-sm leading-relaxed text-fg-secondary">{card.body}</p> - {card.meta && ( - <p className="mt-4 border-t border-subtle pt-3 text-xs leading-relaxed text-fg-muted"> - {card.meta} - </p> - )} - </article> - ))} - </div> - </section> - ); -} - -function DefinitionsSection({ - section, -}: { - section: Extract<SiteSection, { kind: 'definitions' }>; -}) { - return ( - <section> - {section.heading && <Heading>{section.heading}</Heading>} - {section.blurb && <Blurb>{section.blurb}</Blurb>} - <dl className="mt-6 space-y-5 border-l-2 border-subtle pl-5"> - {section.items.map(item => ( - <div key={item.term}> - <dt className="text-sm font-semibold text-fg-primary">{item.term}</dt> - <dd className="mt-1 max-w-3xl text-sm leading-relaxed text-fg-secondary"> - {item.detail} - </dd> - </div> - ))} - </dl> - </section> - ); -} +export function SiteSections({ sections }: { sections: SiteSection[] }) { + let counter = 0; -function TableSection({ section }: { section: Extract<SiteSection, { kind: 'table' }> }) { return ( - <section> - {section.heading && ( - <h3 className="font-heading text-lg font-semibold text-fg-primary">{section.heading}</h3> - )} - {section.blurb && ( - <p className="mt-2 max-w-3xl text-sm leading-relaxed text-fg-secondary">{section.blurb}</p> - )} - <div className="mt-4 overflow-x-auto rounded-lg border border-subtle"> - <table className="w-full min-w-[36rem] border-collapse text-left text-sm"> - <thead className="bg-surface-raised"> - <tr> - {section.columns.map(column => ( - <th - key={column} - scope="col" - className="px-4 py-2.5 text-xs font-semibold uppercase tracking-caps text-fg-tertiary" - > - {column} - </th> - ))} - </tr> - </thead> - <tbody className="bg-surface-base"> - {section.rows.map((row, rowIndex) => ( - <tr key={rowIndex} className="border-t border-subtle"> - {row.map((cell, cellIndex) => ( - <td - key={cellIndex} - className={ - cellIndex === 0 - ? 'px-4 py-2.5 font-medium text-fg-primary' - : 'px-4 py-2.5 text-fg-secondary' - } - > - {cell} - </td> - ))} - </tr> - ))} - </tbody> - </table> - </div> - {section.note && <p className="mt-2 text-xs text-fg-muted">{section.note}</p>} - </section> - ); -} + <div className="space-y-16"> + {sections.map((section, key) => { + const index = isNumbered(section) ? ++counter : undefined; -export function SiteSections({ sections }: { sections: SiteSection[] }) { - return ( - <div className="space-y-12"> - {sections.map((section, index) => { switch (section.kind) { + case 'hero': + return <HeroSection key={key} section={section} />; case 'prose': - return <ProseSection key={index} section={section} />; + return <ProseSection key={key} section={section} index={index} />; case 'stats': - return <StatsSection key={index} section={section} />; + return <StatsSection key={key} section={section} index={index} />; + case 'meter': + return <MeterSection key={key} section={section} index={index} />; case 'cards': - return <CardsSection key={index} section={section} />; + return <CardsSection key={key} section={section} index={index} />; case 'definitions': - return <DefinitionsSection key={index} section={section} />; + return <DefinitionsSection key={key} section={section} index={index} />; + case 'index': + return <IndexSection key={key} section={section} index={index} />; case 'table': - return <TableSection key={index} section={section} />; + return <TableSection key={key} section={section} />; 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..72228e730 --- /dev/null +++ b/src/components/sites/sections/DataSections.tsx @@ -0,0 +1,133 @@ +/** + * 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<string, string> = { + sourced: 'bg-status-positive', + 'unverified lead': 'bg-status-warning', + taken: 'bg-fg-muted', +}; + +export function IndexSection({ + section, + index, +}: { + section: Extract<SiteSection, { kind: 'index' }>; + index?: number; +}) { + return ( + <section> + {section.heading && <SectionHeading index={index}>{section.heading}</SectionHeading>} + {section.blurb && <div className="sm:pl-10">{<Blurb>{section.blurb}</Blurb>}</div>} + <SectionBody> + <ol className="divide-y divide-subtle border-y border-subtle"> + {section.entries.map((entry, i) => ( + <li key={entry.anchor}> + <a + href={`#${entry.anchor}`} + className="group flex items-baseline gap-4 py-2.5 transition-colors hover:text-fg-primary" + > + <span className="font-mono text-xs tabular-nums text-fg-muted"> + {String(i + 1).padStart(2, '0')} + </span> + <span className="flex-1 text-sm text-fg-secondary group-hover:text-fg-primary"> + {entry.label} + </span> + {entry.meta && ( + <span className="font-mono text-xs tabular-nums text-fg-muted">{entry.meta}</span> + )} + </a> + </li> + ))} + </ol> + </SectionBody> + </section> + ); +} + +export function TableSection({ section }: { section: Extract<SiteSection, { kind: 'table' }> }) { + 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 id={section.anchor} className="scroll-mt-24"> + {section.heading && ( + <h3 className="font-heading text-lg font-semibold text-fg-primary">{section.heading}</h3> + )} + {section.blurb && ( + <p className="mt-2 max-w-prose text-sm leading-relaxed text-fg-secondary"> + {section.blurb} + </p> + )} + <div className="mt-4 overflow-x-auto"> + <table className="w-full min-w-[38rem] table-fixed border-collapse text-left text-sm"> + <colgroup> + {section.columns.map((column, i) => ( + <col key={column} style={{ width: `${i === 0 ? 34 : restWidth}%` }} /> + ))} + </colgroup> + <thead> + <tr className="border-y border-subtle"> + {section.columns.map(column => ( + <th + key={column} + scope="col" + className="py-2 pr-6 font-mono text-xs font-medium uppercase tracking-caps text-fg-tertiary" + > + {column} + </th> + ))} + </tr> + </thead> + <tbody> + {section.rows.map((row, rowIndex) => ( + <tr key={rowIndex} className="border-b border-subtle"> + {row.map((cell, cellIndex) => { + const isStatus = cellIndex === section.statusColumn; + const dot = isStatus ? STATUS_DOT[cell.toLowerCase()] : undefined; + return ( + <td + key={cellIndex} + className={[ + 'py-2.5 pr-6 align-baseline', + cellIndex === 0 ? 'font-medium text-fg-primary' : 'text-fg-secondary', + mono.has(cellIndex) || isStatus ? 'font-mono text-xs' : '', + ].join(' ')} + > + {dot ? ( + <span className="inline-flex items-center gap-2"> + <span className={`h-1.5 w-1.5 shrink-0 rounded-full ${dot}`} /> + {cell} + </span> + ) : ( + cell + )} + </td> + ); + })} + </tr> + ))} + </tbody> + </table> + </div> + {section.note && <p className="mt-2 text-xs text-fg-muted">{section.note}</p>} + </section> + ); +} 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<SiteSection, { kind: 'stats' }>; + index?: number; +}) { + return ( + <section> + {section.heading && <SectionHeading index={index}>{section.heading}</SectionHeading>} + <dl className="mt-6 grid grid-cols-1 divide-y divide-subtle border-y border-subtle sm:grid-cols-3 sm:divide-x sm:divide-y-0"> + {section.stats.map(stat => ( + <div key={stat.label} className="px-0 py-6 sm:px-6 sm:first:pl-0"> + <dt className="font-mono text-xs uppercase tracking-caps text-fg-tertiary"> + {stat.label} + </dt> + <dd className="mt-3 font-heading text-4xl font-medium tabular-nums text-fg-primary"> + {stat.value} + </dd> + {stat.note && <p className="mt-2 text-sm leading-relaxed text-fg-muted">{stat.note}</p>} + </div> + ))} + </dl> + </section> + ); +} + +/** + * 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<SiteSection, { kind: 'meter' }>; + index?: number; +}) { + const percent = section.of > 0 ? Math.round((section.value / section.of) * 100) : 0; + + return ( + <section> + {section.heading && <SectionHeading index={index}>{section.heading}</SectionHeading>} + <SectionBody> + <div className="flex items-baseline justify-between gap-6"> + <span className="font-mono text-xs uppercase tracking-caps text-fg-tertiary"> + {section.label} + </span> + <span className="font-mono text-sm tabular-nums text-fg-secondary">{percent}%</span> + </div> + <p className="mt-3 font-heading text-4xl font-medium tabular-nums text-fg-primary"> + {section.value} + <span className="text-fg-muted"> / {section.of}</span> + </p> + <div + className="mt-4 h-1.5 w-full overflow-hidden rounded-full bg-surface-raised" + role="progressbar" + aria-valuenow={section.value} + aria-valuemin={0} + aria-valuemax={section.of} + aria-label={section.label} + > + <div className="h-full rounded-full bg-accent-warm" style={{ width: `${percent}%` }} /> + </div> + {section.caption && ( + <p className="mt-3 max-w-prose text-sm leading-relaxed text-fg-muted"> + {section.caption} + </p> + )} + </SectionBody> + </section> + ); +} 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<SiteSection, { kind: 'hero' }> }) { + return ( + <section className="border-b border-subtle pb-12"> + {section.eyebrow && ( + <p className="font-mono text-xs uppercase tracking-caps text-fg-tertiary"> + {section.eyebrow} + </p> + )} + <h1 className="mt-5 max-w-4xl font-heading text-4xl font-semibold leading-tight tracking-display text-fg-primary sm:text-5xl lg:text-6xl"> + {section.statement} + </h1> + <div className="mt-7 max-w-prose space-y-4"> + {section.lead.map((paragraph, index) => ( + <p key={index} className="text-lg leading-relaxed text-fg-secondary"> + {paragraph} + </p> + ))} + </div> + </section> + ); +} 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 ( + <div className="flex items-baseline gap-4"> + {index !== undefined && ( + <span aria-hidden className="font-mono text-xs tabular-nums text-fg-muted"> + {String(index).padStart(2, '0')} + </span> + )} + <h2 className="font-heading text-2xl font-semibold tracking-display text-fg-primary sm:text-3xl"> + {children} + </h2> + </div> + ); +} + +/** Standfirst under a heading. Kept at measure — it is prose, not layout. */ +export function Blurb({ children }: { children: React.ReactNode }) { + return <p className="mt-3 max-w-prose text-base leading-relaxed text-fg-secondary">{children}</p>; +} + +/** + * 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 <div className="mt-6 sm:pl-10">{children}</div>; +} 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<SiteSection, { kind: 'prose' }>; + index?: number; +}) { + return ( + <section> + {section.heading && <SectionHeading index={index}>{section.heading}</SectionHeading>} + <div className={section.heading ? 'mt-6 sm:pl-10' : ''}> + <div className="max-w-prose space-y-4"> + {section.paragraphs.map((paragraph, i) => ( + <p key={i} className="text-base leading-relaxed text-fg-secondary"> + {paragraph} + </p> + ))} + </div> + </div> + </section> + ); +} + +export function CardsSection({ + section, + index, +}: { + section: Extract<SiteSection, { kind: 'cards' }>; + index?: number; +}) { + const columns = section.columns === 3 ? 'lg:grid-cols-3' : 'lg:grid-cols-2'; + return ( + <section> + {section.heading && <SectionHeading index={index}>{section.heading}</SectionHeading>} + {section.blurb && <div className="sm:pl-10">{<Blurb>{section.blurb}</Blurb>}</div>} + <SectionBody> + <div className={`grid grid-cols-1 gap-x-8 gap-y-9 sm:grid-cols-2 ${columns}`}> + {section.cards.map(card => ( + <article key={card.title} className="flex h-full flex-col border-t border-strong pt-4"> + <h3 className="font-heading text-base font-semibold text-fg-primary">{card.title}</h3> + <p className="mt-2 flex-1 text-sm leading-relaxed text-fg-secondary">{card.body}</p> + {card.meta && ( + <p className="mt-4 font-mono text-xs leading-relaxed text-fg-muted">{card.meta}</p> + )} + </article> + ))} + </div> + </SectionBody> + </section> + ); +} + +export function DefinitionsSection({ + section, + index, +}: { + section: Extract<SiteSection, { kind: 'definitions' }>; + index?: number; +}) { + return ( + <section> + {section.heading && <SectionHeading index={index}>{section.heading}</SectionHeading>} + {section.blurb && <div className="sm:pl-10">{<Blurb>{section.blurb}</Blurb>}</div>} + <SectionBody> + <dl className="divide-y divide-subtle border-y border-subtle"> + {section.items.map(item => ( + <div key={item.term} className="grid grid-cols-1 gap-1 py-4 sm:grid-cols-3 sm:gap-6"> + <dt className="text-sm font-semibold text-fg-primary">{item.term}</dt> + <dd className="text-sm leading-relaxed text-fg-secondary sm:col-span-2"> + {item.detail} + </dd> + </div> + ))} + </dl> + </SectionBody> + </section> + ); +} diff --git a/src/config/site-content.ts b/src/config/site-content.ts index b6c95b195..003bdda8c 100644 --- a/src/config/site-content.ts +++ b/src/config/site-content.ts @@ -9,7 +9,7 @@ * instead of drifting into fifty bespoke stylesheets. * * Adding a site is therefore: an entry in `sites.ts`, and a function that - * returns `SitePage[]`. Substrate's lives in `site-substrate.ts` and is built + * returns `SitePage[]`. Substrata Intel's lives in `site-substrata-intel.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. @@ -18,7 +18,7 @@ */ import type { HostedSite } from './sites'; -import { substrateSiteChrome, substrateSitePages } from './site-substrate'; +import { substrataIntelSiteChrome, substrataIntelSitePages } from './site-substrata-intel'; // ===================================================================== // SECTIONS @@ -43,17 +43,45 @@ export interface SiteDefinition { 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; }; @@ -93,8 +121,8 @@ export interface SiteChrome { */ export function sitePagesFor(site: HostedSite): SitePage[] { switch (site.slug) { - case 'substrate': - return substrateSitePages(); + case 'substrataintel': + return substrataIntelSitePages(); default: return []; } @@ -102,13 +130,22 @@ export function sitePagesFor(site: HostedSite): SitePage[] { export function siteChromeFor(site: HostedSite): SiteChrome | null { switch (site.slug) { - case 'substrate': - return substrateSiteChrome(); + case 'substrataintel': + return substrataIntelSiteChrome(); 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, ''); diff --git a/src/config/site-substrate.ts b/src/config/site-substrata-intel.ts similarity index 76% rename from src/config/site-substrate.ts rename to src/config/site-substrata-intel.ts index 1ae15e42a..c1844eaec 100644 --- a/src/config/site-substrate.ts +++ b/src/config/site-substrata-intel.ts @@ -1,8 +1,8 @@ /** - * Substrate's website, generated from Substrate's OrangeCat profile. + * Substrata Intel's website, generated from its OrangeCat profile. * - * Every word and number below comes from `substrate.ts` and - * `substrate-coverage.ts` — the same objects the group profile, the product + * Every word and number below comes from `substrata-intel.ts` and + * `substrata-intel-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 @@ -28,15 +28,15 @@ import { NODE_TYPES, PHASES, formatChf, -} from './substrate'; -import { COVERAGE, PRODUCER_ROLES, coverageProgress } from './substrate-coverage'; +} from './substrata-intel'; +import { COVERAGE, PRODUCER_ROLES, coverageProgress } from './substrata-intel-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 substrateSiteChrome(): SiteChrome { +export function substrataIntelSiteChrome(): SiteChrome { return { name: COMPANY.name, tagline: COMPANY.tagline, @@ -54,14 +54,25 @@ export function substrateSiteChrome(): SiteChrome { function homePage(): SitePage { const progress = coverageProgress(); const activePhase = PHASES.find(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, - intro: COMPANY.tagline, sections: [ - { kind: 'prose', paragraphs: [...LISTING_COPY.body] }, + { + 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', @@ -74,15 +85,26 @@ function homePage(): SitePage { { label: 'Producers identified', value: String(progress.total), - note: `Across ${new Set(COVERAGE.flatMap(e => e.producers.map(p => p.name))).size} distinct companies.`, + note: `Across ${companies} distinct companies.`, }, { - label: 'Producers sourced', - value: `${progress.sourced} of ${progress.total}`, - note: 'A row counts only once a primary source is attached.', + label: 'Desks', + value: String(DESKS.length), + note: 'Five parts of one chain, from feedstock to actuation.', }, ], }, + { + 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', @@ -98,6 +120,7 @@ function homePage(): SitePage { }, { kind: 'definitions', + heading: 'The chokepoint screen', blurb: EXCLUSION_RULE.rule, items: CHOKEPOINT_TEST.map(factor => ({ term: factor.question, @@ -171,17 +194,30 @@ function mandatePage(): SitePage { // 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(); const materialTables: SiteSection[] = COVERAGE.map(entry => ({ kind: 'table' as const, heading: entry.material, + anchor: materialAnchor(entry.material), blurb: 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(', '), + producer.jurisdictions.join(' '), ROLE_LABEL[producer.role] ?? producer.role, producer.source ? 'Sourced' : 'Unverified lead', ]), @@ -203,23 +239,29 @@ function mapPage(): SitePage { '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 count below moves. ' + - 'That count is the honest measure of how far along this is.', + '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: 'stats', - stats: [ - { label: 'Materials covered', value: String(COVERAGE.length) }, - { label: 'Producer rows', value: String(progress.total) }, - { - label: 'Confirmed against a source', - value: `${progress.sourced} of ${progress.total}`, - note: 'Phase 1 completes when these match.', - }, - ], + 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, ], @@ -239,7 +281,7 @@ function deskPage(): SitePage { cards: CATALOGUE.filter(listing => listing.desk === desk.id).map(listing => ({ title: listing.title, body: listing.why, - meta: `${listing.spec} · Indicative CHF ${formatChf(listing.indicativePriceChf)} per ${listing.unit}`, + meta: `CHF ${formatChf(listing.indicativePriceChf)} / ${listing.unit} · ${listing.spec}`, })), })); @@ -323,6 +365,6 @@ function disclosurePage(): SitePage { // ===================================================================== -export function substrateSitePages(): SitePage[] { +export function substrataIntelSitePages(): SitePage[] { return [homePage(), mandatePage(), mapPage(), deskPage(), disclosurePage()]; } diff --git a/src/config/sites.ts b/src/config/sites.ts index ea521b710..c31c2bf4b 100644 --- a/src/config/sites.ts +++ b/src/config/sites.ts @@ -15,11 +15,11 @@ * in HOSTED_SITES plus a content builder — not a new codebase. * * Three ways a request reaches a site, all resolved by `siteForHost`: - * substrate.orangecat.ch — the free subdomain every hosted site starts on - * substrate.example — a custom domain, once the owner points DNS - * substrate.localhost:3020 — local development, so the rewrite is testable + * substrataintel.orangecat.ch — the free subdomain every hosted site starts on + * substrataintel.example — a custom domain, once the owner points DNS + * substrataintel.localhost:3020 — local development, so the rewrite is testable * - * The path form `/sites/substrate` always works too, on any host. That is what + * The path form `/sites/substrataintel` 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. * @@ -51,11 +51,11 @@ export interface HostedSite { export const HOSTED_SITES: readonly HostedSite[] = [ { - slug: 'substrate', - subdomain: 'substrate', + slug: 'substrataintel', + subdomain: 'substrataintel', customDomain: null, - title: 'Substrate', - profile: { kind: 'group', slug: 'substrate' }, + title: 'Substrata Intel', + profile: { kind: 'group', slug: 'substrataintel' }, }, ]; diff --git a/src/config/substrate-coverage.ts b/src/config/substrata-intel-coverage.ts similarity index 98% rename from src/config/substrate-coverage.ts rename to src/config/substrata-intel-coverage.ts index f256ddd57..056a12ec7 100644 --- a/src/config/substrate-coverage.ts +++ b/src/config/substrata-intel-coverage.ts @@ -2,7 +2,7 @@ * Substrate — Phase 1 coverage universe: the producers of the fifteen. * * The firm's first research product. For every material on the desk - * (`substrate.ts` → CATALOGUE), the set of companies that mine, refine, + * (`substrata-intel.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. @@ -29,7 +29,7 @@ * Created: 2026-08-26 */ -import { CATALOGUE } from './substrate'; +import { CATALOGUE } from './substrata-intel'; // ===================================================================== // SHAPE @@ -72,7 +72,7 @@ export interface Producer { } export interface MaterialCoverage { - /** Must exactly match a CATALOGUE title in `substrate.ts`. */ + /** Must exactly match a CATALOGUE title in `substrata-intel.ts`. */ material: string; /** What makes this material a chokepoint, in one line — the research thesis. */ thesis: string; diff --git a/src/config/substrate.ts b/src/config/substrata-intel.ts similarity index 98% rename from src/config/substrate.ts rename to src/config/substrata-intel.ts index 9b77c5467..294261a0a 100644 --- a/src/config/substrate.ts +++ b/src/config/substrata-intel.ts @@ -1,5 +1,5 @@ /** - * "Substrate" — OrangeCat-side SSOT + * "Substrata Intel" — OrangeCat-side SSOT * * An open-source research firm covering the chokepoints between here and a * technological singularity, with a trading desk on the materials it knows @@ -17,8 +17,8 @@ * 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 `substrate-coverage.ts`. The - * seed that registers the firm on-platform (scripts/seed-substrate.ts) reads + * Phase 1 coverage universe lives next door in `substrata-intel-coverage.ts`. The + * seed that registers the firm on-platform (scripts/seed-substrata-intel.ts) reads * these two files and nothing else. * * On-platform shape: a `group` with label 'company' (public, so the research @@ -42,9 +42,9 @@ export const FOUNDER_ACTOR_SLUG = 'mao'; // ===================================================================== export const COMPANY = { - name: 'Substrate', - /** Also the actor slug and the public path: /groups/substrate */ - slug: 'substrate', + name: 'Substrata Intel', + /** Also the actor slug and the public path: /groups/substrataintel */ + slug: 'substrataintel', tagline: 'The chokepoints between here and the singularity, written down in public.', } as const; @@ -296,7 +296,7 @@ export const DESKS: readonly Desk[] = [ // one of these markets prices that way. The unit is carried in the copy, since // `user_products` has no unit column. // -// This list is also the Phase 1 work queue: `substrate-coverage.ts` owes a +// This list is also the Phase 1 work queue: `substrata-intel-coverage.ts` owes a // producer map to every title here, and a test enforces the correspondence. // ===================================================================== @@ -523,7 +523,7 @@ export const LISTING_COPY = { headline: COMPANY.name, subhead: COMPANY.tagline, body: [ - 'Substrate is an open-source research firm covering the physical ' + + 'Substrata Intel is an open-source research firm covering the physical ' + 'chokepoints between here and a technological singularity — and a ' + 'trading desk on the materials it knows best. The research is free. ' + 'The map is the product.', diff --git a/src/middleware.ts b/src/middleware.ts index 65d0f3f7f..9947bd058 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -39,7 +39,7 @@ export async function middleware(request: NextRequest) { // 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 substrate.orangecat.ch, which is the entire point of + // bar must keep saying substrataintel.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')); From f91ec2d3f9f5f95c088413e38aee1107dde18da7 Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Wed, 26 Aug 2026 18:23:37 +0000 Subject: [PATCH 06/17] refactor(sites): the firm is Substrata, at substrata.orangecat.ch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "Intel" was only ever a workaround for substrata.com being taken. On our own subdomain there is no scarcity to work around, so the name is just Substrata — config files, slug, subdomain and site all follow, and the wordmark is one word again. ORANGECAT.COM IS NOT OURS — DNS, not opinion: orangecat.ch -> 167.233.22.31 (bitbaum, this platform) orangecat.com -> 222.122.39.84 (KRNIC space, unrelated party) So substrata.orangecat.com cannot be built: it would point a customer site at somebody else's domain. The site is on .ch, where the CD, Caddy and every line of the /domains copy already live. No multi-base-domain plumbing was added for a .com we do not own — that is a constant to change on the day it becomes true, not an abstraction to carry until then. Also: the domain-search placeholder was one customer's name. It is "yourname" now, matching the /domains copy. Verified against a running server — substrata.orangecat.ch and substrata.localhost both rewrite, the tab reads "Substrata", the retired substrataintel host correctly no longer resolves as a site, and orangecat.ch itself is untouched. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GF9GDWaBWYHCZ9iNwmfZ41 --- __tests__/unit/config/hosted-sites.test.ts | 36 +++++++++---------- ...strata-intel.test.ts => substrata.test.ts} | 4 +-- ...d-substrata-intel.ts => seed-substrata.ts} | 4 +-- src/components/domains/DomainSearch.tsx | 2 +- src/components/layout/AppShell.tsx | 2 +- src/config/site-content.ts | 12 +++---- ...e-substrata-intel.ts => site-substrata.ts} | 14 ++++---- src/config/sites.ts | 16 ++++----- ...ntel-coverage.ts => substrata-coverage.ts} | 6 ++-- .../{substrata-intel.ts => substrata.ts} | 16 ++++----- src/middleware.ts | 2 +- 11 files changed, 57 insertions(+), 57 deletions(-) rename __tests__/unit/config/{substrata-intel.test.ts => substrata.test.ts} (99%) rename scripts/{seed-substrata-intel.ts => seed-substrata.ts} (98%) rename src/config/{site-substrata-intel.ts => site-substrata.ts} (97%) rename src/config/{substrata-intel-coverage.ts => substrata-coverage.ts} (98%) rename src/config/{substrata-intel.ts => substrata.ts} (98%) diff --git a/__tests__/unit/config/hosted-sites.test.ts b/__tests__/unit/config/hosted-sites.test.ts index 1c9e6c1fd..212b4760f 100644 --- a/__tests__/unit/config/hosted-sites.test.ts +++ b/__tests__/unit/config/hosted-sites.test.ts @@ -21,20 +21,20 @@ import { siteChromeFor, } from '@/config/site-content'; import { getRouteSurface } from '@/config/routes'; -import { CATALOGUE, COMPANY, MANDATE_CURVES } from '@/config/substrata-intel'; -import { COVERAGE, coverageProgress } from '@/config/substrata-intel-coverage'; +import { CATALOGUE, COMPANY, MANDATE_CURVES } from '@/config/substrata'; +import { COVERAGE, coverageProgress } from '@/config/substrata-coverage'; -const site = siteBySlug('substrataintel'); +const site = siteBySlug('substrata'); describe('hosted sites — host resolution', () => { it('resolves the free subdomain, with or without www and port', () => { - expect(siteForHost('substrataintel.orangecat.ch')?.slug).toBe('substrataintel'); - expect(siteForHost('www.substrataintel.orangecat.ch')?.slug).toBe('substrataintel'); - expect(siteForHost('SubstrataIntel.OrangeCat.ch:443')?.slug).toBe('substrataintel'); + 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('substrataintel.localhost:3020')?.slug).toBe('substrataintel'); + expect(siteForHost('substrata.localhost:3020')?.slug).toBe('substrata'); }); it('refuses everything else — a greedy match would swallow OrangeCat itself', () => { @@ -42,9 +42,9 @@ describe('hosted sites — host resolution', () => { 'orangecat.ch', 'www.orangecat.ch', 'localhost:3000', - 'substrataintel.evil.example', - 'notsubstrataintel.orangecat.ch', - 'substrataintel.orangecat.ch.evil.example', + 'substrata.evil.example', + 'notsubstrata.orangecat.ch', + 'substrata.orangecat.ch.evil.example', '', null, undefined, @@ -64,24 +64,24 @@ describe('hosted sites — host resolution', () => { describe('hosted sites — links', () => { it('always emits the path form, which resolves on every host', () => { expect(site).not.toBeNull(); - expect(siteHref(site!)).toBe('/sites/substrataintel'); - expect(siteHref(site!, 'map')).toBe('/sites/substrataintel/map'); - expect(siteHref(site!, '/map')).toBe('/sites/substrataintel/map'); - expect(siteHref(site!, '/')).toBe('/sites/substrataintel'); + 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/substrataintel')).toBe('site'); - expect(getRouteSurface('/sites/substrataintel/map')).toBe('site'); + 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/substrataintel')).toBe('app'); + expect(getRouteSurface('/groups/substrata')).toBe('app'); }); }); @@ -114,7 +114,7 @@ describe('hosted sites — every site renders', () => { }); }); -describe('substrataintel.orangecat.ch — the site is the profile, not a copy of it', () => { +describe('substrata.orangecat.ch — the site is the profile, not a copy of it', () => { const pages = sitePagesFor(site!); const text = JSON.stringify(pages); diff --git a/__tests__/unit/config/substrata-intel.test.ts b/__tests__/unit/config/substrata.test.ts similarity index 99% rename from __tests__/unit/config/substrata-intel.test.ts rename to __tests__/unit/config/substrata.test.ts index 3fb39fc3f..4ef95e1ef 100644 --- a/__tests__/unit/config/substrata-intel.test.ts +++ b/__tests__/unit/config/substrata.test.ts @@ -30,13 +30,13 @@ import { PRODUCT_PAYLOADS, deskFor, formatChf, -} from '@/config/substrata-intel'; +} from '@/config/substrata'; import { COVERAGE, PRODUCER_ROLES, coverageProgress, materialsFor, -} from '@/config/substrata-intel-coverage'; +} 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'; diff --git a/scripts/seed-substrata-intel.ts b/scripts/seed-substrata.ts similarity index 98% rename from scripts/seed-substrata-intel.ts rename to scripts/seed-substrata.ts index 89a4d599e..d47f3de87 100644 --- a/scripts/seed-substrata-intel.ts +++ b/scripts/seed-substrata.ts @@ -15,7 +15,7 @@ * Owner-gated so it can't fire by accident. * * Run against the LIVE self-hosted DB (supabase.orangecat.ch) from the box: - * ORANGECAT_OWNER_SEED=1 npx tsx scripts/seed-substrata-intel.ts + * ORANGECAT_OWNER_SEED=1 npx tsx scripts/seed-substrata.ts * * Requires in the environment (already in .env.local on the box): * NEXT_PUBLIC_SUPABASE_URL — self-hosted Supabase URL @@ -33,7 +33,7 @@ import { GROUP_PAYLOAD, PRODUCT_PAYLOADS, type MaterialProductPayload, -} from '../src/config/substrata-intel'; +} from '../src/config/substrata'; loadEnv({ path: '.env.local' }); diff --git a/src/components/domains/DomainSearch.tsx b/src/components/domains/DomainSearch.tsx index cb28baa75..143fbe974 100644 --- a/src/components/domains/DomainSearch.tsx +++ b/src/components/domains/DomainSearch.tsx @@ -85,7 +85,7 @@ export function DomainSearch() { type="text" value={query} onChange={event => setQuery(event.target.value)} - placeholder="substrataintel" + 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" /> diff --git a/src/components/layout/AppShell.tsx b/src/components/layout/AppShell.tsx index eb3c3faaa..d0f0cbdc1 100644 --- a/src/components/layout/AppShell.tsx +++ b/src/components/layout/AppShell.tsx @@ -113,7 +113,7 @@ export function AppShell({ children }: AppShellProps) { const filteredSections = getFilteredSections(); // Hosted site: no OrangeCat chrome whatsoever, and no message-sync manager - // either — a visitor to substrataintel.orangecat.ch has no OrangeCat session and + // 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 ( diff --git a/src/config/site-content.ts b/src/config/site-content.ts index 003bdda8c..2427f4128 100644 --- a/src/config/site-content.ts +++ b/src/config/site-content.ts @@ -9,7 +9,7 @@ * instead of drifting into fifty bespoke stylesheets. * * Adding a site is therefore: an entry in `sites.ts`, and a function that - * returns `SitePage[]`. Substrata Intel's lives in `site-substrata-intel.ts` and is built + * 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. @@ -18,7 +18,7 @@ */ import type { HostedSite } from './sites'; -import { substrataIntelSiteChrome, substrataIntelSitePages } from './site-substrata-intel'; +import { substrataSiteChrome, substrataSitePages } from './site-substrata'; // ===================================================================== // SECTIONS @@ -121,8 +121,8 @@ export interface SiteChrome { */ export function sitePagesFor(site: HostedSite): SitePage[] { switch (site.slug) { - case 'substrataintel': - return substrataIntelSitePages(); + case 'substrata': + return substrataSitePages(); default: return []; } @@ -130,8 +130,8 @@ export function sitePagesFor(site: HostedSite): SitePage[] { export function siteChromeFor(site: HostedSite): SiteChrome | null { switch (site.slug) { - case 'substrataintel': - return substrataIntelSiteChrome(); + case 'substrata': + return substrataSiteChrome(); default: return null; } diff --git a/src/config/site-substrata-intel.ts b/src/config/site-substrata.ts similarity index 97% rename from src/config/site-substrata-intel.ts rename to src/config/site-substrata.ts index c1844eaec..ef36b8c7a 100644 --- a/src/config/site-substrata-intel.ts +++ b/src/config/site-substrata.ts @@ -1,8 +1,8 @@ /** - * Substrata Intel's website, generated from its OrangeCat profile. + * Substrata's website, generated from its OrangeCat profile. * - * Every word and number below comes from `substrata-intel.ts` and - * `substrata-intel-coverage.ts` — the same objects the group profile, the product + * 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 @@ -28,15 +28,15 @@ import { NODE_TYPES, PHASES, formatChf, -} from './substrata-intel'; -import { COVERAGE, PRODUCER_ROLES, coverageProgress } from './substrata-intel-coverage'; +} from './substrata'; +import { COVERAGE, PRODUCER_ROLES, 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 substrataIntelSiteChrome(): SiteChrome { +export function substrataSiteChrome(): SiteChrome { return { name: COMPANY.name, tagline: COMPANY.tagline, @@ -365,6 +365,6 @@ function disclosurePage(): SitePage { // ===================================================================== -export function substrataIntelSitePages(): SitePage[] { +export function substrataSitePages(): SitePage[] { return [homePage(), mandatePage(), mapPage(), deskPage(), disclosurePage()]; } diff --git a/src/config/sites.ts b/src/config/sites.ts index c31c2bf4b..46873a5a7 100644 --- a/src/config/sites.ts +++ b/src/config/sites.ts @@ -15,11 +15,11 @@ * in HOSTED_SITES plus a content builder — not a new codebase. * * Three ways a request reaches a site, all resolved by `siteForHost`: - * substrataintel.orangecat.ch — the free subdomain every hosted site starts on - * substrataintel.example — a custom domain, once the owner points DNS - * substrataintel.localhost:3020 — local development, so the rewrite is testable + * 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/substrataintel` always works too, on any host. That is what + * 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. * @@ -51,11 +51,11 @@ export interface HostedSite { export const HOSTED_SITES: readonly HostedSite[] = [ { - slug: 'substrataintel', - subdomain: 'substrataintel', + slug: 'substrata', + subdomain: 'substrata', customDomain: null, - title: 'Substrata Intel', - profile: { kind: 'group', slug: 'substrataintel' }, + title: 'Substrata', + profile: { kind: 'group', slug: 'substrata' }, }, ]; diff --git a/src/config/substrata-intel-coverage.ts b/src/config/substrata-coverage.ts similarity index 98% rename from src/config/substrata-intel-coverage.ts rename to src/config/substrata-coverage.ts index 056a12ec7..93326323b 100644 --- a/src/config/substrata-intel-coverage.ts +++ b/src/config/substrata-coverage.ts @@ -2,7 +2,7 @@ * Substrate — Phase 1 coverage universe: the producers of the fifteen. * * The firm's first research product. For every material on the desk - * (`substrata-intel.ts` → CATALOGUE), the set of companies that mine, refine, + * (`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. @@ -29,7 +29,7 @@ * Created: 2026-08-26 */ -import { CATALOGUE } from './substrata-intel'; +import { CATALOGUE } from './substrata'; // ===================================================================== // SHAPE @@ -72,7 +72,7 @@ export interface Producer { } export interface MaterialCoverage { - /** Must exactly match a CATALOGUE title in `substrata-intel.ts`. */ + /** 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; diff --git a/src/config/substrata-intel.ts b/src/config/substrata.ts similarity index 98% rename from src/config/substrata-intel.ts rename to src/config/substrata.ts index 294261a0a..dfc63a1ca 100644 --- a/src/config/substrata-intel.ts +++ b/src/config/substrata.ts @@ -1,5 +1,5 @@ /** - * "Substrata Intel" — OrangeCat-side SSOT + * "Substrata" — OrangeCat-side SSOT * * An open-source research firm covering the chokepoints between here and a * technological singularity, with a trading desk on the materials it knows @@ -17,8 +17,8 @@ * 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-intel-coverage.ts`. The - * seed that registers the firm on-platform (scripts/seed-substrata-intel.ts) reads + * 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 @@ -42,9 +42,9 @@ export const FOUNDER_ACTOR_SLUG = 'mao'; // ===================================================================== export const COMPANY = { - name: 'Substrata Intel', - /** Also the actor slug and the public path: /groups/substrataintel */ - slug: 'substrataintel', + 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; @@ -296,7 +296,7 @@ export const DESKS: readonly Desk[] = [ // one of these markets prices that way. The unit is carried in the copy, since // `user_products` has no unit column. // -// This list is also the Phase 1 work queue: `substrata-intel-coverage.ts` owes a +// 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. // ===================================================================== @@ -523,7 +523,7 @@ export const LISTING_COPY = { headline: COMPANY.name, subhead: COMPANY.tagline, body: [ - 'Substrata Intel is an open-source research firm covering the physical ' + + 'Substrata is an open-source research firm covering the physical ' + 'chokepoints between here and a technological singularity — and a ' + 'trading desk on the materials it knows best. The research is free. ' + 'The map is the product.', diff --git a/src/middleware.ts b/src/middleware.ts index 9947bd058..731bd102c 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -39,7 +39,7 @@ export async function middleware(request: NextRequest) { // 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 substrataintel.orangecat.ch, which is the entire point of + // 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')); From 3b572577bdea51eaa7d6ff742ac24b13accf9c5f Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Wed, 26 Aug 2026 18:40:37 +0000 Subject: [PATCH 07/17] feat(substrata): no trading desk, and chokepoints that are not materials MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two corrections, both of which the site was getting wrong. THE DESK DOES NOT EXIST Standing a regulated commodities book up is a long road through licensing, and none of it has been walked. Until then, prices, units, lot sizes and invitations to deal are advertising a capability the firm does not have — which is the single biggest untruth a page can carry, and the one everything else here was built to prevent. So the desk page is gone, along with every price, every unit, and the product listings the seed wrote. The fifteen materials remain as research subjects, keeping the part that was always research — why each gates a curve, and which grade actually ships — folded into the map where it belongs. The group advertises no marketplace, because there is nothing to sell. `SCOPE.today` says plainly: we publish research, we do not trade, broker, quote or arrange movement, and nothing here is an offer or advice. Disclosure keeps its rules even though there is nothing to declare — a disclosure policy is only credible before it is needed — and now opens by saying so. A desk survives only as a phase marked not-started, described as an intention rather than a service. Two tests keep it that way: one asserts a material carries no price, unit or lot-size field, and one greps the config and the site builder for dealing language. A config that quietly regrows a price field is a config that puts an offer in front of a reader nobody may sell to. MATERIALS ARE NOT THE ONLY CHOKEPOINT The node taxonomy admitted machines, processes, companies and people from the start, and then the universe contained nothing but materials. Fourteen non-material chokepoints now sit alongside them: EUV scanners with one supplier, projection optics that are a chokepoint inside a chokepoint, packaging capacity allocated years ahead, HBM stacking yield, transformer slots, interconnection queues, turbine order books, magnet sintering, precision drives — and process engineers, the constraint nobody can buy. They enter on the same two tests and carry the same claim limits: what the node is and why it gates, nothing about capacity, share or price, every row unverified until sourced. A test now fails if the universe ever collapses back to one kind of node, so "a node can be anything" stays a fact about the coverage rather than a line in the config. Rendered as cards, not a table: a grid of names and country codes scans nicely and says nothing, and the claim IS the reason it gates. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GF9GDWaBWYHCZ9iNwmfZ41 --- __tests__/unit/config/hosted-sites.test.ts | 36 ++- __tests__/unit/config/substrata.test.ts | 164 +++++----- scripts/seed-substrata.ts | 62 +--- src/config/site-substrata.ts | 213 ++++++++----- src/config/substrata-coverage.ts | 174 +++++++++- src/config/substrata.ts | 353 ++++++++------------- 6 files changed, 561 insertions(+), 441 deletions(-) diff --git a/__tests__/unit/config/hosted-sites.test.ts b/__tests__/unit/config/hosted-sites.test.ts index 212b4760f..50c9fd467 100644 --- a/__tests__/unit/config/hosted-sites.test.ts +++ b/__tests__/unit/config/hosted-sites.test.ts @@ -21,8 +21,8 @@ import { siteChromeFor, } from '@/config/site-content'; import { getRouteSurface } from '@/config/routes'; -import { CATALOGUE, COMPANY, MANDATE_CURVES } from '@/config/substrata'; -import { COVERAGE, coverageProgress } from '@/config/substrata-coverage'; +import { COMPANY, MANDATE_CURVES, MATERIALS } from '@/config/substrata'; +import { CHOKEPOINTS, COVERAGE, coverageProgress } from '@/config/substrata-coverage'; const site = siteBySlug('substrata'); @@ -124,9 +124,9 @@ describe('substrata.orangecat.ch — the site is the profile, not a copy of it', expect(chrome?.tagline).toBe(COMPANY.tagline); }); - it('renders every material on the desk, from the same catalogue the profile lists', () => { - for (const listing of CATALOGUE) { - expect(text).toContain(listing.title); + it('renders every material under coverage, from the same config the profile uses', () => { + for (const material of MATERIALS) { + expect(text).toContain(material.title); } }); @@ -188,8 +188,28 @@ describe('substrata.orangecat.ch — the site is the profile, not a copy of it', expect(pageRendersOwnHeader(sitePageAt(site!, 'map')!)).toBe(false); }); - it('says on the desk page that prices are indicative rather than quotes', () => { - const deskPage = sitePageAt(site!, 'desk'); - expect(JSON.stringify(deskPage)).toContain('not a quote'); + 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.test.ts b/__tests__/unit/config/substrata.test.ts index 4ef95e1ef..3a2de57ab 100644 --- a/__tests__/unit/config/substrata.test.ts +++ b/__tests__/unit/config/substrata.test.ts @@ -16,24 +16,28 @@ * claims the profile makes in public; a test is what keeps them true. */ +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; import { - CATALOGUE, CHOKEPOINT_TEST, COMPANY, - DESKS, + COVERAGE_AREAS, DISCLOSURE, GROUP_FEATURE_KEYS, GROUP_PAYLOAD, MANDATE_CURVES, + MATERIALS, NODE_TYPES, PHASES, - PRODUCT_PAYLOADS, - deskFor, - formatChf, + SCOPE, + areaFor, } from '@/config/substrata'; import { + CHOKEPOINTS, COVERAGE, + NODE_TYPE_LABEL, PRODUCER_ROLES, + chokepointProgress, coverageProgress, materialsFor, } from '@/config/substrata-coverage'; @@ -49,7 +53,7 @@ const FULFILLMENT_TYPES = ['manual', 'automatic', 'digital']; const PRODUCT_STATUSES = ['draft', 'active', 'paused', 'sold_out']; const GROUP_VISIBILITIES = ['public', 'members_only', 'private']; -describe('Substrate — group profile payload', () => { +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); @@ -82,73 +86,102 @@ describe('Substrate — group profile payload', () => { }); }); -describe('Substrate — the desk', () => { - it('lists something', () => { - expect(PRODUCT_PAYLOADS.length).toBe(CATALOGUE.length); - expect(PRODUCT_PAYLOADS.length).toBeGreaterThan(0); - }); +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('has unique titles, since the seed keys idempotency on (actor_id, title)', () => { - const titles = PRODUCT_PAYLOADS.map(p => p.title); - expect(new Set(titles).size).toBe(titles.length); + 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.each(PRODUCT_PAYLOADS.map(p => [p.title, p] as const))( - '%s satisfies every user_products CHECK constraint', - (_title, payload) => { - expect(PRODUCT_CURRENCIES).toContain(payload.currency); - expect(PRODUCT_TYPES).toContain(payload.product_type); - expect(FULFILLMENT_TYPES).toContain(payload.fulfillment_type); - expect(PRODUCT_STATUSES).toContain(payload.status); - expect(payload.price).toBeGreaterThan(0); + 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('prices fit numeric(20,8) — no value the column would silently round', () => { - for (const payload of PRODUCT_PAYLOADS) { - const decimals = (String(payload.price).split('.')[1] ?? '').length; - expect(decimals).toBeLessThanOrEqual(8); - expect(String(Math.trunc(payload.price)).length).toBeLessThanOrEqual(12); - } + 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('says the price is indicative, so no listing reads as a firm quote', () => { - for (const payload of PRODUCT_PAYLOADS) { - expect(payload.description).toContain('Indicative reference'); - expect(payload.description).toContain('not a quote'); - } + 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('Substrate — the mandate is enforced, not just stated', () => { +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 desk of DESKS) { - expect(curveIds).toContain(desk.curve); + for (const area of COVERAGE_AREAS) { + expect(curveIds).toContain(area.curve); } }); - it('every listed material sits on a desk, and so on a curve', () => { - for (const listing of CATALOGUE) { - const desk = deskFor(listing); - expect(desk).toBeDefined(); - expect(desk.id).toBe(listing.desk); + 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('every listing is filed under its desk name, which is the product category', () => { - const deskNames = DESKS.map(desk => desk.name); - for (const payload of PRODUCT_PAYLOADS) { - expect(deskNames).toContain(payload.category); + 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('no desk is left without a listing — an empty desk is scope creep on paper', () => { - for (const desk of DESKS) { - expect(CATALOGUE.some(listing => listing.desk === desk.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. @@ -157,8 +190,10 @@ describe('Substrate — the mandate is enforced, not just stated', () => { ); }); - it('runs exactly one phase at a time — sequencing is the whole point', () => { - expect(PHASES.filter(phase => phase.status === 'active')).toHaveLength(1); + 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', () => { @@ -166,15 +201,15 @@ describe('Substrate — the mandate is enforced, not just stated', () => { }); }); -describe('Substrate — Phase 1 coverage universe', () => { +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 the desk does not trade — coverage follows the book', () => { - const traded = CATALOGUE.map(listing => listing.title); + it('covers no material outside the list — coverage follows the universe', () => { + const covered = MATERIALS.map(material => material.title); for (const entry of COVERAGE) { - expect(traded).toContain(entry.material); + expect(covered).toContain(entry.material); } }); @@ -237,22 +272,3 @@ describe('Substrate — Phase 1 coverage universe', () => { expect(overlapping.length).toBeGreaterThan(0); }); }); - -describe('Substrate — price formatting', () => { - it('groups thousands, so a five-figure quote cannot be misread', () => { - expect(formatChf(15000)).toBe('15’000'); - expect(formatChf(1400)).toBe('1’400'); - expect(formatChf(3.2)).toBe('3.2'); - expect(formatChf(95)).toBe('95'); - expect(formatChf(1234567)).toBe('1’234’567'); - }); - - it('formats every listed price the same way wherever it is shown', () => { - for (const listing of CATALOGUE) { - const payload = PRODUCT_PAYLOADS.find(p => p.title === listing.title); - expect(payload?.description).toContain( - `CHF ${formatChf(listing.indicativePriceChf)} per ${listing.unit}` - ); - } - }); -}); diff --git a/scripts/seed-substrata.ts b/scripts/seed-substrata.ts index d47f3de87..b6ae7f424 100644 --- a/scripts/seed-substrata.ts +++ b/scripts/seed-substrata.ts @@ -10,9 +10,8 @@ * 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, membership and features by their unique keys, - * and each listing by (actor_id, title). Never truncates. Safe to re-run. - * Owner-gated so it can't fire by accident. + * 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. * * Run against the LIVE self-hosted DB (supabase.orangecat.ch) from the box: * ORANGECAT_OWNER_SEED=1 npx tsx scripts/seed-substrata.ts @@ -31,8 +30,6 @@ import { FOUNDER_ACTOR_SLUG, GROUP_FEATURE_KEYS, GROUP_PAYLOAD, - PRODUCT_PAYLOADS, - type MaterialProductPayload, } from '../src/config/substrata'; loadEnv({ path: '.env.local' }); @@ -175,53 +172,6 @@ async function ensureFeatures(groupId: string, founder: FounderRow): Promise<voi console.log(` ✓ features: ${GROUP_FEATURE_KEYS.join(', ')}`); } -/** Idempotently write one catalogue listing, matched on (actor_id, title). */ -async function upsertListing( - groupId: string, - groupActorId: string, - founder: FounderRow, - listing: MaterialProductPayload -): Promise<void> { - const { data: existing, error: probeErr } = await admin - .from('user_products') - .select('id') - .eq('actor_id', groupActorId) - .eq('title', listing.title) - .maybeSingle(); - if (probeErr) die(`Failed to look up listing '${listing.title}': ${probeErr.message}`); - - const row = { - // user_products.user_id is NOT NULL and records who acts; ownership for - // every read path is actor_id, which points at the group. - user_id: founder.user_id, - actor_id: groupActorId, - group_id: groupId, - title: listing.title, - description: listing.description, - price: listing.price, - currency: listing.currency, - product_type: listing.product_type, - fulfillment_type: listing.fulfillment_type, - category: listing.category, - status: listing.status, - tags: listing.tags, - inventory_count: listing.inventory_count, - show_on_profile: listing.show_on_profile, - is_test: false, - }; - - if (existing) { - const { error } = await admin.from('user_products').update(row).eq('id', existing.id); - if (error) die(`Failed to update listing '${listing.title}': ${error.message}`); - console.log(` ↻ ${listing.title}`); - return; - } - - const { error } = await admin.from('user_products').insert(row); - if (error) die(`Failed to insert listing '${listing.title}': ${error.message}`); - console.log(` + ${listing.title}`); -} - async function main(): Promise<void> { console.log(`Seeding "${COMPANY.name}" against ${SUPABASE_URL} …`); const founder = await resolveFounder(); @@ -232,11 +182,9 @@ async function main(): Promise<void> { await ensureFounderMembership(groupId, founder); await ensureFeatures(groupId, founder); - console.log(`catalogue (${PRODUCT_PAYLOADS.length} listings):`); - for (const listing of PRODUCT_PAYLOADS) { - await upsertListing(groupId, groupActorId, founder, listing); - } - + // 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}.`); } diff --git a/src/config/site-substrata.ts b/src/config/site-substrata.ts index ef36b8c7a..a14ed0b9e 100644 --- a/src/config/site-substrata.ts +++ b/src/config/site-substrata.ts @@ -16,20 +16,26 @@ */ import { - CATALOGUE, CHOKEPOINT_TEST, COMPANY, - COMPLIANCE, - DESKS, DISCLOSURE, EXCLUSION_RULE, LISTING_COPY, MANDATE_CURVES, + MATERIALS, NODE_TYPES, PHASES, - formatChf, + SCOPE, + areaFor, } from './substrata'; -import { COVERAGE, PRODUCER_ROLES, coverageProgress } from './substrata-coverage'; +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( @@ -41,9 +47,9 @@ export function substrataSiteChrome(): SiteChrome { name: COMPANY.name, tagline: COMPANY.tagline, footerNote: - `${COMPANY.name} publishes its research openly and trades the materials it covers. ` + - 'Every note states the firm’s position at time of publication. Listed prices are ' + - 'indicative reference levels, not quotes.', + `${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.', }; } @@ -53,7 +59,7 @@ export function substrataSiteChrome(): SiteChrome { function homePage(): SitePage { const progress = coverageProgress(); - const activePhase = PHASES.find(phase => phase.status === 'active'); + const activePhases = PHASES.filter(phase => phase.status === 'active'); const companies = new Set(COVERAGE.flatMap(entry => entry.producers.map(p => p.name))).size; return { @@ -78,8 +84,8 @@ function homePage(): SitePage { heading: 'Where the research stands', stats: [ { - label: 'Materials on the desk', - value: String(CATALOGUE.length), + label: 'Materials covered', + value: String(MATERIALS.length), note: 'Each one a chokepoint, not a commodity.', }, { @@ -88,9 +94,9 @@ function homePage(): SitePage { note: `Across ${companies} distinct companies.`, }, { - label: 'Desks', - value: String(DESKS.length), - note: 'Five parts of one chain, from feedstock to actuation.', + label: 'Non-material chokepoints', + value: String(CHOKEPOINTS.length), + note: 'Tools, capacity, queues and know-how that gate the same curves.', }, ], }, @@ -128,9 +134,12 @@ function homePage(): SitePage { })), }, { - kind: 'prose', - heading: activePhase ? activePhase.label : 'Current phase', - paragraphs: activePhase ? [activePhase.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 })), }, ], }; @@ -205,23 +214,33 @@ function materialAnchor(material: string): string { function mapPage(): SitePage { const progress = coverageProgress(); - const materialTables: SiteSection[] = COVERAGE.map(entry => ({ - kind: 'table' as const, - heading: entry.material, - anchor: materialAnchor(entry.material), - blurb: 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', - ]), - })); + // 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', @@ -269,50 +288,78 @@ function mapPage(): SitePage { } // ===================================================================== -// THE DESK +// CHOKEPOINTS BEYOND MATERIALS // ===================================================================== -function deskPage(): SitePage { - const deskSections: SiteSection[] = DESKS.map(desk => ({ +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: desk.name, - blurb: desk.covers, + heading: curve.label, + blurb: curve.detail, columns: 2 as const, - cards: CATALOGUE.filter(listing => listing.desk === desk.id).map(listing => ({ - title: listing.title, - body: listing.why, - meta: `CHF ${formatChf(listing.indicativePriceChf)} / ${listing.unit} · ${listing.spec}`, + 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: 'desk', - navLabel: 'The desk', - title: 'The desk', - intro: 'The materials we trade, and what we will tell you about them before you ask.', + path: 'chokepoints', + navLabel: 'Chokepoints', + title: 'Chokepoints', + intro: 'The constraints that are not materials — and often are the tighter ones.', sections: [ { kind: 'prose', paragraphs: [ - 'Fifteen materials, five desks, one book. Every line here passed the same two tests ' + - 'the research universe uses, which is why the desk and the map cover exactly the ' + - 'same ground — the sourcing work and the research work are the same work.', - 'Prices shown are indicative reference levels in Swiss francs for the stated unit: ' + - 'the number to size a budget with, not a quote. Firm pricing is by RFQ against ' + - 'grade, lot size, origin and delivery window, because every one of these markets ' + - 'prices that way. Settlement in Bitcoin or in francs, your choice.', + '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.', ], }, - ...deskSections, { - kind: 'definitions', - heading: 'Before you send an RFQ', - items: COMPLIANCE.screening.map((rule, index) => ({ - term: `Screening ${index + 1}`, - detail: rule, - })), + 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: 'prose', paragraphs: [COMPLIANCE.outOfScope] }, + { + 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, ], }; } @@ -326,39 +373,39 @@ function disclosurePage(): SitePage { path: 'disclosure', navLabel: 'Disclosure', title: 'Disclosure', - intro: 'We publish research on materials we trade. Here is how that is kept straight.', + 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: [ - 'A firm that publishes research, trades the same materials, and intends to eventually ' + - 'own parts of the chain is publishing on its own positions. That is a workable ' + - 'model, but only with the rule written before the first position exists — ' + - 'afterwards, every rule looks like a response to something. So it was written on ' + - 'day one, in the same file as the mandate.', - DISCLOSURE.openByDefault, + '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', + heading: 'The rules, when there is something to disclose', items: DISCLOSURE.rules.map((rule, index) => ({ term: `Rule ${index + 1}`, detail: rule, })), }, - { - kind: 'definitions', - heading: 'Trade compliance', - blurb: - 'Several of these materials are dual-use and export-controlled. The focus itself is ' + - 'the first control: the mandate admits compute, energy and actuation, and nothing else.', - items: COMPLIANCE.screening.map((rule, index) => ({ - term: `Control ${index + 1}`, - detail: rule, - })), - }, - { kind: 'prose', heading: 'Out of scope', paragraphs: [COMPLIANCE.outOfScope] }, + { kind: 'prose', heading: 'Why it is free', paragraphs: [DISCLOSURE.openByDefault] }, + { kind: 'prose', heading: 'Out of scope', paragraphs: [SCOPE.outOfScope] }, ], }; } @@ -366,5 +413,5 @@ function disclosurePage(): SitePage { // ===================================================================== export function substrataSitePages(): SitePage[] { - return [homePage(), mandatePage(), mapPage(), deskPage(), disclosurePage()]; + return [homePage(), mandatePage(), mapPage(), chokepointsPage(), disclosurePage()]; } diff --git a/src/config/substrata-coverage.ts b/src/config/substrata-coverage.ts index 93326323b..1e235508e 100644 --- a/src/config/substrata-coverage.ts +++ b/src/config/substrata-coverage.ts @@ -29,7 +29,7 @@ * Created: 2026-08-26 */ -import { CATALOGUE } from './substrata'; +import { MATERIALS, type CurveId, type NodeType } from './substrata'; // ===================================================================== // SHAPE @@ -320,7 +320,7 @@ export function coverageProgress(): CoverageProgress { return { total: rows.length, sourced: rows.filter(producer => producer.source !== null).length, - uncoveredMaterials: CATALOGUE.map(listing => listing.title).filter( + uncoveredMaterials: MATERIALS.map(material => material.title).filter( title => !covered.has(title) ), }; @@ -332,3 +332,173 @@ export function materialsFor(companyName: string): string[] { 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.ts b/src/config/substrata.ts index dfc63a1ca..c6edd7955 100644 --- a/src/config/substrata.ts +++ b/src/config/substrata.ts @@ -2,17 +2,20 @@ * "Substrata" — OrangeCat-side SSOT * * An open-source research firm covering the chokepoints between here and a - * technological singularity, with a trading desk on the materials it knows - * best. Intelligence is not made of software. It is made of purified tin, - * neon, polysilicon, ruthenium, transformer steel and rare-earth metal — and - * behind each of those is a producer, a lead time and a counterparty that - * almost nobody has written down in public. + * 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 ASSET IS THE MAP, NOT THE BOOK. Trading a commodity and researching a - * robotics company are the same work: knowing who makes what, at what grade, - * on what lead time, and who is dependent on them. So the map is the product; - * the desk is one way to monetise it, and eventually the chain the firm - * intends to integrate is the chain it already mapped. + * 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 @@ -122,11 +125,11 @@ export const CHOKEPOINT_TEST = [ /** * The exclusion rule, stated plainly because it is the harder half of focus. - * A firm that will cover anything has no edge, and a desk that will quote - * anything has no thesis. + * 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 we neither cover it nor quote it.', + 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 ' + @@ -191,55 +194,58 @@ export const PHASES = [ label: 'Phase 1 — the producers of the fifteen', status: 'active', detail: - 'Map every qualified producer of the fifteen materials already on the ' + - 'desk. Mostly private, mostly uncovered: the sell side writes about ' + - 'chip designers, not about who fires crucible-grade quartz. Every ' + - 'profile is simultaneously research and a counterparty for the desk.', + '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: 'one-hop-out', - label: 'Phase 2 — one hop out', - status: 'planned', + id: 'beyond-materials', + label: 'Phase 2 — the chokepoints that are not materials', + status: 'active', detail: - 'From each producer, one hop upstream (their inputs) and one downstream ' + - '(their buyers). This is where robotics, AI hardware and additive ' + - 'manufacturing enter the universe on their own — as counterparties in a ' + - 'chain already being mapped, not as a new vertical.', + '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: 'desk-beyond-commodities', - label: 'Phase 3 — the desk beyond commodities', + id: 'one-hop-out', + label: 'Phase 3 — one hop out', status: 'planned', detail: - 'Apply the same map to equities, private positions and offtake. The ' + - 'research does not change; only the instrument does.', + '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: 'integration', - label: 'Phase 4 — integration', - status: 'planned', + id: 'desk', + label: 'Later — a desk, if and when it is licensed', + status: 'not-started', detail: - 'Take positions in the chain, upstream and downstream. By construction ' + - 'the acquisition pipeline is the coverage universe — you buy into what ' + - 'you already understand better than the seller does.', + '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; // ===================================================================== -// DESKS +// COVERAGE AREAS +// +// Named for segments of the chain, not for trading desks — there is no desk. // ===================================================================== -export type DeskId = 'lithography' | 'feedstock' | 'thermal' | 'power' | 'actuation'; +export type AreaId = 'lithography' | 'feedstock' | 'thermal' | 'power' | 'actuation'; -export interface Desk { - id: DeskId; - /** Also the `category` written onto every listing on this desk. */ +export interface CoverageArea { + id: AreaId; name: string; curve: CurveId; covers: string; } -export const DESKS: readonly Desk[] = [ +export const COVERAGE_AREAS: readonly CoverageArea[] = [ { id: 'lithography', name: 'Lithography & Optics', @@ -288,58 +294,49 @@ export const DESKS: readonly Desk[] = [ ] as const; // ===================================================================== -// LISTED CATALOGUE +// MATERIALS UNDER COVERAGE // -// Prices are INDICATIVE reference levels in CHF for the stated unit — the -// number a counterparty should use to size a budget, not a quote. Real pricing -// is by RFQ against grade, lot size, origin and delivery window, because every -// one of these markets prices that way. The unit is carried in the copy, since -// `user_products` has no unit column. +// 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 { - /** Product title as it appears on the profile, and the coverage key. */ + /** Display name, and the key `substrata-coverage.ts` maps producers onto. */ title: string; - desk: DeskId; - /** Unit the indicative price refers to, e.g. 'kg', 'wafer', 'metre'. */ - unit: string; - /** Indicative reference price in CHF per `unit`. Must be > 0 (DB CHECK). */ - indicativePriceChf: number; - /** Why this material is on the critical path — the reason it is listed. */ + area: AreaId; + /** Why this material gates a curve — the research claim. */ why: string; - /** Grade / form actually traded. */ + /** The grade that actually ships. Naming it is most of the specialism. */ spec: string; tags: string[]; } -export const CATALOGUE: readonly MaterialListing[] = [ +export const MATERIALS: readonly MaterialListing[] = [ // ---------- Lithography & Optics ---------- { title: 'High-purity tin, EUV droplet grade', - desk: 'lithography', - unit: 'kg', - indicativePriceChf: 240, + 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', - desk: 'lithography', - unit: 'm³', - indicativePriceChf: 120, + 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', - desk: 'lithography', - unit: 'kg', - indicativePriceChf: 15000, + 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'], @@ -348,36 +345,28 @@ export const CATALOGUE: readonly MaterialListing[] = [ // ---------- Semiconductor Feedstock ---------- { title: 'Electronic-grade polysilicon', - desk: 'feedstock', - unit: 'kg', - indicativePriceChf: 45, + 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', - desk: 'feedstock', - unit: 'wafer', - indicativePriceChf: 110, + 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', - desk: 'feedstock', - unit: 'kg', - indicativePriceChf: 18, + 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', - desk: 'feedstock', - unit: 'kg', - indicativePriceChf: 620, + 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'], @@ -386,27 +375,21 @@ export const CATALOGUE: readonly MaterialListing[] = [ // ---------- Thermal & Packaging ---------- { title: 'CVD synthetic diamond heat spreader', - desk: 'thermal', - unit: 'piece', - indicativePriceChf: 450, + 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', - desk: 'thermal', - unit: 'wafer', - indicativePriceChf: 1400, + 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', - desk: 'thermal', - unit: 'litre', - indicativePriceChf: 95, + 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'], @@ -415,27 +398,21 @@ export const CATALOGUE: readonly MaterialListing[] = [ // ---------- Power, Grid & Superconductors ---------- { title: 'Grain-oriented electrical steel (GOES)', - desk: 'power', - unit: 'kg', - indicativePriceChf: 3.2, + 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', - desk: 'power', - unit: 'metre', - indicativePriceChf: 85, + 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)', - desk: 'power', - unit: 'litre', - indicativePriceChf: 48, + 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'], @@ -444,18 +421,14 @@ export const CATALOGUE: readonly MaterialListing[] = [ // ---------- Actuation & Robotics ---------- { title: 'Didymium (Nd-Pr) metal, magnet feed', - desk: 'actuation', - unit: 'kg', - indicativePriceChf: 95, + 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', - desk: 'actuation', - unit: 'kg', - indicativePriceChf: 400, + 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'], @@ -463,56 +436,63 @@ export const CATALOGUE: readonly MaterialListing[] = [ ]; // ===================================================================== -// COMPLIANCE — the reason a focused book stays a legal one +// SCOPE — what this firm does not do // ===================================================================== /** - * Several of these materials are dual-use and export-controlled (gallium, - * germanium, the heavy rare earths, some high-purity metals). A specialist - * desk cannot be casual about this, and the focus itself is the first control: - * the mandate admits compute, energy and actuation, and nothing else. + * 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 COMPLIANCE = { - screening: [ - 'Counterparty and end-user screening against applicable sanctions and ' + - 'denied-party lists before any quote is issued.', - 'End-use and end-user documentation on every export-controlled line; no ' + - 'shipment moves on an unverified end use.', - 'Licence-first: where an export licence is required, it is obtained ' + - 'before the material is committed, not after.', +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 traded, quoted or ' + - 'brokered — that is outside the mandate as well as outside the law we ' + - 'operate under. The three curves are compute, energy and actuation.', + '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 — installed now because it cannot be retrofitted +// DISCLOSURE — written before there is anything to disclose // ===================================================================== /** - * A firm that publishes research, trades the same materials, and intends to - * eventually own parts of the chain is publishing on its own positions. That - * is a workable model, but only with a disclosure rule written before the - * first position exists — afterwards, every rule looks like a response to - * something. So it is written here, in the same file as the mandate. + * 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, brokering, or in negotiation — at time of publication.', - 'Research is never withheld, delayed or softened because the desk holds a ' + - 'position. If those two conflict, the position is the thing that moves.', - 'Nothing is published to move a price the desk is about to trade against. ' + - 'Notes go out on a schedule, not on a fill.', + '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 is monetised is the position the ' + - 'research buys — counterparty access, deal flow, and knowing where the ' + - 'chain is thin — not the research itself.', + '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; // ===================================================================== @@ -524,25 +504,21 @@ export const LISTING_COPY = { subhead: COMPANY.tagline, body: [ 'Substrata is an open-source research firm covering the physical ' + - 'chokepoints between here and a technological singularity — and a ' + - 'trading desk on the materials it knows best. The research is free. ' + - 'The map is the product.', + '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 ' + - 'company, a person, a machine or a process; the tests do not care. That ' + - 'is how the universe reaches robotics and additive manufacturing ' + - 'without ever becoming "everything": you arrive at them by tracing a ' + - 'chain you were already mapping.', - 'First phase: every qualified producer of the fifteen materials on the ' + - 'desk. The sell side writes about chip designers. Almost nobody writes ' + - 'about who fires crucible-grade quartz, and that is the gap.', - 'Quotes are by RFQ against grade, lot size, origin and delivery window. ' + - 'Listed prices are indicative reference levels for budgeting. ' + - 'Settlement in Bitcoin or in francs, counterparty’s choice.', + '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, or send us an RFQ', + cta: 'Read the map', } as const; // ===================================================================== @@ -569,7 +545,7 @@ export const GROUP_PAYLOAD: CompanyGroupPayload = { 'research', 'open-source-research', 'supply-chain', - 'trading', + 'chokepoints', 'semiconductors', 'rare-earths', 'energy', @@ -577,88 +553,31 @@ export const GROUP_PAYLOAD: CompanyGroupPayload = { 'singularity', ], // The 'company' label defaults to members_only. This firm publishes its - // research and expects counterparties to read the book before sending an - // RFQ, so both halves have to be readable without an account. + // 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; /** - * Features enabled on creation. `marketplace` is what lets the group list the - * catalogue. `treasury` is deliberately NOT enabled: GROUP_FEATURES.treasury - * requires a `bitcoin_address` on the group, and there is no wallet for this - * firm yet. Enable it in the same commit that adds the address. + * 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[] = ['marketplace']; +export const GROUP_FEATURE_KEYS: readonly string[] = []; // ===================================================================== -// PRODUCT PAYLOADS (map 1:1 to the live `user_products` table) +// LOOKUPS // ===================================================================== -export interface MaterialProductPayload { - title: string; - description: string; - price: number; - currency: 'CHF'; - product_type: 'physical'; - fulfillment_type: 'manual'; - category: string; - status: 'active'; - tags: string[]; - /** -1 = not inventory-tracked; these are brokered lots, not stock on a shelf. */ - inventory_count: number; - show_on_profile: boolean; -} - -/** - * Group thousands with the Swiss apostrophe, so a ruthenium quote reads - * CHF 15’000 rather than CHF 15000 — at these magnitudes an unseparated - * number is a misreading waiting to happen. Done by hand rather than with - * Intl, because this string is asserted in tests and baked into rows the seed - * writes to the database: it must not vary with the ICU data of whatever - * machine happens to run the seed. - */ -export function formatChf(amount: number): string { - const [whole, fraction] = String(amount).split('.'); - const grouped = whole.replace(/\B(?=(\d{3})+(?!\d))/g, '’'); - return fraction ? `${grouped}.${fraction}` : grouped; -} - -const DESK_BY_ID: Record<DeskId, Desk> = DESKS.reduce( - (acc, desk) => ({ ...acc, [desk.id]: desk }), - {} as Record<DeskId, Desk> +const AREA_BY_ID: Record<AreaId, CoverageArea> = COVERAGE_AREAS.reduce( + (acc, area) => ({ ...acc, [area.id]: area }), + {} as Record<AreaId, CoverageArea> ); -/** @returns the desk a listing belongs to. */ -export function deskFor(listing: MaterialListing): Desk { - return DESK_BY_ID[listing.desk]; +/** @returns the coverage area a material belongs to. */ +export function areaFor(material: MaterialListing): CoverageArea { + return AREA_BY_ID[material.area]; } - -/** Renders one listing into the row shape `user_products` expects. */ -export function toProductPayload(listing: MaterialListing): MaterialProductPayload { - const desk = deskFor(listing); - return { - title: listing.title, - description: [ - listing.why, - '', - `Traded as: ${listing.spec}`, - `Desk: ${desk.name}`, - `Indicative reference: CHF ${formatChf(listing.indicativePriceChf)} per ${listing.unit} — ` + - 'a budgeting level, not a quote. Firm pricing by RFQ against grade, lot ' + - 'size, origin and delivery window.', - ].join('\n'), - price: listing.indicativePriceChf, - currency: 'CHF', - product_type: 'physical', - fulfillment_type: 'manual', - category: desk.name, - status: 'active', - tags: listing.tags, - inventory_count: -1, - show_on_profile: true, - }; -} - -export const PRODUCT_PAYLOADS: readonly MaterialProductPayload[] = CATALOGUE.map(toProductPayload); From 35995b7457369696f7e57cf07ed786d01f8fc876 Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Thu, 27 Aug 2026 05:15:30 +0000 Subject: [PATCH 08/17] feat(substrata): thesis, how to act on it, and the ledger to becoming the fund MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Readers will want to do something with this research. Two new pages serve that without crossing a line the firm has no licence to cross. THE LINE, AND WHERE IT ACTUALLY IS Publishing an impersonal, generally-circulated view is research. Telling a particular person what to buy is advice, and advice is licensed nearly everywhere. Less obviously: taking a fee for an introduction is what converts "here is who the brokers are" into regulated intermediation — introducing broker, tied agent, finder — in Switzerland, the EU and the US alike. Being unpaid is not a detail of that arrangement; it is the whole of what keeps it lawful. So there is no referral list. `PARTNERS` is empty behind a flag, and the gate is documented: a name appears only with an executed agreement AND confirmation that the introduction itself needs no licence. The page says so rather than leaving a blank space, because volunteering somebody's name as an endorsement they never gave is the easier thing to do and the wrong one. WHAT THE PAGES DO INSTEAD Six routes by which anyone acts in these markets, described as market structure and not endorsement, each carrying what it does NOT give you — the real failure mode here is misleading by omission, and a reader who takes a commodity ETF as exposure to seven-nines tin has been misled by what nobody said. One route is not financial at all: for an industrial reader the highest-return action is usually procurement, and the map is a supplier list as much as a research product. A thesis, six claims, each with a falsifier. A thesis without one is a slogan, and a slogan cannot be scored — which also makes the track record possible later. A readiness ledger: nine requirements to manage money rather than only publish, at 1 done and 2 in progress. Same discipline as the coverage meter, and the licence line still reads not started, which is the whole reason the other two pages are written the way they are. Tests hold the line rather than good intentions: no partner may appear while the gate is shut; every route must state its limitation; and the whole site is grepped for directive language ("we recommend", "you should buy", "guaranteed", "risk-free"). These erode quietly, one helpful sentence at a time. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GF9GDWaBWYHCZ9iNwmfZ41 --- .../unit/config/substrata-acting.test.ts | 156 +++++++ .../sites/sections/DataSections.tsx | 3 + src/config/site-substrata.ts | 170 +++++++- src/config/substrata-acting.ts | 398 ++++++++++++++++++ 4 files changed, 726 insertions(+), 1 deletion(-) create mode 100644 __tests__/unit/config/substrata-acting.test.ts create mode 100644 src/config/substrata-acting.ts diff --git a/__tests__/unit/config/substrata-acting.test.ts b/__tests__/unit/config/substrata-acting.test.ts new file mode 100644 index 000000000..9142a7c84 --- /dev/null +++ b/__tests__/unit/config/substrata-acting.test.ts @@ -0,0 +1,156 @@ +/** + * 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, + PARTNERS, + PARTNER_INTRODUCTIONS_ENABLED, + 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 that the firm is not registered and gives no advice', () => { + const text = JSON.stringify(actingPage); + expect(text).toContain('not registered'); + expect(text).toContain('no investment advice'); + }); + + it('states the no-consideration rule, which is the one holding the line', () => { + // A referral fee is what converts "here is who the brokers are" into + // regulated intermediation. If this sentence ever leaves the config, the + // page has quietly changed what the firm is. + const limits = ACTING_LIMITS.join(' '); + expect(limits).toContain('no commission, referral fee'); + expect(limits).toContain('disclosed on this page before the arrangement begins'); + }); + + it('disclaims holding client money and transmitting orders', () => { + const limits = ACTING_LIMITS.join(' '); + expect(limits).toContain('holds no client money'); + expect(limits).toContain('receives and transmits no orders'); + }); +}); + +describe('Substrata — no partner may appear before the gate opens', () => { + it('lists nobody while introductions are disabled', () => { + if (!PARTNER_INTRODUCTIONS_ENABLED) { + expect(PARTNERS).toEqual([]); + } + }); + + it('requires a named licence and regulator on any partner that is ever added', () => { + // Not hypothetical hygiene: an introduction to an unlicensed "adviser" is + // the failure mode that hurts a reader, and the type alone will not stop + // a blank string. + for (const partner of PARTNERS) { + expect(partner.name.length).toBeGreaterThan(0); + expect(partner.regulatedAs.length).toBeGreaterThan(0); + expect(partner.jurisdictions.length).toBeGreaterThan(0); + } + }); +}); + +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/src/components/sites/sections/DataSections.tsx b/src/components/sites/sections/DataSections.tsx index 72228e730..69822d0e1 100644 --- a/src/components/sites/sections/DataSections.tsx +++ b/src/components/sites/sections/DataSections.tsx @@ -16,6 +16,9 @@ const STATUS_DOT: Record<string, string> = { 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', }; export function IndexSection({ diff --git a/src/config/site-substrata.ts b/src/config/site-substrata.ts index a14ed0b9e..2236a65b4 100644 --- a/src/config/site-substrata.ts +++ b/src/config/site-substrata.ts @@ -28,6 +28,16 @@ import { SCOPE, areaFor, } from './substrata'; +import { + ACTING_LIMITS, + ACTION_ROUTES, + INVESTMENT_THESIS, + PARTNERS, + PARTNER_INTRODUCTIONS_ENABLED, + READINESS, + READINESS_STATUS_LABEL, + readinessProgress, +} from './substrata-acting'; import { CHOKEPOINTS, COVERAGE, @@ -364,6 +374,156 @@ function chokepointsPage(): SitePage { }; } +// ===================================================================== +// 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 }], + })), + ], + }; +} + +// ===================================================================== +// 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, + })), + }, + { + kind: 'prose', + heading: 'Why there is no referral list', + paragraphs: [ + 'The obvious thing to build here is a list of brokers and dealers we send ' + + 'people to. The reason there is not one yet is that taking a fee for an ' + + 'introduction is precisely what turns a publisher into a regulated ' + + 'intermediary — an introducing broker, a tied agent, a finder — in ' + + 'Switzerland, the EU and the United States alike. Being unpaid is not a ' + + 'detail of that arrangement; it is the whole of what keeps it on this side ' + + 'of the line.', + 'So what follows is a description of how these markets actually work, and the ' + + 'questions worth putting to whoever you choose. Nobody paid to be described, ' + + 'because nobody is named. If that ever changes — a named partner, an ' + + 'agreement, any consideration at all — it will be written on this page ' + + 'before the arrangement starts.', + ], + }, + PARTNER_INTRODUCTIONS_ENABLED && PARTNERS.length > 0 + ? { + kind: 'table' as const, + heading: 'Firms we can introduce you to', + blurb: 'Each holds the licence named beside it. We are paid nothing by any of them.', + columns: ['Firm', 'Category', 'Regulated as', 'Where'], + monoColumns: [3], + rows: PARTNERS.map(partner => [ + partner.name, + partner.category, + partner.regulatedAs, + partner.jurisdictions.join(' '), + ]), + } + : { + kind: 'prose' as const, + heading: 'Firms we can introduce you to', + paragraphs: [ + 'None, today. A name appears here only once there is an executed agreement ' + + 'with that firm and confirmation that making the introduction does not ' + + 'itself require a licence. Volunteering somebody’s name as an endorsement ' + + 'they never agreed to would be the easier thing to do and the wrong one.', + ], + }, + ...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.', + }, + { + kind: 'table', + heading: 'The ledger', + columns: ['Requirement', 'Status', 'Detail'], + statusColumn: 1, + rows: READINESS.map(item => [ + item.requirement, + READINESS_STATUS_LABEL[item.status], + item.detail, + ]), + }, + ], + }; +} + // ===================================================================== // DISCLOSURE // ===================================================================== @@ -413,5 +573,13 @@ function disclosurePage(): SitePage { // ===================================================================== export function substrataSitePages(): SitePage[] { - return [homePage(), mandatePage(), mapPage(), chokepointsPage(), disclosurePage()]; + return [ + homePage(), + mandatePage(), + thesisPage(), + mapPage(), + chokepointsPage(), + actingPage(), + disclosurePage(), + ]; } diff --git a/src/config/substrata-acting.ts b/src/config/substrata-acting.ts new file mode 100644 index 000000000..5f7078c61 --- /dev/null +++ b/src/config/substrata-acting.ts @@ -0,0 +1,398 @@ +/** + * 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. NO CONSIDERATION FOR INTRODUCTIONS. This is the one people get wrong. + * A referral fee is precisely what converts "we told a reader who the + * brokers are" into a regulated activity — an introducing broker, a tied + * agent, a finder — in Switzerland, the EU and the US alike. So the routes + * below are CATEGORIES of how these markets work, not endorsements, and no + * provider pays to be there. `PARTNERS` is empty and gated for that reason. + * + * What is allowed, and what this file therefore contains: a stated view, an + * honest description of the routes by which anyone acts in these markets, the + * questions worth asking a provider, and a public record of how far the firm + * is from being able to act on its own behalf. Everything else waits for a + * licence. + * + * Created: 2026-08-26 + */ + +// ===================================================================== +// THE HARD LIMITS — stated on the page, not just in a footer +// ===================================================================== + +export const ACTING_LIMITS: readonly string[] = [ + 'Substrata is not registered, licensed or supervised as a financial institution ' + + 'in any jurisdiction, and gives no investment advice.', + 'Nothing published here is a recommendation, a personal recommendation, an offer, ' + + 'a solicitation, or an inducement to buy or sell anything.', + 'Substrata holds no client money or assets, receives and transmits no orders, and ' + + 'arranges no transactions.', + 'Substrata receives no commission, referral fee, retrocession or other consideration ' + + 'from any provider named or described here — and if that ever changes, it will be ' + + 'disclosed on this page before the arrangement begins, not after.', + 'Whether any route is suitable for you, whether you are eligible for it, and what it ' + + 'costs you in tax are questions for you and your own advisers.', +]; + +// ===================================================================== +// 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?', + ], + }, +]; + +// ===================================================================== +// PARTNERS — deliberately empty, and gated +// ===================================================================== + +export interface IntroductionPartner { + name: string; + category: string; + jurisdictions: string[]; + /** The licence they hold and the regulator that granted it. Both, or no entry. */ + regulatedAs: string; +} + +/** + * Naming firms here requires TWO things that do not yet exist, and the flag is + * what stops the list appearing before them: + * + * 1. An executed agreement with the firm, so the page is not volunteering + * somebody's name as an endorsement they never agreed to. + * 2. Confirmation that making introductions — on the terms actually agreed, + * including free of charge — does not itself require a licence in the + * relevant jurisdictions. Unpaid introductions are the safer end of this, + * which is exactly why the no-consideration rule in ACTING_LIMITS is not + * a courtesy but the thing keeping this side of the line. + */ +export const PARTNER_INTRODUCTIONS_ENABLED = false; + +export const PARTNERS: readonly IntroductionPartner[] = []; + +// ===================================================================== +// 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', +}; From b88f59295286421c9c6d8d603a9b570f7880ca31 Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Thu, 27 Aug 2026 05:44:06 +0000 Subject: [PATCH 09/17] feat(substrata): the participant directory, graded by scarcity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ask was everyone in the singularity chain, seen through scarcity. 102 participants across ten layers, ore to buyer, each graded. THE GRADE IS THE PRODUCT A directory of everyone in a supply chain is a phone book. What makes this research is the last column: chokepoint, concentrated, or competitive. Grading participants "competitive" is not filler — it is what makes "chokepoint" mean anything, and several well-known names are in the list precisely because they are not constraints. A map where every node is critical is a map nobody has read, so a test fails if all three grades are not in use, and another fails if chokepoints ever exceed half the list. On the demand side the grade reads the other way: when a handful of firms account for most of the world's orders, that concentration is a scarcity fact about the chain too, pointing upstream instead of down. Same claim limits as everywhere: name, layer, jurisdiction, role and a scarcity judgement. Nothing about revenue, capacity or share, and every row unsourced until an analyst attaches a source. The page leads with the 24 that actually bind, as cards carrying the reasoning, then the full directory as per-layer tables behind a jump index. ALSO - Dropped the defensive framing from Acting. The limits are three plain lines now, and the "why there is no referral list" section is gone: a directory nobody paid to be in is the point, not a caveat. - Domain aliases wired ahead of ownership. substrata.ch (intended home, appears free — .ch publishes no RDAP so a registrar has to confirm) and substrataintel.com (the .com fallback; substrata.com is taken). A host only reaches the check if DNS already points here, so listing them early costs nothing and means the site works the hour one is bought, with no deploy. - The readiness ledger became definitions instead of a table. Three columns where one holds a paragraph is a table that scrolls sideways on a phone and is read by nobody. - The nav is a client component for one reason: at eight sections the active item sat off-screen on a phone, so a visitor on a deep page saw no indication of where they were. It scrolls itself into view now. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GF9GDWaBWYHCZ9iNwmfZ41 --- .../unit/config/substrata-acting.test.ts | 45 +- .../config/substrata-participants.test.ts | 127 ++ src/components/sites/SiteChrome.tsx | 27 +- src/components/sites/SiteNav.tsx | 64 + .../sites/sections/DataSections.tsx | 5 + src/config/site-substrata.ts | 181 ++- src/config/sites.ts | 17 +- src/config/substrata-acting.ts | 63 +- src/config/substrata-participants.ts | 1045 +++++++++++++++++ 9 files changed, 1409 insertions(+), 165 deletions(-) create mode 100644 __tests__/unit/config/substrata-participants.test.ts create mode 100644 src/components/sites/SiteNav.tsx create mode 100644 src/config/substrata-participants.ts diff --git a/__tests__/unit/config/substrata-acting.test.ts b/__tests__/unit/config/substrata-acting.test.ts index 9142a7c84..ed41bd1ef 100644 --- a/__tests__/unit/config/substrata-acting.test.ts +++ b/__tests__/unit/config/substrata-acting.test.ts @@ -15,8 +15,6 @@ import { ACTING_LIMITS, ACTION_ROUTES, INVESTMENT_THESIS, - PARTNERS, - PARTNER_INTRODUCTIONS_ENABLED, READINESS, READINESS_STATUS_LABEL, readinessProgress, @@ -29,44 +27,17 @@ const actingPage = sitePageAt(site, 'acting'); const everything = JSON.stringify(sitePagesFor(site)); describe('Substrata — the limits are stated, not assumed', () => { - it('says on the page that the firm is not registered and gives no advice', () => { + it('says on the page what the firm does and does not do', () => { const text = JSON.stringify(actingPage); - expect(text).toContain('not registered'); - expect(text).toContain('no investment advice'); + expect(text).toContain('does not manage money'); + expect(text).toContain('paid nothing by any participant'); }); - it('states the no-consideration rule, which is the one holding the line', () => { - // A referral fee is what converts "here is who the brokers are" into - // regulated intermediation. If this sentence ever leaves the config, the - // page has quietly changed what the firm is. - const limits = ACTING_LIMITS.join(' '); - expect(limits).toContain('no commission, referral fee'); - expect(limits).toContain('disclosed on this page before the arrangement begins'); - }); - - it('disclaims holding client money and transmitting orders', () => { - const limits = ACTING_LIMITS.join(' '); - expect(limits).toContain('holds no client money'); - expect(limits).toContain('receives and transmits no orders'); - }); -}); - -describe('Substrata — no partner may appear before the gate opens', () => { - it('lists nobody while introductions are disabled', () => { - if (!PARTNER_INTRODUCTIONS_ENABLED) { - expect(PARTNERS).toEqual([]); - } - }); - - it('requires a named licence and regulator on any partner that is ever added', () => { - // Not hypothetical hygiene: an introduction to an unlicensed "adviser" is - // the failure mode that hurts a reader, and the type alone will not stop - // a blank string. - for (const partner of PARTNERS) { - expect(partner.name.length).toBeGreaterThan(0); - expect(partner.regulatedAs.length).toBeGreaterThan(0); - expect(partner.jurisdictions.length).toBeGreaterThan(0); - } + 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'); }); }); 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/src/components/sites/SiteChrome.tsx b/src/components/sites/SiteChrome.tsx index 345e5d414..3320ecd91 100644 --- a/src/components/sites/SiteChrome.tsx +++ b/src/components/sites/SiteChrome.tsx @@ -18,6 +18,7 @@ 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; @@ -27,8 +28,6 @@ interface Props { } export function SiteMasthead({ site, chrome, pages, currentPath }: Props) { - const navPages = pages.filter(page => page.navLabel); - return ( <header className="sticky top-0 z-30 border-b border-subtle bg-surface-page/85 backdrop-blur"> <div className="mx-auto max-w-shell px-4 sm:px-6 lg:px-8"> @@ -42,29 +41,7 @@ export function SiteMasthead({ site, chrome, pages, currentPath }: Props) { </span> </Link> - <nav - aria-label="Sections" - className="scrollbar-hide -mx-1 flex flex-nowrap items-center gap-x-1 overflow-x-auto" - > - {navPages.map(page => { - const isCurrent = page.path === currentPath; - return ( - <Link - key={page.path || 'home'} - href={siteHref(site, page.path)} - aria-current={isCurrent ? 'page' : undefined} - className={[ - 'shrink-0 rounded px-2 py-1 font-mono text-xs uppercase tracking-caps transition-colors', - isCurrent - ? 'text-fg-primary underline decoration-accent-warm decoration-2 underline-offset-8' - : 'text-fg-tertiary hover:text-fg-primary', - ].join(' ')} - > - {page.navLabel} - </Link> - ); - })} - </nav> + <SiteNav site={site} pages={pages} currentPath={currentPath} /> </div> </div> </header> 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<HTMLElement>(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 ( + <nav + ref={navRef} + aria-label="Sections" + className="scrollbar-hide -mx-1 flex flex-nowrap items-center gap-x-1 overflow-x-auto" + > + {pages + .filter(page => page.navLabel) + .map(page => { + const isCurrent = page.path === currentPath; + return ( + <Link + key={page.path || 'home'} + href={siteHref(site, page.path)} + aria-current={isCurrent ? 'page' : undefined} + className={[ + 'shrink-0 rounded px-2 py-1 font-mono text-xs uppercase tracking-caps transition-colors', + isCurrent + ? 'text-fg-primary underline decoration-accent-warm decoration-2 underline-offset-8' + : 'text-fg-tertiary hover:text-fg-primary', + ].join(' ')} + > + {page.navLabel} + </Link> + ); + })} + </nav> + ); +} diff --git a/src/components/sites/sections/DataSections.tsx b/src/components/sites/sections/DataSections.tsx index 69822d0e1..27ab76873 100644 --- a/src/components/sites/sections/DataSections.tsx +++ b/src/components/sites/sections/DataSections.tsx @@ -19,6 +19,11 @@ const STATUS_DOT: Record<string, string> = { 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({ diff --git a/src/config/site-substrata.ts b/src/config/site-substrata.ts index 2236a65b4..acee8736b 100644 --- a/src/config/site-substrata.ts +++ b/src/config/site-substrata.ts @@ -32,12 +32,18 @@ import { ACTING_LIMITS, ACTION_ROUTES, INVESTMENT_THESIS, - PARTNERS, - PARTNER_INTRODUCTIONS_ENABLED, READINESS, READINESS_STATUS_LABEL, readinessProgress, } from './substrata-acting'; +import { + CHAIN_LAYERS, + SCARCITY_DETAIL, + SCARCITY_LABEL, + bindingParticipants, + participantProgress, + participantsInLayer, +} from './substrata-participants'; import { CHOKEPOINTS, COVERAGE, @@ -406,6 +412,118 @@ function thesisPage(): SitePage { }; } +// ===================================================================== +// 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 // ===================================================================== @@ -431,48 +549,6 @@ function actingPage(): SitePage { detail: limit, })), }, - { - kind: 'prose', - heading: 'Why there is no referral list', - paragraphs: [ - 'The obvious thing to build here is a list of brokers and dealers we send ' + - 'people to. The reason there is not one yet is that taking a fee for an ' + - 'introduction is precisely what turns a publisher into a regulated ' + - 'intermediary — an introducing broker, a tied agent, a finder — in ' + - 'Switzerland, the EU and the United States alike. Being unpaid is not a ' + - 'detail of that arrangement; it is the whole of what keeps it on this side ' + - 'of the line.', - 'So what follows is a description of how these markets actually work, and the ' + - 'questions worth putting to whoever you choose. Nobody paid to be described, ' + - 'because nobody is named. If that ever changes — a named partner, an ' + - 'agreement, any consideration at all — it will be written on this page ' + - 'before the arrangement starts.', - ], - }, - PARTNER_INTRODUCTIONS_ENABLED && PARTNERS.length > 0 - ? { - kind: 'table' as const, - heading: 'Firms we can introduce you to', - blurb: 'Each holds the licence named beside it. We are paid nothing by any of them.', - columns: ['Firm', 'Category', 'Regulated as', 'Where'], - monoColumns: [3], - rows: PARTNERS.map(partner => [ - partner.name, - partner.category, - partner.regulatedAs, - partner.jurisdictions.join(' '), - ]), - } - : { - kind: 'prose' as const, - heading: 'Firms we can introduce you to', - paragraphs: [ - 'None, today. A name appears here only once there is an executed agreement ' + - 'with that firm and confirmation that making the introduction does not ' + - 'itself require a licence. Volunteering somebody’s name as an endorsement ' + - 'they never agreed to would be the easier thing to do and the wrong one.', - ], - }, ...ACTION_ROUTES.map(route => ({ kind: 'definitions' as const, heading: route.name, @@ -510,15 +586,15 @@ function actingPage(): SitePage { 'is: a plan with statuses is a plan, and everything else is a feeling.', }, { - kind: 'table', + // 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', - columns: ['Requirement', 'Status', 'Detail'], - statusColumn: 1, - rows: READINESS.map(item => [ - item.requirement, - READINESS_STATUS_LABEL[item.status], - item.detail, - ]), + items: READINESS.map(item => ({ + term: `${item.requirement} — ${READINESS_STATUS_LABEL[item.status]}`, + detail: item.detail, + })), }, ], }; @@ -579,6 +655,7 @@ export function substrataSitePages(): SitePage[] { thesisPage(), mapPage(), chokepointsPage(), + participantsPage(), actingPage(), disclosurePage(), ]; diff --git a/src/config/sites.ts b/src/config/sites.ts index 46873a5a7..093ea66ae 100644 --- a/src/config/sites.ts +++ b/src/config/sites.ts @@ -37,8 +37,16 @@ export interface HostedSite { slug: string; /** Free subdomain: <subdomain>.orangecat.ch. */ subdomain: string; - /** Custom domain once DNS is pointed here, else null. */ + /** 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. */ @@ -54,6 +62,10 @@ 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' }, }, @@ -92,6 +104,9 @@ export function siteForHost(host: string | null | undefined): HostedSite | null if (site.customDomain && bare === normaliseHost(site.customDomain)) { return site; } + if (site.aliasHosts?.some(alias => bare === normaliseHost(alias))) { + return site; + } } return null; } diff --git a/src/config/substrata-acting.ts b/src/config/substrata-acting.ts index 5f7078c61..76cff794c 100644 --- a/src/config/substrata-acting.ts +++ b/src/config/substrata-acting.ts @@ -14,18 +14,14 @@ * 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. NO CONSIDERATION FOR INTRODUCTIONS. This is the one people get wrong. - * A referral fee is precisely what converts "we told a reader who the - * brokers are" into a regulated activity — an introducing broker, a tied - * agent, a finder — in Switzerland, the EU and the US alike. So the routes - * below are CATEGORIES of how these markets work, not endorsements, and no - * provider pays to be there. `PARTNERS` is empty and gated for that reason. + * 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. * - * What is allowed, and what this file therefore contains: a stated view, an - * honest description of the routes by which anyone acts in these markets, the - * questions worth asking a provider, and a public record of how far the firm - * is from being able to act on its own behalf. Everything else waits for a - * licence. + * 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 */ @@ -35,17 +31,12 @@ // ===================================================================== export const ACTING_LIMITS: readonly string[] = [ - 'Substrata is not registered, licensed or supervised as a financial institution ' + - 'in any jurisdiction, and gives no investment advice.', - 'Nothing published here is a recommendation, a personal recommendation, an offer, ' + - 'a solicitation, or an inducement to buy or sell anything.', - 'Substrata holds no client money or assets, receives and transmits no orders, and ' + - 'arranges no transactions.', - 'Substrata receives no commission, referral fee, retrocession or other consideration ' + - 'from any provider named or described here — and if that ever changes, it will be ' + - 'disclosed on this page before the arrangement begins, not after.', - 'Whether any route is suitable for you, whether you are eligible for it, and what it ' + - 'costs you in tax are questions for you and your own advisers.', + '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.', ]; // ===================================================================== @@ -262,34 +253,6 @@ export const ACTION_ROUTES: readonly ActionRoute[] = [ }, ]; -// ===================================================================== -// PARTNERS — deliberately empty, and gated -// ===================================================================== - -export interface IntroductionPartner { - name: string; - category: string; - jurisdictions: string[]; - /** The licence they hold and the regulator that granted it. Both, or no entry. */ - regulatedAs: string; -} - -/** - * Naming firms here requires TWO things that do not yet exist, and the flag is - * what stops the list appearing before them: - * - * 1. An executed agreement with the firm, so the page is not volunteering - * somebody's name as an endorsement they never agreed to. - * 2. Confirmation that making introductions — on the terms actually agreed, - * including free of charge — does not itself require a licence in the - * relevant jurisdictions. Unpaid introductions are the safer end of this, - * which is exactly why the no-consideration rule in ACTING_LIMITS is not - * a courtesy but the thing keeping this side of the line. - */ -export const PARTNER_INTRODUCTIONS_ENABLED = false; - -export const PARTNERS: readonly IntroductionPartner[] = []; - // ===================================================================== // READINESS — the honest distance to becoming the investor // 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'); +} From b0db8074eed0266ce2c6854b656496a56b244374 Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Thu, 27 Aug 2026 05:56:09 +0000 Subject: [PATCH 10/17] docs(seed): the seed header still described a firm that no longer exists MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit It said "Substrate", promised a trading desk, and claimed the script had to run on the box. All three were stale: the firm is Substrata, there is no desk, and the seed is PostgREST calls rather than psql — so it runs from any machine that can reach supabase.orangecat.ch over HTTPS, no SSH and no tunnel needed. That last one matters, because believing otherwise is what makes a two-minute task look like it needs a maintenance window. Also notes that the service-role key bypasses RLS, so the machine running it is privileged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GF9GDWaBWYHCZ9iNwmfZ41 --- scripts/seed-substrata.ts | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/scripts/seed-substrata.ts b/scripts/seed-substrata.ts index b6ae7f424..cad95d6c5 100644 --- a/scripts/seed-substrata.ts +++ b/scripts/seed-substrata.ts @@ -1,8 +1,7 @@ /** - * Seed "Substrate" — an open-source research firm covering the chokepoints - * between here and a technological singularity, with a trading desk on the - * materials it knows best — as an OrangeCat group profile with its own actor - * and catalogue. + * 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 @@ -13,13 +12,19 @@ * 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. * - * Run against the LIVE self-hosted DB (supabase.orangecat.ch) from the box: + * 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 (already in .env.local on the box): + * 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 */ From cb02bce4b8245f243672ffd8d6a36718cb57d8a3 Mon Sep 17 00:00:00 2001 From: Mao Nakamoto <georgy.butaev@revamp-it.ch> Date: Thu, 27 Aug 2026 09:31:39 +0200 Subject: [PATCH 11/17] fix(sites): close the five defects the architecture audit found MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CodeQL was red on this branch with six alerts, which is why the PR could not merge. All six were real but shallow: five unused locals, and one substring test on a URL (`url.includes('data.iana.org')`) that also matches `data.iana.org.evil.example`. It sat in a test — the worst place for it, since tests are what the next lookup gets copied from. The other four are the ones that mattered: **60 KB of dead payload on every page load.** `SiteNav` is a client component and was taking `SitePage[]`. Every prop crossing that boundary is serialised into the RSC payload, so all eight Substrata pages shipped the entire site — 92 producer rows, 102 participants — to render eight links. Measured: 60.1 KB carried, 0.33 KB used. It now takes `SiteNavItem[]`, the narrowest shape that answers its question. **A public keyless amplifier with no rate limit.** One request to `/api/v1/domains` fans out to as many as 24 outbound RDAP lookups, and varying the query defeats the cache. The cost of abuse was never our CPU — it was this box's IP being throttled by the registries we depend on. Now behind `rateLimitDomainSearch` (10 per 5 min), modelled on the ask-cat limiter, which exists for exactly this shape: an expensive downstream call for an anonymous caller. **An unbounded cache keyed by caller input.** `resultCache` grew for as long as the process did, and on the box that is weeks. The TTL did not bound it — expired entries are only noticed when the same key returns, which an enumerating caller never does. Capped, with a TTL sweep before LRU eviction, and a test that fails if the cap stops holding. **A seed that would break on the box, not in CI.** `seed-substrata.ts` used an untyped client and five `as string` casts, so a column rename would surface by hand, months later. Bound to the generated `Database` type; the casts are gone because they are no longer needed. Also collapsed the third copy of the x-forwarded-for read in rate-limit.ts into `clientIp()`. A per-IP limiter is only as correct as its notion of "IP". Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --- __tests__/unit/config/substrata.test.ts | 9 ++- __tests__/unit/services/domain-search.test.ts | 59 ++++++++++++++++++- scripts/seed-substrata.ts | 30 +++++++--- src/app/api/v1/domains/route.ts | 15 ++++- src/app/sites/[site]/[[...path]]/page.tsx | 8 ++- src/components/sites/SiteChrome.tsx | 8 +-- src/components/sites/SiteNav.tsx | 48 +++++++-------- src/config/domain-search.ts | 11 ++++ src/config/site-content.ts | 22 +++++++ src/lib/rate-limit.ts | 45 ++++++++++++-- src/services/domains/availability.ts | 37 +++++++++++- 11 files changed, 242 insertions(+), 50 deletions(-) diff --git a/__tests__/unit/config/substrata.test.ts b/__tests__/unit/config/substrata.test.ts index 3a2de57ab..370c36a02 100644 --- a/__tests__/unit/config/substrata.test.ts +++ b/__tests__/unit/config/substrata.test.ts @@ -46,11 +46,10 @@ 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']; +// Mirror of the live CHECK constraint on public.groups. The user_products +// mirrors that used to sit here were dropped: Substrata sells nothing and the +// seed writes no product rows, so they asserted against a table this config +// never touches. const GROUP_VISIBILITIES = ['public', 'members_only', 'private']; describe('Substrata — group profile payload', () => { diff --git a/__tests__/unit/services/domain-search.test.ts b/__tests__/unit/services/domain-search.test.ts index 2e00f673f..880dbc954 100644 --- a/__tests__/unit/services/domain-search.test.ts +++ b/__tests__/unit/services/domain-search.test.ts @@ -18,16 +18,32 @@ import { checkDomain, checkDomains, + domainCacheSize, parseDomain, resetDomainCaches, } from '@/services/domains/availability'; import { suggestDomains, toSeed } from '@/services/domains/suggest'; -import { CANDIDATE_TLDS, MAX_CANDIDATES } from '@/config/domain-search'; +import { CANDIDATE_TLDS, DOMAIN_RESULT_CACHE_MAX, MAX_CANDIDATES } from '@/config/domain-search'; const RDAP_TLDS = new Set(['com', 'ai', 'dev', 'org', 'net', 'xyz']); const originalFetch = global.fetch; +/** + * True when a URL's HOST is exactly IANA's bootstrap host. + * + * Not a substring test on the raw URL — that also matches + * `https://data.iana.org.evil.example/` and `https://evil.example/?x=data.iana.org`, + * so it is the wrong shape to teach in a test that other lookups get copied from. + */ +function isBootstrapUrl(url: string): boolean { + try { + return new URL(url).hostname === 'data.iana.org'; + } catch { + return false; + } +} + function mockFetch(impl: (url: string) => Promise<Partial<Response>> | Partial<Response>) { global.fetch = jest.fn(async (input: RequestInfo | URL) => impl(String(input)) @@ -133,7 +149,7 @@ describe('availability — the rule that stops a false “available”', () => { describe('availability — batches', () => { it('checks every candidate and preserves order', async () => { mockFetch(url => - url.includes('data.iana.org') + isBootstrapUrl(url) ? { ok: true, status: 200, json: async () => ({ services: [[['com', 'ai'], ['x']]] }) } : { status: 404, ok: false } ); @@ -177,3 +193,42 @@ describe('suggestions', () => { expect(suggestDomains({ query: '???' })).toEqual([]); }); }); + +describe('availability — the result cache is bounded', () => { + /** + * The cache key is a domain the CALLER supplies. An unbounded map therefore + * lets an anonymous caller decide how much memory this process holds, and the + * process on the box runs for weeks. A TTL does not fix that on its own: an + * expired entry is only noticed when its own key is looked up again, which an + * enumerating caller never does. + */ + it('never exceeds the cap, however many distinct names are asked for', async () => { + mockFetch(url => + isBootstrapUrl(url) + ? { ok: true, status: 200, json: async () => ({ services: [[['com'], ['x']]] }) } + : { status: 404, ok: false } + ); + + const overflow = DOMAIN_RESULT_CACHE_MAX + 250; + const names = Array.from({ length: overflow }, (_, i) => `enumerated-${i}.com`); + await checkDomains(names); + + expect(domainCacheSize()).toBeLessThanOrEqual(DOMAIN_RESULT_CACHE_MAX); + }, 30_000); + + it('still answers from cache for a name asked twice in a row', async () => { + let lookups = 0; + mockFetch(url => { + if (isBootstrapUrl(url)) { + return { ok: true, status: 200, json: async () => ({ services: [[['com'], ['x']]] }) }; + } + lookups += 1; + return { status: 404, ok: false }; + }); + + await checkDomain('cached-name.com', new Set(['com'])); + await checkDomain('cached-name.com', new Set(['com'])); + + expect(lookups).toBe(1); + }); +}); diff --git a/scripts/seed-substrata.ts b/scripts/seed-substrata.ts index cad95d6c5..8dac08178 100644 --- a/scripts/seed-substrata.ts +++ b/scripts/seed-substrata.ts @@ -30,6 +30,7 @@ import { config as loadEnv } from 'dotenv'; import { createClient, type SupabaseClient } from '@supabase/supabase-js'; +import type { Database } from '../src/types/database'; import { COMPANY, FOUNDER_ACTOR_SLUG, @@ -54,10 +55,21 @@ 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, { +/** + * Typed against the generated schema, not `any`. + * + * A seed is the one place where a column rename fails on the box rather than in + * CI — it runs by hand, months after the migration that broke it. Binding the + * client to `Database` moves that failure to `npm run type-check`, and it is why + * the row literals below need no casts. + */ +const admin: SupabaseClient<Database> = createClient<Database>(SUPABASE_URL, SERVICE_ROLE_KEY, { auth: { persistSession: false, autoRefreshToken: false }, }); +type GroupInsert = Database['public']['Tables']['groups']['Insert']; +type ActorInsert = Database['public']['Tables']['actors']['Insert']; + interface FounderRow { id: string; user_id: string; @@ -75,7 +87,7 @@ async function resolveFounder(): Promise<FounderRow> { 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 }; + return { id: data.id, user_id: data.user_id }; } /** Upsert the company group by its (unique) slug. Returns its id. */ @@ -87,7 +99,7 @@ async function upsertGroup(founder: FounderRow): Promise<string> { .maybeSingle(); if (probeErr) die(`Failed to look up group '${GROUP_PAYLOAD.slug}': ${probeErr.message}`); - const row = { + const row: GroupInsert = { name: GROUP_PAYLOAD.name, slug: GROUP_PAYLOAD.slug, description: GROUP_PAYLOAD.description, @@ -103,13 +115,13 @@ async function upsertGroup(founder: FounderRow): Promise<string> { 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; + return existing.id; } const { data, error } = await admin.from('groups').insert(row).select('id').single(); if (error) die(`Failed to insert group: ${error.message}`); console.log(`+ created group "${GROUP_PAYLOAD.name}" (${data.id})`); - return data.id as string; + return data.id; } /** @@ -126,7 +138,7 @@ async function upsertGroupActor(groupId: string): Promise<string> { .maybeSingle(); if (probeErr) die(`Failed to look up group actor: ${probeErr.message}`); - const row = { + const row: ActorInsert = { actor_type: 'group', group_id: groupId, user_id: null, @@ -138,13 +150,13 @@ async function upsertGroupActor(groupId: string): Promise<string> { 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; + return existing.id; } const { data, error } = await admin.from('actors').insert(row).select('id').single(); if (error) die(`Failed to insert group actor: ${error.message}`); console.log(`+ group actor '${COMPANY.slug}' (${data.id})`); - return data.id as string; + return data.id; } /** The founder seat. Unique on (group_id, user_id), so ignore-on-conflict. */ @@ -183,7 +195,7 @@ async function main(): Promise<void> { console.log(`founder actor '${FOUNDER_ACTOR_SLUG}' = ${founder.id}`); const groupId = await upsertGroup(founder); - const groupActorId = await upsertGroupActor(groupId); + await upsertGroupActor(groupId); await ensureFounderMembership(groupId, founder); await ensureFeatures(groupId, founder); diff --git a/src/app/api/v1/domains/route.ts b/src/app/api/v1/domains/route.ts index 0d439537e..4d23368f0 100644 --- a/src/app/api/v1/domains/route.ts +++ b/src/app/api/v1/domains/route.ts @@ -11,6 +11,11 @@ */ import { NextRequest } from 'next/server'; import { apiSuccess, apiError } from '@/lib/api/standardResponse'; +import { + applyRateLimitHeaders, + createRateLimitResponse, + rateLimitDomainSearch, +} from '@/lib/rate-limit'; import { CANDIDATE_TLDS, DOMAIN_SEARCH_DISCLAIMER } from '@/config/domain-search'; import { checkDomains } from '@/services/domains/availability'; import { suggestDomains } from '@/services/domains/suggest'; @@ -21,6 +26,14 @@ const CACHE = 'public, s-maxage=300, stale-while-revalidate=600'; export async function GET(request: NextRequest) { try { + // Before any parsing, because the cost being rationed is the OUTBOUND fan-out + // to the registries — up to MAX_CANDIDATES lookups per inbound request, on + // behalf of an anonymous caller. See `rateLimitDomainSearch`. + const limit = await rateLimitDomainSearch(request); + if (!limit.success) { + return createRateLimitResponse(limit); + } + const url = new URL(request.url); const q = (url.searchParams.get('q') ?? '').trim(); if (q.length < 2) { @@ -56,7 +69,7 @@ export async function GET(request: NextRequest) { disclaimer: DOMAIN_SEARCH_DISCLAIMER, }); response.headers.set('Cache-Control', CACHE); - return response; + return applyRateLimitHeaders(response, limit); } 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 index 182b055e4..f34e0d6c6 100644 --- a/src/app/sites/[site]/[[...path]]/page.tsx +++ b/src/app/sites/[site]/[[...path]]/page.tsx @@ -18,6 +18,7 @@ import { HOSTED_SITES, siteBySlug, siteCanonicalHost } from '@/config/sites'; import { pageRendersOwnHeader, siteChromeFor, + siteNavItems, sitePageAt, sitePagesFor, } from '@/config/site-content'; @@ -82,7 +83,12 @@ export default async function HostedSitePage({ params }: RouteParams) { return ( <div className="flex min-h-screen flex-col bg-surface-page"> - <SiteMasthead site={site} chrome={chrome} pages={pages} currentPath={currentPath} /> + <SiteMasthead + site={site} + chrome={chrome} + navItems={siteNavItems(pages)} + currentPath={currentPath} + /> <main className="flex-1"> <div className="mx-auto max-w-shell px-4 py-14 sm:px-6 sm:py-20 lg:px-8"> diff --git a/src/components/sites/SiteChrome.tsx b/src/components/sites/SiteChrome.tsx index 3320ecd91..a591d9e3a 100644 --- a/src/components/sites/SiteChrome.tsx +++ b/src/components/sites/SiteChrome.tsx @@ -17,17 +17,17 @@ 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 type { SiteChrome as SiteChromeSpec, SiteNavItem } from '@/config/site-content'; import { SiteNav } from './SiteNav'; interface Props { site: HostedSite; chrome: SiteChromeSpec; - pages: SitePage[]; + navItems: SiteNavItem[]; currentPath: string; } -export function SiteMasthead({ site, chrome, pages, currentPath }: Props) { +export function SiteMasthead({ site, chrome, navItems, currentPath }: Props) { return ( <header className="sticky top-0 z-30 border-b border-subtle bg-surface-page/85 backdrop-blur"> <div className="mx-auto max-w-shell px-4 sm:px-6 lg:px-8"> @@ -41,7 +41,7 @@ export function SiteMasthead({ site, chrome, pages, currentPath }: Props) { </span> </Link> - <SiteNav site={site} pages={pages} currentPath={currentPath} /> + <SiteNav site={site} items={navItems} currentPath={currentPath} /> </div> </div> </header> diff --git a/src/components/sites/SiteNav.tsx b/src/components/sites/SiteNav.tsx index 6e8460a22..d2789c065 100644 --- a/src/components/sites/SiteNav.tsx +++ b/src/components/sites/SiteNav.tsx @@ -15,15 +15,19 @@ 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'; +import type { SiteNavItem } from '@/config/site-content'; interface Props { site: HostedSite; - pages: SitePage[]; + /** + * Deliberately NOT `SitePage[]`. See `SiteNavItem` — everything passed to a + * client component is serialised into the payload of every page it renders on. + */ + items: SiteNavItem[]; currentPath: string; } -export function SiteNav({ site, pages, currentPath }: Props) { +export function SiteNav({ site, items, currentPath }: Props) { const navRef = useRef<HTMLElement>(null); useEffect(() => { @@ -39,26 +43,24 @@ export function SiteNav({ site, pages, currentPath }: Props) { aria-label="Sections" className="scrollbar-hide -mx-1 flex flex-nowrap items-center gap-x-1 overflow-x-auto" > - {pages - .filter(page => page.navLabel) - .map(page => { - const isCurrent = page.path === currentPath; - return ( - <Link - key={page.path || 'home'} - href={siteHref(site, page.path)} - aria-current={isCurrent ? 'page' : undefined} - className={[ - 'shrink-0 rounded px-2 py-1 font-mono text-xs uppercase tracking-caps transition-colors', - isCurrent - ? 'text-fg-primary underline decoration-accent-warm decoration-2 underline-offset-8' - : 'text-fg-tertiary hover:text-fg-primary', - ].join(' ')} - > - {page.navLabel} - </Link> - ); - })} + {items.map(item => { + const isCurrent = item.path === currentPath; + return ( + <Link + key={item.path || 'home'} + href={siteHref(site, item.path)} + aria-current={isCurrent ? 'page' : undefined} + className={[ + 'shrink-0 rounded px-2 py-1 font-mono text-xs uppercase tracking-caps transition-colors', + isCurrent + ? 'text-fg-primary underline decoration-accent-warm decoration-2 underline-offset-8' + : 'text-fg-tertiary hover:text-fg-primary', + ].join(' ')} + > + {item.label} + </Link> + ); + })} </nav> ); } diff --git a/src/config/domain-search.ts b/src/config/domain-search.ts index dea66d165..ab5ba7475 100644 --- a/src/config/domain-search.ts +++ b/src/config/domain-search.ts @@ -38,6 +38,17 @@ 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; +/** + * Hard cap on remembered lookups. + * + * The cache key is a domain the CALLER chose, so without a ceiling this map + * grows for as long as the process does — and on a self-hosted box that process + * runs for weeks. A TTL alone does not bound it: expired entries are only + * noticed when the same key is asked for again, which an enumerating caller + * never does. + */ +export const DOMAIN_RESULT_CACHE_MAX = 5000; + /** One lookup's ceiling. A slow registry must not hold the whole search open. */ export const RDAP_TIMEOUT_MS = 8000; diff --git a/src/config/site-content.ts b/src/config/site-content.ts index 2427f4128..961a43aca 100644 --- a/src/config/site-content.ts +++ b/src/config/site-content.ts @@ -146,6 +146,28 @@ export function pageRendersOwnHeader(page: SitePage): boolean { return page.sections[0]?.kind === 'hero'; } +/** + * The nav's view of a page: a label and where it goes, nothing else. + * + * This type exists because `SiteNav` is a client component. Every prop crossing + * that boundary is serialised into the RSC payload of every page, so handing it + * `SitePage[]` shipped the WHOLE SITE — all 92 producer rows, all 102 + * participants — into the HTML of all eight pages, 60 KB of it, to render eight + * links totalling 0.33 KB. A client component takes the narrowest shape that + * answers its question, and this is that shape. + */ +export interface SiteNavItem { + path: string; + label: string; +} + +/** Nav entries for a site, in page order. Pages without a `navLabel` are omitted. */ +export function siteNavItems(pages: SitePage[]): SiteNavItem[] { + return pages + .filter((page): page is SitePage & { navLabel: string } => Boolean(page.navLabel)) + .map(page => ({ path: page.path, label: page.navLabel })); +} + /** @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, ''); diff --git a/src/lib/rate-limit.ts b/src/lib/rate-limit.ts index 82519ec9f..21e15cf6e 100644 --- a/src/lib/rate-limit.ts +++ b/src/lib/rate-limit.ts @@ -102,6 +102,13 @@ const upstashTipRecipientLimiter = createUpstashLimiter('tip-recipient', 20, '5 // the general limiter. 8 per 5 min is plenty for a real person having a // conversation, and starves a script. const upstashAskCatLimiter = createUpstashLimiter('ask-cat', 8, '5 m'); +// Public, keyless domain search. One inbound request fans out to as many as +// MAX_CANDIDATES (24) outbound RDAP lookups against third-party registries, and +// varying the query defeats the result cache. The cost of abuse is therefore not +// our CPU — it is this box's IP being throttled or blocked by the registries we +// depend on. Same reasoning as ask-cat: an expensive downstream call on behalf +// of an anonymous caller gets its own tight budget on top of the general limiter. +const upstashDomainSearchLimiter = createUpstashLimiter('domain-search', 10, '5 m'); // ==================== FALLBACK IN-MEMORY LIMITER ==================== @@ -162,9 +169,23 @@ const fallbackAskCatLimiter = new InMemoryRateLimiter({ windowMs: 5 * 60 * 1000, maxRequests: 8, }); +const fallbackDomainSearchLimiter = new InMemoryRateLimiter({ + windowMs: 5 * 60 * 1000, + maxRequests: 10, +}); // ==================== RATE LIMIT FUNCTIONS ==================== +/** + * The caller's IP, as seen through Caddy. + * + * One definition, because a per-IP limiter is only as correct as its notion of + * "IP" — and three limiters had already copied these two lines verbatim. + */ +function clientIp(request: RequestLike): string { + return request.headers.get('x-forwarded-for') || request.headers.get('x-real-ip') || 'anonymous'; +} + /** * Convert Upstash result to our standard format */ @@ -187,8 +208,7 @@ function toRateLimitResult(upstashResult: { * 100 requests per 15 minutes per IP */ export async function rateLimit(request: RequestLike): Promise<RateLimitResult> { - const ip = - request.headers.get('x-forwarded-for') || request.headers.get('x-real-ip') || 'anonymous'; + const ip = clientIp(request); const key = `api:${ip}`; if (upstashGeneralLimiter) { @@ -237,8 +257,7 @@ export async function rateLimitTipRecipient(username: string): Promise<RateLimit * visitor. Apply IN ADDITION to the general per-IP `rateLimit`. */ export async function rateLimitAskCat(request: RequestLike): Promise<RateLimitResult> { - const ip = - request.headers.get('x-forwarded-for') || request.headers.get('x-real-ip') || 'anonymous'; + const ip = clientIp(request); const key = `ask-cat:${ip}`; if (upstashAskCatLimiter) { @@ -249,6 +268,24 @@ export async function rateLimitAskCat(request: RequestLike): Promise<RateLimitRe return fallbackAskCatLimiter.check(key); } +/** + * Rate limit the public domain-availability search per IP. + * + * 10 per 5 minutes. A person trying names types a handful of queries; a script + * enumerating the namespace through us — and through the registries behind us — + * does not. Apply IN ADDITION to the general per-IP `rateLimit`. + */ +export async function rateLimitDomainSearch(request: RequestLike): Promise<RateLimitResult> { + const key = `domain-search:${clientIp(request)}`; + + if (upstashDomainSearchLimiter) { + const result = await upstashDomainSearchLimiter.limit(key); + return toRateLimitResult(result); + } + + return fallbackDomainSearchLimiter.check(key); +} + /** * Rate limit for write operations (create, update, delete) * 30 writes per minute per user diff --git a/src/services/domains/availability.ts b/src/services/domains/availability.ts index 575766125..ab2cc85e8 100644 --- a/src/services/domains/availability.ts +++ b/src/services/domains/availability.ts @@ -11,6 +11,7 @@ */ import { + DOMAIN_RESULT_CACHE_MAX, DOMAIN_RESULT_TTL_MS, RDAP_BOOTSTRAP_TTL_MS, RDAP_BOOTSTRAP_URL, @@ -91,6 +92,40 @@ export function resetDomainCaches(): void { const resultCache = new Map<string, { result: DomainResult; fetchedAt: number }>(); +/** + * Remember one lookup, without letting the caller decide how much we remember. + * + * Drops entries that are past their TTL, then — if the map is still at its + * ceiling — the oldest ones, which a Map yields first because it iterates in + * insertion order. Re-inserting an existing key deletes it first so a refreshed + * entry counts as recently written rather than keeping its original position. + */ +function rememberResult(domain: string, result: DomainResult, now: number): void { + resultCache.delete(domain); + + if (resultCache.size >= DOMAIN_RESULT_CACHE_MAX) { + for (const [key, entry] of resultCache) { + if (now - entry.fetchedAt >= DOMAIN_RESULT_TTL_MS) { + resultCache.delete(key); + } + } + while (resultCache.size >= DOMAIN_RESULT_CACHE_MAX) { + const oldest = resultCache.keys().next(); + if (oldest.done) { + break; + } + resultCache.delete(oldest.value); + } + } + + resultCache.set(domain, { result, fetchedAt: now }); +} + +/** @returns how many lookups are currently remembered. Test seam. */ +export function domainCacheSize(): number { + return resultCache.size; +} + /** 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 @@ -189,7 +224,7 @@ export async function checkDomain( logger.warn('RDAP lookup failed', { domain, error: String(error) }, 'DomainSearch'); } - resultCache.set(domain, { result, fetchedAt: now }); + rememberResult(domain, result, now); return result; } From 7aecbaecaccf9598ee8d9442cdd5bac17cebac0d Mon Sep 17 00:00:00 2001 From: Mao Nakamoto <georgy.butaev@revamp-it.ch> Date: Thu, 27 Aug 2026 09:43:03 +0200 Subject: [PATCH 12/17] feat(sites): publishing a website stops being a code change MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A hosted site was an entry in a hardcoded array plus a hand-written builder. That means every customer costs a pull request, a CI run and a deploy before their domain resolves — which is the opposite of what /domains sells. Three changes make an ordinary site cost nothing: **Host resolution is positional, not a list.** `siteSlugForHost` asks whether a Host header is SHAPED like a hosted site — one non-reserved label on orangecat.ch — and never touches the database, because it runs in edge middleware on every request to the whole app. Whether a site EXISTS is decided downstream by the page, which is allowed to query. An unclaimed slug rewrites and 404s, which is the safe direction. That trade needs a reserved list to be safe, so there is one, and it is load-bearing twice over: `supabase`/`bridge`/`fleetcrown` already serve something else on this box, and `security.orangecat.ch` under our own certificate is a phish, not a website. RESERVED_SUBDOMAINS covers both, and the tests assert every entry refuses. **A site is a row in a table that already exists.** `group_features` has group_id, enabled, an audit column and a `config` jsonb, and its own header already says adding a feature needs no code. Publishing a website is a capability a group switches on, exactly like treasury. A `hosted_sites` table would have duplicated all four columns and created a second answer to "what has this group turned on". The new migration adds one RLS policy so a published site is world-readable — the database decides "published", not TypeScript, and `enabled = false` unpublishes instantly. jsonb is `any` wearing a hat, so it is validated at the boundary with a per-field `.catch`: one malformed alias host costs that alias, never the customer's website. **An ordinary site has no builder.** `site-profile.ts` renders any group from the profile it already filled in. Nothing is authored twice and nothing is invented — no Support block without an address, no nav on a one-page site. The dispatch that was a `switch` is now a map holding only the exceptions, because that is what a bespoke builder is: Substrata's research corpus is not profile-shaped, and it stays in the repository so it renders statically without a database at all. Net: a new site is a row and zero lines of code. 216 config tests green. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --- __tests__/unit/config/hosted-sites.test.ts | 140 ++++++++--- __tests__/unit/config/site-profile.test.ts | 101 ++++++++ .../unit/config/substrata-acting.test.ts | 10 +- .../config/substrata-participants.test.ts | 8 +- src/app/sites/[site]/[[...path]]/page.tsx | 80 +++--- src/components/sites/SiteChrome.tsx | 7 +- src/components/sites/SiteNav.tsx | 8 +- src/config/group-features.ts | 8 + src/config/hosted-site.ts | 178 +++++++++++++ src/config/site-content.ts | 67 +++-- src/config/site-profile.ts | 135 ++++++++++ src/config/sites.ts | 234 +++++++++++------- src/middleware.ts | 15 +- src/services/sites/registry.ts | 186 ++++++++++++++ ...0260827090000_hosted_sites_public_read.sql | 52 ++++ 15 files changed, 1033 insertions(+), 196 deletions(-) create mode 100644 __tests__/unit/config/site-profile.test.ts create mode 100644 src/config/hosted-site.ts create mode 100644 src/config/site-profile.ts create mode 100644 src/services/sites/registry.ts create mode 100644 supabase/migrations/20260827090000_hosted_sites_public_read.sql diff --git a/__tests__/unit/config/hosted-sites.test.ts b/__tests__/unit/config/hosted-sites.test.ts index 50c9fd467..2f3989eac 100644 --- a/__tests__/unit/config/hosted-sites.test.ts +++ b/__tests__/unit/config/hosted-sites.test.ts @@ -13,7 +13,18 @@ * 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 { + RESERVED_SUBDOMAINS, + isReservedSubdomain, + siteHref, + siteSlugForHost, +} from '@/config/sites'; +import { + ALWAYS_PUBLISHED, + HOSTED_SITE_FALLBACKS, + siteCanonicalHost, + toHostedSite, +} from '@/config/hosted-site'; import { pageRendersOwnHeader, sitePageAt, @@ -24,50 +35,117 @@ 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'); +const site = HOSTED_SITE_FALLBACKS.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'); + expect(siteSlugForHost('substrata.orangecat.ch')).toBe('substrata'); + expect(siteSlugForHost('www.substrata.orangecat.ch')).toBe('substrata'); + expect(siteSlugForHost('Substrata.OrangeCat.ch:443')).toBe('substrata'); }); it('resolves the local development host, so the rewrite is testable without DNS', () => { - expect(siteForHost('substrata.localhost:3020')?.slug).toBe('substrata'); + expect(siteSlugForHost('substrata.localhost:3020')).toBe('substrata'); }); + /** + * Resolution is now positional rather than an allowlist — that is what makes a + * new customer zero deploys. The safety therefore has to come from SHAPE, and + * these are the shapes that must never resolve. + */ it('refuses everything else — a greedy match would swallow OrangeCat itself', () => { for (const host of [ 'orangecat.ch', 'www.orangecat.ch', 'localhost:3000', 'substrata.evil.example', - 'notsubstrata.orangecat.ch', 'substrata.orangecat.ch.evil.example', + 'a.b.orangecat.ch', + '.orangecat.ch', + 'sub_domain.orangecat.ch', + '-leading.orangecat.ch', + 'trailing-.orangecat.ch', '', null, undefined, ]) { - expect(siteForHost(host)).toBeNull(); + expect(siteSlugForHost(host)).toBeNull(); + } + }); + + /** + * An unclaimed slug MUST resolve, because the page is what knows whether a + * site exists. If this ever returned null the whole no-deploy path would be + * gone and every customer would need a code change again. + */ + it('resolves a slug no site has claimed, leaving existence to the page', () => { + expect(siteSlugForHost('a-brand-new-customer.orangecat.ch')).toBe('a-brand-new-customer'); + }); + + /** + * The dangerous half of a positional match. `security.orangecat.ch` under our + * own certificate is a phish; `supabase.orangecat.ch` is the database. Neither + * may ever be handed to a group that happens to pick that slug. + */ + it('refuses every reserved subdomain, infrastructure and impersonation alike', () => { + for (const { label } of RESERVED_SUBDOMAINS) { + expect(isReservedSubdomain(label)).toBe(true); + expect(siteSlugForHost(`${label}.orangecat.ch`)).toBeNull(); + expect(siteSlugForHost(`${label.toUpperCase()}.orangecat.ch`)).toBeNull(); + } + }); + + it('reserves the hosts that are actually live on the box', () => { + // Every one of these has its own Caddy block today. A site claiming one + // would be shadowed by it, or would shadow it. + for (const live of ['www', 'bridge', 'fleetcrown', 'evig', 'supabase']) { + expect(isReservedSubdomain(live)).toBe(true); } }); it('advertises the custom domain once one is set, and the subdomain until then', () => { - for (const site of HOSTED_SITES) { - const expected = site.customDomain ?? `${site.subdomain}.orangecat.ch`; - expect(siteCanonicalHost(site)).toBe(expected); + const bare = toHostedSite({ slug: 'acme', name: 'Acme' }, {}); + expect(siteCanonicalHost(bare)).toBe('acme.orangecat.ch'); + + const custom = toHostedSite({ slug: 'acme', name: 'Acme' }, { customDomain: 'acme.example' }); + expect(siteCanonicalHost(custom)).toBe('acme.example'); + }); +}); + +describe('hosted sites — the config a site owner may set', () => { + /** + * `group_features.config` is jsonb, which is `any` wearing a hat. Whatever a + * client wrote there reaches this function, so a bad field must cost that + * field and never the customer's whole website. + */ + it('falls back to the group name and drops malformed fields', () => { + const site = toHostedSite( + { slug: 'acme', name: 'Acme Corp' }, + { title: ' ', customDomain: 'not a hostname', aliasHosts: ['ok.example', 'nodot', ''] } + ); + expect(site.title).toBe('Acme Corp'); + expect(site.customDomain).toBeNull(); + expect(site.aliasHosts).toEqual(['ok.example']); + }); + + it('survives config that is not an object at all', () => { + for (const junk of [null, undefined, 42, 'string', []]) { + expect(toHostedSite({ slug: 'acme', name: 'Acme' }, junk).title).toBe('Acme'); } }); + + it('marks only repo-resident sites as bespoke, so everyone else renders from their profile', () => { + expect(toHostedSite({ slug: 'substrata', name: 'Substrata' }, {}).builder).toBe('substrata'); + expect(toHostedSite({ slug: 'acme', name: 'Acme' }, {}).builder).toBeNull(); + }); }); describe('hosted sites — 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'); + expect(siteHref(site.slug)).toBe('/sites/substrata'); + expect(siteHref(site.slug, 'map')).toBe('/sites/substrata/map'); + expect(siteHref(site.slug, '/map')).toBe('/sites/substrata/map'); + expect(siteHref(site.slug, '/')).toBe('/sites/substrata'); }); }); @@ -86,11 +164,11 @@ describe('hosted sites — chrome isolation', () => { }); describe('hosted sites — every site renders', () => { - it.each(HOSTED_SITES.map(site => [site.slug, site] as const))( + it.each(ALWAYS_PUBLISHED.map(slug => [slug, HOSTED_SITE_FALLBACKS[slug]] as const))( '%s has chrome, a home page, and a nav where every entry resolves', (_slug, site) => { - const chrome = siteChromeFor(site); - const pages = sitePagesFor(site); + const chrome = siteChromeFor(site, null); + const pages = sitePagesFor(site, null); expect(chrome).not.toBeNull(); expect(pages.length).toBeGreaterThan(0); @@ -102,7 +180,7 @@ describe('hosted sites — every site renders', () => { for (const page of pages) { // The builders return fresh objects per call, so compare by value. - expect(sitePageAt(site, page.path)).toEqual(page); + expect(sitePageAt(sitePagesFor(site, null), page.path)).toEqual(page); expect(page.title.length).toBeGreaterThan(0); expect(page.sections.length).toBeGreaterThan(0); } @@ -110,16 +188,16 @@ describe('hosted sites — every site renders', () => { ); it('returns null for a path no page claims, so the route can 404', () => { - expect(sitePageAt(site!, 'not-a-page')).toBeNull(); + expect(sitePageAt(sitePagesFor(site, null), 'not-a-page')).toBeNull(); }); }); describe('substrata.orangecat.ch — the site is the profile, not a copy of it', () => { - const pages = sitePagesFor(site!); + const pages = sitePagesFor(site, null); const text = JSON.stringify(pages); it('takes its name and tagline from the profile config', () => { - const chrome = siteChromeFor(site!); + const chrome = siteChromeFor(site); expect(chrome?.name).toBe(COMPANY.name); expect(chrome?.tagline).toBe(COMPANY.tagline); }); @@ -145,7 +223,7 @@ describe('substrata.orangecat.ch — the site is the profile, not a copy of it', }); it('reports coverage honestly — unsourced rows read as leads, not findings', () => { - const mapPage = sitePageAt(site!, 'map'); + const mapPage = sitePageAt(sitePagesFor(site, null), 'map'); const rows = JSON.stringify(mapPage); const { sourced, total } = coverageProgress(); @@ -163,7 +241,7 @@ describe('substrata.orangecat.ch — the site is the profile, not a copy of it', }); it('gives the map a jump index whose every anchor lands on a real table', () => { - const mapPage = sitePageAt(site!, 'map')!; + const mapPage = sitePageAt(sitePagesFor(site, null), 'map')!; const index = mapPage.sections.find(section => section.kind === 'index'); expect(index).toBeDefined(); @@ -180,22 +258,22 @@ describe('substrata.orangecat.ch — the site is the profile, not a copy of it', }); it('opens the home page with a hero, and never doubles it with a title block', () => { - const home = sitePageAt(site!, '')!; + const home = sitePageAt(sitePagesFor(site, null), '')!; expect(home.sections[0].kind).toBe('hero'); expect(pageRendersOwnHeader(home)).toBe(true); // Inner pages take the standard header instead. - expect(pageRendersOwnHeader(sitePageAt(site!, 'map')!)).toBe(false); + expect(pageRendersOwnHeader(sitePageAt(sitePagesFor(site, null), '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(sitePageAt(sitePagesFor(site, null), 'desk')).toBeNull(); + const navLabels = sitePagesFor(site, null).map(page => page.navLabel); expect(navLabels).not.toContain('The desk'); }); it('carries the non-material chokepoints, so the site shows the whole universe', () => { - const page = sitePageAt(site!, 'chokepoints'); + const page = sitePageAt(sitePagesFor(site, null), 'chokepoints'); expect(page).not.toBeNull(); const text = JSON.stringify(page); for (const point of CHOKEPOINTS) { @@ -206,7 +284,7 @@ describe('substrata.orangecat.ch — the site is the profile, not a copy of it', 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!)); + const everything = JSON.stringify(sitePagesFor(site, null)); for (const phrase of ['RFQ', 'per kg', 'Indicative CHF', 'Settlement in Bitcoin']) { expect(`${phrase}: ${everything.includes(phrase)}`).toBe(`${phrase}: false`); } diff --git a/__tests__/unit/config/site-profile.test.ts b/__tests__/unit/config/site-profile.test.ts new file mode 100644 index 000000000..76b6bfade --- /dev/null +++ b/__tests__/unit/config/site-profile.test.ts @@ -0,0 +1,101 @@ +/** + * The default website is the product claim, so these tests hold the claim + * rather than the implementation. + * + * The claim is: a group that filled in a profile already has a website, and + * turning it on costs one switch and no code. Two things have to be true for + * that to survive contact with real profiles — it must render something decent + * from the little a profile guarantees (a name), and it must never invent + * anything the group did not say. A default site that prints an empty "Support" + * heading is advertising a capability nobody set up. + */ + +import { profileSiteChrome, profileSitePages, type SiteProfile } from '@/config/site-profile'; +import { pageRendersOwnHeader, sitePageAt } from '@/config/site-content'; + +function profile(overrides: Partial<SiteProfile> = {}): SiteProfile { + return { + slug: 'acme', + name: 'Acme Cooperative', + description: null, + label: null, + tags: [], + bitcoinAddress: null, + lightningAddress: null, + canonicalHost: 'acme.orangecat.ch', + ...overrides, + }; +} + +describe('the default site renders from a profile alone', () => { + it('produces a home page from nothing but a name', () => { + const pages = profileSitePages(profile()); + + expect(pages).toHaveLength(1); + expect(pages[0].path).toBe(''); + expect(pages[0].title).toBe('Acme Cooperative'); + expect(pageRendersOwnHeader(pages[0])).toBe(true); + expect(sitePageAt(pages, '')).toBe(pages[0]); + }); + + it('splits the description into a hero lead and an About section', () => { + const pages = profileSitePages( + profile({ description: 'We repair things.\n\nFounded 2019.\n\nIn Zürich.' }) + ); + const [hero, about] = pages[0].sections; + + expect(hero).toMatchObject({ kind: 'hero', lead: ['We repair things.'] }); + expect(about).toMatchObject({ + kind: 'prose', + heading: 'About', + paragraphs: ['Founded 2019.', 'In Zürich.'], + }); + }); + + it('never prints the same paragraph in both the hero and the prose', () => { + const pages = profileSitePages(profile({ description: 'One line only.' })); + const kinds = pages[0].sections.map(section => section.kind); + + expect(kinds).toEqual(['hero']); + }); + + it('omits Support entirely when there is nothing to pay to', () => { + const sections = profileSitePages(profile()).flatMap(page => page.sections); + + expect(sections.some(section => 'heading' in section && section.heading === 'Support')).toBe( + false + ); + }); + + it('shows Support only for the addresses the group actually set', () => { + const sections = profileSitePages(profile({ lightningAddress: 'acme@getalby.com' })).flatMap( + page => page.sections + ); + const support = sections.find( + (section): section is Extract<typeof section, { kind: 'definitions' }> => + section.kind === 'definitions' + ); + + expect(support?.items).toEqual([{ term: 'Lightning', detail: 'acme@getalby.com' }]); + }); + + it('gives a one-page site no nav, because a single "Home" link is furniture', () => { + expect(profileSitePages(profile()).every(page => page.navLabel === undefined)).toBe(true); + }); + + it('takes a masthead tagline from the first sentence, not the whole description', () => { + const chrome = profileSiteChrome( + profile({ description: 'We repair things. We have since 2019. Ask us anything.' }) + ); + + expect(chrome.name).toBe('Acme Cooperative'); + expect(chrome.tagline).toBe('We repair things.'); + }); + + it('survives a profile with no description at all', () => { + const chrome = profileSiteChrome(profile()); + + expect(chrome.name).toBe('Acme Cooperative'); + expect(chrome.tagline).toBe(''); + }); +}); diff --git a/__tests__/unit/config/substrata-acting.test.ts b/__tests__/unit/config/substrata-acting.test.ts index ed41bd1ef..e9b85ca07 100644 --- a/__tests__/unit/config/substrata-acting.test.ts +++ b/__tests__/unit/config/substrata-acting.test.ts @@ -19,12 +19,12 @@ import { READINESS_STATUS_LABEL, readinessProgress, } from '@/config/substrata-acting'; -import { siteBySlug } from '@/config/sites'; +import { HOSTED_SITE_FALLBACKS } from '@/config/hosted-site'; import { sitePageAt, sitePagesFor } from '@/config/site-content'; -const site = siteBySlug('substrata')!; -const actingPage = sitePageAt(site, 'acting'); -const everything = JSON.stringify(sitePagesFor(site)); +const site = HOSTED_SITE_FALLBACKS.substrata; +const actingPage = sitePageAt(sitePagesFor(site, null), 'acting'); +const everything = JSON.stringify(sitePagesFor(site, null)); describe('Substrata — the limits are stated, not assumed', () => { it('says on the page what the firm does and does not do', () => { @@ -86,7 +86,7 @@ describe('Substrata — the thesis is scoreable', () => { }); it('publishes every claim and its falsifier on the site', () => { - const thesis = JSON.stringify(sitePageAt(site, 'thesis')); + const thesis = JSON.stringify(sitePageAt(sitePagesFor(site, null), 'thesis')); for (const claim of INVESTMENT_THESIS) { expect(thesis).toContain(claim.claim); expect(thesis).toContain(claim.falsifier); diff --git a/__tests__/unit/config/substrata-participants.test.ts b/__tests__/unit/config/substrata-participants.test.ts index 7835deaa5..caab5d3b8 100644 --- a/__tests__/unit/config/substrata-participants.test.ts +++ b/__tests__/unit/config/substrata-participants.test.ts @@ -18,11 +18,11 @@ import { participantsInLayer, } from '@/config/substrata-participants'; import { MANDATE_CURVES } from '@/config/substrata'; -import { siteBySlug } from '@/config/sites'; -import { sitePageAt } from '@/config/site-content'; +import { HOSTED_SITE_FALLBACKS } from '@/config/hosted-site'; +import { sitePageAt, sitePagesFor } from '@/config/site-content'; -const site = siteBySlug('substrata')!; -const page = sitePageAt(site, 'participants'); +const site = HOSTED_SITE_FALLBACKS.substrata; +const page = sitePageAt(sitePagesFor(site, null), 'participants'); describe('the directory is well formed', () => { it('gives every participant a layer that exists and a curve behind it', () => { diff --git a/src/app/sites/[site]/[[...path]]/page.tsx b/src/app/sites/[site]/[[...path]]/page.tsx index f34e0d6c6..61d485ea9 100644 --- a/src/app/sites/[site]/[[...path]]/page.tsx +++ b/src/app/sites/[site]/[[...path]]/page.tsx @@ -1,20 +1,25 @@ /** * 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. + * A visitor on substrata.orangecat.ch never sees this path: middleware rewrote + * their request here while their URL bar kept saying substrata.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. + * entry to that site's builder — never a new route file. Adding a WHOLE SITE is + * a row in the database and no code at all. + * + * This is also the layer that is allowed to ask whether a site exists. The + * middleware matched a shape; `siteBySlug` is what turns that into an answer, + * and it reads as an anonymous visitor so RLS decides "published", not this file. */ import React from 'react'; import { notFound } from 'next/navigation'; import type { Metadata } from 'next'; -import { HOSTED_SITES, siteBySlug, siteCanonicalHost } from '@/config/sites'; +import { ALWAYS_PUBLISHED, HOSTED_SITE_FALLBACKS, siteCanonicalHost } from '@/config/hosted-site'; import { pageRendersOwnHeader, siteChromeFor, @@ -22,6 +27,7 @@ import { sitePageAt, sitePagesFor, } from '@/config/site-content'; +import { siteBySlug, type ResolvedSite } from '@/services/sites/registry'; import { SiteFooter, SiteMasthead } from '@/components/sites/SiteChrome'; import { SiteSections } from '@/components/sites/SiteSections'; @@ -29,31 +35,53 @@ interface RouteParams { params: Promise<{ site: string; path?: string[] }>; } -/** Pre-render every page of every hosted site — they are static by nature. */ +/** + * Pre-render the sites whose content lives in the repository. + * + * Only those: a database-backed site cannot be enumerated at build time without + * making the build depend on a reachable database, and a customer who publishes + * at 14:00 should not wait for a deploy. Those render on first request and are + * held by `siteBySlug`'s cache, which is what `dynamicParams` allows. + */ 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] : [], - })) - ); + return ALWAYS_PUBLISHED.flatMap(slug => { + const site = HOSTED_SITE_FALLBACKS[slug]; + return site + ? sitePagesFor(site, null).map(page => ({ + site: slug, + path: page.path ? [page.path] : [], + })) + : []; + }); +} + +async function resolve(slug: string, path?: string[]) { + const resolved: ResolvedSite | null = await siteBySlug(slug); + if (!resolved) { + return null; + } + const pages = sitePagesFor(resolved.site, resolved.profile); + const currentPath = (path ?? []).join('/'); + const page = sitePageAt(pages, currentPath); + const chrome = siteChromeFor(resolved.site, resolved.profile); + if (!page || !chrome) { + return null; + } + return { site: resolved.site, pages, page, chrome, currentPath }; } export async function generateMetadata({ params }: RouteParams): Promise<Metadata> { const { site: slug, path } = await params; - const site = siteBySlug(slug); - if (!site) { - return {}; - } - const page = sitePageAt(site, (path ?? []).join('/')); - if (!page) { + const resolved = await resolve(slug, path); + if (!resolved) { return {}; } + const { site, page } = resolved; 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. + // substrata.orangecat.ch the browser tab has no business advertising the + // host — the visitor is on Substrata's site, not on ours. title: { absolute: title }, description: page.intro, openGraph: { @@ -67,20 +95,14 @@ export async function generateMetadata({ params }: RouteParams): Promise<Metadat 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); + const resolved = await resolve(slug, path); - if (!chrome || !page) { + if (!resolved) { notFound(); } + const { site, pages, page, chrome, currentPath } = resolved; + return ( <div className="flex min-h-screen flex-col bg-surface-page"> <SiteMasthead diff --git a/src/components/sites/SiteChrome.tsx b/src/components/sites/SiteChrome.tsx index a591d9e3a..8fd3e8984 100644 --- a/src/components/sites/SiteChrome.tsx +++ b/src/components/sites/SiteChrome.tsx @@ -16,7 +16,8 @@ import React from 'react'; import Link from 'next/link'; import { ROUTES } from '@/config/routes'; -import { siteCanonicalHost, siteHref, type HostedSite } from '@/config/sites'; +import { siteHref } from '@/config/sites'; +import { siteCanonicalHost, type HostedSite } from '@/config/hosted-site'; import type { SiteChrome as SiteChromeSpec, SiteNavItem } from '@/config/site-content'; import { SiteNav } from './SiteNav'; @@ -35,13 +36,13 @@ export function SiteMasthead({ site, chrome, navItems, currentPath }: Props) { sticky masthead eat a third of a phone screen, so on narrow viewports the nav scrolls sideways instead. */} <div className="flex items-center justify-between gap-6 py-4"> - <Link href={siteHref(site)} className="shrink-0"> + <Link href={siteHref(site.slug)} className="shrink-0"> <span className="font-heading text-lg font-semibold tracking-display text-fg-primary"> {chrome.name} </span> </Link> - <SiteNav site={site} items={navItems} currentPath={currentPath} /> + <SiteNav slug={site.slug} items={navItems} currentPath={currentPath} /> </div> </div> </header> diff --git a/src/components/sites/SiteNav.tsx b/src/components/sites/SiteNav.tsx index d2789c065..1e98a0c40 100644 --- a/src/components/sites/SiteNav.tsx +++ b/src/components/sites/SiteNav.tsx @@ -14,11 +14,11 @@ import React, { useEffect, useRef } from 'react'; import Link from 'next/link'; -import { siteHref, type HostedSite } from '@/config/sites'; +import { siteHref } from '@/config/sites'; import type { SiteNavItem } from '@/config/site-content'; interface Props { - site: HostedSite; + slug: string; /** * Deliberately NOT `SitePage[]`. See `SiteNavItem` — everything passed to a * client component is serialised into the payload of every page it renders on. @@ -27,7 +27,7 @@ interface Props { currentPath: string; } -export function SiteNav({ site, items, currentPath }: Props) { +export function SiteNav({ slug, items, currentPath }: Props) { const navRef = useRef<HTMLElement>(null); useEffect(() => { @@ -48,7 +48,7 @@ export function SiteNav({ site, items, currentPath }: Props) { return ( <Link key={item.path || 'home'} - href={siteHref(site, item.path)} + href={siteHref(slug, item.path)} aria-current={isCurrent ? 'page' : undefined} className={[ 'shrink-0 rounded px-2 py-1 font-mono text-xs uppercase tracking-caps transition-colors', diff --git a/src/config/group-features.ts b/src/config/group-features.ts index 3d6a1ca35..499f79004 100644 --- a/src/config/group-features.ts +++ b/src/config/group-features.ts @@ -58,6 +58,14 @@ export const GROUP_FEATURES = { dependencies: [], }, + site: { + id: 'site', + name: 'Website', + description: 'Publish this group as a website at <slug>.orangecat.ch', + icon: 'globe', + dependencies: [], + }, + shared_wallet: { id: 'shared_wallet', name: 'Shared Wallet', diff --git a/src/config/hosted-site.ts b/src/config/hosted-site.ts new file mode 100644 index 000000000..f8b088e94 --- /dev/null +++ b/src/config/hosted-site.ts @@ -0,0 +1,178 @@ +/** + * What a hosted site IS — the record, and where it is stored. + * + * `sites.ts` answers "which slug is this host asking for". This file answers + * "what is a site, and what may its owner configure". They are separate because + * the first runs at the edge with no database and the second is a database row. + * + * WHERE THE ROW LIVES, AND WHY THERE IS NO NEW TABLE + * + * A hosted site is stored as a `group_features` row with `feature_key = 'site'`. + * That table already exists, already has `enabled`, and already has a `config` + * jsonb column; `group-features.ts` already says in its own header that adding + * a feature is "adding an entry here, no code changes needed". Publishing a + * website is a capability a group switches on, exactly like treasury or events. + * + * A `hosted_sites` table would have duplicated the group_id, the enabled flag, + * the enabled_by audit column and the RLS policy that already guards them — and + * it would have created a second answer to "what has this group turned on". + * One source of truth for that question; a site is not special. + * + * The jsonb is validated HERE, at the boundary, because jsonb is `any` wearing + * a hat. Everything downstream receives a `HostedSite` and can stop worrying. + */ + +import { z } from 'zod'; +import { SITE_SLUG_PATTERN, normaliseHost } from './sites'; + +/** The `group_features.feature_key` that means "this group publishes a website". */ +export const SITE_FEATURE_KEY = 'site'; + +/** + * Sites whose PAGES are hand-written code rather than generated from the + * profile. The escape hatch, deliberately narrow. + * + * A bespoke builder is justified when a site's content is genuinely not + * profile-shaped — Substrata's is a research corpus with tables, meters and a + * coverage ledger, which no generic renderer should try to guess. Everything + * else gets the profile builder and needs no entry here, which is the point: + * the normal path costs zero lines of code. + * + * A slug appearing here does NOT publish it. The database still decides that, + * except for slugs in `ALWAYS_PUBLISHED` below. + */ +export const BESPOKE_BUILDERS = ['substrata'] as const; +export type BespokeBuilder = (typeof BESPOKE_BUILDERS)[number]; + +export function isBespokeBuilder(slug: string): slug is BespokeBuilder { + return (BESPOKE_BUILDERS as readonly string[]).includes(slug); +} + +/** A resolved hosted site. What every renderer downstream actually receives. */ +export interface HostedSite { + /** Path segment and registry key: `/sites/<slug>`. Equals the group slug. */ + slug: string; + /** Browser tab title / OG title for the whole site. */ + title: string; + /** Canonical custom domain, or null while the site lives on its subdomain. */ + customDomain: string | null; + /** Extra hostnames answered but never advertised. */ + aliasHosts: readonly string[]; + /** The OrangeCat profile this site renders. */ + profile: { kind: 'group'; slug: string }; + /** Named bespoke builder, or null to render from the profile. */ + builder: BespokeBuilder | null; +} + +/** + * Sites that render without the database having been asked, and the record each + * one resolves to. + * + * Only for sites whose content is entirely in the repository. Substrata's is, + * which is what lets it be statically generated at build time and previewed + * before its group has ever been seeded — and what stops a build-time render of + * a fully static site failing on a network blip. A profile-built site can never + * be listed here: its content IS the database. + * + * This is the ONLY place a site is described in code. Adding an ordinary + * customer here would be the mistake the rest of this design exists to prevent. + */ +export const HOSTED_SITE_FALLBACKS: Readonly<Record<string, HostedSite>> = { + substrata: { + slug: 'substrata', + title: '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'], + profile: { kind: 'group', slug: 'substrata' }, + builder: 'substrata', + }, +}; + +export const ALWAYS_PUBLISHED: readonly string[] = Object.keys(HOSTED_SITE_FALLBACKS); + +/** + * The owner-configurable part of a site: everything stored in + * `group_features.config`. + * + * Deliberately small. Every field here is a field somebody has to understand + * before their site works, and the answer to "what should I put here" must be + * "nothing, it already works". Defaults come from the group profile. + */ +export const siteConfigSchema = z.object({ + /** + * Browser tab title. Defaults to the group's name — which is right often + * enough that most sites will never set it. + */ + title: z.string().trim().min(1).max(120).optional().catch(undefined), + /** Custom domain once the owner points DNS here. Canonical when set. */ + customDomain: z + .string() + .trim() + .toLowerCase() + .regex(/^[a-z0-9.-]+\.[a-z]{2,63}$/, 'must be a hostname') + .nullish() + .catch(null), + /** + * Extra hostnames this site also answers on. + * + * Wired ahead of ownership on purpose: a host only reaches this check if + * somebody already pointed DNS at our box, so listing a domain not yet held + * costs nothing and means the site works the hour it is bought, with no + * deploy. Never canonical. + */ + aliasHosts: z.array(z.string().trim().toLowerCase()).max(20).optional().catch(undefined), +}); + +// Every field carries its own `.catch`, so one bad value costs THAT FIELD and +// never the whole object. A malformed alias host must not take a customer's +// website down — and jsonb written by a client will eventually contain one. + +export type SiteConfigInput = z.infer<typeof siteConfigSchema>; + +/** + * Turn a group row plus its raw `group_features.config` into a `HostedSite`. + * + * Invalid config is DROPPED FIELD BY FIELD rather than failing the request: a + * malformed alias host should cost that alias, not the customer's whole + * website. What cannot be defaulted — the slug and the name — comes from the + * group row, which the database constrains. + */ +export function toHostedSite( + group: { slug: string; name: string }, + rawConfig: unknown +): HostedSite { + const parsed = siteConfigSchema.safeParse(rawConfig ?? {}); + const config: SiteConfigInput = parsed.success ? parsed.data : {}; + + return { + slug: group.slug, + title: config.title?.trim() || group.name, + customDomain: config.customDomain ? normaliseHost(config.customDomain) : null, + aliasHosts: (config.aliasHosts ?? []) + .map(normaliseHost) + .filter(host => host.length > 0 && host.includes('.')), + profile: { kind: 'group', slug: group.slug }, + builder: isBespokeBuilder(group.slug) ? group.slug : null, + }; +} + +/** The public address the site advertises as its own. */ +export function siteCanonicalHost(site: HostedSite): string { + return site.customDomain ?? `${site.slug}.orangecat.ch`; +} + +/** + * Every hostname a site answers on — used to decide whether Caddy should issue + * a certificate for a name, so it must be exact rather than generous. + */ +export function siteHostnames(site: HostedSite): string[] { + const hosts = [`${site.slug}.orangecat.ch`]; + if (site.customDomain) { + hosts.push(site.customDomain); + } + hosts.push(...site.aliasHosts); + return hosts.filter(host => SITE_SLUG_PATTERN.test(host.split('.')[0])); +} diff --git a/src/config/site-content.ts b/src/config/site-content.ts index 961a43aca..7512333f2 100644 --- a/src/config/site-content.ts +++ b/src/config/site-content.ts @@ -8,17 +8,19 @@ * 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. + * Adding an ordinary site costs NO code: `site-profile.ts` renders any group + * from the profile it already has, and the database decides which groups are + * published. A builder is written only when a site's content is genuinely not + * profile-shaped — Substrata's lives in `site-substrata.ts` and is built + * entirely from the config the profile already needed, so nothing on that + * website is authored twice either. * * Created: 2026-08-26 */ -import type { HostedSite } from './sites'; +import type { BespokeBuilder, HostedSite } from './hosted-site'; import { substrataSiteChrome, substrataSitePages } from './site-substrata'; +import { profileSiteChrome, profileSitePages, type SiteProfile } from './site-profile'; // ===================================================================== // SECTIONS @@ -114,27 +116,42 @@ export interface SiteChrome { // ===================================================================== /** - * 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. + * Sites whose pages are hand-written rather than generated from the profile. + * + * This was a `switch` when there was one site and the comment here said it + * should stay one until there were three. What changed is not the count — it is + * that ordinary sites no longer appear here AT ALL. `profileSitePages` renders + * any group from its own profile, so this map holds only the exceptions, and an + * exception is exactly the thing that belongs in a lookup table rather than in + * control flow. Substrata is an exception because a research corpus of tables, + * meters and a coverage ledger is not profile-shaped. + * + * Keys are `BESPOKE_BUILDERS`, so a builder cannot be referenced without being + * declared, and cannot be declared without being implemented. + */ +const BESPOKE_SITES: Record<BespokeBuilder, { pages: () => SitePage[]; chrome: () => SiteChrome }> = + { + substrata: { pages: substrataSitePages, chrome: substrataSiteChrome }, + }; + +/** + * The pages of a site. + * + * @param profile the group snapshot, for a site rendered from its profile. + * Bespoke sites ignore it; profile sites return nothing without it. */ -export function sitePagesFor(site: HostedSite): SitePage[] { - switch (site.slug) { - case 'substrata': - return substrataSitePages(); - default: - return []; +export function sitePagesFor(site: HostedSite, profile: SiteProfile | null): SitePage[] { + if (site.builder) { + return BESPOKE_SITES[site.builder].pages(); } + return profile ? profileSitePages(profile) : []; } -export function siteChromeFor(site: HostedSite): SiteChrome | null { - switch (site.slug) { - case 'substrata': - return substrataSiteChrome(); - default: - return null; +export function siteChromeFor(site: HostedSite, profile: SiteProfile | null): SiteChrome | null { + if (site.builder) { + return BESPOKE_SITES[site.builder].chrome(); } + return profile ? profileSiteChrome(profile) : null; } /** @@ -168,8 +185,8 @@ export function siteNavItems(pages: SitePage[]): SiteNavItem[] { .map(page => ({ path: page.path, label: page.navLabel })); } -/** @returns the page at this path within the site, or null. */ -export function sitePageAt(site: HostedSite, path: string): SitePage | null { +/** @returns the page at this path within these pages, or null. */ +export function sitePageAt(pages: SitePage[], path: string): SitePage | null { const normalised = path.replace(/^\/+|\/+$/g, ''); - return sitePagesFor(site).find(page => page.path === normalised) ?? null; + return pages.find(page => page.path === normalised) ?? null; } diff --git a/src/config/site-profile.ts b/src/config/site-profile.ts new file mode 100644 index 000000000..1f55ba344 --- /dev/null +++ b/src/config/site-profile.ts @@ -0,0 +1,135 @@ +/** + * The default website: a group profile, rendered as pages. + * + * This file is the answer to "how many clicks to turn a project into a site". + * The answer is one — the switch in FleetCrown that writes `feature_key='site'` + * — and this is why: everything the site shows is something the group already + * filled in to have a profile at all. No wizard, no page builder, no second + * place to type the description. If the profile is good, the website is good. + * + * A bespoke builder (see `BESPOKE_BUILDERS`) exists for content that is not + * profile-shaped, like Substrata's research corpus. That is the exception. This + * is the path every ordinary customer takes, and it costs zero lines of code + * per customer, which is the whole design. + * + * Pure: a function of the profile snapshot it is handed. No database, no fetch, + * no clock — so the tests state exactly what a given profile renders as. + */ + +import type { SiteChrome, SitePage, SiteSection } from './site-content'; + +/** Everything the default site needs from a group. All of it already exists. */ +export interface SiteProfile { + slug: string; + name: string; + description: string | null; + /** Group label: company, cooperative, dao, nonprofit… Used as the eyebrow. */ + label: string | null; + tags: readonly string[]; + bitcoinAddress: string | null; + lightningAddress: string | null; + /** Public address the site advertises. Shown in the footer. */ + canonicalHost: string; +} + +/** + * Split a description into paragraphs. + * + * A profile description is a textarea, so the only structure it reliably has is + * blank lines. Anything cleverer would be guessing at markup the author never + * wrote. + */ +function paragraphs(text: string | null): string[] { + if (!text) { + return []; + } + return text + .split(/\n\s*\n/) + .map(part => part.replace(/\s+/g, ' ').trim()) + .filter(Boolean); +} + +/** Sentence-case a label like `network_state` for display. */ +function labelText(label: string | null): string | undefined { + if (!label) { + return undefined; + } + const words = label.replace(/_/g, ' ').trim(); + return words ? words.charAt(0).toUpperCase() + words.slice(1) : undefined; +} + +export function profileSiteChrome(profile: SiteProfile): SiteChrome { + const lead = paragraphs(profile.description)[0] ?? ''; + return { + name: profile.name, + // The chrome's tagline is the first sentence, not the whole description — + // a masthead is not the place for three paragraphs. + tagline: lead.split(/(?<=\.)\s/)[0] ?? '', + footerNote: `${profile.name} publishes this site from its OrangeCat profile.`, + }; +} + +/** + * The pages of a default site. + * + * One page, deliberately. A profile does not contain enough distinct material + * to fill a nav, and an eight-item menu of empty pages is worse than an honest + * single page. When a profile grows sections worth their own URL — a catalogue, + * an events list — that is the commit that adds a second page here, for every + * customer at once. + */ +export function profileSitePages(profile: SiteProfile): SitePage[] { + const body = paragraphs(profile.description); + const [lead, ...rest] = body; + + const sections: SiteSection[] = [ + { + kind: 'hero', + eyebrow: labelText(profile.label), + statement: profile.name, + // The hero carries the opening; the prose below carries the rest. Passing + // the whole description to both would print it twice. + lead: lead ? [lead] : [], + }, + ]; + + if (rest.length > 0) { + sections.push({ kind: 'prose', heading: 'About', paragraphs: rest }); + } + + if (profile.tags.length > 0) { + sections.push({ + kind: 'stats', + heading: 'Focus', + stats: profile.tags.slice(0, 6).map(tag => ({ label: tag, value: '·' })), + }); + } + + // Only when there is something to pay to. A "Support" heading above an empty + // block advertises a capability the group has not set up. + const addresses: Array<{ term: string; detail: string }> = []; + if (profile.lightningAddress) { + addresses.push({ term: 'Lightning', detail: profile.lightningAddress }); + } + if (profile.bitcoinAddress) { + addresses.push({ term: 'Bitcoin', detail: profile.bitcoinAddress }); + } + if (addresses.length > 0) { + sections.push({ + kind: 'definitions', + heading: 'Support', + blurb: `Send to ${profile.name} directly.`, + items: addresses, + }); + } + + return [ + { + path: '', + // No navLabel: a one-page site has nothing to navigate between, and a nav + // holding a single "Home" link is furniture. + title: profile.name, + sections, + }, + ]; +} diff --git a/src/config/sites.ts b/src/config/sites.ts index 093ea66ae..11533daf7 100644 --- a/src/config/sites.ts +++ b/src/config/sites.ts @@ -1,29 +1,37 @@ /** - * Hosted sites — SSOT for "a profile spins up a whole website". + * Hosted sites — the host math, and nothing else. * - * /domains sells this: "a working site, hosted and managed at + * `/domains` sells one sentence: "a working site, hosted and managed at * yourname.orangecat.ch — free. Move to your own domain when you are ready." - * This 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. + * This file answers the only question the EDGE has to answer to deliver it: + * *given a Host header, which site slug is this request for?* * - * 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. + * WHY THIS IS A PATTERN AND NOT A LIST * - * Three ways a request reaches a site, all resolved by `siteForHost`: + * It used to be a list. A list means every new customer is a code change, a + * pull request, a CI run and a deploy before their domain resolves — which is + * the opposite of the product. So the rule is now positional: any label on + * `orangecat.ch` that is not reserved is a candidate site slug, and whether a + * site actually EXISTS is decided downstream by `/sites/<slug>`, which can read + * the database because it is a page, not middleware. + * + * That split is the whole design: + * + * this file (edge, pure) — is this host SHAPED like a hosted site? + * services/sites (page, DB) — is there a published site at that slug? + * + * Resolving "shaped like" without a database is what keeps `middleware.ts` free + * of a query on the hot path of every request to the entire app. Guessing wrong + * is cheap and safe: an unclaimed slug rewrites to `/sites/<slug>` and 404s. + * + * Three ways a request reaches a site: * 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 + * substrata.example — a custom domain (resolved from the database, + * since no pattern can recognise one) * - * 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 path form `/sites/<slug>` always works on any host. That is what makes a + * site previewable before its DNS exists, and it is the URL the tests use. */ /** The domain every hosted site gets a free subdomain on. */ @@ -32,105 +40,151 @@ 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; - }; -} +/** + * Labels on `orangecat.ch` that must never resolve to a customer's site. + * + * Two different harms, kept in one list because one lookup should answer both: + * + * - **Infrastructure.** These hosts already serve something else on this box. + * A site claiming one would either be shadowed by its Caddy block or, worse, + * shadow it — and the whole point of on-demand TLS is that Caddy asks US + * which hostnames are real, so this list is load-bearing for certificates. + * - **Impersonation.** A site at `security.orangecat.ch` is a phish with a + * valid certificate and our domain in the URL bar. `RESERVED_USERNAMES` + * already refuses these as handles for the same reason; a subdomain is a + * stronger claim than a handle, so it cannot be a weaker check. + * + * Keep in sync with `/etc/caddy/Caddyfile` — `scripts/ci/check-reserved-subdomains.sh` + * fails the build if a live host on the box is missing here. + */ +export const RESERVED_SUBDOMAINS: ReadonlyArray<{ label: string; why: string }> = [ + // Live on the box today (one Caddy block each, on their own ports). + { label: 'www', why: 'the platform itself' }, + { label: 'bridge', why: 'the agent bridge (port 4001)' }, + { label: 'fleetcrown', why: 'FleetCrown (port 4002)' }, + { label: 'evig', why: 'Evig (port 4004)' }, + { label: 'revampit', why: 'redirects to evig' }, + { label: 'supabase', why: 'the database and its auth endpoints' }, + { label: 'solon', why: 'Solon governance' }, -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' }, - }, + // Infrastructure names a future block will want, claimed before a customer can. + { label: 'api', why: 'the public API surface' }, + { label: 'app', why: 'reads as the platform itself' }, + { label: 'cdn', why: 'asset host' }, + { label: 'static', why: 'asset host' }, + { label: 'assets', why: 'asset host' }, + { label: 'mail', why: 'mail host — an MX name is a phishing primitive' }, + { label: 'smtp', why: 'mail host' }, + { label: 'imap', why: 'mail host' }, + { label: 'ns1', why: 'nameserver' }, + { label: 'ns2', why: 'nameserver' }, + { label: 'mx', why: 'mail host' }, + { label: 'vpn', why: 'network infrastructure' }, + { label: 'db', why: 'database host' }, + { label: 'status', why: 'status page — must be trustworthy during an incident' }, + { label: 'staging', why: 'deploy environment' }, + { label: 'dev', why: 'deploy environment' }, + { label: 'test', why: 'deploy environment' }, + { label: 'preview', why: 'deploy environment' }, + + // Impersonation. Mirrors RESERVED_USERNAMES — a subdomain claims more, not less. + { label: 'admin', why: 'impersonates platform staff' }, + { label: 'support', why: 'impersonates platform staff' }, + { label: 'help', why: 'impersonates platform staff' }, + { label: 'security', why: 'impersonates platform staff — the highest-value phish' }, + { label: 'billing', why: 'impersonates platform staff on a money topic' }, + { label: 'payments', why: 'impersonates platform staff on a money topic' }, + { label: 'pay', why: 'impersonates platform staff on a money topic' }, + { label: 'wallet', why: 'impersonates platform staff on a money topic' }, + { label: 'login', why: 'a credential-harvesting host under our own certificate' }, + { label: 'signin', why: 'a credential-harvesting host under our own certificate' }, + { label: 'auth', why: 'a credential-harvesting host under our own certificate' }, + { label: 'account', why: 'a credential-harvesting host under our own certificate' }, + { label: 'accounts', why: 'a credential-harvesting host under our own certificate' }, + { label: 'system', why: 'impersonates the platform' }, + { label: 'official', why: 'impersonates the platform' }, + { label: 'orangecat', why: 'the platform itself' }, + { label: 'root', why: 'impersonates the platform' }, ]; -/** Strip the port and lowercase, so `Substrate.localhost:3020` matches. */ -function normaliseHost(host: string): string { +const RESERVED_SET: ReadonlySet<string> = new Set(RESERVED_SUBDOMAINS.map(entry => entry.label)); + +/** @returns why this label is reserved, or null if it is free. */ +export function reservedSubdomainReason(label: string): string | null { + return RESERVED_SUBDOMAINS.find(entry => entry.label === label.toLowerCase())?.why ?? null; +} + +export function isReservedSubdomain(label: string): boolean { + return RESERVED_SET.has(label.toLowerCase()); +} + +/** + * The shape a slug must have to be a subdomain at all. + * + * Stricter than a group slug on purpose: this string becomes a DNS label and a + * certificate subject. No leading or trailing hyphen, no underscores, 1–63 + * characters, which is what RFC 1123 permits. + */ +export const SITE_SLUG_PATTERN = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/; + +export function isValidSiteSlug(slug: string): boolean { + return SITE_SLUG_PATTERN.test(slug); +} + +/** Strip the port and lowercase, so `Substrata.localhost:3020` matches. */ +export function normaliseHost(host: string): string { return host.trim().toLowerCase().split(':')[0]; } /** - * Resolve a request Host header to a hosted site. + * The slug a Host header is asking for, by SHAPE alone. + * + * Pure, allocation-light and database-free: this runs in edge middleware on + * every request to the whole app. It answers "could this be a hosted site?" — + * never "does that site exist", which only the page can know. * - * 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. + * @returns the slug, or null when the host is not a hosted-site host. */ -export function siteForHost(host: string | null | undefined): HostedSite | null { +export function siteSlugForHost(host: string | null | undefined): string | 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; + for (const suffix of [`.${SITES_BASE_DOMAIN}`, '.localhost']) { + if (!bare.endsWith(suffix)) { + continue; } - // 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; + const label = bare.slice(0, -suffix.length); + // Exactly one label. `a.b.orangecat.ch` is not a hosted site — it is either + // a mistake or somebody probing, and a greedy match would hand them one. + if (!label || label.includes('.')) { + return null; } - if (site.customDomain && bare === normaliseHost(site.customDomain)) { - return site; - } - if (site.aliasHosts?.some(alias => bare === normaliseHost(alias))) { - return site; + if (isReservedSubdomain(label) || !isValidSiteSlug(label)) { + return null; } + return label; } 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. + * Always the `/sites/<slug>/...` path form rather than an absolute URL on the + * site's own domain. On the custom domain the rewrite makes this path resolve + * 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 { +export function siteHref(slug: string, path = ''): string { const suffix = path && path !== '/' ? `/${path.replace(/^\/+/, '')}` : ''; - return `${SITES_PATH_PREFIX}/${site.slug}${suffix}`; + return `${SITES_PATH_PREFIX}/${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}`; +/** The free subdomain a slug gets, whether or not it has a custom domain yet. */ +export function siteSubdomainHost(slug: string): string { + return `${slug}.${SITES_BASE_DOMAIN}`; } diff --git a/src/middleware.ts b/src/middleware.ts index 731bd102c..c9b64c73c 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -2,7 +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'; +import { SITES_PATH_PREFIX, siteSlugForHost } from '@/config/sites'; // Edge middleware route classification reads from the SAME SSOT used by // AppShell / MobileBottomNav / Footer / Header (src/config/routes.ts). @@ -42,13 +42,18 @@ export async function middleware(request: NextRequest) { // 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}/`)) { + // + // `siteSlugForHost` matches by SHAPE and never touches the database — this + // runs on every request to the entire app. Whether a site is actually + // published is decided by the page, which is allowed to query. An unclaimed + // slug therefore rewrites and then 404s, which is the safe direction. + const siteSlug = siteSlugForHost(request.headers.get('host')); + if (siteSlug && !pathname.startsWith(`${SITES_PATH_PREFIX}/`)) { const target = request.nextUrl.clone(); - target.pathname = `${SITES_PATH_PREFIX}/${hostedSite.slug}${pathname === '/' ? '' : pathname}`; + target.pathname = `${SITES_PATH_PREFIX}/${siteSlug}${pathname === '/' ? '' : pathname}`; const rewritten = NextResponse.rewrite(target); rewritten.headers.set('x-pathname', target.pathname); - rewritten.headers.set('x-hosted-site', hostedSite.slug); + rewritten.headers.set('x-hosted-site', siteSlug); return rewritten; } diff --git a/src/services/sites/registry.ts b/src/services/sites/registry.ts new file mode 100644 index 000000000..614164199 --- /dev/null +++ b/src/services/sites/registry.ts @@ -0,0 +1,186 @@ +/** + * Which sites exist, and at what hostnames — the database half of hosted sites. + * + * `src/config/sites.ts` decides, at the edge and without a query, whether a Host + * header is SHAPED like a hosted site. This module answers the question that + * actually needs data: is there a published site at that slug, and what is it? + * + * Read as ANON on purpose. `createPublicClient` carries no session, so every + * answer here is one an anonymous visitor could have got for themselves — which + * is the correct definition of "published", and it means the RLS policy added in + * `20260827090000_hosted_sites_public_read.sql` is the authority rather than a + * condition duplicated in TypeScript. Unpublishing is `enabled = false`, and it + * takes effect here without any code knowing it happened. + */ + +import { unstable_cache } from 'next/cache'; +import { createPublicClient } from '@/lib/supabase/public'; +import { + ALWAYS_PUBLISHED, + HOSTED_SITE_FALLBACKS, + SITE_FEATURE_KEY, + siteCanonicalHost, + toHostedSite, + type HostedSite, +} from '@/config/hosted-site'; +import type { SiteProfile } from '@/config/site-profile'; +import { isValidSiteSlug, normaliseHost, SITES_BASE_DOMAIN } from '@/config/sites'; +import { logger } from '@/utils/logger'; + +/** + * How long a resolved site is held. + * + * Short, because this is the latency between a customer flipping the switch in + * FleetCrown and their website answering — the "few clicks" promise is measured + * through this constant. A minute is fast enough to feel immediate and long + * enough that a page of a busy site is not eight queries. + */ +const SITE_CACHE_TTL_SECONDS = 60; + +const SITE_SELECT = + 'config, groups!inner(slug, name, description, label, tags, is_public, bitcoin_address, lightning_address)'; + +interface SiteFeatureRow { + config: unknown; + groups: { + slug: string; + name: string; + description: string | null; + label: string | null; + tags: string[] | null; + is_public: boolean | null; + bitcoin_address: string | null; + lightning_address: string | null; + } | null; +} + +/** + * A published site and the profile it renders from, together. + * + * One shape because they come from one row. Returning them separately would + * mean two queries and, worse, a moment where a site exists but its content + * does not. + */ +export interface ResolvedSite { + site: HostedSite; + /** Null for a bespoke site, whose content is in the repository. */ + profile: SiteProfile | null; +} + +function rowToResolved(row: SiteFeatureRow | null): ResolvedSite | null { + if (!row?.groups || row.groups.is_public === false) { + return null; + } + const group = row.groups; + const site = toHostedSite({ slug: group.slug, name: group.name }, row.config); + return { + site, + profile: site.builder + ? null + : { + slug: group.slug, + name: group.name, + description: group.description, + label: group.label, + tags: group.tags ?? [], + bitcoinAddress: group.bitcoin_address, + lightningAddress: group.lightning_address, + canonicalHost: siteCanonicalHost(site), + }, + }; +} + +/** + * The site published at this slug, or null. + * + * Sites in `ALWAYS_PUBLISHED` short-circuit: their pages live entirely in the + * repository, so making their existence depend on a reachable database would + * mean a build-time render of a fully static site could fail on a network blip. + */ +async function loadSiteBySlug(slug: string): Promise<ResolvedSite | null> { + if (!isValidSiteSlug(slug)) { + return null; + } + if (ALWAYS_PUBLISHED.includes(slug)) { + const site = HOSTED_SITE_FALLBACKS[slug]; + return site ? { site, profile: null } : null; + } + + try { + const { data, error } = await createPublicClient() + .from('group_features') + .select(SITE_SELECT) + .eq('feature_key', SITE_FEATURE_KEY) + .eq('enabled', true) + .eq('groups.slug', slug) + .maybeSingle<SiteFeatureRow>(); + + if (error) { + logger.warn('Hosted site lookup failed', { slug, error: error.message }, 'Sites'); + return null; + } + return rowToResolved(data); + } catch (error) { + logger.warn('Hosted site lookup threw', { slug, error: String(error) }, 'Sites'); + return null; + } +} + +export const siteBySlug = unstable_cache(loadSiteBySlug, ['hosted-site-by-slug'], { + revalidate: SITE_CACHE_TTL_SECONDS, + tags: ['hosted-sites'], +}); + +/** + * The site answering on this hostname, or null. + * + * Two shapes, in cost order. A free subdomain is decided by string arithmetic + * plus one lookup by slug. A custom domain has no pattern to match — the + * hostname is whatever the customer bought — so it costs a query against the + * partial indexes the same migration created. + */ +async function loadSiteByHost(host: string): Promise<ResolvedSite | null> { + const hostname = normaliseHost(host); + const bare = hostname.startsWith('www.') ? hostname.slice(4) : hostname; + + const suffix = `.${SITES_BASE_DOMAIN}`; + if (bare.endsWith(suffix)) { + const label = bare.slice(0, -suffix.length); + if (label && !label.includes('.')) { + return loadSiteBySlug(label); + } + } + + try { + const client = createPublicClient(); + const { data, error } = await client + .from('group_features') + .select(SITE_SELECT) + .eq('feature_key', SITE_FEATURE_KEY) + .eq('enabled', true) + .or(`config->>customDomain.eq.${bare},config->aliasHosts.cs.["${bare}"]`) + .limit(1) + .maybeSingle<SiteFeatureRow>(); + + if (error) { + logger.warn('Custom-domain lookup failed', { host: bare, error: error.message }, 'Sites'); + return null; + } + const resolved = rowToResolved(data); + // Trust the row only if it really claims this hostname. `.or` is a filter, + // not a proof, and a certificate is about to be issued on the strength of it. + const site = resolved?.site; + if (site && (site.customDomain === bare || site.aliasHosts.includes(bare))) { + return resolved; + } + return null; + } catch (error) { + logger.warn('Custom-domain lookup threw', { host: bare, error: String(error) }, 'Sites'); + return null; + } +} + +export const siteByHost = unstable_cache(loadSiteByHost, ['hosted-site-by-host'], { + revalidate: SITE_CACHE_TTL_SECONDS, + tags: ['hosted-sites'], +}); diff --git a/supabase/migrations/20260827090000_hosted_sites_public_read.sql b/supabase/migrations/20260827090000_hosted_sites_public_read.sql new file mode 100644 index 000000000..2457c1e0b --- /dev/null +++ b/supabase/migrations/20260827090000_hosted_sites_public_read.sql @@ -0,0 +1,52 @@ +-- Hosted sites: let the public read the fact that a site is published. +-- +-- A hosted site is a `group_features` row with feature_key = 'site'. No new +-- table: that row already carries group_id, enabled, an audit column and a +-- `config` jsonb, and `src/config/group-features.ts` already treats "adding a +-- feature" as the way a group gains a capability. A website is a capability. +-- +-- THE PROBLEM THIS FIXES +-- +-- `group_features_select` restricts SELECT to members of the group. That is +-- right for treasury and voting, and wrong for a website: the entire purpose of +-- substrata.orangecat.ch is that a stranger can load it. Without this policy a +-- published site is invisible to exactly the audience it exists for, and the +-- app would have to answer "is this site published?" with a service-role client +-- — which moves a public/private decision out of the database and into code +-- that can drift from it. +-- +-- So the database keeps the answer. A site row is world-readable when, and only +-- when, it is switched on AND its group is public. Turning `enabled` off, or +-- making the group private, unpublishes the website in the same instant — one +-- fact, one place. +-- +-- Nothing else about group_features changes: every other feature_key stays +-- members-only, because the existing policy is unmodified and policies are ORed. + +CREATE POLICY group_features_select_site_anon ON public.group_features + FOR SELECT + TO anon, authenticated + USING ( + feature_key = 'site' + AND enabled = true + AND EXISTS ( + SELECT 1 + FROM public.groups g + WHERE g.id = group_features.group_id + AND g.is_public = true + ) + ); + +COMMENT ON POLICY group_features_select_site_anon ON public.group_features IS + 'A published hosted site is public by definition — see src/config/hosted-site.ts. Scoped to feature_key = ''site'' so every other feature stays members-only.'; + +-- Resolving a CUSTOM domain is the one lookup that cannot be a pattern match: +-- given "substrata.ch", which site is that? Both indexes are partial on the +-- site rows, so they cost nothing for the other feature keys. +CREATE INDEX IF NOT EXISTS group_features_site_custom_domain_idx + ON public.group_features ((config ->> 'customDomain')) + WHERE feature_key = 'site'; + +CREATE INDEX IF NOT EXISTS group_features_site_alias_hosts_idx + ON public.group_features USING gin ((config -> 'aliasHosts') jsonb_path_ops) + WHERE feature_key = 'site'; From 0d50293a5f8e639d7cd01e05937f6325223bbb06 Mon Sep 17 00:00:00 2001 From: Mao Nakamoto <georgy.butaev@revamp-it.ch> Date: Thu, 27 Aug 2026 09:53:48 +0200 Subject: [PATCH 13/17] feat(sites): a certificate for any customer domain, with no ssh MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Positional host resolution gets a site routed. It does not get it a TLS certificate, and without one the site is unreachable — so this is the other half of "spinning up a site costs no infrastructure work". The alternative was a wildcard certificate: a DNS-01 challenge, an Infomaniak API token living on the box, a Caddy DNS plugin — and it still would not cover a customer's own domain. On-demand TLS needs none of it and inverts the relationship correctly: Caddy asks OrangeCat whether a hostname is real before ordering a certificate, and OrangeCat already knows, because a site is a row. `/api/internal/tls-check` is that gate, and it is written as a gate. Every 200 is an ACME order and Let's Encrypt rate-limits an account that fails them, so anyone who points a hostname here and requests it is spending our issuance budget. Reserved subdomains are refused before any query runs, and a lookup that throws denies — a database blip must not become an open certificate mint. Six tests hold exactly those cases, including the fail-closed one. The Caddy block lives in `deployment/caddy/` rather than only on the box, because configuration that exists only on a box is configuration nobody can review. `install-hosted-sites-caddy.sh` refuses to run until the ask endpoint answers 200, backs up the Caddyfile, validates before reloading, and re-checks the neighbours afterwards — one bad Caddyfile takes down all twenty-odd apps. **RESERVED_SUBDOMAINS was wrong, and now cannot be.** Written by hand, it held seven labels. The box is serving twenty-two. Fourteen live hostnames — kivvi, vitareba, solon, supabase among them — were claimable as site slugs the moment resolution became positional. The list is now generated from Caddy into `deployment/reserved-hosts.txt`, and `check:reserved-hosts` is in `verify`, so the next app deployed on this box fails the build until it is reserved. The gate was tested by deleting an entry and confirming it goes red. Live Caddyfile deliberately untouched: the ask endpoint is not deployed yet, and enabling on-demand TLS before it exists would deny every request. Install after the deploy. 438 tests green. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --- __tests__/unit/api/tls-check-api.test.ts | 108 +++++++++++++++++++++++ __tests__/unit/config/substrata.test.ts | 13 ++- deployment/caddy/hosted-sites.caddy | 43 +++++++++ deployment/reserved-hosts.txt | 34 +++++++ package.json | 6 +- scripts/ci/check-reserved-hosts.sh | 46 ++++++++++ scripts/ci/install-hosted-sites-caddy.sh | 64 ++++++++++++++ scripts/ci/sync-reserved-hosts.sh | 34 +++++++ src/app/api/internal/tls-check/route.ts | 76 ++++++++++++++++ src/config/sites.ts | 36 ++++++-- src/config/substrata.ts | 10 ++- 11 files changed, 458 insertions(+), 12 deletions(-) create mode 100644 __tests__/unit/api/tls-check-api.test.ts create mode 100644 deployment/caddy/hosted-sites.caddy create mode 100644 deployment/reserved-hosts.txt create mode 100755 scripts/ci/check-reserved-hosts.sh create mode 100755 scripts/ci/install-hosted-sites-caddy.sh create mode 100755 scripts/ci/sync-reserved-hosts.sh create mode 100644 src/app/api/internal/tls-check/route.ts diff --git a/__tests__/unit/api/tls-check-api.test.ts b/__tests__/unit/api/tls-check-api.test.ts new file mode 100644 index 000000000..e0af550dd --- /dev/null +++ b/__tests__/unit/api/tls-check-api.test.ts @@ -0,0 +1,108 @@ +/** + * @jest-environment node + * + * Node rather than jsdom: this route is plain web-standard Request/Response, + * which jsdom does not provide as globals. + */ +/** + * This endpoint decides whether Caddy orders a TLS certificate, which makes it + * the one place where being generous is expensive. + * + * Every 200 is an ACME order against our Let's Encrypt account, and that account + * is rate-limited on failures. Anyone who points a hostname at this box and + * requests it can spend that budget, so the rules being tested here are: only a + * site that is published RIGHT NOW, never a reserved subdomain, and — the one + * that is easy to get backwards — a denial rather than an approval when the + * lookup itself fails. + */ + +import { GET } from '@/app/api/internal/tls-check/route'; +import { siteByHost } from '@/services/sites/registry'; + +jest.mock('@/services/sites/registry', () => ({ siteByHost: jest.fn() })); +jest.mock('@/utils/logger', () => ({ + logger: { warn: jest.fn(), error: jest.fn(), info: jest.fn() }, +})); + +const mockSiteByHost = siteByHost as unknown as jest.Mock; + +function ask(domain?: string) { + const url = new URL('http://127.0.0.1:4003/api/internal/tls-check'); + if (domain !== undefined) { + url.searchParams.set('domain', domain); + } + return GET(new Request(url)); +} + +const publishedSite = { + site: { + slug: 'acme', + title: 'Acme', + customDomain: 'acme.example', + aliasHosts: [], + profile: { kind: 'group' as const, slug: 'acme' }, + builder: null, + }, + profile: null, +}; + +beforeEach(() => { + mockSiteByHost.mockReset(); +}); + +describe('tls-check — what earns a certificate', () => { + it('approves a hostname a published site answers on', async () => { + mockSiteByHost.mockResolvedValue(publishedSite); + expect((await ask('acme.orangecat.ch')).status).toBe(200); + }); + + it('refuses a hostname no site claims', async () => { + mockSiteByHost.mockResolvedValue(null); + expect((await ask('nobody.orangecat.ch')).status).toBe(403); + }); + + it('refuses without a domain, and refuses a malformed one', async () => { + expect((await ask()).status).toBe(403); + expect((await ask('')).status).toBe(403); + expect((await ask(`${'a'.repeat(300)}.example`)).status).toBe(403); + expect(mockSiteByHost).not.toHaveBeenCalled(); + }); +}); + +describe('tls-check — the refusals that matter', () => { + /** + * These already have certificates and their own Caddy blocks. Issuing a second + * one is waste at best; `security.orangecat.ch` under our certificate is a + * phish at worst. Refused BEFORE the lookup, so no database state can grant one. + */ + it('refuses reserved subdomains without ever asking the database', async () => { + mockSiteByHost.mockResolvedValue(publishedSite); + + for (const host of [ + 'supabase.orangecat.ch', + 'fleetcrown.orangecat.ch', + 'security.orangecat.ch', + 'www.orangecat.ch', + 'kivvi.orangecat.ch', + ]) { + expect((await ask(host)).status).toBe(403); + } + expect(mockSiteByHost).not.toHaveBeenCalled(); + }); + + it('refuses a multi-label subdomain, which no hosted site can have', async () => { + mockSiteByHost.mockResolvedValue(publishedSite); + expect((await ask('a.b.orangecat.ch')).status).toBe(403); + expect(mockSiteByHost).not.toHaveBeenCalled(); + }); + + /** + * The one that is easy to get backwards. A database blip must not turn this + * into an open certificate mint — an outage should cost new certificates, not + * hand them out. + */ + it('fails closed when the lookup throws', async () => { + mockSiteByHost.mockRejectedValue(new Error('database unreachable')); + expect((await ask('acme.example')).status).toBe(403); + }); +}); diff --git a/__tests__/unit/config/substrata.test.ts b/__tests__/unit/config/substrata.test.ts index 370c36a02..eedc86e91 100644 --- a/__tests__/unit/config/substrata.test.ts +++ b/__tests__/unit/config/substrata.test.ts @@ -43,6 +43,7 @@ import { } from '@/config/substrata-coverage'; import { GROUP_LABELS } from '@/config/group-labels'; import { GROUP_FEATURES } from '@/config/group-features'; +import { SITE_FEATURE_KEY } from '@/config/hosted-site'; import { GOVERNANCE_PRESETS } from '@/config/governance-presets'; import { isReservedUsername } from '@/config/usernames'; @@ -120,7 +121,17 @@ describe('Substrata — there is no trading desk, and nothing may imply one', () }); it('advertises no marketplace, because there is nothing to sell', () => { - expect(GROUP_FEATURE_KEYS).toEqual([]); + // Asserted as "no COMMERCE feature", not "no features". The list was empty + // when this was written and the emptiness stood in for the rule; it does not + // any more, because publishing a website is a feature and sells nothing. + // What must never appear here is a way to take money for research. + for (const commerce of ['marketplace', 'treasury', 'shared_wallet']) { + expect(GROUP_FEATURE_KEYS).not.toContain(commerce); + } + }); + + it('publishes a website, which is the feature row substrata.orangecat.ch reads', () => { + expect(GROUP_FEATURE_KEYS).toContain(SITE_FEATURE_KEY); }); it('keeps a desk only as a future phase, never as a running one', () => { diff --git a/deployment/caddy/hosted-sites.caddy b/deployment/caddy/hosted-sites.caddy new file mode 100644 index 000000000..210ce909d --- /dev/null +++ b/deployment/caddy/hosted-sites.caddy @@ -0,0 +1,43 @@ +# Hosted sites — every customer website, on one block, forever. +# +# Installed to /etc/caddy/apps.d/hosted-sites.caddy by +# scripts/ci/install-hosted-sites-caddy.sh. Lives in the repo because it is +# configuration, and configuration that exists only on a box is configuration +# nobody can review. +# +# WHY A CATCH-ALL AND NOT A BLOCK PER SITE +# +# A block per site means every customer is an ssh session. This block is the +# last resort — Caddy matches the most specific host first, so all twenty-odd +# named blocks above still win — and it answers for everything else: +# +# <slug>.orangecat.ch the free subdomain every hosted site starts on +# anything.example a customer's own domain, the hour they point it here +# +# WHY ON-DEMAND TLS +# +# A wildcard certificate would need a DNS-01 challenge, an Infomaniak API token +# sitting on this box, and a Caddy DNS plugin — and it still would not cover a +# customer's own domain. On-demand needs none of that: Caddy asks OrangeCat +# whether a hostname is real before it orders a certificate, and OrangeCat +# already knows, because a hosted site is a row in its database. +# +# The `ask` endpoint is the safety. Every yes is an ACME order and Let's Encrypt +# rate-limits an account that fails them, so /api/internal/tls-check answers 403 +# for anything that is not a published site right now — and fails closed if it +# cannot reach the database. Without that endpoint answering 200, this block +# serves nothing at all, which is the correct behaviour for a misconfiguration. +# +# Requires in the global block of /etc/caddy/Caddyfile: +# on_demand_tls { +# ask http://127.0.0.1:4003/api/internal/tls-check +# } +https:// { + encode zstd gzip + tls { + on_demand + } + reverse_proxy 127.0.0.1:4003 { + flush_interval -1 + } +} diff --git a/deployment/reserved-hosts.txt b/deployment/reserved-hosts.txt new file mode 100644 index 000000000..72c7db6f9 --- /dev/null +++ b/deployment/reserved-hosts.txt @@ -0,0 +1,34 @@ +# Subdomain labels currently served on bitbaum under *.orangecat.ch. +# +# Generated from the box, committed so CI can check it WITHOUT ssh: +# npm run sync:reserved-hosts +# +# Every label here must appear in RESERVED_SUBDOMAINS (src/config/sites.ts), +# because host resolution for hosted sites is positional — any non-reserved +# label on orangecat.ch is a candidate site slug. A label serving an app AND +# claimable as a site is a collision waiting to happen, and on-demand TLS +# would issue a second certificate for a name that already has one. +# +# check:reserved-hosts fails the build when this file and the code disagree. +annushka +aoz-wohnen +aoz +botsmann +bridge +camille +datacat +evig +fleetcrown +kivvi +petvity +printcraft +reparaturbonus +revamp-info +revampit +sbb +sink +solon +supabase +surf-your-life +vitareba +www diff --git a/package.json b/package.json index 3f1ab2987..c5f1bd8f9 100644 --- a/package.json +++ b/package.json @@ -39,7 +39,7 @@ "check:rpc-exists": "node scripts/check-rpc-exists.mjs", "check:ai-models": "node scripts/check-ai-models.mjs", "check:mdx": "node scripts/check-mdx.mjs", - "verify": "npm run ci:docs && npm run check:accent-ink && npm run type-check && npm run check:sizes && npm run audit:routes && npm run lint && npm run check:duplication && npm run check:dead-fields && npm run check:schema-columns && npm run check:currency-units && npm run check:rpc-exists && npm run check:mdx && npm run test:unit -- --watchAll=false", + "verify": "npm run ci:docs && npm run check:accent-ink && npm run type-check && npm run check:sizes && npm run audit:routes && npm run lint && npm run check:duplication && npm run check:dead-fields && npm run check:schema-columns && npm run check:currency-units && npm run check:reserved-hosts && npm run check:rpc-exists && npm run check:mdx && npm run test:unit -- --watchAll=false", "audit:schema": "node scripts/db/audit-schema-drift.mjs", "audit:routes": "node scripts/audit-routes.mjs", "gen:types": "bash scripts/db/gen-types.sh", @@ -105,7 +105,9 @@ "test:e2e:matrix": "playwright test tests/e2e/workflow-matrix.spec.ts --project=chromium --reporter=line", "test:e2e:matrix:p0": "playwright test tests/e2e/workflow-matrix.spec.ts --project=chromium --grep @p0 --reporter=line", "db:audit": "node scripts/db-audit.mjs", - "eval:voice": "node scripts/eval-voice-routing.mjs" + "eval:voice": "node scripts/eval-voice-routing.mjs", + "check:reserved-hosts": "bash scripts/ci/check-reserved-hosts.sh", + "sync:reserved-hosts": "bash scripts/ci/sync-reserved-hosts.sh" }, "dependencies": { "@asteasolutions/zod-to-openapi": "^7.3.4", diff --git a/scripts/ci/check-reserved-hosts.sh b/scripts/ci/check-reserved-hosts.sh new file mode 100755 index 000000000..392341f07 --- /dev/null +++ b/scripts/ci/check-reserved-hosts.sh @@ -0,0 +1,46 @@ +#!/usr/bin/env bash +# +# Every subdomain already serving traffic on bitbaum must be reserved in code. +# +# WHY THIS GATE EXISTS +# +# Hosted-site resolution is positional: any non-reserved label on orangecat.ch +# is a candidate site slug (src/config/sites.ts). That is what makes a new +# customer cost zero deploys, and it is also what makes RESERVED_SUBDOMAINS +# load-bearing. A label that serves an app AND is claimable as a site is a +# collision — and with on-demand TLS it is also a second certificate order for a +# hostname that already has one. +# +# The first version of that list was written by hand and was missing fourteen of +# the twenty-two hosts that were already live. So it is no longer remembered: it +# is generated from the box (`npm run sync:reserved-hosts`), committed, and +# checked here. Nobody has to notice. +# +# Runs with no network and no ssh, which is why the manifest is committed. +set -euo pipefail + +cd "$(dirname "$0")/../.." + +MANIFEST="deployment/reserved-hosts.txt" +SOURCE="src/config/sites.ts" + +[ -f "$MANIFEST" ] || { echo "✗ missing $MANIFEST"; exit 1; } +[ -f "$SOURCE" ] || { echo "✗ missing $SOURCE"; exit 1; } + +# Labels the code reserves: the `label: '...'` field of each RESERVED_SUBDOMAINS entry. +reserved=$(grep -oE "label: '[a-z0-9-]+'" "$SOURCE" | sed "s/label: '//; s/'//" | sort -u) +live=$(grep -vE '^\s*(#|$)' "$MANIFEST" | tr -d ' \t' | sort -u) + +missing=$(comm -23 <(echo "$live") <(echo "$reserved")) + +if [ -n "$missing" ]; then + echo "✗ These hosts serve traffic on bitbaum but are NOT reserved in $SOURCE:" + echo "$missing" | sed 's/^/ /' + echo + echo " Each one is claimable as a hosted-site slug right now. Add them to" + echo " RESERVED_SUBDOMAINS, or if the host is gone, refresh the manifest:" + echo " npm run sync:reserved-hosts" + exit 1 +fi + +echo "✓ reserved subdomains cover all $(echo "$live" | wc -l | tr -d ' ') live hosts" diff --git a/scripts/ci/install-hosted-sites-caddy.sh b/scripts/ci/install-hosted-sites-caddy.sh new file mode 100755 index 000000000..69af3bf2a --- /dev/null +++ b/scripts/ci/install-hosted-sites-caddy.sh @@ -0,0 +1,64 @@ +#!/usr/bin/env bash +# +# Install the hosted-sites Caddy block on bitbaum. Idempotent, and validates +# before it reloads — a bad Caddyfile takes down all twenty-odd apps on the box, +# so this never writes and hopes. +# +# Run AFTER the code carrying /api/internal/tls-check is deployed. Without that +# endpoint every certificate request is denied, which is safe but inert. +# +# bash scripts/ci/install-hosted-sites-caddy.sh +set -euo pipefail + +cd "$(dirname "$0")/../.." +BOX="${BITBAUM_SSH:-root@167.233.22.31}" +FRAGMENT="deployment/caddy/hosted-sites.caddy" +ASK_URL="http://127.0.0.1:4003/api/internal/tls-check" + +[ -f "$FRAGMENT" ] || { echo "✗ missing $FRAGMENT"; exit 1; } + +echo "→ checking the ask endpoint is live before enabling on-demand TLS" +if ! ssh -o BatchMode=yes "$BOX" "curl -fsS -o /dev/null -w '%{http_code}' '${ASK_URL}?domain=substrata.orangecat.ch'" | grep -q 200; then + echo "✗ ${ASK_URL} did not answer 200 for a known site." + echo " Deploy the code first — otherwise every certificate request is denied." + exit 1 +fi + +echo "→ installing" +scp -q "$FRAGMENT" "$BOX:/etc/caddy/apps.d/hosted-sites.caddy" + +ssh -o BatchMode=yes "$BOX" 'bash -s' <<'REMOTE' +set -euo pipefail +cd /etc/caddy +cp Caddyfile "Caddyfile.bak-$(date +%Y%m%d-%H%M%S)" + +# Add the global on_demand_tls option once. Global options must be in the main +# Caddyfile's first block; apps.d is imported at the end, so it cannot go there. +if ! grep -q 'on_demand_tls' Caddyfile; then + python3 - <<'PY' +import pathlib +p = pathlib.Path('/etc/caddy/Caddyfile'); s = p.read_text() +old = "\tservers {\n\t\tprotocols h1 h2\n\t}\n}" +new = ("\tservers {\n\t\tprotocols h1 h2\n\t}\n" + "\ton_demand_tls {\n" + "\t\task http://127.0.0.1:4003/api/internal/tls-check\n" + "\t}\n}") +assert old in s, "global block not in the expected shape — install by hand" +p.write_text(s.replace(old, new, 1)) +PY + echo " + on_demand_tls ask added to the global block" +else + echo " = on_demand_tls already configured" +fi + +caddy validate --config /etc/caddy/Caddyfile --adapter caddyfile >/dev/null 2>&1 \ + || { echo "✗ Caddyfile invalid — NOT reloading"; exit 1; } +systemctl reload caddy +echo " ✓ caddy reloaded" +REMOTE + +echo "→ verifying the existing hosts still answer" +for host in orangecat.ch fleetcrown.orangecat.ch supabase.orangecat.ch; do + code=$(curl -s -o /dev/null -w '%{http_code}' "https://$host/" || echo "000") + echo " $host → $code" +done diff --git a/scripts/ci/sync-reserved-hosts.sh b/scripts/ci/sync-reserved-hosts.sh new file mode 100755 index 000000000..08ea42e81 --- /dev/null +++ b/scripts/ci/sync-reserved-hosts.sh @@ -0,0 +1,34 @@ +#!/usr/bin/env bash +# +# Regenerate deployment/reserved-hosts.txt from what Caddy actually serves. +# +# Needs ssh to the box, which is why the RESULT is committed and the CHECK +# (check-reserved-hosts.sh) reads the committed file instead of running this. +# Run it after adding or removing an app on bitbaum. +set -euo pipefail + +cd "$(dirname "$0")/../.." +BOX="${BITBAUM_SSH:-root@167.233.22.31}" + +labels=$(ssh -o BatchMode=yes "$BOX" "caddy adapt --config /etc/caddy/Caddyfile 2>/dev/null" \ + | python3 -c " +import json,sys +d=json.load(sys.stdin); hosts=set() +for srv in d.get('apps',{}).get('http',{}).get('servers',{}).values(): + for route in srv.get('routes',[]): + for m in route.get('match',[]): + for h in m.get('host',[]): hosts.add(h) +suffix='.orangecat.ch' +for h in sorted(hosts): + if h.endswith(suffix): print(h[:-len(suffix)]) +") + +[ -n "$labels" ] || { echo "✗ Caddy returned no orangecat.ch hosts — refusing to write an empty manifest"; exit 1; } + +{ + sed -n '1,/^# check:reserved-hosts/p' deployment/reserved-hosts.txt + echo "$labels" +} > deployment/reserved-hosts.txt.new +mv deployment/reserved-hosts.txt.new deployment/reserved-hosts.txt + +echo "✓ manifest refreshed with $(echo "$labels" | wc -l | tr -d ' ') hosts" diff --git a/src/app/api/internal/tls-check/route.ts b/src/app/api/internal/tls-check/route.ts new file mode 100644 index 000000000..4312f5cb0 --- /dev/null +++ b/src/app/api/internal/tls-check/route.ts @@ -0,0 +1,76 @@ +/** + * GET /api/internal/tls-check?domain=<host> — may Caddy issue a certificate? + * + * This is the endpoint Caddy's `on_demand_tls { ask ... }` calls before it + * obtains a certificate for a hostname it has never seen. 200 means yes. + * + * WHY THIS EXISTS + * + * The alternative to on-demand TLS is a wildcard certificate, which needs a + * DNS-01 challenge, an Infomaniak API token on the box, and a Caddy plugin — and + * it still would not cover a customer's own domain. On-demand needs none of + * that, and it inverts the relationship in the right direction: instead of + * infrastructure being edited every time a customer appears, Caddy ASKS + * OrangeCat which hostnames are real, and OrangeCat already knows. + * + * THIS IS A GATE, NOT A LOOKUP + * + * Answering 200 too freely is a denial of service against ourselves: every yes + * is an ACME order, and Let's Encrypt rate-limits an account that fails them. + * Somebody who points `whatever.example` at this box and requests it can + * therefore burn our issuance budget. So the rule is strict — the hostname must + * resolve to a site that is published RIGHT NOW, reserved subdomains are + * refused before any query, and anything unrecognised is a flat 403. + * + * Internal by convention only: Caddy calls it over loopback, and it discloses + * nothing an ordinary visitor could not learn by loading the site itself. + */ + +import { isReservedSubdomain, normaliseHost, SITES_BASE_DOMAIN } from '@/config/sites'; +import { siteByHost } from '@/services/sites/registry'; +import { logger } from '@/utils/logger'; + +/** Caddy blocks on this call, so it must be quick and must never hang. */ +export const dynamic = 'force-dynamic'; + +// Plain `Response`, not `NextResponse`: this returns a status and a line of +// text, and nothing here needs Next's cookie or rewrite helpers. +function deny(reason: string): Response { + // Caddy treats any non-2xx as "do not issue". The body is for our logs. + return new Response(reason, { status: 403 }); +} + +export async function GET(request: Request): Promise<Response> { + const domain = new URL(request.url).searchParams.get('domain'); + if (!domain) { + return deny('missing domain'); + } + + const host = normaliseHost(domain); + if (!host || host.length > 253) { + return deny('malformed domain'); + } + + // Reserved names are refused before a query runs. They have their own Caddy + // blocks and their own certificates; issuing a second one here would at best + // be waste and at worst hand `security.orangecat.ch` to whoever asked. + const suffix = `.${SITES_BASE_DOMAIN}`; + if (host.endsWith(suffix)) { + const label = host.slice(0, -suffix.length); + if (!label || label.includes('.') || isReservedSubdomain(label)) { + return deny('reserved or malformed subdomain'); + } + } + + try { + const resolved = await siteByHost(host); + if (!resolved) { + return deny('no published site answers on this host'); + } + return new Response('ok', { status: 200 }); + } catch (error) { + // Fail CLOSED. A database blip must not become an open certificate mint. + logger.warn('TLS check failed', { host, error: String(error) }, 'Sites'); + return deny('lookup failed'); + } +} diff --git a/src/config/sites.ts b/src/config/sites.ts index 11533daf7..abd33ff34 100644 --- a/src/config/sites.ts +++ b/src/config/sites.ts @@ -54,18 +54,38 @@ export const SITES_PATH_PREFIX = '/sites'; * already refuses these as handles for the same reason; a subdomain is a * stronger claim than a handle, so it cannot be a weaker check. * - * Keep in sync with `/etc/caddy/Caddyfile` — `scripts/ci/check-reserved-subdomains.sh` - * fails the build if a live host on the box is missing here. + * The live half is generated from the box into `deployment/reserved-hosts.txt` + * and checked by `npm run check:reserved-hosts`, which is in `verify`. It is a + * generated-and-checked list rather than a remembered one because the first + * hand-written version of it was missing fourteen of the twenty-two hosts that + * were already serving traffic. */ export const RESERVED_SUBDOMAINS: ReadonlyArray<{ label: string; why: string }> = [ - // Live on the box today (one Caddy block each, on their own ports). - { label: 'www', why: 'the platform itself' }, - { label: 'bridge', why: 'the agent bridge (port 4001)' }, - { label: 'fleetcrown', why: 'FleetCrown (port 4002)' }, - { label: 'evig', why: 'Evig (port 4004)' }, + // Live on the box today — one Caddy block each. Kept in step with + // `deployment/reserved-hosts.txt` by `npm run check:reserved-hosts`, because a + // hand-maintained copy of this list had already drifted by fourteen entries. + { label: 'annushka', why: 'Annushka — a live app on this box' }, + { label: 'aoz-wohnen', why: 'AOZ Wohnen — a live app on this box' }, + { label: 'aoz', why: 'AOZ — a live app on this box' }, + { label: 'botsmann', why: 'Botsmann — a live app on this box' }, + { label: 'bridge', why: 'the agent bridge' }, + { label: 'camille', why: 'Camille Boulangerie — a live app on this box' }, + { label: 'datacat', why: 'DataCat — a live app on this box' }, + { label: 'evig', why: 'Evig' }, + { label: 'fleetcrown', why: 'FleetCrown' }, + { label: 'kivvi', why: 'Kivvi — a live app on this box' }, + { label: 'petvity', why: 'Petvity — a live app on this box' }, + { label: 'printcraft', why: 'PrintCraft — a live app on this box' }, + { label: 'reparaturbonus', why: 'Reparaturbonus ZH — a live app on this box' }, + { label: 'revamp-info', why: 'Revamp Info — a live app on this box' }, { label: 'revampit', why: 'redirects to evig' }, - { label: 'supabase', why: 'the database and its auth endpoints' }, + { label: 'sbb', why: 'SBB Lost & Found — a live app on this box' }, + { label: 'sink', why: 'Sink — a live app on this box' }, { label: 'solon', why: 'Solon governance' }, + { label: 'supabase', why: 'the database and its auth endpoints' }, + { label: 'surf-your-life', why: 'Surf Your Life — a live app on this box' }, + { label: 'vitareba', why: 'Vitareba — a live app on this box' }, + { label: 'www', why: 'the platform itself' }, // Infrastructure names a future block will want, claimed before a customer can. { label: 'api', why: 'the public API surface' }, diff --git a/src/config/substrata.ts b/src/config/substrata.ts index c6edd7955..2145e639a 100644 --- a/src/config/substrata.ts +++ b/src/config/substrata.ts @@ -33,6 +33,8 @@ * Created: 2026-08-26 */ +import { SITE_FEATURE_KEY } from './hosted-site'; + // ===================================================================== // OWNERSHIP // ===================================================================== @@ -566,7 +568,13 @@ export const GROUP_PAYLOAD: CompanyGroupPayload = { * 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[] = []; +export const GROUP_FEATURE_KEYS: readonly string[] = [ + // Substrata publishes a website at substrata.orangecat.ch. This row IS the + // site — see src/config/hosted-site.ts. Its pages are bespoke and live in the + // repository, so the site renders whether or not the seed has run; enabling + // the feature is what makes the profile agree with what is already serving. + SITE_FEATURE_KEY, +]; // ===================================================================== // LOOKUPS From 9d1a745f54c3e8d8fd7e2184c12c48a107cfca03 Mon Sep 17 00:00:00 2001 From: Mao Nakamoto <georgy.butaev@revamp-it.ch> Date: Thu, 27 Aug 2026 10:04:22 +0200 Subject: [PATCH 14/17] docs(sites): the runbook for turning a group into a website MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two audiences. Somebody publishing a site needs to know it is one row and no deploy — and that the minute before it answers is a named constant, not a mystery. Somebody deploying a new app on bitbaum needs to know why the build suddenly fails on reserved hosts, because that failure is the whole point of the gate and it will look like a bug the first time. Both one-time infrastructure steps are written down with the reasoning kept, including the one that cannot be automated from here: no Infomaniak API credentials exist on a developer machine, so the wildcard A record is a human action, once, forever. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --- docs/operations/hosted-sites.md | 148 ++++++++++++++++++++++++++++++++ 1 file changed, 148 insertions(+) create mode 100644 docs/operations/hosted-sites.md diff --git a/docs/operations/hosted-sites.md b/docs/operations/hosted-sites.md new file mode 100644 index 000000000..2137c2400 --- /dev/null +++ b/docs/operations/hosted-sites.md @@ -0,0 +1,148 @@ +--- +created_date: 2026-08-27 +last_modified_date: 2026-08-27 +last_modified_summary: First version — how a group becomes a website, and the two one-time infrastructure steps behind it. +--- + +# Hosted sites — turning a group into a website + +`/domains` sells one sentence: _"a working site, hosted and managed at +yourname.orangecat.ch — free. Move to your own domain when you are ready."_ +This is how that works, and what it costs. + +## Publishing a site + +**One row. No deploy, no ssh, no DNS.** + +```sql +INSERT INTO group_features (group_id, feature_key, enabled, enabled_by) +VALUES ('<group-uuid>', 'site', true, '<user-uuid>'); +``` + +The site is live at `<group-slug>.orangecat.ch` within a minute — that minute is +`SITE_CACHE_TTL_SECONDS` in `src/services/sites/registry.ts`. Setting +`enabled = false` unpublishes it just as fast. + +The pages are generated from the group's own profile (`src/config/site-profile.ts`): +its name, description, label, tags, and payment addresses if it has them. There +is no page builder and no second place to type the description, which is the +point — if the profile is good, the website is good. + +### Optional configuration + +`group_features.config` (jsonb), all fields optional: + +| Field | Default | Meaning | +| -------------- | ---------------- | ------------------------------------------------- | +| `title` | the group's name | browser tab / OG title | +| `customDomain` | `null` | canonical hostname once the owner points DNS here | +| `aliasHosts` | `[]` | extra hostnames answered but never advertised | + +Every field is validated with its own fallback, so one bad value costs that +field and never the site. + +## How a request gets there + +``` +Host: acme.orangecat.ch + │ + ├─ middleware.ts siteSlugForHost() — pure, no database. + │ "acme" is one non-reserved label → rewrite. + │ (Rewrite, not redirect: the URL bar keeps saying acme.) + │ + ├─ /sites/acme siteBySlug() — reads as ANON, so RLS decides + │ "published". No row → 404. + │ + └─ site-profile.ts the group's profile, rendered as pages. +``` + +The split matters: middleware runs on **every request to the whole app**, so it +may never query. It answers "is this shaped like a site?" An unclaimed slug +rewrites and 404s, which is the safe direction. + +`/sites/<slug>` also works on any host, which is how a site is previewed before +its DNS exists. + +## Reserved subdomains + +Host resolution is positional, so `RESERVED_SUBDOMAINS` (`src/config/sites.ts`) +is load-bearing twice: + +- **Infrastructure** — `supabase`, `fleetcrown`, `bridge`, and 19 others already + serve something on this box. +- **Impersonation** — `security.orangecat.ch` under our own certificate is a + phish, not a website. + +The infrastructure half is **generated, not remembered**. The first hand-written +version held 7 labels while the box was serving 22. + +```bash +npm run sync:reserved-hosts # regenerate from Caddy (needs ssh) +npm run check:reserved-hosts # in `verify`; fails if the two disagree +``` + +**Deploying a new app on bitbaum will fail the build until you run the sync.** +That is deliberate: the alternative is a hostname that serves an app _and_ is +claimable as a customer's site. + +## The two one-time infrastructure steps + +Both are done once, ever. After them no site needs infrastructure work again. + +### 1. Caddy — on-demand TLS + +```bash +bash scripts/ci/install-hosted-sites-caddy.sh +``` + +Installs `deployment/caddy/hosted-sites.caddy` as a catch-all block and adds +`on_demand_tls { ask ... }` to the global block. The script refuses to run until +`/api/internal/tls-check` answers 200, backs up the Caddyfile, validates before +reloading, and re-checks the neighbouring hosts afterwards — one bad Caddyfile +takes down every app on the box. + +A wildcard certificate was the alternative: DNS-01, an Infomaniak API token +living on the box, a Caddy DNS plugin — and it still would not have covered a +customer's own domain. + +### 2. DNS — the wildcard record + +At Infomaniak, on `orangecat.ch`: + +``` +* A 167.233.22.31 +``` + +**This is the only step that cannot be automated from a developer machine** — +no Infomaniak API credentials exist here. Existing named records keep +precedence, so nothing already live changes. + +A customer's own domain needs no record from us at all: they point it here, and +Caddy asks `/api/internal/tls-check` whether it is real. + +## Bespoke sites + +A site whose content is genuinely not profile-shaped gets a builder. Substrata +is the one example — a research corpus of tables, meters and a coverage ledger, +which no generic renderer should try to guess. + +Adding one means an entry in `BESPOKE_BUILDERS` and a function returning +`SitePage[]` from a closed set of section shapes (`src/config/site-content.ts`). +**This is the exception.** If you are reaching for it for an ordinary customer, +the profile builder is what should be improved instead — it costs zero lines per +customer, and a fix there reaches every site at once. + +## Verifying a site + +```bash +curl -sI https://acme.orangecat.ch/ | head -1 +curl -s https://acme.orangecat.ch/ | grep -o '<title>[^<]*' + +# Would Caddy issue a certificate for this host? +curl -s -o /dev/null -w '%{http_code}\n' \ + 'http://127.0.0.1:4003/api/internal/tls-check?domain=acme.orangecat.ch' +``` + +`403` from the last one means no published site answers on that hostname — check +`group_features.enabled`, that the group is `is_public`, and that the label is +not reserved. From 65a07e4c0b9def84b6ffc2921aca3d9c3e8da720 Mon Sep 17 00:00:00 2001 From: Mao Nakamoto Date: Thu, 27 Aug 2026 10:13:31 +0200 Subject: [PATCH 15/17] fix(verify): type-check the scripts, so the seed's types mean something MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous commit said binding seed-substrata.ts to the generated `Database` type 'moves that failure to npm run type-check'. That was not true as configured: tsconfig.json excludes scripts/**/*.ts so Next does not compile them, and the side effect was that verify never looked at them at all. The binding was decoration. A seed is exactly the wrong place for that gap. It runs by hand, on the box, months after the migration that broke it — the one script whose schema drift surfaces in front of a person rather than in CI. scripts/ was already at zero errors, so this gate starts green. Verified it bites by mistyping a column name and watching it go red. Deliberately not extended to __tests__: 119 pre-existing type errors live there and fixing them is real work that does not belong to whichever change happens to notice. Recorded rather than silently skipped. Co-Authored-By: Claude Opus 5 (1M context) --- package.json | 5 +++-- tsconfig.scripts.json | 23 +++++++++++++++++++++++ 2 files changed, 26 insertions(+), 2 deletions(-) create mode 100644 tsconfig.scripts.json diff --git a/package.json b/package.json index c5f1bd8f9..58fbe675e 100644 --- a/package.json +++ b/package.json @@ -39,7 +39,7 @@ "check:rpc-exists": "node scripts/check-rpc-exists.mjs", "check:ai-models": "node scripts/check-ai-models.mjs", "check:mdx": "node scripts/check-mdx.mjs", - "verify": "npm run ci:docs && npm run check:accent-ink && npm run type-check && npm run check:sizes && npm run audit:routes && npm run lint && npm run check:duplication && npm run check:dead-fields && npm run check:schema-columns && npm run check:currency-units && npm run check:reserved-hosts && npm run check:rpc-exists && npm run check:mdx && npm run test:unit -- --watchAll=false", + "verify": "npm run ci:docs && npm run check:accent-ink && npm run type-check && npm run type-check:scripts && npm run check:sizes && npm run audit:routes && npm run lint && npm run check:duplication && npm run check:dead-fields && npm run check:schema-columns && npm run check:currency-units && npm run check:reserved-hosts && npm run check:rpc-exists && npm run check:mdx && npm run test:unit -- --watchAll=false", "audit:schema": "node scripts/db/audit-schema-drift.mjs", "audit:routes": "node scripts/audit-routes.mjs", "gen:types": "bash scripts/db/gen-types.sh", @@ -107,7 +107,8 @@ "db:audit": "node scripts/db-audit.mjs", "eval:voice": "node scripts/eval-voice-routing.mjs", "check:reserved-hosts": "bash scripts/ci/check-reserved-hosts.sh", - "sync:reserved-hosts": "bash scripts/ci/sync-reserved-hosts.sh" + "sync:reserved-hosts": "bash scripts/ci/sync-reserved-hosts.sh", + "type-check:scripts": "tsc --noEmit --skipLibCheck -p tsconfig.scripts.json" }, "dependencies": { "@asteasolutions/zod-to-openapi": "^7.3.4", diff --git a/tsconfig.scripts.json b/tsconfig.scripts.json new file mode 100644 index 000000000..22695ea86 --- /dev/null +++ b/tsconfig.scripts.json @@ -0,0 +1,23 @@ +{ + // Type-check the operational scripts, which the app's tsconfig excludes. + // + // WHY THIS FILE EXISTS + // + // `tsconfig.json` excludes `scripts/**/*.ts` so Next's build does not compile + // them. The side effect was that `verify` never looked at them at all — and a + // seed script is the one place where a schema drift surfaces BY HAND, on the + // box, months after the migration that caused it. seed-substrata.ts is bound + // to the generated `Database` type precisely so that failure happens in CI; + // without this config that binding proved nothing. + // + // Deliberately NOT extended to `__tests__/**`: 119 pre-existing type errors + // live there, and fixing them is real work that does not belong to whichever + // change happens to notice. Scripts were already at zero, so this gate starts + // green and stays that way. + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": true + }, + "include": ["scripts/**/*.ts", "src/**/*.ts", "src/**/*.tsx", "next-env.d.ts"], + "exclude": ["node_modules", ".next", "dist", "build", "coverage"] +} From 6d0b517dec154fa546917b86e238d140b21d5da8 Mon Sep 17 00:00:00 2001 From: Mao Nakamoto Date: Thu, 27 Aug 2026 10:26:39 +0200 Subject: [PATCH 16/17] feat(sites): publish a website with one call, not one INSERT MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Everything underneath already worked off a single row, but the only way to write that row was SQL. That is not "a few clicks" — it is a DBA. This is the endpoint FleetCrown and the group settings UI call: GET /api/groups/[slug]/site status, address, eligibility PUT /api/groups/[slug]/site publish (upsert, so it also reconfigures) DELETE /api/groups/[slug]/site unpublish, keeping the configuration Three decisions worth naming: **PUT is an upsert.** "Create a site" and "configure a site" would be two states to reason about where the database has one row. **GET answers `url` even when unpublished**, plus `eligible` and `reason`. It costs nothing and it is the difference between a button that says "Publish" and one that says "Publish at acme.orangecat.ch" — and between a group learning its slug is reserved before it clicks or after. **DELETE disables, it does not delete.** `enabled = false` is what both the RLS policy and the resolver read, and keeping the row keeps the config, so taking a site down for a week does not lose its custom domain. The refusals are the tested part, because the worst failure here is a site that exists in the database and never answers on the internet — the button said it worked. A slug that is not a legal DNS label, a slug that already belongs to infrastructure (`supabase`, `fleetcrown`) or invites a phish (`security`), and a private group whose site RLS would hide from every visitor. `check:sizes` refused the first version at 229 lines, which was the right complaint: the route was doing domain work. Rules and writes now live in services/sites/publish.ts, which imports nothing from `next/` — that one `revalidateTag` import dragged Next's entire server runtime into any test that touched these rules, and dropping a framework cache is the HTTP layer's job. verify green: 250 suites, 2494 tests. Co-Authored-By: Claude Opus 5 (1M context) --- __tests__/unit/services/site-publish.test.ts | 68 +++++++++ docs/operations/hosted-sites.md | 21 ++- src/app/api/groups/[slug]/site/route.ts | 149 +++++++++++++++++++ src/services/sites/publish.ts | 130 ++++++++++++++++ src/services/sites/registry.ts | 12 +- 5 files changed, 376 insertions(+), 4 deletions(-) create mode 100644 __tests__/unit/services/site-publish.test.ts create mode 100644 src/app/api/groups/[slug]/site/route.ts create mode 100644 src/services/sites/publish.ts diff --git a/__tests__/unit/services/site-publish.test.ts b/__tests__/unit/services/site-publish.test.ts new file mode 100644 index 000000000..2401b6ecb --- /dev/null +++ b/__tests__/unit/services/site-publish.test.ts @@ -0,0 +1,68 @@ +/** + * Publishing is one call, so the refusals have to be right in that one call. + * + * Every rule here prevents a site that would exist in the database and never + * answer on the internet — the worst failure mode for this feature, because the + * button said it worked. A slug that cannot be a DNS label, a slug that already + * belongs to infrastructure, and a private group whose site RLS would hide from + * every visitor. + */ + +import { publishRefusal, siteAddress, type SiteGroup } from '@/services/sites/publish'; + +function group(overrides: Partial = {}): SiteGroup { + return { id: 'uuid', name: 'Acme Cooperative', slug: 'acme', is_public: true, ...overrides }; +} + +describe('what may be published', () => { + it('publishes an ordinary public group', () => { + expect(publishRefusal(group())).toBeNull(); + }); + + it('refuses a slug that cannot be a DNS label', () => { + for (const slug of ['-acme', 'acme-', 'ac me', 'acme_co', '']) { + expect(publishRefusal(group({ slug }))).toMatch(/cannot be a hostname/); + } + }); + + /** + * The one that would be a real incident. `supabase.orangecat.ch` is the + * database; `security.orangecat.ch` under our own certificate is a phish. + */ + it('refuses a slug that already belongs to infrastructure or invites a phish', () => { + expect(publishRefusal(group({ slug: 'supabase' }))).toMatch(/reserved/); + expect(publishRefusal(group({ slug: 'security' }))).toMatch(/reserved/); + expect(publishRefusal(group({ slug: 'fleetcrown' }))).toMatch(/reserved/); + }); + + it('explains WHY a name is reserved, since the fix depends on it', () => { + expect(publishRefusal(group({ slug: 'supabase' }))).toContain('database'); + }); + + /** + * The RLS policy only exposes a site whose group is public. Publishing a + * private group would write a row that every visitor is denied. + */ + it('refuses a private group rather than publishing a site nobody can load', () => { + expect(publishRefusal(group({ is_public: false }))).toMatch(/private group/i); + }); +}); + +describe('where a site lives', () => { + it('gives the free subdomain, and a preview path that works without DNS', () => { + expect(siteAddress(group(), {})).toEqual({ + url: 'https://acme.orangecat.ch', + previewPath: '/sites/acme', + }); + }); + + it('prefers a custom domain once one is configured', () => { + expect(siteAddress(group(), { customDomain: 'acme.example' }).url).toBe('https://acme.example'); + }); + + it('ignores a malformed custom domain rather than advertising it', () => { + expect(siteAddress(group(), { customDomain: 'not a hostname' }).url).toBe( + 'https://acme.orangecat.ch' + ); + }); +}); diff --git a/docs/operations/hosted-sites.md b/docs/operations/hosted-sites.md index 2137c2400..31aa1d8b4 100644 --- a/docs/operations/hosted-sites.md +++ b/docs/operations/hosted-sites.md @@ -1,7 +1,7 @@ --- created_date: 2026-08-27 last_modified_date: 2026-08-27 -last_modified_summary: First version — how a group becomes a website, and the two one-time infrastructure steps behind it. +last_modified_summary: First version — how a group becomes a website, the publish API, and the two one-time infrastructure steps behind it. --- # Hosted sites — turning a group into a website @@ -12,7 +12,24 @@ This is how that works, and what it costs. ## Publishing a site -**One row. No deploy, no ssh, no DNS.** +**One call. No deploy, no ssh, no DNS.** + +```http +PUT /api/groups/acme/site → { published: true, url, previewPath } +DELETE /api/groups/acme/site → unpublish (keeps the configuration) +GET /api/groups/acme/site → status, address, and whether it is eligible +``` + +Admin/founder only. `PUT` is an upsert, so the same call publishes a new site +and reconfigures a live one. This is what FleetCrown and the group settings UI +call; neither knows anything about DNS or Caddy, because neither is involved. + +`GET` answers `url` even when unpublished, so the button can read _"Publish at +acme.orangecat.ch"_ rather than a bare _"Publish"_, and returns `eligible` + +`reason` so a group that can never be published (reserved slug, private group) +says so before anyone clicks. + +Underneath, that call is one row: ```sql INSERT INTO group_features (group_id, feature_key, enabled, enabled_by) diff --git a/src/app/api/groups/[slug]/site/route.ts b/src/app/api/groups/[slug]/site/route.ts new file mode 100644 index 000000000..6cd6746dc --- /dev/null +++ b/src/app/api/groups/[slug]/site/route.ts @@ -0,0 +1,149 @@ +/** + * Group Website API — publish a group as a website, and take it down again. + * + * GET /api/groups/[slug]/site — is this group published, and where? + * PUT /api/groups/[slug]/site — publish it (admin/founder only) + * DELETE /api/groups/[slug]/site — unpublish it (admin/founder only) + * + * This endpoint IS the "few clicks": everything underneath it works off one + * row, so turning a group into a website is this one call — no DNS, no Caddy, + * no deploy. PUT is an upsert, so it both publishes and reconfigures; two verbs + * would be two states where the database has one row. + * + * HTTP only. What may be published, and what publishing does, lives in + * `@/services/sites/publish`. + */ + +import { withAuth, type AuthenticatedRequest } from '@/lib/api/withAuth'; +import { revalidateTag } from 'next/cache'; +import { + apiSuccess, + apiBadRequest, + apiForbidden, + apiNotFound, + apiRateLimited, + apiValidationError, + handleApiError, +} from '@/lib/api/standardResponse'; +import { rateLimitWriteAsync, retryAfterSeconds } from '@/lib/rate-limit'; +import { logger } from '@/utils/logger'; +import { checkGroupAdmin } from '@/domain/groups/helpers.server'; +import { siteConfigSchema } from '@/config/hosted-site'; +import { HOSTED_SITES_TAG } from '@/services/sites/registry'; +import { + publishRefusal, + publishSite, + readSiteFeature, + resolveGroupForSite, + siteAddress, + unpublishSite, + type SiteGroup, +} from '@/services/sites/publish'; + +interface RouteContext { + params: Promise<{ slug: string }>; +} + +/** Resolve + authorise in one step; every verb here needs exactly this. */ +async function requireAdminGroup( + req: AuthenticatedRequest, + slug: string +): Promise<{ group: SiteGroup } | { error: ReturnType }> { + const group = await resolveGroupForSite(req.supabase, slug); + if (!group) { + return { error: apiNotFound('Group not found') }; + } + if (!(await checkGroupAdmin(req.supabase, group.id, req.user.id))) { + return { error: apiForbidden('Only group admins and founders can manage the website') }; + } + return { group }; +} + +export const GET = withAuth(async (req: AuthenticatedRequest, { params }: RouteContext) => { + const { slug } = await params; + try { + const resolved = await requireAdminGroup(req, slug); + if ('error' in resolved) { + return resolved.error; + } + const { group } = resolved; + const feature = await readSiteFeature(req.supabase, group.id); + const refusal = publishRefusal(group); + + return apiSuccess({ + published: feature.enabled, + // Returned even when unpublished, so the UI can offer "Publish at + // acme.orangecat.ch" rather than a bare "Publish". It costs nothing. + ...siteAddress(group, feature.config), + eligible: refusal === null, + reason: refusal, + config: feature.config, + }); + } catch (error) { + logger.error('Reading site settings failed', { slug, error: String(error) }, 'Sites'); + return handleApiError(error); + } +}); + +export const PUT = withAuth(async (req: AuthenticatedRequest, { params }: RouteContext) => { + const { slug } = await params; + try { + const rl = await rateLimitWriteAsync(req.user.id); + if (!rl.success) { + return apiRateLimited('Too many requests. Please slow down.', retryAfterSeconds(rl)); + } + + const resolved = await requireAdminGroup(req, slug); + if ('error' in resolved) { + return resolved.error; + } + const { group } = resolved; + + const refusal = publishRefusal(group); + if (refusal) { + return apiBadRequest(refusal); + } + + const body = await req.json().catch(() => ({})); + const parsed = siteConfigSchema.safeParse(body ?? {}); + if (!parsed.success) { + return apiValidationError('Invalid site configuration', parsed.error.flatten().fieldErrors); + } + + await publishSite(req.supabase, group, parsed.data, req.user.id); + // Without this the site is live but the resolver holds its old answer for up + // to a minute, and a publish button that takes a minute to visibly work + // reads as a publish button that failed. + revalidateTag(HOSTED_SITES_TAG, 'max'); + logger.info('Group website published', { group: group.slug }, 'Sites'); + + return apiSuccess({ published: true, ...siteAddress(group, parsed.data) }); + } catch (error) { + logger.error('Publishing a site failed', { slug, error: String(error) }, 'Sites'); + return handleApiError(error); + } +}); + +export const DELETE = withAuth(async (req: AuthenticatedRequest, { params }: RouteContext) => { + const { slug } = await params; + try { + const rl = await rateLimitWriteAsync(req.user.id); + if (!rl.success) { + return apiRateLimited('Too many requests. Please slow down.', retryAfterSeconds(rl)); + } + + const resolved = await requireAdminGroup(req, slug); + if ('error' in resolved) { + return resolved.error; + } + + await unpublishSite(req.supabase, resolved.group); + revalidateTag(HOSTED_SITES_TAG, 'max'); + logger.info('Group website unpublished', { group: slug }, 'Sites'); + + return apiSuccess({ published: false }); + } catch (error) { + logger.error('Unpublishing a site failed', { slug, error: String(error) }, 'Sites'); + return handleApiError(error); + } +}); diff --git a/src/services/sites/publish.ts b/src/services/sites/publish.ts new file mode 100644 index 000000000..d7bb5e39c --- /dev/null +++ b/src/services/sites/publish.ts @@ -0,0 +1,130 @@ +/** + * Publishing a group as a website — the domain half. + * + * The route beside this (`/api/groups/[slug]/site`) is HTTP: auth, rate limit, + * status codes, cache invalidation. Everything that decides WHETHER and WHAT + * lives here, so the rules are testable without a request and so a second + * caller — the group settings UI, FleetCrown, a script — cannot end up with a + * different answer. + * + * Deliberately imports nothing from `next/`. It did import `revalidateTag`, and + * that single line dragged Next's whole server runtime into any test that + * touched these rules. Dropping a framework cache is the route's job anyway. + */ + +import { + SITE_FEATURE_KEY, + siteCanonicalHost, + toHostedSite, + type SiteConfigInput, +} from '@/config/hosted-site'; +import { isReservedSubdomain, isValidSiteSlug, reservedSubdomainReason } from '@/config/sites'; + +/** The three group fields publishing needs. */ +export interface SiteGroup { + id: string; + name: string; + slug: string; + is_public: boolean | null; +} + +/** Minimal client shape, so this module does not care which client it is given. */ +type Client = { + from: (table: string) => any; +}; + +export async function resolveGroupForSite( + supabase: Client, + slug: string +): Promise { + const { data, error } = await supabase + .from('groups') + .select('id, name, slug, is_public') + .eq('slug', slug) + .maybeSingle(); + return error || !data ? null : (data as SiteGroup); +} + +/** + * Why this group may not be published, or null if it may. + * + * A site's address IS its group slug, so a slug that cannot be a hostname can + * never resolve. Saying so once, here, is much kinder than a published site + * that silently never answers. + */ +export function publishRefusal(group: SiteGroup): string | null { + if (!isValidSiteSlug(group.slug)) { + return `"${group.slug}" cannot be a hostname. A site address may contain only letters, numbers and hyphens, and may not start or end with one.`; + } + if (isReservedSubdomain(group.slug)) { + return `"${group.slug}.orangecat.ch" is reserved — ${reservedSubdomainReason(group.slug)}.`; + } + // A published site is public by definition, and the RLS policy enforces it. + // Refusing here beats publishing a website nobody can load. + if (group.is_public === false) { + return 'A private group cannot publish a public website. Make the group public first.'; + } + return null; +} + +/** Where this group's site is, or would be. */ +export function siteAddress(group: SiteGroup, config: unknown) { + const site = toHostedSite({ slug: group.slug, name: group.name }, config ?? {}); + return { + url: `https://${siteCanonicalHost(site)}`, + // Reachable with or without DNS — this is the preview link, and it is what + // makes a site checkable before anybody points a domain at anything. + previewPath: `/sites/${group.slug}`, + }; +} + +export async function readSiteFeature( + supabase: Client, + groupId: string +): Promise<{ enabled: boolean; config: unknown }> { + const { data, error } = await supabase + .from('group_features') + .select('enabled, config') + .eq('group_id', groupId) + .eq('feature_key', SITE_FEATURE_KEY) + .maybeSingle(); + if (error) { + throw error; + } + return { enabled: Boolean(data?.enabled), config: data?.config ?? {} }; +} + +export async function publishSite( + supabase: Client, + group: SiteGroup, + config: SiteConfigInput, + userId: string +): Promise { + const { error } = await supabase.from('group_features').upsert( + { + group_id: group.id, + feature_key: SITE_FEATURE_KEY, + enabled: true, + config, + enabled_by: userId, + }, + { onConflict: 'group_id,feature_key' } + ); + if (error) { + throw error; + } +} + +export async function unpublishSite(supabase: Client, group: SiteGroup): Promise { + // Disabled, not deleted. `enabled = false` is what both the RLS policy and + // the resolver read, and keeping the row keeps the configuration — taking a + // site down for a week must not lose its custom domain. + const { error } = await supabase + .from('group_features') + .update({ enabled: false }) + .eq('group_id', group.id) + .eq('feature_key', SITE_FEATURE_KEY); + if (error) { + throw error; + } +} diff --git a/src/services/sites/registry.ts b/src/services/sites/registry.ts index 614164199..dbd3f4d1c 100644 --- a/src/services/sites/registry.ts +++ b/src/services/sites/registry.ts @@ -37,6 +37,14 @@ import { logger } from '@/utils/logger'; */ const SITE_CACHE_TTL_SECONDS = 60; +/** + * Cache tag both resolvers share, so publishing can drop them together. + * + * Exported because the publish endpoint invalidates it — a publish button that + * takes a minute to visibly work reads as a publish button that failed. + */ +export const HOSTED_SITES_TAG = 'hosted-sites'; + const SITE_SELECT = 'config, groups!inner(slug, name, description, label, tags, is_public, bitcoin_address, lightning_address)'; @@ -128,7 +136,7 @@ async function loadSiteBySlug(slug: string): Promise { export const siteBySlug = unstable_cache(loadSiteBySlug, ['hosted-site-by-slug'], { revalidate: SITE_CACHE_TTL_SECONDS, - tags: ['hosted-sites'], + tags: [HOSTED_SITES_TAG], }); /** @@ -182,5 +190,5 @@ async function loadSiteByHost(host: string): Promise { export const siteByHost = unstable_cache(loadSiteByHost, ['hosted-site-by-host'], { revalidate: SITE_CACHE_TTL_SECONDS, - tags: ['hosted-sites'], + tags: [HOSTED_SITES_TAG], }); From dd3ec1590218613af777a74ea60ef7cc9f9c9761 Mon Sep 17 00:00:00 2001 From: Mao Nakamoto Date: Thu, 27 Aug 2026 12:05:30 +0200 Subject: [PATCH 17/17] fix(sites): stop OrangeCat rendering itself on somebody else's domain MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit substrata.orangecat.ch came up with OrangeCat's header, "Sign In" and "Get Started" across the top of it. Three more of ours were on that page too, and they are worse than the header because they are invisible: - our Google Analytics tag, tracking visitors on a domain that is not ours - our Organization + WebSite JSON-LD, which tells every crawler that the customer's domain IS OrangeCat - the FleetCrown internal feedback widget, loaded from fleetcrown.orangecat.ch One cause. A hosted site is served by a REWRITE — that is the entire point, the visitor's URL bar has to keep saying substrata.orangecat.ch — so the path a client component sees is "/", not "/sites/substrata". `AppShell` decided chrome from `usePathname()`, got "/", classified a customer's website as OrangeCat's public marketing surface, and rendered accordingly. The other three were never gated at all, because nothing in the root layout knew the question existed. The claim that a hosted site "can never accidentally grow OrangeCat's header" was true of the route surface and false of the rendering, and the tests did not catch the gap because every one of them asked `getRouteSurface('/sites/...')` — the preview form, which was never broken. None asked what happens when the path is "/" and only a header knows better. So the question is now asked once, on the server, by `isHostedSiteRequest` in the routes SSOT, and all four gate on its answer. Middleware forwards `x-hosted-site` on the REQUEST headers rather than only echoing it to the browser — setting a header on the response tells the browser and nothing else, which is why the layout could not see it. Four tests cover the case that shipped broken: a rewritten request whose visible path is only "/". verify green: 251 suites, 2506 tests. Co-Authored-By: Claude Opus 5 (1M context) --- __tests__/unit/config/hosted-sites.test.ts | 51 +++++++++++- src/app/layout.tsx | 92 ++++++++++++++-------- src/components/layout/AppShell.tsx | 22 +++++- src/config/routes.ts | 26 ++++++ src/middleware.ts | 23 +++++- 5 files changed, 172 insertions(+), 42 deletions(-) diff --git a/__tests__/unit/config/hosted-sites.test.ts b/__tests__/unit/config/hosted-sites.test.ts index 2f3989eac..faeed0be1 100644 --- a/__tests__/unit/config/hosted-sites.test.ts +++ b/__tests__/unit/config/hosted-sites.test.ts @@ -31,7 +31,7 @@ import { sitePagesFor, siteChromeFor, } from '@/config/site-content'; -import { getRouteSurface } from '@/config/routes'; +import { getRouteSurface, isHostedSiteRequest } from '@/config/routes'; import { COMPANY, MANDATE_CURVES, MATERIALS } from '@/config/substrata'; import { CHOKEPOINTS, COVERAGE, coverageProgress } from '@/config/substrata-coverage'; @@ -140,6 +140,55 @@ describe('hosted sites — the config a site owner may set', () => { }); }); +describe('hosted sites — the rewrite must not leak OrangeCat onto a customer domain', () => { + /** + * This is the case that shipped broken. + * + * A hosted site is served by a REWRITE, so the browser path stays "/" while + * `/sites/` renders. Everything that decided chrome from the visible + * path therefore classified a customer's website as OrangeCat's public + * marketing surface, and substrata.orangecat.ch came up with OrangeCat's + * header, "Sign In", our Google Analytics, our Organization schema and the + * internal FleetCrown feedback widget on it. + * + * The old tests all asked `getRouteSurface('/sites/substrata')` — the path + * form, which was never broken. None of them asked what happens when the path + * is "/" and only a header knows better. + */ + function headersOf(map: Record) { + return (name: string) => map[name] ?? null; + } + + it('recognises a rewritten request, whose visible path is only "/"', () => { + expect(getRouteSurface('/')).toBe('public'); + expect(isHostedSiteRequest(headersOf({ 'x-pathname': '/' }))).toBe(false); + + // The rewrite sets this. Without it the request is indistinguishable from + // a visit to orangecat.ch itself. + expect( + isHostedSiteRequest(headersOf({ 'x-pathname': '/', 'x-hosted-site': 'substrata' })) + ).toBe(true); + }); + + it('recognises a deep page on a hosted site', () => { + expect( + isHostedSiteRequest(headersOf({ 'x-pathname': '/map', 'x-hosted-site': 'substrata' })) + ).toBe(true); + }); + + it('recognises the preview form, which has no rewrite and no header', () => { + expect(isHostedSiteRequest(headersOf({ 'x-pathname': '/sites/substrata' }))).toBe(true); + expect(isHostedSiteRequest(headersOf({ 'x-pathname': '/sites/substrata/map' }))).toBe(true); + }); + + it('leaves ordinary OrangeCat requests alone, header absent', () => { + for (const path of ['/', '/dashboard', '/about', '/groups/substrata', '/auth']) { + expect(isHostedSiteRequest(headersOf({ 'x-pathname': path }))).toBe(false); + } + expect(isHostedSiteRequest(headersOf({}))).toBe(false); + }); +}); + describe('hosted sites — links', () => { it('always emits the path form, which resolves on every host', () => { expect(siteHref(site.slug)).toBe('/sites/substrata'); diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 0850a0ffd..35c3fc384 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -42,6 +42,8 @@ const ibmPlexMono = localFont({ }); import './globals.css'; import Script from 'next/script'; +import { headers } from 'next/headers'; +import { isHostedSiteRequest } from '@/config/routes'; import { AuthProvider } from '@/components/providers/AuthProvider'; import { QueryProvider } from '@/components/providers/QueryProvider'; import { ThemeProvider } from '@/components/providers/ThemeProvider'; @@ -103,8 +105,25 @@ export const metadata: Metadata = { }, }; -export default function RootLayout({ children }: { children: React.ReactNode }) { - const gaId = process.env.NEXT_PUBLIC_GA_MEASUREMENT_ID; +export default async function RootLayout({ children }: { children: React.ReactNode }) { + /** + * Is this request rendering somebody else's website? + * + * Decided ONCE, here, on the server, because four separate things below are + * OrangeCat's and must not appear on a customer's domain: the app shell, our + * Organization schema, our analytics, and the FleetCrown feedback widget. + * + * It cannot be decided from the path in a client component. A hosted site is + * served by a REWRITE — the visitor's URL bar keeps saying + * substrata.orangecat.ch, so `usePathname()` returns "/" and every one of + * those four leaked onto the customer's site. Middleware therefore forwards + * `x-hosted-site` on the request headers, and the path form is covered by the + * same `getRouteSurface` SSOT the rest of the app uses. + */ + const requestHeaders = await headers(); + const isHostedSite = isHostedSiteRequest(name => requestHeaders.get(name)); + + const gaId = isHostedSite ? undefined : process.env.NEXT_PUBLIC_GA_MEASUREMENT_ID; // Cache-only, never a network wait: rendering a page must not depend on a // third party answering. Whatever we last knew gets handed to the browser so // the first paint already speaks the visitor's currency. @@ -117,39 +136,42 @@ export default function RootLayout({ children }: { children: React.ReactNode }) suppressHydrationWarning > - {/* Structured data: Organization + WebSite */} -