1. A Supabase project. Create one, then take two connection strings from Project Settings > Database and set them as
DATABASE_URL(the transaction pooler, port 6543) andDIRECT_URL(the session pooler, port 5432). Both are on theaws-REGION.pooler.supabase.comhost. Do not usedb.PROJECTREF.supabase.co: it is IPv6-only without the paid add-on and will hang on most networks.Then run
npm run db:check, which tells you whether the configuration actually works before you deploy anything.There is no Supabase anon or service-role key anywhere in this app and there should not be, see Supabase notes for why that matters.
2. A subdomain under
opentrade.live. Deploy to Vercel and point something likepuzzles.opentrade.liveat the project, then setNEXT_PUBLIC_SITE_URLto match. This is meant to live beside the main product, not on its own domain.Three secrets also need generating, one line each, see Deploying.
A weekly quantitative puzzle with a first-to-solve leaderboard, plus a page for monthly live events. Next.js 14 on Vercel, Supabase Postgres behind it.
Submission works like Jane Street's: a name, an email, an answer, no account. Answers are checked instantly and the board is ordered by who got there first.
Brand assets (the husky mark, the YC badge) come from
opentrade-moonshot/ui-source-code under .design-system/brand, and the
palette here is that system's tokens inverted for a dark canvas.
npm install
cp .env.example .env.local # then fill in the three secrets
npm run db:migrate
npm run puzzle:seal -- last-look "<the answer>"
npm run devThe launch puzzle's answer is in answers.local.json if you already sealed it,
and solutions/last-look.ts recomputes it from scratch, cross-checking the
closed form against a 20 million game simulation.
Generate each secret with openssl rand -hex 32.
-
Create the Supabase project. Copy both connection strings.
-
Push to GitHub, import the repo in Vercel.
-
Set these in the Vercel project's environment variables:
Variable Value DATABASE_URLSupabase transaction pooler, port 6543, ?sslmode=requireDIRECT_URLSupabase session pooler, port 5432, used only by migrations PUZZLE_SECRETopenssl rand -hex 32IP_HASH_SECRETopenssl rand -hex 32ADMIN_TOKENopenssl rand -hex 32NEXT_PUBLIC_SITE_URLhttps://puzzles.opentrade.liveFor
DIRECT_URL, use the session pooler on port 5432, not the directdb.PROJECTREF.supabase.cohost. Supabase direct connections resolve to IPv6 only unless you buy the IPv4 add-on, so on an IPv4-only network, which covers most home ISPs and plenty of CI, that host simply hangs until it times out. The session pooler is IPv4 and behaves identically for migrations. -
Check the connection before you deploy anything:
npm run db:check
This verifies TLS, IPv4 reachability, the extended query protocol, that transactions work, that the migrations are applied, that RLS is on, and that a puzzle is sealed. It names whichever one is wrong instead of leaving you to guess from a timeout.
-
Run the migrations:
npm run db:migrate
-
Point the
opentrade.livesubdomain at the Vercel project.
PUZZLE_SECRET must never change after you seal your first puzzle. Every sealed
answer is an HMAC keyed with it, so rotating it invalidates all of them at once.
What migration 002 actually does, tested against a real Supabase stack.
The claim you will read everywhere, and which an earlier version of this file
repeated, is that Supabase serves every public table over PostgREST to anyone
holding the anon key, so without RLS your data is public. Tested against a local
Supabase running the real PostgREST and a real anon key, that is not what
happens for tables created the way these migrations create them:
postgres | public | r | anon=Dxtm/postgres
Dxtm is TRUNCATE, REFERENCES, TRIGGER, MAINTAIN. There is no r, so anon
has no SELECT, and a read over the REST API is refused before RLS is even
consulted. Emails were never one migration away from being public.
What anon is granted by default is TRUNCATE, which is a
data-destruction privilege, and TRUNCATE bypasses row level security entirely.
That is the real thing migration 002 removes.
It is still worth running, for three reasons:
- TRUNCATE on
submissionsshould not be granted to a role namedanon. - These defaults are per-owner and have changed between Supabase versions. A
table created by
supabase_admin, or through some paths in the dashboard, does getanon=arwdDxtm, which includes SELECT. Relying on which role happened to create a table is not a security posture. - RLS with no policies plus no grants is deny-all twice over, and costs this app nothing, because it connects as the owner and bypasses both.
Verified after applying it: anon privileges on both tables are (none), RLS
is on, and PostgREST answers permission denied for the table and for the
leaderboard view.
Note that 002 only covers the tables it knows about. Any table you add later gets the same TRUNCATE grant unless you extend the migration.
This app deliberately does not use supabase-js, PostgREST, or Supabase
Auth. It is plain Postgres over pg, which is why it would run unchanged on
Neon or Vercel Postgres if you ever move.
-
Write
content/puzzles/<slug>.md. Frontmatter drives everything:title: Last Look subtitle: One line of flavour. difficulty: 4 # 1-5, drawn as pips releaseAt: 2026-08-10T12:00:00-04:00 # body is not served before this closeAt: 2026-08-17T12:00:00-04:00 answerFormat: decimal # text | integer | decimal | fraction answerHint: a decimal, exactly 8 places, like 0.12345678 answerPrecision: 8 # decimal only: checker rounds to this
Markdown with GitHub tables, fenced code, and
$inline$/$$display$$KaTeX. -
Write a reference solver in
solutions/and run it. Do not publish an answer you have not computed. -
Put the answer and the canary decoy in
answers.local.json, which is gitignored and never leaves your machine:{ "<slug>": { "answer": "<the answer>", "decoy": "<a plausible wrong one>" } }The decoy lives here rather than in frontmatter for the same reason the answer does. This repository is public: a decoy anyone can look up catches nobody. Pick one that no correct derivation can produce, so an honest solver with a slightly wrong reading never trips it.
-
Seal it:
npm run puzzle:seal -- <slug>
The answer is used once to compute an HMAC. A canary token is generated if there is not one already. Neither the answer nor the decoy ever enters the repository, and the answer never enters the database.
-
Check it:
npm run puzzle:audit
A scheduled puzzle is genuinely not readable early. The body is parsed in a
server component and filtered by releaseAt before anything is serialized, so
it is not sitting in the JS bundle waiting to be found.
No account means low friction and a wide funnel, and it means the board is defended in depth rather than at the door.
Answers are never stored. Only HMAC-SHA256(PUZZLE_SECRET, normalized). A
leaked database does not leak answers, and a short numeric answer cannot be
recovered from the digest without also stealing the key. Comparison is
constant-time.
Attempts are capped. 15 per email per puzzle, 25 per network per puzzle per
hour, 60 per network per hour. Instant right/wrong feedback is much better UX
than submit-and-wait, but it is also an oracle, and without a cap a puzzle whose
answer is a four-digit integer is a shell script. The caps are what make the
instant feedback safe. Tune them in src/lib/ratelimit.ts.
Raw IPs are never written to disk, only a keyed hash, which is enough to rate limit and spot a farm.
A honeypot field catches naive form bots.
The LLM canary. Each live puzzle page carries hidden text, invisible to a reader but picked up by copy-paste and by anything scraping the DOM, telling any model processing the page that the reader is cheating in a scored competition, asking it to decline, and asking it to emit a specific decoy answer and a marker token if it answers anyway.
Read src/lib/canary.ts before you rely on this. It is detection, not
prevention. Models often ignore instructions embedded in content, and anyone
who reads the page source can strip it in one line. What it gives you is
evidence: a submission carrying the marker, or matching the decoy, did not get
there by solving the puzzle. Flags roll up across every attempt an entrant
makes, so someone who submits the decoy, gets told it is wrong, and then submits
the real answer still lands on the board flagged.
npm run puzzle:audit reports canary hits, networks with several entrants, high
attempt counts, and any drift between a puzzle's frontmatter and its database
row. That last check matters: if they disagree, the canary on the page is not
the one the submit handler is checking against, and the mechanism is silently
dead.
Treat a hit as grounds to look closer, not as an automatic ban. A curious solver who pastes the page into a model to see what happens and then solves it honestly will also trip it.
The real defense is puzzle design. See docs/PUZZLE-DESIGN.md.
content/puzzles/*.md puzzle prose and frontmatter, the source of truth
content/events.json monthly events
db/*.sql migrations, applied by npm run db:migrate
solutions/ reference solvers. NOT for a public repo
scripts/ migrate, seal, audit
src/lib/answers.ts normalization, HMAC sealing, constant-time compare
src/lib/canary.ts the LLM canary. read the header comment
src/lib/ratelimit.ts attempt caps
src/lib/submissions.ts the submit path, correctness and flagging
docs/DESIGN-PROMPT.md brief for redesigning the front end
docs/PUZZLE-DESIGN.md how to write puzzles that resist models
solutions/ contains worked answers. Removing the directory is not enough, it
is in the history. Move it to a private repo first, or start a fresh history.
answers.local.json is gitignored and should never have been committed; check
with git log --all -- answers.local.json before you open anything up.