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
@@ -175,6 +175,12 @@ shape as `pnpm authorization:bootstrap-owner`: explicit, never HTTP-exposed, nev
> discriminated union so a configuration reason names a **key** where a dependency reason names a
> dependency. Everything else here is unchanged: startup atomicity, the absence of a `failed` state,
> the default-enabled policy, and the dependency propagation those new reasons flow through.
>
> **See also [ADR 0030](0030-host-provided-module-object-storage.md)**, which adds one further
> reason, `storage_unavailable`, for a module whose declared logical object-storage bucket this host
> cannot bind. It deliberately reports a *binding* problem and never a *health* one: a configured
> provider that is briefly unreachable remains an operational condition a module retries through,
> exactly as this ADR established for external resources generally.
There is consequently **no `failed` runtime state**, because no live process could ever report one.
Naming a state nothing can produce is exactly the mistake `MODULE_LIFECYCLE_STAGES` made. It is the
@@ -303,6 +303,13 @@ already declares logical buckets: when the storage service exists, the bucket bi
there and the configuration key should be retired. That overlap is recorded here deliberately so it
is resolved by decision rather than by accident.
> **Cross-reference — see [ADR 0030](0030-host-provided-module-object-storage.md).**
> That overlap is now resolved, in the direction this section anticipated. `manifest.storage.buckets`
> is the logical storage requirement, Core provides a host-managed binding for it, and the
> transitional `bucket` *configuration* key is retired: a logical bucket is a static declaration the
> platform validates before any module code runs, not a value an operator types. Everything else here
> is unchanged.
## Validated against the real product
Every configuration-shaped literal in Announcements at commit `9aab486`, classified:
@@ -0,0 +1,307 @@
# 0030: Modules declare logical buckets; hosts provide object storage
- Status: Accepted
- Date: 2026-08-23
- Extends: [0004](0004-replace-unmaintained-minio.md),
[0016](0016-module-database-access.md),
[0021](0021-modules-as-hostable-products.md),
[0028](0028-module-operational-lifecycle.md),
[0029](0029-module-configuration-and-secrets.md)
## Context
`manifest.storage.buckets` has existed since manifest version 1 and meant nothing. A module could
declare `{ "id": "records" }`, discovery would validate the identifier and reject duplicates, and
then nothing anywhere read the field. Doctor said so plainly: *"Core storage integration has not
been implemented yet."*
Meanwhile the product that will become Ekklesia's first storage consumer does this
(`The_River/Announcements`, commit `9aab486`):
```js
const response = await fetch("http://10.40.0.2:9000/announcements?list-type=2");
const data = new XMLParser().parse(await response.text());
slides = (data.ListBucketResult?.Contents ?? [])
.map((file) => file.Key)
.filter((name) => /\.(png|jpg|jpeg|webp)$/i.test(name))
.map((name) => `http://10.40.0.2:9000/announcements/${encode(name)}`);
```
A hardcoded LAN address, a hand-rolled `ListObjectsV2` XML parse, an anonymously readable bucket,
and direct S3 URLs handed to the browser. Every one of those has to disappear, and none of them can
be replaced with "let the module configure its own S3 endpoint" — that would make every module a
separate S3 deployment decision and reintroduce the coupling in a tidier costume.
Two facts from the repository shaped what was possible.
**The provider question was already decided.** ADR 0004 replaced the archived MinIO baseline with
SeaweedFS 4.28 in the development Compose stack, deliberately using `S3_*` configuration names
rather than product-specific ones, and recorded that *"provider-specific Core APIs were rejected
because they would couple every module to this choice."* The service is already pinned, already
provisioned, and already has credentials in `.env.example`.
**ADR 0029 left a loose end and named it.** It accepted a `bucket` *configuration key* as a
transitional answer while observing that `manifest.storage.buckets` already existed for exactly this
purpose, and asked for the overlap to be resolved rather than left to drift. This ADR resolves it.
## Decision
### The logical/physical split, and which side owns what
```text
a module owns I require logical bucket "announcement-assets"
a host owns endpoint · region · credentials · path style · TLS · physical bucket naming
```
That line is the whole design. A module names a bucket; the host decides what a bucket *is*. The
module-facing contract (`packages/contracts/src/module-storage.ts`) contains no endpoint, no region,
no credential, no signing, no `S3`, and no `SeaweedFS` — not as a field, not as a type, not as a
string. A future standalone host constructs the same interface over its own SeaweedFS, a shared one,
MinIO, or AWS S3, and the module cannot tell.
### `manifest.storage.buckets` becomes a real requirement, and the manifest does not change
Declaring a bucket now means "I require this". No new field, no new version — **manifest version 5
is unchanged**. The existing declaration already expressed the requirement precisely; it simply had
no consumer.
Every declared bucket is required. A separate `required: false` would have meant a manifest version
bump (`moduleBucketDeclarationSchema` is `.strict()`) for a case no module has, and "declare it if
you need it" is a simpler rule than one with an escape hatch.
**This retires ADR 0029's transitional `bucket` configuration key.** A logical bucket is a static
declaration the platform can validate before any module code runs, not a value an operator types.
Module configuration is for things an operator decides; storage requirements are for things a module
knows about itself.
The template consequently declares **no** bucket any more. A starting point that could not run
without S3 configured would be a poor starting point, so storage is something a module author opts
into deliberately.
### Physical naming: deterministic, collision-safe, and never module-visible
```text
ekklesia-announcements--announcement-assets-3f0c1d9a6b21
└─prefix─┘└──── module--bucket (truncated) ────┘└─digest─┘
```
Three properties, each load-bearing:
**`--` as the separator, because it cannot occur inside either identifier.** Both `moduleIdSchema`
and `logicalBucketIdSchema` require `[a-z0-9]` after every hyphen, so a doubled hyphen is
unrepresentable in each half. A single hyphen would have been ambiguous in precisely the way that
matters: module `a-b` + bucket `c` and module `a` + bucket `b-c` would both render `a-b-c`, silently
handing two unrelated modules one namespace.
**A digest over the full, untruncated identity.** S3 caps bucket names at 63 characters while
Ekklesia identifiers allow 64 + 48, so the readable middle is truncated. The digest is taken *before*
truncation, so two modules whose names truncate identically still get different buckets.
**A configurable prefix** (`S3_BUCKET_PREFIX`, default `ekklesia`), so several Ekklesia-compatible
deployments can share one S3-compatible service — which is exactly the standalone story: one
SeaweedFS holding Announcements, Video, Sermons, and Signage, isolated by binding rather than by
running four storage servers.
The result is never exposed to module code. `ModuleStorageBucket.id` is the **logical** id, and there
is no accessor for anything else.
### The module-facing API, kept small
```ts
const assets = context.storage.bucket("announcement-assets");
await assets.list({ prefix: "slides/" });
await assets.get("slides/welcome.png"); // → { body: ReadableStream, contentType, ... }
await assets.put("slides/welcome.png", bytes, { contentType: "image/png" });
await assets.head("slides/welcome.png");
await assets.delete("slides/welcome.png");
```
Five operations, chosen against what Announcements actually does — list a bucket, read images — plus
the writes any real product needs. There is deliberately no multipart upload, no lifecycle rule, no
replication, no ACL or bucket-policy management, and **no bucket creation or deletion**: which
buckets exist follows from an installed manifest, not from running module code.
`get` returns a Web `ReadableStream`, so an object goes straight into a `Response` without this
process holding it in memory. `put` accepts a stream too, but requires `contentLength` alongside one
— every S3-compatible provider needs the length up front for a single-part upload, and inferring it
would mean buffering the very thing the stream exists to avoid. Refusing loudly beats discovering it
through production memory pressure.
Metadata is `contentType`, `contentLength`, `etag`, `lastModified` — the four fields every
S3-compatible provider actually returns, not a mirror of S3's metadata surface. Content type matters
because Announcements serves images and must set a correct header without sniffing bytes.
Object keys allow `/` (modules genuinely use `slides/welcome.png`) and reject what would be
*ambiguous* rather than dangerous: empty, over 1024 UTF-8 bytes, control characters, lone surrogates,
backslashes, and a leading `/`. A key cannot escape its bucket in any case — the physical bucket
comes from the binding, never from the key.
### Errors are a closed set, and nothing from the provider survives
`ModuleStorageError` with eight codes (`STORAGE_OBJECT_NOT_FOUND`, `STORAGE_PROVIDER_UNAVAILABLE`,
`STORAGE_OPERATION_DENIED`, …). No message, request id, XML body, bucket name, endpoint, or caught
cause crosses the translation boundary. Provider errors routinely carry the host and sometimes the
signed request, and a module is entitled to log whatever it catches, so this is the only place that
boundary can be held. The one echoed value is the module's own object key.
### `context.storage`, beside `services` rather than inside it
Storage sits on `ModuleRuntimeContext` and `ModuleRouteHandlerContext` — **not** on
`ModuleRegistrationContext`, exactly like `services`, because it performs network I/O and
registration stays declarative. (`config` is on registration precisely because it is the opposite: a
resolved in-memory snapshot.)
It is emphatically not a field on `ModuleRuntimeServices`. That object is deliberately **one shared
identity every module receives**, a property the runtime has a test for; a storage facade is scoped
to one module's buckets. Putting it there would have forced one of those two properties to give, and
would have handed every module every other module's storage.
### Startup performs no storage I/O, and bucket creation is lazy
Constructing the provider opens no connection. Buckets are ensured on **first use** of a binding,
memoized per binding so it happens once per process — and a *failed* attempt is discarded, so a
transient outage does not poison the binding for the life of the process.
Eager provisioning at startup was rejected, and this was the sharpest decision in the pass. If
Ekklesia ensured buckets during boot, a storage service that was briefly down would stop the whole
platform from starting — strictly worse than the product being replaced, whose slideshow survives its
object store vanishing. The platform must not become less resilient because storage got abstracted.
Creation is idempotent and destruction does not exist. Nothing deletes a bucket: not disabling a
module, not removing one, not emptying it.
### Lifecycle: binding availability, never provider health
One new blocked reason, `storage_unavailable`, meaning *this module declares a logical bucket and
this host has no storage provider configured*. It names the logical bucket and nothing else.
```text
enabled → storage bindable? → configuration valid? → dependencies eligible? → runs
```
Resolution stays **pure and synchronous**. It asks whether storage is *configured*, never whether the
service answered — so eligibility can never depend on a probe that happened to time out during boot.
A storage-blocked module propagates to its required dependents through ADR 0028's existing mechanism
rather than a parallel one.
The distinction this preserves is the one the real product already proved matters:
| situation | classification |
| --- | --- |
| no provider configured | lifecycle problem — module blocked |
| required bucket cannot be bound | lifecycle problem — module blocked |
| configured provider times out at 10:15 | operational error the module catches and retries |
`running` still means the `start` hook completed, never that every external resource stays healthy.
A transient outage writes nothing, anywhere.
### Host configuration, using the names ADR 0004 already chose
`S3_ENDPOINT`, `S3_ACCESS_KEY`, `S3_SECRET_KEY`, `S3_REGION`, `S3_FORCE_PATH_STYLE`, and the new
`S3_BUCKET_PREFIX` — read only by the executable entry point, validated into a typed config, and
never seen by a module, a module UI, a browser bundle, a diagnostic, or an API response.
Storage is **optional**. No `S3_ENDPOINT` means no provider, which is a complete deployment: modules
that declare no bucket are unaffected. Once an endpoint *is* set, credentials become required — a
half-configured provider silently attempting anonymous access would be a worse failure than a loud
one. Production requires `https` on the same reasoning the database transport requires verified TLS;
an endpoint embedding userinfo is rejected outright rather than stripped.
Credentials are never persisted in PostgreSQL and never routed through module configuration
(ADR 0029), which has no encryption at rest to offer them.
### The provider: S3-compatible, via the AWS SDK, not hand-rolled
`@aws-sdk/client-s3` addressing an arbitrary endpoint with path-style URLs. Hand-rolling SigV4 for a
security-sensitive path was rejected; the SDK is the mature, maintained implementation and works
against any S3-compatible service.
Probing the pinned SeaweedFS image directly, rather than trusting AWS documentation, found a real
divergence: **`CreateBucket` on an existing bucket reports `BucketAlreadyExists`, not AWS's
`BucketAlreadyOwnedByYou`.** A provider treating only the AWS spelling as success would have failed
on every second boot against this repository's own development storage. Both are accepted, and a live
test pins it. This is exactly the provider-quirk risk ADR 0004 anticipated.
### Browser delivery goes through the module's own routes
```text
browser → module API route → context.storage → object bytes
```
No S3 credentials reach the browser, no provider client is bundled for it, and `ModuleUiHostContext`
is unchanged. A module streams an object into a `Response` from its own authorized route.
This is a genuine behavioural change for Announcements, which today hands the browser direct S3 URLs.
Proxying costs a hop and makes the module API the media path; signed URLs and public-object routing
are the follow-up design, deliberately not built here without a real requirement to shape them.
### Standalone → Ekklesia migration: the seam, not the engine
Core provides list, read, and write. **A module owns semantic migration**, because only the module
knows which objects are canonical, which are generated, whether database references need updating,
and how to verify success. Core will not inspect Announcements filenames and guess.
```text
copy → verify → switch binding/state → verify → optionally remove old data later
```
Nothing in this pass deletes source data, and nothing ever should implicitly.
### Storage unavailable never means "start your own"
A module running inside Ekklesia that finds storage unconfigured is **blocked with a clear
diagnostic**, never permitted to launch its own storage server. A future standalone deployment
wrapper may bundle SeaweedFS as a convenience — that is deployment composition, not module runtime
behaviour, and the distinction must not blur.
## Alternatives
- **Per-module `storageEndpoint` / `storageAccessKey` configuration.** Rejected — the mistake this
ADR exists to prevent. Every module would become a separate S3 deployment decision.
- **A new manifest field or version.** Rejected: `manifest.storage.buckets` already expressed the
requirement exactly; it only lacked a consumer.
- **Keeping ADR 0029's `bucket` configuration key.** Retired: two mechanisms for one fact.
- **Handing modules a raw bucket name or provider client.** Rejected: it would let any module open
any bucket and would weld every module to S3.
- **A single hyphen between module and bucket.** Rejected: demonstrably ambiguous, and the ambiguity
silently merges two modules' namespaces.
- **Hashing without a readable portion.** Rejected: an operator debugging a permissions problem needs
to recognize the bucket.
- **Eager bucket provisioning at startup.** Rejected: it would make a storage outage a platform
outage, less resilient than the product being replaced.
- **Treating provider unreachability as a lifecycle state.** Rejected explicitly: it would mark
running modules blocked over a network blip and destroy the distinction ADR 0028 established.
- **Hand-rolled SigV4.** Rejected for a security-sensitive path with a mature alternative.
- **Storage on `ModuleRuntimeServices`.** Rejected: `services` is one shared object; storage is
per-module.
- **Signed URLs / public object routing now.** Deferred: no requirement yet exists to shape them, and
guessing would produce a contract we would have to break.
- **Range requests in `get`.** Deferred: an additive option, cheap to add when video needs seeking,
and outside "keep the first contract small".
- **Naming the contract after SeaweedFS.** Rejected by ADR 0004 before this pass began.
## Consequences
`manifest.storage.buckets` means something for the first time since it was introduced. A module asks
for a logical bucket and gets isolated, host-managed storage; the host decides what backs it; and
Announcements has a path away from `http://10.40.0.2:9000` that does not replace one hardcoded
address with a configurable one.
Core gains one dependency (`@aws-sdk/client-s3`, server-only), one new export path
(`@ekklesia/core/storage`), one blocked reason, one Doctor check, and a required `moduleStorage`
option on `createApiApp`. The template no longer declares a bucket. The live integration harness now
brings up the Compose file's already-pinned SeaweedFS alongside PostgreSQL.
Costs worth naming. Object delivery to browsers now goes through module routes, which is a real
change in shape for Announcements and will want signed URLs or caching before large media. The
provider is **not** "production-ready" by ADR 0004's own bar — that list includes ranges, multipart,
and signed URLs, none of which exist here. And bucket bindings are platform-global, needing a scope
column whenever an organization model arrives, like every other Core table.
## Deferred
Not implemented or promised here: signed URLs, public object routing, or a CDN; range requests;
multipart upload; object lifecycle rules, replication, or bucket policies; a storage settings
Dashboard; per-organization storage; provider administration or SeaweedFS process management; the
standalone host and its Compose stack; automatic standalone → Ekklesia migration and any migration
UI; and the Announcements migration itself.
+1
View File
@@ -38,3 +38,4 @@ ADRs capture durable choices that affect more than one package or phase.
- [0027: Dashboard navigation is presentation configuration, and modules stay flat](0027-dashboard-navigation-and-flat-modules.md) — Accepted
- [0028: Module operational lifecycle — enablement, eligibility, and what "running" means](0028-module-operational-lifecycle.md) — Accepted
- [0029: Module configuration is manifest-declared, host-resolved, and secret-free at rest](0029-module-configuration-and-secrets.md) — Accepted
- [0030: Modules declare logical buckets; hosts provide object storage](0030-host-provided-module-object-storage.md) — Accepted