feat: one generic image, with installed modules in installation-owned storage (ADR 0048)
CI / verify (pull_request) Failing after 47s
CI / infrastructure-smoke (pull_request) Skipped
CI / migration-smoke (pull_request) Skipped
CI / image-build (pull_request) Skipped
CI / deploy-smoke (same-origin) (pull_request) Skipped
CI / deploy-smoke (split) (pull_request) Skipped

The production image now contains zero feature modules and is the same for every
installation. Installed modules live in module storage that one Core resolver
selects from EKKLESIA_MODULES_DIR: the checkout's modules/ when unset, an
absolute path that must exist when set (/var/lib/ekklesia/modules in the image).
A relative, blank, missing, or unreadable configured directory fails closed with
a sanitized error that never contains the value.

- Host configuration reads EKKLESIA_MODULES_DIR. The API, migration plan, apply
  and validate, module validation, module enablement, and Doctor all resolve it
  the same way; the test-only API override still takes precedence.
- Doctor receives Core's migrations directory explicitly instead of deriving it
  from the modules directory's parent.
- The migration runner retains and reports the applied history of a module that
  is not installed, and plans and applies Core's and present modules' migrations
  around it. Core's history and every installed module's history stay strict: a
  missing or changed applied migration, or a downgrade, still fails. No down
  migrations, no data deletion.
- deploy/Dockerfile copies no module and installs nothing for the module
  template; .dockerignore excludes modules/ and the build refuses a context that
  contains it; the runtime has an empty root-owned /var/lib/ekklesia/modules and
  sets EKKLESIA_MODULES_DIR.
- deploy/compose.yaml bind-mounts EKKLESIA_MODULES_HOST_DIR read-only into api
  and migrate, with create_host_path disabled.
- scripts/check-image.sh proves the image holds no modules and that a populated
  modules/ directory leaves its runtime file system identical; CI runs it.
- The split deploy smoke now uses the same generic image: it boots with no
  modules, installs a self-contained test module packaged from the template
  (test-only), migrates and restarts, removes it (Core runs, planning reports the
  retained history, data survives), and restores it with its data.

