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
+58 -2
View File
@@ -64,6 +64,39 @@ dependencies remain the only way one module may require another.
- Generated bootstrap artifacts may connect discovered entry points at the application edge;
they do not become manually maintained Core registries.
### The single-module standalone host (architectural target, not built)
Every module-facing contract in this platform is written so that **full Ekklesia is not the only
thing that can satisfy it**. The intended second implementation is a reusable single-module
standalone host — conceptually `@ekklesia/standalone-host` — which loads exactly one module and
supplies the same host contracts Ekklesia does: configuration, secrets, object storage, HTTP, module
lifecycle, and later realtime and display/UI hosting.
**No such package exists yet, and this pass does not create one.** It is recorded here because it is
what several contract decisions are *for*, and because a change that quietly makes it unimplementable
is a change worth catching. The test is simple: a module must never be able to ask which host is
running it. There is deliberately no `runningInsideEkklesia`, no `standaloneMode`, and no value a
module could branch on — a module names logical things (a bucket id, a configuration key, a route)
and the host resolves them.
Object storage is the current worked example. `StorageProvider` is the seam: everything above it —
the scoped facade, binding resolution, delivery URLs and their bounds, lifecycle eligibility — is
host-neutral, and a standalone host implements one interface to get all of it, including direct
browser delivery ([ADR 0031](decisions/0031-direct-object-delivery.md)).
### Storage servers are shared infrastructure, never per-module
- A module hosted under Ekklesia uses **Ekklesia's** storage service, through its own bindings.
- A module under a standalone host uses whatever that host was given — a bundled service, or one
shared with other deployments.
- A module runtime **never starts a storage service itself**, and nothing in the module contract
could express doing so.
Several standalone applications may share one storage deployment; that is exactly why physical bucket
names carry a configurable installation prefix alongside the module and bucket ids. Tying one storage
server to one module would multiply operational surface for a church running a handful of small
applications, and would make the shared-service deployment — the normal one — the exception.
## Security invariants
- Authorization is enforced server-side.
@@ -380,5 +413,28 @@ resolver, propagating to required dependents. Crucially, eligibility asks whethe
operational error a module retries through, never a lifecycle state, so the platform is no less
resilient than the products it will absorb. Startup performs no storage I/O at all. Credentials are
deployment-supplied, never persisted, never routed through module configuration, and never visible to
modules, module UI, diagnostics, or API responses. Browsers reach objects through a module's own
routes; signed URLs and public object delivery are deferred.
modules, module UI, diagnostics, or API responses.
Phase 7A pass 4 takes Ekklesia out of the data path
([ADR 0031](decisions/0031-direct-object-delivery.md)). A module asks a logical bucket for a
temporary, browser-usable URL — `createReadUrl(key)` returning `{ url, expiresAt }` — and the browser
fetches the object from the storage service directly. The contract still names no mechanism: signing,
expiry format, and endpoint stay below the provider seam, so a host with no S3 in it could satisfy
the same method. The URL is opaque to module code and a **bearer capability** to whoever holds it, so
the module authorizes the request in its own route *before* asking for one; storage bounds reach, not
identity. Lifetimes are bounded (a minute to six hours, fifteen minutes by default) and out-of-range
requests are refused rather than clamped. Logical keys stay canonical state; URLs are generated on
demand and never persisted.
This is what makes media practical: a `Range` request against a delivery URL is answered by the
storage service directly, so byte-range seeking needs no proxy in Core. Signing is a local
computation, so a slideshow can build a URL per slide without a single round trip, and repeated calls
inside a window return a byte-identical URL so browser caches and URL-keyed DOM elements hold.
Because an S3-compatible signature covers the request's `Host`, a URL cannot be signed for one origin
and rewritten to another. The origin a *browser* uses is therefore a signing input
(`S3_DELIVERY_ENDPOINT`), defaulting to the provider endpoint — which is why the standard deployment
configures nothing. A reverse proxy in front of storage must pass the original `Host` through
unchanged; it may terminate TLS, because the scheme is not signed. Streaming through a module's own
route remains available for responses that must be computed or authorized per byte, and signed
**write** URLs are deliberately not offered.