Concepts
Assets
Direct-to-storage uploads, asset states, cacheable delivery, and deletion rules.
Assets — images, PDFs, documents, audio, video — are uploaded directly to S3-compatible storage with presigned requests, verified by Myna, and delivered from a dedicated host with aggressive caching for published files. Like entries, assets are drafts until published, and their publication rides on the change set workflow.
Upload flow
Uploads never proxy file bytes through the API. Three steps, all requiring assets:write (see scopes):
# 1. Request an upload slot.
$ curl -X POST https://api.myna.sh/v1/projects/my-site/assets/uploads \
-H "Authorization: Bearer myna_sk_..." \
-d '{"filename":"hero.png","contentType":"image/png","byteSize":48211,"checksum":"9e107d9d..."}'
# → { uploadId, storageKey, method: "PUT", url, headers, expiresAt }
# 2. PUT the bytes to the presigned URL (valid 15 minutes).
$ curl -X PUT "<presigned url>" -H "content-type: image/png" --data-binary @hero.png
# 3. Complete — Myna HEADs the object and creates the asset.
$ curl -X POST https://api.myna.sh/v1/projects/my-site/assets/uploads/upl_01.../complete \
-H "Authorization: Bearer myna_sk_..."
On complete, Myna verifies the stored object with a HEAD request: byte size must match exactly, the content type must match, and the checksum is compared best-effort (a single-part PUT's S3/MinIO ETag equals the MD5 hex digest). Any mismatch fails with VALIDATION_FAILED and no asset is created. Successful completion yields an asset in the draft state. Pending uploads that are never completed expire after 24 hours. myna assets upload <path...> wraps all three steps — see CLI commands.
Storage keys are immutable and never derived solely from user filenames. Assets with identical checksums may be deduplicated within a project.
Finding duplicates
A checksum answers "are these the same bytes?", which is not the duplicate a media library accumulates. The same photograph exported again at another quality, or resized for a hero slot, is new bytes and an old picture.
Every uploaded or replaced image therefore also gets a perceptualHash: a 64-bit difference hash, 16 hex characters, computed from a 9×8 greyscale reduction of the image after its EXIF rotation is applied. Re-encoding, rescaling, and mild recompression leave it alone.
$ myna assets similar ast_01...
ID FILENAME DISTANCE STATE
ast_01H... hero-2400.png 1 published
GET /v1/projects/:project/assets/:asset/similar returns { similar: [{ asset, distance }] }, ordered nearest first. distance is the Hamming distance in bits out of 64: 0–2 is the same image re-encoded, and by 10 distinct photographs of the same subject start appearing. ?maxDistance= defaults to 5 and is capped at 16; ?limit= pages the result. Reading it needs assets:read.
myna assets upload prints any match it finds, and myna_upload_asset returns them in similar, because the moment to notice a duplicate is before it is referenced. Filter by exact checksum on GET /assets when you want byte-identical matches instead.
Two boundaries worth knowing: the fingerprint covers images Myna can decode (JPEG, PNG, WebP, AVIF) and is null for everything else, and it is computed at upload, so assets that predate it never match. Re-upload or myna assets replace gives an older asset a fingerprint.
Blocked types
Executables and active documents are rejected at upload time by both content type and extension: html, htm, xhtml, shtml, svg, svgz, xml, xsl, xslt, js, mjs, cjs, php, phtml, exe, dll, so, dylib, bat, cmd, sh, com, msi, scr, jar, app. HTML and SVG are excluded because a browser executes script in them, and assets are served from a host you may put behind your own domain. Use raster formats for images.
Content types are compared after stripping parameters, so image/svg+xml; charset=utf-8 is refused exactly like image/svg+xml. The check runs twice — once when the upload slot is issued against the declared type, and again on completion against the type the stored object actually carries.
Delivery safety
Delivery is governed by a separate, narrower allowlist. Images, audio, video, PDF, plain text, CSV, Markdown, and web fonts are served inline under their own content type. Anything else is served as application/octet-stream with Content-Disposition: attachment, so an unusual file can still be stored and downloaded but can never render as a document. Every asset response also carries X-Content-Type-Options: nosniff and Content-Security-Policy: default-src 'none'; sandbox, which keeps even an inline PDF from reaching the origin it was served from.
States
| State | Meaning |
|---|---|
pending |
Upload slot issued; bytes not yet verified |
draft |
Verified and stored; not publicly delivered |
published |
Publicly delivered and cacheable |
quarantined |
Withdrawn from public delivery; treated like a draft (credential required) |
deleted |
Soft-deleted; not delivered |
An asset becomes published when a change set containing it as an item publishes, or automatically when a publishing entry's revision references it — referenced pending/draft assets are promoted in the same transaction as the entry pointers.
A reference is any ast_ id in the entry's data, whether it is the whole value of an asset field or embedded in a longer string. The second case is what a rich-text or markdown body holds: an image in prose is stored as a delivery URL, and the id is a substring of it. Both count — for auto-publishing here, for the altText collection policy, for orphan detection, and for the deletion block below. An image on a published page is never an orphan and never silently left in draft.
Delivery and caching
Every asset has one canonical URL:
https://api.myna.sh/assets/:project/:asset/:filename
:project accepts the project id or slug; :filename is the display filename — it is cosmetic, because delivery resolves on project and asset id, so a URL built from an id alone works. Read the exact URL from an asset's url field, or build one with images.url; do not assemble it by hand.
Delivery is a path on the API rather than a host of its own. A dedicated host is a supported deployment shape — point one at the API and it serves delivery at its root, without the /assets prefix — but myna.sh does not run one, so https://api.myna.sh/assets is the base to use and the SDK's default. Delivery semantics depend on state:
- Published assets send
cache-control: public, max-age=31536000, immutable, anETagderived from the checksum (If-None-Match→304),accept-ranges: byteswith byte-range requests answered206,content-disposition: inline, andx-content-type-options: nosniff. Delivered bandwidth is metered per organization. - Draft (and quarantined) assets return
401unless the request carries a project-bound preview token (as a bearer token or?token=query parameter) or a management credential withassets:read. Draft responses sendcache-control: private, no-store.
Image transformations
Image assets can be resized and re-encoded on delivery, so a page requests the size it is about to render instead of the full-resolution original:
| Parameter | Meaning |
|---|---|
w |
Target width in CSS pixels. Snapped up to the next supported width. |
format |
auto (default), webp, avif, jpeg, or png. auto negotiates from the browser's Accept header. |
q |
Quality. Snapped up to the next supported step: 50, 65, 80, 95. Defaults to 80. |
https://api.myna.sh/assets/my-site/ast_01j9.../hero.png?w=640
https://api.myna.sh/assets/my-site/ast_01j9.../hero.png?w=1200&format=webp&q=70
Supported widths are 16, 32, 48, 64, 96, 128, 256, 384, 640, 750, 828, 1080, 1200, 1920, 2048, 3840. A request for 500 is served at 640 — the parameter space is deliberately closed so an asset can only ever have a small, fixed number of derivatives: 16 widths × 4 formats × 4 quality steps, and no more. Images are never upscaled: asking for more pixels than the original has returns the original's size.
Rendering a derivative that is not yet cached is rate-limited per client address; serving one that is cached is not. A page requesting a normal set of sizes never notices, but walking the parameter space to force re-encodes answers 429.
Transformations apply to image/jpeg, image/png, image/webp, and image/avif. Other content types, including SVG (which is refused at upload), are served unchanged. An unsupported format or a non-numeric w answers 400.
Each derivative is rendered once and written back to object storage, so the second request for the same URL is a read. Derivatives carry their own ETag and, because format=auto varies per browser, Vary: accept. Draft assets are transformable too, under the same preview-token gate as the original.
The SDK builds these URLs for you:
myna.images.url(post.fields.cover, { width: 640 });
myna.images.srcSet(post.fields.cover, [640, 1200, 1920]);
Intrinsic pixel dimensions are recorded at upload and returned as width and height on the asset, so a page can reserve layout space before the image loads.
Limits
Per-file upload size and total storage are plan entitlements — 50 MB per file and 1 GB storage on Free, 500 MB and 25 GB on Pro; see plans. When an organization exceeds its storage allowance, new uploads are blocked with PLAN_LIMIT_REACHED (HTTP 402) but existing published assets keep serving — public sites fail conservatively.
Deletion
DELETE /v1/projects/:project/assets/:asset (scope assets:delete) is soft: the asset stops being delivered but is recoverable for 30 days. Deletion is blocked while any current draft or published entry revision references the asset — the request fails with CONFLICT listing the referencing entryIds (see error codes). Check references first with GET /v1/projects/:project/assets/:asset?usage=true. Staging an asset delete item on a change set defers the deletion until the change set publishes.
Metadata and alt text
myna assets update <id> --alt "…" and PATCH /v1/projects/:project/assets/:asset edit displayFilename (the delivery filename; originalFilename is preserved), caption, defaultAlt, and tags. defaultAlt is the asset's project-wide default alt text; individual usages can override it — an entry's asset field value may carry its own usage-level alt text, which takes precedence over the default when rendering that entry. Set a defaultAlt on upload-adjacent workflows so every future usage has a sensible fallback. A collection that declares policy.altText() refuses to publish an entry whose images lack one — see collection policy.
Organization and cleanup
- Tags — lightweight labels on each asset (
tags: ["brand", "hero"]viaPATCH); filter listings withGET /v1/projects/:project/assets?tag=brand. - Duplicate detection — every asset stores a
checksum;?checksum=<md5>finds existing copies before uploading the same file again. - Orphan cleanup —
?orphaned=truelists assets no current draft or published entry revision references, ready for review and deletion. An image embedded in a body counts as referenced, so a picture a page displays never appears here.
Replacing an asset
myna assets replace <id> <file> --confirm-replace, or POST /v1/projects/:project/assets/:asset/replace with {"uploadId": "upl_..."}, swaps the asset's binary for a newly uploaded one (same two-step upload flow, but finish with replace instead of complete). The asset keeps its identity: the ast_ id, alt text, caption, tags, state, and every entry reference stay intact, and the delivery URL now serves the new file.
