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
@@ -0,0 +1,214 @@
|
||||
# 0031: Browsers fetch objects directly, through opaque temporary read URLs
|
||||
|
||||
- Status: Accepted
|
||||
- Date: 2026-08-23
|
||||
- Extends: [0004](0004-replace-unmaintained-minio.md),
|
||||
[0021](0021-modules-as-hostable-products.md),
|
||||
[0028](0028-module-operational-lifecycle.md),
|
||||
[0030](0030-host-provided-module-object-storage.md)
|
||||
|
||||
## Context
|
||||
|
||||
[ADR 0030](0030-host-provided-module-object-storage.md) gave a module a scoped `context.storage` and
|
||||
took every provider detail away from it. That is right for module code, but it 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 travels storage → Ekklesia → browser, and Ekklesia's
|
||||
event loop carries it.
|
||||
|
||||
For the product this platform exists to replace that is not a hypothetical cost. `The_River/Announcements`
|
||||
(commit `9aab486`) is a slideshow: a display renders full-screen images for hours, rotating every
|
||||
seven seconds, and the server rebuilds its slide list every thirty. Proxying it would put Ekklesia in
|
||||
the path of every image on every display in the building, permanently, to deliver bytes that never
|
||||
change. Worse, it would make Ekklesia responsible for re-implementing what an object store already
|
||||
does — byte ranges above all, without which video seeking cannot work at all.
|
||||
|
||||
The existing application solves this by making the bucket world-readable and hardcoding its address:
|
||||
|
||||
```js
|
||||
slides = files.map((name) => `http://10.40.0.2:9000/announcements/${encode(name)}`);
|
||||
```
|
||||
|
||||
Direct delivery, at the cost of a permanently public bucket, an origin compiled into the client, and
|
||||
no per-object control of any kind. We want the delivery path and none of the cost.
|
||||
|
||||
## Decision
|
||||
|
||||
A module can ask a logical bucket for a temporary, browser-usable URL for one object:
|
||||
|
||||
```ts
|
||||
const assets = context.storage.bucket("announcement-assets");
|
||||
const { url, expiresAt } = await assets.createReadUrl("slides/welcome.png");
|
||||
```
|
||||
|
||||
The browser then fetches the object from the host's storage service directly. Ekklesia is not in the
|
||||
data path.
|
||||
|
||||
### The contract names intent, not mechanism
|
||||
|
||||
`createReadUrl` is the whole surface. There is no `presign`, no expiry format, no signature, no
|
||||
region, no endpoint, and no bucket — a module names the key it already knows and receives something a
|
||||
browser can use. The first implementation is S3-compatible presigned `GET`, but nothing about that
|
||||
reaches the contract: a host with no S3 anywhere in it could satisfy the same method by minting its
|
||||
own opaque token, and no module would change. This is the same reasoning that keeps `ModuleStorage`
|
||||
free of provider vocabulary in ADR 0030, applied to delivery.
|
||||
|
||||
The return value is `{ url, expiresAt }` and deliberately nothing else. `expiresAt` exists so that a
|
||||
caller can decide *when to ask again* without reading the URL, which is what makes "the URL is
|
||||
opaque" an honest rule rather than an aspiration.
|
||||
|
||||
### A read URL is a bearer capability
|
||||
|
||||
Anyone holding the URL can read that one object until it expires. It is not an authenticated
|
||||
request, and this ADR does not pretend otherwise.
|
||||
|
||||
The division of responsibility is therefore explicit:
|
||||
|
||||
- **Whether this person may have the URL** is the module's own authorization decision, made in its
|
||||
route before it asks for one. Storage does not know about people, permissions, or principals.
|
||||
- **How far the URL reaches once issued** is the storage contract's guarantee: one object, in one
|
||||
module's bucket, for a bounded time.
|
||||
|
||||
Storage authorization does not replace domain authorization, and the contract deliberately offers no
|
||||
hook that would let it try.
|
||||
|
||||
### Expiry is bounded, and the bounds come from the reference consumer
|
||||
|
||||
- **Default 15 minutes.** Announcements rebuilds its slide list every 30 seconds and re-broadcasts
|
||||
state every 7. Fifteen minutes is roughly thirty times that natural refresh, so a display that
|
||||
loses its connection for ten minutes still renders every slide the moment it reconnects — without
|
||||
anyone building a refresh mechanism.
|
||||
- **Floor of 1 minute.** Below that, ordinary clock skew between Ekklesia and the storage service
|
||||
can expire a URL before a browser can spend it.
|
||||
- **Ceiling of 6 hours.** This bounds the damage when a URL leaks. It outlasts any single gathering
|
||||
while staying nowhere near a durable credential, and it binds well before the seven-day maximum
|
||||
S3-compatible signing itself permits — so the contract's limit is the one that applies.
|
||||
|
||||
Out-of-range requests are **refused, not clamped**. Silently shortening a lifetime would surface
|
||||
later as URLs expiring for no visible reason; silently lengthening one would weaken a capability
|
||||
without telling anyone. "Expires forever" is unreachable rather than discouraged.
|
||||
|
||||
### Logical keys are canonical state; URLs are not
|
||||
|
||||
A module persists the object key. It generates a URL when something is about to be delivered and then
|
||||
forgets it. Storing a URL would be storing a credential with an expiry date embedded in it, and it
|
||||
would break the moment the deployment's delivery origin changed.
|
||||
|
||||
### Read URLs are stable within a window
|
||||
|
||||
Signing is a pure function of the request and the clock, so signing the same object twice a second
|
||||
apart produces two different URLs for identical bytes. That is not merely untidy. A browser caches by
|
||||
URL, so a changed query string is a guaranteed cache miss — and UI frameworks routinely key list
|
||||
items by URL. The reference client renders:
|
||||
|
||||
```jsx
|
||||
<img key={`current-${currentImage}`} src={currentImage} />
|
||||
```
|
||||
|
||||
A new URL for an unchanged image therefore tears the element down and re-downloads it. A slideshow
|
||||
rebuilding its state every few seconds would re-fetch every slide continuously, having gained a
|
||||
direct path to storage and lost the caching that made it worth having.
|
||||
|
||||
So the signing timestamp is rounded down to a window of one third of the requested lifetime. Repeated
|
||||
calls within that window return a **byte-identical** URL, and at least two-thirds of the lifetime
|
||||
always remains when one is handed out. Ekklesia also sets `Cache-Control: private, max-age=N` at
|
||||
signing time, where `N` is the guaranteed remainder — the pinned SeaweedFS image returns no
|
||||
`Cache-Control` of its own, which would otherwise leave browsers guessing.
|
||||
|
||||
### The provider endpoint and the browser delivery origin are different things
|
||||
|
||||
They cannot be collapsed, and this was measured rather than assumed. An S3-compatible signature
|
||||
covers the request's **`Host` header**, so a URL signed for one origin and then re-pointed at another
|
||||
fails verification. Rewriting after signing is not available.
|
||||
|
||||
The browser-facing origin is therefore a **signing input**. `S3_DELIVERY_ENDPOINT` sets it, and when
|
||||
unset it is the provider endpoint — which is the ordinary case and the reason a normal deployment
|
||||
configures nothing.
|
||||
|
||||
What the signature does **not** cover is the scheme. Terminating TLS at a reverse proxy and
|
||||
forwarding plaintext to the storage service verifies correctly, **provided the proxy passes the
|
||||
original `Host` through unchanged**. That is the single configuration requirement direct delivery
|
||||
imposes on a deployment, and it is pinned by an integration test rather than described here and
|
||||
hoped for.
|
||||
|
||||
### Zero configuration for an ordinary deployment
|
||||
|
||||
The development stack publishes the storage service on loopback, so `S3_ENDPOINT` is already an
|
||||
origin a browser can reach and serves both roles. An operator running the standard stack configures
|
||||
no endpoint, no keys, no bucket, and no delivery origin; modules declare logical buckets, bindings
|
||||
provision themselves, and delivery URLs work. `S3_DELIVERY_ENDPOINT` exists for the deployment where
|
||||
Core reaches storage by a private service name — and only then.
|
||||
|
||||
### What a browser sees
|
||||
|
||||
A read URL reveals the storage origin, the physical bucket name, the object key, and signing query
|
||||
parameters. This is accepted and stated plainly rather than worked around.
|
||||
|
||||
Physical bucket names are **implementation metadata, not secrets**. Nothing is authorized by knowing
|
||||
one: the bucket is not publicly readable, and a URL's signature covers its own path, so editing the
|
||||
bucket or key in a URL produces a rejection rather than another object. Hiding them would mean
|
||||
reintroducing a proxy layer and recreating the data hop this ADR exists to remove — a real cost for a
|
||||
cosmetic gain.
|
||||
|
||||
The distinction that matters is between **module code**, which must never depend on these details and
|
||||
cannot reach them, and **the browser**, which inevitably sees the URL it is fetching.
|
||||
|
||||
## Consequences
|
||||
|
||||
### What this buys
|
||||
|
||||
Byte ranges work directly against the storage service — verified, `206` with a correct
|
||||
`Content-Range` — so video seeking becomes the provider's job and needs no range proxy in Core. This
|
||||
was the single largest reason to prefer direct delivery over proxying, and it removes a whole
|
||||
subsystem from the platform's future.
|
||||
|
||||
Signing is a local computation: 200 URLs took 66 ms with no network traffic. A slideshow can build
|
||||
forty delivery URLs as cheaply as it builds forty strings, so no batch API is needed and none is
|
||||
added.
|
||||
|
||||
### What it costs
|
||||
|
||||
A leaked URL is usable until it expires. That is the nature of the mechanism, and it is why the
|
||||
ceiling is 6 hours rather than a day and why URLs are never logged.
|
||||
|
||||
`createReadUrl` does not check that the object exists. A URL for a missing key — or one deleted after
|
||||
signing — returns `404` when fetched, which is what a browser already handles. Verifying up front
|
||||
would add a round trip to every URL, and a module listing forty slides would pay forty of them for
|
||||
something it just read.
|
||||
|
||||
### Logging and diagnostics
|
||||
|
||||
A read URL is treated as a bearer token. Complete URLs are never logged and never printed by Doctor.
|
||||
Doctor reports the storage origin, and the delivery origin when it differs, and says explicitly that
|
||||
a separate delivery origin was **not** probed — reporting "reachable" without that caveat would let
|
||||
an operator conclude direct delivery works when only half of it was tested.
|
||||
|
||||
### CORS
|
||||
|
||||
The pinned SeaweedFS image reflects any `Origin` back in `Access-Control-Allow-Origin` and does not
|
||||
set `Access-Control-Allow-Credentials`. Read URLs carry no cookies and no browser credentials, so a
|
||||
reflected origin grants nothing beyond what possession of the URL already grants. Ekklesia therefore
|
||||
configures no CORS policy of its own in this pass and relies on the provider's default, which is
|
||||
sufficient for element loads (`<img>`, `<video>`) and for `fetch` without credentials. A deployment
|
||||
that wants a narrower policy sets it on the storage service or its reverse proxy. This is recorded as
|
||||
an observed default rather than an endorsement.
|
||||
|
||||
### Lifecycle is unchanged
|
||||
|
||||
Storage lifecycle semantics stay exactly as ADR 0030 set them. A signing failure is an operational
|
||||
error a module may retry; it never changes a module's runtime state. Nothing here makes a running
|
||||
module blocked.
|
||||
|
||||
### Standalone-host compatibility
|
||||
|
||||
Everything a module touches — `createReadUrl`, the bounds, the `{ url, expiresAt }` shape — lives
|
||||
above the provider seam. A future single-module standalone host implements `StorageProvider` and
|
||||
gets delivery for free. A module never asks which host is running it, and there is deliberately no
|
||||
value it could branch on.
|
||||
|
||||
### Deferred
|
||||
|
||||
Signed **write** and **delete** URLs are not built. Uploads still go through a module route, where
|
||||
the module can validate what it is accepting; handing a browser a capability to write into a
|
||||
module's bucket needs its own decision about size, type, and quota, and does not belong in the pass
|
||||
that established reads. Realtime URL refresh is not built either — ordinary API reloads are
|
||||
sufficient, and a future realtime pass can refresh state and URLs together when it exists.
|
||||
@@ -39,3 +39,4 @@ ADRs capture durable choices that affect more than one package or phase.
|
||||
- [0028: Module operational lifecycle — enablement, eligibility, and what "running" means](0028-module-operational-lifecycle.md) — Accepted
|
||||
- [0029: Module configuration is manifest-declared, host-resolved, and secret-free at rest](0029-module-configuration-and-secrets.md) — Accepted
|
||||
- [0030: Modules declare logical buckets; hosts provide object storage](0030-host-provided-module-object-storage.md) — Accepted
|
||||
- [0031: Browsers fetch objects directly, through opaque temporary read URLs](0031-direct-object-delivery.md) — Accepted
|
||||
|
||||
@@ -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.
|
||||
|
||||
+34
-4
@@ -682,6 +682,7 @@ and no route returns a provider endpoint or credential.
|
||||
| `S3_REGION` | Defaults to `us-east-1` |
|
||||
| `S3_FORCE_PATH_STYLE` | Defaults to `true`; self-hosted services need it |
|
||||
| `S3_BUCKET_PREFIX` | Defaults to `ekklesia`; namespaces this installation on a shared service |
|
||||
| `S3_DELIVERY_ENDPOINT` | The origin a **browser** reaches storage on. Defaults to `S3_ENDPOINT`; set it only when that is not browser-reachable |
|
||||
|
||||
Production requires `https`, exactly as the database transport requires verified TLS. An endpoint
|
||||
embedding credentials is rejected rather than stripped. These values reach Core's provider and
|
||||
@@ -690,6 +691,16 @@ nothing else — never a module, module UI, browser bundle, diagnostic, or API r
|
||||
The repository's development Compose stack already runs SeaweedFS for this, and `.env.example`
|
||||
already carries matching values.
|
||||
|
||||
`S3_DELIVERY_ENDPOINT` exists because an S3-compatible signature covers the request's `Host`, so a
|
||||
delivery URL cannot be signed for one origin and rewritten to another — the browser-facing origin has
|
||||
to be known when the URL is signed. Leave it unset whenever `S3_ENDPOINT` is itself reachable from a
|
||||
browser, which is true of the development stack (Compose publishes storage on loopback) and of any
|
||||
deployment where Core and browsers use the same name. Set it when Core reaches storage privately —
|
||||
`http://object-storage:8333` — and browsers reach it publicly. The scheme is *not* signed, so a
|
||||
reverse proxy may terminate TLS and forward plaintext, **provided it passes the original `Host`
|
||||
header through unchanged**; a proxy that rewrites `Host` to its backend makes every delivery URL fail
|
||||
with `403`.
|
||||
|
||||
### Logical buckets, and what modules get
|
||||
|
||||
A module declares logical buckets in its manifest (`storage.buckets`) and receives a scoped facade at
|
||||
@@ -717,14 +728,33 @@ never prevents the platform from booting, and nothing ever deletes a bucket.
|
||||
|
||||
### Serving objects to browsers
|
||||
|
||||
There are no signed URLs and no public object routing. A module streams objects through its own
|
||||
authorized route:
|
||||
A module can hand a browser a temporary URL and let it fetch the object from storage directly
|
||||
([ADR 0031](../architecture/decisions/0031-direct-object-delivery.md)):
|
||||
|
||||
```text
|
||||
browser → GET /api/v1/modules/<id>/assets/<key> → context.storage → object bytes
|
||||
browser → GET /api/v1/modules/<id>/slides → module route → { key, url, expiresAt }
|
||||
browser → GET <url> ─────────────────────────────────────→ storage service
|
||||
```
|
||||
|
||||
which keeps every S3 concern server-side and gives module UI no credentials.
|
||||
The module authorizes the request in its own route, asks its logical bucket for a URL, and returns
|
||||
it. Ekklesia is not in the data path, so byte ranges, caching, and revalidation are the storage
|
||||
service's job — a `Range` request against a delivery URL is answered directly with `206`, which is
|
||||
what makes media seeking work without a range proxy in Core.
|
||||
|
||||
```ts
|
||||
const assets = context.storage.bucket("announcement-assets");
|
||||
const { url, expiresAt } = await assets.createReadUrl("slides/welcome.png");
|
||||
```
|
||||
|
||||
A delivery URL is a **bearer capability**: whoever holds it can read that one object until it
|
||||
expires. Deciding who may have one is the module's authorization decision, made before it asks;
|
||||
the storage contract only bounds how far the URL reaches. Treat a complete URL as a secret — never
|
||||
log one (see [Security](security.md#object-storage-is-scoped-by-binding-and-modules-never-hold-credentials)).
|
||||
|
||||
Streaming through the module's own route with `context.storage.get(...)` remains available and is
|
||||
still the right choice when the response must be computed, transformed, or authorized per byte.
|
||||
There are no signed **write** URLs: uploads go through a module route, where the module can validate
|
||||
what it is accepting.
|
||||
|
||||
## Module HTTP routes
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user