Skip to content

Repository files navigation

OpenTrade Puzzles

Two things are needed before this runs

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) and DIRECT_URL (the session pooler, port 5432). Both are on the aws-REGION.pooler.supabase.com host. Do not use db.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 like puzzles.opentrade.live at the project, then set NEXT_PUBLIC_SITE_URL to 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.

Running it

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 dev

The 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.

Deploying

  1. Create the Supabase project. Copy both connection strings.

  2. Push to GitHub, import the repo in Vercel.

  3. Set these in the Vercel project's environment variables:

    Variable Value
    DATABASE_URL Supabase transaction pooler, port 6543, ?sslmode=require
    DIRECT_URL Supabase session pooler, port 5432, used only by migrations
    PUZZLE_SECRET openssl rand -hex 32
    IP_HASH_SECRET openssl rand -hex 32
    ADMIN_TOKEN openssl rand -hex 32
    NEXT_PUBLIC_SITE_URL https://puzzles.opentrade.live

    For DIRECT_URL, use the session pooler on port 5432, not the direct db.PROJECTREF.supabase.co host. 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.

  4. 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.

  5. Run the migrations:

    npm run db:migrate
  6. Point the opentrade.live subdomain 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.

Supabase notes

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:

  1. TRUNCATE on submissions should not be granted to a role named anon.
  2. 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 get anon=arwdDxtm, which includes SELECT. Relying on which role happened to create a table is not a security posture.
  3. 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.

Publishing a puzzle

  1. 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.

  2. Write a reference solver in solutions/ and run it. Do not publish an answer you have not computed.

  3. 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.

  4. 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.

  5. 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.

Keeping the leaderboard honest

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.

Layout

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

Before making this repo public

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.

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages