# Asset registry HTTP contract This contract exposes the asset operations — `put` / `identify` / `resolve` / `remove` / `replace` — over HTTP. All paths below are relative to a deployment-defined base URL. Path segments are percent-encoded UTF-8 strings. Metadata travels as JSON; asset bytes travel as bytes, as a multipart part on writes and as the whole body on reads. Successful operations with no return value respond with `204 No Content`. The server backend for this contract ships as `createAssetHttpHandler`, with asset requests dispatched by `createStorageHttpHandler`. The conformance server this package provides remains test-only: it implements no authentication or authorization model beyond what the contract's response codes require, because deriving the principal from an authenticated session is the reference server's job. The design this contract serves has three layers — an allocated asset id names a registry entry, and a registry entry names a content hash, and a content hash names bytes. **Only the first layer is on the wire.** The content hash is an internal deduplication key: it MUST NOT appear in any URL, response header, response body, error message, error details, or log line. That prohibition is not satisfied by omitting the hash itself. Bytes are shared across principals and their storage row has no owner, so **no response header, body value, or status may be derived from the blob row except the representation's byte length.** Every other value on a byte response comes from the registry entry — its media type, its revision, its own creation time. The byte length is allowed because it is an inherent property of the representation and is the same value a `GET` obtains from the returned bytes; retaining it in the registry lets `HEAD` reproduce `Content-Length` without fetching those bytes. `Last-Modified` is the trap worth naming: sourced from the shared row's creation time, as a static-file idiom naturally would, it reports a timestamp earlier than the caller's own write and thereby proves another principal stored those bytes first. Byte responses MUST NOT carry `Last-Modified`, `Age`, or any other value read from the shared row; note that the `no-store` requirement below already makes `Last-Modified` pointless. Frameworks that generate `ETag` and `Last-Modified` by default MUST have both disabled on these routes. The hash MUST be a collision-resistant digest of at least SHA-256 strength, untruncated. This is a security requirement rather than an implementation preference, and it is the unstated precondition of two rules below: a server writes bytes unconditionally, so under a colliding digest one principal's write silently substitutes the bytes every other principal's entries resolve to — and an object-storage byte layer names each object by that digest, so a collision there is a cross-principal overwrite at the storage layer as well. ## An asset id is opaque and unconstrained An asset id is a string this package allocated, but nothing in this contract may depend on its shape. Ids are compared, never parsed. There is no id validator that can *reject* an id on either side, and a server MUST NOT refuse one for its content. (The client does inspect an id for the three transport classes below, but only ever to answer "miss" locally — never to reject.) The consequence is the one that matters: **an id this registry never allocated is a miss, not an error.** An id from another id space, an empty string, a string containing NUL, four kilobytes of padding, `../../etc/passwd` — each is an ordinary lookup that finds nothing. This is not leniency. An id whose *shape* could be rejected would answer a question the caller is not entitled to ask, and a caller who can distinguish "malformed" from "not yours" has learned something about the id space. The browser backend documents this rule for the same reason, and the shared conformance suite pins each case. A server therefore stores and looks up the decoded id as an opaque value — a bound query parameter, or a derived constrained storage id — **never as a path component, a filename, or an unescaped fragment of a query**. An id like `a/../../b`, arriving percent-encoded as `a%2F..%2F..%2Fb`, is one opaque lookup that traverses nothing. ### Ids that cannot be a path segment Validity is a property of the id; reachability is a property of the transport. An id sits **mid-path** here, before the trailing `content` segment, and three classes of id cannot be carried there: 1. **An unpaired UTF-16 surrogate** has no percent-encoding; `encodeURIComponent` throws. 2. **A whole-id `.` or `..`** is normalized away by URL path parsing before the request is sent, so `/assets/../content` would address `/content`. 3. **The empty id** collapses `/assets//content`, which intermediaries that merge slashes rewrite to `/assets/content`. The third has no counterpart in the [KVStore HTTP contract](./kv-http-contract.md), whose key is a trailing segment and round-trips when empty. The danger in all three is identical and it is not that the request fails — it is that the request **succeeds against something else**. The client MUST therefore resolve these three locally, before building any request. **It does not raise an error: it answers as a miss** — `resolve` returns `null`, `remove` succeeds as a no-op, and `replace` fails as it does for any id the registry does not hold, with a synthesized `ASSET_NOT_FOUND` since there is no response to reconstitute one from. This is where this contract deliberately diverges from KV, which refuses an unencodable key with a client-side `KEY_NOT_ENCODABLE`. Throwing would be wrong here: an unknown id is a miss by the rule above, the shared conformance suite pins the empty string as a miss on both `resolve` and `remove`, and a client that threw would both fail that suite and reintroduce the shape oracle the rule exists to prevent. The distinction to preserve is not loud-versus-quiet; it is that the id must never address something it does not name. Note that ids which merely *look* structural need none of this. `../../etc/passwd` percent-encodes to `..%2F..%2Fetc%2Fpasswd`, one ordinary segment that reaches the server and misses there; it illustrates the id-domain rule, not the local-resolution one. ### Transport hazards a deployment owns Beyond those three, the path carries an id through intermediaries that may refuse or rewrite it, and the shared suite pins ids in each category. A conforming deployment MUST pass the id segment through unmodified, and MUST verify that it does: - **A percent-encoded NUL** (`%00`) is rejected outright by some servers and filters. - **A percent-encoded slash** (`%2F`) is rejected or normalized by servers that do not allow encoded slashes by default — which the `bucket/path/to/object.png` shape produces. - **A very long id** may exceed a request-target ceiling and be refused with `431` before routing. No length is invalid; a long id is simply not reachable past that bound. Each of these turns a pinned miss into a thrown error, and none is predictable client-side, so none may be papered over by mapping an unclassifiable `4xx` to a miss — that would break the rule that only a specific code becomes a miss. They are deployment configuration, and the shared suite's cases stand as the acceptance test for it. Real ids, being allocated by this package, encounter none of them. ## Endpoints | Method | Path | Purpose | Success | | --- | --- | --- | --- | | `POST` | `/assets` | Allocate a new id and store the submitted bytes under it. | `201` with `{ "id": "ast_…" }` and `X-Asset-Revision` | | `GET` | `/assets/{id}/content` | Read the bytes stored under an id. | `200` with the bytes | | `HEAD` | `/assets/{id}/content` | Read identity headers without reading the byte layer. | `200`, no body | | `PUT` | `/assets/{id}/content` | Replace the bytes stored under an existing id. | `204` with `X-Asset-Revision` | | `DELETE` | `/assets/{id}` | Remove the registry entry. | `204` for any id the policy admits | The route table admits exactly one segment after `assets`, and it is the id. There is no principal segment and no digest segment to supply, so any other path shape is `404 ROUTE_NOT_FOUND` — a routing outcome that requires inspecting nothing. A path that *does* match a table entry but with a method that entry does not list is `405 METHOD_NOT_ALLOWED`, carrying an `Allow` header naming that route's methods; since route matching never consults the registry, neither outcome discloses anything about an id. **No route takes a query parameter.** A server MUST reject any request whose target contains `?` at all with `400 VALIDATION_FAILED` — stated on the raw target rather than on a parsed query, so that a bare trailing `?` cannot be read as absent by one implementation and present by another. That is total where an enumeration of forbidden parameter names would not be, and it costs nothing, because there is no parameter here to preserve. There is deliberately **no metadata read route**. No backend offers one: the browser store's public surface is `put` / `resolve` / `invalidate` / `release` / `replace` / `remove` / `close`, and no metadata member is ever returned to a caller. (`contentType` is read back, but only by the store itself, to label the bytes it hands out.) A route no backend can satisfy is not a contract but a promise. Warm-cache revalidation is what `HEAD` is for. `HEAD` performs an ownership-checked registry identity read and MUST NOT read or materialize the asset bytes. Its status and headers are identical to `GET` for the same registry state; only the response body is absent. The registry's recorded byte length supplies `Content-Length`, so reproducing the `GET` headers does not require a byte-layer read. Read routes MUST be served with `Cache-Control: private, no-store` and `Vary: Cookie, Authorization`, and MUST NOT be cached by any intermediary; the client sends its reads with `cache: 'no-store'` for the same reason, since a UA cache serving a stale `200` would defeat revision revalidation invisibly. Note that this differs from a content-addressed design, where bytes named by their digest never change and are safe to cache forever: here `replace` mutates the bytes behind a **stable** id, so no HTTP cache may serve a response for an id without revalidating. The client's own snapshot cache is a different thing and is governed below. Byte responses MUST NOT advertise `Accept-Ranges` and MUST ignore a `Range` header, always answering `200` with the complete representation. A `206` would carry a response model this contract does not define, on a route whose headers are load-bearing for revalidation. ## Transport: bytes and metadata `POST /assets` and `PUT /assets/{id}/content` take `multipart/form-data`, with the parts in this order: - **`meta`** — `application/json`, the metadata object. Required on `POST`. **Optional on `PUT`**, where its absence is meaningful; see below. - **`bytes`** — the asset bytes, carrying the asset's own `Content-Type`. The package client uses `application/octet-stream` when the source blob has no type, because multipart parsers otherwise supply their own default media type. The multipart framing MUST be parsed by a standards-conforming multipart parser; a deployment MUST NOT hand-roll one. A bespoke grammar disagrees with the parsers intermediaries use, and every such disagreement is a way for a scanner and the application to see different parts. Both parts MUST carry a `filename`. Without one, a conforming parser returns the part as a string, discards its headers, and text-decodes its payload, silently replacing invalid UTF-8. Each filename is fixed by the package client, never read by this package, and never derived from caller data. Preserving `meta` as a file allows the server to require its `Content-Type` to be `application/json` and to decode its bytes as UTF-8 with errors reported rather than replacement characters inserted. Multipart rather than metadata alongside a raw body, because there is nowhere safe to put the metadata. It is an open-ended object, and real callers fill it with generated-narration text and image-generation prompts — **unbounded caller-supplied content**. In a query parameter that content would sit in the request target, where it hits the request-target ceiling and is written verbatim into every intermediary's access log. A custom header has the same size problem for the same reason. Multipart is a standards-defined framing rather than a bespoke one, which is the property that matters; `maxMetaBytes` must be enforced against the parsed metadata part before the JSON is parsed rather than after. From this, one rule the other layers have no need for: > **Metadata is unbounded caller-supplied content. It MUST NOT appear in any URL, response header, response body, log line, error message, or error details.** The single exception is `contentType`, which determines a byte response's `Content-Type` as specified below. In particular a `Content-Disposition` filename MUST NOT be derived from metadata: it would put caller content in a header, and an unescaped one is a header-injection primitive. Use a fixed name or the id. The client's request-header hook MUST NOT set `Content-Type`, and a client whose hook does MUST fail loud rather than choose a winner. Under multipart this is more serious than mislabelling: overwriting `Content-Type` destroys the boundary parameter, and every write from that deployment fails to parse with an error naming nothing an operator can act on. ### Sizes Three byte limits and one part-count limit, because they bound different things: | Limit | Default | Bounds | Measured on | | --- | --- | --- | --- | | `maxRequestBytes` | 33 MiB | The whole request | Raw octets off the wire, before parsing | | `maxAssetBytes` | 32 MiB | The `bytes` part | Decoded part content | | `maxMetaBytes` | 64 KiB | The `meta` part | Decoded part content | | `maxParts` | 8 | Number of multipart parts | Parsed frames | Only `maxRequestBytes` is enforced before multipart parsing. It bounds the raw body read from the wire and is therefore the only ceiling on parser work and request-derived memory. The platform's `Response.formData()` then materializes every part before `maxParts`, `maxMetaBytes`, and `maxAssetBytes` are checked on the parsed result; those three limits bound what the handler accepts, not what the multipart parser processes. This trade is deliberate. A streaming multipart parser with its own limits would introduce a second grammar that could disagree with the standards-conforming platform parser, recreating the scanner/application parser differential this delegation exists to remove. Bounded memory is preserved by `maxRequestBytes`; `maxParts`, `maxMetaBytes`, and `maxAssetBytes` remain finer-grained admission rules for the parsed request. Separate asset and metadata limits are still necessary because one shared admission limit either caps assets at metadata scale or admits metadata at asset scale. Per-part header bounding belongs to the standards-conforming parser rather than to this contract. The outer bound is measured differently from the inner two, and deliberately so: it exists to stop reading, so it cannot wait for a decode. A handler MUST assert at construction that `maxRequestBytes` exceeds `maxAssetBytes + maxMetaBytes` with room for multipart framing, or the outer bound silently masks the inner one — with the defaults above equal, an asset at exactly `maxAssetBytes` would always be rejected by the request bound, reporting the wrong limit. None may be inferred from `Content-Length`, which is a claim by the sender. Counting bytes as they are read is necessary but not sufficient for the decoded limits — under a `Content-Encoding` the bytes read are a fraction of the bytes stored, and the expansion lands inside the transaction that holds the write. A server therefore MUST reject `Content-Encoding` on these requests with `400 VALIDATION_FAILED`. Exceeding any size limit is `413 PAYLOAD_TOO_LARGE`, raised **before any bytes are stored**. ### Malformed and hostile bodies A request whose `Content-Type` is not `multipart/form-data` is `415 UNSUPPORTED_MEDIA_TYPE`. A failure by the multipart parser, including invalid boundary framing, is `400 VALIDATION_FAILED` with a fixed package message. The expected entries are exactly `meta` and `bytes` on `POST`, and `bytes` with optional `meta` on `PUT`; any other entry name or any duplicate is `400 VALIDATION_FAILED`. Duplicate entries in particular MUST be rejected rather than resolved by a first-wins or last-wins rule: a scanning intermediary and the application choosing differently is a parser differential, and the two would disagree about which metadata a request carried. When both entries are present, `meta` MUST precede `bytes`. ### Recording the media type On `POST`, the recorded media type is `meta.contentType` when that member is **present** — including when it is the empty string — and the `bytes` part's own `Content-Type` only when it is absent. The distinction is `??` rather than `||`, and it is observable: an explicitly empty `contentType` records the empty string and is therefore served as an attachment, where a fallback would have served the blob's type inline. On `PUT`, when `meta` is present it replaces the entry's metadata wholesale and the media type is derived the same way. When `meta` is **absent**, the entry's existing metadata is retained and its media type is retained unless the `bytes` part carries one. The package client always carries a media type on `bytes`: an untyped source blob is sent as `application/octet-stream`. This is necessary because a standards-conforming parser supplies a default media type for a file part whose header omits one, so omission cannot survive parsing as an empty type and cannot signal retention. Metadata omission still retains the existing metadata object; only the media type follows the replacement bytes. The two branches are metadata present versus metadata absent, matching the browser backend's metadata replacement and retention behavior. The absent branch is the one that matters: regenerating bytes in place while keeping the recorded provenance is the use case `replace` exists for. A wire on which `meta` were mandatory would force a client to send `{}`, erasing the accumulated prompt, model, narration, and voice on every regeneration — through a call that returns `204`. Media-type retention is a deliberate divergence. The browser backend can retain the existing media type when metadata is absent and the replacement blob is untyped. The HTTP path cannot reproduce that behavior because a conforming multipart parser supplies a default type for a file part whose header omits one. The package client therefore sends `application/octet-stream` for an untyped replacement, and that type replaces the recorded media type even while the existing metadata object is retained. ### Serving bytes Every response carrying bytes MUST send `X-Content-Type-Options: nosniff` and MUST serve a `Content-Type` from a renderable allowlist. The default allowlist is: ``` image/png image/jpeg image/gif image/webp image/avif audio/mpeg audio/mp4 audio/ogg audio/wav audio/webm video/mp4 video/webm video/ogg ``` Matching is exact, case-insensitive, and whole-string, with no parameters accepted. A deployment may narrow this list; widening it is a decision about executable content. **Excluded, and MUST remain excluded:** `image/svg+xml` and `image/svg`, `text/html`, `application/xhtml+xml`, `text/xml` and `application/xml`, and `application/pdf`. These are document formats that execute script, not media, and serving one same-origin turns stored bytes into stored script. A recorded media type that is not a string, is empty, or is outside the allowlist is served as `application/octet-stream` with `Content-Disposition: attachment`. The empty case is real when metadata explicitly carries an empty `contentType`. The client MUST label its minted object URL from the **served** `Content-Type` and from nothing else; labelling it from metadata would reintroduce inside a `blob:` URL exactly what the allowlist excludes. **Relabelling is not refusal.** `resolve` returns a minted URL for every stored asset regardless of media type; a non-renderable asset yields a URL labelled `application/octet-stream` that a media element will not render, and that is the intended outcome rather than a miss. Returning `null` instead would fail the shared conformance suite, every blob in which is `text/plain` — outside any sensible allowlist — and would conflate "these bytes are not safe to render inline" with "there are no bytes." The disposition header governs direct navigation and does no work on this path, where bytes reach the caller through `fetch`; the protection that carries the weight is the relabelling together with `nosniff`. The three-layer design improves on a content-addressed one here, and it is worth stating why. Where bytes are shared across principals and the recorded media type travels with the bytes, one principal's metadata can determine how another principal's response is labelled — a stored cross-principal typing attack. Here the media type lives on the **registry entry**, and every entry belongs to exactly one principal, so a principal can only mislabel their own bytes. The attack is unrepresentable rather than mitigated, given the collision-resistant digest required above and the rule that no response value derives from the shared row. ### `X-Asset-Revision` `GET` and `HEAD` on `/assets/{id}/content`, and the success responses of `POST /assets` and `PUT /assets/{id}/content`, MUST carry an `X-Asset-Revision` header: a monotonically increasing integer on the registry entry, starting at 1 and incremented by each `replace`. It is opaque to the client except for equality comparison. It MUST NOT be derived from the content in any way. A content-derived validator — the reflex choice, and what an `ETag` from a static file server or object store would be — is the content hash under a different header name, and would disclose byte equality across ids to anyone holding two of them. Byte responses MUST NOT carry a content-derived `ETag` for the same reason. Carrying it on the write responses too means a client learns the revision it produced without a second request, which would otherwise race a concurrent `replace`. The headers and the body of a `GET` byte response MUST be produced from **one transactionally coordinated read**. Reading the revision and media type, releasing the transaction, and then reading the bytes lets a concurrent `replace` pair revision *n* headers with revision *n+1* bytes, which the client then caches as authoritative. The browser backend closes the same window deliberately, reading the registry entry in the same transaction as the bytes. `HEAD` reads only the registry identity and recorded byte length in one ownership-checked query. ## Resolution yields a client-minted object URL `resolve` returns a URL the caller can use as an `` / `