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
+33
View File
@@ -283,6 +283,39 @@ correctness and portability boundary like every other module-facing mechanism he
What it does guarantee is that Ekklesia never hands a module another module's data, never gives module
UI or a browser a credential, and never prints one in diagnostics.
### Delivery URLs are bearer capabilities, and are treated as secrets
A module can ask a bucket for a temporary URL that lets a browser fetch one object directly
([ADR 0031](../architecture/decisions/0031-direct-object-delivery.md)). Whoever holds that URL can
read that object until it expires, with no further check of who they are.
- **Authorization happens before issuance, not inside the URL.** A module's route authorizes the
request and only then asks for a URL. The storage contract has no notion of a principal and offers
no hook that would let it make a permission decision; it bounds *reach*, not *who*.
- **Lifetimes are bounded and cannot be waived.** One minute at the shortest, six hours at the
longest, fifteen minutes by default. Out-of-range requests are refused rather than clamped, so
"expires forever" is unreachable rather than merely discouraged.
- **One object, one bucket.** The signature covers the URL's own path, so editing the key or the
bucket in a delivery URL yields a rejection rather than a different object — including another
module's object.
- **Never logged, never printed.** A complete delivery URL is treated exactly like a token: it is not
logged, and Doctor never prints one. Where a URL must be identified in a log, identify it by module
id, logical bucket id, key, and expiry — never by its query parameters.
- **Reads only.** There are no signed write or delete URLs. Uploads go through a module route where
the module can validate what it accepts.
- **Never canonical state.** Modules persist logical object keys and generate URLs on demand. A
stored URL is a stored credential with an expiry date in it.
**What a browser sees.** A delivery URL reveals the storage origin, the physical bucket name, the
object key, and signing parameters. Physical bucket names are implementation metadata, not secrets —
nothing is authorized by knowing one, since the bucket is not publicly readable and a tampered URL is
rejected. The guarantee is that *module code* cannot reach those details, not that a browser cannot
see the URL it is fetching.
**Buckets stay private.** Direct delivery deliberately does **not** work by making buckets anonymous;
an unsigned request for the same object is refused, and an integration test asserts it so that a
future change cannot "fix" delivery by opening a bucket up.
### Client-side permission checks are not a security boundary
A host may tell a module UI what permissions it believes the viewer holds