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 }> { 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 { 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); }); });