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]>
237 lines
8.7 KiB
TypeScript
237 lines
8.7 KiB
TypeScript
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 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";
|
|
|
|
/**
|
|
* Builds the scoped storage facade one module receives.
|
|
*
|
|
* **This is where isolation is enforced, and it is enforced by construction.** The facade closes over
|
|
* exactly the bindings this module's own manifest declared; there is no lookup table spanning
|
|
* modules, no name that resolves outside those bindings, and no accessor taking a physical bucket.
|
|
* Module A cannot reach module B's storage by knowing its bucket name, its module id, or its
|
|
* physical bucket name, because none of those is an input to anything here (ADR 0030).
|
|
*
|
|
* Two modules that both declare `assets` receive two facades over two different physical buckets —
|
|
* see `physicalBucketName` for why that separation is collision-safe rather than merely likely.
|
|
*/
|
|
|
|
/** Ensures a binding's physical bucket exists, at most once per process per binding.
|
|
*
|
|
* Lazy rather than eager at startup, deliberately: a storage outage must never stop the platform
|
|
* from booting. The promise is memoized so concurrent first uses share one round trip, and a
|
|
* *failed* attempt is discarded so a transient outage does not poison the binding for the life of
|
|
* the process — the next call retries, which is exactly the resilience a module like a slideshow
|
|
* depends on (ADR 0030). */
|
|
function createBucketEnsurer(
|
|
provider: StorageProvider,
|
|
physicalBucket: string,
|
|
): () => Promise<void> {
|
|
let pending: Promise<void> | undefined;
|
|
return () => {
|
|
pending ??= provider.ensureBucket(physicalBucket).catch((error: unknown) => {
|
|
pending = undefined;
|
|
throw error;
|
|
});
|
|
return pending;
|
|
};
|
|
}
|
|
|
|
/** Echoes the offending key only when it is a short string — a module's own input is safe to hand
|
|
* back, but a non-string or an enormous value is not something to put into an error message. */
|
|
function assertValidKey(key: unknown): asserts key is string {
|
|
if (isValidModuleStorageKey(key)) {
|
|
return;
|
|
}
|
|
const echo = typeof key === "string" && key.length <= 256 ? key : undefined;
|
|
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,
|
|
): ModuleStorageBucket {
|
|
const ensure = createBucketEnsurer(provider, binding.physicalBucket);
|
|
|
|
return Object.freeze({
|
|
// The **logical** id, never the physical bucket. A module that logs this leaks nothing about
|
|
// 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();
|
|
await provider.delete(binding.physicalBucket, key);
|
|
},
|
|
|
|
async get(key: string): Promise<ModuleStorageObject> {
|
|
assertValidKey(key);
|
|
await ensure();
|
|
return provider.get(binding.physicalBucket, key);
|
|
},
|
|
|
|
async head(key: string): Promise<ModuleStorageObjectMetadata> {
|
|
assertValidKey(key);
|
|
await ensure();
|
|
return provider.head(binding.physicalBucket, key);
|
|
},
|
|
|
|
async list(options: ModuleStorageListOptions = {}): Promise<ModuleStorageListing> {
|
|
await ensure();
|
|
return provider.list(binding.physicalBucket, options);
|
|
},
|
|
|
|
async put(
|
|
key: string,
|
|
body: ModuleStorageBody,
|
|
options: ModuleStoragePutOptions = {},
|
|
): Promise<ModuleStorageObjectMetadata> {
|
|
assertValidKey(key);
|
|
if (body instanceof ReadableStream && options.contentLength === undefined) {
|
|
throw new ModuleStorageError("STORAGE_CONTENT_LENGTH_REQUIRED", key);
|
|
}
|
|
await ensure();
|
|
return provider.put(binding.physicalBucket, key, body, options);
|
|
},
|
|
});
|
|
}
|
|
|
|
export interface CreateModuleStorageOptions {
|
|
readonly provider: StorageProvider;
|
|
readonly bucketPrefix: string;
|
|
readonly moduleId: string;
|
|
/** The logical bucket ids from this module's own manifest, in declaration order. */
|
|
readonly logicalBucketIds: readonly string[];
|
|
}
|
|
|
|
/**
|
|
* The scoped `ModuleStorage` for one module.
|
|
*
|
|
* A module declaring no buckets gets `EMPTY_MODULE_STORAGE` — present, frozen, and refusing every
|
|
* bucket — so `context.storage` is never `undefined` and module code never branches on whether the
|
|
* host supplied storage.
|
|
*/
|
|
export function createModuleStorage(options: CreateModuleStorageOptions): ModuleStorage {
|
|
if (options.logicalBucketIds.length === 0) {
|
|
return EMPTY_MODULE_STORAGE;
|
|
}
|
|
const bindings = resolveModuleStorageBindings(
|
|
options.bucketPrefix,
|
|
options.moduleId,
|
|
options.logicalBucketIds,
|
|
);
|
|
// A `Map` rather than a plain object: every lookup key here comes from module code, and indexing a
|
|
// plain object with such a key is the shape of a prototype-pollution bug. With a `Map` there is no
|
|
// prototype to reach, so `constructor` or `__proto__` are ordinary absent keys.
|
|
const handles = new Map<string, ModuleStorageBucket>(
|
|
bindings.map((binding) => [
|
|
binding.logicalBucketId,
|
|
createBucketHandle(options.provider, binding),
|
|
]),
|
|
);
|
|
const declared = Object.freeze([...handles.keys()].sort());
|
|
|
|
return Object.freeze({
|
|
bucket: (logicalBucketId: string): ModuleStorageBucket => {
|
|
const handle = typeof logicalBucketId === "string" ? handles.get(logicalBucketId) : undefined;
|
|
if (!handle) {
|
|
// Including another module's bucket id, which is simply not a key in this map.
|
|
throw new ModuleStorageError("STORAGE_BUCKET_UNDECLARED", logicalBucketId);
|
|
}
|
|
return handle;
|
|
},
|
|
buckets: (): readonly string[] => declared,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Every module's storage, resolved once at startup.
|
|
*
|
|
* Returns an empty map when the host has no storage provider. That is not an error: an installation
|
|
* with no object storage is a complete, valid deployment, and only modules that *declared* a bucket
|
|
* are affected — those are reported blocked by the lifecycle resolver before any of this is reached
|
|
* (ADR 0030).
|
|
*/
|
|
export function createModuleStorageLookup(
|
|
provider: StorageProvider | undefined,
|
|
bucketPrefix: string,
|
|
modules: readonly { readonly moduleId: string; readonly logicalBucketIds: readonly string[] }[],
|
|
): ReadonlyMap<string, ModuleStorage> {
|
|
if (!provider) {
|
|
return new Map();
|
|
}
|
|
return new Map(
|
|
modules.map((module) => [
|
|
module.moduleId,
|
|
createModuleStorage({
|
|
bucketPrefix,
|
|
logicalBucketIds: module.logicalBucketIds,
|
|
moduleId: module.moduleId,
|
|
provider,
|
|
}),
|
|
]),
|
|
);
|
|
}
|