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
@@ -33,6 +33,12 @@ export const MODULE_STORAGE_ERROR_CODES = [
|
||||
* buffering would defeat the only reason to pass a stream, and discovering that through memory
|
||||
* pressure in production is worse than discovering it at the call site. */
|
||||
"STORAGE_CONTENT_LENGTH_REQUIRED",
|
||||
/** A requested read-URL lifetime is outside the range this contract allows. Refused rather than
|
||||
* clamped: silently shortening a lifetime a module asked for would surface later as URLs expiring
|
||||
* for no visible reason, and silently lengthening one would weaken a bearer capability. */
|
||||
"STORAGE_READ_URL_EXPIRY_INVALID",
|
||||
/** A read-URL download filename contains something that cannot be put in a response header. */
|
||||
"STORAGE_READ_URL_FILENAME_INVALID",
|
||||
/** Anything else the provider reported, reduced to a code. */
|
||||
"STORAGE_OPERATION_FAILED",
|
||||
] as const;
|
||||
@@ -48,6 +54,8 @@ const storageErrorMessages: Readonly<Record<ModuleStorageErrorCode, string>> = O
|
||||
STORAGE_OPERATION_DENIED: "The storage operation was denied.",
|
||||
STORAGE_OPERATION_FAILED: "The storage operation failed.",
|
||||
STORAGE_PROVIDER_UNAVAILABLE: "The storage service could not be reached.",
|
||||
STORAGE_READ_URL_EXPIRY_INVALID: "The requested read URL lifetime is out of range.",
|
||||
STORAGE_READ_URL_FILENAME_INVALID: "The read URL download filename is invalid.",
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -167,6 +175,97 @@ export interface ModuleStorageListing {
|
||||
readonly cursor?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* How long a read URL may live, in seconds.
|
||||
*
|
||||
* The floor exists because a lifetime shorter than a minute is not reliably usable: the host and the
|
||||
* storage service keep their own clocks, and a few seconds of skew between them would expire a URL
|
||||
* before the browser could spend it.
|
||||
*
|
||||
* The default is deliberately far longer than any caller needs to *use* a URL and far shorter than a
|
||||
* credential. The reference consumer this was measured against — a church slideshow — hands its
|
||||
* display a complete, freshly built state every few seconds and rebuilds that state from storage
|
||||
* every thirty; 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.
|
||||
*
|
||||
* The ceiling is what bounds the damage when a URL leaks. A read URL is a bearer capability: whoever
|
||||
* holds it can read that one object until it expires. Six hours outlasts any single gathering while
|
||||
* staying nowhere near durable — and it is well inside the seven-day maximum S3-compatible signing
|
||||
* itself permits, so the contract's limit binds before any provider's does.
|
||||
*/
|
||||
export const MODULE_STORAGE_READ_URL_MIN_SECONDS = 60;
|
||||
export const MODULE_STORAGE_READ_URL_MAX_SECONDS = 6 * 60 * 60;
|
||||
export const MODULE_STORAGE_READ_URL_DEFAULT_SECONDS = 15 * 60;
|
||||
|
||||
/** The longest download filename this contract accepts. Generous for a real filename, short enough
|
||||
* that no header built from it approaches a provider's limit. */
|
||||
export const MODULE_STORAGE_READ_URL_FILENAME_MAX_LENGTH = 128;
|
||||
|
||||
/**
|
||||
* Whether a value is a download filename this contract accepts.
|
||||
*
|
||||
* This is stricter than what a filesystem allows, and deliberately so: the value ends up inside a
|
||||
* `Content-Disposition` **response header**. A carriage return or line feed in a header value is a
|
||||
* header-injection primitive, and a double quote closes the quoted string early and lets the rest be
|
||||
* read as further header parameters. Rather than escape those and hope every provider unescapes them
|
||||
* the same way, the contract accepts only characters that need no escaping at all.
|
||||
*
|
||||
* A path separator is refused for the same reason a leading `/` is refused in an object key: the
|
||||
* name is a label the browser saves under, not a location, and one that looks like a path invites a
|
||||
* browser or an operating system to interpret it as one.
|
||||
*/
|
||||
export function isValidModuleStorageReadUrlFilename(value: unknown): value is string {
|
||||
if (typeof value !== "string" || value === "") {
|
||||
return false;
|
||||
}
|
||||
if (value.length > MODULE_STORAGE_READ_URL_FILENAME_MAX_LENGTH) {
|
||||
return false;
|
||||
}
|
||||
if (value === "." || value === "..") {
|
||||
return false;
|
||||
}
|
||||
// Letters, digits, and a small set of separators every filesystem and header encoding agrees on.
|
||||
return /^[A-Za-z0-9][A-Za-z0-9 ._()-]*$/u.test(value);
|
||||
}
|
||||
|
||||
export interface ModuleStorageReadUrlOptions {
|
||||
/** Seconds the URL stays valid, between `MODULE_STORAGE_READ_URL_MIN_SECONDS` and
|
||||
* `MODULE_STORAGE_READ_URL_MAX_SECONDS`. Omit to take the host's default. */
|
||||
readonly expiresIn?: number;
|
||||
/** Offers the object as a download under this name instead of displaying it inline.
|
||||
*
|
||||
* Inline is the default because these are page assets — an image or a video a browser is meant to
|
||||
* render, not save. This exists for the one case a module cannot otherwise reach: serving
|
||||
* something like a bulletin PDF as a named download would otherwise force the module to proxy
|
||||
* every byte through its own route, which is the hop this whole facility removes. */
|
||||
readonly downloadFilename?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A temporary, browser-usable URL for one object.
|
||||
*
|
||||
* **`url` is opaque.** Its shape, hostname, path, and query are the host's business and may change
|
||||
* with the host's deployment or provider; module code must not parse it, rewrite it, derive the
|
||||
* storage service's identity from it, or store it. Everything a caller legitimately needs to decide
|
||||
* *when to ask again* is in `expiresAt`, precisely so that nobody has to read the URL to find out.
|
||||
*
|
||||
* **It is a bearer capability, not an authenticated request.** Anyone who obtains it can read that
|
||||
* one object until it expires, with no further check of who they are. Deciding whether a given
|
||||
* person may have it is the module's own authorization decision, made before it asks for one — this
|
||||
* contract only guarantees that the URL reaches no further than the single object it names.
|
||||
*
|
||||
* **It is never canonical state.** The logical object key is what a module persists; a URL is
|
||||
* generated when something is about to be delivered and then forgotten. Storing one in a database
|
||||
* would be storing a credential with an expiry date in it.
|
||||
*/
|
||||
export interface ModuleStorageReadUrl {
|
||||
readonly url: string;
|
||||
/** When the URL stops working. A caller compares this against the clock to decide whether to ask
|
||||
* for a fresh one; it never has to inspect `url` to find out. */
|
||||
readonly expiresAt: Date;
|
||||
}
|
||||
|
||||
/**
|
||||
* One logical bucket a module declared, already bound to wherever the host keeps it.
|
||||
*
|
||||
@@ -186,6 +285,24 @@ export interface ModuleStorageBucket {
|
||||
options?: ModuleStoragePutOptions,
|
||||
): Promise<ModuleStorageObjectMetadata>;
|
||||
delete(key: string): Promise<void>;
|
||||
/**
|
||||
* A temporary URL a browser can fetch this object from directly.
|
||||
*
|
||||
* The point is to take the host out of the data path: without it, delivering an image means the
|
||||
* bytes travel storage → module route → browser, and the module's process carries every byte of
|
||||
* every asset. With it the browser talks to the host's storage service itself, and the module
|
||||
* handles one small response instead.
|
||||
*
|
||||
* Nothing about *how* is visible here. There is no signing, no endpoint, no expiry format, and no
|
||||
* provider — a module names the key it already knows and receives something a browser can use. A
|
||||
* host with no S3 anywhere in it could satisfy this same method.
|
||||
*
|
||||
* The object is not checked for existence first. A URL for a key that is missing — or that is
|
||||
* deleted after signing — simply fails when it is fetched, which is what a browser already knows
|
||||
* how to handle. Verifying up front would add a round trip to every URL, and a module listing
|
||||
* forty slides and asking for forty URLs would pay forty of them for information it just read.
|
||||
*/
|
||||
createReadUrl(key: string, options?: ModuleStorageReadUrlOptions): Promise<ModuleStorageReadUrl>;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user