Assets Module — Admin
HTTP surface for the operator's media library — upload any file once, catalogue it, and hand out the public link that the storefront's asset host serves.
HTTP surface for the media library: the operator uploads images, video, audio and documents once, and the library hands back a public link on the same asset host the storefront already serves from. That link is what goes into content pages, email templates, notification payloads, banners — anywhere a URL is wanted.
Source:
api-modules/asset/src/controllers/admin-asset.controller.ts.Bytes never pass through the API. The browser asks for a presigned
PUT, sends the file straight to storage, then registers the resulting key. The row is a catalogue entry over that object, and it doubles as the upload history — a removed or purged entry is never deleted, so who uploaded what and when stays auditable.
Conventions
Authentication
All endpoints require a Better-Auth admin session and a role granting the matching asset:* permission.
| Endpoint group | Permission |
|---|---|
GET /admin/assets, GET /admin/assets/:id, GET /admin/assets/stats, GET /admin/assets/folders | asset: read |
POST /admin/assets/upload-url, POST /admin/assets | asset: create |
PUT /admin/assets/:id, POST /admin/assets/:id/restore | asset: update |
DELETE /admin/assets/:id (with or without purge) | asset: delete |
Response envelope
Successful responses are wrapped by ResponseInterceptor:
{
"data": <payload>,
"message": "Success",
"statusCode": 200,
"metadata": { /* optional, e.g. pagination */ }
}Error envelope
{ "message": "…", "statusCode": 400, "errorCode": "…" }Upload flow
Two calls, plus one direct-to-storage PUT the API never sees.
POST /admin/assets/upload-url— mints a presignedPUT. The declaredcontentLengthis signed into the URL, so a body of any other size is rejected by storage; the content type is checked against the library allowlist.PUT <uploadUrl>— the browser sends the bytes with the returnedheadersverbatim.POST /admin/assets— registers the key. The serviceHEADs the object first, so a failed upload can never leave a library entry whose public link 404s.
Accepted content types
Images (jpeg, png, webp, gif, avif, bmp, tiff, x-icon), video (mp4, webm, quicktime), audio (mpeg, mp4, ogg, wav), documents (pdf, csv, plain, json, zip, the Office/OpenXML family) and web fonts (woff, woff2, ttf, otf).
text/html, image/svg+xml and every script type are rejected. Anything a browser will execute, served from the asset host, is stored XSS — the library is wider than the generic POST /storage/presigned allowlist but held to that same rule.
Size is bounded by STORAGE_MAX_UPLOAD_MB (default 25 MB).
Endpoints
POST /admin/assets/upload-url
Permission: asset: create.
| Field | Type | Notes |
|---|---|---|
fileName | string | Original name; its extension shapes the generated key |
contentType | string? | Falls back to a lookup on fileName |
contentLength | int | The file's exact byte size — signed into the PUT |
{
"data": {
"key": "library/2026/9/6f1f…-a2.png",
"uploadUrl": "https://…",
"method": "PUT",
"expiresIn": 300,
"headers": { "Content-Type": "image/png", "Content-Length": "48213" },
"publicUrl": "https://assets.example.com/library/2026/9/6f1f…-a2.png"
}
}Keys live under library/<year>/<month>/<uuid><ext> — deliberately not the generic endpoint's per-uploader assets/<userId>/… scope, because library objects outlive the staff account that uploaded them.
POST /admin/assets
Permission: asset: create. Registers an object already uploaded.
| Field | Type | Notes |
|---|---|---|
key | string | Must be a library/… key this API minted |
fileName | string | |
mimeType | string | Must be in the allowlist |
sizeBytes | int | |
title | string? | Defaults to fileName |
folder | string? | Flat operator label, not a storage path |
alt | string? | |
width, height | int? | Sent by the browser for images |
metadata | object? |
Returns 201 with the asset, including its url.
GET /admin/assets
Permission: asset: read. Paginated (buildPaginatedResponse → metadata: { total, limit, offset, hasMore }).
| Query | Notes |
|---|---|
searchValue | Matches both title and fileName |
kind | image | video | audio | document | other |
folder | Exact folder |
uploadedBy | Staff user id |
includeRemoved | Include removed + purged entries — this is what turns the list into the upload history |
sortBy | createdAt (default, desc), updatedAt, title, fileName, sizeBytes |
limit, offset | Standard querySchema pagination |
Each row:
{
"id": "…",
"key": "library/2026/9/6f1f…-a2.png",
"url": "https://assets.example.com/library/2026/9/6f1f…-a2.png",
"fileName": "hero-banner.png",
"title": "Homepage hero",
"mimeType": "image/png",
"kind": "image",
"sizeBytes": 48213,
"folder": "campaigns",
"alt": "Autumn sale hero",
"width": 1600,
"height": 600,
"status": "available",
"uploadedBy": { "id": "…", "name": "Priya", "email": "priya@example.com" },
"metadata": null,
"createdAt": "2026-09-21T09:12:44.000Z",
"updatedAt": "2026-09-21T09:12:44.000Z",
"deletedAt": null,
"purgedAt": null
}status is derived: available (listed, object served), deleted (hidden from the library, object still served so existing links live), purged (object gone from storage; the entry survives as history).
GET /admin/assets/stats
Permission: asset: read. { totalAssets, uploadedLast30Days } over the non-removed rows — two counts, deliberately nothing that has to scan every row's size.
GET /admin/assets/folders
Permission: asset: read. [{ folder, count }] for every folder in use — drives the library's folder filter and the upload dialog's autocomplete.
GET /admin/assets/:id
Permission: asset: read. 404 when unknown.
PUT /admin/assets/:id
Permission: asset: update. Accepts title, folder, alt, metadata only — the stored file is immutable once catalogued, so re-uploading is the way to change the bytes and every link already handed out keeps pointing at the same object. 400 on a purged asset.
DELETE /admin/assets/:id
Permission: asset: delete.
| Query | Effect |
|---|---|
| (none) | Removes the entry from the library. The object stays in storage, so links already pasted into a page or an email keep resolving. Reversible via restore. |
purge=true | Also deletes the object. Every link already handed out starts returning 404. Irreversible. |
Either way the row survives with deletedAt (and purgedAt) set, so the upload stays in the history.
POST /admin/assets/:id/restore
Permission: asset: update. Returns a removed entry to the library. 400 when the asset was purged — the bytes are gone, so re-upload instead.
Events
Emitted via EventEmitter2 after the write: asset.created, asset.updated, asset.deleted (carries purged), asset.restored.
Admin UI
Storefront → Assets, with two tabs:
- Library — the usable assets, with totals (count, uploads in the last 30 days), search, type + folder filters, one-click copy-link on every row, and an upload dialog that takes multiple files at once.
- Upload history — every upload ever made, newest first, including removed and purged ones, showing the uploader and the timestamp alongside the status.
Analytics Module — Admin surface
Admin-facing HTTP surface for precomputed platform-wide dashboard snapshots (GMV, orders, commission & payouts, vendor leaderboard, customers, inventory KPIs for today / 7d / 30d / all-time) and a live SSE platform paid-order feed.
Audit Module — Admin
Unified audit log capturing every domain event (attributed to the triggering actor via request-context propagation) plus eventless HTTP mutations, enriched (geo + device) and stored in ClickHouse via a dedicated event bus and a separately-deployable worker. Filterable, paginated admin read API.