The tech stack, for nerds
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.
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 holdenc1: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:
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.