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]>
This commit is contained in:
co-authored by
Claude Opus 5
parent
9a3b8dab8f
commit
7ff063b87a
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user