feat: add host-provided module object storage
CI / verify (pull_request) Successful in 36s
CI / infrastructure-smoke (pull_request) Successful in 13s
CI / migration-smoke (pull_request) Failing after 2m20s

`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:
ekklesia
2026-08-23 08:45:53 -05:00
co-authored by Claude Opus 5
parent 527239f6de
commit b9ca0d560d
55 changed files with 4420 additions and 74 deletions
+5
View File
@@ -15,6 +15,10 @@
"types": "./dist/modules/index.d.ts",
"import": "./dist/modules/index.js"
},
"./storage": {
"types": "./dist/storage/index.d.ts",
"import": "./dist/storage/index.js"
},
"./dashboard": {
"types": "./dist/dashboard/index.d.ts",
"import": "./dist/dashboard/index.js"
@@ -49,6 +53,7 @@
"typecheck": "tsc --noEmit --project tsconfig.json"
},
"dependencies": {
"@aws-sdk/client-s3": "3.1116.0",
"@ekklesia/contracts": "workspace:*",
"better-auth": "1.6.23",
"drizzle-orm": "0.45.2",
+9 -3
View File
@@ -5,6 +5,8 @@ import {
createModuleLifecycleDatabase,
createModuleLifecycleStore,
discoverModules,
isDependencyBlockedDetail,
isStorageBlockedDetail,
ModuleDiscoveryError,
ModuleLifecycleStoreError,
resolveModuleEligibility,
@@ -132,9 +134,13 @@ async function main(): Promise<void> {
if (!blocked) {
continue;
}
// Names the dependency or the configuration key, never a configuration value.
const cause =
"dependency" in blocked ? blocked.dependency.moduleId : blocked.configurationKey;
// Names the dependency, the configuration key, or the logical storage bucket — never a
// configuration value, an endpoint, or a credential.
const cause = isDependencyBlockedDetail(blocked)
? blocked.dependency.moduleId
: isStorageBlockedDetail(blocked)
? blocked.storageBucketId
: blocked.configurationKey;
console.log(`- "${entry.moduleId}" will not start: ${blocked.reason} (${cause}).`);
}
} finally {
+1 -1
View File
@@ -21,7 +21,7 @@ const sections: readonly DoctorSection[] = Object.freeze([
section("Data definitions", ["schemas.discovery", "migrations.discovery"]),
section("PostgreSQL", ["database.transport", "postgresql.connection", "postgresql.plan"]),
section("Identity", ["identity.configuration", "identity.client-address"]),
section("Object storage", ["object-storage.integration"]),
section("Object storage", ["object-storage.integration", "object-storage.bindings"]),
]);
const statusSymbols: Readonly<Record<DoctorCheck["status"], string>> = Object.freeze({
+149 -10
View File
@@ -3,6 +3,7 @@ import {
capabilityIdSchema,
EMPTY_MODULE_SECRET_SOURCE,
isVersionCompatible,
logicalBucketIdSchema,
moduleConfigurationKeySchema,
moduleIdSchema,
type ModuleSecretSource,
@@ -42,8 +43,16 @@ import {
type PostgresMigrationDatabaseOptions,
planModuleMigrations,
} from "../migrations/index.js";
import {
createS3StorageProvider,
describeStorageProvider,
physicalBucketName,
type StorageProviderConfig,
} from "../storage/index.js";
import {
composePlatformPermissionRegistry,
isConfigurationBlockedDetail,
isStorageBlockedDetail,
createModuleConfigurationDatabase,
createModuleConfigurationStore,
createModuleLifecycleDatabase,
@@ -75,7 +84,8 @@ export type DoctorCheckId =
| "postgresql.plan"
| "identity.configuration"
| "identity.client-address"
| "object-storage.integration";
| "object-storage.integration"
| "object-storage.bindings";
export type DoctorCheckStatus = "failure" | "pass" | "skipped" | "warning";
export type DoctorResult = "not-ready" | "ready" | "ready-with-warnings";
@@ -113,6 +123,9 @@ export interface DoctorOptions {
* matches what `startApi` would resolve. Omitted supplies nothing — Doctor then reports a required
* secret as missing, which is the honest answer for a deployment that has not set it. */
readonly moduleSecrets?: ModuleSecretSource;
/** The validated host storage provider, or `undefined` when this installation has none. Doctor
* never receives raw environment values and never prints a credential. */
readonly storage?: StorageProviderConfig | undefined;
readonly runtimeRange: string;
readonly runtimeVersion: string;
}
@@ -150,6 +163,9 @@ export interface DoctorDependencies {
readonly database: MigrationDatabase;
}) => Promise<MigrationPlanResult>;
readonly runtimeSatisfies: (version: string, range: string) => boolean;
/** Probes the configured storage provider for reachability. Behind the dependency boundary like
* every other I/O Doctor performs, so the whole report stays exercisable without a network. */
readonly probeStorage: (config: StorageProviderConfig) => Promise<void>;
}
const doctorPostgresTimeoutMs = 10_000;
@@ -194,6 +210,16 @@ async function readModuleConfigurationForRuntime(
}
}
/** Opens a provider client, probes, and always closes it — Doctor never leaves a connection behind. */
async function probeStorageForRuntime(config: StorageProviderConfig): Promise<void> {
const provider = createS3StorageProvider(config);
try {
await provider.probe();
} finally {
await provider.close().catch(() => undefined);
}
}
const productionDependencies: DoctorDependencies = Object.freeze({
composePermissions: composePlatformPermissionRegistry,
createMigrationDatabase: createPostgresMigrationDatabase,
@@ -201,6 +227,7 @@ const productionDependencies: DoctorDependencies = Object.freeze({
discoverModules,
discoverSchemas: discoverModuleSchemas,
planMigrations: planModuleMigrations,
probeStorage: probeStorageForRuntime,
readModuleConfiguration: readModuleConfigurationForRuntime,
readModuleLifecycle: readModuleLifecycleForRuntime,
runtimeSatisfies: isVersionCompatible,
@@ -333,7 +360,17 @@ function safeLifecycleDetail(entry: ModuleEligibility): string {
if (!entry.blocked) {
return `${identity} · enabled`;
}
if (!("dependency" in entry.blocked)) {
if (isStorageBlockedDetail(entry.blocked)) {
// A storage block names the **logical** bucket the module declared — never an endpoint, a
// credential, or the physical bucket the host maps it to. Re-validated against the same grammar
// the manifest accepted it under, for the same defense-in-depth reason as every other
// interpolation here.
if (!logicalBucketIdSchema.safeParse(entry.blocked.storageBucketId).success) {
throw new TypeError("Unsafe module lifecycle diagnostic value.");
}
return `${identity} · blocked · ${entry.blocked.reason} · ${entry.blocked.storageBucketId}`;
}
if (isConfigurationBlockedDetail(entry.blocked)) {
// A configuration block names the offending **key** and never its value: a key is declared in a
// public manifest, while a value may be a credential or arbitrary operator-entered text. The key
// is re-validated against the same grammar the manifest accepted it under, for the same
@@ -407,7 +444,7 @@ function safeConfigurationDetail(entry: ModuleEligibility, declaredCount: number
return `${identity} · no configuration declared`;
}
const declared = `${declaredCount} ${plural(declaredCount, "key")} declared`;
if (entry.blocked && !("dependency" in entry.blocked)) {
if (entry.blocked && isConfigurationBlockedDetail(entry.blocked)) {
if (!moduleConfigurationKeySchema.safeParse(entry.blocked.configurationKey).success) {
throw new TypeError("Unsafe module configuration diagnostic value.");
}
@@ -458,6 +495,65 @@ function moduleConfigurationCheck(
return freezeCheck("modules.configuration", "pass", "Module configuration valid", details);
}
/**
* Reports which installed modules require object storage, and where the host keeps each binding.
*
* Names the module, the **logical** bucket it declared, and the physical bucket the host maps it to
* — the last of which is safe and useful here precisely because Doctor is an operator tool: someone
* debugging a permissions problem needs to know which bucket to look at. It is still never reachable
* from module-facing code (ADR 0030). No endpoint, access key, or secret key appears in any form.
*/
function storageBindingsCheck(
modules: readonly DiscoveredModule[],
entries: readonly ModuleEligibility[],
config: StorageProviderConfig | undefined,
): DoctorCheck {
const requiring = modules.filter((module) => module.manifest.storage.buckets.length > 0);
if (requiring.length === 0) {
// An installation whose modules need no object storage is not unhealthy for lacking a provider.
return freezeCheck("object-storage.bindings", "pass", "No module requires object storage", [
"0 modules declare a logical storage bucket.",
]);
}
const blocked = entries.filter(
(entry) => entry.blocked !== undefined && isStorageBlockedDetail(entry.blocked),
);
const details = requiring.flatMap((module) => {
const moduleId = module.manifest.id;
if (!moduleIdSchema.safeParse(moduleId).success) {
throw new TypeError("Unsafe module storage diagnostic value.");
}
return module.manifest.storage.buckets.map((bucket) => {
if (!logicalBucketIdSchema.safeParse(bucket.id).success) {
throw new TypeError("Unsafe module storage diagnostic value.");
}
return config === undefined
? `${moduleId} · ${bucket.id} · unbound (no storage provider configured)`
: `${moduleId} · ${bucket.id} → ${physicalBucketName(config.bucketPrefix, moduleId, bucket.id)}`;
});
});
if (config === undefined) {
return freezeCheck(
"object-storage.bindings",
"failure",
blocked.length === 1
? "1 module requires object storage that is not configured"
: `${blocked.length} modules require object storage that is not configured`,
[
"Configure an S3-compatible Ekklesia storage provider (S3_ENDPOINT, S3_ACCESS_KEY, S3_SECRET_KEY).",
...details,
],
);
}
return freezeCheck(
"object-storage.bindings",
"pass",
`${details.length} ${plural(details.length, "storage binding")} resolved`,
details,
);
}
function moduleDiscoveryFailure(error: unknown): DoctorCheck {
const details =
error instanceof Error && error.name === "ModuleDiscoveryError"
@@ -788,6 +884,9 @@ export async function diagnoseEkklesia(
// failed. It needs only a successful discovery and a configured database.
let lifecycleCheck: DoctorCheck;
let configurationCheck: DoctorCheck;
// Hoisted so the storage-bindings check below reports the same eligibility this pass resolved,
// rather than resolving a second time and risking a different answer.
let eligibilityEntries: readonly ModuleEligibility[] = [];
const skippedConfiguration = (detail: string): DoctorCheck =>
freezeCheck("modules.configuration", "skipped", "Module configuration skipped", [detail]);
if (!discoveredModules) {
@@ -819,10 +918,13 @@ export async function diagnoseEkklesia(
poolOptions,
);
const settings = await dependencies.readModuleConfiguration(options.databaseUrl, poolOptions);
const resolution = resolveModuleEligibility(discoveredModules, configuration, {
secrets: options.moduleSecrets ?? EMPTY_MODULE_SECRET_SOURCE,
settings,
});
const resolution = resolveModuleEligibility(
discoveredModules,
configuration,
{ secrets: options.moduleSecrets ?? EMPTY_MODULE_SECRET_SOURCE, settings },
{ providerConfigured: options.storage !== undefined },
);
eligibilityEntries = resolution.entries;
lifecycleCheck = moduleLifecycleCheck(resolution.entries);
configurationCheck = moduleConfigurationCheck(
resolution.entries,
@@ -930,9 +1032,45 @@ export async function diagnoseEkklesia(
options.databaseEnvironment,
);
const storageCheck = freezeCheck("object-storage.integration", "skipped", "Check unavailable", [
"Core storage integration has not been implemented yet.",
]);
// Two separate questions, deliberately. "Is a provider configured and reachable?" is
// infrastructure diagnostics; "can every module's declared bucket be bound?" is what decides
// whether modules run. Conflating them would let a momentary network blip read as a module
// problem (ADR 0030).
let storageCheck: DoctorCheck;
if (!options.storage) {
storageCheck = freezeCheck(
"object-storage.integration",
"skipped",
"Object storage not configured",
["No S3_ENDPOINT is set. This is a complete deployment unless a module requires storage."],
);
} else {
try {
await dependencies.probeStorage(options.storage);
storageCheck = freezeCheck("object-storage.integration", "pass", "Object storage reachable", [
describeStorageProvider(options.storage),
]);
} catch {
// A warning, never a failure: the provider is configured, and a module that is already
// running keeps running through a transient outage. Reporting this as `not-ready` would make
// the platform look broken because a network hiccup happened while Doctor ran.
storageCheck = freezeCheck(
"object-storage.integration",
"warning",
"Object storage unreachable",
[
describeStorageProvider(options.storage),
"The configured storage service did not respond. Modules already running are unaffected.",
],
);
}
}
const storageBindings = discoveredModules
? storageBindingsCheck(discoveredModules, eligibilityEntries, options.storage)
: freezeCheck("object-storage.bindings", "skipped", "Storage bindings skipped", [
"Complete module discovery is required.",
]);
return publishReport([
runtimeCheck,
@@ -949,5 +1087,6 @@ export async function diagnoseEkklesia(
identityCheck,
clientAddressDoctorCheck,
storageCheck,
storageBindings,
]);
}
+99 -10
View File
@@ -62,7 +62,59 @@ export interface ModuleConfigurationBlockedDetail {
readonly configurationKey: string;
}
export type ModuleBlockedDetail = ModuleDependencyBlockedDetail | ModuleConfigurationBlockedDetail;
/** A block caused by unsatisfiable object-storage requirements (ADR 0030), naming the **logical**
* bucket the module declared — never an endpoint, a credential, or a physical bucket name. */
export interface ModuleStorageBlockedDetail {
readonly reason: "storage_unavailable";
readonly storageBucketId: string;
}
export type ModuleBlockedDetail =
| ModuleDependencyBlockedDetail
| ModuleConfigurationBlockedDetail
| ModuleStorageBlockedDetail;
/** Narrows a block to the dependency variant. Exported so every consumer discriminates the same
* way — a bare `"dependency" in detail` repeated at each call site is how one of them eventually
* gets it wrong as variants are added. */
export function isDependencyBlockedDetail(
detail: ModuleBlockedDetail,
): detail is ModuleDependencyBlockedDetail {
return (
detail.reason === "required_dependency_disabled" ||
detail.reason === "required_dependency_blocked"
);
}
export function isStorageBlockedDetail(
detail: ModuleBlockedDetail,
): detail is ModuleStorageBlockedDetail {
return detail.reason === "storage_unavailable";
}
export function isConfigurationBlockedDetail(
detail: ModuleBlockedDetail,
): detail is ModuleConfigurationBlockedDetail {
return (
detail.reason === "configuration_missing" ||
detail.reason === "configuration_invalid" ||
detail.reason === "secret_missing"
);
}
/**
* Whether this host can bind the logical object-storage buckets modules declare (ADR 0030).
*
* A boolean, deliberately, and resolved without touching the network: eligibility must stay a pure,
* synchronous function, and it must never depend on whether the storage service happened to answer a
* probe during boot. A configured provider that is temporarily unreachable is an operational
* condition a module retries through, not an administrative state change — which is the distinction
* the real Announcements product already relies on, since its slideshow survives its object store
* going away.
*/
export interface ModuleStorageAvailability {
readonly providerConfigured: boolean;
}
export interface ModuleEligibilityResolution {
/**
@@ -108,10 +160,15 @@ function frozenEligibility(entry: MutableEligibility): ModuleEligibility {
dependency: Object.freeze({ ...entry.blocked.dependency }),
reason: entry.blocked.reason,
})
: Object.freeze({
configurationKey: entry.blocked.configurationKey,
reason: entry.blocked.reason,
}),
: "storageBucketId" in entry.blocked
? Object.freeze({
reason: entry.blocked.reason,
storageBucketId: entry.blocked.storageBucketId,
})
: Object.freeze({
configurationKey: entry.blocked.configurationKey,
reason: entry.blocked.reason,
}),
}
: {}),
});
@@ -162,6 +219,7 @@ export function resolveModuleEligibility(
discoveredModules: readonly DiscoveredModule[],
configuration: ModuleLifecycleConfiguration,
moduleConfiguration: ModuleConfigurationInput = { settings: { records: [] } },
storage: ModuleStorageAvailability = { providerConfigured: false },
): ModuleEligibilityResolution {
const configuredByModuleId = new Map(
configuration.modules.map((record) => [record.moduleId, record.enabled] as const),
@@ -180,6 +238,26 @@ export function resolveModuleEligibility(
});
}
// A module that declares logical object-storage buckets needs a host provider to bind them to. The
// check is deliberately about *binding availability*, not provider health: it asks whether this
// deployment has storage configured at all, never whether the service answered just now
// (ADR 0030). Runs before the dependency pass so a storage-blocked module propagates to its
// required dependents through exactly the existing mechanism rather than a second one.
for (const discovered of discoveredModules) {
const entry = entries.get(discovered.manifest.id);
if (!entry?.eligible) {
continue;
}
const declaredBucket = discovered.manifest.storage.buckets[0];
if (declaredBucket !== undefined && !storage.providerConfigured) {
entry.eligible = false;
entry.blocked = {
reason: "storage_unavailable",
storageBucketId: declaredBucket.id,
};
}
}
// Validate each enabled module's own configuration before the dependency pass, so a module
// blocked by its own configuration propagates to its required dependents through exactly the
// existing mechanism rather than a second one. Runs only for modules that are still eligible: a
@@ -306,6 +384,9 @@ export interface ModuleLifecycleView {
readonly dependency?: ModuleBlockingDependency;
/** Present for a configuration reason (ADR 0029) — the offending key, never its value. */
readonly configurationKey?: string;
/** Present for a storage reason (ADR 0030) — the logical bucket the module declared, never an
* endpoint, a credential, or a physical bucket name. */
readonly storageBucketId?: string;
};
/**
* `true` when applying the *current* configuration would produce a different running set than
@@ -347,6 +428,7 @@ export function resolveModuleLifecycleView(
snapshot: ModuleLifecycleSnapshot,
configuration: ModuleLifecycleConfiguration,
moduleConfiguration: ModuleConfigurationInput = { settings: { records: [] } },
storage: ModuleStorageAvailability = { providerConfigured: false },
): readonly ModuleLifecycleView[] {
const started = new Set(snapshot.startedModuleIds);
const bootEligibility = new Map(
@@ -361,6 +443,7 @@ export function resolveModuleLifecycleView(
snapshot.discoveredModules,
configuration,
moduleConfiguration,
storage,
);
const wouldRun = new Set(pending.eligibleModules.map((module) => module.manifest.id));
@@ -379,11 +462,17 @@ export function resolveModuleLifecycleView(
reason: boot.blocked.reason,
state: "blocked" as const,
}
: {
configurationKey: boot.blocked.configurationKey,
reason: boot.blocked.reason,
state: "blocked" as const,
}
: "storageBucketId" in boot.blocked
? {
reason: boot.blocked.reason,
state: "blocked" as const,
storageBucketId: boot.blocked.storageBucketId,
}
: {
configurationKey: boot.blocked.configurationKey,
reason: boot.blocked.reason,
state: "blocked" as const,
}
: // Enabled and unblocked at boot, yet not started: unreachable while startup stays
// all-or-nothing, and reported as blocked-by-nothing rather than as `running`, so a
// future change to startup atomicity can never make this silently claim success.
+39 -2
View File
@@ -2,7 +2,9 @@ import { pathToFileURL } from "node:url";
import {
type CapabilityId,
EMPTY_MODULE_CONFIGURATION,
EMPTY_MODULE_STORAGE,
type ModuleConfiguration,
type ModuleStorage,
type CapabilityRegistrar,
type CapabilityResolver,
capabilityIdSchema,
@@ -96,6 +98,20 @@ function configurationFor(
return configurations.get(moduleId) ?? EMPTY_MODULE_CONFIGURATION;
}
/**
* Per-module scoped object storage, keyed by module id (ADR 0030).
*
* A lookup for the same reason configuration is one, and emphatically **not** a field on
* `ModuleRuntimeServices`: `services` is one shared object identity every module receives, while a
* storage facade is scoped to exactly the buckets one module declared. Sharing it would hand every
* module every other module's storage.
*/
export type ModuleStorageLookup = ReadonlyMap<string, ModuleStorage>;
function storageFor(storages: ModuleStorageLookup, moduleId: string): ModuleStorage {
return storages.get(moduleId) ?? EMPTY_MODULE_STORAGE;
}
export interface ModuleRuntimeTimeoutOptions {
/** Test-only narrowing of `MODULE_STOP_HOOK_TIMEOUT_MS`, applied to both normal stop and startup
* rollback, so a deadline can be proven in milliseconds instead of ten seconds. Production
@@ -115,6 +131,12 @@ export interface ModuleRuntimeCompositionOptions extends ModuleRuntimeTimeoutOpt
* `EMPTY_MODULE_CONFIGURATION`.
*/
readonly configurations?: ModuleConfigurationLookup;
/**
* The per-module scoped storage facades this runtime hands to each module's `start`, `stop`, and
* HTTP handler contexts (ADR 0030). Omitted by a caller exercising no storage; every module then
* receives `EMPTY_MODULE_STORAGE`, which refuses every bucket.
*/
readonly storages?: ModuleStorageLookup;
}
export interface StartModuleRuntimeOptions
@@ -257,6 +279,7 @@ class StagedCapabilityRegistry {
phase: "rollback" | "start" | "stop",
services: ModuleRuntimeServices,
config: ModuleConfiguration,
storage: ModuleStorage,
): ModuleRuntimeContext {
const capabilities: CapabilityResolver = Object.freeze({
resolve: (capabilityId: CapabilityId): RegisteredCapability | undefined => {
@@ -264,7 +287,13 @@ class StagedCapabilityRegistry {
return this.#registered.get(capabilityId);
},
});
return Object.freeze({ capabilities, config, moduleId: module.manifest.id, services });
return Object.freeze({
capabilities,
config,
moduleId: module.manifest.id,
services,
storage,
});
}
publish(): CapabilityRegistry {
@@ -599,6 +628,7 @@ async function stopModules(
phase: "rollback" | "stop",
services: ModuleRuntimeServices,
configurations: ModuleConfigurationLookup,
storages: ModuleStorageLookup,
options: StopModulesOptions = {},
): Promise<readonly ModuleRuntimeIssue[]> {
const timeoutMs = options.hookTimeoutMs ?? MODULE_STOP_HOOK_TIMEOUT_MS;
@@ -619,6 +649,7 @@ async function stopModules(
phase,
services,
configurationFor(configurations, moduleId),
storageFor(storages, moduleId),
),
),
),
@@ -657,6 +688,7 @@ async function startModules(
registry: StagedCapabilityRegistry,
services: ModuleRuntimeServices,
configurations: ModuleConfigurationLookup,
storages: ModuleStorageLookup,
options: StopModulesOptions = {},
): Promise<void> {
// A module joins the rollback set the moment its `start` hook is *invoked*, not when it
@@ -675,6 +707,7 @@ async function startModules(
"start",
services,
configurationFor(configurations, module.discovered.manifest.id),
storageFor(storages, module.discovered.manifest.id),
),
);
} catch (error) {
@@ -693,6 +726,7 @@ async function startModules(
"rollback",
services,
configurations,
storages,
options,
);
throw new ModuleRuntimeError([startIssue, ...rollbackIssues]);
@@ -717,8 +751,9 @@ export async function startDiscoveredModuleRuntime(
const capabilityRegistry = new StagedCapabilityRegistry();
const httpRoutes = new StagedModuleHttpRoutes();
const configurations = timeouts.configurations ?? new Map<string, ModuleConfiguration>();
const storages = timeouts.storages ?? new Map<string, ModuleStorage>();
await registerModules(modules, capabilityRegistry, httpRoutes, configurations);
await startModules(modules, capabilityRegistry, services, configurations, timeouts);
await startModules(modules, capabilityRegistry, services, configurations, storages, timeouts);
const capabilities = capabilityRegistry.publish();
const routes = httpRoutes.publish();
@@ -744,6 +779,7 @@ export async function startDiscoveredModuleRuntime(
"stop",
services,
configurations,
storages,
timeouts,
);
if (issues.length > 0) {
@@ -761,6 +797,7 @@ export async function startModuleRuntime(
const discovered = await discoverModules(options);
return startDiscoveredModuleRuntime(discovered, options.services, {
...(options.configurations === undefined ? {} : { configurations: options.configurations }),
...(options.storages === undefined ? {} : { storages: options.storages }),
...(options.hookTimeoutMs === undefined ? {} : { hookTimeoutMs: options.hookTimeoutMs }),
});
}
+116
View File
@@ -0,0 +1,116 @@
import { createHash } from "node:crypto";
/**
* How a module's logical bucket becomes a physical bucket on the host's storage service.
*
* This is **host implementation detail**. Nothing module-facing exposes the result: a module names
* its logical bucket and the binding is resolved for it, so a module can neither observe nor choose
* a physical bucket (ADR 0030).
*/
/**
* The prefix every physical bucket this installation owns begins with.
*
* It exists so several Ekklesia-compatible deployments — or an Ekklesia installation and an
* unrelated application — can share one S3-compatible service without colliding, which is exactly
* the deployment shape the standalone story needs. Bounded and grammar-checked because it becomes
* the leading characters of an S3 bucket name.
*/
export const DEFAULT_STORAGE_BUCKET_PREFIX = "ekklesia";
export const STORAGE_BUCKET_PREFIX_MAX_LENGTH = 16;
const BUCKET_PREFIX_PATTERN = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/u;
export function isValidStorageBucketPrefix(value: unknown): value is string {
return (
typeof value === "string" &&
value.length > 0 &&
value.length <= STORAGE_BUCKET_PREFIX_MAX_LENGTH &&
BUCKET_PREFIX_PATTERN.test(value)
);
}
/**
* S3's own bucket-name ceiling, which every S3-compatible provider shares. Verified against the
* pinned SeaweedFS 4.28 development image: a 63-character name is accepted.
*/
const PHYSICAL_BUCKET_MAX_LENGTH = 63;
/** Long enough that a collision is not a practical concern, short enough to leave the readable part
* of the name legible. */
const DIGEST_LENGTH = 12;
/**
* The unambiguous logical identity of one binding: a module and one of its declared buckets.
*
* `--` is the separator because it **cannot occur inside either identifier**. Both `moduleIdSchema`
* and `logicalBucketIdSchema` require at least one `[a-z0-9]` after every hyphen, so a doubled
* hyphen is unrepresentable in each half. A single hyphen would have been ambiguous in exactly the
* way that matters: module `a-b` + bucket `c` and module `a` + bucket `b-c` would both render as
* `a-b-c`, silently handing two unrelated modules the same physical namespace.
*/
function bindingIdentity(moduleId: string, logicalBucketId: string): string {
return `${moduleId}--${logicalBucketId}`;
}
/**
* Maps a module's logical bucket to the physical bucket name the host uses for it.
*
* ```text
* announcements + announcement-assets
* → ekklesia-announcements--announcement-assets-3f0c1d9a6b21
* ```
*
* Deterministic, so the same declaration always resolves to the same bucket across restarts,
* deployments, and hosts — which is what makes the binding stable enough to hold data.
*
* Collision-safe on two independent grounds. The `--` separator makes the logical identity
* unambiguous, and the appended digest is taken over that **full untruncated identity**, so even two
* modules whose names truncate to the same readable prefix still receive different buckets. Two
* different modules both declaring `assets` therefore always land in different physical namespaces —
* which is the isolation property this whole scheme exists to provide.
*
* The readable middle is truncated only as far as S3's 63-character ceiling requires, and never at a
* trailing hyphen, so the result always satisfies S3's naming rules: it begins with a letter, ends
* with an alphanumeric, contains only lowercase letters, digits, and single hyphens, and can never
* resemble an IP address.
*/
export function physicalBucketName(
prefix: string,
moduleId: string,
logicalBucketId: string,
): string {
const identity = bindingIdentity(moduleId, logicalBucketId);
const digest = createHash("sha256")
.update(identity, "utf8")
.digest("hex")
.slice(0, DIGEST_LENGTH);
// prefix + "-" + readable + "-" + digest
const budget = PHYSICAL_BUCKET_MAX_LENGTH - prefix.length - 1 - 1 - DIGEST_LENGTH;
const readable = identity.slice(0, Math.max(1, budget)).replace(/-+$/u, "");
return `${prefix}-${readable}-${digest}`;
}
/** One resolved binding: what the module calls it, and where the host actually keeps it. */
export interface ModuleStorageBinding {
readonly moduleId: string;
readonly logicalBucketId: string;
/** Host detail. Never reachable from `ModuleStorage`; present here because Core's provider needs
* it, and because diagnostics may name it for an operator. */
readonly physicalBucket: string;
}
/** Resolves every binding one module's manifest declares, in declaration order. */
export function resolveModuleStorageBindings(
prefix: string,
moduleId: string,
logicalBucketIds: readonly string[],
): readonly ModuleStorageBinding[] {
return Object.freeze(
logicalBucketIds.map((logicalBucketId) =>
Object.freeze({
logicalBucketId,
moduleId,
physicalBucket: physicalBucketName(prefix, moduleId, logicalBucketId),
}),
),
);
}
+185
View File
@@ -0,0 +1,185 @@
import {
DEFAULT_STORAGE_BUCKET_PREFIX,
isValidStorageBucketPrefix,
STORAGE_BUCKET_PREFIX_MAX_LENGTH,
} from "./binding.js";
export type StorageConfigErrorCode =
| "STORAGE_ACCESS_KEY_MISSING"
| "STORAGE_BUCKET_PREFIX_INVALID"
| "STORAGE_ENDPOINT_INSECURE_IN_PRODUCTION"
| "STORAGE_ENDPOINT_INVALID"
| "STORAGE_ENDPOINT_WITH_CREDENTIALS"
| "STORAGE_REGION_INVALID"
| "STORAGE_SECRET_KEY_MISSING";
const storageConfigMessages: Readonly<Record<StorageConfigErrorCode, string>> = Object.freeze({
STORAGE_ACCESS_KEY_MISSING: "A storage access key is required when a storage endpoint is set.",
STORAGE_BUCKET_PREFIX_INVALID: "The storage bucket prefix must be lowercase kebab-case.",
STORAGE_ENDPOINT_INSECURE_IN_PRODUCTION:
"A production storage endpoint must use https, exactly as the database transport must use TLS.",
STORAGE_ENDPOINT_INVALID: "The storage endpoint must be an absolute http or https URL.",
STORAGE_ENDPOINT_WITH_CREDENTIALS:
"The storage endpoint must not embed credentials; supply them separately.",
STORAGE_REGION_INVALID: "The storage region must be a short alphanumeric identifier.",
STORAGE_SECRET_KEY_MISSING: "A storage secret key is required when a storage endpoint is set.",
});
/**
* Never carries the endpoint, region, access key, secret key, or any other configured value — only a
* fixed code and message. Matching `IdentityConfigError` and `DatabaseTransportError` exactly, and
* for a sharper reason here: two of the values this validates are credentials.
*/
export class StorageConfigError extends Error {
readonly code: StorageConfigErrorCode;
constructor(code: StorageConfigErrorCode) {
super(storageConfigMessages[code]);
this.name = "StorageConfigError";
this.code = code;
this.stack = `${this.name}: ${this.message}`;
Object.freeze(this);
}
}
export type StorageEnvironment = "development" | "production";
export interface StorageProviderConfigInput {
readonly endpoint?: string | undefined;
readonly region?: string | undefined;
readonly accessKey?: string | undefined;
readonly secretKey?: string | undefined;
readonly forcePathStyle?: string | boolean | undefined;
readonly bucketPrefix?: string | undefined;
}
/**
* The validated host storage configuration.
*
* **Modules never see this.** It reaches Core's provider and nothing else; the module-facing
* `ModuleStorage` contract has no accessor that could return an endpoint, a region, or a credential
* (ADR 0030).
*/
export interface StorageProviderConfig {
readonly endpoint: string;
readonly region: string;
readonly accessKey: string;
readonly secretKey: string;
/** True for every S3-compatible service that is not AWS itself — SeaweedFS and MinIO both require
* it — so it defaults on. Virtual-host addressing needs wildcard DNS the typical self-hosted
* deployment does not have. */
readonly forcePathStyle: boolean;
readonly bucketPrefix: string;
}
const DEFAULT_REGION = "us-east-1";
const REGION_PATTERN = /^[a-z0-9][a-z0-9-]{0,31}$/u;
function parseBoolean(value: string | boolean | undefined, fallback: boolean): boolean {
if (value === undefined) {
return fallback;
}
if (typeof value === "boolean") {
return value;
}
return value !== "false";
}
/**
* Validates the host's storage configuration, or reports that storage is not configured at all.
*
* **Storage is optional.** An installation with no `endpoint` returns `undefined`, and that is a
* complete, valid deployment: modules that declare no buckets run exactly as before. Only a module
* that actually requires a bucket is affected, and it is reported blocked rather than crashing
* anything (ADR 0030).
*
* Once an endpoint *is* set, credentials become required — a half-configured provider that silently
* attempted anonymous access would be a worse failure than a loud one.
*
* Production requires `https`, on the same reasoning the database transport requires verified TLS:
* object bytes for a church may include member photographs and pastoral material, and shipping them
* over plaintext because it was convenient is not a tradeoff this platform makes. Development may
* use `http`, which is what the repository's own SeaweedFS Compose service speaks.
*
* An endpoint carrying userinfo is rejected outright rather than stripped: credentials belong in
* their own fields, where they are never logged, and a URL that embeds them tends to end up in
* places a URL is printed.
*/
export function createStorageProviderConfig(
input: StorageProviderConfigInput | undefined,
options: { readonly environment?: StorageEnvironment } = {},
): StorageProviderConfig | undefined {
const endpoint = input?.endpoint?.trim();
if (endpoint === undefined || endpoint === "") {
return undefined;
}
let parsed: URL;
try {
parsed = new URL(endpoint);
} catch {
throw new StorageConfigError("STORAGE_ENDPOINT_INVALID");
}
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
throw new StorageConfigError("STORAGE_ENDPOINT_INVALID");
}
if (parsed.username !== "" || parsed.password !== "") {
throw new StorageConfigError("STORAGE_ENDPOINT_WITH_CREDENTIALS");
}
const environment = options.environment ?? "production";
if (environment === "production" && parsed.protocol !== "https:") {
throw new StorageConfigError("STORAGE_ENDPOINT_INSECURE_IN_PRODUCTION");
}
const region = input?.region?.trim() === "" ? undefined : input?.region?.trim();
if (region !== undefined && !REGION_PATTERN.test(region)) {
throw new StorageConfigError("STORAGE_REGION_INVALID");
}
const accessKey = input?.accessKey;
if (typeof accessKey !== "string" || accessKey.trim() === "") {
throw new StorageConfigError("STORAGE_ACCESS_KEY_MISSING");
}
const secretKey = input?.secretKey;
if (typeof secretKey !== "string" || secretKey.trim() === "") {
throw new StorageConfigError("STORAGE_SECRET_KEY_MISSING");
}
const bucketPrefix = input?.bucketPrefix?.trim();
if (
bucketPrefix !== undefined &&
bucketPrefix !== "" &&
!isValidStorageBucketPrefix(bucketPrefix)
) {
throw new StorageConfigError("STORAGE_BUCKET_PREFIX_INVALID");
}
return Object.freeze({
accessKey,
bucketPrefix:
bucketPrefix === undefined || bucketPrefix === ""
? DEFAULT_STORAGE_BUCKET_PREFIX
: bucketPrefix,
endpoint: parsed.origin + (parsed.pathname === "/" ? "" : parsed.pathname.replace(/\/+$/u, "")),
forcePathStyle: parseBoolean(input?.forcePathStyle, true),
region: region ?? DEFAULT_REGION,
secretKey,
});
}
/**
* A one-line operator-facing description of the configured provider that is safe to print anywhere —
* Doctor output, a log line, a support transcript.
*
* It names the endpoint's **origin only** (never a path, query, or userinfo), the region, the
* addressing style, and the bucket prefix. It never includes the access key, the secret key, or any
* value derived from them. `describeDatabaseTransport` sets the same precedent.
*/
export function describeStorageProvider(config: StorageProviderConfig): string {
const origin = new URL(config.endpoint).origin;
return `S3-compatible endpoint ${origin} · region ${config.region} · ${
config.forcePathStyle ? "path-style" : "virtual-host-style"
} · bucket prefix "${config.bucketPrefix}"`;
}
export { STORAGE_BUCKET_PREFIX_MAX_LENGTH };
+5
View File
@@ -0,0 +1,5 @@
export * from "./binding.js";
export * from "./config.js";
export * from "./module-storage.js";
export * from "./provider.js";
export * from "./s3-provider.js";
+185
View File
@@ -0,0 +1,185 @@
import {
EMPTY_MODULE_STORAGE,
isValidModuleStorageKey,
type ModuleStorage,
type ModuleStorageBody,
type ModuleStorageBucket,
ModuleStorageError,
type ModuleStorageListOptions,
type ModuleStorageListing,
type ModuleStorageObject,
type ModuleStorageObjectMetadata,
type ModuleStoragePutOptions,
} from "@ekklesia/contracts";
import { type ModuleStorageBinding, resolveModuleStorageBindings } from "./binding.js";
import type { StorageProvider } from "./provider.js";
/**
* Builds the scoped storage facade one module receives.
*
* **This is where isolation is enforced, and it is enforced by construction.** The facade closes over
* exactly the bindings this module's own manifest declared; there is no lookup table spanning
* modules, no name that resolves outside those bindings, and no accessor taking a physical bucket.
* Module A cannot reach module B's storage by knowing its bucket name, its module id, or its
* physical bucket name, because none of those is an input to anything here (ADR 0030).
*
* Two modules that both declare `assets` receive two facades over two different physical buckets —
* see `physicalBucketName` for why that separation is collision-safe rather than merely likely.
*/
/** Ensures a binding's physical bucket exists, at most once per process per binding.
*
* Lazy rather than eager at startup, deliberately: a storage outage must never stop the platform
* from booting. The promise is memoized so concurrent first uses share one round trip, and a
* *failed* attempt is discarded so a transient outage does not poison the binding for the life of
* the process — the next call retries, which is exactly the resilience a module like a slideshow
* depends on (ADR 0030). */
function createBucketEnsurer(
provider: StorageProvider,
physicalBucket: string,
): () => Promise<void> {
let pending: Promise<void> | undefined;
return () => {
pending ??= provider.ensureBucket(physicalBucket).catch((error: unknown) => {
pending = undefined;
throw error;
});
return pending;
};
}
/** Echoes the offending key only when it is a short string — a module's own input is safe to hand
* back, but a non-string or an enormous value is not something to put into an error message. */
function assertValidKey(key: unknown): asserts key is string {
if (isValidModuleStorageKey(key)) {
return;
}
const echo = typeof key === "string" && key.length <= 256 ? key : undefined;
throw new ModuleStorageError("STORAGE_KEY_INVALID", echo);
}
function createBucketHandle(
provider: StorageProvider,
binding: ModuleStorageBinding,
): ModuleStorageBucket {
const ensure = createBucketEnsurer(provider, binding.physicalBucket);
return Object.freeze({
// The **logical** id, never the physical bucket. A module that logs this leaks nothing about
// where the host keeps its data.
id: binding.logicalBucketId,
async delete(key: string): Promise<void> {
assertValidKey(key);
await ensure();
await provider.delete(binding.physicalBucket, key);
},
async get(key: string): Promise<ModuleStorageObject> {
assertValidKey(key);
await ensure();
return provider.get(binding.physicalBucket, key);
},
async head(key: string): Promise<ModuleStorageObjectMetadata> {
assertValidKey(key);
await ensure();
return provider.head(binding.physicalBucket, key);
},
async list(options: ModuleStorageListOptions = {}): Promise<ModuleStorageListing> {
await ensure();
return provider.list(binding.physicalBucket, options);
},
async put(
key: string,
body: ModuleStorageBody,
options: ModuleStoragePutOptions = {},
): Promise<ModuleStorageObjectMetadata> {
assertValidKey(key);
if (body instanceof ReadableStream && options.contentLength === undefined) {
throw new ModuleStorageError("STORAGE_CONTENT_LENGTH_REQUIRED", key);
}
await ensure();
return provider.put(binding.physicalBucket, key, body, options);
},
});
}
export interface CreateModuleStorageOptions {
readonly provider: StorageProvider;
readonly bucketPrefix: string;
readonly moduleId: string;
/** The logical bucket ids from this module's own manifest, in declaration order. */
readonly logicalBucketIds: readonly string[];
}
/**
* The scoped `ModuleStorage` for one module.
*
* A module declaring no buckets gets `EMPTY_MODULE_STORAGE` — present, frozen, and refusing every
* bucket — so `context.storage` is never `undefined` and module code never branches on whether the
* host supplied storage.
*/
export function createModuleStorage(options: CreateModuleStorageOptions): ModuleStorage {
if (options.logicalBucketIds.length === 0) {
return EMPTY_MODULE_STORAGE;
}
const bindings = resolveModuleStorageBindings(
options.bucketPrefix,
options.moduleId,
options.logicalBucketIds,
);
// A `Map` rather than a plain object: every lookup key here comes from module code, and indexing a
// plain object with such a key is the shape of a prototype-pollution bug. With a `Map` there is no
// prototype to reach, so `constructor` or `__proto__` are ordinary absent keys.
const handles = new Map<string, ModuleStorageBucket>(
bindings.map((binding) => [
binding.logicalBucketId,
createBucketHandle(options.provider, binding),
]),
);
const declared = Object.freeze([...handles.keys()].sort());
return Object.freeze({
bucket: (logicalBucketId: string): ModuleStorageBucket => {
const handle = typeof logicalBucketId === "string" ? handles.get(logicalBucketId) : undefined;
if (!handle) {
// Including another module's bucket id, which is simply not a key in this map.
throw new ModuleStorageError("STORAGE_BUCKET_UNDECLARED", logicalBucketId);
}
return handle;
},
buckets: (): readonly string[] => declared,
});
}
/**
* Every module's storage, resolved once at startup.
*
* Returns an empty map when the host has no storage provider. That is not an error: an installation
* with no object storage is a complete, valid deployment, and only modules that *declared* a bucket
* are affected — those are reported blocked by the lifecycle resolver before any of this is reached
* (ADR 0030).
*/
export function createModuleStorageLookup(
provider: StorageProvider | undefined,
bucketPrefix: string,
modules: readonly { readonly moduleId: string; readonly logicalBucketIds: readonly string[] }[],
): ReadonlyMap<string, ModuleStorage> {
if (!provider) {
return new Map();
}
return new Map(
modules.map((module) => [
module.moduleId,
createModuleStorage({
bucketPrefix,
logicalBucketIds: module.logicalBucketIds,
moduleId: module.moduleId,
provider,
}),
]),
);
}
+51
View File
@@ -0,0 +1,51 @@
import type {
ModuleStorageBody,
ModuleStorageListOptions,
ModuleStorageListing,
ModuleStorageObject,
ModuleStorageObjectMetadata,
ModuleStoragePutOptions,
} from "@ekklesia/contracts";
/**
* The seam between Core's module-facing storage facade and whatever actually stores bytes.
*
* This is the **portability boundary made executable**. Everything above it — the scoped facade,
* binding resolution, lifecycle eligibility, diagnostics — is provider-agnostic and would be
* identical in a standalone host. Everything below it is one implementation
* (`createS3StorageProvider`) that could be replaced without any module changing a line.
*
* It deliberately speaks in **physical buckets**: resolving a module's logical bucket to a physical
* one has already happened by the time anything reaches here (`./binding.js`), so a provider never
* has to know what a module is. That split is what keeps scoping enforcement in exactly one place
* rather than duplicated into every provider.
*
* Also deliberately absent: any operation that *destroys* a bucket. Core never deletes a bucket, so
* there is no method to call by mistake, and disabling or removing a module cannot take data with it
* (ADR 0030).
*/
export interface StorageProvider {
/**
* Makes sure a physical bucket exists, idempotently.
*
* Called lazily on first use of a binding rather than at startup, so a storage outage can never
* prevent the platform from booting.
*/
ensureBucket(physicalBucket: string): Promise<void>;
list(physicalBucket: string, options: ModuleStorageListOptions): Promise<ModuleStorageListing>;
head(physicalBucket: string, key: string): Promise<ModuleStorageObjectMetadata>;
get(physicalBucket: string, key: string): Promise<ModuleStorageObject>;
put(
physicalBucket: string,
key: string,
body: ModuleStorageBody,
options: ModuleStoragePutOptions,
): Promise<ModuleStorageObjectMetadata>;
delete(physicalBucket: string, key: string): Promise<void>;
/** A cheap reachability probe for diagnostics only. Its result is **never** persisted and never
* influences a module's lifecycle state — a provider being briefly unreachable is an operational
* condition, not an administrative decision (ADR 0030). */
probe(): Promise<void>;
/** Releases provider connections. Called once, during API shutdown. */
close(): Promise<void>;
}
+289
View File
@@ -0,0 +1,289 @@
import { Readable } from "node:stream";
import {
CreateBucketCommand,
DeleteObjectCommand,
GetObjectCommand,
HeadObjectCommand,
ListBucketsCommand,
ListObjectsV2Command,
PutObjectCommand,
S3Client,
} from "@aws-sdk/client-s3";
import {
type ModuleStorageBody,
ModuleStorageError,
type ModuleStorageListOptions,
type ModuleStorageListing,
type ModuleStorageObject,
type ModuleStorageObjectMetadata,
type ModuleStoragePutOptions,
} from "@ekklesia/contracts";
import type { StorageProviderConfig } from "./config.js";
import type { StorageProvider } from "./provider.js";
/**
* The platform's first real `StorageProvider`, speaking the S3-compatible protocol.
*
* **It is not named after a product, and it must not become one.** SeaweedFS is what the
* repository's development Compose stack runs and the likely deployment target (ADR 0004), but
* nothing here depends on SeaweedFS: the same code addresses MinIO, AWS S3, Garage, or anything else
* speaking the protocol, selected purely by host configuration (ADR 0030).
*
* Server-only, and structurally so: this module imports `node:stream` and the AWS SDK, so a browser
* bundle importing it would fail to build. `@ekklesia/module-ui-sdk` and `surfaces/*` never reach it,
* because a module never imports Core at all.
*/
/** Bounds a single listing page. S3-compatible providers cap this at 1000 regardless. */
const MAX_LIST_PAGE_SIZE = 1000;
interface S3LikeError {
readonly name?: unknown;
readonly $metadata?: { readonly httpStatusCode?: unknown };
}
function errorName(error: unknown): string {
const candidate = error as S3LikeError | null;
return typeof candidate?.name === "string" ? candidate.name : "";
}
function statusCode(error: unknown): number | undefined {
const candidate = error as S3LikeError | null;
const status = candidate?.$metadata?.httpStatusCode;
return typeof status === "number" ? status : undefined;
}
/**
* Reduces any provider failure to one of the contract's closed codes.
*
* **Nothing from the caught error survives this function** — not its message, not its request id,
* not the XML body, not the endpoint it was talking to. Provider errors routinely carry the bucket
* name, the host, and sometimes the full signed request, and a module is entitled to log whatever it
* catches, so translation here is the only place that boundary can be held.
*
* The mapping is verified against the pinned SeaweedFS 4.28 image rather than assumed from AWS
* documentation: a missing object reports `NoSuchKey`, a missing bucket `NoSuchBucket`, and — the
* one that actually differs from AWS — creating an existing bucket reports `BucketAlreadyExists`
* where AWS reports `BucketAlreadyOwnedByYou`.
*/
function translateStorageError(error: unknown, key?: string): ModuleStorageError {
const name = errorName(error);
const status = statusCode(error);
if (name === "NoSuchKey" || name === "NotFound" || (status === 404 && key !== undefined)) {
return new ModuleStorageError("STORAGE_OBJECT_NOT_FOUND", key);
}
if (name === "NoSuchBucket" || status === 404) {
return new ModuleStorageError("STORAGE_BUCKET_UNAVAILABLE");
}
if (
name === "AccessDenied" ||
name === "InvalidAccessKeyId" ||
name === "SignatureDoesNotMatch"
) {
return new ModuleStorageError("STORAGE_OPERATION_DENIED");
}
if (status === 403) {
return new ModuleStorageError("STORAGE_OPERATION_DENIED");
}
// Connection-level failures surface as Node system errors or the SDK's own timeout types rather
// than as an HTTP status, which is exactly the transient case a module is expected to retry.
if (
status === undefined &&
(name.startsWith("Timeout") ||
name === "TimeoutError" ||
name === "AbortError" ||
/ECONN|ENOTFOUND|EAI_AGAIN|EHOSTUNREACH|ENETUNREACH|EPIPE|ETIMEDOUT/u.test(
`${name} ${String((error as { code?: unknown } | null)?.code ?? "")}`,
))
) {
return new ModuleStorageError("STORAGE_PROVIDER_UNAVAILABLE");
}
if (status !== undefined && status >= 500) {
return new ModuleStorageError("STORAGE_PROVIDER_UNAVAILABLE");
}
return new ModuleStorageError("STORAGE_OPERATION_FAILED", key);
}
function metadataFrom(
key: string,
source: {
readonly ContentType?: string | undefined;
readonly ContentLength?: number | undefined;
readonly ETag?: string | undefined;
readonly LastModified?: Date | undefined;
},
): ModuleStorageObjectMetadata {
return Object.freeze({
key,
// Providers quote the ETag; unquoting keeps the value usable as an HTTP validator without every
// module learning to strip quotes.
...(source.ETag === undefined ? {} : { etag: source.ETag.replaceAll('"', "") }),
...(source.ContentLength === undefined ? {} : { contentLength: source.ContentLength }),
...(source.ContentType === undefined ? {} : { contentType: source.ContentType }),
...(source.LastModified === undefined ? {} : { lastModified: source.LastModified }),
});
}
/** Converts the contract's accepted body shapes into something the SDK can send without this
* process buffering a large object. A Web stream becomes a Node stream rather than an array of
* bytes, which is the whole reason the contract accepts one. */
function toRequestBody(body: ModuleStorageBody): Uint8Array | string | Readable | Blob {
if (body instanceof ReadableStream) {
return Readable.fromWeb(body as Parameters<typeof Readable.fromWeb>[0]);
}
return body;
}
export function createS3StorageProvider(config: StorageProviderConfig): StorageProvider {
const client = new S3Client({
credentials: { accessKeyId: config.accessKey, secretAccessKey: config.secretKey },
endpoint: config.endpoint,
forcePathStyle: config.forcePathStyle,
region: config.region,
});
return Object.freeze({
async ensureBucket(physicalBucket: string): Promise<void> {
try {
await client.send(new CreateBucketCommand({ Bucket: physicalBucket }));
} catch (error) {
const name = errorName(error);
// Idempotency, spelled out for both dialects. AWS reports `BucketAlreadyOwnedByYou`; the
// pinned SeaweedFS image reports `BucketAlreadyExists` with a 409 — verified directly, not
// assumed. Treating only the AWS spelling as success would make every second boot fail
// against the repository's own development storage service.
if (name === "BucketAlreadyOwnedByYou" || name === "BucketAlreadyExists") {
return;
}
if (name === "AccessDenied" || statusCode(error) === 403) {
// The host's credentials cannot create it. Surfaced as an unavailable bucket rather than
// silently falling back to "maybe it exists": an operator needs to know their credentials
// are insufficient, not discover it through a later confusing read failure.
throw new ModuleStorageError("STORAGE_BUCKET_UNAVAILABLE");
}
throw translateStorageError(error);
}
},
async list(
physicalBucket: string,
options: ModuleStorageListOptions,
): Promise<ModuleStorageListing> {
try {
const response = await client.send(
new ListObjectsV2Command({
Bucket: physicalBucket,
...(options.cursor === undefined ? {} : { ContinuationToken: options.cursor }),
...(options.limit === undefined
? {}
: { MaxKeys: Math.max(1, Math.min(options.limit, MAX_LIST_PAGE_SIZE)) }),
...(options.prefix === undefined ? {} : { Prefix: options.prefix }),
}),
);
const objects = (response.Contents ?? []).flatMap((entry) =>
entry.Key === undefined
? []
: [
metadataFrom(entry.Key, {
ETag: entry.ETag,
ContentLength: entry.Size,
LastModified: entry.LastModified,
}),
],
);
return Object.freeze({
objects: Object.freeze(objects),
// Only forwarded when the provider says the listing is truncated, so an absent cursor
// always means "complete" rather than "the provider happened to omit a token".
...(response.IsTruncated === true && response.NextContinuationToken !== undefined
? { cursor: response.NextContinuationToken }
: {}),
});
} catch (error) {
throw translateStorageError(error);
}
},
async head(physicalBucket: string, key: string): Promise<ModuleStorageObjectMetadata> {
try {
const response = await client.send(
new HeadObjectCommand({ Bucket: physicalBucket, Key: key }),
);
return metadataFrom(key, response);
} catch (error) {
throw translateStorageError(error, key);
}
},
async get(physicalBucket: string, key: string): Promise<ModuleStorageObject> {
try {
const response = await client.send(
new GetObjectCommand({ Bucket: physicalBucket, Key: key }),
);
if (!response.Body) {
throw new ModuleStorageError("STORAGE_OPERATION_FAILED", key);
}
// A Web stream, never a buffered body: a module hands this straight to a `Response`, so a
// large object never passes through this process's memory in one piece.
const body = response.Body.transformToWebStream() as ReadableStream<Uint8Array>;
return Object.freeze({ ...metadataFrom(key, response), body });
} catch (error) {
if (error instanceof ModuleStorageError) {
throw error;
}
throw translateStorageError(error, key);
}
},
async put(
physicalBucket: string,
key: string,
body: ModuleStorageBody,
options: ModuleStoragePutOptions,
): Promise<ModuleStorageObjectMetadata> {
try {
const response = await client.send(
new PutObjectCommand({
Body: toRequestBody(body),
Bucket: physicalBucket,
Key: key,
...(options.contentLength === undefined
? {}
: { ContentLength: options.contentLength }),
...(options.contentType === undefined ? {} : { ContentType: options.contentType }),
}),
);
return metadataFrom(key, {
ETag: response.ETag,
...(options.contentLength === undefined ? {} : { ContentLength: options.contentLength }),
...(options.contentType === undefined ? {} : { ContentType: options.contentType }),
});
} catch (error) {
throw translateStorageError(error, key);
}
},
async delete(physicalBucket: string, key: string): Promise<void> {
try {
await client.send(new DeleteObjectCommand({ Bucket: physicalBucket, Key: key }));
} catch (error) {
throw translateStorageError(error, key);
}
},
async probe(): Promise<void> {
try {
// The cheapest call that proves the endpoint is reachable and the credentials sign
// correctly, without creating, reading, or writing anything.
await client.send(new ListBucketsCommand({}));
} catch (error) {
throw translateStorageError(error);
}
},
async close(): Promise<void> {
client.destroy();
},
});
}
+7 -2
View File
@@ -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
View File
@@ -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");
});
});
+309
View File
@@ -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);
});
});
+357
View File
@@ -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] ?? "");
});
it("keeps two modules declaring the same logical bucket completely isolated", async () => {
const shared = provider();
const prefix = disposablePrefix();
const announcements = createModuleStorage({
bucketPrefix: prefix,
logicalBucketIds: ["assets"],
moduleId: "announcements",
provider: shared,
});
const video = createModuleStorage({
bucketPrefix: prefix,
logicalBucketIds: ["assets"],
moduleId: "video",
provider: shared,
});
await announcements.bucket("assets").put("same-key.txt", "from-announcements");
await video.bucket("assets").put("same-key.txt", "from-video");
// Same logical name, same key, one real storage service — and two different objects.
expect(
await new Response((await announcements.bucket("assets").get("same-key.txt")).body).text(),
).toBe("from-announcements");
expect(await new Response((await video.bucket("assets").get("same-key.txt")).body).text()).toBe(
"from-video",
);
// Neither module can see the other's object, even by listing.
expect((await announcements.bucket("assets").list()).objects.map((o) => o.key)).toEqual([
"same-key.txt",
]);
// And there is no way to ask for the other module's bucket at all.
expect(() => announcements.bucket("video-assets")).toThrow(ModuleStorageError);
});
it("accepts the longest bucket name the mapping can produce", async () => {
const shared = provider();
const prefix = disposablePrefix();
const moduleId = `m${"o".repeat(62)}`;
const bucket = physicalBucketName(prefix, moduleId, `b${"u".repeat(46)}`);
expect(bucket.length).toBeLessThanOrEqual(63);
// Proven against the real server rather than against the documented rule.
await expect(shared.ensureBucket(bucket)).resolves.toBeUndefined();
});
it("supports nested object keys, which modules genuinely use", async () => {
const prefix = disposablePrefix();
const bucket = createModuleStorage({
bucketPrefix: prefix,
logicalBucketIds: ["assets"],
moduleId: "announcements",
provider: provider(),
}).bucket("assets");
await bucket.put("2026/easter/slides/welcome.png", "x", { contentType: "image/png" });
expect((await bucket.list({ prefix: "2026/easter/" })).objects.map((o) => o.key)).toEqual([
"2026/easter/slides/welcome.png",
]);
});
it("probes a reachable provider, and reports an unreachable one as provider-unavailable", async () => {
await expect(provider().probe()).resolves.toBeUndefined();
const unreachable = createS3StorageProvider(
createStorageProviderConfig(
{
accessKey: "unused",
// A port nothing listens on: a real connection failure rather than a simulated one.
endpoint: "http://127.0.0.1:1",
secretKey: "unused",
},
{ environment: "development" },
)!,
);
try {
let caught: unknown;
try {
await unreachable.probe();
} catch (error) {
caught = error;
}
expect(caught).toBeInstanceOf(ModuleStorageError);
// Transient by nature: a module retries through this, and no lifecycle state changes
// (ADR 0030).
expect((caught as ModuleStorageError).code).toBe("STORAGE_PROVIDER_UNAVAILABLE");
} finally {
await unreachable.close().catch(() => undefined);
}
});
it("never deletes a bucket, even when every object in it is removed", async () => {
const shared = provider();
const prefix = disposablePrefix();
const bucket = createModuleStorage({
bucketPrefix: prefix,
logicalBucketIds: ["assets"],
moduleId: "announcements",
provider: shared,
}).bucket("assets");
await bucket.put("only.txt", "x");
await bucket.delete("only.txt");
// The bucket still exists and still accepts writes: emptying is not removal, and nothing in this
// platform destroys a bucket (ADR 0030).
expect((await bucket.list()).objects).toEqual([]);
await expect(bucket.put("again.txt", "y")).resolves.toMatchObject({ key: "again.txt" });
});
});
+775
View File
@@ -0,0 +1,775 @@
import {
EMPTY_MODULE_STORAGE,
isValidModuleStorageKey,
MODULE_STORAGE_KEY_MAX_BYTES,
type ModuleStorageBody,
ModuleStorageError,
type ModuleStorageListOptions,
type ModuleStorageListing,
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 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");
expect(Object.keys(bucket).sort()).toEqual(["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",
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("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");
});
});