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
+1
View File
@@ -54,6 +54,7 @@
},
"dependencies": {
"@aws-sdk/client-s3": "3.1116.0",
"@aws-sdk/s3-request-presigner": "3.1116.0",
"@ekklesia/contracts": "workspace:*",
"better-auth": "1.6.23",
"drizzle-orm": "0.45.2",
+9
View File
@@ -1049,6 +1049,15 @@ export async function diagnoseEkklesia(
await dependencies.probeStorage(options.storage);
storageCheck = freezeCheck("object-storage.integration", "pass", "Object storage reachable", [
describeStorageProvider(options.storage),
// Said plainly rather than implied: this probe used the endpoint Core talks to, and a
// separate browser-facing origin is by definition something a browser reaches and this
// process may not. Reporting "reachable" without that caveat would let an operator conclude
// direct delivery works when only half of it was tested.
...(options.storage.deliveryEndpoint === options.storage.endpoint
? []
: [
"The browser delivery origin is configured separately and was not probed; verify it from a browser. Any reverse proxy in front of it must pass the original Host header through unchanged, or signed read URLs will be rejected.",
]),
]);
} catch {
// A warning, never a failure: the provider is configured, and a module that is already
+83 -17
View File
@@ -7,6 +7,9 @@ import {
export type StorageConfigErrorCode =
| "STORAGE_ACCESS_KEY_MISSING"
| "STORAGE_BUCKET_PREFIX_INVALID"
| "STORAGE_DELIVERY_ENDPOINT_INSECURE_IN_PRODUCTION"
| "STORAGE_DELIVERY_ENDPOINT_INVALID"
| "STORAGE_DELIVERY_ENDPOINT_WITH_CREDENTIALS"
| "STORAGE_ENDPOINT_INSECURE_IN_PRODUCTION"
| "STORAGE_ENDPOINT_INVALID"
| "STORAGE_ENDPOINT_WITH_CREDENTIALS"
@@ -16,6 +19,12 @@ export type StorageConfigErrorCode =
const storageConfigMessages: Readonly<Record<StorageConfigErrorCode, string>> = Object.freeze({
STORAGE_ACCESS_KEY_MISSING: "A storage access key is required when a storage endpoint is set.",
STORAGE_BUCKET_PREFIX_INVALID: "The storage bucket prefix must be lowercase kebab-case.",
STORAGE_DELIVERY_ENDPOINT_INSECURE_IN_PRODUCTION:
"A production browser delivery endpoint must use https, or browsers will refuse to load from it.",
STORAGE_DELIVERY_ENDPOINT_INVALID:
"The browser delivery endpoint must be an absolute http or https URL.",
STORAGE_DELIVERY_ENDPOINT_WITH_CREDENTIALS:
"The browser delivery endpoint must not embed credentials.",
STORAGE_ENDPOINT_INSECURE_IN_PRODUCTION:
"A production storage endpoint must use https, exactly as the database transport must use TLS.",
STORAGE_ENDPOINT_INVALID: "The storage endpoint must be an absolute http or https URL.",
@@ -51,6 +60,8 @@ export interface StorageProviderConfigInput {
readonly secretKey?: string | undefined;
readonly forcePathStyle?: string | boolean | undefined;
readonly bucketPrefix?: string | undefined;
/** The origin a **browser** reaches object storage on, when that differs from `endpoint`. */
readonly deliveryEndpoint?: string | undefined;
}
/**
@@ -70,6 +81,53 @@ export interface StorageProviderConfig {
* deployment does not have. */
readonly forcePathStyle: boolean;
readonly bucketPrefix: string;
/**
* The origin browser-facing read URLs are signed for.
*
* Equal to `endpoint` unless the deployment separates them, which is the common case once Core and
* the storage service share a private network: Core reaches the service at something like
* `http://object-storage:8333`, a name that means nothing in a browser, while the browser reaches
* it through a public name.
*
* The two roles cannot be collapsed by rewriting a URL after the fact. An S3-compatible signature
* covers the request's `Host` header, so a URL signed for one host and then re-pointed at another
* fails verification — which is why this is a *signing* input rather than a post-processing step.
* What the signature does **not** cover is the scheme, so terminating TLS at a reverse proxy and
* forwarding plaintext to the storage service verifies correctly, provided the proxy passes the
* original `Host` through unchanged.
*/
readonly deliveryEndpoint: string;
}
interface EndpointErrorCodes {
readonly invalid: StorageConfigErrorCode;
readonly withCredentials: StorageConfigErrorCode;
readonly insecure: StorageConfigErrorCode;
}
/** Applies one rule set to both endpoints, so the browser-facing origin can never be validated more
* loosely than the one Core itself uses. */
function normalizeEndpoint(
value: string,
environment: StorageEnvironment,
codes: EndpointErrorCodes,
): string {
let parsed: URL;
try {
parsed = new URL(value);
} catch {
throw new StorageConfigError(codes.invalid);
}
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
throw new StorageConfigError(codes.invalid);
}
if (parsed.username !== "" || parsed.password !== "") {
throw new StorageConfigError(codes.withCredentials);
}
if (environment === "production" && parsed.protocol !== "https:") {
throw new StorageConfigError(codes.insecure);
}
return parsed.origin + (parsed.pathname === "/" ? "" : parsed.pathname.replace(/\/+$/u, ""));
}
const DEFAULT_REGION = "us-east-1";
@@ -114,22 +172,25 @@ export function createStorageProviderConfig(
return undefined;
}
let parsed: URL;
try {
parsed = new URL(endpoint);
} catch {
throw new StorageConfigError("STORAGE_ENDPOINT_INVALID");
}
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
throw new StorageConfigError("STORAGE_ENDPOINT_INVALID");
}
if (parsed.username !== "" || parsed.password !== "") {
throw new StorageConfigError("STORAGE_ENDPOINT_WITH_CREDENTIALS");
}
const environment = options.environment ?? "production";
if (environment === "production" && parsed.protocol !== "https:") {
throw new StorageConfigError("STORAGE_ENDPOINT_INSECURE_IN_PRODUCTION");
}
const normalizedEndpoint = normalizeEndpoint(endpoint, environment, {
insecure: "STORAGE_ENDPOINT_INSECURE_IN_PRODUCTION",
invalid: "STORAGE_ENDPOINT_INVALID",
withCredentials: "STORAGE_ENDPOINT_WITH_CREDENTIALS",
});
// Unset means the one endpoint serves both roles, which is correct whenever Core and the browser
// reach storage by the same name — the repository's own development stack publishes the service on
// loopback, so the ordinary developer configures nothing here at all.
const deliveryInput = input?.deliveryEndpoint?.trim();
const deliveryEndpoint =
deliveryInput === undefined || deliveryInput === ""
? normalizedEndpoint
: normalizeEndpoint(deliveryInput, environment, {
insecure: "STORAGE_DELIVERY_ENDPOINT_INSECURE_IN_PRODUCTION",
invalid: "STORAGE_DELIVERY_ENDPOINT_INVALID",
withCredentials: "STORAGE_DELIVERY_ENDPOINT_WITH_CREDENTIALS",
});
const region = input?.region?.trim() === "" ? undefined : input?.region?.trim();
if (region !== undefined && !REGION_PATTERN.test(region)) {
@@ -160,7 +221,8 @@ export function createStorageProviderConfig(
bucketPrefix === undefined || bucketPrefix === ""
? DEFAULT_STORAGE_BUCKET_PREFIX
: bucketPrefix,
endpoint: parsed.origin + (parsed.pathname === "/" ? "" : parsed.pathname.replace(/\/+$/u, "")),
deliveryEndpoint,
endpoint: normalizedEndpoint,
forcePathStyle: parseBoolean(input?.forcePathStyle, true),
region: region ?? DEFAULT_REGION,
secretKey,
@@ -177,9 +239,13 @@ export function createStorageProviderConfig(
*/
export function describeStorageProvider(config: StorageProviderConfig): string {
const origin = new URL(config.endpoint).origin;
const deliveryOrigin = new URL(config.deliveryEndpoint).origin;
// Named only when it differs, so the ordinary single-origin deployment reads exactly as it did
// before this distinction existed. Both are host infrastructure names, never credentials.
const delivery = deliveryOrigin === origin ? "" : ` · browser delivery ${deliveryOrigin}`;
return `S3-compatible endpoint ${origin} · region ${config.region} · ${
config.forcePathStyle ? "path-style" : "virtual-host-style"
} · bucket prefix "${config.bucketPrefix}"`;
} · bucket prefix "${config.bucketPrefix}"${delivery}`;
}
export { STORAGE_BUCKET_PREFIX_MAX_LENGTH };
+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();
+21 -1
View File
@@ -1,10 +1,11 @@
import type {
ModuleStorageBody,
ModuleStorageListOptions,
ModuleStorageListing,
ModuleStorageListOptions,
ModuleStorageObject,
ModuleStorageObjectMetadata,
ModuleStoragePutOptions,
ModuleStorageReadUrl,
} from "@ekklesia/contracts";
/**
@@ -42,6 +43,25 @@ export interface StorageProvider {
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). */
+96
View File
@@ -9,6 +9,7 @@ import {
PutObjectCommand,
S3Client,
} from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
import {
type ModuleStorageBody,
ModuleStorageError,
@@ -17,6 +18,7 @@ import {
type ModuleStorageObject,
type ModuleStorageObjectMetadata,
type ModuleStoragePutOptions,
type ModuleStorageReadUrl,
} from "@ekklesia/contracts";
import type { StorageProviderConfig } from "./config.js";
import type { StorageProvider } from "./provider.js";
@@ -173,6 +175,43 @@ const DEFAULT_TRANSPORT_BOUNDS: S3TransportBounds = Object.freeze({
socketIdleTimeoutMs: 5_000,
});
/**
* How coarsely a read URL's signing timestamp is rounded down, as a fraction of its lifetime.
*
* Signing is a pure function of the request and the clock, so signing the same object twice a second
* apart yields two different URLs for the same bytes. That is worse than untidy. A browser caches by
* URL, so a changed query string is a guaranteed cache miss, and UI frameworks routinely key list
* items by URL — the reference consumer this was measured against renders `<img key={url}>`, so a
* new URL for an unchanged image tears the element down and re-downloads it. A slideshow rebuilding
* its state every few seconds would therefore re-fetch every slide continuously, having gained a
* direct path to storage and lost the caching that made it worth having.
*
* Rounding the signing time down to a window makes repeated calls return a **byte-identical** URL
* within that window, so caches hit and keyed elements stay mounted. Dividing by three, rather than
* fixing a duration, keeps the guarantee proportional: whatever lifetime is asked for, at least
* two-thirds of it always remains when a URL is handed out.
*/
const READ_URL_STABILITY_DIVISOR = 3;
interface ReadUrlWindow {
readonly signedAt: Date;
readonly expiresAt: Date;
readonly guaranteedSeconds: number;
}
function readUrlWindow(nowMs: number, expiresInSeconds: number): ReadUrlWindow {
const windowSeconds = Math.max(1, Math.floor(expiresInSeconds / READ_URL_STABILITY_DIVISOR));
const windowMs = windowSeconds * 1000;
const signedAtMs = Math.floor(nowMs / windowMs) * windowMs;
return {
expiresAt: new Date(signedAtMs + expiresInSeconds * 1000),
// Deliberately derived from the window rather than from the current clock: anything that varies
// second by second would end up in the signed query string and undo the stability above.
guaranteedSeconds: expiresInSeconds - windowSeconds,
signedAt: new Date(signedAtMs),
};
}
export function createS3StorageProvider(
config: StorageProviderConfig,
/** Tightened only by the live provider suite, so it can prove the deadline actually fires without
@@ -197,6 +236,25 @@ export function createS3StorageProvider(
},
});
/**
* The client read URLs are signed with.
*
* An S3-compatible signature covers the request's `Host`, so a URL cannot be signed against the
* endpoint Core talks to and then re-pointed at the origin a browser can reach — verification
* fails. The delivery origin therefore has to be a *signing input*, which is what this second
* client supplies. It never sends a request: presigning is a local computation, so this opens no
* socket and is built only when the two origins actually differ.
*/
const signingClient =
config.deliveryEndpoint === config.endpoint
? client
: new S3Client({
credentials: { accessKeyId: config.accessKey, secretAccessKey: config.secretKey },
endpoint: config.deliveryEndpoint,
forcePathStyle: config.forcePathStyle,
region: config.region,
});
return Object.freeze({
async ensureBucket(physicalBucket: string): Promise<void> {
try {
@@ -326,6 +384,41 @@ export function createS3StorageProvider(
}
},
async createReadUrl(
physicalBucket: string,
key: string,
options: {
readonly expiresInSeconds: number;
readonly downloadFilename?: string | undefined;
},
): Promise<ModuleStorageReadUrl> {
const window = readUrlWindow(Date.now(), options.expiresInSeconds);
try {
const url = await getSignedUrl(
signingClient,
new GetObjectCommand({
Bucket: physicalBucket,
Key: key,
// The pinned SeaweedFS image returns no `Cache-Control` of its own, which leaves
// browsers guessing how long an asset may be reused. Stating it turns the stable URL
// above into an actual cache hit. It never outlives the URL itself.
ResponseCacheControl: `private, max-age=${window.guaranteedSeconds}`,
...(options.downloadFilename === undefined
? {}
: {
ResponseContentDisposition: `attachment; filename="${options.downloadFilename}"`,
}),
}),
{ expiresIn: options.expiresInSeconds, signingDate: window.signedAt },
);
return Object.freeze({ expiresAt: window.expiresAt, url });
} catch (error) {
// Signing is local, so a failure here is a malformed request rather than an outage — but it
// is translated exactly like any other so no SDK exception can reach a module.
throw translateStorageError(error, key);
}
},
async probe(): Promise<void> {
try {
// The cheapest call that proves the endpoint is reachable and the credentials sign
@@ -338,6 +431,9 @@ export function createS3StorageProvider(
async close(): Promise<void> {
client.destroy();
if (signingClient !== client) {
signingClient.destroy();
}
},
});
}
+2 -1
View File
@@ -1,4 +1,4 @@
import { parsePermissionId, type ModuleManifest } from "@ekklesia/contracts";
import { type ModuleManifest, parsePermissionId } from "@ekklesia/contracts";
import { describe, expect, it, vi } from "vitest";
import { createPermissionRegistry, PermissionRegistryError } from "../src/authorization/index.js";
import {
@@ -1219,6 +1219,7 @@ describe("Ekklesia Doctor — object storage", () => {
const storageConfig = {
accessKey: "AKIAEXAMPLEACCESSID",
bucketPrefix: "ekklesia",
deliveryEndpoint: "https://storage.example.test",
endpoint: "https://storage.example.test",
forcePathStyle: true,
region: "us-east-1",
+458
View File
@@ -0,0 +1,458 @@
import { randomBytes } from "node:crypto";
import { request } from "node:http";
import type { ModuleStorage } from "@ekklesia/contracts";
import { afterAll, describe, expect, it } from "vitest";
import { createStorageProviderConfig } from "../src/storage/config.js";
import { createModuleStorage } from "../src/storage/module-storage.js";
import type { StorageProvider } from "../src/storage/provider.js";
import { createS3StorageProvider } from "../src/storage/s3-provider.js";
/**
* Direct browser delivery against a **real S3-compatible server** (ADR 0031).
*
* Everything here is a claim that cannot be checked without a live provider. That a signature
* survives being served through a reverse proxy, that a byte range works without Core in the path,
* that a tampered URL is refused, that expiry is actually enforced — none of these are properties of
* the code in this repository, they are properties of the agreement between the signer and the
* server, and only the server can settle them.
*
* The bucket frugality of `module-storage.integration.ts` applies here for the same reason: a bucket
* is a collection on the pinned SeaweedFS image and collections come out of a finite, disk-derived
* budget. Tests share one bucket and separate by key.
*/
const endpointEnvironment = "EKKLESIA_TEST_STORAGE_ENDPOINT";
const accessKeyEnvironment = "EKKLESIA_TEST_STORAGE_ACCESS_KEY";
const secretKeyEnvironment = "EKKLESIA_TEST_STORAGE_SECRET_KEY";
class IntegrationHarnessError extends Error {
constructor(message: string) {
super(message);
this.name = "IntegrationHarnessError";
this.stack = `${this.name}: ${this.message}`;
}
}
function providerConfig(deliveryEndpoint?: string) {
const endpoint = process.env[endpointEnvironment];
if (typeof endpoint !== "string" || endpoint.trim() === "") {
throw new IntegrationHarnessError("The storage integration endpoint is not configured.");
}
const config = createStorageProviderConfig(
{
accessKey: process.env[accessKeyEnvironment],
...(deliveryEndpoint === undefined ? {} : { deliveryEndpoint }),
endpoint,
secretKey: process.env[secretKeyEnvironment],
},
{ environment: "development" },
);
if (!config) {
throw new IntegrationHarnessError("The storage integration configuration is invalid.");
}
return config;
}
function endpointOrigin(): URL {
return new URL(providerConfig().endpoint);
}
const openProviders: StorageProvider[] = [];
function provider(deliveryEndpoint?: string): StorageProvider {
const created = createS3StorageProvider(providerConfig(deliveryEndpoint));
openProviders.push(created);
return created;
}
let cachedProvider: StorageProvider | undefined;
function sharedProvider(): StorageProvider {
cachedProvider ??= provider();
return cachedProvider;
}
const runPrefix = `ekkdel${randomBytes(3).toString("hex")}`;
/** The scoped facade a module would receive — every test drives delivery through this, never through
* the provider, so nothing here can accidentally prove something a module could not do. */
function moduleBucket(moduleId = "announcements", logicalBucketId = "announcement-assets") {
return createModuleStorage({
bucketPrefix: runPrefix,
logicalBucketIds: [logicalBucketId],
moduleId,
provider: sharedProvider(),
}).bucket(logicalBucketId);
}
let namespaceCounter = 0;
function keyNamespace(): string {
namespaceCounter += 1;
return `case-${namespaceCounter}`;
}
/** Issues a request to the real backend while presenting an arbitrary `Host`, which is exactly what
* a reverse proxy in front of storage does. `fetch` cannot express this: `Host` is a forbidden
* header name there, so it would silently send the wrong one and prove nothing. */
function getWithHost(
pathAndQuery: string,
hostHeader: string,
): Promise<{ status: number; headers: Record<string, string | string[] | undefined> }> {
const target = endpointOrigin();
return new Promise((resolve, reject) => {
const outbound = request(
{
headers: { Host: hostHeader },
host: target.hostname,
method: "GET",
path: pathAndQuery,
port: target.port,
},
(response) => {
response.resume();
response.on("end", () =>
resolve({ headers: response.headers, status: response.statusCode ?? 0 }),
);
},
);
outbound.on("error", reject);
outbound.end();
});
}
afterAll(async () => {
for (const open of openProviders.splice(0)) {
await open.close().catch(() => undefined);
}
});
describe.sequential("live direct object delivery", () => {
it("hands a browser a URL that returns the object, with its content type intact", async () => {
const bucket = moduleBucket();
const key = `${keyNamespace()}/welcome.png`;
const png = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
await bucket.put(key, png, { contentType: "image/png" });
const delivery = await bucket.createReadUrl(key);
// An ordinary fetch with no credentials, no SDK, and no Ekklesia in the path.
const response = await fetch(delivery.url);
expect(response.status).toBe(200);
expect(response.headers.get("content-type")).toBe("image/png");
expect(new Uint8Array(await response.arrayBuffer())).toEqual(png);
expect(delivery.expiresAt.getTime()).toBeGreaterThan(Date.now());
});
it("refuses the same object without the URL's authorization", async () => {
const bucket = moduleBucket();
const key = `${keyNamespace()}/private.png`;
await bucket.put(key, "x");
const delivery = await bucket.createReadUrl(key);
const unsigned = new URL(delivery.url);
for (const parameter of [...unsigned.searchParams.keys()]) {
unsigned.searchParams.delete(parameter);
}
// The bucket is not public: delivery works because the URL carries authorization, not because
// anyone may read the object. This is the assertion that would fail if a future change "fixed"
// delivery by making buckets anonymous.
expect((await fetch(unsigned.toString())).status).toBe(403);
});
it("refuses a tampered signature, a swapped key, and a swapped bucket", async () => {
const bucket = moduleBucket();
const namespace = keyNamespace();
await bucket.put(`${namespace}/a.txt`, "a");
await bucket.put(`${namespace}/b.txt`, "b");
const delivery = await bucket.createReadUrl(`${namespace}/a.txt`);
const forged = new URL(delivery.url);
forged.searchParams.set("X-Amz-Signature", "0".repeat(64));
expect((await fetch(forged.toString())).status).toBe(403);
// The signature covers the path, so a URL is a capability for one object rather than for the
// bucket it happens to live in.
expect((await fetch(delivery.url.replace("/a.txt", "/b.txt"))).status).toBe(403);
// And swapping the physical bucket is refused too, which is what stops a URL for one module's
// object from being edited into a URL for another module's.
const physical = new URL(delivery.url).pathname.split("/")[1] ?? "";
expect(physical).not.toBe("");
expect((await fetch(delivery.url.replace(physical, physical.replace(/.$/u, "z")))).status).toBe(
403,
);
});
it("stops working once it expires", async () => {
// Driven at the provider seam so the deadline can be one second: the module-facing contract
// deliberately refuses lifetimes that short, and waiting out its minute-long floor would buy no
// extra confidence about whether the server enforces expiry.
const bucket = `${runPrefix}-expiry`.toLowerCase();
await sharedProvider().ensureBucket(bucket);
await sharedProvider().put(bucket, "fleeting.txt", "x", {});
const delivery = await sharedProvider().createReadUrl(bucket, "fleeting.txt", {
expiresInSeconds: 1,
});
expect((await fetch(delivery.url)).status).toBe(200);
await new Promise((resolve) => setTimeout(resolve, 2_500));
expect((await fetch(delivery.url)).status).toBe(403);
});
it("serves byte ranges directly, with no Core process in the data path", async () => {
const bucket = moduleBucket();
const key = `${keyNamespace()}/sermon.bin`;
const payload = "0123456789".repeat(500);
await bucket.put(key, payload, { contentType: "video/mp4" });
const delivery = await bucket.createReadUrl(key);
const ranged = await fetch(delivery.url, { headers: { Range: "bytes=10-19" } });
// This is what makes direct delivery worth having for media: seeking in a video is the provider's
// job, and Ekklesia never sees the bytes. A proxying route would have had to implement all of it.
expect(ranged.status).toBe(206);
expect(ranged.headers.get("content-range")).toBe(`bytes 10-19/${payload.length}`);
expect(await ranged.text()).toBe("0123456789");
expect((await fetch(delivery.url)).headers.get("accept-ranges")).toBe("bytes");
});
it("returns the same URL for the same object within a window, so caches and DOM keys hold", async () => {
const bucket = moduleBucket();
const key = `${keyNamespace()}/stable.png`;
await bucket.put(key, "x", { contentType: "image/png" });
// Signing timestamps have one-second granularity of their own, so the pair has to straddle a
// second for this to mean anything — otherwise it would pass with no windowing at all. That
// introduces the one race worth handling: the pair can land either side of a window boundary,
// which is not a defect but would look like one. A crossing cannot recur immediately, so one
// retry settles it deterministically rather than leaving a rare failure in the suite.
let first = await bucket.createReadUrl(key);
await new Promise((resolve) => setTimeout(resolve, 1_100));
let second = await bucket.createReadUrl(key);
if (second.url !== first.url) {
first = await bucket.createReadUrl(key);
await new Promise((resolve) => setTimeout(resolve, 1_100));
second = await bucket.createReadUrl(key);
}
// A second apart and byte-identical. Without this a client that rebuilds its state on a timer
// re-downloads every asset each time, because the URL is the browser's cache key — and a UI that
// keys elements by URL remounts them.
expect(second.url).toBe(first.url);
expect(second.expiresAt.getTime()).toBe(first.expiresAt.getTime());
expect((await fetch(second.url)).status).toBe(200);
});
it("asks the browser to cache the object for no longer than the URL lives", async () => {
const bucket = moduleBucket();
const key = `${keyNamespace()}/cacheable.png`;
await bucket.put(key, "x", { contentType: "image/png" });
const delivery = await bucket.createReadUrl(key);
const response = await fetch(delivery.url);
const cacheControl = response.headers.get("cache-control") ?? "";
expect(cacheControl).toMatch(/^private, max-age=\d+$/u);
const maxAge = Number(/max-age=(\d+)/u.exec(cacheControl)?.[1]);
expect(maxAge).toBeGreaterThan(0);
expect(delivery.expiresAt.getTime() - Date.now()).toBeGreaterThanOrEqual(maxAge * 1000);
// Revalidation still works, which is what keeps a long-running display cheap.
expect(response.headers.get("etag")).toMatch(/"[0-9a-f]{32}"/u);
});
it("offers a download name when asked, and displays inline otherwise", async () => {
const bucket = moduleBucket();
const key = `${keyNamespace()}/bulletin.pdf`;
await bucket.put(key, "%PDF-", { contentType: "application/pdf" });
const inline = await fetch((await bucket.createReadUrl(key)).url);
expect(inline.headers.get("content-disposition")).toBeNull();
const download = await bucket.createReadUrl(key, { downloadFilename: "Sunday Bulletin.pdf" });
const saved = await fetch(download.url);
expect(saved.status).toBe(200);
expect(saved.headers.get("content-disposition")).toBe(
'attachment; filename="Sunday Bulletin.pdf"',
);
});
describe("the browser delivery origin is a signing input, not a rewrite", () => {
const deliveryHost = "storage.example.org";
it("signs for the delivery origin and verifies behind a Host-preserving proxy", async () => {
const bucket = createModuleStorage({
bucketPrefix: runPrefix,
logicalBucketIds: ["announcement-assets"],
moduleId: "announcements",
// Core still reaches storage at the endpoint it always did; only signing differs.
provider: provider(`https://${deliveryHost}`),
}).bucket("announcement-assets");
const key = `${keyNamespace()}/proxied.png`;
await bucket.put(key, "x", { contentType: "image/png" });
const delivery = await bucket.createReadUrl(key);
const signed = new URL(delivery.url);
expect(signed.host).toBe(deliveryHost);
// Delivered through a proxy that passes the original Host through: the backend verifies it,
// even though the signed scheme was https and this hop is plaintext. SigV4 covers the Host
// header, the path, and the query — not the scheme — so terminating TLS at a proxy is fine.
const throughProxy = await getWithHost(signed.pathname + signed.search, deliveryHost);
expect(throughProxy.status).toBe(200);
// A proxy that replaces Host with its backend's breaks the signature. This is the single
// configuration requirement direct delivery imposes on a deployment.
const hostRewritten = await getWithHost(
signed.pathname + signed.search,
`${endpointOrigin().hostname}:${endpointOrigin().port}`,
);
expect(hostRewritten.status).toBe(403);
});
it("cannot be faked by rewriting a URL signed for the internal endpoint", async () => {
const bucket = moduleBucket();
const key = `${keyNamespace()}/internal.png`;
await bucket.put(key, "x");
const delivery = await bucket.createReadUrl(key);
const rewritten = new URL(delivery.url);
rewritten.host = deliveryHost;
const attempted = await getWithHost(rewritten.pathname + rewritten.search, deliveryHost);
// The reason `deliveryEndpoint` exists at all: post-hoc rewriting is not an option, so the
// browser-facing origin has to be known at signing time.
expect(attempted.status).toBe(403);
});
});
it("keeps a module's delivery URLs inside its own bucket", async () => {
const announcements = moduleBucket("announcements", "assets");
const video = moduleBucket("video", "assets");
const key = `${keyNamespace()}/same-name.txt`;
await announcements.put(key, "from-announcements");
await video.put(key, "from-video");
const fromAnnouncements = await fetch((await announcements.createReadUrl(key)).url);
const fromVideo = await fetch((await video.createReadUrl(key)).url);
// Same logical bucket name, same key, two modules — and a URL from one never reaches the other's
// object, because the physical bucket it names came from the binding rather than from anything
// the module said.
expect(await fromAnnouncements.text()).toBe("from-announcements");
expect(await fromVideo.text()).toBe("from-video");
});
});
/**
* An Announcements-shaped module, modelled on the real slideshow this platform exists to replace.
*
* The real one, today, hardcodes an object-storage origin, lists a public bucket over anonymous
* HTTP, and hands the browser absolute URLs it built by string concatenation. This is the same
* behaviour expressed against the module contract — and the point of writing it out is what it is
* **unable** to say. Its entire world is the parameter list: a `ModuleStorage` and two names it
* declared in its own manifest. There is no endpoint to hardcode, no credential to hold, no bucket
* to name, and no branch on which host is running it, so the identical function would run unchanged
* under a single-module standalone host (ADR 0031).
*/
interface Slide {
readonly key: string;
readonly url: string;
readonly expiresAt: Date;
}
async function announcementsSlideshowState(storage: ModuleStorage): Promise<readonly Slide[]> {
const assets = storage.bucket("announcement-assets");
const listing = await assets.list({ prefix: "slides/" });
const images = listing.objects.filter((object) => /\.(png|jpe?g|webp)$/iu.test(object.key));
return Promise.all(
images.map(async (object) => {
const delivery = await assets.createReadUrl(object.key);
return { expiresAt: delivery.expiresAt, key: object.key, url: delivery.url };
}),
);
}
describe.sequential("an Announcements-shaped module delivers slides directly", () => {
it("lists its own slides and hands a browser URLs that work", async () => {
const storage = createModuleStorage({
bucketPrefix: runPrefix,
logicalBucketIds: ["announcement-assets"],
moduleId: "announcements",
provider: sharedProvider(),
});
const assets = storage.bucket("announcement-assets");
const png = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
await assets.put("slides/welcome.png", png, { contentType: "image/png" });
await assets.put("slides/offering.jpg", png, { contentType: "image/jpeg" });
// Not an image, and the slideshow must not try to display it.
await assets.put("slides/notes.txt", "not a slide");
const state = await announcementsSlideshowState(storage);
expect(state.map((slide) => slide.key)).toEqual(["slides/offering.jpg", "slides/welcome.png"]);
// The display fetches every slide itself. Nothing in this repository carries the image bytes.
for (const slide of state) {
const response = await fetch(slide.url);
expect(response.status).toBe(200);
expect(new Uint8Array(await response.arrayBuffer())).toEqual(png);
}
expect(await fetch(state[0]?.url ?? "").then((r) => r.headers.get("content-type"))).toBe(
"image/jpeg",
);
});
it("persists logical keys, never URLs", async () => {
const storage = createModuleStorage({
bucketPrefix: runPrefix,
logicalBucketIds: ["announcement-assets"],
moduleId: "announcements",
provider: sharedProvider(),
});
await storage.bucket("announcement-assets").put("slides/keep.png", "x", {
contentType: "image/png",
});
const state = await announcementsSlideshowState(storage);
const slide = state.find((candidate) => candidate.key === "slides/keep.png");
if (!slide) {
throw new IntegrationHarnessError("The Announcements-shaped fixture lost its own slide.");
}
// What a module writes to its own tables is the key: it is stable, it is meaningful, and it
// still resolves tomorrow. The URL is a temporary capability that happens to be how today's
// browser reaches it, and storing one would be storing a credential with an expiry date in it.
expect(slide.key).toBe("slides/keep.png");
expect(slide.url).not.toBe(slide.key);
expect(slide.expiresAt.getTime()).toBeGreaterThan(Date.now());
// The module's own durable state names nothing about the host. The URL it hands the browser
// inevitably does — that asymmetry is the whole design, and it is asserted rather than assumed.
const persisted = JSON.stringify({ key: slide.key });
expect(persisted).not.toMatch(/http|amz|signature|ekkdel|127\.0\.0\.1/iu);
expect(new URL(slide.url).searchParams.has("X-Amz-Signature")).toBe(true);
});
it("builds a whole slideshow's URLs without a round trip each", async () => {
const storage = createModuleStorage({
bucketPrefix: runPrefix,
logicalBucketIds: ["announcement-assets"],
moduleId: "announcements",
provider: sharedProvider(),
});
const assets = storage.bucket("announcement-assets");
// Warm the binding first, so what is measured is signing rather than the one lazy CreateBucket
// round trip every binding pays once.
await assets.createReadUrl("slides/warm.png");
const startedAt = Date.now();
const urls = await Promise.all(
Array.from({ length: 40 }, (_, index) => assets.createReadUrl(`slides/deck-${index}.png`)),
);
const elapsed = Date.now() - startedAt;
expect(new Set(urls.map((delivery) => delivery.url)).size).toBe(40);
// Signing is local arithmetic, so a forty-slide deck costs no provider traffic and needs no
// batch API to stay cheap. A per-URL round trip could not come close to this.
expect(elapsed).toBeLessThan(1_000);
});
});
+236 -2
View File
@@ -2,10 +2,13 @@ import {
EMPTY_MODULE_STORAGE,
isValidModuleStorageKey,
MODULE_STORAGE_KEY_MAX_BYTES,
MODULE_STORAGE_READ_URL_DEFAULT_SECONDS,
MODULE_STORAGE_READ_URL_MAX_SECONDS,
MODULE_STORAGE_READ_URL_MIN_SECONDS,
type ModuleStorageBody,
ModuleStorageError,
type ModuleStorageListOptions,
type ModuleStorageListing,
type ModuleStorageListOptions,
type ModuleStorageObject,
type ModuleStorageObjectMetadata,
type ModuleStoragePutOptions,
@@ -81,6 +84,15 @@ function fakeProvider(options: { failEnsure?: () => Error } = {}): StorageProvid
const provider: StorageProvider = {
async close() {},
async createReadUrl(physicalBucket, key, urlOptions) {
calls.push({ key, operation: "createReadUrl", physicalBucket });
// Shaped like a real delivery URL — opaque, single-object, carrying an expiry — without any
// signing, so facade-level tests prove scoping and validation rather than cryptography.
return Object.freeze({
expiresAt: new Date(Date.now() + urlOptions.expiresInSeconds * 1000),
url: `https://delivery.test/${physicalBucket}/${encodeURIComponent(key)}?token=fake`,
});
},
async delete(physicalBucket, key) {
calls.push({ key, operation: "delete", physicalBucket });
objectsIn(physicalBucket).delete(key);
@@ -354,7 +366,17 @@ describe("module storage scoping", () => {
// The handle names the *logical* bucket, and has no accessor for anything else.
expect(bucket.id).toBe("announcement-assets");
expect(Object.keys(bucket).sort()).toEqual(["delete", "get", "head", "id", "list", "put"]);
// Still the complete surface, now including delivery: a module can ask for a browser URL but has
// gained no way to ask where the object actually lives.
expect(Object.keys(bucket).sort()).toEqual([
"createReadUrl",
"delete",
"get",
"head",
"id",
"list",
"put",
]);
expect(Object.keys(storage).sort()).toEqual(["bucket", "buckets"]);
const serialized = JSON.stringify({ bucket, storage });
expect(serialized).not.toContain(
@@ -569,6 +591,9 @@ describe("storage provider configuration", () => {
expect(config).toEqual({
accessKey: "AKIAEXAMPLEACCESSID",
bucketPrefix: "ekklesia",
// Unset, so the one endpoint serves both roles — the zero-configuration case, and the reason
// an ordinary deployment never has to think about browser delivery at all.
deliveryEndpoint: "https://storage.example.test",
endpoint: "https://storage.example.test",
// Path-style by default: every self-hosted S3-compatible service needs it, and virtual-host
// addressing would require wildcard DNS a typical deployment does not have.
@@ -578,6 +603,85 @@ describe("storage provider configuration", () => {
});
});
it("lets one endpoint serve both roles when no delivery origin is configured", () => {
// The zero-configuration case, and the one an ordinary church deployment stays in: the
// repository's own development stack publishes storage on loopback, so the endpoint Core uses is
// already an origin a browser can reach and direct delivery needs nothing configured.
expect(createStorageProviderConfig(base)?.deliveryEndpoint).toBe(
"https://storage.example.test",
);
expect(createStorageProviderConfig({ ...base, deliveryEndpoint: " " })?.deliveryEndpoint).toBe(
"https://storage.example.test",
);
});
it("keeps the browser delivery origin separate from the endpoint Core talks to", () => {
const config = createStorageProviderConfig(
{
...base,
deliveryEndpoint: "https://storage.example.org/",
endpoint: "http://object-storage:8333",
},
{ environment: "development" },
);
// The deployment this exists for: Core reaches storage by a private service name, browsers reach
// it by a public one, and the signature has to be made for the public one because it cannot be
// rewritten afterwards.
expect(config?.endpoint).toBe("http://object-storage:8333");
expect(config?.deliveryEndpoint).toBe("https://storage.example.org");
});
it("validates the delivery origin exactly as strictly as the endpoint", () => {
expect(() => createStorageProviderConfig({ ...base, deliveryEndpoint: "not-a-url" })).toThrow(
expect.objectContaining({ code: "STORAGE_DELIVERY_ENDPOINT_INVALID" }),
);
expect(() =>
createStorageProviderConfig({ ...base, deliveryEndpoint: "ftp://storage.example.org" }),
).toThrow(expect.objectContaining({ code: "STORAGE_DELIVERY_ENDPOINT_INVALID" }));
expect(() =>
createStorageProviderConfig({ ...base, deliveryEndpoint: "https://k:[email protected]" }),
).toThrow(expect.objectContaining({ code: "STORAGE_DELIVERY_ENDPOINT_WITH_CREDENTIALS" }));
});
it("refuses a plaintext delivery origin in production", () => {
// A browser on an https page will not load an http asset anyway, so allowing this would produce
// URLs that are silently unusable rather than a configuration error anyone can see.
expect(() =>
createStorageProviderConfig(
{ ...base, deliveryEndpoint: "http://storage.example.org" },
{ environment: "production" },
),
).toThrow(
expect.objectContaining({ code: "STORAGE_DELIVERY_ENDPOINT_INSECURE_IN_PRODUCTION" }),
);
});
it("describes a separate delivery origin without naming a credential", () => {
const split = createStorageProviderConfig(
{
...base,
deliveryEndpoint: "https://storage.example.org",
endpoint: "http://object-storage:8333",
},
{ environment: "development" },
);
const single = createStorageProviderConfig(base);
if (!split || !single) {
throw new Error("The storage configuration fixture produced no configuration.");
}
const described = describeStorageProvider(split);
expect(described).toContain("http://object-storage:8333");
expect(described).toContain("https://storage.example.org");
expect(described).not.toContain("AKIAEXAMPLEACCESSID");
expect(described).not.toContain("sk-example-secret-material");
// And an ordinary single-origin deployment reads exactly as it did before the distinction
// existed, so nobody is asked to understand a split that does not apply to them.
expect(describeStorageProvider(single)).not.toContain("browser delivery");
});
it("requires both credentials once an endpoint is set", () => {
expect(() => createStorageProviderConfig({ ...base, accessKey: undefined })).toThrow(
expect.objectContaining({ code: "STORAGE_ACCESS_KEY_MISSING" }),
@@ -773,3 +877,133 @@ describe("an Announcements-shaped module reads its slides without knowing any in
expect(JSON.stringify(caught)).not.toContain("http");
});
});
describe("read URLs are bounded, scoped, and provider-neutral", () => {
function bucketFor(moduleId = "announcements", logicalBucketIds = ["assets"]) {
const provider = fakeProvider();
const storage = createModuleStorage({
bucketPrefix: "ekklesia",
logicalBucketIds,
moduleId,
provider,
});
return { bucket: storage.bucket(logicalBucketIds[0] ?? "assets"), provider, storage };
}
it("takes the host's default lifetime when the module asks for none", async () => {
const { bucket, provider } = bucketFor();
const delivery = await bucket.createReadUrl("welcome.png");
// A module that expresses no opinion gets the host's, which is the case that must stay easy:
// needing to know a duration to fetch an image would be provider knowledge leaking upward.
const expectedMs = MODULE_STORAGE_READ_URL_DEFAULT_SECONDS * 1000;
expect(delivery.expiresAt.getTime() - Date.now()).toBeGreaterThan(expectedMs - 5_000);
expect(delivery.expiresAt.getTime() - Date.now()).toBeLessThanOrEqual(expectedMs);
expect(provider.calls.at(-1)).toMatchObject({
key: "welcome.png",
operation: "createReadUrl",
physicalBucket: expect.stringContaining("ekklesia-announcements--assets-"),
});
});
it("accepts a lifetime inside the allowed range", async () => {
const { bucket } = bucketFor();
await expect(
bucket.createReadUrl("welcome.png", { expiresIn: MODULE_STORAGE_READ_URL_MIN_SECONDS }),
).resolves.toMatchObject({ url: expect.any(String) });
await expect(
bucket.createReadUrl("welcome.png", { expiresIn: MODULE_STORAGE_READ_URL_MAX_SECONDS }),
).resolves.toMatchObject({ url: expect.any(String) });
});
it.each([
["below the floor", MODULE_STORAGE_READ_URL_MIN_SECONDS - 1],
["above the ceiling", MODULE_STORAGE_READ_URL_MAX_SECONDS + 1],
["a year", 365 * 24 * 60 * 60],
["zero", 0],
["negative", -60],
["fractional", 90.5],
["not a number", Number.NaN],
["infinite", Number.POSITIVE_INFINITY],
])("refuses a lifetime %s rather than clamping it", async (_label, expiresIn) => {
const { bucket, provider } = bucketFor();
const before = provider.calls.length;
await expect(bucket.createReadUrl("welcome.png", { expiresIn })).rejects.toMatchObject({
code: "STORAGE_READ_URL_EXPIRY_INVALID",
});
// Refused in the facade, so no provider ever sees a value it might interpret differently — and
// "expires forever" is unreachable rather than merely discouraged.
expect(provider.calls.filter((call) => call.operation === "createReadUrl")).toHaveLength(0);
expect(provider.calls.length).toBe(before);
});
it.each([
["a quote", 'in"jected.pdf'],
["a carriage return", "a\r\nX-Injected: 1.pdf"],
["a newline", "a\nb.pdf"],
["a path separator", "slides/welcome.png"],
["a backslash", "slides\\welcome.png"],
["a semicolon", "a;filename=b.pdf"],
["nothing at all", ""],
])("refuses a download filename containing %s", async (_label, downloadFilename) => {
const { bucket } = bucketFor();
// The value lands in a Content-Disposition response header, so anything that could close the
// quoted string or start a new header is refused at the contract rather than escaped and hoped
// over.
await expect(bucket.createReadUrl("bulletin.pdf", { downloadFilename })).rejects.toMatchObject({
code: "STORAGE_READ_URL_FILENAME_INVALID",
});
});
it("accepts an ordinary download filename", async () => {
const { bucket } = bucketFor();
await expect(
bucket.createReadUrl("bulletin.pdf", { downloadFilename: "Sunday Bulletin (2026).pdf" }),
).resolves.toMatchObject({ url: expect.any(String) });
});
it("refuses an invalid key before it reaches the provider", async () => {
const { bucket, provider } = bucketFor();
await expect(bucket.createReadUrl("/absolute.png")).rejects.toMatchObject({
code: "STORAGE_KEY_INVALID",
});
expect(provider.calls.filter((call) => call.operation === "createReadUrl")).toHaveLength(0);
});
it("cannot produce a URL for a bucket this module did not declare", () => {
const { storage } = bucketFor("announcements", ["assets"]);
// There is no path to another module's object at all: the name does not resolve, so delivery
// inherits scoping from the facade rather than re-implementing it.
expect(() => storage.bucket("video-assets")).toThrow(ModuleStorageError);
});
it("sends two modules' identical logical names to two different physical buckets", async () => {
const provider = fakeProvider();
const urlFor = async (moduleId: string) => {
const bucket = createModuleStorage({
bucketPrefix: "ekklesia",
logicalBucketIds: ["assets"],
moduleId,
provider,
}).bucket("assets");
await bucket.createReadUrl("same-key.txt");
return provider.calls.at(-1)?.physicalBucket;
};
expect(await urlFor("announcements")).not.toBe(await urlFor("video"));
});
it("returns nothing but an opaque URL and an expiry", async () => {
const { bucket } = bucketFor();
const delivery = await bucket.createReadUrl("welcome.png");
// No provider identity, no physical bucket as a separate field, no credentials, no endpoint —
// a module can act on this without being able to learn anything about the host from it.
expect(Object.keys(delivery).sort()).toEqual(["expiresAt", "url"]);
expect(delivery.expiresAt).toBeInstanceOf(Date);
});
});