Skip to content

uploads — Presigned Upload API for Trusted Backends

Direct-to-B2 presigned uploads for trusted internal Scenarix backends. One endpoint. The backend never sees the bytes — it only signs a URL the caller PUTs to.

Endpoint

POST /api/apiViaKey/uploads/presign-upload

API-key auth only — there is no JaduAuth mount. Studio's own frontend uses POST /api/assetGen/presign-upload instead, which has a different key layout and is not this contract.

Header Required Notes
x-api-key yes Must appear in the comma-separated API_REQUEST_KEYS env var
x-user-id yes Recorded as the Sentry user. Caller-asserted — trusted callers only
Content-Type yes application/json

Request

Every field is required.

curl -X POST https://<host>/api/apiViaKey/uploads/presign-upload \
  -H "x-api-key: $API_KEY" \
  -H "x-user-id: 8f2c1b0e-1234-4a56-9876-abcdef012345" \
  -H "Content-Type: application/json" \
  -d '{
    "assetType": "image",
    "contentType": "image/png",
    "fileSize": 1024,
    "folder": "storydesk/story-123"
  }'
Field Notes
assetType image, audio, or script. Drives the MIME allowlist and the size cap. video is not exposed
contentType Must belong to the declared assetType (see below)
fileSize Exact byte count of the body you are about to PUT, not a maximum. See "Why fileSize is exact"
folder Your destination folder inside studio's root. See "Where the file lands"

Supported contentType per assetType:

assetType contentType Cap
image image/jpeg, image/jpg, image/png, image/gif, image/webp, image/svg+xml 10 MB
audio see SharedConstants.AUDIO_MIME_TO_EXTENSION (audio/mpeg, audio/webm, …) 50 MB
script application/pdf, application/msword, …wordprocessingml.document, text/plain 10 MB

Response

{
  "isSuccess": true,
  "message": "Presigned upload URL generated successfully",
  "data": {
    "uploadUrl": "https://…",
    "publicUrl": "https://<bucket>.s3.<region>.backblazeb2.com/assetUpload/storydesk/story-123/upload-<uuid>.png",
    "key": "assetUpload/storydesk/story-123/upload-<uuid>.png",
    "contentType": "image/png",
    "contentLength": 1024,
    "maxUploadSize": 10485760,
    "expiresIn": 900
  }
}

maxUploadSize is the per-object cap for that assetType, echoed so you can message your own users. expiresIn is seconds until uploadUrl stops working (15 min).

Step 2 — the PUT

curl -X PUT "$UPLOAD_URL" \
  -H "Content-Type: image/png" \
  -H "Content-Length: 1024" \
  --data-binary @file.png

Both headers are signed and must match the response exactly. Any mismatch — a different content type, or a body of a different length — makes B2 reject the PUT with SignatureDoesNotMatch. An expired URL returns 403.

Studio is not involved in this step; failures here are between you and B2 and surface as B2's own S3 error codes.

After a successful PUT the bytes are readable at publicUrl. Keep it — studio stores no record of it, so there is no way to look the URL up later.

Where the file lands

assetUpload/<your folder>/upload-<uuid>.<ext>

assetUpload/ is studio-owned and prepended server-side. Your folder nests inside it and cannot escape it.

The filename is always generated by studio, so you cannot choose it and cannot overwrite an existing object — every call produces a new file. The extension is derived from contentType.

folder rules — violations are rejected with a 400, never silently cleaned up:

  • 1–200 characters
  • /-separated, at most 5 segments
  • each segment matches [A-Za-z0-9._-]+
  • no leading or trailing /, no empty segments, no . or .. segment

contentType is matched against the allowlist above by own-property lookup, so JavaScript Object.prototype key names (constructor, toString, __proto__, …) are rejected like any other unsupported type.

Why fileSize is exact

A presigned PUT can only sign an exact Content-Length. The only S3 mechanism that expresses a maximum is a presigned POST policy (content-length-range), and Backblaze's S3-compatible API lists POST-based presigned uploads among its unsupported features.

So fileSize is what makes the cap real: it is bound into the signature, and B2 itself rejects an oversized body. Sending it also means you get a clean 400 before transferring anything instead of a SignatureDoesNotMatch after uploading the whole file. For a server-to-server caller holding the buffer it is just buffer.length.

Errors

All errors use the standard envelope: { "isSuccess": false, "message": "…", "data": {} }.

Status Cause
400 Validation failure — unknown assetType, contentType not valid for that assetType, fileSize non-positive/non-integer/over the cap, or folder missing or violating a naming rule
401 Missing or invalid x-api-key, or missing x-user-id
500 Unexpected server error, including a B2 signing failure

There is no 502: signing is local, so there is no upstream generation call to fail.

Design notes

  • publicUrl is world-readable and permanent. It is a plain public B2 object URL with no expiry and no auth. The only protection is that the uuid in the path is unguessable. Do not upload anything that must stay private.
  • Every issued URL is logged. key, folder, assetType, fileSize and the asserted user id are written to the request log, so an object in the bucket can be traced back to a call. There is no database record and no logId; correlate by requestId, method and path.
  • Nothing prunes assetUpload/. There is no lifecycle rule, so uploads accumulate indefinitely.
  • folder is caller-asserted, exactly like x-user-id. API_REQUEST_KEYS is a flat env allowlist with no per-key identity, so any valid key can write into any folder and folder must never be treated as proof of origin. Acceptable for trusted internal infrastructure; must be revisited before any external partner is given a key. Deriving the folder root from the API key would fix it.
  • No rate limiting.
  • Uploads always target the production bucket. testModeMiddleware exists but is not mounted, so the x-test-mode header has no effect on any route.
  • Shares B2Service.generatePresignedPutUrl with assetGen via its keyPrefix option. The studio route passes no prefix and keeps its historical images/ / audios/ / scripts/ layout and its maxBytes response field; this route sets a prefix and renames that field to maxUploadSize. The two contracts are deliberately independent.

Full design: 2026-08-18-presign-upload-api-via-key-design.md, which lives outside this repository — in the parent studio/ workspace at studio/docs/superpowers/specs/, one level above this git root (studio-backend/). A clone of studio-backend alone will not contain it.