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 (
+
+ );
+}
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 (
+
+ {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 = 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/`, 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: string;
+ /** Free 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: 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//...` 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 = DESKS.reduce(
(acc, desk) => ({ ...acc, [desk.id]: desk }),
{} as Record
@@ -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/ 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
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
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) {
+ global.fetch = jest.fn(async (input: RequestInfo | URL) =>
+ impl(String(input))
+ ) as unknown as typeof fetch;
+}
+
+beforeEach(() => {
+ resetDomainCaches();
+});
+
+afterEach(() => {
+ global.fetch = originalFetch;
+ jest.restoreAllMocks();
+});
+
+describe('parseDomain', () => {
+ it('accepts a plain domain and normalises it', () => {
+ expect(parseDomain('SubstrataIntel.COM')).toEqual({ name: 'substrataintel', tld: 'com' });
+ expect(parseDomain(' https://substrataintel.com/path ')).toEqual({
+ name: 'substrataintel',
+ tld: 'com',
+ });
+ expect(parseDomain('substrataintel.com.')).toEqual({ name: 'substrataintel', tld: 'com' });
+ });
+
+ it('rejects things that are not domains', () => {
+ for (const input of ['substrataintel', '', ' ', '-bad.com', 'bad-.com', 'x.c', 'a b.com']) {
+ expect(parseDomain(input)).toBeNull();
+ }
+ });
+});
+
+describe('availability — the rule that stops a false “available”', () => {
+ it('reports unregistered only when a supported registry answers 404', async () => {
+ mockFetch(() => ({ status: 404, ok: false }));
+ const result = await checkDomain('substrataintel.com', RDAP_TLDS);
+
+ expect(result.status).toBe('unregistered');
+ expect(result.rdapSupported).toBe(true);
+ });
+
+ it('reports registered when the registry returns a record', async () => {
+ mockFetch(() => ({ status: 200, ok: true }));
+ const result = await checkDomain('google.com', RDAP_TLDS);
+ expect(result.status).toBe('registered');
+ });
+
+ it.each(['ch', 'io', 'co'])(
+ 'never claims a .%s domain is free — that registry publishes no RDAP',
+ async tld => {
+ // A redirector 404s for these exactly as it does for a free name. If this
+ // test ever goes green on 'unregistered', the feature is lying.
+ mockFetch(() => ({ status: 404, ok: false }));
+ const result = await checkDomain(`orangecat.${tld}`, RDAP_TLDS);
+
+ expect(result.status).toBe('unknown');
+ expect(result.rdapSupported).toBe(false);
+ expect(result.reason).toContain('no RDAP service');
+ }
+ );
+
+ it('treats a timeout as unresolved, never as free', async () => {
+ mockFetch(() => {
+ throw new Error('TimeoutError');
+ });
+ const result = await checkDomain('substrataintel.com', RDAP_TLDS);
+ expect(result.status).toBe('unknown');
+ });
+
+ it('treats an unexpected registry status as unresolved', async () => {
+ mockFetch(() => ({ status: 500, ok: false }));
+ const result = await checkDomain('substrataintel.com', RDAP_TLDS);
+ expect(result.status).toBe('unknown');
+ });
+
+ it('treats a missing bootstrap as unresolved rather than assuming anything', async () => {
+ mockFetch(() => ({ status: 404, ok: false }));
+ const result = await checkDomain('substrataintel.com', null);
+ expect(result.status).toBe('unknown');
+ expect(result.reason).toContain('could not be loaded');
+ });
+
+ it('rejects a malformed query without calling any registry', async () => {
+ const fetchSpy = jest.fn();
+ global.fetch = fetchSpy as unknown as typeof fetch;
+
+ const result = await checkDomain('not a domain', RDAP_TLDS);
+ expect(result.status).toBe('unknown');
+ expect(fetchSpy).not.toHaveBeenCalled();
+ });
+
+ it('caches a result so a repeated lookup does not hit the registry twice', async () => {
+ const fetchSpy = jest.fn(async () => ({ status: 404, ok: false }) as Partial);
+ global.fetch = fetchSpy as unknown as typeof fetch;
+
+ await checkDomain('substrataintel.com', RDAP_TLDS);
+ await checkDomain('substrataintel.com', RDAP_TLDS);
+
+ expect(fetchSpy).toHaveBeenCalledTimes(1);
+ });
+});
+
+describe('availability — batches', () => {
+ it('checks every candidate and preserves order', async () => {
+ mockFetch(url =>
+ url.includes('data.iana.org')
+ ? { ok: true, status: 200, json: async () => ({ services: [[['com', 'ai'], ['x']]] }) }
+ : { status: 404, ok: false }
+ );
+
+ const results = await checkDomains(['a.com', 'b.ai', 'c.ch']);
+ expect(results.map(r => r.domain)).toEqual(['a.com', 'b.ai', 'c.ch']);
+ expect(results.map(r => r.status)).toEqual(['unregistered', 'unregistered', 'unknown']);
+ });
+});
+
+describe('suggestions', () => {
+ it('reduces a phrase to a usable label', () => {
+ expect(toSeed('Substrata Intel')).toBe('substrataintel');
+ expect(toSeed(' Café Ltd. ')).toBe('cafeltd');
+ expect(toSeed('!!!')).toBe('');
+ });
+
+ it('leads with the exact domain when the user typed one', () => {
+ const candidates = suggestDomains({ query: 'substrataintel.com' });
+ expect(candidates[0]).toBe('substrataintel.com');
+ });
+
+ it('offers the bare name across every TLD before any invented variant', () => {
+ const candidates = suggestDomains({ query: 'substrate' });
+ const bare = CANDIDATE_TLDS.map(tld => `substrate.${tld}`);
+ expect(candidates.slice(0, bare.length)).toEqual(bare);
+ });
+
+ it('honours a caller-supplied TLD list, which is how FleetCrown narrows it', () => {
+ const candidates = suggestDomains({ query: 'substrate', tlds: ['.ch', 'COM'] });
+ expect(candidates.slice(0, 2)).toEqual(['substrate.ch', 'substrate.com']);
+ });
+
+ it('never fans out past the cap, and never repeats a candidate', () => {
+ const candidates = suggestDomains({ query: 'substrate' });
+ expect(candidates.length).toBeLessThanOrEqual(MAX_CANDIDATES);
+ expect(new Set(candidates).size).toBe(candidates.length);
+ });
+
+ it('returns nothing usable for a query with no letters or digits', () => {
+ expect(suggestDomains({ query: '???' })).toEqual([]);
+ });
+});
diff --git a/src/app/(public)/domains/page.tsx b/src/app/(public)/domains/page.tsx
index 032ecdf42..37b34056c 100644
--- a/src/app/(public)/domains/page.tsx
+++ b/src/app/(public)/domains/page.tsx
@@ -3,6 +3,7 @@ import Link from 'next/link';
import { Metadata } from 'next';
import { Globe, ShieldCheck, Server } from 'lucide-react';
import Button from '@/components/ui/Button';
+import { DomainSearch } from '@/components/domains/DomainSearch';
import { ROUTES } from '@/config/routes';
import {
DOMAINS_SERVICE_URL,
@@ -32,6 +33,15 @@ export default function DomainsPage() {
to your own domain when you're ready, and everything keeps working.
+
+ {/* The rung before everything else on this page: you cannot host a
+ domain you have not found yet. */}
+
+
+ Don't have a name yet? Check one against the registries.
+
+
+
diff --git a/src/app/api/v1/domains/route.ts b/src/app/api/v1/domains/route.ts
new file mode 100644
index 000000000..0d439537e
--- /dev/null
+++ b/src/app/api/v1/domains/route.ts
@@ -0,0 +1,64 @@
+/**
+ * GET /api/v1/domains?q=...&tlds=com,ch — is this name free anywhere?
+ *
+ * The rung before the two services /domains sells: somebody with no domain
+ * cannot buy hosting for one. Public and keyless because registry RDAP records
+ * are public — the same reasoning as /api/v1/demand and /api/v1/search.
+ *
+ * Shared surface by design. OrangeCat's /domains page and FleetCrown both call
+ * this, so the honesty rules (a .ch "not found" is never reported as available)
+ * are enforced once, server-side, instead of being re-implemented per client.
+ */
+import { NextRequest } from 'next/server';
+import { apiSuccess, apiError } from '@/lib/api/standardResponse';
+import { CANDIDATE_TLDS, DOMAIN_SEARCH_DISCLAIMER } from '@/config/domain-search';
+import { checkDomains } from '@/services/domains/availability';
+import { suggestDomains } from '@/services/domains/suggest';
+import { logger } from '@/utils/logger';
+
+/** Registry lookups are slow and cached; let a CDN hold the answer briefly. */
+const CACHE = 'public, s-maxage=300, stale-while-revalidate=600';
+
+export async function GET(request: NextRequest) {
+ try {
+ const url = new URL(request.url);
+ const q = (url.searchParams.get('q') ?? '').trim();
+ if (q.length < 2) {
+ return apiError('Query "q" (min 2 chars) is required', 'BAD_REQUEST', 400);
+ }
+
+ const tldParam = url.searchParams.get('tlds');
+ const tlds = tldParam
+ ? tldParam
+ .split(',')
+ .map(tld => tld.trim().replace(/^\./, '').toLowerCase())
+ .filter(tld => /^[a-z]{2,63}$/.test(tld))
+ : undefined;
+
+ const candidates = suggestDomains({ query: q, tlds });
+ if (candidates.length === 0) {
+ return apiError('Query contains no usable domain label', 'BAD_REQUEST', 400);
+ }
+
+ const results = await checkDomains(candidates);
+
+ const response = apiSuccess({
+ query: q,
+ tlds: tlds ?? CANDIDATE_TLDS,
+ // Callers that want only the confident answers can filter on this
+ // without re-deriving the rule.
+ counts: {
+ unregistered: results.filter(r => r.status === 'unregistered').length,
+ registered: results.filter(r => r.status === 'registered').length,
+ unknown: results.filter(r => r.status === 'unknown').length,
+ },
+ results,
+ disclaimer: DOMAIN_SEARCH_DISCLAIMER,
+ });
+ response.headers.set('Cache-Control', CACHE);
+ return response;
+ } catch (err) {
+ logger.error('GET /api/v1/domains failed', { err }, 'DomainSearch');
+ return apiError('Domain search failed', 'INTERNAL_ERROR', 500);
+ }
+}
diff --git a/src/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 = {
+ unregistered: 'bg-status-positive-subtle text-status-positive',
+ registered: 'bg-surface-raised text-fg-tertiary',
+ unknown: 'bg-status-warning-subtle text-status-warning',
+};
+
+/** Free names first — they are the answer. Then unresolved, then taken. */
+const STATUS_ORDER: Record = { unregistered: 0, unknown: 1, registered: 2 };
+
+export function DomainSearch() {
+ const [query, setQuery] = useState('');
+ const [results, setResults] = useState(null);
+ const [disclaimer, setDisclaimer] = useState('');
+ const [isSearching, setIsSearching] = useState(false);
+ const [error, setError] = useState(null);
+
+ const search = useCallback(
+ async (event: React.FormEvent) => {
+ event.preventDefault();
+ const trimmed = query.trim();
+ if (trimmed.length < 2) {
+ setError('Type at least two characters.');
+ return;
+ }
+
+ setIsSearching(true);
+ setError(null);
+ try {
+ const response = await fetch(`/api/v1/domains?q=${encodeURIComponent(trimmed)}`);
+ const body = await response.json();
+ if (!response.ok) {
+ setError(body?.error?.message ?? 'Search failed. Try again in a moment.');
+ setResults(null);
+ return;
+ }
+ const payload = body.data ?? body;
+ setResults(
+ [...(payload.results ?? [])].sort(
+ (a: DomainResult, b: DomainResult) => STATUS_ORDER[a.status] - STATUS_ORDER[b.status]
+ )
+ );
+ setDisclaimer(payload.disclaimer ?? '');
+ } catch {
+ setError('Could not reach the registry lookup. Try again in a moment.');
+ setResults(null);
+ } finally {
+ setIsSearching(false);
+ }
+ },
+ [query]
+ );
+
+ return (
+
+
+
+ {error &&
{error}
}
+
+ {results && results.length === 0 && (
+
+ Nothing to check — that query has no usable domain label.
+
+ );
+}
diff --git a/src/config/domain-search.ts b/src/config/domain-search.ts
new file mode 100644
index 000000000..dea66d165
--- /dev/null
+++ b/src/config/domain-search.ts
@@ -0,0 +1,107 @@
+/**
+ * Domain search — SSOT for the availability check /domains offers.
+ *
+ * Finding a name is the rung BEFORE the two services /domains already sells
+ * (build me a site, host it at yourname.orangecat.ch). Somebody who does not
+ * yet have a domain cannot buy hosting for one, so this closes the front of
+ * that funnel — and it is the same capability FleetCrown needs when it stands
+ * up a customer, which is why the check lives behind a public v1 endpoint
+ * rather than inside a React component.
+ *
+ * WHY THIS IS BUILT ON THE IANA BOOTSTRAP, NOT ON "404 MEANS FREE"
+ *
+ * RDAP (RFC 7482) is the registries' own structured successor to WHOIS: free,
+ * keyless, authoritative for the registries that run it. The naive
+ * implementation asks rdap.org for a domain and calls a 404 "available".
+ * That implementation would have told this project that ORANGECAT.CH — its own
+ * production domain — was free, because .ch operates no public RDAP service
+ * and a redirector cannot distinguish "no such domain" from "no such registry".
+ * The same false positive applies to .io and .co.
+ *
+ * So a TLD earns a definitive answer only by appearing in IANA's RDAP
+ * bootstrap. Everything else returns `unknown`, and the UI says so. A search
+ * tool that guesses is worse than one that admits the gap, because the guess
+ * is what someone acts on.
+ *
+ * Created: 2026-08-26
+ */
+
+/** IANA's registry of which TLDs actually run RDAP. The gate for a real answer. */
+export const RDAP_BOOTSTRAP_URL = 'https://data.iana.org/rdap/dns.json';
+
+/** Redirector that forwards a domain query to the registry's own RDAP server. */
+export const RDAP_QUERY_BASE = 'https://rdap.org/domain';
+
+/** Bootstrap is republished rarely; an hour of staleness costs nothing. */
+export const RDAP_BOOTSTRAP_TTL_MS = 60 * 60 * 1000;
+
+/** Per-domain result cache. Registrations do not change minute to minute. */
+export const DOMAIN_RESULT_TTL_MS = 10 * 60 * 1000;
+
+/** One lookup's ceiling. A slow registry must not hold the whole search open. */
+export const RDAP_TIMEOUT_MS = 8000;
+
+/** Concurrent RDAP requests. Polite to the registries, fast enough for a page. */
+export const RDAP_CONCURRENCY = 6;
+
+/** Hard cap on candidates per search, so one query cannot fan out unbounded. */
+export const MAX_CANDIDATES = 24;
+
+/**
+ * TLDs offered by default, best-first.
+ *
+ * `.ch` leads despite having no RDAP because this is a Swiss platform and it
+ * is the right domain for most of its users — it simply comes back as
+ * "check manually" rather than as a confident yes.
+ */
+export const CANDIDATE_TLDS: readonly string[] = [
+ 'com',
+ 'ch',
+ 'ai',
+ 'io',
+ 'dev',
+ 'org',
+ 'net',
+ 'xyz',
+];
+
+/**
+ * Name shapes tried around a seed word. Deliberately short: a wall of
+ * machine-generated names is noise, and the good name is usually the seed
+ * itself or the seed plus one honest qualifier.
+ */
+export const NAME_PATTERNS: readonly { id: string; render: (seed: string) => string }[] = [
+ { id: 'bare', render: seed => seed },
+ { id: 'intel', render: seed => `${seed}intel` },
+ { id: 'research', render: seed => `${seed}research` },
+ { id: 'labs', render: seed => `${seed}labs` },
+ { id: 'group', render: seed => `${seed}group` },
+ { id: 'get', render: seed => `get${seed}` },
+];
+
+/** What a lookup can conclude. `unknown` is a first-class answer, not a failure. */
+export type DomainStatus = 'registered' | 'unregistered' | 'unknown';
+
+export const DOMAIN_STATUS_COPY: Record = {
+ registered: {
+ label: 'Taken',
+ detail: 'The registry holds a registration record for this name.',
+ },
+ unregistered: {
+ label: 'No registration found',
+ detail:
+ 'The registry reports no record. Premium pricing, registry reservations and ' +
+ 'trademark conflicts are not visible here — confirm at a registrar before you count on it.',
+ },
+ unknown: {
+ label: 'Check manually',
+ detail:
+ 'This registry runs no public RDAP service, so nothing can be concluded either way. ' +
+ 'A registrar lookup is the only reliable answer for this TLD.',
+ },
+};
+
+/** Labels a domain the platform already knows about, so search never mis-sells one. */
+export const DOMAIN_SEARCH_DISCLAIMER =
+ 'Availability is reported from registry RDAP records, not from a registrar. ' +
+ 'It is not a reservation, a price, or a guarantee that a name can be registered.';
diff --git a/src/config/public-api.ts b/src/config/public-api.ts
index a16b9988e..d91aa9ec3 100644
--- a/src/config/public-api.ts
+++ b/src/config/public-api.ts
@@ -100,6 +100,14 @@ export const PUBLIC_API_INTEGRATION_ENDPOINTS = [
methods: ['GET'] as const,
endpoint: `${PUBLIC_API_BASE}/demand`,
},
+ {
+ // Domain availability. Public and keyless — registry RDAP records are
+ // public. Shared with FleetCrown so the "a .ch not-found proves nothing"
+ // rule is enforced server-side once, not re-implemented per client.
+ name: 'domains',
+ methods: ['GET'] as const,
+ endpoint: `${PUBLIC_API_BASE}/domains`,
+ },
] as const;
/**
diff --git a/src/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; 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 | 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();
+ 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();
+
+/** 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 | null,
+ now: number = Date.now()
+): Promise {
+ 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 {
+ 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();
+
+ 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
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
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) {
-
-
-
- {page.title}
-
- {page.intro && (
-
- {page.intro}
-
- )}
-
+
+ {/* A page either opens with its own hero or gets the standard title
+ block — never both. The rule lives in site-content.ts so it holds
+ for every hosted site, not just this one. */}
+ {!pageRendersOwnHeader(page) && (
+
+
+ {page.title}
+
+ {page.intro && (
+
+ {page.intro}
+
+ )}
+
+ )}
diff --git a/src/components/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 (
-
+
-
-
-
+ {/* 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. */}
+