Files
Ekklesia/core/tests/module-storage.test.ts
T
ekklesiaandClaude Opus 5 7ff063b87a
CI / verify (pull_request) Successful in 36s
CI / infrastructure-smoke (pull_request) Successful in 20s
CI / migration-smoke (pull_request) Successful in 56s
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]>
2026-08-23 17:12:07 -05:00

1010 lines
39 KiB
TypeScript

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 ModuleStorageListing,
type ModuleStorageListOptions,
type ModuleStorageObject,
type ModuleStorageObjectMetadata,
type ModuleStoragePutOptions,
} from "@ekklesia/contracts";
import { describe, expect, it } from "vitest";
import {
DEFAULT_STORAGE_BUCKET_PREFIX,
isValidStorageBucketPrefix,
physicalBucketName,
resolveModuleStorageBindings,
} from "../src/storage/binding.js";
import { createStorageProviderConfig, describeStorageProvider } from "../src/storage/config.js";
import { createModuleStorage, createModuleStorageLookup } from "../src/storage/module-storage.js";
import type { StorageProvider } from "../src/storage/provider.js";
/** Reads a Web stream to bytes without depending on DOM-only helper types. */
async function collect(stream: ReadableStream<Uint8Array>): Promise<Uint8Array> {
const reader = stream.getReader();
const chunks: Uint8Array[] = [];
let total = 0;
for (;;) {
const { done, value } = await reader.read();
if (done) {
break;
}
chunks.push(value);
total += value.byteLength;
}
const merged = new Uint8Array(total);
let offset = 0;
for (const chunk of chunks) {
merged.set(chunk, offset);
offset += chunk.byteLength;
}
return merged;
}
function streamOf(bytes: Uint8Array): ReadableStream<Uint8Array> {
return new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(bytes);
controller.close();
},
});
}
interface RecordedCall {
readonly operation: string;
readonly physicalBucket: string;
readonly key?: string;
}
/**
* A recording in-memory `StorageProvider`.
*
* Deliberately keyed by **physical** bucket, exactly as the real provider is, so a test can assert
* which physical namespace an operation actually reached — which is the only way to prove isolation
* rather than assume it.
*/
function fakeProvider(options: { failEnsure?: () => Error } = {}): StorageProvider & {
readonly calls: readonly RecordedCall[];
readonly buckets: ReadonlyMap<string, Map<string, { bytes: Uint8Array; contentType?: string }>>;
} {
const calls: RecordedCall[] = [];
const buckets = new Map<string, Map<string, { bytes: Uint8Array; contentType?: string }>>();
const objectsIn = (physicalBucket: string) => {
const bucket = buckets.get(physicalBucket);
if (!bucket) {
throw new ModuleStorageError("STORAGE_BUCKET_UNAVAILABLE");
}
return bucket;
};
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);
},
async ensureBucket(physicalBucket) {
calls.push({ operation: "ensureBucket", physicalBucket });
const failure = options.failEnsure?.();
if (failure) {
throw failure;
}
if (!buckets.has(physicalBucket)) {
buckets.set(physicalBucket, new Map());
}
},
async get(physicalBucket, key): Promise<ModuleStorageObject> {
calls.push({ key, operation: "get", physicalBucket });
const stored = objectsIn(physicalBucket).get(key);
if (!stored) {
throw new ModuleStorageError("STORAGE_OBJECT_NOT_FOUND", key);
}
return Object.freeze({
body: streamOf(stored.bytes),
contentLength: stored.bytes.byteLength,
key,
...(stored.contentType === undefined ? {} : { contentType: stored.contentType }),
});
},
async head(physicalBucket, key): Promise<ModuleStorageObjectMetadata> {
calls.push({ key, operation: "head", physicalBucket });
const stored = objectsIn(physicalBucket).get(key);
if (!stored) {
throw new ModuleStorageError("STORAGE_OBJECT_NOT_FOUND", key);
}
return Object.freeze({ contentLength: stored.bytes.byteLength, key });
},
async list(
physicalBucket,
listOptions: ModuleStorageListOptions,
): Promise<ModuleStorageListing> {
calls.push({ operation: "list", physicalBucket });
const entries = [...objectsIn(physicalBucket).keys()]
.filter((key) => listOptions.prefix === undefined || key.startsWith(listOptions.prefix))
.sort();
return Object.freeze({
objects: Object.freeze(entries.map((key) => Object.freeze({ key }))),
});
},
async probe() {},
async put(
physicalBucket,
key,
body: ModuleStorageBody,
putOptions: ModuleStoragePutOptions,
): Promise<ModuleStorageObjectMetadata> {
calls.push({ key, operation: "put", physicalBucket });
const bytes =
typeof body === "string"
? new TextEncoder().encode(body)
: body instanceof Uint8Array
? body
: await collect(body as ReadableStream<Uint8Array>);
objectsIn(physicalBucket).set(key, {
bytes,
...(putOptions.contentType === undefined ? {} : { contentType: putOptions.contentType }),
});
return Object.freeze({ contentLength: bytes.byteLength, key });
},
};
return Object.assign(provider, { buckets: buckets as never, calls: calls as never });
}
describe("physical bucket naming", () => {
it("is deterministic, so a binding survives restarts and redeployments", () => {
const first = physicalBucketName("ekklesia", "announcements", "announcement-assets");
const second = physicalBucketName("ekklesia", "announcements", "announcement-assets");
expect(first).toBe(second);
expect(first).toContain("announcements--announcement-assets");
});
it("keeps two modules declaring the same logical name in different namespaces", () => {
const announcements = physicalBucketName("ekklesia", "announcements", "assets");
const video = physicalBucketName("ekklesia", "video", "assets");
// The property the whole scheme exists for: `assets` is an obvious name two unrelated modules
// will both pick.
expect(announcements).not.toBe(video);
});
it("cannot be confused by a hyphen in either identifier", () => {
// The ambiguity a single-hyphen separator would have produced: module `a-b` + bucket `c` and
// module `a` + bucket `b-c` would both render as `a-b-c`, silently sharing a namespace.
const left = physicalBucketName("ekklesia", "a-b", "c");
const right = physicalBucketName("ekklesia", "a", "b-c");
expect(left).not.toBe(right);
});
it("stays within S3's 63-character bucket-name limit even for maximal identifiers", () => {
const moduleId = `m${"o".repeat(62)}`.slice(0, 64);
const bucketId = `b${"u".repeat(46)}`.slice(0, 48);
const name = physicalBucketName("ekklesia", moduleId, bucketId);
expect(name.length).toBeLessThanOrEqual(63);
// Still distinct from a neighbour that truncates to the same readable prefix, because the digest
// is taken over the full untruncated identity.
expect(name).not.toBe(physicalBucketName("ekklesia", moduleId, `${bucketId.slice(0, 47)}x`));
});
it("always produces a name S3 itself would accept", () => {
const samples = [
physicalBucketName("ekklesia", "a", "b"),
physicalBucketName("ekklesia", `m${"o".repeat(62)}`, `b${"u".repeat(46)}`),
physicalBucketName("church", "announcements", "announcement-assets"),
physicalBucketName("ekklesia", "a-b", "c-d"),
];
for (const name of samples) {
expect(name.length).toBeGreaterThanOrEqual(3);
expect(name.length).toBeLessThanOrEqual(63);
expect(name).toMatch(/^[a-z0-9][a-z0-9-]*[a-z0-9]$/u);
// No dots at all, so S3's adjacent-period and period-next-to-hyphen rules are unreachable and
// virtual-host-style addressing stays TLS-safe should a provider ever use it. Consecutive
// hyphens are explicitly fine — `--` is the separator, and the pinned SeaweedFS image accepts
// such names (verified directly against it in the integration suite).
expect(name).not.toContain(".");
// Never IP-shaped, and never a reserved prefix, because it always begins with the validated
// alphabetic prefix.
expect(name).not.toMatch(/^\d+-\d+-\d+-\d+$/u);
expect(name.startsWith("xn--")).toBe(false);
expect(name.startsWith("sthree-")).toBe(false);
expect(name.endsWith("-s3alias")).toBe(false);
expect(name.endsWith("--ol-s3")).toBe(false);
}
});
it("isolates two installations sharing one storage service through the prefix", () => {
const first = physicalBucketName("ekklesia", "announcements", "assets");
const second = physicalBucketName("stanne", "announcements", "assets");
expect(first).not.toBe(second);
});
it("accepts only a safe bucket prefix", () => {
expect(isValidStorageBucketPrefix(DEFAULT_STORAGE_BUCKET_PREFIX)).toBe(true);
for (const bad of ["", "Ekklesia", "ekklesia_", "-ekklesia", "ekklesia--x", "a".repeat(17)]) {
expect(isValidStorageBucketPrefix(bad)).toBe(false);
}
});
it("resolves every declared bucket in declaration order", () => {
const bindings = resolveModuleStorageBindings("ekklesia", "announcements", [
"announcement-assets",
"thumbnails",
]);
expect(bindings.map((binding) => binding.logicalBucketId)).toEqual([
"announcement-assets",
"thumbnails",
]);
expect(new Set(bindings.map((binding) => binding.physicalBucket)).size).toBe(2);
});
});
describe("object key validation", () => {
it("accepts ordinary and nested keys", () => {
for (const key of ["welcome.png", "slides/welcome.png", "a/b/c/deep.webp", "ünïcode.png"]) {
expect(isValidModuleStorageKey(key)).toBe(true);
}
});
it("rejects keys that would be ambiguous or unrepresentable", () => {
for (const key of [
"",
"/leading-slash.png",
"back\\slash.png",
"controlcharacter.png",
"controlcharacter.png",
"deletecharacter.png",
"\ud800lone-surrogate.png",
"x".repeat(MODULE_STORAGE_KEY_MAX_BYTES + 1),
]) {
expect(isValidModuleStorageKey(key)).toBe(false);
}
});
it("measures the length limit in UTF-8 bytes, not characters", () => {
// 512 three-byte characters is 1536 bytes — over the limit despite being 512 characters.
expect(isValidModuleStorageKey("あ".repeat(512))).toBe(false);
expect(isValidModuleStorageKey("あ".repeat(341))).toBe(true);
});
it("does not treat a key as a filesystem path", () => {
// `..` has no traversal meaning to an object store, and the physical bucket never comes from the
// key, so this is an ordinary (if odd) object name rather than an escape.
expect(isValidModuleStorageKey("../still-just-a-name.png")).toBe(true);
});
});
describe("module storage scoping", () => {
const provider = () => fakeProvider();
it("gives a module exactly the buckets its manifest declared", () => {
const storage = createModuleStorage({
bucketPrefix: "ekklesia",
logicalBucketIds: ["announcement-assets", "thumbnails"],
moduleId: "announcements",
provider: provider(),
});
expect(storage.buckets()).toEqual(["announcement-assets", "thumbnails"]);
expect(storage.bucket("announcement-assets").id).toBe("announcement-assets");
});
it("refuses a logical bucket the module did not declare", () => {
const storage = createModuleStorage({
bucketPrefix: "ekklesia",
logicalBucketIds: ["announcement-assets"],
moduleId: "announcements",
provider: provider(),
});
expect(() => storage.bucket("video-assets")).toThrow(ModuleStorageError);
try {
storage.bucket("video-assets");
} catch (error) {
expect((error as ModuleStorageError).code).toBe("STORAGE_BUCKET_UNDECLARED");
}
});
it("gives a module no way to reach another module's storage by knowing its name", async () => {
const shared = fakeProvider();
const announcements = createModuleStorage({
bucketPrefix: "ekklesia",
logicalBucketIds: ["assets"],
moduleId: "announcements",
provider: shared,
});
const video = createModuleStorage({
bucketPrefix: "ekklesia",
logicalBucketIds: ["assets"],
moduleId: "video",
provider: shared,
});
await announcements.bucket("assets").put("shared-name.txt", "from-announcements");
await video.bucket("assets").put("shared-name.txt", "from-video");
// Same logical name, same key, same provider — and two different objects, because the physical
// namespaces differ.
const decoder = new TextDecoder();
const fromAnnouncements = decoder.decode(
await collect((await announcements.bucket("assets").get("shared-name.txt")).body),
);
const fromVideo = decoder.decode(
await collect((await video.bucket("assets").get("shared-name.txt")).body),
);
expect(fromAnnouncements).toBe("from-announcements");
expect(fromVideo).toBe("from-video");
expect(shared.buckets.size).toBe(2);
});
it("exposes no physical bucket, endpoint, or credential through the module-facing surface", () => {
const storage = createModuleStorage({
bucketPrefix: "ekklesia",
logicalBucketIds: ["announcement-assets"],
moduleId: "announcements",
provider: provider(),
});
const bucket = storage.bucket("announcement-assets");
// The handle names the *logical* bucket, and has no accessor for anything else.
expect(bucket.id).toBe("announcement-assets");
// 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(
physicalBucketName("ekklesia", "announcements", "announcement-assets"),
);
expect(serialized).not.toContain("http");
});
it("gives a module declaring no buckets an empty, frozen storage that refuses everything", () => {
const storage = createModuleStorage({
bucketPrefix: "ekklesia",
logicalBucketIds: [],
moduleId: "calendar",
provider: provider(),
});
expect(storage).toBe(EMPTY_MODULE_STORAGE);
expect(storage.buckets()).toEqual([]);
expect(() => storage.bucket("anything")).toThrow(ModuleStorageError);
expect(Object.isFrozen(storage)).toBe(true);
});
it("resolves nothing at all when the host has no provider", () => {
const lookup = createModuleStorageLookup(undefined, "ekklesia", [
{ logicalBucketIds: ["assets"], moduleId: "announcements" },
]);
expect(lookup.size).toBe(0);
});
it.each([
"__proto__",
"constructor",
"prototype",
"toString",
])("treats the reserved property name %s as an ordinary undeclared bucket", (name) => {
const storage = createModuleStorage({
bucketPrefix: "ekklesia",
logicalBucketIds: ["assets"],
moduleId: "announcements",
provider: provider(),
});
expect(() => storage.bucket(name)).toThrow(ModuleStorageError);
});
});
describe("bucket provisioning", () => {
it("ensures the bucket lazily on first use, not at construction", async () => {
const shared = fakeProvider();
const storage = createModuleStorage({
bucketPrefix: "ekklesia",
logicalBucketIds: ["assets"],
moduleId: "announcements",
provider: shared,
});
// Constructing the facade — which is what startup does — touches the provider zero times, so a
// storage outage can never stop the platform from booting.
expect(shared.calls).toHaveLength(0);
await storage.bucket("assets").list();
expect(shared.calls.filter((call) => call.operation === "ensureBucket")).toHaveLength(1);
});
it("ensures at most once per binding across many operations", async () => {
const shared = fakeProvider();
const bucket = createModuleStorage({
bucketPrefix: "ekklesia",
logicalBucketIds: ["assets"],
moduleId: "announcements",
provider: shared,
}).bucket("assets");
await Promise.all([bucket.list(), bucket.list(), bucket.put("a.txt", "a")]);
await bucket.list();
expect(shared.calls.filter((call) => call.operation === "ensureBucket")).toHaveLength(1);
});
it("retries provisioning after a transient failure rather than poisoning the binding", async () => {
let failures = 1;
const shared = fakeProvider({
failEnsure: () =>
failures-- > 0 ? new ModuleStorageError("STORAGE_PROVIDER_UNAVAILABLE") : undefined!,
});
const bucket = createModuleStorage({
bucketPrefix: "ekklesia",
logicalBucketIds: ["assets"],
moduleId: "announcements",
provider: shared,
}).bucket("assets");
await expect(bucket.list()).rejects.toMatchObject({ code: "STORAGE_PROVIDER_UNAVAILABLE" });
// The next call tries again. A memoized *failure* would leave a module permanently broken by one
// network blip, which is exactly the resilience the real Announcements product depends on.
await expect(bucket.list()).resolves.toEqual({ objects: [] });
});
it("never offers a way to delete a bucket", () => {
const storage = createModuleStorage({
bucketPrefix: "ekklesia",
logicalBucketIds: ["assets"],
moduleId: "announcements",
provider: fakeProvider(),
});
const surface = [...Object.keys(storage), ...Object.keys(storage.bucket("assets"))];
expect(surface.join(" ")).not.toMatch(/deleteBucket|destroy|drop|removeBucket/iu);
});
});
describe("storage operations through the scoped facade", () => {
async function announcementsStorage() {
const provider = fakeProvider();
const storage = createModuleStorage({
bucketPrefix: "ekklesia",
logicalBucketIds: ["announcement-assets"],
moduleId: "announcements",
provider,
});
return { bucket: storage.bucket("announcement-assets"), provider };
}
it("round-trips an object and preserves its content type", async () => {
const { bucket } = await announcementsStorage();
const bytes = new Uint8Array([0x89, 0x50, 0x4e, 0x47]);
await bucket.put("slides/welcome.png", bytes, { contentType: "image/png" });
const object = await bucket.get("slides/welcome.png");
expect(object.contentType).toBe("image/png");
expect(await collect(object.body)).toEqual(bytes);
});
it("lists with a prefix and deletes", async () => {
const { bucket } = await announcementsStorage();
await bucket.put("slides/a.png", "a");
await bucket.put("slides/b.png", "b");
await bucket.put("other/c.png", "c");
expect((await bucket.list({ prefix: "slides/" })).objects.map((o) => o.key)).toEqual([
"slides/a.png",
"slides/b.png",
]);
await bucket.delete("slides/a.png");
expect((await bucket.list({ prefix: "slides/" })).objects.map((o) => o.key)).toEqual([
"slides/b.png",
]);
});
it("reports a missing object as a structured not-found error", async () => {
const { bucket } = await announcementsStorage();
await bucket.put("present.png", "x");
await expect(bucket.get("absent.png")).rejects.toMatchObject({
code: "STORAGE_OBJECT_NOT_FOUND",
key: "absent.png",
});
await expect(bucket.head("absent.png")).rejects.toMatchObject({
code: "STORAGE_OBJECT_NOT_FOUND",
});
});
it("rejects an invalid key before reaching the provider", async () => {
const { bucket, provider } = await announcementsStorage();
await expect(bucket.get("/leading-slash.png")).rejects.toMatchObject({
code: "STORAGE_KEY_INVALID",
});
// Not even the bucket was ensured: validation happens first.
expect(provider.calls).toHaveLength(0);
});
it("refuses a streamed body with no content length rather than buffering it", async () => {
const { bucket } = await announcementsStorage();
const stream = streamOf(new TextEncoder().encode("x"));
await expect(bucket.put("streamed.bin", stream)).rejects.toMatchObject({
code: "STORAGE_CONTENT_LENGTH_REQUIRED",
});
});
it("accepts a streamed body when the length is supplied", async () => {
const { bucket } = await announcementsStorage();
const stream = streamOf(new TextEncoder().encode("hello"));
const metadata = await bucket.put("streamed.bin", stream, { contentLength: 5 });
expect(metadata.key).toBe("streamed.bin");
});
});
describe("storage provider configuration", () => {
// Distinctive values, deliberately: a generic `"key"` would make a "no credential leaked"
// assertion pass or fail on whether a fixed error message happens to use the English word.
const base = {
accessKey: "AKIAEXAMPLEACCESSID",
endpoint: "https://storage.example.test",
secretKey: "sk-example-secret-material",
};
it("treats an absent endpoint as an installation with no object storage", () => {
expect(createStorageProviderConfig(undefined)).toBeUndefined();
expect(createStorageProviderConfig({ endpoint: "" })).toBeUndefined();
expect(createStorageProviderConfig({})).toBeUndefined();
});
it("accepts a valid S3-compatible configuration with safe defaults", () => {
const config = createStorageProviderConfig(base);
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.
forcePathStyle: true,
region: "us-east-1",
secretKey: "sk-example-secret-material",
});
});
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" }),
);
expect(() => createStorageProviderConfig({ ...base, secretKey: " " })).toThrow(
expect.objectContaining({ code: "STORAGE_SECRET_KEY_MISSING" }),
);
});
it("rejects an endpoint that embeds credentials", () => {
expect(() =>
createStorageProviderConfig({ ...base, endpoint: "https://key:[email protected]" }),
).toThrow(expect.objectContaining({ code: "STORAGE_ENDPOINT_WITH_CREDENTIALS" }));
});
it.each([
"not-a-url",
"ftp://storage.example.test",
"//storage.example.test",
])("rejects the malformed endpoint %s", (endpoint) => {
expect(() => createStorageProviderConfig({ ...base, endpoint })).toThrow(
expect.objectContaining({ code: "STORAGE_ENDPOINT_INVALID" }),
);
});
it("requires https in production but allows http in development", () => {
const http = { ...base, endpoint: "http://127.0.0.1:8333" };
expect(() => createStorageProviderConfig(http, { environment: "production" })).toThrow(
expect.objectContaining({ code: "STORAGE_ENDPOINT_INSECURE_IN_PRODUCTION" }),
);
expect(createStorageProviderConfig(http, { environment: "development" })?.endpoint).toBe(
"http://127.0.0.1:8333",
);
});
it("rejects an unsafe bucket prefix", () => {
expect(() => createStorageProviderConfig({ ...base, bucketPrefix: "Not Valid" })).toThrow(
expect.objectContaining({ code: "STORAGE_BUCKET_PREFIX_INVALID" }),
);
});
it("never carries a configured value in a configuration error", () => {
let captured: unknown;
try {
createStorageProviderConfig({
...base,
endpoint: "https://super-secret-host.invalid",
secretKey: undefined,
});
} catch (error) {
captured = error;
}
const rendered = `${String(captured)} ${JSON.stringify(captured)}`;
// No configured *value* survives: not the endpoint, not either credential. (The fixed message
// legitimately contains the word "key" — what must never appear is a key's value.)
expect(rendered).not.toContain("super-secret-host.invalid");
expect(rendered).not.toContain(base.accessKey);
expect(rendered).not.toContain(base.secretKey);
});
it("describes the provider for an operator without printing a credential", () => {
const config = createStorageProviderConfig({
...base,
accessKey: "AKIAVERYSECRETKEY",
endpoint: "https://storage.example.test/ignored-path",
secretKey: "a-very-secret-value",
});
if (!config) {
throw new Error("expected a configuration");
}
const description = describeStorageProvider(config);
expect(description).toContain("https://storage.example.test");
expect(description).toContain("path-style");
expect(description).not.toContain("AKIAVERYSECRETKEY");
expect(description).not.toContain("a-very-secret-value");
});
});
/**
* The Announcements-shaped proof.
*
* Modelled on the real product's requirements — a bucket of announcement images, listed and read —
* and never importing it. The point is what the fixture *cannot* see: it names a logical bucket and
* object keys, and has no access to SeaweedFS, an endpoint, an access key, or the physical bucket.
*/
describe("an Announcements-shaped module reads its slides without knowing any infrastructure", () => {
/** Everything this fixture knows. Compare with the real product, which today hardcodes
* `http://10.40.0.2:9000/announcements?list-type=2` and parses S3 XML by hand. */
const LOGICAL_BUCKET = "announcement-assets";
async function loadSlideKeys(storage: {
bucket: (id: string) => {
list: (options?: ModuleStorageListOptions) => Promise<ModuleStorageListing>;
};
}): Promise<readonly string[]> {
const listing = await storage.bucket(LOGICAL_BUCKET).list();
// Extension filtering is the module's own domain logic, exactly as it is today — Core has no
// opinion about which objects are slides.
return listing.objects
.map((object) => object.key)
.filter((key) => /\.(png|jpe?g|webp)$/iu.test(key));
}
it("lists and reads its own images through the logical bucket alone", async () => {
const provider = fakeProvider();
const storage = createModuleStorage({
bucketPrefix: "ekklesia",
logicalBucketIds: [LOGICAL_BUCKET],
moduleId: "announcements",
provider,
});
const bucket = storage.bucket(LOGICAL_BUCKET);
await bucket.put("welcome.png", new Uint8Array([1, 2, 3]), { contentType: "image/png" });
await bucket.put("notes.txt", "not a slide");
expect(await loadSlideKeys(storage)).toEqual(["welcome.png"]);
const slide = await bucket.get("welcome.png");
expect(slide.contentType).toBe("image/png");
// Straight into a `Response` — the shape a module route uses to serve an image to a browser
// without the browser ever learning that object storage exists.
// The shape a module route uses: the stream and its content type go straight into a `Response`,
// so a browser receives the image without ever learning that object storage exists.
const headers = new Headers();
if (slide.contentType) {
headers.set("content-type", slide.contentType);
}
expect(headers.get("content-type")).toBe("image/png");
expect(await collect(slide.body)).toEqual(new Uint8Array([1, 2, 3]));
});
it("knows the logical bucket and object keys, and nothing about the host", async () => {
const provider = fakeProvider();
const storage = createModuleStorage({
bucketPrefix: "ekklesia",
logicalBucketIds: [LOGICAL_BUCKET],
moduleId: "announcements",
provider,
});
await storage.bucket(LOGICAL_BUCKET).list();
const reachable = JSON.stringify({
bucketHandle: storage.bucket(LOGICAL_BUCKET),
buckets: storage.buckets(),
storage,
});
for (const infrastructure of [
"seaweed",
"SeaweedFS",
"10.40.0.2",
"9000",
"8333",
"s3",
"S3",
"accessKey",
"secretKey",
physicalBucketName("ekklesia", "announcements", LOGICAL_BUCKET),
]) {
expect(reachable).not.toContain(infrastructure);
}
// The physical bucket exists — the host created it — but only the provider ever saw its name.
expect([...provider.buckets.keys()]).toEqual([
physicalBucketName("ekklesia", "announcements", LOGICAL_BUCKET),
]);
});
it("surfaces a storage outage to the module without any lifecycle state changing", async () => {
const provider = fakeProvider({
failEnsure: () => new ModuleStorageError("STORAGE_PROVIDER_UNAVAILABLE"),
});
const bucket = createModuleStorage({
bucketPrefix: "ekklesia",
logicalBucketIds: [LOGICAL_BUCKET],
moduleId: "announcements",
provider,
}).bucket(LOGICAL_BUCKET);
// Exactly what the real product does today: catch, keep the previous slides, and retry on the
// next poll. Nothing here writes anything, anywhere — a transient outage is not an
// administrative decision (ADR 0030).
let caught: unknown;
try {
await bucket.list();
} catch (error) {
caught = error;
}
expect(caught).toBeInstanceOf(ModuleStorageError);
expect((caught as ModuleStorageError).code).toBe("STORAGE_PROVIDER_UNAVAILABLE");
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);
});
});