feat: deliver module objects directly to browsers through opaque read URLs
CI / verify (pull_request) Successful in 36s
CI / infrastructure-smoke (pull_request) Successful in 20s
CI / migration-smoke (pull_request) Successful in 56s

ADR 0030 left exactly one way to get an image to a browser: read the
object into the module's process and write it back out of a module HTTP
route. Every byte of every asset travelled storage -> Ekklesia -> browser.
For the slideshow this platform exists to replace that means Ekklesia in
the path of every image on every display, permanently, for bytes that
never change — and it would make Ekklesia responsible for re-implementing
byte ranges, without which video seeking cannot work at all.

A module can now ask a logical bucket for a temporary browser URL:

    const { url, expiresAt } = await assets.createReadUrl("slides/welcome.png");

The contract names intent, not mechanism. There is no signing, expiry
format, endpoint, or bucket in it — a host with no S3 anywhere could
satisfy the same method, which is what keeps a future single-module
standalone host able to implement it. The URL is opaque to module code and
a bearer capability to whoever holds it, so a module authorizes the
request in its own route before asking: storage bounds reach, never
identity. Lifetimes run one minute to six hours, fifteen by default, and
out-of-range values are refused rather than clamped, so "expires forever"
is unreachable. Logical keys stay canonical state; URLs are generated on
demand and never persisted or logged.

The provider endpoint and the browser delivery origin are separated
because measurement showed they cannot be collapsed: an S3-compatible
signature covers the request Host, so a URL signed for one origin and
rewritten to another is rejected. The browser-facing origin is therefore a
signing input (S3_DELIVERY_ENDPOINT), defaulting to S3_ENDPOINT — so the
standard deployment configures nothing and delivery just works. The scheme
is not signed, so a reverse proxy may terminate TLS provided it passes the
original Host through; that is pinned by a test rather than described and
hoped for.

Read URLs are stable within a window of one third of their lifetime.
Signing is a pure function of the clock, so naive re-signing yields a new
URL per second — a guaranteed browser cache miss, and the reference client
keys its <img> by URL, so an unchanged image would be torn down and
re-downloaded continuously. Quantizing the signing timestamp makes
repeated calls byte-identical while leaving at least two-thirds of the
lifetime; Cache-Control is set at signing time to the guaranteed
remainder, because the pinned SeaweedFS image sends none of its own.

Verified against live SeaweedFS: byte ranges answered directly with 206
and a correct Content-Range, so no range proxy is needed in Core; signing
is local (200 URLs in 66ms), so no batch API is needed; unsigned requests
for the same object are refused, so delivery never depends on a public
bucket; tampered signatures, swapped keys, and swapped buckets all
rejected. Signed write and delete URLs are deliberately not built.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
ekklesia
2026-08-23 17:12:07 -05:00
co-authored by Claude Opus 5
parent 9a3b8dab8f
commit 7ff063b87a
23 changed files with 1593 additions and 42 deletions
+56
View File
@@ -1161,6 +1161,7 @@ bucket.head(key); // → metadata only
bucket.get(key); // → metadata + body: ReadableStream
bucket.put(key, body, { contentType, contentLength });
bucket.delete(key);
bucket.createReadUrl(key, { expiresIn, downloadFilename }); // → { url, expiresAt }
```
`get` gives you a Web stream, so hand it straight to a `Response` rather than buffering. `put` takes
@@ -1174,6 +1175,61 @@ is complete. Ignoring it will silently truncate a large bucket.
There is deliberately **no** way to create or delete a bucket. Which buckets exist follows from your
manifest and an operator's installation, and nothing your module does can destroy one.
### Delivering objects to a browser
Streaming an object through your own route works, but it puts your module in the path of every byte.
For assets a browser is going to fetch anyway — slide images, video, a PDF — ask for a delivery URL
instead and let the browser fetch it directly
([ADR 0031](../architecture/decisions/0031-direct-object-delivery.md)):
```ts
const assets = context.storage.bucket("announcement-assets");
const slides = await assets.list({ prefix: "slides/" });
return Response.json(
await Promise.all(
slides.objects.map(async (object) => ({
key: object.key,
...(await assets.createReadUrl(object.key)),
})),
),
);
```
Signing is local, so building forty URLs costs no round trips and needs no batch call.
**Treat the URL as opaque.** Do not parse it, rewrite it, or read the storage host or bucket out of
it — all of that is the host's business and may change with the deployment. Everything you
legitimately need is `expiresAt`.
`expiresAt` is a `Date`, like `lastModified` on object metadata. Returning it through
`Response.json(...)` serializes it to an ISO 8601 string, which is what a browser should receive —
don't invent your own epoch or locale-formatted representation on the way out, and don't assume a
client can hand the string straight back as a `Date` without parsing it.
**Treat the URL as a bearer token.** Anyone holding it can read that one object until it expires.
Deciding *who may have one* is your route's authorization decision, made before you ask for it;
storage only guarantees the URL reaches no further than the single object it names. Never log a
complete delivery URL, and never return one to someone your route would not have served the bytes to.
**Persist the key, never the URL.** A key is stable and still resolves tomorrow. A URL is a
credential with an expiry date in it, and storing one in your tables will break.
`expiresIn` is in seconds and bounded — a minute at the shortest, six hours at the longest, fifteen
minutes if you say nothing. Out-of-range values are refused rather than quietly adjusted. A
long-running display should re-request state periodically rather than hold a URL forever; if an image
is already loaded when its URL expires, nothing breaks, and the next state refresh carries a fresh
one.
Pass `downloadFilename` only when you want the browser to *save* the file rather than display it —
a bulletin PDF, say. Images and video should stay inline, which is the default.
The URL is not checked against the object first. A URL for a key that does not exist, or one deleted
after signing, simply returns `404` when fetched.
There are no signed **write** URLs. Uploads come through your own route, where you can check size,
type, and who is asking.
### Object keys
Keys may nest (`slides/2026/welcome.png`). They are not filesystem paths — `..` means nothing to an