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
+52 -1
View File
@@ -1,15 +1,21 @@
import {
EMPTY_MODULE_STORAGE,
isValidModuleStorageKey,
isValidModuleStorageReadUrlFilename,
MODULE_STORAGE_READ_URL_DEFAULT_SECONDS,
MODULE_STORAGE_READ_URL_MAX_SECONDS,
MODULE_STORAGE_READ_URL_MIN_SECONDS,
type ModuleStorage,
type ModuleStorageBody,
type ModuleStorageBucket,
ModuleStorageError,
type ModuleStorageListOptions,
type ModuleStorageListing,
type ModuleStorageListOptions,
type ModuleStorageObject,
type ModuleStorageObjectMetadata,
type ModuleStoragePutOptions,
type ModuleStorageReadUrl,
type ModuleStorageReadUrlOptions,
} from "@ekklesia/contracts";
import { type ModuleStorageBinding, resolveModuleStorageBindings } from "./binding.js";
import type { StorageProvider } from "./provider.js";
@@ -58,6 +64,27 @@ function assertValidKey(key: unknown): asserts key is string {
throw new ModuleStorageError("STORAGE_KEY_INVALID", echo);
}
/**
* Resolves a requested read-URL lifetime against the contract's bounds.
*
* Enforced here rather than in a provider so every provider agrees on what a module may ask for, and
* so an out-of-range request is refused rather than quietly clamped — a module that asks for a
* week's lifetime has a misunderstanding worth surfacing, not a value worth rounding.
*/
function resolveExpirySeconds(requested: number | undefined): number {
if (requested === undefined) {
return MODULE_STORAGE_READ_URL_DEFAULT_SECONDS;
}
if (
!Number.isInteger(requested) ||
requested < MODULE_STORAGE_READ_URL_MIN_SECONDS ||
requested > MODULE_STORAGE_READ_URL_MAX_SECONDS
) {
throw new ModuleStorageError("STORAGE_READ_URL_EXPIRY_INVALID");
}
return requested;
}
function createBucketHandle(
provider: StorageProvider,
binding: ModuleStorageBinding,
@@ -69,6 +96,30 @@ function createBucketHandle(
// where the host keeps its data.
id: binding.logicalBucketId,
async createReadUrl(
key: string,
options: ModuleStorageReadUrlOptions = {},
): Promise<ModuleStorageReadUrl> {
assertValidKey(key);
const expiresInSeconds = resolveExpirySeconds(options.expiresIn);
if (
options.downloadFilename !== undefined &&
!isValidModuleStorageReadUrlFilename(options.downloadFilename)
) {
throw new ModuleStorageError("STORAGE_READ_URL_FILENAME_INVALID", key);
}
// The bucket is ensured for the same reason every other operation ensures it: a URL for a
// bucket that was never created would resolve to a failure the browser reports rather than one
// the module can see. Memoized per binding, so a module signing forty slides pays for it once.
await ensure();
return provider.createReadUrl(binding.physicalBucket, key, {
expiresInSeconds,
...(options.downloadFilename === undefined
? {}
: { downloadFilename: options.downloadFilename }),
});
},
async delete(key: string): Promise<void> {
assertValidKey(key);
await ensure();