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
+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: