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]>
72 lines
3.2 KiB
TypeScript
72 lines
3.2 KiB
TypeScript
import type {
|
|
ModuleStorageBody,
|
|
ModuleStorageListing,
|
|
ModuleStorageListOptions,
|
|
ModuleStorageObject,
|
|
ModuleStorageObjectMetadata,
|
|
ModuleStoragePutOptions,
|
|
ModuleStorageReadUrl,
|
|
} from "@ekklesia/contracts";
|
|
|
|
/**
|
|
* The seam between Core's module-facing storage facade and whatever actually stores bytes.
|
|
*
|
|
* This is the **portability boundary made executable**. Everything above it — the scoped facade,
|
|
* binding resolution, lifecycle eligibility, diagnostics — is provider-agnostic and would be
|
|
* identical in a standalone host. Everything below it is one implementation
|
|
* (`createS3StorageProvider`) that could be replaced without any module changing a line.
|
|
*
|
|
* It deliberately speaks in **physical buckets**: resolving a module's logical bucket to a physical
|
|
* one has already happened by the time anything reaches here (`./binding.js`), so a provider never
|
|
* has to know what a module is. That split is what keeps scoping enforcement in exactly one place
|
|
* rather than duplicated into every provider.
|
|
*
|
|
* Also deliberately absent: any operation that *destroys* a bucket. Core never deletes a bucket, so
|
|
* there is no method to call by mistake, and disabling or removing a module cannot take data with it
|
|
* (ADR 0030).
|
|
*/
|
|
export interface StorageProvider {
|
|
/**
|
|
* Makes sure a physical bucket exists, idempotently.
|
|
*
|
|
* Called lazily on first use of a binding rather than at startup, so a storage outage can never
|
|
* prevent the platform from booting.
|
|
*/
|
|
ensureBucket(physicalBucket: string): Promise<void>;
|
|
list(physicalBucket: string, options: ModuleStorageListOptions): Promise<ModuleStorageListing>;
|
|
head(physicalBucket: string, key: string): Promise<ModuleStorageObjectMetadata>;
|
|
get(physicalBucket: string, key: string): Promise<ModuleStorageObject>;
|
|
put(
|
|
physicalBucket: string,
|
|
key: string,
|
|
body: ModuleStorageBody,
|
|
options: ModuleStoragePutOptions,
|
|
): Promise<ModuleStorageObjectMetadata>;
|
|
delete(physicalBucket: string, key: string): Promise<void>;
|
|
/**
|
|
* A URL a browser can read one object from directly, valid for `expiresIn` seconds.
|
|
*
|
|
* The seconds and the download filename arrive already validated against the contract's bounds —
|
|
* a provider enforces neither, so two providers can never disagree about what a module is allowed
|
|
* to ask for.
|
|
*
|
|
* A provider that cannot issue such a URL has no business implementing this interface: the
|
|
* facility is part of the storage contract, not an optional extra a module has to feel around for.
|
|
* An implementation with no notion of signing could satisfy it by minting its own opaque token.
|
|
*/
|
|
createReadUrl(
|
|
physicalBucket: string,
|
|
key: string,
|
|
options: {
|
|
readonly expiresInSeconds: number;
|
|
readonly downloadFilename?: string | undefined;
|
|
},
|
|
): Promise<ModuleStorageReadUrl>;
|
|
/** A cheap reachability probe for diagnostics only. Its result is **never** persisted and never
|
|
* influences a module's lifecycle state — a provider being briefly unreachable is an operational
|
|
* condition, not an administrative decision (ADR 0030). */
|
|
probe(): Promise<void>;
|
|
/** Releases provider connections. Called once, during API shutdown. */
|
|
close(): Promise<void>;
|
|
}
|