The Publish Pipeline
Publish is the moment plaintext becomes ciphertext — the single most security-critical client flow in the app, and the place the trust boundary is actually crossed. Everything the server will ever store about a photo is produced here, in the browser, before a single byte is uploaded. The code is apps/web/src/lib/publish/ (single-photo and batch both in pipeline.ts), with shared audience/recovery helpers and the server endpoint in apps/web/src/lib/server/api/photo-upload.ts.
Inputs
Section titled “Inputs”By the time publish runs, the upload flow has produced:
- a decoded image (the full-resolution pixels, in memory),
- a set of detected faces, each with a decision: tagged to a
persongroup, or left untagged, and - an audience: the
circlegroups the photo is shared with.
See Recognition for how the faces and suggestions got there, and Frontend Architecture for the upload page’s state machine (upload-detection / upload-recognition factories, the batch cluster-review, the preview-state for the publish preview).
The steps
Section titled “The steps”For each photo, the pipeline does the following — all client-side:
- Prepare assets (audience-independent).
prepare-assets.tsrenders the pixelated base — every detected face region is overwritten with a 5×5 mosaic, irreversibly — then produces a display image atDISPLAY_MAX_DIM(1600 px long edge) and a thumbnail atTHUMB_MAX_DIM(1200 px), both WebP-encoded atWEBP_QUALITY(0.85). This prefix depends only on the image and faces, not the audience, so it’s computed once and cached (thepreparedAssetscache-hit guard relies on display and publish sourcing dimensions/quality from the sameasset-dimensions.ts). - Encrypt the caption to the audience. The pixelated display image and thumbnail are plaintext-redacted BBP assets. A fresh content DEK encrypts only the caption and is wrapped to each circle’s current epoch public key in
photo_circles. - Crop and seal face entities. For each tagged face, the pipeline crops the original unpixelated region and masks overlaps. It seals
{ bbox, landmarks?, epoch, pixels }as one BBP entity body, then wraps the entity DEK both to the person-group epoch key and the uploader account key. D1 stores the sealed body and wraps; there is no plaintext bounding-box or standalone landmark column. - Build sealed intersection entities where a crop requires two groups. The content key is XOR-split (
splitKey/combineKey), each half is wrapped to one group, and both geometry and pixels remain inside the sealed BBP body. The overlapping region is masked in each individual face entity and restored only for viewers holding both halves. - Compute and upload embeddings. For each tagged face, an embedding is extracted from the full-resolution source (min-size-gated, sharpness-threaded — see Recognition), encoded with the model-version stamp, encrypted to the person-group’s epoch key, and
POSTed to/api/person-embeddingswith the photo’s realphotoId. This step is non-blocking: per-embedding failures are collected as warnings, not thrown — a face that fails to embed is still published and stays manually taggable. - Upload the photo.
bbpEncodeContainerembeds the plaintext-redacted base and shuffled sealed entities in one WebP-carried JUMBF payload. A single multipartPOST /api/photoscarries that container, a plaintext-redacted thumbnail, the aligned entity ACL map, circle/caption metadata, aspect ratio, andidempotencyKey. See API Contracts. - Update the feed optimistically. The publish result seeds the client’s feed cache (keyed on the server’s canonical
createdAt) so the new photo appears instantly without a round-trip.
The server, throughout, validates membership and shapes, stores blobs in R2 and rows in D1, and never decrypts anything.
Resilience
Section titled “Resilience”Publishing has to survive flaky networks and concurrent key rotations without producing duplicates or leaking plaintext:
- Idempotency. The
idempotencyKey(plus the unique(owner, idempotencyKey)index) means a retried upload returns the already-committed photo instead of inserting a duplicate. - Stale-epoch retry. If a circle’s epoch was rotated between key-fetch and upload, the server returns 409 stale-epoch. The pipeline refreshes the affected circle keys, re-encrypts the wrapped content keys, and retries the multipart upload with the same idempotency key (safe, because a 409’d upload stored nothing). The retry happens once.
- Cancellation. The flow threads an
AbortSignal; cancelling cuts the loop at every phase boundary, so a cancelled publish doesn’t keep running the embedder or firing fetches after the user backed out. - Recovery surfaces. Publish-time recovery/key errors render through the shared
RecoveryErrorPanelon both the single and batch publish pages, rather than a silent failure.
Single vs batch
Section titled “Single vs batch”The single-photo and batch pipelines (both in pipeline.ts) share the encryption machinery and the audience/recovery helpers but differ deliberately in defaults — for example the default audience differs (single uses all faces with a personGroupId; batch is tag-only), and the publish-page hints/filters differ. Don’t “unify” those asymmetries without reading why they exist. Batch publish adds clustering (the same person across many photos is grouped so you tag once — face-clustering.ts) and a per-photo face-editor modal; its state is split into preview-state.svelte.ts + face-inputs.ts + route-local components to keep each file small. publishBatch runs publishPhotoWith per photo in series (not in parallel), deriving each photo’s idempotency key as ${base}-${i}.
Where to look
Section titled “Where to look”publish/pipeline.ts (single-photo + batch orchestration: pixelate → encrypt → slices → embeddings → upload → optimistic cache), the shared publish/audience + publish/recovery helpers and RecoveryErrorPanel.svelte, server/api/photo-upload.ts (server-side parsing/validation/storage), and server/api/photos.ts (the thin route delegate + the stale-epoch ConflictError).