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 { let pending: Promise | 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 { 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 { assertValidKey(key); await ensure(); await provider.delete(binding.physicalBucket, key); }, async get(key: string): Promise { assertValidKey(key); await ensure(); return provider.get(binding.physicalBucket, key); }, async head(key: string): Promise { assertValidKey(key); await ensure(); return provider.head(binding.physicalBucket, key); }, async list(options: ModuleStorageListOptions = {}): Promise { await ensure(); return provider.list(binding.physicalBucket, options); }, async put( key: string, body: ModuleStorageBody, options: ModuleStoragePutOptions = {}, ): Promise { 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( 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 { if (!provider) { return new Map(); } return new Map( modules.map((module) => [ module.moduleId, createModuleStorage({ bucketPrefix, logicalBucketIds: module.logicalBucketIds, moduleId: module.moduleId, provider, }), ]), ); }