Case study: wisp
Context
flit already covers “get this file onto my other device right now” — direct WebRTC transfer, both peers online simultaneously, nothing ever touches a server disk. It’s the wrong tool for “here’s a file for you, whenever you get to it.” That requires the server to hold something, even briefly, which is new territory for this project family: everything else here (flit, waste-go) is built around the server seeing as little as structurally possible, up to and including zero bytes of the actual payload.
The brief, as originally scoped: async, single-retrieval, encrypted such that the server operator can’t read what they’re storing even if they wanted to. Not zipline (persistent hosting, you keep the link) and not flit (synchronous, no storage) — something that sits in the gap between them.
Problem
Build a drop-off point that:
- Lets a sender upload without the recipient being online
- Makes the server incapable of reading file contents, filename, or the decryption key — not just policy-forbidden, actually incapable
- Deletes the file after one successful retrieval, without a way for a partial or failed download to either strand the file forever or destroy it before it arrived
- Doesn’t accumulate unbounded storage from links nobody ever opens
- Ships as a single self-hosted container, matching how everything else here deploys
The interesting constraint was the third one. It sounds like it should be trivial — “delete after download” — and it’s exactly the kind of thing that’s easy to get subtly wrong in a way that only shows up under real network conditions.
Approach
1) Key-in-fragment encryption
The client generates a random 256-bit key with libsodium, encrypts the file with crypto_secretbox, and uploads only the ciphertext. The key is never transmitted — it’s appended to the share link after #, and browsers structurally never include URL fragments in HTTP requests. This isn’t a policy the server chooses to follow; it’s a property of how browsers work, which is the whole point. Same trick Firefox Send and PrivateBin used, and the only part of “the server can’t read this” that actually needs to be true rather than promised.
Filename and content-type ride inside the encrypted envelope too — a small length-prefixed JSON header, sealed along with the file bytes, rather than sitting in the server’s metadata table as an easy piece of information to leak. The server’s database knows a blob’s size and expiry. It doesn’t know what it’s called.
2) The deletion race
The naive version — delete the blob as soon as the download response finishes streaming — has a real failure mode. If the recipient’s connection drops at 80%, or their browser crashes mid-decrypt, the server has already considered the file “served” and removed it. The link is now dead and the recipient never actually got the file. No retry, no recovery. This is the specific bug that made Firefox Send frustrating to rely on.
The fix moves the decision to the client, where the actual proof of success lives — a successful AEAD decrypt is an integrity guarantee, for free. The flow:
GET /api/drops/:idstreams the ciphertext and returns a single-use confirm token in a response header.- The client decrypts locally. If that succeeds, it calls
POST /api/drops/:id/confirmwith the token. - Only on a valid confirm does the server delete the blob and mark the row burned.
If confirm never arrives — dropped connection, closed tab, whatever — the blob simply stays available, and the recipient can reload the link and try again. It’s not gone until either a confirm succeeds or the TTL backstop sweeps it. This trades a theoretically “cleaner” guaranteed-single-view model for one with no silent unrecoverable failures, which is the right trade: the failure mode of “someone could re-download before confirming” is bounded by the TTL anyway, so it’s no worse than a slightly generous expiry window.
Confirm tokens live in SQLite rather than in memory, expiring after 15 minutes — a server restart between download and confirm shouldn’t strand a client holding a token the process no longer remembers.
3) TTL as backstop, not primary mechanism
A background sweep (setInterval, default every 15 minutes) deletes any blob whose expires_at has passed and was never confirmed — separate code path from the confirm-triggered deletion, doing the same disk/DB cleanup. Default TTL is 3 days. This is what actually bounds worst-case storage growth; the confirm flow handles the common case, the TTL handles the “sender uploaded, recipient never showed up” case.
4) Storage, deliberately boring
Ciphertext blobs on local disk, one file per upload, named by a 128-bit random id kept separate from the encryption key — knowing the id from, say, a server access log doesn’t grant access to anything, since the key never reached the server to log. Metadata in SQLite (better-sqlite3, WAL mode) rather than a separate database service. This matches how npm-statuspage and the rest of the self-hosted stack already work: no infrastructure this doesn’t strictly need.
5) A password gate, because uploads cost disk
Unlike flit, which costs nothing server-side per transfer, every wisp upload consumes real disk space until it’s confirmed or expires. flit and waste-go can afford to be open — no accounts, anyone with the anchor URL can join. wisp can’t quite make the same call yet: v1 sits behind a single shared password (X-Upload-Password header, checked against an env var) gating the upload screen only — downloads need no password, since the link itself (including the fragment) is already the credential. It’s a blunt instrument, not the final answer; a per-identity signed-upload scheme reusing the waste-go/flit Ed25519 identity model is the more interesting version, deferred until there’s a real reason to build it.
6) Shipping it
Multi-stage Docker build: Vite client build, TypeScript server compile, then a lean runtime image. The one wrinkle is better-sqlite3’s native addon has no prebuilt binary for this platform/Node combination yet, so both the server-build stage and the final runtime stage need a C++ toolchain (python3 make g++) — installed, used to rebuild the addon, then purged again in the same layer to keep the final image from carrying a compiler around. The data/ directory (SQLite DB + blob files) is explicitly excluded from the image and lives in a named Docker volume instead, so redeploys don’t clobber it. Gitea Actions builds and pushes on every push to main, same pattern as the rest of this project family.
What I’d do differently
The shared-password gate is the obvious thing to revisit — it works, but it’s the least interesting part of the design next to everything upstream of it. Ed25519-signed uploads, reusing identity infrastructure that already exists in flit and waste-go, would raise the bar without inventing new auth.
No resumable uploads — a dropped connection mid-upload just fails, matching flit’s own noted gap around resumable transfer. For the file sizes this is aimed at, that’s an acceptable corner to leave uncut for now.
Stack
Client: TypeScript, Vite, libsodium-wrappers (crypto) — no framework, deliberately minimal DOM manipulation for a two-screen app.
Server: Node.js, Express, better-sqlite3 (WAL mode), a setInterval-based cleanup sweep — no external services, no queue, no separate cron.
Infrastructure: Docker (multi-stage build), a single named volume for persistent data, Gitea Actions → private container registry → docker-compose deploy. Same operational shape as the rest of the self-hosted stack.