feat: add host-provided module object storage
`manifest.storage.buckets` has existed since manifest version 1 and meant nothing — discovery validated it and no code read it. This turns it into a real capability, which is what stands between Announcements and the module system: that product currently fetches a hardcoded LAN S3 endpoint, parses ListObjectsV2 XML by hand, and hands the browser direct object URLs. - A module declares a logical bucket and receives a scoped facade at `context.storage` — list, head, get, put, delete. The contract names no provider: no endpoint, region, credential, or physical bucket is reachable from it, which is what lets the same module run under a future standalone host over its own or a shared S3-compatible service. - Core's first provider speaks S3 through the AWS SDK. SeaweedFS is the deployment target (ADR 0004), never an API name. Probing the pinned image found `CreateBucket` reporting `BucketAlreadyExists` rather than AWS's `BucketAlreadyOwnedByYou`; treating only the AWS spelling as success would have failed every second boot. A live test pins it. - Logical → physical mapping is deterministic and collision-safe: an installation prefix, the module and bucket ids joined by `--` (which cannot occur inside either identifier, so `a-b`+`c` and `a`+`b-c` cannot collide), and a digest over the full untruncated identity. Two modules declaring `assets` always get separate namespaces; neither can see the physical name. - Declaring a bucket is now a requirement: with no provider configured the module is `blocked · storage_unavailable` through ADR 0028's existing resolver, propagating to required dependents. An installation with no object storage stays a complete deployment. - Binding availability is not provider health. Eligibility asks whether storage is configured, never whether it answered; bucket creation is lazy on first use so a storage outage cannot stop the platform booting; a running module whose store blinks stays running and retries. Nothing deletes a bucket. - Credentials are deployment-supplied, never persisted, never routed through module configuration, and never visible to modules, module UI, diagnostics, or API responses. Provider errors translate to a closed code set carrying no endpoint, bucket, request id, or XML. - Retires ADR 0029's transitional `bucket` configuration key. No manifest version change. The template declares no bucket, so it still runs without S3. See ADR 0030. Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5
parent
527239f6de
commit
b9ca0d560d
@@ -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.
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user