Skip to content
Talk to our solutions team

Assets

The asset plane holds files and the metadata that describes them. It is separate from the content plane by design: a byte stream should not pay for a transactional stack, and the two are addressed differently, cached differently and authorized differently.

They meet at one seam. A content entry’s field of type file holds a reference to an asset, and the delivery path resolves it. See File fields.

A content store is a named place assets live, configured per tenant. A tenant may have several, and the same store can back more than one content space.

GET /contentstore/list stores available to this tenant

Each store is declared in the product’s content/stores/stores.yaml, with its versioning, its cache policy and its access rules. The tenant’s configuration says where its bytes live. See Operations.

ModeBytesMetadataIndexSuits
s3 / azureobject storagesidecar in the bucketdatabaseproduction behind a CDN
fs + databaselocal or mounted disksidecar on diskdatabaseself-hosted at scale
fs onlylocal or mounted disksidecar on diskin memory, built by walking at bootsmall and mid-size sites, local development, CI

Every stored object carries a metadata sidecar alongside it, and the sidecar is the source of truth. The store is self-describing: point the service at an existing bucket, container or directory and it works, with no import step.

The database is a rebuildable index. It exists to answer questions the store cannot, such as listing by tag or filtering by size, and it can be reconstructed by walking the store. Serving a known reference touches no database at all.

fs-only is a first-class mode rather than a degraded one. It is how Astro content collections and similar tools operate, and it makes the database an accelerator rather than a dependency. The in-memory index is bounded at 50,000 entries, both on the initial walk and on later growth; a store past that bound is refused with a message naming the upgrade path.

POST /store/:contentstore/asset
Content-Type: multipart/form-data

The file goes in the file part. Everything else is a query parameter.

ParameterMeaning
pathFolder path to file the asset under, such as /images/2026
parentfolderidFolder by id, as an alternative to path
createfoldersCreate missing folders along path. Optional
filenameOverride the uploaded file’s name
overwriteReplace, or add a version when versioning is on
localeRequired when the store has locales enabled
descriptionStored on the sidecar
cacheexpiryPer-asset cache policy
transformApply a transform on the way in
Terminal window
curl -s "${H[@]}" -X POST "$BASE/store/default/asset?path=/images&createfolders=true" \
-F "file=@./hero.jpg"

The folder path is recorded on the asset’s sidecar, so the folder tree is derived from where the assets are. A folder exists because something is in it, and a path resolves with nothing to query.

With versioning enabled on the store, re-uploading an asset adds a version rather than replacing the bytes. Blobs are keyed <locale>:<version>, and a read can pin one with ?version=.

Terminal window
curl -s "${H[@]}" -X PUT "$BASE/store/default/asset?id=01J9X…" -F "file=@./hero-v2.jpg"

Three addressing forms, all equivalent:

GET /store/:contentstore/asset/id/:id
GET /store/:contentstore/asset/path/*path
GET /store/:contentstore/asset?id=… or ?path=…
ParameterMeaning
versionPin a version. Omitted reads the current one
localeRequired when the store has locales enabled
transformA native transform expression
w, h, fit, fm, f, formatImage parameters. See Image transforms
metadata=trueReturn the asset’s metadata as JSON instead of its bytes
downloadAsk the store for a download disposition

Addressing by id is the fastest path. Path addressing resolves through a cached path-to-id map, dropped whole on any folder change, since a rename moves every descendant path.

Asset reads implement the caching and streaming semantics a browser and a CDN expect.

BehaviourDetail
Range requestsAccept-Ranges: bytes, 206 Partial Content, Content-Range, multipart ranges
Conditional requestsETag with If-None-Match, Last-Modified with If-Modified-Since, answering 304
ETagThe stored content hash, quoted as RFC 9110 requires. Weak comparison on If-None-Match
Cache-ControlA first-class per-store policy, so a CDN can do its job
Unsatisfiable byte range416 Range Not Satisfiable
Unrecognised range unitIgnored, and the full representation is served, per RFC 9110 §14.2

Range support is what makes audio and video work. Without it a <video> element cannot seek and must download the whole body before it plays.

Terminal window
curl -s -D- -o/dev/null "${H[@]}" -H 'Range: bytes=0-1023' \
"$BASE/store/default/asset/id/01J9X…"
HTTP/1.1 206 Partial Content
Accept-Ranges: bytes
Content-Range: bytes 0-1023/8482304
ETag: "9f86d081884c7d65…"
Cache-Control: public, max-age=3600

When the bytes live in object storage and no transform was requested, the service authorizes the read and then issues a redirect to a presigned URL. The store serves the client directly, so serving cost stops scaling with bytes served.

A filesystem store reports that it cannot presign, and the service serves the bytes itself with range and conditional support. That is the right path for that storage mode.

POST /store/:contentstore/folder create
GET /store/:contentstore/list list a folder
PUT /store/:contentstore/folder update, rename or move
DELETE /store/:contentstore/folder delete

Each store carries a rule per operation. The evaluated block is taken from the first of these that declares one, so a specific asset can carry policy the rest of the store does not:

  1. the asset’s meta.access
  2. its folder’s meta.access
  3. the store’s access: block

The store’s block sits on its entry in the product’s content/stores/stores.yaml:

contentstores:
- name: default
access:
createasset: { language: expr, rule: 'true' }
getasset: { language: expr, rule: 'true' }
getassets: { language: expr, rule: 'true' }
downloadasset: { language: expr, rule: 'env.asset.name != "QUARANTINED.txt"' }
updateasset: { language: expr, rule: 'true' }
updateassetmeta: { language: expr, rule: 'true' }
deleteasset: { language: expr, rule: 'true' }
renameasset: { language: expr, rule: 'true' }
createfolder: { language: expr, rule: 'true' }
getfolder: { language: expr, rule: 'true' }
listfolder: { language: expr, rule: 'true' }
updatefolder: { language: expr, rule: 'true' }
deletefolder: { language: expr, rule: 'true' }

Store rules use rule:, not expression:. The expression: spelling belongs to entity access rules, which are a different surface.

A store rule is evaluated against the asset and the folder, which is what makes it the right place for attribute policy such as quarantine flags, content types and folder placement.

BindingHolds
env.assetThe asset record, including meta.*
env.folderThe folder record
env.paramsOperation parameters, such as payload on a write

Identity-based authorization for content lives on the content type’s own access rules, which receive user.*, row.*, tenant, entity and action. See The content model.

The rule is evaluated on every read, including the fast path that answers from the sidecar without touching the database.

  • Image transforms: derivatives, and the URL vocabularies accepted
  • Operations: configuring stores and their backends
  • API: every route and parameter