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 S3 with a presigned URL, served from a CDN, and referenced by opaque id so no other primitive ever proxies a byte.

Status: Live and deployed. An S3 bucket behind a CloudFront distribution (Origin Access Control, objects private) ships in Terraform; uploads go direct to S3 over a presigned 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 S3 object key, content type, size, visibility, and status — and the bytes themselves never flow through the application. The browser uploads them straight to S3 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 presigned S3 PUT URL (15-minute TTL) against a fresh PENDING asset; the browser uploads directly to S3; 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 CloudFront URL (the S3 object stays private, reached via Origin Access Control); PRIVATE assets are never exposed on a stable URL — each read mints a short-lived presigned 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 real S3 object (HEAD for size + checksum) rather than trusting the finalize call.
S3 + CloudFront
The store is an SSE-encrypted, versioned, fully-private S3 bucket; serving is a CloudFront distribution with OAC so only the CDN can read the bucket. Deployed in Terraform alongside the app, wired through MEDIA_S3_BUCKET and MEDIA_PUBLIC_BASE_URL.

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 presigned S3 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 presigned URL to PUT the bytes straight to S3:

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 (S3), 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] -- presigned PUT --> S3[(S3 bucket)]
  Cl -- request / finalize --> Md(Media)
  Md -- presign --> S3
  CF[CloudFront] -- OAC read --> S3
  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.