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
@@ -356,3 +356,29 @@ is hot-reloaded. Migration discovery remains independent of configuration validi
independent of enabled state. Endpoints and credentials for shared infrastructure stay platform
concerns — a module declares which bucket it needs, not an S3 endpoint — leaving room for the object
storage service Core still does not have. There is no settings UI.
Phase 7A pass 3 turns `manifest.storage.buckets` — inert since manifest version 1 — into a real
host-provided capability ([ADR 0030](decisions/0030-host-provided-module-object-storage.md)). A module
declares a **logical** bucket and receives a scoped facade at `context.storage` offering list, head,
get, put, and delete, with `get` returning a Web stream so an object never has to be buffered. The
contract names no provider: there is no endpoint, region, credential, or physical bucket reachable
from it, which is what lets the same module run under a future standalone host backed by its own or a
shared S3-compatible service.
The host owns everything else. Core's first provider speaks the S3-compatible protocol through the
AWS SDK against a configured endpoint, with SeaweedFS as the intended deployment target (ADR 0004) but
never as an API name. A logical bucket maps to a physical one deterministically — a configurable
installation prefix, the module and bucket ids joined by a separator that cannot occur inside either,
and a digest over the full identity — so two modules declaring `assets` are always isolated and one
storage service can host several installations. Bucket creation is idempotent and lazy on first use;
nothing ever deletes a bucket.
Storage is optional: an installation without a provider is complete, and only a module that *declares*
a bucket is affected — it is reported `blocked · storage_unavailable` through ADR 0028's existing
resolver, propagating to required dependents. Crucially, eligibility asks whether storage is
**configured**, never whether it answered: a configured provider that is briefly unreachable stays an
operational error a module retries through, never a lifecycle state, so the platform is no less
resilient than the products it will absorb. Startup performs no storage I/O at all. Credentials are
deployment-supplied, never persisted, never routed through module configuration, and never visible to
modules, module UI, diagnostics, or API responses. Browsers reach objects through a module's own
routes; signed URLs and public object delivery are deferred.
+65 -3
View File
@@ -389,9 +389,11 @@ account.
3. Construct the module database facade and the frozen `ModuleRuntimeServices` object from that same
pool (see [Module database access](#module-database-access)).
4. Construct the Core identity runtime, authorization store, and evaluator.
5. Read the stored module enablement configuration and then the stored module configuration, and
resolve, over that exact same discovered inventory, which modules are **eligible** to run and
what each eligible module's frozen configuration snapshot is (`resolveModuleEligibility`,
5. Construct the host object-storage provider from validated configuration, if this installation has
one — building the client opens no connection, so a storage outage can never stop the platform
from booting (ADR 0030). Then read the stored module enablement configuration and the stored module
configuration, and resolve, over that exact same discovered inventory, which modules are
**eligible** to run and what each eligible module's frozen configuration snapshot is (`resolveModuleEligibility`,
`@ekklesia/core/modules` — see [Module lifecycle](#module-lifecycle) and
[Module configuration](#module-configuration)). The two reads are sequential rather than raced so
a broken deployment reports the same error code every time.
@@ -664,6 +666,66 @@ different authorities. Mutations additionally require an exact trusted `Origin`
No response carries a filesystem path, package name, manifest path, caught error, stack, or secret
value.
## Module object storage
Ekklesia can provide modules with logical object storage backed by any S3-compatible service
([ADR 0030](../architecture/decisions/0030-host-provided-module-object-storage.md)). There is **no
HTTP API for it**: storage is a runtime capability modules use, not a resource the platform exposes,
and no route returns a provider endpoint or credential.
### Host configuration
| variable | meaning |
| --- | --- |
| `S3_ENDPOINT` | Absolute `http`/`https` URL. **Absent means this installation has no object storage.** |
| `S3_ACCESS_KEY`, `S3_SECRET_KEY` | Required once an endpoint is set |
| `S3_REGION` | Defaults to `us-east-1` |
| `S3_FORCE_PATH_STYLE` | Defaults to `true`; self-hosted services need it |
| `S3_BUCKET_PREFIX` | Defaults to `ekklesia`; namespaces this installation on a shared service |
Production requires `https`, exactly as the database transport requires verified TLS. An endpoint
embedding credentials is rejected rather than stripped. These values reach Core's provider and
nothing else — never a module, module UI, browser bundle, diagnostic, or API response.
The repository's development Compose stack already runs SeaweedFS for this, and `.env.example`
already carries matching values.
### Logical buckets, and what modules get
A module declares logical buckets in its manifest (`storage.buckets`) and receives a scoped facade at
`context.storage` on its runtime and HTTP handler contexts. It names a logical bucket; the host maps
it to a physical one deterministically and never exposes that name.
An installation with **no** provider is a complete deployment: modules declaring no bucket run
normally. A module that *does* declare one is reported `blocked · storage_unavailable` on
`GET /api/v1/module-lifecycle`, naming the logical bucket:
```json
{ "runtime": { "state": "blocked", "reason": "storage_unavailable",
"storageBucketId": "announcement-assets" } }
```
Its required dependents block behind it through the ordinary dependency mechanism.
### Binding availability is not provider health
Eligibility asks whether storage is **configured**, never whether the service answered. A configured
provider that is temporarily unreachable is an operational error the module catches and retries —
`running` still means the `start` hook completed, and no lifecycle state changes because a storage
call timed out. Buckets are created lazily on first use rather than at startup, so a storage outage
never prevents the platform from booting, and nothing ever deletes a bucket.
### Serving objects to browsers
There are no signed URLs and no public object routing. A module streams objects through its own
authorized route:
```text
browser → GET /api/v1/modules/<id>/assets/<key> → context.storage → object bytes
```
which keeps every S3 concern server-side and gives module UI no credentials.
## Module HTTP routes
An installed module's routes (declared via `http.route(...)` in its `register` hook — see
+38
View File
@@ -245,6 +245,44 @@ Reading or changing configuration requires `core.modules.configuration.manage` (
`Origin` and a bounded body, like every other Core mutation. Module UI receives no configuration at
all in this phase.
### Object storage is scoped by binding, and modules never hold credentials
A module declares a logical bucket; the host resolves it to a physical bucket on an S3-compatible
service and hands the module a facade scoped to exactly that
([ADR 0030](../architecture/decisions/0030-host-provided-module-object-storage.md)).
What that gives, structurally rather than by discipline:
- **No credential reaches a module.** The endpoint, region, access key, and secret key live in the
host's validated configuration and reach Core's provider only. `ModuleStorage` has no accessor that
could return any of them, and none appears in any error, diagnostic, or API response.
- **No module can open another module's bucket.** The facade closes over exactly the bindings that
module's manifest declared. Knowing another module's bucket name, module id, or physical bucket
name buys nothing, because none of those is an input to anything — an undeclared name is simply not
a key in the map.
- **No module chooses a physical bucket or an endpoint.** Both come from host configuration and the
deterministic binding, never from module input, so a module cannot aim the platform's S3 client at
a server of its choosing.
- **Two modules declaring the same logical name are isolated.** Physical names are derived from
module id *and* bucket id with a separator that cannot occur inside either, plus a digest over the
full identity — so `assets` in two modules is two namespaces, not one.
- **Object keys cannot escape a bucket**, because the bucket never comes from the key.
- **Nothing deletes a bucket.** There is no such operation to call, so disabling or removing a module
cannot take data with it.
- **Provider errors are translated, never forwarded.** No endpoint, request id, bucket name, or XML
body survives into a `ModuleStorageError` a module might log or return.
Endpoints must be absolute `http`/`https`, must not embed credentials, and must use `https` in
production — the same standard the database transport holds. Credentials are never persisted in
PostgreSQL and never routed through module configuration, which has no encryption at rest to offer
them (ADR 0029).
**What this does not claim.** A module is still trusted code in the API process, so a malicious module
could read `process.env` directly and obtain the provider credentials that way. Scoped storage is a
correctness and portability boundary like every other module-facing mechanism here — not a sandbox.
What it does guarantee is that Ekklesia never hands a module another module's data, never gives module
UI or a browser a credential, and never prints one in diagnostics.
### Client-side permission checks are not a security boundary
A host may tell a module UI what permissions it believes the viewer holds
+23
View File
@@ -193,6 +193,29 @@ rejected without touching `Object.prototype`, and a module blocked by its own co
from the mountable UI inventory exactly like a disabled one. See
[ADR 0029](../architecture/decisions/0029-module-configuration-and-secrets.md).
A seventh dedicated suite (`core/tests/module-storage.integration.ts`, included in the same
`pnpm test:migrations:integration` run) proves the object-storage contract against a **real
S3-compatible server** — the SeaweedFS service the repository's Compose file already pins, brought up
alongside PostgreSQL by the same harness, so this adds no new CI infrastructure.
It exists because mocking would have hidden a real divergence: probing the pinned SeaweedFS 4.28 image
directly showed that `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, and no unit test would have
caught it. The suite pins that idempotency, plus a real round trip with content type and unquoted
ETag preserved, streaming a body without buffering, a Web stream going straight into a `Response`,
prefix listing and real pagination, a missing object mapping to a structured not-found error carrying
no endpoint or XML, two modules declaring the same logical bucket staying genuinely isolated on one
server, the longest bucket name the mapping can produce being accepted, a real connection failure
mapping to `STORAGE_PROVIDER_UNAVAILABLE`, and a bucket surviving the deletion of every object in it.
Unit suites (`core/tests/module-storage.test.ts`, `core/tests/module-storage-lifecycle.test.ts`) use an
injected in-memory provider and never touch the network. They prove the parts the protocol cannot:
binding determinism and collision-safety, scoping and cross-module isolation, key validation, lazy
provisioning and retry-after-failure, configuration validation and credential redaction, and the
lifecycle integration including dependency propagation. See
[ADR 0030](../architecture/decisions/0030-host-provided-module-object-storage.md).
### Which kind of timing proof lives where
Pass 4 introduced several deadlines, and their tests deliberately differ in kind:
+108
View File
@@ -969,6 +969,12 @@ write one row, and start nothing. The change takes effect at the next `pnpm api:
so a module that breaks startup can be turned off without the API — the HTTP lifecycle API is served
by the process such a module takes down.
`pnpm doctor`'s `object-storage.integration` check reports whether a storage provider is configured
and reachable, and `object-storage.bindings` lists which modules require storage and where each
logical bucket is bound. Neither prints a credential. A module requiring storage on an installation
with none configured makes the bindings check fail with a remediation line; a provider that is merely
unreachable is a warning, because modules already running are unaffected.
`pnpm doctor`'s `modules.configuration` check reports whether each installed module's configuration
would let it run, naming the offending key when it would not. It prints **no configuration values** —
secret or otherwise — only counts, keys, and reasons.
@@ -1096,6 +1102,108 @@ leaves room for.)
Configuration tells you *how to behave*, never *whether to run*. Whether your module runs is its
host's decision, described next.
## Object storage
Declare the logical buckets you need in `ekklesia.module.json`, and read and write them through
`context.storage` ([ADR 0030](../architecture/decisions/0030-host-provided-module-object-storage.md)).
```json
"storage": {
"buckets": [
{ "id": "announcement-assets", "description": "Slide images an operator uploads." }
]
}
```
```ts
export default defineModuleRuntime({
id: "announcements",
version: "1.0.0",
register({ http }) {
http.route({
method: "GET",
path: "/assets/:key",
permission: "announcements.assets.read",
maxBodyBytes: 0,
async handler(_request, context) {
const object = await context.storage.bucket("announcement-assets").get(context.params.key);
return new Response(object.body, {
headers: { "content-type": object.contentType ?? "application/octet-stream" },
});
},
});
},
});
```
**You name a logical bucket. You never name a service.** There is no endpoint, region, credential, or
physical bucket anywhere in this contract, and no accessor that could return one. That is what lets
the same module run under Ekklesia and under a future standalone host backed by its own SeaweedFS, a
shared one, MinIO, or AWS S3 — without your code changing.
Never add `storageEndpoint`, `storageAccessKey`, or similar to your module configuration. Those are
the host's, and a module that configures its own object store is a module welded to one deployment.
### Declaring a bucket is a real requirement
If you declare a bucket and the installation has no storage provider configured, your module is
**blocked** — reported as `storage_unavailable` naming that bucket, with its required dependents
blocked behind it. Your `start` hook never runs, so it never has to defend against missing storage.
Declare a bucket only if you need one. A module that declares none is completely unaffected by
whether the installation has object storage, which is why the template declares none.
### The operations
```ts
bucket.list({ prefix, limit, cursor }); // → { objects, cursor? }
bucket.head(key); // → metadata only
bucket.get(key); // → metadata + body: ReadableStream
bucket.put(key, body, { contentType, contentLength });
bucket.delete(key);
```
`get` gives you a Web stream, so hand it straight to a `Response` rather than buffering. `put` takes
a `Uint8Array`, string, `Blob`, or a `ReadableStream` — a stream **must** come with `contentLength`,
because every S3-compatible provider needs the length up front and inferring it would mean buffering
the object you were trying to stream.
A listing is paginated. When `cursor` is present, more objects remain; when it is absent, the listing
is complete. Ignoring it will silently truncate a large bucket.
There is deliberately **no** way to create or delete a bucket. Which buckets exist follows from your
manifest and an operator's installation, and nothing your module does can destroy one.
### Object keys
Keys may nest (`slides/2026/welcome.png`). They are not filesystem paths — `..` means nothing to an
object store, and your bucket comes from the host's binding rather than from the key, so a key cannot
escape it. Empty keys, keys over 1024 UTF-8 bytes, control characters, backslashes, and a leading `/`
are rejected as `STORAGE_KEY_INVALID`.
### Storage failures are yours to handle, and never change your lifecycle
```ts
try {
const listing = await context.storage.bucket("announcement-assets").list();
} catch (error) {
// STORAGE_PROVIDER_UNAVAILABLE, STORAGE_OBJECT_NOT_FOUND, STORAGE_OPERATION_DENIED, ...
}
```
`ModuleStorageError` carries a code and nothing else — no endpoint, credential, bucket name, or
provider body — so it is safe to log or return.
**A storage outage is not a lifecycle event.** If the object store is briefly unreachable, your module
stays `running`; retry on your own schedule and report health in your own UI. The platform will never
disable or block a running module because a storage call timed out.
### Serving objects to a browser
Module UI gets no storage access and no credentials. Serve bytes through your own authorized route,
as in the example above: `browser → your module API → your scoped storage`. Signed URLs and public
object delivery do not exist yet.
### Whether your module runs at all is your host's decision
Between discovery and startup, the platform consults its own **enablement configuration** and