Skip to content

The invariants

These rules are the reason Beluga is shaped the way it is. They are not style. A change that violates one will usually compile, pass review, and be wrong in a way that shows up as money. Copy this list into your fork’s CLAUDE.md, AGENTS.md, or whatever your assistant reads.

  1. Money never comes from the request. Prices and totals are read from the database on every path. A request carries product and variant ids and quantities only. The loop in server/routes/checkout.ts is where this is enforced.
  2. The webhook is the only thing that marks an order paid. The success redirect proves nothing: a buyer can close the tab, and the URL can be visited directly. Never add a payment state transition anywhere else.
  3. Webhook handling is idempotent. Events are deduplicated by id, and a failed handler releases the dedup record so Stripe’s retry is actually processed rather than dismissed as a duplicate. See recordWebhookEvent and forgetWebhookEvent in db/orders-repository.ts.
  4. Every admin route is behind requireAdmin and verifyCsrf, applied once to the whole router in server/routes/admin.ts. Never mount an admin endpoint outside that router, and register every new route in server/security.test.ts.
  5. Every schema change lands in both dialects. db/schema.sqlite.ts and db/schema.pg.ts are edited together, then npm run db:generate emits a migration for each. Commit all of it.
  6. Products reach Stripe only through explicit publish (POST /api/admin/products/:id/publish). Saving never writes to Stripe.
  7. Stripe Prices are immutable. Changing an amount creates a new Price and archives the old one, which is why historic orders still resolve. Do not ā€œfixā€ this by mutating.
  • The cart stores identifiers, never prices or image URLs, so a price change cannot leave a stale amount in someone’s open tab (rule 1).
  • The confirmation page polls the order’s status rather than assuming success (2).
  • A refund is requested from Stripe and recorded by the charge.refunded webhook, never written directly (2 and 3).
  • Changing how a store quotes tax reaches Stripe only when each product is republished, and nothing republishes itself (6 and 7).
  • A test key and a live key have separate catalogues; publishing under one puts nothing in the other (6).

Not invariants, but the codebase holds to them and a contribution should too.

  • Validation lives in shared/ as zod schemas, never inline in a route.
  • Routes stay thin: parse, call a repository function, respond. SQL lives in db/*-repository.ts.
  • Errors use httpError(status, message) with a user-facing message.
  • Client mutations go through csrfPost, csrfPut and csrfDelete, wrapped in a hook in src/admin/queries.ts that invalidates the public store key.
  • Comments explain why, not what.

Conventions and contributing has the longer version, and Gotchas the things that look like bugs and are not.