Skip to content

Decisions

Ten decisions, and what they were chosen over.

Most of the cost of building software is not in the code that gets written. It is in the handful of choices nobody stopped to make, and the defaults that filled the gap. These are ten of mine, on the site you are reading, each one next to the answer you get if nobody decides.

Every entry ends with where to look. Not a screenshot and not a claim — a file and a symbol in the repository that serves this page, so you can check any of it against the thing it describes.

01

A fresh nonce on every response, instead of allowing inline scripts

Every response carries a Content Security Policy with a one-time nonce, plus strict-dynamic so legitimately loaded scripts can pull their own dependencies without widening the policy for everything else.

The default

'unsafe-inline' in script-src, because that is what makes a framework work first time.

What it costs

'unsafe-inline' means any script an attacker gets onto the page runs, which is most of what a content policy exists to stop. The awkward part of the nonce is the part worth knowing: it forces dynamic rendering. The force-dynamic export in the layout is load-bearing — remove it as dead configuration and every script on the site is blocked.

Where to look

  • src/proxy.ts
  • src/app/layout.tsx — dynamic

02

Orders are fulfilled by the webhook, never by the success page

Payment is treated as real only when the signed webhook arrives and its event id has been claimed atomically. The redirect to /success renders a receipt and fulfils nothing.

The default

Fulfil on the redirect, where you can watch it work while testing.

What it costs

People close tabs. They lose signal in a lift, they get a phone call, their browser restores a different session. Every one of those is a customer who paid and received nothing, and you will not hear about most of them. The redirect is a courtesy; the signed webhook is the fact.

Where to look

  • src/app/api/webhooks/stripe/route.ts — handle, fulfilAutomated
  • src/lib/idempotency.ts

03

The browser is never allowed to name a price

A checkout request carries a slug or a quote id. Nothing else. The amount is read server-side from the catalogue, or from a row this server wrote earlier.

The default

Post the amount with the order and validate it on the server before charging.

What it costs

Validation is the wrong instinct here, and it is worth being precise about why. Validating a price implies there is a legitimate case for the browser sending one. There isn't. So there is deliberately no price field in any request schema to validate — nothing to tamper with, because nothing is accepted. A field you never read cannot be got wrong later by someone who does not know the rule.

Where to look

  • src/app/api/checkout/route.ts — Body
  • src/lib/catalog.ts

04

DNS over HTTPS, not the resolver the machine gives you

The scanner resolves TXT and MX records through a DNS-over-HTTPS endpoint rather than node:dns.

The default

node:dns. It is in the standard library and it works.

What it costs

During development the system resolver reported a missing SPF record for a domain that had a perfectly good one — it had cached the negative answer, and dig on the same machine agreed with it. That scanner sells a paid report about somebody else's infrastructure. A report that is confidently wrong about another company's email security is worse than no report at all, because they will act on it.

Where to look

  • src/lib/scan.ts — DOH, dns

05

Two security headers are deliberately production-only

HSTS and upgrade-insecure-requests are set in production and withheld in development.

The default

Set every security header in every environment, always on.

What it costs

Over plain-HTTP local development, upgrade-insecure-requests rewrites every subresource to https, where nothing is listening. Styles, fonts and scripts fail — silently, with no console error pointing at the cause. It looks like a build problem, and you can lose most of a day to it before suspecting a header you set on purpose.

Where to look

  • src/proxy.ts

06

The application connects as a role that cannot change the schema

The app's database role holds SELECT, INSERT, UPDATE and DELETE on five named tables and nothing else. No DDL, no TRUNCATE, no access to system catalogues.

The default

Connect as the owner, because that is the connection string the provider hands you.

What it costs

As the owner, a SQL injection or a bad migration reaches the structure of the database, not just its contents — dropped tables, altered constraints, read access to roles and hashes. Least privilege turns the worst case into a data problem instead of a structural one. It has a real running cost, and stating it is part of the decision: add a table and it is invisible to the app until you grant it, so the grant goes in the same commit as the table.

Where to look

  • db/schema.sql

07

That role is created in SQL, never through the provider's console

The database role is created with CREATE ROLE ... LOGIN PASSWORD in the migration. The migration also checks the role's memberships and raises a warning if it finds the wrong one.

The default

Click 'create role' in the dashboard. It is right there.

What it costs

Neon adds every role it creates — dashboard, CLI or API — to neon_superuser, which holds default privileges on all future tables. A role created that way is an administrator wearing a least-privilege name, and the membership cannot be revoked afterwards, because the owner role lacks ADMIN OPTION on it. You do not get to fix this later; you get to recreate the role. That is why the migration warns rather than trusting the name.

Where to look

  • db/schema.sql
  • scripts/migrate.mjs

08

The greys on this site are not the greys they were copied from

The two muted text colours were moved off the palette they started from, and both now pass WCAG AA on every surface they appear on.

The default

Take the palette from a company whose design you admire. They have thought about it more than you have.

What it costs

They had, and it still failed. The lighter grey measured 3.6:1 on white and 3.3:1 on the grey band — under the 4.5:1 that small text needs, at the 13px caption size actually wearing it. Borrowed taste is not borrowed correctness, and a consultancy that sells accessibility work cannot ship captions its own audit would fail. The replacements clear 4.5:1 on both surfaces, and the darker of the two was taken a step further so the two still read as different ranks.

Where to look

  • src/app/globals.css — --text-dim, --text-faint

09

The scroll animation cannot leave content invisible

Revealed content renders visible. It arms itself only after confirming a working IntersectionObserver, and abandons the effect after 1.5 seconds of silence.

The default

Start at opacity zero and fade in when the element scrolls into view.

What it costs

That default is one failed assumption away from a blank page. If the observer never fires — a zero-height viewport, an offscreen embedding, a reader mode, a browser that does not run your script — content that starts hidden stays hidden permanently. The reader sees nothing and has no idea anything is missing. An animation is worth exactly nothing next to the text it is decorating, so it has to fail in the direction of showing the text.

Where to look

  • src/components/reveal.tsx

10

The database is optional, on purpose

With no database configured, the rate limiter keeps working in memory and the site runs. A fresh clone needs no environment variables at all.

The default

Require the database and fail on boot without it.

What it costs

This one is a trade rather than a mistake to avoid, and the honest half is the half worth publishing: the in-memory fallback does not work across instances, so production must set a real database and the code says so plainly rather than hoping. What it buys is that the project runs the moment it is cloned — which is the difference between a contributor reading the code and a contributor debugging someone else's environment.

Where to look

  • src/lib/rate-limit.ts — rateLimit, memoryLimit

None of this is exotic.

Every decision above is one an experienced engineer would recognise, and several are in the documentation of the tools involved. What makes them worth writing down is that none of them is what happens automatically — each one is a place where the obvious path and the correct path separate, quietly, and stay separated until somebody looks.

That gap is the whole job. It is also why writing code faster than anyone reads it is a worse idea than it sounds.