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.
Content stores
Section titled “Content stores”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 tenantEach 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.
Storage modes
Section titled “Storage modes”| Mode | Bytes | Metadata | Index | Suits |
|---|---|---|---|---|
s3 / azure | object storage | sidecar in the bucket | database | production behind a CDN |
fs + database | local or mounted disk | sidecar on disk | database | self-hosted at scale |
fs only | local or mounted disk | sidecar on disk | in memory, built by walking at boot | small 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.
Uploading
Section titled “Uploading”POST /store/:contentstore/assetContent-Type: multipart/form-dataThe file goes in the file part. Everything else is a query parameter.
| Parameter | Meaning |
|---|---|
path | Folder path to file the asset under, such as /images/2026 |
parentfolderid | Folder by id, as an alternative to path |
createfolders | Create missing folders along path. Optional |
filename | Override the uploaded file’s name |
overwrite | Replace, or add a version when versioning is on |
locale | Required when the store has locales enabled |
description | Stored on the sidecar |
cacheexpiry | Per-asset cache policy |
transform | Apply a transform on the way in |
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.
Versioning
Section titled “Versioning”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=.
curl -s "${H[@]}" -X PUT "$BASE/store/default/asset?id=01J9X…" -F "file=@./hero-v2.jpg"Reading
Section titled “Reading”Three addressing forms, all equivalent:
GET /store/:contentstore/asset/id/:idGET /store/:contentstore/asset/path/*pathGET /store/:contentstore/asset?id=… or ?path=…| Parameter | Meaning |
|---|---|
version | Pin a version. Omitted reads the current one |
locale | Required when the store has locales enabled |
transform | A native transform expression |
w, h, fit, fm, f, format | Image parameters. See Image transforms |
metadata=true | Return the asset’s metadata as JSON instead of its bytes |
download | Ask 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.
HTTP semantics
Section titled “HTTP semantics”Asset reads implement the caching and streaming semantics a browser and a CDN expect.
| Behaviour | Detail |
|---|---|
| Range requests | Accept-Ranges: bytes, 206 Partial Content, Content-Range, multipart ranges |
| Conditional requests | ETag with If-None-Match, Last-Modified with If-Modified-Since, answering 304 |
| ETag | The stored content hash, quoted as RFC 9110 requires. Weak comparison on If-None-Match |
| Cache-Control | A first-class per-store policy, so a CDN can do its job |
| Unsatisfiable byte range | 416 Range Not Satisfiable |
| Unrecognised range unit | Ignored, 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.
curl -s -D- -o/dev/null "${H[@]}" -H 'Range: bytes=0-1023' \ "$BASE/store/default/asset/id/01J9X…"HTTP/1.1 206 Partial ContentAccept-Ranges: bytesContent-Range: bytes 0-1023/8482304ETag: "9f86d081884c7d65…"Cache-Control: public, max-age=3600Presigned redirects
Section titled “Presigned redirects”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.
Folders
Section titled “Folders”POST /store/:contentstore/folder createGET /store/:contentstore/list list a folderPUT /store/:contentstore/folder update, rename or moveDELETE /store/:contentstore/folder deleteAccess rules
Section titled “Access rules”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:
- the asset’s
meta.access - its folder’s
meta.access - 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.
Bindings
Section titled “Bindings”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.
| Binding | Holds |
|---|---|
env.asset | The asset record, including meta.* |
env.folder | The folder record |
env.params | Operation 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.
See also
Section titled “See also”- Image transforms: derivatives, and the URL vocabularies accepted
- Operations: configuring stores and their backends
- API: every route and parameter