The media pipeline: uploads you can trust, bytes you don't proxy
Receipts and photos are the one place a splitting app handles arbitrary user bytes, and that makes the media path both a bandwidth problem and a security problem. Fable's answer: the API never touches the bytes (clients upload and download directly to Cloudflare R2 via presigned URLs, served through a CDN), and it never trusts what the client says a file is (every upload is validated against its actual magic-byte signature, not its claimed MIME type). This chapter walks that pipeline, the access control that gates private media, and the honest list of what it deliberately doesn't do yet.
The media pipeline: uploads you can trust, bytes you don't proxy
Media is where Fable handles the one thing the rest of the app doesn't: arbitrary bytes from an untrusted client. A receipt photo, a profile picture, a PDF — large, binary, and potentially hostile. That combination makes the media path simultaneously a bandwidth problem (you don't want gigabytes flowing through your API) and a security problem (you can't trust a byte the client sends). Fable's pipeline is built around two refusals that answer exactly those two problems: the API never transports the bytes, and the API never trusts the client's claim about what a file is.
This chapter is the object-storage lesson made concrete, plus the validation layer that lesson didn't cover.
The upload path: validate, then hand off
When a client wants to attach a receipt, the flow is:
- The client asks the API to prepare an upload. The API validates the request and returns a presigned URL scoped to one object.
- The client
PUTs the file directly to R2 using that URL — the bytes never touch the API. - The API records the media metadata (owner, kind, MIME type, the R2 key) as a row, hung off a group like everything else.
The two refusals live in this path. Bytes bypass the API via presigned URLs — the object-storage lesson's central pattern — so a dozen concurrent 10 MB receipt uploads don't pin the API's memory or bandwidth; the API's job is authorization, not transport. And every upload is validated, which is the security half.
Trusting no byte: MIME whitelists and magic-byte checks
A client sends a Content-Type header saying "this is image/jpeg." That header is a claim, and claims from clients are not facts. A malicious client can declare image/jpeg and upload an executable, a script, or an HTML file crafted to be interpreted as something dangerous when served. So Fable validates in two layers:
- A MIME whitelist per kind. Each media kind accepts only certain types — a
receiptaccepts common image formats (jpeg/png/webp/heic/heif), a general image addsgif, documents allow PDF and common office types. A type not on the kind's whitelist is rejected outright. - Magic-byte validation. This is the important one. Rather than believe the declared MIME type, the API inspects the file's actual leading bytes — its magic-byte signature — and confirms they match the claimed type. A real JPEG starts with specific bytes; a real WebP has
RIFF…WEBPat a known offset. If the declared type and the actual signature disagree, the upload is rejected. This is what stops "an.exerenamed to.jpg" — the classic upload attack where a client lies about a file's type to smuggle something dangerous past a type check. The MIME string can lie; the bytes can't (as easily).
There's a pragmatic boundary: images are byte-validated (their signatures are simple and stable), but formats with many container variants — HEIC, office docs, audio/video — are only MIME-whitelisted, because robustly signature-checking every container variant is a losing battle. Validate what you can validate cheaply and reliably; whitelist the rest. It's a deliberate, honest trade, not an oversight.
The download path: access control on private bytes
Media isn't public by default — a receipt belongs to a group, and only that group's members should see it. So the read path enforces the same tenancy check as every query: the API's assertReadable confirms the viewer is a member of the group the media belongs to before handing back a way to fetch it. The client fetches the metadata by id first (which runs the access check), and then the bytes come either from a public CDN URL (for media that can be public, cached at the edge so R2 is hit once) or a presigned GET URL with a limited TTL (for private media, re-fetched when the presign expires). Either way the access decision is the API's and the transport is R2's — authorization here, bytes there, exactly as the upload path.
Planned (TDD)
A media pipeline with server-side processing: thumbnails, image variants, EXIF stripping, blurhash placeholders.
Shipped
Direct-to-R2 uploads via presigned URLs with MIME-whitelist + magic-byte validation and group-scoped access control; CDN/presigned delivery. Server-side processing (variants, EXIF strip, blurhash) deferred to a later phase.
Transport (don't proxy bytes) and security (don't trust the declared type) are non-negotiable and shipped inline. Image processing needs either inline CPU that competes with the money workload or a worker fleet that doesn't exist yet — so it's deferred as an optimization, with EXIF-stripping flagged as the item closest to privacy and first to promote.
Interview takeaway
For any "handle user uploads / media" prompt:
- "Clients upload and download directly to object storage via presigned URLs; the API authorizes but never transports the bytes." The single highest-signal media statement — it shows you won't build the API into a bandwidth bottleneck.
- "I validate the file's magic bytes, not just its declared MIME type, because the Content-Type is a client claim." Names the disguised-upload attack and its fix.
- "Private media is access-controlled at the API, then served via a short-lived presigned URL or a CDN for public assets." Ties authorization and delivery together.
- "Server-side processing (thumbnails, EXIF strip) I'd do async on a worker, and defer until there's a queue — except EXIF stripping, which is privacy, so it moves up." Shows the correctness-vs-optimization judgment.
The bytes live in R2 and reach users through the CDN; the metadata rows live in the data model; the direct-transport pattern is the object-storage lesson in production.
Go deeper
- S3/R2 presigned uploads — The mechanics of the direct-to-storage upload that keeps bytes off the API — the transport half of the pipeline.
- OWASP — Unrestricted File Upload — The catalog of upload attacks (disguised types, dangerous content) that magic-byte validation and per-kind whitelists defend against — the security half.
- File signatures (magic bytes) reference — The actual leading-byte signatures Fable checks (JPEG, PNG, WebP's RIFF/WEBP) — how "the bytes can't lie as easily as the header" works in practice.
Check yourself
Answer out loud, as if an interviewer asked. If you hand-wave, reread that section.
- Media handling is both a bandwidth problem and a security problem. State the two "refusals" Fable's pipeline is built on, and which problem each one solves.
- A client sends Content-Type: image/jpeg. Why is that not sufficient to trust the file, and what does magic-byte validation add? What specific attack does it stop?
- Fable byte-validates images but only MIME-whitelists HEIC/office/video. Explain the pragmatic reason for that boundary and why it is a deliberate trade rather than a gap.
- How is private media access-controlled, and how do the bytes actually reach the client (public CDN vs presigned GET)? Where is the authorization decision made vs the transport?
- Fable does no server-side image processing yet. Give the correctness-vs-optimization principle behind that choice, and explain why EXIF stripping is the item flagged to promote soonest.
- For a media-upload prompt, what is the highest-signal thing to say about where bytes flow, and what is the highest-signal thing to say about validation?
Settlement flow: a state machine for money moving between people
Recording who owes what is arithmetic; recording that a debt was actually paid is a negotiation between two people, and it's where a splitting app either earns trust or loses it. This chapter walks Fable's settlement flow as what it really is — a small, actor-gated state machine (requested → marked_paid → confirmed, with dispute and cancel escapes) guarded by a Postgres advisory lock so two concurrent confirmations can't pay a debt twice. Then the real bug that shipped: an over-settle guard tied to the wrong decomposition of a group's debts, which rejected perfectly valid payments by a few paise when 'simplify debts' was on.
Push notifications: the inbox is the record, the push is the interruption
Notifications look like a simple 'send a push when something happens' feature and are actually two systems with opposite jobs: a durable in-app inbox that is the record of what happened, and a transient push that is a best-effort interruption you must earn the right to send. Fable separates them, delivers pushes via FCM only when the user isn't already looking, gates every push through per-category preferences, group mutes, and cooldowns, and prunes dead device tokens — because the fastest way to lose notification permission forever is to over-notify. This chapter is that architecture and the restraint behind it.