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
@@ -209,12 +209,43 @@ no endpoint or XML, two modules declaring the same logical bucket staying genuin
|
||||
server, the longest bucket name the mapping can produce being accepted, a real connection failure
|
||||
mapping to `STORAGE_PROVIDER_UNAVAILABLE`, and a bucket surviving the deletion of every object in it.
|
||||
|
||||
An eighth suite (`core/tests/module-delivery.integration.ts`, same harness) proves direct browser
|
||||
delivery against that same live server, because every claim it makes is a property of the agreement
|
||||
between the signer and the provider rather than of code in this repository
|
||||
([ADR 0031](../architecture/decisions/0031-direct-object-delivery.md)). It pins: a delivery URL
|
||||
returning the object with its content type intact to an ordinary credential-free `fetch`; the same
|
||||
object being refused **without** the URL's authorization, so delivery can never be quietly "fixed" by
|
||||
making a bucket public; tampered signatures, swapped keys, and swapped buckets all rejected; expiry
|
||||
actually enforced; a `Range` request answered directly with `206` and a correct `Content-Range`;
|
||||
repeated calls inside a window returning a byte-identical URL so browser caches and URL-keyed DOM
|
||||
elements hold; `Cache-Control` never outliving the URL; an optional download filename; and two
|
||||
modules' identical logical bucket names resolving to different objects.
|
||||
|
||||
Its most load-bearing tests concern the **browser delivery origin**. They sign for a public origin and
|
||||
replay the request against the real backend through `node:http` with an explicit `Host` header —
|
||||
which `fetch` cannot do, since `Host` is a forbidden header name there and it would silently send the
|
||||
wrong one and prove nothing. Together they establish that a signature survives a reverse proxy that
|
||||
preserves `Host` (even across TLS termination, because the scheme is not signed), that it fails when
|
||||
a proxy rewrites `Host`, and that a URL signed for the internal endpoint cannot be rewritten to the
|
||||
public one afterwards — which is the entire reason the delivery origin is a signing input rather than
|
||||
a post-processing step.
|
||||
|
||||
An Announcements-shaped fixture in the same file models the real slideshow: it lists its own slides,
|
||||
filters images, builds a delivery URL for each, and fetches every one directly. It is written to
|
||||
demonstrate what it *cannot* say — its whole world is a `ModuleStorage` and two names from its own
|
||||
manifest — and it asserts that the module's durable state holds keys while only the URL carries
|
||||
host detail. It also proves that forty URLs cost no provider round trips.
|
||||
|
||||
Unit suites (`core/tests/module-storage.test.ts`, `core/tests/module-storage-lifecycle.test.ts`) use an
|
||||
injected in-memory provider and never touch the network. They prove the parts the protocol cannot:
|
||||
binding determinism and collision-safety, scoping and cross-module isolation, key validation, lazy
|
||||
provisioning and retry-after-failure, configuration validation and credential redaction, and the
|
||||
lifecycle integration including dependency propagation. See
|
||||
[ADR 0030](../architecture/decisions/0030-host-provided-module-object-storage.md).
|
||||
lifecycle integration including dependency propagation. They also own the delivery bounds, which need
|
||||
no server at all: every out-of-range lifetime refused rather than clamped, every download filename
|
||||
that could inject a response header refused, and the module-facing surface pinned so a new accessor
|
||||
cannot appear unnoticed. See
|
||||
[ADR 0030](../architecture/decisions/0030-host-provided-module-object-storage.md) and
|
||||
[ADR 0031](../architecture/decisions/0031-direct-object-delivery.md).
|
||||
|
||||
### Which kind of timing proof lives where
|
||||
|
||||
|
||||
Reference in New Issue
Block a user