← All posts

The tech stack, for nerds

engineering

This is the long one. If you’ve ever wondered how you build a multi-device, real-time-synced, AI-assisted app where the server provably cannot read user data, this post is the answer, with the trade-offs left in.

The one constraint that shapes everything

save brain power stores notes that span employers. That only works if the operator (me) is not a trust dependency — so the core invariant is:

The server only ever stores, relays, and indexes ciphertext.

Every architectural decision below falls out of that single constraint. Plaintext exists in exactly one place: your unlocked browser tab.

Cloudflare Worker — routing · auth · rate limits

your browser — the only place plaintext exists

encrypt (AES-GCM, your DEK) → ciphertext out

ciphertext in → decrypt on read

web UI

decrypted collections · AI calls · integrations

D1 rows · R2 blobs · Durable Objects

stores enc1:… blobs + structural metadata

Runtime: Cloudflare, all the way down

The entire backend is one Cloudflare Worker plus Cloudflare’s storage primitives. No VMs, no containers, no region picker:

  • Workers run the server: session auth, a two-endpoint data bridge, and webhooks. Cold starts are effectively zero and the free-tier economics of a personal-scale app are absurd (in the good way).
  • D1 (SQLite at the edge) holds the rows. Content columns are prefixed _ by convention and hold enc1: ciphertext; structural columns (ids, foreign keys, timestamps, status enums) stay plaintext so the database can still do database things — join, index, cascade.
  • R2 holds large encrypted text — note bodies, AI summaries, generated documents — as sealed blobs. The D1 column keeps only an r2: pointer, so row reads stay small and the big ciphertext lives in object storage. Keys are prefixed server-side with the authenticated user id: that prefix is the access-control model.
  • Durable Objects power live sync (more below).
  • D1 Sessions API gives read-your-writes: every response carries a bookmark, clients send back the freshest bookmark they’ve seen, and the server takes the max — so a read replica can never travel back in time on you.

The client: TanStack everything

The frontend owes a lot to the TanStack family:

  • TanStack Start is the framework — SSR, server functions, file routes — compiled by Vite with Cloudflare’s plugin so the same build emits the Worker.
  • TanStack DB + Query turn the API into collections: live-queryable, optimistically-mutating local sets. Every screen reads decrypted rows from collections; every write is an optimistic mutation that rolls back if the server refuses.

The whole client/server data plane is deliberately tiny — two server functions:

load({ query, args })   →  runs a named read,  returns rows
save({ op, data })      →  runs a named command, returns null

Ninety-odd commands and forty-odd queries hang off those two endpoints by name lookup. Adding a feature adds commands and queries, never endpoints. The schema itself is defined once with Drizzle ORM and shared end to end: the same table definitions drive migrations, the server commands, and the client’s knowledge of which columns are encrypted (any _-prefixed column, discovered mechanically — nothing hand-listed, nothing to drift).

The crypto, concretely

  • A data-encryption key (DEK) encrypts field values with AES-GCM via WebCrypto. It lives in browser memory while the app is unlocked, and nowhere else, ever.
  • The DEK is wrapped by keys derived from your passphrase and from a recovery phrase; the server stores only wrapped blobs it cannot open.
  • Writes encrypt just before leaving the browser; reads decrypt as rows enter the collections. Everything between — network, Worker, D1, R2, backups — sees enc1: strings.
  • Lose both the passphrase and the recovery phrase and the data is gone. That’s not a missing feature; it’s the receipt that the encryption is real.

Live sync without disclosure

Multi-device sync is a per-user Durable Object acting as a pure WebSocket broadcast hub — it stores nothing and hibernates between events. When a device writes, the hub tells the user’s other devices which collections changed, never what changed:

write

"tasks changed"

refetch + decrypt locally

laptop

Worker

D1

(ciphertext)

SyncHub (DO)

phone

Payloads are collection names plus a D1 bookmark. Recipients refetch through the normal decrypt-on-read path. Real-time UX, zero new trust surface, and conflicts stay whole-row last-write-wins — the only policy compatible with columns the server can’t inspect.

Integrations and AI: the browser does the work

The server can’t hold your GitHub token or call OpenAI on your behalf — it would see plaintext. So it doesn’t:

  • Integrations (GitHub, Linear, Granola) run in the browser against the providers’ APIs, through a dumb CORS forward proxy — a ~150-line Hono Worker that relays requests and strips identifying headers. It’s open source, it logs nothing, and you can run your own; the hosted default is rate-limited per IP using Cloudflare’s native counters precisely because those keep no logs either.
  • Google Calendar uses Google Identity Services: the browser gets short-lived tokens directly from Google with a public client id — no client secret exists anywhere in the system.
  • AI features (meeting prep, brag documents, weekly briefs, task summaries) are bring-your-own-key: your Anthropic/OpenAI/OpenRouter key, encrypted at rest, decrypted in your tab, calls routed through the proxy you chose. Prompts are assembled from your already-decrypted local collections; results are encrypted before they’re stored. My servers are never in the loop — for AI or anything else.

The unglamorous essentials

  • better-auth handles sessions and OAuth sign-in on the Worker, storing only what auth needs.
  • Stripe runs billing entirely on its own hosted surfaces — Checkout for the card-less trial, the customer portal for cancel/invoices. My side keeps one table (user → customer) plus a webhook-maintained entitlement snapshot. Card data never exists near this codebase.
  • Resend + React Email send the (single, content-free) welcome email.
  • Bun is the package manager, test runner, and the reason 700 tests finish in under three seconds. The oxc toolchain (oxlint, oxfmt) lints and formats the monorepo in about one.
  • Astro builds this very site — static pages plus markdown content collections, deployed on Cloudflare Pages, no client framework shipped.
  • marked, tz-lookup, world-atlas, topojson-client and other small excellent libraries do exactly one thing each; the timezone-picker map is a plain equirectangular projection because that one is linear and needs no d3.

What the trade-offs cost

Honesty section. Zero-knowledge is not free:

  • Search and AI can’t run server-side. Everything compute-ish happens in your browser, which caps how much magic can happen while you’re away.
  • Sync is signal-only, so devices refetch more than a server-merged design would.
  • Support can’t see your data, which means support can’t fix your data. Export exists precisely so you’re never stuck.
  • Key loss is data loss. By design, and clearly labeled.

I think the trade is obviously right for this product: a work journal that spans employers is exactly the data that should answer to no one but you.

If any of this made you want to poke at it: the proxy is open source, and I’m easy to find when something looks wrong — that’s what the community repo is for.