leptons1618@anishgiri.dev:~/case-studies/portfolio less writeup.md
Portfolio — A Site Where Publishing Is a Database Write
Content as rows instead of files, which makes publishing a save — and makes the write endpoint the most security-sensitive file in the repository.
- published
- September 4, 2026
- read
- 5 min
- stack
- 6 tools
- outcomes
- 6
- media
- —
01The Problem
The obvious architecture for a content site is markdown files and a deploy. It works until you want to fix a typo from a phone, reorder what leads the home page, or keep a draft only you can see — at which point every small edit means pushing to main and waiting on a build to check a sentence. Moving content into a database removes the waiting, but it also makes a single HTTP endpoint the trust boundary for the entire site, and that endpoint has to be the only path in.
02The Solution
Store every project, case study and post as a row in Cloudflare D1, rendered per request, with markdown converted to HTML on write so a page view is a read and nothing more. Funnel every write through one endpoint whose column identifiers all come from a source-defined allowlist, because a table or column name cannot be a bound parameter in SQL and a column that gets dropped silently is a save that reports success and loses work.
A portfolio is a blog with worse defaults
The obvious way to build this is files in a repo: markdown per project, a commit to publish, a deploy to wait for. That model is fine right up until the moment you want to fix a typo from a phone, or reorder what leads the home page, or have a draft that only you can see. Then you are pushing to main and waiting on a build to check a sentence.
This site treats content as rows in a database. Publishing is a save. A project is a row, a case study is a row, a journal entry is a row. Nothing is a file, so there is nothing to commit and nothing to wait for.
The part that turned out to be the actual work
Moving content into D1 means the write endpoint is now the trust boundary for the whole site, and there is exactly one of them. That constraint shaped src/lib/content-schema.ts more than anything else did.
The rule the file exists to enforce is short: a table name or a column name cannot be a bound parameter in SQL — only values can. So every identifier that reaches a statement has to come from source and never from a request. bind() therefore looks up the keys a caller sent; it never derives an identifier from them. An unrecognised key is refused outright rather than dropped, because a save that silently discards a field reports success and loses work.
Two decisions in that file are worth stealing regardless of whether you ever use D1:
Refuse loudly, do not filter. readableColumns() throws for the table holding the API keys, rather than selecting around it. The comment on that line is the whole argument: a filter would be a line somebody could delete one afternoon and a test that still passed.
Turn the database’s language into a sentence. SQLite speaks D1_ERROR: NOT NULL constraint failed: journal.summary: SQLITE_CONSTRAINT, which is correct, useless, and reads as a fault in the site rather than as an empty field twenty pixels from the Save button. explainConstraint() maps the column back through the same map bind() uses in the other direction and answers “Summary is required, and was left empty.”
There is a subtler version of the same idea. A singleton document table cannot accept author-named slugs, or a typo like resumee succeeds — it inserts a row nothing will ever read, and the save reports success. So the valid slugs are a closed set spelled out in source, and the daily-journal ledger that sits beside them is deliberately not writable through this endpoint at all.
Markdown in, HTML already made
Content is stored as markdown and rendered to HTML on write, not on read. So a page request is a database read and a string insert; nothing parses markdown per visitor. The server escapes raw HTML and unwraps unsafe links on the way in, which is why an HTML block arrives on the page as visible source rather than as markup — the failure mode is ugly, and it is the right one.
Rows are read back in the shape they are written
GET /api/content returns exactly the fields POST /api/content accepts. That sounds like a detail and it is the difference between a scriptable edit and a 400 about a field called repo_url. SQLite has no array and no boolean, so '[]' has to become an array again and 0 has to become false — and a hand-edited malformed list degrades to [] rather than taking a whole listing down with it.
The admin knows what a good project page looks like
The form is not a textarea with a Save button. frontmatter-editor.ts holds a live preview that renders with the same classes the real card uses, a chip editor that drops duplicates case-insensitively (“PyTorch, pytorch” is one stack item), character and line counters, and a completeness checklist with explicit thresholds — summary between 120 and 240 characters, four tags, five stack items, four highlights, a repository or demo link.
None of it refuses a save. It is advice, and the thresholds are the ones the assistant writes to, so a run of the assistant should tick every line it is responsible for.
The part I like most is reviewChanges(). When a run of the assistant edits the form, every field it touched gets marked, with Keep and Revert on each one individually, plus a bar counting them at the top. And any later manual edit to a marked field clears the mark — once a person has touched it, it is theirs. That is a small thing that changes how much you trust letting a model near the thing you publish under your name.
Where the secrets are not
The AI provider rows hold the one secret this site stores, and apiKey is writable but not readable anywhere. GET /api/ai/providers selects columns by name and returns a fingerprint. The admin can replace a key and can never read one back, which is what makes a compromised admin session worth less than the credential behind it.
GitHub OAuth lives in a separate stateless Worker so the token exchange is not entangled with serving pages.
03Achievements
- The project frontmatter form carries a completeness checklist with real thresholds — summary length, tag and stack counts, highlight counts, a repository link — and a live preview built from the same classes the real card uses.
- AI edits are reviewed per field with individual Keep and Revert, and any manual edit to a marked field clears the mark.
- Database constraint failures are translated into sentences naming the field and what to do, instead of surfacing raw SQL error text.
- Content is written back in exactly the shape it is accepted in, so a row read, edited and posted straight round-trips without a field-name 400.
- The AI provider secret is writable but readable by no endpoint, which keeps a compromised admin session worth less than the credential behind it.
- Markdown becomes HTML on write rather than on read, so no visitor request parses markdown.