Docs: deploy README (module storage, install/update/remove/restore, the runtime
modules target), .env.example, API configuration, module development guide,
testing, workflow, security, and the architecture overview.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
ekklesia
2026-10-07 08:38:14 -05:00
co-authored by Claude Opus 5.5
parent 4d28a82937
commit e36b70d8d4
50 changed files with 2275 additions and 224 deletions
+1
View File
@@ -60,6 +60,7 @@ No real values are shown here — configure per environment and never commit the
| `EKKLESIA_DATABASE_CA` | Optional. PEM text of a private certificate authority to trust in addition to the system store, for a database using an internal CA. |
| `EKKLESIA_API_HOST` | Optional. Listen address. Defaults to loopback (`127.0.0.1`). An explicit non-loopback address is permitted for future deployment. |
| `EKKLESIA_API_PORT` | Optional. Listen port, `0`–`65535` (`0` requests an OS-assigned port). Defaults to `3000`. |
| `EKKLESIA_MODULES_DIR` | Optional. The installed-modules directory, as an absolute path ([ADR 0048](../architecture/decisions/0048-generic-image-and-installed-module-storage.md)). Unset means the checkout's own `modules/`, where a missing directory is simply zero modules. Set, the directory must exist: a relative, blank, or unreadable value, or a missing directory, stops startup before any database connection with a sanitized `MODULES_DIRECTORY_INVALID` or `MODULES_DIRECTORY_UNREADABLE` that never contains the value. The production image sets `/var/lib/ekklesia/modules`. Every operator command and Doctor read the same value. |
| `EKKLESIA_IDENTITY_SECRET` | At least 32 characters, high entropy (for example `openssl rand -base64 32`). Same requirement as Phase 4A. |
| `EKKLESIA_IDENTITY_BASE_URL` | Exactly one canonical `http`/`https` origin — scheme, host, and non-default port only, with no path, trailing slash, query, fragment, credentials, or explicit default port. `https` is required in production; loopback and unspecified hosts are rejected in production. |
| `EKKLESIA_IDENTITY_TRUSTED_ORIGINS` | Comma-separated list of canonical origins, validated identically to the base URL (no wildcards). Used for both Better Auth's own CSRF/origin checks and the API's CORS policy — there is only one trusted-origin list. The base URL's own origin is always trusted as well and need not be listed ([ADR 0045](../architecture/decisions/0045-split-dashboard-and-api-origins.md)). |
+5 -3
View File
@@ -173,8 +173,9 @@ integration layer depends on authorization).
exact same installed-module inventory every time it starts, in a fixed order, before anything
else:
1. Resolve module-discovery configuration (the repository's `modules/` directory, and the
platform/Module SDK versions from their own `package.json` files).
1. Resolve module-discovery configuration: the installed-modules directory, chosen by Core's one
resolver from `EKKLESIA_MODULES_DIR` (the repository's own `modules/` when it is unset, ADR
0048), and the platform/Module SDK versions from the platform's own `package.json` files.
2. Discover installed modules exactly once (`discoverModules`).
3. Compose the platform permission registry from that exact discovered list
(`composePlatformPermissionRegistry`).
@@ -199,7 +200,8 @@ never falls back to `CORE_PERMISSION_REGISTRY` or any Core-only registry. The re
resolved from this compiled/source file's own location (two directories up from
`apps/api/dist/*.js` or `apps/api/src/*.ts`, which sit at identical depth), never `process.cwd()`
— a package-scoped test run has `apps/api` as its working directory, which would otherwise point
discovery at the wrong `modules/` directory.
discovery at the wrong `modules/` directory. A configured `EKKLESIA_MODULES_DIR` that is not an
absolute path to an existing directory fails at step 1, before anything else exists.
The resulting registry is a **static, immutable snapshot** taken once at startup. There is no hot
installation, removal, or dynamic mutation of installed modules or their permissions while the
+10 -5
View File
@@ -35,8 +35,10 @@ The lint, typecheck, and test commands run both repository-root checks and every
package-level Turborepo task. New packages add their own `lint`, `typecheck`, and `test` scripts;
the root verification command discovers them automatically.
`pnpm modules:validate` builds the workspace, then scans `modules/*` without a central registry. A
missing or empty modules directory is valid and reports zero installed modules. Installed modules
`pnpm modules:validate` builds the workspace, then scans the installed-modules directory without a
central registry: the checkout's own `modules/`, or `EKKLESIA_MODULES_DIR` when it is set (ADR
0048). A missing or empty `modules/` is valid and reports zero installed modules; a configured
directory must exist. Installed modules
must produce the compiled JavaScript entrypoints declared by their static manifests. Each module's
declared browser contributions are listed with their target capability and range, and marked
according to whether a compatible provider is installed — a contribution nobody provides is otherwise
@@ -113,9 +115,12 @@ identity secret, not a module secret value.
Every check has a stable ID and one of four states: pass, warning, failure, or skipped. Independent
checks continue after a failure; a failed prerequisite causes only its dependent checks to be
skipped. Pending or approval-blocked migrations are warnings and are never applied. Invalid
history, checksum disagreement, unknown applied migrations, or a failed configured connection are
failures. If `DATABASE_URL` is missing, PostgreSQL is skipped and all static checks still run.
skipped. Pending or approval-blocked migrations are warnings and are never applied. Applied
history that belongs to a module which is not installed is reported as `retained` and is neither a
warning nor a failure (ADR 0048). Invalid history, checksum disagreement, an applied migration that
an installed module or Core no longer contains, or a failed configured connection are failures. An
unusable configured `EKKLESIA_MODULES_DIR` fails module discovery. If `DATABASE_URL` is missing,
PostgreSQL is skipped and all static checks still run.
`READY` means every check passed. `READY WITH WARNINGS` means no check failed but at least one check
warned or was skipped. Both results exit zero. `NOT READY` means at least one check failed and exits
+9
View File
@@ -472,6 +472,15 @@ CI builds and smoke-tests end to end on every change against disposable services
publishes, or runs it against a real installation. No `.env` has ever been committed; only
`.env.example`, which the image build context excludes.
The image contains no modules
([ADR 0048](../architecture/decisions/0048-generic-image-and-installed-module-storage.md)).
Installed modules come from installation-owned module storage, which the reference deployment mounts
**read-only** into `api` and `migrate`, so the Ekklesia process cannot modify installed code. That
is an integrity property, not a sandbox: an installed module is still trusted code with the API's
database role, exactly as above. The repository's boundary checks prove only source modules in a
workspace, and an installed package usually ships none, so installing one means trusting that
module's own CI and whoever placed it in module storage.
Two production defaults are relevant but are **not** by themselves a deployment guarantee:
- `apps/api` defaults its listen address to loopback (`127.0.0.1`), so it is not reachable off-host
+37
View File
@@ -171,6 +171,16 @@ whole-batch DDL rollback, Drizzle breakpoint execution, real advisory-lock concu
timeouts, lock release after failure, exact destructive approvals, and public-error sanitization.
It does not make migrations automatic during Core startup.
It also proves ADR 0048's retained history for an uninstalled module end to end, with real
discovery: Core's committed migrations (the newest one withheld at first) and the module template
installed in a modules directory outside the repository are applied, the module writes a row, and
its directory is removed. Planning then reports the module's history as retained, and the withheld
Core migration applies with the module absent. The module's table and row survive. Reinstalled, the
module's history validates strictly again with nothing pending, its row is intact, and a reinstalled
package whose applied migration changed is refused. A history row for an absent module is retained
and reported, never fatal, while an unknown row belonging to an installed module is still rejected
without repair.
A dedicated set of scenarios exercises the committed Core identity migration against real
PostgreSQL: read-only planning on a fresh database, applying it and confirming exactly the four
expected `ekklesia_core_*` tables and their indexes exist, a genuine no-op on a second apply, Core
@@ -554,6 +564,33 @@ the following pull-request status context to `main` branch protection:
CI / migration-smoke (pull_request)
```
## The generic platform image and the reference deployment
These need Docker and build images locally; nothing is pushed or published.
- `bash scripts/check-image.sh` builds the production image and proves it is the one generic
platform artifact ADR 0048 requires. It checks that the image has:
- no `/opt/ekklesia/modules`;
- no module template;
- no `ekklesia.module.json` outside installed dependencies;
- an empty, root-owned `/var/lib/ekklesia/modules`;
- `EKKLESIA_MODULES_DIR` set, in the image configuration and in a running process.
It then builds again from the same checkout with a populated `modules/` directory, and requires
the same checks and an identical runtime file system. CI's `image-build` job runs it.
- `pnpm deploy:smoke` (`scripts/smoke-deploy.sh`) runs the reference deployment end to end on the
generic image with empty module storage. With `EKKLESIA_SMOKE_TOPOLOGY=split`, the same image also
runs the installed-module lifecycle:
- the module template, packaged as a self-contained test module
(`scripts/lib/test-module-package.Dockerfile`, test-only), is installed into module storage;
- its migration is planned and explicitly applied, and the API restarted;
- a real browser mounts it;
- it is removed and the API restarted, and Core still runs, migrations still plan and apply, and
its data survives;
- it is restored, with its data.
See [deploy/README.md](../../deploy/README.md#testing-this-deployment).
## Safe troubleshooting
Confirm the prerequisites without printing configuration: