Uploads
The three-step S3 multipart pipeline — initiate, parallel PUT, complete — and the abort path that keeps S3 clean.
Files never pass through the API. The backend authorises the target, mints presigned URLs, and the browser uploads directly to S3.
The three steps
Initiate
POST /api/upload/initiate
Content-Type: application/json
{
"fileName": "brand-mark.png",
"contentType": "image/png",
"partCount": 5,
"ownerScope": "organization",
"collectionId": "CLa7Bk9x2Q",
"projectId": "PRa7Bk9x2Q",
"collectionIds": ["CLa7Bk9x2Q", "CLb8Cm0y3R"]
}The backend authorises the target before minting anything, then returns an uploadId, a key, and one presigned URL per part.
collectionId and collectionIds are not alternatives — which one applies depends on ownerScope:
ownerScope |
Field used | Checked against |
|---|---|---|
personal |
collectionId |
the caller owns that collection |
organization |
collectionIds and projectId |
both belong to the resolved organization |
The field for the other scope is ignored.
If the target is not yours, or does not belong to the resolved organization, the call fails before any URL is minted: 404 Collection <id> not found or 404 Project not found. It is 404 rather than 403 for the same reason ownership mismatches are — see Conventions.
partCount must be between 1 and 10,000. contentType must match /^[\w.+-]+\/[\w.+-]+$/. fileName is capped at 255 characters.
Upload the parts
The client PUTs each 5 MB chunk straight to S3 in parallel and reads the ETag response header from each.
const response = await fetch(presignedUrl, {
method: "PUT",
body: chunk,
});
const eTag = response.headers.get("ETag");The part URLs are valid for one hour. A single failed part can be retried against the same URL for as long as it lasts — the parts are independent, and S3 accepts a re-PUT of the same part number. Past the hour, or if ETag comes back null, the upload cannot be completed: abort it and start again.
Complete
POST /api/upload/complete
Content-Type: application/json
{
"uploadId": "…",
"key": "organizations/ORa7Bk9x2Q/uploads/8f1c6f2e-3d0a-4b7c-9e21-5a0d7c4b1e93-brand-mark.png",
"contentType": "image/png",
"parts": [{ "partNumber": 1, "eTag": "…" }],
"ownerScope": "organization",
"projectId": "PRa7Bk9x2Q",
"collectionIds": ["CLa7Bk9x2Q"]
}The backend finalises the S3 multipart upload, then persists the asset, its first version, and an asset_logs row in a single transaction. A failure at any point rolls all three back together.
Aborting
POST /api/upload/abort
Content-Type: application/json
{ "uploadId": "…", "key": "…" }
An abandoned multipart upload leaves orphaned parts that S3 bills for. Call abort on any failure — the client is the only thing that knows the upload died.
Abort itself is best-effort: if the call fails, the parts stay in S3 and nothing else will clean them up, because no server-side job tracks abandoned uploads. Retry it once, then move on rather than blocking the user. A bucket lifecycle rule to expire incomplete multipart uploads would make this self-healing and does not exist yet.
A malformed or foreign key returns 400 Invalid upload key; a key whose prefix does not match the declared ownerScope returns 400 Upload key does not match owner scope.
Uploading a new version
Pass an existing assetId and an optional changeNote to complete, and the pipeline appends an AssetVersion rather than creating a new asset:
{
"uploadId": "…",
"key": "…",
"contentType": "image/png",
"parts": [{ "partNumber": 1, "eTag": "…" }],
"assetId": "ASa7Bk9x2Q",
"changeNote": "Tightened the wordmark spacing"
}
asset_versions has a unique constraint on (asset, version_number), so two concurrent version uploads cannot both claim the same number — the loser surfaces as a retryable 409.
Collections are optional
collectionId is nullable throughout the upload path. An asset can exist attached only to an organization, or to a project, with no collection at all. Both halves support this: the backend persists assets with no collection, and the SPA allows uploads without choosing one.
What lands in the database
| Table | Row |
|---|---|
assets |
name, type, short + long URL, current_version: 1, and whichever of collection_id / owner_user_id / organization_id / project_id / created_by_user_id apply |
asset_versions |
version_number, s3_key, asset_short_url, asset_long_url, optional change_note |
asset_logs |
activity_type: created (or version_added) with a human-readable message |
activity_logs |
written separately by the interceptor, after the response, not in the transaction |
