Why direct-to-Spaces

Tenancy documents are PDFs of multi-megabyte size; listing photos are JPEGs around 2 MB. Streaming those bytes through the API would:
  • Saturate the egress of the small Node containers running the API.
  • Add a per-request multi-second window during which a worker thread is pinned to socket reads.
  • Force us to size the API tier for upload throughput rather than request throughput.
Instead the client uploads bytes directly to DigitalOcean Spaces using a presigned PUT URL that the API issues. The API never sees the file contents during the upload. The signature is SigV4 and is bound at signing time to:
  • The exact object key (tenancy-docs/{userId}/{ulid}.{ext} for private, listings/{userId}/{ulid}.{ext} for public).
  • The exact Content-Type declared at presign time.
  • A 10-minute expiry window.
If the client tries to PUT different bytes than declared, or a different content-type, or to a different key, Spaces rejects the upload at the storage edge.

Two buckets, two privacy posture

fileTypeBucketPublic CDN?cdnUrl on confirmNotes
TENANCY_DOCswappr-privateNonullOnly admins can read these, via a separate signed-download endpoint (Phase 6).
HOME_PHOTOswappr-publicYesReturnedServed via the CDN; no auth needed to fetch.
This split is why /uploads/confirm returns cdnUrl: null for tenancy documents — there is no public URL to return.

The two-step round trip

After confirm the returned uploadId is what feeds the typed onboarding/listing endpoints (uploadId on step-1-tenancy, photoUploadIds on step-3-photos and /current-home/me/photos).

Client-supplied display metadata (photoMeta)

For home photos the client crops the image on-device (free 4:3 for listings, circular 1:1 for avatars) and can compute a BlurHash plus the cropped pixel dimensions before attaching. Both attach endpoints — step-3-photos and /current-home/me/photos — accept an optional photoMeta: [{ uploadId, blurhash, width, height }] array. When provided, the server stores those values on the embedded photo; the app then paints the BlurHash as a progressive placeholder while the full image loads, and the same fields are echoed back on GET /current-home/me, GET /matches, and GET /matches/:matchId. photoMeta is fully optional and keyed by uploadId; entries not referenced in photoUploadIds are ignored.

What Phase 2 actually enforces

Be honest with the frontend team about what the current implementation does — and doesn’t — guarantee. Phase 2 enforces:
  • The mimeType is on the allowlist for the chosen fileType (per the presign Zod schema).
  • The sizeBytes is within the allowed range.
  • The Content-Type is pinned at presign time into the SigV4 signature, so the client cannot upload a different MIME than it declared without the storage edge rejecting the PUT.
  • On confirm, the API HEADs the object and re-checks the stored Content-Type against the extension-implied MIME. If they disagree the row is not inserted and 400 VALIDATION_FAILED is returned.
  • Object keys are namespaced under tenancy-docs/{userId}/... or listings/{userId}/.... The confirm endpoint rejects keys that don’t start with the caller’s userId — preventing cross-user fileKey injection.
Phase 2 does NOT yet enforce (deferred to Phase 7 image-worker):
  • Magic-byte verification of the actual file bytes. A determined attacker can still upload a JPEG with PDF bytes inside — the size/MIME pinning narrows the surface but does not eliminate it.
  • EXIF GPS stripping. Photos shipped from camera apps may contain location metadata until the worker runs.
  • Thumbnail and resize variant generation.
  • Blurhash placeholder generation.
  • Server-side malware scanning of TENANCY_DOC PDFs.
The image.process BullMQ queue is wired in Phase 2 but the worker is a stub — confirmed photos get a row in uploads and an enqueued job, but the job is not yet consumed. thumbnailUrl therefore still mirrors url until Phase 7. The width, height, and blurhash fields, however, are populated whenever the client passes photoMeta at attach time (see above); they fall back to 0/0/"" only when the client omits it. Phase 7’s worker will additionally backfill these for legacy photos and generate true thumbnails.

Orphan handling

If the client calls /uploads/presign but never PUTs the bytes (or PUTs them and then drops the network before calling /uploads/confirm), the object either:
  • Never exists in Spaces (PUT never landed), in which case there is nothing to clean up.
  • Exists in Spaces but has no uploads row, in which case it is an orphan.
Phase 2 does not yet sweep orphans. The plan is a Phase 7 nightly cron that lists objects under listings/ and tenancy-docs/, joins against the uploads collection by fileKey, and deletes objects older than 24 hours with no row. Until then, orphans are tolerated.

TTL on presigned URLs

Presigned URLs are valid for 10 minutes from issuance. The frontend should:
  • Not cache them — fetch a fresh URL whenever the user actually picks files.
  • Retry with a fresh presign if a PUT fails with 403 SignatureDoesNotMatch (most likely cause: TTL elapsed).
  • Not surface uploadUrl to logging or analytics — it is short-lived but a credential while alive.

See also