Colophon
How this site is built, and what it knows about you
Everything here is checkable. The stack is what the repository actually depends on, the limits are printed from the table the rate limiter reads, and the privacy list includes the answers that are not flattering.
The stack
cat stack.txt
| Layer | What | Why |
|---|---|---|
| framework | Next.js 16, App Router | Static pages with incremental revalidation; server components by default. |
| language | TypeScript, strict | No implicit any anywhere, including the database layer. |
| database | Neon Postgres + Drizzle | Serverless HTTP driver; the schema is typed from one file. |
| media | Cloudflare R2 | Free egress; every image transcoded to AVIF and WebP at four widths. |
| editor | Tiptap | Posts are stored as a JSON document, not HTML, so nothing is ever injected. |
| styling | Tailwind CSS v4 | CSS-first tokens; one accent, one type scale, no component library theme. |
| auth | Auth.js v5 + GitHub | One allowlisted account. There is no users table to attack. |
| hosting | Vercel, deployed from main | A push is the whole deploy procedure. No CLI, no dashboard step. |
| tests | Vitest | An offline unit suite and an opt-in one that hits the real services. |
What is actually enforced
Security on a personal site is usually a paragraph. These are the controls in the code, each one a thing that refuses a request rather than a thing that hides a button.
./audit --controls
authorisation Every admin mutation re-checks the session server-side before touching the database. The route guard is not the UI.
validation Every public write parses into a closed Zod schema. Unknown fields are a 400, not a shrug.
csp A content security policy that pins object-src, base-uri, form-action and frame-ancestors.
private files Resume PDFs are private objects. A short-lived signed URL is fetched server-side and streamed, so the signature never reaches a browser.
audit Every admin action and every sign-in - accepted or refused - writes a row I can read.
moderation Nothing a reader writes appears until I have read it. Turnstile and the rate limit only keep the queue usable.
The rate limits
Printed from the table the limiter reads, so this cannot drift away from what the site does. Counted per instance, which is the honest limitation of doing this without a shared store.
./audit --limits
| Surface | Allowed | Window |
|---|---|---|
| login | 10 | 10 min |
| adminMutation | 300 | 1 min |
| adminUpload | 40 | 5 min |
| adminRead | 600 | 1 min |
| comment | 5 | 5 min |
| interaction | 120 | 1 min |
| track | 240 | 1 min |
| resolver | 60 | 1 min |
| search | 120 | 1 min |
| og | 60 | 1 min |
| revalidate | 60 | 1 min |
What this site knows about you
./audit --privacy
cookies for readers — none
third-party analytics — none
ad or tracking scripts — none
fonts — self-hosted
your IP address — hashed with a salt that rotates daily, never stored raw
read counts — a number per post, with nothing attached to it
link opens — a timestamp and those daily hashes, so I know a share was read
The last line is the one worth saying plainly: when I share a resume or a short link, I can see that it was opened and roughly when. I cannot see who you are, and after a day the hash that grouped your visits is not reproducible even with the database in front of me.