Assets
Assets
A project's storefront exposes six routes that browse, upload and update assets in whichever media libraries its installed apps registered. One wire contract in front of any number of asset backends: the same call reaches Shopify Files, the Laioutr CDN, or a DAM you connected yourself, and the response shape does not change.
The routes are built for a client that is not yours to run — an internal tool, a partner's page, a desktop app, a migration script, an MCP server that sets media on a section. Access is authorized by a restricted key: bound to one project, limited to reading or writing assets, and revocable on its own.
Base URL
The routes live on the project's frontend deployment, not on api.laioutr.cloud. The base URL is the storefront's own URL, listed under Hosting in Cockpit.
POST https://shop.example.com/api/public/media/list
Every media route is POST, including the two that only read. Bodies and responses are JSON, except media/upload, which takes multipart/form-data. The one GET in the class is the OpenAPI document.
| Route | Purpose | Scope |
|---|---|---|
/api/public/media/libraries | List the registered libraries and their capabilities | media:read |
/api/public/media/list | Browse or search one library, one page at a time | media:read |
/api/public/media/upload-targets | Staged upload, phase 1: mint signed upload targets | media:write |
/api/public/media/finalize | Staged upload, phase 2: commit the uploaded refs | media:write |
/api/public/media/upload | Proxied upload: relay the bytes through the storefront | media:write |
/api/public/media/update-asset | Change an asset's name, alt text or tags | media:write |
A path under /api/public/ that maps to no scope answers 404, so a route can never be added without one.
Getting access
Two things must be true before a call succeeds: the project has opted the route class in by listing an allowed origin, and the caller holds a restricted key with the right scope.
Allow an origin. /api/public/* answers 404 on every path until the project lists at least one allowed origin under Allowed origins in project settings — nothing before that confirms the routes exist. An entry is normalized to scheme, host and port, so a pasted https://admin.example.com/media is stored as https://admin.example.com; anything that is not an http/https URL is refused, because it could never match an Origin header.
Mint a restricted key. Create one under Create key in API keys, choosing the Restricted key type and the project it should address. The full value is shown once. It carries only the scopes you tick — media:read to browse, media:write to upload, organise folders and edit assets, media:delete to delete assets and folders — and reaches /api/public/media/* on that one project and nothing else, so it can neither read the project's configuration nor deploy. An organization key presented here is refused, and a restricted key presented to any other route class is refused.
Authorization: Bearer rstk_<...>
laioutrrc, which is baked at build time. Adding an origin in Cockpit does not open it on the running deployment.CORS is the whole origin policy. Every route needs an Authorization header, which is not CORS-safelisted, so a cross-origin browser call always preflights — and a disallowed origin gets no Access-Control-Allow-Origin, so the browser sends neither the read nor the write.
How the gate behaves
- The project listed no allowed origin —
404, the whole class. - Preflight — answered from the project's origin list, with no credential.
- A path with no scope mapping —
404./api/public/openapi.jsonmaps to no scope on purpose and is served here, with no credential. - No bearer credential —
401. - The key is unknown, revoked, expired, or belongs to another project —
401. - The key is valid here but lacks the scope the path needs —
403. - Cockpit unreachable, slow or malformed —
503. The gate never fails open.
A caller that sends no Origin header is not a browser, so the origin list does not apply to it and the key alone authorizes the call. Use that for server-to-server access.
curl -X POST https://shop.example.com/api/public/media/list \
-H "Authorization: Bearer $LAIOUTR_RESTRICTED_KEY" \
-H "Content-Type: application/json" \
-d '{ "library": "@laioutr-app/cms", "limit": 24 }'
Before you ship one
- There is no rate limiting. Revocation is the whole control surface, which is why the key list shows last use and revokes in one click. Revoking takes effect on the next request; nothing is cached.
- Expiry is optional on purpose. A client with no backend cannot renew a key, so a forced expiry only schedules an outage. Set one when the client is short-lived.
- Treat the key as published from the moment it reaches a browser. Scope it to
media:readunless the client genuinely uploads. - Proxy through your own backend when you need per-user rules the scope model cannot express. Hold the key server-side and forward; the hop sends no
Origin, so no origin needs allowing.
OpenAPI specification
@laioutr-core/frontend-coreEvery storefront that has opted in serves an OpenAPI 3.1 description of the whole /api/public/ class:
curl https://shop.example.com/api/public/openapi.json
It needs no key, but it follows the same opt-in and origin list as the other routes: a project with no allowed origin answers 404 here too.
The document is generated when it is requested, from the same schemas the storefront validates requests with. It therefore describes the version that is deployed, and it grows as routes are added to the class. It includes each operation's request and response bodies, the scope the key needs, and the error statuses listed under Errors. Its server URL is /, so a tool that loads it from the storefront targets that storefront.
Use it to import the routes into Postman, Insomnia or Bruno, or to generate a typed client:
npx openapi-typescript https://shop.example.com/api/public/openapi.json -o laioutr-public-api.ts
media/libraries for the ones a deployment actually registers.Discover libraries
POST /api/public/media/libraries takes no body and returns one descriptor per registered library. Call it first: the descriptors say which fields the other routes will honour.
curl -X POST https://shop.example.com/api/public/media/libraries \
-H "Authorization: Bearer $LAIOUTR_RESTRICTED_KEY"
const libraries = await $fetch<MediaLibraryDescriptor[]>('/api/public/media/libraries', {
baseURL: 'https://shop.example.com',
method: 'POST',
headers: { Authorization: `Bearer ${restrictedKey}` },
});
[
{
"id": "@laioutr-app/cms",
"label": "Laioutr",
"iconSrc": "/app-cms/logo.png",
"capabilities": {
"search": true,
"tags": true,
"folders": true,
"createFolders": true,
"moveAssets": true,
"sorts": [
{ "key": "createdAt:desc", "label": "Newest" },
{ "key": "createdAt:asc", "label": "Oldest" },
{ "key": "name:asc", "label": "Name A–Z" },
{ "key": "fileSize:desc", "label": "Largest" }
],
"upload": {
"transfer": "staged",
"maxBatchSize": 20,
"maxFileSize": 104857600,
"accept": ["image/png", "image/jpeg", "image/webp", "image/avif", "image/gif", "image/svg+xml", "video/mp4", "audio/mpeg", "application/pdf", "application/zip"]
},
"deleteAssets": true,
"renameFolders": true,
"moveFolders": true,
"deleteFolders": true,
"updateAssets": { "name": true, "alt": true, "tags": true }
}
},
{
"id": "@laioutr-app/shopify",
"label": "Shopify",
"iconSrc": "/app-shopify/shopify-logo.svg",
"capabilities": {
"search": true,
"folders": false,
"sorts": [
{ "key": "CREATED_AT:desc", "label": "Newest first" },
{ "key": "CREATED_AT:asc", "label": "Oldest first" }
],
"upload": {
"transfer": "staged",
"maxBatchSize": 10,
"accept": ["image/*", "video/*"]
}
}
}
]
Capabilities drive the rest of the contract
Capabilities are static. A caller cannot push a connector outside its declared contract.
| Capability | What it unlocks |
|---|---|
search | term on media/list |
tags | tags on media/list |
folders | folderId and scope on media/list, plus folders in the response |
sorts | sorting on media/list, restricted to the declared key values |
upload | transfer picks the upload path; accept, maxFileSize and maxBatchSize are client hints |
createFolders | media/create-folder |
renameFolders | media/rename-folder |
moveFolders | media/move-folder |
deleteFolders | media/delete-folder, for an empty folder |
moveAssets | media/move-assets |
deleteAssets | media/delete-assets |
updateAssets | media/update-asset, for each field set to true: name, alt, tags |
A route answers 400 when the library does not implement its operation. The capability tells you the same in advance, so read the descriptor before you offer an action.
A sorting value that is not one of the declared keys is dropped rather than rejected, and the library falls back to its own default order.
What the shipped libraries declare
Capabilities differ per backend, so write against the descriptor rather than against a library you have tested by hand. What ships today:
| Laioutr CDN | Shopify | Shopware | |
|---|---|---|---|
id | @laioutr-app/cms | @laioutr-app/shopify | @laioutr/app-shopware |
search | ✅ | ✅ | ✅ |
tags | ✅ | ❌ | ✅ |
folders | ✅ | ❌ | ✅ |
sorts | date, name, size | date, name | date, name, size |
upload | ✅ staged | ✅ staged | ❌ |
maxBatchSize | 20 | 10 | n/a |
maxFileSize | per project, 100 MB by default | not declared | n/a |
accept | images, mp4, audio, documents, archives, PSD, glTF (Files) | image/*, video/* | n/a |
updateAssets | name, alt, tags | ❌ | ❌ |
| folder writes | create, rename, move, delete | ❌ | ❌ |
moveAssets | ✅ | ❌ | ❌ |
deleteAssets | ✅ | ❌ | ❌ |
Browse a library
POST /api/public/media/list returns one page of one location.
id from media/libraries. An unregistered id answers 404.1 and 100. There is no default; omitting it fails validation.nextCursor. Omit it for the first page. Pagination is always cursor-based, never offset-based.capabilities.search.['file'] returns only distribution files, and both together return both. Filtering happens in the connector, so an image-only request never pays for video results. A library without distribution files returns no items for ['file'].capabilities.tags.key values from capabilities.sorts.capabilities.folders.folder (the default) browses the location folderId selects. all ignores folderId and searches the whole library. The distinction matters on backends where the root holds only unfiled assets, so "root" and "everywhere" are different sets. Requires capabilities.folders.curl -X POST https://shop.example.com/api/public/media/list \
-H "Authorization: Bearer $LAIOUTR_RESTRICTED_KEY" \
-H "Content-Type: application/json" \
-d '{
"library": "@laioutr-app/cms",
"limit": 24,
"term": "hero",
"type": ["image"],
"sorting": "createdAt:desc"
}'
The response is a MediaListResult:
interface MediaListResult {
items: ProviderStudioLibraryItem[];
folders?: MediaFolder[]; // subfolders of the queried location, first page only
nextCursor?: string; // absent means end of results
total?: number; // optional; many backends cannot count cheaply
}
type ProviderStudioLibraryItem = ProviderStudioMediaItem | ProviderStudioFileItem;
interface ProviderStudioMediaItem {
kind?: 'media'; // absent also means media
media: Media; // the canonical Media object that gets stored on a prop
previewUrl?: string; // thumbnail for a picker grid; absent when there is none, e.g. audio
file?: LibraryFile; // the stored original
fileName?: string;
externalId?: string; // the backend's stable asset id
tags?: string[];
status?: 'ready' | 'processing' | 'failed';
statusMessage?: string;
}
interface ProviderStudioFileItem {
kind: 'file'; // a distribution file: no media, never stored on a media prop
file: LibraryFile;
fileName?: string;
externalId?: string;
tags?: string[];
status?: 'ready' | 'processing' | 'failed';
statusMessage?: string;
}
interface LibraryFile {
url: string; // the original; on the Laioutr CDN it ends in the asset's name
mimeType: string;
size?: number; // bytes
downloadUrl?: string; // makes the browser save the file instead of opening it
}
interface MediaFolder {
id: string; // pass back as folderId to browse into it
name: string;
parentId?: string;
childCount?: number;
}
{
"items": [
{
"media": {
"type": "image",
"sources": [
{
"provider": "laioutrCms",
"src": "https://p_2f9k4x.cdn.laioutr.cloud/d1m7q8v3.webp",
"width": 2400,
"height": 1350,
"origin": { "libraryId": "@laioutr-app/cms", "externalId": "d1m7q8v3" }
}
]
},
"previewUrl": "https://p_2f9k4x.cdn.laioutr.cloud/cdn-cgi/image/width=400,quality=80,format=auto/d1m7q8v3.webp",
"file": {
"url": "https://p_2f9k4x.cdn.laioutr.cloud/d1m7q8v3/hero-spring-campaign.webp",
"mimeType": "image/webp",
"size": 284120,
"downloadUrl": "https://p_2f9k4x.cdn.laioutr.cloud/d1m7q8v3/hero-spring-campaign.webp?download"
},
"kind": "media",
"fileName": "hero-spring-campaign.webp",
"externalId": "d1m7q8v3"
},
{
"kind": "file",
"file": {
"url": "https://p_2f9k4x.cdn.laioutr.cloud/k9w3f2pa/price-list.pdf",
"mimeType": "application/pdf",
"size": 96410,
"downloadUrl": "https://p_2f9k4x.cdn.laioutr.cloud/k9w3f2pa/price-list.pdf?download"
},
"fileName": "price-list.pdf",
"externalId": "k9w3f2pa"
}
],
"folders": [{ "id": "fld_campaigns", "name": "Campaigns", "childCount": 4 }],
"nextCursor": "eyJzb3J0VmFsdWUiOjE3NTY5MDA4MDAsImlkIjoiZDFtN3E4djMifQ",
"total": 312
}
Four things about that shape catch callers out:
- Check the kind before you read
media. A file item has nomediaand nopreviewUrl. Sendtypewith media kinds when you only want something to store on a media prop. foldersrides the first page only. A request that carries acursorcontinues the asset stream and returns nofolders. Build the folder tree from cursorless requests.totalis optional. Do not build a pager that needs a page count. FollownextCursoruntil it is absent.- An absent
statusmeans ready. Backends that transcode reportprocessingwhile they work andfailedwhen they give up, with an optionalstatusMessage. Afaileditem is listed but is not safe to store on a prop.
Staged upload
In staged mode the bytes go straight from the client to the asset backend, and the storefront only signs and records. Libraries that declare capabilities.upload.transfer: 'staged' take this path.
Phase 1: mint targets
POST /api/public/media/upload-targets takes file metadata only. No bytes cross this route.
id. The library must expose staged uploads, or the route answers 400.{ name, mimeType, size }. Validate against the library's accept, maxFileSize and maxBatchSize first; a backend that rejects the batch will do so here, not politely.curl -X POST https://shop.example.com/api/public/media/upload-targets \
-H "Authorization: Bearer $LAIOUTR_RESTRICTED_KEY" \
-H "Content-Type: application/json" \
-d '{
"library": "@laioutr-app/cms",
"files": [{ "name": "lookbook-01.jpg", "mimeType": "image/jpeg", "size": 1843200 }]
}'
The response is one UploadTarget per file, in the same order:
interface UploadTarget {
ref: string; // opaque token; hand it back to media/finalize
url: string;
method?: 'PUT' | 'POST'; // defaults to PUT
fields?: Record<string, string>; // form fields for S3/GCS-style POST targets
headers?: Record<string, string>; // send these verbatim with the bytes
}
Send the bytes exactly as the target describes:
- Set every entry of
headerson the request, verbatim. - With no
fields, send the raw file as the body (PUTunlessmethodsays otherwise). - With
fields, build amultipart/form-databody, append every field first, and append the file last under the namefile. S3 and GCS POST policies reject a body whose file part arrives before the policy fields.
Backends pin Content-Type and Content-Length into the signature, so a request that deviates is rejected, and the failure surfaces at finalize as a missing object rather than at upload time.
PUT from the calling origin fails in the browser with an opaque error while every server-side log looks healthy.Phase 2: finalize
POST /api/public/media/finalize commits the refs whose bytes landed and returns finished items. Connectors absorb backend processing here, so a returned item is ready to store on a prop.
id, same as phase 1.ref from the targets you actually uploaded. Skip the ones that failed in transit.curl -X POST https://shop.example.com/api/public/media/finalize \
-H "Authorization: Bearer $LAIOUTR_RESTRICTED_KEY" \
-H "Content-Type: application/json" \
-d '{ "library": "@laioutr-app/cms", "refs": ["r_8kd2mq1f"] }'
Proxied upload
In proxied mode the bytes travel through the storefront, which hands them to the connector. Libraries declaring transfer: 'proxied' take this path; it suits small files and backends with no signed-upload support.
POST /api/public/media/upload is the one route that takes multipart/form-data. Send a library text field plus one or more file parts. The field name of a file part is ignored, so any name works.
curl -X POST https://shop.example.com/api/public/media/upload \
-H "Authorization: Bearer $LAIOUTR_RESTRICTED_KEY" \
-F "library=@laioutr-app/acme-dam" \
-F "file=@./lookbook-01.jpg"
The response is the same UploadOutcome[] finalize returns.
Upload outcomes
Both upload paths return per-file results, so one bad file never sinks the batch. A request whose files all failed is still 200.
type UploadOutcome =
| { ref: string; ok: true; item: ProviderStudioLibraryItem }
| { ref: string; ok: false; error: { code: UploadErrorCode; message: string } };
type UploadErrorCode = 'too_large' | 'unsupported_type' | 'processing_failed' | 'upload_failed';
[
{
"ref": "r_8kd2mq1f",
"ok": true,
"item": {
"media": {
"type": "image",
"sources": [
{
"provider": "laioutrCms",
"src": "https://p_2f9k4x.cdn.laioutr.cloud/h5r2n9wc.jpg",
"width": 1600,
"height": 2400,
"origin": { "libraryId": "@laioutr-app/cms", "externalId": "h5r2n9wc" }
}
]
},
"previewUrl": "https://p_2f9k4x.cdn.laioutr.cloud/cdn-cgi/image/width=400,quality=80,format=auto/h5r2n9wc.jpg",
"fileName": "lookbook-01.jpg",
"externalId": "h5r2n9wc"
}
},
{
"ref": "r_p3xw07ba",
"ok": false,
"error": { "code": "too_large", "message": "File exceeds the 100 MB limit" }
}
]
An item that fails the canonical Media parse on the way out is converted into an upload_failed outcome rather than returned, so a successful media outcome always carries a storable media. An uploaded distribution file comes back as a file item. On the Laioutr CDN, content that is an executable or an installer is refused with unsupported_type, whatever type it was declared as; see Files.
Update an asset
POST /api/public/media/update-asset changes the name, the alt text or the tags of one asset, and returns the stored item. Only the fields you send change. The library must declare each field you send in capabilities.updateAssets.
id.externalId.null or an empty string clears it.[] clears it.Send at least one of name, alt and tags. No value may contain a NUL character.
curl -X POST https://shop.example.com/api/public/media/update-asset \
-H "Authorization: Bearer $LAIOUTR_RESTRICTED_KEY" \
-H "Content-Type: application/json" \
-d '{ "library": "@laioutr-app/cms", "assetId": "d1m7q8v3", "alt": "Spring campaign, three jackets on a rail" }'
The response is the stored ProviderStudioLibraryItem, with the values as the library saved them after trimming and removing duplicates.
Organise folders
Four routes create, rename, move and delete folders in a library that declares capabilities.folders. Each one needs its own capability as well, and the media:write scope; deleting needs media:delete.
A rename or a move never changes the delivery URL of an asset inside the folder, so a URL already stored on a prop keeps working. A library that would have to change one does not offer the operation.
A folder name is trimmed and must be 1 to 255 characters, with no NUL character. Two folders in the same parent cannot share a name.
Create a folder
POST /api/public/media/create-folder creates a folder and returns it. Requires capabilities.createFolders.
id.id of the folder to create it in. Omit it for the root.curl -X POST https://shop.example.com/api/public/media/create-folder \
-H "Authorization: Bearer $LAIOUTR_RESTRICTED_KEY" \
-H "Content-Type: application/json" \
-d '{ "library": "@laioutr-app/cms", "name": "Spring 2027", "parentId": "fld_campaigns" }'
The response is the new MediaFolder:
{ "id": "fld_spring27", "name": "Spring 2027", "parentId": "fld_campaigns" }
The library refuses with name_taken (409) when the parent already holds a folder of that name, and with invalid_target (422) when parentId names no folder.
Rename a folder
POST /api/public/media/rename-folder gives a folder a new name and returns it. Requires capabilities.renameFolders.
id.curl -X POST https://shop.example.com/api/public/media/rename-folder \
-H "Authorization: Bearer $LAIOUTR_RESTRICTED_KEY" \
-H "Content-Type: application/json" \
-d '{ "library": "@laioutr-app/cms", "folderId": "fld_spring27", "name": "Spring 2027 campaign" }'
The response is the renamed MediaFolder. The library refuses with not_found (404) for an unknown folder, and with name_taken (409) when a sibling already has the name. A new spelling of the folder's own name is no conflict.
Move a folder
POST /api/public/media/move-folder moves a folder, with everything in it, under another folder or to the root, and returns it. Requires capabilities.moveFolders.
id.null moves the folder to the root; the field itself is required.curl -X POST https://shop.example.com/api/public/media/move-folder \
-H "Authorization: Bearer $LAIOUTR_RESTRICTED_KEY" \
-H "Content-Type: application/json" \
-d '{ "library": "@laioutr-app/cms", "folderId": "fld_spring27", "parentId": null }'
The response is the moved MediaFolder. The library refuses with:
not_found(404) for an unknown folder;invalid_target(422) whenparentIdnames no folder, or names the folder itself or one of its subfolders;name_taken(409) when the new parent already holds a folder of that name.
Delete a folder
POST /api/public/media/delete-folder deletes an empty folder. Requires capabilities.deleteFolders and the media:delete scope.
id.curl -X POST https://shop.example.com/api/public/media/delete-folder \
-H "Authorization: Bearer $LAIOUTR_RESTRICTED_KEY" \
-H "Content-Type: application/json" \
-d '{ "library": "@laioutr-app/cms", "folderId": "fld_spring27" }'
The response is { "ok": true }. The route never deletes contents: a folder that holds an asset or a subfolder is refused with not_empty (409). Move or delete what is inside first, then delete the folder. An unknown folder is refused with not_found (404).
Move assets
POST /api/public/media/move-assets moves one or more assets into a folder, or to the root. Requires capabilities.moveAssets and the media:write scope.
id.externalId.null moves the assets to the root; the field itself is required.curl -X POST https://shop.example.com/api/public/media/move-assets \
-H "Authorization: Bearer $LAIOUTR_RESTRICTED_KEY" \
-H "Content-Type: application/json" \
-d '{ "library": "@laioutr-app/cms", "assetIds": ["d1m7q8v3", "h5r2n9wc"], "folderId": "fld_spring27" }'
The response is { "ok": true }. A move never changes an asset's delivery URL. A library that can only tell at run time that a move would change one refuses with url_change (409). An unknown target folder is refused with invalid_target (422).
On the Laioutr CDN, an id that matches no asset in the project is skipped, not refused, and the response does not count what moved. List the target folder to confirm the result.
Delete assets
POST /api/public/media/delete-assets deletes one or more assets. Requires capabilities.deleteAssets and the media:delete scope.
id.externalId.curl -X POST https://shop.example.com/api/public/media/delete-assets \
-H "Authorization: Bearer $LAIOUTR_RESTRICTED_KEY" \
-H "Content-Type: application/json" \
-d '{ "library": "@laioutr-app/cms", "assetIds": ["h5r2n9wc"] }'
The response is { "ok": true }. On the Laioutr CDN the asset leaves the library first and its stored file is removed after that. An id that matches no asset in the project is skipped, not refused.
Errors
The gate answers first, before any request body is read:
| Status | statusMessage | Cause | Resolution |
|---|---|---|---|
401 | Unauthorized | No bearer credential, or a key that is unknown, revoked, expired or another project's | Mint a key for this project; a denial never says whether the key exists elsewhere |
403 | Forbidden | The key is valid here but lacks the scope the path requires | Add the scope the path needs: media:write, or media:delete for media/delete-assets and media/delete-folder |
404 | Not Found | The project has not listed an allowed origin, or the path maps to no scope | Add an origin in project settings and redeploy; check the path against the route table |
503 | Service Unavailable | Cockpit could not be reached, answered slowly, or the storefront's own configuration is incomplete | Transient — retry. If it persists, the storefront's configuration is the problem, not your key |
Past the gate, failures arrive as H3 error bodies: { statusCode, statusMessage, message, data? }.
| Status | statusMessage | Cause | Resolution |
|---|---|---|---|
400 | Validation Error | The body failed schema validation, for example a missing or out-of-range limit | Read data for the Zod issue list; it names the offending path |
400 | Provider does not support staged uploads | media/upload-targets or media/finalize against a library without them | Check capabilities.upload.transfer and use media/upload instead |
400 | Provider does not support proxied uploads | media/upload against a staged-only library | Use the media/upload-targets and media/finalize pair |
400 | Provider does not support <operation> | A folder, move, delete or update route against a library without that operation, for example Provider does not support deleting folders | Check the matching capability before you call the route |
400 | Provider cannot update the field: <field> | media/update-asset sends a field the library does not declare | Send only the fields set to true in capabilities.updateAssets |
404 | Unknown media library: <id> | library names no registered library | Call media/libraries; the app may not be installed on this deployment |
403, 404, 409, 422 | Media Library Error | The library refused the request; see the codes below | data.code names the refusal; data.message is written for an editor |
502 | Media Library Error | The connector threw | data.library and data.message carry the library id and the connector's message |
A refusal carries one of these codes in data.code, and the status follows from the code:
data.code | Status | Means |
|---|---|---|
not_found | 404 | The asset or folder does not exist in this library |
not_empty | 409 | The folder holds an asset or a subfolder |
name_taken | 409 | The parent already holds a folder of that name |
url_change | 409 | The operation would change a delivery URL, so the library refuses it |
invalid_target | 422 | The target folder does not exist, or a folder would move into itself or a subfolder |
not_enabled | 403 | The project has not enabled what the request needs, for example video uploads on the CDN |
The 502 exists so a connector failure stays legible. Without it, a raw throw would be masked as an opaque 500 in production and leave the caller with nothing to display.
Related
Changelog
Storefronts that enable the public API now serve an OpenAPI 3.1 description of it at GET /api/public/openapi.json, generated from the schemas the routes validate with. It needs no key but follows the project's opt-in and origin list. See OpenAPI specification.
A media library connector is now declared with defineX.mediaLibrary(...) on the app's Orchestr builder instead of the standalone defineMediaLibraryProvider factory. Identity comes from the builder's .meta(); the connector declares static capabilities and its handlers receive the app's per-request ctx. Browsing moves to opaque-cursor pagination with folders, an explicit type filter, and per-file upload results (proxied or staged).
Show migration
Move the connector onto your app's Orchestr builder. Identity moves from
the provider's name/label/iconSrc to the builder's .meta(),
pagination moves from offset/limit to an opaque cursor, and list
returns a MediaListResult and takes ctx as a second argument.
// Before
export default defineMediaLibraryProvider({
name: 'my-dam',
label: 'My DAM',
list: async ({ offset, limit, search, sort }) => ({ items, total, offset, limit }),
});
// After
export default defineMyDam.mediaLibrary({
capabilities: { search: true, folders: false, sorts: [] },
list: async (query, ctx) => ({ items, nextCursor, total }),
});
defineMediaLibraryProvider(...) is deprecated in favour of defineX.mediaLibrary(...). It keeps working as a compatibility shim, in a degraded mode (no folders, no staged upload, no declared sorts, no server-side type filtering), and logs a one-time warning at registration.