25 Commits
Author SHA1 Message Date
The_River 42acdeb9e8 Merge pull request 'feat: transition module packages and template to database ABI v1' (#72) from feat/module-package-abi-v1-transition into main
CI / verify (push) Successful in 59s
CI / infrastructure-smoke (push) Successful in 36s
CI / migration-smoke (push) Successful in 1m53s
CI / image-build (push) Successful in 12s
CI / deploy-smoke (same-origin) (push) Successful in 49s
CI / deploy-smoke (split) (push) Successful in 1m21s
Reviewed-on: https://git.riverofglorymn.org/River_of_Glory_Church/Ekklesia/pulls/72
Reviewed-by: The_River <[email protected]>
2026-10-07 23:14:49 +00:00
ekklesia 70854d234c feat: transition module packages to database ABI v1
CI / verify (pull_request) Successful in 1m1s
CI / infrastructure-smoke (pull_request) Successful in 36s
CI / migration-smoke (pull_request) Successful in 1m54s
CI / image-build (pull_request) Successful in 31s
CI / deploy-smoke (same-origin) (pull_request) Successful in 52s
CI / deploy-smoke (split) (pull_request) Successful in 1m32s
2026-10-07 18:00:04 -05:00
The_River 20ad1ae208 Merge pull request 'feat: deliver module database ABI through manifest v8' (#71) from feat/manifest-v8-database-abi into main
CI / verify (push) Successful in 1m4s
CI / infrastructure-smoke (push) Successful in 38s
CI / migration-smoke (push) Successful in 1m47s
CI / image-build (push) Successful in 12s
CI / deploy-smoke (same-origin) (push) Successful in 49s
CI / deploy-smoke (split) (push) Successful in 1m24s
Reviewed-on: https://git.riverofglorymn.org/River_of_Glory_Church/Ekklesia/pulls/71
Reviewed-by: The_River <[email protected]>
2026-10-07 22:45:52 +00:00
ekklesia 49b4d4165d feat: deliver module database ABI by manifest
CI / verify (pull_request) Successful in 1m0s
CI / infrastructure-smoke (pull_request) Successful in 36s
CI / migration-smoke (pull_request) Successful in 1m53s
CI / image-build (pull_request) Successful in 23s
CI / deploy-smoke (same-origin) (pull_request) Successful in 50s
CI / deploy-smoke (split) (pull_request) Successful in 1m30s
2026-10-07 17:29:32 -05:00
The_River a1cdcb3ad1 Merge pull request 'feat: add module database ABI v1 adapters' (#70) from feat/module-database-abi-adapters into main
CI / verify (push) Successful in 1m4s
CI / infrastructure-smoke (push) Successful in 37s
CI / migration-smoke (push) Successful in 1m49s
CI / image-build (push) Successful in 11s
CI / deploy-smoke (same-origin) (push) Successful in 43s
CI / deploy-smoke (split) (push) Successful in 1m23s
Reviewed-on: https://git.riverofglorymn.org/River_of_Glory_Church/Ekklesia/pulls/70
Reviewed-by: The_River <[email protected]>
2026-10-07 22:00:06 +00:00
ekklesia 859675c3c7 feat: add module database ABI v1 adapters
CI / verify (pull_request) Successful in 58s
CI / infrastructure-smoke (pull_request) Successful in 35s
CI / migration-smoke (pull_request) Successful in 1m53s
CI / image-build (pull_request) Successful in 29s
CI / deploy-smoke (same-origin) (pull_request) Successful in 49s
CI / deploy-smoke (split) (pull_request) Successful in 1m31s
2026-10-07 16:44:28 -05:00
The_River 4a3958c616 Merge pull request 'feat: Core's Module Database ABI v1 executor, proven against PostgreSQL (ADR 0050)' (#69) from feat/module-database-abi-executor into main
CI / verify (push) Successful in 56s
CI / infrastructure-smoke (push) Successful in 35s
CI / migration-smoke (push) Successful in 1m48s
CI / image-build (push) Successful in 12s
CI / deploy-smoke (same-origin) (push) Successful in 41s
CI / deploy-smoke (split) (push) Successful in 1m22s
Reviewed-on: https://git.riverofglorymn.org/River_of_Glory_Church/Ekklesia/pulls/69
Reviewed-by: The_River <[email protected]>
2026-10-07 21:25:15 +00:00
ekklesiaandClaude Opus 5.5 317a4ce410 fix: ADR 0050 callback-error precedence, and the honest limits of transaction control
CI / verify (pull_request) Successful in 1m4s
CI / infrastructure-smoke (pull_request) Successful in 38s
CI / migration-smoke (pull_request) Successful in 1m54s
CI / image-build (pull_request) Successful in 22s
CI / deploy-smoke (same-origin) (pull_request) Successful in 51s
CI / deploy-smoke (split) (pull_request) Successful in 1m37s
Narrow contract and implementation adjustments from review. No SDK, manifest,
packaging, or Signage work.

Callback-error precedence:
- A transaction or savepoint callback that rejects keeps its own error, even
  while sibling work it started is still settling (one failed half of a
  Promise.all). Core drains that work, rolls back, and never commits or
  releases.
- A callback that resolves with work outstanding (a forgotten await) still
  fails with MODULE_DATABASE_SCOPE_WORK_OUTSTANDING and rolls back.

Transaction-control SQL:
- ADR 0050 forbids it through query() as a conformance rule for trusted code,
  not a security guarantee. Core does not parse SQL, so PostgreSQL has run
  the statement before Core sees it. Core detects the violation from the
  command tag or the transaction status, and claims nothing it cannot do: a
  module's own COMMIT has committed.
- After a violation, the transaction's scopes refuse every later statement
  without sending it (it would otherwise autocommit). Core never commits the
  transaction itself, and the connection is destroyed rather than reused,
  for top-level violations too.
- The commit and rollback guarantees apply to conforming use.

Duplicate object-mode columns: documented as found only after execution, so a
mutation can have taken effect; atomicity needs an ABI transaction.

Tests prove each of these against PostgreSQL: Promise.all failures in a
transaction and a savepoint keep 23505; a forgotten await still rolls back; a
module COMMIT's prior work is committed while nothing after it runs; a backend
PID changes after every violation; duplicate RETURNING columns persist at the
top level and roll back in a transaction.

ADR 0050 stays Accepted: the executor conforms to the revised text.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-07 16:12:01 -05:00
ekklesiaandClaude Opus 5.5 6db50516b7 feat: Core's Module Database ABI v1 executor, proven against PostgreSQL (ADR 0050)
CI / verify (pull_request) Successful in 59s
CI / infrastructure-smoke (pull_request) Successful in 27s
CI / migration-smoke (pull_request) Successful in 1m53s
CI / image-build (pull_request) Successful in 23s
CI / deploy-smoke (same-origin) (pull_request) Successful in 41s
CI / deploy-smoke (split) (pull_request) Successful in 1m29s
Implements the first ADR 0050 slice: Core's ORM-neutral executor and its
PostgreSQL evidence. Nothing is wired to modules yet; manifest v8, SDK
adapters, schema metadata, packaging, and Signage are later slices. The
legacy ModuleDatabase facade is unchanged.

Contracts (@ekklesia/contracts, no imports at all):
- ABI version 1; query { text, values, rowMode } returning
  { command, rowCount, rows, fields: [{ name, typeOid }] };
- executor, transaction, and savepoint types with exactly the ABI's
  members; parameter and result value types;
- ModuleDatabaseError { code, sqlState? }: eight stable codes, and a
  SQLSTATE only when PostgreSQL supplied one; static messages, no cause,
  frozen; isModuleDatabaseError recognises it by shape.

Core (createModuleDatabaseExecutor over the API's existing pool):
- one statement per query, enforced by forcing the extended query
  protocol so PostgreSQL itself refuses a second statement (42601);
  no SQL parsing;
- parameters: null, boolean, number, bigint, string, Uint8Array, and
  rectangular arrays of them; everything else refused before sending;
- a Core-owned decoding table that never consults pg's global parser
  registry: int8, numeric, temporal, and JSON as exact text; bytea as a
  plain Uint8Array; built-in arrays element by element;
- every operation pins TimeZone, DateStyle, IntervalStyle, bytea_output,
  and extra_float_digits, then resets with DISCARD ALL before returning
  the connection, or destroys it when it cannot be reset cleanly;
- callback-scoped transactions with explicit isolation and access mode;
  a COMMIT that PostgreSQL answers with ROLLBACK is reported as failed;
- savepoints named by Core; only the innermost scope is active; scopes
  are revoked when their callback settles;
- a callback that settles with work outstanding is rolled back, never
  committed or released;
- transaction-control statements sent through query are refused by
  command tag and transaction status, and fail the transaction;
- the module ID is carried for Core's attribution only.

Tests:
- 83 integration tests on a database with hostile defaults: queries,
  the one-statement rule, parameters, decoding, errors, transactions,
  savepoints, forgotten awaits, pool safety after every failure class,
  and session state;
- unit tests for encoding, decoding, options, sanitization, and scope
  bookkeeping;
- type-level and source checks that no ORM or driver object crosses
  the boundary.

ADR 0050 is Accepted.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-07 16:01:46 -05:00
The_River d43031997b Merge pull request 'docs: propose ADR 0050, Module Database ABI v1' (#68) from docs/adr-0050-module-database-abi into main
CI / verify (push) Successful in 59s
CI / infrastructure-smoke (push) Successful in 36s
CI / migration-smoke (push) Successful in 1m57s
CI / image-build (push) Successful in 11s
CI / deploy-smoke (same-origin) (push) Successful in 50s
CI / deploy-smoke (split) (push) Successful in 1m22s
Reviewed-on: https://git.riverofglorymn.org/River_of_Glory_Church/Ekklesia/pulls/68
Reviewed-by: The_River <[email protected]>
2026-10-07 20:33:49 +00:00
ekklesiaandClaude Opus 5.5 11e2495484 docs: ADR 0050, tighten transaction scope, single-statement, and error rules
CI / verify (pull_request) Successful in 56s
CI / infrastructure-smoke (pull_request) Successful in 36s
CI / migration-smoke (pull_request) Successful in 1m52s
CI / image-build (pull_request) Successful in 22s
CI / deploy-smoke (same-origin) (pull_request) Successful in 49s
CI / deploy-smoke (split) (pull_request) Successful in 1m29s
Three narrow clarifications to the proposed contract. No other decision
changes.

- Outstanding work: module code awaits every operation before its
  transaction or savepoint callback returns. A callback that settles
  with work outstanding is a scope-usage error and never commits. Core
  may let started operations settle before rolling back and releasing
  the connection, but waiting never turns into a commit. Executors stay
  revoked once their scope has finished.
- One statement per query() call: a correctness and API rule, not a
  security boundary. It is upheld by a reliable driver or protocol
  mechanism, never a home-grown SQL parser. An implementation that
  cannot uphold it stops and reports instead of weakening the ADR.
- Errors: `code` is a stable Ekklesia-owned category and always present.
  `sqlState` is optional, present only when PostgreSQL supplied one.
  ABI-detected failures (closed scope, invalid parameter, invalid
  option) carry a code and no SQLSTATE.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-07 15:27:29 -05:00
ekklesiaandClaude Opus 5.5 759872834d docs: propose ADR 0050, Module Database ABI v1
CI / verify (pull_request) Successful in 57s
CI / infrastructure-smoke (pull_request) Successful in 35s
CI / migration-smoke (pull_request) Successful in 1m50s
CI / image-build (pull_request) Successful in 22s
CI / deploy-smoke (same-origin) (pull_request) Successful in 49s
CI / deploy-smoke (split) (pull_request) Successful in 1m31s
Core and modules exchange PostgreSQL SQL text, bound parameters, result
rows with field metadata, and Core-owned transaction scopes. Never ORM
objects. A module may use Drizzle, Kysely, raw SQL, or another
module-side builder. Core's own ORM becomes an implementation detail.

ADR 0050 decides:
- an explicit correctness/compatibility boundary, not a security
  sandbox: no claim for SQL parsing, execution-time prefix checks,
  SET ROLE, or PostgreSQL roles;
- opt-in through manifest version 8 `database.abi: 1`, a hard cutover
  like every manifest version; a version 8 module without `database`
  keeps the legacy ModuleDatabase facade during the deprecation window;
- the executor: query({ text, values, rowMode }) returning
  { command, rowCount, rows, fields }, one executor per module;
- a small parameter set (no Date, no implicit JSON, no identifiers);
- a Core-owned decoding table that loses no precision and does not
  depend on time zones (int8, numeric, temporal, and JSON as text;
  bytea as Uint8Array);
- a sanitized ModuleDatabaseError { code, sqlState? }, recognised
  structurally;
- callback-scoped transactions and Core-named savepoints, revoked when
  their callback settles;
- module-side adapters (Drizzle via pg-proxy, raw SQL, later Kysely);
- plain schema metadata for ABI v1 modules instead of Drizzle objects;
- migrations unchanged; packageFormat stays 1; ABI v1 packages omit
  transitional.databaseRuntime.

It partly supersedes ADR 0016 and amends ADR 0007 for ABI v1 modules,
and extends ADR 0049. Status Proposed. Accepted ADRs are unchanged. No
runtime, SDK, manifest, packaging, migration, or Signage changes.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-07 15:18:59 -05:00
The_River 9ddb5e2796 Merge pull request 'chore: license Ekklesia under AGPL-3.0-or-later' (#67) from chore/agpl-license into main
CI / verify (push) Successful in 56s
CI / infrastructure-smoke (push) Successful in 35s
CI / migration-smoke (push) Successful in 1m51s
CI / image-build (push) Successful in 12s
CI / deploy-smoke (same-origin) (push) Successful in 42s
CI / deploy-smoke (split) (push) Successful in 1m21s
Reviewed-on: https://git.riverofglorymn.org/River_of_Glory_Church/Ekklesia/pulls/67
Reviewed-by: The_River <[email protected]>
2026-10-07 19:13:05 +00:00
ekklesiaandClaude Opus 5.5 52a665502f chore: license Ekklesia under AGPL-3.0-or-later instead of AGPL-3.0-only
CI / verify (pull_request) Successful in 57s
CI / infrastructure-smoke (pull_request) Successful in 35s
CI / migration-smoke (pull_request) Successful in 1m52s
CI / image-build (pull_request) Successful in 29s
CI / deploy-smoke (same-origin) (pull_request) Successful in 50s
CI / deploy-smoke (split) (pull_request) Successful in 1m30s
The project decision is the GNU Affero General Public License, version 3 or
(at your option) any later version.

- "license": "AGPL-3.0-or-later" in all 14 Ekklesia-owned package.json files.
- README: the License section states AGPL v3 or any later version (SPDX
  AGPL-3.0-or-later) and carries the FSF's standard grant: "either version 3 of
  the License, or (at your option) any later version", with its warranty
  disclaimer and pointer to the license.
- Module template README and module development guide: AGPL-3.0-or-later for
  Ekklesia and official modules.

LICENSE is unchanged: it remains the canonical AGPLv3 text.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-07 13:53:56 -05:00
ekklesiaandClaude Opus 5.5 9a328f647e chore: license Ekklesia under AGPL-3.0-only
CI / verify (pull_request) Successful in 1m2s
CI / infrastructure-smoke (pull_request) Successful in 36s
CI / migration-smoke (pull_request) Successful in 1m52s
CI / image-build (pull_request) Successful in 30s
CI / deploy-smoke (same-origin) (pull_request) Successful in 50s
CI / deploy-smoke (split) (pull_request) Successful in 1m39s
Ekklesia is open source under the GNU Affero General Public License, version 3
only. Module Package v1 (#66) bundles Ekklesia's SDK and contracts into
distributable module packages, so the platform needs an explicit license
before such artifacts are distributed.

- LICENSE: the GNU AGPL v3 text, verbatim from
  https://www.gnu.org/licenses/agpl-3.0.txt
  (sha256 0d96a4ff68ad6d4b6f1f30f713b18d5184912ba8dd389f86aa7710db079abcb0).
- "license": "AGPL-3.0-only" in the root package.json and every Ekklesia
  workspace package (13 packages), the module template included. Nothing else in
  any manifest changed.
- README: a License section replacing the "no license selected yet" placeholder:
  AGPL v3 only, source available under its terms, section 13's network-use
  obligation, and the policy that official Ekklesia modules use AGPL-3.0-only.
- Module template README and module development guide: the template declares
  the license; Module Package v1 does not yet record a module's own license,
  which needs a format change under ADR 0049.

AGPL-3.0-only, not -or-later: the decision is AGPL-3.0, and nothing in the
repository grants later versions. No runtime, ADR, package-format, Signage, or
instance change. The packaged template is byte-identical before and after.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-07 13:38:33 -05:00
The_River 22caa1dceb Merge pull request 'feat: Ekklesia Module Package v1, the canonical packager and verifier (ADR 0049)' (#66) from feat/module-package-v1 into main
CI / verify (push) Successful in 57s
CI / infrastructure-smoke (push) Successful in 26s
CI / migration-smoke (push) Successful in 1m50s
CI / image-build (push) Successful in 11s
CI / deploy-smoke (same-origin) (push) Successful in 42s
CI / deploy-smoke (split) (push) Successful in 1m26s
Reviewed-on: https://git.riverofglorymn.org/River_of_Glory_Church/Ekklesia/pulls/66
Reviewed-by: The_River <[email protected]>
2026-10-07 18:33:28 +00:00
ekklesiaandClaude Opus 5.5 27d85e0162 fix(smoke): hand the verifier the package's archive and digest as one readable release pair
CI / verify (pull_request) Successful in 58s
CI / infrastructure-smoke (pull_request) Successful in 36s
CI / migration-smoke (pull_request) Successful in 1m51s
CI / image-build (pull_request) Successful in 22s
CI / deploy-smoke (same-origin) (pull_request) Successful in 42s
CI / deploy-smoke (split) (pull_request) Successful in 1m31s
The split deploy smoke failed in CI with "The SHA-256 file could not be read."
BuildKit's local exporter creates its output directory (`--output
type=local,dest=$work/module-package`) with mode 0700, owned by whoever ran the
build. That directory was mounted into the verifier, which runs as the image's
unprivileged `node` user (uid 1000). In CI the build runs as root, so the
directory was root:root 0700 and the verifier could read neither file; the
digest file is simply read first. On a workstation the build runs as uid 1000,
the same uid as `node`, so it was readable by coincidence.

scripts/lib/module-package-release.sh turns one build's output into the release
pair: it requires exactly one archive and its .sha256 and nothing else, checks
that the digest line names that archive, then creates a fresh directory that
must not exist yet (0755) and copies both files into it (0644). The smoke stages
and mounts only that directory; BuildKit's output directory is never mounted.
Archive-digest verification stays mandatory: the verifier still reads the
.sha256 file. Nothing in the verifier changed.

Tests: the helper is driven in bash against a 0700 build output, and refuses a
missing, malformed, or mismatched digest, stray output, a second archive, and
an existing release directory, creating nothing. A static test pins the smoke
to the release pair and the verifier's single mount with --sha256-file.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-07 13:10:58 -05:00
ekklesiaandClaude Opus 5.5 35ef31df9f feat: Ekklesia Module Package v1, the canonical packager and verifier (ADR 0049)
CI / verify (pull_request) Successful in 57s
CI / infrastructure-smoke (pull_request) Successful in 35s
CI / migration-smoke (pull_request) Successful in 1m42s
CI / image-build (pull_request) Successful in 29s
CI / deploy-smoke (same-origin) (pull_request) Successful in 51s
CI / deploy-smoke (split) (pull_request) Failing after 54s
A separately versioned module is now distributed as one deterministic
.tar.gz and its SHA-256, produced by an Ekklesia-owned packager and checked by
Core before anything is installed. This slice implements the portable `any`
target, with the module template as the end-to-end proof. ADR 0049 is
accepted.

@ekklesia/contracts: the Module Package v1 contract. The strict
ekklesia.package.json schema (packageFormat 1, identity, target, runtime,
provenance, incorporated Ekklesia packages, strict transitional facts, an
exact byte-wise-sorted file list with size and SHA-256), target names, and the
generated runtime package.json.

@ekklesia/module-pack (new, tooling): `ekklesia-pack-module --module <dir>
--target any --out <dir> --source-revision <commit>`.
- Bundles server, schema, and jobs with esbuild, SDK, contracts, and Drizzle
  included; only Node built-ins remain imports. Any warning, unresolved
  import, external dependency, or native or platform-specific package fails.
- Copies browser artifacts as built, after proving them current, and the
  migrations; generates package.json and ekklesia.package.json; never ships
  src, tests, declarations, source maps, or workspace ranges.
- THIRD_PARTY_NOTICES: every third-party package traced from the bundles'
  own inputs, credited once with its declared licence and every licence,
  copying, copyright, and notice file it ships. A missing licence fails; a
  missing text fails unless its standard text is complete without copyright
  lines (Apache-2.0, vendored, needed by drizzle-orm).
- Deterministic ustar and gzip: sorted paths, uid/gid 0, empty names, mtime
  0, 0755/0644, no links. Byte-identical across runs and machines.
- Writes only the archive and its .sha256; never deploys, links, or installs,
  so the workspace's install state is untouched.

Core: verifyModulePackage and `verify-module-package.js <archive>
[--sha256 | --sha256-file] [--extract-to <new dir>]`. Reads the archive in
memory and refuses links, special entries, setuid/setgid/sticky modes, unsafe
or escaping paths, and a second top-level directory; checks metadata, every
file, identity, target, Node.js range, SDK, and the transitional Drizzle
agreement; runs the platform's module and migration discovery; loads the
backend once in a forked process with an empty environment; only then
extracts, with normalized modes, into a new directory. Errors are sanitized.

@ekklesia/module-ui-build: analyzeModuleUi reports each artifact's inputs
and whether the built file is current. The nested stylesheet build now works
from the module directory, so artifacts no longer depend on the caller's
working directory.

The split deploy smoke builds the package with the canonical packager
(scripts/lib/build-module-package.Dockerfile replaces the test-only pnpm
deploy packaging), verifies it with the same generic image, installs the
verified tree, removes it, and reinstalls the exact same archive.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-07 12:44:54 -05:00
The_River 45d6506c10 Merge pull request 'docs: propose ADR 0049, Ekklesia Module Package v1' (#65) from docs/adr-0049-module-package into main
CI / verify (push) Successful in 55s
CI / infrastructure-smoke (push) Successful in 36s
CI / migration-smoke (push) Successful in 1m52s
CI / image-build (push) Successful in 11s
CI / deploy-smoke (same-origin) (push) Successful in 41s
CI / deploy-smoke (split) (push) Successful in 1m26s
Reviewed-on: https://git.riverofglorymn.org/River_of_Glory_Church/Ekklesia/pulls/65
Reviewed-by: The_River <[email protected]>
2026-10-07 15:21:30 +00:00
ekklesiaandClaude Opus 5.5 1119cc5fdf docs: propose ADR 0049, Ekklesia Module Package v1
CI / verify (pull_request) Successful in 52s
CI / infrastructure-smoke (pull_request) Successful in 27s
CI / migration-smoke (pull_request) Successful in 1m50s
CI / image-build (pull_request) Successful in 22s
CI / deploy-smoke (same-origin) (pull_request) Successful in 49s
CI / deploy-smoke (split) (pull_request) Successful in 1m41s
A separately versioned module is distributed as one immutable, self-contained,
deterministic .tar.gz with a separately distributed SHA-256. The package
contract is independent of hosting, registry, transport, and marketplace.

ADR 0049 extends ADR 0048 with:
- the package layout: one top-level module directory with a generated runtime
  package.json, the unchanged manifest, ekklesia.package.json, bundled backend
  entrypoints, built browser artifacts, migrations, THIRD_PARTY_NOTICES, and
  node_modules only for native dependencies;
- backend bundling of all pure-JavaScript dependencies, with externals decided
  and validated by the canonical packager, never by module authors;
- targets: `any`, or <platform>-<arch>[-<libc>] (linux-x64-glibc for Signage);
- ekklesia.package.json (packageFormat 1): id, version, target, runtime needs,
  provenance, incorporated SDK versions, a `transitional` Drizzle requirement
  explicitly not the Module Database ABI, and per-file size and SHA-256;
- archive safety: no links of any kind, no absolute or `..` paths, no special
  files or setuid/setgid; extraction normalizes ownership and modes;
- the intended installation lifecycle and its rollback limit: code-only
  rollback only before new migrations apply, a backup restore after;
- integrity, not authenticity; signing and trust roots deferred;
- pnpm deploy explicitly not the packaging mechanism.

Status Proposed, following the repository's usual lifecycle. ADR 0047 gains a
cross-reference to consume canonical packages. The module development guide no
longer recommends running pnpm deploy in a workspace, which corrupts that
workspace's install state. No runtime, tooling, Docker, CI, Signage, or
instance changes.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-07 10:13:34 -05:00
The_River fbf8f6edac Merge pull request 'feat: one generic image, with installed modules in installation-owned storage (ADR 0048)' (#64) from feat/installed-module-storage into main
CI / verify (push) Successful in 53s
CI / infrastructure-smoke (push) Successful in 36s
CI / migration-smoke (push) Successful in 1m52s
CI / image-build (push) Successful in 11s
CI / deploy-smoke (same-origin) (push) Successful in 43s
CI / deploy-smoke (split) (push) Successful in 1m17s
Reviewed-on: https://git.riverofglorymn.org/River_of_Glory_Church/Ekklesia/pulls/64
Reviewed-by: The_River <[email protected]>
2026-10-07 14:15:31 +00:00
ekklesiaandClaude Opus 5.5 f122e910fc test: generate the external module fixture instead of copying ignored build output
CI / verify (pull_request) Successful in 52s
CI / infrastructure-smoke (pull_request) Successful in 35s
CI / migration-smoke (pull_request) Successful in 1m48s
CI / image-build (pull_request) Successful in 28s
CI / deploy-smoke (same-origin) (pull_request) Successful in 41s
CI / deploy-smoke (split) (pull_request) Successful in 1m37s
The test proving that migrations of a module installed outside the repository
are discovered, while Core's come from the platform, copied templates/module
including dist/. dist/ is gitignored and never committed. CI runs `pnpm test`
before any build of the template, where only the template's own UI test may
have written dist/ui, dist/display, and dist/contributions, so discovery
failed with MODULE_ENTRYPOINT_MISSING for ./dist/server/index.js and
./dist/schema/index.js (and, had the UI test not run yet, the copy itself
would have failed). Locally a previous `pnpm build` had left dist/ complete.

The test now generates a minimal valid installed module in a temporary
directory with createMigrationModule, moved unchanged from the migration
discovery test into core/tests/support so both share it. It depends on no
build output, ignored file, or node_modules. It also asserts more precisely:
the module directory is outside the repository, Core's migrations match the
platform's own core/drizzle inventory by identity and checksum, and the
module's migration matches the SQL that was installed.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-07 09:07:59 -05:00
ekklesiaandClaude Opus 5.5 e36b70d8d4 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]>
2026-10-07 08:38:14 -05:00
The_River 4d28a82937 Merge pull request 'docs: accept ADR 0048, one generic image with installed modules outside it' (#63) from docs/adr-0048-installed-modules into main
CI / verify (push) Successful in 50s
CI / infrastructure-smoke (push) Successful in 35s
CI / migration-smoke (push) Successful in 1m49s
CI / image-build (push) Successful in 8s
CI / deploy-smoke (same-origin) (push) Successful in 47s
CI / deploy-smoke (split) (push) Successful in 44s
Reviewed-on: https://git.riverofglorymn.org/River_of_Glory_Church/Ekklesia/pulls/63
Reviewed-by: The_River <[email protected]>
2026-10-07 13:04:24 +00:00
ekklesiaandClaude Opus 5.5 cefdde36a2 docs: accept ADR 0048, one generic image with installed modules outside it
CI / verify (pull_request) Successful in 52s
CI / infrastructure-smoke (pull_request) Successful in 35s
CI / migration-smoke (pull_request) Successful in 1m49s
CI / image-build (pull_request) Successful in 21s
CI / deploy-smoke (same-origin) (pull_request) Successful in 41s
CI / deploy-smoke (split) (pull_request) Successful in 1m3s
Ekklesia produces one platform image with zero feature modules. Installed
modules live in installation-owned storage selected by EKKLESIA_MODULES_DIR
(/var/lib/ekklesia/modules in the image, <repo>/modules in development),
mounted read-only and discovered only at a controlled restart. An installed
module directory is self-contained; the platform lends it no dependencies.

Uninstall is distinct from disable and purge: removing module code keeps its
data and stored state, and applied migration history of an absent module is
retained and reported instead of blocking planning. Present modules and Core
stay strictly validated.

Supersedes in part ADR 0044 (composition from pinned sources) and ADR 0009
(rejection of history absent from the inventory); amends ADR 0006; extends
ADR 0028 and ADR 0030. ADR 0047 gains a cross-reference saying it must be
revised before acceptance, and deploy/README.md notes that its composition
model is being replaced. No code, Dockerfile, Compose, CI, or test changes.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-07 07:45:21 -05:00
170 changed files with 15226 additions and 673 deletions
+5
View File
@@ -2,6 +2,11 @@
# `pnpm install --frozen-lockfile` and builds its own outputs, so nothing installed or built on the
# host may enter the context, and no secret or environment file may enter any layer.
# Installed modules never enter the platform image (ADR 0048): an installation keeps them in its own
# module storage, mounted at run time. Excluding the checkout's `modules/` is what makes a build
# identical whether or not modules happen to be present in the source tree.
/modules
# Host-installed dependencies and build outputs: the image produces its own.
**/node_modules
**/dist
+6
View File
@@ -10,6 +10,12 @@
# The plaintext loopback DATABASE_URL below is therefore only usable with this set.
NODE_ENV=development
# Installed modules (ADR 0048). Unset, the checkout's own modules/ is used, as module development
# and the platform's tests expect. Set, it must be an absolute path to an existing directory;
# anything else stops the API and every module command with a sanitized error. The production
# image sets /var/lib/ekklesia/modules.
# EKKLESIA_MODULES_DIR=/absolute/path/to/modules
POSTGRES_DB=ekklesia
POSTGRES_USER=ekklesia
POSTGRES_PASSWORD=ekklesia-development-only
+12 -20
View File
@@ -152,23 +152,14 @@ jobs:
- name: Sweep stale CI stacks from the runner
run: bash scripts/ci-sweep.sh --stale
# The platform-only image (ADR 0044): a checkout has no `modules/`, so this proves the image
# builds, validates, and assembles with zero modules. Build only — no registry login, no
# credentials, no push, no tag anywhere but this runner's local image store.
# Tagged per run and attempt, not only per commit: a pull request and a push of the same commit
# can build at once on one runner's Docker daemon, and neither may remove the other's image.
# Labeled like every CI stack, so this job's final step removes it even if the build is killed.
- name: Build the production image without modules
shell: bash
run: |
source scripts/lib/ci-project.sh
docker build \
--file deploy/Dockerfile \
--build-arg REVISION="$GITHUB_SHA" \
--label "${EKKLESIA_CI_LABEL}=$(ci_project_name image)" \
--label "${EKKLESIA_CI_CREATED_LABEL}=$(date +%s)" \
--tag ekklesia-ci:"$GITHUB_SHA-${GITHUB_RUN_ID:-0}-${GITHUB_RUN_ATTEMPT:-1}" \
.
# The one generic platform image (ADR 0044, ADR 0048), proved rather than assumed: it contains no
# module, no module template, and no module manifest; its module storage mount point is empty;
# EKKLESIA_MODULES_DIR names it; and a checkout with a populated modules/ directory builds an
# identical runtime file system. Build only — no registry login, no credentials, no push, no tag
# anywhere but this runner's local image store. Tagged per run and attempt, and labeled like
# every CI stack, so this job's final step removes the images even if a build is killed.
- name: Build the generic platform image and prove it holds no modules
run: bash scripts/check-image.sh
# Whatever happened above, this job's own stacks do not outlive it.
- name: Remove this job's CI stacks
@@ -179,8 +170,9 @@ jobs:
needs: verify
runs-on: ubuntu-latest
timeout-minutes: 30
# Both topologies (ADR 0045): same-origin, the default every installation gets, and split, which
# adds a test-only module and drives a real headless browser across the two origins.
# Both topologies (ADR 0045), each on the one generic image: same-origin, the default every
# installation gets, and split, which installs a test-only module into module storage, removes
# it, and restores it (ADR 0048), and drives a real headless browser across the two origins.
strategy:
fail-fast: false
matrix:
@@ -206,7 +198,7 @@ jobs:
- name: Sweep stale CI stacks from the runner
run: bash scripts/ci-sweep.sh --stale
# The reference deployment end to end (ADR 0044): a platform-only image, base + proxy with
# The reference deployment end to end (ADR 0044): the generic platform image, base + proxy with
# Caddy's internal CA (test-only), an explicit migration apply against this job's own fresh
# PostgreSQL, then the API, Dashboard, storage delivery, client-address chain, and verify-full
# database TLS through the proxy. Every secret is generated for this run and discarded with
+661
View File
@@ -0,0 +1,661 @@
GNU AFFERO GENERAL PUBLIC LICENSE
Version 3, 19 November 2007
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
Preamble
The GNU Affero General Public License is a free, copyleft license for
software and other kinds of works, specifically designed to ensure
cooperation with the community in the case of network server software.
The licenses for most software and other practical works are designed
to take away your freedom to share and change the works. By contrast,
our General Public Licenses are intended to guarantee your freedom to
share and change all versions of a program--to make sure it remains free
software for all its users.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
have the freedom to distribute copies of free software (and charge for
them if you wish), that you receive source code or can get it if you
want it, that you can change the software or use pieces of it in new
free programs, and that you know you can do these things.
Developers that use our General Public Licenses protect your rights
with two steps: (1) assert copyright on the software, and (2) offer
you this License which gives you legal permission to copy, distribute
and/or modify the software.
A secondary benefit of defending all users' freedom is that
improvements made in alternate versions of the program, if they
receive widespread use, become available for other developers to
incorporate. Many developers of free software are heartened and
encouraged by the resulting cooperation. However, in the case of
software used on network servers, this result may fail to come about.
The GNU General Public License permits making a modified version and
letting the public access it on a server without ever releasing its
source code to the public.
The GNU Affero General Public License is designed specifically to
ensure that, in such cases, the modified source code becomes available
to the community. It requires the operator of a network server to
provide the source code of the modified version running there to the
users of that server. Therefore, public use of a modified version, on
a publicly accessible server, gives the public access to the source
code of the modified version.
An older license, called the Affero General Public License and
published by Affero, was designed to accomplish similar goals. This is
a different license, not a version of the Affero GPL, but Affero has
released a new version of the Affero GPL which permits relicensing under
this license.
The precise terms and conditions for copying, distribution and
modification follow.
TERMS AND CONDITIONS
0. Definitions.
"This License" refers to version 3 of the GNU Affero General Public License.
"Copyright" also means copyright-like laws that apply to other kinds of
works, such as semiconductor masks.
"The Program" refers to any copyrightable work licensed under this
License. Each licensee is addressed as "you". "Licensees" and
"recipients" may be individuals or organizations.
To "modify" a work means to copy from or adapt all or part of the work
in a fashion requiring copyright permission, other than the making of an
exact copy. The resulting work is called a "modified version" of the
earlier work or a work "based on" the earlier work.
A "covered work" means either the unmodified Program or a work based
on the Program.
To "propagate" a work means to do anything with it that, without
permission, would make you directly or secondarily liable for
infringement under applicable copyright law, except executing it on a
computer or modifying a private copy. Propagation includes copying,
distribution (with or without modification), making available to the
public, and in some countries other activities as well.
To "convey" a work means any kind of propagation that enables other
parties to make or receive copies. Mere interaction with a user through
a computer network, with no transfer of a copy, is not conveying.
An interactive user interface displays "Appropriate Legal Notices"
to the extent that it includes a convenient and prominently visible
feature that (1) displays an appropriate copyright notice, and (2)
tells the user that there is no warranty for the work (except to the
extent that warranties are provided), that licensees may convey the
work under this License, and how to view a copy of this License. If
the interface presents a list of user commands or options, such as a
menu, a prominent item in the list meets this criterion.
1. Source Code.
The "source code" for a work means the preferred form of the work
for making modifications to it. "Object code" means any non-source
form of a work.
A "Standard Interface" means an interface that either is an official
standard defined by a recognized standards body, or, in the case of
interfaces specified for a particular programming language, one that
is widely used among developers working in that language.
The "System Libraries" of an executable work include anything, other
than the work as a whole, that (a) is included in the normal form of
packaging a Major Component, but which is not part of that Major
Component, and (b) serves only to enable use of the work with that
Major Component, or to implement a Standard Interface for which an
implementation is available to the public in source code form. A
"Major Component", in this context, means a major essential component
(kernel, window system, and so on) of the specific operating system
(if any) on which the executable work runs, or a compiler used to
produce the work, or an object code interpreter used to run it.
The "Corresponding Source" for a work in object code form means all
the source code needed to generate, install, and (for an executable
work) run the object code and to modify the work, including scripts to
control those activities. However, it does not include the work's
System Libraries, or general-purpose tools or generally available free
programs which are used unmodified in performing those activities but
which are not part of the work. For example, Corresponding Source
includes interface definition files associated with source files for
the work, and the source code for shared libraries and dynamically
linked subprograms that the work is specifically designed to require,
such as by intimate data communication or control flow between those
subprograms and other parts of the work.
The Corresponding Source need not include anything that users
can regenerate automatically from other parts of the Corresponding
Source.
The Corresponding Source for a work in source code form is that
same work.
2. Basic Permissions.
All rights granted under this License are granted for the term of
copyright on the Program, and are irrevocable provided the stated
conditions are met. This License explicitly affirms your unlimited
permission to run the unmodified Program. The output from running a
covered work is covered by this License only if the output, given its
content, constitutes a covered work. This License acknowledges your
rights of fair use or other equivalent, as provided by copyright law.
You may make, run and propagate covered works that you do not
convey, without conditions so long as your license otherwise remains
in force. You may convey covered works to others for the sole purpose
of having them make modifications exclusively for you, or provide you
with facilities for running those works, provided that you comply with
the terms of this License in conveying all material for which you do
not control copyright. Those thus making or running the covered works
for you must do so exclusively on your behalf, under your direction
and control, on terms that prohibit them from making any copies of
your copyrighted material outside their relationship with you.
Conveying under any other circumstances is permitted solely under
the conditions stated below. Sublicensing is not allowed; section 10
makes it unnecessary.
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
No covered work shall be deemed part of an effective technological
measure under any applicable law fulfilling obligations under article
11 of the WIPO copyright treaty adopted on 20 December 1996, or
similar laws prohibiting or restricting circumvention of such
measures.
When you convey a covered work, you waive any legal power to forbid
circumvention of technological measures to the extent such circumvention
is effected by exercising rights under this License with respect to
the covered work, and you disclaim any intention to limit operation or
modification of the work as a means of enforcing, against the work's
users, your or third parties' legal rights to forbid circumvention of
technological measures.
4. Conveying Verbatim Copies.
You may convey verbatim copies of the Program's source code as you
receive it, in any medium, provided that you conspicuously and
appropriately publish on each copy an appropriate copyright notice;
keep intact all notices stating that this License and any
non-permissive terms added in accord with section 7 apply to the code;
keep intact all notices of the absence of any warranty; and give all
recipients a copy of this License along with the Program.
You may charge any price or no price for each copy that you convey,
and you may offer support or warranty protection for a fee.
5. Conveying Modified Source Versions.
You may convey a work based on the Program, or the modifications to
produce it from the Program, in the form of source code under the
terms of section 4, provided that you also meet all of these conditions:
a) The work must carry prominent notices stating that you modified
it, and giving a relevant date.
b) The work must carry prominent notices stating that it is
released under this License and any conditions added under section
7. This requirement modifies the requirement in section 4 to
"keep intact all notices".
c) You must license the entire work, as a whole, under this
License to anyone who comes into possession of a copy. This
License will therefore apply, along with any applicable section 7
additional terms, to the whole of the work, and all its parts,
regardless of how they are packaged. This License gives no
permission to license the work in any other way, but it does not
invalidate such permission if you have separately received it.
d) If the work has interactive user interfaces, each must display
Appropriate Legal Notices; however, if the Program has interactive
interfaces that do not display Appropriate Legal Notices, your
work need not make them do so.
A compilation of a covered work with other separate and independent
works, which are not by their nature extensions of the covered work,
and which are not combined with it such as to form a larger program,
in or on a volume of a storage or distribution medium, is called an
"aggregate" if the compilation and its resulting copyright are not
used to limit the access or legal rights of the compilation's users
beyond what the individual works permit. Inclusion of a covered work
in an aggregate does not cause this License to apply to the other
parts of the aggregate.
6. Conveying Non-Source Forms.
You may convey a covered work in object code form under the terms
of sections 4 and 5, provided that you also convey the
machine-readable Corresponding Source under the terms of this License,
in one of these ways:
a) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by the
Corresponding Source fixed on a durable physical medium
customarily used for software interchange.
b) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by a
written offer, valid for at least three years and valid for as
long as you offer spare parts or customer support for that product
model, to give anyone who possesses the object code either (1) a
copy of the Corresponding Source for all the software in the
product that is covered by this License, on a durable physical
medium customarily used for software interchange, for a price no
more than your reasonable cost of physically performing this
conveying of source, or (2) access to copy the
Corresponding Source from a network server at no charge.
c) Convey individual copies of the object code with a copy of the
written offer to provide the Corresponding Source. This
alternative is allowed only occasionally and noncommercially, and
only if you received the object code with such an offer, in accord
with subsection 6b.
d) Convey the object code by offering access from a designated
place (gratis or for a charge), and offer equivalent access to the
Corresponding Source in the same way through the same place at no
further charge. You need not require recipients to copy the
Corresponding Source along with the object code. If the place to
copy the object code is a network server, the Corresponding Source
may be on a different server (operated by you or a third party)
that supports equivalent copying facilities, provided you maintain
clear directions next to the object code saying where to find the
Corresponding Source. Regardless of what server hosts the
Corresponding Source, you remain obligated to ensure that it is
available for as long as needed to satisfy these requirements.
e) Convey the object code using peer-to-peer transmission, provided
you inform other peers where the object code and Corresponding
Source of the work are being offered to the general public at no
charge under subsection 6d.
A separable portion of the object code, whose source code is excluded
from the Corresponding Source as a System Library, need not be
included in conveying the object code work.
A "User Product" is either (1) a "consumer product", which means any
tangible personal property which is normally used for personal, family,
or household purposes, or (2) anything designed or sold for incorporation
into a dwelling. In determining whether a product is a consumer product,
doubtful cases shall be resolved in favor of coverage. For a particular
product received by a particular user, "normally used" refers to a
typical or common use of that class of product, regardless of the status
of the particular user or of the way in which the particular user
actually uses, or expects or is expected to use, the product. A product
is a consumer product regardless of whether the product has substantial
commercial, industrial or non-consumer uses, unless such uses represent
the only significant mode of use of the product.
"Installation Information" for a User Product means any methods,
procedures, authorization keys, or other information required to install
and execute modified versions of a covered work in that User Product from
a modified version of its Corresponding Source. The information must
suffice to ensure that the continued functioning of the modified object
code is in no case prevented or interfered with solely because
modification has been made.
If you convey an object code work under this section in, or with, or
specifically for use in, a User Product, and the conveying occurs as
part of a transaction in which the right of possession and use of the
User Product is transferred to the recipient in perpetuity or for a
fixed term (regardless of how the transaction is characterized), the
Corresponding Source conveyed under this section must be accompanied
by the Installation Information. But this requirement does not apply
if neither you nor any third party retains the ability to install
modified object code on the User Product (for example, the work has
been installed in ROM).
The requirement to provide Installation Information does not include a
requirement to continue to provide support service, warranty, or updates
for a work that has been modified or installed by the recipient, or for
the User Product in which it has been modified or installed. Access to a
network may be denied when the modification itself materially and
adversely affects the operation of the network or violates the rules and
protocols for communication across the network.
Corresponding Source conveyed, and Installation Information provided,
in accord with this section must be in a format that is publicly
documented (and with an implementation available to the public in
source code form), and must require no special password or key for
unpacking, reading or copying.
7. Additional Terms.
"Additional permissions" are terms that supplement the terms of this
License by making exceptions from one or more of its conditions.
Additional permissions that are applicable to the entire Program shall
be treated as though they were included in this License, to the extent
that they are valid under applicable law. If additional permissions
apply only to part of the Program, that part may be used separately
under those permissions, but the entire Program remains governed by
this License without regard to the additional permissions.
When you convey a copy of a covered work, you may at your option
remove any additional permissions from that copy, or from any part of
it. (Additional permissions may be written to require their own
removal in certain cases when you modify the work.) You may place
additional permissions on material, added by you to a covered work,
for which you have or can give appropriate copyright permission.
Notwithstanding any other provision of this License, for material you
add to a covered work, you may (if authorized by the copyright holders of
that material) supplement the terms of this License with terms:
a) Disclaiming warranty or limiting liability differently from the
terms of sections 15 and 16 of this License; or
b) Requiring preservation of specified reasonable legal notices or
author attributions in that material or in the Appropriate Legal
Notices displayed by works containing it; or
c) Prohibiting misrepresentation of the origin of that material, or
requiring that modified versions of such material be marked in
reasonable ways as different from the original version; or
d) Limiting the use for publicity purposes of names of licensors or
authors of the material; or
e) Declining to grant rights under trademark law for use of some
trade names, trademarks, or service marks; or
f) Requiring indemnification of licensors and authors of that
material by anyone who conveys the material (or modified versions of
it) with contractual assumptions of liability to the recipient, for
any liability that these contractual assumptions directly impose on
those licensors and authors.
All other non-permissive additional terms are considered "further
restrictions" within the meaning of section 10. If the Program as you
received it, or any part of it, contains a notice stating that it is
governed by this License along with a term that is a further
restriction, you may remove that term. If a license document contains
a further restriction but permits relicensing or conveying under this
License, you may add to a covered work material governed by the terms
of that license document, provided that the further restriction does
not survive such relicensing or conveying.
If you add terms to a covered work in accord with this section, you
must place, in the relevant source files, a statement of the
additional terms that apply to those files, or a notice indicating
where to find the applicable terms.
Additional terms, permissive or non-permissive, may be stated in the
form of a separately written license, or stated as exceptions;
the above requirements apply either way.
8. Termination.
You may not propagate or modify a covered work except as expressly
provided under this License. Any attempt otherwise to propagate or
modify it is void, and will automatically terminate your rights under
this License (including any patent licenses granted under the third
paragraph of section 11).
However, if you cease all violation of this License, then your
license from a particular copyright holder is reinstated (a)
provisionally, unless and until the copyright holder explicitly and
finally terminates your license, and (b) permanently, if the copyright
holder fails to notify you of the violation by some reasonable means
prior to 60 days after the cessation.
Moreover, your license from a particular copyright holder is
reinstated permanently if the copyright holder notifies you of the
violation by some reasonable means, this is the first time you have
received notice of violation of this License (for any work) from that
copyright holder, and you cure the violation prior to 30 days after
your receipt of the notice.
Termination of your rights under this section does not terminate the
licenses of parties who have received copies or rights from you under
this License. If your rights have been terminated and not permanently
reinstated, you do not qualify to receive new licenses for the same
material under section 10.
9. Acceptance Not Required for Having Copies.
You are not required to accept this License in order to receive or
run a copy of the Program. Ancillary propagation of a covered work
occurring solely as a consequence of using peer-to-peer transmission
to receive a copy likewise does not require acceptance. However,
nothing other than this License grants you permission to propagate or
modify any covered work. These actions infringe copyright if you do
not accept this License. Therefore, by modifying or propagating a
covered work, you indicate your acceptance of this License to do so.
10. Automatic Licensing of Downstream Recipients.
Each time you convey a covered work, the recipient automatically
receives a license from the original licensors, to run, modify and
propagate that work, subject to this License. You are not responsible
for enforcing compliance by third parties with this License.
An "entity transaction" is a transaction transferring control of an
organization, or substantially all assets of one, or subdividing an
organization, or merging organizations. If propagation of a covered
work results from an entity transaction, each party to that
transaction who receives a copy of the work also receives whatever
licenses to the work the party's predecessor in interest had or could
give under the previous paragraph, plus a right to possession of the
Corresponding Source of the work from the predecessor in interest, if
the predecessor has it or can get it with reasonable efforts.
You may not impose any further restrictions on the exercise of the
rights granted or affirmed under this License. For example, you may
not impose a license fee, royalty, or other charge for exercise of
rights granted under this License, and you may not initiate litigation
(including a cross-claim or counterclaim in a lawsuit) alleging that
any patent claim is infringed by making, using, selling, offering for
sale, or importing the Program or any portion of it.
11. Patents.
A "contributor" is a copyright holder who authorizes use under this
License of the Program or a work on which the Program is based. The
work thus licensed is called the contributor's "contributor version".
A contributor's "essential patent claims" are all patent claims
owned or controlled by the contributor, whether already acquired or
hereafter acquired, that would be infringed by some manner, permitted
by this License, of making, using, or selling its contributor version,
but do not include claims that would be infringed only as a
consequence of further modification of the contributor version. For
purposes of this definition, "control" includes the right to grant
patent sublicenses in a manner consistent with the requirements of
this License.
Each contributor grants you a non-exclusive, worldwide, royalty-free
patent license under the contributor's essential patent claims, to
make, use, sell, offer for sale, import and otherwise run, modify and
propagate the contents of its contributor version.
In the following three paragraphs, a "patent license" is any express
agreement or commitment, however denominated, not to enforce a patent
(such as an express permission to practice a patent or covenant not to
sue for patent infringement). To "grant" such a patent license to a
party means to make such an agreement or commitment not to enforce a
patent against the party.
If you convey a covered work, knowingly relying on a patent license,
and the Corresponding Source of the work is not available for anyone
to copy, free of charge and under the terms of this License, through a
publicly available network server or other readily accessible means,
then you must either (1) cause the Corresponding Source to be so
available, or (2) arrange to deprive yourself of the benefit of the
patent license for this particular work, or (3) arrange, in a manner
consistent with the requirements of this License, to extend the patent
license to downstream recipients. "Knowingly relying" means you have
actual knowledge that, but for the patent license, your conveying the
covered work in a country, or your recipient's use of the covered work
in a country, would infringe one or more identifiable patents in that
country that you have reason to believe are valid.
If, pursuant to or in connection with a single transaction or
arrangement, you convey, or propagate by procuring conveyance of, a
covered work, and grant a patent license to some of the parties
receiving the covered work authorizing them to use, propagate, modify
or convey a specific copy of the covered work, then the patent license
you grant is automatically extended to all recipients of the covered
work and works based on it.
A patent license is "discriminatory" if it does not include within
the scope of its coverage, prohibits the exercise of, or is
conditioned on the non-exercise of one or more of the rights that are
specifically granted under this License. You may not convey a covered
work if you are a party to an arrangement with a third party that is
in the business of distributing software, under which you make payment
to the third party based on the extent of your activity of conveying
the work, and under which the third party grants, to any of the
parties who would receive the covered work from you, a discriminatory
patent license (a) in connection with copies of the covered work
conveyed by you (or copies made from those copies), or (b) primarily
for and in connection with specific products or compilations that
contain the covered work, unless you entered into that arrangement,
or that patent license was granted, prior to 28 March 2007.
Nothing in this License shall be construed as excluding or limiting
any implied license or other defenses to infringement that may
otherwise be available to you under applicable patent law.
12. No Surrender of Others' Freedom.
If conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot convey a
covered work so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you may
not convey it at all. For example, if you agree to terms that obligate you
to collect a royalty for further conveying from those to whom you convey
the Program, the only way you could satisfy both those terms and this
License would be to refrain entirely from conveying the Program.
13. Remote Network Interaction; Use with the GNU General Public License.
Notwithstanding any other provision of this License, if you modify the
Program, your modified version must prominently offer all users
interacting with it remotely through a computer network (if your version
supports such interaction) an opportunity to receive the Corresponding
Source of your version by providing access to the Corresponding Source
from a network server at no charge, through some standard or customary
means of facilitating copying of software. This Corresponding Source
shall include the Corresponding Source for any work covered by version 3
of the GNU General Public License that is incorporated pursuant to the
following paragraph.
Notwithstanding any other provision of this License, you have
permission to link or combine any covered work with a work licensed
under version 3 of the GNU General Public License into a single
combined work, and to convey the resulting work. The terms of this
License will continue to apply to the part which is the covered work,
but the work with which it is combined will remain governed by version
3 of the GNU General Public License.
14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of
the GNU Affero General Public License from time to time. Such new versions
will be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the
Program specifies that a certain numbered version of the GNU Affero General
Public License "or any later version" applies to it, you have the
option of following the terms and conditions either of that numbered
version or of any later version published by the Free Software
Foundation. If the Program does not specify a version number of the
GNU Affero General Public License, you may choose any version ever published
by the Free Software Foundation.
If the Program specifies that a proxy can decide which future
versions of the GNU Affero General Public License can be used, that proxy's
public statement of acceptance of a version permanently authorizes you
to choose that version for the Program.
Later license versions may give you additional or different
permissions. However, no additional obligations are imposed on any
author or copyright holder as a result of your choosing to follow a
later version.
15. Disclaimer of Warranty.
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
16. Limitation of Liability.
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
SUCH DAMAGES.
17. Interpretation of Sections 15 and 16.
If the disclaimer of warranty and limitation of liability provided
above cannot be given local legal effect according to their terms,
reviewing courts shall apply local law that most closely approximates
an absolute waiver of all civil liability in connection with the
Program, unless a warranty or assumption of liability accompanies a
copy of the Program in return for a fee.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest
to attach them to the start of each source file to most effectively
state the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper mail.
If your software can interact with users remotely through a computer
network, you should also make sure that it provides a way for users to
get its source. For example, if your program is a web application, its
interface could display a "Source" link that leads users to an archive
of the code. There are many ways you could offer source, and different
solutions will be better for different programs; see section 13 for the
specific requirements.
You should also get your employer (if you work as a programmer) or school,
if any, to sign a "copyright disclaimer" for the program, if necessary.
For more information on this, and how to apply and follow the GNU AGPL, see
<https://www.gnu.org/licenses/>.
+45 -5
View File
@@ -35,6 +35,24 @@ concurrency and release, exact destructive approvals, and sanitization against t
adapter. A Core-owned `pnpm doctor` dashboard combines the existing static discovery and read-only
migration-planning services into one concise readiness report.
**Ekklesia ships one generic image, and installed modules live outside it
([ADR 0048](docs/architecture/decisions/0048-generic-image-and-installed-module-storage.md)).** The
production image built from [`deploy/Dockerfile`](deploy/Dockerfile) contains zero feature modules
and is the same for every installation. Installed modules live in installation-owned module storage,
selected by `EKKLESIA_MODULES_DIR` (`/var/lib/ekklesia/modules` in the image, the checkout's
`modules/` when unset) and mounted read-only by the reference deployment. Each installed module is a
self-contained directory carrying its own production dependencies. Installing, updating, or removing
a module needs only a controlled restart and, when it has migrations, an explicit plan and apply.
Nothing is hot loaded. Removing a module's code keeps its data and its stored state; its applied
migration history is retained and reported, and blocks no other migration. A module is distributed
as one **Ekklesia Module Package v1**
([ADR 0049](docs/architecture/decisions/0049-module-package-v1.md)): a deterministic `.tar.gz` and
its SHA-256, produced by the canonical packager (`@ekklesia/module-pack`) with the module's backend
bundled, and checked by Core's verifier before it is installed. Only the portable `any` target exists
so far. ABI-v1 packages may bundle a module-owned Drizzle version without coupling it to Core;
legacy-v8 packages retain their transitional exact-version check. Purge, native-target packages,
signing, and any installer or marketplace do not exist yet.
Phase 4A added Core-owned identity foundations: a Core-owned Better Auth database schema
(`ekklesia_core_user`, `ekklesia_core_session`, `ekklesia_core_account`,
`ekklesia_core_verification`), a first-class Core-owned migration source that coexists with
@@ -490,8 +508,8 @@ reported `blocked · configuration_missing · bucket` — never imported, never
from inside its own `start` hook — and its required dependents block behind it through ADR 0028's
existing mechanism. Doctor and `pnpm modules:validate` report the same thing while still importing no
module code. That change introduced `manifestVersion: 5` as a hard cutover; the current strict
manifest is version 7 after ADR 0033 added the optional display declaration and ADR 0041 added
browser contributions.
manifest is version 8 after ADR 0050 added the optional database ABI declaration. Version 8 is a
hard cutover; a version 7 source module or package must be updated and rebuilt.
**Ekklesia does not store secrets, and does not pretend to.** There is no encryption at rest here and
no key management, so a `secret` key's value is supplied by the deployment
@@ -825,7 +843,7 @@ is versioned with no dual acceptance; migrating is one `pnpm build`.
Phase 8 pass 3 lets one module contribute a browser renderer to a capability another module provides
([ADR 0041](docs/architecture/decisions/0041-module-browser-contributions.md)) — the browser half of
the capability system Core has had since Phase 1. `manifestVersion: 7` declares a namespaced id, its
the capability system Core has had since Phase 1. `manifestVersion: 8` declares a namespaced id, its
own version, a target capability, a compatible range, and one artifact, and nothing else: there is no
payload schema, slot, ordering, or rendering hint anywhere, so Core cannot learn what any of it
means. Publication requires that another eligible module provides the capability inside the declared
@@ -1056,5 +1074,27 @@ environment.
- [Module development](docs/module-development/README.md)
- [Architecture decisions](docs/architecture/decisions/README.md)
No open-source license has been selected yet. A license should be added only after the project
owner makes that decision.
## License
Ekklesia is licensed under the **GNU Affero General Public License, version 3 or any later version**
(SPDX `AGPL-3.0-or-later`). The full text of version 3 is in [LICENSE](LICENSE). Its source is
available to you under the terms of that license.
> Ekklesia is free software: you can redistribute it and/or modify it under the terms of the GNU
> Affero General Public License as published by the Free Software Foundation, either version 3 of
> the License, or (at your option) any later version.
>
> Ekklesia is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
> the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero
> General Public License for more details.
>
> You should have received a copy of the GNU Affero General Public License along with Ekklesia. If
> not, see <https://www.gnu.org/licenses/>.
Section 13 of the license adds an obligation for network use: if you modify Ekklesia and run your
modified version for users who interact with it remotely over a network, your version must offer
those users the opportunity to receive its Corresponding Source.
Official Ekklesia modules, published by the Ekklesia project, are licensed under `AGPL-3.0-or-later`
as well. Each module's own repository declares its license. The module template declares it in its
`package.json`, so a module started from the template begins with the same license.
+1
View File
@@ -1,6 +1,7 @@
{
"name": "@ekklesia/api",
"version": "0.1.2",
"license": "AGPL-3.0-or-later",
"private": true,
"type": "module",
"files": [
+5 -5
View File
@@ -20,6 +20,7 @@ import type {
ModuleLifecycleSnapshot,
ModuleLifecycleStore,
ModuleRealtimeRegistry,
ModuleRuntimeServicesLookup,
ModuleStorageLookup,
ModuleUiPublicationRegistry,
} from "@ekklesia/core/modules";
@@ -62,11 +63,10 @@ export interface CreateApiAppOptions {
/** The started runtime's immutable module-owned realtime registry. Required explicitly even when
* empty, for the same reason as module routes: application composition never guesses what ran. */
readonly moduleRealtime: ModuleRealtimeRegistry;
/** The same frozen services object the caller's started module runtime already passed to every
* module's `start`/`stop` hooks (`apps/api/src/server.ts`) — reference-identical, never a fresh
* object built here, so a module's HTTP handlers and lifecycle hooks share exactly one
* `ModuleDatabase` for the lifetime of this API runtime. */
readonly moduleServices: ModuleRuntimeServices;
/** The same per-module service lookup the caller's started module runtime used for every
* module's `start`/`stop` hooks (`apps/api/src/server.ts`) — each handler receives its module's
* reference-identical service object, never a fresh facade or executor built per request. */
readonly moduleServices: ModuleRuntimeServicesLookup | ModuleRuntimeServices;
/** The platform's module UI publication policy — always the one Core builds from the exact
* discovered inventory this runtime started (`createModuleUiPublicationRegistry`,
* `apps/api/src/server.ts`). Required rather than defaulted, for the same reason `moduleRoutes`
+5 -1
View File
@@ -1,4 +1,4 @@
import type { ModuleDatabase } from "@ekklesia/contracts";
import type { ModuleDatabase, ModuleDatabaseExecutor, ModuleId } from "@ekklesia/contracts";
import {
type AuthorizationDrizzleDatabase,
createAuthorizationDrizzleView,
@@ -12,6 +12,7 @@ import { createDeviceDrizzleView, type DeviceDrizzleDatabase } from "@ekklesia/c
import { createIdentityDrizzleView, type IdentityDrizzleDatabase } from "@ekklesia/core/identity";
import {
createModuleConfigurationView,
createModuleDatabaseExecutor,
createModuleDatabaseView,
createModuleLifecycleView,
type ModuleConfigurationDrizzleDatabase,
@@ -24,6 +25,8 @@ export interface ApiDatabaseHandle {
readonly dashboard: DashboardDrizzleDatabase;
readonly identity: IdentityDrizzleDatabase;
readonly module: ModuleDatabase;
/** Builds one ABI-v1 executor attributed to exactly one module, over this handle's shared pool. */
readonly moduleExecutor: (moduleId: ModuleId) => ModuleDatabaseExecutor;
readonly moduleConfiguration: ModuleConfigurationDrizzleDatabase;
readonly moduleLifecycle: ModuleLifecycleDrizzleDatabase;
/**
@@ -76,6 +79,7 @@ export function createApiDatabase(
dashboard: createDashboardDrizzleView(pool),
identity: createIdentityDrizzleView(pool),
module: createModuleDatabaseView(pool),
moduleExecutor: (moduleId: ModuleId) => createModuleDatabaseExecutor(pool, { moduleId }),
moduleConfiguration: createModuleConfigurationView(pool),
moduleLifecycle: createModuleLifecycleView(pool),
ping: async (statementTimeoutMs: number) => {
+19 -6
View File
@@ -1,6 +1,8 @@
import {
EMPTY_MODULE_CONFIGURATION,
EMPTY_MODULE_STORAGE,
type ModuleDatabase,
type ModuleDatabaseExecutor,
type ModuleId,
type ModuleRouteHandlerContext,
type ModuleRuntimeServices,
@@ -9,6 +11,7 @@ import {
import type {
ModuleConfigurationLookup,
ModuleRealtimeRegistry,
ModuleRuntimeServicesLookup,
ModuleStorageLookup,
} from "@ekklesia/core/modules";
import type { Context, Hono } from "hono";
@@ -37,14 +40,14 @@ export interface ModuleHttpRoutesOptions extends AuthorizationMiddlewareOptions
* received for this API runtime — never a fresh one built per request, and never touched for a
* denied, trusted-origin-rejected, or `OPTIONS` request, since a handler is only ever invoked
* after authorization succeeds. */
readonly services: ModuleRuntimeServices;
readonly services: ModuleRuntimeServicesLookup | ModuleRuntimeServices;
/**
* Per-module frozen configuration snapshots — the exact map the module runtime started from
* (ADR 0029), so a handler and that module's `start`/`stop` hooks receive the identical object
* rather than two independently resolved values. A module with no entry receives
* `EMPTY_MODULE_CONFIGURATION`, so `context.config` is always present.
*
* Unlike `services`, which is one shared object, this is keyed by module id: a handler can only
* Like `services`, this is keyed by module id: a handler can only
* ever reach its own module's configuration, because that is the only entry looked up for it.
*/
readonly configurations: ModuleConfigurationLookup;
@@ -232,6 +235,11 @@ export function mountModuleHttpRoutes(
if (!options.registry.has(route.permission)) {
throw new ModuleRouteConfigurationError();
}
const services =
"get" in options.services ? options.services.get(route.moduleId) : options.services;
if (!services) {
throw new ModuleRouteConfigurationError();
}
const requireRoutePermission = requirePermission(middlewareOptions, route.permission);
// The route's own conscious body contract (`route.maxBodyBytes` — 0 means no body accepted at
@@ -243,19 +251,24 @@ export function mountModuleHttpRoutes(
const routeBodyLimit = platformBodyLimit(route.maxBodyBytes, route.bodyMode);
const path = moduleRoutePath(route.moduleId, route.path);
const finalHandler = async (c: Context): Promise<Response> => {
const context: ModuleRouteHandlerContext = Object.freeze({
const context = Object.freeze({
config: options.configurations.get(route.moduleId) ?? EMPTY_MODULE_CONFIGURATION,
moduleId: route.moduleId,
storage: options.storages.get(route.moduleId) ?? EMPTY_MODULE_STORAGE,
params: Object.freeze({ ...c.req.param() }),
principal: getAuthorizedPrincipal(c),
realtime: options.realtime.publisherFor(route.moduleId),
services: options.services,
});
services,
}) as ModuleRouteHandlerContext<ModuleDatabase | ModuleDatabaseExecutor>;
let response: Response;
try {
response = await route.handler(c.req.raw, context);
response = await (
route.handler as (
request: Request,
context: ModuleRouteHandlerContext<ModuleDatabase | ModuleDatabaseExecutor>,
) => PromiseLike<Response> | Response
)(c.req.raw, context);
} catch {
return sanitizedModuleHandlerFailure(c);
}
+18 -4
View File
@@ -6,6 +6,7 @@ import {
composePlatformPermissionRegistry,
discoverModules,
type DiscoveredModule,
resolveModulesDirectory,
} from "@ekklesia/core/modules";
export type PlatformPermissionConfigurationErrorCode = "PLATFORM_PERMISSION_CONFIGURATION_INVALID";
@@ -55,10 +56,18 @@ async function readPackageVersion(packagePath: string): Promise<string> {
}
export interface PrepareStartupModuleInventoryOptions {
/**
* The deployment's `EKKLESIA_MODULES_DIR`, raw, exactly as host configuration read it (ADR 0048).
* A production input, not a test seam: the executable entry point always passes it. Unset means
* the repository's own `modules/`; set means that absolute directory, which must exist. It is
* resolved by Core's one resolver, so an unusable value fails startup with a sanitized
* `ModuleDiscoveryError` before any pool exists.
*/
readonly configuredModulesDirectory?: string | undefined;
/** Test-only escape hatch: overrides the directory module discovery scans for installed
* modules. Never set by the executable entry point — production always discovers the real
* repository's `modules/` directory. Platform and Module SDK versions are always read from the
* real repository's own `package.json` files regardless of this override. */
* modules, taking precedence over `configuredModulesDirectory`, which is then never resolved.
* Never set by the executable entry point. Platform and Module SDK versions are always read from
* the real repository's own `package.json` files regardless of this override. */
readonly modulesDirectoryOverride?: string;
/** Test-only escape hatch: supplies an already-discovered module list directly, skipping the
* filesystem discovery step entirely, while still running the real `composePlatformPermissionRegistry`
@@ -109,6 +118,11 @@ export async function prepareStartupModuleInventory(
}
const repositoryRoot = resolveRepositoryRoot();
// Chosen before anything is read, so an unusable configured directory fails first and alone.
const modulesDirectory =
options.modulesDirectoryOverride === undefined
? resolveModulesDirectory({ configured: options.configuredModulesDirectory, repositoryRoot })
: { modulesDirectory: options.modulesDirectoryOverride, modulesDirectoryRequired: false };
const [platformVersion, moduleSdkVersion] = await Promise.all([
readPackageVersion(path.join(repositoryRoot, "package.json")),
readPackageVersion(path.join(repositoryRoot, "packages/module-sdk/package.json")),
@@ -116,7 +130,7 @@ export async function prepareStartupModuleInventory(
const modules = await discoverModules({
moduleSdkVersion,
modulesDirectory: options.modulesDirectoryOverride ?? path.join(repositoryRoot, "modules"),
...modulesDirectory,
platformVersion,
});
+30 -13
View File
@@ -2,8 +2,8 @@ import type { Socket } from "node:net";
import path from "node:path";
import { pathToFileURL } from "node:url";
import {
type AnyModuleRuntimeServices,
EMPTY_MODULE_SECRET_SOURCE,
type ModuleRuntimeServices,
type ModuleSecretSource,
} from "@ekklesia/contracts";
import {
@@ -27,6 +27,7 @@ import {
type DiscoveredModule,
type ModuleConfigurationSettings,
type ModuleEligibilityResolution,
type ModuleRuntimeServicesLookup,
resolveModuleEligibility,
type StartedModuleRuntime,
startDiscoveredModuleRuntime,
@@ -154,8 +155,12 @@ export interface StartApiOptions {
* test needs somewhere to observe the operational failure the user is never shown.
*/
readonly reportNotificationFailure?: IdentityNotificationReporter;
/** Test-only escape hatch: overrides the directory module discovery scans. Never set by the
* executable entry point. See `prepareStartupModuleInventory`. */
/** The deployment's raw `EKKLESIA_MODULES_DIR` (ADR 0048), a production input the executable
* entry point always passes from host configuration. See `prepareStartupModuleInventory`. */
readonly configuredModulesDirectory?: string | undefined;
/** Test-only escape hatch: overrides the directory module discovery scans, taking precedence
* over `configuredModulesDirectory`. Never set by the executable entry point. See
* `prepareStartupModuleInventory`. */
readonly modulesDirectoryOverride?: string;
/** Test-only escape hatch: supplies an already-discovered module list directly, skipping
* filesystem discovery entirely — this exact list drives both permission composition and module
@@ -389,16 +394,13 @@ function bindServer(
* (`prepareStartupModuleInventory`, `./platform-permissions.js`).
* 2. Construct the one shared PostgreSQL pool (identity, authorization, and the module database
* facade each get a typed Drizzle view over it — see `./database.js`).
* 3. Construct the frozen `ModuleRuntimeServices` (currently just `database`) from that same pool,
* then the Core identity runtime, authorization store, and evaluator.
* 3. Construct the Core identity runtime, authorization store, and evaluator from that pool.
* 4. Read the stored module enablement configuration and resolve, from that exact same discovered
* inventory, which modules are eligible to run (`resolveModuleEligibility`).
* 5. Start the module runtime from the eligible subset (`startDiscoveredModuleRuntime`) — never
* rediscovering modules a second time — passing it the same services object every module's
* `start`/`stop` hook receives.
* rediscovering modules a second time — passing the manifest-selected service map.
* 6. Create the Hono application, mounting the started module runtime's published routes, publishing
* UI for that same eligible set, and passing the exact same services object to every module HTTP
* handler.
* UI for that same eligible set, and passing the same per-module service map to HTTP handlers.
* 7. Bind the Node HTTP server.
*
* Discovery and permission composition happen before any pool, Better Auth instance, module
@@ -429,6 +431,7 @@ export async function startApi(
options: StartApiOptions = {},
): Promise<RunningApi> {
const inventory = await prepareStartupModuleInventory({
configuredModulesDirectory: options.configuredModulesDirectory,
...(options.modulesDirectoryOverride === undefined
? {}
: { modulesDirectoryOverride: options.modulesDirectoryOverride }),
@@ -454,12 +457,10 @@ export async function startApi(
const storageProvider = config.storage ? createS3StorageProvider(config.storage) : undefined;
const mailProvider = config.mail ? createSmtpMailProvider(config.mail) : undefined;
let services: ModuleRuntimeServices;
let runtime: ReturnType<typeof createIdentityRuntime>;
let authorizationStore: ReturnType<typeof createAuthorizationStore>;
let deviceStore: ReturnType<typeof createDeviceStore>;
try {
services = Object.freeze({ database: databaseHandle.module });
runtime = createIdentityRuntime({
config: config.identity,
db: databaseHandle.identity,
@@ -527,6 +528,15 @@ export async function startApi(
throw error;
}
const services: ModuleRuntimeServicesLookup = new Map<string, AnyModuleRuntimeServices>(
eligibility.eligibleModules.map((module) => [
module.manifest.id,
module.manifest.database?.abi === 1
? Object.freeze({ database: databaseHandle.moduleExecutor(module.manifest.id) })
: Object.freeze({ database: databaseHandle.module }),
]),
);
// One scoped facade per eligible module, over the exact buckets that module's own manifest
// declares. Built from the eligible set so a blocked module never receives storage at all.
const moduleStorages = createModuleStorageLookup(
@@ -769,6 +779,7 @@ export function readConfigFromEnvironment(processEnvironment: HostEnvironment):
readonly input: Parameters<typeof createApiRuntimeConfig>[0];
readonly environment: "development" | "production";
readonly moduleSecrets: ModuleSecretSource;
readonly modulesDirectory: string | undefined;
} {
const host = loadHostConfiguration(processEnvironment);
return {
@@ -784,13 +795,19 @@ export function readConfigFromEnvironment(processEnvironment: HostEnvironment):
storage: host.storage,
},
moduleSecrets: host.moduleSecrets,
modulesDirectory: host.modulesDirectory,
};
}
export async function main(): Promise<void> {
const { environment, input, moduleSecrets } = readConfigFromEnvironment(process.env);
const { environment, input, moduleSecrets, modulesDirectory } = readConfigFromEnvironment(
process.env,
);
const config = createApiRuntimeConfig(input, { environment });
const running = await startApi(config, { moduleSecrets });
const running = await startApi(config, {
configuredModulesDirectory: modulesDirectory,
moduleSecrets,
});
const address = running.server.address();
const boundPort = address && typeof address === "object" ? address.port : config.port;
+6 -7
View File
@@ -342,7 +342,7 @@ async function createGatedRouteModule(
compatibility: { moduleSdk: "^0.1.0", platform: "^0.1.0" },
entrypoints: { server: "./dist/server/index.js" },
id,
manifestVersion: 7,
manifestVersion: 8,
name: `${id} module`,
permissions: [{ baseline: "public", description: "Gated drain probe.", id: permissionId }],
version: "0.1.0",
@@ -386,7 +386,7 @@ async function createFixtureModule(
): Promise<void> {
const moduleDirectory = path.join(modulesDirectory, id);
const manifest = {
manifestVersion: 7,
manifestVersion: 8,
id,
name: `${id} module`,
version: "0.1.0",
@@ -524,7 +524,7 @@ async function createUiModule(
compatibility: { moduleSdk: "^0.1.0", platform: "^0.1.0" },
entrypoints: { server: "./dist/server/index.js", ui: "./dist/ui/index.js" },
id,
manifestVersion: 7,
manifestVersion: 8,
name: `${id} module`,
version,
}),
@@ -2590,6 +2590,7 @@ describe.sequential("live API — installed module permission end-to-end", () =>
});
const report = await diagnoseEkklesia({
coreMigrationsDirectory,
moduleDiscovery: {
moduleSdkVersion: "0.1.0",
modulesDirectory,
@@ -2599,10 +2600,8 @@ describe.sequential("live API — installed module permission end-to-end", () =>
runtimeVersion: process.versions.node,
});
// Only the module-discovery and permission-composition checks are asserted here — this
// fixture's temporary directory has no `core/drizzle` sibling for migrations discovery to
// find, which is unrelated to what this scenario proves and would otherwise make the check
// fail for a reason that has nothing to do with permission composition.
// Only the module-discovery and permission-composition checks are asserted here; nothing else
// in the report bears on what this scenario proves.
const moduleCheck = report.checks.find((entry) => entry.id === "modules.discovery");
const permissionsCheck = report.checks.find((entry) => entry.id === "modules.permissions");
expect(moduleCheck).toMatchObject({ status: "pass" });
+1 -1
View File
@@ -77,7 +77,7 @@ async function createModule(modulesDirectory: string, fixture: ModuleFixture): P
...(fixture.withUi === false ? {} : { ui: "./dist/ui/index.js" }),
},
id: fixture.id,
manifestVersion: 7,
manifestVersion: 8,
name: fixture.name,
version: "1.0.0",
}),
+1 -1
View File
@@ -124,7 +124,7 @@ async function createDisplayModuleDirectory(): Promise<string> {
server: "./dist/server/index.js",
},
id: "announcements",
manifestVersion: 7,
manifestVersion: 8,
name: "Announcements fixture",
permissions: [
{
@@ -151,6 +151,13 @@ describe("host configuration parity between startApi and Doctor", () => {
);
});
it("hands the API the same modules directory Doctor and the operator commands read", () => {
expect(readConfigFromEnvironment(environment()).modulesDirectory).toBeUndefined();
const env = environment({ EKKLESIA_MODULES_DIR: "/var/lib/ekklesia/modules" });
expect(readConfigFromEnvironment(env).modulesDirectory).toBe("/var/lib/ekklesia/modules");
expect(loadHostConfiguration(env).modulesDirectory).toBe("/var/lib/ekklesia/modules");
});
it("reads only this listener's own address and port outside the shared loader", () => {
const config = apiView(
environment({ EKKLESIA_API_HOST: "0.0.0.0", EKKLESIA_API_PORT: "8080" }),
+69 -2
View File
@@ -104,7 +104,7 @@ async function createFixtureModule(
): Promise<void> {
const moduleDirectory = path.join(modulesDirectory, id);
const manifest = {
manifestVersion: 7,
manifestVersion: 8,
id,
name: `${id} module`,
version: "0.1.0",
@@ -270,7 +270,7 @@ function fakeDiscoveredModule(
compatibility: { moduleSdk: "^0.1.0", platform: "^0.1.0" },
entrypoints: { server: "./dist/does-not-exist/index.js" },
id,
manifestVersion: 7,
manifestVersion: 8,
name: `${id} module`,
permissions,
version: "0.1.0",
@@ -1004,6 +1004,73 @@ describe("Ekklesia API module permission composition at startup", () => {
expect(poolEnd).toHaveBeenCalledTimes(1);
});
it("discovers installed modules from the configured modules directory (ADR 0048)", async () => {
const modulesDirectory = await createTemporaryModulesDirectory();
await createFixtureModule(modulesDirectory, "incompatible", {
compatibility: { moduleSdk: "^0.1.0", platform: ">=99.0.0" },
});
// The module that fails discovery lives only in the configured directory, so this rejection
// proves discovery read it rather than the repository's own `modules/`.
await expect(
startApi(validConfig(), { configuredModulesDirectory: modulesDirectory }),
).rejects.toBeInstanceOf(ModuleDiscoveryError);
expect(poolConstructor).not.toHaveBeenCalled();
});
it("starts with zero modules from an empty configured modules directory", async () => {
const modulesDirectory = await createTemporaryModulesDirectory();
poolConstructor.mockImplementation(function fakePool() {
return { end: vi.fn().mockResolvedValue(undefined), on: vi.fn(), query: emptyQuery() };
});
betterAuthMock.mockReturnValue({ api: { getSession: vi.fn() }, handler: vi.fn() });
mockSuccessfulServe();
const running = await startApi(validConfig(), { configuredModulesDirectory: modulesDirectory });
expect(poolConstructor).toHaveBeenCalledTimes(1);
await running.shutdown();
});
it.each([
["a relative path", "relative/private-mount-5b2a", "MODULES_DIRECTORY_INVALID"],
["a missing directory", "/nonexistent/private-mount-5b2a", "MODULES_DIRECTORY_UNREADABLE"],
])("fails closed on %s, before any pool exists, naming no path", async (_label, configured, code) => {
let captured: unknown;
try {
await startApi(validConfig(), { configuredModulesDirectory: configured });
} catch (error) {
captured = error;
}
expect(captured).toBeInstanceOf(ModuleDiscoveryError);
const issues = (captured as ModuleDiscoveryError).issues;
expect(issues.map((issue) => issue.code)).toEqual([code]);
expect(JSON.stringify(issues)).not.toContain("private-mount-5b2a");
expect((captured as Error).message).not.toContain("private-mount-5b2a");
expect(poolConstructor).not.toHaveBeenCalled();
expect(betterAuthMock).not.toHaveBeenCalled();
expect(serveMock).not.toHaveBeenCalled();
});
it("lets the test-only override take precedence over a configured directory", async () => {
const modulesDirectory = await createTemporaryModulesDirectory();
poolConstructor.mockImplementation(function fakePool() {
return { end: vi.fn().mockResolvedValue(undefined), on: vi.fn(), query: emptyQuery() };
});
betterAuthMock.mockReturnValue({ api: { getSession: vi.fn() }, handler: vi.fn() });
mockSuccessfulServe();
// The configured value is unusable; the override wins, so it is never even resolved.
const running = await startApi(validConfig(), {
configuredModulesDirectory: "relative/never-resolved",
modulesDirectoryOverride: modulesDirectory,
});
expect(poolConstructor).toHaveBeenCalledTimes(1);
await running.shutdown();
});
it("does not perform module discovery merely by importing the platform-permissions module", async () => {
const module = await import("../src/platform-permissions.js");
@@ -108,7 +108,7 @@ async function createModule(modulesDirectory: string, fixture: ModuleFixture): P
configuration: fixture.configuration ?? [],
entrypoints: { server: "./dist/server/index.js" },
id: fixture.id,
manifestVersion: 7,
manifestVersion: 8,
name: `${fixture.id} module`,
version: "1.0.0",
}),
@@ -8,6 +8,7 @@ import {
createPostgresMigrationDatabase,
discoverAllMigrations,
} from "@ekklesia/core/migrations";
import { moduleDatabaseExecutorModuleId } from "@ekklesia/core/modules";
import { Client, type QueryResultRow } from "pg";
import { afterAll, describe, expect, it } from "vitest";
import { createApiRuntimeConfig } from "../src/config.js";
@@ -441,6 +442,11 @@ describe.sequential("live API — module database explicit migration ownership",
const handle = createApiDatabase(database.url);
expect(handle.module).toBeDefined();
const alpha = handle.moduleExecutor("alpha");
const beta = handle.moduleExecutor("beta");
expect(alpha).not.toBe(beta);
expect(moduleDatabaseExecutorModuleId(alpha)).toBe("alpha");
expect(moduleDatabaseExecutorModuleId(beta)).toBe("beta");
await handle.close();
expect(await tableExists(database.url)).toBe(false);
+1 -1
View File
@@ -105,7 +105,7 @@ async function createModule(modulesDirectory: string, fixture: ModuleFixture = {
: {}),
},
id,
manifestVersion: 7,
manifestVersion: 8,
name: `${id} module`,
permissions: fixture.display
? [
+41 -10
View File
@@ -6,8 +6,8 @@ import {
type ModuleRouteHandlerContext,
type ModuleRuntimeServices,
moduleRoutesOverlap,
parsePermissionId,
type PermissionId,
parsePermissionId,
type RegisteredModuleHttpRoute,
} from "@ekklesia/contracts";
import {
@@ -20,20 +20,21 @@ import type { EkklesiaIdentityRuntime } from "@ekklesia/core/identity";
import {
createModuleUiPublicationRegistry,
EMPTY_MODULE_REALTIME_REGISTRY,
type ModuleRuntimeServicesLookup,
} from "@ekklesia/core/modules";
import { describe, expect, it, vi } from "vitest";
import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
import { testDashboardHostAssets } from "./support/dashboard-host-assets.js";
import { readyHealth } from "./support/ready-health.js";
import { createApiApp } from "../src/app.js";
import {
canonicalizeVaryHeader,
mergedVaryHeader,
ModuleRouteConfigurationError,
mergedVaryHeader,
} from "../src/module-http-routes.js";
import { emptyModuleLifecycle } from "./support/in-memory-module-lifecycle.js";
import { testDashboardHostAssets } from "./support/dashboard-host-assets.js";
import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
import { emptyModuleConfiguration } from "./support/in-memory-module-configuration.js";
import { emptyModuleLifecycle } from "./support/in-memory-module-lifecycle.js";
import { emptyModuleStorage } from "./support/in-memory-module-storage.js";
import { readyHealth } from "./support/ready-health.js";
const trustedOrigin = "https://dashboard.example.test";
const untrustedOrigin = "https://evil.example.test";
@@ -107,7 +108,7 @@ function buildModuleApp(options: {
readonly registry: PermissionRegistry;
readonly routes: readonly RegisteredModuleHttpRoute[];
readonly runtime?: EkklesiaIdentityRuntime;
readonly services?: ModuleRuntimeServices;
readonly services?: ModuleRuntimeServices | ModuleRuntimeServicesLookup;
readonly trustedOrigins?: readonly string[];
}) {
const evaluator = options.evaluator ?? {
@@ -145,6 +146,36 @@ function permissionRegistry(
}
describe("module HTTP route mounting", () => {
it("selects the owning module's stable service entry for its handler", async () => {
const calendar = fakeModuleServices();
const people = fakeModuleServices();
const services: ModuleRuntimeServicesLookup = new Map([
["calendar", calendar],
["people", people],
]);
let received: unknown;
const { app } = buildModuleApp({
registry: permissionRegistry([{ id: readPermission, baseline: "public" }]),
routes: [
moduleRoute({
handler: (_request, context) => {
received = context.services;
return Response.json({ ok: true });
},
method: "GET",
moduleId: "calendar",
path: "/services",
permission: readPermission,
}),
],
services,
});
expect((await app.request("/api/v1/modules/calendar/services")).status).toBe(200);
expect(received).toBe(calendar);
expect(received).not.toBe(people);
});
it('mounts a "/" route at exactly the module root, never with an added trailing slash', async () => {
const handler = vi.fn<ModuleRouteHandler>(async () => new Response("root", { status: 200 }));
const { app } = buildModuleApp({
@@ -508,7 +539,7 @@ describe("module route authorization", () => {
expect(response.status).toBe(403);
expect(await response.json()).toEqual({ code: "AUTHORIZATION_DENIED" });
expect(handler).not.toHaveBeenCalled();
expectDatabaseFakeUntouched(services);
expectDatabaseFakeUntouched(services as ModuleRuntimeServices);
});
it("does not invoke the handler when principal resolution fails", async () => {
@@ -637,7 +668,7 @@ describe("module route trusted-origin enforcement", () => {
}
expect(handler).not.toHaveBeenCalled();
expect(evaluator.authorize).not.toHaveBeenCalled();
expectDatabaseFakeUntouched(services);
expectDatabaseFakeUntouched(services as ModuleRuntimeServices);
});
it("allows a trusted-origin state-changing request through to the handler", async () => {
@@ -766,7 +797,7 @@ describe("module route CORS", () => {
expect(handler).not.toHaveBeenCalled();
expect(evaluator.authorize).not.toHaveBeenCalled();
expectDatabaseFakeUntouched(services);
expectDatabaseFakeUntouched(services as ModuleRuntimeServices);
});
it("never reflects an untrusted origin for a module route", async () => {
@@ -79,7 +79,7 @@ async function createModule(modulesDirectory: string, fixture: ModuleFixture): P
...(fixture.dependencies ? { dependencies: fixture.dependencies } : {}),
entrypoints: { server: "./dist/server/index.js", ui: "./dist/ui/index.js" },
id: fixture.id,
manifestVersion: 7,
manifestVersion: 8,
name: `${fixture.id} module`,
version: "1.0.0",
}),
+1 -1
View File
@@ -188,7 +188,7 @@ async function createFixtureModule(control: FixtureControl): Promise<{
compatibility: { moduleSdk: "^0.1.0", platform: "^0.1.0" },
entrypoints: { server: "./dist/server/index.js" },
id: "announcements",
manifestVersion: 7,
manifestVersion: 8,
name: "Announcements fixture",
permissions: [
{
+1 -1
View File
@@ -129,7 +129,7 @@ async function createFixtureModule(
: {}),
},
id,
manifestVersion: 7,
manifestVersion: 8,
name,
permissions: [
{
+1 -1
View File
@@ -99,7 +99,7 @@ async function createModule(modulesDirectory: string, fixture: ModuleFixture): P
version: "1.0.0",
})),
id: fixture.id,
manifestVersion: 7,
manifestVersion: 8,
name: `${fixture.id} module`,
permissions: (fixture.permissions ?? []).map((permission) => ({
...permission,
+1 -1
View File
@@ -139,7 +139,7 @@ async function createModule(modulesDirectory: string, fixture: ModuleFixture): P
version: "1.0.0",
})),
id: fixture.id,
manifestVersion: 7,
manifestVersion: 8,
name: `${fixture.id} module`,
permissions:
fixture.display === undefined
+1
View File
@@ -1,6 +1,7 @@
{
"name": "@ekklesia/core",
"version": "0.1.2",
"license": "AGPL-3.0-or-later",
"private": true,
"type": "module",
"files": [
+23 -3
View File
@@ -8,6 +8,7 @@ import {
formatDoctorReport,
} from "../diagnostics/index.js";
import { type HostEnvironment, loadHostConfiguration } from "../host/index.js";
import { type ModulesDirectorySelection, resolveModulesDirectory } from "../modules/index.js";
interface ModuleSdkPackageDocument {
readonly version?: unknown;
@@ -25,8 +26,10 @@ interface RootPackageDocument extends ModuleSdkPackageDocument {
*/
export interface DoctorCommandMetadata {
readonly moduleSdkVersion: string;
readonly modulesDirectory: string;
readonly platformVersion: string;
/** Where the platform is installed: Core's migrations live beneath it, and so does the
* development default modules directory. Installed modules may live anywhere else. */
readonly repositoryRoot: string;
readonly runtimeRange: string;
readonly runtimeVersion: string;
}
@@ -49,6 +52,22 @@ function requiredString(value: unknown): string {
return value;
}
/**
* The modules directory exactly as `startApi` would choose it. An unusable configured value is not
* thrown here, because Doctor must still produce a report: it is handed on as a required directory
* for discovery to refuse, which reports it as a sanitized module-discovery failure.
*/
function doctorModulesDirectory(
configured: string | undefined,
repositoryRoot: string,
): ModulesDirectorySelection {
try {
return resolveModulesDirectory({ configured, repositoryRoot });
} catch {
return { modulesDirectory: configured ?? "", modulesDirectoryRequired: true };
}
}
/**
* Turns one deployment environment into Doctor's inputs.
*
@@ -69,6 +88,7 @@ export function createDoctorOptions(
const host = loadHostConfiguration(environment);
return {
clientAddress: host.clientAddress,
coreMigrationsDirectory: path.join(metadata.repositoryRoot, "core", "drizzle"),
databaseEnvironment: host.environment,
databaseTransport: host.databaseTransport,
databaseUrl: host.databaseUrl,
@@ -76,7 +96,7 @@ export function createDoctorOptions(
mailConfiguration: host.mail,
moduleDiscovery: {
moduleSdkVersion: metadata.moduleSdkVersion,
modulesDirectory: metadata.modulesDirectory,
...doctorModulesDirectory(host.modulesDirectory, metadata.repositoryRoot),
platformVersion: metadata.platformVersion,
},
moduleSecrets: host.moduleSecrets,
@@ -97,8 +117,8 @@ export async function runDoctorCommand(repositoryRoot = process.cwd()): Promise<
const report = await diagnoseEkklesia(
createDoctorOptions(process.env, {
moduleSdkVersion: requiredString(moduleSdkPackage.version),
modulesDirectory: path.join(repositoryRoot, "modules"),
platformVersion: requiredString(rootPackage.version),
repositoryRoot,
runtimeRange: requiredString(rootPackage.engines?.node),
runtimeVersion: process.versions.node,
}),
+10 -1
View File
@@ -1,7 +1,9 @@
import { readFile } from "node:fs/promises";
import path from "node:path";
import type { HostEnvironment } from "../host/index.js";
import { discoverAllMigrations } from "../migrations/index.js";
import type { ModuleMigrationAggregate } from "../modules/index.js";
import { resolveCommandModulesDirectory } from "./modules-directory-environment.js";
interface PackageVersion {
readonly version?: unknown;
@@ -15,9 +17,16 @@ async function readPackageVersion(packagePath: string): Promise<string> {
return packageData.version;
}
/**
* Core's migrations come from the platform (`<repositoryRoot>/core/drizzle`); installed modules'
* come from the configured modules directory, wherever it is (ADR 0048). Neither is ever derived
* from the other.
*/
export async function discoverRepositoryMigrations(
repositoryRoot = process.cwd(),
environment: HostEnvironment = process.env,
): Promise<ModuleMigrationAggregate> {
const modulesDirectory = resolveCommandModulesDirectory(environment, repositoryRoot);
const [platformVersion, moduleSdkVersion] = await Promise.all([
readPackageVersion(path.join(repositoryRoot, "package.json")),
readPackageVersion(path.join(repositoryRoot, "packages/module-sdk/package.json")),
@@ -27,7 +36,7 @@ export async function discoverRepositoryMigrations(
coreMigrationsDirectory: path.join(repositoryRoot, "core", "drizzle"),
moduleDiscovery: {
moduleSdkVersion,
modulesDirectory: path.join(repositoryRoot, "modules"),
...modulesDirectory,
platformVersion,
},
});
+21 -3
View File
@@ -1,5 +1,9 @@
import type { ModuleMigrationAggregate } from "../modules/index.js";
import type { MigrationApplyResult, MigrationPlanResult } from "../migrations/index.js";
import type {
MigrationApplyResult,
MigrationPlanResult,
RetainedMigrationHistory,
} from "../migrations/index.js";
function plural(count: number, singular: string): string {
return count === 1 ? singular : `${singular}s`;
@@ -28,10 +32,19 @@ export function formatMigrationValidationReport(
return Object.freeze(lines);
}
/** An absent module's applied history (ADR 0048): kept, reported, and never a reason to stop. */
function retainedLines(retained: readonly RetainedMigrationHistory[]): readonly string[] {
return retained.map(
(module) =>
`- [retained] ${module.moduleId}: ${module.appliedCount} applied ${plural(module.appliedCount, "migration")} kept; the module is not installed`,
);
}
export function formatMigrationPlanReport(plan: MigrationPlanResult): readonly string[] {
const readyCount = plan.pendingCount - plan.approvalRequiredCount;
const retainedSummary = plan.retained.length === 0 ? "" : `, ${plan.retained.length} retained`;
const lines = [
`Migration plan (${plan.appliedCount} applied, ${readyCount} pending, ${plan.approvalRequiredCount} approval-blocked):`,
`Migration plan (${plan.appliedCount} applied, ${readyCount} pending, ${plan.approvalRequiredCount} approval-blocked${retainedSummary}):`,
];
for (const entry of plan.entries) {
@@ -40,6 +53,7 @@ export function formatMigrationPlanReport(plan: MigrationPlanResult): readonly s
if (plan.entries.length === 0) {
lines.push("- No installed module migrations.");
}
lines.push(...retainedLines(plan.retained));
lines.push(
"Informational only: this plan is read-only and may become stale; apply revalidates history under the migration lock.",
);
@@ -49,7 +63,10 @@ export function formatMigrationPlanReport(plan: MigrationPlanResult): readonly s
export function formatMigrationApplyReport(result: MigrationApplyResult): readonly string[] {
if (result.noOp) {
return Object.freeze(["Migration apply completed: database is already current (no changes)."]);
return Object.freeze([
"Migration apply completed: database is already current (no changes).",
...retainedLines(result.retained),
]);
}
const lines = [
@@ -58,5 +75,6 @@ export function formatMigrationApplyReport(result: MigrationApplyResult): readon
for (const migration of result.applied) {
lines.push(`- ${migration.identity} sha256=${migration.checksum}`);
}
lines.push(...retainedLines(result.retained));
return Object.freeze(lines);
}
@@ -0,0 +1,17 @@
import { type HostEnvironment, loadHostConfiguration } from "../host/index.js";
import { type ModulesDirectorySelection, resolveModulesDirectory } from "../modules/index.js";
/**
* The modules directory an operator command discovers, read through the same host configuration
* `startApi` and Doctor use and resolved by the one Core resolver (ADR 0048). Throws the resolver's
* sanitized `ModuleDiscoveryError` for an unusable value, which every command already reports.
*/
export function resolveCommandModulesDirectory(
environment: HostEnvironment,
repositoryRoot: string,
): ModulesDirectorySelection {
return resolveModulesDirectory({
configured: loadHostConfiguration(environment).modulesDirectory,
repositoryRoot,
});
}
+2 -1
View File
@@ -12,6 +12,7 @@ import {
resolveModuleEligibility,
} from "../modules/index.js";
import { resolveExecutableDatabaseTransport } from "./database-transport-environment.js";
import { resolveCommandModulesDirectory } from "./modules-directory-environment.js";
/**
* Enables or disables one installed module, out of band from the running API.
@@ -99,7 +100,7 @@ async function main(): Promise<void> {
// configurable here, which is the case this command exists for.
const discovered = await discoverModules({
moduleSdkVersion,
modulesDirectory: path.join(repositoryRoot, "modules"),
...resolveCommandModulesDirectory(process.env, repositoryRoot),
platformVersion,
});
if (!discovered.some((module) => module.manifest.id === moduleId)) {
+2 -1
View File
@@ -9,6 +9,7 @@ import {
ModulePermissionCompositionError,
ModuleSchemaDiscoveryError,
} from "../modules/index.js";
import { resolveCommandModulesDirectory } from "./modules-directory-environment.js";
interface PackageVersion {
version?: unknown;
@@ -30,7 +31,7 @@ async function main(): Promise<void> {
]);
const schemaAggregate = await discoverModuleSchemas({
moduleSdkVersion,
modulesDirectory: path.join(repositoryRoot, "modules"),
...resolveCommandModulesDirectory(process.env, repositoryRoot),
platformVersion,
});
const modules = schemaAggregate.discoveredModules;
+175
View File
@@ -0,0 +1,175 @@
import { readFile } from "node:fs/promises";
import path from "node:path";
import { pathToFileURL } from "node:url";
import {
type ModulePackagePlatform,
ModulePackageVerificationError,
verifyModulePackage,
} from "../modules/index.js";
/**
* `verify-module-package` — verifies an Ekklesia Module Package v1 archive against this platform
* (ADR 0049), and never installs it.
*
* node core/dist/cli/verify-module-package.js <archive>
* [--sha256 <hex> | --sha256-file <file>] the archive's expected SHA-256
* [--extract-to <new directory>] write the verified module directory there
*
* Run from the platform's root (`/opt/ekklesia` in the image), like every operator command: the
* platform's own versions are read from there. `--extract-to` writes only after every check passed,
* into a directory that must not exist yet; it is the staging step of an installation, not the
* installation itself.
*/
class VerifyArgumentsError extends Error {}
const USAGE =
"Usage: verify-module-package <archive> [--sha256 <hex> | --sha256-file <file>] [--extract-to <new directory>]";
export interface VerifyModulePackageArguments {
readonly archivePath: string;
readonly expectedSha256?: string;
readonly sha256File?: string;
readonly extractTo?: string;
}
function normalizedDigest(value: string): string {
const digest = value.trim().toLowerCase();
if (!/^[0-9a-f]{64}$/u.test(digest)) {
throw new VerifyArgumentsError("The SHA-256 must be 64 hexadecimal characters.");
}
return digest;
}
export function parseVerifyModulePackageArguments(
argv: readonly string[],
): VerifyModulePackageArguments {
const positional: string[] = [];
const options = new Map<string, string>();
for (let index = 0; index < argv.length; index += 1) {
const argument = argv[index] ?? "";
if (argument.startsWith("--")) {
if (!["--sha256", "--sha256-file", "--extract-to"].includes(argument)) {
throw new VerifyArgumentsError(USAGE);
}
const value = argv[index + 1];
if (value === undefined || value.startsWith("--") || options.has(argument)) {
throw new VerifyArgumentsError(USAGE);
}
options.set(argument, value);
index += 1;
} else {
positional.push(argument);
}
}
const archivePath = positional[0];
if (positional.length !== 1 || archivePath === undefined || archivePath === "") {
throw new VerifyArgumentsError(USAGE);
}
if (options.has("--sha256") && options.has("--sha256-file")) {
throw new VerifyArgumentsError(USAGE);
}
const sha256 = options.get("--sha256");
const sha256File = options.get("--sha256-file");
const extractTo = options.get("--extract-to");
return {
archivePath,
...(sha256 === undefined ? {} : { expectedSha256: normalizedDigest(sha256) }),
...(sha256File === undefined ? {} : { sha256File }),
...(extractTo === undefined ? {} : { extractTo }),
};
}
/** The `sha256sum` format: one line, the digest, whitespace, and a file name that is never trusted. */
export function parseSha256File(contents: string): string {
const lines = contents.split("\n").filter((line) => line.trim() !== "");
const match = lines.length === 1 ? /^([0-9a-fA-F]{64})\s+\*?\S/u.exec(lines[0] ?? "") : null;
if (!match?.[1]) {
throw new VerifyArgumentsError("The SHA-256 file must hold one sha256sum line.");
}
return match[1].toLowerCase();
}
async function readJson(file: string): Promise<Record<string, unknown>> {
return JSON.parse(await readFile(file, "utf8")) as Record<string, unknown>;
}
function requiredString(value: unknown): string {
if (typeof value !== "string" || value === "") {
throw new VerifyArgumentsError("The platform's own package metadata could not be read.");
}
return value;
}
/** What this platform is, read from where it is installed. The transitional database runtime is
* the Drizzle version Core itself depends on (ADR 0049). */
export async function readModulePackagePlatform(
repositoryRoot: string,
): Promise<ModulePackagePlatform> {
const [root, sdk, core] = await Promise.all([
readJson(path.join(repositoryRoot, "package.json")),
readJson(path.join(repositoryRoot, "packages", "module-sdk", "package.json")),
readJson(path.join(repositoryRoot, "core", "package.json")),
]);
const dependencies = core.dependencies as Record<string, unknown> | undefined;
return {
databaseRuntime: { "drizzle-orm": requiredString(dependencies?.["drizzle-orm"]) },
moduleSdkVersion: requiredString(sdk.version),
platformVersion: requiredString(root.version),
};
}
async function main(): Promise<void> {
const parsed = parseVerifyModulePackageArguments(process.argv.slice(2));
let expectedSha256 = parsed.expectedSha256;
if (parsed.sha256File !== undefined) {
let contents: string;
try {
contents = await readFile(parsed.sha256File, "utf8");
} catch {
throw new VerifyArgumentsError("The SHA-256 file could not be read.");
}
expectedSha256 = parseSha256File(contents);
}
const platform = await readModulePackagePlatform(process.cwd()).catch(() => {
throw new VerifyArgumentsError("The platform's own package metadata could not be read.");
});
const result = await verifyModulePackage({
archivePath: parsed.archivePath,
platform,
...(expectedSha256 === undefined ? {} : { expectedSha256 }),
...(parsed.extractTo === undefined ? {} : { extractTo: parsed.extractTo }),
});
console.log(
`Module package verified: ${result.moduleId}@${result.version} (target ${result.target}), ${result.fileCount} files, sha256 ${result.archiveSha256}.`,
);
if (expectedSha256 === undefined) {
console.log("The archive's SHA-256 was not checked: no expected digest was given.");
}
if (parsed.extractTo !== undefined) {
console.log("The verified module directory was extracted. Nothing was installed.");
}
}
function isDirectExecution(): boolean {
const entrypoint = process.argv[1];
return (
entrypoint !== undefined && import.meta.url === pathToFileURL(path.resolve(entrypoint)).href
);
}
if (isDirectExecution()) {
main().catch((error: unknown) => {
if (error instanceof ModulePackageVerificationError) {
console.error(`[${error.code}] ${error.message}`);
if (error.issueCodes.length > 0) {
console.error(`Module validation issues: ${error.issueCodes.join(", ")}`);
}
} else if (error instanceof VerifyArgumentsError) {
console.error(error.message);
} else {
console.error("Module package verification failed unexpectedly.");
}
process.exitCode = 1;
});
}
+35 -17
View File
@@ -1,4 +1,3 @@
import path from "node:path";
import {
capabilityIdSchema,
EMPTY_MODULE_SECRET_SOURCE,
@@ -43,6 +42,7 @@ import {
} from "../mail/index.js";
import {
createPostgresMigrationDatabase,
type DiscoverAllMigrationsOptions,
discoverAllMigrations,
type MigrationDatabase,
type MigrationPlanResult,
@@ -64,6 +64,7 @@ import {
isConfigurationBlockedDetail,
isStorageBlockedDetail,
type ModuleConfigurationSettings,
ModuleDiscoveryError,
type ModuleDiscoveryOptions,
type ModuleEligibility,
type ModuleLifecycleConfiguration,
@@ -126,6 +127,9 @@ export interface DoctorIdentityConfigurationOptions {
export interface DoctorOptions {
readonly clientAddress?: ClientAddressConfigInput | undefined;
/** Core's own migrations, where the platform is installed. Never derived from the modules
* directory: installed modules live in installation-owned storage, anywhere at all (ADR 0048). */
readonly coreMigrationsDirectory: string;
/** The environment the executable resolved, used only for transport policy evaluation; identity
* configuration keeps its own `identityConfiguration.environment` for the same purpose. */
readonly databaseEnvironment?: DatabaseTransportEnvironment | undefined;
@@ -170,7 +174,7 @@ export interface DoctorDependencies {
options: PostgresMigrationDatabaseOptions,
) => MigrationDatabase;
readonly discoverMigrations: (
options: ModuleDiscoveryOptions,
options: DiscoverAllMigrationsOptions,
) => Promise<ModuleMigrationAggregate>;
readonly discoverModules: (
options: ModuleDiscoveryOptions,
@@ -207,15 +211,6 @@ const checksumPattern = /^[a-f0-9]{64}$/u;
const migrationIdentityPattern =
/^[a-z][a-z0-9]*(?:-[a-z0-9]+)*:\d{4,}_[a-z][a-z0-9]*(?:_[a-z0-9]+)*$/u;
async function discoverAllMigrationsForRuntime(
options: ModuleDiscoveryOptions,
): Promise<ModuleMigrationAggregate> {
return discoverAllMigrations({
coreMigrationsDirectory: path.join(path.dirname(options.modulesDirectory), "core", "drizzle"),
moduleDiscovery: options,
});
}
/** Opens its own short-lived connection, reads, and always closes it — Doctor never leaves a pool
* behind, and never shares one with the migration planner's, whose lifetime it does not control. */
async function readModuleLifecycleForRuntime(
@@ -273,7 +268,7 @@ async function probeMailForRuntime(config: MailProviderConfig): Promise<void> {
const productionDependencies: DoctorDependencies = Object.freeze({
composePermissions: composePlatformPermissionRegistry,
createMigrationDatabase: createPostgresMigrationDatabase,
discoverMigrations: discoverAllMigrationsForRuntime,
discoverMigrations: discoverAllMigrations,
discoverModules,
discoverSchemas: discoverModuleSchemas,
planMigrations: planModuleMigrations,
@@ -633,11 +628,23 @@ function storageBindingsCheck(
details,
);
}
function moduleDiscoveryFailureDetail(error: unknown): string {
if (!(error instanceof ModuleDiscoveryError)) {
return "Static module discovery could not be completed.";
}
// The modules directory itself, rather than anything in it (ADR 0048). Fixed wording, never the
// configured value: a path is machine detail.
if (error.issues.some((issue) => issue.code === "MODULES_DIRECTORY_INVALID")) {
return "The configured modules directory is not usable.";
}
if (error.issues.some((issue) => issue.code === "MODULES_DIRECTORY_UNREADABLE")) {
return "The configured modules directory could not be read.";
}
return "The installed module inventory is invalid.";
}
function moduleDiscoveryFailure(error: unknown): DoctorCheck {
const details =
error instanceof Error && error.name === "ModuleDiscoveryError"
? ["The installed module inventory is invalid."]
: ["Static module discovery could not be completed."];
const details = [moduleDiscoveryFailureDetail(error)];
return freezeCheck("modules.discovery", "failure", "Module discovery failed", details);
}
@@ -733,6 +740,14 @@ function migrationPlanCheck(plan: MigrationPlanResult): DoctorCheck {
}
return `${entry.status} · ${safeMigrationReference(entry.identity, entry.checksum)}`;
}),
// An absent module's applied history (ADR 0048) is information, never attention: removing a
// module's code keeps its data, and nothing here is pending.
...plan.retained.map((module) => {
if (!/^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/u.test(module.moduleId)) {
throw new TypeError("Unsafe migration plan diagnostic value.");
}
return `retained · ${module.moduleId} · ${module.appliedCount} applied ${plural(module.appliedCount, "migration")} kept; the module is not installed`;
}),
];
if (pendingCount + blockedCount === 0) {
@@ -1132,7 +1147,10 @@ export async function diagnoseEkklesia(
}
try {
migrations = await dependencies.discoverMigrations(options.moduleDiscovery);
migrations = await dependencies.discoverMigrations({
coreMigrationsDirectory: options.coreMigrationsDirectory,
moduleDiscovery: options.moduleDiscovery,
});
const inventory = migrations.migrations.map((migration) =>
safeMigrationReference(migration.identity, migration.checksum),
);
+5
View File
@@ -59,6 +59,10 @@ export interface HostConfiguration {
/** The deployment's module secrets, resolvable only under names Core derives from a module's own
* declared keys — there is no arbitrary variable lookup. */
readonly moduleSecrets: ModuleSecretSource;
/** `EKKLESIA_MODULES_DIR`, raw and unvalidated: `undefined` only when the variable is unset. Every
* executable passes it to `resolveModulesDirectory`, the one place it is interpreted (ADR 0048),
* so a relative or blank value is refused there rather than repaired here. */
readonly modulesDirectory: string | undefined;
readonly storage: StorageProviderConfigInput;
}
@@ -144,6 +148,7 @@ export function loadHostConfiguration(environment: HostEnvironment): HostConfigu
username: environment.EKKLESIA_SMTP_USERNAME,
},
moduleSecrets: createEnvironmentModuleSecretSource(environment),
modulesDirectory: environment.EKKLESIA_MODULES_DIR,
// The `S3_*` names ADR 0004 already established for this repository's Compose stack. Absent
// endpoint means this installation has no object storage, which is a complete deployment
// (ADR 0030).
+71 -9
View File
@@ -2,6 +2,7 @@ import type {
DiscoveredModuleMigration,
ModuleMigrationAggregate,
} from "../modules/migration-discovery.js";
import { CORE_MIGRATION_SOURCE_ID } from "./core-source.js";
const DEFAULT_MIGRATION_LOCK_TIMEOUT_MS = 10_000;
@@ -86,11 +87,24 @@ export interface MigrationPlanEntry {
readonly tag: string;
}
/**
* Applied history kept for a module that is not installed (ADR 0048): the module id and how many of
* its migrations were applied. It is reported, never matched against an inventory, and never a
* reason to refuse planning or applying anything else.
*/
export interface RetainedMigrationHistory {
readonly appliedCount: number;
readonly moduleId: string;
}
export interface MigrationPlanResult {
readonly appliedCount: number;
readonly approvalRequiredCount: number;
readonly entries: readonly MigrationPlanEntry[];
readonly pendingCount: number;
/** Absent modules' applied history, ordered by module id; empty when every applied migration
* belongs to Core or an installed module. */
readonly retained: readonly RetainedMigrationHistory[];
}
export interface AppliedMigrationResult {
@@ -105,6 +119,8 @@ export interface MigrationApplyResult {
readonly applied: readonly AppliedMigrationResult[];
readonly appliedCount: number;
readonly noOp: boolean;
/** The same report as `MigrationPlanResult.retained`, read under the migration lock. */
readonly retained: readonly RetainedMigrationHistory[];
}
export interface PlanModuleMigrationsOptions {
@@ -204,10 +220,38 @@ function validateHistoryRecordShape(record: AppliedMigrationRecord): void {
}
}
interface ValidatedAppliedHistory {
/** Identities of applied migrations that belong to Core or an installed module. */
readonly applied: ReadonlySet<string>;
readonly retained: readonly RetainedMigrationHistory[];
}
/**
* Every module id this inventory treats as installed: each discovered module, whether or not it
* owns migrations, every module that contributed migrations, and always Core. A module counts as
* absent only when it is none of these, so a module that is installed but no longer ships its old
* migrations stays strict, and Core's history can never be classified as an absent module's.
*/
function installedMigrationSources(aggregate: ModuleMigrationAggregate): ReadonlySet<string> {
return new Set<string>([
CORE_MIGRATION_SOURCE_ID,
...aggregate.discoveredModules.map((module) => module.manifest.id),
...aggregate.modules.map((module) => module.moduleId),
]);
}
function freezeRetained(counts: ReadonlyMap<string, number>): readonly RetainedMigrationHistory[] {
return Object.freeze(
[...counts.entries()]
.sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0))
.map(([moduleId, appliedCount]) => Object.freeze({ appliedCount, moduleId })),
);
}
function validateAppliedHistoryUnchecked(
aggregate: ModuleMigrationAggregate,
history: readonly AppliedMigrationRecord[],
): ReadonlySet<string> {
): ValidatedAppliedHistory {
if (!Array.isArray(history)) {
throw runnerError("MIGRATION_HISTORY_INVALID");
}
@@ -215,6 +259,9 @@ function validateAppliedHistoryUnchecked(
const inventory = new Map(
aggregate.migrations.map((migration) => [migration.identity, migration]),
);
const installed = installedMigrationSources(aggregate);
const applied = new Set<string>();
const retained = new Map<string, number>();
const seenOrders = new Set<number>();
const seenIdentities = new Set<string>();
const seenModuleIndexes = new Set<string>();
@@ -240,7 +287,19 @@ function validateAppliedHistoryUnchecked(
const migration = knownMigration(inventory, record.identity);
if (!migration) {
throw runnerError("MIGRATION_UNKNOWN_APPLIED");
if (installed.has(record.moduleId)) {
// Core, or a module that is installed now: its history is strict, exactly as ADR 0009
// established. A migration it no longer ships is a downgrade or a rewritten package.
throw runnerError("MIGRATION_UNKNOWN_APPLIED");
}
// An absent module's history (ADR 0048): retained and reported, never repaired, deleted, or
// matched against anything. It must still name its own module and tag, so a corrupt row is
// never mistaken for one.
if (record.identity !== `${record.moduleId}:${record.tag}`) {
throw runnerError("MIGRATION_HISTORY_INVALID");
}
retained.set(record.moduleId, (retained.get(record.moduleId) ?? 0) + 1);
continue;
}
if (
record.moduleId !== migration.moduleId ||
@@ -252,11 +311,12 @@ function validateAppliedHistoryUnchecked(
if (record.checksum !== migration.checksum) {
throw runnerError("MIGRATION_CHECKSUM_MISMATCH", migration.identity);
}
applied.add(record.identity);
}
for (const module of aggregate.modules) {
const applied = history.filter((record) => record.moduleId === module.moduleId);
for (const [position, record] of applied.entries()) {
const moduleHistory = history.filter((record) => record.moduleId === module.moduleId);
for (const [position, record] of moduleHistory.entries()) {
if (record.identity !== module.migrations[position]?.identity) {
const migration = knownMigration(inventory, record.identity);
throw runnerError("MIGRATION_HISTORY_INVALID", migration?.identity);
@@ -264,13 +324,13 @@ function validateAppliedHistoryUnchecked(
}
}
return seenIdentities;
return Object.freeze({ applied, retained: freezeRetained(retained) });
}
function validateAppliedHistory(
aggregate: ModuleMigrationAggregate,
history: readonly AppliedMigrationRecord[],
): ReadonlySet<string> {
): ValidatedAppliedHistory {
try {
return validateAppliedHistoryUnchecked(aggregate, history);
} catch (error) {
@@ -285,7 +345,7 @@ function planFromHistory(
aggregate: ModuleMigrationAggregate,
history: readonly AppliedMigrationRecord[],
): MigrationPlanResult {
const applied = validateAppliedHistory(aggregate, history);
const { applied, retained } = validateAppliedHistory(aggregate, history);
const entries = Object.freeze(
aggregate.migrations.map((migration): MigrationPlanEntry => {
const isApplied = applied.has(migration.identity);
@@ -314,6 +374,7 @@ function planFromHistory(
approvalRequiredCount,
entries,
pendingCount: entries.length - appliedCount,
retained,
});
}
@@ -472,14 +533,14 @@ export async function applyModuleMigrations(
} catch {
throw runnerError("MIGRATION_HISTORY_READ_FAILED");
}
const applied = validateAppliedHistory(options.aggregate, history);
const { applied, retained } = validateAppliedHistory(options.aggregate, history);
const pending = Object.freeze(
options.aggregate.migrations.filter((migration) => !applied.has(migration.identity)),
);
await preflightApprovals(pending, options.approvals ?? denyAllMigrationApprovals);
if (pending.length === 0) {
return Object.freeze({ applied: Object.freeze([]), appliedCount: 0, noOp: true });
return Object.freeze({ applied: Object.freeze([]), appliedCount: 0, noOp: true, retained });
}
let executingIdentity: string | undefined;
@@ -508,6 +569,7 @@ export async function applyModuleMigrations(
applied: resultMigrations,
appliedCount: resultMigrations.length,
noOp: false,
retained,
});
} catch (error) {
primaryError = error;
+349
View File
@@ -0,0 +1,349 @@
import {
type ModuleDatabaseField,
ModuleDatabaseError,
type ModuleDatabaseValue,
} from "@ekklesia/contracts";
/**
* The value contract of Module Database ABI v1 (ADR 0050): how parameters are encoded before they
* reach PostgreSQL, and how result text is decoded after. Both are Core's, and neither consults
* `pg`'s process-global type registry or any ORM, which module code could change or differ from.
*
* Every value travels in PostgreSQL's text format. The session settings the decoding depends on
* (`DateStyle`, `IntervalStyle`, `TimeZone`, `bytea_output`, `extra_float_digits`) are pinned by the
* executor for every ABI operation (`./database-abi.ts`).
*/
/** PostgreSQL's limit on bound parameters in one statement (the protocol's 16-bit count). */
export const MODULE_DATABASE_MAX_PARAMETERS = 65_535;
/** PostgreSQL's limit on array dimensions (`MAXDIM`). */
const maximumArrayDimensions = 6;
function parameterInvalid(): ModuleDatabaseError {
return new ModuleDatabaseError("MODULE_DATABASE_PARAMETER_INVALID");
}
function isScalar(value: unknown): boolean {
return (
value === null ||
typeof value === "boolean" ||
typeof value === "number" ||
typeof value === "bigint" ||
typeof value === "string" ||
value instanceof Uint8Array
);
}
function scalarText(value: boolean | number | bigint | string): string {
// `String` spells NaN, Infinity, and -Infinity exactly as PostgreSQL's float and numeric input
// accepts them.
return typeof value === "string" ? value : String(value);
}
function byteaText(value: Uint8Array): string {
return `\\x${Buffer.from(value.buffer, value.byteOffset, value.byteLength).toString("hex")}`;
}
function quoteArrayElement(text: string): string {
return `"${text.replaceAll("\\", "\\\\").replaceAll('"', '\\"')}"`;
}
/**
* Validates one array level and returns its PostgreSQL array literal. Every element of a level is
* either a sub-array or a scalar, and sibling sub-arrays have equal lengths, because PostgreSQL
* arrays are rectangular. Every non-null element is quoted, which PostgreSQL accepts for any element
* type.
*/
function arrayLiteral(values: readonly unknown[], depth: number): string {
if (depth > maximumArrayDimensions) {
throw parameterInvalid();
}
const elements: string[] = [];
let nestedLength: number | undefined;
let nested: boolean | undefined;
for (let index = 0; index < values.length; index += 1) {
if (!Object.hasOwn(values, index)) {
throw parameterInvalid();
}
const element = values[index];
const isNested = Array.isArray(element);
if (nested === undefined) {
nested = isNested;
} else if (nested !== isNested) {
throw parameterInvalid();
}
if (isNested) {
if (nestedLength === undefined) {
nestedLength = element.length;
} else if (nestedLength !== element.length) {
throw parameterInvalid();
}
elements.push(arrayLiteral(element, depth + 1));
} else if (element === null) {
elements.push("NULL");
} else if (element instanceof Uint8Array) {
elements.push(quoteArrayElement(byteaText(element)));
} else if (isScalar(element)) {
elements.push(quoteArrayElement(scalarText(element as boolean | number | bigint | string)));
} else {
throw parameterInvalid();
}
}
return `{${elements.join(",")}}`;
}
/**
* Validates `values` against the ABI's parameter set and encodes each one into the only forms the
* driver then passes through unchanged: `null`, a string, or a `Buffer` copy of a `Uint8Array`.
* Anything else, including `undefined`, `Date`, plain objects, other class instances, functions,
* symbols, sparse or ragged arrays, and arrays deeper than PostgreSQL allows, is refused before
* anything reaches PostgreSQL. The error never names the value.
*/
export function encodeModuleDatabaseParameters(values: unknown): (string | Buffer | null)[] {
if (values === undefined) {
return [];
}
if (!Array.isArray(values) || values.length > MODULE_DATABASE_MAX_PARAMETERS) {
throw parameterInvalid();
}
const encoded: (string | Buffer | null)[] = [];
for (let index = 0; index < values.length; index += 1) {
if (!Object.hasOwn(values, index)) {
throw parameterInvalid();
}
const value: unknown = values[index];
if (value === null) {
encoded.push(null);
} else if (value instanceof Uint8Array) {
// A copy: the driver serializes when the statement is sent, which may be after the caller
// has changed its own bytes.
encoded.push(Buffer.from(value));
} else if (Array.isArray(value)) {
encoded.push(arrayLiteral(value, 1));
} else if (isScalar(value)) {
encoded.push(scalarText(value as boolean | number | bigint | string));
} else {
throw parameterInvalid();
}
}
return encoded;
}
// --- Results -----------------------------------------------------------------------------------
type ScalarDecoder = (text: string) => ModuleDatabaseValue;
const asText: ScalarDecoder = (text) => text;
const asBoolean: ScalarDecoder = (text) => text === "t";
const asInteger: ScalarDecoder = (text) => Number.parseInt(text, 10);
// `Number` reads PostgreSQL's NaN, Infinity, -Infinity, and exponent forms. With
// `extra_float_digits` pinned to 1 the text is the shortest form that round-trips exactly.
const asFloat: ScalarDecoder = (text) => Number(text);
function hexDigit(code: number): number {
if (code >= 48 && code <= 57) {
return code - 48;
}
if (code >= 97 && code <= 102) {
return code - 87;
}
if (code >= 65 && code <= 70) {
return code - 55;
}
return -1;
}
/**
* `bytea` text to bytes. The executor pins `bytea_output` to `hex`; the older `escape` form is read
* too, so a transaction that changed the setting still decodes faithfully rather than failing.
* Always a plain `Uint8Array`, never a `Buffer`.
*/
const asBytes: ScalarDecoder = (text) => {
if (text.startsWith("\\x")) {
const length = (text.length - 2) / 2;
const bytes = new Uint8Array(length);
for (let index = 0; index < length; index += 1) {
const high = hexDigit(text.charCodeAt(2 + index * 2));
const low = hexDigit(text.charCodeAt(3 + index * 2));
bytes[index] = high * 16 + low;
}
return bytes;
}
const bytes: number[] = [];
for (let index = 0; index < text.length; index += 1) {
const code = text.charCodeAt(index);
if (code === 92 && text.charCodeAt(index + 1) === 92) {
bytes.push(92);
index += 1;
} else if (code === 92) {
bytes.push(Number.parseInt(text.slice(index + 1, index + 4), 8));
index += 3;
} else {
bytes.push(code);
}
}
return Uint8Array.from(bytes);
};
/** Built-in scalar type OIDs with a decoding other than "PostgreSQL's text output, unchanged". Every
* type not listed here, `int8`, `numeric`, date and time types, `json`, and `jsonb` included,
* arrives as its exact text. */
const scalarDecoders: ReadonlyMap<number, ScalarDecoder> = new Map([
[16, asBoolean], // bool
[17, asBytes], // bytea
[21, asInteger], // int2
[23, asInteger], // int4
[26, asInteger], // oid
[700, asFloat], // float4
[701, asFloat], // float8
]);
/** Built-in array type OIDs, each with its element type OID. An array of a type not listed here
* (an enum, a domain, an extension type) arrives as its text, like any other type. */
const arrayElementTypes: ReadonlyMap<number, number> = new Map([
[1000, 16], // bool[]
[1001, 17], // bytea[]
[1002, 18], // "char"[]
[1003, 19], // name[]
[1005, 21], // int2[]
[1007, 23], // int4[]
[1009, 25], // text[]
[1014, 1042], // bpchar[]
[1015, 1043], // varchar[]
[1016, 20], // int8[]
[1021, 700], // float4[]
[1022, 701], // float8[]
[1028, 26], // oid[]
[1115, 1114], // timestamp[]
[1182, 1082], // date[]
[1183, 1083], // time[]
[1185, 1184], // timestamptz[]
[1187, 1186], // interval[]
[1231, 1700], // numeric[]
[1270, 1266], // timetz[]
[2951, 2950], // uuid[]
[199, 114], // json[]
[3807, 3802], // jsonb[]
]);
class ArrayTextError extends Error {}
/**
* Parses PostgreSQL's array text output into nested arrays, each element decoded by `decode`.
* Unquoted `NULL` is null; a quoted `"NULL"` is the string. A non-default lower bound
* (`[0:1]={…}`) is not represented: a JavaScript array is a zero-based list. Every element type this
* decodes uses `,` as its delimiter.
*/
export function parsePostgresArrayText(text: string, decode: ScalarDecoder): ModuleDatabaseValue[] {
let position = 0;
if (text.startsWith("[")) {
const equals = text.indexOf("=");
if (equals === -1) {
throw new ArrayTextError();
}
position = equals + 1;
}
function parseArray(): ModuleDatabaseValue[] {
if (text[position] !== "{") {
throw new ArrayTextError();
}
position += 1;
const items: ModuleDatabaseValue[] = [];
if (text[position] === "}") {
position += 1;
return items;
}
for (;;) {
const character = text[position];
if (character === "{") {
items.push(parseArray());
} else if (character === '"') {
position += 1;
let element = "";
for (;;) {
const next = text[position];
if (next === undefined) {
throw new ArrayTextError();
}
if (next === "\\") {
element += text[position + 1] ?? "";
position += 2;
} else if (next === '"') {
position += 1;
break;
} else {
element += next;
position += 1;
}
}
items.push(decode(element));
} else {
const start = position;
while (position < text.length && text[position] !== "," && text[position] !== "}") {
position += 1;
}
const element = text.slice(start, position);
items.push(element.toUpperCase() === "NULL" ? null : decode(element));
}
if (text[position] === ",") {
position += 1;
} else if (text[position] === "}") {
position += 1;
return items;
} else {
throw new ArrayTextError();
}
}
}
const parsed = parseArray();
if (position !== text.length) {
throw new ArrayTextError();
}
return parsed;
}
function scalarDecoder(typeOid: number): ScalarDecoder {
return scalarDecoders.get(typeOid) ?? asText;
}
/** Decodes one non-null column value from its PostgreSQL text by the column's type OID. */
export function decodeModuleDatabaseValue(typeOid: number, text: string): ModuleDatabaseValue {
const elementType = arrayElementTypes.get(typeOid);
if (elementType === undefined) {
return scalarDecoder(typeOid)(text);
}
try {
return parsePostgresArrayText(text, scalarDecoder(elementType));
} catch {
// PostgreSQL produced this text, so it cannot be malformed; should it ever be, the column still
// arrives faithfully, as text, rather than as a guess.
return text;
}
}
/**
* The `types` object handed to `pg` for every ABI statement. Its parser returns the raw text for
* every type, so the driver's global registry, which module code could change, is never consulted
* and the ABI decodes from text itself. It is an empty array as well because the driver also reads
* the same option as the Parse message's parameter-type list: empty, so PostgreSQL infers every
* parameter type from the statement.
*/
export const rawTextTypes: { getTypeParser: () => (text: string) => string } & never[] =
Object.freeze(
Object.assign([] as never[], {
getTypeParser: () => (text: string) => text,
}),
);
/** Decodes one positional row of raw driver text. */
export function decodeModuleDatabaseRow(
fields: readonly ModuleDatabaseField[],
raw: readonly unknown[],
): ModuleDatabaseValue[] {
return fields.map((field, index) => {
const value = raw[index];
return typeof value === "string" ? decodeModuleDatabaseValue(field.typeOid, value) : null;
});
}
+596
View File
@@ -0,0 +1,596 @@
import {
MODULE_DATABASE_ABI_VERSION,
type ModuleDatabaseArrayRow,
ModuleDatabaseError,
type ModuleDatabaseErrorCode,
type ModuleDatabaseExecutor,
type ModuleDatabaseField,
type ModuleDatabaseObjectRow,
type ModuleDatabaseQueryable,
type ModuleDatabaseQueryResult,
type ModuleDatabaseTransaction,
type ModuleId,
moduleIdSchema,
} from "@ekklesia/contracts";
import { DatabaseError, type Pool, type PoolClient, type QueryArrayConfig } from "pg";
import {
decodeModuleDatabaseRow,
encodeModuleDatabaseParameters,
rawTextTypes,
} from "./database-abi-values.js";
/**
* Core's Module Database ABI v1 executor (ADR 0050).
*
* A module hands Core SQL text, ordered parameter values, and a row mode; Core hands back decoded
* rows, column metadata, and stable errors. No ORM, driver, pool, client, or connection object ever
* crosses in either direction. The executor is built over the one pool the API already owns
* (ADR 0016) and never closes it.
*
* This is a correctness and compatibility boundary, **not a security sandbox**: a trusted module can
* still send any SQL its database role permits. Nothing here inspects table names.
*
* ### Connections and session state
*
* Every ABI operation, a top-level statement or a whole transaction, borrows a connection from the
* pool, pins the session settings the ABI's decoding depends on, does its work, and resets the
* session with `DISCARD ALL` before returning the connection. So no setting, temporary table,
* listener, advisory lock, prepared statement, or role change a module makes survives the operation
* that made it, and nothing the ABI pinned reaches the pool's other users. A connection that cannot
* be reset cleanly, or that is not idle when the operation ends, is destroyed rather than returned.
* `DISCARD ALL` also deallocates named prepared statements; Core uses none on the shared pool.
*
* ### One statement per `query`
*
* Every ABI statement is sent with PostgreSQL's extended query protocol, forced with the driver's
* `queryMode: "extended"` even when there are no parameters. PostgreSQL itself refuses a Parse
* message holding more than one statement (SQLSTATE 42601), using its own SQL grammar, so comments,
* quoted literals, and semicolons inside them are never mistaken for a second statement. Core does
* not parse SQL. A statement text that holds no statement at all is refused after PostgreSQL reports
* it empty.
*
* ### Transaction control belongs to the callbacks
*
* Transaction-control SQL (`BEGIN`, `START TRANSACTION`, `COMMIT`, `ROLLBACK`, `SAVEPOINT`,
* `RELEASE SAVEPOINT`, `PREPARE TRANSACTION`, and their aliases) is forbidden through `query`: a
* conformance rule for trusted module code, not a security guarantee. Core cannot refuse such a
* statement beforehand without parsing SQL, which it does not do, and it cannot undo one PostgreSQL
* has already executed: a `COMMIT` sent through a transaction's `query` has committed by the time
* Core sees it. What Core does is detect the violation afterwards, from what PostgreSQL itself
* reports (the statement's command tag, and the protocol's transaction status), and fail safely:
* the call rejects, an enclosing transaction is never committed by Core and rejects too, and the
* connection is destroyed rather than returned to the pool. The commit and rollback guarantees of
* the transaction API apply to conforming use.
*/
/** The settings the ABI's decoding and determinism depend on, pinned for every ABI operation. */
const pinSessionStatement =
"SET TimeZone TO 'UTC'; SET DateStyle TO 'ISO, MDY'; SET IntervalStyle TO 'postgres'; " +
"SET bytea_output TO 'hex'; SET extra_float_digits TO 1";
const resetSessionStatement = "DISCARD ALL";
const transactionControlCommands: ReadonlySet<string> = new Set([
"BEGIN",
"COMMIT",
"PREPARE",
"RELEASE",
"ROLLBACK",
"SAVEPOINT",
"START",
]);
const isolationStatements: ReadonlyMap<unknown, string> = new Map([
["read committed", "READ COMMITTED"],
["repeatable read", "REPEATABLE READ"],
["serializable", "SERIALIZABLE"],
]);
/** `pg` 8.22 reports the protocol's ReadyForQuery transaction status through this method, which
* its type declarations omit. `acquire` refuses a client without it rather than guessing. */
type AbiClient = PoolClient & { getTransactionStatus(): string | null };
interface PreparedQuery {
readonly config: QueryArrayConfig;
readonly rowMode: "array" | "object";
}
interface RawResult {
readonly command: string | null;
readonly fields: readonly { readonly dataTypeID: number; readonly name: string }[];
readonly rowCount: number | null;
readonly rows: readonly unknown[][];
}
function databaseError(code: ModuleDatabaseErrorCode, cause?: unknown): ModuleDatabaseError {
// Only PostgreSQL's own SQLSTATE survives. The driver's message, detail, hint, position, and
// object names, and every non-PostgreSQL error, are dropped here.
if (cause instanceof DatabaseError && typeof cause.code === "string") {
return new ModuleDatabaseError(code, { sqlState: cause.code });
}
return new ModuleDatabaseError(code);
}
/** A PostgreSQL error leaves the connection usable; anything else (a dropped connection, a
* client-side timeout) may leave it mid-protocol, so it must be destroyed. */
function connectionSurvives(error: unknown): boolean {
return error instanceof DatabaseError;
}
function prepareQuery(request: unknown): PreparedQuery {
if (typeof request !== "object" || request === null || Array.isArray(request)) {
throw new ModuleDatabaseError("MODULE_DATABASE_REQUEST_INVALID");
}
for (const key of Object.keys(request)) {
if (key !== "text" && key !== "values" && key !== "rowMode") {
throw new ModuleDatabaseError("MODULE_DATABASE_REQUEST_INVALID");
}
}
const { rowMode, text, values } = request as Record<string, unknown>;
if (typeof text !== "string" || text.length === 0) {
throw new ModuleDatabaseError("MODULE_DATABASE_REQUEST_INVALID");
}
if (rowMode !== undefined && rowMode !== "object" && rowMode !== "array") {
throw new ModuleDatabaseError("MODULE_DATABASE_REQUEST_INVALID");
}
const config = {
queryMode: "extended",
rowMode: "array",
text,
types: rawTextTypes,
values: encodeModuleDatabaseParameters(values),
};
return { config: config as unknown as QueryArrayConfig, rowMode: rowMode ?? "object" };
}
function beginStatement(options: unknown): string {
if (options === undefined) {
return "BEGIN ISOLATION LEVEL READ COMMITTED READ WRITE";
}
if (typeof options !== "object" || options === null || Array.isArray(options)) {
throw new ModuleDatabaseError("MODULE_DATABASE_TRANSACTION_OPTION_INVALID");
}
for (const key of Object.keys(options)) {
if (key !== "isolation" && key !== "readOnly") {
throw new ModuleDatabaseError("MODULE_DATABASE_TRANSACTION_OPTION_INVALID");
}
}
const { isolation, readOnly } = options as Record<string, unknown>;
const level = isolation === undefined ? "READ COMMITTED" : isolationStatements.get(isolation);
if (level === undefined || (readOnly !== undefined && typeof readOnly !== "boolean")) {
throw new ModuleDatabaseError("MODULE_DATABASE_TRANSACTION_OPTION_INVALID");
}
return `BEGIN ISOLATION LEVEL ${level} ${readOnly === true ? "READ ONLY" : "READ WRITE"}`;
}
function buildResult(
raw: RawResult,
rowMode: "array" | "object",
): ModuleDatabaseQueryResult<ModuleDatabaseArrayRow | ModuleDatabaseObjectRow> {
if (typeof raw.command !== "string" || raw.command === "") {
// PostgreSQL answered with EmptyQueryResponse: the text held no statement.
throw new ModuleDatabaseError("MODULE_DATABASE_REQUEST_INVALID");
}
const fields: readonly ModuleDatabaseField[] = Object.freeze(
raw.fields.map((field) => Object.freeze({ name: field.name, typeOid: field.dataTypeID })),
);
const decoded = raw.rows.map((row) => decodeModuleDatabaseRow(fields, row));
let rows: readonly (ModuleDatabaseArrayRow | ModuleDatabaseObjectRow)[];
if (rowMode === "array") {
rows = decoded;
} else {
if (new Set(fields.map((field) => field.name)).size !== fields.length) {
// Object rows cannot hold two columns with one name; refusing beats dropping one.
throw new ModuleDatabaseError("MODULE_DATABASE_REQUEST_INVALID");
}
// `Object.fromEntries` defines properties, so a column named `__proto__` stays a column.
rows = decoded.map((row) =>
Object.fromEntries(fields.map((field, index) => [field.name, row[index] ?? null])),
);
}
return Object.freeze({
command: raw.command,
fields,
rowCount: raw.rowCount ?? null,
rows: Object.freeze(rows),
});
}
/** A borrowed client's `error` events are already reported through the statement they interrupt.
* The pool only listens to idle clients, so without this a connection the server terminates while
* borrowed would surface as an unhandled `error` event. */
function ignoreClientError(): void {}
function destroy(client: AbiClient): void {
client.removeListener("error", ignoreClientError);
client.release(true);
}
async function acquire(pool: Pool, failure: ModuleDatabaseErrorCode): Promise<AbiClient> {
let client: AbiClient;
try {
client = (await pool.connect()) as AbiClient;
} catch (error) {
throw databaseError(failure, error);
}
client.on("error", ignoreClientError);
if (typeof client.getTransactionStatus !== "function") {
destroy(client);
throw new ModuleDatabaseError(failure);
}
try {
await client.query(pinSessionStatement);
} catch (error) {
destroy(client);
throw databaseError(failure, error);
}
return client;
}
/** Returns a connection to the pool only when it is idle and resets cleanly; destroys it
* otherwise. Never throws. */
async function relinquish(client: AbiClient, healthy: boolean): Promise<void> {
if (!healthy || client.getTransactionStatus() !== "I") {
destroy(client);
return;
}
try {
await client.query(resetSessionStatement);
} catch {
destroy(client);
return;
}
client.removeListener("error", ignoreClientError);
client.release();
}
/** One open scope: a transaction or a savepoint inside it. */
interface Scope {
activeChild: Scope | undefined;
readonly children: Set<Scope>;
closed: boolean;
/** Operations started through this scope's executor that have not settled yet. */
pending: number;
}
interface TransactionState {
readonly client: AbiClient;
healthy: boolean;
/** Set when the transaction can no longer be committed: transaction control sent through
* `query`, or a savepoint that could not be undone. */
failure: ModuleDatabaseError | undefined;
finished: boolean;
/** Settled-never-rejecting shadows of every statement sent on the connection. */
readonly inflight: Set<Promise<void>>;
savepoints: number;
}
function newScope(parent?: Scope): Scope {
const scope: Scope = { activeChild: undefined, children: new Set(), closed: false, pending: 0 };
parent?.children.add(scope);
return scope;
}
function closeScope(scope: Scope): void {
scope.closed = true;
for (const child of scope.children) {
closeScope(child);
}
}
function assertActive(scope: Scope): void {
if (scope.closed) {
throw new ModuleDatabaseError("MODULE_DATABASE_SCOPE_CLOSED");
}
if (scope.activeChild !== undefined) {
throw new ModuleDatabaseError("MODULE_DATABASE_SCOPE_INACTIVE");
}
}
/** Once a transaction can no longer be committed, its scopes send nothing more. After a
* transaction-control violation in particular, a later statement would otherwise run outside any
* transaction, as its own autocommitted statement. */
function assertUsable(state: TransactionState): void {
if (state.failure !== undefined) {
throw new ModuleDatabaseError(state.failure.code);
}
}
function track<T>(state: TransactionState, promise: Promise<T>): Promise<T> {
const shadow = promise.then(
() => undefined,
() => undefined,
);
state.inflight.add(shadow);
void shadow.then(() => state.inflight.delete(shadow));
return promise;
}
/** Waits until every statement already sent has settled, including any sent while waiting. */
async function drain(state: TransactionState): Promise<void> {
while (state.inflight.size > 0) {
await Promise.all([...state.inflight]);
}
}
/** Runs one of Core's own transaction-control statements. Refuses, without sending anything, once
* the transaction has finished. */
async function control(state: TransactionState, statement: string): Promise<void> {
if (state.finished) {
throw new ModuleDatabaseError("MODULE_DATABASE_SCOPE_CLOSED");
}
try {
await track(state, state.client.query(statement));
} catch (error) {
if (!connectionSurvives(error)) {
state.healthy = false;
}
throw databaseError("MODULE_DATABASE_TRANSACTION_FAILED", error);
}
}
async function rollback(state: TransactionState): Promise<void> {
try {
await state.client.query("ROLLBACK");
} catch {
state.healthy = false;
}
}
type Outcome<T> = { readonly value: T } | { readonly error: unknown };
async function settle<T>(run: () => Promise<T> | T): Promise<Outcome<T>> {
try {
return { value: await run() };
} catch (error) {
return { error };
}
}
const moduleByExecutor = new WeakMap<object, ModuleId>();
/** Which module an ABI executor, or a transaction or savepoint executor derived from it, belongs
* to. For Core's own logging, diagnostics, and attribution; never exposed to modules. */
export function moduleDatabaseExecutorModuleId(executor: object): ModuleId | undefined {
return moduleByExecutor.get(executor);
}
export interface ModuleDatabaseExecutorOptions {
/** The module this executor serves. Carried for attribution only: the ABI enforces no table
* ownership at run time (ADR 0050). */
readonly moduleId: ModuleId;
}
/**
* Creates one module's ABI v1 executor over the API's existing pool. Synchronous and free of I/O:
* nothing touches the database until the module calls it. The returned object, and every
* transaction and savepoint executor it hands out, is frozen and holds exactly the ABI's members.
*/
export function createModuleDatabaseExecutor(
pool: Pool,
options: ModuleDatabaseExecutorOptions,
): ModuleDatabaseExecutor {
const parsedModuleId = moduleIdSchema.safeParse(options.moduleId);
if (!parsedModuleId.success) {
throw new TypeError("A module database executor needs a valid module ID.");
}
const moduleId = parsedModuleId.data;
async function query(
request: unknown,
): Promise<ModuleDatabaseQueryResult<ModuleDatabaseArrayRow | ModuleDatabaseObjectRow>> {
const prepared = prepareQuery(request);
const client = await acquire(pool, "MODULE_DATABASE_QUERY_FAILED");
let healthy = true;
try {
let raw: RawResult;
try {
raw = (await client.query(prepared.config)) as unknown as RawResult;
} catch (error) {
healthy = connectionSurvives(error);
throw databaseError("MODULE_DATABASE_QUERY_FAILED", error);
}
if (
(raw.command !== null && transactionControlCommands.has(raw.command)) ||
client.getTransactionStatus() !== "I"
) {
// A transaction-control violation. PostgreSQL has already run it; the connection is
// destroyed below rather than returned in a state Core did not choose.
healthy = false;
throw new ModuleDatabaseError("MODULE_DATABASE_REQUEST_INVALID");
}
return buildResult(raw, prepared.rowMode);
} finally {
await relinquish(client, healthy);
}
}
function scopeExecutor(state: TransactionState, scope: Scope): ModuleDatabaseTransaction {
async function scopeQuery(
request: unknown,
): Promise<ModuleDatabaseQueryResult<ModuleDatabaseArrayRow | ModuleDatabaseObjectRow>> {
assertActive(scope);
assertUsable(state);
const prepared = prepareQuery(request);
scope.pending += 1;
try {
let raw: RawResult;
try {
raw = (await track(state, state.client.query(prepared.config))) as unknown as RawResult;
} catch (error) {
if (!connectionSurvives(error)) {
state.healthy = false;
}
throw databaseError("MODULE_DATABASE_QUERY_FAILED", error);
}
if (
(raw.command !== null && transactionControlCommands.has(raw.command)) ||
state.client.getTransactionStatus() === "I"
) {
// A transaction-control violation. PostgreSQL has already run it, and Core cannot undo
// that; it can only never commit this transaction itself, and destroy the connection.
state.failure ??= new ModuleDatabaseError("MODULE_DATABASE_REQUEST_INVALID");
state.healthy = false;
throw new ModuleDatabaseError("MODULE_DATABASE_REQUEST_INVALID");
}
return buildResult(raw, prepared.rowMode);
} finally {
scope.pending -= 1;
}
}
async function savepoint<T>(
callback: (savepoint: ModuleDatabaseTransaction) => Promise<T> | T,
): Promise<T> {
assertActive(scope);
assertUsable(state);
if (typeof callback !== "function") {
throw new ModuleDatabaseError("MODULE_DATABASE_REQUEST_INVALID");
}
scope.pending += 1;
const child = newScope(scope);
scope.activeChild = child;
state.savepoints += 1;
// Core's own identifier: a fixed prefix and a counter, never anything a module supplied.
const name = `ekklesia_savepoint_${state.savepoints}`;
try {
await control(state, `SAVEPOINT ${name}`);
if (child.closed) {
throw new ModuleDatabaseError("MODULE_DATABASE_SCOPE_CLOSED");
}
const outcome = await settle(() => callback(scopeExecutor(state, child)));
const outstanding = child.pending > 0;
closeScope(child);
await drain(state);
if (scope.closed) {
// The enclosing transaction finished first; it rolls back, and nothing more is sent.
throw new ModuleDatabaseError("MODULE_DATABASE_SCOPE_CLOSED");
}
if ("error" in outcome) {
// A rejected callback's own error stays the error, even if sibling work it started was
// still settling: that work has been drained, and is undone here with everything else.
try {
await control(state, `ROLLBACK TO SAVEPOINT ${name}`);
await control(state, `RELEASE SAVEPOINT ${name}`);
} catch (error) {
state.failure ??= new ModuleDatabaseError("MODULE_DATABASE_TRANSACTION_FAILED");
throw error;
}
throw outcome.error;
}
if (outstanding) {
// A callback that resolved with work outstanding forgot an await. Roll back to the
// savepoint and never release it.
try {
await control(state, `ROLLBACK TO SAVEPOINT ${name}`);
} catch (error) {
state.failure ??= new ModuleDatabaseError("MODULE_DATABASE_TRANSACTION_FAILED");
throw error;
}
throw new ModuleDatabaseError("MODULE_DATABASE_SCOPE_WORK_OUTSTANDING");
}
try {
await control(state, `RELEASE SAVEPOINT ${name}`);
} catch (releaseError) {
// Typically a failed statement left the transaction aborted. Undo the savepoint so the
// transaction stays usable, and report that this savepoint did not complete.
try {
await control(state, `ROLLBACK TO SAVEPOINT ${name}`);
await control(state, `RELEASE SAVEPOINT ${name}`);
} catch {
state.failure ??= new ModuleDatabaseError("MODULE_DATABASE_TRANSACTION_FAILED");
}
throw releaseError;
}
return (outcome as { readonly value: T }).value;
} finally {
closeScope(child);
if (scope.activeChild === child) {
scope.activeChild = undefined;
}
scope.pending -= 1;
}
}
const executor = Object.freeze({
abiVersion: MODULE_DATABASE_ABI_VERSION,
query: scopeQuery as ModuleDatabaseQueryable["query"],
savepoint,
});
moduleByExecutor.set(executor, moduleId);
return executor;
}
async function transaction<T>(
callback: (transaction: ModuleDatabaseTransaction) => Promise<T> | T,
options?: unknown,
): Promise<T> {
if (typeof callback !== "function") {
throw new ModuleDatabaseError("MODULE_DATABASE_REQUEST_INVALID");
}
const begin = beginStatement(options);
const client = await acquire(pool, "MODULE_DATABASE_TRANSACTION_FAILED");
const state: TransactionState = {
client,
failure: undefined,
finished: false,
healthy: true,
inflight: new Set(),
savepoints: 0,
};
try {
try {
await client.query(begin);
} catch (error) {
state.healthy = connectionSurvives(error);
throw databaseError("MODULE_DATABASE_TRANSACTION_FAILED", error);
}
const scope = newScope();
const outcome = await settle(() => callback(scopeExecutor(state, scope)));
const outstanding = scope.pending > 0;
closeScope(scope);
await drain(state);
state.finished = true;
if ("error" in outcome) {
// The callback's own error stays primary, even when sibling work it started (say, the
// other half of a `Promise.all`) was still settling. That work has been drained, and rolls
// back with the rest; outstanding work never leads to a commit.
await rollback(state);
throw outcome.error;
}
if (outstanding) {
// The callback resolved with work it never awaited: a forgotten `await`.
await rollback(state);
throw new ModuleDatabaseError("MODULE_DATABASE_SCOPE_WORK_OUTSTANDING");
}
if (state.failure !== undefined) {
await rollback(state);
throw state.failure;
}
let committed: { readonly command: string | null };
try {
committed = await client.query("COMMIT");
} catch (error) {
state.healthy = connectionSurvives(error);
throw databaseError("MODULE_DATABASE_TRANSACTION_FAILED", error);
}
if (committed.command !== "COMMIT") {
// PostgreSQL answers COMMIT with ROLLBACK when a failed statement had aborted the
// transaction. Nothing was committed, and PostgreSQL supplied no SQLSTATE to report.
throw new ModuleDatabaseError("MODULE_DATABASE_TRANSACTION_FAILED");
}
return outcome.value;
} finally {
state.finished = true;
await relinquish(client, state.healthy);
}
}
const executor = Object.freeze({
abiVersion: MODULE_DATABASE_ABI_VERSION,
query: query as ModuleDatabaseQueryable["query"],
transaction: transaction as ModuleDatabaseExecutor["transaction"],
});
moduleByExecutor.set(executor, moduleId);
return executor;
}
+35 -1
View File
@@ -47,6 +47,7 @@ export type ModuleDiscoveryIssueCode =
| "MODULE_PLATFORM_INCOMPATIBLE"
| "MODULE_SDK_INCOMPATIBLE"
| "MODULE_VERSION_MISMATCH"
| "MODULES_DIRECTORY_INVALID"
| "MODULES_DIRECTORY_UNREADABLE";
export interface ModuleDiscoveryIssue {
@@ -117,9 +118,35 @@ export interface DiscoveredModule {
export interface ModuleDiscoveryOptions {
readonly moduleSdkVersion: string;
readonly modulesDirectory: string;
/**
* True when an operator configured the directory explicitly (`EKKLESIA_MODULES_DIR`, ADR 0048).
* A configured directory that is missing, not a directory, or not an absolute path then fails
* discovery closed rather than reading as zero modules: a mistyped mount must never quietly start
* the platform with nothing installed. False or omitted keeps the development default lenient,
* where a checkout with no `modules/` simply has no modules.
*/
readonly modulesDirectoryRequired?: boolean;
readonly platformVersion: string;
}
/** The one sanitized message for an unusable configured modules directory. It never carries the
* value: a path is machine detail, and a misplaced value could be anything at all. */
export const MODULES_DIRECTORY_INVALID_MESSAGE = "EKKLESIA_MODULES_DIR must be an absolute path.";
/**
* Whether a configured modules directory is usable as given: a non-empty absolute path with no
* surrounding whitespace and no NUL byte. Deliberately strict rather than forgiving, because
* trimming or resolving a near-miss would silently pick a directory the operator did not name.
*/
export function isUsableModulesDirectoryPath(value: string): boolean {
return (
value.length > 0 &&
value.trim() === value &&
!value.includes("\u0000") &&
path.isAbsolute(value)
);
}
interface ModulePackageData {
name: string;
version: string;
@@ -695,6 +722,13 @@ export async function discoverModules(
});
}
}
if (options.modulesDirectoryRequired && !isUsableModulesDirectoryPath(options.modulesDirectory)) {
issues.push({
code: "MODULES_DIRECTORY_INVALID",
message: MODULES_DIRECTORY_INVALID_MESSAGE,
moduleDirectory: ".",
});
}
if (issues.length > 0) {
throw new ModuleDiscoveryError(issues);
}
@@ -703,7 +737,7 @@ export async function discoverModules(
try {
directoryEntries = await readdir(options.modulesDirectory, { withFileTypes: true });
} catch (error) {
if (errorCode(error) === "ENOENT") {
if (errorCode(error) === "ENOENT" && !options.modulesDirectoryRequired) {
return Object.freeze([]);
}
throw new ModuleDiscoveryError([
+3
View File
@@ -2,11 +2,14 @@ export * from "./configuration.js";
export * from "./configuration-schema.js";
export * from "./configuration-store.js";
export * from "./database.js";
export * from "./database-abi.js";
export * from "./discovery.js";
export * from "./lifecycle.js";
export * from "./lifecycle-schema.js";
export * from "./lifecycle-store.js";
export * from "./migration-discovery.js";
export * from "./modules-directory.js";
export * from "./module-package-verification.js";
export * from "./permissions.js";
export * from "./realtime.js";
export * from "./runtime.js";
+203
View File
@@ -0,0 +1,203 @@
import { gunzipSync } from "node:zlib";
/**
* Reads an Ekklesia Module Package v1 archive (ADR 0049) entirely in memory and enforces the
* archive-safety rules before anything is written anywhere.
*
* Deliberately a strict reader rather than a general tar implementation: v1 packages hold only
* POSIX ustar directories and regular files, so every other entry type is refused instead of
* interpreted. Nothing here touches the filesystem; extraction happens only after the caller has
* verified the whole package.
*/
export type ModulePackageArchiveErrorCode =
| "MODULE_PACKAGE_LAYOUT_INVALID"
| "MODULE_PACKAGE_LINK"
| "MODULE_PACKAGE_SPECIAL_ENTRY"
| "MODULE_PACKAGE_TOO_LARGE"
| "MODULE_PACKAGE_UNREADABLE"
| "MODULE_PACKAGE_UNSAFE_MODE"
| "MODULE_PACKAGE_UNSAFE_PATH";
export class ModulePackageArchiveError extends Error {
readonly code: ModulePackageArchiveErrorCode;
constructor(code: ModulePackageArchiveErrorCode) {
super(code);
this.name = "ModulePackageArchiveError";
this.code = code;
}
}
/** Generous for a module, small enough that a hostile archive cannot exhaust the host. */
export const MODULE_PACKAGE_MAX_COMPRESSED_BYTES = 256 * 1024 * 1024;
export const MODULE_PACKAGE_MAX_UNCOMPRESSED_BYTES = 1024 * 1024 * 1024;
export const MODULE_PACKAGE_MAX_ENTRIES = 100_000;
export interface ModulePackageArchiveEntry {
/** Relative to the top-level module directory; empty for that directory itself. */
readonly path: string;
readonly type: "directory" | "file";
readonly data: Buffer;
}
export interface ModulePackageArchive {
/** The single top-level directory's name. Validated as a module id by the caller. */
readonly topLevel: string;
/** Every entry except the top-level directory itself, in archive order. */
readonly entries: readonly ModulePackageArchiveEntry[];
}
const BLOCK = 512;
function field(block: Buffer, offset: number, length: number): string {
const raw = block.subarray(offset, offset + length);
const end = raw.indexOf(0);
return raw.subarray(0, end === -1 ? length : end).toString("latin1");
}
function octalField(block: Buffer, offset: number, length: number): number {
const text = field(block, offset, length).trim();
if (!/^[0-7]+$/u.test(text)) {
throw new ModulePackageArchiveError("MODULE_PACKAGE_UNREADABLE");
}
const value = Number.parseInt(text, 8);
if (!Number.isSafeInteger(value)) {
throw new ModulePackageArchiveError("MODULE_PACKAGE_UNREADABLE");
}
return value;
}
function headerChecksumValid(block: Buffer): boolean {
const declared = octalField(block, 148, 8);
let sum = 0;
for (let index = 0; index < BLOCK; index += 1) {
sum += index >= 148 && index < 156 ? 0x20 : (block[index] ?? 0);
}
return sum === declared;
}
/** Printable ASCII, no backslash, relative, and no empty, `.`, or `..` segment. */
function safeSegments(value: string): readonly string[] {
if (
value.length === 0 ||
!/^[\x20-\x7e]+$/u.test(value) ||
value.includes("\\") ||
value.startsWith("/")
) {
throw new ModulePackageArchiveError("MODULE_PACKAGE_UNSAFE_PATH");
}
const segments = value.split("/");
if (segments.some((segment) => segment === "" || segment === "." || segment === "..")) {
throw new ModulePackageArchiveError("MODULE_PACKAGE_UNSAFE_PATH");
}
return segments;
}
export function readModulePackageArchive(compressed: Buffer): ModulePackageArchive {
if (compressed.length > MODULE_PACKAGE_MAX_COMPRESSED_BYTES) {
throw new ModulePackageArchiveError("MODULE_PACKAGE_TOO_LARGE");
}
let raw: Buffer;
try {
raw = gunzipSync(compressed, { maxOutputLength: MODULE_PACKAGE_MAX_UNCOMPRESSED_BYTES });
} catch (error) {
throw new ModulePackageArchiveError(
error instanceof RangeError ? "MODULE_PACKAGE_TOO_LARGE" : "MODULE_PACKAGE_UNREADABLE",
);
}
const entries: ModulePackageArchiveEntry[] = [];
const kinds = new Map<string, "directory" | "file">();
/** Every path that is an ancestor of some entry, so a file can never claim one. */
const ancestors = new Set<string>();
let topLevel: string | undefined;
let offset = 0;
let ended = false;
while (offset + BLOCK <= raw.length) {
const block = raw.subarray(offset, offset + BLOCK);
if (block.every((byte) => byte === 0)) {
ended = true;
break;
}
if (field(block, 257, 6) !== "ustar" || field(block, 263, 2) !== "00") {
throw new ModulePackageArchiveError("MODULE_PACKAGE_UNREADABLE");
}
if (!headerChecksumValid(block)) {
throw new ModulePackageArchiveError("MODULE_PACKAGE_UNREADABLE");
}
if (entries.length + 1 > MODULE_PACKAGE_MAX_ENTRIES) {
throw new ModulePackageArchiveError("MODULE_PACKAGE_TOO_LARGE");
}
const typeflag = field(block, 156, 1);
if (typeflag === "1" || typeflag === "2") {
throw new ModulePackageArchiveError("MODULE_PACKAGE_LINK");
}
const type =
typeflag === "5" ? "directory" : typeflag === "0" || typeflag === "" ? "file" : undefined;
if (type === undefined) {
// Devices, FIFOs, contiguous files, and every extended or long-name header: v1 packages hold
// directories and regular files, so nothing else is interpreted.
throw new ModulePackageArchiveError("MODULE_PACKAGE_SPECIAL_ENTRY");
}
const mode = octalField(block, 100, 8);
if ((mode & 0o7000) !== 0) {
throw new ModulePackageArchiveError("MODULE_PACKAGE_UNSAFE_MODE");
}
const name = field(block, 0, 100);
const prefix = field(block, 345, 155);
let fullPath = prefix === "" ? name : `${prefix}/${name}`;
if (type === "directory" && fullPath.endsWith("/")) {
fullPath = fullPath.slice(0, -1);
}
const segments = safeSegments(fullPath);
const top = segments[0] ?? "";
if (topLevel === undefined) {
topLevel = top;
} else if (top !== topLevel) {
throw new ModulePackageArchiveError("MODULE_PACKAGE_LAYOUT_INVALID");
}
const size = type === "file" ? octalField(block, 124, 12) : 0;
const dataStart = offset + BLOCK;
const dataEnd = dataStart + size;
if (dataEnd > raw.length) {
throw new ModulePackageArchiveError("MODULE_PACKAGE_UNREADABLE");
}
const relative = segments.slice(1).join("/");
if (relative === "") {
if (type !== "directory") {
throw new ModulePackageArchiveError("MODULE_PACKAGE_LAYOUT_INVALID");
}
} else {
if (kinds.has(relative)) {
throw new ModulePackageArchiveError("MODULE_PACKAGE_LAYOUT_INVALID");
}
// No entry may sit below a file, and no file may claim a path something already sits below.
// Linear in the total number of path segments, whatever order the entries arrive in.
if (type === "file" && ancestors.has(relative)) {
throw new ModulePackageArchiveError("MODULE_PACKAGE_LAYOUT_INVALID");
}
for (let index = 1; index < segments.length - 1; index += 1) {
const ancestor = segments.slice(1, index + 1).join("/");
if (kinds.get(ancestor) === "file") {
throw new ModulePackageArchiveError("MODULE_PACKAGE_LAYOUT_INVALID");
}
ancestors.add(ancestor);
}
kinds.set(relative, type);
entries.push({ data: Buffer.from(raw.subarray(dataStart, dataEnd)), path: relative, type });
}
offset = dataStart + Math.ceil(size / BLOCK) * BLOCK;
}
if (!ended || topLevel === undefined) {
throw new ModulePackageArchiveError("MODULE_PACKAGE_UNREADABLE");
}
return Object.freeze({ entries: Object.freeze(entries), topLevel });
}
+100
View File
@@ -0,0 +1,100 @@
// The isolated, short-lived validation process of Module Package v1 verification (ADR 0049).
//
// The verifier forks this file with an empty environment and one JSON argument naming a candidate
// module's backend entrypoints. It imports each one, so a package whose code cannot load, or whose
// bundle still reaches for a dependency it does not contain, is refused before it is ever installed.
// Whatever the module's top-level code does happens here, in a process with no database URL, no
// secret, and no platform state, and that is discarded as soon as it reports.
//
// The result travels over the IPC channel, never stdout, so nothing a module prints can forge it.
// It carries fixed role and reason names only: never an error message, a path, or module output.
//
// Written as erasable TypeScript with no relative imports, so Node can run the source file directly
// in tests as well as the compiled file in the image.
import { pathToFileURL } from "node:url";
import {
parseModuleRuntimeDefinition,
parseModuleSchemaDefinition,
parseModuleSchemaMetadataDefinition,
} from "@ekklesia/contracts";
interface ProbeRequest {
readonly databaseAbi?: 1;
readonly moduleId: string;
readonly version: string;
readonly entrypoints: Readonly<Record<string, string>>;
}
interface ProbeFailure {
readonly role: string;
readonly reason: "contract" | "export" | "identity" | "load";
}
function defaultExport(namespace: unknown): unknown {
return typeof namespace === "object" && namespace !== null && "default" in namespace
? (namespace as { default: unknown }).default
: undefined;
}
async function probe(request: ProbeRequest): Promise<readonly ProbeFailure[]> {
const failures: ProbeFailure[] = [];
for (const role of Object.keys(request.entrypoints).sort()) {
const entrypoint = request.entrypoints[role];
if (entrypoint === undefined) {
continue;
}
let namespace: unknown;
try {
namespace = await import(pathToFileURL(entrypoint).href);
} catch {
failures.push({ reason: "load", role });
continue;
}
try {
if (role === "server") {
const definition = parseModuleRuntimeDefinition(defaultExport(namespace));
if (definition.id !== request.moduleId || definition.version !== request.version) {
failures.push({ reason: "identity", role });
}
if ((definition.database?.abi === 1) !== (request.databaseAbi === 1)) {
failures.push({ reason: "contract", role });
}
} else if (role === "schema") {
const definition =
request.databaseAbi === 1
? parseModuleSchemaMetadataDefinition(defaultExport(namespace))
: parseModuleSchemaDefinition(defaultExport(namespace));
if (definition.moduleId !== request.moduleId) {
failures.push({ reason: "identity", role });
}
}
} catch {
failures.push({ reason: "export", role });
}
}
return failures;
}
function report(message: unknown): void {
if (typeof process.send !== "function") {
process.exitCode = 2;
return;
}
process.send(message, () => {
// A module may leave timers or handles behind; the probe's work is done either way.
process.exit(0);
});
}
let request: ProbeRequest | undefined;
try {
request = JSON.parse(process.argv[2] ?? "") as ProbeRequest;
} catch {
report({ failures: [{ reason: "load", role: "request" }] });
}
if (request !== undefined) {
probe(request).then(
(failures) => report({ failures }),
() => report({ failures: [{ reason: "load", role: "probe" }] }),
);
}
@@ -0,0 +1,493 @@
import { fork } from "node:child_process";
import { createHash } from "node:crypto";
import { chmod, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
import {
isVersionCompatible,
MODULE_PACKAGE_ANY_TARGET,
MODULE_PACKAGE_FORMAT,
MODULE_PACKAGE_METADATA_FILENAME,
type ModuleManifest,
type ModulePackageMetadata,
safeParseModuleManifest,
safeParseModulePackageMetadata,
safeParseModulePackageRuntimePackage,
} from "@ekklesia/contracts";
import { discoverModules, MODULE_MANIFEST_FILENAME, ModuleDiscoveryError } from "./discovery.js";
import { discoverModuleMigrations, ModuleMigrationDiscoveryError } from "./migration-discovery.js";
import {
type ModulePackageArchive,
ModulePackageArchiveError,
type ModulePackageArchiveErrorCode,
readModulePackageArchive,
} from "./module-package-archive.js";
import { discoverModuleSchemas, ModuleSchemaDiscoveryError } from "./schema-discovery.js";
/**
* Verifies an Ekklesia Module Package v1 archive (ADR 0049) against the platform it runs on, without
* ever installing it.
*
* Order matters: everything that can be decided from bytes is decided before anything is written,
* and nothing is executed until the package has proven itself well-formed. The archive is read and
* checked for safety in memory; its metadata, every payload digest, and every identity statement
* are checked next; only then is it written to a fresh temporary directory, validated by the same
* discovery the platform uses, and its backend loaded once in a separate, short-lived process with
* an empty environment. A caller that asks for extraction gets the verified tree written to a new
* directory only after all of that succeeded.
*
* Errors carry a stable code and a fixed message. Paths, archive entry names, file contents, module
* output, and exception messages never appear in them: an archive is untrusted input, and none of
* it is ever echoed.
*/
export type ModulePackageVerificationErrorCode =
| ModulePackageArchiveErrorCode
| "MODULE_PACKAGE_DATABASE_RUNTIME_INCOMPATIBLE"
| "MODULE_PACKAGE_DATABASE_RUNTIME_UNEXPECTED"
| "MODULE_PACKAGE_DIGEST_MISMATCH"
| "MODULE_PACKAGE_ENTRYPOINT_LOAD_FAILED"
| "MODULE_PACKAGE_EXTRACTION_FAILED"
| "MODULE_PACKAGE_FILE_MISMATCH"
| "MODULE_PACKAGE_FILE_MISSING"
| "MODULE_PACKAGE_FILE_UNLISTED"
| "MODULE_PACKAGE_FORMAT_UNSUPPORTED"
| "MODULE_PACKAGE_IDENTITY_MISMATCH"
| "MODULE_PACKAGE_MANIFEST_INVALID"
| "MODULE_PACKAGE_METADATA_INVALID"
| "MODULE_PACKAGE_METADATA_MISSING"
| "MODULE_PACKAGE_MODULE_INVALID"
| "MODULE_PACKAGE_NATIVE_FILE"
| "MODULE_PACKAGE_RUNTIME_INCOMPATIBLE"
| "MODULE_PACKAGE_RUNTIME_PACKAGE_INVALID"
| "MODULE_PACKAGE_SDK_INCOMPATIBLE"
| "MODULE_PACKAGE_TARGET_INCOMPATIBLE"
| "MODULE_PACKAGE_TRANSITIONAL_UNSUPPORTED";
const messages: Readonly<Record<ModulePackageVerificationErrorCode, string>> = Object.freeze({
MODULE_PACKAGE_DATABASE_RUNTIME_INCOMPATIBLE:
"The package's bundled database runtime does not match this platform's (a transitional constraint).",
MODULE_PACKAGE_DATABASE_RUNTIME_UNEXPECTED:
"A Module Database ABI package must not carry a transitional database runtime.",
MODULE_PACKAGE_DIGEST_MISMATCH: "The archive does not match the SHA-256 it was given.",
MODULE_PACKAGE_ENTRYPOINT_LOAD_FAILED:
"A backend entrypoint could not be loaded from the package alone.",
MODULE_PACKAGE_EXTRACTION_FAILED:
"The verified package could not be extracted to a new directory.",
MODULE_PACKAGE_FILE_MISMATCH: "A payload file does not match its listed size or SHA-256.",
MODULE_PACKAGE_FILE_MISSING: "A file listed in the package metadata is missing from the archive.",
MODULE_PACKAGE_FILE_UNLISTED: "The archive contains a file the package metadata does not list.",
MODULE_PACKAGE_FORMAT_UNSUPPORTED: "The package format is not one this platform implements.",
MODULE_PACKAGE_IDENTITY_MISMATCH:
"The package metadata, runtime package.json, and manifest disagree about the module's identity.",
MODULE_PACKAGE_LAYOUT_INVALID:
"The archive must contain exactly one top-level module directory, with each path once.",
MODULE_PACKAGE_LINK: "The archive contains a link; module packages may contain none.",
MODULE_PACKAGE_MANIFEST_INVALID: "The package's module manifest is missing or invalid.",
MODULE_PACKAGE_METADATA_INVALID: "The package metadata is invalid.",
MODULE_PACKAGE_METADATA_MISSING: "The archive contains no package metadata.",
MODULE_PACKAGE_MODULE_INVALID: "The packaged module failed module validation.",
MODULE_PACKAGE_NATIVE_FILE: "An any-target package contains a native file.",
MODULE_PACKAGE_RUNTIME_INCOMPATIBLE:
"The package needs a Node.js version this host does not run.",
MODULE_PACKAGE_RUNTIME_PACKAGE_INVALID:
"The package's runtime package.json is not the generated runtime shape.",
MODULE_PACKAGE_SDK_INCOMPATIBLE:
"The Module SDK bundled into the package is outside the manifest's own moduleSdk range.",
MODULE_PACKAGE_SPECIAL_ENTRY:
"The archive contains an entry that is neither a directory nor a regular file.",
MODULE_PACKAGE_TARGET_INCOMPATIBLE: "The package's target is not one this platform can run.",
MODULE_PACKAGE_TOO_LARGE: "The archive exceeds the module package size limits.",
MODULE_PACKAGE_TRANSITIONAL_UNSUPPORTED:
"The package metadata contains transitional facts this platform does not understand.",
MODULE_PACKAGE_UNREADABLE: "The file is not a readable gzip-compressed ustar archive.",
MODULE_PACKAGE_UNSAFE_MODE: "The archive contains a setuid, setgid, or sticky entry.",
MODULE_PACKAGE_UNSAFE_PATH: "The archive contains an absolute, escaping, or non-portable path.",
});
export class ModulePackageVerificationError extends Error {
readonly code: ModulePackageVerificationErrorCode;
/** Stable discovery issue codes, when module validation failed; never their messages. */
readonly issueCodes: readonly string[];
constructor(code: ModulePackageVerificationErrorCode, issueCodes: readonly string[] = []) {
super(messages[code]);
this.name = "ModulePackageVerificationError";
this.code = code;
this.issueCodes = Object.freeze([...new Set(issueCodes)].sort());
this.stack = `${this.name}: ${this.message}`;
Object.freeze(this);
}
}
/** What this platform is, for the checks that compare a package against it. */
export interface ModulePackagePlatform {
readonly platformVersion: string;
readonly moduleSdkVersion: string;
/** The transitional database runtime facts this platform requires a package to agree with. */
readonly databaseRuntime: Readonly<Record<string, string>>;
/** Defaults to the running process's Node.js version. */
readonly nodeVersion?: string;
}
export interface VerifyModulePackageOptions {
readonly archivePath: string;
/** When given, the archive's SHA-256 must equal it (lowercase hexadecimal). */
readonly expectedSha256?: string;
readonly platform: ModulePackagePlatform;
/** A directory that must not exist yet; the verified module directory is written there. */
readonly extractTo?: string;
readonly loadTimeoutMs?: number;
}
export interface ModulePackageVerificationResult {
readonly archiveSha256: string;
/** Payload files, the metadata file not included. */
readonly fileCount: number;
readonly moduleId: string;
readonly target: string;
readonly version: string;
}
function fail(code: ModulePackageVerificationErrorCode, issueCodes?: readonly string[]): never {
throw new ModulePackageVerificationError(code, issueCodes);
}
function sha256(data: Uint8Array): string {
return createHash("sha256").update(data).digest("hex");
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function parseJson(data: Buffer | undefined): unknown {
if (data === undefined) {
return undefined;
}
try {
return JSON.parse(data.toString("utf8")) as unknown;
} catch {
return undefined;
}
}
/** The transitional facts this verifier understands, and nothing else (ADR 0049). */
const UNDERSTOOD_TRANSITIONAL: Readonly<Record<string, readonly string[]>> = Object.freeze({
databaseRuntime: Object.freeze(["drizzle-orm"]),
});
function checkTransitional(raw: Record<string, unknown>): void {
const transitional = raw.transitional;
if (transitional === undefined) {
return;
}
if (!isRecord(transitional)) {
fail("MODULE_PACKAGE_TRANSITIONAL_UNSUPPORTED");
}
for (const [key, value] of Object.entries(transitional)) {
const understood = UNDERSTOOD_TRANSITIONAL[key];
if (!understood || !isRecord(value)) {
fail("MODULE_PACKAGE_TRANSITIONAL_UNSUPPORTED");
}
if (Object.keys(value).some((name) => !understood.includes(name))) {
fail("MODULE_PACKAGE_TRANSITIONAL_UNSUPPORTED");
}
}
}
function readMetadata(files: ReadonlyMap<string, Buffer>): ModulePackageMetadata {
const raw = parseJson(files.get(MODULE_PACKAGE_METADATA_FILENAME));
if (!files.has(MODULE_PACKAGE_METADATA_FILENAME)) {
fail("MODULE_PACKAGE_METADATA_MISSING");
}
if (!isRecord(raw)) {
fail("MODULE_PACKAGE_METADATA_INVALID");
}
if (raw.packageFormat !== MODULE_PACKAGE_FORMAT) {
fail("MODULE_PACKAGE_FORMAT_UNSUPPORTED");
}
checkTransitional(raw);
const parsed = safeParseModulePackageMetadata(raw);
if (!parsed.success) {
fail("MODULE_PACKAGE_METADATA_INVALID");
}
return parsed.data;
}
function checkPayload(metadata: ModulePackageMetadata, files: ReadonlyMap<string, Buffer>): void {
const listed = new Set(metadata.files.map((file) => file.path));
for (const filePath of files.keys()) {
if (filePath !== MODULE_PACKAGE_METADATA_FILENAME && !listed.has(filePath)) {
fail("MODULE_PACKAGE_FILE_UNLISTED");
}
}
for (const file of metadata.files) {
const data = files.get(file.path);
if (data === undefined) {
fail("MODULE_PACKAGE_FILE_MISSING");
}
if (data.length !== file.size || sha256(data) !== file.sha256) {
fail("MODULE_PACKAGE_FILE_MISMATCH");
}
}
}
function checkIdentity(
archive: ModulePackageArchive,
metadata: ModulePackageMetadata,
files: ReadonlyMap<string, Buffer>,
): ModuleManifest {
const runtimePackage = safeParseModulePackageRuntimePackage(parseJson(files.get("package.json")));
if (!runtimePackage.success) {
fail("MODULE_PACKAGE_RUNTIME_PACKAGE_INVALID");
}
const manifest = safeParseModuleManifest(parseJson(files.get(MODULE_MANIFEST_FILENAME)));
if (!manifest.success) {
fail("MODULE_PACKAGE_MANIFEST_INVALID");
}
if (
archive.topLevel !== metadata.module.id ||
manifest.data.id !== metadata.module.id ||
manifest.data.version !== metadata.module.version ||
runtimePackage.data.version !== metadata.module.version
) {
fail("MODULE_PACKAGE_IDENTITY_MISMATCH");
}
return manifest.data;
}
function checkCompatibility(
metadata: ModulePackageMetadata,
manifest: ModuleManifest,
files: ReadonlyMap<string, Buffer>,
platform: ModulePackagePlatform,
): void {
// This verifier implements the portable target only. Native targets are a later slice.
if (metadata.target !== MODULE_PACKAGE_ANY_TARGET) {
fail("MODULE_PACKAGE_TARGET_INCOMPATIBLE");
}
if ([...files.keys()].some((filePath) => filePath.toLowerCase().endsWith(".node"))) {
fail("MODULE_PACKAGE_NATIVE_FILE");
}
if (!isVersionCompatible(platform.nodeVersion ?? process.versions.node, metadata.runtime.node)) {
fail("MODULE_PACKAGE_RUNTIME_INCOMPATIBLE");
}
const sdk = metadata.incorporates["@ekklesia/module-sdk"];
if (sdk !== undefined && !isVersionCompatible(sdk, manifest.compatibility.moduleSdk)) {
fail("MODULE_PACKAGE_SDK_INCOMPATIBLE");
}
if (manifest.database?.abi === 1) {
if (metadata.transitional?.databaseRuntime !== undefined) {
fail("MODULE_PACKAGE_DATABASE_RUNTIME_UNEXPECTED");
}
return;
}
// Transitional legacy-v8 path (ADR 0049): Core queries the module's Drizzle tables with its own
// Drizzle instance, so a recorded module runtime must match. ABI-v1 packages return above: their
// bundled ORM is a private implementation detail and is never compared with Core's.
for (const [runtime, version] of Object.entries(metadata.transitional?.databaseRuntime ?? {})) {
if (platform.databaseRuntime[runtime] !== version) {
fail("MODULE_PACKAGE_DATABASE_RUNTIME_INCOMPATIBLE");
}
}
}
/** Writes the archive's directories (`0755`) and files (`0644`) under `root`, which must not exist.
* Safe by construction: every path was validated as relative and non-escaping, and only directories
* and regular files exist. Ownership is the writing process's, never the archive's. */
async function writeTree(root: string, archive: ModulePackageArchive): Promise<void> {
await mkdir(root, { mode: 0o755 });
await chmod(root, 0o755);
for (const entry of archive.entries) {
const target = path.join(root, ...entry.path.split("/"));
if (entry.type === "directory") {
await mkdir(target, { mode: 0o755, recursive: true });
await chmod(target, 0o755);
} else {
await mkdir(path.dirname(target), { mode: 0o755, recursive: true });
await writeFile(target, entry.data, { flag: "wx", mode: 0o644 });
await chmod(target, 0o644);
}
}
}
function probeEntrypointPath(): string {
// The probe sits beside this file: compiled `.js` in an installation, source `.ts` under a test
// runner, which Node runs directly. Either way it is a separate process.
const self = fileURLToPath(import.meta.url);
return path.join(path.dirname(self), `module-package-probe${path.extname(self)}`);
}
/** Loads every backend entrypoint in a short-lived child with an empty environment, and reports
* only whether each loaded and exported a definition naming this module. */
async function loadEntrypoints(
manifest: Pick<ModuleManifest, "database" | "id" | "version">,
entrypoints: Readonly<Record<string, string>>,
timeoutMs: number,
): Promise<void> {
const outcome = await new Promise<"ok" | "failed">((resolve) => {
let settled = false;
let child: ReturnType<typeof fork> | undefined;
let timer: ReturnType<typeof setTimeout> | undefined;
const finish = (value: "ok" | "failed"): void => {
if (!settled) {
settled = true;
clearTimeout(timer);
child?.kill("SIGKILL");
resolve(value);
}
};
try {
child = fork(
probeEntrypointPath(),
[
JSON.stringify({
...(manifest.database?.abi === 1 ? { databaseAbi: 1 } : {}),
entrypoints,
moduleId: manifest.id,
version: manifest.version,
}),
],
{
cwd: tmpdir(),
env: {},
execArgv: [],
serialization: "json",
stdio: ["ignore", "ignore", "ignore", "ipc"],
},
);
} catch {
finish("failed");
return;
}
timer = setTimeout(() => finish("failed"), timeoutMs);
child.on("message", (message: unknown) => {
const failures = isRecord(message) ? message.failures : undefined;
finish(Array.isArray(failures) && failures.length === 0 ? "ok" : "failed");
});
child.on("error", () => finish("failed"));
child.on("exit", () => finish("failed"));
});
if (outcome !== "ok") {
fail("MODULE_PACKAGE_ENTRYPOINT_LOAD_FAILED");
}
}
function discoveryIssueCodes(error: unknown): readonly string[] {
if (
error instanceof ModuleDiscoveryError ||
error instanceof ModuleMigrationDiscoveryError ||
error instanceof ModuleSchemaDiscoveryError
) {
return error.issues.map((issue) => issue.code);
}
return [];
}
export async function verifyModulePackage(
options: VerifyModulePackageOptions,
): Promise<ModulePackageVerificationResult> {
let compressed: Buffer;
try {
compressed = await readFile(options.archivePath);
} catch {
fail("MODULE_PACKAGE_UNREADABLE");
}
const archiveSha256 = sha256(compressed);
if (options.expectedSha256 !== undefined && options.expectedSha256 !== archiveSha256) {
fail("MODULE_PACKAGE_DIGEST_MISMATCH");
}
let archive: ModulePackageArchive;
try {
archive = readModulePackageArchive(compressed);
} catch (error) {
if (error instanceof ModulePackageArchiveError) {
fail(error.code);
}
fail("MODULE_PACKAGE_UNREADABLE");
}
const files = new Map(
archive.entries
.filter((entry) => entry.type === "file")
.map((entry) => [entry.path, entry.data] as const),
);
const metadata = readMetadata(files);
checkPayload(metadata, files);
const manifest = checkIdentity(archive, metadata, files);
checkCompatibility(metadata, manifest, files, options.platform);
const scratch = await mkdtemp(path.join(tmpdir(), "ekklesia-package-verify-"));
try {
// The candidate alone, in a scratch inventory of its own: never module storage.
const modulesDirectory = path.join(scratch, "candidates");
await mkdir(modulesDirectory, { mode: 0o755 });
await writeTree(path.join(modulesDirectory, metadata.module.id), archive);
const discoveryOptions = {
moduleSdkVersion: options.platform.moduleSdkVersion,
modulesDirectory,
modulesDirectoryRequired: true,
platformVersion: options.platform.platformVersion,
} as const;
let entrypoints: Record<string, string>;
try {
// The same static validation the platform runs, against this package alone. A module that
// requires another module is validated with its dependencies when it is installed.
const [discovered] = await discoverModules(discoveryOptions);
if (discovered === undefined) {
fail("MODULE_PACKAGE_MODULE_INVALID");
}
await discoverModuleMigrations(discoveryOptions);
entrypoints = {
server: discovered.entrypoints.server,
...(discovered.entrypoints.schema ? { schema: discovered.entrypoints.schema } : {}),
...(discovered.entrypoints.jobs ? { jobs: discovered.entrypoints.jobs } : {}),
};
} catch (error) {
if (error instanceof ModulePackageVerificationError) {
throw error;
}
fail("MODULE_PACKAGE_MODULE_INVALID", discoveryIssueCodes(error));
}
// Executed only now, and first in isolation: a schema is imported in this process below only
// after the probe proved it loads on its own.
await loadEntrypoints(manifest, entrypoints, options.loadTimeoutMs ?? 30_000);
try {
await discoverModuleSchemas(discoveryOptions);
} catch (error) {
fail("MODULE_PACKAGE_MODULE_INVALID", discoveryIssueCodes(error));
}
if (options.extractTo !== undefined) {
const extractTo = options.extractTo;
let created = false;
try {
await mkdir(extractTo, { mode: 0o755 });
created = true;
await rm(extractTo, { recursive: true });
await writeTree(extractTo, archive);
} catch {
// Never leave a partial module directory behind; never remove one this call did not create.
if (created) {
await rm(extractTo, { force: true, recursive: true }).catch(() => undefined);
}
fail("MODULE_PACKAGE_EXTRACTION_FAILED");
}
}
} finally {
await rm(scratch, { force: true, recursive: true });
}
return Object.freeze({
archiveSha256,
fileCount: metadata.files.length,
moduleId: metadata.module.id,
target: metadata.target,
version: metadata.module.version,
});
}
+66
View File
@@ -0,0 +1,66 @@
import path from "node:path";
import {
isUsableModulesDirectoryPath,
MODULES_DIRECTORY_INVALID_MESSAGE,
ModuleDiscoveryError,
} from "./discovery.js";
/** The host environment variable that selects the installed-modules directory (ADR 0048). */
export const MODULES_DIRECTORY_ENVIRONMENT_VARIABLE = "EKKLESIA_MODULES_DIR";
export interface ResolveModulesDirectoryOptions {
/** The raw configured value, exactly as the host environment supplied it; `undefined` when the
* variable is unset. */
readonly configured: string | undefined;
/** Where the platform itself is installed. Only consulted for the development default. */
readonly repositoryRoot: string;
}
/** Spreads directly into `ModuleDiscoveryOptions`, so every consumer discovers through the same
* selection, including whether a missing directory is an error. */
export interface ModulesDirectorySelection {
readonly modulesDirectory: string;
readonly modulesDirectoryRequired: boolean;
}
/**
* The one place an installation's modules directory is chosen (ADR 0048).
*
* - Unset: `<repositoryRoot>/modules`, the development checkout's own directory. A checkout without
* one has no modules, exactly as before.
* - Set: an absolute path, normalized and used as given — never resolved against the repository or
* the working directory — and required to exist when discovery reads it. The shipping image sets
* it to `/var/lib/ekklesia/modules`.
*
* Anything else throws a `ModuleDiscoveryError` carrying `MODULES_DIRECTORY_INVALID` and a fixed
* message, so every executable already reports it through the discovery error path it has, and the
* supplied value never reaches an error, a log, or a report.
*
* Nothing here — and nothing that calls it — locates the platform's own files relative to the
* result: Core's migrations and package metadata live under `repositoryRoot`, wherever modules are.
*/
export function resolveModulesDirectory(
options: ResolveModulesDirectoryOptions,
): ModulesDirectorySelection {
if (options.configured === undefined) {
return Object.freeze({
modulesDirectory: path.join(options.repositoryRoot, "modules"),
modulesDirectoryRequired: false,
});
}
if (!isUsableModulesDirectoryPath(options.configured)) {
throw new ModuleDiscoveryError([
{
code: "MODULES_DIRECTORY_INVALID",
message: MODULES_DIRECTORY_INVALID_MESSAGE,
moduleDirectory: ".",
},
]);
}
const normalized = path.normalize(options.configured);
return Object.freeze({
modulesDirectory:
normalized.length > 1 && normalized.endsWith(path.sep) ? normalized.slice(0, -1) : normalized,
modulesDirectoryRequired: true,
});
}
+67 -23
View File
@@ -1,5 +1,6 @@
import { pathToFileURL } from "node:url";
import {
type AnyModuleRuntimeServices,
type CapabilityId,
type CapabilityRegistrar,
type CapabilityResolver,
@@ -7,12 +8,15 @@ import {
EMPTY_MODULE_CONFIGURATION,
EMPTY_MODULE_STORAGE,
type ModuleConfiguration,
type ModuleDatabase,
type ModuleDatabaseExecutor,
type ModuleHttpRouteRegistrar,
type ModuleId,
ModuleRealtimeError,
type ModuleRealtimePublisher,
type ModuleRegistrationContext,
type ModuleRouteDefinitionInput,
type ModuleRouteHandler,
type ModuleRuntimeContext,
type ModuleRuntimeDefinition,
type ModuleRuntimeServices,
@@ -68,6 +72,7 @@ export type ModuleRuntimeIssueCode =
| "MODULE_ROUTE_PERMISSION_INVALID"
| "MODULE_ROUTE_PERMISSION_UNDECLARED"
| "MODULE_RUNTIME_EXPORT_INVALID"
| "MODULE_RUNTIME_DATABASE_MISMATCH"
| "MODULE_RUNTIME_ID_MISMATCH"
| "MODULE_RUNTIME_IMPORT_FAILED"
| "MODULE_RUNTIME_VERSION_MISMATCH"
@@ -105,9 +110,8 @@ export interface StartedModuleRuntime {
/**
* Per-module frozen configuration snapshots, keyed by module id (ADR 0029).
*
* A lookup rather than a field on `ModuleRuntimeServices`, because `services` is deliberately **one
* shared object identity every module receives** — a property this runtime has a test for — while
* configuration is per-module by definition. A module with no entry receives
* A lookup rather than a field on `ModuleRuntimeServices`, because configuration is resolved and
* frozen per module independently of its live service object. A module with no entry receives
* `EMPTY_MODULE_CONFIGURATION` rather than `undefined`, so `context.config` is always present and
* module code never branches on whether the platform supplied one.
*/
@@ -124,12 +128,33 @@ function configurationFor(
* Per-module scoped object storage, keyed by module id (ADR 0030).
*
* A lookup for the same reason configuration is one, and emphatically **not** a field on
* `ModuleRuntimeServices`: `services` is one shared object identity every module receives, while a
* storage facade is scoped to exactly the buckets one module declared. Sharing it would hand every
* `ModuleRuntimeServices`: a storage facade is scoped to exactly the buckets one module declared.
* Sharing it would hand every
* module every other module's storage.
*/
export type ModuleStorageLookup = ReadonlyMap<string, ModuleStorage>;
/** One frozen service object per module id. Legacy modules may point at objects wrapping the same
* facade; ABI-v1 modules each point at their own attributed executor. */
export type ModuleRuntimeServicesLookup = ReadonlyMap<string, AnyModuleRuntimeServices>;
export type ModuleRuntimeServicesSource = ModuleRuntimeServicesLookup | ModuleRuntimeServices;
function servicesFor(
services: ModuleRuntimeServicesSource,
module: DiscoveredModule,
): AnyModuleRuntimeServices {
const selected = "get" in services ? services.get(module.manifest.id) : services;
if (!selected) {
throw new TypeError(`Module ${module.manifest.id} has no runtime services.`);
}
const expectsAbi = module.manifest.database?.abi === 1;
const isAbi = "abiVersion" in selected.database && selected.database.abiVersion === 1;
if (expectsAbi !== isAbi) {
throw new TypeError(`Module ${module.manifest.id} has the wrong database service.`);
}
return selected;
}
function storageFor(storages: ModuleStorageLookup, moduleId: string): ModuleStorage {
return storages.get(moduleId) ?? EMPTY_MODULE_STORAGE;
}
@@ -164,10 +189,9 @@ export interface ModuleRuntimeCompositionOptions extends ModuleRuntimeTimeoutOpt
export interface StartModuleRuntimeOptions
extends ModuleDiscoveryOptions,
ModuleRuntimeCompositionOptions {
/** The same frozen services object every started module's `start`/`stop` hooks and (once
* `apps/api` mounts routes) HTTP handlers receive for this runtime — a required production
* dependency, never an optional test-only global. See `ModuleRuntimeServices`. */
readonly services: ModuleRuntimeServices;
/** The manifest-selected service object for each module. One module's `start`/`stop` hooks and
* handlers receive its same entry; ABI-v1 entries carry distinct attributed executors. */
readonly services: ModuleRuntimeServicesSource;
}
interface LoadedModule {
@@ -175,6 +199,13 @@ interface LoadedModule {
readonly discovered: DiscoveredModule;
}
type AnyRegistrationHook = (
context: ModuleRegistrationContext<ModuleDatabase | ModuleDatabaseExecutor>,
) => unknown;
type AnyRuntimeHook = (
context: ModuleRuntimeContext<ModuleDatabase | ModuleDatabaseExecutor>,
) => unknown;
interface CapabilityRegistrationStage {
readonly capabilities: CapabilityRegistrar;
commit(): readonly ModuleRuntimeIssue[];
@@ -469,7 +500,7 @@ class StagedModuleHttpRoutes {
local.push(
Object.freeze({
bodyMode: parsed.data.bodyMode,
handler: parsed.data.handler,
handler: parsed.data.handler as ModuleRouteHandler,
maxBodyBytes: parsed.data.maxBodyBytes,
method: parsed.data.method,
moduleId: module.manifest.id,
@@ -555,6 +586,19 @@ async function loadRuntimeDefinitions(
),
);
}
const runtimeDeclaresAbi = definition.database?.abi === 1;
const manifestDeclaresAbi = discovered.manifest.database?.abi === 1;
if (runtimeDeclaresAbi !== manifestDeclaresAbi) {
matchesManifest = false;
issues.push(
runtimeIssue(
"MODULE_RUNTIME_DATABASE_MISMATCH",
"load",
discovered.manifest.id,
`Module runtime database declaration does not match manifest ${discovered.manifest.id}.`,
),
);
}
if (matchesManifest) {
loaded.push({ definition, discovered });
}
@@ -580,15 +624,15 @@ async function registerModules(
module.discovered.manifest.id,
new Set(module.discovered.manifest.permissions.map((declaration) => declaration.id)),
);
const context: ModuleRegistrationContext = Object.freeze({
const context = Object.freeze({
capabilities: capabilityStage.capabilities,
config: configurationFor(configurations, module.discovered.manifest.id),
http: routeStage.http,
moduleId: module.discovered.manifest.id,
realtime: realtimeStage.realtime,
});
}) as ModuleRegistrationContext<ModuleDatabase | ModuleDatabaseExecutor>;
try {
await module.definition.register?.(context);
await (module.definition.register as AnyRegistrationHook | undefined)?.(context);
} catch (error) {
if (error instanceof ModuleRuntimeSignal) {
throw new ModuleRuntimeError([error.issue]);
@@ -685,7 +729,7 @@ async function stopModules(
modules: readonly LoadedModule[],
registry: StagedCapabilityRegistry,
phase: "rollback" | "stop",
services: ModuleRuntimeServices,
services: ModuleRuntimeServicesSource,
configurations: ModuleConfigurationLookup,
storages: ModuleStorageLookup,
realtimeRegistry: StagedModuleRealtimeRegistry,
@@ -698,7 +742,7 @@ async function stopModules(
// A dependency may keep doing ordinary work while its dependents stop. Deactivate only the
// module whose stop hook is about to run, not every publisher at the beginning of the sequence.
realtimeRegistry.deactivateModule(moduleId);
const stop = module.definition.stop;
const stop = module.definition.stop as AnyRuntimeHook | undefined;
if (!stop) {
// No synthetic callback and no timer for a module that declares no stop hook.
continue;
@@ -710,13 +754,13 @@ async function stopModules(
registry.createRuntimeContext(
module.discovered,
phase,
services,
servicesFor(services, module.discovered) as ModuleRuntimeServices,
configurationFor(configurations, moduleId),
storageFor(storages, moduleId),
realtimeRegistry.publisherFor(moduleId),
),
) as ModuleRuntimeContext<ModuleDatabase | ModuleDatabaseExecutor>,
),
),
).then(() => undefined),
timeoutMs,
);
if (outcome === "timed-out") {
@@ -750,7 +794,7 @@ async function stopModules(
async function startModules(
modules: readonly LoadedModule[],
registry: StagedCapabilityRegistry,
services: ModuleRuntimeServices,
services: ModuleRuntimeServicesSource,
configurations: ModuleConfigurationLookup,
storages: ModuleStorageLookup,
realtimeRegistry: StagedModuleRealtimeRegistry,
@@ -766,15 +810,15 @@ async function startModules(
for (const module of modules) {
rollbackCandidates.push(module);
try {
await module.definition.start?.(
await (module.definition.start as AnyRuntimeHook | undefined)?.(
registry.createRuntimeContext(
module.discovered,
"start",
services,
servicesFor(services, module.discovered) as ModuleRuntimeServices,
configurationFor(configurations, module.discovered.manifest.id),
storageFor(storages, module.discovered.manifest.id),
realtimeRegistry.publisherFor(module.discovered.manifest.id),
),
) as ModuleRuntimeContext<ModuleDatabase | ModuleDatabaseExecutor>,
);
const missingSnapshots = realtimeRegistry.missingSnapshots(module.discovered.manifest.id);
if (missingSnapshots.length > 0) {
@@ -831,7 +875,7 @@ async function startModules(
*/
export async function startDiscoveredModuleRuntime(
discoveredModules: readonly DiscoveredModule[],
services: ModuleRuntimeServices,
services: ModuleRuntimeServicesSource,
timeouts: ModuleRuntimeCompositionOptions = {},
): Promise<StartedModuleRuntime> {
const modules = await loadRuntimeDefinitions(discoveredModules);
+129 -11
View File
@@ -2,9 +2,11 @@ import { pathToFileURL } from "node:url";
import {
type ModuleId,
type ModuleSchemaDefinition,
type ModuleSchemaMetadataDefinition,
moduleTableLocalNameSchema,
moduleTablePrefix,
parseModuleSchemaDefinition,
parseModuleSchemaMetadataDefinition,
physicalModuleTableNameSchema,
} from "@ekklesia/contracts";
import { is } from "drizzle-orm";
@@ -40,7 +42,9 @@ export interface DiscoveredModuleTable {
readonly exportName: string;
readonly moduleId: ModuleId;
readonly name: string;
readonly table: AnyPgTable;
/** Present only for a temporary legacy-facade module. ABI-v1 schema discovery publishes no ORM
* object and contributes nothing to the legacy aggregate below. */
readonly table?: AnyPgTable;
}
export interface DiscoveredModuleSchema {
@@ -55,21 +59,40 @@ export interface ModuleSchemaAggregate {
readonly tables: readonly DiscoveredModuleTable[];
}
interface LoadedModuleSchema {
interface LoadedLegacyModuleSchema {
readonly definition: ModuleSchemaDefinition;
readonly discovered: DiscoveredModule;
readonly mode: "legacy";
}
interface LoadedAbiModuleSchema {
readonly definition: ModuleSchemaMetadataDefinition;
readonly discovered: DiscoveredModule;
readonly mode: "abi-v1";
}
type LoadedModuleSchema = LoadedLegacyModuleSchema | LoadedAbiModuleSchema;
interface InspectedModuleSchema {
readonly discovered: DiscoveredModule;
readonly tables: ModuleTableCandidate[];
}
interface ModuleTableCandidate extends DiscoveredModuleTable {
interface LegacyModuleTableCandidate extends DiscoveredModuleTable {
readonly mode: "legacy";
readonly foreignKeys: ReturnType<typeof getTableConfig>["foreignKeys"];
readonly path: string;
readonly table: AnyPgTable;
}
interface AbiModuleTableCandidate extends DiscoveredModuleTable {
readonly mode: "abi-v1";
readonly foreignKeys: ModuleSchemaMetadataDefinition["tables"][number]["foreignKeys"];
readonly path: string;
}
type ModuleTableCandidate = LegacyModuleTableCandidate | AbiModuleTableCandidate;
const drizzleTableIsAlias = Symbol.for("drizzle:IsAlias");
export class ModuleSchemaDiscoveryError extends Error {
@@ -156,9 +179,13 @@ async function loadSchemaDefinitions(discoveredModules: readonly DiscoveredModul
continue;
}
let definition: ModuleSchemaDefinition;
let definition: ModuleSchemaDefinition | ModuleSchemaMetadataDefinition;
const mode = discovered.manifest.database?.abi === 1 ? "abi-v1" : "legacy";
try {
definition = parseModuleSchemaDefinition(defaultExport(moduleNamespace));
definition =
mode === "abi-v1"
? parseModuleSchemaMetadataDefinition(defaultExport(moduleNamespace))
: parseModuleSchemaDefinition(defaultExport(moduleNamespace));
} catch {
issues.push(
issue(
@@ -182,7 +209,11 @@ async function loadSchemaDefinitions(discoveredModules: readonly DiscoveredModul
continue;
}
loaded.push({ definition, discovered });
loaded.push(
mode === "abi-v1"
? { definition: definition as ModuleSchemaMetadataDefinition, discovered, mode }
: { definition: definition as ModuleSchemaDefinition, discovered, mode },
);
}
return { issues, loaded };
@@ -197,6 +228,58 @@ function inspectTables(
for (const loaded of loadedSchemas) {
const moduleId = loaded.discovered.manifest.id;
if (loaded.mode === "abi-v1") {
const candidates: AbiModuleTableCandidate[] = [];
loaded.definition.tables.forEach((table, index) => {
const path = `tables.${index}`;
const candidate: AbiModuleTableCandidate = {
exportName: table.name,
foreignKeys: table.foreignKeys,
mode: "abi-v1",
moduleId,
name: table.name,
path,
};
const tablePrefix = moduleTablePrefix(moduleId);
if (!table.name.startsWith(tablePrefix)) {
issues.push(
issue(
"MODULE_SCHEMA_TABLE_NAMESPACE_INVALID",
moduleId,
`Table ${table.name} is not namespaced for module ${moduleId}.`,
{ path, tableName: table.name },
),
);
} else if (
!moduleTableLocalNameSchema.safeParse(table.name.slice(tablePrefix.length)).success
) {
issues.push(
issue(
"MODULE_SCHEMA_TABLE_NAME_INVALID",
moduleId,
`Module ${moduleId} has an invalid local table name.`,
{ path },
),
);
}
const existing = firstTableByName.get(table.name);
if (existing) {
issues.push(
issue(
"MODULE_SCHEMA_TABLE_DUPLICATE",
moduleId,
`Table ${table.name} is declared more than once.`,
{ path, tableName: table.name },
),
);
} else {
firstTableByName.set(table.name, candidate);
}
candidates.push(candidate);
});
inspectedModules.push({ discovered: loaded.discovered, tables: candidates });
continue;
}
const seenTables = new Set<AnyPgTable>();
const candidates: ModuleTableCandidate[] = [];
const entries = Object.entries(loaded.definition.tables).sort(([left], [right]) =>
@@ -293,6 +376,7 @@ function inspectTables(
const candidate: ModuleTableCandidate = {
exportName,
foreignKeys: config.foreignKeys,
mode: "legacy",
moduleId,
name: config.name,
path,
@@ -321,8 +405,8 @@ function inspectTables(
}
function validForeignKeyTarget(
foreignKey: ModuleTableCandidate["foreignKeys"][number],
candidate: ModuleTableCandidate,
foreignKey: LegacyModuleTableCandidate["foreignKeys"][number],
candidate: LegacyModuleTableCandidate,
): AnyPgTable | undefined {
try {
const reference = foreignKey.reference();
@@ -356,7 +440,9 @@ function inspectForeignKeys(
const ownerByTable = new Map<AnyPgTable, ModuleId>();
for (const inspected of inspectedModules) {
for (const candidate of inspected.tables) {
ownerByTable.set(candidate.table, candidate.moduleId);
if (candidate.mode === "legacy") {
ownerByTable.set(candidate.table, candidate.moduleId);
}
}
}
@@ -364,6 +450,32 @@ function inspectForeignKeys(
const moduleId = inspected.discovered.manifest.id;
const ownedTables = new Set(inspected.tables.map((candidate) => candidate.table));
for (const candidate of inspected.tables) {
if (candidate.mode === "abi-v1") {
const ownedNames = new Set(inspected.tables.map((table) => table.name));
candidate.foreignKeys.forEach((foreignKey, foreignKeyIndex) => {
const path = `${candidate.path}.foreignKeys.${foreignKeyIndex}`;
if (!foreignKey.table.startsWith(moduleTablePrefix(moduleId))) {
issues.push(
issue(
"MODULE_SCHEMA_CROSS_MODULE_FOREIGN_KEY",
moduleId,
`Table ${candidate.name} has a foreign key outside module ${moduleId}.`,
{ path, tableName: candidate.name },
),
);
} else if (!ownedNames.has(foreignKey.table)) {
issues.push(
issue(
"MODULE_SCHEMA_FOREIGN_KEY_TARGET_MISSING",
moduleId,
`Table ${candidate.name} references a table not exported by module ${moduleId}.`,
{ path, tableName: candidate.name },
),
);
}
});
continue;
}
candidate.foreignKeys.forEach((foreignKey, foreignKeyIndex) => {
const path = `${candidate.path}.foreignKeys.${foreignKeyIndex}`;
const foreignTable = validForeignKeyTarget(foreignKey, candidate);
@@ -446,7 +558,9 @@ function publishAggregate(
moduleId: inspected.discovered.manifest.id,
tables: Object.freeze(
inspected.tables
.map(({ foreignKeys: _foreignKeys, path: _path, ...table }) => Object.freeze(table))
.map(({ foreignKeys: _foreignKeys, mode: _mode, path: _path, ...table }) =>
Object.freeze(table),
)
.sort((left, right) => compareText(left.exportName, right.exportName)),
),
}),
@@ -458,7 +572,11 @@ function publishAggregate(
.sort((left, right) => compareText(left.name, right.name)),
);
const drizzleSchema = Object.freeze(
Object.fromEntries(tables.map((table) => [table.name, table.table] as const)),
Object.fromEntries(
tables.flatMap((table) =>
table.table === undefined ? [] : [[table.name, table.table] as const],
),
),
);
return Object.freeze({ discoveredModules, drizzleSchema, modules, tables });
+1 -1
View File
@@ -91,7 +91,7 @@ async function createModule(
writeFile(
path.join(moduleDirectory, "ekklesia.module.json"),
JSON.stringify({
manifestVersion: 7,
manifestVersion: 8,
id,
name: `${id} module`,
version: "0.1.0",
+1 -1
View File
@@ -59,7 +59,7 @@ async function createModule(modulesDirectory: string, fixture: ModuleFixture): P
: {}),
entrypoints: { server: "./dist/server/index.js", ui: "./dist/ui/index.js" },
id: fixture.id,
manifestVersion: 7,
manifestVersion: 8,
name: fixture.name,
version: "1.0.0",
}),
+37 -6
View File
@@ -8,10 +8,11 @@ import {
diagnoseEkklesia,
formatDoctorReport,
} from "../src/diagnostics/index.js";
import type {
DiscoveredModule,
ModuleMigrationAggregate,
ModuleSchemaAggregate,
import {
type DiscoveredModule,
discoverModules,
type ModuleMigrationAggregate,
type ModuleSchemaAggregate,
} from "../src/modules/index.js";
/**
@@ -34,8 +35,8 @@ const moduleSecret = "module-secret-material";
const metadata = Object.freeze({
moduleSdkVersion: "0.1.0",
modulesDirectory: "/not-rendered/modules",
platformVersion: "0.1.0",
repositoryRoot: "/not-rendered",
runtimeRange: ">=24.11.0 <25",
runtimeVersion: "24.15.0",
});
@@ -82,7 +83,7 @@ function discoveredModule(id: string, overrides: Record<string, unknown> = {}):
},
entrypoints: { server: "./dist/server.js" },
id,
manifestVersion: 7,
manifestVersion: 8,
name: `${id} module`,
permissions: [],
storage: { buckets: [] },
@@ -454,3 +455,33 @@ describe("Doctor host-configuration parity — sanitization", () => {
expect(JSON.stringify(report)).not.toContain("S3_ACCESS_KEY");
});
});
describe("Doctor reads the modules directory from the same host configuration (ADR 0048)", () => {
it("uses the repository's own modules directory when EKKLESIA_MODULES_DIR is unset", () => {
const doctorOptions = createDoctorOptions(environment(), metadata);
expect(doctorOptions.moduleDiscovery.modulesDirectory).toBe("/not-rendered/modules");
expect(doctorOptions.moduleDiscovery.modulesDirectoryRequired).toBe(false);
expect(doctorOptions.coreMigrationsDirectory).toBe("/not-rendered/core/drizzle");
});
it("uses a configured directory, and still finds Core's migrations under the platform", () => {
const doctorOptions = createDoctorOptions(
environment({ EKKLESIA_MODULES_DIR: "/var/lib/ekklesia/modules" }),
metadata,
);
expect(doctorOptions.moduleDiscovery.modulesDirectory).toBe("/var/lib/ekklesia/modules");
expect(doctorOptions.moduleDiscovery.modulesDirectoryRequired).toBe(true);
expect(doctorOptions.coreMigrationsDirectory).toBe("/not-rendered/core/drizzle");
});
it("hands an unusable value to discovery to report, rather than throwing before a report exists", async () => {
const doctorOptions = createDoctorOptions(
environment({ EKKLESIA_MODULES_DIR: "relative/private-mount-91aa" }),
metadata,
);
expect(doctorOptions.moduleDiscovery.modulesDirectoryRequired).toBe(true);
const report = await diagnoseEkklesia(doctorOptions, dependencies({ discoverModules }));
expect(report.checks.find((entry) => entry.id === "modules.discovery")?.status).toBe("failure");
expect(JSON.stringify(report)).not.toContain("private-mount-91aa");
});
});
+4
View File
@@ -68,6 +68,7 @@ describe("Ekklesia Doctor terminal report", () => {
it("renders the real zero-module and no-database shape exactly", async () => {
const report = await diagnoseEkklesia(
{
coreMigrationsDirectory: "/not-rendered/core/drizzle",
moduleDiscovery: {
moduleSdkVersion: "0.1.0",
modulesDirectory: "/not-rendered/modules",
@@ -135,6 +136,7 @@ Result: READY WITH WARNINGS`);
it("renders every check the report contains, so none can silently affect the result", async () => {
const report = await diagnoseEkklesia(
{
coreMigrationsDirectory: "/not-rendered/core/drizzle",
moduleDiscovery: {
moduleSdkVersion: "0.1.0",
modulesDirectory: "/not-rendered/modules",
@@ -159,6 +161,7 @@ Result: READY WITH WARNINGS`);
it("does not depend on NO_COLOR or terminal interactivity", async () => {
const report = await diagnoseEkklesia(
{
coreMigrationsDirectory: "/not-rendered/core/drizzle",
moduleDiscovery: {
moduleSdkVersion: "0.1.0",
modulesDirectory: "/not-rendered/modules",
@@ -207,6 +210,7 @@ Result: READY WITH WARNINGS`);
const report = await diagnoseEkklesia(
{
databaseUrl: "postgresql://another:[email protected]/db",
coreMigrationsDirectory: "/not-rendered/core/drizzle",
moduleDiscovery: {
moduleSdkVersion: "0.1.0",
modulesDirectory: "/not-rendered/modules",
+130 -5
View File
@@ -1,5 +1,9 @@
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { type ModuleManifest, parsePermissionId } from "@ekklesia/contracts";
import { describe, expect, it, vi } from "vitest";
import { afterEach, describe, expect, it, vi } from "vitest";
import { createPermissionRegistry, PermissionRegistryError } from "../src/authorization/index.js";
import {
type DoctorCheck,
@@ -28,7 +32,11 @@ const checksumA = "a".repeat(64);
const checksumB = "b".repeat(64);
const checksumC = "c".repeat(64);
function discoveredModule(id: string, version = "0.1.0"): DiscoveredModule {
function discoveredModule(
id: string,
version = "0.1.0",
database?: { readonly abi: 1 },
): DiscoveredModule {
return {
contributions: [],
directory: `/unpublished/${id}`,
@@ -37,13 +45,14 @@ function discoveredModule(id: string, version = "0.1.0"): DiscoveredModule {
capabilities: [],
compatibility: { moduleSdk: "^0.1.0", platform: "^0.1.0" },
configuration: [],
...(database ? { database } : {}),
dependencies: {
capabilities: { optional: {}, required: {} },
modules: { optional: {}, required: {} },
},
entrypoints: { server: "./dist/server.js" },
id,
manifestVersion: 7,
manifestVersion: 8,
name: `${id} module`,
permissions: [],
storage: { buckets: [] },
@@ -114,7 +123,10 @@ function emptyDatabase(): MigrationDatabase {
});
}
function plan(entries: MigrationPlanResult["entries"] = []): MigrationPlanResult {
function plan(
entries: MigrationPlanResult["entries"] = [],
retained: MigrationPlanResult["retained"] = [],
): MigrationPlanResult {
const appliedCount = entries.filter((entry) => entry.status === "applied").length;
const approvalRequiredCount = entries.filter(
(entry) => entry.status === "approval-blocked",
@@ -124,6 +136,7 @@ function plan(entries: MigrationPlanResult["entries"] = []): MigrationPlanResult
approvalRequiredCount,
entries: Object.freeze([...entries]),
pendingCount: entries.length - appliedCount,
retained: Object.freeze([...retained]),
});
}
@@ -151,6 +164,7 @@ function dependencies(overrides: Partial<DoctorDependencies> = {}): DoctorDepend
function options(databaseUrl?: string): DoctorOptions {
return {
...(databaseUrl === undefined ? {} : { databaseUrl }),
coreMigrationsDirectory: "/not-rendered/core/drizzle",
moduleDiscovery: {
moduleSdkVersion: "0.1.0",
modulesDirectory: "/not-rendered/modules",
@@ -285,7 +299,7 @@ describe("Ekklesia Doctor outbound email reporting", () => {
describe("Ekklesia Doctor diagnostics", () => {
it("publishes a healthy deterministic static report without database configuration", async () => {
const people = discoveredModule("people");
const calendar = discoveredModule("calendar", "0.2.0");
const calendar = discoveredModule("calendar", "0.2.0", { abi: 1 });
const inventory = migrationAggregate(
[migration("people", "0000_initial", checksumA)],
[people, calendar],
@@ -318,6 +332,7 @@ describe("Ekklesia Doctor diagnostics", () => {
]);
expect(check(report, "modules.discovery").details).toEqual(["people 0.1.0", "calendar 0.2.0"]);
expect(check(report, "modules.dependencies").details).toEqual(["Order: people → calendar"]);
expect(check(report, "schemas.discovery")).toMatchObject({ status: "pass" });
expect(check(report, "modules.permissions")).toMatchObject({
details: ["0 permissions registered"],
status: "pass",
@@ -1657,3 +1672,113 @@ describe("Doctor reports declared browser contributions", () => {
]);
});
});
describe("Doctor with installation-owned module storage (ADR 0048)", () => {
const platformCoreMigrations = path.join(
path.dirname(fileURLToPath(import.meta.url)),
"..",
"drizzle",
);
const temporaryDirectories: string[] = [];
afterEach(async () => {
await Promise.all(
temporaryDirectories.splice(0).map((directory) => rm(directory, { recursive: true })),
);
});
it("finds Core's migrations where the platform is installed, not beside the modules", async () => {
// A modules directory far from the platform, as the image mounts /var/lib/ekklesia/modules. Its
// parent has no `core/drizzle`, so a Doctor that still derived Core's migrations from the
// modules directory would fail migration discovery here.
const external = await mkdtemp(path.join(tmpdir(), "ekklesia-external-modules-"));
temporaryDirectories.push(external);
const report = await diagnoseEkklesia({
coreMigrationsDirectory: platformCoreMigrations,
moduleDiscovery: {
moduleSdkVersion: "0.1.0",
modulesDirectory: external,
modulesDirectoryRequired: true,
platformVersion: "0.1.0",
},
runtimeRange: ">=24.11.0 <25",
runtimeVersion: "24.15.0",
});
expect(check(report, "modules.discovery").status).toBe("pass");
const migrations = check(report, "migrations.discovery");
expect(migrations.status).toBe("pass");
expect(migrations.details.some((detail) => detail.startsWith("core:0000_"))).toBe(true);
expect(JSON.stringify(report)).not.toContain(external);
});
it("reports an unusable configured modules directory as a discovery failure, naming no path", async () => {
const report = await diagnoseEkklesia({
coreMigrationsDirectory: platformCoreMigrations,
moduleDiscovery: {
moduleSdkVersion: "0.1.0",
modulesDirectory: "relative/private-mount-3c9d",
modulesDirectoryRequired: true,
platformVersion: "0.1.0",
},
runtimeRange: ">=24.11.0 <25",
runtimeVersion: "24.15.0",
});
const discovery = check(report, "modules.discovery");
expect(discovery.status).toBe("failure");
expect(discovery.details).toEqual(["The configured modules directory is not usable."]);
expect(check(report, "migrations.discovery").status).toBe("skipped");
expect(JSON.stringify(report)).not.toContain("private-mount-3c9d");
});
it("reports a configured modules directory that does not exist, naming no path", async () => {
const parent = await mkdtemp(path.join(tmpdir(), "ekklesia-missing-modules-"));
temporaryDirectories.push(parent);
const report = await diagnoseEkklesia({
coreMigrationsDirectory: platformCoreMigrations,
moduleDiscovery: {
moduleSdkVersion: "0.1.0",
modulesDirectory: path.join(parent, "absent"),
modulesDirectoryRequired: true,
platformVersion: "0.1.0",
},
runtimeRange: ">=24.11.0 <25",
runtimeVersion: "24.15.0",
});
const discovery = check(report, "modules.discovery");
expect(discovery.status).toBe("failure");
expect(discovery.details).toEqual(["The configured modules directory could not be read."]);
expect(JSON.stringify(report)).not.toContain(parent);
});
it("reports an absent module's retained history without blocking readiness", async () => {
const report = await diagnoseEkklesia(
options("postgres://[email protected]/ekklesia"),
dependencies({
planMigrations: vi.fn(async () =>
plan(
[planEntry("core:0000_identity_core_tables", "a".repeat(64), "applied")],
[Object.freeze({ appliedCount: 2, moduleId: "calendar" })],
),
),
}),
);
const planCheck = check(report, "postgresql.plan");
expect(planCheck.status).toBe("pass");
expect(planCheck.details).toContain(
"retained · calendar · 2 applied migrations kept; the module is not installed",
);
});
it("passes Core's own migration directory to migration discovery", async () => {
const discoverMigrations = vi.fn(async () => migrationAggregate());
await diagnoseEkklesia(options(), dependencies({ discoverMigrations }));
expect(discoverMigrations).toHaveBeenCalledWith({
coreMigrationsDirectory: "/not-rendered/core/drizzle",
moduleDiscovery: options().moduleDiscovery,
});
});
});
@@ -1,5 +1,5 @@
{
"manifestVersion": 7,
"manifestVersion": 8,
"id": "db-facade",
"name": "Database Facade Integration Test Module",
"description": "Test-only installed module fixture proving ModuleDatabase against real PostgreSQL. Never discovered by production startup, Doctor, or normal migration aggregation — only apps/api/tests/module-database.integration.ts points discovery at it explicitly.",
@@ -68,6 +68,7 @@ const INTERPRETED_VARIABLES = Object.freeze([
"EKKLESIA_IDENTITY_BASE_URL",
"EKKLESIA_IDENTITY_SECRET",
"EKKLESIA_IDENTITY_TRUSTED_ORIGINS",
"EKKLESIA_MODULES_DIR",
"EKKLESIA_PUBLIC_SIGNUP_ENABLED",
"EKKLESIA_SMTP_FROM_ADDRESS",
"EKKLESIA_SMTP_FROM_NAME",
+13
View File
@@ -48,6 +48,19 @@ describe("host configuration loading", () => {
expect(host.identity.secret).toBeUndefined();
});
it("carries the configured modules directory verbatim, and nothing when it is unset", () => {
expect(loadHostConfiguration({}).modulesDirectory).toBeUndefined();
expect(
loadHostConfiguration({ EKKLESIA_MODULES_DIR: "/var/lib/ekklesia/modules" }).modulesDirectory,
).toBe("/var/lib/ekklesia/modules");
// Validation is the resolver's job (ADR 0048). The loader neither repairs a relative or blank
// value nor treats a set-but-empty variable as unset, so the resolver can refuse it.
expect(
loadHostConfiguration({ EKKLESIA_MODULES_DIR: "relative/modules" }).modulesDirectory,
).toBe("relative/modules");
expect(loadHostConfiguration({ EKKLESIA_MODULES_DIR: "" }).modulesDirectory).toBe("");
});
it("carries every object-storage field the storage passes established", () => {
const host = loadHostConfiguration(productionEnvironment);
+67 -1
View File
@@ -40,6 +40,7 @@ describe("migration command reports", () => {
}),
]),
pendingCount: 2,
retained: Object.freeze([]),
});
expect(formatMigrationPlanReport(plan)).toEqual([
@@ -64,6 +65,7 @@ describe("migration command reports", () => {
]),
appliedCount: 1,
noOp: false,
retained: Object.freeze([]),
});
expect(formatMigrationApplyReport(applied)).toEqual([
@@ -72,7 +74,12 @@ describe("migration command reports", () => {
]);
expect(
formatMigrationApplyReport(
Object.freeze({ applied: Object.freeze([]), appliedCount: 0, noOp: true }),
Object.freeze({
applied: Object.freeze([]),
appliedCount: 0,
noOp: true,
retained: Object.freeze([]),
}),
),
).toEqual(["Migration apply completed: database is already current (no changes)."]);
});
@@ -85,6 +92,7 @@ describe("migration command reports", () => {
approvalRequiredCount: 0,
entries: Object.freeze([]),
pendingCount: 0,
retained: Object.freeze([]),
}),
),
).toEqual([
@@ -93,4 +101,62 @@ describe("migration command reports", () => {
"Informational only: this plan is read-only and may become stale; apply revalidates history under the migration lock.",
]);
});
it("reports an absent module's retained history without treating it as work (ADR 0048)", () => {
const retained = Object.freeze([
Object.freeze({ appliedCount: 1, moduleId: "calendar" }),
Object.freeze({ appliedCount: 3, moduleId: "signage" }),
]);
const retainedLines = [
"- [retained] calendar: 1 applied migration kept; the module is not installed",
"- [retained] signage: 3 applied migrations kept; the module is not installed",
];
expect(
formatMigrationPlanReport(
Object.freeze({
appliedCount: 0,
approvalRequiredCount: 0,
entries: Object.freeze([]),
pendingCount: 0,
retained,
}),
),
).toEqual([
"Migration plan (0 applied, 0 pending, 0 approval-blocked, 2 retained):",
"- No installed module migrations.",
...retainedLines,
"Informational only: this plan is read-only and may become stale; apply revalidates history under the migration lock.",
]);
expect(
formatMigrationApplyReport(
Object.freeze({ applied: Object.freeze([]), appliedCount: 0, noOp: true, retained }),
),
).toEqual([
"Migration apply completed: database is already current (no changes).",
...retainedLines,
]);
expect(
formatMigrationApplyReport(
Object.freeze({
applied: Object.freeze([
Object.freeze({
checksum: "e".repeat(64),
identity: "core:0007_later",
index: 7,
moduleId: "core",
tag: "0007_later",
}),
]),
appliedCount: 1,
noOp: false,
retained,
}),
),
).toEqual([
"Migration apply completed (1 migration applied):",
`- core:0007_later sha256=${"e".repeat(64)}`,
...retainedLines,
]);
});
});
@@ -17,7 +17,7 @@ function fakeModule(
} = {},
): DiscoveredModule {
const manifest = parseModuleManifest({
manifestVersion: 7,
manifestVersion: 8,
id,
name: `${id} module`,
version: "1.0.0",
@@ -194,7 +194,7 @@ afterAll(async () => {
function manifestWith(configuration: readonly Record<string, unknown>[], id = "announcements") {
return parseModuleManifest({
manifestVersion: 7,
manifestVersion: 8,
id,
name: `${id} module`,
version: "1.0.0",
+8 -8
View File
@@ -21,7 +21,7 @@ function manifestWith(
id = "announcements",
) {
return parseModuleManifest({
manifestVersion: 7,
manifestVersion: 8,
id,
name: `${id} module`,
version: "1.0.0",
@@ -92,7 +92,7 @@ describe("module configuration declarations", () => {
it("rejects two declarations of the same key, so an ordinary key and a secret can never collide", () => {
const result = safeParseModuleManifest({
manifestVersion: 7,
manifestVersion: 8,
id: "announcements",
name: "announcements module",
version: "1.0.0",
@@ -114,7 +114,7 @@ describe("module configuration declarations", () => {
it("rejects a default on a secret, because a checked-in default credential is a leaked one", () => {
const result = safeParseModuleManifest({
manifestVersion: 7,
manifestVersion: 8,
id: "announcements",
name: "announcements module",
version: "1.0.0",
@@ -130,7 +130,7 @@ describe("module configuration declarations", () => {
it("rejects a key that is both required and defaulted, which states an intent nothing can honour", () => {
const result = safeParseModuleManifest({
manifestVersion: 7,
manifestVersion: 8,
id: "announcements",
name: "announcements module",
version: "1.0.0",
@@ -169,7 +169,7 @@ describe("module configuration declarations", () => {
],
])("rejects %s", (_label, declaration) => {
const result = safeParseModuleManifest({
manifestVersion: 7,
manifestVersion: 8,
id: "announcements",
name: "announcements module",
version: "1.0.0",
@@ -189,7 +189,7 @@ describe("module configuration declarations", () => {
"valueOf",
])("rejects the reserved object property name %s as a configuration key", (key) => {
const result = safeParseModuleManifest({
manifestVersion: 7,
manifestVersion: 8,
id: "announcements",
name: "announcements module",
version: "1.0.0",
@@ -210,7 +210,7 @@ describe("module configuration declarations", () => {
"",
])("rejects the malformed configuration key %s", (key) => {
const result = safeParseModuleManifest({
manifestVersion: 7,
manifestVersion: 8,
id: "announcements",
name: "announcements module",
version: "1.0.0",
@@ -591,7 +591,7 @@ describe("configuration over a real discovered inventory", () => {
writeFile(
path.join(moduleDirectory, "ekklesia.module.json"),
JSON.stringify({
manifestVersion: 7,
manifestVersion: 8,
id,
name: `${id} module`,
version: "1.0.0",
File diff suppressed because it is too large Load Diff
+867
View File
@@ -0,0 +1,867 @@
import { readFile } from "node:fs/promises";
import {
isModuleDatabaseError,
type ModuleDatabaseErrorCode,
type ModuleDatabaseTransaction,
} from "@ekklesia/contracts";
import { DatabaseError, type Pool } from "pg";
import { describe, expect, it, vi } from "vitest";
import {
createModuleDatabaseExecutor,
moduleDatabaseExecutorModuleId,
} from "../src/modules/database-abi.js";
import {
decodeModuleDatabaseValue,
encodeModuleDatabaseParameters,
MODULE_DATABASE_MAX_PARAMETERS,
parsePostgresArrayText,
} from "../src/modules/database-abi-values.js";
/**
* Module Database ABI v1 (ADR 0050) without a database: parameter validation and encoding, result
* decoding from PostgreSQL text, option validation, error sanitization, and scope bookkeeping.
*
* Everything that depends on PostgreSQL's own behaviour (type OIDs, the protocol, SQLSTATEs,
* transactions, savepoints, session settings) is proven against a real database in
* `module-database-abi.integration.ts`. The scripted client here only replays command tags and
* transaction status, so the executor's own bookkeeping can be observed statement by statement.
*/
async function rejection(promise: Promise<unknown>): Promise<unknown> {
try {
await promise;
} catch (error) {
return error;
}
throw new Error("Expected the operation to reject.");
}
async function expectAbiError(
promise: Promise<unknown>,
code: ModuleDatabaseErrorCode,
sqlState?: string,
): Promise<void> {
const error = await rejection(promise);
expect(isModuleDatabaseError(error)).toBe(true);
expect(error).toMatchObject({ code });
expect((error as { sqlState?: string }).sqlState).toBe(sqlState);
}
function syncThrows(run: () => unknown): unknown {
try {
run();
} catch (error) {
return error;
}
throw new Error("Expected the call to throw.");
}
// --- Parameters --------------------------------------------------------------------------------
describe("encodeModuleDatabaseParameters", () => {
it("encodes every scalar category as text, or bytes, and nothing else", () => {
const bytes = new Uint8Array([0, 1, 255]);
const encoded = encodeModuleDatabaseParameters([
null,
true,
false,
1.5,
Number.NaN,
Number.POSITIVE_INFINITY,
Number.NEGATIVE_INFINITY,
9_007_199_254_740_993n,
'it\'s \\ "quoted"',
bytes,
]);
expect(encoded.slice(0, 9)).toEqual([
null,
"true",
"false",
"1.5",
"NaN",
"Infinity",
"-Infinity",
"9007199254740993",
'it\'s \\ "quoted"',
]);
expect(Buffer.isBuffer(encoded[9])).toBe(true);
expect([...(encoded[9] as Buffer)]).toEqual([0, 1, 255]);
});
it("copies bytes, so a later change by the caller cannot reach the statement", () => {
const bytes = new Uint8Array([1, 2, 3]);
const view = bytes.subarray(1);
const [encoded] = encodeModuleDatabaseParameters([view]);
bytes[1] = 9;
expect([...(encoded as Buffer)]).toEqual([2, 3]);
});
it("encodes arrays as quoted PostgreSQL array literals, nested for more dimensions", () => {
expect(
encodeModuleDatabaseParameters([
[1, null, 3],
['a"b', "c\\d", "", "NULL", "{,}"],
[
[1, 2],
[3, 4],
],
[new Uint8Array([0, 255])],
[true, 2n],
[],
]),
).toEqual([
'{"1",NULL,"3"}',
'{"a\\"b","c\\\\d","","NULL","{,}"}',
'{{"1","2"},{"3","4"}}',
'{"\\\\x00ff"}',
'{"true","2"}',
"{}",
]);
});
it("treats omitted values as no parameters", () => {
expect(encodeModuleDatabaseParameters(undefined)).toEqual([]);
});
class Point {
readonly x = 1;
}
const refused: readonly [string, unknown][] = [
["a Date", new Date(0)],
["a plain object", { a: 1 }],
["a null-prototype object", Object.create(null)],
["undefined", undefined],
["a function", () => 1],
["a symbol", Symbol("s")],
["a class instance", new Point()],
["a Map", new Map()],
["another typed array", new Int16Array([1])],
["an ArrayBuffer", new ArrayBuffer(1)],
["a boxed string", new String("x")],
["an array holding a Date", [new Date(0)]],
["an array holding undefined", [1, undefined]],
["an array holding an object", [{ a: 1 }]],
// biome-ignore lint/suspicious/noSparseArray: the hole is the case under test.
["a sparse array", [1, , 3]],
["scalars mixed with sub-arrays", [1, [2]]],
["a null where a sub-array belongs", [[1], null]],
["ragged sub-arrays", [[1], [1, 2]]],
["more dimensions than PostgreSQL allows", [[[[[[[1]]]]]]]],
];
for (const [label, value] of refused) {
it(`refuses ${label}, without naming the value`, () => {
const error = syncThrows(() => encodeModuleDatabaseParameters(["fine", value]));
expect(isModuleDatabaseError(error)).toBe(true);
expect(error).toMatchObject({ code: "MODULE_DATABASE_PARAMETER_INVALID" });
expect((error as Error).message).not.toMatch(/fine|1970|Point|Symbol/u);
});
}
it("accepts exactly PostgreSQL's six array dimensions", () => {
expect(encodeModuleDatabaseParameters([[[[[[[1]]]]]]])).toEqual(['{{{{{{"1"}}}}}}']);
});
it("refuses a values list that is not an array, or longer than the protocol allows", () => {
for (const values of [
"x",
{ 0: "x", length: 1 },
new Array(MODULE_DATABASE_MAX_PARAMETERS + 1).fill(1),
]) {
const error = syncThrows(() => encodeModuleDatabaseParameters(values));
expect(error).toMatchObject({ code: "MODULE_DATABASE_PARAMETER_INVALID" });
}
expect(
encodeModuleDatabaseParameters(new Array(MODULE_DATABASE_MAX_PARAMETERS).fill(1)),
).toHaveLength(MODULE_DATABASE_MAX_PARAMETERS);
});
it("refuses a self-referencing array instead of recursing forever", () => {
const cyclic: unknown[] = [];
cyclic.push(cyclic);
expect(syncThrows(() => encodeModuleDatabaseParameters([cyclic]))).toMatchObject({
code: "MODULE_DATABASE_PARAMETER_INVALID",
});
});
});
// --- Results -----------------------------------------------------------------------------------
describe("decodeModuleDatabaseValue", () => {
it("decodes the scalar types the table names, and keeps everything else as exact text", () => {
expect(decodeModuleDatabaseValue(16, "t")).toBe(true);
expect(decodeModuleDatabaseValue(16, "f")).toBe(false);
expect(decodeModuleDatabaseValue(21, "-32768")).toBe(-32_768);
expect(decodeModuleDatabaseValue(23, "2147483647")).toBe(2_147_483_647);
expect(decodeModuleDatabaseValue(26, "4294967295")).toBe(4_294_967_295);
expect(decodeModuleDatabaseValue(700, "1.5")).toBe(1.5);
expect(decodeModuleDatabaseValue(701, "NaN")).toBeNaN();
expect(decodeModuleDatabaseValue(701, "Infinity")).toBe(Number.POSITIVE_INFINITY);
expect(decodeModuleDatabaseValue(701, "-Infinity")).toBe(Number.NEGATIVE_INFINITY);
expect(decodeModuleDatabaseValue(701, "1e+300")).toBe(1e300);
// Precision-sensitive and zone-sensitive types stay exactly as PostgreSQL wrote them.
expect(decodeModuleDatabaseValue(20, "9223372036854775807")).toBe("9223372036854775807");
expect(decodeModuleDatabaseValue(1700, "0.1000000000000000000001")).toBe(
"0.1000000000000000000001",
);
expect(decodeModuleDatabaseValue(3802, '{"a": 12345678901234567890}')).toBe(
'{"a": 12345678901234567890}',
);
expect(decodeModuleDatabaseValue(1184, "2026-10-07 12:00:00+00")).toBe(
"2026-10-07 12:00:00+00",
);
// An unknown OID (an enum, a domain, an extension type) is text.
expect(decodeModuleDatabaseValue(987_654, "anything")).toBe("anything");
});
it("decodes bytea to a plain Uint8Array, from hex or the older escape form", () => {
const hex = decodeModuleDatabaseValue(17, "\\x00ff10");
expect(Object.getPrototypeOf(hex)).toBe(Uint8Array.prototype);
expect([...(hex as Uint8Array)]).toEqual([0, 255, 16]);
expect([...(decodeModuleDatabaseValue(17, "\\x") as Uint8Array)]).toEqual([]);
const escaped = decodeModuleDatabaseValue(17, "a\\000\\\\\\377");
expect([...(escaped as Uint8Array)]).toEqual([97, 0, 92, 255]);
});
it("decodes built-in arrays element by element", () => {
expect(decodeModuleDatabaseValue(1016, "{1,NULL,9223372036854775807}")).toEqual([
"1",
null,
"9223372036854775807",
]);
expect(decodeModuleDatabaseValue(1231, "{1.10,2}")).toEqual(["1.10", "2"]);
expect(decodeModuleDatabaseValue(1000, "{t,f}")).toEqual([true, false]);
expect(decodeModuleDatabaseValue(1007, "{{1,2},{3,4}}")).toEqual([
[1, 2],
[3, 4],
]);
const bytes = decodeModuleDatabaseValue(1001, '{"\\\\x00ff"}') as Uint8Array[];
expect([...(bytes[0] as Uint8Array)]).toEqual([0, 255]);
expect(decodeModuleDatabaseValue(1009, "{}")).toEqual([]);
// An array of a type the table does not know arrives as text, like the type itself.
expect(decodeModuleDatabaseValue(987_655, "{a,b}")).toBe("{a,b}");
});
});
describe("parsePostgresArrayText", () => {
const text = (value: string) => value;
it('honours quoting, escapes, and the difference between NULL and "NULL"', () => {
expect(parsePostgresArrayText('{"a\\"b","c,d",NULL,"NULL","","{}",null}', text)).toEqual([
'a"b',
"c,d",
null,
"NULL",
"",
"{}",
null,
]);
expect(parsePostgresArrayText('{"back\\\\slash"}', text)).toEqual(["back\\slash"]);
});
it("drops a non-default lower bound: a JavaScript array is a zero-based list", () => {
expect(parsePostgresArrayText("[0:1]={5,6}", text)).toEqual(["5", "6"]);
expect(parsePostgresArrayText("[1:1][2:3]={{7,8}}", text)).toEqual([["7", "8"]]);
});
it("refuses text that is not an array literal", () => {
for (const malformed of ["", "{", "{1", '{"a', "{1}x", "[1:2]{1,2}", "1"]) {
expect(() => parsePostgresArrayText(malformed, text)).toThrow();
}
});
});
// --- The executor, over a scripted client -------------------------------------------------------
interface ScriptedStatement {
readonly text: string;
readonly values?: readonly unknown[];
}
interface Script {
readonly pool: Pool;
readonly statements: ScriptedStatement[];
readonly released: (boolean | undefined)[];
/** Resolve a statement only when `release` is called, to model in-flight work. */
holdNext(): () => void;
failNext(error: unknown): void;
/** Fail the next statement whose text is exactly `text`. */
failWhen(text: string, error: unknown): void;
connectCount(): number;
}
function commandOf(text: string): string {
const word = /^\s*([A-Za-z]+)/u.exec(text)?.[1]?.toUpperCase() ?? "";
return word === "END" ? "COMMIT" : word;
}
/**
* A pool whose one client replays PostgreSQL's command tags and transaction status for simple SQL.
* It is not PostgreSQL: it exists only so the executor's bookkeeping can be observed.
*/
function scriptedPool(): Script {
const statements: ScriptedStatement[] = [];
const released: (boolean | undefined)[] = [];
let status = "I";
let connects = 0;
let hold: Promise<void> | undefined;
let failure: unknown;
const failures = new Map<string, unknown>();
const client = {
getTransactionStatus: () => status,
on: () => client,
removeListener: () => client,
async query(config: string | { text: string; values?: unknown[] }) {
const text = typeof config === "string" ? config : config.text;
statements.push(
typeof config === "string" ? { text } : { text, values: config.values ?? [] },
);
const held = hold;
hold = undefined;
if (held !== undefined) {
await held;
}
if (failure !== undefined || failures.has(text)) {
const error = failure ?? failures.get(text);
failure = undefined;
failures.delete(text);
if (status === "T") {
status = "E";
}
throw error;
}
const command = commandOf(text);
if (command === "BEGIN" || command === "START") {
status = "T";
} else if (command === "COMMIT" || (command === "ROLLBACK" && !/\bTO\b/iu.test(text))) {
const aborted = status === "E";
status = "I";
return { command: aborted ? "ROLLBACK" : command, fields: [], rowCount: null, rows: [] };
}
return {
command,
fields: [{ dataTypeID: 23, name: "n" }],
rowCount: 1,
rows: [["1"]],
};
},
release: (destroy?: boolean) => {
released.push(destroy);
},
};
return {
connectCount: () => connects,
failNext(error) {
failure = error;
},
failWhen(text, error) {
failures.set(text, error);
},
holdNext() {
let release!: () => void;
hold = new Promise<void>((resolve) => {
release = resolve;
});
return release;
},
pool: {
connect: vi.fn(async () => {
connects += 1;
return client;
}),
} as unknown as Pool,
released,
statements,
};
}
function pgError(code: string): DatabaseError {
const error = new DatabaseError(
'duplicate key value violates unique constraint "example_secret_key"',
100,
"error",
);
error.code = code;
error.detail = "Key (secret)=(hunter2) already exists.";
error.hint = "a hint";
error.table = "example_records";
error.constraint = "example_secret_key";
return error;
}
const pin =
"SET TimeZone TO 'UTC'; SET DateStyle TO 'ISO, MDY'; SET IntervalStyle TO 'postgres'; SET bytea_output TO 'hex'; SET extra_float_digits TO 1";
function texts(script: Script): string[] {
return script.statements.map((statement) => statement.text);
}
describe("createModuleDatabaseExecutor", () => {
it("builds a frozen executor holding exactly the ABI's members, without touching the pool", () => {
const script = scriptedPool();
const executor = createModuleDatabaseExecutor(script.pool, { moduleId: "example" });
expect(Object.keys(executor).sort()).toEqual(["abiVersion", "query", "transaction"]);
expect(executor.abiVersion).toBe(1);
expect(Object.isFrozen(executor)).toBe(true);
expect(Object.getPrototypeOf(executor)).toBe(Object.prototype);
for (const forbidden of ["pool", "client", "$client", "_", "session", "dialect", "select"]) {
expect(forbidden in executor).toBe(false);
}
expect(script.connectCount()).toBe(0);
});
it("carries its module for Core's attribution, and refuses an invalid module ID", () => {
const executor = createModuleDatabaseExecutor(scriptedPool().pool, { moduleId: "care-team" });
expect(moduleDatabaseExecutorModuleId(executor)).toBe("care-team");
expect(moduleDatabaseExecutorModuleId({})).toBeUndefined();
expect(() =>
createModuleDatabaseExecutor(scriptedPool().pool, { moduleId: "Not A Module" }),
).toThrow(TypeError);
});
it("pins the session, runs one extended-protocol statement, and resets before releasing", async () => {
const script = scriptedPool();
const executor = createModuleDatabaseExecutor(script.pool, { moduleId: "example" });
const result = await executor.query({ text: "SELECT 1 AS n", values: [5n] });
expect(result).toEqual({
command: "SELECT",
fields: [{ name: "n", typeOid: 23 }],
rowCount: 1,
rows: [{ n: 1 }],
});
expect(texts(script)).toEqual([pin, "SELECT 1 AS n", "DISCARD ALL"]);
expect(script.statements[1]).toEqual({ text: "SELECT 1 AS n", values: ["5"] });
expect(script.released).toEqual([undefined]);
expect(Object.isFrozen(result)).toBe(true);
expect(Object.isFrozen(result.rows)).toBe(true);
expect(Object.isFrozen(result.fields)).toBe(true);
});
it("refuses a malformed request before borrowing a connection", async () => {
const script = scriptedPool();
const executor = createModuleDatabaseExecutor(script.pool, { moduleId: "example" });
const query = executor.query as (request: unknown) => Promise<unknown>;
for (const request of [
undefined,
null,
"SELECT 1",
[],
{ text: "" },
{ text: 1 },
{ text: "SELECT 1", rowMode: "rows" },
{ text: "SELECT 1", name: "prepared" },
{ text: "SELECT 1", types: [] },
]) {
await expectAbiError(query(request), "MODULE_DATABASE_REQUEST_INVALID");
}
await expectAbiError(
query({ text: "SELECT $1", values: [new Date()] }),
"MODULE_DATABASE_PARAMETER_INVALID",
);
expect(script.connectCount()).toBe(0);
});
it("reports only PostgreSQL's SQLSTATE from a database error, never its text", async () => {
const script = scriptedPool();
const executor = createModuleDatabaseExecutor(script.pool, { moduleId: "example" });
script.failWhen("SELECT 1", pgError("23505"));
const error = await rejection(executor.query({ text: "SELECT 1" }));
expect(error).toMatchObject({ code: "MODULE_DATABASE_QUERY_FAILED", sqlState: "23505" });
const serialized = `${JSON.stringify(error)} ${String(error)} ${(error as Error).stack}`;
for (const secret of [
"hunter2",
"example_secret_key",
"example_records",
"duplicate",
"hint",
]) {
expect(serialized).not.toContain(secret);
}
expect((error as Error).cause).toBeUndefined();
// A PostgreSQL error leaves the connection usable, so it is reset and returned.
expect(script.released).toEqual([undefined]);
});
it("drops a non-PostgreSQL failure's code and destroys the connection it happened on", async () => {
const script = scriptedPool();
const executor = createModuleDatabaseExecutor(script.pool, { moduleId: "example" });
script.failWhen(
"SELECT 1",
Object.assign(new Error("Connection terminated: postgres://u:p@h/db"), { code: "EPIPE" }),
);
const error = await rejection(executor.query({ text: "SELECT 1" }));
expect(error).toMatchObject({ code: "MODULE_DATABASE_QUERY_FAILED" });
expect((error as { sqlState?: string }).sqlState).toBeUndefined();
expect(JSON.stringify(error)).not.toContain("postgres://");
expect(script.released).toEqual([true]);
});
it("refuses a top-level transaction-control statement and destroys a connection it left open", async () => {
const script = scriptedPool();
const executor = createModuleDatabaseExecutor(script.pool, { moduleId: "example" });
await expectAbiError(executor.query({ text: "BEGIN" }), "MODULE_DATABASE_REQUEST_INVALID");
expect(script.released).toEqual([true]);
expect(texts(script)).not.toContain("DISCARD ALL");
});
});
describe("transaction options", () => {
const invalid: readonly unknown[] = [
null,
"serializable",
[],
{ isolation: "read uncommitted" },
{ isolation: "SERIALIZABLE" },
{ isolation: "constructor" },
{ readOnly: "yes" },
{ readOnly: 1 },
{ deferrable: true },
{ isolation: "serializable", retries: 3 },
];
for (const options of invalid) {
it(`refuses ${JSON.stringify(options)} before borrowing a connection`, async () => {
const script = scriptedPool();
const executor = createModuleDatabaseExecutor(script.pool, { moduleId: "example" });
const callback = vi.fn();
await expectAbiError(
executor.transaction(callback, options as never),
"MODULE_DATABASE_TRANSACTION_OPTION_INVALID",
);
expect(callback).not.toHaveBeenCalled();
expect(script.connectCount()).toBe(0);
});
}
it("refuses a callback that is not a function", async () => {
const script = scriptedPool();
const executor = createModuleDatabaseExecutor(script.pool, { moduleId: "example" });
await expectAbiError(
executor.transaction("SELECT 1" as never),
"MODULE_DATABASE_REQUEST_INVALID",
);
expect(script.connectCount()).toBe(0);
});
it("begins every transaction with an explicit isolation level and access mode", async () => {
const cases: readonly [unknown, string][] = [
[undefined, "BEGIN ISOLATION LEVEL READ COMMITTED READ WRITE"],
[{}, "BEGIN ISOLATION LEVEL READ COMMITTED READ WRITE"],
[{ readOnly: true }, "BEGIN ISOLATION LEVEL READ COMMITTED READ ONLY"],
[{ isolation: "repeatable read" }, "BEGIN ISOLATION LEVEL REPEATABLE READ READ WRITE"],
[
{ isolation: "serializable", readOnly: false },
"BEGIN ISOLATION LEVEL SERIALIZABLE READ WRITE",
],
];
for (const [options, begin] of cases) {
const script = scriptedPool();
const executor = createModuleDatabaseExecutor(script.pool, { moduleId: "example" });
await executor.transaction(async () => undefined, options as never);
expect(texts(script)).toEqual([pin, begin, "COMMIT", "DISCARD ALL"]);
}
});
});
describe("transaction scopes", () => {
it("commits a resolved callback and returns its value", async () => {
const script = scriptedPool();
const executor = createModuleDatabaseExecutor(script.pool, { moduleId: "example" });
const value = await executor.transaction(async (tx) => {
expect(Object.keys(tx).sort()).toEqual(["abiVersion", "query", "savepoint"]);
expect(Object.isFrozen(tx)).toBe(true);
expect(moduleDatabaseExecutorModuleId(tx)).toBe("example");
await tx.query({ text: "INSERT INTO example_records VALUES (1)" });
return "done";
});
expect(value).toBe("done");
expect(texts(script).slice(1)).toEqual([
"BEGIN ISOLATION LEVEL READ COMMITTED READ WRITE",
"INSERT INTO example_records VALUES (1)",
"COMMIT",
"DISCARD ALL",
]);
});
it("rolls back a rejected callback and rejects with the callback's own error, untouched", async () => {
const script = scriptedPool();
const executor = createModuleDatabaseExecutor(script.pool, { moduleId: "example" });
const own = new Error("module failure");
const error = await rejection(
executor.transaction(async (tx) => {
await tx.query({ text: "INSERT INTO example_records VALUES (1)" });
throw own;
}),
);
expect(error).toBe(own);
expect(texts(script)).toContain("ROLLBACK");
expect(texts(script)).not.toContain("COMMIT");
});
it("revokes the executor when the callback settles; later use sends nothing", async () => {
const script = scriptedPool();
const executor = createModuleDatabaseExecutor(script.pool, { moduleId: "example" });
let escaped: ModuleDatabaseTransaction | undefined;
await executor.transaction((tx) => {
escaped = tx;
});
const sent = script.statements.length;
await expectAbiError(
(escaped as ModuleDatabaseTransaction).query({ text: "SELECT 1" }),
"MODULE_DATABASE_SCOPE_CLOSED",
);
await expectAbiError(
(escaped as ModuleDatabaseTransaction).savepoint(async () => undefined),
"MODULE_DATABASE_SCOPE_CLOSED",
);
expect(script.statements).toHaveLength(sent);
});
it("never commits a callback that returned with a query still outstanding, even once it succeeds", async () => {
const script = scriptedPool();
const executor = createModuleDatabaseExecutor(script.pool, { moduleId: "example" });
let forgotten: Promise<unknown> | undefined;
let releaseHeld: (() => void) | undefined;
const outcome = executor.transaction((tx) => {
releaseHeld = script.holdNext();
forgotten = tx.query({ text: "INSERT INTO example_records VALUES (1)" });
});
await vi.waitFor(() => expect(releaseHeld).toBeDefined());
// Core waits for the started statement to settle before rolling back the connection.
await new Promise((resolve) => setTimeout(resolve, 10));
expect(texts(script)).not.toContain("ROLLBACK");
releaseHeld?.();
await expectAbiError(outcome, "MODULE_DATABASE_SCOPE_WORK_OUTSTANDING");
await expect(forgotten).resolves.toBeDefined();
expect(texts(script).slice(-2)).toEqual(["ROLLBACK", "DISCARD ALL"]);
expect(texts(script)).not.toContain("COMMIT");
});
it("keeps a rejected callback's own error when sibling work is still settling, and still never commits", async () => {
const script = scriptedPool();
const executor = createModuleDatabaseExecutor(script.pool, { moduleId: "example" });
const own = new Error("module failure");
let releaseHeld: (() => void) | undefined;
const outcome = executor.transaction(async (tx) => {
releaseHeld = script.holdNext();
void tx.query({ text: "INSERT INTO example_records VALUES (1)" });
throw own;
});
await vi.waitFor(() => expect(releaseHeld).toBeDefined());
// The sibling statement is drained before Core rolls back.
await new Promise((resolve) => setTimeout(resolve, 10));
expect(texts(script)).not.toContain("ROLLBACK");
releaseHeld?.();
expect(await rejection(outcome)).toBe(own);
expect(texts(script).slice(-2)).toEqual(["ROLLBACK", "DISCARD ALL"]);
expect(texts(script)).not.toContain("COMMIT");
});
it("refuses a transaction-control statement inside a transaction and never commits it", async () => {
const script = scriptedPool();
const executor = createModuleDatabaseExecutor(script.pool, { moduleId: "example" });
const error = await rejection(
executor.transaction(async (tx) => {
await expectAbiError(
tx.query({ text: "SAVEPOINT mine" }),
"MODULE_DATABASE_REQUEST_INVALID",
);
return "carried on";
}),
);
expect(error).toMatchObject({ code: "MODULE_DATABASE_REQUEST_INVALID" });
expect(texts(script)).not.toContain("COMMIT");
expect(texts(script)).toContain("ROLLBACK");
// The connection is destroyed, never reset and reused.
expect(texts(script)).not.toContain("DISCARD ALL");
expect(script.released).toEqual([true]);
});
it("reports a COMMIT that PostgreSQL answered with ROLLBACK as a failed transaction", async () => {
const script = scriptedPool();
const executor = createModuleDatabaseExecutor(script.pool, { moduleId: "example" });
const outcome = executor.transaction(async (tx) => {
script.failNext(pgError("22012"));
await expectAbiError(
tx.query({ text: "SELECT 1/0" }),
"MODULE_DATABASE_QUERY_FAILED",
"22012",
);
});
await expectAbiError(outcome, "MODULE_DATABASE_TRANSACTION_FAILED");
});
});
describe("savepoint scopes", () => {
it("names savepoints itself, releases a resolved one, and rolls back a rejected one", async () => {
const script = scriptedPool();
const executor = createModuleDatabaseExecutor(script.pool, { moduleId: "example" });
const own = new Error("savepoint failure");
await executor.transaction(async (tx) => {
await tx.savepoint(async (sp) => {
await sp.query({ text: "INSERT INTO example_records VALUES (1)" });
});
expect(await rejection(tx.savepoint(async () => Promise.reject(own)))).toBe(own);
});
expect(texts(script).slice(2, -2)).toEqual([
"SAVEPOINT ekklesia_savepoint_1",
"INSERT INTO example_records VALUES (1)",
"RELEASE SAVEPOINT ekklesia_savepoint_1",
"SAVEPOINT ekklesia_savepoint_2",
"ROLLBACK TO SAVEPOINT ekklesia_savepoint_2",
"RELEASE SAVEPOINT ekklesia_savepoint_2",
]);
});
it("makes the parent inactive while a savepoint is open, so scopes cannot overlap", async () => {
const script = scriptedPool();
const executor = createModuleDatabaseExecutor(script.pool, { moduleId: "example" });
await executor.transaction(async (tx) => {
await tx.savepoint(async (sp) => {
await expectAbiError(tx.query({ text: "SELECT 1" }), "MODULE_DATABASE_SCOPE_INACTIVE");
await expectAbiError(
tx.savepoint(async () => undefined),
"MODULE_DATABASE_SCOPE_INACTIVE",
);
await sp.savepoint(async (inner) => {
await expectAbiError(sp.query({ text: "SELECT 1" }), "MODULE_DATABASE_SCOPE_INACTIVE");
await inner.query({ text: "SELECT 2" });
});
await sp.query({ text: "SELECT 3" });
});
await tx.query({ text: "SELECT 4" });
});
expect(texts(script)).toContain("SAVEPOINT ekklesia_savepoint_2");
expect(texts(script)).toContain("COMMIT");
});
it("revokes an escaped savepoint executor", async () => {
const script = scriptedPool();
const executor = createModuleDatabaseExecutor(script.pool, { moduleId: "example" });
await executor.transaction(async (tx) => {
let escaped: ModuleDatabaseTransaction | undefined;
await tx.savepoint((sp) => {
escaped = sp;
});
await expectAbiError(
(escaped as ModuleDatabaseTransaction).query({ text: "SELECT 1" }),
"MODULE_DATABASE_SCOPE_CLOSED",
);
await tx.query({ text: "SELECT 2" });
});
expect(texts(script)).toContain("COMMIT");
});
it("rolls back, and never releases, a savepoint whose callback left work outstanding", async () => {
const script = scriptedPool();
const executor = createModuleDatabaseExecutor(script.pool, { moduleId: "example" });
await executor.transaction(async (tx) => {
await expectAbiError(
tx.savepoint((sp) => {
const release = script.holdNext();
void sp.query({ text: "INSERT INTO example_records VALUES (1)" });
setTimeout(release, 10);
}),
"MODULE_DATABASE_SCOPE_WORK_OUTSTANDING",
);
});
expect(texts(script).slice(2, -2)).toEqual([
"SAVEPOINT ekklesia_savepoint_1",
"INSERT INTO example_records VALUES (1)",
"ROLLBACK TO SAVEPOINT ekklesia_savepoint_1",
]);
});
it("keeps a rejected savepoint callback's own error, rolling back and releasing after its sibling work settles", async () => {
const script = scriptedPool();
const executor = createModuleDatabaseExecutor(script.pool, { moduleId: "example" });
const own = new Error("savepoint failure");
await executor.transaction(async (tx) => {
const error = await rejection(
tx.savepoint(async (sp) => {
const release = script.holdNext();
void sp.query({ text: "INSERT INTO example_records VALUES (1)" });
setTimeout(release, 10);
throw own;
}),
);
expect(error).toBe(own);
});
expect(texts(script).slice(2, -2)).toEqual([
"SAVEPOINT ekklesia_savepoint_1",
"INSERT INTO example_records VALUES (1)",
"ROLLBACK TO SAVEPOINT ekklesia_savepoint_1",
"RELEASE SAVEPOINT ekklesia_savepoint_1",
]);
expect(texts(script)).toContain("COMMIT");
});
it("counts an unawaited savepoint as the transaction's outstanding work", async () => {
const script = scriptedPool();
const executor = createModuleDatabaseExecutor(script.pool, { moduleId: "example" });
let forgotten: Promise<unknown> | undefined;
await expectAbiError(
executor.transaction((tx) => {
forgotten = tx.savepoint(async (sp) => {
await sp.query({ text: "INSERT INTO example_records VALUES (1)" });
});
}),
"MODULE_DATABASE_SCOPE_WORK_OUTSTANDING",
);
await expectAbiError(forgotten as Promise<unknown>, "MODULE_DATABASE_SCOPE_CLOSED");
expect(texts(script)).not.toContain("COMMIT");
expect(texts(script).filter((text) => text.startsWith("RELEASE"))).toEqual([]);
expect(texts(script).at(-1)).toBe("DISCARD ALL");
});
});
describe("the ABI's own sources", () => {
it("import no ORM, query builder, or ORM type", async () => {
for (const file of [
"../src/modules/database-abi.ts",
"../src/modules/database-abi-values.ts",
]) {
const source = await readFile(new URL(file, import.meta.url), "utf8");
const specifiers = [...source.matchAll(/\bfrom\s+"([^"]+)"/gu)].map((match) => match[1]);
expect(
specifiers.every((specifier) =>
["@ekklesia/contracts", "pg", "./database-abi-values.js"].includes(specifier ?? ""),
),
).toBe(true);
expect(source).not.toMatch(/drizzle|kysely|NodePg|PgTable|PgColumn/iu);
}
});
});
+1 -1
View File
@@ -34,7 +34,7 @@ async function createModulesDirectory(): Promise<string> {
function manifestFor(id: string): Record<string, unknown> {
return {
manifestVersion: 7,
manifestVersion: 8,
id,
name: `${id} module`,
version: "0.1.0",
@@ -200,7 +200,7 @@ afterAll(async () => {
* by eligibility resolution. */
function fakeModule(id: string, dependencies?: Record<string, unknown>): DiscoveredModule {
const manifest = parseModuleManifest({
manifestVersion: 7,
manifestVersion: 8,
id,
name: `${id} module`,
version: "1.0.0",
+2 -2
View File
@@ -22,7 +22,7 @@ function fakeModule(
} = {},
): DiscoveredModule {
const manifest = parseModuleManifest({
manifestVersion: 7,
manifestVersion: 8,
id,
name: `${id} module`,
version: "0.1.0",
@@ -344,7 +344,7 @@ describe("resolveModuleEligibility — over a real discovered inventory", () =>
writeFile(
path.join(moduleDirectory, "ekklesia.module.json"),
JSON.stringify({
manifestVersion: 7,
manifestVersion: 8,
id,
name: `${id} module`,
version: "0.1.0",
+7 -159
View File
@@ -1,5 +1,5 @@
import { createHash } from "node:crypto";
import { mkdir, mkdtemp, rm, symlink, writeFile } from "node:fs/promises";
import { mkdtemp, rm, symlink, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
@@ -10,34 +10,15 @@ import {
ModuleMigrationDiscoveryError,
type ModuleMigrationDiscoveryIssueCode,
} from "../src/modules/index.js";
import {
createMigrationModule,
manifestFor,
originSnapshotId,
snapshotId,
} from "./support/migration-module-fixture.js";
const originSnapshotId = "00000000-0000-0000-0000-000000000000";
const temporaryDirectories: string[] = [];
interface MigrationFixture {
readonly breakpoints?: boolean;
readonly destructive?:
| { readonly mode: "none" }
| { readonly mode: "approval-required"; readonly reason: string };
readonly snapshotId?: string;
readonly sql: string | Uint8Array;
readonly tag: string;
readonly timestamp?: number;
}
interface MigrationModuleFixture {
readonly dependencies?: Record<string, unknown>;
readonly manifest?: Record<string, unknown>;
readonly migrations?: readonly MigrationFixture[] | false;
readonly schemaSource?: string;
readonly serverSource?: string;
}
interface CreatedMigrationModule {
readonly migrationDirectory?: string;
readonly moduleDirectory: string;
}
afterEach(async () => {
await Promise.all(
temporaryDirectories
@@ -52,139 +33,6 @@ async function createModulesDirectory(): Promise<string> {
return directory;
}
function snapshotId(index: number): string {
return `10000000-0000-4000-8000-${(index + 1).toString(16).padStart(12, "0")}`;
}
function manifestFor(
id: string,
options: {
readonly dependencies?: Record<string, unknown>;
readonly migrations?: boolean;
readonly schema?: boolean;
} = {},
): Record<string, unknown> {
return {
manifestVersion: 7,
id,
name: `${id} module`,
version: "0.1.0",
compatibility: { platform: "^0.1.0", moduleSdk: "^0.1.0" },
...(options.dependencies ? { dependencies: options.dependencies } : {}),
capabilities: [],
permissions: [],
entrypoints: {
server: "./dist/server/index.js",
...(options.schema ? { schema: "./dist/schema/index.js" } : {}),
...(options.migrations === false ? {} : { migrations: "./drizzle" }),
},
};
}
function defaultMigrations(id: string): readonly MigrationFixture[] {
return [
{
sql: `CREATE TABLE "${id.replaceAll("-", "_")}_records" ("id" integer);\n`,
tag: "0000_initial",
},
];
}
async function createMigrationModule(
modulesDirectory: string,
id: string,
fixture: MigrationModuleFixture = {},
): Promise<CreatedMigrationModule> {
const migrations =
fixture.migrations === false ? undefined : (fixture.migrations ?? defaultMigrations(id));
const moduleDirectory = path.join(modulesDirectory, id);
const migrationDirectory = migrations ? path.join(moduleDirectory, "drizzle") : undefined;
await mkdir(path.join(moduleDirectory, "dist/server"), { recursive: true });
if (fixture.schemaSource !== undefined) {
await mkdir(path.join(moduleDirectory, "dist/schema"), { recursive: true });
}
if (migrationDirectory) {
await mkdir(path.join(migrationDirectory, "meta"), { recursive: true });
}
await Promise.all([
writeFile(
path.join(moduleDirectory, "package.json"),
JSON.stringify({
name: `@test/module-${id}`,
version: "0.1.0",
type: "module",
ekklesia: { layer: "module" },
}),
),
writeFile(
path.join(moduleDirectory, "ekklesia.module.json"),
JSON.stringify(
fixture.manifest ??
manifestFor(id, {
...(fixture.dependencies ? { dependencies: fixture.dependencies } : {}),
migrations: migrations !== undefined,
schema: fixture.schemaSource !== undefined,
}),
),
),
writeFile(
path.join(moduleDirectory, "dist/server/index.js"),
fixture.serverSource ?? `export default { id: ${JSON.stringify(id)}, version: "0.1.0" };`,
),
...(fixture.schemaSource === undefined
? []
: [writeFile(path.join(moduleDirectory, "dist/schema/index.js"), fixture.schemaSource)]),
]);
if (!migrationDirectory || !migrations) {
return { moduleDirectory };
}
const journalEntries = migrations.map((migration, index) => ({
idx: index,
version: "7",
when: migration.timestamp ?? 1_700_000_000_000 + index,
tag: migration.tag,
breakpoints: migration.breakpoints ?? true,
}));
const policyEntries = migrations.map((migration) => ({
tag: migration.tag,
destructive: migration.destructive ?? { mode: "none" },
}));
await Promise.all([
writeFile(
path.join(migrationDirectory, "meta/_journal.json"),
JSON.stringify({ version: "7", dialect: "postgresql", entries: journalEntries }),
),
writeFile(
path.join(migrationDirectory, "meta/_ekklesia.json"),
JSON.stringify({ version: 1, moduleId: id, entries: policyEntries }),
),
...migrations.flatMap((migration, index) => {
const idForSnapshot = migration.snapshotId ?? snapshotId(index);
const previousId =
index === 0
? originSnapshotId
: (migrations[index - 1]?.snapshotId ?? snapshotId(index - 1));
return [
writeFile(path.join(migrationDirectory, `${migration.tag}.sql`), migration.sql),
writeFile(
path.join(migrationDirectory, `meta/${index.toString().padStart(4, "0")}_snapshot.json`),
JSON.stringify({
version: "7",
dialect: "postgresql",
id: idForSnapshot,
prevId: previousId,
}),
),
];
}),
]);
return { migrationDirectory, moduleDirectory };
}
function discoveryOptions(modulesDirectory: string) {
return {
moduleSdkVersion: "0.1.0",
+254 -3
View File
@@ -13,6 +13,7 @@ import {
type MigrationTransaction,
planModuleMigrations,
} from "../src/migrations/runner.js";
import type { DiscoveredModule } from "../src/modules/discovery.js";
import type {
DiscoveredModuleMigration,
ModuleMigrationAggregate,
@@ -323,6 +324,7 @@ describe("module migration planning", () => {
},
],
pendingCount: 1,
retained: [],
});
expect(database.events).toEqual(["session:open", "history:read", "session:close"]);
expect(database.historyTableExists).toBe(false);
@@ -344,10 +346,11 @@ describe("module migration planning", () => {
approvalRequiredCount: 0,
entries: [],
pendingCount: 0,
retained: [],
});
expect(Object.isFrozen(result)).toBe(true);
expect(Object.isFrozen(result.entries)).toBe(true);
expect(applyResult).toEqual({ applied: [], appliedCount: 0, noOp: true });
expect(applyResult).toEqual({ applied: [], appliedCount: 0, noOp: true, retained: [] });
expect(database.historyTableExists).toBe(false);
expect(database.events).not.toContain("transaction:begin");
});
@@ -548,6 +551,7 @@ describe("module migration apply", () => {
],
appliedCount: 1,
noOp: false,
retained: [],
});
});
@@ -580,7 +584,7 @@ describe("module migration apply", () => {
database,
});
expect(result).toEqual({ applied: [], appliedCount: 0, noOp: true });
expect(result).toEqual({ applied: [], appliedCount: 0, noOp: true, retained: [] });
expect(Object.isFrozen(result)).toBe(true);
expect(Object.isFrozen(result.applied)).toBe(true);
expectNoPersistentMutation(database);
@@ -601,7 +605,7 @@ describe("module migration apply", () => {
database,
});
expect(result).toEqual({ applied: [], appliedCount: 0, noOp: true });
expect(result).toEqual({ applied: [], appliedCount: 0, noOp: true, retained: [] });
expect(database.historyTableExists).toBe(false);
expect(database.events).toEqual([
"session:open",
@@ -980,3 +984,250 @@ describe("module migration failures and side effects", () => {
expect(database.committedHistory).toEqual([]);
});
});
/**
* Marks modules as present in the discovered inventory without giving them migrations, the way a
* real aggregate lists every discovered module whether or not it owns migration history. Only the
* manifest id is read by the runner.
*/
function withDiscovered(
inventory: ModuleMigrationAggregate,
...moduleIds: readonly string[]
): ModuleMigrationAggregate {
return Object.freeze({
...inventory,
discoveredModules: Object.freeze(
moduleIds.map((id) => ({ manifest: { id } }) as unknown as DiscoveredModule),
),
});
}
describe("retained history for absent modules (ADR 0048)", () => {
it("retains and reports an absent module's applied history without failing the plan", async () => {
const coreInitial = migration("core", 0);
const calendarInitial = migration("calendar", 0);
const calendarLater = migration("calendar", 1);
const history = [
historyRecord(coreInitial, 1),
historyRecord(calendarInitial, 2),
historyRecord(calendarLater, 3),
];
const database = new FakeMigrationDatabase({ history });
const result = await planModuleMigrations({
aggregate: aggregate({ id: "core", migrations: [coreInitial] }),
database,
});
expect(result.appliedCount).toBe(1);
expect(result.pendingCount).toBe(0);
expect(result.entries.map((entry) => entry.identity)).toEqual([coreInitial.identity]);
expect(result.retained).toEqual([{ appliedCount: 2, moduleId: "calendar" }]);
expect(Object.isFrozen(result.retained)).toBe(true);
expect(Object.isFrozen(result.retained[0])).toBe(true);
expect(database.committedHistory).toEqual(history);
expectNoPersistentMutation(database);
});
it("applies a later Core migration while a module is absent, leaving its history intact", async () => {
const coreInitial = migration("core", 0);
const coreLater = migration("core", 1);
const calendarInitial = migration("calendar", 0);
const history = [historyRecord(coreInitial, 1), historyRecord(calendarInitial, 2)];
const database = new FakeMigrationDatabase({ history });
const result = await applyModuleMigrations({
aggregate: aggregate({ id: "core", migrations: [coreInitial, coreLater] }),
database,
});
expect(result.applied.map((applied) => applied.identity)).toEqual([coreLater.identity]);
expect(result.retained).toEqual([{ appliedCount: 1, moduleId: "calendar" }]);
expect(database.executedStatements).toEqual([coreLater.sql]);
expect(database.committedHistory.map((record) => record.identity)).toEqual([
coreInitial.identity,
calendarInitial.identity,
coreLater.identity,
]);
});
it("applies a present module's migrations while another module is absent", async () => {
const peopleInitial = migration("people", 0);
const calendarInitial = migration("calendar", 0);
const database = new FakeMigrationDatabase({ history: [historyRecord(calendarInitial, 1)] });
const result = await applyModuleMigrations({
aggregate: aggregate({ id: "people", migrations: [peopleInitial] }),
database,
});
expect(result.applied.map((applied) => applied.identity)).toEqual([peopleInitial.identity]);
expect(result.retained).toEqual([{ appliedCount: 1, moduleId: "calendar" }]);
});
it("reports retained modules in a stable order with exact counts", async () => {
const zeta = [migration("zeta", 0), migration("zeta", 1), migration("zeta", 2)];
const alpha = migration("alpha", 0);
const database = new FakeMigrationDatabase({
history: [
historyRecord(zeta[0] as DiscoveredModuleMigration, 1),
historyRecord(alpha, 2),
historyRecord(zeta[1] as DiscoveredModuleMigration, 3),
historyRecord(zeta[2] as DiscoveredModuleMigration, 4),
],
});
const result = await planModuleMigrations({ aggregate: aggregate(), database });
expect(result.retained).toEqual([
{ appliedCount: 1, moduleId: "alpha" },
{ appliedCount: 3, moduleId: "zeta" },
]);
});
it("returns a module to strict validation: valid history plans cleanly and nothing is retained", async () => {
const initial = migration("people", 0);
const later = migration("people", 1);
const database = new FakeMigrationDatabase({ history: [historyRecord(initial, 1)] });
const result = await planModuleMigrations({
aggregate: aggregate({ id: "people", migrations: [initial, later] }),
database,
});
expect(result.retained).toEqual([]);
expect(result.entries.map((entry) => [entry.identity, entry.status])).toEqual([
[initial.identity, "applied"],
[later.identity, "pending"],
]);
});
it("fails when a returning module no longer ships a previously applied migration", async () => {
const initial = migration("people", 0);
const later = migration("people", 1);
const database = new FakeMigrationDatabase({
history: [historyRecord(initial, 1), historyRecord(later, 2)],
});
const downgraded = aggregate({ id: "people", migrations: [initial] });
await captureRunnerError(
planModuleMigrations({ aggregate: downgraded, database }),
"MIGRATION_UNKNOWN_APPLIED",
);
await captureRunnerError(
applyModuleMigrations({ aggregate: downgraded, database }),
"MIGRATION_UNKNOWN_APPLIED",
);
expect(database.executedStatements).toEqual([]);
});
it("fails when a returning module's applied migration has different contents", async () => {
const initial = migration("people", 0);
const database = new FakeMigrationDatabase({ history: [historyRecord(initial, 1)] });
const edited = aggregate({
id: "people",
migrations: [migration("people", 0, { sql: "SELECT 'edited';\n" })],
});
const error = await captureRunnerError(
planModuleMigrations({ aggregate: edited, database }),
"MIGRATION_CHECKSUM_MISMATCH",
);
expect(error.migrationIdentity).toBe(initial.identity);
});
it("fails when a returning module's history is no longer a prefix of its journal", async () => {
const initial = migration("people", 0);
const later = migration("people", 1);
const database = new FakeMigrationDatabase({ history: [historyRecord(later, 1)] });
await captureRunnerError(
planModuleMigrations({
aggregate: aggregate({ id: "people", migrations: [initial, later] }),
database,
}),
"MIGRATION_HISTORY_INVALID",
);
});
it("keeps a present module that no longer ships any migrations strict", async () => {
const initial = migration("people", 0);
const database = new FakeMigrationDatabase({ history: [historyRecord(initial, 1)] });
await captureRunnerError(
planModuleMigrations({ aggregate: withDiscovered(aggregate(), "people"), database }),
"MIGRATION_UNKNOWN_APPLIED",
);
});
it("never treats Core's history as an absent module's", async () => {
const coreInitial = migration("core", 0);
const coreGhost = migration("core", 1);
// An inventory with no Core source at all: Core's rows are still strict, never retained.
await captureRunnerError(
planModuleMigrations({
aggregate: aggregate({ id: "people", migrations: [] }),
database: new FakeMigrationDatabase({ history: [historyRecord(coreInitial, 1)] }),
}),
"MIGRATION_UNKNOWN_APPLIED",
);
// A Core row missing from Core's own inventory.
await captureRunnerError(
planModuleMigrations({
aggregate: aggregate({ id: "core", migrations: [coreInitial] }),
database: new FakeMigrationDatabase({
history: [historyRecord(coreInitial, 1), historyRecord(coreGhost, 2)],
}),
}),
"MIGRATION_UNKNOWN_APPLIED",
);
});
it.each([
[
"an identity that does not name its own module and tag",
(source: DiscoveredModuleMigration) => [
historyRecord(source, 1, { identity: "calendar:0099_elsewhere" }),
],
],
[
"a duplicate applied order",
(source: DiscoveredModuleMigration) => [
historyRecord(source, 1),
historyRecord(migration("calendar", 1), 1),
],
],
[
"a duplicate index",
(source: DiscoveredModuleMigration) => [
historyRecord(source, 1),
historyRecord(migration("calendar", 1), 2, { index: 0 }),
],
],
])("still rejects retained rows with %s", async (_label, makeHistory) => {
const database = new FakeMigrationDatabase({
history: makeHistory(migration("calendar", 0)),
});
await captureRunnerError(
planModuleMigrations({ aggregate: aggregate(), database }),
"MIGRATION_HISTORY_INVALID",
);
});
it("rejects a retained row that claims a present module's migration identity", async () => {
const peopleInitial = migration("people", 0);
const database = new FakeMigrationDatabase({
history: [historyRecord(peopleInitial, 1, { moduleId: "calendar" })],
});
await captureRunnerError(
planModuleMigrations({
aggregate: aggregate({ id: "people", migrations: [peopleInitial] }),
database,
}),
"MIGRATION_HISTORY_INVALID",
);
});
});
@@ -0,0 +1,438 @@
import { mkdtemp, readdir, rm, stat, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import {
ModulePackageVerificationError,
type ModulePackageVerificationErrorCode,
verifyModulePackage,
} from "../src/modules/index.js";
import {
type FixtureEntry,
modulePackageFixture,
type PackageFixtureOptions,
sha256,
} from "./support/module-package-fixture.js";
const platform = {
databaseRuntime: { "drizzle-orm": "0.45.2" },
moduleSdkVersion: "0.1.2",
platformVersion: "0.1.2",
} as const;
const temporaryDirectories: string[] = [];
afterEach(async () => {
await Promise.all(
temporaryDirectories
.splice(0)
.map((directory) => rm(directory, { force: true, recursive: true })),
);
});
async function scratch(): Promise<string> {
const directory = await mkdtemp(path.join(tmpdir(), "ekklesia-verify-"));
temporaryDirectories.push(directory);
return directory;
}
async function archiveFile(options: PackageFixtureOptions = {}): Promise<{
readonly archivePath: string;
readonly sha256: string;
}> {
const fixture = await modulePackageFixture(options);
const archivePath = path.join(await scratch(), "private-host-name-5e1d.tar.gz");
await writeFile(archivePath, fixture.archive);
return { archivePath, sha256: fixture.sha256 };
}
async function rejection(
options: PackageFixtureOptions,
code: ModulePackageVerificationErrorCode,
extra: { readonly expectedSha256?: string } = {},
): Promise<ModulePackageVerificationError> {
const { archivePath } = await archiveFile(options);
try {
await verifyModulePackage({ archivePath, platform, ...extra });
} catch (error) {
expect(error).toBeInstanceOf(ModulePackageVerificationError);
const verificationError = error as ModulePackageVerificationError;
expect(verificationError.code).toBe(code);
// Sanitized: never the archive's location, a temporary directory, or text from inside it.
const rendered = JSON.stringify({ ...verificationError, message: verificationError.message });
expect(rendered).not.toContain("private-host-name-5e1d");
expect(rendered).not.toContain(tmpdir());
expect(rendered).not.toContain("attacker-chosen");
return verificationError;
}
throw new Error(`Expected ${code}.`);
}
function withEntry(entry: FixtureEntry): PackageFixtureOptions {
return { extraEntries: [entry] };
}
function abiPackage(overrides: PackageFixtureOptions = {}): PackageFixtureOptions {
return {
manifest: (manifest) => ({ ...manifest, database: { abi: 1 } }),
schemaSource: `export default {
moduleId: "example",
tables: [{ name: "example_records", foreignKeys: [] }]
};\n`,
serverSource:
'export default { database: { abi: 1 }, id: "example", version: "0.1.0", register() {} };\n',
...overrides,
};
}
describe("Module Package v1 verification (ADR 0049)", () => {
it("verifies a valid any-target package, its digest, and its module", async () => {
const { archivePath, sha256: digest } = await archiveFile();
const result = await verifyModulePackage({ archivePath, expectedSha256: digest, platform });
expect(result).toEqual({
archiveSha256: digest,
fileCount: 8,
moduleId: "example",
target: "any",
version: "0.1.0",
});
});
it("extracts only a verified package, with normalized modes, into a new directory", async () => {
const { archivePath } = await archiveFile();
const target = path.join(await scratch(), "example");
await verifyModulePackage({ archivePath, extractTo: target, platform });
const names = (await readdir(target, { recursive: true })).sort();
expect(names).toContain("ekklesia.package.json");
expect(names).toContain("dist/server/index.js");
expect((await stat(target)).mode & 0o7777).toBe(0o755);
expect((await stat(path.join(target, "dist", "server", "index.js"))).mode & 0o7777).toBe(0o644);
const { archivePath: broken } = await archiveFile({ runtimePackage: { name: "x" } });
const refused = path.join(await scratch(), "refused");
await expect(
verifyModulePackage({ archivePath: broken, extractTo: refused, platform }),
).rejects.toBeInstanceOf(ModulePackageVerificationError);
await expect(stat(refused)).rejects.toMatchObject({ code: "ENOENT" });
});
it("refuses to extract over an existing directory", async () => {
const { archivePath } = await archiveFile();
const existing = await scratch();
await expect(
verifyModulePackage({ archivePath, extractTo: existing, platform }),
).rejects.toMatchObject({ code: "MODULE_PACKAGE_EXTRACTION_FAILED" });
});
it("fails a wrong archive digest", async () => {
await rejection({}, "MODULE_PACKAGE_DIGEST_MISMATCH", { expectedSha256: sha256("other") });
});
it("fails a payload whose contents no longer match their digest", async () => {
await rejection(
{
tamper: (files) => {
files.set("dist/server/index.js", Buffer.from("export default {}; // attacker-chosen\n"));
},
},
"MODULE_PACKAGE_FILE_MISMATCH",
);
});
it("fails a listed file missing from the archive", async () => {
await rejection(
{ tamper: (files) => void files.delete("THIRD_PARTY_NOTICES") },
"MODULE_PACKAGE_FILE_MISSING",
);
});
it("fails an archive file that is not listed", async () => {
await rejection(
withEntry({ path: "example/attacker-chosen.js", data: "extra" }),
"MODULE_PACKAGE_FILE_UNLISTED",
);
});
it("fails when the manifest and the package disagree about the version", async () => {
await rejection(
{ manifest: (manifest) => ({ ...manifest, version: "0.2.0" }) },
"MODULE_PACKAGE_IDENTITY_MISMATCH",
);
});
it("fails when the metadata names another module", async () => {
await rejection(
{ metadata: (metadata) => ({ ...metadata, module: { id: "other", version: "0.1.0" } }) },
"MODULE_PACKAGE_IDENTITY_MISMATCH",
);
});
it("fails a runtime package.json that is not the generated shape", async () => {
await rejection(
{
runtimePackage: {
name: "@test/module-example",
version: "0.1.0",
private: true,
type: "module",
ekklesia: { layer: "module" },
dependencies: { "@ekklesia/module-sdk": "workspace:*" },
},
},
"MODULE_PACKAGE_RUNTIME_PACKAGE_INVALID",
);
});
it("fails an unsupported package format", async () => {
await rejection(
{ metadata: (metadata) => ({ ...metadata, packageFormat: 2 }) },
"MODULE_PACKAGE_FORMAT_UNSUPPORTED",
);
});
it("fails transitional metadata it does not understand", async () => {
await rejection(
{ metadata: (metadata) => ({ ...metadata, transitional: { orm: { prisma: "6.0.0" } } }) },
"MODULE_PACKAGE_TRANSITIONAL_UNSUPPORTED",
);
await rejection(
{
metadata: (metadata) => ({
...metadata,
transitional: { databaseRuntime: { "drizzle-orm": "0.45.2", prisma: "6.0.0" } },
}),
},
"MODULE_PACKAGE_TRANSITIONAL_UNSUPPORTED",
);
});
it("enforces today's database runtime agreement, and accepts it when it holds", async () => {
await rejection(
{
metadata: (metadata) => ({
...metadata,
transitional: { databaseRuntime: { "drizzle-orm": "0.44.0" } },
}),
},
"MODULE_PACKAGE_DATABASE_RUNTIME_INCOMPATIBLE",
);
const { archivePath } = await archiveFile({
metadata: (metadata) => ({
...metadata,
transitional: { databaseRuntime: { "drizzle-orm": "0.45.2" } },
}),
});
await expect(verifyModulePackage({ archivePath, platform })).resolves.toMatchObject({
moduleId: "example",
});
});
it("verifies ABI-v1 plain schema metadata without comparing Drizzle with Core", async () => {
const { archivePath } = await archiveFile(abiPackage());
await expect(
verifyModulePackage({
archivePath,
platform: { ...platform, databaseRuntime: { "drizzle-orm": "99.0.0" } },
}),
).resolves.toMatchObject({ moduleId: "example" });
});
it("rejects transitional database runtime metadata from an ABI-v1 package", async () => {
await rejection(
abiPackage({
metadata: (metadata) => ({
...metadata,
transitional: { databaseRuntime: { "drizzle-orm": "0.45.2" } },
}),
}),
"MODULE_PACKAGE_DATABASE_RUNTIME_UNEXPECTED",
);
});
it("rejects an unsupported database ABI and malformed ABI-v1 schema metadata", async () => {
await rejection(
abiPackage({ manifest: (manifest) => ({ ...manifest, database: { abi: 2 } }) }),
"MODULE_PACKAGE_MANIFEST_INVALID",
);
const schemaError = await rejection(
abiPackage({
schemaSource:
'export default { moduleId: "example", tables: { records: "attacker-chosen" } };\n',
}),
"MODULE_PACKAGE_ENTRYPOINT_LOAD_FAILED",
);
expect(schemaError.message).not.toContain("attacker-chosen");
});
it("fails a target this platform cannot run", async () => {
await rejection(
{
metadata: (metadata) => ({
...metadata,
target: "linux-arm64-musl",
runtime: { node: ">=20", nodeApi: 9 },
}),
},
"MODULE_PACKAGE_TARGET_INCOMPATIBLE",
);
});
it("fails a native file inside an any-target package", async () => {
const native = await modulePackageFixture({
tamper: (files) => {
files.set("dist/addon.node", Buffer.from([0x7f, 0x45, 0x4c, 0x46]));
},
metadata: (metadata) => {
const files = [
...(metadata.files as { path: string; size: number; sha256: string }[]),
{
path: "dist/addon.node",
size: 4,
sha256: sha256(Buffer.from([0x7f, 0x45, 0x4c, 0x46])),
},
].sort((left, right) => (left.path < right.path ? -1 : 1));
return { ...metadata, files };
},
});
const archivePath = path.join(await scratch(), "native.tar.gz");
await writeFile(archivePath, native.archive);
await expect(verifyModulePackage({ archivePath, platform })).rejects.toMatchObject({
code: "MODULE_PACKAGE_NATIVE_FILE",
});
});
it("fails a package that needs a newer Node.js", async () => {
await rejection(
{ metadata: (metadata) => ({ ...metadata, runtime: { node: ">=99" } }) },
"MODULE_PACKAGE_RUNTIME_INCOMPATIBLE",
);
});
it("fails an incorporated SDK outside the manifest's own range", async () => {
await rejection(
{
metadata: (metadata) => ({
...metadata,
incorporates: { "@ekklesia/module-sdk": "0.9.0" },
}),
},
"MODULE_PACKAGE_SDK_INCOMPATIBLE",
);
});
it("fails a module whose declared entrypoint is missing", async () => {
await rejection(
{
manifest: (manifest) => ({
...manifest,
version: "0.1.0",
entrypoints: {
...(manifest.entrypoints as Record<string, unknown>),
schema: "./dist/schema/index.js",
},
}),
},
"MODULE_PACKAGE_MODULE_INVALID",
);
});
it("fails a backend that imports a dependency the package does not contain", async () => {
const error = await rejection(
{
serverSource:
'import pad from "ekklesia-dependency-not-in-this-package";\nexport default { id: "example", version: "0.1.0", register() { pad(); } };\n',
},
"MODULE_PACKAGE_ENTRYPOINT_LOAD_FAILED",
);
expect(error.message).not.toContain("ekklesia-dependency-not-in-this-package");
});
it("fails a backend whose default export names another module", async () => {
await rejection(
{ serverSource: 'export default { id: "other", version: "0.1.0" };\n' },
"MODULE_PACKAGE_ENTRYPOINT_LOAD_FAILED",
);
});
it.each([
["an absolute path", { path: "/etc/attacker-chosen", data: "x" }, "MODULE_PACKAGE_UNSAFE_PATH"],
[
"a .. segment",
{ path: "example/../attacker-chosen", data: "x" },
"MODULE_PACKAGE_UNSAFE_PATH",
],
[
"a symbolic link",
{ path: "example/link", type: "symlink", linkname: "/etc/passwd" },
"MODULE_PACKAGE_LINK",
],
[
"a hard link",
{ path: "example/hard", type: "hardlink", linkname: "example/package.json" },
"MODULE_PACKAGE_LINK",
],
[
"a device node",
{ path: "example/device", type: "character" },
"MODULE_PACKAGE_SPECIAL_ENTRY",
],
["a FIFO", { path: "example/fifo", type: "fifo" }, "MODULE_PACKAGE_SPECIAL_ENTRY"],
[
"a setuid file",
{ path: "example/setuid", data: "x", mode: 0o4755 },
"MODULE_PACKAGE_UNSAFE_MODE",
],
[
"a setgid directory",
{ path: "example/setgid", type: "directory", mode: 0o2755 },
"MODULE_PACKAGE_UNSAFE_MODE",
],
[
"a second top-level directory",
{ path: "attacker-chosen/file", data: "x" },
"MODULE_PACKAGE_LAYOUT_INVALID",
],
[
"a duplicate entry",
{ path: "example/package.json", data: "{}" },
"MODULE_PACKAGE_LAYOUT_INVALID",
],
] as const)("fails %s", async (_label, entry, code) => {
await rejection(withEntry(entry as FixtureEntry), code);
});
it("fails a file that is not a gzip-compressed tar archive", async () => {
const archivePath = path.join(await scratch(), "plain.tar.gz");
await writeFile(archivePath, "not an archive");
await expect(verifyModulePackage({ archivePath, platform })).rejects.toMatchObject({
code: "MODULE_PACKAGE_UNREADABLE",
});
});
it("fails an archive without package metadata", async () => {
const fixture = await modulePackageFixture();
const { gunzipSync, gzipSync } = await import("node:zlib");
// Rebuild without the metadata entry by filtering the fixture through a fresh archive.
const archivePath = path.join(await scratch(), "no-metadata.tar.gz");
const raw = gunzipSync(fixture.archive);
const blocks: Buffer[] = [];
for (let offset = 0; offset + 512 <= raw.length; ) {
const headerBlock = raw.subarray(offset, offset + 512);
if (headerBlock.every((byte) => byte === 0)) {
break;
}
const name = headerBlock.subarray(0, 100).toString("latin1").replace(/\0.*$/su, "");
const size = Number.parseInt(headerBlock.subarray(124, 136).toString("latin1"), 8);
const length = 512 + Math.ceil(size / 512) * 512;
if (!name.endsWith("ekklesia.package.json")) {
blocks.push(raw.subarray(offset, offset + length));
}
offset += length;
}
await writeFile(archivePath, gzipSync(Buffer.concat([...blocks, Buffer.alloc(1024)])));
await expect(verifyModulePackage({ archivePath, platform })).rejects.toMatchObject({
code: "MODULE_PACKAGE_METADATA_MISSING",
});
});
});
+1 -1
View File
@@ -28,7 +28,7 @@ interface PermissionInput {
* is read by `composePlatformPermissionRegistry`; the rest exist only to satisfy the type. */
function fakeModule(id: string, permissions: readonly PermissionInput[] = []): DiscoveredModule {
const manifest = parseModuleManifest({
manifestVersion: 7,
manifestVersion: 8,
id,
name: `${id} module`,
version: "0.1.0",
+1 -1
View File
@@ -31,7 +31,7 @@ function manifest(
permissions: readonly Record<string, string>[] = [],
): Record<string, unknown> {
return {
manifestVersion: 7,
manifestVersion: 8,
id,
name: `${id} module`,
version: "0.1.0",
+96 -23
View File
@@ -2,20 +2,23 @@ import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import type {
AnyModuleRuntimeServices,
ModuleConfiguration,
ModuleDatabase,
ModuleDatabaseExecutor,
ModuleRuntimeServices,
} from "@ekklesia/contracts";
import { afterEach, describe, expect, it, vi } from "vitest";
import { discoverModules } from "../src/modules/discovery.js";
import { resolveModuleEligibility } from "../src/modules/lifecycle.js";
import {
ModuleDiscoveryError,
ModuleRuntimeError,
type ModuleRuntimeIssueCode,
type ModuleRuntimeServicesLookup,
startDiscoveredModuleRuntime,
startModuleRuntime,
} from "../src/modules/index.js";
import { resolveModuleEligibility } from "../src/modules/lifecycle.js";
vi.mock("../src/modules/discovery.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../src/modules/discovery.js")>();
@@ -52,12 +55,13 @@ function manifestFor(
id: string,
options: {
capabilities?: Array<{ id: string; version: string }>;
database?: { abi: 1 };
dependencies?: Record<string, unknown>;
permissions?: Array<{ id: string; baseline: string; description: string }>;
} = {},
): Record<string, unknown> {
return {
manifestVersion: 7,
manifestVersion: 8,
id,
name: `${id} module`,
version: "0.1.0",
@@ -67,6 +71,7 @@ function manifestFor(
},
capabilities: options.capabilities ?? [],
permissions: options.permissions ?? [],
...(options.database ? { database: options.database } : {}),
...(options.dependencies ? { dependencies: options.dependencies } : {}),
entrypoints: {
server: "./dist/server/index.js",
@@ -145,13 +150,29 @@ function fakeModuleDatabase(): ModuleDatabase {
}) as unknown as ModuleDatabase;
}
function fakeModuleRuntimeServices(): ModuleRuntimeServices {
return Object.freeze({ database: fakeModuleDatabase() });
function fakeModuleRuntimeServices(): ModuleRuntimeServicesLookup {
const services = new Map<string, ModuleRuntimeServices>();
const database = fakeModuleDatabase();
return new Proxy(services, {
get(target, property, receiver) {
if (property === "get") {
return (moduleId: string) => {
let value = target.get(moduleId);
if (!value) {
value = Object.freeze({ database });
target.set(moduleId, value);
}
return value;
};
}
return Reflect.get(target, property, receiver);
},
});
}
function runtimeOptions(
modulesDirectory: string,
services: ModuleRuntimeServices = fakeModuleRuntimeServices(),
services: ModuleRuntimeServicesLookup = fakeModuleRuntimeServices(),
) {
return {
moduleSdkVersion: "0.1.0",
@@ -1067,6 +1088,54 @@ describe("already-discovered module runtime", () => {
});
describe("module runtime services", () => {
it("delivers only the manifest-selected database mode and distinct ABI executors", async () => {
const modulesDirectory = await createModulesDirectory();
const recorder = createReferenceRecorder();
const legacyDatabase = fakeModuleDatabase();
const alphaExecutor = Object.freeze({
abiVersion: 1,
query: vi.fn(),
transaction: vi.fn(),
}) as unknown as ModuleDatabaseExecutor;
const betaExecutor = Object.freeze({
abiVersion: 1,
query: vi.fn(),
transaction: vi.fn(),
}) as unknown as ModuleDatabaseExecutor;
const services: ModuleRuntimeServicesLookup = new Map<string, AnyModuleRuntimeServices>([
["legacy", Object.freeze({ database: legacyDatabase })],
["alpha", Object.freeze({ database: alphaExecutor })],
["beta", Object.freeze({ database: betaExecutor })],
]);
await createRuntimeModule(modulesDirectory, "legacy", {
serverSource: `export default { id: "legacy", version: "0.1.0", start({ services }) { ${recorder.expression}.push(services.database); } };`,
});
for (const id of ["alpha", "beta"]) {
await createRuntimeModule(modulesDirectory, id, {
manifest: manifestFor(id, { database: { abi: 1 } }),
serverSource: `export default { database: { abi: 1 }, id: ${JSON.stringify(id)}, version: "0.1.0", start({ services }) { ${recorder.expression}.push(services.database); } };`,
});
}
const runtime = await startModuleRuntime(runtimeOptions(modulesDirectory, services));
expect(recorder.values).toEqual([alphaExecutor, betaExecutor, legacyDatabase]);
expect("select" in alphaExecutor).toBe(false);
expect(alphaExecutor).not.toBe(betaExecutor);
await runtime.stop();
});
it("rejects a runtime database declaration that disagrees with its manifest", async () => {
const modulesDirectory = await createModulesDirectory();
await createRuntimeModule(modulesDirectory, "mismatch", {
manifest: manifestFor("mismatch", { database: { abi: 1 } }),
serverSource: `export default { id: "mismatch", version: "0.1.0" };`,
});
const error = await captureRuntimeError(modulesDirectory);
expect(runtimeIssueCodes(error)).toContain("MODULE_RUNTIME_DATABASE_MISMATCH");
});
it("never gives register a services field, and gives start and stop the exact same services object", async () => {
const modulesDirectory = await createModulesDirectory();
const services = fakeModuleRuntimeServices();
@@ -1086,11 +1155,11 @@ describe("module runtime services", () => {
expect(registerServices.values).toEqual([undefined]);
expect(lifecycleServices.values).toHaveLength(2);
expect(lifecycleServices.values[0]).toBe(services);
expect(lifecycleServices.values[1]).toBe(services);
expect(lifecycleServices.values[0]).toBe(services.get("wired"));
expect(lifecycleServices.values[1]).toBe(services.get("wired"));
});
it("shares the exact same services object identity across every module's start hook", async () => {
it("selects a stable services object per module rather than sharing one across modules", async () => {
const modulesDirectory = await createModulesDirectory();
const services = fakeModuleRuntimeServices();
const recorder = createReferenceRecorder();
@@ -1112,7 +1181,8 @@ describe("module runtime services", () => {
const runtime = await startModuleRuntime(runtimeOptions(modulesDirectory, services));
expect(recorder.values).toEqual([services, services]);
expect(recorder.values).toEqual([services.get("alpha"), services.get("beta")]);
expect(recorder.values[0]).not.toBe(recorder.values[1]);
await runtime.stop();
});
@@ -1141,23 +1211,26 @@ describe("module runtime services", () => {
startModuleRuntime(runtimeOptions(modulesDirectory, services)),
).rejects.toBeInstanceOf(ModuleRuntimeError);
expect(recorder.values).toEqual([services, services]);
expect(recorder.values).toEqual([services.get("started"), services.get("started")]);
});
it("starts and stops a zero-module runtime without ever touching the provided services", async () => {
const modulesDirectory = await createModulesDirectory();
const services = fakeModuleRuntimeServices();
const database = fakeModuleDatabase();
const services: ModuleRuntimeServicesLookup = new Map([
["unused", Object.freeze({ database })],
]);
const runtime = await startModuleRuntime(runtimeOptions(modulesDirectory, services));
await runtime.stop();
expect(runtime.modules).toEqual([]);
expect(vi.mocked(services.database.select)).not.toHaveBeenCalled();
expect(vi.mocked(services.database.insert)).not.toHaveBeenCalled();
expect(vi.mocked(services.database.update)).not.toHaveBeenCalled();
expect(vi.mocked(services.database.delete)).not.toHaveBeenCalled();
expect(vi.mocked(services.database.execute)).not.toHaveBeenCalled();
expect(vi.mocked(services.database.transaction)).not.toHaveBeenCalled();
expect(vi.mocked(database.select)).not.toHaveBeenCalled();
expect(vi.mocked(database.insert)).not.toHaveBeenCalled();
expect(vi.mocked(database.update)).not.toHaveBeenCalled();
expect(vi.mocked(database.delete)).not.toHaveBeenCalled();
expect(vi.mocked(database.execute)).not.toHaveBeenCalled();
expect(vi.mocked(database.transaction)).not.toHaveBeenCalled();
});
});
@@ -1463,7 +1536,7 @@ describe("module configuration delivery (ADR 0029)", () => {
expect(recorder.values).toEqual([["object", []]]);
});
it("keeps the services object one shared identity even though configuration is per-module", async () => {
it("keeps each module's services object stable while configuration remains per-module", async () => {
const modulesDirectory = await createModulesDirectory();
const services = fakeModuleRuntimeServices();
const recorder = createReferenceRecorder();
@@ -1492,11 +1565,11 @@ describe("module configuration delivery (ADR 0029)", () => {
await runtime.stop();
const [alphaServices, alphaConfig, bravoServices, bravoConfig] = recorder.values;
// This is the reason configuration is a sibling of `services` rather than a field inside it:
// `services` is deliberately one shared object for the whole runtime, while configuration must
// differ per module. Both properties hold simultaneously.
expect(alphaServices).toBe(services);
expect(bravoServices).toBe(services);
// Configuration remains a sibling of `services`: both are selected per module, but they are
// independently composed snapshots with separate contracts and lifetimes.
expect(alphaServices).toBe(services.get("alpha"));
expect(bravoServices).toBe(services.get("bravo"));
expect(alphaServices).not.toBe(bravoServices);
expect(alphaConfig).not.toBe(bravoConfig);
expect((alphaConfig as ModuleConfiguration).getString("label")).toBe("alpha");
expect((bravoConfig as ModuleConfiguration).getString("label")).toBe("bravo");
+63 -2
View File
@@ -38,10 +38,14 @@ async function createModulesDirectory(): Promise<string> {
function manifestFor(
id: string,
options: { dependencies?: Record<string, unknown>; schema?: string | false } = {},
options: {
database?: { abi: 1 };
dependencies?: Record<string, unknown>;
schema?: string | false;
} = {},
): Record<string, unknown> {
return {
manifestVersion: 7,
manifestVersion: 8,
id,
name: `${id} module`,
version: "0.1.0",
@@ -49,6 +53,7 @@ function manifestFor(
...(options.dependencies ? { dependencies: options.dependencies } : {}),
capabilities: [],
permissions: [],
...(options.database ? { database: options.database } : {}),
entrypoints: {
server: "./dist/server/index.js",
...(options.schema === false ? {} : { schema: options.schema ?? "./dist/schema/index.js" }),
@@ -126,6 +131,62 @@ function issueCodes(error: ModuleSchemaDiscoveryError): ModuleSchemaDiscoveryIss
}
describe("module schema discovery", () => {
it("loads ABI-v1 schema entrypoints as plain metadata without importing an ORM", async () => {
const modulesDirectory = await createModulesDirectory();
await createSchemaModule(modulesDirectory, "calendar", {
manifest: manifestFor("calendar", { database: { abi: 1 } }),
schemaSource: `
export default {
moduleId: "calendar",
tables: [
{ name: "calendar_events", foreignKeys: [] },
{ name: "calendar_attendees", foreignKeys: [{ table: "calendar_events" }] }
]
};
`,
});
const aggregate = await discoverModuleSchemas(discoveryOptions(modulesDirectory));
expect(aggregate.tables.map((table) => table.name)).toEqual([
"calendar_attendees",
"calendar_events",
]);
expect(aggregate.tables.every((table) => table.table === undefined)).toBe(true);
expect(aggregate.drizzleSchema).toEqual({});
});
it("aggregates ABI-v1 prefix, duplicate, cross-module FK, missing target, and malformed errors", async () => {
const modulesDirectory = await createModulesDirectory();
await createSchemaModule(modulesDirectory, "alpha", {
manifest: manifestFor("alpha", { database: { abi: 1 } }),
schemaSource: `export default {
moduleId: "alpha",
tables: [
{ name: "wrong_records", foreignKeys: [] },
{ name: "alpha_records", foreignKeys: [{ table: "beta_records" }] },
{ name: "alpha_records", foreignKeys: [{ table: "alpha_missing" }] }
]
};`,
});
await createSchemaModule(modulesDirectory, "malformed", {
manifest: manifestFor("malformed", { database: { abi: 1 } }),
schemaSource: `export default { moduleId: "malformed", tables: [{ name: "malformed_rows", unknown: true }] };`,
});
const error = await captureSchemaError(modulesDirectory);
expect(issueCodes(error)).toEqual(
expect.arrayContaining([
"MODULE_SCHEMA_CROSS_MODULE_FOREIGN_KEY",
"MODULE_SCHEMA_EXPORT_INVALID",
"MODULE_SCHEMA_FOREIGN_KEY_TARGET_MISSING",
"MODULE_SCHEMA_TABLE_DUPLICATE",
"MODULE_SCHEMA_TABLE_NAMESPACE_INVALID",
]),
);
});
it("returns a frozen empty aggregate without importing module servers", async () => {
const modulesDirectory = await createModulesDirectory();
const recorder = createGlobalEventRecorder();
+1 -1
View File
@@ -18,7 +18,7 @@ function fakeModule(
} = {},
): DiscoveredModule {
const manifest = parseModuleManifest({
manifestVersion: 7,
manifestVersion: 8,
id,
name: `${id} module`,
version: "1.0.0",
+1 -1
View File
@@ -79,7 +79,7 @@ async function createModule(modulesDirectory: string, fixture: ModuleFixture): P
version: contribution.version,
})),
id: fixture.id,
manifestVersion: 7,
manifestVersion: 8,
name: `${fixture.id} module`,
permissions:
fixture.display === undefined
@@ -0,0 +1,119 @@
import { createHash } from "node:crypto";
import { mkdtemp, readdir, readFile, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { afterEach, describe, expect, it } from "vitest";
import { discoverRepositoryMigrations } from "../src/cli/migration-command-support.js";
import { resolveCommandModulesDirectory } from "../src/cli/modules-directory-environment.js";
import { discoverCoreMigrations } from "../src/migrations/index.js";
import { ModuleDiscoveryError } from "../src/modules/index.js";
import { createMigrationModule } from "./support/migration-module-fixture.js";
const repositoryRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..");
const temporaryDirectories: string[] = [];
afterEach(async () => {
await Promise.all(
temporaryDirectories.splice(0).map((directory) => rm(directory, { recursive: true })),
);
});
async function externalModulesDirectory(): Promise<string> {
const directory = await mkdtemp(path.join(tmpdir(), "ekklesia-installed-modules-"));
temporaryDirectories.push(directory);
return directory;
}
describe("operator commands select the modules directory through host configuration", () => {
it("defaults to the repository's own modules directory", () => {
expect(resolveCommandModulesDirectory({}, "/opt/ekklesia")).toEqual({
modulesDirectory: "/opt/ekklesia/modules",
modulesDirectoryRequired: false,
});
});
it("honours EKKLESIA_MODULES_DIR", () => {
expect(
resolveCommandModulesDirectory(
{ EKKLESIA_MODULES_DIR: "/var/lib/ekklesia/modules" },
"/opt/ekklesia",
),
).toEqual({ modulesDirectory: "/var/lib/ekklesia/modules", modulesDirectoryRequired: true });
});
it("refuses a relative EKKLESIA_MODULES_DIR without echoing it", () => {
expect(() =>
resolveCommandModulesDirectory({ EKKLESIA_MODULES_DIR: "modules-9e1f" }, "/opt/ekklesia"),
).toThrow(ModuleDiscoveryError);
try {
resolveCommandModulesDirectory({ EKKLESIA_MODULES_DIR: "modules-9e1f" }, "/opt/ekklesia");
} catch (error) {
expect(JSON.stringify((error as ModuleDiscoveryError).issues)).not.toContain("9e1f");
}
});
it("plans migrations for modules installed outside the repository, with Core's from the platform", async () => {
// A minimal installed module generated from nothing in the repository, so this test never
// depends on build output or ignored files: a clean checkout runs it before anything is built.
const modulesDirectory = await externalModulesDirectory();
const installed = await createMigrationModule(modulesDirectory, "example");
expect(path.relative(repositoryRoot, modulesDirectory).startsWith("..")).toBe(true);
const aggregate = await discoverRepositoryMigrations(repositoryRoot, {
EKKLESIA_MODULES_DIR: modulesDirectory,
});
expect(aggregate.modules.map((module) => module.moduleId)).toEqual(["core", "example"]);
// Core's migrations are exactly the platform's own, byte for byte, from `core/drizzle`.
const platformCore = await discoverCoreMigrations({
coreMigrationsDirectory: path.join(repositoryRoot, "core", "drizzle"),
});
const fingerprint = (migrations: readonly { identity: string; checksum: string }[]) =>
migrations.map((migration) => `${migration.identity} ${migration.checksum}`);
expect(platformCore.length).toBeGreaterThan(0);
expect(fingerprint(aggregate.modules[0]?.migrations ?? [])).toEqual(fingerprint(platformCore));
// The module's migration is the one installed outside the repository, byte for byte.
const installedSql = await readFile(
path.join(installed.migrationDirectory ?? "", "0000_initial.sql"),
);
expect(fingerprint(aggregate.modules[1]?.migrations ?? [])).toEqual([
`example:0000_initial ${createHash("sha256").update(installedSql).digest("hex")}`,
]);
});
it("fails closed when the configured directory does not exist", async () => {
const parent = await externalModulesDirectory();
await expect(
discoverRepositoryMigrations(repositoryRoot, {
EKKLESIA_MODULES_DIR: path.join(parent, "absent"),
}),
).rejects.toBeInstanceOf(ModuleDiscoveryError);
});
});
describe("no source chooses a modules directory except the resolver", () => {
async function sourceFiles(directory: string): Promise<readonly string[]> {
const entries = await readdir(directory, { recursive: true, withFileTypes: true });
return entries
.filter((entry) => entry.isFile() && entry.name.endsWith(".ts"))
.map((entry) => path.join(entry.parentPath, entry.name));
}
it("never joins `modules` onto a root anywhere else in Core or the API", async () => {
const files = [
...(await sourceFiles(path.join(repositoryRoot, "core", "src"))),
...(await sourceFiles(path.join(repositoryRoot, "apps", "api", "src"))),
];
const resolver = path.join(repositoryRoot, "core", "src", "modules", "modules-directory.ts");
for (const file of files) {
if (file === resolver) {
continue;
}
const text = await readFile(file, "utf8");
expect(text, path.relative(repositoryRoot, file)).not.toMatch(/,\s*"modules"\s*\)/u);
}
});
});
+159
View File
@@ -0,0 +1,159 @@
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import {
discoverModules,
ModuleDiscoveryError,
type ModuleDiscoveryIssueCode,
resolveModulesDirectory,
} from "../src/modules/index.js";
const versions = { moduleSdkVersion: "0.1.0", platformVersion: "0.1.0" } as const;
const repositoryRoot = path.join(path.sep, "opt", "ekklesia");
const temporaryDirectories: string[] = [];
afterEach(async () => {
await Promise.all(
temporaryDirectories.splice(0).map((directory) => rm(directory, { recursive: true })),
);
});
async function temporaryDirectory(): Promise<string> {
const directory = await mkdtemp(path.join(tmpdir(), "ekklesia-modules-directory-"));
temporaryDirectories.push(directory);
return directory;
}
function captureDiscoveryError(operation: () => unknown): ModuleDiscoveryError {
try {
operation();
} catch (error) {
expect(error).toBeInstanceOf(ModuleDiscoveryError);
return error as ModuleDiscoveryError;
}
throw new Error("Expected a ModuleDiscoveryError.");
}
async function captureAsyncDiscoveryError(
operation: Promise<unknown>,
): Promise<ModuleDiscoveryError> {
try {
await operation;
} catch (error) {
expect(error).toBeInstanceOf(ModuleDiscoveryError);
return error as ModuleDiscoveryError;
}
throw new Error("Expected a ModuleDiscoveryError.");
}
function codes(error: ModuleDiscoveryError): readonly ModuleDiscoveryIssueCode[] {
return error.issues.map((issue) => issue.code);
}
/** Everything an operator, a log, or a test report could ever see from this error. */
function rendered(error: ModuleDiscoveryError): string {
return JSON.stringify({ issues: error.issues, message: error.message, stack: error.stack });
}
describe("resolveModulesDirectory", () => {
it("defaults to the repository's own modules directory when nothing is configured", () => {
expect(resolveModulesDirectory({ configured: undefined, repositoryRoot })).toEqual({
modulesDirectory: path.join(repositoryRoot, "modules"),
modulesDirectoryRequired: false,
});
});
it("accepts an explicit absolute path, normalized, and requires it to exist", () => {
expect(
resolveModulesDirectory({ configured: "/var/lib/ekklesia/modules/", repositoryRoot }),
).toEqual({ modulesDirectory: "/var/lib/ekklesia/modules", modulesDirectoryRequired: true });
expect(
resolveModulesDirectory({ configured: "/var/lib/./ekklesia//modules", repositoryRoot }),
).toEqual({ modulesDirectory: "/var/lib/ekklesia/modules", modulesDirectoryRequired: true });
});
it("never resolves a configured path against the repository or the working directory", () => {
const resolved = resolveModulesDirectory({ configured: "/srv/modules", repositoryRoot });
expect(resolved.modulesDirectory).toBe("/srv/modules");
});
it.each([
["a relative name", "modules"],
["a dot-relative path", "./modules"],
["a parent-relative path", "../private/modules"],
["a home-relative path", "~/modules"],
["the empty string", ""],
["whitespace only", " "],
["an absolute path with surrounding whitespace", " /var/lib/ekklesia/modules"],
["a NUL byte", "/var/lib/ekklesia/\u0000modules"],
])("rejects %s with a sanitized MODULES_DIRECTORY_INVALID", (_label, configured) => {
const error = captureDiscoveryError(() =>
resolveModulesDirectory({ configured, repositoryRoot }),
);
expect(codes(error)).toEqual(["MODULES_DIRECTORY_INVALID"]);
expect(error.issues[0]?.message).toBe("EKKLESIA_MODULES_DIR must be an absolute path.");
});
it("never echoes the supplied value in the error", () => {
const configured = "relative/secret-token-a1b2c3/postgresql://admin:hunter2@db/modules";
const error = captureDiscoveryError(() =>
resolveModulesDirectory({ configured, repositoryRoot }),
);
const text = rendered(error);
expect(text).not.toContain("secret-token-a1b2c3");
expect(text).not.toContain("hunter2");
expect(text).not.toContain("relative/");
});
});
describe("module discovery against a configured modules directory", () => {
it("fails closed, without the path, when a configured directory does not exist", async () => {
const parent = await temporaryDirectory();
const missing = path.join(parent, "private-mount-name-7f3e");
const error = await captureAsyncDiscoveryError(
discoverModules({ ...versions, modulesDirectory: missing, modulesDirectoryRequired: true }),
);
expect(codes(error)).toEqual(["MODULES_DIRECTORY_UNREADABLE"]);
expect(rendered(error)).not.toContain("private-mount-name-7f3e");
expect(rendered(error)).not.toContain(parent);
});
it("fails closed when a configured path is a file rather than a directory", async () => {
const parent = await temporaryDirectory();
const file = path.join(parent, "modules");
await writeFile(file, "not a directory");
const error = await captureAsyncDiscoveryError(
discoverModules({ ...versions, modulesDirectory: file, modulesDirectoryRequired: true }),
);
expect(codes(error)).toEqual(["MODULES_DIRECTORY_UNREADABLE"]);
expect(rendered(error)).not.toContain(parent);
});
it("rejects a relative configured path even when it bypasses the resolver", async () => {
const error = await captureAsyncDiscoveryError(
discoverModules({ ...versions, modulesDirectory: "modules", modulesDirectoryRequired: true }),
);
expect(codes(error)).toEqual(["MODULES_DIRECTORY_INVALID"]);
});
it("treats an existing, empty configured directory as zero installed modules", async () => {
const directory = await temporaryDirectory();
await expect(
discoverModules({ ...versions, modulesDirectory: directory, modulesDirectoryRequired: true }),
).resolves.toEqual([]);
});
it("keeps the development default lenient: a missing default directory is zero modules", async () => {
const parent = await temporaryDirectory();
await expect(
discoverModules({ ...versions, modulesDirectory: path.join(parent, "modules") }),
).resolves.toEqual([]);
});
it("discovers through the resolver's own selection unchanged", async () => {
const directory = await temporaryDirectory();
const selection = resolveModulesDirectory({ configured: directory, repositoryRoot });
await expect(discoverModules({ ...versions, ...selection })).resolves.toEqual([]);
});
});
+197 -5
View File
@@ -1,5 +1,7 @@
import { createHash, randomBytes } from "node:crypto";
import { cp, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import net from "node:net";
import { tmpdir } from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { Client, type QueryResult, type QueryResultRow } from "pg";
@@ -8,6 +10,7 @@ import { reportMigrationCommandError } from "../src/cli/migration-command-errors
import {
applyModuleMigrations,
createPostgresMigrationDatabase,
discoverAllMigrations,
discoverCoreMigrations,
type MigrationApproval,
type MigrationApprovalProvider,
@@ -764,7 +767,7 @@ describe.sequential("live PostgreSQL module migration execution", () => {
database: migrationDatabase,
});
expect(result).toEqual({ applied: [], appliedCount: 0, noOp: true });
expect(result).toEqual({ applied: [], appliedCount: 0, noOp: true, retained: [] });
expect(await databaseState(database.client)).toEqual(before);
const afterData = await safeQuery<QueryResultRow>(
database.client,
@@ -834,7 +837,48 @@ describe.sequential("live PostgreSQL module migration execution", () => {
});
});
it("rejects an unknown applied identity without repair or further execution", async () => {
it("retains an absent module's history and still applies later work (ADR 0048)", async () => {
await withIsolatedDatabase("absent_history", async (database) => {
const initial = peopleInitial();
const later = peopleLater();
const initialInventory = aggregate(moduleFixture("people", [initial]));
const completeInventory = aggregate(moduleFixture("people", [initial, later]));
const migrationDatabase = createPostgresMigrationDatabase(database.url);
await applyModuleMigrations({ aggregate: initialInventory, database: migrationDatabase });
// History left by a module that is no longer installed: kept, reported, never fatal.
await safeQuery(
database.client,
`
INSERT INTO public.ekklesia_core_migrations
(identity, module_id, migration_tag, migration_index, checksum)
VALUES ($1, $2, $3, $4, $5)
`,
["ghost:0000_unknown", "ghost", "0000_unknown", 0, "f".repeat(64)],
);
const plan = await planModuleMigrations({
aggregate: completeInventory,
database: migrationDatabase,
});
expect(plan.retained).toEqual([{ appliedCount: 1, moduleId: "ghost" }]);
expect(plan.pendingCount).toBe(1);
const result = await applyModuleMigrations({
aggregate: completeInventory,
database: migrationDatabase,
});
expect(result.applied.map((migration) => migration.identity)).toEqual([later.identity]);
expect(result.retained).toEqual([{ appliedCount: 1, moduleId: "ghost" }]);
expect(await columnExists(database.client, "people_records", "email")).toBe(true);
expect((await readHistory(database.client)).map((row) => row.identity)).toEqual([
initial.identity,
"ghost:0000_unknown",
later.identity,
]);
});
});
it("rejects an unknown applied identity of an installed module without repair or further execution", async () => {
await withIsolatedDatabase("unknown_history", async (database) => {
const initial = peopleInitial();
const later = peopleLater();
@@ -849,7 +893,7 @@ describe.sequential("live PostgreSQL module migration execution", () => {
(identity, module_id, migration_tag, migration_index, checksum)
VALUES ($1, $2, $3, $4, $5)
`,
["ghost:0000_unknown", "ghost", "0000_unknown", 0, "f".repeat(64)],
["people:0099_unknown", "people", "0099_unknown", 99, "f".repeat(64)],
);
const corrupted = await databaseState(database.client);
@@ -866,7 +910,7 @@ describe.sequential("live PostgreSQL module migration execution", () => {
expect(await columnExists(database.client, "people_records", "email")).toBe(false);
expect((await readHistory(database.client)).map((row) => row.identity)).toEqual([
initial.identity,
"ghost:0000_unknown",
"people:0099_unknown",
]);
});
});
@@ -1358,7 +1402,7 @@ describe.sequential("live PostgreSQL Core identity migration execution", () => {
database: migrationDatabase,
});
expect(result).toEqual({ applied: [], appliedCount: 0, noOp: true });
expect(result).toEqual({ applied: [], appliedCount: 0, noOp: true, retained: [] });
expect(await databaseState(database.client)).toEqual(before);
});
});
@@ -1851,3 +1895,151 @@ describe.sequential("live PostgreSQL migration timeout precedence", () => {
});
});
});
/**
* ADR 0048 end to end, with real discovery: Core's committed migrations, the module template
* installed in a modules directory outside the repository, and a real PostgreSQL. Core's newest
* migration is withheld from a copy of `core/drizzle` at first, so a genuinely later Core migration
* can be applied while the module is absent.
*/
describe.sequential("live PostgreSQL retained history for an uninstalled module", () => {
const repositoryRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..");
const templateModule = path.join(repositoryRoot, "templates", "module");
const versions = { moduleSdkVersion: "0.1.0", platformVersion: "0.1.0" } as const;
async function coreWithoutNewestMigration(directory: string): Promise<string> {
const target = path.join(directory, "core-drizzle");
await cp(coreMigrationsDirectory, target, { recursive: true });
const journalPath = path.join(target, "meta", "_journal.json");
const policyPath = path.join(target, "meta", "_ekklesia.json");
const journal = JSON.parse(await readFile(journalPath, "utf8")) as {
entries: { idx: number; tag: string }[];
};
const policy = JSON.parse(await readFile(policyPath, "utf8")) as { entries: unknown[] };
const newest = journal.entries.pop();
policy.entries.pop();
if (!newest) {
throw harnessError("Core has no migration to withhold.");
}
await writeFile(journalPath, `${JSON.stringify(journal, null, 2)}\n`);
await writeFile(policyPath, `${JSON.stringify(policy, null, 2)}\n`);
await rm(path.join(target, `${newest.tag}.sql`));
await rm(path.join(target, "meta", `${String(newest.idx).padStart(4, "0")}_snapshot.json`));
return target;
}
async function installExample(modulesDirectory: string): Promise<void> {
for (const entry of ["package.json", "ekklesia.module.json", "drizzle", "dist"]) {
await cp(path.join(templateModule, entry), path.join(modulesDirectory, "example", entry), {
recursive: true,
});
}
}
async function inventory(coreDirectory: string, modulesDirectory: string) {
return discoverAllMigrations({
coreMigrationsDirectory: coreDirectory,
moduleDiscovery: { ...versions, modulesDirectory, modulesDirectoryRequired: true },
});
}
async function exampleValues(client: Client): Promise<readonly string[]> {
const result = await safeQuery<{ readonly value: string } & QueryResultRow>(
client,
"SELECT value FROM public.example_records ORDER BY value",
);
return result.rows.map((row) => row.value);
}
it("keeps an uninstalled module's data and history, applies later Core work, and restores it", async () => {
const scratch = await mkdtemp(path.join(tmpdir(), "ekklesia-retained-history-"));
try {
const earlyCore = await coreWithoutNewestMigration(scratch);
const modulesDirectory = path.join(scratch, "modules");
await mkdir(modulesDirectory);
await installExample(modulesDirectory);
await withIsolatedDatabase("retained_module", async (database) => {
const migrationDatabase = createPostgresMigrationDatabase(database.url);
// 1. Core (minus its newest migration) and the installed module, applied together.
const first = await applyModuleMigrations({
aggregate: await inventory(earlyCore, modulesDirectory),
database: migrationDatabase,
});
expect(first.applied.map((migration) => migration.identity)).toContain(
"example:0000_initial",
);
expect(first.retained).toEqual([]);
// 2. Module-owned data.
await safeQuery(
database.client,
"INSERT INTO public.example_records (id, value) VALUES (gen_random_uuid(), 'kept')",
);
// 3. The module is uninstalled: its directory is gone from module storage.
await rm(path.join(modulesDirectory, "example"), { recursive: true });
// 4. A later Core migration plans and applies while the module is absent.
const absentInventory = await inventory(coreMigrationsDirectory, modulesDirectory);
const absentPlan = await planModuleMigrations({
aggregate: absentInventory,
database: migrationDatabase,
});
expect(absentPlan.retained).toEqual([{ appliedCount: 1, moduleId: "example" }]);
expect(
absentPlan.entries.filter((entry) => entry.status === "pending").map((e) => e.moduleId),
).toEqual(["core"]);
const later = await applyModuleMigrations({
aggregate: absentInventory,
database: migrationDatabase,
});
expect(later.applied).toHaveLength(1);
expect(later.applied[0]?.moduleId).toBe("core");
expect(later.retained).toEqual([{ appliedCount: 1, moduleId: "example" }]);
// 5. The module's table and rows are untouched, and so is its history.
expect(await exampleValues(database.client)).toEqual(["kept"]);
expect((await readHistory(database.client)).map((row) => row.identity)).toContain(
"example:0000_initial",
);
// 6. The module is reinstalled.
await installExample(modulesDirectory);
const restored = await inventory(coreMigrationsDirectory, modulesDirectory);
// 7. Its history is strict again, and valid; its data is where it left it.
const restoredPlan = await planModuleMigrations({
aggregate: restored,
database: migrationDatabase,
});
expect(restoredPlan.retained).toEqual([]);
expect(restoredPlan.pendingCount).toBe(0);
expect(
restoredPlan.entries.find((entry) => entry.identity === "example:0000_initial")?.status,
).toBe("applied");
const noOp = await applyModuleMigrations({
aggregate: restored,
database: migrationDatabase,
});
expect(noOp.noOp).toBe(true);
expect(await exampleValues(database.client)).toEqual(["kept"]);
// A returning package whose applied migration changed is refused, exactly as before.
const sqlPath = path.join(modulesDirectory, "example", "drizzle", "0000_initial.sql");
await writeFile(sqlPath, `${await readFile(sqlPath, "utf8")}-- edited\n`);
await captureRunnerError(
planModuleMigrations({
aggregate: await inventory(coreMigrationsDirectory, modulesDirectory),
database: migrationDatabase,
}),
"MIGRATION_CHECKSUM_MISMATCH",
);
expect(await exampleValues(database.client)).toEqual(["kept"]);
});
} finally {
await rm(scratch, { recursive: true, force: true });
}
});
});
@@ -0,0 +1,171 @@
import { mkdir, writeFile } from "node:fs/promises";
import path from "node:path";
/**
* A minimal, valid installed module written into a directory the caller owns: `package.json`,
* `ekklesia.module.json`, a compiled-ESM server stub at `dist/server/index.js`, and, unless
* disabled, a native Drizzle migration directory (journal, `_ekklesia.json` policy, snapshot chain,
* and SQL) that passes Core's migration discovery.
*
* Everything is generated here, from nothing in the repository: no build output, no ignored file,
* and no `node_modules`. Migration discovery imports no module code, so the stub is never run.
*/
export const originSnapshotId = "00000000-0000-0000-0000-000000000000";
export interface MigrationFixture {
readonly breakpoints?: boolean;
readonly destructive?:
| { readonly mode: "none" }
| { readonly mode: "approval-required"; readonly reason: string };
readonly snapshotId?: string;
readonly sql: string | Uint8Array;
readonly tag: string;
readonly timestamp?: number;
}
export interface MigrationModuleFixture {
readonly dependencies?: Record<string, unknown>;
readonly manifest?: Record<string, unknown>;
readonly migrations?: readonly MigrationFixture[] | false;
readonly schemaSource?: string;
readonly serverSource?: string;
}
export interface CreatedMigrationModule {
readonly migrationDirectory?: string;
readonly moduleDirectory: string;
}
export function snapshotId(index: number): string {
return `10000000-0000-4000-8000-${(index + 1).toString(16).padStart(12, "0")}`;
}
export function manifestFor(
id: string,
options: {
readonly dependencies?: Record<string, unknown>;
readonly migrations?: boolean;
readonly schema?: boolean;
} = {},
): Record<string, unknown> {
return {
manifestVersion: 8,
id,
name: `${id} module`,
version: "0.1.0",
compatibility: { platform: "^0.1.0", moduleSdk: "^0.1.0" },
...(options.dependencies ? { dependencies: options.dependencies } : {}),
capabilities: [],
permissions: [],
entrypoints: {
server: "./dist/server/index.js",
...(options.schema ? { schema: "./dist/schema/index.js" } : {}),
...(options.migrations === false ? {} : { migrations: "./drizzle" }),
},
};
}
export function defaultMigrations(id: string): readonly MigrationFixture[] {
return [
{
sql: `CREATE TABLE "${id.replaceAll("-", "_")}_records" ("id" integer);\n`,
tag: "0000_initial",
},
];
}
export async function createMigrationModule(
modulesDirectory: string,
id: string,
fixture: MigrationModuleFixture = {},
): Promise<CreatedMigrationModule> {
const migrations =
fixture.migrations === false ? undefined : (fixture.migrations ?? defaultMigrations(id));
const moduleDirectory = path.join(modulesDirectory, id);
const migrationDirectory = migrations ? path.join(moduleDirectory, "drizzle") : undefined;
await mkdir(path.join(moduleDirectory, "dist/server"), { recursive: true });
if (fixture.schemaSource !== undefined) {
await mkdir(path.join(moduleDirectory, "dist/schema"), { recursive: true });
}
if (migrationDirectory) {
await mkdir(path.join(migrationDirectory, "meta"), { recursive: true });
}
await Promise.all([
writeFile(
path.join(moduleDirectory, "package.json"),
JSON.stringify({
name: `@test/module-${id}`,
version: "0.1.0",
type: "module",
ekklesia: { layer: "module" },
}),
),
writeFile(
path.join(moduleDirectory, "ekklesia.module.json"),
JSON.stringify(
fixture.manifest ??
manifestFor(id, {
...(fixture.dependencies ? { dependencies: fixture.dependencies } : {}),
migrations: migrations !== undefined,
schema: fixture.schemaSource !== undefined,
}),
),
),
writeFile(
path.join(moduleDirectory, "dist/server/index.js"),
fixture.serverSource ?? `export default { id: ${JSON.stringify(id)}, version: "0.1.0" };`,
),
...(fixture.schemaSource === undefined
? []
: [writeFile(path.join(moduleDirectory, "dist/schema/index.js"), fixture.schemaSource)]),
]);
if (!migrationDirectory || !migrations) {
return { moduleDirectory };
}
const journalEntries = migrations.map((migration, index) => ({
idx: index,
version: "7",
when: migration.timestamp ?? 1_700_000_000_000 + index,
tag: migration.tag,
breakpoints: migration.breakpoints ?? true,
}));
const policyEntries = migrations.map((migration) => ({
tag: migration.tag,
destructive: migration.destructive ?? { mode: "none" },
}));
await Promise.all([
writeFile(
path.join(migrationDirectory, "meta/_journal.json"),
JSON.stringify({ version: "7", dialect: "postgresql", entries: journalEntries }),
),
writeFile(
path.join(migrationDirectory, "meta/_ekklesia.json"),
JSON.stringify({ version: 1, moduleId: id, entries: policyEntries }),
),
...migrations.flatMap((migration, index) => {
const idForSnapshot = migration.snapshotId ?? snapshotId(index);
const previousId =
index === 0
? originSnapshotId
: (migrations[index - 1]?.snapshotId ?? snapshotId(index - 1));
return [
writeFile(path.join(migrationDirectory, `${migration.tag}.sql`), migration.sql),
writeFile(
path.join(migrationDirectory, `meta/${index.toString().padStart(4, "0")}_snapshot.json`),
JSON.stringify({
version: "7",
dialect: "postgresql",
id: idForSnapshot,
prevId: previousId,
}),
),
];
}),
]);
return { migrationDirectory, moduleDirectory };
}
@@ -0,0 +1,207 @@
import { createHash } from "node:crypto";
import { mkdtemp, readdir, readFile, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { gzipSync } from "node:zlib";
import { createMigrationModule } from "./migration-module-fixture.js";
/**
* Test-only builders for Ekklesia Module Package v1 archives (ADR 0049). They write ustar archives
* by hand so a test can produce exactly what the canonical packager never would: links, device
* nodes, traversal, a second top-level directory, a corrupted payload.
*/
export type FixtureEntryType = "file" | "directory" | "symlink" | "hardlink" | "character" | "fifo";
export interface FixtureEntry {
readonly path: string;
readonly type?: FixtureEntryType;
readonly mode?: number;
readonly data?: Uint8Array | string;
readonly linkname?: string;
}
const typeflags: Readonly<Record<FixtureEntryType, string>> = {
character: "3",
directory: "5",
fifo: "6",
file: "0",
hardlink: "1",
symlink: "2",
};
function octal(value: number, width: number): string {
return `${value.toString(8).padStart(width - 1, "0")}\u0000`;
}
function header(entry: FixtureEntry, size: number): Buffer {
const block = Buffer.alloc(512);
const name =
entry.type === "directory" && !entry.path.endsWith("/") ? `${entry.path}/` : entry.path;
let prefix = "";
let base = name;
if (Buffer.byteLength(name) > 100) {
const split = name.lastIndexOf("/", name.length - 2);
prefix = name.slice(0, split);
base = name.slice(split + 1);
}
block.write(base, 0, 100, "latin1");
block.write(octal(entry.mode ?? (entry.type === "directory" ? 0o755 : 0o644), 8), 100, "latin1");
block.write(octal(0, 8), 108, "latin1");
block.write(octal(0, 8), 116, "latin1");
block.write(octal(size, 12), 124, "latin1");
block.write(octal(0, 12), 136, "latin1");
block.write(" ", 148, "latin1");
block.write(typeflags[entry.type ?? "file"], 156, "latin1");
block.write(entry.linkname ?? "", 157, 100, "latin1");
block.write("ustar\u000000", 257, "latin1");
block.write(prefix, 345, 155, "latin1");
let checksum = 0;
for (const byte of block) {
checksum += byte;
}
block.write(`${checksum.toString(8).padStart(6, "0")}\u0000 `, 148, "latin1");
return block;
}
/** A gzip-compressed ustar archive of exactly these entries, in this order. */
export function tarGz(entries: readonly FixtureEntry[]): Buffer {
const parts: Buffer[] = [];
for (const entry of entries) {
const data =
entry.type === undefined || entry.type === "file"
? Buffer.from(entry.data ?? "")
: Buffer.alloc(0);
parts.push(header(entry, data.length), data, Buffer.alloc((512 - (data.length % 512)) % 512));
}
parts.push(Buffer.alloc(1024));
return gzipSync(Buffer.concat(parts), { level: 9 });
}
export function sha256(data: Uint8Array | string): string {
return createHash("sha256").update(data).digest("hex");
}
export interface PackageFixtureOptions {
readonly id?: string;
readonly version?: string;
readonly serverSource?: string;
readonly schemaSource?: string;
readonly manifest?: (manifest: Record<string, unknown>) => Record<string, unknown>;
readonly runtimePackage?: Record<string, unknown>;
readonly metadata?: (metadata: Record<string, unknown>) => Record<string, unknown>;
/** Changes the payload after its digests were computed. */
readonly tamper?: (files: Map<string, Buffer>) => void;
/** Entries appended after the package's own, under any path. */
readonly extraEntries?: readonly FixtureEntry[];
}
export interface PackageFixture {
readonly archive: Buffer;
readonly sha256: string;
}
async function readTree(root: string): Promise<Map<string, Buffer>> {
const files = new Map<string, Buffer>();
for (const entry of await readdir(root, { recursive: true, withFileTypes: true })) {
if (entry.isFile()) {
const absolute = path.join(entry.parentPath, entry.name);
files.set(path.relative(root, absolute).split(path.sep).join("/"), await readFile(absolute));
}
}
return files;
}
/**
* A valid any-target package of a minimal module: a server stub with no imports, one migration, a
* notices file, the generated runtime package.json, and complete metadata. Every option changes one
* thing, so a test can break exactly the rule it is about.
*/
export async function modulePackageFixture(
options: PackageFixtureOptions = {},
): Promise<PackageFixture> {
const id = options.id ?? "example";
const version = options.version ?? "0.1.0";
const scratch = await mkdtemp(path.join(tmpdir(), "ekklesia-package-fixture-"));
let files: Map<string, Buffer>;
try {
const created = await createMigrationModule(scratch, id, {
...(options.schemaSource === undefined ? {} : { schemaSource: options.schemaSource }),
serverSource:
options.serverSource ??
`export default { id: ${JSON.stringify(id)}, version: ${JSON.stringify(version)}, register() {} };\n`,
});
files = await readTree(created.moduleDirectory);
} finally {
await rm(scratch, { force: true, recursive: true });
}
const manifest = JSON.parse(
files.get("ekklesia.module.json")?.toString("utf8") ?? "{}",
) as Record<string, unknown>;
files.set(
"ekklesia.module.json",
Buffer.from(
`${JSON.stringify(options.manifest ? options.manifest(manifest) : { ...manifest, version }, null, 2)}\n`,
),
);
files.set(
"package.json",
Buffer.from(
`${JSON.stringify(
options.runtimePackage ?? {
name: `@test/module-${id}`,
version,
private: true,
type: "module",
ekklesia: { layer: "module" },
},
null,
2,
)}\n`,
),
);
files.set("THIRD_PARTY_NOTICES", Buffer.from("No third-party software is included.\n"));
const listed = [...files.entries()]
.sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0))
.map(([filePath, data]) => ({ path: filePath, size: data.length, sha256: sha256(data) }));
const baseMetadata: Record<string, unknown> = {
packageFormat: 1,
module: { id, version },
target: "any",
runtime: { node: ">=20" },
provenance: {
revision: "86ae907f59458508e3277666cdd214f5bd7c1078",
packager: { name: "@ekklesia/module-pack", version: "0.1.2" },
},
incorporates: { "@ekklesia/module-sdk": "0.1.2" },
files: listed,
};
const metadata = options.metadata ? options.metadata(baseMetadata) : baseMetadata;
options.tamper?.(files);
const directories = new Set<string>([id]);
for (const filePath of files.keys()) {
const segments = filePath.split("/");
for (let index = 1; index < segments.length; index += 1) {
directories.add(`${id}/${segments.slice(0, index).join("/")}`);
}
}
const all = new Map<string, FixtureEntry>();
for (const directory of directories) {
all.set(directory, { path: directory, type: "directory" });
}
for (const [filePath, data] of files) {
all.set(`${id}/${filePath}`, { path: `${id}/${filePath}`, data });
}
all.set(`${id}/ekklesia.package.json`, {
path: `${id}/ekklesia.package.json`,
data: `${JSON.stringify(metadata, null, 2)}\n`,
});
const ordered = [...all.values()].sort((left, right) =>
left.path < right.path ? -1 : left.path > right.path ? 1 : 0,
);
const archive = tarGz([...ordered, ...(options.extraEntries ?? [])]);
return { archive, sha256: sha256(archive) };
}
@@ -0,0 +1,75 @@
import { readFile } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";
import {
parseSha256File,
parseVerifyModulePackageArguments,
readModulePackagePlatform,
} from "../src/cli/verify-module-package.js";
const repositoryRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..");
const digest = "ab".repeat(32);
describe("verify-module-package arguments", () => {
it("takes an archive and, optionally, a digest and an extraction directory", () => {
expect(parseVerifyModulePackageArguments(["example-0.1.0-any.tar.gz"])).toEqual({
archivePath: "example-0.1.0-any.tar.gz",
});
expect(
parseVerifyModulePackageArguments([
"x.tar.gz",
"--sha256",
digest.toUpperCase(),
"--extract-to",
"/staging/example",
]),
).toEqual({ archivePath: "x.tar.gz", expectedSha256: digest, extractTo: "/staging/example" });
expect(
parseVerifyModulePackageArguments(["x.tar.gz", "--sha256-file", "x.tar.gz.sha256"]),
).toEqual({ archivePath: "x.tar.gz", sha256File: "x.tar.gz.sha256" });
});
it.each([
["no archive", []],
["two archives", ["a.tar.gz", "b.tar.gz"]],
["an unknown option", ["a.tar.gz", "--force"]],
["both digest options", ["a.tar.gz", "--sha256", digest, "--sha256-file", "a.sha256"]],
["a malformed digest", ["a.tar.gz", "--sha256", "abc"]],
["an option without its value", ["a.tar.gz", "--extract-to"]],
])("refuses %s", (_label, argv) => {
expect(() => parseVerifyModulePackageArguments(argv)).toThrow();
});
});
describe("verify-module-package digest files", () => {
it("reads the sha256sum format, ignoring the recorded file name", () => {
expect(parseSha256File(`${digest} example-0.1.0-any.tar.gz\n`)).toBe(digest);
expect(parseSha256File(`${digest.toUpperCase()} *renamed.tar.gz\n`)).toBe(digest);
});
it.each([
"",
"not-a-digest x.tar.gz\n",
`${digest}\n${digest} second\n`,
])("refuses %j", (contents) => {
expect(() => parseSha256File(contents)).toThrow();
});
});
describe("verify-module-package platform facts", () => {
it("reads this platform's versions and its transitional database runtime from the repository", async () => {
const root = JSON.parse(await readFile(path.join(repositoryRoot, "package.json"), "utf8"));
const core = JSON.parse(
await readFile(path.join(repositoryRoot, "core", "package.json"), "utf8"),
);
const sdk = JSON.parse(
await readFile(path.join(repositoryRoot, "packages", "module-sdk", "package.json"), "utf8"),
);
await expect(readModulePackagePlatform(repositoryRoot)).resolves.toEqual({
databaseRuntime: { "drizzle-orm": core.dependencies["drizzle-orm"] },
moduleSdkVersion: sdk.version,
platformVersion: root.version,
});
});
});
+37 -29
View File
@@ -1,9 +1,10 @@
# Ekklesia's one production image (ADR 0044): the `api` service and every operator-run CLI, from one
# build. See deploy/README.md for how to build and run it.
# Ekklesia's one generic platform image (ADR 0044, ADR 0048): the `api` service and every operator-run
# CLI, from one build, for every installation. See deploy/README.md for how to build and run it.
#
# The build context is an already-assembled workspace: the platform, zero or more module packages in
# `modules/<id>`, and the `pnpm-lock.yaml` that describes exactly that set. This file clones nothing
# and chooses no modules. With an empty or absent `modules/`, it builds the platform alone.
# The build context is this repository. The image contains **zero feature modules**: installed
# modules live in installation-owned storage, mounted read-only at /var/lib/ekklesia/modules
# (EKKLESIA_MODULES_DIR) and discovered when a process starts. `.dockerignore` keeps a checkout's
# `modules/` out of the context, so a build is the same whether or not one is present.
#
# No `# syntax=` directive, deliberately: it would make BuildKit pull an unpinned frontend image.
# The built-in frontend supports everything below, including cache mounts.
@@ -28,9 +29,10 @@ COPY package.json ./
RUN corepack enable && corepack install
# ---------------------------------------------------------------------------------------------------
# manifests: the lockfile, workspace configuration, and every workspace package.json, at their
# repository paths. Dependency installation keys on these alone, so a source-only change reuses the
# installed layers.
# manifests: the lockfile, workspace configuration, and the package.json of every platform package
# the runtime needs, at their repository paths. Dependency installation keys on these alone, so a
# source-only change reuses the installed layers. The module template is a workspace package but not
# part of the platform, so neither its manifest nor its dependencies reach the runtime.
# ---------------------------------------------------------------------------------------------------
FROM base AS manifests
WORKDIR /context
@@ -38,9 +40,8 @@ COPY . .
RUN set -eu; \
mkdir /manifests; \
cp package.json pnpm-lock.yaml pnpm-workspace.yaml .npmrc /manifests/; \
for manifest in apps/*/package.json builders/*/package.json core/package.json \
modules/*/package.json packages/*/package.json surfaces/*/package.json \
templates/*/package.json; do \
for manifest in apps/*/package.json core/package.json packages/*/package.json \
surfaces/*/package.json; do \
if [ -f "$manifest" ]; then install -D -m 0644 "$manifest" "/manifests/$manifest"; fi; \
done
@@ -52,37 +53,38 @@ FROM toolchain AS build
# module UI builder's, which every module build invokes), and pnpm links a bin only when its target
# already exists. The store cache mount keeps a rebuild off the network all the same.
COPY . .
# A second line of defence behind `.dockerignore`: a context that still carries installed modules is
# refused rather than built into a platform image.
RUN if [ -e modules ]; then \
echo "The build context contains modules/. Installed modules never enter the platform image (ADR 0048)." >&2; \
exit 1; \
fi
RUN --mount=type=cache,id=ekklesia-pnpm-store,target=/pnpm/store \
pnpm install --frozen-lockfile --store-dir /pnpm/store
# The same gates `pnpm verify` runs for boundaries, modules, and migrations, against exactly the
# module set in this context. `pnpm boundaries` comes first and reads only sources: among its rules,
# a module that imports something at run time without declaring it a production dependency fails
# here, because the production-only install below would leave it unresolvable at API startup. Each
# The same gates `pnpm verify` runs for boundaries, modules, and migrations. With no modules in the
# context, module validation covers the empty set and migration validation covers Core's own. Each
# validate script builds first; the final build is a Turbo cache hit that keeps the intent explicit.
RUN pnpm boundaries && pnpm modules:validate && pnpm migrations:validate && pnpm build
# What the runtime resolves at startup, at the paths it resolves them from:
# - every workspace package's `dist/` (every package exports only from `dist/`), which includes
# - every platform package's `dist/` (every package exports only from `dist/`), which includes
# the Dashboard and display host assets under `surfaces/shell/dist/browser/`;
# - Core's migrations, `core/drizzle/` with its `meta/` journal;
# - each module directory whole, minus `node_modules`, because each manifest names its own
# entrypoints, UI artifacts, and migrations directory.
# `node_modules` comes from the production-only install below, never from this stage.
# - Core's migrations, `core/drizzle/` with its `meta/` journal.
# Never a module and never the module template. `node_modules` comes from the production-only
# install below, never from this stage.
RUN set -eu; \
mkdir -p /runtime/core /runtime/modules; \
mkdir -p /runtime/core; \
for package in apps/* core packages/* surfaces/*; do \
if [ -d "$package/dist" ]; then \
mkdir -p "/runtime/$package"; \
cp -a "$package/dist" "/runtime/$package/dist"; \
fi; \
done; \
cp -a core/drizzle /runtime/core/drizzle; \
if [ -d modules ]; then \
tar -C modules --exclude='node_modules' -cf - . | tar -C /runtime/modules -xf -; \
fi
cp -a core/drizzle /runtime/core/drizzle
# ---------------------------------------------------------------------------------------------------
# prod-deps: the same lockfile, production dependencies only. No compiler, bundler, or test runner
# reaches the runtime image.
# prod-deps: the same lockfile, production dependencies only, for the platform packages alone. No
# compiler, bundler, or test runner reaches the runtime image, and neither does anything only the
# module template depends on.
# ---------------------------------------------------------------------------------------------------
FROM toolchain AS prod-deps
COPY --from=manifests /manifests/ ./
@@ -109,13 +111,19 @@ LABEL org.opencontainers.image.title="Ekklesia" \
# the API still binds 127.0.0.1. `production` is stated rather than relied on as the default.
ENV NODE_ENV=production \
EKKLESIA_API_HOST=0.0.0.0 \
EKKLESIA_API_PORT=3000
EKKLESIA_API_PORT=3000 \
EKKLESIA_MODULES_DIR=/var/lib/ekklesia/modules
# The repository layout, preserved: the API finds the repository root three directories above
# `apps/api/dist/`, and every CLI uses its working directory as the root.
# `apps/api/dist/`, and every CLI uses its working directory as the root. Installed modules are not
# under it: they are read from EKKLESIA_MODULES_DIR.
WORKDIR /opt/ekklesia
COPY --from=prod-deps /opt/ekklesia/ ./
COPY --from=build /runtime/ ./
# The mount point for installation-owned module storage (ADR 0048), empty in the image. An image run
# with nothing mounted there has zero modules; the reference deployment mounts host storage
# read-only over it.
RUN install -d -o root -g root -m 0755 /var/lib/ekklesia/modules
# Files stay root-owned: the unprivileged runtime user can read the installation, never modify it.
USER node
+176 -50
View File
@@ -64,6 +64,7 @@ TLS is everywhere, without any exception in Core:
| `/etc/ekklesia/object-storage.env` | The S3 key pair | `object-storage` |
| `/etc/ekklesia/tunnel.env` | `TUNNEL_TOKEN` | `tunnel` |
| `/etc/ekklesia/tls/` | The CA and server certificates and keys | mounted file by file |
| `/var/lib/ekklesia/modules/` | Installed modules ([Installed modules](#installed-modules)); not a secret | `api`, `migrate`, read-only |
The secret files are owned by root with mode 600, one per service, so no container receives another
service's credentials. None is committed, and `deploy/.env` is ignored by Git. `EKKLESIA_ENV_DIR`
@@ -94,10 +95,19 @@ and `EKKLESIA_TLS_DIR` in `deploy/.env` move the directories.
`EKKLESIA_IDENTITY_BASE_URL` and `EKKLESIA_IDENTITY_TRUSTED_ORIGINS` are `https://` and your
Ekklesia hostname. `S3_DELIVERY_ENDPOINT` is `https://` and your storage hostname.
5. **Configure the proxy:** copy `Caddyfile.example` to `Caddyfile`, and set `EKKLESIA_HOSTNAME`,
5. **Create module storage**, empty, even if you install no module yet:
```sh
sudo install -d -o root -g root -m 755 /var/lib/ekklesia/modules
```
`api` and `migrate` refuse to start without it: Compose never creates it for you, so a mistyped
path cannot quietly become an empty directory. `EKKLESIA_MODULES_HOST_DIR` in `deploy/.env`
moves it.
6. **Configure the proxy:** copy `Caddyfile.example` to `Caddyfile`, and set `EKKLESIA_HOSTNAME`,
`EKKLESIA_STORAGE_HOSTNAME`, and `EKKLESIA_ACME_EMAIL` in `deploy/.env`. Both hostnames must
resolve to this host with ports 80 and 443 reachable for HTTP-01, or see [DNS-01](#dns-01).
6. **Start the data services, then migrate explicitly:**
7. **Start the data services, then migrate explicitly:**
```sh
docker compose -f compose.yaml -f compose.proxy.yaml up -d --wait postgres object-storage
@@ -107,8 +117,8 @@ and `EKKLESIA_TLS_DIR` in `deploy/.env` move the directories.
Nothing ever applies migrations automatically: the API never does, and `migrate` runs only when
you run it.
7. **Start everything:** `docker compose -f compose.yaml -f compose.proxy.yaml up -d --wait`.
8. **Check it:** `curl https://<hostname>/api/v1/health/ready` answers `{"status":"ready"}`, and
8. **Start everything:** `docker compose -f compose.yaml -f compose.proxy.yaml up -d --wait`.
9. **Check it:** `curl https://<hostname>/api/v1/health/ready` answers `{"status":"ready"}`, and
`docker compose run --rm migrate node core/dist/cli/doctor.js` reports `READY` (with warnings
for anything optional you left unconfigured, such as email).
@@ -123,25 +133,114 @@ is created already verified, so they can sign in before any email relay exists
([ADR 0035](../docs/architecture/decisions/0035-initial-platform-owner-bootstrap.md)). To supply the
password from a secret manager instead, pipe it on standard input and use `-T` instead of `-it`.
### Installed modules
The image contains no modules
([ADR 0048](../docs/architecture/decisions/0048-generic-image-and-installed-module-storage.md)).
Installed modules live on the host, in module storage: `/var/lib/ekklesia/modules`, or
`EKKLESIA_MODULES_HOST_DIR`. Compose mounts it **read-only** at `/var/lib/ekklesia/modules` in `api`
and `migrate`, which is where `EKKLESIA_MODULES_DIR` points. Ekklesia never writes there.
Installing, updating, or removing a module never needs a new image, only a restart.
**What belongs there.** One directory per module, each extracted from that module's **Ekklesia
Module Package**
([ADR 0049](../docs/architecture/decisions/0049-module-package-v1.md)): one archive,
`<id>-<version>-<target>.tar.gz`, and the SHA-256 published beside it. A package holds the module's
generated `package.json`, its manifest, `ekklesia.package.json`, bundled backend code, built browser
artifacts, migrations, and `THIRD_PARTY_NOTICES`
([Module development](../docs/module-development/README.md#an-installed-module-is-self-contained)).
Copying a module's `dist/` alone is not enough. Files are owned by root and readable by everyone
(directories 755, files 644), so the API's unprivileged user can read them and never change them.
The digest proves the archive is the one you were given; it does not prove who published it.
**Nothing else belongs there.** Every non-hidden directory must be a valid module, or the API does
not start. That includes `lost+found`, so if module storage is a file system of its own, mount it
one level up and point `EKKLESIA_MODULES_HOST_DIR` at a directory inside it. Hidden entries are
ignored, which is what makes staging possible.
**Changes take effect only at a restart.** A running API never notices module storage changing, and
never loads or unloads a module while it runs. Change module storage only while the API is stopped:
it reads browser artifacts from disk as it serves them, so files changed under a running API would
serve one version's browser code beside another's server code.
To **install or update** a module, with `<id>` its module id and the package and its `.sha256` in
the current directory, first **verify** it with the image's own verifier. It checks the archive's
digest, its safety (no links, special files, or escaping paths), every file's digest, the package's
identity, target, and Node.js range against this platform, runs the platform's module and migration
validation, and loads the backend once in a short-lived process with no environment. Only then does
it write the verified module directory, to a new directory outside module storage:
```sh
mkdir -m 777 staging # writable by the image's unprivileged user; nothing else is
docker run --rm --network none --read-only --tmpfs /tmp \
-v "$PWD:/package:ro" -v "$PWD/staging:/staging" \
--entrypoint node "$EKKLESIA_IMAGE" core/dist/cli/verify-module-package.js \
/package/<id>-<version>-any.tar.gz --sha256-file /package/<id>-<version>-any.tar.gz.sha256 \
--extract-to /staging/<id>
```
Then install the verified directory:
```sh
docker compose stop api
# Stage under a hidden name, which discovery ignores, then rename it into place in one step.
sudo cp -R staging/<id> /var/lib/ekklesia/modules/.incoming-<id>
sudo chown -R root:root /var/lib/ekklesia/modules/.incoming-<id>
sudo chmod -R a+rX,go-w /var/lib/ekklesia/modules/.incoming-<id>
sudo mv /var/lib/ekklesia/modules/<id> /var/lib/ekklesia/modules/.previous-<id> # update only
sudo mv /var/lib/ekklesia/modules/.incoming-<id> /var/lib/ekklesia/modules/<id>
docker compose run --rm migrate node core/dist/cli/validate-modules.js
docker compose run --rm migrate # plan: review it
docker compose run --rm migrate node core/dist/cli/apply-migrations.js # only when the plan is right
docker compose up -d --wait api
```
Back up before an update that brings migrations: migrations are forward-only. A newly installed
module is enabled unless an administrator disabled it before; set its configuration and grants as
its documentation says. Keep `.previous-<id>` until the update has proven itself, then delete it.
To **remove** a module, stop the API, delete (or move away) its directory, and start the API again.
Remove first any module that *requires* it, or the API refuses to start. Removing a module's code
deletes nothing else. Its tables and rows, its buckets and objects, its enablement, configuration,
Dashboard placement, and grants all stay. Its applied migration history stays too, and the
migration plan and Doctor report it as `retained` history for a module that is not installed. That
is information, not a problem, and it blocks no other migration. To stop a module without removing
it, disable it instead (`set-module-enabled.js --disable`, [Operator commands](#operator-commands)).
There is no purge: deleting a module's data is a separate operation that does not exist yet.
To **restore** a removed module, verify and install its package again as above, at the same version
or a newer one. It
finds its data, its settings, and its history where it left them. Its history is strict again: a
package that no longer contains a migration that was applied, or contains one with different
contents, is refused by the migration plan, so an older version cannot be installed over newer data.
Module storage is installation state: back it up with the database and the buckets.
### Upgrading
1. **Pull or build the new image**, and change `EKKLESIA_IMAGE` in `deploy/.env`. Keep the old tag.
Installed modules stay where they are, in module storage.
2. **Back up** ([Backups](#backups)).
3. **Plan, review, then apply:** `docker compose run --rm migrate` with the new image, then
3. **Check the installed modules against the new platform:**
`docker compose run --rm migrate node core/dist/cli/validate-modules.js`. A module whose
`compatibility.platform` range excludes the new version fails here, and would stop the API from
starting: update it first, or stay on the old image.
4. **Plan, review, then apply:** `docker compose run --rm migrate` with the new image, then
`docker compose run --rm migrate node core/dist/cli/apply-migrations.js`.
4. **Restart on the new image:** `docker compose -f compose.yaml -f compose.proxy.yaml up -d --wait`.
5. **Restart on the new image:** `docker compose -f compose.yaml -f compose.proxy.yaml up -d --wait`.
### Rolling back
Set `EKKLESIA_IMAGE` back to the previous tag and run `up -d --wait` again. That is enough only when
the upgrade applied no migrations (the plan in step 3 showed none pending). Migrations are
the upgrade applied no migrations (the plan in step 4 showed none pending). Migrations are
forward-only. If any were applied, restore the backup taken in step 2 before starting the previous
image, because it was not built for the newer schema.
image, because it was not built for the newer schema. Module storage is not part of the image, so a
rollback does not change installed modules.
### Backups
Slice 6 adds the scheduled backup job and the restore procedure (ADR 0044, Backups). Until then,
take a `pg_dump` and a copy of every bucket before each upgrade by hand.
take a `pg_dump`, a copy of every bucket, and a copy of module storage before each upgrade by hand.
### Split Dashboard and API origins
@@ -309,9 +408,13 @@ deployment:
- `pnpm deploy:config` validates the base, base + proxy, and base + proxy + tunnel with the example
env files. CI runs it in `pnpm ci:verify`.
- `pnpm deploy:smoke` (`scripts/smoke-deploy.sh`) runs the whole deployment end to end, against a
platform-only image and a disposable PostgreSQL and SeaweedFS that exist only for that run. It
follows the first boot above and checks the following through the proxy:
- `bash scripts/check-image.sh` builds the image and proves it is generic: no `/opt/ekklesia/modules`,
no module template, no `ekklesia.module.json` outside installed dependencies, an empty root-owned
`/var/lib/ekklesia/modules`, and `EKKLESIA_MODULES_DIR` set. It then builds again from a checkout
with a populated `modules/` directory and requires an identical runtime file system. CI runs it.
- `pnpm deploy:smoke` (`scripts/smoke-deploy.sh`) runs the whole deployment end to end, against the
generic image, empty module storage, and a disposable PostgreSQL and SeaweedFS that exist only for
that run. It follows the first boot above and checks the following through the proxy:
- readiness and the Dashboard;
- a signed object URL fetched through the storage hostname, and the same image loaded twice over
HTTP/2, the second time as a conditional request that must be a clean `304`;
@@ -324,12 +427,22 @@ deployment:
- Doctor.
`EKKLESIA_SMOKE_TOPOLOGY=split` runs the [split topology](#split-dashboard-and-api-origins)
instead: an image with the repository's module template installed as a test-only module, every
hostname's served and refused paths, exact CORS for the Dashboard's origin and none for others,
and then a real headless Chromium (pinned by digest, driven over the DevTools protocol) that signs
in on the Dashboard's hostname, checks the session cookie lives only on the API's, mounts the
module's UI from the API's origin and sees its own credentialed requests succeed, mounts its display
on the API's hostname with its service worker, and signs out.
instead, on the **same generic image**, and also proves [Installed modules](#installed-modules)
end to end:
- the image boots with no module;
- the repository's module template, packaged as an Ekklesia Module Package v1 by the canonical
packager, is verified by the image itself and installed into module storage, its migration is
planned and explicitly applied, the API restarts, and the module reads data it owns;
- every hostname's served and refused paths, exact CORS for the Dashboard's origin and none for
others;
- a real headless Chromium (pinned by digest, driven over the DevTools protocol) signs in on the
Dashboard's hostname, checks the session cookie lives only on the API's, mounts the module's UI
from the API's origin and sees its own credentialed requests succeed, mounts its display on the
API's hostname with its service worker, and signs out;
- the module is removed and the API restarted: Core still runs, the module's routes are gone,
migration planning and applying succeed and report its retained history, and its data is intact;
- the exact same package is verified and installed again and the API restarted: its history
validates strictly and it returns its previous data.
CI runs both topologies on every change.
- **Test-only:** the smoke test runs Caddy with its internal CA (`local_certs`), because a test run
@@ -350,34 +463,37 @@ is no second image and no second build.
### What it builds
The build context is an **already-assembled workspace**: this repository, plus zero or more module
packages already present in `modules/<id>`, plus a `pnpm-lock.yaml` describing exactly that set.
The Dockerfile clones nothing and chooses no modules. It builds what it is given. With no
`modules/` directory, it builds the platform alone; CI builds that case on every change.
The build context is **this repository**, and the image is the same for every installation
([ADR 0048](../docs/architecture/decisions/0048-generic-image-and-installed-module-storage.md)). It
contains **zero feature modules**: installed modules live in module storage outside it
([Installed modules](#installed-modules)). No input is specific to an installation: no module list,
no lockfile of its own, no branding, no configuration.
Assembling the context (checking out the platform at a release tag, each module at a pinned tag or
commit, and the instance's own lockfile) belongs to the installation's instance composition, not to
this file.
A `modules/` directory in the checkout never reaches the image. `.dockerignore` keeps it out of the
build context, and the build refuses a context that still contains one. The module template is a
workspace package, so it is built and validated with everything else, but neither its files nor its
dependencies reach the runtime image. `scripts/check-image.sh` proves all of this on every change
([Testing this deployment](#testing-this-deployment)).
| Stage | Does |
| --- | --- |
| `base` | `node:24.18.0-trixie-slim`, pinned by its multi-architecture index digest. Debian, so glibc, because module dependencies such as sharp ship glibc prebuilt binaries. Every stage starts here. |
| `toolchain` | Enables pnpm through Corepack, at exactly the root `packageManager` version. |
| `manifests` | Collects the lockfile, workspace configuration, `.npmrc`, and every workspace `package.json` at its repository path. |
| `build` | `pnpm install --frozen-lockfile`, then `pnpm boundaries`, `pnpm modules:validate`, `pnpm migrations:validate`, and `pnpm build`, against exactly the modules in the context. Never uses host `node_modules`: `.dockerignore` excludes them. |
| `prod-deps` | The same lockfile with `--prod`: production dependencies only. |
| `runtime` | Production dependencies plus built outputs, owned by root and run as the unprivileged `node` user (uid 1000). No pnpm, no compiler, no curl. |
| `manifests` | Collects the lockfile, workspace configuration, `.npmrc`, and the `package.json` of every platform package (not the module template's) at its repository path. |
| `build` | Refuses a context containing `modules/`, then `pnpm install --frozen-lockfile`, `pnpm boundaries`, `pnpm modules:validate`, `pnpm migrations:validate`, and `pnpm build`. Never uses host `node_modules`: `.dockerignore` excludes them. |
| `prod-deps` | The same lockfile with `--prod`, for the platform packages only: production dependencies only. |
| `runtime` | Production dependencies plus built outputs, owned by root and run as the unprivileged `node` user (uid 1000), and an empty, root-owned `/var/lib/ekklesia/modules`. No pnpm, no compiler, no curl. |
The runtime keeps the repository layout at `/opt/ekklesia`, because that layout is how the platform
finds its files:
- the API takes the repository root as three directories above `apps/api/dist/`, and reads
`package.json`, `packages/module-sdk/package.json`, and `modules/` beneath it;
`package.json` and `packages/module-sdk/package.json` beneath it;
- every CLI takes its **working directory** as the root and also reads Core's migrations in
`core/drizzle/`, so commands must run from `/opt/ekklesia`, the image's `WORKDIR`;
- the Dashboard and display host files are read from `surfaces/shell/dist/browser/`;
- each module directory is copied whole, minus `node_modules`, because each manifest names its
own entrypoints, UI artifacts, and migrations directory.
- installed modules are read from `EKKLESIA_MODULES_DIR`, `/var/lib/ekklesia/modules`, never from
under `/opt/ekklesia`. With nothing mounted there, the image runs with no modules.
### Building
@@ -399,30 +515,37 @@ set `DOCKER_BUILDKIT=1` if your Docker CLI has no buildx plugin.
No secret is a build argument, and none enters any layer. `.env` files are excluded from the build
context entirely, and no registry credentials are needed.
**A module missing a production dependency fails the build, not the API.** The runtime receives
production dependencies only, so a module that imports a package at run time but declares it only
as a development or peer dependency would build and then fail at startup. `pnpm boundaries` runs
in the build stage and rejects that module first
(`runtime-imports-are-production-dependencies`, see
[Repository boundaries](../docs/architecture/repository-boundaries.md)). The same applies to every
other boundary rule: a module the platform's own CI would reject cannot be built into an image.
### The runtime an installed module targets
**A platform-only image does not exercise native module dependencies.** With no modules in the
context, nothing like sharp is installed, so the CI image build proves nothing about them. An
instance build that includes such a module must smoke-test the built image itself: start it,
confirm `/api/v1/health/ready` answers `200`, and exercise the module's native code path (for
example, process one image).
An installed module runs on the image's Node.js, inside the API process, from its own directory
([ADR 0048](../docs/architecture/decisions/0048-generic-image-and-installed-module-storage.md)):
- **Node.js 24.18.0 on Debian 13, so glibc**, for the architecture the image was built for. A native
dependency such as sharp must be built or downloaded for exactly that.
- **Its own dependencies only.** Node resolves a module's imports from the module's directory, and
the platform's `node_modules` is never on that path. A module whose server code imports something
its directory does not contain stops the API from starting, with a sanitized
`MODULE_RUNTIME_IMPORT_FAILED`.
- **The platform's exact Drizzle version, for now.** Core runs a module's queries through its own
Drizzle instance over tables the module defined with its own copy, so the two copies must be
interchangeable. That is a constraint of the current implementation, not a permanent contract.
The repository's boundary checks (`pnpm boundaries`) prove only the source modules in a workspace.
An installed module's conformance is its own CI's job, so check a module before installing it:
`validate-modules.js` ([Operator commands](#operator-commands)) validates what can be checked on an
installed directory, and the API's startup refuses anything it cannot load.
### Configuration
Configuration comes **only from the container environment**, never from a file inside the image.
The image sets exactly three variables itself:
The image sets exactly four variables itself:
| Variable | Image value | Why |
| --- | --- | --- |
| `NODE_ENV` | `production` | Stated rather than relied on. Anything other than `development` already validates as production. |
| `EKKLESIA_API_HOST` | `0.0.0.0` | Reachable from other containers. Outside a container the platform default stays `127.0.0.1`. |
| `EKKLESIA_API_PORT` | `3000` | The platform default, made explicit; `EXPOSE 3000`. |
| `EKKLESIA_MODULES_DIR` | `/var/lib/ekklesia/modules` | Where installed modules are read from (ADR 0048). Outside a container, unset means the checkout's own `modules/`. Compose states the same value beside the mount. |
Everything else is the ordinary host configuration in
[API § Required configuration](../docs/development/api.md#required-configuration), supplied by the
@@ -443,8 +566,8 @@ Never publish the API's port to the host or the Internet. Only the proxy publish
- **Read-only:** the image was verified under exactly these flags. The API reached `healthy`, and
the migration and Doctor commands ran. Nothing wrote to `/tmp`, which stays mounted as headroom
for module code that needs scratch space. The installation, `/home/node` included, is not
writable, so anything that needs a writable path other than `/tmp` is a defect to report, not to
work around.
writable, and neither is module storage, which is mounted read-only. Anything that needs a
writable path other than `/tmp` is a defect to report, not to work around.
### Health
@@ -482,14 +605,17 @@ reports direct mode for an API that trusts its proxy. The running API's own valu
| Apply migrations | `docker compose run --rm migrate node core/dist/cli/apply-migrations.js` |
| Doctor (read-only report) | `docker compose run --rm migrate node core/dist/cli/doctor.js` |
| Create the initial owner (interactive) | `docker compose run --rm -it migrate node core/dist/cli/create-initial-owner.js --email [email protected]` |
| Validate the installed modules (read-only) | `docker compose run --rm migrate node core/dist/cli/validate-modules.js` |
| Verify a module package (read-only unless extracting) | `node core/dist/cli/verify-module-package.js <archive> --sha256-file <archive>.sha256 [--extract-to <new directory>]`, in a `docker run` of the image ([Installed modules](#installed-modules)) |
| Enable or disable a module | `docker compose run --rm migrate node core/dist/cli/set-module-enabled.js --enable --module <id>` (or `--disable`) |
The plan, apply, Doctor, and initial-owner commands are exercised by the smoke test; module
enablement is not.
Every command reads installed modules from the same `EKKLESIA_MODULES_DIR` the API does. The plan,
apply, Doctor, and initial-owner commands are exercised by the smoke test, and so are installing,
removing, and restoring a module; module validation and enablement are not.
### Size
The platform-only image is about 378 MB: the Debian slim Node base (about 230 MB), production
The image is about 378 MB, and it contains no module: the Debian slim Node base (about 230 MB), production
dependencies (about 141 MB), and built outputs (about 6 MB). A rebuild that reuses a warm pnpm store
cache can come out about 21 MB larger. When esbuild's install script runs, it hard-links its
`bin/esbuild` to the native binary it ships beside it; when pnpm restores esbuild from the store's
+12
View File
@@ -32,8 +32,20 @@ x-ekklesia-app: &ekklesia-app
# without any storage TLS exception. The database CA travels separately, as PEM text in
# EKKLESIA_DATABASE_CA, and is the only CA the database connection trusts.
NODE_EXTRA_CA_CERTS: /run/ekklesia/tls/ca.crt
# Installed modules (ADR 0048). The image sets the same value; stating it beside the mount below
# keeps the variable and the mount target from ever drifting apart.
EKKLESIA_MODULES_DIR: /var/lib/ekklesia/modules
volumes:
- ${EKKLESIA_TLS_DIR:-/etc/ekklesia/tls}/ca.crt:/run/ekklesia/tls/ca.crt:ro
# Installation-owned module storage, outside the image (ADR 0048). Read-only: Ekklesia never
# writes installed code. The host directory must already exist; a missing one stops the
# container from starting instead of Compose quietly creating an empty, root-owned directory.
- type: bind
source: ${EKKLESIA_MODULES_HOST_DIR:-/var/lib/ekklesia/modules}
target: /var/lib/ekklesia/modules
read_only: true
bind:
create_host_path: false
networks:
data: {}
storage: {}
+6
View File
@@ -15,6 +15,12 @@ EKKLESIA_IMAGE=
EKKLESIA_ENV_DIR=/etc/ekklesia
EKKLESIA_TLS_DIR=/etc/ekklesia/tls
# Installed modules (ADR 0048): the host directory mounted read-only into `api` and `migrate` at
# /var/lib/ekklesia/modules. It must already exist; Compose never creates it. Create it once, owned
# by root, before the first `up`:
# sudo install -d -o root -g root -m 755 /var/lib/ekklesia/modules
# EKKLESIA_MODULES_HOST_DIR=/var/lib/ekklesia/modules
# --- compose.proxy.yaml ---------------------------------------------------------------------------
# The Ekklesia hostname and the storage hostname (signed object URLs). In the default same-origin
# topology (Caddyfile.example) EKKLESIA_HOSTNAME serves the Dashboard, the API, and displays.
+8 -3
View File
@@ -6,9 +6,14 @@
# Never commit the filled-in file. Generate secrets with scripts/gen-secrets.sh, and the database CA
# line with scripts/init-tls.sh.
#
# The image already sets NODE_ENV=production, EKKLESIA_API_HOST=0.0.0.0, and EKKLESIA_API_PORT=3000.
# Compose sets NODE_EXTRA_CA_CERTS. The proxy overlay sets the three EKKLESIA_CLIENT_ADDRESS_*
# variables. None of those belong here.
# The image already sets NODE_ENV=production, EKKLESIA_API_HOST=0.0.0.0, EKKLESIA_API_PORT=3000, and
# EKKLESIA_MODULES_DIR=/var/lib/ekklesia/modules. Compose sets NODE_EXTRA_CA_CERTS and mounts module
# storage at that path. The proxy overlay sets the three EKKLESIA_CLIENT_ADDRESS_* variables. None of
# those belong here.
#
# Installed modules (ADR 0048) are read from EKKLESIA_MODULES_DIR, which must name the mounted
# directory. Move the host directory with EKKLESIA_MODULES_HOST_DIR in deploy/.env instead.
# EKKLESIA_MODULES_DIR=/var/lib/ekklesia/modules
#
# Values that are part of the reference topology are filled in; everything installation-specific is
# blank. Full reference: docs/development/api.md#required-configuration.
@@ -13,6 +13,12 @@
[ADR 0043](0043-display-offline-code-fallback.md),
[ADR 0044](0044-self-hosted-reference-deployment.md),
[ADR 0045](0045-split-dashboard-and-api-origins.md)
- Must be revised against: [ADR 0048](0048-generic-image-and-installed-module-storage.md) —
steps 1 and 3 below build the module into a deployment image, which ADR 0048 rules out. Before
this proposal is accepted, the harness must run the module from module storage in the generic
image.
- Should consume: [ADR 0049](0049-module-package-v1.md), when accepted — the harness installs the
module's canonical package, the artifact a church would install, rather than a workspace build.
## Context
@@ -0,0 +1,311 @@
# 0048: Ekklesia ships one generic image, and installed modules live outside it
- Status: Accepted
- Date: 2026-10-07
- Supersedes (partial): [0044](0044-self-hosted-reference-deployment.md) — only its "Composition
from pinned sources" section, its topology table's statement that the `api` image contains "the
enabled modules", and its consequence that modules are built inside a platform workspace with a
per-instance lockfile. The one reference deployment, its topology, TLS everywhere, object storage,
explicit migrations, health endpoints, backups, and secrets remain accepted and unchanged.
- Supersedes (partial): [0009](0009-transactional-module-migrations.md) — only its rule that an
applied migration absent from the current inventory is rejected, which that ADR left to "a future
explicit platform policy". This ADR is that policy, for modules only. The fixed history contract,
the per-module exact-prefix rule, checksums, locking, the single transaction, approvals, and
forward-only execution remain accepted and unchanged.
- Amends: [0006](0006-versioned-capabilities-and-compiled-module-runtime.md) — installing a module
"by cloning it into `modules/`, installing workspace dependencies, building the workspace" becomes
the development workflow only. Inert manifests, atomic discovery, compiled ESM entrypoints, and
the trusted-plugin model remain accepted and unchanged.
- Extends: [0028](0028-module-operational-lifecycle.md),
[0030](0030-host-provided-module-object-storage.md)
- Related: [ADR 0007](0007-module-owned-drizzle-schemas.md),
[ADR 0008](0008-module-owned-migration-contracts.md),
[ADR 0016](0016-module-database-access.md),
[ADR 0021](0021-modules-as-hostable-products.md),
[ADR 0027](0027-dashboard-navigation-and-flat-modules.md),
[ADR 0029](0029-module-configuration-and-secrets.md),
[ADR 0047](0047-module-ci-harness.md)
## Context
ADR 0044 builds an installation's image from an **instance composition**: a separate repository
per installation pins the platform and each module, commits its own lockfile, assembles them into
one workspace, and builds the platform's Dockerfile over it. River of Glory Church runs exactly
that. In practice:
- Every module change, however small, is a new image build and a new image. The image an
installation runs is never the image the platform's CI built and tested.
- The Dockerfile copies whatever `modules/` contains into the image. What a platform build
produces therefore depends on what happens to be in the source checkout.
- A module resolves its runtime dependencies only through the platform's own workspace install. No
module can run anywhere except inside an Ekklesia workspace built together with it.
- Each installation needs its own assembly repository, lockfile, and build pipeline.
Removing a module has a further problem. Its stored platform state (enablement, configuration,
Dashboard placement, grants) and its data (tables, buckets) are already retained by design
(ADR 0027, ADR 0028, ADR 0029, ADR 0030). Its migration history is not: ADR 0009 rejects any
applied migration that is missing from the current inventory. Once a module with migrations is
removed, no migration can be planned or applied again, including Core's own on a platform upgrade,
until the module's code is put back.
Ekklesia will eventually install modules from a marketplace. That needs a platform whose image does
not change when its modules do. Designing the marketplace is not part of this decision.
## Decision
### One generic platform image, with no modules in it
Ekklesia produces **one** platform image for every installation. It contains Core, `apps/api`, the
Dashboard and display host, the operator commands, and the platform packages they need. It contains
**zero feature modules**: not the module template, and not a test module.
Building the image does not depend on which modules exist in the source checkout. A `modules/`
directory in the build context is never a build input: a platform build produces the same platform
whether or not one is present.
The build takes no installation-specific input: no module list, no per-installation lockfile, no
branding, no configuration. Per-installation application images and per-installation assembly
repositories are not part of the deployment model. An installation that already runs one may keep
it as a rollback path until it has moved; the platform does not support building new ones.
### Installed modules live in installation-owned storage
Installed modules live **outside the image**, in persistent storage the installation owns.
- **`EKKLESIA_MODULES_DIR`** selects the modules directory, as an absolute path. It is read once,
through the host configuration the API and Doctor already share. The API and every operator
command (migration plan and apply, module and migration validation, module enablement, Doctor)
honour the same value. There is one modules directory and one discovery path; discovery itself is
unchanged.
- **The shipping image sets it to `/var/lib/ekklesia/modules`.**
- **Unset**, as in a development checkout, it defaults to `<repository>/modules`. Workspace
development, module CI, and the platform's tests keep working as they do now.
- **A configured directory that is missing or unreadable fails closed** with a sanitized error. A
mistyped mount must never quietly start the platform with no modules.
- **Nothing in the platform locates its own files relative to the modules directory.** Core's
migrations and the platform's package metadata are found where the platform is installed, never
beside the modules.
In the reference deployment, the modules directory is persistent host storage mounted into the
`api` and `migrate` containers, and the mount is **read-only to Ekklesia**. The Ekklesia process
never writes to module storage. Whatever installs a module writes to that storage from outside the
process: today an operator, later perhaps an installer. Giving Ekklesia itself write access is a
separate, future decision. Module storage is installation state, so it belongs in the
installation's backups beside the database and the buckets.
Changes to module storage are discovered **only when a process starts**. The API sees them at a
controlled restart, and an operator command sees them when it runs. There is no file watching, hot
loading, or hot unloading. Changing modules is therefore a sequence, never an edit to a running
API: stop the API, change module storage, plan and explicitly apply migrations, start the API. A
running API may still read browser artifacts from disk, so changing files under it is unsupported.
Discovery's existing rules carry over unchanged. Core scans the immediate, non-hidden directories,
rejects a symbolic-link module directory, and rejects the whole inventory if any entry is invalid.
Two consequences matter for anything that writes module storage:
- the directory holds nothing but module directories;
- hidden entries are ignored, so a module can be staged under a hidden name and then renamed into
place in one step.
### An installed module directory is self-contained
An installed module directory is **self-contained for runtime use**: every file the platform reads
or loads from it, and everything those files import at run time, resolves inside that directory.
It contains:
- `package.json`, whose version matches the manifest's;
- `ekklesia.module.json`;
- every built artifact its manifest declares: the server entrypoint, the schema entrypoint when it
owns tables, the jobs entrypoint, and the primary, display, and contribution browser artifacts;
- its migrations directory, with the journal and Ekklesia metadata, when it has migration history;
- the **production runtime dependencies** of its server, schema, and jobs code, inside the module
directory. That includes `@ekklesia/module-sdk`, `@ekklesia/contracts`, Drizzle, and every
third-party package the module imports.
Copying `dist/` alone does not produce an installed module. Source, tests, and development
dependencies are not required.
**The platform lends a module nothing.** Node resolves a module's imports from the module's own
directory upwards, and the platform's own `node_modules` is never on that path. Ekklesia does not
add it there through links, `NODE_PATH`, loader hooks, aliases, or import maps. A module whose
imports do not resolve fails to load, and because startup is atomic, the API does not start. That
failure is sanitized like any other.
**Native dependencies** must be built for the platform image's runtime: its Node.js version and ABI,
its C library, and its CPU architecture. The platform documents that runtime for each image.
**Browser artifacts** are already self-contained bundles (ADR 0023, ADR 0040). Nothing changes for
them.
**Drizzle, for now.** A module defines its tables with its own copy of Drizzle, and Core runs that
module's queries through the platform's Drizzle instance (ADR 0016). Objects from two copies of the
library only interoperate when the copies are interchangeable. **The current implementation may
therefore require a module to carry exactly the Drizzle version the platform uses.** This is a
constraint of the current implementation, not a permanent part of the module contract. A stable
module database ABI, or a compatibility rule that permits a range, replaces it when one exists.
Core already validates a module's runtime and schema definitions structurally, against its own
contracts. It never relies on object identity with the module's copy of the SDK. The module's
`compatibility.moduleSdk` range continues to be checked against the platform's SDK version.
The archive or package format a module is delivered in, how a package is signed, and how it is
transported to an installation are deferred.
### Disable, uninstall, and purge are different operations
| Operation | Module code | Stored platform state | Module data |
| --- | --- | --- | --- |
| **Disable** (ADR 0028) | Stays installed; not loaded | Enablement record set to disabled | Kept |
| **Uninstall** | Removed from module storage | Kept and ignored: enablement, configuration, Dashboard placement, grants | Kept: tables and rows, buckets and objects, migration history |
| **Purge** | Not installed | Deleted | Deleted |
**Purge is deferred.** Until it exists, nothing in the platform deletes module data, and removing
module code never does. No table is dropped, no bucket or object is deleted (ADR 0030), and no
migration history or stored platform record is removed just because a module is absent. There are
no down migrations (ADR 0008, ADR 0009).
**A removed module may be reinstalled** with the same module id, and it recovers its retained state:
- its enablement decision, configuration, Dashboard placement, and grants apply again;
- its tables and objects are where it left them;
- its migration history is validated strictly again (next section).
Reinstalling a newer version plans that version's new migrations on top of the retained history.
**Dependencies are unchanged.** A present module whose *required* dependency is absent still fails
discovery, and the API does not start. Dependents are removed first. An optional dependency may be
absent, exactly as before.
### Migration history for an absent module is retained and reported
A module is **absent** when no module in the current discovered inventory has its module id.
**Applied history belonging to an absent module is retained and reported, not treated as fatal.**
- Its rows are still checked for shape and for global consistency: unique identities, and unique,
increasing applied order. A corrupt row still fails.
- They are not matched against any inventory. Migration plan, apply, and Doctor report them as
retained history for an absent module: the module id and how many migrations it has.
- Their presence never blocks planning or applying Core's migrations, or those of any present
module.
**History belonging to a present module stays strict, including a module that has just returned.**
Its applied history must be an exact prefix of its current journal, and every row must match its
migration's module, tag, index, and checksum. Therefore:
- a previously applied migration missing from the package fails;
- a checksum mismatch fails;
- a downgrade to a version with fewer migrations, or any incomplete or gapped history, fails.
A different package that claims a retained module id is held to that history in exactly the same
way.
**Core's history is never treated as absent.** Rows belonging to Core's migration source are always
validated strictly; an applied Core migration missing from Core's inventory is still fatal.
Applying migrations never touches an absent module's database objects, and nothing runs a down
migration or deletes data because a module is absent. Deleting retained history belongs to purge.
This is safe because nothing applied in the platform can depend on an absent module's objects:
- a module may not hold a foreign key into another module's tables (ADR 0007);
- a module's migrations may change only its own objects (ADR 0008);
- Core never depends on a module's tables.
### The trust boundary does not move
Installed modules remain **trusted backend code** (ADR 0006, ADR 0015, ADR 0021). They run inside
the API process with its database role. Module storage, the read-only mount, and self-contained
packages are integrity and operational properties. **They are not a security sandbox.** The
read-only mount stops the Ekklesia process from modifying installed code. It does not limit what
installed code can do.
What the platform can still check against an installed directory is unchanged:
- discovery: the manifest, the package, entrypoint containment, compatibility ranges, and the
dependency graph;
- module, schema, and migration validation, run against module storage.
The repository's boundary checks (`pnpm boundaries`) can only prove **source** modules present in a
workspace. That includes the rule that every runtime import is a declared production dependency. An
installed package normally ships no source, so the platform cannot run those checks on it.
Conformance of a published or installed module must therefore be enforced:
- by the module's own CI;
- by packaging validation;
- by the module harness (ADR 0047).
Until packaging validation exists, installing a module means trusting that module's CI and the
operator who installed it, exactly as installing trusted code always has.
### Development keeps the workspace
In a development checkout, `<repository>/modules` remains the default, and a module is still
developed as a workspace package there. Module CI and the module harness may still assemble a module
into a platform workspace to build and test it. A deployment image is never built from such a
workspace.
## Consequences
- Every installation runs the same platform image, and that image is the one the platform's CI
tested. Installing, updating, or removing a module needs no image build. It needs only a
controlled restart and, when the module has migrations, an explicit migration plan and apply.
- Per-installation assembly repositories become unnecessary. River of Glory Church's instance
repository keeps running its pinned image as a rollback path until the new model is proven there.
Once the platform image stops accepting modules from its build context, that repository cannot
move to a newer platform release, which is intended.
- Module authors gain a packaging step: a self-contained directory with production dependencies and,
where needed, native binaries built for the image's runtime. Packages grow, and modules that share
a library each carry their own copy. That cost is accepted.
- Removing a module no longer blocks migrations. Its data stays, and reinstalling restores it.
- Operators own module storage: they create it, keep it in backups, and keep anything but module
directories out of it.
- ADR 0047, still proposed, assembles a module into the platform and builds a deployment image from
it. Before it is accepted, it must instead run the module from module storage in the generic
image.
- This ADR records the decision; it changes no code. Selecting `EKKLESIA_MODULES_DIR`, the image
build, the retained-history policy, the read-only mount, and the CI proof that the image is
independent of installed modules follow in separate changes. Until they land, the repository
behaves as `deploy/README.md` and the development guides describe.
## Alternatives
- **Keep per-installation images (ADR 0044).** Rejected: every module change is an image build, no
two installations run the same image, and a platform build depends on its checkout.
- **Lend modules the platform's dependencies** through links, `NODE_PATH`, loader hooks, or import
maps. Rejected:
- it couples every installed module to the platform's private dependency graph, so upgrading a
platform dependency could silently break an installed module;
- ESM ignores `NODE_PATH`;
- a resolution hook would be a second module-loading mechanism beside Node's own.
- **Require one bundled file per entrypoint as the package format.** Not adopted as a requirement:
native dependencies cannot be bundled, and the package format is deferred. Bundling is one
permitted way to produce a self-contained directory, not the definition of one.
- **Let the API write module storage, so it can install modules itself.** Deferred, not adopted:
code that can rewrite the code it executes is a larger trust decision, and it belongs with the
marketplace.
- **Hot loading and unloading.** Rejected: ES modules cannot be unloaded, and atomic discovery,
dependency-ordered startup, and immutable permission composition all assume a fresh process.
- **Delete data, or run down migrations, on uninstall.** Rejected: migrations are forward-only, and
a mistaken removal must never destroy data. Deleting data is purge, and purge is explicit.
- **Keep rejecting history for absent modules (ADR 0009 as written).** Rejected: removing any module
with migrations would block every later migration, including Core's.
- **Accept history for absent modules only behind an operator flag.** Rejected: applying migrations
with a module absent cannot harm that module's data, and the plan already reports what is
retained. A flag would turn every routine uninstall into a special case.
## Deferred
- The module archive or package transport format.
- Package signatures and trust roots.
- A marketplace or catalog.
- An installer UI.
- Installation by the running platform, and any write access for Ekklesia to module storage.
- Hot loading, hot unloading, and file watching.
- The purge workflow: deleting a module's tables, buckets and objects, migration history, and stored
platform records.
- A stable module database ABI, or a compatibility rule wider than the current exact Drizzle
requirement.
- A platform-provided packaging and package-validation command.
- Publishing the platform image and automated deployment from CI, still deferred from ADR 0044.
@@ -0,0 +1,426 @@
# 0049: A module release is one immutable, self-contained package: Ekklesia Module Package v1
- Status: Accepted
- Date: 2026-10-07
- Supersedes: none
- Extends: [0048](0048-generic-image-and-installed-module-storage.md)
- Related: [ADR 0006](0006-versioned-capabilities-and-compiled-module-runtime.md),
[ADR 0007](0007-module-owned-drizzle-schemas.md),
[ADR 0008](0008-module-owned-migration-contracts.md),
[ADR 0009](0009-transactional-module-migrations.md),
[ADR 0016](0016-module-database-access.md),
[ADR 0021](0021-modules-as-hostable-products.md),
[ADR 0023](0023-module-ui-build-pipeline.md),
[ADR 0028](0028-module-operational-lifecycle.md),
[ADR 0040](0040-module-stylesheets-and-artifact-isolation.md),
[ADR 0041](0041-module-browser-contributions.md),
[ADR 0044](0044-self-hosted-reference-deployment.md),
[ADR 0047](0047-module-ci-harness.md)
## Context
ADR 0048 took modules out of the platform image. An installation keeps them in its own module
storage, mounted read-only, and discovers them at a controlled restart. Each installed module must
be a self-contained directory: the platform lends it no dependencies. ADR 0048 deferred the format
a module is delivered in.
That gap is now the obstacle. Nothing yet produces an installed module directory except a test-only
Dockerfile, so a module release cannot be built, distributed, and installed on its own. An
investigation against the module template and Signage 0.1.2 established:
- **`dist/` alone does not run.** Compiled server and schema code imports `@ekklesia/module-sdk`
(and through it `@ekklesia/contracts`, zod, and semver) and `drizzle-orm` by name, and Node never
resolves those from the platform's `node_modules`.
- **Bundling works.** Bundling the template's server and schema with esbuild gave about 720 KB of
backend code and no `node_modules` at all. Inside the generic image, run as its unprivileged
user from a read-only mount at `/var/lib/ekklesia/modules`, it passed module, schema, and
migration validation, and its server entrypoint imported. Core's own Drizzle instance built a
query over the bundled module's table.
- **Native dependencies cannot be bundled.** Signage needs `sharp`, a Node-API addon whose
platform packages carry an 18.6 MB `libvips` shared library for one operating system, CPU, and
C library (Linux, x64, glibc ≥ 2.28). With its backend bundled and only `sharp` and its own
dependencies kept beside it, Signage was 22 MB and contained no link. It validated and processed
an image inside the same image.
- **`pnpm deploy` is the wrong tool.** Non-legacy mode refuses unless the whole workspace switches
to injected workspace packages. Legacy mode leaves a link escaping the package. Every mode
ships packages nothing loads at run time (React and its DOM, type packages, database drivers
pulled in as peers) and leaves `workspace:` ranges in copied manifests. Running it inside a
workspace rewrites that workspace's install state to production-only, which then breaks the
workspace's own builds. It did so in the investigation's scratch workspaces, and earlier in a
developer's own checkout.
- **A deterministic archive is cheap.** A tar archive with sorted paths, fixed owner and
timestamps, and a gzip header without a name or time produced the same SHA-256 twice. Gzip packed
Signage to 8.5 MB.
Module releases also need a contract that does not depend on where they are hosted. Ekklesia's
source and CI live on a Gitea server today; a marketplace, another host, object storage, or offline
media may carry the same release tomorrow.
## Decision
### Purpose
A separately versioned Ekklesia module is distributed as an **Ekklesia Module Package**: one
immutable, self-contained archive. Installing, updating, or removing a module never rebuilds the
platform image (ADR 0048).
The package contract is independent of marketplace discovery, of the source-code host, of any
artifact registry, of the church installing it, and of the URL it was fetched from. Catalogues,
marketplaces, and signing are separate concerns, and none of them is decided here.
### The artifact is a deterministic `.tar.gz`
Module Package v1 is a gzip-compressed tar archive. Its identity is its contents, never its
transport: the same archive may travel through Gitea, GitHub, an OCI registry, object storage,
offline media, or a future Ekklesia marketplace without any change to what it installs. No registry,
host, or OCI concept is part of the package format.
The canonical packager produces the archive **deterministically**: the same packager version and
the same inputs produce byte-identical output. At minimum:
- **Entries.** Every directory and regular file appears in byte-wise sorted path order, directories
before their contents.
- **Ownership.** uid and gid are 0 and the user and group names are empty.
- **Timestamps.** Every entry's modification time is 0, and the archive holds no access, change, or
creation time.
- **Modes.** Directories are `0755` and files `0644` (see [Archive safety](#archive-safety)).
- **No host metadata.** No extended attributes, ACLs, or archive-level headers that carry host or
time information.
- **gzip.** The header has no file name and a modification time of 0, at a fixed compression level.
The SHA-256 of the complete archive is distributed beside it, as a separate `.sha256` file in the
`sha256sum` format. The **recommended** file name is `<module id>-<module version>-<target>.tar.gz`,
for example `signage-0.1.2-linux-x64-glibc.tar.gz`. A file name is a convenience, never trusted:
what the archive is comes from its contents and its digest.
### What a package contains
A v1 archive contains exactly one top-level directory, named with the module id. Its contents are
the installed module directory ADR 0048 requires:
```text
<module id>/
package.json generated runtime metadata
ekklesia.module.json the module's manifest, unchanged
ekklesia.package.json package metadata (below)
THIRD_PARTY_NOTICES licence and notice material for everything bundled or included
dist/ bundled backend entrypoints, their shared chunks, and the declared
UI, display, and contribution artifacts
drizzle/ migration SQL, journal, snapshots, and _ekklesia.json, when declared
node_modules/ only when the package needs a native runtime dependency (below)
```
Every entrypoint the manifest declares exists at its declared path.
It contains nothing else, and in particular:
- source files, unless something at run time reads them;
- tests;
- TypeScript declarations;
- source maps, until a later decision permits them;
- development dependencies;
- any `workspace:` reference;
- any link (see [Archive safety](#archive-safety)).
**The runtime `package.json` is generated by the packager, never copied** from the module's
development workspace. It holds only what module discovery reads:
- `name` and `version` (equal to the manifest's version);
- `"private": true`;
- `"type": "module"`;
- `"ekklesia": { "layer": "module" }`.
It declares no dependencies, scripts, exports, or imports, because nothing at run time resolves
through them.
The manifest is copied unchanged. It stays the module's authoritative runtime contract, and its
`compatibility.platform` and `compatibility.moduleSdk` ranges stay the only place those are stated.
### Backend code is bundled
The canonical packager bundles the module's backend entrypoints (server, schema, and jobs) with
every pure-JavaScript dependency they use. That includes `@ekklesia/module-sdk`,
`@ekklesia/contracts`, and ordinary libraries. The bundles are ES modules at the manifest's declared
paths, and they may share chunks under `dist/`. Browser artifacts are already self-contained bundles
(ADR 0023, ADR 0040, ADR 0041) and are packaged as built.
The platform lends an installed module nothing (ADR 0048). **`node_modules` in a package is the
exception, not the default.** A dependency stays outside the bundle only when it must exist as
separate files at run time: a native Node addon, or a package that is equally platform-specific.
Then the package carries it, and exactly its own runtime dependencies, flat under
`node_modules/<name>`.
**The canonical packager decides what stays external, and validates it.** It refuses to package a
module whose bundled code still needs a file it did not include, or whose externals are not native
or platform-specific. Choosing which dependencies to leave unbundled is not part of the module
author's interface.
### Targets
Every package has one **target**:
- **`any`**: the package contains no native or platform-specific file. It runs wherever its declared
Node.js range is met.
- **`<platform>-<arch>[-<libc>]`**: the package contains native code for exactly one environment.
- `<platform>` and `<arch>` use Node.js's own `process.platform` and `process.arch` names.
- On Linux, `<libc>` is required and is `glibc` or `musl`. On other platforms it is omitted.
Examples: `linux-x64-glibc`, `linux-arm64-glibc`, `linux-x64-musl`. Signage, because of `sharp`, is
`linux-x64-glibc` for the current production image.
A package has exactly one target. A module that ships for several environments publishes one
package per target. Packages carrying several architectures at once are not part of v1.
### `ekklesia.package.json`
For example (the values are illustrative):
```json
{
"packageFormat": 1,
"module": { "id": "signage", "version": "0.1.2" },
"target": "linux-x64-glibc",
"runtime": {
"node": ">=24.11.0 <25",
"nodeApi": 9,
"libcVersion": ">=2.28"
},
"provenance": {
"revision": "86ae907f59458508e3277666cdd214f5bd7c1078",
"repository": "https://example.org/your-org/signage.git",
"packager": { "name": "@ekklesia/module-pack", "version": "0.2.0" }
},
"incorporates": {
"@ekklesia/module-sdk": "0.1.2",
"@ekklesia/contracts": "0.1.2"
},
"transitional": {
"databaseRuntime": { "drizzle-orm": "0.45.2" }
},
"files": [
{ "path": "THIRD_PARTY_NOTICES", "size": 48211, "sha256": "…" },
{ "path": "dist/server/index.js", "size": 1346, "sha256": "…" }
]
}
```
The fields are:
- **`packageFormat`**: the integer `1`. An installer refuses a format it does not implement. Format
versions are strict cutovers, like manifest versions.
- **`module.id`, `module.version`**: must equal the manifest's and the runtime `package.json`'s.
The top-level directory must be named with the id.
- **`target`**: as defined above.
- **`runtime`**: what the package needs from the Node.js runtime that will load it.
- `node`, a semantic-version range, is always present.
- A native package also states:
- `nodeApi`: the minimum Node-API version, for Node-API addons;
- or `nodeAbi`: the exact `process.versions.modules`, for an addon built against one Node
ABI;
- and on Linux, `libcVersion`: the minimum C library version.
- **`provenance`**: how the package was built.
- `revision`: the exact source revision, always present.
- `repository`: informational only. Nothing trusts it, and it ties the package to no host.
- `packager`: the packager's name and version.
- **`incorporates`**: the exact versions of the Ekklesia SDK and runtime libraries bundled into the
package. The packager refuses a package whose incorporated `@ekklesia/module-sdk` does not satisfy
the manifest's own `compatibility.moduleSdk` range, so the two can never disagree.
- **`transitional`**: compatibility facts that today's implementation needs and that are not part
of the long-term contract. See [Database runtime compatibility](#database-runtime-compatibility).
An installer must understand every key it finds there or refuse the package.
- **`files`**: one entry for every regular file in the module directory **except
`ekklesia.package.json` itself**, sorted by path. Each entry has its path relative to the module
directory, its size in bytes, and its SHA-256. The set is exact: a payload file missing from the
list, or a listed file missing from the payload, invalidates the package.
The archive's `.sha256` covers the whole archive, `ekklesia.package.json` included. The per-file
digests let an installer, or an operator later, check an extracted directory without the archive.
Platform and Module SDK compatibility stay in `ekklesia.module.json` and are not repeated here.
### Database runtime compatibility
Core runs a module's queries through the platform's Drizzle instance over tables the module defined
with its own copy of Drizzle (ADR 0016, ADR 0048). Those objects only interoperate when the two
copies are interchangeable. So while it is needed, a package bundles Drizzle with its backend code,
records the version in `transitional.databaseRuntime`, and an installer refuses a package whose
Drizzle version differs from the platform's.
**This is an implementation constraint of today's platform, not the Ekklesia Module Database ABI.**
Object identity and version agreement between two Drizzle copies are not a contract this ADR
establishes or that module authors may rely on. A separate Module Database ABI decision will replace
it, and that ABI will make the platform's ORM an implementation detail. When it lands, the
`transitional.databaseRuntime` requirement goes away under the format-versioning rules here.
### Licence obligations
Bundling copies third-party code into the module's own files, where the licence headers that came
with it may not survive. **Every package therefore carries `THIRD_PARTY_NOTICES`**: a generated
plain-text file at the module directory's root. It names each third-party package bundled into
or included in the package, with its version and licence, and holds the licence and notice text
that redistribution requires.
The packager's implementation must establish exactly how licence and notice material is collected
and preserved, including licence comments that bundling would otherwise strip. Until it does, no
package is treated as a distributable release.
### Archive safety
**v1 packages contain no links of any kind.** Neither symbolic nor hard links are permitted, so no
link can escape the package, and there is no case where one is allowed. Native dependencies were
packaged without any.
A conforming archive also contains no:
- absolute path;
- `..` path segment;
- path outside the single top-level module directory;
- device node, FIFO, or socket;
- setuid, setgid, or sticky bit.
It holds only directories and regular files.
**Extraction normalizes ownership and modes.** Everything is owned by the installation, not by the
uid or gid in the archive. Directories are `0755` and files `0644`, readable by the API's
unprivileged user and writable by nobody but the installer. No file in a v1 package is executable.
Loading JavaScript and native addons needs no execute bit.
An installer refuses an archive that breaks any of these rules, and never extracts it partially into
module storage.
### Installation model
This ADR records the intended installation lifecycle; it does not implement it.
```text
acquire the archive
→ verify the archive's SHA-256
→ extract into a hidden staging directory inside module storage
→ validate ekklesia.package.json, then every file's size and SHA-256
→ validate the target and runtime against this platform and host
→ validate the manifest and the complete candidate inventory: the installed modules, with this
candidate in place of any version of the same module
→ load the backend entrypoints once in an isolated, short-lived validation process
→ produce the migration plan
→ stop the API
→ swap the candidate into the active module directory, keeping the previous version
→ apply migrations, only with explicit operator approval
→ start the API
→ verify readiness
```
Details:
- **Hidden directories.** Staging and previous versions live in dot-prefixed directories inside
module storage. Module discovery ignores hidden entries (ADR 0048), so neither is ever loaded.
- **The swap.** It happens only while the API is stopped. It is as close to atomic as the
filesystem allows, and an interrupted swap is recoverable from the kept previous version.
- **Who writes.** Module storage stays read-only to Ekklesia. Whatever performs the installation
writes it from outside the API process (ADR 0048).
- **No hot loading.** Discovery still happens only when a process starts.
- **Migrations stay explicit.** Nothing in this lifecycle applies a migration on its own (ADR 0009,
ADR 0044).
### Rollback
- **Before any migration from the new version is applied,** restoring the previous module directory
and restarting is a supported rollback.
- **After forward migrations are applied,** restoring older code is **not** a supported rollback.
The older package lacks the newer migrations, so migration planning refuses it as a downgrade
(ADR 0048). Even running it would mean old code against a newer schema. A true rollback then
means restoring the matching database and storage backup, unless a future, module-specific
compatibility mechanism says otherwise.
There are no down migrations (ADR 0008, ADR 0009). An installer must make the point of no return
explicit: back up before applying, and offer code-only rollback only while no new migration has been
applied.
### Trust
The SHA-256 digest proves integrity: the archive is the one whose digest you were given. It says
nothing about who published it. Package signatures, publisher trust roots, marketplace verification,
and trust levels for third-party modules are deferred.
Installed modules remain trusted backend code under ADR 0048. A conforming package is not sandboxed,
and passing validation does not make a module safe; it makes it well-formed.
### Canonical tooling
Packages are produced by one Ekklesia-owned packager, proposed as the workspace package
`packages/module-pack` with the command `ekklesia-pack-module`, run in a module's CI. Its command
line is an implementation detail; this ADR defines what it must produce.
**`pnpm deploy` is not the packaging mechanism**, for the reasons in Context:
- it includes runtime dependencies nothing loads;
- it leaves `workspace:` metadata;
- it leaves links that escape the package, or needs a workspace-wide linking change to avoid them;
- it rewrites the install state of the workspace it runs in.
The repository's test-only packaging of the module template
(`scripts/lib/test-module-package.Dockerfile`) is replaced by the canonical packager when one
exists.
### Relationship to the module CI harness
ADR 0047, still proposed, is already marked for revision against ADR 0048. Its harness should
consume canonical module packages: a module's CI builds its package, and the harness installs that
exact archive into module storage of the generic image. The harness then tests the artifact a church
would install, not a workspace build of it. Until the packager exists, the harness and smoke tests
may keep their current test-only packaging.
## Consequences
- A module release becomes one file plus its digest, built once by the module's CI and installable
on any compatible Ekklesia installation without touching the platform image.
- Module authors gain a packaging step and a target per native environment. A pure-JavaScript
module ships one `any` package; Signage ships `linux-x64-glibc` packages for the current fleet.
- Packages are small and auditable: bundled code plus, only where needed, a native dependency
tree. There are no links and no development dependencies, and every file is listed with its
digest.
- Each package carries its own copy of the SDK and contracts. Version agreement with the platform is
checked when the package is built, against the manifest's `moduleSdk` range, and Core keeps
validating module definitions structurally (ADR 0048).
- Rolling back after a schema-changing update is a backup restore. Operators must take a backup
before approving migrations.
- Licence handling becomes the packager's responsibility, and until it is proven no package
counts as a release.
- The Drizzle requirement is visible in package metadata as `transitional`, so its eventual removal
is a recorded format change and not a silent behaviour change.
## Alternatives
- **`pnpm deploy` as the packager.** Rejected for the reasons in Context.
- **Copying the workspace's dependency tree without bundling.** Rejected as the default. It ships
far more than runs (49 MB against 22 MB for Signage), keeps `workspace:` ranges, and makes every
pure-JavaScript dependency a file-layout concern. Bundling is already how browser artifacts are
built.
- **Platform-lent runtime dependencies.** Rejected by ADR 0048.
- **Installing the SDK from a registry when the module is installed.** Rejected: installation would
need network access and a resolver on every installation, and an installation's code would depend
on what a registry served that day. Publishing the SDK packages for module *builds* is a separate,
compatible future step.
- **Module-author-chosen externals.** Rejected: an arbitrary external is an undeclared runtime
dependency the platform cannot verify, and it reintroduces the `node_modules` layout as a
contract.
- **An OCI artifact as the package format.** Not adopted for v1: it needs registry tooling and login
to install, and adds nothing to integrity that the digest does not already give. Nothing here
prevents carrying the same `.tar.gz` inside an OCI artifact.
- **`.tar.zst`.** Not adopted for v1: about 17 % smaller for Signage at a high compression level,
but about three times slower to produce there, and less universally available than gzip. A later
format version may add it.
- **Packages carrying several architectures at once.** Not adopted for v1: each extra architecture
adds about 18.5 MB for Signage, for a fleet that runs one architecture. A later format version may
allow it.
- **Permitting internal symbolic links.** Rejected: links are where escape checks are subtle, and
the native dependencies that motivated the question were packaged without any.
## Deferred
- Package signatures and publisher trust roots.
- A marketplace or catalogue, and registry or hosting-provider integration.
- An installer, and any installer UI; an automatic update service.
- The purge workflow (ADR 0048).
- Packages carrying several architectures at once.
- The stable Module Database ABI that retires `transitional.databaseRuntime`.
- Source maps and other debugging material in or beside packages.
- Publishing the Ekklesia SDK packages for module builds outside a platform workspace.
- Moving Signage to the new package format.
@@ -0,0 +1,556 @@
# 0050: Module Database ABI v1: modules and Core exchange SQL, never ORM objects
- Status: Accepted
- Date: 2026-10-07
- Supersedes (partial): [0016](0016-module-database-access.md) — for modules that declare ABI v1,
only its definition of the module database service as `Pick<NodePgDatabase, …>`, a facade over
Core's Drizzle instance, and its rule that every module receives the same `services` object.
One pool, no pool or connection string for modules, no database access during `register`, the
shutdown order, handler error sanitization, and the trust boundary remain accepted and unchanged.
Modules that do not declare ABI v1 keep ADR 0016's facade until it is removed (see
[Compatibility and deprecation](#compatibility-and-deprecation)).
- Amends: [0007](0007-module-owned-drizzle-schemas.md) — for modules that declare ABI v1, the
schema definition Core inspects is plain Ekklesia-owned metadata instead of Drizzle table objects.
Table namespacing, identifier rules, uniqueness, same-module foreign keys, atomic aggregation, and
sanitized discovery errors remain accepted and unchanged.
- Extends: [0049](0049-module-package-v1.md) — this is the Module Database ABI that ADR 0049 deferred
and anticipated. ABI v1 packages carry no `transitional.databaseRuntime`. `packageFormat` stays 1.
- Related: [ADR 0006](0006-versioned-capabilities-and-compiled-module-runtime.md),
[ADR 0008](0008-module-owned-migration-contracts.md),
[ADR 0009](0009-transactional-module-migrations.md),
[ADR 0015](0015-static-module-http-routing-and-runtime-lifecycle.md),
[ADR 0021](0021-modules-as-hostable-products.md),
[ADR 0047](0047-module-ci-harness.md),
[ADR 0048](0048-generic-image-and-installed-module-storage.md)
## Context
ADR 0016 gave module code database access through `ModuleDatabase`, which is
`Pick<NodePgDatabase, "select" | "selectDistinct" | "selectDistinctOn" | "insert" | "update" |
"delete" | "execute" | "transaction">`. Core implements it as a frozen object whose eight methods are
bound to Core's own `drizzle(pool)` instance.
ADR 0048 then took modules out of the platform image, and ADR 0049 made each module release a
self-contained package that bundles its own copy of Drizzle. The two copies now meet at run time,
and an investigation of the current code (Drizzle 0.45.2) found the coupling runs deeper than a
type:
- **Module objects cross into Core's ORM.** A module builds queries from table, column, and `SQL`
objects made by its own Drizzle copy. Core's Drizzle instance then compiles and decodes them.
This only works while the two copies agree on internal symbols and entity kinds, which is why ADR
0049 records the module's Drizzle version under `transitional.databaseRuntime` and refuses a
mismatch.
- **Core's ORM objects cross into the module.** `transaction` hands module code Core's live
`NodePgTransaction`. At run time that object is a full Drizzle database: its session, dialect,
relational query builder, and `rollback()` are all reachable, whatever the `Pick` type says. A
reference kept after the callback settles still points at a connection that has returned to the
pool.
- **Result decoding is split between two ORMs.** Core's Drizzle session overrides `pg`'s parsers
for each query, so temporal values arrive as raw text. Then the module's column objects convert
them. Neither half is an Ekklesia contract. `pg`'s default parsers are also process-global and
mutable, and some lose information: `jsonb` numbers beyond double precision, and `numeric[]`
elements, are parsed to JavaScript numbers.
- **Errors are ORM and driver shapes.** A module that needs to recognize a PostgreSQL condition
reads it from whatever object Drizzle or `pg` throws. Drizzle 0.45 wraps driver errors in its own
error type, whose message carries the SQL text and the parameters.
- **Schema validation needs Drizzle objects.** Module validation, Doctor, and the package verifier
import a module's schema entrypoint and inspect it with Drizzle's `is(…, PgTable)`,
`getTableConfig`, and `PgColumn`. That is the same cross-copy dependency, at validation time. Core
extracts only plain facts from it: table names and foreign-key target tables.
Two findings show the dependency is unnecessary:
- **Migration execution is already independent of the ORM runtime.** Migration discovery reads SQL
files and JSON metadata, and the runner executes SQL text. Neither imports Drizzle. Drizzle Kit
snapshots are validated metadata; they are not needed to apply a migration.
- **The module side can compile to SQL alone.** Drizzle and Kysely both compile a query to
PostgreSQL text with `$n` placeholders and an ordered parameter list. Drizzle 0.45.2's own
`pg-proxy` driver, given only a callback that receives SQL text, parameters, and an array-or-object
row mode, ran `select`, `update`, and `insert … returning`, and decoded `timestamptz` and `int8`
text with the module's own column definitions. That driver has no transactions, which a
module-side adapter has to supply.
Signage 0.1.2, the first production module, uses `select`, `insert`, `update`, `delete`,
`transaction`, one `returning`, and SQL expressions in `set` clauses. It uses no `execute`, joins,
row locks, isolation options, or result row counts. None of its usage needs a Core ORM object. It
recognizes a uniqueness conflict by reading SQLSTATE `23505` from the thrown error. A stable,
Ekklesia-owned place for that code is desirable regardless of which ORM either side uses. (This
paragraph is motivation, not part of the decision.)
## Decision
### The goal: the ORM is an implementation detail on both sides
Ekklesia defines a stable, Ekklesia-owned **Module Database ABI** between Core and modules.
- **The boundary is PostgreSQL itself.** Exactly these concepts cross it:
- SQL text;
- ordered, bound parameter values;
- result rows and their metadata;
- transaction scopes that Core controls.
- **Core never receives or depends on a module's ORM or query-builder object.** That includes:
- Drizzle tables, columns, `SQL` objects, query builders, and transactions;
- Kysely objects;
- any other third-party ORM or runtime object.
- **Modules never receive Core's ORM objects, driver objects, or connections.**
- **Each side chooses its own tools.** A module may use Drizzle, Kysely, raw SQL, or any module-side
query builder that produces PostgreSQL text and parameters. Core may keep Drizzle, or replace it,
without any module noticing.
This ADR defines **Module Database ABI version 1**. The TypeScript names below are illustrative; the
concepts and the semantics are the contract.
### A correctness boundary, not a security boundary
**The Module Database ABI is a correctness and compatibility boundary. It is not a security
sandbox.** Installed modules remain trusted backend code running inside the API process with its
database role (ADR 0006, ADR 0016, ADR 0048).
Nothing in this ADR claims, and nothing built from it may claim, that the following can stop
malicious module code from reaching data that is not its own:
- the ABI itself;
- parsing or inspecting SQL;
- checking table prefixes when statements execute;
- `SET ROLE`;
- PostgreSQL roles.
A module sends arbitrary SQL by design, including SQL that changes its role back. It also runs
in-process, where it can reach the network and the environment without the ABI.
Table ownership remains what ADR 0007 made it: a static correctness check of declared tables and
foreign keys, run during validation. The ABI keeps that check honest by removing the ORM objects it
used to depend on. It adds no runtime enforcement.
### Declaring the ABI: manifest version 8
A module opts into the ABI explicitly, in its static manifest. The manifest is strict and changes by
hard cutover (ADR 0006, ADR 0014, ADR 0021, ADR 0029), so the declaration arrives with
**manifest version 8**. Conceptually:
```json
{
"manifestVersion": 8,
"database": { "abi": 1 }
}
```
- **`database.abi: 1`** means the module receives a Module Database ABI v1 executor. It never
receives the legacy facade.
- **No `database` field** means the module receives ADR 0016's legacy `ModuleDatabase` facade
during the deprecation window.
- **Any other ABI value** fails validation. A platform that does not implement a declared ABI refuses
the module rather than guessing.
- **Version 8 is a hard cutover, like every manifest version before it.** Core parses exactly one
manifest version; it never parses version 7 and version 8 side by side.
- An existing source module moves to version 8, and repacks, without changing any database code.
- A package already built with a version 7 manifest must be rebuilt.
- Module Package v1 has only just landed, and no published ecosystem depends on version 7
packages, so this costs a rebuild and nothing more.
In this ADR, a **legacy module** means a module with a version 8 manifest and no `database`
declaration, which therefore uses the legacy facade. It never means a module with a version 7
manifest.
Because the declaration is static, Core, the packager, and the verifier know a module's database
contract without loading its code. Because Core enforces it, an ABI v1 module cannot reach the
legacy facade, so nothing at run time can depend on Drizzle agreement.
The rest of manifest version 8 (field placement, validation messages, the template, and the
cutover itself) is an implementation detail of the change that introduces it.
### The executor
An ABI v1 module's database service is an **executor**. Conceptually:
```ts
query({ text, values, rowMode }) → { command, rowCount, rows, fields }
transaction(callback, options) → the callback's result
```
- **`text`** is exactly one PostgreSQL statement, using `$1 … $n` placeholders. **One `query` call
runs one statement.** This is a correctness and API rule, not a security boundary.
- The implementation upholds it with a reliable driver or protocol mechanism where one exists,
such as PostgreSQL's extended query protocol, which accepts a single statement per request.
- It does not require, and must not grow, an Ekklesia SQL parser.
- If an implementation cannot reliably uphold this rule, the implementing change stops and reports
the conflict. It does not weaken this ADR silently.
- **`values`** is the ordered list of bound parameters (see
[Parameters](#parameters)). Values are always bound and never interpolated into the text.
- **`rowMode`** is `"object"` or `"array"`.
- `"object"` rows are keyed by column name.
- `"array"` rows are positional, in `fields` order. An ORM that selects the same column name twice,
as joins do, needs this mode.
- Duplicate column names are invalid in object mode. Core rejects such a result rather than
silently dropping a column.
- With the current PostgreSQL and driver execution path, the result's shape is only known once
the statement has run. So a mutating statement can already have had its effects when the
result-shape error is reported.
- This is not a security guarantee. A caller that needs atomicity runs the mutation inside an ABI
transaction, where the error rolls the mutation back with everything else.
- **`command`** is PostgreSQL's command tag word, such as `SELECT`, `INSERT`, `UPDATE`, or `DELETE`.
- **`rowCount`** is the number of rows PostgreSQL reports as processed, or `null` when it reports
none.
- **`rows`** are the result rows, decoded as in [Results](#results). `RETURNING` rows are ordinary
rows.
- **`fields`** are the ordered column metadata, present even when there are no rows: at least each
column's name and its PostgreSQL type OID.
The executor contains no ORM type, AST, query object, or schema object, and accepts none.
**One executor per module.** Each ABI v1 module receives its own executor, bound to its module id.
The module's `start` and `stop` hooks and its route handlers all receive that same object, which
keeps ADR 0016's guarantee that lifecycle code and handlers share one database service. Different
modules get different executors, so Core can attribute every statement to the module that issued it.
**Observability belongs to Core.** Core may record per-module timings, command tags, row counts, and
error codes. It never writes SQL text or parameter values to logs, diagnostics, Doctor output, or
HTTP responses.
**Session state is not part of the ABI.** Top-level `query` calls are separate autocommit statements
that may run on different pooled connections. A module must not rely on session-level state, and
must not change it. That includes:
- session `SET`;
- temporary tables;
- named prepared statements;
- `LISTEN`;
- session advisory locks.
Settings that must hold for one unit of work use `SET LOCAL` inside a transaction. Core may reset or
discard a connection that a module changed. `COPY`, cursors, streaming results, and named prepared
statements are not part of v1.
### Parameters
The ABI deliberately accepts a small set of PostgreSQL wire-safe values:
| Value | Sent as |
| --- | --- |
| `null` | SQL `NULL` |
| `boolean` | `true` / `false` |
| `number` | its JavaScript decimal text; `NaN`, `Infinity`, and `-Infinity` as PostgreSQL's `NaN`, `Infinity`, and `-Infinity` |
| `bigint` | its decimal text |
| `string` | the text, unchanged |
| `Uint8Array` | `bytea` |
| arrays of the above | a PostgreSQL array, nested for more dimensions |
Everything else is refused with a sanitized error before anything reaches PostgreSQL, rather than
being coerced. In particular:
- **`Date` is not an ABI parameter.** A JavaScript `Date` is an instant. Whether it should become a
`timestamp` or a `timestamptz`, and in which time zone, depends on a column type the ABI cannot
see. The module-side adapter converts it to explicit text first; for example, Drizzle's timestamp
columns already send ISO-8601 text.
- **Plain objects are not implicit JSON.** A module or adapter encodes JSON explicitly, as a string,
and the SQL casts it where needed.
- **Identifiers are never parameters.** Table and column names are part of the module's compiled
SQL text. The ABI provides no identifier interpolation.
### Results
Core owns one documented decoding table for ABI v1. It does not depend on `pg`'s global parser
registry, which module code could change, or on any ORM. Values that a generic conversion could
change, or change silently by time zone, arrive in a stable raw form:
| PostgreSQL type | Arrives as |
| --- | --- |
| any `NULL` | `null` |
| `bool` | `boolean` |
| `int2`, `int4`, `oid` | `number` |
| `int8` | `string`, its exact decimal text |
| `float4`, `float8` | `number`, including `NaN` and `±Infinity` |
| `numeric` / `decimal` | `string`, its exact text (`NaN` included) |
| `text`, `varchar`, `bpchar`, `name`, `uuid` | `string` |
| `date`, `timestamp`, `timestamptz`, `time`, `timetz`, `interval` | `string`: PostgreSQL's ISO text output |
| `json`, `jsonb` | `string`: the JSON text, not parsed |
| `bytea` | `Uint8Array` |
| arrays of any of the above | JavaScript arrays (nested for more dimensions), each element decoded by the same rule, with `null` elements as `null` |
| any other type | `string`: PostgreSQL's text output |
- **Temporal text is fixed for every module.** Core fixes the session's `DateStyle` (ISO),
`IntervalStyle` (`postgres`), and `TimeZone` (UTC) on every connection that serves the ABI. The
same instant therefore always produces the same text, whatever the server's or the host's local
zone.
- **`bytea` has one representation.** A value is a `Uint8Array`. A module must not rely on it being
any particular subclass.
- **Higher-level conversion belongs to the module side.** Converting text to `Date`, `numeric` to a
decimal type, `int8` to `bigint` or `number`, and JSON text to values is the adapter's job, or the
module's. The Drizzle column types Signage uses already accept exactly these raw forms.
Changing how any type arrives, including decoding a type that arrives as text today, requires a new
ABI version.
### Errors
Every failure the executor reports is a Core-owned, sanitized **`ModuleDatabaseError`**.
Conceptually:
```ts
ModuleDatabaseError { code, sqlState? }
```
The two fields mean different things:
- **`code`** is always present. It is a stable, Ekklesia-owned error **category**, meant for
programmatic handling. Its categories cover at least:
- PostgreSQL rejected the statement;
- a parameter value was refused;
- a transaction option was refused;
- the scope was closed or used incorrectly;
- the transaction could not be completed.
This ADR does not freeze the complete set or the exact names. Once published, a code keeps its
meaning.
- **`sqlState`** is optional. It is the five-character SQLSTATE that PostgreSQL itself reported, such
as `23505` (unique violation), `23503` (foreign-key violation), or `40001` (serialization failure).
It is present only when the database supplied one, and Core never invents one.
- Failures that the ABI detects itself have a `code` and no `sqlState`: a closed scope, an
invalid parameter type, or an invalid transaction option.
- Modules branch on `code` for ABI failures and on `sqlState` for PostgreSQL conditions.
- **It never carries:**
- SQL text or parameter values;
- the driver's message, detail, hint, constraint, table, or column names;
- a stack, a cause, connection details, or any Core, `pg`, or Drizzle object.
- **Modules recognize it structurally** (its name and its `code`), never with `instanceof`. A module
bundles its own copy of the SDK (ADR 0049), so class identity across the boundary is not a
contract.
This replaces modules depending on `DrizzleQueryError`, `pg`'s `DatabaseError`, or any other driver
or ORM error shape. An uncaught `ModuleDatabaseError` in a route handler is sanitized at the
handler boundary exactly as before (ADR 0015, ADR 0016).
### Transactions
Transactions are **callback-scoped**, and Core owns the connection throughout:
```ts
transaction(async (tx) => { … }, { isolation, readOnly })
```
- **Starting.** Core takes a connection from its pool and begins a transaction with the requested
options. It then calls the callback with `tx`, a Module Database ABI v1 executor scoped to that
transaction alone. No `pg` client and no Core ORM transaction is ever exposed.
- **Commit and rollback.** Core commits only when the callback's promise resolves and no operation
launched through `tx` is still outstanding. When the callback rejects, Core rolls back, and the
transaction call rejects with the callback's error. There is no `rollback()` method: to roll back,
the module throws.
- **All work is awaited inside the callback.** Module code must await every database operation it
starts through `tx` before its callback returns. What happens when work is still outstanding
depends on how the callback settled:
- **Resolved with work outstanding.** The module forgot an `await` and has used the scope
incorrectly. The transaction is never committed: Core rolls it back, and the transaction call
rejects with the scope-usage `ModuleDatabaseError`. A forgotten `await` therefore fails. It never
quietly changes what the transaction contains.
- **Rejected with work outstanding.** The callback's own rejection stays the transaction's error.
Core rolls back and does not replace that error, a PostgreSQL failure included, with the
scope-usage error just because sibling work was still settling. This is what happens when one
query in `await Promise.all([tx.query(a), tx.query(b)])` fails while the other is still running.
- Either way, outstanding work never leads to a commit.
- Core may let operations that already started finish, or settle, before it rolls back and releases
the connection, so that a pooled connection is never handed on mid-operation. Waiting for them
never turns into a commit.
- **Transaction control belongs to the callbacks.** Transaction-control SQL is forbidden through
`query`: `BEGIN`, `START TRANSACTION`, `COMMIT`, `ROLLBACK`, `SAVEPOINT`, `RELEASE SAVEPOINT`, and
every other statement that starts, ends, or marks a transaction. Modules control a transaction's
lifecycle only through the `transaction` and `savepoint` callbacks.
- This is a conformance rule for trusted module code, not a sandbox or a security guarantee.
- Core does not parse SQL to enforce it. Such a statement is valid PostgreSQL, so PostgreSQL
executes it before Core can see its result.
- Core detects a violation where the driver and protocol make that possible: from the statement's
command tag, and from the connection's transaction status. It then fails safely:
- the call rejects;
- the transaction's scopes refuse every later statement without sending it, because it would
otherwise run outside any transaction;
- Core never commits the enclosing transaction itself, and that transaction rejects too;
- the connection is destroyed rather than reused.
- Core does not claim to undo a transaction-control statement PostgreSQL has already executed. A
`COMMIT` sent through a transaction's `query` has already committed the work before it.
- The commit and rollback guarantees in this section apply to conforming use of the ABI.
- **Revocation.** When the callback settles, `tx` is revoked, and it stays revoked once the scope has
finished. Any later call on it fails with the
closed-scope `ModuleDatabaseError` and sends nothing to PostgreSQL. A `tx` that escapes its
callback can never run a statement on a connection that has gone back to the pool.
- **Options.** `isolation` is `read committed`, `repeatable read`, or `serializable`. `readOnly` is a
boolean. An unknown option or value is refused, never ignored. `deferrable` is reserved for a
later version.
- **No automatic retries.** Core never retries a transaction. A callback may have side effects
outside the database, so retrying on `40001` is the module's decision.
- **Failed statements.** PostgreSQL semantics apply unchanged. After a failed statement the
transaction is aborted, and later statements fail until the scope ends. A module that wants to
continue after a failure uses a savepoint.
**Savepoints** are callback-scoped too:
```ts
tx.savepoint(async (sp) => { … })
```
- **Naming.** Core generates every savepoint identifier. Modules never provide one.
- **Release and rollback.** A resolved callback releases the savepoint. A rejected one rolls back
to it, releases it, and rejects with the callback's error.
- **Outstanding work.** The awaiting rule for transactions applies unchanged.
- A savepoint callback that resolves with operations still outstanding is a scope-usage error.
The savepoint is rolled back, never released, and the call rejects with that error.
- A savepoint callback that rejects keeps its own error. The savepoint is rolled back as for any
rejection.
- Either way, outstanding work never leads to a release.
- **One active scope.** Only the innermost open scope may run statements. While a savepoint is
open, its parent executor refuses statements and new savepoints, so savepoint scopes can never
overlap.
- **Revocation.** `sp` is revoked when its callback settles, exactly like `tx`.
Nested transactions are savepoints. No nested-transaction API exposes a driver or ORM object.
**Timeouts and cancellation stay with Core.** The existing per-statement bounds of the pool
(`statement_timeout` and `query_timeout`) apply to every ABI statement. Core may also bound how long
a transaction stays open. Modules cannot change Core's timeout policy through the ABI. `AbortSignal`
and module-initiated cancellation are not part of v1.
### Module-side adapters
The ABI is ORM-neutral. The Module SDK may ship adapters **on the module side**, bundled into each
module like the rest of the SDK (ADR 0049):
- **A Drizzle adapter.** It turns the executor into a Drizzle database the module's own tables work
with. It may use Drizzle's `pg-proxy` driver, mapping row-array queries to `rowMode: "array"` and
the rest to `"object"`. It supplies `transaction` by opening an ABI transaction and binding a fresh
Drizzle instance to `tx`.
- **A raw-SQL helper.** A tagged template that turns interpolations into placeholders and values.
- **Later, a Kysely adapter.**
Adapter types and query-builder types are not part of the ABI. Core never learns, and never needs
to learn, that a module used Drizzle. A module may bundle any Drizzle version its adapter supports.
### Schema metadata
For an ABI v1 module, Core validates table ownership from **plain, Ekklesia-owned metadata**, not
from ORM objects. Conceptually:
```yaml
tables:
- name: care_team_members
foreignKeys:
- table: care_team_teams
```
- **What it is.** Plain data: strings, arrays, and records, with no functions or class instances.
Core validates its structure strictly.
- **What Core checks.** It is enough for the checks ADR 0007 already makes:
- the module namespace and identifier rules;
- uniqueness;
- foreign keys that stay within the module and target its declared tables;
- atomic, deterministic aggregation with sanitized issues.
- **What Core never does.** For ABI v1 modules, Core does not call Drizzle's `is(…, PgTable)`,
`getTableConfig`, or `PgColumn` checks on module-provided objects, and does not import an ORM to
understand the metadata.
- **How a module produces it.** A Drizzle helper in the Module SDK may derive it from the module's
own Drizzle tables, on the module side. A Kysely or raw-SQL module may write it by hand or produce
it another way.
- **What it promises.** The metadata is a declaration checked for consistency. It is exactly as
strong a correctness check as today's, and no stronger: the database enforces columns when the
migration runs.
Legacy modules keep the existing Drizzle-object path while legacy support exists. Where the metadata
lives (in the schema entrypoint's default export or in a static file) is decided by the change that
implements it. Either way, it must be readable without Core importing an ORM.
### Migrations are unchanged
Migration discovery, validation, and execution stay as ADR 0008, ADR 0009, and ADR 0048 define
them. The runner already executes SQL without an ORM runtime. Migrations stay forward-only and
explicit, and history is retained for absent modules.
The Drizzle Kit migration file format (journal, snapshots, and `_ekklesia.json`) remains the module
migration contract. Whether that build-tool format should become Ekklesia-owned is a separate
question, and this ADR does not change it.
### Module Package v1
ADR 0049's `packageFormat` stays **1**. For a module that declares ABI v1:
- **Drizzle is allowed in the bundle.** It is a module-side implementation detail, for example of
the adapter.
- **The packager omits `transitional.databaseRuntime`.** Core requires no Drizzle runtime agreement.
- **The verifier checks the ABI declaration and the plain schema metadata instead.** It refuses an
ABI v1 package that carries `transitional.databaseRuntime`. Such a package was made by a packager
that did not know the module's database contract.
A legacy module's package keeps today's `transitional.databaseRuntime` recording and validation for
as long as the legacy facade exists.
This needs no new package format. ADR 0049 already lets a format 1 package carry no `transitional`
facts. A platform from before this change cannot misread an ABI v1 package either: it refuses the
version 8 manifest.
### Compatibility and deprecation
The deprecation window applies to the **legacy database facade**, not to manifest version 7.
1. **Phase 1.** Core serves both contracts: the legacy facade to version 8 modules without a
`database` declaration, and an ABI v1 executor to modules that declare `abi: 1`.
2. **Phase 2.** The Module SDK and the module template use ABI v1. First-party modules move over
independently, each in its own change.
3. **Phase 3.** After at least one documented deprecation window, ending in a breaking Module SDK
release, the legacy facade may be removed. Its removal date, and how the manifest then expresses
the database contract, belong to that later decision.
The legacy facade is transitional. It is not part of the permanent module contract, and it keeps
its current behaviour until it is removed.
## Consequences
- A module's ORM, and its version, no longer has to match Core's. Core can upgrade or replace its
own Drizzle without rebuilding any ABI v1 module.
- `transitional.databaseRuntime`, recorded in ADR 0049 as an implementation constraint, disappears
for ABI v1 packages, as ADR 0049 anticipated.
- Modules get one stable error shape with SQLSTATE, and one documented result encoding that loses
no precision and does not depend on time zones.
- A transaction object can no longer outlive its callback or reach a pooled connection afterwards.
- Every module moves to manifest version 8. Already-built version 7 packages must be rebuilt.
Database code changes only when a module opts into ABI v1.
- An ABI v1 module bundles an adapter. Its Drizzle code mostly stays as it is: the query builders
are its own.
- Core takes on a documented decoding table, transaction scoping, savepoint management, and error
mapping that Drizzle did implicitly before. Each needs live PostgreSQL test evidence.
- Core must serve two database contracts during the deprecation window.
- None of this is a sandbox. A trusted module can still do anything the API's database role can.
## Alternatives
- **An ORM AST or query-object ABI.** Rejected. Anything richer than SQL text and parameters is one
ORM's internal representation, so it would tie the ABI to that ORM and its release cadence. The
only part worth keeping, the row mode and field metadata, is already in the executor.
- **An Ekklesia-owned query DSL.** Rejected. It would cover less of PostgreSQL than Drizzle or
Kysely, cost more to maintain than either, and still force every module to rewrite its queries.
- **A permanent, versioned Drizzle facade.** Rejected as the permanent contract. It would make Drizzle
version agreement a permanent install-time constraint. Core's transaction object would keep
reaching module code. Core could never change its ORM. It remains only as the transitional legacy
facade.
- **Exposing the pool, a client, or a connection string.** Rejected, as in ADR 0016. Connection
lifecycle, timeouts, and transaction cleanup belong to the platform.
- **SQL parsing or execution-time prefix checks as a security boundary.** Rejected. SQL can name
tables in too many ways, and a trusted module can send any SQL, so a parser would be bypassable
and a false promise. ADR 0016 rejected the same idea.
- **`SET ROLE` or per-module PostgreSQL roles as a security boundary.** Rejected. Any SQL the module
sends can reset the role, and in-process code can reach the database without the ABI. Roles in a
module's CI, as a test aid, would be a correctness tool, not a boundary.
- **`Date` as a direct parameter.** Rejected. Its meaning depends on the target column's type, which
the ABI cannot see. Adapters convert it explicitly.
- **`pg`'s default parsing as the result contract.** Rejected. It is process-global and mutable, it
loses precision for `jsonb` numbers and `numeric[]` elements, and it is not an Ekklesia contract.
- **Parsing manifest versions 7 and 8 side by side.** Rejected. Manifest versions are hard cutovers,
and the deprecation window is about the database facade, not the manifest.
## Deferred
- The implementation: Core's executor, decoding table, transactions, savepoints, errors, and their
tests.
- Manifest version 8's exact mechanics, and the template and fixture cutover.
- The Module SDK's Drizzle adapter and raw-SQL helper.
- A Kysely adapter.
- The legacy facade's removal date and the release that removes it.
- Redesigning the migration file format.
- SQL cancellation and `AbortSignal`.
- Stronger process or database isolation for modules.
- Moving Signage to ABI v1.
+3
View File
@@ -56,3 +56,6 @@ ADRs capture durable choices that affect more than one package or phase.
- [0045: The Dashboard and the API may live on separate origins](0045-split-dashboard-and-api-origins.md) — Accepted
- [0046: Ekklesia's branding is never forced on an installation](0046-installation-owned-branding.md) — Proposed
- [0047: Every module proves itself inside Ekklesia, with one harness the platform publishes](0047-module-ci-harness.md) — Proposed
- [0048: Ekklesia ships one generic image, and installed modules live outside it](0048-generic-image-and-installed-module-storage.md) — Accepted
- [0049: A module release is one immutable, self-contained package: Ekklesia Module Package v1](0049-module-package-v1.md) — Accepted
- [0050: Module Database ABI v1: modules and Core exchange SQL, never ORM objects](0050-module-database-abi-v1.md) — Accepted
+39 -1
View File
@@ -609,7 +609,7 @@ a sandbox: it is `open` precisely because module code is trusted, same-realm cod
[ADR 0041](decisions/0041-module-browser-contributions.md) lets one module contribute a browser
renderer to a capability another module provides — the browser half of the capability system Core has
had since ADR 0006. `manifestVersion: 7` declares a namespaced id, its own version, a target
had since ADR 0006. `manifestVersion: 8` declares a namespaced id, its own version, a target
capability, a compatible range, and one artifact. That is the complete list: there is no payload
schema, slot, ordering, or rendering hint anywhere in the platform, so Core cannot learn what a
"slide" is because the declaration cannot express it. Discovery resolves the artifact through the
@@ -652,3 +652,41 @@ Still deliberately absent after Phase 8: a theme editor, a brand builder, or any
module store or installer; a standalone host; signed write URLs, resumable uploads, or any body above
the absolute ceiling; module-owned offline *content*; and every real feature module, Signage
included. Nothing in this pass claims any of them exists.
[ADR 0048](decisions/0048-generic-image-and-installed-module-storage.md) separates the platform
artifact from what an installation installs:
- **One generic image.** `deploy/Dockerfile` builds one image with zero feature modules, the same
for every installation; a checkout's `modules/` never reaches it.
- **Module storage.** Installed modules live in installation-owned storage that one Core resolver
selects from `EKKLESIA_MODULES_DIR`. The API, every operator command, and Doctor use the same
resolver, and Core's own migrations are always found under the platform, never beside the
modules. A configured directory that is relative, blank, missing, or unreadable fails closed
with a sanitized error.
- **Self-contained modules.** An installed module is a self-contained directory with its own
production dependencies; the platform lends it none. Discovery still happens only when a process
starts, so module changes take effect at a controlled restart and nothing is hot loaded.
- **Removing keeps data.** Removing a module's code keeps its data and stored state. The migration
runner retains and reports the applied history of a module that is absent, instead of refusing
to plan, while Core's history and every installed module's history stay strict, so a returning
module that lost or changed an applied migration is still refused.
- **Trust is unchanged.** Installed modules are still trusted code; module storage is mounted
read-only, which keeps Ekklesia from modifying installed code but does not sandbox it.
[ADR 0049](decisions/0049-module-package-v1.md) defines how a module is distributed: one Ekklesia
Module Package v1, a deterministic `.tar.gz` with its SHA-256.
- **The packager.** `@ekklesia/module-pack` bundles the module's backend with every pure-JavaScript
dependency, the SDK, contracts, and Drizzle included. It copies the built browser artifacts and
migrations, generates the runtime `package.json`, `ekklesia.package.json` with every file's digest,
and `THIRD_PARTY_NOTICES`, and writes the archive.
- **The verifier.** Core's `verifyModulePackage` reads the archive in memory and refuses links,
special files, and escaping paths. It checks every digest and identity, the target and runtime,
runs the platform's own module, schema, and migration validation, and loads the backend once in a
short-lived process with an empty environment. Only then does it extract anything.
- **What exists today.** Only the `any` target. A manifest-v8 ABI module carries no
`transitional.databaseRuntime`; Drizzle may be bundled as its private implementation detail, and
Core validates plain schema metadata without comparing ORM versions. A version 8 module that
omits `database` remains on the temporary legacy facade and keeps ADR 0049's exact transitional
Drizzle agreement. The canonical template now demonstrates ABI v1. The legacy facade still
exists.
+8 -5
View File
@@ -136,11 +136,14 @@ These rules map platform ownership to filesystem and package boundaries. The exe
declared by the consumer.
- Code a workspace package ships (its `src/` and `bin/`, excluding tests and declaration files)
declares every runtime import in `dependencies` or `optionalDependencies`. A peer declaration may
stay beside it, but never replaces it: a workspace package is linked into its consumers as a
directory, so its imports resolve from its own `node_modules`, where a consumer never satisfies a
peer, and a production install (`pnpm install --prod`, as the production image runs) omits
development dependencies. Type-only imports are exempt; a `.tsx`/`.jsx` file counts as importing
`react`, because the automatic JSX runtime imports `react/jsx-runtime` implicitly
stay beside it, but ordinarily never replaces it: a workspace package is linked into its consumers
as a directory, so its imports resolve from its own `node_modules`, where a consumer never
satisfies a peer, and a production install (`pnpm install --prod`, as the production image runs)
omits development dependencies. The one narrow exception is
`@ekklesia/module-database`'s `drizzle-orm` peer: modules choose that compatible runtime and the
Module Package v1 bundler incorporates it into their self-contained backend; Core never loads the
adapter package. Type-only imports are exempt; a `.tsx`/`.jsx` file counts as importing `react`,
because the automatic JSX runtime imports `react/jsx-runtime` implicitly
(`runtime-imports-are-production-dependencies`).
- Workspace aliases are forbidden because they obscure package ownership in the dependency graph.
- Workspace package names are unique and shared packages declare a recognized architectural role.
+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)). |
+6 -4
View File
@@ -138,7 +138,7 @@ enforces those permissions while the module is disabled, because it mounts no ro
## Module permission composition
A module manifest's `permissions` array (introduced in `manifestVersion: 4`; the current strict
manifest is version 7 — see
manifest is version 8 — see
[Module development](../module-development/README.md#canonical-manifest)) declares permissions in
the exact same `PermissionDeclaration` shape — but declaring one there only proves it is
well-formed and correctly namespaced under the module's own id; it does not, by itself, register
@@ -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
+14
View File
@@ -472,6 +472,20 @@ 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. A Module Package v1 (ADR 0049) is verified
before it is installed: its digest, its archive safety (no links, special files, setuid bits, or
escaping paths), every file's digest, and its identity and compatibility. The digest proves
integrity, not who published the package; signing is not implemented. The verifier loads the backend
in a short-lived process with an empty environment, so module code never sees a database URL or a
secret while it is being checked.
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
+92
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
@@ -499,6 +509,36 @@ an `authenticated` baseline, from platform ownership, or from a user grant. Its
answer "yes" to every human question, so any accidental fall-through into a human path would flip
those assertions.
A further suite (`core/tests/module-database-abi.integration.ts`, same harness) proves Core's Module
Database ABI v1 executor against real PostgreSQL
([ADR 0050](../architecture/decisions/0050-module-database-abi-v1.md)). Nothing it asserts is mocked,
because every claim is a property of PostgreSQL or of the protocol:
- one statement per `query`, enforced by PostgreSQL's extended protocol rather than by parsing, so
semicolons inside literals, identifiers, and comments are never mistaken for a second statement;
- every allowed parameter category bound exactly, and every refused one refused before PostgreSQL
sees it;
- the decoding table for each type the ADR names;
- PostgreSQL's SQLSTATE preserved while its message, detail, constraint, and values are hidden;
- commit, rollback, isolation levels, read-only, no retries, and an aborted transaction never being
committed;
- callback-scoped savepoints;
- a forgotten `await` rolling back instead of committing, while a rejected callback (one half of a
failed `Promise.all`) keeps its own error;
- transaction-control SQL sent through `query` detected only after PostgreSQL has run it: Core
cannot undo a module's own `COMMIT`, so the suite pins what it does do: it refuses everything
after, never commits, and destroys the connection;
- duplicate object-mode columns found only after a mutation ran, unless it ran in a transaction;
- revoked executors reaching nothing.
The suite's database is created with deliberately hostile defaults: a non-UTC time zone, a non-ISO
date style, `sql_standard` intervals, `escape` bytea output, and reduced float digits. So every
decoding assertion also proves the executor pins its own session settings. The pool-safety cases use
a one-connection pool, which proves the next borrower gets a connection with exactly the database's
defaults after every failure class: no transaction, setting, temporary table, listener, or advisory
lock carries over. The same is true after a connection the server terminated. Parameter validation,
decoding from text, option validation, error sanitization, and scope bookkeeping are also unit-tested
without a database in `core/tests/module-database-abi.test.ts`.
Outbound email is proven against a **real SMTP server** rather than a mock
(`core/tests/mail-smtp.integration.test.ts`, [ADR 0037](../architecture/decisions/0037-host-provided-outbound-email.md)).
`smtp-server` — the reference implementation from the same project as the client — runs in-process,
@@ -554,6 +594,58 @@ the following pull-request status context to `main` branch protection:
CI / migration-smoke (pull_request)
```
## Module packages
Ekklesia Module Package v1 (ADR 0049) is proven at three levels, all in `pnpm verify`:
- `packages/contracts/tests/module-package.test.ts`: the `ekklesia.package.json` schema, target
names, and the generated runtime `package.json`.
- `core/tests/module-package-verification.test.ts`: the verifier against hand-built archives,
including what the packager never produces. A valid package verifies and extracts with normalized
modes. A wrong digest, a corrupted or missing or unlisted file, disagreeing identities, an unknown
format, unknown transitional metadata, an incompatible target, Node.js range, SDK, or legacy
database runtime, ABI metadata carrying a forbidden transitional runtime, malformed ABI schema
metadata, a missing entrypoint, a backend that imports something the package lacks, absolute and
`..` paths, symbolic and hard links, device nodes and FIFOs, setuid and setgid entries, a second
top-level directory, and a duplicate entry each fail with their own sanitized code. The backend
load check runs in a real child process and selects the schema contract from the manifest.
- `packages/module-pack/tests/`: the packager against generated fixture modules (payload contents,
generated `package.json`, metadata, bundled backend with only built-in imports, refusal of native
and platform-specific dependencies and of unknown or textless licences, notices, byte-identical
archives, normalized tar and gzip headers), and end to end against the built module template:
packed twice to identical bytes without changing the workspace's install state, verified by Core's
verifier with a deliberately different Core Drizzle version, extracted elsewhere, and loaded with
Node's own resolution hooks proving nothing outside the package is resolved. The package contains
module-side Drizzle but no `transitional.databaseRuntime`. Turbo builds the template and Core
first, and runs the template's own tests (which rebuild its UI in place) before these.
## 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 is packaged by the canonical packager (Module Package v1, ADR 0049),
as ABI v1, verified by the image's own verifier, and 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;
- the exact same package is verified and installed again, and the module returns with its data.
See [deploy/README.md](../../deploy/README.md#testing-this-deployment).
## Safe troubleshooting
Confirm the prerequisites without printing configuration:
+236 -11
View File
@@ -26,7 +26,10 @@ application's. A module consumes those; it never redefines or bypasses them.
## Required package shape
Each immediate child of `modules/` must be an ESM workspace package with a unique package name, a
A module is developed as an ESM workspace package in a checkout's `modules/` directory, and it is
installed, self-contained, into an installation's module storage
([An installed module is self-contained](#an-installed-module-is-self-contained)). In a checkout,
each immediate child of `modules/` must be an ESM workspace package with a unique package name, a
strict semantic version, and the module layer marker:
```text
@@ -66,6 +69,125 @@ module with no migration history omits `entrypoints.migrations` and needs no pla
The package version must match the manifest and runtime-definition versions. Internal package
dependencies use `workspace:` ranges and supported public package entrypoints.
### An installed module is self-contained
Ekklesia's image contains no modules
([ADR 0048](../architecture/decisions/0048-generic-image-and-installed-module-storage.md)). An
installation keeps installed modules in its own module storage, the directory `EKKLESIA_MODULES_DIR`
names (`/var/lib/ekklesia/modules` in the production image, the checkout's `modules/` when it is
unset). Discovery reads it only when a process starts. Nothing is hot loaded, and an installed module
is read-only to the platform.
An installed module directory is **self-contained for runtime use**: everything the platform reads
or loads from it, and everything those files import at run time, resolves inside it. You never
assemble that directory by hand. You build your module, and the canonical packager turns it into an
**Ekklesia Module Package v1**
([ADR 0049](../architecture/decisions/0049-module-package-v1.md)): one deterministic archive and its
SHA-256, which an installation verifies and extracts into module storage.
**Copying `dist/` is not an installed module.** Your compiled server code imports
`@ekklesia/module-sdk` and `drizzle-orm` by name, and Node resolves those only from your module's
own directory upwards. The platform's `node_modules` is never on that path, and Ekklesia adds it
through no link, `NODE_PATH`, loader hook, or alias. The packager therefore bundles your backend.
#### Packaging your module
Build your module, then run the packager from the workspace your module is built in. It is a
development dependency like `@ekklesia/module-ui-build`:
```bash
pnpm exec ekklesia-pack-module --module . --target any --out ../packages \
--source-revision "$(git rev-parse HEAD)"
```
It writes `<id>-<version>-any.tar.gz` and its `.sha256` line, and nothing else anywhere: it never
installs, links, or deploys, so your workspace is left exactly as it was. Never run `pnpm deploy` in
a workspace instead: it rewrites that workspace's install state to production-only.
The archive holds one directory named with your module id:
```text
<id>/
├── package.json generated: name, version, private, "type": "module", the layer marker
├── ekklesia.module.json your manifest, byte for byte
├── ekklesia.package.json package metadata: target, runtime, provenance, file digests
├── THIRD_PARTY_NOTICES licence and notice text of every third-party package bundled
├── dist/server/index.js your backend entrypoints, bundled
├── dist/schema/index.js
├── dist/chunks/ code your backend entrypoints share
├── dist/ui/ … dist/display/ … dist/contributions/ your browser artifacts, exactly as built
└── drizzle/ your migrations
```
- **Bundled backend.** Your server, schema, and jobs entrypoints are bundled with every
pure-JavaScript dependency they use, the Module SDK, the contracts, and Drizzle included. The only
imports left are Node.js built-ins. A dependency the bundle cannot resolve fails packaging; nothing
is left to be found at run time.
- **Never shipped:** `src/`, tests, TypeScript declarations, source maps, development dependencies,
your development `package.json`, any `workspace:` range, and links of any kind.
- **Browser artifacts must be current.** The packager rebuilds them in memory and refuses an artifact
on disk that differs, so run your build first.
- **`any` is the only target today.** A dependency that is native or platform-specific (a compiled
addon, a package that declares an operating system or CPU, or one that loads a per-platform binary
such as `sharp`) cannot be in an `any` package, and packaging fails. Native targets come later.
- **Licences.** `THIRD_PARTY_NOTICES` credits every third-party package your bundles contain, with
its declared licence and the licence and notice files it ships. A package that declares no licence
fails packaging, and so does one that ships no licence text, unless its licence's standard text is
complete without a copyright line (Apache-2.0, which the packager then supplies and says so). The
Ekklesia platform's own packages are listed in `ekklesia.package.json` under `incorporates`.
- **Deterministic.** The same packager version and the same built inputs produce the same archive,
byte for byte, wherever the checkout is.
**Database ABI v1 is the current path.** A module that declares `"database": { "abi": 1 }` keeps
Drizzle and its exact version inside its own bundle as an implementation detail. Its package omits
`transitional.databaseRuntime`; Core validates the module's plain schema metadata and never compares
the bundled Drizzle version with its own. The canonical template demonstrates this path with
`@ekklesia/module-database/drizzle`.
A manifest-v8 module that omits `database` remains on the temporary legacy facade. Only that legacy
package records bundled Drizzle under `transitional.databaseRuntime`, and the verifier continues to
require exact agreement with Core. The legacy facade has not been removed.
#### Verifying a package
`node core/dist/cli/verify-module-package.js <archive> --sha256-file <archive>.sha256`, run from an
Ekklesia installation (`/opt/ekklesia` in the image), checks a package against that platform without
installing it. Pass `--extract-to <new directory>` to receive the verified module directory.
[deploy/README.md](../../deploy/README.md#installed-modules) shows the whole installation.
The repository's boundary checks (`pnpm boundaries`) prove only source modules in a workspace. An
installed package normally has no source, so the platform cannot check it the same way. Your
module's own CI is where its boundary rules, its production dependencies, and its behaviour inside
Ekklesia are proven ([ADR 0047](../architecture/decisions/0047-module-ci-harness.md)).
### License
Ekklesia is licensed under the GNU Affero General Public License, version 3 or (at your option) any
later version, `AGPL-3.0-or-later` ([LICENSE](../../LICENSE)), and official Ekklesia modules use the
same license. Declare it in your module's development `package.json`
(`"license": "AGPL-3.0-or-later"`), as the module template does.
A Module Package v1 does not yet record a module's own license: its runtime `package.json` and
`ekklesia.package.json` have fixed fields, and its contents are fixed, by ADR 0049. Carrying the
module's license, and the license text for the Ekklesia code bundled into its backend, needs a
change to that format.
### Removing a module keeps its data
Removing your module's directory from module storage is not deleting its data:
- your tables and rows, buckets and objects stay;
- the platform keeps its enablement decision, your configuration, its Dashboard placement, and
permission grants;
- your applied migration history stays, and the migration plan reports it as retained history for
a module that is not installed.
Reinstalled with the same module id, your module finds all of it again. Its history is validated
strictly again, so a reinstalled package must still contain every migration that was ever applied,
unchanged. Never delete or edit a released migration, and never ship an older version over newer
data. Disabling is different from removing: a disabled module stays installed and is simply not
loaded. Deleting a module's data (purge) does not exist yet.
## Canonical manifest
`ekklesia.module.json` is inert and authoritative, so Core can validate every installed module
@@ -73,7 +195,7 @@ without executing module code:
```json
{
"manifestVersion": 7,
"manifestVersion": 8,
"id": "calendar",
"name": "Calendar",
"version": "0.1.0",
@@ -150,10 +272,10 @@ has its own exact semantic version because its public contract can evolve indepe
module release. Capability dependencies use semantic-version ranges. Optional means the provider
may be absent; if it is installed, its capability version must be compatible.
`manifestVersion: 7` (`packages/contracts/src/module-manifest.ts`) is a required, exact literal — a
version 6 manifest, and any other version, fails validation outright rather than being silently
`manifestVersion: 8` (`packages/contracts/src/module-manifest.ts`) is a required, exact literal — a
version 7 manifest, and any other version, fails validation outright rather than being silently
reinterpreted; there is no compatibility adapter or automatic migration between manifest versions.
Version 7 adds the optional `contributions` array
Already-built version 7 packages must be rebuilt. Version 7 added the optional `contributions` array
([ADR 0041](../architecture/decisions/0041-module-browser-contributions.md)). A version 6 module that
contributes nothing changes only the version literal; every browser artifact remains independently
optional ([ADR 0033](../architecture/decisions/0033-display-kiosk-host-contract.md)).
@@ -1343,9 +1465,10 @@ export default defineConfig({
```
Keep `tables.ts` limited to Drizzle declarations so the stable generator can load it without
executing the ESM Module SDK wrapper. `src/schema/index.ts` remains the production compiled schema
entrypoint and wraps those same exported table objects with `defineModuleSchema`; tables are not
declared twice.
executing the ESM module wrapper. For an ABI-v1 module, `src/schema/index.ts` remains the production
compiled schema entrypoint and converts those same table objects to plain metadata with
`moduleDrizzleSchemaMetadata`; tables are not declared twice. A legacy-v8 module continues to wrap
them with `defineModuleSchema` while that facade exists.
Run the module's `migration:generate` script with a lowercase snake-case name, review the generated
SQL and snapshot, and add the matching entry to `drizzle/meta/_ekklesia.json`. Every entry must
@@ -1366,9 +1489,110 @@ destructive behavior. Review those properties explicitly. Core is the only migra
follows Drizzle's exact `--> statement-breakpoint` delimiter and never naively splits semicolons or
falls back to non-transactional execution.
## Database access
## Module Database ABI v1
`context.services.database` — available in `start`, `stop`, and every authorized route handler,
The stable database boundary is Module Database ABI v1: SQL text, ordered bound values, an explicit
row mode, low-level PostgreSQL result values, and callback-scoped transactions. ORM objects never
cross it. A manifest version 8 module opts in with `"database": { "abi": 1 }`; omitting `database`
selects the temporary legacy facade. “Legacy” here describes that facade, never a version 7
manifest. Core selects the service from the validated manifest and gives each ABI module its own
executor, reused by that module's lifecycle hooks and route handlers.
A Drizzle module imports the optional adapter subpath and declares its own exact compatible
`drizzle-orm` dependency. The adapter's peer range (`>=0.45.2 <0.46.0`) describes adapter API
compatibility; it is not a Core or platform compatibility range:
```ts
import { moduleDrizzle } from "@ekklesia/module-database/drizzle";
import { defineModuleRuntime } from "@ekklesia/module-sdk";
import * as schema from "./schema/tables.js";
export default defineModuleRuntime({
database: { abi: 1 },
id: "calendar",
version: "0.1.0",
async start({ services }) {
const db = moduleDrizzle(services.database, { schema });
const events = await db.select().from(schema.events);
},
});
```
The repeated runtime discriminator is what gives lifecycle and route-handler code the exact
`ModuleDatabaseExecutor` type instead of a legacy-or-ABI union. Core still chooses from the static
manifest and refuses startup when the runtime discriminator disagrees with it; neither contract can
silently receive the other service.
Drizzle compiles SQL and parameters before calling the ABI. The adapter always requests positional
(`rowMode: "array"`) results, gives those rows back to Drizzle in column order, and Drizzle applies
the module's own column decoders for bigint, numeric, dates/timestamps, JSON, byte arrays, nulls, and
supported arrays. This also makes joins with duplicate PostgreSQL column names unambiguous. Core
never imports this adapter and has no dependency on the module's Drizzle version. The adapter surface
is deliberately limited to `select`, `insert`, `update`, `delete`, and `transaction`; returning,
joins, and SQL expressions come from those normal Drizzle builders.
`db.transaction(callback)` maps to the ABI transaction callback. The callback receives another
Drizzle-facing database backed by the scoped ABI executor; a nested `.transaction()` maps to the
ABI's `savepoint` callback. Callback values and errors propagate, Core remains authoritative for
commit/rollback and escaped-scope behavior, and the adapter adds no retries or transaction-control
SQL.
Drizzle wraps execution failures in `DrizzleQueryError`. Recover the stable sanitized ABI error by
shape instead of using a PostgreSQL error instance, parsing a message, or reading `.cause.code`:
```ts
import { findEkklesiaDatabaseError } from "@ekklesia/module-database";
try {
await db.insert(schema.events).values(event);
} catch (error) {
const databaseError = findEkklesiaDatabaseError(error);
if (databaseError?.sqlState === "23505") {
// Handle a uniqueness conflict.
}
}
```
Modules that do not want Drizzle use the package's ORM-neutral tag. Every interpolation is one bound
value; v1 deliberately provides no dynamic-identifier or trusted-fragment escape hatch:
```ts
import { sql } from "@ekklesia/module-database";
const statement = sql`
SELECT id, title
FROM calendar_events
WHERE id = ${eventId}
`;
const result = await executor.query({ ...statement, rowMode: "object" });
```
The tag does not stringify values. Unsupported values pass through unchanged and the ABI executor
rejects them; strings containing quotes or SQL syntax always remain parameters.
ABI modules also export plain schema metadata rather than Drizzle objects. Drizzle users can derive
it on the module side; raw-SQL modules can write the same inert shape directly:
```ts
import { moduleDrizzleSchemaMetadata } from "@ekklesia/module-database/drizzle";
import * as tables from "./tables.js";
export default moduleDrizzleSchemaMetadata("calendar", tables);
```
The result is `{ moduleId, tables: [{ name, foreignKeys: [{ table }] }] }`. Core validates table
prefixes, duplicates, and same-module foreign-key targets from those strings and never inspects an
ABI module's ORM objects.
The canonical module template declares ABI v1, uses the module-side Drizzle adapter, and exports
plain schema metadata derived with `moduleDrizzleSchemaMetadata`. Its Module Package v1 still
bundles Drizzle, but carries no `transitional.databaseRuntime`: that version belongs to the module,
not Core.
## Legacy database access
For a version 8 manifest that omits `database`, `context.services.database` — available in `start`, `stop`, and every authorized route handler,
never in `register` — is a `ModuleDatabase`: a typed Drizzle query surface over exactly `select`,
`selectDistinct`, `selectDistinctOn`, `insert`, `update`, `delete`, `execute`, and `transaction`,
preserving Drizzle's own table-aware argument and result inference. It is the platform's only
@@ -1447,7 +1671,8 @@ pnpm migrations:plan
pnpm verify
```
`pnpm modules:validate` builds the workspace and then atomically validates every installed module.
`pnpm modules:validate` builds the workspace and then atomically validates every installed module,
from the checkout's `modules/` or from `EKKLESIA_MODULES_DIR` when it is set.
It rejects malformed manifests, duplicate IDs or package names, version mismatches, incompatible
platform or SDK ranges, missing required modules or capabilities, incompatible installed
dependencies, cycles, and missing, escaping, or non-JavaScript server or schema entrypoints.
+3
View File
@@ -1,6 +1,7 @@
{
"name": "ekklesia",
"version": "0.1.2",
"license": "AGPL-3.0-or-later",
"private": true,
"type": "module",
"description": "Open, modular church technology platform",
@@ -34,7 +35,9 @@
"migrations:validate": "turbo run build && node core/dist/cli/validate-migrations.js",
"modules:disable": "turbo run build && node --env-file-if-exists=.env core/dist/cli/set-module-enabled.js --disable",
"modules:enable": "turbo run build && node --env-file-if-exists=.env core/dist/cli/set-module-enabled.js --enable",
"modules:pack": "turbo run build && node packages/module-pack/bin/ekklesia-pack-module.mjs",
"modules:validate": "turbo run build && node core/dist/cli/validate-modules.js",
"modules:verify-package": "turbo run build && node core/dist/cli/verify-module-package.js",
"test": "turbo run test test:root",
"test:migrations:integration": "pnpm build && bash scripts/test-migrations-integration.sh",
"test:root": "vitest run --config vitest.config.ts",
+1
View File
@@ -1,6 +1,7 @@
{
"name": "@ekklesia/contracts",
"version": "0.1.2",
"license": "AGPL-3.0-or-later",
"type": "module",
"files": [
"dist"
+4
View File
@@ -4,12 +4,16 @@ export * from "./identity.js";
export * from "./module-configuration.js";
export * from "./module-contribution.js";
export * from "./module-database.js";
export * from "./module-database-abi.js";
export * from "./module-http.js";
export * from "./module-lifecycle.js";
export * from "./module-manifest.js";
export * from "./module-migration.js";
export * from "./module-package.js";
export * from "./module-realtime.js";
export * from "./module-runtime.js";
export * from "./module-schema.js";
export * from "./module-schema-metadata.js";
export * from "./module-schema-names.js";
export * from "./module-storage.js";
export * from "./versions.js";
@@ -0,0 +1,231 @@
/**
* Module Database ABI v1 (ADR 0050): the stable boundary between Core and a module's database code.
*
* Only PostgreSQL concepts cross it: SQL text, ordered bound parameter values, decoded result rows
* with their column metadata, and transaction scopes that Core controls. **Nothing in this file
* names an ORM, a query builder, or a driver.** A module may build its SQL with Drizzle, Kysely, raw
* text, or anything else, and Core never learns which; Core may change its own ORM without any
* module noticing.
*
* This is a correctness and compatibility boundary, **not a security sandbox**. Installed modules
* remain trusted backend code that runs in the API process with its database role (ADR 0048), and
* nothing here, including table prefixes, `SET ROLE`, or PostgreSQL roles, can stop a module from
* reaching data that is not its own.
*/
/** The ABI version these types describe. A module declares it; every executor reports it. */
export const MODULE_DATABASE_ABI_VERSION = 1;
export type ModuleDatabaseAbiVersion = typeof MODULE_DATABASE_ABI_VERSION;
/**
* A scalar parameter value. Deliberately small: each has exactly one PostgreSQL text encoding.
*
* - `number` is sent as its JavaScript decimal text; `NaN`, `Infinity`, and `-Infinity` as
* PostgreSQL's own spellings.
* - `bigint` is sent as its decimal text.
* - `Uint8Array` is sent as `bytea`.
*
* A JavaScript `Date` is **not** a parameter: whether it means a `timestamp` or a `timestamptz`, and
* in which zone, depends on a column the ABI cannot see. Plain objects are not implicit JSON; encode
* JSON explicitly as a string. Identifiers are never parameters.
*/
export type ModuleDatabaseScalar = null | boolean | number | bigint | string | Uint8Array;
/** A parameter value: a scalar, or a PostgreSQL array of scalars, nested for more dimensions. */
export type ModuleDatabaseParameter = ModuleDatabaseScalar | readonly ModuleDatabaseParameter[];
/**
* A decoded result value. Values that a generic conversion could change arrive as PostgreSQL's
* exact text: `int8`, `numeric`, every date and time type, `json`, and `jsonb`. `bytea` arrives as a
* `Uint8Array`, and arrays of the built-in types decode element by element.
*/
export type ModuleDatabaseValue =
| null
| boolean
| number
| string
| Uint8Array
| readonly ModuleDatabaseValue[];
/** `"object"` rows are keyed by column name; `"array"` rows are positional, in `fields` order. */
export type ModuleDatabaseRowMode = "object" | "array";
export type ModuleDatabaseObjectRow = Readonly<Record<string, ModuleDatabaseValue>>;
export type ModuleDatabaseArrayRow = readonly ModuleDatabaseValue[];
/** One query: exactly one PostgreSQL statement with `$1 … $n` placeholders. */
export interface ModuleDatabaseQuery {
readonly text: string;
readonly values?: readonly ModuleDatabaseParameter[];
/** Defaults to `"object"`. */
readonly rowMode?: ModuleDatabaseRowMode;
}
/** The ordered column metadata of a result, present even when it has no rows. */
export interface ModuleDatabaseField {
readonly name: string;
/** The column's PostgreSQL type OID. */
readonly typeOid: number;
}
export interface ModuleDatabaseQueryResult<Row> {
/** PostgreSQL's command tag word, such as `SELECT`, `INSERT`, `UPDATE`, or `DELETE`. */
readonly command: string;
/** The number of rows PostgreSQL reports as processed, or `null` when it reports none. */
readonly rowCount: number | null;
readonly rows: readonly Row[];
readonly fields: readonly ModuleDatabaseField[];
}
/** What every executor can do: run one statement. */
export interface ModuleDatabaseQueryable {
readonly abiVersion: ModuleDatabaseAbiVersion;
query(
query: ModuleDatabaseQuery & { readonly rowMode: "array" },
): Promise<ModuleDatabaseQueryResult<ModuleDatabaseArrayRow>>;
query(
query: ModuleDatabaseQuery & { readonly rowMode?: "object" },
): Promise<ModuleDatabaseQueryResult<ModuleDatabaseObjectRow>>;
query(
query: ModuleDatabaseQuery,
): Promise<ModuleDatabaseQueryResult<ModuleDatabaseObjectRow | ModuleDatabaseArrayRow>>;
}
export type ModuleDatabaseIsolationLevel = "read committed" | "repeatable read" | "serializable";
/** Transaction options. Any other key or value is refused, never ignored. Omitted, a transaction is
* `read committed` and read-write. */
export interface ModuleDatabaseTransactionOptions {
readonly isolation?: ModuleDatabaseIsolationLevel;
readonly readOnly?: boolean;
}
/**
* The executor inside a transaction or a savepoint. It is valid only while its callback runs: once
* that callback settles it is revoked, and every later call fails without reaching PostgreSQL.
*
* Only the innermost open scope may run statements: while a savepoint is open, its parent refuses
* statements and new savepoints. A callback must await every operation it starts before it returns.
* One that resolves with work outstanding forgot an `await`: it fails with
* `MODULE_DATABASE_SCOPE_WORK_OUTSTANDING` and is rolled back. One that rejects keeps its own error.
* Either way, outstanding work never leads to a commit or a release.
*
* Transaction-control SQL (`BEGIN`, `COMMIT`, `ROLLBACK`, `SAVEPOINT`, `RELEASE SAVEPOINT`, …) is
* forbidden through `query`; the callbacks control the transaction.
*/
export interface ModuleDatabaseTransaction extends ModuleDatabaseQueryable {
/** Core names the savepoint. A resolved callback with no work outstanding releases it; a rejected
* one rolls back to it and keeps its own error. */
savepoint<T>(callback: (savepoint: ModuleDatabaseTransaction) => Promise<T> | T): Promise<T>;
}
/**
* A module's database service under ABI v1. Top-level `query` calls are separate autocommit
* statements on whichever pooled connection is free: no session state carries from one to the
* next, and none survives the operation that made it.
*/
export interface ModuleDatabaseExecutor extends ModuleDatabaseQueryable {
/** Core begins the transaction, commits when `callback` resolves with no work outstanding, and
* rolls back otherwise; a rejected callback's own error is the transaction's error. Core never
* retries it. These guarantees apply to conforming use: no transaction-control SQL through
* `query`. */
transaction<T>(
callback: (transaction: ModuleDatabaseTransaction) => Promise<T> | T,
options?: ModuleDatabaseTransactionOptions,
): Promise<T>;
}
/**
* Every failure category an ABI v1 executor reports. A code is a stable category for programmatic
* handling and keeps its meaning once published; the specific reason is in the static message.
* PostgreSQL's own condition, when there is one, is `sqlState`, a separate field.
*/
export const MODULE_DATABASE_ERROR_CODES = [
/** The call is not valid ABI use: a malformed query request or callback, a statement text that
* holds no statement, an object-mode result with duplicate column names, or a transaction-control
* statement sent through `query`. A programming error.
*
* The last two are only detectable once PostgreSQL has run the statement, so its effects may
* already have happened: a mutation with duplicate result columns has been applied (inside a
* transaction, it rolls back with it), and transaction control has taken effect, which Core cannot
* undo. */
"MODULE_DATABASE_REQUEST_INVALID",
/** A parameter value is outside the ABI's parameter set. Refused before anything reaches
* PostgreSQL. */
"MODULE_DATABASE_PARAMETER_INVALID",
/** The statement did not complete. `sqlState` is present when PostgreSQL reported the condition;
* absent when the database could not be reached or the connection failed. */
"MODULE_DATABASE_QUERY_FAILED",
/** A transaction option or value is not one the ABI defines. Refused before the transaction
* starts. */
"MODULE_DATABASE_TRANSACTION_OPTION_INVALID",
/** The transaction could not be begun or completed, or a savepoint could not be completed.
* `sqlState` is present when PostgreSQL reported the condition. A transaction left aborted by a
* failed statement ends here too: PostgreSQL rolls it back, and nothing is committed. */
"MODULE_DATABASE_TRANSACTION_FAILED",
/** The executor's scope has finished, so it is revoked. */
"MODULE_DATABASE_SCOPE_CLOSED",
/** The executor's scope is open but not active, because a savepoint inside it is open. */
"MODULE_DATABASE_SCOPE_INACTIVE",
/** A transaction or savepoint callback resolved while work it started was still outstanding: a
* forgotten `await`. The scope was rolled back, never committed or released. (A callback that
* rejects keeps its own error instead.) */
"MODULE_DATABASE_SCOPE_WORK_OUTSTANDING",
] as const;
export type ModuleDatabaseErrorCode = (typeof MODULE_DATABASE_ERROR_CODES)[number];
const databaseErrorMessages: Readonly<Record<ModuleDatabaseErrorCode, string>> = Object.freeze({
MODULE_DATABASE_PARAMETER_INVALID: "A database parameter value is not supported.",
MODULE_DATABASE_QUERY_FAILED: "The database statement did not complete.",
MODULE_DATABASE_REQUEST_INVALID: "The database request is not valid.",
MODULE_DATABASE_SCOPE_CLOSED: "The database scope has finished and can no longer be used.",
MODULE_DATABASE_SCOPE_INACTIVE: "The database scope cannot be used while a savepoint is open.",
MODULE_DATABASE_SCOPE_WORK_OUTSTANDING:
"The database scope finished with work still outstanding, so it was rolled back.",
MODULE_DATABASE_TRANSACTION_FAILED: "The database transaction could not be completed.",
MODULE_DATABASE_TRANSACTION_OPTION_INVALID: "A database transaction option is not supported.",
});
const sqlStatePattern = /^[0-9A-Z]{5}$/u;
/**
* The one error an ABI v1 executor reports, sanitized by construction: a stable `code`, PostgreSQL's
* `sqlState` when the database supplied one, and a static message. It never carries SQL text,
* parameter values, the driver's message, detail, hint, or object names, a cause, a stack beyond
* its own name and message, or any driver or ORM object.
*
* Recognize it with `isModuleDatabaseError`, never `instanceof`: a module bundles its own copy of
* this package (ADR 0049), so class identity across the boundary is not a contract.
*/
export class ModuleDatabaseError extends Error {
readonly code: ModuleDatabaseErrorCode;
/** Declared, not initialized, so the property exists only when PostgreSQL supplied a SQLSTATE. */
declare readonly sqlState?: string;
constructor(code: ModuleDatabaseErrorCode, options: { readonly sqlState?: string } = {}) {
super(databaseErrorMessages[code]);
this.name = "ModuleDatabaseError";
this.code = code;
if (options.sqlState !== undefined && sqlStatePattern.test(options.sqlState)) {
this.sqlState = options.sqlState;
}
this.stack = `${this.name}: ${this.message}`;
Object.freeze(this);
}
}
/** Whether `value` is a Module Database ABI error, judged by its shape rather than its class. */
export function isModuleDatabaseError(
value: unknown,
): value is Pick<ModuleDatabaseError, "code" | "message" | "name" | "sqlState"> {
if (!(value instanceof Error) || value.name !== "ModuleDatabaseError") {
return false;
}
const { code, sqlState } = value as { code?: unknown; sqlState?: unknown };
return (
typeof code === "string" &&
(MODULE_DATABASE_ERROR_CODES as readonly string[]).includes(code) &&
(sqlState === undefined || (typeof sqlState === "string" && sqlStatePattern.test(sqlState)))
);
}

Some files were not shown because too many files have changed in this diff Show More