feat: add host-provided module object storage
`manifest.storage.buckets` has existed since manifest version 1 and meant nothing — discovery validated it and no code read it. This turns it into a real capability, which is what stands between Announcements and the module system: that product currently fetches a hardcoded LAN S3 endpoint, parses ListObjectsV2 XML by hand, and hands the browser direct object URLs. - A module declares a logical bucket and receives a scoped facade at `context.storage` — list, head, get, put, delete. The contract names no provider: no endpoint, region, credential, or physical bucket is reachable from it, which is what lets the same module run under a future standalone host over its own or a shared S3-compatible service. - Core's first provider speaks S3 through the AWS SDK. SeaweedFS is the deployment target (ADR 0004), never an API name. Probing the pinned image found `CreateBucket` reporting `BucketAlreadyExists` rather than AWS's `BucketAlreadyOwnedByYou`; treating only the AWS spelling as success would have failed every second boot. A live test pins it. - Logical → physical mapping is deterministic and collision-safe: an installation prefix, the module and bucket ids joined by `--` (which cannot occur inside either identifier, so `a-b`+`c` and `a`+`b-c` cannot collide), and a digest over the full untruncated identity. Two modules declaring `assets` always get separate namespaces; neither can see the physical name. - Declaring a bucket is now a requirement: with no provider configured the module is `blocked · storage_unavailable` through ADR 0028's existing resolver, propagating to required dependents. An installation with no object storage stays a complete deployment. - Binding availability is not provider health. Eligibility asks whether storage is configured, never whether it answered; bucket creation is lazy on first use so a storage outage cannot stop the platform booting; a running module whose store blinks stays running and retries. Nothing deletes a bucket. - Credentials are deployment-supplied, never persisted, never routed through module configuration, and never visible to modules, module UI, diagnostics, or API responses. Provider errors translate to a closed code set carrying no endpoint, bucket, request id, or XML. - Retires ADR 0029's transitional `bucket` configuration key. No manifest version change. The template declares no bucket, so it still runs without S3. See ADR 0030. Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5
parent
527239f6de
commit
b9ca0d560d
@@ -47,6 +47,9 @@ function noConfigurationDependencies(): DoctorDependencies {
|
||||
planMigrations: async () => {
|
||||
throw new Error("database must not be planned");
|
||||
},
|
||||
probeStorage: async () => {
|
||||
throw new Error("storage must not be probed");
|
||||
},
|
||||
readModuleConfiguration: async () => {
|
||||
throw new Error("module configuration must not be read");
|
||||
},
|
||||
@@ -112,8 +115,10 @@ Identity
|
||||
Trusts only the actual immediate socket peer for rate limiting; no forwarded header is trusted.
|
||||
|
||||
Object storage
|
||||
○ Check unavailable
|
||||
Core storage integration has not been implemented yet.
|
||||
○ Object storage not configured
|
||||
No S3_ENDPOINT is set. This is a complete deployment unless a module requires storage.
|
||||
✓ No module requires object storage
|
||||
0 modules declare a logical storage bucket.
|
||||
|
||||
Result: READY WITH WARNINGS`);
|
||||
});
|
||||
|
||||
+171
-4
@@ -138,6 +138,7 @@ function dependencies(overrides: Partial<DoctorDependencies> = {}): DoctorDepend
|
||||
discoverModules: vi.fn(async () => Object.freeze([])),
|
||||
discoverSchemas: vi.fn(async () => schemaAggregate()),
|
||||
planMigrations: vi.fn(async () => plan()),
|
||||
probeStorage: vi.fn(async () => undefined),
|
||||
readModuleConfiguration: vi.fn(async () => Object.freeze({ records: Object.freeze([]) })),
|
||||
readModuleLifecycle: vi.fn(async () => Object.freeze({ modules: Object.freeze([]) })),
|
||||
runtimeSatisfies: vi.fn(() => true),
|
||||
@@ -217,6 +218,7 @@ describe("Ekklesia Doctor diagnostics", () => {
|
||||
"identity.configuration",
|
||||
"identity.client-address",
|
||||
"object-storage.integration",
|
||||
"object-storage.bindings",
|
||||
]);
|
||||
expect(check(report, "modules.discovery").details).toEqual(["people 0.1.0", "calendar 0.2.0"]);
|
||||
expect(check(report, "modules.dependencies").details).toEqual(["Order: people → calendar"]);
|
||||
@@ -553,15 +555,19 @@ describe("Ekklesia Doctor diagnostics", () => {
|
||||
expect(serialized).not.toContain("also-secret.invalid");
|
||||
});
|
||||
|
||||
it("always reports object storage as unavailable without probing configuration", async () => {
|
||||
const report = await diagnoseEkklesia(options(), dependencies());
|
||||
it("skips the storage check, without probing, when no provider is configured", async () => {
|
||||
const services = dependencies();
|
||||
const report = await diagnoseEkklesia(options(), services);
|
||||
|
||||
expect(check(report, "object-storage.integration")).toEqual({
|
||||
details: ["Core storage integration has not been implemented yet."],
|
||||
details: [
|
||||
"No S3_ENDPOINT is set. This is a complete deployment unless a module requires storage.",
|
||||
],
|
||||
id: "object-storage.integration",
|
||||
status: "skipped",
|
||||
summary: "Check unavailable",
|
||||
summary: "Object storage not configured",
|
||||
});
|
||||
expect(services.probeStorage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("continues static discovery when runtime validation fails", async () => {
|
||||
@@ -1208,3 +1214,164 @@ describe("Ekklesia Doctor — module configuration", () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Ekklesia Doctor — object storage", () => {
|
||||
const storageConfig = {
|
||||
accessKey: "AKIAEXAMPLEACCESSID",
|
||||
bucketPrefix: "ekklesia",
|
||||
endpoint: "https://storage.example.test",
|
||||
forcePathStyle: true,
|
||||
region: "us-east-1",
|
||||
secretKey: "sk-example-secret-material",
|
||||
} as const;
|
||||
|
||||
function storingModule(id: string, buckets: readonly string[]): DiscoveredModule {
|
||||
const base = discoveredModule(id);
|
||||
return {
|
||||
...base,
|
||||
manifest: {
|
||||
...base.manifest,
|
||||
storage: { buckets: buckets.map((bucketId) => ({ id: bucketId })) },
|
||||
} as unknown as ModuleManifest,
|
||||
};
|
||||
}
|
||||
|
||||
it("does not probe, and stays healthy, when no provider is configured and nothing needs one", async () => {
|
||||
const services = dependencies({
|
||||
discoverModules: vi.fn(async () => Object.freeze([discoveredModule("calendar")])),
|
||||
});
|
||||
|
||||
const report = await diagnoseEkklesia(options(databaseUrl), services);
|
||||
|
||||
expect(services.probeStorage).not.toHaveBeenCalled();
|
||||
expect(check(report, "object-storage.integration").status).toBe("skipped");
|
||||
// The load-bearing assertion: an installation whose modules need no object storage is not
|
||||
// unhealthy for lacking a provider.
|
||||
expect(check(report, "object-storage.bindings").status).toBe("pass");
|
||||
expect(check(report, "object-storage.bindings").details).toEqual([
|
||||
"0 modules declare a logical storage bucket.",
|
||||
]);
|
||||
});
|
||||
|
||||
it("fails the bindings check when a module requires storage and none is configured", async () => {
|
||||
const services = dependencies({
|
||||
discoverModules: vi.fn(async () =>
|
||||
Object.freeze([storingModule("announcements", ["announcement-assets"])]),
|
||||
),
|
||||
});
|
||||
|
||||
const report = await diagnoseEkklesia(options(databaseUrl), services);
|
||||
const bindings = check(report, "object-storage.bindings");
|
||||
|
||||
expect(bindings.status).toBe("failure");
|
||||
expect(bindings.details[0]).toContain("Configure an S3-compatible Ekklesia storage provider");
|
||||
expect(bindings.details).toContain(
|
||||
"announcements · announcement-assets · unbound (no storage provider configured)",
|
||||
);
|
||||
// And the lifecycle check agrees, because both come from one resolution.
|
||||
expect(check(report, "modules.lifecycle").details).toContain(
|
||||
"announcements 0.1.0 · blocked · storage_unavailable · announcement-assets",
|
||||
);
|
||||
});
|
||||
|
||||
it("resolves and lists every binding when a provider is configured", async () => {
|
||||
const services = dependencies({
|
||||
discoverModules: vi.fn(async () =>
|
||||
Object.freeze([storingModule("announcements", ["announcement-assets"])]),
|
||||
),
|
||||
});
|
||||
|
||||
const report = await diagnoseEkklesia(
|
||||
{ ...options(databaseUrl), storage: storageConfig },
|
||||
services,
|
||||
);
|
||||
const bindings = check(report, "object-storage.bindings");
|
||||
|
||||
expect(bindings.status).toBe("pass");
|
||||
// The physical bucket is named here deliberately: Doctor is an operator tool, and someone
|
||||
// debugging a permissions problem needs to know which bucket to look at. It is still never
|
||||
// reachable from module-facing code.
|
||||
expect(bindings.details[0]).toMatch(
|
||||
/^announcements · announcement-assets → ekklesia-announcements--announcement-assets-[0-9a-f]{12}$/u,
|
||||
);
|
||||
});
|
||||
|
||||
it("probes a configured provider and reports it reachable, without printing a credential", async () => {
|
||||
const services = dependencies({
|
||||
discoverModules: vi.fn(async () => Object.freeze([discoveredModule("calendar")])),
|
||||
});
|
||||
|
||||
const report = await diagnoseEkklesia(
|
||||
{ ...options(databaseUrl), storage: storageConfig },
|
||||
services,
|
||||
);
|
||||
const integration = check(report, "object-storage.integration");
|
||||
|
||||
expect(services.probeStorage).toHaveBeenCalledWith(storageConfig);
|
||||
expect(integration.status).toBe("pass");
|
||||
expect(integration.details[0]).toContain("https://storage.example.test");
|
||||
const serialized = JSON.stringify(integration);
|
||||
expect(serialized).not.toContain("AKIAEXAMPLEACCESSID");
|
||||
expect(serialized).not.toContain("sk-example-secret-material");
|
||||
});
|
||||
|
||||
it("warns rather than fails when a configured provider is unreachable", async () => {
|
||||
const services = dependencies({
|
||||
discoverModules: vi.fn(async () => Object.freeze([discoveredModule("calendar")])),
|
||||
probeStorage: vi.fn(async () => {
|
||||
throw new Error("connect ECONNREFUSED 10.40.0.2:8333 accessKey=AKIAEXAMPLEACCESSID");
|
||||
}),
|
||||
});
|
||||
|
||||
const report = await diagnoseEkklesia(
|
||||
{ ...options(databaseUrl), storage: storageConfig },
|
||||
services,
|
||||
);
|
||||
const integration = check(report, "object-storage.integration");
|
||||
|
||||
// A warning, never a failure. A momentary network blip must not make the whole platform read as
|
||||
// NOT READY, and it must never look like a module problem (ADR 0030).
|
||||
expect(integration.status).toBe("warning");
|
||||
expect(report.result).not.toBe("not-ready");
|
||||
expect(integration.details).toContain(
|
||||
"The configured storage service did not respond. Modules already running are unaffected.",
|
||||
);
|
||||
const serialized = JSON.stringify(integration);
|
||||
expect(serialized).not.toContain("AKIAEXAMPLEACCESSID");
|
||||
expect(serialized).not.toContain("10.40.0.2");
|
||||
});
|
||||
|
||||
it("still resolves bindings when the provider is unreachable, because binding is not health", async () => {
|
||||
const services = dependencies({
|
||||
discoverModules: vi.fn(async () =>
|
||||
Object.freeze([storingModule("announcements", ["announcement-assets"])]),
|
||||
),
|
||||
probeStorage: vi.fn(async () => {
|
||||
throw new Error("unreachable");
|
||||
}),
|
||||
});
|
||||
|
||||
const report = await diagnoseEkklesia(
|
||||
{ ...options(databaseUrl), storage: storageConfig },
|
||||
services,
|
||||
);
|
||||
|
||||
expect(check(report, "object-storage.integration").status).toBe("warning");
|
||||
// The module is still eligible: a configured-but-briefly-unreachable provider is an operational
|
||||
// condition, not a lifecycle state.
|
||||
expect(check(report, "object-storage.bindings").status).toBe("pass");
|
||||
expect(check(report, "modules.lifecycle").details).toContain("announcements 0.1.0 · enabled");
|
||||
});
|
||||
|
||||
it("skips bindings when discovery itself failed", async () => {
|
||||
const services = dependencies({
|
||||
discoverModules: vi.fn(async () => {
|
||||
throw new Error("broken inventory");
|
||||
}),
|
||||
});
|
||||
|
||||
const report = await diagnoseEkklesia(options(databaseUrl), services);
|
||||
|
||||
expect(check(report, "object-storage.bindings").status).toBe("skipped");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,309 @@
|
||||
import { parseModuleManifest } from "@ekklesia/contracts";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { DiscoveredModule } from "../src/modules/discovery.js";
|
||||
import {
|
||||
isStorageBlockedDetail,
|
||||
resolveModuleEligibility,
|
||||
resolveModuleLifecycleView,
|
||||
} from "../src/modules/lifecycle.js";
|
||||
|
||||
/** A minimal in-memory `DiscoveredModule`, built through `parseModuleManifest` so a fixture real
|
||||
* discovery would reject can never stand in for one it would accept. */
|
||||
function fakeModule(
|
||||
id: string,
|
||||
options: {
|
||||
readonly buckets?: readonly string[];
|
||||
readonly dependencies?: Record<string, unknown>;
|
||||
readonly configuration?: readonly Record<string, unknown>[];
|
||||
} = {},
|
||||
): DiscoveredModule {
|
||||
const manifest = parseModuleManifest({
|
||||
manifestVersion: 5,
|
||||
id,
|
||||
name: `${id} module`,
|
||||
version: "1.0.0",
|
||||
compatibility: { platform: "^0.1.0", moduleSdk: "^0.1.0" },
|
||||
...(options.configuration ? { configuration: options.configuration } : {}),
|
||||
...(options.dependencies ? { dependencies: options.dependencies } : {}),
|
||||
...(options.buckets ? { storage: { buckets: options.buckets.map((id) => ({ id })) } } : {}),
|
||||
entrypoints: { server: "./dist/does-not-exist/index.js" },
|
||||
});
|
||||
return {
|
||||
directory: `/modules/${id}`,
|
||||
entrypoints: { server: `/modules/${id}/dist/does-not-exist/index.js` },
|
||||
manifest,
|
||||
manifestPath: `/modules/${id}/ekklesia.module.json`,
|
||||
packageName: `@test/${id}`,
|
||||
};
|
||||
}
|
||||
|
||||
function enablement(...records: readonly (readonly [string, boolean])[]) {
|
||||
return { modules: records.map(([moduleId, enabled]) => ({ moduleId, enabled })) };
|
||||
}
|
||||
|
||||
const noConfiguration = { settings: { records: [] } };
|
||||
const withStorage = { providerConfigured: true };
|
||||
const withoutStorage = { providerConfigured: false };
|
||||
|
||||
function ids(modules: readonly DiscoveredModule[]): readonly string[] {
|
||||
return modules.map((module) => module.manifest.id);
|
||||
}
|
||||
|
||||
function entry(
|
||||
resolution: ReturnType<typeof resolveModuleEligibility>,
|
||||
moduleId: string,
|
||||
): ReturnType<typeof resolveModuleEligibility>["entries"][number] {
|
||||
const found = resolution.entries.find((candidate) => candidate.moduleId === moduleId);
|
||||
if (!found) {
|
||||
throw new Error(`No eligibility entry for ${moduleId}.`);
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
describe("declared storage decides eligibility", () => {
|
||||
it("keeps a module requiring storage eligible when a provider is configured", () => {
|
||||
const resolution = resolveModuleEligibility(
|
||||
[fakeModule("announcements", { buckets: ["announcement-assets"] })],
|
||||
enablement(),
|
||||
noConfiguration,
|
||||
withStorage,
|
||||
);
|
||||
|
||||
expect(ids(resolution.eligibleModules)).toEqual(["announcements"]);
|
||||
expect(entry(resolution, "announcements").blocked).toBeUndefined();
|
||||
});
|
||||
|
||||
it("blocks a module requiring storage when the host has no provider, naming the logical bucket", () => {
|
||||
const resolution = resolveModuleEligibility(
|
||||
[fakeModule("announcements", { buckets: ["announcement-assets"] })],
|
||||
enablement(),
|
||||
noConfiguration,
|
||||
withoutStorage,
|
||||
);
|
||||
|
||||
expect(resolution.eligibleModules).toEqual([]);
|
||||
expect(entry(resolution, "announcements").blocked).toEqual({
|
||||
reason: "storage_unavailable",
|
||||
storageBucketId: "announcement-assets",
|
||||
});
|
||||
});
|
||||
|
||||
it("leaves a module declaring no buckets entirely unaffected by storage", () => {
|
||||
const resolution = resolveModuleEligibility(
|
||||
[fakeModule("calendar")],
|
||||
enablement(),
|
||||
noConfiguration,
|
||||
withoutStorage,
|
||||
);
|
||||
|
||||
// An installation with no object storage is a complete, valid deployment.
|
||||
expect(ids(resolution.eligibleModules)).toEqual(["calendar"]);
|
||||
expect(entry(resolution, "calendar").blocked).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not evaluate a disabled module's storage requirement", () => {
|
||||
const resolution = resolveModuleEligibility(
|
||||
[fakeModule("announcements", { buckets: ["announcement-assets"] })],
|
||||
enablement(["announcements", false]),
|
||||
noConfiguration,
|
||||
withoutStorage,
|
||||
);
|
||||
|
||||
// Disabled is not blocked: reporting a storage problem for something nobody asked to run would
|
||||
// be noise.
|
||||
expect(entry(resolution, "announcements").blocked).toBeUndefined();
|
||||
expect(entry(resolution, "announcements").configured.enabled).toBe(false);
|
||||
});
|
||||
|
||||
it("defaults to no provider when the caller supplies no storage input at all", () => {
|
||||
// Every pre-existing caller keeps its exact behaviour: the parameter is optional, and a module
|
||||
// that declares no bucket is unaffected either way.
|
||||
const withoutArgument = resolveModuleEligibility([fakeModule("calendar")], enablement());
|
||||
expect(ids(withoutArgument.eligibleModules)).toEqual(["calendar"]);
|
||||
|
||||
const requiring = resolveModuleEligibility(
|
||||
[fakeModule("announcements", { buckets: ["assets"] })],
|
||||
enablement(),
|
||||
);
|
||||
expect(requiring.eligibleModules).toEqual([]);
|
||||
});
|
||||
|
||||
it("names only the logical bucket — never an endpoint, credential, or physical bucket", () => {
|
||||
const resolution = resolveModuleEligibility(
|
||||
[fakeModule("announcements", { buckets: ["announcement-assets"] })],
|
||||
enablement(),
|
||||
noConfiguration,
|
||||
withoutStorage,
|
||||
);
|
||||
const serialized = JSON.stringify(resolution.entries);
|
||||
|
||||
expect(serialized).toContain("announcement-assets");
|
||||
expect(serialized).not.toContain("ekklesia-announcements--");
|
||||
expect(serialized).not.toContain("http");
|
||||
expect(serialized).not.toMatch(/accessKey|secretKey|endpoint/iu);
|
||||
});
|
||||
});
|
||||
|
||||
describe("storage blocking propagates through the dependency graph", () => {
|
||||
const graph = [
|
||||
fakeModule("media-storage", { buckets: ["objects"] }),
|
||||
fakeModule("media", { dependencies: { modules: { required: { "media-storage": "^1.0.0" } } } }),
|
||||
fakeModule("announcements", { dependencies: { modules: { required: { media: "^1.0.0" } } } }),
|
||||
];
|
||||
|
||||
it("blocks required dependents of a storage-blocked module, transitively", () => {
|
||||
const resolution = resolveModuleEligibility(
|
||||
graph,
|
||||
enablement(),
|
||||
noConfiguration,
|
||||
withoutStorage,
|
||||
);
|
||||
|
||||
expect(resolution.eligibleModules).toEqual([]);
|
||||
// One mechanism, not two: the chain terminates at the module whose own requirement is
|
||||
// unsatisfiable, exactly as it terminates at a disabled or misconfigured module.
|
||||
expect(entry(resolution, "media-storage").blocked).toEqual({
|
||||
reason: "storage_unavailable",
|
||||
storageBucketId: "objects",
|
||||
});
|
||||
expect(entry(resolution, "media").blocked).toEqual({
|
||||
dependency: { kind: "module", moduleId: "media-storage" },
|
||||
reason: "required_dependency_blocked",
|
||||
});
|
||||
expect(entry(resolution, "announcements").blocked).toEqual({
|
||||
dependency: { kind: "module", moduleId: "media" },
|
||||
reason: "required_dependency_blocked",
|
||||
});
|
||||
});
|
||||
|
||||
it("unblocks the whole chain once a provider is configured", () => {
|
||||
const resolution = resolveModuleEligibility(graph, enablement(), noConfiguration, withStorage);
|
||||
|
||||
expect(ids(resolution.eligibleModules)).toEqual(["media-storage", "media", "announcements"]);
|
||||
});
|
||||
|
||||
it("never blocks an unrelated module because another module needs storage", () => {
|
||||
const resolution = resolveModuleEligibility(
|
||||
[...graph, fakeModule("calendar")],
|
||||
enablement(),
|
||||
noConfiguration,
|
||||
withoutStorage,
|
||||
);
|
||||
|
||||
expect(ids(resolution.eligibleModules)).toEqual(["calendar"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("storage and configuration blocking coexist without a second mechanism", () => {
|
||||
it("reports the storage requirement first when both are unsatisfiable", () => {
|
||||
const resolution = resolveModuleEligibility(
|
||||
[
|
||||
fakeModule("announcements", {
|
||||
buckets: ["assets"],
|
||||
configuration: [{ key: "bucketLabel", type: "string", required: true }],
|
||||
}),
|
||||
],
|
||||
enablement(),
|
||||
noConfiguration,
|
||||
withoutStorage,
|
||||
);
|
||||
|
||||
// Deterministic ordering — storage is evaluated before configuration — so an operator always
|
||||
// sees the same reason for the same state rather than one that depends on evaluation order.
|
||||
const blocked = entry(resolution, "announcements").blocked;
|
||||
expect(blocked && isStorageBlockedDetail(blocked)).toBe(true);
|
||||
});
|
||||
|
||||
it("falls through to the configuration reason once storage is available", () => {
|
||||
const resolution = resolveModuleEligibility(
|
||||
[
|
||||
fakeModule("announcements", {
|
||||
buckets: ["assets"],
|
||||
configuration: [{ key: "bucketLabel", type: "string", required: true }],
|
||||
}),
|
||||
],
|
||||
enablement(),
|
||||
noConfiguration,
|
||||
withStorage,
|
||||
);
|
||||
|
||||
expect(entry(resolution, "announcements").blocked).toEqual({
|
||||
configurationKey: "bucketLabel",
|
||||
reason: "configuration_missing",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("the lifecycle view reports storage state honestly", () => {
|
||||
const discoveredModules = [fakeModule("announcements", { buckets: ["announcement-assets"] })];
|
||||
|
||||
function bootSnapshot(storage: { providerConfigured: boolean }) {
|
||||
const resolution = resolveModuleEligibility(
|
||||
discoveredModules,
|
||||
enablement(),
|
||||
noConfiguration,
|
||||
storage,
|
||||
);
|
||||
return {
|
||||
discoveredModules,
|
||||
eligibility: resolution.entries,
|
||||
startedModuleIds: resolution.eligibleModules.map((module) => module.manifest.id),
|
||||
};
|
||||
}
|
||||
|
||||
it("reports a storage-blocked module with the logical bucket and no pending restart", () => {
|
||||
const view = resolveModuleLifecycleView(
|
||||
bootSnapshot(withoutStorage),
|
||||
enablement(),
|
||||
noConfiguration,
|
||||
withoutStorage,
|
||||
);
|
||||
|
||||
expect(view[0]?.runtime).toEqual({
|
||||
reason: "storage_unavailable",
|
||||
state: "blocked",
|
||||
storageBucketId: "announcement-assets",
|
||||
});
|
||||
expect(view[0]?.restartRequired).toBe(false);
|
||||
});
|
||||
|
||||
it("reports a pending restart once a provider is configured for a blocked module", () => {
|
||||
const view = resolveModuleLifecycleView(
|
||||
bootSnapshot(withoutStorage),
|
||||
enablement(),
|
||||
noConfiguration,
|
||||
withStorage,
|
||||
);
|
||||
|
||||
// The next boot really will start it, so the honest answer is that a restart is pending — while
|
||||
// the runtime line still describes what *this* process did.
|
||||
expect(view[0]?.runtime.state).toBe("blocked");
|
||||
expect(view[0]?.restartRequired).toBe(true);
|
||||
});
|
||||
|
||||
it("reports a running module with storage as ordinary and unremarkable", () => {
|
||||
const view = resolveModuleLifecycleView(
|
||||
bootSnapshot(withStorage),
|
||||
enablement(),
|
||||
noConfiguration,
|
||||
withStorage,
|
||||
);
|
||||
|
||||
expect(view[0]?.runtime).toEqual({ state: "running" });
|
||||
expect(view[0]?.restartRequired).toBe(false);
|
||||
});
|
||||
|
||||
it("puts no infrastructure detail into the view", () => {
|
||||
const view = resolveModuleLifecycleView(
|
||||
bootSnapshot(withoutStorage),
|
||||
enablement(),
|
||||
noConfiguration,
|
||||
withoutStorage,
|
||||
);
|
||||
const serialized = JSON.stringify(view);
|
||||
|
||||
expect(serialized).toContain("announcement-assets");
|
||||
expect(serialized).not.toContain("ekklesia-announcements--");
|
||||
expect(serialized).not.toMatch(/http|accessKey|secretKey|seaweed/iu);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,357 @@
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { ModuleStorageError } from "@ekklesia/contracts";
|
||||
import { afterAll, describe, expect, it } from "vitest";
|
||||
import { physicalBucketName } from "../src/storage/binding.js";
|
||||
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";
|
||||
|
||||
/**
|
||||
* The storage contract against a **real S3-compatible server**.
|
||||
*
|
||||
* The unit suite proves scoping, binding, and validation with an in-memory provider; this proves the
|
||||
* things only a real server can — and the reason it must exist is concrete. Probing the pinned
|
||||
* SeaweedFS 4.28 image directly revealed that `CreateBucket` on an existing bucket reports
|
||||
* `BucketAlreadyExists`, **not** AWS's `BucketAlreadyOwnedByYou`. A provider that only treated the
|
||||
* AWS spelling as success would have failed on every second boot against this repository's own
|
||||
* development storage service, and no amount of mocking would have caught it.
|
||||
*
|
||||
* SeaweedFS is used rather than a generic mock because it is the intended deployment target
|
||||
* (ADR 0004) and because the repository's Compose file already pins and provisions it, so this adds
|
||||
* no new CI infrastructure. Nothing here is SeaweedFS-specific in the *contract* — the same suite
|
||||
* would pass against MinIO or AWS S3, which is the portability claim being tested.
|
||||
*/
|
||||
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() {
|
||||
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],
|
||||
endpoint,
|
||||
secretKey: process.env[secretKeyEnvironment],
|
||||
},
|
||||
// The disposable service speaks plaintext on loopback, exactly like the disposable PostgreSQL
|
||||
// this suite already uses; production configuration would refuse it.
|
||||
{ environment: "development" },
|
||||
);
|
||||
if (!config) {
|
||||
throw new IntegrationHarnessError("The storage integration configuration is invalid.");
|
||||
}
|
||||
return config;
|
||||
}
|
||||
|
||||
/** A unique prefix per run, so repeated runs and parallel jobs never collide and no test depends on
|
||||
* another's leftovers. */
|
||||
function disposablePrefix(): string {
|
||||
return `ekktest${randomBytes(3).toString("hex")}`;
|
||||
}
|
||||
|
||||
const openProviders: StorageProvider[] = [];
|
||||
|
||||
function provider(): StorageProvider {
|
||||
const created = createS3StorageProvider(providerConfig());
|
||||
openProviders.push(created);
|
||||
return created;
|
||||
}
|
||||
|
||||
afterAll(async () => {
|
||||
for (const open of openProviders.splice(0)) {
|
||||
await open.close().catch(() => undefined);
|
||||
}
|
||||
});
|
||||
|
||||
describe.sequential("live S3-compatible module storage", () => {
|
||||
it("creates a module's bucket idempotently, across both provider dialects", async () => {
|
||||
const shared = provider();
|
||||
const prefix = disposablePrefix();
|
||||
const bucket = physicalBucketName(prefix, "announcements", "announcement-assets");
|
||||
|
||||
await shared.ensureBucket(bucket);
|
||||
// The assertion this whole suite exists for: the pinned SeaweedFS image answers a repeat
|
||||
// `CreateBucket` with `BucketAlreadyExists` rather than AWS's `BucketAlreadyOwnedByYou`, and the
|
||||
// provider must treat both as success. Every restart depends on this.
|
||||
await expect(shared.ensureBucket(bucket)).resolves.toBeUndefined();
|
||||
await expect(shared.ensureBucket(bucket)).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("round-trips an object through the scoped facade with its content type preserved", async () => {
|
||||
const prefix = disposablePrefix();
|
||||
const storage = createModuleStorage({
|
||||
bucketPrefix: prefix,
|
||||
logicalBucketIds: ["announcement-assets"],
|
||||
moduleId: "announcements",
|
||||
provider: provider(),
|
||||
});
|
||||
const bucket = storage.bucket("announcement-assets");
|
||||
const png = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
||||
|
||||
const written = await bucket.put("slides/welcome.png", png, { contentType: "image/png" });
|
||||
expect(written.key).toBe("slides/welcome.png");
|
||||
|
||||
const object = await bucket.get("slides/welcome.png");
|
||||
expect(object.contentType).toBe("image/png");
|
||||
expect(object.contentLength).toBe(png.byteLength);
|
||||
// Unquoted for the caller — providers return a quoted ETag, and every module learning to strip
|
||||
// quotes would be a contract failure rather than a module failure.
|
||||
expect(object.etag).toMatch(/^[0-9a-f]{32}$/u);
|
||||
expect(new Uint8Array(await new Response(object.body).arrayBuffer())).toEqual(png);
|
||||
});
|
||||
|
||||
it("streams a body without buffering, when the length is supplied", async () => {
|
||||
const prefix = disposablePrefix();
|
||||
const bucket = createModuleStorage({
|
||||
bucketPrefix: prefix,
|
||||
logicalBucketIds: ["assets"],
|
||||
moduleId: "media",
|
||||
provider: provider(),
|
||||
}).bucket("assets");
|
||||
const payload = "x".repeat(64 * 1024);
|
||||
const stream = new Blob([payload]).stream() as ReadableStream<Uint8Array>;
|
||||
|
||||
await bucket.put("large.bin", stream, {
|
||||
contentLength: payload.length,
|
||||
contentType: "application/octet-stream",
|
||||
});
|
||||
|
||||
const object = await bucket.get("large.bin");
|
||||
expect(object.contentLength).toBe(payload.length);
|
||||
expect(await new Response(object.body).text()).toBe(payload);
|
||||
});
|
||||
|
||||
it("returns a Web stream a module can hand straight to a Response", async () => {
|
||||
const prefix = disposablePrefix();
|
||||
const bucket = createModuleStorage({
|
||||
bucketPrefix: prefix,
|
||||
logicalBucketIds: ["assets"],
|
||||
moduleId: "announcements",
|
||||
provider: provider(),
|
||||
}).bucket("assets");
|
||||
await bucket.put("welcome.png", new Uint8Array([1, 2, 3]), { contentType: "image/png" });
|
||||
|
||||
const object = await bucket.get("welcome.png");
|
||||
expect(object.body).toBeInstanceOf(ReadableStream);
|
||||
// The browser-delivery shape this pass settles on: browser → module route → scoped storage,
|
||||
// with no S3 knowledge or credential anywhere near the browser.
|
||||
const response = new Response(object.body, {
|
||||
headers: { "content-type": object.contentType ?? "application/octet-stream" },
|
||||
});
|
||||
expect(response.headers.get("content-type")).toBe("image/png");
|
||||
expect(new Uint8Array(await response.arrayBuffer())).toEqual(new Uint8Array([1, 2, 3]));
|
||||
});
|
||||
|
||||
it("heads, lists with a prefix, and deletes", async () => {
|
||||
const prefix = disposablePrefix();
|
||||
const bucket = createModuleStorage({
|
||||
bucketPrefix: prefix,
|
||||
logicalBucketIds: ["assets"],
|
||||
moduleId: "announcements",
|
||||
provider: provider(),
|
||||
}).bucket("assets");
|
||||
|
||||
await bucket.put("slides/a.png", "a", { contentType: "image/png" });
|
||||
await bucket.put("slides/b.png", "b", { contentType: "image/png" });
|
||||
await bucket.put("other/c.txt", "c");
|
||||
|
||||
const metadata = await bucket.head("slides/a.png");
|
||||
expect(metadata.contentType).toBe("image/png");
|
||||
expect(metadata.contentLength).toBe(1);
|
||||
expect(metadata.lastModified).toBeInstanceOf(Date);
|
||||
|
||||
const listing = await bucket.list({ prefix: "slides/" });
|
||||
expect(listing.objects.map((object) => object.key)).toEqual(["slides/a.png", "slides/b.png"]);
|
||||
expect(listing.objects[0]?.contentLength).toBe(1);
|
||||
// A complete listing carries no cursor, so an absent cursor always means "done".
|
||||
expect(listing.cursor).toBeUndefined();
|
||||
|
||||
await bucket.delete("slides/a.png");
|
||||
expect((await bucket.list({ prefix: "slides/" })).objects.map((o) => o.key)).toEqual([
|
||||
"slides/b.png",
|
||||
]);
|
||||
});
|
||||
|
||||
it("paginates a listing rather than silently truncating it", async () => {
|
||||
const prefix = disposablePrefix();
|
||||
const bucket = createModuleStorage({
|
||||
bucketPrefix: prefix,
|
||||
logicalBucketIds: ["assets"],
|
||||
moduleId: "paged",
|
||||
provider: provider(),
|
||||
}).bucket("assets");
|
||||
for (const index of [0, 1, 2, 3, 4]) {
|
||||
await bucket.put(`object-${index}.txt`, String(index));
|
||||
}
|
||||
|
||||
const first = await bucket.list({ limit: 2 });
|
||||
expect(first.objects).toHaveLength(2);
|
||||
expect(first.cursor).toBeDefined();
|
||||
|
||||
const keys = [...first.objects.map((object) => object.key)];
|
||||
let cursor = first.cursor;
|
||||
while (cursor !== undefined) {
|
||||
const page = await bucket.list({ cursor, limit: 2 });
|
||||
keys.push(...page.objects.map((object) => object.key));
|
||||
cursor = page.cursor;
|
||||
}
|
||||
|
||||
expect(keys.sort()).toEqual([
|
||||
"object-0.txt",
|
||||
"object-1.txt",
|
||||
"object-2.txt",
|
||||
"object-3.txt",
|
||||
"object-4.txt",
|
||||
]);
|
||||
});
|
||||
|
||||
it("maps a missing object to a structured not-found error, never a provider exception", async () => {
|
||||
const prefix = disposablePrefix();
|
||||
const bucket = createModuleStorage({
|
||||
bucketPrefix: prefix,
|
||||
logicalBucketIds: ["assets"],
|
||||
moduleId: "announcements",
|
||||
provider: provider(),
|
||||
}).bucket("assets");
|
||||
await bucket.put("present.txt", "x");
|
||||
|
||||
let caught: unknown;
|
||||
try {
|
||||
await bucket.get("absent.txt");
|
||||
} catch (error) {
|
||||
caught = error;
|
||||
}
|
||||
|
||||
expect(caught).toBeInstanceOf(ModuleStorageError);
|
||||
expect((caught as ModuleStorageError).code).toBe("STORAGE_OBJECT_NOT_FOUND");
|
||||
expect((caught as ModuleStorageError).key).toBe("absent.txt");
|
||||
// Nothing from the provider survives translation: no endpoint, no bucket, no request id, no XML.
|
||||
const rendered = `${String(caught)} ${JSON.stringify(caught)}`;
|
||||
expect(rendered).not.toContain("http");
|
||||
expect(rendered).not.toContain(prefix);
|
||||
expect(rendered).not.toContain("<?xml");
|
||||
expect(rendered).not.toContain(process.env[accessKeyEnvironment] ?? " | ||||