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.
- The exact object key (
tenancy-docs/{userId}/{ulid}.{ext}for private,listings/{userId}/{ulid}.{ext}for public). - The exact
Content-Typedeclared at presign time. - A 10-minute expiry window.
Two buckets, two privacy posture
fileType | Bucket | Public CDN? | cdnUrl on confirm | Notes |
|---|---|---|---|---|
TENANCY_DOC | swappr-private | No | null | Only admins can read these, via a separate signed-download endpoint (Phase 6). |
HOME_PHOTO | swappr-public | Yes | Returned | Served via the CDN; no auth needed to fetch. |
/uploads/confirm returns cdnUrl: null for tenancy documents — there is no public URL to return.
The two-step round trip
After confirm the returneduploadId 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
mimeTypeis on the allowlist for the chosenfileType(per the presign Zod schema). - The
sizeBytesis within the allowed range. - The
Content-Typeis 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 storedContent-Typeagainst the extension-implied MIME. If they disagree the row is not inserted and400 VALIDATION_FAILEDis returned. - Object keys are namespaced under
tenancy-docs/{userId}/...orlistings/{userId}/.... The confirm endpoint rejects keys that don’t start with the caller’s userId — preventing cross-userfileKeyinjection.
- 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_DOCPDFs.
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
uploadsrow, in which case it is an orphan.
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
uploadUrlto logging or analytics — it is short-lived but a credential while alive.
See also
- Presign upload URLs
- Confirm uploads
- Step 1 — tenancy — consumes a
TENANCY_DOCupload. - Step 3 — photos — consumes
HOME_PHOTOuploads. - Add listing photos — post-onboarding photo additions.