diff --git a/app/gateway/page.tsx b/app/gateway/page.tsx
new file mode 100644
index 00000000..9ca21df7
--- /dev/null
+++ b/app/gateway/page.tsx
@@ -0,0 +1,256 @@
+import type { Metadata } from 'next';
+import { AI_INCIDENTS } from '@/lib/ai-incidents';
+import { GatewayInstall } from '@/components/gateway-install';
+import { GatewayJoin } from '@/components/gateway-join';
+import { JourneySteps } from '@/components/journey-steps';
+
+const DESCRIPTION =
+ 'AI is useful and not yet trustworthy: real cases, each with its source. How we are building trust in, piece by piece, what runs today and what is still direction, and how to run it on your own machine.';
+
+export const metadata: Metadata = {
+ title: 'Trust before autonomy — TrustShell',
+ description: DESCRIPTION,
+ // A candidate front door, kept out of search until Sean decides whether it replaces the landing page.
+ robots: { index: false, follow: true },
+ openGraph: { title: 'Trust before autonomy — TrustShell', description: DESCRIPTION, type: 'website' },
+};
+
+/**
+ * /gateway: a candidate front door (Sean, 2026-10-24). Four jobs, in order: show that today's AI is
+ * not yet trustworthy (real cases, lib/ai-incidents.ts), say how we believe trust gets built in, get
+ * the visitor to install it locally, and let them join.
+ *
+ * THE BELIEF IS SEAN'S, THE STATUS LINES ARE THE CODE'S. Each piece pairs the idea with a "Today" line
+ * that is true of the shipped package and the live engine, and a "Not yet" line for the rest. The
+ * Today lines follow the README after the 2026-10-23 claims audit (#615, #619, #644): the hosted check
+ * is two checkers and a second opinion, not proof; reputation that moves with an agent is the aim;
+ * Carry is built and not released; x402 buying is key-gated; the Inspect record is digested, not
+ * signed. A piece moves from "Not yet" to "Today" when the code does, not when the copy does.
+ *
+ * The two on-chain receipts are the engine's own hero receipt (GET /api/v1/receipts/hero), both
+ * transactions read back from Base Sepolia on 2026-10-24 with status 1: the USDC transfer to the
+ * Base Sepolia USDC contract and the write to the ERC-8004 ReputationRegistry 0x8004B663….
+ */
+const SETTLEMENT_TX = '0x2a7ac151c23983f59564fc3da5c7ea74fdbe390f9e97fcbf70c79be27089967a';
+const REPUTATION_TX = '0xd362c1b0c819e2e1ee7bce601531afb0be1eef20c1be4ab8dc643e524d19e917';
+const basescan = (tx: string) => `https://sepolia.basescan.org/tx/${tx}`;
+
+const PIECES: ReadonlyArray<{ id: string; name: string; idea: string; today: React.ReactNode; notYet: string }> = [
+ {
+ id: 'hal',
+ name: 'HAL, the Hallucination Assessment Layer',
+ idea: 'AIs fact-check each other, so no single model gets the last word.',
+ today:
+ 'Send a sentence and secrets are scrubbed on your device first. Then two AI checkers from different model families read it. Both must agree for Checks out or Caught; anything else is Not checked, never a pass. Two checkers agreeing is a second opinion, not proof.',
+ notYet: 'More checkers, each weighted by a reputation it has earned.',
+ },
+ {
+ id: 'repid',
+ name: 'RepID, reputation that is earned',
+ idea: 'An agent’s standing comes from what it has done, not from what it says about itself.',
+ today:
+ 'Our engine scores agents and writes the score as ERC-8004 feedback on Base Sepolia, where anyone can read it. The scoring code is public. A new agent starts at the bottom tier.',
+ notYet: 'Reputation that moves with your agent to other platforms. That is the aim, and it is not a property of this release.',
+ },
+ {
+ id: 'receipts',
+ name: 'Receipts: provenance and history',
+ idea: 'Your agent’s work is checked and recorded, so it learns and earns trust before it gets autonomy.',
+ today: (
+ <>
+ Inspect checks what an agent said about a GitHub Actions run against GitHub's own record. Its record is canonical
+ JSON with a SHA-256 digest; it is not signed yet. On chain, our own house agents trade with each other: the first
+ settlement, on 24 May 2026, moved 0.10 test USDC (
+
+ payment
+
+ ), and the reputation write that followed is on Base Sepolia too (
+
+ ERC-8004 write
+
+ ).
+ >
+ ),
+ notYet: 'Feedback bound to its receipt, its payment, the delivered work and an independent check is not built yet.',
+ },
+ {
+ id: 'x402',
+ name: 'x402: agents exchanging value',
+ idea: 'As your agent proves it keeps the guardrails you set for your Personal AI, it can trade with other agents, and its value grows.',
+ today:
+ 'The package refuses a payment that has no policy, or is over the cap the policy states, before anything is signed. The calling code supplies the policy, so this stops a mistaken agent, not one that writes its own. Buying from another agent needs an API key and a funded test wallet, so it is paused for anyone without them.',
+ notYet: 'Paying only for work that checked out.',
+ },
+ {
+ id: 'testnet',
+ name: 'A test network, so nothing real is at risk',
+ idea: 'Train and test an agent with no money on the line.',
+ today:
+ 'Everything on chain here runs on Base Sepolia, a test network, not mainnet. Its tokens are free from a public faucet and worth nothing.',
+ notYet: 'Mainnet.',
+ },
+ {
+ id: 'yours',
+ name: 'Portable, private, yours',
+ idea: 'Use any model without vendor lock-in, and keep your privacy and your controls.',
+ today:
+ 'Inspect’s only network call is to api.github.com. The MCP server runs on your machine inside Claude Desktop, Cursor or Claude Code, and the sentence check works on what any model told you. The code is open source, Apache 2.0.',
+ notYet: 'Carry, which takes your profile, rules and notes to another machine, is built and not yet released.',
+ },
+];
+
+const H2 = 'text-2xl sm:text-3xl font-bold text-white text-wrap';
+const SECTION = 'px-4 sm:px-6 py-14 border-t border-slate-800';
+
+export default function GatewayPage() {
+ return (
+
+
+
+
Trust before autonomy
+
+ AI is useful. It is not yet trustworthy.
+
+
+ It invents policies, cites cases that do not exist, and acts without permission, and it sounds just as sure when
+ it is wrong. Below: what that has already cost, each case with its source, then how we believe trust gets built
+ in, and how to run it on your own machine today.
+
+ Seven documented cases from 2024 and 2025. Every detail comes from the linked source, and every quote matches it
+ word for word. If you find one we got wrong, tell us.
+
+ We believe a safe and ethical AI people can trust is built, not announced. It should be portable, so you can use
+ any model without vendor lock-in, and it should leave you holding your own privacy and controls. We are building
+ that trust harness and calling it TrustShell. Here is each piece: the idea, what runs today, and what does not yet.
+
+
+ {PIECES.map((p) => (
+
+
{p.name}
+
{p.idea}
+
+ Today
+ {p.today}
+
+
+ Not yet
+ {p.notYet}
+
+
+ ))}
+
+
+ Coming soon: TrustMarket.dev and TrustTrader.dev. Neither is part of TrustShell today.
+
+
+
+
+
+
+
+ Install it and try it on your own machine
+
+
+ A browser demo shows you a result. Running it yourself shows you how it gets there: the command line, the npm
+ package in your own code, or the MCP server inside the AI app you already use. All three are the free, open-source
+ package @hyperdag/trustshell.
+
+
+
+
Then take an agent through the whole ramp
+
+ Create an agent, claim it with two signatures from your own wallet, fund it from a public faucet, back it with a
+ deposit, let it trade, and watch its RepID move. No step uses real money: the chain is the Base Sepolia test
+ network. TrustShell never sends you tokens; a public faucet does.
+
+
+
+
+
+
+
+
+
+ Help us build agents people can depend on
+
+
+ We are learning, in the open, how to train agents to be dependable and trustworthy, so that together we can build a
+ safe, ethical and benevolent AGI for human flourishing. Leave your email, tell us what would earn your trust, and
+ join the conversation.
+
+
+ Report a wrong verdict
+
+ : a claim we marked SUPPORTED that we should not have is the bug we most want to hear about.
+
+
+
+ Build with us
+
+ : ERC-8004, x402 and OpenClaw, with what runs today and what is not built yet.
+
+
+
+
+
+ );
+}
diff --git a/components/gateway-install.tsx b/components/gateway-install.tsx
new file mode 100644
index 00000000..099b9651
--- /dev/null
+++ b/components/gateway-install.tsx
@@ -0,0 +1,111 @@
+'use client';
+
+import { useState } from 'react';
+import { AGENT_TABS, INSTALL, TERMINAL_COMMAND, mcpPaste } from '@/components/home-agent';
+
+/**
+ * /gateway, "install it": the three ways to run TrustShell on your own machine. Every command comes
+ * from the constants the home page already uses (components/home-agent.tsx), so a version bump there
+ * moves this page too, and the two cannot disagree. The Inspect line is the README's own first
+ * command. The SDK lines are the README's usage (`new TrustShell()`, `verifyOutput`).
+ */
+export const INSPECT_COMMAND =
+ 'npx @hyperdag/trustshell@latest check https://github.com/OWNER/REPO/actions/runs/1234567890 --claims-file claims.txt';
+
+export const SDK_INSTALL = 'npm install @hyperdag/trustshell';
+
+export const SDK_SNIPPET = `import { TrustShell } from '@hyperdag/trustshell';
+
+const shell = new TrustShell(); // no key needed for a check
+const result = await shell.verifyOutput('The Eiffel Tower is located in Rome, Italy.');
+console.log(result.verdict, result.evidence);`;
+
+const CODE =
+ 'max-w-full min-w-0 overflow-x-auto whitespace-pre-wrap break-all rounded-xl border border-slate-800 bg-slate-950 p-4 font-mono text-xs text-indigo-200';
+
+function Copyable({ text, label }: { text: string; label: string }) {
+ const [copied, setCopied] = useState(false);
+ // A person's click only; the text goes to their own clipboard and nowhere else.
+ async function copy() {
+ try {
+ await navigator.clipboard.writeText(text);
+ setCopied(true);
+ } catch {
+ setCopied(false);
+ }
+ }
+ return (
+
+ Check what your AI agent said about a public GitHub Actions run against GitHub's own record. Its only network
+ call is to api.github.com: no account, no key, nothing uploaded. Put your agent's words in{' '}
+ claims.txt, one plain sentence per paragraph, and cite the run id.
+
+
+
+ Or check one sentence an AI told you. This one leaves your machine: secrets are scrubbed on your device first, then
+ the sentence goes to our hosted checkers.
+
3. The MCP server, inside the AI app you already use
+
Paste this block into your app's MCP settings file and restart the app.
+
+ {AGENT_TABS.map((tab) => (
+
+ {tab.label}: {tab.where}
+
+ ))}
+
+
+
+
+ );
+}
diff --git a/components/gateway-join.tsx b/components/gateway-join.tsx
new file mode 100644
index 00000000..04e9150b
--- /dev/null
+++ b/components/gateway-join.tsx
@@ -0,0 +1,116 @@
+'use client';
+
+import { useState } from 'react';
+import { createClient } from '@/lib/supabase';
+
+/**
+ * /gateway, "join us": an email and an optional note, saved to the TrustShell signup table.
+ *
+ * NOT `waitlist`. Every insert into `waitlist` fires two triggers [MEASURED 2026-10-24, pg_trigger]:
+ * `on_waitlist_insert` mails the address a TrustTrader welcome ("0% vs 49.63% without veto", "$499/mo
+ * value", from hello@trusttrader.dev), and `trg_notify_new_signup` posts the address to Telegram. A
+ * page about trust cannot answer a signup with claims nobody has checked.
+ *
+ * `trustshell_v15_waitlist` instead [MEASURED 2026-10-24]: RLS on, anon may INSERT and has no read
+ * policy, no triggers, every column but email defaulted. The note rides in its `metadata` jsonb. The
+ * browser insert with the publishable key is the path components/roadmap-v15.tsx used against this
+ * table in production (rows with source trustshell.dev/v15-form, 2026-09-19).
+ *
+ * A failure is never shown as a success: no key in the build, a refused insert or a dropped request
+ * all read "Not saved", with GitHub as the way to reach us instead.
+ */
+export const SIGNUP_TABLE = 'trustshell_v15_waitlist';
+export const SIGNUP_SOURCE = 'trustshell.dev/gateway';
+export const MAX_NOTE = 1000;
+const EMAIL_RE = /^[^@\s]+@[^@\s]+\.[^@\s]+$/;
+
+type Status = 'idle' | 'sending' | 'saved' | 'failed';
+
+const FIELD =
+ 'w-full min-h-11 rounded-lg border border-slate-700 bg-slate-950 px-3 py-2 text-white placeholder:text-slate-500 focus:outline-none focus:ring-2 focus:ring-amber-500/60';
+
+export function GatewayJoin() {
+ const [email, setEmail] = useState('');
+ const [note, setNote] = useState('');
+ const [status, setStatus] = useState('idle');
+
+ async function submit(e: React.FormEvent) {
+ e.preventDefault();
+ if (!EMAIL_RE.test(email.trim())) return;
+ setStatus('sending');
+ try {
+ const text = note.trim();
+ const { error } = await createClient()
+ .from(SIGNUP_TABLE)
+ .insert({ email: email.trim(), source: SIGNUP_SOURCE, ...(text ? { metadata: { note: text } } : {}) });
+ setStatus(error ? 'failed' : 'saved');
+ } catch {
+ // No key in this build, or the request never left: still a failure, never a success.
+ setStatus('failed');
+ }
+ }
+
+ if (status === 'saved') {
+ return (
+
+ Saved. Thank you. Nothing is emailed to you automatically; we will write when there is something worth your time.
+
+ );
+ }
+
+ return (
+
+ );
+}
diff --git a/lib/ai-incidents.ts b/lib/ai-incidents.ts
new file mode 100644
index 00000000..18de0ada
--- /dev/null
+++ b/lib/ai-incidents.ts
@@ -0,0 +1,128 @@
+/**
+ * Real, documented cases of AI being confidently wrong or acting without permission, for /gateway.
+ *
+ * EVERY FIELD COMES FROM THE SOURCE IT LINKS. Each source was fetched on 2026-10-24 and each `quote`
+ * was matched word for word against the fetched text (report: E:\dev\reports\2026-10-24\
+ * GATEWAY_PAGE.md). `what` paraphrases the source and adds nothing it does not say. A page whose
+ * point is trust cannot carry one invented detail, so the traps found while checking are kept here:
+ * - Deloitte: the refund amount was not disclosed, and Deloitte did not confirm AI caused the errors
+ * (AP says "apparent"). Neither appears below.
+ * - Air Canada: "a separate legal entity" is the tribunal's summary of the airline's argument, not
+ * the airline's words. Not quoted.
+ * - UK High Court: AI use is the claimant's own account in Al-Haroun only; in the Ayinde half of the
+ * same judgment the court did not find AI was the cause. Only Al-Haroun is described.
+ * - Replit: the deletion is SaaStr's founder's account; The Register says he "claimed" it, and so do we.
+ * Add a case only with a fetched primary or major-outlet source and a quote checked against it.
+ */
+export type AiIncident = {
+ id: string;
+ title: string;
+ /** When it happened, as precisely as the source says and no more precisely. */
+ when: string;
+ what: string;
+ /** Under 15 words, verbatim from the source. */
+ quote: string;
+ failure: string;
+ source: { outlet: string; published: string; url: string };
+};
+
+export const AI_INCIDENTS: readonly AiIncident[] = [
+ {
+ id: 'air-canada',
+ title: 'An airline was held to what its chatbot invented',
+ when: 'Decided 14 February 2024',
+ what:
+ 'After a customer’s grandmother died, Air Canada’s website chatbot told them they could apply for a bereavement fare retroactively. The airline’s own policy page said the opposite. The tribunal found negligent misrepresentation and ordered Air Canada to pay.',
+ quote: 'Air Canada did not take reasonable care to ensure its chatbot was accurate',
+ failure: 'Made-up policy',
+ source: {
+ outlet: 'BC Civil Resolution Tribunal, Moffatt v. Air Canada, 2024 BCCRT 149',
+ published: '14 February 2024',
+ url: 'https://decisions.civilresolutionbc.ca/crt/crtd/en/525448/1/document.do',
+ },
+ },
+ {
+ id: 'google-ai-overviews',
+ title: 'Search answers built from jokes',
+ when: 'May 2024',
+ what:
+ 'After AI Overviews launched across the U.S., Google acknowledged that some odd, inaccurate or unhelpful answers showed up, including one drawn from satire about eating rocks and forum advice to put glue on pizza.',
+ quote: 'less-than-helpful advice, like using glue to get cheese to stick to pizza',
+ failure: 'Satire and forum posts presented as fact',
+ source: {
+ outlet: 'Google, The Keyword (Elizabeth Reid, VP, Search)',
+ published: '30 May 2024',
+ url: 'https://blog.google/products/search/ai-overviews-update-may-2024/',
+ },
+ },
+ {
+ id: 'cursor-support-bot',
+ title: 'A support bot announced a policy that did not exist',
+ when: 'April 2025',
+ what:
+ 'A Cursor user kept being logged out when switching machines. A support agent called “Sam” replied that one device per subscription was a core security feature. Cursor had no such policy, and Sam was an AI bot. Users publicly cancelled before the company apologized.',
+ quote: 'no such policy existed, and Sam was a bot',
+ failure: 'Made-up policy, from a bot not disclosed as one',
+ source: {
+ outlet: 'Ars Technica',
+ published: '17 April 2025',
+ url: 'https://arstechnica.com/ai/2025/04/cursor-ai-support-bot-invents-fake-policy-and-triggers-user-uproar/',
+ },
+ },
+ {
+ id: 'replit-database',
+ title: 'A coding agent deleted a production database during a code freeze',
+ when: 'July 2025',
+ what:
+ 'SaaStr’s founder said Replit’s AI agent deleted a database despite instructions not to change code without permission, created fake data and reports, and wrongly said a rollback was impossible. Replit’s CEO called it unacceptable and announced separate development and production databases.',
+ quote: 'deleted a database despite his instructions not to change any code without permission',
+ failure: 'Action without permission, then false reports about it',
+ source: {
+ outlet: 'The Register',
+ published: '21 July 2025',
+ url: 'https://www.theregister.com/2025/07/21/replit_saastr_vibe_coding_incident/',
+ },
+ },
+ {
+ id: 'reading-list',
+ title: 'Newspapers printed a reading list of books that do not exist',
+ when: 'May 2025',
+ what:
+ 'The Chicago Sun-Times and at least one edition of The Philadelphia Inquirer ran a syndicated summer reading list crediting invented books to real authors. The writer said it was partly generated by AI.',
+ quote: 'Only five of the 15 titles on the list are real.',
+ failure: 'Invented facts, published without a check',
+ source: {
+ outlet: 'NPR',
+ published: '20 May 2025',
+ url: 'https://www.npr.org/2025/05/20/nx-s1-5405022/fake-summer-reading-list-ai',
+ },
+ },
+ {
+ id: 'deloitte-report',
+ title: 'A government report with references that do not exist',
+ when: 'Reported October 2025',
+ what:
+ 'Deloitte Australia agreed to partially refund the Australian government for a report containing apparent AI-generated errors, including references to nonexistent academic papers. The revised version discloses that Azure OpenAI was used.',
+ quote: 'a fabricated quote from a federal court judgment',
+ failure: 'Invented citations and quotes in paid work',
+ source: {
+ outlet: 'AP News',
+ published: '7 October 2025',
+ url: 'https://apnews.com/article/australia-ai-errors-deloitte-ab54858680ffc4ae6555b31c8fb987f3',
+ },
+ },
+ {
+ id: 'uk-high-court',
+ title: 'A court found 18 cited cases that do not exist',
+ when: 'Judgment 6 June 2025',
+ what:
+ 'In Al-Haroun v Qatar National Bank, witness statements cited 45 authorities and 18 did not exist. The claimant said the citations came from publicly available AI tools, legal search engines and online sources. The High Court referred the solicitor to the regulator and warned the whole profession.',
+ quote: 'In eighteen instances, the case cited does not exist.',
+ failure: 'Invented legal citations',
+ source: {
+ outlet: 'High Court of England and Wales, [2025] EWHC 1383 (Admin), via the National Archives',
+ published: '6 June 2025',
+ url: 'https://caselaw.nationalarchives.gov.uk/ewhc/admin/2025/1383',
+ },
+ },
+];
diff --git a/package.json b/package.json
index 555d83fe..e789cca8 100644
--- a/package.json
+++ b/package.json
@@ -84,6 +84,7 @@
"test:roles-honesty": "node tests/e2e/roles-honesty.mjs",
"test:build-walk": "node tests/e2e/build-walk.mjs",
"test:site-walk": "node tests/e2e/site-walk.mjs",
+ "test:gateway-walk": "node tests/e2e/gateway-walk.mjs",
"test:check-walk": "node tests/e2e/check-walk.mjs",
"test:stranger-path-walk": "node tests/e2e/stranger-path-walk.mjs",
"test:scrub-outbound-walk": "node tests/e2e/scrub-outbound-walk.mjs",
diff --git a/tests/e2e/gateway-walk.mjs b/tests/e2e/gateway-walk.mjs
new file mode 100644
index 00000000..d25623ff
--- /dev/null
+++ b/tests/e2e/gateway-walk.mjs
@@ -0,0 +1,173 @@
+/**
+ * trustshell.dev/gateway, driven in a real browser: the parts only a browser can prove.
+ *
+ * - Loading the page makes no request to any other host (the cases' sources are links, not fetches).
+ * - Copy puts exactly the Inspect command and a parseable MCP setup on the clipboard.
+ * - Every stage of the ramp the page links to (/agents → /leaderboard) is served.
+ * - The join form posts { email, source, metadata: { note } } to trustshell_v15_waitlist and to
+ * nothing else (never `waitlist`, whose trigger mails a TrustTrader promotion), says "Saved"
+ * only on a 201, and "Not saved" on a refusal (PostgREST PGRST205 here) or a dropped request.
+ * - Nothing scrolls sideways at 375 px.
+ *
+ * The Supabase origin is a stub: the build is given NEXT_PUBLIC_SUPABASE_URL=https://stub-supabase.invalid
+ * and a fake publishable key, and every request to it is answered by page.route. No row is written
+ * anywhere. NOT in gating CI (it needs a browser and a server), like its siblings.
+ *
+ * npm run test:gateway-walk # builds, serves on :3131, walks
+ * npm run test:gateway-walk -- --base=http://localhost:3131 # walks a server you already run
+ *
+ * With --base, the server must have been built with the stub URL above, or the form checks fail.
+ * Exit: 0 every check passed · 1 any failed · 2 NOT_CHECKED (no browser, port busy, app never up).
+ */
+import { chromiumExecutablePath, LAUNCH_ARGS, loadPlaywrightOrExit, spawnNext } from './chromium-path.mjs';
+
+const { chromium } = await loadPlaywrightOrExit();
+const BASE_ARG = process.argv.find((a) => a.startsWith('--base='));
+const PORT = 3131;
+const BASE = (BASE_ARG ? BASE_ARG.slice('--base='.length) : `http://127.0.0.1:${PORT}`).replace(/\/+$/, '');
+const STUB = 'https://stub-supabase.invalid';
+const INSPECT =
+ 'npx @hyperdag/trustshell@latest check https://github.com/OWNER/REPO/actions/runs/1234567890 --claims-file claims.txt';
+const RAMP = ['/agents', '/bind', '/faucet', '/stake', '/spend', '/leaderboard'];
+
+const results = [];
+const check = (name, ok, detail = '') => {
+ results.push({ name, ok: !!ok, detail });
+ console.log(`${ok ? 'PASS' : 'FAIL'} ${name}${ok ? '' : ` ${detail}`}`);
+};
+
+let killApp = () => {};
+if (!BASE_ARG) {
+ try {
+ await fetch(`${BASE}/`, { signal: AbortSignal.timeout(2000) });
+ console.error(`NOT_CHECKED: port ${PORT} is already serving; this suite tests only a server it started (or pass --base=).`);
+ process.exit(2);
+ } catch { /* free: the state we want */ }
+ const env = { ...process.env, NEXT_PUBLIC_SUPABASE_URL: STUB, NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY: 'sb_publishable_e2e_stub' };
+ const build = spawnNext(['build'], { stdio: 'ignore', env });
+ if ((await new Promise((r) => build.on('exit', r))) !== 0) {
+ console.error('FATAL: next build failed');
+ process.exit(1);
+ }
+ const app = spawnNext(['start', '--port', String(PORT)], { stdio: 'ignore', detached: true, env });
+ killApp = () => { try { process.kill(-app.pid, 'SIGKILL'); } catch { try { app.kill('SIGKILL'); } catch {} } };
+ process.on('exit', killApp);
+}
+
+async function up(url, ms = 180_000) {
+ for (const end = Date.now() + ms; Date.now() < end; await new Promise((r) => setTimeout(r, 500))) {
+ try { if ((await fetch(url)).ok) return true; } catch { /* not yet */ }
+ }
+ return false;
+}
+if (!(await up(`${BASE}/gateway`))) {
+ console.error('NOT_CHECKED: the app never became ready');
+ process.exit(2);
+}
+
+const browser = await chromium.launch({ executablePath: chromiumExecutablePath(), args: LAUNCH_ARGS });
+try {
+ const desk = await browser.newContext({ viewport: { width: 1280, height: 800 } });
+ await desk.grantPermissions(['clipboard-read', 'clipboard-write'], { origin: new URL(BASE).origin });
+ const page = await desk.newPage();
+
+ // Every request the page makes, and every write the form attempts.
+ const requests = [];
+ page.on('request', (r) => requests.push(r.url()));
+ const posts = [];
+ let answer = 'created';
+ await page.route(`${STUB}/**`, async (route) => {
+ const req = route.request();
+ posts.push({ url: req.url(), method: req.method(), body: req.postData() });
+ if (answer === 'abort') return route.abort('failed');
+ if (answer === 'missing') {
+ return route.fulfill({
+ status: 404,
+ contentType: 'application/json',
+ body: JSON.stringify({ code: 'PGRST205', message: "Could not find the table 'public.trustshell_v15_waitlist' in the schema cache" }),
+ });
+ }
+ return route.fulfill({ status: 201, body: '' });
+ });
+
+ const res = await page.goto(`${BASE}/gateway`, { waitUntil: 'networkidle' });
+ check('the page is served', res?.status() === 200, String(res?.status()));
+ check('the headline says AI is not yet trustworthy', (await page.locator('h1').innerText()).includes('It is not yet trustworthy.'));
+ const origin = new URL(BASE).origin;
+ const foreign = requests.filter((u) => !u.startsWith(origin) && !u.startsWith('data:'));
+ check('loading the page asks no other host for anything', foreign.length === 0, foreign.join(', '));
+
+ const cases = page.locator('[data-testid=incident]');
+ check('seven cases are shown', (await cases.count()) === 7, String(await cases.count()));
+ const sources = await cases.locator('a').evaluateAll((as) => as.map((a) => a.href));
+ check('every case links an https source', sources.length === 7 && sources.every((h) => h.startsWith('https://')), sources.join(' '));
+
+ // The OS clipboard may store CRLF (Windows does); the page writes LF. Compare text, not platform.
+ const clip = async () => (await page.evaluate(() => navigator.clipboard.readText())).split(String.fromCharCode(13)).join('');
+ await page.getByRole('button', { name: 'Copy the Inspect command' }).click();
+ check('copy: the Inspect command lands on the clipboard exactly', (await clip()) === INSPECT, JSON.stringify(await clip()));
+ await page.getByRole('button', { name: 'Copy the MCP setup' }).click();
+ let mcp = null;
+ try { mcp = JSON.parse(await clip()); } catch { /* reported below */ }
+ check(
+ 'copy: the MCP setup is JSON that runs trustshell-mcp from the package',
+ mcp?.mcpServers?.trustshell?.command === 'npx' && mcp.mcpServers.trustshell.args.includes('trustshell-mcp'),
+ JSON.stringify(mcp),
+ );
+
+ const rampHrefs = await page.locator('nav[aria-label="Where you are in the journey"] a').evaluateAll((as) => as.map((a) => new URL(a.href).pathname));
+ check('the ramp links every stage, Create to Earn', JSON.stringify(rampHrefs) === JSON.stringify(RAMP), rampHrefs.join(' '));
+ for (const p of RAMP) {
+ const r = await page.request.get(`${BASE}${p}`);
+ check(`ramp: ${p} is served`, r.status() === 200, String(r.status()));
+ }
+
+ // --- the join form
+ const email = page.locator('#join-email');
+ const note = page.locator('#join-note');
+ const submit = page.getByRole('button', { name: 'Count me in' });
+
+ answer = 'missing';
+ await email.fill('walker@example.com');
+ await note.fill('Show me what it got wrong.');
+ await submit.click();
+ await page.locator('[data-testid=join-failed]').waitFor({ timeout: 10_000 }).catch(() => {});
+ check('join: a refused insert reads Not saved, never Saved', (await page.locator('[data-testid=join-failed]').isVisible()) && !(await page.locator('[data-testid=join-saved]').count()));
+
+ answer = 'abort';
+ await submit.click();
+ await page.waitForTimeout(1500);
+ check('join: a request that never arrives reads Not saved', (await page.locator('[data-testid=join-failed]').isVisible()) && !(await page.locator('[data-testid=join-saved]').count()));
+
+ answer = 'created';
+ await submit.click();
+ await page.locator('[data-testid=join-saved]').waitFor({ timeout: 10_000 }).catch(() => {});
+ check('join: a 201 reads Saved', await page.locator('[data-testid=join-saved]').isVisible());
+
+ const writes = posts.filter((p) => p.method === 'POST');
+ check('join: every write goes to trustshell_v15_waitlist and none to waitlist', writes.length >= 2 && writes.every((w) => new URL(w.url).pathname === '/rest/v1/trustshell_v15_waitlist'), writes.map((w) => w.url).join(' '));
+ let row = null;
+ try { row = JSON.parse(writes.at(-1)?.body ?? 'null'); } catch { /* reported below */ }
+ const one = Array.isArray(row) ? row[0] : row;
+ check(
+ 'join: the row is the email, the source and the note, nothing else',
+ one && JSON.stringify(Object.keys(one).sort()) === '["email","metadata","source"]' && one.email === 'walker@example.com' && JSON.stringify(one.metadata) === '{"note":"Show me what it got wrong."}' && one.source === 'trustshell.dev/gateway',
+ JSON.stringify(row),
+ );
+ await desk.close();
+
+ // --- phone
+ const phone = await browser.newContext({ viewport: { width: 375, height: 812 }, isMobile: true, hasTouch: true });
+ const pp = await phone.newPage();
+ await pp.goto(`${BASE}/gateway`, { waitUntil: 'networkidle' });
+ check('phone: nothing scrolls sideways', await pp.evaluate(() => document.documentElement.scrollWidth <= document.documentElement.clientWidth));
+ check('phone: the headline is above the fold', ((await pp.locator('h1').boundingBox())?.y ?? 9999) < 812);
+ await phone.close();
+} finally {
+ await browser.close();
+ killApp();
+}
+
+const failed = results.filter((r) => !r.ok);
+console.log(`\n${results.length - failed.length}/${results.length} passed`);
+process.exit(failed.length ? 1 : 0);
diff --git a/tests/gateway-page.test.ts b/tests/gateway-page.test.ts
new file mode 100644
index 00000000..70c2a119
--- /dev/null
+++ b/tests/gateway-page.test.ts
@@ -0,0 +1,162 @@
+/**
+ * /gateway: the candidate front door. RENDERED, like tests/home-check.test.ts, so these assert on what
+ * a visitor reads, not on source text. The browser half (copy buttons, the join form's success and
+ * failure paths) is driven by tests/e2e/gateway-walk.mjs.
+ *
+ * What must not regress:
+ * - every case on the page links its source and quotes it briefly (lib/ai-incidents.ts);
+ * - every piece of the plan says what runs today AND what does not yet; nothing is promoted to
+ * "today" by copy alone;
+ * - the join form writes to `trustshell_v15_waitlist` and never to `waitlist`, whose insert trigger
+ * mails a TrustTrader promotion;
+ * - the install commands are the home page's own, so the two pages cannot drift.
+ */
+/* eslint-disable @typescript-eslint/no-require-imports -- a fresh module registry per render needs require(), as in home-check.test.ts */
+import { readFileSync } from 'node:fs';
+import { join } from 'node:path';
+import type { ReactElement } from 'react';
+import { AI_INCIDENTS } from '../lib/ai-incidents';
+
+const ROOT = join(__dirname, '..');
+
+function render(modulePath: string): { html: string; calls: unknown[] } {
+ const calls: unknown[] = [];
+ const prev = global.fetch;
+ global.fetch = ((...args: unknown[]) => {
+ calls.push(args);
+ return Promise.reject(new Error('no request may leave while the page renders'));
+ }) as typeof fetch;
+ try {
+ let html = '';
+ jest.isolateModules(() => {
+ const { createElement } = require('react') as typeof import('react');
+ const { renderToStaticMarkup } = require('react-dom/server') as typeof import('react-dom/server');
+ const Page = (require(modulePath) as { default: () => ReactElement }).default;
+ html = renderToStaticMarkup(createElement(Page));
+ });
+ return { html, calls };
+ } finally {
+ global.fetch = prev;
+ }
+}
+
+/** The words a reader sees. `&` is decoded LAST, so `<` stays `<` (CodeQL js/double-escaping). */
+function words(html: string): string {
+ return html
+ .replace(/<[^>]+>/g, ' ')
+ .replace(/"/g, '"')
+ .replace(/'/g, "'")
+ .replace(/</g, '<')
+ .replace(/>/g, '>')
+ .replace(/&/g, '&')
+ .replace(/\s+/g, ' ');
+}
+
+describe('/gateway', () => {
+ const { html, calls } = render('../app/gateway/page');
+ const text = words(html);
+ const hrefs = [...html.matchAll(/href="([^"]+)"/g)].map((m) => m[1].replace(/&/g, '&'));
+
+ it('sends nothing while rendering', () => {
+ expect(calls).toHaveLength(0);
+ });
+
+ describe('the cases', () => {
+ it('shows every case in lib/ai-incidents.ts, and says how many', () => {
+ expect(html.match(/data-testid="incident"/g)).toHaveLength(AI_INCIDENTS.length);
+ expect(AI_INCIDENTS).toHaveLength(7);
+ expect(text).toContain('Seven documented cases');
+ });
+
+ it.each(AI_INCIDENTS.map((c) => [c.id, c]))('%s links an https source and quotes it briefly', (_id, c) => {
+ expect(c.source.url).toMatch(/^https:\/\//);
+ expect(hrefs).toContain(c.source.url);
+ expect(c.quote.split(/\s+/).length).toBeLessThan(15);
+ expect(text).toContain(c.quote);
+ expect(c.source.published).toMatch(/\b20\d\d\b/);
+ expect(c.when).toMatch(/\b20\d\d\b/);
+ });
+
+ it('keeps the traps found while checking the sources out of the copy', () => {
+ const all = JSON.stringify(AI_INCIDENTS);
+ expect(all).not.toMatch(/440,000|\$290,000/); // Deloitte's refund amount was not disclosed
+ expect(all).not.toMatch(/separate legal entity/); // the tribunal's summary, not Air Canada's words
+ expect(all).not.toMatch(/Ayinde/); // the court did not find AI was the cause in that half
+ expect(all).toMatch(/apparent AI-generated errors/); // AP's word, kept
+ });
+ });
+
+ describe('the plan', () => {
+ const pieces = html.match(/
]*data-testid="piece"[\s\S]*?<\/li>/g) ?? [];
+
+ it('pairs every piece with a Today line and a Not yet line', () => {
+ expect(pieces.length).toBe(6);
+ for (const p of pieces) {
+ expect(words(p)).toMatch(/Today /);
+ expect(words(p)).toMatch(/Not yet /);
+ }
+ });
+
+ it('states the audited limits, not the aims, as today', () => {
+ expect(text).toContain('Two checkers agreeing is a second opinion, not proof.');
+ expect(text).toContain('it is not a property of this release');
+ expect(text).toContain('it is not signed yet');
+ expect(text).toContain('is built and not yet released');
+ expect(text).toContain('it is paused for anyone without them');
+ expect(text).toContain('Base Sepolia, a test network, not mainnet');
+ });
+
+ it('makes none of the claims nobody has measured', () => {
+ expect(text).not.toMatch(/hallucination[- ]free|guarantee|mathematically|proven to|trustless|unbreakable|zero risk/i);
+ expect(text).not.toMatch(/\bsix\b|\b6 (providers|checkers|AIs)/i);
+ });
+
+ it('names TrustMarket and TrustTrader as coming soon without linking either', () => {
+ expect(text).toContain('Coming soon: TrustMarket.dev and TrustTrader.dev.');
+ expect(hrefs.some((h) => /trust(market|trader)\.dev/i.test(h))).toBe(false);
+ });
+
+ it('links the two on-chain receipts to BaseScan on Base Sepolia', () => {
+ const scans = hrefs.filter((h) => h.startsWith('https://sepolia.basescan.org/tx/0x'));
+ expect(scans).toHaveLength(2);
+ for (const s of scans) expect(s).toMatch(/\/tx\/0x[0-9a-f]{64}$/);
+ });
+ });
+
+ describe('install', () => {
+ const agent = readFileSync(join(ROOT, 'components/home-agent.tsx'), 'utf8');
+ const pinned = agent.match(/export const INSTALL = '([^']+)'/)?.[1];
+
+ it('offers the CLI, the npm package and the MCP server', () => {
+ for (const id of ['install-cli', 'install-sdk', 'install-mcp']) expect(html).toContain(`data-testid="${id}"`);
+ expect(text).toContain('npm install @hyperdag/trustshell');
+ expect(text).toContain('"trustshell-mcp"');
+ expect(text).toContain("import { TrustShell } from '@hyperdag/trustshell';");
+ });
+
+ it('uses the home page install command, so the two pages pin the same version', () => {
+ expect(pinned).toMatch(/^npm i -g @hyperdag\/trustshell@\d+\.\d+\.\d+$/);
+ expect(text).toContain(pinned!);
+ });
+ });
+
+ describe('join', () => {
+ const join_ = readFileSync(join(ROOT, 'components/gateway-join.tsx'), 'utf8');
+
+ it('renders the form and the conversation links', () => {
+ expect(html).toContain('data-testid="join-form"');
+ expect(hrefs).toContain('https://github.com/DealAppSeo/trustshell/issues/new/choose');
+ });
+
+ it('writes to trustshell_v15_waitlist, never to waitlist (whose trigger mails a TrustTrader promotion)', () => {
+ expect(join_).toContain("export const SIGNUP_TABLE = 'trustshell_v15_waitlist'");
+ expect(join_).toMatch(/\.from\(SIGNUP_TABLE\)/);
+ expect(join_).not.toMatch(/from\(['"]waitlist['"]\)/);
+ });
+
+ it('treats a thrown error or an error result as a failure, never as saved', () => {
+ expect(join_).toMatch(/setStatus\(error \? 'failed' : 'saved'\)/);
+ expect(join_).toMatch(/catch \{[\s\S]*?setStatus\('failed'\)/);
+ });
+ });
+});