carlos / platform Public

Media handling: a `kind:media` serving class over an app's store, public and private Feature Request open

Migrated from github.com/carlosframework/platform issue #375, opened 2026-08-31. GitHub issues are closed for this repo; see AGENTS.md, "Work is tracked on amadan, not GitHub".

Still outstanding: Implement the kind:media serving class over an app's store; the design is settled and no code exists.

---

Brainstormed with Paul on 2026-08-31. No code written, no spec file yet — this issue is the session's record so the work can be picked up cold.

The gap

An app that holds a store (#55) gets a bucket and scoped credentials, and then has three bad options for serving what its users upload: make the bucket public (no hostname of its own, no cache control, no purge), presign every URL (a fresh URL per request, so nothing caches), or proxy the bytes through its own instance (the tenant's box becomes a one-region CDN sharing CPU with the app). Every app with user uploads hits this.

CARLOS already owns the four expensive parts of the answer — multi-region edges with latency steering, wildcard TLS minted from the route table, a response cache with purge, and the signed-pointer discipline. What is missing is a serving class that reads app-owned objects.

Three projects, not one

Media is where platforms accrete surface area fastest, so this splits into three that ship independently. A is independently valuable; B and C are worthless without it.

  • A. kind:media — the serving class. A fourth route kind that serves an app's store bucket through the edge, cached, on its own hostname, public and private. This issue's task list is A.
  • B. Image transforms behind smart URLs. /w_800,f_webp/photos/cat.jpg — synchronous, pure CPU, edge-cacheable, derivatives written back to the bucket so a cold edge does not redo Ireland's work.
  • C. Audio/video to HLS. An async transcode job writing a rendition ladder into the bucket; the serving side is then just A. Needs a job runner this repo does not have.

Measured on main, not assumed

  • The edge cannot read a tenant's store bucket. infra/modules/region-host/main.tf:76 scopes the host role's S3 access to exactly two ARNs, the deployment bucket and the data bucket. Store buckets live in the same operator account but outside that policy. A new grant is required, and its shape is most of the design.
  • The shared response cache cannot safely serve private objects as it stands. edge.go:994 builds the cache key from host + method + path + RawQuery, and the GetFor hit at edge.go:1000 returns before the handler runs. Put a signature in the query and it becomes part of the key, so every link is its own entry and expiring links flood the LRU. Strip it from the key and a cache hit skips verification, serving private bytes to anyone who asks. There is no safe middle.
  • There is a precedent for the exit. Kind=="status" is exempt from the shared cache in both directions (edge.go:990) and caches for itself, because an unbounded keyspace in a process-global LRU lets one tenant evict every other tenant's entries. kind:media has that problem plus an authorization one, so it should cache for itself too — which is also where B's derivatives will want to live.
  • There is a precedent for streaming tenant-adjacent bytes through the edge. internal/edge/storedoor.go:54 argues the case in its header: "artifact bytes stream through the edge rather than being redirected to a presigned URL." That is the platform's own bucket, not a tenant's, but the mechanism and the reasoning already exist.
  • This crosses a stated rule, deliberately. The object-storage spec (2026-08-10-object-storage-primitive-design.md:27) says "Control plane only, never the data plane… permanently out of scope." Reads crossing is the whole point of a CDN; writes must not — uploads stay direct-to-S3 on the app's own credentials. The spec needs an explicit amendment saying so rather than a quiet contradiction.
  • Route kinds today are instance | service | static (provision.ValidKind, internal/provision/provision.go:337), plus status which the edge dispatches but the validator does not accept.
  • CGO_ENABLED=0 is enforced, with intent: .amadan/ci.d/20-cross@5 sets it explicitly "so that adding a cgo dependency fails loudly rather than quietly." This is B's central constraint, not A's.
  • There is no job queue. internal/schedule is cron and once-timers on an instance's own control socket — "run this later on your own box," not "queue this on a worker pool." C needs something new; a tenant running its own ffmpeg under a once-timer is a legitimate zero-cost answer in the meantime.

Decided in session

  • Build A first, on its own, and let B and C be their own specs against real demand.
  • *Media serves public and private files.* Paul, 2026-08-31, ruling against a public-only v1. Private is not deferrable.
  • Because private is in scope, kind:media caches for itself rather than using the shared LRU, per the status precedent above.

Open — needed before the spec

  1. What "private" means. Two different systems answer to the word, and the answer sets the whole authorization design: - Capability link — a signed, expiring URL naming an object or a prefix. Anyone holding it can fetch; the bytes are identical for every holder, so the edge caches by object key and one popular private file is cached once. The app decides who gets a link, at mint time, using its own rules. What S3 presigning, imgix, Cloudinary and Mux all do. Revocation lags by the expiry. - Identity-bound — the edge knows who the viewer is and something decides whether that person may read that object. Revocation is immediate, but the check consults the app on every miss, putting the tenant's instance back in the hot path, and cross-viewer caching needs care to avoid serving one person's file to another.

Recommendation: capability links, prefix-scoped, expiring. Identity-bound behaviour sits on top — the app checks identity when it mints, and a short expiry bounds how stale that check goes. Prefix scope rather than exact-key scope is what makes C work later: an HLS stream is a manifest plus hundreds of segments, and signing each segment individually is why people fall back to signed cookies. Awaiting Paul.

  1. How the edge is granted read. Three candidates, and (1) above narrows them: widen the host role to all store buckets (one line of Terraform, but every edge can then read every tenant's data — the blast radius the IMDS fence work in #351 was narrowing); mint a per-store read-only credential and build a delivery path to the edge that does not exist today; or have the provisioner attach a bucket policy at grant time letting the edge role read one declared media prefix of that one bucket. The third needs no secret delivery, records the authority in store.json next to the existing grant, and keeps it per-store and operator-granted. Recommended, pending (1).
  1. Where the signing key lives, if (1) lands on capability links. The app needs to sign and the edge needs to verify. An ed25519 pair with the public half in the route record — which the edge already syncs — and the private half delivered to the app as env through the sealed config.json path keeps no secret on the edge and matches how release pointers already work. The cost is a 64-byte signature in the URL; truncated HMAC is shorter but puts a shared secret in the registry row.
  1. Abuse surface, for B. A public transform URL is CPU amplification — ?w=1 through ?w=9999 is 9,999 cache misses on the box that also terminates TLS for every other tenant. Needs a named-preset allow-list or signed transform parameters from the first commit, not later. The existing maxEntriesPerPath admission cap bounds cache pollution and does nothing about CPU already spent.
  1. Who pays for derivatives, for B and C. Five widths by three formats is fifteen objects per source image; one HLS ladder is typically 10-40x the source in object count. It lands in the tenant's bucket by default, which is probably right, but it should be a decision rather than an accident.

Tasks — piece A

Blocked on open questions 1-3.

  • [ ] Settle open questions 1-3 with Paul
  • [ ] Write the design to docs/superpowers/specs/, and amend 2026-08-10-object-storage-primitive-design.md:27 to say reads cross the data plane and writes never do
  • [ ] store.json: declare a media prefix and its visibility split; record the edge-read grant alongside the existing operator grant
  • [ ] Provisioner: attach and tear down the bucket policy that grants the edge role read on the declared prefix
  • [ ] provision.ValidKind + ValidateShape: accept kind:media, refuse the fields it must not carry (addr, unit, hibernate) the way static already does
  • [ ] Console door: accept {"kind":"media"} on instance create, bound to the app's own domain, with the store binding validated
  • [ ] Edge: mediaHandlerFor, modeled on staticHandlerFor — range requests, conditional GET, content type, and its own bounded cache
  • [ ] Edge: signature verification ahead of any cached read, and exempt kind:media from the shared LRU in both directions
  • [ ] Purge: a member-triggered purge for a media key or prefix
  • [ ] Hostname minting, following 2026-08-23-static-platform-host-design.md
  • [ ] carlos media verbs, usable with no infra access per AGENTS.md
  • [ ] Steering: decide whether kind:media defaults to latency steering (see the same open question on kind:static, blocked on #286)
  • [ ] Roll and verify on flagship; live-check a private fetch, a cache hit, and an expired link

Tasks — filed, not scheduled

  • [ ] B. Image transforms — resolve the cgo fork first, then abuse surface, then derivative storage
  • [ ] C. HLS — needs a job runner; revisit when a tenant asks

🤖 Generated with Claude Code

Demand, 2026-10-02: three consumers are now waiting on stage 1, and on what sits above it.

  • rastrillo decided its upload shape in rastrillo#59. Uploads go straight to the bucket when the store is S3 and JavaScript is running, and through the app otherwise. Image processing is taken from the platform rather than built in the framework. So rastrillo is not extracting Tito's internal/imaging, and serves originals until media exists.
  • Tito Go proxies uploads today and writes its own PNG renditions (internal/instance/assets.go, internal/imaging, served by capability token from assets/r/). That is exactly the "app runs its own media" shape the roadmap means to make unnecessary. Its roadmap to rastrillo (W5) moves the upload store onto blobs.
  • Docs still ships "insert image" disabled, as roadmap §1 already notes.

Filed alongside this, so each can be scheduled on its own:

  • Image transforms (stage 2): blocked on this thread.
  • Bucket CORS for direct browser uploads (stage 5's CORS ownership): does not need stage 1.
  • Per-object scan on demand: the cleared spec 2026-09-02-scan-on-demand-design.md.