Storage — Presigned Upload
Cross-role utility endpoint that mints short-lived S3 presigned URLs for direct browser uploads. Used by both the admin and vendor-admin uploaders (product photos, return-evidence…
Cross-role utility endpoint that mints short-lived S3 presigned URLs for direct browser uploads. Used by both the admin and vendor-admin uploaders (product photos, return-evidence photos, banners, etc.) — the same handler serves any authenticated session because S3 access is gated by the presign signature, not by role. The storage module is registered globally (StorageModule.forRoot({...}).global = true) so other modules inject StorageService directly without re-importing.
Source:
api-modules/storage/src/controllers/storage.controller.ts
Authentication
| Property | Value |
|---|---|
| Guard | BetterAuthGuard (re-export of @sc/auth's AuthGuard) |
| Required | Any valid session — admin or vendor or customer |
| Role checks | None at the route level. Authorization is implicit: the presign is bound to a randomly generated server-side key (assets/<year>/<month>/<hash><ext>) so the caller cannot overwrite an existing object or pick a path |
S3 credentials live on the server (StorageModuleOptions, sourced from S3_* env vars in apps/api/src/app.module.ts) and are never sent to the client — only the time-limited presigned PUT URL is. Direct deletes happen through StorageService.deleteFile from other modules; there is no client-facing delete endpoint.
Endpoints
POST /storage/presigned — Mint a presigned upload URL
Body
{
"fileName": "ada-avatar.jpg",
"contentType": "image/jpeg" // optional
}| Field | Type | Constraints |
|---|---|---|
fileName | string | Min length 1. Only the extension is used (lowercased) to build the storage key; the rest of the name is discarded |
contentType | string? | Falls back to mime-types.lookup(fileName), then application/octet-stream |
Response 200
{
"data": {
"key": "assets/2026/5/k3jr8x2lp9a.jpg",
"url": "assets/2026/5/k3jr8x2lp9a.jpg",
"uploadUrl": "https://s3.example.com/supercommerce/assets/2026/5/k3jr8x2lp9a.jpg?X-Amz-Signature=...",
"method": "PUT",
"expiresIn": 300,
"headers": {
"Content-Type": "image/jpeg"
}
},
"message": "Presigned URL generated successfully"
}| Field | Type | Meaning |
|---|---|---|
key | string | The S3 object key the file will live at. Path pattern: assets/<year>/<month>/<random-hash><ext> |
url | string | Same as key for v1 — both fields are returned for client compatibility. The public URL is composed downstream from S3_PUBLIC_URL + key |
uploadUrl | string | The presigned PUT URL the client uploads the file body to directly |
method | "PUT" | HTTP method to use against uploadUrl |
expiresIn | number | Seconds until the presigned URL expires (default 300, i.e. 5 minutes) |
headers | { "Content-Type": string } | Must be sent on the upload request — the signature is bound to this header |
Side effects
None server-side at presign time. The S3 PutObject happens client-side directly against S3; no record is written. Persistence of the resulting key onto a domain entity (product image row, return photo row, etc.) happens on the downstream domain mutation that the client makes after a successful upload.
Idempotency / retry handling
Every call mints a fresh key — the random hash means even identical request bodies produce different keys. There is no dedup or upsert semantics at the storage layer; clients that retry the presign + upload will leave orphan objects on S3 if they don't reuse the first key.
The presigned PUT is single-shot but the URL itself is replayable until expiresIn lapses; S3 will overwrite the object on a repeat PUT to the same key (i.e. the upload step is idempotent on key, not on call).
Failure modes
| Status | Code | When |
|---|---|---|
| 400 | VALIDATION_ERROR | Body fails zod (fileName missing or empty) |
| 401 | UNAUTHORIZED | No active session — BetterAuthGuard rejected |
S3-side errors at upload time (signature mismatch, expiry, content-type mismatch) surface to the client directly from S3 and are not proxied through this endpoint.
Related
- /Users/ashik/Codes/superlabs/supercommerce/docs/catalog.md — product image upload flow;
productImage.urlstores thekeyreturned here. - /Users/ashik/Codes/superlabs/supercommerce/docs/banner.md — banner image uploads.
- /Users/ashik/Codes/superlabs/supercommerce/docs/order.md — return-evidence photo uploads use
StorageServicedirectly (no separate HTTP endpoint).
Webhook — ClickPost (Shipping)
Public HTTP surface that ClickPost's servers call to deliver shipment tracking events. The route is vendor-scoped (/:vendorId) so each vendor configures their own ClickPost…
Beautybarn Data Migration
How the @sc/seeder one-shot migrator moves customers, orders, rewards, reviews and wishlists from the legacy beautybarn (Prisma/Postgres) database into supercommerce, including the same-email customer merge, password preservation, money scaling and the order-status crosswalk.