Primitive spec

Media

The media-store primitive. Media holds the files and images everything else points at — product photos, site assets, the PDF that goes out for signature — uploaded straight to storage with a short-lived signed URL, served from a CDN, and referenced by opaque id so no other primitive ever proxies a byte.

Status: Live and deployed. Objects are private at rest behind a CDN that alone can read them; uploads go direct to storage over a signed PUT, and the Demerzel media library browses, copies URLs, and deletes. Image processing (thumbnails, transforms) and multipart upload are the next slices.

What it owns

Media owns one thing: a tenant's blobs and where they live. A MediaAsset row is the durable handle — the storage key, content type, size, visibility, and status — and the bytes themselves never flow through the application. The browser uploads them straight to storage with a short-lived signed URL, and reads come back through a CDN.

Media does not own what an asset means. A product photo's link to a Hardin variant, a signed PDF's link to a Sign envelope, a hero image on a Radiant ui_view — those references live in the owning primitive, which holds only the opaque med_* id.

Concepts

MediaAsset
The durable handle for one stored file. Carries filename, contentType (validated against an allow-list), sizeBytes, a derived storageKey ({tenant}/{assetId}/{filename}), an optional checksum, image widthPx / heightPx, and free-form metadata (alt text, caption). Soft-deletable; referenced elsewhere only by its med_* id.
Presigned upload
Three steps, no byte ever touching the app. The client asks for a ticket (POST /media/v1/uploads) and gets back a signed PUT URL (15-minute TTL) against a fresh PENDING asset; the browser uploads directly to storage; then finalize flips the asset to READY. The application is a stateless broker of signatures, not a file proxy.
Visibility
PUBLIC assets serve from a stable CDN URL (the underlying object stays private, readable only by the CDN); PRIVATE assets are never exposed on a stable URL — each read mints a short-lived signed GET. The default is PRIVATE.
Status
PENDING (row created, presigned URL handed out, upload not yet confirmed) → READY (finalized). A later slice will reconcile the stored object itself (size + checksum) rather than trusting the finalize call.
Storage and delivery
The store is encrypted at rest, versioned, and fully private; serving is a CDN that alone is permitted to read it. Nothing is world-readable by default, and a public asset is public because it was marked so, not because the bucket was.

API surface

All endpoints are versioned under /media/v1/, return RFC 7807 problem details on error, and read tenantId from the bearer token. The application never receives the file bytes — only the metadata around them.

Media endpoints in the Foundation API reference OpenAPI 3.1 schema for Media with request/response shapes, parameters, and a try-it client.

Quick reference

MethodPathPurpose
POST/media/v1/uploadsRequest an upload ticket — returns a signed PUT URL against a new PENDING asset.
POST/media/v1/uploads/{id}/finalizeConfirm the upload completed; flips the asset to READY and returns its serving URL.
GET/media/v1/assetsList assets (filter by ?visibility=, ?contentType=).
GET/media/v1/assets/{id}Fetch one asset with its resolved serving URL (CDN or presigned).
DELETE/media/v1/assets/{id}Soft-delete an asset.

Example: upload an image

Ask for a ticket; you get a signed URL to PUT the bytes straight to storage:

POST /media/v1/uploads
Content-Type: application/json
Authorization: Bearer <token>

{ "filename": "hero.jpg", "contentType": "image/jpeg", "sizeBytes": 248137, "visibility": "PUBLIC" }
→ 201 Created
{
  "assetId":          "med_01JC…",
  "uploadUrl":        "https://…s3…/?X-Amz-Signature=…",
  "storageKey":       "t_01J8K2…/med_01JC…/hero.jpg",
  "expiresInSeconds": 900
}

// browser PUTs the file bytes directly to uploadUrl, then:
POST /media/v1/uploads/med_01JC…/finalize
→ 200 OK  { "status": "READY", "url": "https://d111….cloudfront.net/t_01J8K2…/med_01JC…/hero.jpg" }

How it fits with the rest

flowchart LR
  Cl[Browser] -- signed PUT --> St[(Object store)]
  Cl -- request / finalize --> Md(Media)
  Md -- sign --> St
  CF[CDN] -- private read --> St
  Sg[Sign] -. med_* source / signed PDF .-> Md
  R[Radiant ui_view] -. med_* .-> Md
  Ha[Hardin product image] -. med_* .-> Md
            

Media is referenced, never depended on. Sign points an envelope at the med_* document to sign and records the completed PDF back as a new asset; Radiant ui_view assets and Hardin product images carry a med_* id; Speaker templates embed one. The contract is the same opaque-id-in convention as everywhere else — the owning primitive holds the reference, Media holds the bytes.