Giving the feed back to the user.
Every social network I've used in the last decade has the same loop: you follow people, an algorithm decides what you actually see, and ads are slotted in between. The follow graph stops being the feed. It becomes a suggestion the algorithm overrides.
Velvet Blum is the opposite of that. You curate your network. Your network is your feed. No ranking, no recommendations, no ads. Reverse-chronological, always.
Live: velvet-blum.pages.dev.

The interesting primitive isn't the feed — it's the lens.
Visit /u/<handle>/lens and you see that user's feed — the posts they would see, in their reverse-chronological order, through their follow graph. Read-only. You can't react or comment as them. But every post has a tiny + follow @author button so you can copy accounts you didn't know about into your own network.
It turns curation into a public good. Instead of an algorithm doing taste-matching for you, you borrow taste from a person you trust. One click and a stranger's curator becomes a contributor to yours.

The other thing I wanted was DMs the server can't read.
secretbox. Server never sees the passphrase or the unwrapped key.crypto_box_seal). Server stores ciphertext only.
If the D1 database leaks tomorrow, the messages stay opaque. The keys to read them never left the clients.
"Isn't a vibecoded social app a security disaster?" Fair question. Two pieces are worth being concrete about: account access and message contents.
Account access. There are no passwords to leak or reuse. Auth is magic-link only: you put in your email, the API mints a single-use token, you click it, and the server creates a session. The session token is 32 bytes from crypto.getRandomValues (256 bits of entropy), stored server-side in Workers KV, and handed back as an httpOnly, SameSite=Lax, Secure cookie. JavaScript can't read it, so an XSS bug can't exfiltrate it; SameSite blocks the obvious CSRF shapes. Magic-link issuance is rate-limited (5 per email per 10 minutes), and links are single-use and time-bound. The practical floor of your account security is your email inbox.
How login works, end to end:
POST /auth/magic-link with an email. The API generates a token, stores (token, email, expiresAt) in D1, and emails a link via Resend.POST /auth/consume with the token. The API marks the magic-link row consumed (single-use), looks up or creates the user, mints a session token, writes s:<token> → userId to Workers KV with a 30-day TTL, and sets the vb_session cookie.sessionMiddleware, which reads the cookie and resolves it back to a userId via KV. Protected routes wrap that with requireAuth.The whole auth surface is small enough to read in one sitting: apps/api/src/routes/auth.ts for the login flow, and apps/web/src/lib/crypto.ts (~150 lines) for every line of crypto in the app.
Message contents. The DM keys live on your device, not on the server. The X25519 keypair is generated client-side; the private key is wrapped with an Argon2id-derived key (OPSLIMIT_MODERATE / MEMLIMIT_MODERATE, random per-user salt) using libsodium secretbox, and only the wrapped blob is uploaded. Once unwrapped with your passphrase, the raw private key is cached in the browser's IndexedDB on that device. The server stores public keys, wrapped private keys, and sealed ciphertexts — nothing it can decrypt. A session hijacker on a fresh device sees encrypted blobs until they also know your passphrase.
What this doesn't give you. Sealed-box DMs use static keys, so there's no forward secrecy: if a device is fully compromised, past ciphertexts on that device become readable. There's no 2FA or active-session list yet, no "log out everywhere" button, and no re-auth prompt for sensitive actions. The crypto is standard libsodium primitives in roughly 150 lines (apps/web/src/lib/crypto.ts) — easy to audit, but not formally audited. Forward secrecy via ephemeral session keys, device management, and a passphrase-strength meter are the obvious next steps.
The whole thing is one pnpm workspace: apps/api (Hono), apps/web (SvelteKit), packages/db (Drizzle schema + migrations), packages/schema (shared Zod types), infra/wrangler.toml.
I'm not against ranking in general — I'm against ranking I didn't ask for. The algorithmic feed conflates two things: what is new in my network and what someone thinks I want. The first is a fact. The second is a guess. Velvet Blum only shows you the first. If you want a different selection, you build a different network — or you borrow one through a lens.
It's also a much smaller surface. No ranking pipeline, no engagement metrics, no recommendation model, no ad inventory. The whole product is a couple of Workers, a D1, an R2, and a handful of Durable Objects.
crypto.ts and the auth surface.Try it: velvet-blum.pages.dev. If you make a network, share your lens URL.