feat: deliver module objects directly to browsers through opaque read URLs
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:
co-authored by
Claude Opus 5
parent
9a3b8dab8f
commit
7ff063b87a
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user