diff --git a/.env.example b/.env.example
new file mode 100644
index 0000000..9e1b12d
--- /dev/null
+++ b/.env.example
@@ -0,0 +1,10 @@
+# The quiz's expert deck, sign-in and leaderboard (/play). Without these the
+# site still builds, and the open quiz still plays; the rest stays off.
+# See content/quiz/README.md.
+
+# Supabase project settings: Project Settings > API. Public by design.
+NEXT_PUBLIC_SUPABASE_URL=
+NEXT_PUBLIC_SUPABASE_ANON_KEY=
+
+# Server only. Never prefix it with NEXT_PUBLIC_.
+SUPABASE_SERVICE_ROLE_KEY=
diff --git a/.gitignore b/.gitignore
index e272c39..23e4cfe 100644
--- a/.gitignore
+++ b/.gitignore
@@ -17,3 +17,6 @@ next-env.d.ts
# misc
.DS_Store
npm-debug.log*
+
+# the expert deck: never in the public repo (see content/quiz/README.md)
+/private/
diff --git a/README.md b/README.md
index c1242a9..b6a4867 100644
--- a/README.md
+++ b/README.md
@@ -64,7 +64,7 @@ Please read [CONTRIBUTING.md](CONTRIBUTING.md) first: it covers the status rules
## How it's built
-A small [Next.js](https://nextjs.org) app with no backend, no database and no tracking. Every page is static, built from the files in [`content/`](content/).
+A small [Next.js](https://nextjs.org) app with no tracking. Every page of the guide is static, built from the files in [`content/`](content/). The one exception is the [quiz](https://ax.guide/play): its open deck plays in the browser, while sign-in, the expert deck and the leaderboard run on [Supabase](https://supabase.com). Without Supabase keys the site still builds, and the open quiz still plays. See [`content/quiz/README.md`](content/quiz/README.md).
```
content/
@@ -74,9 +74,12 @@ content/
pairs.json the pairs people mix up (/compare)
guide.json which one do I need (/which)
home.json, site.json page text: titles, nav, footer, status labels
+ quiz/open.json the quiz's open deck (the expert deck is kept out of the repo)
contribute.md the /contribute page
lib/schema.ts the rules every standard's front matter must follow
lib/standards.ts reads, checks and numbers the standards
+lib/quiz*.ts the quiz: cards, scoring, dealing, expert runs
+supabase/migrations/ the quiz's database: players, the expert deck, runs
app/ pages and share images (Next.js App Router)
components/ small presentational components
tests/ checks every content file and this README's table
@@ -101,6 +104,7 @@ npm run dev # http://localhost:3000
| `npm run lint` | ESLint |
| `npm run typecheck` | TypeScript |
| `npm run readme` | Regenerate the table of standards in this README |
+| `npm run quiz:sql` | Check `private/quiz-expert.json` and write the SQL that loads it into Supabase |
### Deploy
diff --git a/app/api/play/me/route.ts b/app/api/play/me/route.ts
new file mode 100644
index 0000000..707d560
--- /dev/null
+++ b/app/api/play/me/route.ts
@@ -0,0 +1,10 @@
+import { json } from "@/lib/play-route";
+import { playerName } from "@/lib/quiz-expert";
+import { currentUserId, playBackendEnabled } from "@/lib/supabase-server";
+
+/** Whether expert runs are on, and who is signed in. */
+export async function GET() {
+ if (!playBackendEnabled()) return json({ enabled: false, player: null });
+ const userId = await currentUserId();
+ return json({ enabled: true, player: userId ? { name: (await playerName(userId)) ?? "player" } : null });
+}
diff --git a/app/api/play/runs/[id]/route.ts b/app/api/play/runs/[id]/route.ts
new file mode 100644
index 0000000..04dade0
--- /dev/null
+++ b/app/api/play/runs/[id]/route.ts
@@ -0,0 +1,21 @@
+import { z } from "zod";
+import { withPlayer } from "@/lib/play-route";
+import { answerCard, nextCard, PlayError } from "@/lib/quiz-expert";
+
+const step = z.discriminatedUnion("action", [
+ z.strictObject({ action: z.literal("answer"), position: z.int().min(0), choice: z.int().min(0).max(3).nullable() }),
+ z.strictObject({ action: z.literal("next"), position: z.int().min(0) }),
+]);
+
+/** One step of an expert run: answer the open card, or deal the next one. */
+export async function POST(request: Request, { params }: { params: Promise<{ id: string }> }) {
+ const { id } = await params;
+ return withPlayer(async (userId) => {
+ const parsed = step.safeParse(await request.json().catch(() => undefined));
+ if (!parsed.success) throw new PlayError(400, "Malformed step.");
+ const body = parsed.data;
+ return body.action === "answer"
+ ? answerCard(userId, id, body.position, body.choice)
+ : nextCard(userId, id, body.position);
+ });
+}
diff --git a/app/api/play/runs/route.ts b/app/api/play/runs/route.ts
new file mode 100644
index 0000000..f7e1d43
--- /dev/null
+++ b/app/api/play/runs/route.ts
@@ -0,0 +1,7 @@
+import { withPlayer } from "@/lib/play-route";
+import { startRun } from "@/lib/quiz-expert";
+
+/** Start an expert run: deals its first card. */
+export async function POST() {
+ return withPlayer((userId) => startRun(userId));
+}
diff --git a/app/api/play/signout/route.ts b/app/api/play/signout/route.ts
new file mode 100644
index 0000000..1e97421
--- /dev/null
+++ b/app/api/play/signout/route.ts
@@ -0,0 +1,7 @@
+import { json } from "@/lib/play-route";
+import { signOut } from "@/lib/supabase-server";
+
+export async function POST() {
+ await signOut();
+ return json({ ok: true });
+}
diff --git a/app/auth/callback/route.ts b/app/auth/callback/route.ts
new file mode 100644
index 0000000..f575434
--- /dev/null
+++ b/app/auth/callback/route.ts
@@ -0,0 +1,14 @@
+import { NextResponse } from "next/server";
+import { safeNext } from "@/lib/quiz";
+import { exchangeAuthCode } from "@/lib/supabase-server";
+
+/** Where GitHub, Google and email sign-in links land: set the session, go back to the game. */
+export async function GET(request: Request) {
+ const url = new URL(request.url);
+ const code = url.searchParams.get("code");
+ const next = safeNext(url.searchParams.get("next"));
+ const ok = code ? await exchangeAuthCode(code) : false;
+ const target = new URL(next, url.origin);
+ if (!ok) target.searchParams.set("signin", "failed");
+ return NextResponse.redirect(target);
+}
diff --git a/app/home.css b/app/home.css
index a624173..04fee6f 100644
--- a/app/home.css
+++ b/app/home.css
@@ -266,7 +266,12 @@
@media (min-width: 48rem) {
.next__list {
- grid-template-columns: repeat(3, minmax(0, 1fr));
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ }
+}
+@media (min-width: 72rem) {
+ .next__list {
+ grid-template-columns: repeat(4, minmax(0, 1fr));
}
}
diff --git a/app/play/leaderboard/page.tsx b/app/play/leaderboard/page.tsx
new file mode 100644
index 0000000..4314c0c
--- /dev/null
+++ b/app/play/leaderboard/page.tsx
@@ -0,0 +1,78 @@
+import type { Metadata } from "next";
+import Link from "next/link";
+import { leaderboard } from "@/lib/quiz-expert";
+import "../play.css";
+
+export const metadata: Metadata = {
+ title: "Quiz leaderboard",
+ description: "The best expert runs on the ax.guide agentic web quiz.",
+ alternates: { canonical: "/play/leaderboard" },
+};
+
+// Read fresh at most once a minute.
+export const revalidate = 60;
+
+type Props = { searchParams: Promise<{ period?: string }> };
+
+/** Each player's best expert run: the expert deck is server-side, so these scores were earned. */
+export default async function LeaderboardPage({ searchParams }: Props) {
+ const period = (await searchParams).period === "week" ? "week" : "all";
+ const rows = await leaderboard(period);
+ return (
+
+
+
The ax.guide quiz
+
Leaderboard
+
+ Each player's best expert run: 18 cards from a deck whose answers aren't public. Equal scores share a
+ place, and the earlier run is listed first.
+
+
+
+ {rows === undefined ? (
+
The leaderboard opens with the expert deck, soon.
+ ) : rows.length === 0 ? (
+
No expert runs yet. The top spot is open.
+ ) : (
+
+
+
+
#
+
Player
+
+ Right
+
+
+ Points
+
+
+
+
+ {rows.map((row) => (
+
+
{row.rank}
+
+ {row.name}
+
+
+ {row.correct}/{row.total}
+
+
{row.score.toLocaleString("en-US")}
+
+ ))}
+
+
+ )}
+
+ Play
+
+
+ );
+}
diff --git a/app/play/page.tsx b/app/play/page.tsx
new file mode 100644
index 0000000..4242421
--- /dev/null
+++ b/app/play/page.tsx
@@ -0,0 +1,25 @@
+import type { Metadata } from "next";
+import { QuizGame } from "@/components/quiz/quiz-game";
+import { getOpenDeck } from "@/lib/quiz-open";
+import { getStandards } from "@/lib/standards";
+import "./play.css";
+
+export const metadata: Metadata = {
+ title: "The agentic web quiz",
+ description:
+ "How well do you know the standards that let AI agents find, use, sign in to, shop on and pay websites? Twelve cards, twenty seconds each.",
+ alternates: { canonical: "/play" },
+};
+
+/**
+ * The quiz. Static: the open deck ships with the page and an open run plays in
+ * the browser. Expert runs talk to /api/play.
+ */
+export default function PlayPage() {
+ const standardNames = Object.fromEntries(getStandards().map((s) => [s.id, s.name]));
+ return (
+
+ {RUN_LENGTH.open} cards on the standards that let AI agents find, use, sign in to, shop on and pay websites,
+ and the web plumbing under them. {CARD_SECONDS} seconds a card. Fast answers score more, and so does a streak.
+
+
+
+ {notice && (
+
+ {notice}
+
+ )}
+ {error && (
+
+ {error}
+
+ )}
+
+
+
+
{RUN_LENGTH.open} cards
+
+ Quick run
+
+
No sign-up. A random dozen from the open deck, then a score to share.
+
+
+
+
+
{RUN_LENGTH.expert} cards · ranked
+
+ Expert run
+
+
+ A second deck of 60 harder cards: headers, handshakes and the pairs everyone mixes up. Its answers aren't
+ on GitHub, so the leaderboard means something.
+
+ {me === undefined ? null : !me.enabled ? (
+
The expert deck and leaderboard open soon.
+ ) : me.player ? (
+ <>
+
+
+ Playing as {me.player.name}.{" "}
+
+
+ >
+ ) : (
+ <>
+
Sign in to unlock it:
+
+ >
+ )}
+
+
+
+
+ {me?.enabled && (
+ <>
+ See the leaderboard ·{" "}
+ >
+ )}
+ Every answer links to the page that explains it.
+
+ Check {email} for a sign-in link. It brings you straight back here.
+
+ );
+ }
+
+ return (
+
+
+
+
+
+
+ {state === "error" && (
+
+ Sign-in didn't start. Try again, or another way.
+
+ )}
+
+ Only your GitHub handle, or your first name and an initial, appear on the leaderboard. Never your email.
+
+
+ );
+}
diff --git a/content/home.json b/content/home.json
index 159379a..1c2b56f 100644
--- a/content/home.json
+++ b/content/home.json
@@ -40,6 +40,11 @@
"body": "Start from what you're building, not from the acronym.",
"href": "/which"
},
+ {
+ "title": "Test yourself",
+ "body": "Twelve cards, twenty seconds each. Then share your score.",
+ "href": "/play"
+ },
{
"title": "Fix or add a standard",
"body": "Every page is a file. Open a pull request.",
diff --git a/content/quiz/README.md b/content/quiz/README.md
new file mode 100644
index 0000000..59faa0a
--- /dev/null
+++ b/content/quiz/README.md
@@ -0,0 +1,57 @@
+# The quiz
+
+[ax.guide/play](https://ax.guide/play) is a card game about the agentic web: the twelve standards on the map and the web plumbing they build on. It has two decks.
+
+| Deck | Where it lives | A run | Who can play |
+| --- | --- | --- | --- |
+| **Open** | [`open.json`](open.json), plus one "Real or hype?" card per standard, written from its page | 12 cards, played in the browser | Anyone, no sign-up |
+| **Expert** | Supabase, never this repository | 18 cards, dealt and scored by the server | Signed-in players; ranked on the leaderboard |
+
+The expert deck is kept out of the repository on purpose. If its answers were on GitHub, the leaderboard would mean nothing.
+
+## Cards
+
+Each card is checked against [`lib/quiz-schema.ts`](../../lib/quiz-schema.ts), and `npm test` fails on a broken card. A card has:
+
+- `id`: lowercase words and hyphens, starting with `o-` in the open deck and `x-` in the expert deck.
+- `kind`: one of these four.
+ - `quick`: a plain question.
+ - `which`: "Which standard am I?"
+ - `fake`: "Spot the fake". `answer` points at the made-up option.
+ - `real`: "Real or hype?"
+- `prompt`, `options` (two to four) and `answer` (the index of the right option).
+- `explain`: one or two sentences shown after the answer. Make the player smarter even when they got it right.
+- `standard`: the standard the card is about, if any. "Read more" links to its page.
+- `source`: the primary source for the fact.
+- `checked`: the day someone last checked the card.
+
+Options are shuffled when a card is dealt, so an option can never depend on its position ("both of the above"). The exceptions are `real` cards and True/False cards, which keep their written order.
+
+**Write cards that stay true.** Ask how a standard works, who made it, what it is for, and what it gets confused with. Avoid version numbers, deployment counts and "latest draft" dates: they change monthly. A standard's status is already asked by its generated "Real or hype?" card, whose answer changes the day its page does. The house style of [CONTRIBUTING.md](../../CONTRIBUTING.md) applies: plain words, no em dashes.
+
+## Scoring
+
+A right answer earns 100 points. Answering inside 3 seconds adds up to 50 more, sliding to nothing at 20 seconds. Each right answer in an unbroken streak adds 10 more, up to 50. A wrong answer, or running out of time, earns nothing and breaks the streak. The rules are in [`lib/quiz.ts`](../../lib/quiz.ts).
+
+On an expert run the server deals each card and keeps time with its own clock. A card's clock stops when it is answered and starts again only when the next card is dealt, so reading an explanation costs nothing. A card can't be answered twice, and asking for it again doesn't restart its clock.
+
+## Setting up the expert deck, sign-in and the leaderboard
+
+The open quiz needs none of this. Without it, `/play` shows the expert deck and leaderboard as "soon".
+
+1. **Create a Supabase project.** Open its SQL editor and run [`supabase/migrations/20261030000000_quiz.sql`](../../supabase/migrations/20261030000000_quiz.sql), or use `supabase db push` with the Supabase CLI. Every table has row level security and no policies, so only the site's server can read them.
+2. **Turn on sign-in** (Authentication > Sign In / Providers): GitHub, Google and Email.
+ - **GitHub:** create an OAuth app at github.com/settings/developers whose callback URL is `https://.supabase.co/auth/v1/callback`.
+ - **Google:** create an OAuth client in Google Cloud with the same callback.
+ - **Email:** Supabase sends the magic links itself. Its built-in sender is rate-limited, so add your own SMTP (for example Resend) before launch.
+3. **Set the redirect URLs** (Authentication > URL Configuration):
+ - **Site URL:** `https://ax.guide`
+ - **Redirect URLs:** `https://ax.guide/auth/callback`, `https://*-.vercel.app/auth/callback` for previews, and `http://localhost:3000/auth/callback`
+4. **Add the three variables** from [`.env.example`](../../.env.example) in Vercel (Project > Settings > Environment Variables). The service role key is a secret: keep it server-only.
+5. **Load the expert deck.** Keep it in `private/quiz-expert.json`; the `private/` folder is gitignored. Run `npm run quiz:sql`: it checks the deck with the same tests as the open deck, then writes `private/quiz-expert.sql`. Run that file in the SQL editor. Re-running it updates changed cards, adds new ones and switches off cards you removed. Runs already in progress keep working.
+
+Store the master copy of `quiz-expert.json` somewhere private, such as a private repository, a password manager or a private gist. The database holds a copy, but not a convenient one to edit.
+
+## Players' data
+
+A player's leaderboard name is their GitHub handle, or their first name and last initial, or `player-` and six characters. It is never their email. Runs are kept to rank them. Deleting a user in Supabase Auth deletes their profile and runs. Open runs never leave the browser.
diff --git a/content/quiz/open.json b/content/quiz/open.json
new file mode 100644
index 0000000..666f1b4
--- /dev/null
+++ b/content/quiz/open.json
@@ -0,0 +1,513 @@
+[
+ {
+ "id": "o-ard-which",
+ "kind": "which",
+ "prompt": "Your travel agent needs a weather tool it has never heard of, so it asks a registry that searches sites' tool lists. Which standard is at work?",
+ "options": ["NLWeb", "WebMCP", "ARD", "Poppy"],
+ "answer": 2,
+ "explain": "ARD is a search engine for agent tools: sites publish /.well-known/ard.json and registries collect those files to answer plain-language searches. A high ranking still isn't proof a tool is trustworthy.",
+ "standard": "ard",
+ "source": "https://agenticresourcediscovery.org/spec/",
+ "checked": "2026-10-30"
+ },
+ {
+ "id": "o-ard-vs-ai-catalog",
+ "kind": "quick",
+ "prompt": "ARD and ai-catalog agreed to stay loosely linked. How do they split the work?",
+ "options": ["ARD publishes, ai-catalog discovers", "ai-catalog publishes, ARD discovers", "ARD replaced ai-catalog outright", "They merged into one shared file"],
+ "answer": 1,
+ "explain": "ai-catalog is the publishing format, your site's list of its AI offerings; ARD is the discovery format that registries crawl. The practical tip: serve the same file at both ard.json and ai-catalog.json.",
+ "standard": "ard",
+ "source": "https://github.com/ards-project/ard-spec/blob/main/adr/0010-ard-and-ai-catalog-loose-coupling.md",
+ "checked": "2026-10-30"
+ },
+ {
+ "id": "o-ai-catalog-which",
+ "kind": "which",
+ "prompt": "A restaurant lists its booking tool and its jobs tool in one JSON file, like a table of contents for AI. Which standard?",
+ "options": ["ai-catalog", "NLWeb", "UCP", "auth.md"],
+ "answer": 0,
+ "explain": "ai-catalog is one JSON list at /.well-known/ai-catalog.json. Like a table of contents, it doesn't hold the tools themselves; each entry says what kind of thing it is and links to its full description or includes it.",
+ "standard": "ai-catalog",
+ "source": "https://ai-catalog.io/",
+ "checked": "2026-10-30"
+ },
+ {
+ "id": "o-ai-catalog-still-serve",
+ "kind": "quick",
+ "prompt": "ARD calls ai-catalog its predecessor. True or false: it still pays to serve ai-catalog.json.",
+ "options": ["True", "False"],
+ "answer": 0,
+ "explain": "ai-catalog was split off, not retired. MCP's Server Cards point discovery at ai-catalog.json and many publishers only use that address, so serve one file at both ai-catalog.json and ard.json.",
+ "standard": "ai-catalog",
+ "source": "https://ax.guide/standards/ai-catalog",
+ "checked": "2026-10-30"
+ },
+ {
+ "id": "o-nlweb-which",
+ "kind": "which",
+ "prompt": "Your recipe site answers \"vegetarian dinners under 30 minutes, no nuts\" at /ask for people and /mcp for agents. Which standard?",
+ "options": ["WebMCP", "ARD", "UCP", "NLWeb"],
+ "answer": 3,
+ "explain": "NLWeb indexes the structured data a site already has, like Schema.org tags and RSS, and answers only from that site. Every NLWeb site is also an MCP server, so agents can ask the same questions.",
+ "standard": "nlweb",
+ "source": "https://developers.cloudflare.com/ai-search/how-to/nlweb",
+ "checked": "2026-10-30"
+ },
+ {
+ "id": "o-nlweb-w3c-group",
+ "kind": "quick",
+ "prompt": "NLWeb has a W3C community group. What does that make NLWeb?",
+ "options": ["A W3C Recommendation", "Still not a W3C standard", "A W3C Candidate Recommendation", "An official W3C working draft"],
+ "answer": 1,
+ "explain": "Community groups are open forums that anyone can propose, and they aren't a standards track. W3C standards come out of working groups, so NLWeb is an open spec with a W3C discussion space.",
+ "standard": "nlweb",
+ "source": "https://www.w3.org/community/nlweb",
+ "checked": "2026-10-30"
+ },
+ {
+ "id": "o-guha-two-standards",
+ "kind": "quick",
+ "prompt": "R.V. Guha helped create RSS and Schema.org. Which two agentic-web standards also carry his name?",
+ "options": ["UCP and ACP", "x402 and MPP", "ARD and NLWeb", "WebMCP and Poppy"],
+ "answer": 2,
+ "explain": "Guha conceived NLWeb at Microsoft and co-authored ARD with Google and Hugging Face. NLWeb even reuses his earlier work: it indexes a site's Schema.org data.",
+ "standard": "nlweb",
+ "source": "https://ax.guide/standards/nlweb",
+ "checked": "2026-10-30"
+ },
+ {
+ "id": "o-webmcp-which",
+ "kind": "which",
+ "prompt": "A salon's page calls document.modelContext.registerTool() so the agent in your browser can book_appointment. Which standard?",
+ "options": ["NLWeb", "WebMCP", "Poppy", "UCP"],
+ "answer": 1,
+ "explain": "WebMCP lets a page hand typed tools, with names, descriptions and fields, to the agent in your browser. The agent stops hunting for the calendar widget and just calls the tool.",
+ "standard": "webmcp",
+ "source": "https://webmachinelearning.github.io/webmcp/",
+ "checked": "2026-10-30"
+ },
+ {
+ "id": "o-webmcp-where-it-runs",
+ "kind": "quick",
+ "prompt": "When an agent calls a WebMCP tool, where does the work happen?",
+ "options": ["In your own signed-in browser tab", "On a separate MCP server", "Inside an agent search registry", "On the card network's servers"],
+ "answer": 0,
+ "explain": "WebMCP tools are JavaScript functions on the page, running in your tab and your session, so you can watch and step in. A regular MCP server serves agents that never load your page.",
+ "standard": "webmcp",
+ "source": "https://developer.chrome.com/docs/ai/webmcp",
+ "checked": "2026-10-30"
+ },
+ {
+ "id": "o-webmcp-tool-text-risk",
+ "kind": "quick",
+ "prompt": "WebMCP tools come with plain-English descriptions the AI reads. What's the catch?",
+ "options": ["Descriptions slow pages down", "Only Chrome can read them", "They must be translated", "A shady site can trick the agent"],
+ "answer": 3,
+ "explain": "Tool descriptions are text the model reads, so a malicious page can try prompt injection. Chrome says agents must defend against this and gives sites ways to flag untrusted text.",
+ "standard": "webmcp",
+ "source": "https://developer.chrome.com/docs/ai/webmcp/secure-tools",
+ "checked": "2026-10-30"
+ },
+ {
+ "id": "o-web-bot-auth-which",
+ "kind": "which",
+ "prompt": "A news site lets a verified shopping agent in but blocks a scraper wearing the exact same user-agent label. Which standard tells them apart?",
+ "options": ["auth.md", "Poppy", "Web Bot Auth", "ARD"],
+ "answer": 2,
+ "explain": "With Web Bot Auth, the bot's operator publishes public keys and signs every request. Anyone can copy a label, but nobody can fake a valid signature without the private key.",
+ "standard": "web-bot-auth",
+ "source": "https://datatracker.ietf.org/doc/draft-ietf-webbotauth-httpsig-protocol/",
+ "checked": "2026-10-30"
+ },
+ {
+ "id": "o-web-bot-auth-rfc",
+ "kind": "quick",
+ "prompt": "Web Bot Auth didn't invent its own crypto. Which existing IETF standard does it sign requests with?",
+ "options": ["JSON Web Tokens", "HTTP Message Signatures", "TLS client certificates", "OAuth device flow"],
+ "answer": 1,
+ "explain": "It reuses RFC 9421, HTTP Message Signatures, which signs chosen parts of a request. Web Bot Auth adds where a bot's keys live and how a site finds them.",
+ "standard": "web-bot-auth",
+ "source": "https://datatracker.ietf.org/doc/draft-ietf-webbotauth-httpsig-protocol/",
+ "checked": "2026-10-30"
+ },
+ {
+ "id": "o-card-networks-web-bot-auth",
+ "kind": "quick",
+ "prompt": "True or false: Visa's Trusted Agent Protocol and Mastercard's Agent Pay build on Web Bot Auth.",
+ "options": ["True", "False"],
+ "answer": 0,
+ "explain": "Both build on Web Bot Auth's signed requests and add a tag saying whether the agent is browsing or buying. The card networks reused the bot ID layer instead of inventing their own.",
+ "standard": "web-bot-auth",
+ "source": "https://blog.cloudflare.com/secure-agentic-commerce/",
+ "checked": "2026-10-30"
+ },
+ {
+ "id": "o-auth-md-which",
+ "kind": "which",
+ "prompt": "Your coding agent calls a scraping API you've never used, gets \"401: who are you?\" and a file explaining how to sign you up. Which standard?",
+ "options": ["auth.md", "Poppy", "Web Bot Auth", "AP2"],
+ "answer": 0,
+ "explain": "auth.md is a sign-up page written for robots, served at yourapp.com/auth.md. However the agent signs you up, it ends with a normal short-lived, revocable OAuth token tied to you.",
+ "standard": "auth-md",
+ "source": "https://github.com/workos/auth.md",
+ "checked": "2026-10-30"
+ },
+ {
+ "id": "o-auth-md-fake-path",
+ "kind": "fake",
+ "prompt": "Spot the fake: which is NOT one of auth.md's ways for an agent to sign you up?",
+ "options": ["Vouched for by the agent's maker", "Type a code on the service's page", "Pay a $1 fee to prove you're human", "Start anonymous, claim it later"],
+ "answer": 2,
+ "explain": "auth.md has three paths: the agent's maker vouches for you, you sign in and type a short code on the service's own page, or the agent starts anonymous and you claim the account later. No fee involved.",
+ "standard": "auth-md",
+ "source": "https://github.com/workos/auth.md",
+ "checked": "2026-10-30"
+ },
+ {
+ "id": "o-poppy-which",
+ "kind": "which",
+ "prompt": "It's a valet key for your personal agent: it can sign in and act for you, but can't open the glovebox. Which standard?",
+ "options": ["auth.md", "Web Bot Auth", "AP2", "Poppy"],
+ "answer": 3,
+ "explain": "Poppy, the Personal Agent Protocol, lets your agent sign in to a company as you, with only the permissions you granted. The company explains how at /.well-known/poppy.json.",
+ "standard": "poppy",
+ "source": "https://personalagentprotocol.org/docs/spec",
+ "checked": "2026-10-30"
+ },
+ {
+ "id": "o-poppy-design-partners",
+ "kind": "real",
+ "prompt": "True or false: OpenAI, Visa and PayPal are Poppy design partners, so they already support it.",
+ "options": ["True", "False"],
+ "answer": 1,
+ "explain": "Design partners help shape a draft; that's not the same as shipping it. Before believing a logo, check whether the company actually serves a poppy.json.",
+ "standard": "poppy",
+ "source": "https://sierra.ai/blog/poppy",
+ "checked": "2026-10-30"
+ },
+ {
+ "id": "o-poppy-fake-pairing",
+ "kind": "fake",
+ "prompt": "Spot the fake: which standard is paired with the wrong creators?",
+ "options": ["Poppy: Google + Visa", "UCP: Google + Shopify", "ACP: OpenAI + Stripe", "MPP: Stripe + Tempo"],
+ "answer": 0,
+ "explain": "Poppy came from Sierra and Meta, announced October 6, 2026. The rest are right: Google and Shopify launched UCP, OpenAI and Stripe launched ACP, and Stripe and Tempo launched MPP.",
+ "standard": "poppy",
+ "source": "https://sierra.ai/blog/poppy",
+ "checked": "2026-10-30"
+ },
+ {
+ "id": "o-ucp-which",
+ "kind": "which",
+ "prompt": "A universal power socket for shopping: any assistant plugs into any store, from catalog to cart to checkout. Which standard?",
+ "options": ["ACP", "UCP", "AP2", "Poppy"],
+ "answer": 1,
+ "explain": "UCP defines the common steps of buying and carries them over ordinary web APIs, MCP or A2A. A store describes what it supports at /.well-known/ucp, and the retailer stays the legal seller.",
+ "standard": "ucp",
+ "source": "https://ucp.dev/",
+ "checked": "2026-10-30"
+ },
+ {
+ "id": "o-ucp-council-seat",
+ "kind": "real",
+ "prompt": "True or false: Amazon sits on UCP's technical council, so Amazon sells through UCP.",
+ "options": ["True", "False"],
+ "answer": 1,
+ "explain": "A council seat isn't a storefront. Companies join councils to shape a spec; whether they deploy it is a separate question you check store by store.",
+ "standard": "ucp",
+ "source": "https://ucp.dev/documentation/announcements/",
+ "checked": "2026-10-30"
+ },
+ {
+ "id": "o-acp-which",
+ "kind": "which",
+ "prompt": "You want your products shown in ChatGPT with accurate, current prices, and you already use Stripe. Which standard?",
+ "options": ["UCP", "NLWeb", "ACP", "ARD"],
+ "answer": 2,
+ "explain": "ACP comes from OpenAI and Stripe. Merchants push product feeds and promotions so ChatGPT can show current products, prices and comparisons. UCP is the rival built for any assistant.",
+ "standard": "acp",
+ "source": "https://openai.com/index/powering-product-discovery-in-chatgpt/",
+ "checked": "2026-10-30"
+ },
+ {
+ "id": "o-acp-main-job",
+ "kind": "quick",
+ "prompt": "ACP launched alongside \"Instant Checkout\" in ChatGPT. What is its main job today?",
+ "options": ["Paying with stablecoins", "Signing users in to stores", "Ranking stores by trust", "Product discovery in ChatGPT"],
+ "answer": 3,
+ "explain": "In March 2026 OpenAI expanded ACP to product discovery, while checkout moved back to the merchant's side. The headline feature, buying inside the chat, is the part that was scaled back.",
+ "standard": "acp",
+ "source": "https://openai.com/index/powering-product-discovery-in-chatgpt/",
+ "checked": "2026-10-30"
+ },
+ {
+ "id": "o-ap2-which",
+ "kind": "which",
+ "prompt": "A signed, notarized shopping list with a spending limit the bank can check before paying. Which standard?",
+ "options": ["AP2", "x402", "MPP", "ACP"],
+ "answer": 0,
+ "explain": "AP2 records what you approved as signed \"mandates,\" so the bank and merchant can answer \"did a real person agree to this?\" It authorizes payments; other rails actually move the money.",
+ "standard": "ap2",
+ "source": "https://github.com/google-agentic-commerce/AP2",
+ "checked": "2026-10-30"
+ },
+ {
+ "id": "o-ap2-human-not-present",
+ "kind": "quick",
+ "prompt": "AP2 supports \"Human Not Present\" purchases. What does that mean?",
+ "options": ["The agent pays with no limits", "You pre-sign rules; it buys later", "The merchant signs on your behalf", "A bot buys with no user at all"],
+ "answer": 1,
+ "explain": "You sign the rules up front, say \"concert tickets, under $200,\" and the agent buys later when the conditions are met, even at 3 a.m. The signed limits are what make that safe for the bank.",
+ "standard": "ap2",
+ "source": "https://github.com/google-agentic-commerce/AP2",
+ "checked": "2026-10-30"
+ },
+ {
+ "id": "o-ap2-fake-mandate",
+ "kind": "fake",
+ "prompt": "Spot the fake: AP2 records your consent in signed mandates. Which one is made up?",
+ "options": ["Open checkout mandate", "Closed checkout mandate", "Refund mandate", "Payment mandate"],
+ "answer": 2,
+ "explain": "AP2 has checkout mandates (open while they hold your limits, closed once they lock exact items and price) and payment mandates for how you pay. There's no refund mandate.",
+ "standard": "ap2",
+ "source": "https://github.com/google-agentic-commerce/AP2",
+ "checked": "2026-10-30"
+ },
+ {
+ "id": "o-mpp-which",
+ "kind": "which",
+ "prompt": "A research agent opens a pay-as-you-go \"session\" on Tempo, pays a fraction of a cent per article, and settles the total when it finishes. Which standard?",
+ "options": ["AP2", "ACP", "x402", "MPP"],
+ "answer": 3,
+ "explain": "MPP, from Stripe and Tempo, answers with HTTP 402 like x402 but adds pay-as-you-go \"sessions\" and subscriptions. Sessions run on stablecoin rails like Tempo; cards through Stripe cover one-off charges.",
+ "standard": "mpp",
+ "source": "https://mpp.dev/",
+ "checked": "2026-10-30"
+ },
+ {
+ "id": "o-mpp-individual-draft",
+ "kind": "quick",
+ "prompt": "MPP's core spec is an \"individual\" IETF Internet-Draft. What does \"individual\" tell you?",
+ "options": ["No working group has taken it on", "The IETF has approved it", "Only one company may use it", "It's already a finished RFC"],
+ "answer": 0,
+ "explain": "Anyone can post an Internet-Draft. An individual draft has no formal standing until a working group adopts it, so \"it's at the IETF\" isn't the same as \"the IETF is standardizing it.\"",
+ "standard": "mpp",
+ "source": "https://datatracker.ietf.org/doc/draft-httpauth-payment/",
+ "checked": "2026-10-30"
+ },
+ {
+ "id": "o-x402-which",
+ "kind": "which",
+ "prompt": "A parking meter on every web page: drop in a cent of USDC, the gate opens, no account needed. Which standard?",
+ "options": ["MPP", "x402", "AP2", "ACP"],
+ "answer": 1,
+ "explain": "x402, created by Coinbase and now run by a Linux Foundation project, answers with HTTP 402 and a price, and the client attaches a signed stablecoin payment. MPP is the close cousin that also takes cards.",
+ "standard": "x402",
+ "source": "https://github.com/x402-foundation/x402/blob/main/specs/x402-specification-v2.md",
+ "checked": "2026-10-30"
+ },
+ {
+ "id": "o-x402-billion-402s",
+ "kind": "real",
+ "prompt": "True or false: Cloudflare sees about a billion 402 responses a day, so x402 handles a billion payments a day.",
+ "options": ["True", "False"],
+ "answer": 1,
+ "explain": "A 402 is a request for payment, not a payment, and most never get paid. To size x402, count settled dollars, not 402 responses or transaction counts.",
+ "standard": "x402",
+ "source": "https://blog.cloudflare.com/x402/",
+ "checked": "2026-10-30"
+ },
+ {
+ "id": "o-x402-facilitator",
+ "kind": "quick",
+ "prompt": "In x402, the client attaches a signed USDC payment. What does the \"facilitator\" do?",
+ "options": ["Issues the stablecoin", "Signs the user in", "Settles it on a blockchain", "Ranks paid APIs"],
+ "answer": 2,
+ "explain": "The facilitator checks the payment and settles it on-chain in seconds, so the server doesn't run blockchain plumbing itself. Fees are a fraction of a cent, which card networks can't match.",
+ "standard": "x402",
+ "source": "https://github.com/x402-foundation/x402/blob/main/specs/x402-specification-v2.md",
+ "checked": "2026-10-30"
+ },
+ {
+ "id": "o-http-402-reserved",
+ "kind": "quick",
+ "prompt": "HTTP has had a 402 Payment Required code since the 1990s. What does RFC 9110 say it does?",
+ "options": ["Redirects to a checkout", "Blocks unpaid crawlers", "It's reserved for future use", "Asks for a card number"],
+ "answer": 2,
+ "explain": "RFC 9110 just says 402 is \"reserved for future use,\" and it sat unused for decades. x402 and MPP finally give it a job: answer with a price and how to pay.",
+ "source": "https://www.rfc-editor.org/rfc/rfc9110.html#section-15.5.3",
+ "checked": "2026-10-30"
+ },
+ {
+ "id": "o-robots-txt-location",
+ "kind": "quick",
+ "prompt": "Where must a site put its robots.txt file?",
+ "options": ["/robots.txt", "/.well-known/robots.txt", "/meta/robots.txt", "/api/robots.txt"],
+ "answer": 0,
+ "explain": "RFC 9309 says the file must be /robots.txt at the top level. It's older than the /.well-known/ folder (RFC 8615) that newer files like ard.json and poppy.json use.",
+ "source": "https://www.rfc-editor.org/rfc/rfc9309.html",
+ "checked": "2026-10-30"
+ },
+ {
+ "id": "o-robots-txt-request",
+ "kind": "quick",
+ "prompt": "True or false: robots.txt rules are a polite request, not a lock.",
+ "options": ["True", "False"],
+ "answer": 0,
+ "explain": "RFC 9309 says the rules \"are not a form of access authorization.\" Good crawlers follow them; a bot that ignores them isn't stopped, which is why sites want signed bot identity like Web Bot Auth.",
+ "source": "https://www.rfc-editor.org/rfc/rfc9309.html",
+ "checked": "2026-10-30"
+ },
+ {
+ "id": "o-oauth-device-code",
+ "kind": "quick",
+ "prompt": "Your TV says \"Go to example.com/device and enter WDJB-MJHT.\" Which OAuth flow is that?",
+ "options": ["Client credentials grant", "Device authorization grant", "Implicit grant", "Refresh token grant"],
+ "answer": 1,
+ "explain": "The device authorization grant (RFC 8628) lets a device with no good keyboard get your approval on another screen. Poppy uses the same pattern when you approve an agent on another device with a code.",
+ "source": "https://www.rfc-editor.org/rfc/rfc8628.html",
+ "checked": "2026-10-30"
+ },
+ {
+ "id": "o-oauth-pkce",
+ "kind": "quick",
+ "prompt": "PKCE, pronounced \"pixy,\" is added to OAuth's sign-in code flow. What problem does it solve?",
+ "options": ["Users forgetting passwords", "Tokens that never expire", "Bots solving CAPTCHAs", "Someone stealing the sign-in code"],
+ "answer": 3,
+ "explain": "The app makes a one-time secret, sends a hashed version at the start, and reveals the secret when trading the code for a token. A thief who grabs the code can't use it without that secret.",
+ "source": "https://www.rfc-editor.org/rfc/rfc7636.html",
+ "checked": "2026-10-30"
+ },
+ {
+ "id": "o-mcp-who",
+ "kind": "quick",
+ "prompt": "Who introduced the Model Context Protocol (MCP) in November 2024?",
+ "options": ["Anthropic", "OpenAI", "Google", "Microsoft"],
+ "answer": 0,
+ "explain": "Anthropic open-sourced MCP on November 25, 2024 as one standard way to plug AI apps into tools and data. Lots here builds on it: every NLWeb site is an MCP server, and WebMCP borrows its idea of tools.",
+ "source": "https://www.anthropic.com/news/model-context-protocol",
+ "checked": "2026-10-30"
+ },
+ {
+ "id": "o-mcp-json-rpc",
+ "kind": "quick",
+ "prompt": "Under the hood, what message format do MCP clients and servers speak?",
+ "options": ["GraphQL", "SOAP", "JSON-RPC 2.0", "gRPC"],
+ "answer": 2,
+ "explain": "MCP messages are JSON-RPC 2.0: small JSON objects with a method, params and an id. That keeps them simple to carry over a local pipe or over HTTP.",
+ "source": "https://modelcontextprotocol.io/specification/2025-06-18",
+ "checked": "2026-10-30"
+ },
+ {
+ "id": "o-mcp-fake-primitive",
+ "kind": "fake",
+ "prompt": "Spot the fake: MCP servers offer three core building blocks. Which isn't one?",
+ "options": ["Tools", "Actions", "Resources", "Prompts"],
+ "answer": 1,
+ "explain": "MCP servers expose tools (functions the model can call), resources (data it can read) and prompts (reusable templates). \"Actions\" sounds right but isn't an MCP building block.",
+ "source": "https://modelcontextprotocol.io/specification/2025-06-18/server",
+ "checked": "2026-10-30"
+ },
+ {
+ "id": "o-a2a-agent-card-path",
+ "kind": "quick",
+ "prompt": "Google's A2A protocol lets agents talk to agents. Where does an agent publish its Agent Card?",
+ "options": ["/.well-known/ai-plugin.json", "/agent/card.xml", "/.well-known/agents.txt", "/.well-known/agent-card.json"],
+ "answer": 3,
+ "explain": "An A2A Agent Card is a JSON file at /.well-known/agent-card.json saying what the agent can do and how to reach it. Google launched A2A in April 2025 and later gave it to the Linux Foundation.",
+ "source": "https://a2a-protocol.org/latest/specification/",
+ "checked": "2026-10-30"
+ },
+ {
+ "id": "o-mcp-vs-a2a",
+ "kind": "quick",
+ "prompt": "MCP and A2A get mixed up a lot. What's the usual split?",
+ "options": ["MCP links tools, A2A links agents", "MCP links agents, A2A links tools", "MCP handles payments, A2A logins", "MCP runs in browsers, A2A servers"],
+ "answer": 0,
+ "explain": "MCP connects an AI app to tools and data; A2A lets independent agents find each other and hand off tasks. Google pitched A2A as a complement to MCP, not a replacement.",
+ "source": "https://developers.googleblog.com/en/a2a-a-new-era-of-agent-interoperability/",
+ "checked": "2026-10-30"
+ },
+ {
+ "id": "o-llms-txt-format",
+ "kind": "quick",
+ "prompt": "Jeremy Howard proposed /llms.txt in September 2024. What's inside it?",
+ "options": ["Crawl rules in robots syntax", "A Markdown guide to the site", "A JSON list of API keys", "Signed bot public keys"],
+ "answer": 1,
+ "explain": "llms.txt is a Markdown file with a short summary and links to the pages worth reading, so a model finds the good stuff fast. It guides models; it doesn't set rules the way robots.txt does.",
+ "source": "https://llmstxt.org/",
+ "checked": "2026-10-30"
+ },
+ {
+ "id": "o-user-agent-fakeable",
+ "kind": "quick",
+ "prompt": "A request says \"User-Agent: ChatGPT-User\". Why can't a site trust that alone?",
+ "options": ["It expires after an hour", "Browsers strip it out", "Anyone can type any value", "Only Google can read it"],
+ "answer": 2,
+ "explain": "A user-agent string is a self-written label, so a scraper can copy it word for word. IP checks break easily too, which is why Web Bot Auth adds cryptographic signatures.",
+ "standard": "web-bot-auth",
+ "source": "https://ax.guide/standards/web-bot-auth",
+ "checked": "2026-10-30"
+ },
+ {
+ "id": "o-jwks-contents",
+ "kind": "quick",
+ "prompt": "Sign-in servers publish a JWKS so others can check their signatures. What's in a JWKS?",
+ "options": ["A set of public keys", "A list of allowed bots", "Encrypted user passwords", "Signed access tokens"],
+ "answer": 0,
+ "explain": "A JWK Set (RFC 7517) is a JSON object with a \"keys\" array of public keys. Sharing public keys is safe; the private keys stay with the owner and do the signing.",
+ "source": "https://www.rfc-editor.org/rfc/rfc7517.html",
+ "checked": "2026-10-30"
+ },
+ {
+ "id": "o-usdc-issuer",
+ "kind": "quick",
+ "prompt": "x402 payments usually move USDC, a dollar stablecoin. Which company issues USDC?",
+ "options": ["Coinbase", "Tether", "Stripe", "Circle"],
+ "answer": 3,
+ "explain": "Circle issues USDC, a token designed to stay worth one US dollar. Coinbase, which created x402, is a major USDC partner, hence the common mix-up.",
+ "source": "https://www.circle.com/usdc",
+ "checked": "2026-10-30"
+ },
+ {
+ "id": "o-rfc9421-fake-header",
+ "kind": "fake",
+ "prompt": "Spot the fake: which header is NOT defined by RFC 9421, HTTP Message Signatures?",
+ "options": ["Signature", "Signature-Input", "Accept-Signature", "Signature-Owner"],
+ "answer": 3,
+ "explain": "RFC 9421 defines Signature-Input (what was signed and how), Signature (the signature itself) and Accept-Signature (a server asking for one). Web Bot Auth builds on these.",
+ "source": "https://www.rfc-editor.org/rfc/rfc9421.html",
+ "checked": "2026-10-30"
+ },
+ {
+ "id": "o-cors-fake-header",
+ "kind": "fake",
+ "prompt": "Spot the fake CORS header: which one doesn't exist?",
+ "options": ["Access-Control-Allow-Agents", "Access-Control-Allow-Origin", "Access-Control-Allow-Methods", "Access-Control-Max-Age"],
+ "answer": 0,
+ "explain": "CORS headers tell browsers which other sites' scripts may read a response. There's no agent switch: CORS is about browser origins, not bots, so bot identity needs something like Web Bot Auth.",
+ "source": "https://fetch.spec.whatwg.org/#http-cors-protocol",
+ "checked": "2026-10-30"
+ },
+ {
+ "id": "o-oauth-fake-well-known",
+ "kind": "fake",
+ "prompt": "Spot the fake: three of these OAuth discovery files are registered under /.well-known/. Which is invented?",
+ "options": ["openid-configuration", "oauth-agent-login", "oauth-authorization-server", "oauth-protected-resource"],
+ "answer": 1,
+ "explain": "openid-configuration and oauth-authorization-server describe a sign-in server; oauth-protected-resource (RFC 9728) tells a client which server guards an API. auth.md and MCP lean on these same files.",
+ "source": "https://www.iana.org/assignments/well-known-uris/well-known-uris.xhtml",
+ "checked": "2026-10-30"
+ },
+ {
+ "id": "o-http-fake-status",
+ "kind": "fake",
+ "prompt": "Spot the fake HTTP status code: which one isn't in the official registry?",
+ "options": ["402 Payment Required", "451 Unavailable For Legal Reasons", "432 Bot Not Verified", "425 Too Early"],
+ "answer": 2,
+ "explain": "402, 425 (RFC 8470) and 451 (RFC 7725, a nod to Fahrenheit 451) are all registered. There's no status code for unverified bots; that job falls to signatures, not a new number.",
+ "source": "https://www.iana.org/assignments/http-status-codes/http-status-codes.xhtml",
+ "checked": "2026-10-30"
+ }
+]
diff --git a/content/site.json b/content/site.json
index 045ff06..bb9da5a 100644
--- a/content/site.json
+++ b/content/site.json
@@ -27,6 +27,10 @@
"label": "Which one?",
"href": "/which"
},
+ {
+ "label": "Quiz",
+ "href": "/play"
+ },
{
"label": "Contribute",
"href": "/contribute"
diff --git a/lib/og-quiz.tsx b/lib/og-quiz.tsx
new file mode 100644
index 0000000..0dacd89
--- /dev/null
+++ b/lib/og-quiz.tsx
@@ -0,0 +1,48 @@
+import { BRAND } from "./brand";
+import { OgBody, ogImage } from "./og";
+
+/** A quiz result's share image: the score, the grid, and a dare. */
+export function quizShareImage({
+ kicker,
+ score,
+ correct,
+ total,
+ results,
+}: {
+ kicker: string;
+ score: number;
+ correct: number;
+ total: number;
+ results: boolean[];
+}) {
+ const square = total > 12 ? 44 : 60;
+ return ogImage(
+
+
{kicker}
+
+ {score.toLocaleString("en-US")}
+ PTS
+
+
+ {correct} of {total} right on the agentic web quiz
+
+
+ {results.map((right, i) => (
+
+ ))}
+
+
+ Can you beat it?
+ ax.guide/play
+
+ ,
+ );
+}
diff --git a/lib/play-route.ts b/lib/play-route.ts
new file mode 100644
index 0000000..60326f6
--- /dev/null
+++ b/lib/play-route.ts
@@ -0,0 +1,24 @@
+import "server-only";
+import { NextResponse } from "next/server";
+import { PlayError } from "./quiz-expert";
+import { currentUserId } from "./supabase-server";
+
+/** No quiz response is ever cached: each one belongs to one player at one moment. */
+const NO_STORE = { "Cache-Control": "private, no-store" };
+
+export function json(body: unknown, status = 200): NextResponse {
+ return NextResponse.json(body, { status, headers: NO_STORE });
+}
+
+/** Run a quiz handler for the signed-in player, turning PlayErrors into JSON errors. */
+export async function withPlayer(handler: (userId: string) => Promise): Promise {
+ try {
+ const userId = await currentUserId();
+ if (!userId) return json({ error: "Sign in to play an expert run." }, 401);
+ return json(await handler(userId));
+ } catch (error) {
+ if (error instanceof PlayError) return json({ error: error.message }, error.status);
+ console.error(error);
+ return json({ error: "Something went wrong. Try again." }, 500);
+ }
+}
diff --git a/lib/quiz-expert.ts b/lib/quiz-expert.ts
new file mode 100644
index 0000000..b8c6b2b
--- /dev/null
+++ b/lib/quiz-expert.ts
@@ -0,0 +1,273 @@
+import "server-only";
+import { z } from "zod";
+import {
+ CARD_SECONDS,
+ type CardResult,
+ cardPoints,
+ type DealtCard,
+ dealCard,
+ LATENCY_GRACE_MS,
+ learnMore,
+ optionOrder,
+ pickCards,
+ RUN_CAPS,
+ RUN_LENGTH,
+} from "./quiz";
+import { type QuizQuestion, quizQuestion } from "./quiz-schema";
+import { getStandard } from "./standards";
+import { adminClient } from "./supabase-server";
+
+/*
+ * Expert runs, dealt and scored on the server so the leaderboard can trust
+ * them. A run moves one way: deal a card (its clock starts), answer it (the
+ * clock stops, the answer is revealed), deal the next. Every update is
+ * guarded by the run's position and phase, so a card can't be answered twice
+ * or have its clock restarted.
+ */
+
+/** Runs a player may start in a day: plenty to improve, too few to farm the deck in an afternoon. */
+const RUNS_PER_DAY = 20;
+
+const runCards = z.array(z.strictObject({ id: z.string(), order: z.array(z.int()) }));
+
+interface RunRow {
+ id: string;
+ user_id: string;
+ cards: unknown;
+ position: number;
+ served_at: string | null;
+ score: number;
+ correct: number;
+ streak: number;
+ results: string;
+ finished_at: string | null;
+}
+
+export class PlayError extends Error {
+ constructor(
+ readonly status: 400 | 401 | 404 | 409 | 429 | 503,
+ message: string,
+ ) {
+ super(message);
+ }
+}
+
+function db() {
+ const client = adminClient();
+ if (!client) throw new PlayError(503, "Expert runs are not switched on here yet.");
+ return client;
+}
+
+/** Active expert cards, checked against the schema; a broken row is skipped, not dealt. */
+async function loadQuestions(ids?: string[]): Promise