Storage
File uploads backed by S3-compatible object storage.
Last updated on
7 min readStorage in packages/storage is a thin layer over the AWS S3 SDK, intentionally provider-agnostic. Any S3-compatible store works because the only configuration that changes is the endpoint URL.
Uploads go directly from the browser to the bucket via a presigned URL, so serverless body-size limits never apply. The server is not in the upload path but it is in the validation path: a finalize step re-fetches the bytes, validates them with sharp, re-encodes the result to a stable final key, and cleans up the temporary key in every server-observable failure mode.
How an upload flows
Presign, direct PUT to the bucket, then a server-side validate-and-finalize.
Constraints and validation
MIME, size, and the sharp re-encode that is the security boundary.
Cleanup behavior
The four helpers that wipe keys from a single image to a whole principal.
Add an upload surface
Reuse presign and finalize for any new file-upload feature.
How An Upload Flows
Why three round-trips. It moves bytes off the serverless boundary (no Vercel 4.5 MB body limit, no equivalent platform cap) while still letting the server validate the content. The presigned URL expires in 6 minutes, so if a user walks away mid-upload the URL stops working before any infinite-write window is opened.
Why finalize re-encodes. Re-encoding through sharp is the security boundary. A file that decodes cleanly in sharp is unambiguously an image. Anything else throws and the temp key is deleted automatically.
The diagram source lives at apps/docs/diagrams/storage-upload.mmd. Rerun pnpm --filter @syntaxkit/docs diagrams:build after editing it to refresh both SVG variants.
Package Layout
The storage package owns the entire upload subsystem: key conventions, policy, the sharp pipeline, and the presign + finalize S3 orchestration. presignImageUpload and finalizeImageUpload are plain functions here, throwing a framework-agnostic StorageError. The oRPC endpoints in packages/api/src/router/storage.ts are a thin adapter: they enforce auth and abuse policy, resolve the bucket from the environment, call into the storage package, and map StorageError to ORPCError. The storage package owns the workflow; the API package owns the transport.
Configuring Storage
The provider-specific env blocks (AWS S3, Cloudflare R2, MinIO) plus a verification step live on Setup: Storage. The kit doesn't care which S3-compatible store you pick: same SDK, same code path, only the endpoint and credentials differ.
The full per-variable reference is on Environment Variables: Object Storage (S3).
File Constraints And Server-Side Validation
| Constraint | Value |
|---|---|
| Allowed MIME types | image/jpeg, image/png, image/webp, image/gif |
| Max file size | 1 MB |
| Max output dimension | 2048 px (longest side, fit inside) |
| Output format | JPEG (mozjpeg, q85) by default; PNG when alpha is present |
Client-side checks reject the wrong size or MIME before presign. Server-side sharp is the source of truth: anything that doesn't decode (or that exceeds the dimension limit) returns an error and the temp object is removed. Final outputs are always re-encoded; original bytes never become the served file.
The size limit is enforced in three layers, because presigned PUT URLs do not reliably enforce Content-Length across S3-compatible providers:
- The client and the presign Zod schema reject anything larger than
MAX_FILE_SIZEbefore a URL is signed. finalizeissues aHeadObjectfirst and refuses any temp key whoseContent-Lengthexceeds the limit (or is missing). No body bytes are downloaded in that path.- The
GetObjectbody is then read with a hard byte cap. If the streamed payload exceeds the cap mid-read (for example because a non-conformant backend understated the size, or the user re-uploaded between HEAD and GET), the read aborts and the temp key is deleted. The worker never materializes more thanMAX_FILE_SIZEin memory.
What Gets Uploaded
User avatar
From personal settings. Uses AvatarUploader. Writes back through user.updateImage, which calls deleteS3ImageIfNeeded on the previous key.
Organization logo
From org settings. Uses the same AvatarUploader. Writes back through organization.updateLogo with the same cleanup behavior.
AI chat image attachments
From the chat composer. Uses uploadChatAttachments. URLs are embedded into Message.parts, and the chat router validates the URL belongs under images/<userId>/ before sending to the model.
Required Bucket Configuration
The kit doesn't and can't auto-provision your bucket. Two settings are easy to miss because they're operational rather than code-level. Both rules are copy-pasteable.
CORS. The browser PUTs directly to a presigned URL. Without CORS allowing PUT from your app origin, every upload fails silently (or with an opaque "Failed to upload to S3" toast). Recommended rule:
[
{
"AllowedOrigins": ["https://your-app.com"],
"AllowedMethods": ["PUT"],
"AllowedHeaders": ["*"],
"ExposeHeaders": ["ETag"]
}
]Lifecycle rule for tmp/. The kit deletes temp objects when finalize succeeds, when image processing fails, or when the final upload to the long-term key fails. The one case it cannot catch is the user abandoning the upload between presign and finalize; the server is never called again, so app code has no event to react to. A bucket lifecycle rule that expires tmp/ after 1 day catches that residual case (and is a sensible safety net regardless):
{
"Rules": [
{
"ID": "expire-tmp-uploads",
"Status": "Enabled",
"Filter": { "Prefix": "tmp/" },
"Expiration": { "Days": 1 }
}
]
}Graceful Degradation
When the storage env block is missing, isStorageEnabled returns false and the dashboard handles it differently per surface:
- Personal settings and organization settings replace the avatar form with a placeholder card explaining that storage isn't configured.
- The AI chat composer hides the image-attachment button entirely. The rest of the chat keeps working.
pnpm setup:doctor reports storage status alongside every other capability. See Environment Variables for the full env matrix.
Cleanup Behavior
Four cleanup helpers cover the lifecycle, in order from "single key" to "everything a principal ever uploaded".
deleteS3ImageIfNeeded
The cleanup helper for finalized images. Skips external URLs and only deletes when the value is a kit-managed S3 key. Best-effort by design (errors are logged, not thrown). Runs when a user uploads a new avatar or an org uploads a new logo (deleting the old one), and from beforeUserDelete (user image) and afterOrganizationDelete (org logo).
deleteS3Keys
Deletes an explicit list of keys in batches of up to 1000 (the S3 DeleteObjects limit). Runs from beforeOrganizationDelete: every chat-attachment URL referenced by Message.parts in the org's chats is collected before the Prisma cascade removes the rows, then deleted in batched requests. Best-effort per chunk: a transient failure on one batch never aborts the rest.
deleteS3Prefix
Lists every object under a prefix (with ListObjectsV2 pagination) and pipes it through deleteS3Keys. Runs from beforeUserDelete for images/{userId}/ (final attachments) and tmp/images/{userId}/ (orphan temp uploads where presign succeeded but finalize never ran). The GDPR-correct path: every byte the deleted user uploaded is wiped, referenced from a message or not.
deleteObjectIfPresent
The cleanup helper for in-flight uploads. Runs inside finalizeImageUpload whenever something between the user's temp PUT and the final PUT goes wrong: the bytes don't decode, sharp can't process them, or the final PUT itself fails. The temp key is removed in every server-observable failure path.
Why The Org-Delete Hook Runs Before The Cascade
afterDeleteOrganization can't read the messages: by the time it fires, Prisma has already cascaded Chat/Message rows away. Reading Message.parts therefore happens in beforeDeleteOrganization, alongside the existing afterDeleteOrganization logo cleanup. Together they cover every S3 byte tied to the org.
Single-Reference Assumption
Every presign + finalize round trip mints a fresh crypto.randomUUID() key, and the chat composer never reuses a previous upload, so each images/{userId}/<uuid> key is referenced by at most one message in practice. Cleanup deletes by key without reference counting. If you ever build a "send same image again" / attachment library / cross-chat reuse feature, replace this with a chat_attachment table that tracks (key, userId, refCount) and only deletes from S3 when refCount drops to zero.
IAM
Both cleanup paths require list and delete on the bucket. The IAM role that the app runs under needs s3:ListBucket, s3:DeleteObject, and s3:DeleteObjects on arn:aws:s3:::<bucket> and arn:aws:s3:::<bucket>/* (or the equivalent on R2/MinIO). Without s3:ListBucket the user-delete prefix wipe degrades to a silent no-op.
Adding A New Upload Surface
The pattern is the same for any new file-upload feature.
Reuse the presign and finalize procedures
Don't build a parallel upload route. orpc.storage.presign and orpc.storage.finalize already enforce auth, abuse policy, key ownership, MIME, size, and image validation. Anything that needs different rules is a separate discussion.
Mirror the client pattern from AvatarUploader
Call orpc.storage.presign with the file metadata, PUT the bytes to the returned URL with the right Content-Type, then call orpc.storage.finalize with the temporaryKey. The avatar uploader is the reference implementation; the chat attachments path uses the same shape.
Decide where the resulting imageKey lives
For simple replacements, write back through an existing mutation that calls deleteS3ImageIfNeeded on the old value. For new entities, store the key on the row and add a Prisma cascade or hook so it cleans up on delete.
Render public URLs with getImageUrl
The helper composes NEXT_PUBLIC_S3_PUBLIC_URL plus the key correctly and pass-throughs external URLs unchanged. Don't string-template the URL yourself.
Extend CSP if your upload host differs from the public host
getStorageConnectOrigins in apps/web/lib/storage-origins.ts already covers the two paths the kit uses. If you point storage at a third hostname, extend the helper so the browser can fetch to it.
Where To Go Next
API
The presign and finalize procedures themselves and how the chat router validates attachment URLs.
Authentication
The before-delete hooks that call deleteS3ImageIfNeeded for user images and owned org logos.
AI
The chat composer surface that uses uploadChatAttachments and embeds image URLs into Message.parts.
Database
Where User.image, Organization.logo, and Message.parts live alongside the cascade rules.
Setup
The full env matrix, including the storage block and how setup-doctor reports on it.
