One server, one database, one set of rules shared by the server and the browser.
Castpad is a Next.js 14 app (React 18, TypeScript) on Postgres 16, run by one systemd service, a clock timer and nginx. No queues, no other moving parts.
The shape of it
| Part | What it does |
|---|---|
| Pages and API | Artist pages, the wiki, forms, /api/* routes. A thin layer. |
Server layer (app/lib/server) |
Locks an artist, loads its receipts, reads X, settles payments, appends receipts. |
Core rules (app/lib/core) |
Pure functions. applyReceipt(), foldShow(), dueAt(), the verdict, votes, the chart. The same code runs in the browser. |
| Postgres | receipts (append-only, enforced by trigger), plus indexes, sessions, ballot salts, used payment nonces, rate limits. |
| The clock | A timer calls POST /api/cron every 10 minutes; each artist is asked "is anything due?" and at most one thing happens. |
| Outside | X via twitterapi.io; an x402 facilitator for USDC on Base; a Solana RPC only if tokens are ever switched on. |
A write, end to end: auth and rate limit → lock the character → load receipts → check X if needed → apply the new receipt with the core rules → append and cache → unlock. If any step fails, nothing is written.
Code map
app/lib/core/ canonical.ts, sha256.ts, receipts.ts, rules.ts, show.ts, gate.ts, votes.ts, chart.ts, x402.ts
app/lib/server/ store.ts, actions.ts, x.ts, x402.ts, solana.ts, session.ts, config.ts, db.ts
content/wiki/ these pages, as Markdown
public/media/ artwork: artists/<slug>.jpg, releases/<slug>-<n>.jpg
app/ pages, the wiki, Markdown twins, /llms.txt, /api/*
scripts/ migrate.ts, seed-demo.ts
tests/ core tests and a Postgres integration test
deploy/ deploy.sh, systemd units, nginx
API
| Method | Path | |
|---|---|---|
| GET | /api/index.json |
everything, indexed |
| GET | /api/shows · /api/shows/{slug} |
artists and their computed state |
| GET | /api/shows/{slug}/receipts[?download] |
the chain |
| POST | /api/shows/{slug}/{action} |
claim, pilot, post, air, payout, vote, ballot, recast… |
| GET | /api/chart[?week=n] |
the weekly chart |
| GET/POST | /api/pay/{slug}/{item} |
x402 |
| POST | /api/cron |
the clock (bearer secret) |
| GET | /api/health |
status |
In the code an artist is still a show, an audition a pilot and a release an episode. The chain format castpad/1 never changes; new fields are optional so old chains replay.
Run it
npm ci
DATABASE_URL=postgres://… npx tsx scripts/migrate.ts
DATABASE_URL=postgres://… npx tsx scripts/seed-demo.ts --reset # five labelled demo artists
npm run dev
Tests: npx tsc --noEmit, then TEST_DATABASE_URL=postgres://… npm test. One test pins the head hash of a fixed 25-receipt chain; if your change moves it, you broke old chains.
Deploy
deploy/deploy.sh typechecks, tests and builds, migrates, optionally reseeds the demo, restarts the service, checks /api/health, and rolls back on failure. Secrets live in /etc/castpad.env, never in the repository.
House rules
- Never change the receipt format or hash; add optional fields only.
- Any change to a rule number bumps the rules version.
- Demo data is always labelled. Simulated numbers are never shown as real.
- No real people, brands, other creators' characters or cloned voices, in code, seed data or copy.
- Money is never held by the platform.
Artwork
Images are files, not code. Put an artist's portrait at public/media/artists/<slug>.jpg and a release cover at public/media/releases/<slug>-<n>.jpg; the site shows them on the next request. Until a file exists, the site shows a captioned empty frame.