32 Commits
Author SHA1 Message Date
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
The_River fc504eee0a Merge pull request 'chore(release): prepare 0.1.2' (#62) from chore/release-0.1.2 into main
CI / verify (push) Successful in 56s
CI / infrastructure-smoke (push) Successful in 38s
CI / migration-smoke (push) Successful in 1m51s
CI / image-build (push) Successful in 7s
CI / deploy-smoke (same-origin) (push) Successful in 41s
CI / deploy-smoke (split) (push) Successful in 52s
Reviewed-on: https://git.riverofglorymn.org/River_of_Glory_Church/Ekklesia/pulls/62
Reviewed-by: The_River <[email protected]>
2026-10-06 04:08:58 +00:00
ekklesiaandClaude Opus 5.5 5abc8be167 chore(release): prepare 0.1.2
CI / verify (pull_request) Successful in 55s
CI / infrastructure-smoke (pull_request) Successful in 36s
CI / migration-smoke (pull_request) Successful in 1m51s
CI / image-build (pull_request) Successful in 27s
CI / deploy-smoke (same-origin) (pull_request) Successful in 42s
CI / deploy-smoke (split) (pull_request) Successful in 1m11s
Every workspace package declares 0.1.2, as the 0.1.1 release did for
itself, and the deploy README's build example names it. The tag
v0.1.2 created on 12dca3e carries package.json 0.1.1, which the
instance's assemble.sh refuses ("its tag and package.json disagree"),
and the running platform would report 0.1.1.

0.1.2 carries what the first production deployment found:
- #57: Doctor checks the client-address configuration the API runs;
- #58: storage over HTTP/1.1 (repeat image loads no longer fail with
  ERR_HTTP2_PROTOCOL_ERROR), and storage CORS owned by the proxy;
- #60: CI stacks never outlive their job;
- #56: display-device smoke scenarios;
- #59, #61: ADR 0047 proposed, and the CI documentation corrected for
  Gitea 1.27.2.

templates/module keeps its own version.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-05 22:56:35 -05:00
The_River 12dca3e8a4 Merge pull request 'docs: the server runs Gitea 1.27.2, not 1.24.2; say what it actually honours' (#61) from docs/gitea-1.27-ci-facts into main
CI / verify (push) Successful in 51s
CI / infrastructure-smoke (push) Successful in 34s
CI / migration-smoke (push) Successful in 1m50s
CI / image-build (push) Successful in 7s
CI / deploy-smoke (same-origin) (push) Successful in 45s
CI / deploy-smoke (split) (push) Successful in 45s
Reviewed-on: https://git.riverofglorymn.org/River_of_Glory_Church/Ekklesia/pulls/61
Reviewed-by: The_River <[email protected]>
2026-10-06 03:50:31 +00:00
ekklesiaandClaude Opus 5.5 0ec1755279 docs: the server runs Gitea 1.27.2, not 1.24.2; say what it actually honours
CI / verify (pull_request) Successful in 54s
CI / infrastructure-smoke (pull_request) Successful in 37s
CI / migration-smoke (pull_request) Successful in 1m50s
CI / image-build (pull_request) Successful in 18s
CI / deploy-smoke (same-origin) (pull_request) Successful in 42s
CI / deploy-smoke (split) (pull_request) Successful in 1m1s
The CI documentation, the workflow header, the README, ADR 0047 and the
sweep's comment all said the server was Gitea 1.24.2, which ignores
`permissions`, `concurrency` and `timeout-minutes`. The server reports
1.27.2 (GET /api/v1/version).

Each claim is now stated from what is verified on this server:
- `concurrency` is honoured, observed: Ekklesia runs 133 and 135 were
  cancelled when a newer run started for the same pull request;
- `permissions` is supported by 1.27 with Gitea's own scopes, but the
  token's actual authority is not visible through the API and not yet
  proven, so nothing claims the job token is read-only;
- `timeout-minutes` is no longer listed as unsupported, but is not
  verified, and teardown still relies on the stack sweep and a reaper.

Requirement 1 for executing untrusted contributions (upgrade to the
1.26 line or later) is marked done; items 2 and 3, configuring and
proving token authority, remain. ADR 0047 (still Proposed) restates
its "docker run, not a reusable workflow" reasons for 1.27, where
private sources are supported. The reasons still hold without the 1.24
argument.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-05 22:11:00 -05:00
The_River 874deabea3 Merge pull request 'fix(ci): nothing a CI job starts outlives it, even when the runner kills the job' (#60) from fix/ci-stack-cleanup into main
CI / verify (push) Successful in 55s
CI / infrastructure-smoke (push) Successful in 29s
CI / migration-smoke (push) Successful in 1m49s
CI / image-build (push) Successful in 7s
CI / deploy-smoke (same-origin) (push) Successful in 48s
CI / deploy-smoke (split) (push) Successful in 50s
Reviewed-on: https://git.riverofglorymn.org/River_of_Glory_Church/Ekklesia/pulls/60
Reviewed-by: The_River <[email protected]>
2026-10-06 02:11:53 +00:00
ekklesiaandClaude Opus 5.5 62951975da fix(ci): nothing a CI job starts outlives it, even when the runner kills the job
CI / verify (pull_request) Successful in 57s
CI / infrastructure-smoke (pull_request) Successful in 28s
CI / migration-smoke (pull_request) Successful in 1m49s
CI / image-build (pull_request) Successful in 19s
CI / deploy-smoke (same-origin) (pull_request) Successful in 48s
CI / deploy-smoke (split) (pull_request) Successful in 53s
A migration-smoke stack (PostgreSQL and SeaweedFS) ran on the shared
runner for two days. Every CI script removes its stack in an EXIT trap,
but a trap never runs when the runner kills the job container: a
cancelled job, or the runner's own time limit (Gitea 1.24 ignores
timeout-minutes). Reproduced: SIGKILL of the infrastructure smoke
mid-run leaves exactly that stack running.

Cleanup now has three layers:
- the script's own trap, unchanged;
- a final `if: always()` step in every job that starts stacks
  (infrastructure-smoke, migration-smoke, image-build, deploy-smoke)
  runs `scripts/ci-sweep.sh --job`, which removes everything that job
  started at any age;
- a first step in each of those jobs runs `scripts/ci-sweep.sh --stale`,
  which removes CI stacks older than three hours: what a killed job left
  before it could clean up.

How stacks are found is defined once, in scripts/lib/ci-project.sh:
- every CI Compose project is named `ekklesia-ci-<run>-<attempt>-<job
  key>-<kind>-<unique>`, and Compose labels its containers, networks and
  volumes with that name;
- the deploy smoke labels everything it starts outside Compose:
  browser, kiosk and driver containers, the kiosk volume, built images;
- images also record when CI built them. An image's own creation time
  can be its base's (BuildKit, metadata-only builds), and the sweep
  removed a brand-new image as "old" until it stopped trusting that.

The job key is per matrix leg, so one topology's cleanup cannot remove
the other's running stack. The sweep never touches anything outside the
ekklesia-ci- prefix, disconnects rather than removes a job container
attached to a stack's network, and refuses --job outside CI. Network
creation times are read as RFC 3339; Docker's default form was
unparseable.

scripts/test-ci-sweep.sh proves all of that against real Docker, and
CI runs it before relying on it. security.md and testing.md document
the rule, and that it complements, not replaces, a runner-side reaper.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-06 02:01:01 +00:00
ekklesiaandClaude Opus 5.5 703d92436b test(ci): every CI stack is cleaned up, even when its job is killed
A migration-smoke stack (PostgreSQL and SeaweedFS) leaked on the shared
runner and ran for two days, until it was removed by hand. Every CI
script already tears its stack down in an EXIT/INT/TERM trap, but a
trap never runs when the runner kills the job container: a cancelled
job, or the runner's own time limit, since Gitea 1.24 ignores
timeout-minutes.

Static checks (tests/ci-scripts.test.ts):
- every stack script names its project through one helper: the reserved
  ekklesia-ci- prefix, run, attempt, job key, and a unique suffix;
- everything the deploy smoke creates outside Compose carries the CI
  label;
- every workflow job that starts stacks sweeps stale CI stacks first,
  and removes its own in a final if: always() step. Each matrix leg has
  its own key, so one cannot remove the other's running stack.

Behaviour against real Docker (scripts/test-ci-sweep.sh):
- --job removes this job's containers, network, volume, image and
  staging directory, and leaves other jobs' and non-CI stacks alone;
- --job disconnects, never removes, a job container on the stack's
  network, and refuses to run outside CI;
- --stale spares young stacks and removes old ones, never anything
  outside the prefix.

Fails today: there is no helper, no sweep, no label and no step.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-06 02:01:01 +00:00
The_River 43911a088d Merge pull request 'docs: propose ADR 0047, a published harness every module runs in CI' (#59) from docs/adr-0047-module-harness into main
CI / verify (push) Successful in 58s
CI / infrastructure-smoke (push) Successful in 13s
CI / migration-smoke (push) Successful in 1m50s
CI / image-build (push) Successful in 6s
CI / deploy-smoke (same-origin) (push) Successful in 40s
CI / deploy-smoke (split) (push) Successful in 49s
Reviewed-on: https://git.riverofglorymn.org/River_of_Glory_Church/Ekklesia/pulls/59
Reviewed-by: The_River <[email protected]>
2026-10-06 02:00:39 +00:00
ekklesiaandClaude Opus 5.5 3a2f5d68f6 docs: ADR 0047, nothing the harness starts outlives its job
CI / verify (pull_request) Successful in 58s
CI / infrastructure-smoke (pull_request) Successful in 21s
CI / migration-smoke (pull_request) Successful in 1m51s
CI / image-build (pull_request) Successful in 19s
CI / deploy-smoke (same-origin) (pull_request) Successful in 43s
CI / deploy-smoke (split) (pull_request) Successful in 57s
A job the runner kills never runs its own cleanup trap, and the
platform's CI leaked a whole migration stack that way. The harness
follows the platform's three-layer rule:
- everything it starts is named and labeled as a CI stack;
- it cleans up in a trap;
- the template workflow sweeps stale CI stacks before running it, and
  removes its own in a final if: always() step.

The sweep ships in the harness image.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-05 20:54:38 -05:00
ekklesiaandClaude Opus 5.5 3827841205 docs: propose ADR 0047, a published harness every module runs in CI
CI / verify (pull_request) Successful in 53s
CI / infrastructure-smoke (pull_request) Successful in 21s
CI / migration-smoke (pull_request) Successful in 1m49s
CI / image-build (pull_request) Successful in 19s
CI / deploy-smoke (same-origin) (pull_request) Successful in 49s
CI / deploy-smoke (split) (pull_request) Canceled after 18s
Signage reached production with three defects that appear only inside
Ekklesia in a real browser, and no CI that ran it there. The ADR
proposes:
- a versioned harness image, ekklesia-module-harness:<platform version>,
  built and pushed by the platform's release CI;
- what the harness does: assemble a module into that platform, validate
  and build it, migrate a disposable database, start the split reference
  deployment, and mount every declared UI surface in headless Chromium;
- what fails it: console errors, unexpected 4xx/5xx, unhandled
  rejections, a display soak, and generic no-dead-ends injections;
- testing at the lowest and newest platform versions of a module's
  range;
- extra smoke steps in ekklesia.harness.json, beside the manifest
  rather than in it;
- a later data-access and egress check against declarations;
- a short template workflow that calls the harness by digest with one
  `docker run`. Not a reusable workflow: thinly supported on Gitea
  1.24, and private sources only arrive in 1.26.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-06 01:50:06 +00:00
The_River 2152eb036a Merge pull request 'fix(deploy): reach storage over HTTP/1.1, and own storage CORS in the proxy' (#58) from fix/storage-delivery-http1-cors into main
CI / verify (push) Successful in 53s
CI / infrastructure-smoke (push) Successful in 20s
CI / migration-smoke (push) Successful in 1m47s
CI / image-build (push) Successful in 7s
CI / deploy-smoke (same-origin) (push) Successful in 48s
CI / deploy-smoke (split) (push) Successful in 52s
Reviewed-on: https://git.riverofglorymn.org/River_of_Glory_Church/Ekklesia/pulls/58
Reviewed-by: The_River <[email protected]>
2026-10-06 01:39:39 +00:00
ekklesiaandClaude Opus 5.5 e1ac1da416 fix(deploy): reach storage over HTTP/1.1, and own storage CORS in the proxy
CI / verify (pull_request) Successful in 50s
CI / infrastructure-smoke (pull_request) Successful in 13s
CI / migration-smoke (pull_request) Successful in 1m47s
CI / image-build (pull_request) Successful in 18s
CI / deploy-smoke (same-origin) (pull_request) Successful in 47s
CI / deploy-smoke (split) (pull_request) Successful in 1m2s
Every reload of a cached files. image failed in the browser with
ERR_HTTP2_PROTOCOL_ERROR. A browser revalidates a cached signed URL
with If-None-Match. Caddy reached SeaweedFS over HTTP/2, and there
SeaweedFS answers with a 304 declaring content-type: application/xml
and content-length: 327 while sending no body. Caddy then logged
"aborting with incomplete response ... unexpected EOF" and reset the
stream. Over HTTP/1.1 SeaweedFS's 304 is clean. Measured layer by layer:
DevTools, curl through Caddy, and curl direct over HTTP/1.1 and HTTP/2.

The storage upstream is now HTTP/1.1 only (`versions 1.1`). Browsers
still reach the proxy over HTTP/2 and HTTP/3.

Delivery-layer CORS is the deployment's, so it no longer depends on
which S3 backend is behind it. SeaweedFS's default reflected any origin
and advertised PUT, POST and DELETE. Now the proxy:
- grants CORS only to the origins that read objects with fetch(): the
  Ekklesia origin, or, when split, the API origin (displays) and the
  Dashboard origin (a module's preview caches images through fetch());
- allows GET and HEAD only, with Vary: Origin;
- answers preflights itself, so they never reach storage;
- strips the object store's own CORS headers.

The deploy README documents the policy, its reasons, and what a
self-run proxy must do.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-06 01:19:17 +00:00
ekklesiaandClaude Opus 5.5 dcbd7613ee test(deploy): load the same image twice over HTTP/2, and pin storage CORS
Two checks on storage delivery through the proxy, in both topologies.

The same signed image, twice, over a real HTTP/2 connection (node:http2;
Node's fetch speaks HTTP/1.1, where the failure does not exist). The
second load is conditional, as a browser's revalidation is, and must be
a clean 304. Today Caddy reaches SeaweedFS over HTTP/2, SeaweedFS's 304
declares Content-Length: 327 and sends no body, and Caddy resets the
stream. A browser reports that as ERR_HTTP2_PROTOCOL_ERROR on every
reload of a cached files. image.

CORS on delivery is the deployment's own:
- exactly the API origin (and the Dashboard origin when split) is
  allowed, with Vary: Origin and nothing else;
- any other origin gets no CORS header;
- a preflight is answered by the proxy (204, GET and HEAD only) and
  never reaches the object store.

Today SeaweedFS reflects every origin with PUT, POST and DELETE, and
answers the preflight itself.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-06 01:19:17 +00:00
The_River dbb4648a35 Merge pull request 'fix(deploy): give migrate the API's client-address settings, and say what Doctor checked' (#57) from fix/doctor-client-address into main
CI / verify (push) Successful in 54s
CI / infrastructure-smoke (push) Successful in 21s
CI / migration-smoke (push) Successful in 1m45s
CI / image-build (push) Successful in 8s
CI / deploy-smoke (same-origin) (push) Successful in 39s
CI / deploy-smoke (split) (push) Successful in 44s
Reviewed-on: https://git.riverofglorymn.org/River_of_Glory_Church/Ekklesia/pulls/57
Reviewed-by: The_River <[email protected]>
2026-10-06 01:18:18 +00:00
ekklesiaandClaude Opus 5.5 f1d6501239 fix(deploy): give migrate the API's client-address settings, and say what Doctor checked
CI / verify (pull_request) Successful in 1m51s
CI / infrastructure-smoke (pull_request) Successful in 20s
CI / migration-smoke (pull_request) Successful in 1m48s
CI / image-build (pull_request) Successful in 35s
CI / deploy-smoke (same-origin) (pull_request) Successful in 50s
CI / deploy-smoke (split) (pull_request) Successful in 1m16s
Doctor checks the client-address configuration of the process it runs
in, and operators run it as `migrate`. The proxy overlay set
trusted-proxy mode on `api` only, so Doctor reported "Direct
client-address mode" for an installation that trusts its proxy.

- compose.proxy.yaml defines the three variables once, as a YAML
  anchor, and applies it to both `api` and `migrate`. Rendered with
  `docker compose config`, `api` is unchanged and `migrate` now
  matches it, keeping NODE_EXTRA_CA_CERTS from the base.
- The example deploy/.env sets
  COMPOSE_FILE=compose.yaml:compose.proxy.yaml, as the instance already
  does. Plain `docker compose run --rm migrate ...` in the README then
  runs with the overlay; it used to read compose.yaml alone.
- Every client-address check in Doctor now says it checked the
  environment Doctor runs with, and how to read the running API's own
  value (`docker compose exec api printenv
  EKKLESIA_CLIENT_ADDRESS_MODE`, confirmed to work in the image).
- The deploy README explains why operator commands need the same files
  as the stack.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-06 00:42:38 +00:00
ekklesiaandClaude Opus 5.5 54ac6ab83f test(deploy): Doctor must check the client-address configuration the API runs
Behind the reference proxy the API runs in trusted-proxy mode, but
compose.proxy.yaml gives those variables to `api` only. The documented
Doctor command runs as `migrate`, so it validated an empty
configuration and reported "Direct client-address mode" for an
installation that trusts its proxy. That is what the first production
installation saw.

Fails today:
- deploy.test.ts: the proxy overlay gives `migrate` no client-address
  environment;
- doctor.test.ts: the check does not say whose configuration it read;
- smoke-deploy.sh: Doctor through the proxy overlay does not report
  trusted-proxy mode (Docker).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-06 00:42:38 +00:00
The_River fa493365bd Merge pull request 'test(deploy): opt-in display-device scenarios for the split smoke' (#56) from test/m0-display-device-repro into main
CI / verify (push) Successful in 53s
CI / infrastructure-smoke (push) Successful in 22s
CI / migration-smoke (push) Successful in 1m49s
CI / image-build (push) Successful in 8s
CI / deploy-smoke (same-origin) (push) Successful in 42s
CI / deploy-smoke (split) (push) Successful in 52s
Reviewed-on: https://git.riverofglorymn.org/River_of_Glory_Church/Ekklesia/pulls/56
Reviewed-by: The_River <[email protected]>
2026-10-06 00:24:47 +00:00
ekklesiaandClaude Opus 5.5 f50e8f4201 test(deploy): word the long-watch checks for any watch length
CI / verify (pull_request) Successful in 52s
CI / infrastructure-smoke (pull_request) Successful in 21s
CI / migration-smoke (pull_request) Successful in 1m48s
CI / image-build (pull_request) Successful in 18s
CI / deploy-smoke (same-origin) (pull_request) Successful in 41s
CI / deploy-smoke (split) (pull_request) Successful in 59s
The recycle step's checks spoke of "the realtime recycle" even when
run with a watch shorter than the five-minute connection age, as module
CI does. They now say what they check: live for the whole watch (plus
"across a realtime recycle" when the watch is long enough), and never
remounted.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-05 19:09:51 -05:00
ekklesiaandClaude Opus 5.5 ff8a3e8097 test(deploy): add opt-in display-device scenarios to the split smoke
CI / verify (pull_request) Successful in 53s
CI / infrastructure-smoke (pull_request) Successful in 13s
CI / migration-smoke (pull_request) Successful in 1m48s
CI / image-build (pull_request) Successful in 20s
CI / deploy-smoke (same-origin) (pull_request) Successful in 48s
CI / deploy-smoke (split) (pull_request) Successful in 55s
Reproduction harness for what the first production Signage deployment
found. With EKKLESIA_SMOKE_DISPLAY_DEVICE=1, the split smoke starts a
second headless Chromium (the "kiosk") whose profile directory is a
Docker volume. It pairs that kiosk through the real flow, closes it
cleanly, restarts it on the same profile, and then runs injected-failure
scenarios: two pages on one credential, a connectivity blip, one refused
viewer-session request, one refused realtime connection, a cross-origin
image-caching probe, a full realtime recycle, and revocation.

Every DevTools command and page-side request has its own deadline, and
each step has a budget. A step that overruns prints the exact call it
was waiting for. The proxy's "aborting with incomplete response" lines
are listed afterwards by UTC time and path only.

Off by default: several scenarios expect Signage and fail on today's
v0.1.1, by design.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-05 18:25:12 -05:00
111 changed files with 11109 additions and 513 deletions
+5
View File
@@ -2,6 +2,11 @@
# `pnpm install --frozen-lockfile` and builds its own outputs, so nothing installed or built on the # `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. # 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. # Host-installed dependencies and build outputs: the image produces its own.
**/node_modules **/node_modules
**/dist **/dist
+6
View File
@@ -10,6 +10,12 @@
# The plaintext loopback DATABASE_URL below is therefore only usable with this set. # The plaintext loopback DATABASE_URL below is therefore only usable with this set.
NODE_ENV=development 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_DB=ekklesia
POSTGRES_USER=ekklesia POSTGRES_USER=ekklesia
POSTGRES_PASSWORD=ekklesia-development-only POSTGRES_PASSWORD=ekklesia-development-only
+70 -29
View File
@@ -1,18 +1,13 @@
name: CI name: CI
# IMPORTANT — the currently inventoried server is Gitea 1.24.2, which *ignores* several keys used # The server runs Gitea 1.27.2 (docs/development/security.md, "What this server honours"):
# below. Per https://docs.gitea.com/1.24/usage/actions/comparison, its unsupported workflow syntax
# includes `permissions` / `jobs.<job_id>.permissions`, `concurrency`, and
# `jobs.<job_id>.timeout-minutes`. They are declared here deliberately, so the workflow is already
# correct on a supported release and on GitHub Actions, but on this server they are inert:
# #
# * `permissions:` does NOT restrict the in-job `GITEA_TOKEN`. Job-token permission controls, # * `concurrency:` is honoured: a newer push to a pull request cancels the run it supersedes, so a
# repository/owner defaults, and server-side maximum-permission clamping arrive in the 1.26 # cancelled run's own cleanup is never guaranteed (see the CI stack sweep in each stack job).
# feature line (https://docs.gitea.com/1.26/usage/actions/token-permissions). Until the server # * `permissions:` is supported by 1.27 with Gitea's own scopes, but what the job token can do on
# is upgraded and configured, the token's authority is whatever the server grants — not what # this server is not yet proven, so nothing here relies on it being read-only.
# this file requests. # * `timeout-minutes:` is no longer listed as unsupported, but is not verified here either;
# * `concurrency:` and `timeout-minutes:` are NOT bounded-execution guarantees here. Cancellation, # teardown relies on the stack sweep and the runner's own reaper.
# timeout, and teardown must be enforced by the trusted runner provisioner/reaper.
# #
# `persist-credentials: false` below is unaffected by any of this and is enforced by the action # `persist-credentials: false` below is unaffected by any of this and is enforced by the action
# itself. See docs/development/security.md. # itself. See docs/development/security.md.
@@ -63,6 +58,9 @@ jobs:
needs: verify needs: verify
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 10 timeout-minutes: 10
env:
# Names this job's stacks, so its final step removes exactly these (scripts/lib/ci-project.sh).
EKKLESIA_CI_JOB_KEY: infrastructure-smoke
steps: steps:
- name: Check out repository - name: Check out repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
@@ -72,13 +70,29 @@ jobs:
# in this workflow pushes, so the credential is never needed after checkout. # in this workflow pushes, so the credential is never needed after checkout.
persist-credentials: false persist-credentials: false
# A job the runner killed (cancelled, or past the runner's own time limit) never ran its
# cleanup. Anything a CI job left on this shared daemon more than three hours ago goes first.
- name: Sweep stale CI stacks from the runner
run: bash scripts/ci-sweep.sh --stale
- name: Prove the sweep removes exactly what it should, against real Docker resources
run: bash scripts/test-ci-sweep.sh
- name: Exercise PostgreSQL and S3 infrastructure - name: Exercise PostgreSQL and S3 infrastructure
run: bash scripts/smoke-infrastructure.sh run: bash scripts/smoke-infrastructure.sh
# Whatever happened above, this job's own stacks do not outlive it.
- name: Remove this job's CI stacks
if: always()
run: bash scripts/ci-sweep.sh --job
migration-smoke: migration-smoke:
needs: verify needs: verify
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 15 timeout-minutes: 15
env:
# Names this job's stacks, so its final step removes exactly these (scripts/lib/ci-project.sh).
EKKLESIA_CI_JOB_KEY: migration-smoke
steps: steps:
- name: Check out repository - name: Check out repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
@@ -88,6 +102,11 @@ jobs:
# in this workflow pushes, so the credential is never needed after checkout. # in this workflow pushes, so the credential is never needed after checkout.
persist-credentials: false persist-credentials: false
# A job the runner killed (cancelled, or past the runner's own time limit) never ran its
# cleanup. Anything a CI job left on this shared daemon more than three hours ago goes first.
- name: Sweep stale CI stacks from the runner
run: bash scripts/ci-sweep.sh --stale
- name: Set up Node.js - name: Set up Node.js
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with: with:
@@ -104,11 +123,18 @@ jobs:
- name: Exercise transactional migrations against PostgreSQL - name: Exercise transactional migrations against PostgreSQL
run: pnpm test:migrations:integration run: pnpm test:migrations:integration
# Whatever happened above, this job's own stacks do not outlive it.
- name: Remove this job's CI stacks
if: always()
run: bash scripts/ci-sweep.sh --job
image-build: image-build:
needs: verify needs: verify
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 20 timeout-minutes: 20
env: env:
# Names this job's stacks, so its final step removes exactly these (scripts/lib/ci-project.sh).
EKKLESIA_CI_JOB_KEY: image-build
# Cache mounts in deploy/Dockerfile need BuildKit; this selects it even where the Docker CLI # Cache mounts in deploy/Dockerfile need BuildKit; this selects it even where the Docker CLI
# has no buildx plugin. # has no buildx plugin.
DOCKER_BUILDKIT: "1" DOCKER_BUILDKIT: "1"
@@ -121,29 +147,32 @@ jobs:
# in this workflow pushes, so the credential is never needed after checkout. # in this workflow pushes, so the credential is never needed after checkout.
persist-credentials: false persist-credentials: false
# The platform-only image (ADR 0044): a checkout has no `modules/`, so this proves the image # A job the runner killed (cancelled, or past the runner's own time limit) never ran its
# builds, validates, and assembles with zero modules. Build only — no registry login, no # cleanup. Anything a CI job left on this shared daemon more than three hours ago goes first.
# credentials, no push, no tag anywhere but this runner's local image store. - name: Sweep stale CI stacks from the runner
# Tagged per run and attempt, not only per commit: a pull request and a push of the same commit run: bash scripts/ci-sweep.sh --stale
# can build at once on one runner's Docker daemon, and neither may remove the other's image.
- name: Build the production image without modules
run: >-
docker build
--file deploy/Dockerfile
--build-arg REVISION="$GITHUB_SHA"
--tag ekklesia-ci:"$GITHUB_SHA-${GITHUB_RUN_ID:-0}-${GITHUB_RUN_ATTEMPT:-1}"
.
- name: Remove the image from the runner # 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
if: always() if: always()
run: docker image rm --force ekklesia-ci:"$GITHUB_SHA-${GITHUB_RUN_ID:-0}-${GITHUB_RUN_ATTEMPT:-1}" || true run: bash scripts/ci-sweep.sh --job
deploy-smoke: deploy-smoke:
needs: verify needs: verify
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 30 timeout-minutes: 30
# Both topologies (ADR 0045): same-origin, the default every installation gets, and split, which # Both topologies (ADR 0045), each on the one generic image: same-origin, the default every
# adds a test-only module and drives a real headless browser across the two origins. # 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: strategy:
fail-fast: false fail-fast: false
matrix: matrix:
@@ -151,6 +180,8 @@ jobs:
- same-origin - same-origin
- split - split
env: env:
# Names this job's stacks, so its final step removes exactly these (scripts/lib/ci-project.sh).
EKKLESIA_CI_JOB_KEY: deploy-smoke-${{ matrix.topology }}
DOCKER_BUILDKIT: "1" DOCKER_BUILDKIT: "1"
EKKLESIA_SMOKE_TOPOLOGY: ${{ matrix.topology }} EKKLESIA_SMOKE_TOPOLOGY: ${{ matrix.topology }}
steps: steps:
@@ -162,10 +193,20 @@ jobs:
# in this workflow pushes, so the credential is never needed after checkout. # in this workflow pushes, so the credential is never needed after checkout.
persist-credentials: false persist-credentials: false
# The reference deployment end to end (ADR 0044): a platform-only image, base + proxy with # A job the runner killed (cancelled, or past the runner's own time limit) never ran its
# cleanup. Anything a CI job left on this shared daemon more than three hours ago goes first.
- name: Sweep stale CI stacks from the runner
run: bash scripts/ci-sweep.sh --stale
# 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 # 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 # 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 # database TLS through the proxy. Every secret is generated for this run and discarded with
# it; nothing is pushed, published, or logged in to. # it; nothing is pushed, published, or logged in to.
- name: Smoke-test the reference deployment (${{ matrix.topology }}) - name: Smoke-test the reference deployment (${{ matrix.topology }})
run: bash scripts/smoke-deploy.sh run: bash scripts/smoke-deploy.sh
# Whatever happened above, this job's own stacks do not outlive it.
- name: Remove this job's CI stacks
if: always()
run: bash scripts/ci-sweep.sh --job
+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/>.
+44 -5
View File
@@ -35,6 +35,22 @@ 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 adapter. A Core-owned `pnpm doctor` dashboard combines the existing static discovery and read-only
migration-planning services into one concise readiness report. 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. 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 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_user`, `ekklesia_core_session`, `ekklesia_core_account`,
`ekklesia_core_verification`), a first-class Core-owned migration source that coexists with `ekklesia_core_verification`), a first-class Core-owned migration source that coexists with
@@ -218,9 +234,10 @@ undeployed state, and the launch checklist in a single place. **CI is accepted a
trusted-code-only boundary**: the self-hosted Gitea runner executes only code controlled and trusted-code-only boundary**: the self-hosted Gitea runner executes only code controlled and
reviewed by trusted repository collaborators, and unknown or untrusted pull requests must not reviewed by trusted repository collaborators, and unknown or untrusted pull requests must not
execute on it automatically. What remains is operational and cannot be proven from this repository — execute on it automatically. What remains is operational and cannot be proven from this repository —
upgrading Gitea past 1.24.2 (which *ignores* the workflow's `permissions`, `concurrency`, and configuring and proving job-token authority server-side (the server now runs Gitea 1.27.2, which
`timeout-minutes` declarations), clamping job-token authority server-side, enforcing timeout and honours `concurrency` and documents `permissions`, but neither token limits nor `timeout-minutes` are
teardown in a trusted provisioner/reaper, and proving disposable runner isolation. Those are verified on it yet), enforcing timeout and teardown in a trusted provisioner/reaper, and proving
disposable runner isolation. Those are
prerequisites for **executing untrusted contributions**, alongside the deployment checklist that prerequisites for **executing untrusted contributions**, alongside the deployment checklist that
gates a **public launch**; moving future public contribution workflows to a service with isolated gates a **public launch**; moving future public contribution workflows to a service with isolated
hosted runners is an acceptable alternative. **Neither set of requirements blocks Phase 6** — Surface hosted runners is an acceptable alternative. **Neither set of requirements blocks Phase 6** — Surface
@@ -1055,5 +1072,27 @@ environment.
- [Module development](docs/module-development/README.md) - [Module development](docs/module-development/README.md)
- [Architecture decisions](docs/architecture/decisions/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 ## License
owner makes that decision.
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.
+2 -1
View File
@@ -1,6 +1,7 @@
{ {
"name": "@ekklesia/api", "name": "@ekklesia/api",
"version": "0.1.1", "version": "0.1.2",
"license": "AGPL-3.0-or-later",
"private": true, "private": true,
"type": "module", "type": "module",
"files": [ "files": [
+18 -4
View File
@@ -6,6 +6,7 @@ import {
composePlatformPermissionRegistry, composePlatformPermissionRegistry,
discoverModules, discoverModules,
type DiscoveredModule, type DiscoveredModule,
resolveModulesDirectory,
} from "@ekklesia/core/modules"; } from "@ekklesia/core/modules";
export type PlatformPermissionConfigurationErrorCode = "PLATFORM_PERMISSION_CONFIGURATION_INVALID"; export type PlatformPermissionConfigurationErrorCode = "PLATFORM_PERMISSION_CONFIGURATION_INVALID";
@@ -55,10 +56,18 @@ async function readPackageVersion(packagePath: string): Promise<string> {
} }
export interface PrepareStartupModuleInventoryOptions { 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 /** 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 * modules, taking precedence over `configuredModulesDirectory`, which is then never resolved.
* repository's `modules/` directory. Platform and Module SDK versions are always read from the * Never set by the executable entry point. Platform and Module SDK versions are always read from
* real repository's own `package.json` files regardless of this override. */ * the real repository's own `package.json` files regardless of this override. */
readonly modulesDirectoryOverride?: string; readonly modulesDirectoryOverride?: string;
/** Test-only escape hatch: supplies an already-discovered module list directly, skipping the /** Test-only escape hatch: supplies an already-discovered module list directly, skipping the
* filesystem discovery step entirely, while still running the real `composePlatformPermissionRegistry` * filesystem discovery step entirely, while still running the real `composePlatformPermissionRegistry`
@@ -109,6 +118,11 @@ export async function prepareStartupModuleInventory(
} }
const repositoryRoot = resolveRepositoryRoot(); 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([ const [platformVersion, moduleSdkVersion] = await Promise.all([
readPackageVersion(path.join(repositoryRoot, "package.json")), readPackageVersion(path.join(repositoryRoot, "package.json")),
readPackageVersion(path.join(repositoryRoot, "packages/module-sdk/package.json")), readPackageVersion(path.join(repositoryRoot, "packages/module-sdk/package.json")),
@@ -116,7 +130,7 @@ export async function prepareStartupModuleInventory(
const modules = await discoverModules({ const modules = await discoverModules({
moduleSdkVersion, moduleSdkVersion,
modulesDirectory: options.modulesDirectoryOverride ?? path.join(repositoryRoot, "modules"), ...modulesDirectory,
platformVersion, platformVersion,
}); });
+16 -4
View File
@@ -154,8 +154,12 @@ export interface StartApiOptions {
* test needs somewhere to observe the operational failure the user is never shown. * test needs somewhere to observe the operational failure the user is never shown.
*/ */
readonly reportNotificationFailure?: IdentityNotificationReporter; readonly reportNotificationFailure?: IdentityNotificationReporter;
/** Test-only escape hatch: overrides the directory module discovery scans. Never set by the /** The deployment's raw `EKKLESIA_MODULES_DIR` (ADR 0048), a production input the executable
* executable entry point. See `prepareStartupModuleInventory`. */ * 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; readonly modulesDirectoryOverride?: string;
/** Test-only escape hatch: supplies an already-discovered module list directly, skipping /** Test-only escape hatch: supplies an already-discovered module list directly, skipping
* filesystem discovery entirely — this exact list drives both permission composition and module * filesystem discovery entirely — this exact list drives both permission composition and module
@@ -429,6 +433,7 @@ export async function startApi(
options: StartApiOptions = {}, options: StartApiOptions = {},
): Promise<RunningApi> { ): Promise<RunningApi> {
const inventory = await prepareStartupModuleInventory({ const inventory = await prepareStartupModuleInventory({
configuredModulesDirectory: options.configuredModulesDirectory,
...(options.modulesDirectoryOverride === undefined ...(options.modulesDirectoryOverride === undefined
? {} ? {}
: { modulesDirectoryOverride: options.modulesDirectoryOverride }), : { modulesDirectoryOverride: options.modulesDirectoryOverride }),
@@ -769,6 +774,7 @@ export function readConfigFromEnvironment(processEnvironment: HostEnvironment):
readonly input: Parameters<typeof createApiRuntimeConfig>[0]; readonly input: Parameters<typeof createApiRuntimeConfig>[0];
readonly environment: "development" | "production"; readonly environment: "development" | "production";
readonly moduleSecrets: ModuleSecretSource; readonly moduleSecrets: ModuleSecretSource;
readonly modulesDirectory: string | undefined;
} { } {
const host = loadHostConfiguration(processEnvironment); const host = loadHostConfiguration(processEnvironment);
return { return {
@@ -784,13 +790,19 @@ export function readConfigFromEnvironment(processEnvironment: HostEnvironment):
storage: host.storage, storage: host.storage,
}, },
moduleSecrets: host.moduleSecrets, moduleSecrets: host.moduleSecrets,
modulesDirectory: host.modulesDirectory,
}; };
} }
export async function main(): Promise<void> { 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 config = createApiRuntimeConfig(input, { environment });
const running = await startApi(config, { moduleSecrets }); const running = await startApi(config, {
configuredModulesDirectory: modulesDirectory,
moduleSecrets,
});
const address = running.server.address(); const address = running.server.address();
const boundPort = address && typeof address === "object" ? address.port : config.port; const boundPort = address && typeof address === "object" ? address.port : config.port;
+3 -4
View File
@@ -2590,6 +2590,7 @@ describe.sequential("live API — installed module permission end-to-end", () =>
}); });
const report = await diagnoseEkklesia({ const report = await diagnoseEkklesia({
coreMigrationsDirectory,
moduleDiscovery: { moduleDiscovery: {
moduleSdkVersion: "0.1.0", moduleSdkVersion: "0.1.0",
modulesDirectory, modulesDirectory,
@@ -2599,10 +2600,8 @@ describe.sequential("live API — installed module permission end-to-end", () =>
runtimeVersion: process.versions.node, runtimeVersion: process.versions.node,
}); });
// Only the module-discovery and permission-composition checks are asserted here — this // Only the module-discovery and permission-composition checks are asserted here; nothing else
// fixture's temporary directory has no `core/drizzle` sibling for migrations discovery to // in the report bears on what this scenario proves.
// 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.
const moduleCheck = report.checks.find((entry) => entry.id === "modules.discovery"); const moduleCheck = report.checks.find((entry) => entry.id === "modules.discovery");
const permissionsCheck = report.checks.find((entry) => entry.id === "modules.permissions"); const permissionsCheck = report.checks.find((entry) => entry.id === "modules.permissions");
expect(moduleCheck).toMatchObject({ status: "pass" }); expect(moduleCheck).toMatchObject({ status: "pass" });
@@ -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", () => { it("reads only this listener's own address and port outside the shared loader", () => {
const config = apiView( const config = apiView(
environment({ EKKLESIA_API_HOST: "0.0.0.0", EKKLESIA_API_PORT: "8080" }), environment({ EKKLESIA_API_HOST: "0.0.0.0", EKKLESIA_API_PORT: "8080" }),
+67
View File
@@ -1004,6 +1004,73 @@ describe("Ekklesia API module permission composition at startup", () => {
expect(poolEnd).toHaveBeenCalledTimes(1); 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 () => { it("does not perform module discovery merely by importing the platform-permissions module", async () => {
const module = await import("../src/platform-permissions.js"); const module = await import("../src/platform-permissions.js");
+2 -1
View File
@@ -1,6 +1,7 @@
{ {
"name": "@ekklesia/core", "name": "@ekklesia/core",
"version": "0.1.1", "version": "0.1.2",
"license": "AGPL-3.0-or-later",
"private": true, "private": true,
"type": "module", "type": "module",
"files": [ "files": [
+23 -3
View File
@@ -8,6 +8,7 @@ import {
formatDoctorReport, formatDoctorReport,
} from "../diagnostics/index.js"; } from "../diagnostics/index.js";
import { type HostEnvironment, loadHostConfiguration } from "../host/index.js"; import { type HostEnvironment, loadHostConfiguration } from "../host/index.js";
import { type ModulesDirectorySelection, resolveModulesDirectory } from "../modules/index.js";
interface ModuleSdkPackageDocument { interface ModuleSdkPackageDocument {
readonly version?: unknown; readonly version?: unknown;
@@ -25,8 +26,10 @@ interface RootPackageDocument extends ModuleSdkPackageDocument {
*/ */
export interface DoctorCommandMetadata { export interface DoctorCommandMetadata {
readonly moduleSdkVersion: string; readonly moduleSdkVersion: string;
readonly modulesDirectory: string;
readonly platformVersion: 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 runtimeRange: string;
readonly runtimeVersion: string; readonly runtimeVersion: string;
} }
@@ -49,6 +52,22 @@ function requiredString(value: unknown): string {
return value; 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. * Turns one deployment environment into Doctor's inputs.
* *
@@ -69,6 +88,7 @@ export function createDoctorOptions(
const host = loadHostConfiguration(environment); const host = loadHostConfiguration(environment);
return { return {
clientAddress: host.clientAddress, clientAddress: host.clientAddress,
coreMigrationsDirectory: path.join(metadata.repositoryRoot, "core", "drizzle"),
databaseEnvironment: host.environment, databaseEnvironment: host.environment,
databaseTransport: host.databaseTransport, databaseTransport: host.databaseTransport,
databaseUrl: host.databaseUrl, databaseUrl: host.databaseUrl,
@@ -76,7 +96,7 @@ export function createDoctorOptions(
mailConfiguration: host.mail, mailConfiguration: host.mail,
moduleDiscovery: { moduleDiscovery: {
moduleSdkVersion: metadata.moduleSdkVersion, moduleSdkVersion: metadata.moduleSdkVersion,
modulesDirectory: metadata.modulesDirectory, ...doctorModulesDirectory(host.modulesDirectory, metadata.repositoryRoot),
platformVersion: metadata.platformVersion, platformVersion: metadata.platformVersion,
}, },
moduleSecrets: host.moduleSecrets, moduleSecrets: host.moduleSecrets,
@@ -97,8 +117,8 @@ export async function runDoctorCommand(repositoryRoot = process.cwd()): Promise<
const report = await diagnoseEkklesia( const report = await diagnoseEkklesia(
createDoctorOptions(process.env, { createDoctorOptions(process.env, {
moduleSdkVersion: requiredString(moduleSdkPackage.version), moduleSdkVersion: requiredString(moduleSdkPackage.version),
modulesDirectory: path.join(repositoryRoot, "modules"),
platformVersion: requiredString(rootPackage.version), platformVersion: requiredString(rootPackage.version),
repositoryRoot,
runtimeRange: requiredString(rootPackage.engines?.node), runtimeRange: requiredString(rootPackage.engines?.node),
runtimeVersion: process.versions.node, runtimeVersion: process.versions.node,
}), }),
+10 -1
View File
@@ -1,7 +1,9 @@
import { readFile } from "node:fs/promises"; import { readFile } from "node:fs/promises";
import path from "node:path"; import path from "node:path";
import type { HostEnvironment } from "../host/index.js";
import { discoverAllMigrations } from "../migrations/index.js"; import { discoverAllMigrations } from "../migrations/index.js";
import type { ModuleMigrationAggregate } from "../modules/index.js"; import type { ModuleMigrationAggregate } from "../modules/index.js";
import { resolveCommandModulesDirectory } from "./modules-directory-environment.js";
interface PackageVersion { interface PackageVersion {
readonly version?: unknown; readonly version?: unknown;
@@ -15,9 +17,16 @@ async function readPackageVersion(packagePath: string): Promise<string> {
return packageData.version; 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( export async function discoverRepositoryMigrations(
repositoryRoot = process.cwd(), repositoryRoot = process.cwd(),
environment: HostEnvironment = process.env,
): Promise<ModuleMigrationAggregate> { ): Promise<ModuleMigrationAggregate> {
const modulesDirectory = resolveCommandModulesDirectory(environment, repositoryRoot);
const [platformVersion, moduleSdkVersion] = await Promise.all([ const [platformVersion, moduleSdkVersion] = await Promise.all([
readPackageVersion(path.join(repositoryRoot, "package.json")), readPackageVersion(path.join(repositoryRoot, "package.json")),
readPackageVersion(path.join(repositoryRoot, "packages/module-sdk/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"), coreMigrationsDirectory: path.join(repositoryRoot, "core", "drizzle"),
moduleDiscovery: { moduleDiscovery: {
moduleSdkVersion, moduleSdkVersion,
modulesDirectory: path.join(repositoryRoot, "modules"), ...modulesDirectory,
platformVersion, platformVersion,
}, },
}); });
+21 -3
View File
@@ -1,5 +1,9 @@
import type { ModuleMigrationAggregate } from "../modules/index.js"; 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 { function plural(count: number, singular: string): string {
return count === 1 ? singular : `${singular}s`; return count === 1 ? singular : `${singular}s`;
@@ -28,10 +32,19 @@ export function formatMigrationValidationReport(
return Object.freeze(lines); 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[] { export function formatMigrationPlanReport(plan: MigrationPlanResult): readonly string[] {
const readyCount = plan.pendingCount - plan.approvalRequiredCount; const readyCount = plan.pendingCount - plan.approvalRequiredCount;
const retainedSummary = plan.retained.length === 0 ? "" : `, ${plan.retained.length} retained`;
const lines = [ 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) { for (const entry of plan.entries) {
@@ -40,6 +53,7 @@ export function formatMigrationPlanReport(plan: MigrationPlanResult): readonly s
if (plan.entries.length === 0) { if (plan.entries.length === 0) {
lines.push("- No installed module migrations."); lines.push("- No installed module migrations.");
} }
lines.push(...retainedLines(plan.retained));
lines.push( lines.push(
"Informational only: this plan is read-only and may become stale; apply revalidates history under the migration lock.", "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[] { export function formatMigrationApplyReport(result: MigrationApplyResult): readonly string[] {
if (result.noOp) { 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 = [ const lines = [
@@ -58,5 +75,6 @@ export function formatMigrationApplyReport(result: MigrationApplyResult): readon
for (const migration of result.applied) { for (const migration of result.applied) {
lines.push(`- ${migration.identity} sha256=${migration.checksum}`); lines.push(`- ${migration.identity} sha256=${migration.checksum}`);
} }
lines.push(...retainedLines(result.retained));
return Object.freeze(lines); 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, resolveModuleEligibility,
} from "../modules/index.js"; } from "../modules/index.js";
import { resolveExecutableDatabaseTransport } from "./database-transport-environment.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. * 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. // configurable here, which is the case this command exists for.
const discovered = await discoverModules({ const discovered = await discoverModules({
moduleSdkVersion, moduleSdkVersion,
modulesDirectory: path.join(repositoryRoot, "modules"), ...resolveCommandModulesDirectory(process.env, repositoryRoot),
platformVersion, platformVersion,
}); });
if (!discovered.some((module) => module.manifest.id === moduleId)) { if (!discovered.some((module) => module.manifest.id === moduleId)) {
+2 -1
View File
@@ -9,6 +9,7 @@ import {
ModulePermissionCompositionError, ModulePermissionCompositionError,
ModuleSchemaDiscoveryError, ModuleSchemaDiscoveryError,
} from "../modules/index.js"; } from "../modules/index.js";
import { resolveCommandModulesDirectory } from "./modules-directory-environment.js";
interface PackageVersion { interface PackageVersion {
version?: unknown; version?: unknown;
@@ -30,7 +31,7 @@ async function main(): Promise<void> {
]); ]);
const schemaAggregate = await discoverModuleSchemas({ const schemaAggregate = await discoverModuleSchemas({
moduleSdkVersion, moduleSdkVersion,
modulesDirectory: path.join(repositoryRoot, "modules"), ...resolveCommandModulesDirectory(process.env, repositoryRoot),
platformVersion, platformVersion,
}); });
const modules = schemaAggregate.discoveredModules; 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;
});
}
+44 -18
View File
@@ -1,4 +1,3 @@
import path from "node:path";
import { import {
capabilityIdSchema, capabilityIdSchema,
EMPTY_MODULE_SECRET_SOURCE, EMPTY_MODULE_SECRET_SOURCE,
@@ -43,6 +42,7 @@ import {
} from "../mail/index.js"; } from "../mail/index.js";
import { import {
createPostgresMigrationDatabase, createPostgresMigrationDatabase,
type DiscoverAllMigrationsOptions,
discoverAllMigrations, discoverAllMigrations,
type MigrationDatabase, type MigrationDatabase,
type MigrationPlanResult, type MigrationPlanResult,
@@ -64,6 +64,7 @@ import {
isConfigurationBlockedDetail, isConfigurationBlockedDetail,
isStorageBlockedDetail, isStorageBlockedDetail,
type ModuleConfigurationSettings, type ModuleConfigurationSettings,
ModuleDiscoveryError,
type ModuleDiscoveryOptions, type ModuleDiscoveryOptions,
type ModuleEligibility, type ModuleEligibility,
type ModuleLifecycleConfiguration, type ModuleLifecycleConfiguration,
@@ -126,6 +127,9 @@ export interface DoctorIdentityConfigurationOptions {
export interface DoctorOptions { export interface DoctorOptions {
readonly clientAddress?: ClientAddressConfigInput | undefined; 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 /** The environment the executable resolved, used only for transport policy evaluation; identity
* configuration keeps its own `identityConfiguration.environment` for the same purpose. */ * configuration keeps its own `identityConfiguration.environment` for the same purpose. */
readonly databaseEnvironment?: DatabaseTransportEnvironment | undefined; readonly databaseEnvironment?: DatabaseTransportEnvironment | undefined;
@@ -170,7 +174,7 @@ export interface DoctorDependencies {
options: PostgresMigrationDatabaseOptions, options: PostgresMigrationDatabaseOptions,
) => MigrationDatabase; ) => MigrationDatabase;
readonly discoverMigrations: ( readonly discoverMigrations: (
options: ModuleDiscoveryOptions, options: DiscoverAllMigrationsOptions,
) => Promise<ModuleMigrationAggregate>; ) => Promise<ModuleMigrationAggregate>;
readonly discoverModules: ( readonly discoverModules: (
options: ModuleDiscoveryOptions, options: ModuleDiscoveryOptions,
@@ -207,15 +211,6 @@ const checksumPattern = /^[a-f0-9]{64}$/u;
const migrationIdentityPattern = const migrationIdentityPattern =
/^[a-z][a-z0-9]*(?:-[a-z0-9]+)*:\d{4,}_[a-z][a-z0-9]*(?:_[a-z0-9]+)*$/u; /^[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 /** 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. */ * behind, and never shares one with the migration planner's, whose lifetime it does not control. */
async function readModuleLifecycleForRuntime( async function readModuleLifecycleForRuntime(
@@ -273,7 +268,7 @@ async function probeMailForRuntime(config: MailProviderConfig): Promise<void> {
const productionDependencies: DoctorDependencies = Object.freeze({ const productionDependencies: DoctorDependencies = Object.freeze({
composePermissions: composePlatformPermissionRegistry, composePermissions: composePlatformPermissionRegistry,
createMigrationDatabase: createPostgresMigrationDatabase, createMigrationDatabase: createPostgresMigrationDatabase,
discoverMigrations: discoverAllMigrationsForRuntime, discoverMigrations: discoverAllMigrations,
discoverModules, discoverModules,
discoverSchemas: discoverModuleSchemas, discoverSchemas: discoverModuleSchemas,
planMigrations: planModuleMigrations, planMigrations: planModuleMigrations,
@@ -633,11 +628,23 @@ function storageBindingsCheck(
details, 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 { function moduleDiscoveryFailure(error: unknown): DoctorCheck {
const details = const details = [moduleDiscoveryFailureDetail(error)];
error instanceof Error && error.name === "ModuleDiscoveryError"
? ["The installed module inventory is invalid."]
: ["Static module discovery could not be completed."];
return freezeCheck("modules.discovery", "failure", "Module discovery failed", details); 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)}`; 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) { if (pendingCount + blockedCount === 0) {
@@ -905,17 +920,25 @@ function databaseTransportCheck(
} }
} }
/** Doctor reads only its own process's environment, so it says so: run as the reference
* deployment's `migrate`, it is given the API's values by the proxy overlay, but run any other way
* it may be checking a configuration the API is not running. */
const CLIENT_ADDRESS_SCOPE =
"Checked: the environment Doctor runs with. In the reference deployment `migrate` is given the API's client-address values; a running API's own value is shown by `docker compose exec api printenv EKKLESIA_CLIENT_ADDRESS_MODE`.";
function clientAddressCheck(input: ClientAddressConfigInput | undefined): DoctorCheck { function clientAddressCheck(input: ClientAddressConfigInput | undefined): DoctorCheck {
try { try {
const config = createClientAddressConfig(input, TRUSTED_CLIENT_ADDRESS_HEADER); const config = createClientAddressConfig(input, TRUSTED_CLIENT_ADDRESS_HEADER);
if (config.mode === "direct") { if (config.mode === "direct") {
return freezeCheck("identity.client-address", "pass", "Direct client-address mode", [ return freezeCheck("identity.client-address", "pass", "Direct client-address mode", [
"Trusts only the actual immediate socket peer for rate limiting; no forwarded header is trusted.", "Trusts only the actual immediate socket peer for rate limiting; no forwarded header is trusted.",
CLIENT_ADDRESS_SCOPE,
]); ]);
} }
return freezeCheck("identity.client-address", "pass", "Trusted-proxy client-address mode", [ return freezeCheck("identity.client-address", "pass", "Trusted-proxy client-address mode", [
`Trusts header "${config.header}" only from a request whose immediate socket peer matches one of ${config.trustedProxies.length} configured trusted ${plural(config.trustedProxies.length, "proxy")}.`, `Trusts header "${config.header}" only from a request whose immediate socket peer matches one of ${config.trustedProxies.length} configured trusted ${plural(config.trustedProxies.length, "proxy")}.`,
"The reverse proxy must overwrite (never append to) this header, and the origin must not be reachable except through it.", "The reverse proxy must overwrite (never append to) this header, and the origin must not be reachable except through it.",
CLIENT_ADDRESS_SCOPE,
]); ]);
} catch (error) { } catch (error) {
const code = const code =
@@ -926,7 +949,7 @@ function clientAddressCheck(input: ClientAddressConfigInput | undefined): Doctor
"identity.client-address", "identity.client-address",
"failure", "failure",
"Client-address configuration invalid", "Client-address configuration invalid",
[`[${code}] Configured client-address values failed validation.`], [`[${code}] Configured client-address values failed validation.`, CLIENT_ADDRESS_SCOPE],
); );
} }
} }
@@ -1124,7 +1147,10 @@ export async function diagnoseEkklesia(
} }
try { try {
migrations = await dependencies.discoverMigrations(options.moduleDiscovery); migrations = await dependencies.discoverMigrations({
coreMigrationsDirectory: options.coreMigrationsDirectory,
moduleDiscovery: options.moduleDiscovery,
});
const inventory = migrations.migrations.map((migration) => const inventory = migrations.migrations.map((migration) =>
safeMigrationReference(migration.identity, migration.checksum), 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 /** The deployment's module secrets, resolvable only under names Core derives from a module's own
* declared keys — there is no arbitrary variable lookup. */ * declared keys — there is no arbitrary variable lookup. */
readonly moduleSecrets: ModuleSecretSource; 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; readonly storage: StorageProviderConfigInput;
} }
@@ -144,6 +148,7 @@ export function loadHostConfiguration(environment: HostEnvironment): HostConfigu
username: environment.EKKLESIA_SMTP_USERNAME, username: environment.EKKLESIA_SMTP_USERNAME,
}, },
moduleSecrets: createEnvironmentModuleSecretSource(environment), moduleSecrets: createEnvironmentModuleSecretSource(environment),
modulesDirectory: environment.EKKLESIA_MODULES_DIR,
// The `S3_*` names ADR 0004 already established for this repository's Compose stack. Absent // 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 // endpoint means this installation has no object storage, which is a complete deployment
// (ADR 0030). // (ADR 0030).
+70 -8
View File
@@ -2,6 +2,7 @@ import type {
DiscoveredModuleMigration, DiscoveredModuleMigration,
ModuleMigrationAggregate, ModuleMigrationAggregate,
} from "../modules/migration-discovery.js"; } from "../modules/migration-discovery.js";
import { CORE_MIGRATION_SOURCE_ID } from "./core-source.js";
const DEFAULT_MIGRATION_LOCK_TIMEOUT_MS = 10_000; const DEFAULT_MIGRATION_LOCK_TIMEOUT_MS = 10_000;
@@ -86,11 +87,24 @@ export interface MigrationPlanEntry {
readonly tag: string; 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 { export interface MigrationPlanResult {
readonly appliedCount: number; readonly appliedCount: number;
readonly approvalRequiredCount: number; readonly approvalRequiredCount: number;
readonly entries: readonly MigrationPlanEntry[]; readonly entries: readonly MigrationPlanEntry[];
readonly pendingCount: number; 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 { export interface AppliedMigrationResult {
@@ -105,6 +119,8 @@ export interface MigrationApplyResult {
readonly applied: readonly AppliedMigrationResult[]; readonly applied: readonly AppliedMigrationResult[];
readonly appliedCount: number; readonly appliedCount: number;
readonly noOp: boolean; readonly noOp: boolean;
/** The same report as `MigrationPlanResult.retained`, read under the migration lock. */
readonly retained: readonly RetainedMigrationHistory[];
} }
export interface PlanModuleMigrationsOptions { 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( function validateAppliedHistoryUnchecked(
aggregate: ModuleMigrationAggregate, aggregate: ModuleMigrationAggregate,
history: readonly AppliedMigrationRecord[], history: readonly AppliedMigrationRecord[],
): ReadonlySet<string> { ): ValidatedAppliedHistory {
if (!Array.isArray(history)) { if (!Array.isArray(history)) {
throw runnerError("MIGRATION_HISTORY_INVALID"); throw runnerError("MIGRATION_HISTORY_INVALID");
} }
@@ -215,6 +259,9 @@ function validateAppliedHistoryUnchecked(
const inventory = new Map( const inventory = new Map(
aggregate.migrations.map((migration) => [migration.identity, migration]), 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 seenOrders = new Set<number>();
const seenIdentities = new Set<string>(); const seenIdentities = new Set<string>();
const seenModuleIndexes = new Set<string>(); const seenModuleIndexes = new Set<string>();
@@ -240,8 +287,20 @@ function validateAppliedHistoryUnchecked(
const migration = knownMigration(inventory, record.identity); const migration = knownMigration(inventory, record.identity);
if (!migration) { if (!migration) {
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"); 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 ( if (
record.moduleId !== migration.moduleId || record.moduleId !== migration.moduleId ||
record.tag !== migration.tag || record.tag !== migration.tag ||
@@ -252,11 +311,12 @@ function validateAppliedHistoryUnchecked(
if (record.checksum !== migration.checksum) { if (record.checksum !== migration.checksum) {
throw runnerError("MIGRATION_CHECKSUM_MISMATCH", migration.identity); throw runnerError("MIGRATION_CHECKSUM_MISMATCH", migration.identity);
} }
applied.add(record.identity);
} }
for (const module of aggregate.modules) { for (const module of aggregate.modules) {
const applied = history.filter((record) => record.moduleId === module.moduleId); const moduleHistory = history.filter((record) => record.moduleId === module.moduleId);
for (const [position, record] of applied.entries()) { for (const [position, record] of moduleHistory.entries()) {
if (record.identity !== module.migrations[position]?.identity) { if (record.identity !== module.migrations[position]?.identity) {
const migration = knownMigration(inventory, record.identity); const migration = knownMigration(inventory, record.identity);
throw runnerError("MIGRATION_HISTORY_INVALID", migration?.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( function validateAppliedHistory(
aggregate: ModuleMigrationAggregate, aggregate: ModuleMigrationAggregate,
history: readonly AppliedMigrationRecord[], history: readonly AppliedMigrationRecord[],
): ReadonlySet<string> { ): ValidatedAppliedHistory {
try { try {
return validateAppliedHistoryUnchecked(aggregate, history); return validateAppliedHistoryUnchecked(aggregate, history);
} catch (error) { } catch (error) {
@@ -285,7 +345,7 @@ function planFromHistory(
aggregate: ModuleMigrationAggregate, aggregate: ModuleMigrationAggregate,
history: readonly AppliedMigrationRecord[], history: readonly AppliedMigrationRecord[],
): MigrationPlanResult { ): MigrationPlanResult {
const applied = validateAppliedHistory(aggregate, history); const { applied, retained } = validateAppliedHistory(aggregate, history);
const entries = Object.freeze( const entries = Object.freeze(
aggregate.migrations.map((migration): MigrationPlanEntry => { aggregate.migrations.map((migration): MigrationPlanEntry => {
const isApplied = applied.has(migration.identity); const isApplied = applied.has(migration.identity);
@@ -314,6 +374,7 @@ function planFromHistory(
approvalRequiredCount, approvalRequiredCount,
entries, entries,
pendingCount: entries.length - appliedCount, pendingCount: entries.length - appliedCount,
retained,
}); });
} }
@@ -472,14 +533,14 @@ export async function applyModuleMigrations(
} catch { } catch {
throw runnerError("MIGRATION_HISTORY_READ_FAILED"); throw runnerError("MIGRATION_HISTORY_READ_FAILED");
} }
const applied = validateAppliedHistory(options.aggregate, history); const { applied, retained } = validateAppliedHistory(options.aggregate, history);
const pending = Object.freeze( const pending = Object.freeze(
options.aggregate.migrations.filter((migration) => !applied.has(migration.identity)), options.aggregate.migrations.filter((migration) => !applied.has(migration.identity)),
); );
await preflightApprovals(pending, options.approvals ?? denyAllMigrationApprovals); await preflightApprovals(pending, options.approvals ?? denyAllMigrationApprovals);
if (pending.length === 0) { 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; let executingIdentity: string | undefined;
@@ -508,6 +569,7 @@ export async function applyModuleMigrations(
applied: resultMigrations, applied: resultMigrations,
appliedCount: resultMigrations.length, appliedCount: resultMigrations.length,
noOp: false, noOp: false,
retained,
}); });
} catch (error) { } catch (error) {
primaryError = error; primaryError = error;
+35 -1
View File
@@ -47,6 +47,7 @@ export type ModuleDiscoveryIssueCode =
| "MODULE_PLATFORM_INCOMPATIBLE" | "MODULE_PLATFORM_INCOMPATIBLE"
| "MODULE_SDK_INCOMPATIBLE" | "MODULE_SDK_INCOMPATIBLE"
| "MODULE_VERSION_MISMATCH" | "MODULE_VERSION_MISMATCH"
| "MODULES_DIRECTORY_INVALID"
| "MODULES_DIRECTORY_UNREADABLE"; | "MODULES_DIRECTORY_UNREADABLE";
export interface ModuleDiscoveryIssue { export interface ModuleDiscoveryIssue {
@@ -117,9 +118,35 @@ export interface DiscoveredModule {
export interface ModuleDiscoveryOptions { export interface ModuleDiscoveryOptions {
readonly moduleSdkVersion: string; readonly moduleSdkVersion: string;
readonly modulesDirectory: 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; 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 { interface ModulePackageData {
name: string; name: string;
version: 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) { if (issues.length > 0) {
throw new ModuleDiscoveryError(issues); throw new ModuleDiscoveryError(issues);
} }
@@ -703,7 +737,7 @@ export async function discoverModules(
try { try {
directoryEntries = await readdir(options.modulesDirectory, { withFileTypes: true }); directoryEntries = await readdir(options.modulesDirectory, { withFileTypes: true });
} catch (error) { } catch (error) {
if (errorCode(error) === "ENOENT") { if (errorCode(error) === "ENOENT" && !options.modulesDirectoryRequired) {
return Object.freeze([]); return Object.freeze([]);
} }
throw new ModuleDiscoveryError([ throw new ModuleDiscoveryError([
+2
View File
@@ -7,6 +7,8 @@ export * from "./lifecycle.js";
export * from "./lifecycle-schema.js"; export * from "./lifecycle-schema.js";
export * from "./lifecycle-store.js"; export * from "./lifecycle-store.js";
export * from "./migration-discovery.js"; export * from "./migration-discovery.js";
export * from "./modules-directory.js";
export * from "./module-package-verification.js";
export * from "./permissions.js"; export * from "./permissions.js";
export * from "./realtime.js"; export * from "./realtime.js";
export * from "./runtime.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 });
}
+89
View File
@@ -0,0 +1,89 @@
// 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 } from "@ekklesia/contracts";
interface ProbeRequest {
readonly moduleId: string;
readonly version: string;
readonly entrypoints: Readonly<Record<string, string>>;
}
interface ProbeFailure {
readonly role: string;
readonly reason: "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 });
}
} else if (role === "schema") {
const definition = 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,479 @@
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_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_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");
}
// Transitional (ADR 0049): today a module's Drizzle copy must be interchangeable with the
// platform's, because Core queries the module's tables with its own Drizzle instance. This is an
// implementation constraint pending a Module Database ABI, not that ABI.
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(
moduleId: string,
version: string,
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({ entrypoints, moduleId, 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(
metadata.module.id,
metadata.module.version,
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,
});
}
+36 -5
View File
@@ -8,10 +8,11 @@ import {
diagnoseEkklesia, diagnoseEkklesia,
formatDoctorReport, formatDoctorReport,
} from "../src/diagnostics/index.js"; } from "../src/diagnostics/index.js";
import type { import {
DiscoveredModule, type DiscoveredModule,
ModuleMigrationAggregate, discoverModules,
ModuleSchemaAggregate, type ModuleMigrationAggregate,
type ModuleSchemaAggregate,
} from "../src/modules/index.js"; } from "../src/modules/index.js";
/** /**
@@ -34,8 +35,8 @@ const moduleSecret = "module-secret-material";
const metadata = Object.freeze({ const metadata = Object.freeze({
moduleSdkVersion: "0.1.0", moduleSdkVersion: "0.1.0",
modulesDirectory: "/not-rendered/modules",
platformVersion: "0.1.0", platformVersion: "0.1.0",
repositoryRoot: "/not-rendered",
runtimeRange: ">=24.11.0 <25", runtimeRange: ">=24.11.0 <25",
runtimeVersion: "24.15.0", runtimeVersion: "24.15.0",
}); });
@@ -454,3 +455,33 @@ describe("Doctor host-configuration parity — sanitization", () => {
expect(JSON.stringify(report)).not.toContain("S3_ACCESS_KEY"); 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");
});
});
+5
View File
@@ -68,6 +68,7 @@ describe("Ekklesia Doctor terminal report", () => {
it("renders the real zero-module and no-database shape exactly", async () => { it("renders the real zero-module and no-database shape exactly", async () => {
const report = await diagnoseEkklesia( const report = await diagnoseEkklesia(
{ {
coreMigrationsDirectory: "/not-rendered/core/drizzle",
moduleDiscovery: { moduleDiscovery: {
moduleSdkVersion: "0.1.0", moduleSdkVersion: "0.1.0",
modulesDirectory: "/not-rendered/modules", modulesDirectory: "/not-rendered/modules",
@@ -117,6 +118,7 @@ Identity
Identity runtime configuration (signing key, base URL, trusted origins) is not set. Identity runtime configuration (signing key, base URL, trusted origins) is not set.
✓ Direct client-address mode ✓ Direct client-address mode
Trusts only the actual immediate socket peer for rate limiting; no forwarded header is trusted. Trusts only the actual immediate socket peer for rate limiting; no forwarded header is trusted.
Checked: the environment Doctor runs with. In the reference deployment \`migrate\` is given the API's client-address values; a running API's own value is shown by \`docker compose exec api printenv EKKLESIA_CLIENT_ADDRESS_MODE\`.
Object storage Object storage
○ Object storage not configured ○ Object storage not configured
@@ -134,6 +136,7 @@ Result: READY WITH WARNINGS`);
it("renders every check the report contains, so none can silently affect the result", async () => { it("renders every check the report contains, so none can silently affect the result", async () => {
const report = await diagnoseEkklesia( const report = await diagnoseEkklesia(
{ {
coreMigrationsDirectory: "/not-rendered/core/drizzle",
moduleDiscovery: { moduleDiscovery: {
moduleSdkVersion: "0.1.0", moduleSdkVersion: "0.1.0",
modulesDirectory: "/not-rendered/modules", modulesDirectory: "/not-rendered/modules",
@@ -158,6 +161,7 @@ Result: READY WITH WARNINGS`);
it("does not depend on NO_COLOR or terminal interactivity", async () => { it("does not depend on NO_COLOR or terminal interactivity", async () => {
const report = await diagnoseEkklesia( const report = await diagnoseEkklesia(
{ {
coreMigrationsDirectory: "/not-rendered/core/drizzle",
moduleDiscovery: { moduleDiscovery: {
moduleSdkVersion: "0.1.0", moduleSdkVersion: "0.1.0",
modulesDirectory: "/not-rendered/modules", modulesDirectory: "/not-rendered/modules",
@@ -206,6 +210,7 @@ Result: READY WITH WARNINGS`);
const report = await diagnoseEkklesia( const report = await diagnoseEkklesia(
{ {
databaseUrl: "postgresql://another:[email protected]/db", databaseUrl: "postgresql://another:[email protected]/db",
coreMigrationsDirectory: "/not-rendered/core/drizzle",
moduleDiscovery: { moduleDiscovery: {
moduleSdkVersion: "0.1.0", moduleSdkVersion: "0.1.0",
modulesDirectory: "/not-rendered/modules", modulesDirectory: "/not-rendered/modules",
+136 -2
View File
@@ -1,5 +1,9 @@
import { type ModuleManifest, parsePermissionId } from "@ekklesia/contracts"; import { type ModuleManifest, parsePermissionId } from "@ekklesia/contracts";
import { describe, expect, it, vi } from "vitest"; import { mkdtemp, 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, vi } from "vitest";
import { createPermissionRegistry, PermissionRegistryError } from "../src/authorization/index.js"; import { createPermissionRegistry, PermissionRegistryError } from "../src/authorization/index.js";
import { import {
type DoctorCheck, type DoctorCheck,
@@ -114,7 +118,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 appliedCount = entries.filter((entry) => entry.status === "applied").length;
const approvalRequiredCount = entries.filter( const approvalRequiredCount = entries.filter(
(entry) => entry.status === "approval-blocked", (entry) => entry.status === "approval-blocked",
@@ -124,6 +131,7 @@ function plan(entries: MigrationPlanResult["entries"] = []): MigrationPlanResult
approvalRequiredCount, approvalRequiredCount,
entries: Object.freeze([...entries]), entries: Object.freeze([...entries]),
pendingCount: entries.length - appliedCount, pendingCount: entries.length - appliedCount,
retained: Object.freeze([...retained]),
}); });
} }
@@ -151,6 +159,7 @@ function dependencies(overrides: Partial<DoctorDependencies> = {}): DoctorDepend
function options(databaseUrl?: string): DoctorOptions { function options(databaseUrl?: string): DoctorOptions {
return { return {
...(databaseUrl === undefined ? {} : { databaseUrl }), ...(databaseUrl === undefined ? {} : { databaseUrl }),
coreMigrationsDirectory: "/not-rendered/core/drizzle",
moduleDiscovery: { moduleDiscovery: {
moduleSdkVersion: "0.1.0", moduleSdkVersion: "0.1.0",
modulesDirectory: "/not-rendered/modules", modulesDirectory: "/not-rendered/modules",
@@ -958,6 +967,21 @@ describe("Ekklesia Doctor diagnostics", () => {
expect(report.result).toBe("ready-with-warnings"); expect(report.result).toBe("ready-with-warnings");
}); });
it("says whose client-address configuration it checked: this process's, not a running API's", async () => {
for (const clientAddress of [
undefined,
{ header: "x-forwarded-for", mode: "trusted-proxy" as const, trustedProxies: ["10.0.0.1"] },
{ header: "x-forwarded-for", mode: "trusted-proxy" as const, trustedProxies: ["bad"] },
]) {
const report = await diagnoseEkklesia(
{ ...options(), ...(clientAddress ? { clientAddress } : {}) },
dependencies(),
);
const details = check(report, "identity.client-address").details;
expect(details.some((detail) => detail.includes("environment Doctor runs with"))).toBe(true);
}
});
it("passes client-address configuration with an explicitly configured trusted-proxy mode", async () => { it("passes client-address configuration with an explicitly configured trusted-proxy mode", async () => {
const report = await diagnoseEkklesia( const report = await diagnoseEkklesia(
{ {
@@ -1642,3 +1666,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,
});
});
});
@@ -68,6 +68,7 @@ const INTERPRETED_VARIABLES = Object.freeze([
"EKKLESIA_IDENTITY_BASE_URL", "EKKLESIA_IDENTITY_BASE_URL",
"EKKLESIA_IDENTITY_SECRET", "EKKLESIA_IDENTITY_SECRET",
"EKKLESIA_IDENTITY_TRUSTED_ORIGINS", "EKKLESIA_IDENTITY_TRUSTED_ORIGINS",
"EKKLESIA_MODULES_DIR",
"EKKLESIA_PUBLIC_SIGNUP_ENABLED", "EKKLESIA_PUBLIC_SIGNUP_ENABLED",
"EKKLESIA_SMTP_FROM_ADDRESS", "EKKLESIA_SMTP_FROM_ADDRESS",
"EKKLESIA_SMTP_FROM_NAME", "EKKLESIA_SMTP_FROM_NAME",
+13
View File
@@ -48,6 +48,19 @@ describe("host configuration loading", () => {
expect(host.identity.secret).toBeUndefined(); 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", () => { it("carries every object-storage field the storage passes established", () => {
const host = loadHostConfiguration(productionEnvironment); const host = loadHostConfiguration(productionEnvironment);
+67 -1
View File
@@ -40,6 +40,7 @@ describe("migration command reports", () => {
}), }),
]), ]),
pendingCount: 2, pendingCount: 2,
retained: Object.freeze([]),
}); });
expect(formatMigrationPlanReport(plan)).toEqual([ expect(formatMigrationPlanReport(plan)).toEqual([
@@ -64,6 +65,7 @@ describe("migration command reports", () => {
]), ]),
appliedCount: 1, appliedCount: 1,
noOp: false, noOp: false,
retained: Object.freeze([]),
}); });
expect(formatMigrationApplyReport(applied)).toEqual([ expect(formatMigrationApplyReport(applied)).toEqual([
@@ -72,7 +74,12 @@ describe("migration command reports", () => {
]); ]);
expect( expect(
formatMigrationApplyReport( 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)."]); ).toEqual(["Migration apply completed: database is already current (no changes)."]);
}); });
@@ -85,6 +92,7 @@ describe("migration command reports", () => {
approvalRequiredCount: 0, approvalRequiredCount: 0,
entries: Object.freeze([]), entries: Object.freeze([]),
pendingCount: 0, pendingCount: 0,
retained: Object.freeze([]),
}), }),
), ),
).toEqual([ ).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.", "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,
]);
});
}); });
+7 -159
View File
@@ -1,5 +1,5 @@
import { createHash } from "node:crypto"; 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 { tmpdir } from "node:os";
import path from "node:path"; import path from "node:path";
import { afterEach, describe, expect, it } from "vitest"; import { afterEach, describe, expect, it } from "vitest";
@@ -10,34 +10,15 @@ import {
ModuleMigrationDiscoveryError, ModuleMigrationDiscoveryError,
type ModuleMigrationDiscoveryIssueCode, type ModuleMigrationDiscoveryIssueCode,
} from "../src/modules/index.js"; } 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[] = []; 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 () => { afterEach(async () => {
await Promise.all( await Promise.all(
temporaryDirectories temporaryDirectories
@@ -52,139 +33,6 @@ async function createModulesDirectory(): Promise<string> {
return directory; 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) { function discoveryOptions(modulesDirectory: string) {
return { return {
moduleSdkVersion: "0.1.0", moduleSdkVersion: "0.1.0",
+254 -3
View File
@@ -13,6 +13,7 @@ import {
type MigrationTransaction, type MigrationTransaction,
planModuleMigrations, planModuleMigrations,
} from "../src/migrations/runner.js"; } from "../src/migrations/runner.js";
import type { DiscoveredModule } from "../src/modules/discovery.js";
import type { import type {
DiscoveredModuleMigration, DiscoveredModuleMigration,
ModuleMigrationAggregate, ModuleMigrationAggregate,
@@ -323,6 +324,7 @@ describe("module migration planning", () => {
}, },
], ],
pendingCount: 1, pendingCount: 1,
retained: [],
}); });
expect(database.events).toEqual(["session:open", "history:read", "session:close"]); expect(database.events).toEqual(["session:open", "history:read", "session:close"]);
expect(database.historyTableExists).toBe(false); expect(database.historyTableExists).toBe(false);
@@ -344,10 +346,11 @@ describe("module migration planning", () => {
approvalRequiredCount: 0, approvalRequiredCount: 0,
entries: [], entries: [],
pendingCount: 0, pendingCount: 0,
retained: [],
}); });
expect(Object.isFrozen(result)).toBe(true); expect(Object.isFrozen(result)).toBe(true);
expect(Object.isFrozen(result.entries)).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.historyTableExists).toBe(false);
expect(database.events).not.toContain("transaction:begin"); expect(database.events).not.toContain("transaction:begin");
}); });
@@ -548,6 +551,7 @@ describe("module migration apply", () => {
], ],
appliedCount: 1, appliedCount: 1,
noOp: false, noOp: false,
retained: [],
}); });
}); });
@@ -580,7 +584,7 @@ describe("module migration apply", () => {
database, 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)).toBe(true);
expect(Object.isFrozen(result.applied)).toBe(true); expect(Object.isFrozen(result.applied)).toBe(true);
expectNoPersistentMutation(database); expectNoPersistentMutation(database);
@@ -601,7 +605,7 @@ describe("module migration apply", () => {
database, 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.historyTableExists).toBe(false);
expect(database.events).toEqual([ expect(database.events).toEqual([
"session:open", "session:open",
@@ -980,3 +984,250 @@ describe("module migration failures and side effects", () => {
expect(database.committedHistory).toEqual([]); 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,387 @@
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 {
type ModulePackageVerificationErrorCode,
ModulePackageVerificationError,
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] };
}
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("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",
});
});
});
@@ -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 { createHash, randomBytes } from "node:crypto";
import { cp, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import net from "node:net"; import net from "node:net";
import { tmpdir } from "node:os";
import path from "node:path"; import path from "node:path";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import { Client, type QueryResult, type QueryResultRow } from "pg"; import { Client, type QueryResult, type QueryResultRow } from "pg";
@@ -8,6 +10,7 @@ import { reportMigrationCommandError } from "../src/cli/migration-command-errors
import { import {
applyModuleMigrations, applyModuleMigrations,
createPostgresMigrationDatabase, createPostgresMigrationDatabase,
discoverAllMigrations,
discoverCoreMigrations, discoverCoreMigrations,
type MigrationApproval, type MigrationApproval,
type MigrationApprovalProvider, type MigrationApprovalProvider,
@@ -764,7 +767,7 @@ describe.sequential("live PostgreSQL module migration execution", () => {
database: migrationDatabase, 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); expect(await databaseState(database.client)).toEqual(before);
const afterData = await safeQuery<QueryResultRow>( const afterData = await safeQuery<QueryResultRow>(
database.client, 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) => { await withIsolatedDatabase("unknown_history", async (database) => {
const initial = peopleInitial(); const initial = peopleInitial();
const later = peopleLater(); const later = peopleLater();
@@ -849,7 +893,7 @@ describe.sequential("live PostgreSQL module migration execution", () => {
(identity, module_id, migration_tag, migration_index, checksum) (identity, module_id, migration_tag, migration_index, checksum)
VALUES ($1, $2, $3, $4, $5) 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); 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 columnExists(database.client, "people_records", "email")).toBe(false);
expect((await readHistory(database.client)).map((row) => row.identity)).toEqual([ expect((await readHistory(database.client)).map((row) => row.identity)).toEqual([
initial.identity, initial.identity,
"ghost:0000_unknown", "people:0099_unknown",
]); ]);
}); });
}); });
@@ -1358,7 +1402,7 @@ describe.sequential("live PostgreSQL Core identity migration execution", () => {
database: migrationDatabase, 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); 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: 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" }),
},
};
}
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,205 @@
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 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, {
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,
});
});
});
+41 -2
View File
@@ -70,18 +70,57 @@
# Browsers fetch signed object URLs here (S3_DELIVERY_ENDPOINT, ADR 0031). # Browsers fetch signed object URLs here (S3_DELIVERY_ENDPOINT, ADR 0031).
{$EKKLESIA_STORAGE_HOSTNAME} { {$EKKLESIA_STORAGE_HOSTNAME} {
# Delivery-layer CORS belongs to the deployment, not to the object store: this block decides it,
# so it stays the same with any S3 backend behind it. Only the origins whose pages read objects
# with fetch() get CORS: the Ekklesia origin, which serves the Dashboard and the displays. An <img> needs none. Every object
# URL is still a signed, expiring capability; CORS only decides which pages may read the bytes.
@cors_origin header Origin https://{$EKKLESIA_HOSTNAME}
# A preflight is answered here and never reaches storage. Only GET and HEAD are ever allowed, and
# no request header beyond the CORS-safelisted ones.
@preflight method OPTIONS
handle @preflight {
header @cors_origin {
Access-Control-Allow-Origin {http.request.header.Origin}
Access-Control-Allow-Methods "GET, HEAD"
Access-Control-Max-Age 600
}
header Vary Origin
respond 204
}
handle {
# Set when the response is written, after the upstream's own CORS headers were removed below.
header @cors_origin >Access-Control-Allow-Origin {http.request.header.Origin}
header >Vary Origin
reverse_proxy https://object-storage:8443 { reverse_proxy https://object-storage:8443 {
# An S3 signature covers the Host header: pass the original through unchanged, or every signed # An S3 signature covers the Host header: pass the original through unchanged, or every
# URL fails verification. # signed URL fails verification.
header_up Host {host} header_up Host {host}
header_up -CF-Connecting-IP header_up -CF-Connecting-IP
header_up -X-Real-IP header_up -X-Real-IP
header_up -Forwarded header_up -Forwarded
header_up -True-Client-IP header_up -True-Client-IP
# The object store's own CORS answer (SeaweedFS reflects any origin, with every method)
# is not the deployment's policy.
header_down -Access-Control-Allow-Origin
header_down -Access-Control-Allow-Methods
header_down -Access-Control-Allow-Headers
header_down -Access-Control-Expose-Headers
header_down -Access-Control-Allow-Credentials
header_down -Access-Control-Max-Age
header_down -Vary
transport http { transport http {
# object-storage presents a certificate from the installation-private CA. # object-storage presents a certificate from the installation-private CA.
tls_trust_pool file /etc/caddy/ekklesia-ca.crt tls_trust_pool file /etc/caddy/ekklesia-ca.crt
tls_server_name object-storage tls_server_name object-storage
# HTTP/1.1 only. Over HTTP/2, SeaweedFS answers a conditional request with a 304 that
# declares a Content-Length it never sends; Caddy then aborts the browser's stream,
# which a browser reports as ERR_HTTP2_PROTOCOL_ERROR on every revalidation. Its
# HTTP/1.1 304 is clean. Browsers still reach this proxy over HTTP/2 and HTTP/3.
versions 1.1
}
} }
} }
} }
+45 -2
View File
@@ -92,18 +92,61 @@
# Browsers fetch signed object URLs here (S3_DELIVERY_ENDPOINT, ADR 0031). # Browsers fetch signed object URLs here (S3_DELIVERY_ENDPOINT, ADR 0031).
{$EKKLESIA_STORAGE_HOSTNAME} { {$EKKLESIA_STORAGE_HOSTNAME} {
# Delivery-layer CORS belongs to the deployment, not to the object store: this block decides it,
# so it stays the same with any S3 backend behind it. Only the origins whose pages read objects
# with fetch() get CORS: the API origin, where displays run, and the Dashboard origin,
# where a module's preview may cache images too. An <img> needs none. Every object
# URL is still a signed, expiring capability; CORS only decides which pages may read the bytes.
@cors_origin {
header Origin https://{$EKKLESIA_HOSTNAME}
header Origin https://{$EKKLESIA_DASHBOARD_HOSTNAME}
}
# A preflight is answered here and never reaches storage. Only GET and HEAD are ever allowed, and
# no request header beyond the CORS-safelisted ones.
@preflight method OPTIONS
handle @preflight {
header @cors_origin {
Access-Control-Allow-Origin {http.request.header.Origin}
Access-Control-Allow-Methods "GET, HEAD"
Access-Control-Max-Age 600
}
header Vary Origin
respond 204
}
handle {
# Set when the response is written, after the upstream's own CORS headers were removed below.
header @cors_origin >Access-Control-Allow-Origin {http.request.header.Origin}
header >Vary Origin
reverse_proxy https://object-storage:8443 { reverse_proxy https://object-storage:8443 {
# An S3 signature covers the Host header: pass the original through unchanged, or every signed # An S3 signature covers the Host header: pass the original through unchanged, or every
# URL fails verification. # signed URL fails verification.
header_up Host {host} header_up Host {host}
header_up -CF-Connecting-IP header_up -CF-Connecting-IP
header_up -X-Real-IP header_up -X-Real-IP
header_up -Forwarded header_up -Forwarded
header_up -True-Client-IP header_up -True-Client-IP
# The object store's own CORS answer (SeaweedFS reflects any origin, with every method)
# is not the deployment's policy.
header_down -Access-Control-Allow-Origin
header_down -Access-Control-Allow-Methods
header_down -Access-Control-Allow-Headers
header_down -Access-Control-Expose-Headers
header_down -Access-Control-Allow-Credentials
header_down -Access-Control-Max-Age
header_down -Vary
transport http { transport http {
# object-storage presents a certificate from the installation-private CA. # object-storage presents a certificate from the installation-private CA.
tls_trust_pool file /etc/caddy/ekklesia-ca.crt tls_trust_pool file /etc/caddy/ekklesia-ca.crt
tls_server_name object-storage tls_server_name object-storage
# HTTP/1.1 only. Over HTTP/2, SeaweedFS answers a conditional request with a 304 that
# declares a Content-Length it never sends; Caddy then aborts the browser's stream,
# which a browser reports as ERR_HTTP2_PROTOCOL_ERROR on every revalidation. Its
# HTTP/1.1 304 is clean. Browsers still reach this proxy over HTTP/2 and HTTP/3.
versions 1.1
}
} }
} }
} }
+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 # Ekklesia's one generic platform image (ADR 0044, ADR 0048): the `api` service and every operator-run
# build. See deploy/README.md for how to build and run it. # 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 # The build context is this repository. The image contains **zero feature modules**: installed
# `modules/<id>`, and the `pnpm-lock.yaml` that describes exactly that set. This file clones nothing # modules live in installation-owned storage, mounted read-only at /var/lib/ekklesia/modules
# and chooses no modules. With an empty or absent `modules/`, it builds the platform alone. # (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. # No `# syntax=` directive, deliberately: it would make BuildKit pull an unpinned frontend image.
# The built-in frontend supports everything below, including cache mounts. # The built-in frontend supports everything below, including cache mounts.
@@ -28,9 +29,10 @@ COPY package.json ./
RUN corepack enable && corepack install RUN corepack enable && corepack install
# --------------------------------------------------------------------------------------------------- # ---------------------------------------------------------------------------------------------------
# manifests: the lockfile, workspace configuration, and every workspace package.json, at their # manifests: the lockfile, workspace configuration, and the package.json of every platform package
# repository paths. Dependency installation keys on these alone, so a source-only change reuses the # the runtime needs, at their repository paths. Dependency installation keys on these alone, so a
# installed layers. # 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 FROM base AS manifests
WORKDIR /context WORKDIR /context
@@ -38,9 +40,8 @@ COPY . .
RUN set -eu; \ RUN set -eu; \
mkdir /manifests; \ mkdir /manifests; \
cp package.json pnpm-lock.yaml pnpm-workspace.yaml .npmrc /manifests/; \ cp package.json pnpm-lock.yaml pnpm-workspace.yaml .npmrc /manifests/; \
for manifest in apps/*/package.json builders/*/package.json core/package.json \ for manifest in apps/*/package.json core/package.json packages/*/package.json \
modules/*/package.json packages/*/package.json surfaces/*/package.json \ surfaces/*/package.json; do \
templates/*/package.json; do \
if [ -f "$manifest" ]; then install -D -m 0644 "$manifest" "/manifests/$manifest"; fi; \ if [ -f "$manifest" ]; then install -D -m 0644 "$manifest" "/manifests/$manifest"; fi; \
done 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 # 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. # already exists. The store cache mount keeps a rebuild off the network all the same.
COPY . . 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 \ RUN --mount=type=cache,id=ekklesia-pnpm-store,target=/pnpm/store \
pnpm install --frozen-lockfile --store-dir /pnpm/store pnpm install --frozen-lockfile --store-dir /pnpm/store
# The same gates `pnpm verify` runs for boundaries, modules, and migrations, against exactly the # The same gates `pnpm verify` runs for boundaries, modules, and migrations. With no modules in the
# module set in this context. `pnpm boundaries` comes first and reads only sources: among its rules, # context, module validation covers the empty set and migration validation covers Core's own. Each
# 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
# validate script builds first; the final build is a Turbo cache hit that keeps the intent explicit. # 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 RUN pnpm boundaries && pnpm modules:validate && pnpm migrations:validate && pnpm build
# What the runtime resolves at startup, at the paths it resolves them from: # 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/`; # the Dashboard and display host assets under `surfaces/shell/dist/browser/`;
# - Core's migrations, `core/drizzle/` with its `meta/` journal; # - Core's migrations, `core/drizzle/` with its `meta/` journal.
# - each module directory whole, minus `node_modules`, because each manifest names its own # Never a module and never the module template. `node_modules` comes from the production-only
# entrypoints, UI artifacts, and migrations directory. # install below, never from this stage.
# `node_modules` comes from the production-only install below, never from this stage.
RUN set -eu; \ RUN set -eu; \
mkdir -p /runtime/core /runtime/modules; \ mkdir -p /runtime/core; \
for package in apps/* core packages/* surfaces/*; do \ for package in apps/* core packages/* surfaces/*; do \
if [ -d "$package/dist" ]; then \ if [ -d "$package/dist" ]; then \
mkdir -p "/runtime/$package"; \ mkdir -p "/runtime/$package"; \
cp -a "$package/dist" "/runtime/$package/dist"; \ cp -a "$package/dist" "/runtime/$package/dist"; \
fi; \ fi; \
done; \ done; \
cp -a core/drizzle /runtime/core/drizzle; \ cp -a core/drizzle /runtime/core/drizzle
if [ -d modules ]; then \
tar -C modules --exclude='node_modules' -cf - . | tar -C /runtime/modules -xf -; \
fi
# --------------------------------------------------------------------------------------------------- # ---------------------------------------------------------------------------------------------------
# prod-deps: the same lockfile, production dependencies only. No compiler, bundler, or test runner # prod-deps: the same lockfile, production dependencies only, for the platform packages alone. No
# reaches the runtime image. # compiler, bundler, or test runner reaches the runtime image, and neither does anything only the
# module template depends on.
# --------------------------------------------------------------------------------------------------- # ---------------------------------------------------------------------------------------------------
FROM toolchain AS prod-deps FROM toolchain AS prod-deps
COPY --from=manifests /manifests/ ./ 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. # the API still binds 127.0.0.1. `production` is stated rather than relied on as the default.
ENV NODE_ENV=production \ ENV NODE_ENV=production \
EKKLESIA_API_HOST=0.0.0.0 \ 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 # 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 WORKDIR /opt/ekklesia
COPY --from=prod-deps /opt/ekklesia/ ./ COPY --from=prod-deps /opt/ekklesia/ ./
COPY --from=build /runtime/ ./ 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. # Files stay root-owned: the unprivileged runtime user can read the installation, never modify it.
USER node USER node
+215 -53
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/object-storage.env` | The S3 key pair | `object-storage` |
| `/etc/ekklesia/tunnel.env` | `TUNNEL_TOKEN` | `tunnel` | | `/etc/ekklesia/tunnel.env` | `TUNNEL_TOKEN` | `tunnel` |
| `/etc/ekklesia/tls/` | The CA and server certificates and keys | mounted file by file | | `/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 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` 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_IDENTITY_BASE_URL` and `EKKLESIA_IDENTITY_TRUSTED_ORIGINS` are `https://` and your
Ekklesia hostname. `S3_DELIVERY_ENDPOINT` is `https://` and your storage hostname. 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 `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). 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 ```sh
docker compose -f compose.yaml -f compose.proxy.yaml up -d --wait postgres object-storage 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 Nothing ever applies migrations automatically: the API never does, and `migrate` runs only when
you run it. you run it.
7. **Start everything:** `docker compose -f compose.yaml -f compose.proxy.yaml up -d --wait`. 8. **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 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 `docker compose run --rm migrate node core/dist/cli/doctor.js` reports `READY` (with warnings
for anything optional you left unconfigured, such as email). 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 ([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`. 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 ### Upgrading
1. **Pull or build the new image**, and change `EKKLESIA_IMAGE` in `deploy/.env`. Keep the old tag. 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)). 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`. `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 ### Rolling back
Set `EKKLESIA_IMAGE` back to the previous tag and run `up -d --wait` again. That is enough only when 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 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 ### Backups
Slice 6 adds the scheduled backup job and the restore procedure (ADR 0044, Backups). Until then, 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 ### Split Dashboard and API origins
@@ -178,6 +277,29 @@ the topology and warns when the two hostnames are not siblings.
The Dashboard learns the API's origin at run time: the API writes its identity base URL into the page The Dashboard learns the API's origin at run time: the API writes its identity base URL into the page
it serves (`<meta name="ekklesia-api-origin">`). Nothing about the topology is built into the image. it serves (`<meta name="ekklesia-api-origin">`). Nothing about the topology is built into the image.
### Storage delivery: CORS is the deployment's
Browsers fetch objects directly from the storage hostname through signed, expiring URLs (ADR 0031).
**Which pages may read those bytes with `fetch()` is decided by the delivery layer, the proxy, and
never by the object store.** That policy therefore stays the same with SeaweedFS, R2, or any other
S3 backend behind it, and an installation that changes backends changes nothing about CORS.
The reference proxy's storage block:
- grants CORS only to the origins whose pages read objects with `fetch()`: the Ekklesia origin in the
same-origin topology; the API origin (displays) and the Dashboard origin (a module's preview) in
the split topology. An `<img>` needs no CORS and is unaffected;
- allows `GET` and `HEAD` only, and no request header beyond the CORS-safelisted ones, always with
`Vary: Origin`;
- answers preflight `OPTIONS` requests itself, so they never reach storage;
- removes the object store's own CORS headers. SeaweedFS's default reflects any origin and
advertises `PUT`, `POST`, and `DELETE`.
The proxy reaches storage over **HTTP/1.1 only**. Over HTTP/2, SeaweedFS answers a conditional
request with a `304` that declares a `Content-Length` it never sends. The proxy then resets the
browser's stream, and every reload of a cached image fails with `ERR_HTTP2_PROTOCOL_ERROR`.
Browsers still reach the proxy itself over HTTP/2 and HTTP/3.
### Behind an existing reverse proxy ### Behind an existing reverse proxy
Run the base alone and attach `api` and `object-storage` to your proxy's network with an overlay of Run the base alone and attach `api` and `object-storage` to your proxy's network with an overlay of
@@ -213,6 +335,8 @@ Then make your proxy do what `Caddyfile.example` does:
- forward the storage hostname to `https://object-storage:8443`, verifying it against - forward the storage hostname to `https://object-storage:8443`, verifying it against
`/etc/ekklesia/tls/ca.crt` with server name `object-storage`, and **pass the original `Host` `/etc/ekklesia/tls/ca.crt` with server name `object-storage`, and **pass the original `Host`
through unchanged**, or every signed URL fails (ADR 0031). through unchanged**, or every signed URL fails (ADR 0031).
- reach `object-storage` over **HTTP/1.1** only, and own the storage hostname's CORS as described in
[Storage delivery](#storage-delivery-cors-is-the-deployments).
The proxy's address must be fixed and must never be handed to anything else. With Docker, give its The proxy's address must be fixed and must never be handed to anything else. With Docker, give its
network an explicit subnet, limit `ip_range` to part of it, and fix the proxy's address outside that network an explicit subnet, limit `ip_range` to part of it, and fix the proxy's address outside that
@@ -284,11 +408,18 @@ deployment:
- `pnpm deploy:config` validates the base, base + proxy, and base + proxy + tunnel with the example - `pnpm deploy:config` validates the base, base + proxy, and base + proxy + tunnel with the example
env files. CI runs it in `pnpm ci:verify`. 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 - `bash scripts/check-image.sh` builds the image and proves it is generic: no `/opt/ekklesia/modules`,
platform-only image and a disposable PostgreSQL and SeaweedFS that exist only for that run. It no module template, no `ekklesia.module.json` outside installed dependencies, an empty root-owned
follows the first boot above and checks the following through the proxy: `/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; - readiness and the Dashboard;
- a signed object URL fetched through the storage hostname; - 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`;
- storage CORS: exactly the allowed origins, `GET` and `HEAD` only, preflights answered by the
proxy, and none of the object store's own CORS headers;
- the client-address chain, both from an ordinary peer and from the tunnel connector's address; - the client-address chain, both from an ordinary peer and from the tunnel connector's address;
- initial-owner sign-in and sign-out; - initial-owner sign-in and sign-out;
- that the API's database connection is TLS 1.3, and that it is refused by a foreign CA and by a - that the API's database connection is TLS 1.3, and that it is refused by a foreign CA and by a
@@ -296,12 +427,22 @@ deployment:
- Doctor. - Doctor.
`EKKLESIA_SMOKE_TOPOLOGY=split` runs the [split topology](#split-dashboard-and-api-origins) `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 instead, on the **same generic image**, and also proves [Installed modules](#installed-modules)
hostname's served and refused paths, exact CORS for the Dashboard's origin and none for others, end to end:
and then a real headless Chromium (pinned by digest, driven over the DevTools protocol) that signs - the image boots with no module;
in on the Dashboard's hostname, checks the session cookie lives only on the API's, mounts the - the repository's module template, packaged as an Ekklesia Module Package v1 by the canonical
module's UI from the API's origin and sees its own credentialed requests succeed, mounts its display packager, is verified by the image itself and installed into module storage, its migration is
on the API's hostname with its service worker, and signs out. 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. CI runs both topologies on every change.
- **Test-only:** the smoke test runs Caddy with its internal CA (`local_certs`), because a test run - **Test-only:** the smoke test runs Caddy with its internal CA (`local_certs`), because a test run
@@ -322,44 +463,47 @@ is no second image and no second build.
### What it builds ### What it builds
The build context is an **already-assembled workspace**: this repository, plus zero or more module The build context is **this repository**, and the image is the same for every installation
packages already present in `modules/<id>`, plus a `pnpm-lock.yaml` describing exactly that set. ([ADR 0048](../docs/architecture/decisions/0048-generic-image-and-installed-module-storage.md)). It
The Dockerfile clones nothing and chooses no modules. It builds what it is given. With no contains **zero feature modules**: installed modules live in module storage outside it
`modules/` directory, it builds the platform alone; CI builds that case on every change. ([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 A `modules/` directory in the checkout never reaches the image. `.dockerignore` keeps it out of the
commit, and the instance's own lockfile) belongs to the installation's instance composition, not to build context, and the build refuses a context that still contains one. The module template is a
this file. 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 | | 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. | | `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. | | `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. | | `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` | `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. | | `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`: production dependencies only. | | `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). No pnpm, no compiler, no curl. | | `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 The runtime keeps the repository layout at `/opt/ekklesia`, because that layout is how the platform
finds its files: finds its files:
- the API takes the repository root as three directories above `apps/api/dist/`, and reads - 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 - 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`; `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/`; - 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 - installed modules are read from `EKKLESIA_MODULES_DIR`, `/var/lib/ekklesia/modules`, never from
own entrypoints, UI artifacts, and migrations directory. under `/opt/ekklesia`. With nothing mounted there, the image runs with no modules.
### Building ### Building
```sh ```sh
docker build \ docker build \
--file deploy/Dockerfile \ --file deploy/Dockerfile \
--build-arg VERSION=0.1.1 \ --build-arg VERSION=0.1.2 \
--build-arg REVISION="$(git rev-parse HEAD)" \ --build-arg REVISION="$(git rev-parse HEAD)" \
--build-arg SOURCE=https://git.example.org/your-org/ekklesia \ --build-arg SOURCE=https://git.example.org/your-org/ekklesia \
--tag ekklesia:0.1.1 \ --tag ekklesia:0.1.2 \
. .
``` ```
@@ -371,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 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. context entirely, and no registry credentials are needed.
**A module missing a production dependency fails the build, not the API.** The runtime receives ### The runtime an installed module targets
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.
**A platform-only image does not exercise native module dependencies.** With no modules in the An installed module runs on the image's Node.js, inside the API process, from its own directory
context, nothing like sharp is installed, so the CI image build proves nothing about them. An ([ADR 0048](../docs/architecture/decisions/0048-generic-image-and-installed-module-storage.md)):
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 - **Node.js 24.18.0 on Debian 13, so glibc**, for the architecture the image was built for. A native
example, process one image). 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
Configuration comes **only from the container environment**, never from a file inside the image. 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 | | Variable | Image value | Why |
| --- | --- | --- | | --- | --- | --- |
| `NODE_ENV` | `production` | Stated rather than relied on. Anything other than `development` already validates as production. | | `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_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_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 Everything else is the ordinary host configuration in
[API § Required configuration](../docs/development/api.md#required-configuration), supplied by the [API § Required configuration](../docs/development/api.md#required-configuration), supplied by the
@@ -415,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 - **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 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 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 writable, and neither is module storage, which is mounted read-only. Anything that needs a
work around. writable path other than `/tmp` is a defect to report, not to work around.
### Health ### Health
@@ -440,20 +591,31 @@ for a development checkout, not for the image.
`migrate` has `profiles: ["ops"]`, so `up` never starts it. It never restarts, has no health check, `migrate` has `profiles: ["ops"]`, so `up` never starts it. It never restarts, has no health check,
and its default command is the read-only plan. **Nothing applies migrations automatically.** and its default command is the read-only plan. **Nothing applies migrations automatically.**
Run operator commands with the same Compose files as the stack. `COMPOSE_FILE` in `deploy/.env`
(set in [`env/compose.env.example`](env/compose.env.example)) does that for every plain
`docker compose` command below. It matters for Doctor in particular: Doctor checks the
client-address configuration of the process it runs in. The proxy overlay gives `migrate` the
API's trusted-proxy settings, so Doctor reports what the API runs. Without the overlay, Doctor
reports direct mode for an API that trusts its proxy. The running API's own value is shown by
`docker compose exec api printenv EKKLESIA_CLIENT_ADDRESS_MODE`.
| Task | Command (from `deploy/`) | | Task | Command (from `deploy/`) |
| --- | --- | | --- | --- |
| Plan migrations (read-only) | `docker compose run --rm migrate` | | Plan migrations (read-only) | `docker compose run --rm migrate` |
| Apply migrations | `docker compose run --rm migrate node core/dist/cli/apply-migrations.js` | | 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` | | 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]` | | 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`) | | 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 Every command reads installed modules from the same `EKKLESIA_MODULES_DIR` the API does. The plan,
enablement is not. 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 ### 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 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 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 `bin/esbuild` to the native binary it ships beside it; when pnpm restores esbuild from the store's
+13 -6
View File
@@ -13,6 +13,13 @@
# its lower half; the proxy (and the tunnel connector, if used) take addresses from the upper half, # its lower half; the proxy (and the tunnel connector, if used) take addresses from the upper half,
# which Docker never hands to another container. # which Docker never hands to another container.
# Exactly one trusted peer: this proxy. Set from the same variable that fixes the proxy's address,
# so the two cannot drift apart. These override any value in ekklesia.env.
x-ekklesia-client-address: &ekklesia-client-address
EKKLESIA_CLIENT_ADDRESS_MODE: trusted-proxy
EKKLESIA_CLIENT_ADDRESS_HEADER: x-forwarded-for
EKKLESIA_CLIENT_ADDRESS_TRUSTED_PROXIES: ${EKKLESIA_PROXY_ADDRESS:-10.249.250.254}/32
services: services:
proxy: proxy:
image: caddy:2.11.4-alpine@sha256:6aeddd44c3078b0f9a35206472a11420648a79c184603ef95957d0a20044cb2b image: caddy:2.11.4-alpine@sha256:6aeddd44c3078b0f9a35206472a11420648a79c184603ef95957d0a20044cb2b
@@ -59,12 +66,12 @@ services:
api: api:
networks: networks:
edge: {} edge: {}
environment: environment: *ekklesia-client-address
# Exactly one trusted peer: this proxy. Set here from the same variable that fixes the proxy's
# address, so the two cannot drift apart. These override any value in ekklesia.env. # Operator commands (migrations, Doctor) run as `migrate`. It gets the API's client-address
EKKLESIA_CLIENT_ADDRESS_MODE: trusted-proxy # configuration too, so that Doctor checks what the API actually runs rather than an empty one.
EKKLESIA_CLIENT_ADDRESS_HEADER: x-forwarded-for migrate:
EKKLESIA_CLIENT_ADDRESS_TRUSTED_PROXIES: ${EKKLESIA_PROXY_ADDRESS:-10.249.250.254}/32 environment: *ekklesia-client-address
object-storage: object-storage:
networks: networks:
+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 # 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. # EKKLESIA_DATABASE_CA, and is the only CA the database connection trusts.
NODE_EXTRA_CA_CERTS: /run/ekklesia/tls/ca.crt 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: volumes:
- ${EKKLESIA_TLS_DIR:-/etc/ekklesia/tls}/ca.crt:/run/ekklesia/tls/ca.crt:ro - ${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: networks:
data: {} data: {}
storage: {} storage: {}
+12
View File
@@ -2,6 +2,12 @@
# Compose reads automatically from this directory. Nothing here is a secret; secrets live in the # Compose reads automatically from this directory. Nothing here is a secret; secrets live in the
# per-service files under EKKLESIA_ENV_DIR. # per-service files under EKKLESIA_ENV_DIR.
# Which Compose files make up this installation, so that every plain `docker compose …` command
# run from this directory, operator commands through `migrate` included, sees the same services and
# settings as the running stack. Add compose.tunnel.yaml if you use the tunnel connector. Without
# the proxy overlay here, Doctor checks a `migrate` that lacks the API's client-address settings.
COMPOSE_FILE=compose.yaml:compose.proxy.yaml
# The image to run, by registry, name, and tag (or digest). Required. # The image to run, by registry, name, and tag (or digest). Required.
EKKLESIA_IMAGE= EKKLESIA_IMAGE=
@@ -9,6 +15,12 @@ EKKLESIA_IMAGE=
EKKLESIA_ENV_DIR=/etc/ekklesia EKKLESIA_ENV_DIR=/etc/ekklesia
EKKLESIA_TLS_DIR=/etc/ekklesia/tls 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 --------------------------------------------------------------------------- # --- compose.proxy.yaml ---------------------------------------------------------------------------
# The Ekklesia hostname and the storage hostname (signed object URLs). In the default same-origin # 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. # 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 # Never commit the filled-in file. Generate secrets with scripts/gen-secrets.sh, and the database CA
# line with scripts/init-tls.sh. # 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. # The image already sets NODE_ENV=production, EKKLESIA_API_HOST=0.0.0.0, EKKLESIA_API_PORT=3000, and
# Compose sets NODE_EXTRA_CA_CERTS. The proxy overlay sets the three EKKLESIA_CLIENT_ADDRESS_* # EKKLESIA_MODULES_DIR=/var/lib/ekklesia/modules. Compose sets NODE_EXTRA_CA_CERTS and mounts module
# variables. None of those belong here. # 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 # Values that are part of the reference topology are filled in; everything installation-specific is
# blank. Full reference: docs/development/api.md#required-configuration. # blank. Full reference: docs/development/api.md#required-configuration.
@@ -0,0 +1,262 @@
# 0047: Every module proves itself inside Ekklesia, with one harness the platform publishes
- Status: Proposed
- Date: 2026-10-05
- Supersedes: none
- Related: [ADR 0016](0016-module-database-access.md),
[ADR 0021](0021-modules-as-hostable-products.md),
[ADR 0028](0028-module-operational-lifecycle.md),
[ADR 0030](0030-host-provided-module-object-storage.md),
[ADR 0033](0033-display-kiosk-host-contract.md),
[ADR 0036](0036-managed-device-identity-and-pairing.md),
[ADR 0041](0041-module-browser-contributions.md),
[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
Modules are independently versioned products (ADR 0021). They live in their own repositories, and
none of them can be built, run, or rendered without Ekklesia. The first production module, Signage,
reached a church with three defects that only show when it runs **inside** Ekklesia, in a real
browser:
- every heartbeat its display sent was refused with a 400, because the display and the server
disagreed about `null`;
- a display that failed one request at start-up never recovered without a reload;
- its media cache leaked one blob per slide per refresh, until the browser refused the display's own
requests.
Signage had unit tests, browser tests against a stubbed host, and no CI. Every test passed, because
each half was tested against its own idea of the other, and nothing ever ran the module in the
platform it ships into.
The platform's own deployment smoke (ADR 0044, ADR 0045) does run a module in a real browser, but
only the repository's template, and only far enough to mount it. The instance repository runs the
smoke against its real modules, but only at release time, after a module has already been tagged.
Each module repository that wants more than that has to assemble the platform, build an image,
start a deployment, and drive a browser itself. Signage is about to do exactly that by hand. The
second module, Classroom, would copy it, and the copies would drift from the platform they test.
## Decision
### The platform publishes a versioned module harness
Each platform release publishes one OCI image, `ekklesia-module-harness:<platform version>`. It is
built from the platform at that release tag by the platform's own release CI, and pushed to the
organization's registry. A version is never overwritten. Modules reference it **by digest**.
The harness contains the platform source at that version: its `deploy/` directory, its Dockerfile,
its lockfile, and its smoke scripts. It also contains the pinned Node.js and pnpm, the browser
driver, and the pinned headless Chromium image reference. Given a module checkout, it:
1. **assembles** the module into the platform at `modules/<id>`, exactly as an instance does (ADR
0044), and installs with `--frozen-lockfile`;
2. runs the platform's own gates against that set: `pnpm boundaries`, `modules:validate`,
`migrations:validate`, and the build;
3. **builds** the deployment image from the assembled workspace with the platform's Dockerfile;
4. starts the **reference deployment** in the split topology (ADR 0045), with a disposable
PostgreSQL and object store, Caddy's internal CA, and freshly generated secrets;
5. plans, then **explicitly applies**, migrations against that fresh database. This is the only
database the harness ever migrates;
6. creates the initial owner, grants the module's declared permissions to a test person, and
**mounts every UI surface the manifest declares**, in headless Chromium. That covers:
- the primary UI in the Dashboard;
- the display, paired as a device with no human session (ADR 0036);
- each browser contribution, whenever the module that hosts it is present (ADR 0041);
7. runs a short **display soak**, and the **no-dead-ends** injections below;
8. tears everything down: containers, networks, volumes, and the work directory.
It **fails** on any of the following:
- a `console.error` or uncaught exception on any page;
- an unhandled promise rejection;
- any 4xx or 5xx response that the harness did not itself expect. Expected ones are listed in the
harness, for example a display's descriptor before it is paired;
- a page that never mounts;
- a failed or refused request (CORS, blocked, network error);
- a soak leak.
Each failure line names the surface, the request path (never its query), and the step.
### The display soak and the no-dead-ends checks are generic
These checks come from what Signage got wrong, and they apply to any module with a display.
- **Soak**, two minutes by default. The display stays mounted with no remount and no offline
indicator. Its realtime streams stay healthy. Live object URLs stay bounded. JavaScript heap
growth, measured over DevTools, stays within a fixed ratio.
- **No dead ends.** One at a time, the harness refuses the first request to each module route the
display calls at start-up, and the first realtime connection. Within a bounded time and with no
reload, the display must make that request again and succeed. A display has nobody to press
Retry.
Module-specific behaviour, such as Signage's heartbeat being recorded, is not guessed by the
harness. A module declares it (below).
### A module is tested against both ends of its compatibility range
A module's CI runs the harness twice: against the **lowest** and the **newest** published platform
version within its manifest's `compatibility.platform` range. Testing every version in between adds
cost and finds little. Testing only the newest misses the floor the manifest promises to work on.
The two versions, and their harness digests, are pinned in the module's repository. The harness
refuses to run if:
- a pinned version lies outside the module's range;
- the lowest pinned version is not the range's floor.
Moving to a newer platform is a deliberate, reviewed pin change, as an instance's is (ADR 0044). It
is never a moving tag.
A frozen install of the module assembled into each of those two platforms needs a lockfile per
pinned version. The module keeps them (`ci/lock/<platform version>.yaml`), and the harness provides
the one command that regenerates them inside its own pinned Node.js. A stale lockfile fails the
frozen install, which is the signal to regenerate it in a reviewed change.
### Modules declare extra smoke steps, beside their manifest rather than in it
A module may add steps of its own in `ekklesia.harness.json`, next to `ekklesia.module.json`. Steps
are declarative first:
- open a surface;
- wait for a test id;
- call one of the module's routes as a named role, expecting a status;
- soak for N minutes;
- inject a failure into a named route;
- check a value in the module's own table, through its own route.
For what a declaration cannot express, a step may name a driver script in the module's repository.
The harness runs that script with the same DevTools client and the same failure rules.
**This is deliberately not a manifest field.** Manifest versions are strict, hard cutovers that every
host validates (ADR 0033). A test step is not a runtime contract, and a host must never read one.
Putting steps in the manifest would force a manifest version bump on every module for something
that has no meaning outside CI.
### Later: the harness checks what a module actually touches against what it declares
In a later phase, the harness records what the module does while it runs:
- the tables and schemas it reads and writes;
- the buckets it uses;
- its outbound network connections, with the egress network blocked and every attempt logged.
It compares them with what the manifest declares (ADR 0016, ADR 0030). A module that touches what it
did not declare fails. This is the module-trust check. The egress half needs a manifest declaration
of outbound destinations that does not exist yet, and is a separate decision. This ADR fixes only
the harness's shape, so that the recording phases can be added without changing how modules call
it.
### The template's workflow stays short, and calls the harness by digest
`templates/module` ships a workflow that:
- checks out the module with `persist-credentials: false` and pinned actions;
- runs the module's own unit tests, typecheck, and lint;
- sweeps stale CI stacks from the runner, then runs the harness, once per pinned platform version,
with one `docker run` of the harness image **by digest**, and finally, always, removes this job's
own stacks. The module directory is mounted read-only, and the runner's Docker is used exactly
as the platform's smoke uses it.
It references no secret, needs no registry credential (the harness image is public within the
organization), and uses no `pull_request_target`. The trusted-code-only rule for self-hosted runners
applies to module repositories exactly as it does to the platform (see `docs/development/security.md`).
**Why `docker run`, and not a reusable workflow or a composite action.** The server runs Gitea
1.27.2. (This proposal first assumed 1.24.2; the reasons below hold on the actual version.)
- Its runner can call a reusable workflow from another repository by cloning that repository, and
since 1.26 that includes private repositories (go-gitea/gitea#32562). That support is still thinly
documented, and has known problems in practice.
- A reusable workflow would make the harness's behaviour depend on how this server and runner
version expand it. An image runs the same everywhere it runs.
- Composite actions from another repository work through absolute `uses:` URLs. That would still
wrap the same `docker run`, adding a second pinned reference and nothing else.
An image digest is the one reference that is equally immutable, equally reviewable, and works
unchanged on any Gitea release and on GitHub Actions. A composite action can be added later as a
convenience over the same image, without changing what a module pins.
### Nothing the harness starts outlives the job that started it
The harness starts Docker stacks on a shared runner, and a runner can kill a job outright: a
cancelled or superseded job (`concurrency` cancels superseded runs on this server), or the runner's
own time limit. A script's own cleanup trap does not run then. The platform's CI leaked a whole
migration stack that way, and it ran until someone removed it by hand. So the harness follows the
platform's rule (`scripts/ci-sweep.sh`, `docs/development/security.md`):
- everything it starts is named and labeled as a CI stack of its run, attempt, and job key;
- it removes its own stacks in a trap;
- the template workflow runs the harness's **stale sweep** before the harness starts, and its
**job sweep** in a final `if: always()` step, so a killed run's stacks are removed by that run's
last step, or failing that by the next job on the runner.
The sweep ships inside the harness image, so a module never copies it.
### Publishing follows the instance's release pattern
The harness image is pushed only by a `publish` job that runs on `v*` tags, after the platform's CI
has built and tested that exact image (checked by image ID). That job:
- uses a write-only package token, the only secret in the platform's workflow;
- refuses to overwrite an existing version;
- prints the digest.
Pull requests and branch builds build the harness and run it against the template, but never push.
The registry is reached exactly as the instance's publish job reaches it.
### Adoption
- **Signage** uses an interim CI job until the first harness is published. That job assembles
Signage into a pinned platform commit and runs the platform's display-device smoke scenarios.
Signage switches to the harness as soon as one is published.
- **Classroom** follows.
- **The template** carries the workflow from the first harness release on, so every new module has
it from its first commit.
## Consequences
- A module cannot reach a release without having run inside Ekklesia, in a real browser, at both
ends of the platform range it claims.
- The defects that reached the first production Signage deployment fail a module's CI instead:
refused requests, dead ends, leaks, and console errors.
- The platform owns one harness, versioned with itself. Modules do not copy deployment scripts that
then drift.
- Every module's CI needs Docker on its runner, and takes minutes rather than seconds. The harness
runs as separate jobs from the unit tests, so a quick failure still reports quickly.
- The platform's release process gains one published artifact, one credential, and one more failure
mode at release time.
- A module now carries a lockfile for each pinned platform version. The harness makes regenerating
them one command, and a stale one fails visibly.
## Alternatives
- **Each module keeps its own copy of the smoke scripts.** Rejected. That is today's situation in
embryo, and it drifts.
- **A reusable workflow in the platform repository.** Rejected for now, for the reasons above: thin
support in practice, and a harness whose behaviour depends on whichever runner and server version
happens to expand it.
- **Publish the harness as an npm package.** Rejected. The harness needs Docker, a browser, and the
platform's deployment files anyway, so a package would ship a fraction of it and assume the rest.
- **Test only the newest platform version.** Rejected. A module's range floor would go untested
until an installation that has not upgraded meets it.
- **Put smoke steps in the manifest.** Rejected. Manifest versions are hard cutovers with runtime
meaning, and test steps have neither.
- **Run the harness in the platform's CI against every known module.** Rejected. The platform would
need every module's source and would fail on modules it does not own. The direction is module
tests platform, not the reverse.
## Deferred
- The data-access and egress recording phases, and the manifest declaration egress needs.
- A composite action over the harness image.
- Running the harness against a module's declared dependencies at their own pinned versions, for
contributions hosted by another module.
- Visual regression of mounted surfaces.
@@ -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.
+3
View File
@@ -55,3 +55,6 @@ ADRs capture durable choices that affect more than one package or phase.
- [0044: Ekklesia ships one self-hosted reference deployment, composed from pinned sources](0044-self-hosted-reference-deployment.md) — Accepted - [0044: Ekklesia ships one self-hosted reference deployment, composed from pinned sources](0044-self-hosted-reference-deployment.md) — Accepted
- [0045: The Dashboard and the API may live on separate origins](0045-split-dashboard-and-api-origins.md) — Accepted - [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 - [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
@@ -652,3 +652,38 @@ 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 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 the absolute ceiling; module-owned offline *content*; and every real feature module, Signage
included. Nothing in this pass claims any of them exists. 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,
and the transitional Drizzle agreement, runs the platform's own module 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. Drizzle agreement is recorded as transitional, not a
database ABI.
+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_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_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_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_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_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)). | | `EKKLESIA_IDENTITY_TRUSTED_ORIGINS` | Comma-separated list of canonical origins, validated identically to the base URL (no wildcards). Used for both Better Auth's own CSRF/origin checks and the API's CORS policy — there is only one trusted-origin list. The base URL's own origin is always trusted as well and need not be listed ([ADR 0045](../architecture/decisions/0045-split-dashboard-and-api-origins.md)). |
+5 -3
View File
@@ -173,8 +173,9 @@ integration layer depends on authorization).
exact same installed-module inventory every time it starts, in a fixed order, before anything exact same installed-module inventory every time it starts, in a fixed order, before anything
else: else:
1. Resolve module-discovery configuration (the repository's `modules/` directory, and the 1. Resolve module-discovery configuration: the installed-modules directory, chosen by Core's one
platform/Module SDK versions from their own `package.json` files). 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`). 2. Discover installed modules exactly once (`discoverModules`).
3. Compose the platform permission registry from that exact discovered list 3. Compose the platform permission registry from that exact discovered list
(`composePlatformPermissionRegistry`). (`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 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()` `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 — 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 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 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; package-level Turborepo task. New packages add their own `lint`, `typecheck`, and `test` scripts;
the root verification command discovers them automatically. the root verification command discovers them automatically.
`pnpm modules:validate` builds the workspace, then scans `modules/*` without a central registry. A `pnpm modules:validate` builds the workspace, then scans the installed-modules directory without a
missing or empty modules directory is valid and reports zero installed modules. Installed modules 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 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 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 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 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 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 skipped. Pending or approval-blocked migrations are warnings and are never applied. Applied
history, checksum disagreement, unknown applied migrations, or a failed configured connection are history that belongs to a module which is not installed is reported as `retained` and is neither a
failures. If `DATABASE_URL` is missing, PostgreSQL is skipped and all static checks still run. 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 `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 warned or was skipped. Both results exit zero. `NOT READY` means at least one check failed and exits
+68 -22
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 publishes, or runs it against a real installation. No `.env` has ever been committed; only
`.env.example`, which the image build context excludes. `.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: 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 - `apps/api` defaults its listen address to loopback (`127.0.0.1`), so it is not reachable off-host
@@ -693,8 +707,10 @@ public Dashboard login must be assessed with the actual edge/proxy controls befo
## Continuous integration boundary ## Continuous integration boundary
CI runs on self-hosted Gitea Actions runners. The inventoried server is **Gitea 1.24.2**, and that CI runs on self-hosted Gitea Actions runners. The server reports **Gitea 1.27.2** (`GET
version determines which parts of the workflow are real guarantees and which are not. /api/v1/version`, checked 2026-10-06; this document previously said 1.24.2, which was stale). The
server's actual version, not the documentation of an earlier one, determines which parts of the
workflow are real guarantees and which are not.
### The current operating rule: trusted code only ### The current operating rule: trusted code only
@@ -727,24 +743,53 @@ so a later step in the same job cannot read or reuse it from there.
It does **not** remove the short-lived `GITEA_TOKEN` from the job environment, and it does **not** It does **not** remove the short-lived `GITEA_TOKEN` from the job environment, and it does **not**
reduce that token's server-side authority. A step that wants the token can still read it. reduce that token's server-side authority. A step that wants the token can still read it.
### What Gitea 1.24 ignores — so the branch cannot claim it ### What this server honours, and what is not yet verified
Per the official comparison for this version Each item separates what Gitea documents for 1.27
(<https://docs.gitea.com/1.24/usage/actions/comparison>), Gitea 1.24's unsupported workflow syntax (<https://docs.gitea.com/1.27/usage/actions/comparison>) from what has been observed on this server:
includes `permissions` and `jobs.<job_id>.permissions`, `concurrency`, and
`jobs.<job_id>.timeout-minutes`. On this server those declarations are inert. Therefore:
- **The workflow's `permissions: contents: read` does not restrict the job token.** This branch - **`concurrency` is honoured.** Gitea supports it since 1.26 (go-gitea/gitea#32751), and on this
cannot claim the `GITEA_TOKEN` is read-only, because nothing in workflow YAML makes it so on server a newer push to a pull request cancels the run it supersedes. Observed: Ekklesia run 133 was
1.24. Job-token permission controls, repository/owner defaults, and server-side cancelled when run 134 started for the same pull request, and run 135 when run 137 did. A
maximum-permission clamping arrive in the 1.26 feature line superseded run's own cleanup is therefore never guaranteed to run, which is one reason for the
(<https://docs.gitea.com/1.26/usage/actions/token-permissions>). stack sweep below.
- **`concurrency` and `timeout-minutes` are not bounded-execution guarantees here.** Cancellation, - **`permissions` is supported by 1.27, with Gitea's own scopes, but is not verified here.** The
timeout, and teardown must be enforced by a trusted provisioner and reaper outside the workflow. server-side token-permission settings are not visible through the API, and nothing has yet proven
what the job token can and cannot do on this server. Until that proof exists (requirement 3
below), this repository still does not claim the `GITEA_TOKEN` is read-only.
- **`timeout-minutes` is no longer listed as unsupported for 1.27, but is not verified here.**
Teardown does not rely on it: the stack sweep and a trusted reaper do.
- **Still ignored:** `jobs.<job_id>.environment`, and any `runs-on` other than a single label.
The declarations are kept deliberately: they are already correct for a supported Gitea release and The declarations stay in the workflow either way: they state the intent, and are correct on GitHub
for GitHub Actions, and keeping them means the upgrade does not require re-deriving the intent. They Actions as well.
are documented as ignored, not removed.
### Disposable stacks never outlive their job
Every CI script removes its own Compose stack in an `EXIT` trap, but a trap never runs when the runner
kills the job container: a cancelled job, or the runner's own time limit. One such stack ran on the
shared runner for two days. So stack cleanup has three layers:
- **The script's own trap**, for every ordinary failure.
- **A final `if: always()` step** in every job that starts stacks: `scripts/ci-sweep.sh --job` removes
everything that job started, at any age, even after a failed step.
- **A sweep before every such job starts**: `scripts/ci-sweep.sh --stale` removes anything a CI job
left more than three hours ago, which catches whatever a killed job could not clean up itself.
A stack is found by name and label, never by guesswork (`scripts/lib/ci-project.sh`):
- every CI Compose project is named `ekklesia-ci-<run>-<attempt>-<job key>-…`, and Compose labels
its containers, networks, and volumes with that name;
- anything a script starts outside Compose carries `org.riverofglorymn.ekklesia.ci.project`;
- images also carry the time CI built them, because an image's own creation time can be its base's.
The job key is per matrix leg, so one leg's cleanup cannot match another's running stack. Nothing
outside the `ekklesia-ci-` prefix is ever removed. A job container attached to a stack's network is
disconnected, never removed.
**This is not a substitute for a runner-side reaper.** A stack killed with its job is removed only
when another job that sweeps runs on the same runner. Bounded execution and teardown are still the
runner host's to enforce, as above.
### Runner topology is not verified ### Runner topology is not verified
@@ -766,16 +811,17 @@ they are not conditions on merging trusted, reviewed work.
None of them can be satisfied by editing this repository; each requires access to the Gitea server None of them can be satisfied by editing this repository; each requires access to the Gitea server
and runner hosts, and each must be proven there. and runner hosts, and each must be proven there.
1. Upgrade Gitea to a reviewed supported release with job-token permission controls — at least the 1. ~~Upgrade Gitea to a reviewed supported release with job-token permission controls — at least
1.26 feature line. Select the actual release during infrastructure work, after backup and the 1.26 feature line.~~ **Done:** the server runs 1.27.2. Items 2 and 3 now configure and prove
compatibility review; do not pin a version from this document. what it makes possible.
2. Configure repository and owner defaults, and the server-side maximum permission, so workflow 2. Configure repository and owner defaults, and the server-side maximum permission, so workflow
code cannot request more than read-only repository access. code cannot request more than read-only repository access.
3. Prove against the real server that the job's `GITEA_TOKEN` cannot push code, create or update 3. Prove against the real server that the job's `GITEA_TOKEN` cannot push code, create or update
releases, packages, issues, or pull requests, change Actions configuration, or reach another releases, packages, issues, or pull requests, change Actions configuration, or reach another
private repository. private repository.
4. Enforce job timeout, cancellation, concurrency, and teardown in the trusted 4. Enforce job timeout and teardown in the trusted provisioner/reaper. `concurrency` is honoured
provisioner/reaper rather than relying on the ignored workflow keys above. by the server; `timeout-minutes` is not yet verified, and the in-workflow stack sweep only
complements a reaper.
5. Complete a disposable-guest runner isolation proof: one throwaway guest per job, torn down by a 5. Complete a disposable-guest runner isolation proof: one throwaway guest per job, torn down by a
trusted reaper outside workflow YAML, holding no deployment credential, registration token, or trusted reaper outside workflow YAML, holding no deployment credential, registration token, or
reusable key. reusable key.
+66
View File
@@ -159,12 +159,28 @@ also receives a safely named disposable database. Direct host runs use a Docker-
port bound only to `127.0.0.1`; a containerized Gitea job joins the isolated Compose network and port bound only to `127.0.0.1`; a containerized Gitea job joins the isolated Compose network and
uses the PostgreSQL service name. uses the PostgreSQL service name.
In CI, a stack whose job was killed before its own cleanup ran is still removed: by that job's final
`if: always()` step, or by the stale sweep before a later job starts (`scripts/ci-sweep.sh`, see
[Disposable stacks never outlive their job](security.md#disposable-stacks-never-outlive-their-job)).
`bash scripts/test-ci-sweep.sh` proves what the sweep removes and what it leaves alone, against real
Docker resources.
The live suite proves fresh read-only planning, initial and incremental application, genuine The live suite proves fresh read-only planning, initial and incremental application, genuine
no-op behavior, exact applied checksums and history, edited or corrupt history rejection, no-op behavior, exact applied checksums and history, edited or corrupt history rejection,
whole-batch DDL rollback, Drizzle breakpoint execution, real advisory-lock concurrency and whole-batch DDL rollback, Drizzle breakpoint execution, real advisory-lock concurrency and
timeouts, lock release after failure, exact destructive approvals, and public-error sanitization. timeouts, lock release after failure, exact destructive approvals, and public-error sanitization.
It does not make migrations automatic during Core startup. 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 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 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 expected `ekklesia_core_*` tables and their indexes exist, a genuine no-op on a second apply, Core
@@ -548,6 +564,56 @@ the following pull-request status context to `main` branch protection:
CI / migration-smoke (pull_request) 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 database
runtime, 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.
- `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, extracted elsewhere, and loaded with Node's own resolution hooks proving nothing outside
the package is resolved. 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),
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 ## Safe troubleshooting
Confirm the prerequisites without printing configuration: Confirm the prerequisites without printing configuration:
+122 -2
View File
@@ -26,7 +26,10 @@ application's. A module consumes those; it never redefines or bypasses them.
## Required package shape ## 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: strict semantic version, and the module layer marker:
```text ```text
@@ -66,6 +69,122 @@ 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 The package version must match the manifest and runtime-definition versions. Internal package
dependencies use `workspace:` ranges and supported public package entrypoints. 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.
**Drizzle, for now.** You define tables with your own copy of Drizzle, and Core runs your queries
through the platform's Drizzle instance (see [Database access](#database-access)). Objects from two
copies interoperate only when the copies are interchangeable, so the current implementation requires
**exactly the Drizzle version the platform uses**: the one `@ekklesia/module-sdk` depends on. The
package records it under `transitional`, and an installation refuses a package whose Drizzle differs.
This is a constraint of today's implementation, not a permanent contract.
#### 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 ## Canonical manifest
`ekklesia.module.json` is inert and authoritative, so Core can validate every installed module `ekklesia.module.json` is inert and authoritative, so Core can validate every installed module
@@ -1447,7 +1566,8 @@ pnpm migrations:plan
pnpm verify 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 It rejects malformed manifests, duplicate IDs or package names, version mismatches, incompatible
platform or SDK ranges, missing required modules or capabilities, incompatible installed platform or SDK ranges, missing required modules or capabilities, incompatible installed
dependencies, cycles, and missing, escaping, or non-JavaScript server or schema entrypoints. dependencies, cycles, and missing, escaping, or non-JavaScript server or schema entrypoints.
+4 -1
View File
@@ -1,6 +1,7 @@
{ {
"name": "ekklesia", "name": "ekklesia",
"version": "0.1.1", "version": "0.1.2",
"license": "AGPL-3.0-or-later",
"private": true, "private": true,
"type": "module", "type": "module",
"description": "Open, modular church technology platform", "description": "Open, modular church technology platform",
@@ -34,7 +35,9 @@
"migrations:validate": "turbo run build && node core/dist/cli/validate-migrations.js", "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: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: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: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": "turbo run test test:root",
"test:migrations:integration": "pnpm build && bash scripts/test-migrations-integration.sh", "test:migrations:integration": "pnpm build && bash scripts/test-migrations-integration.sh",
"test:root": "vitest run --config vitest.config.ts", "test:root": "vitest run --config vitest.config.ts",
+2 -1
View File
@@ -1,6 +1,7 @@
{ {
"name": "@ekklesia/contracts", "name": "@ekklesia/contracts",
"version": "0.1.1", "version": "0.1.2",
"license": "AGPL-3.0-or-later",
"type": "module", "type": "module",
"files": [ "files": [
"dist" "dist"
+1
View File
@@ -8,6 +8,7 @@ export * from "./module-http.js";
export * from "./module-lifecycle.js"; export * from "./module-lifecycle.js";
export * from "./module-manifest.js"; export * from "./module-manifest.js";
export * from "./module-migration.js"; export * from "./module-migration.js";
export * from "./module-package.js";
export * from "./module-realtime.js"; export * from "./module-realtime.js";
export * from "./module-runtime.js"; export * from "./module-runtime.js";
export * from "./module-schema.js"; export * from "./module-schema.js";
+203
View File
@@ -0,0 +1,203 @@
import { z } from "zod";
import { moduleIdSchema } from "./identifiers.js";
import { semanticVersionRangeSchema, semanticVersionSchema } from "./versions.js";
/**
* Ekklesia Module Package v1 (ADR 0049): the format a separately versioned module is distributed
* in. One deterministic `.tar.gz` holding exactly one top-level directory named with the module id,
* whose contents are the installed module directory ADR 0048 requires. This file defines the
* package's metadata (`ekklesia.package.json`), its target names, and the runtime `package.json`
* the packager generates. The archive encoding itself belongs to the packager and the verifier.
*/
export const MODULE_PACKAGE_FORMAT = 1;
export const MODULE_PACKAGE_METADATA_FILENAME = "ekklesia.package.json";
export const MODULE_PACKAGE_NOTICES_FILENAME = "THIRD_PARTY_NOTICES";
/** The portable target: a package with no native or platform-specific file. */
export const MODULE_PACKAGE_ANY_TARGET = "any";
/**
* `any`, or `<platform>-<arch>[-<libc>]` in Node.js's own `process.platform` and `process.arch`
* vocabulary. On Linux the C library is required, because a native addon built against one does
* not load against the other; elsewhere it is meaningless and refused.
*/
export const modulePackageTargetSchema = z.string().superRefine((value, context) => {
if (value === MODULE_PACKAGE_ANY_TARGET) {
return;
}
const match = /^([a-z][a-z0-9]*)-([a-z][a-z0-9]*)(?:-([a-z]+))?$/u.exec(value);
const platform = match?.[1];
const libc = match?.[3];
const valid =
match !== null &&
platform !== "any" &&
(platform === "linux" ? libc === "glibc" || libc === "musl" : libc === undefined);
if (!valid) {
context.addIssue({
code: "custom",
message: "Must be any, or <platform>-<arch>, with -glibc or -musl on Linux.",
});
}
});
/** A path inside the module directory: relative, POSIX, printable ASCII, and never escaping. ASCII
* keeps JavaScript's string order identical to the archive's byte-wise order. */
const payloadPathSchema = z.string().superRefine((value, context) => {
const segments = value.split("/");
const valid =
/^[\x20-\x7e]+$/u.test(value) &&
!value.includes("\\") &&
!value.startsWith("/") &&
segments.every((segment) => segment !== "" && segment !== "." && segment !== "..") &&
value !== MODULE_PACKAGE_METADATA_FILENAME;
if (!valid) {
context.addIssue({
code: "custom",
message: "Must be a portable path inside the module directory, other than the metadata file.",
});
}
});
const modulePackageFileSchema = z.strictObject({
path: payloadPathSchema,
size: z.number().int().nonnegative(),
sha256: z.string().regex(/^[0-9a-f]{64}$/u, "Must be a lowercase hexadecimal SHA-256."),
});
const modulePackageRuntimeSchema = z.strictObject({
/** The Node.js versions the package was built for. Always present. */
node: semanticVersionRangeSchema,
/** Native packages only: the minimum Node-API version their addons need. */
nodeApi: z.number().int().positive().optional(),
/** Native packages only: the exact `process.versions.modules` an ABI-specific addon needs. */
nodeAbi: z
.string()
.regex(/^[0-9]+$/u)
.optional(),
/** Native Linux packages only: the minimum C library version. */
libcVersion: semanticVersionRangeSchema.optional(),
});
const ekklesiaPackageNameSchema = z
.string()
.regex(/^@ekklesia\/[a-z0-9][a-z0-9-]*$/u, "Must name an @ekklesia package.");
/**
* Compatibility facts today's implementation needs and that are not part of the long-term contract
* (ADR 0049). Strict at every level: a reader that finds a fact it does not understand must refuse
* the package rather than ignore it.
*
* `databaseRuntime` records the Drizzle version bundled with the module's backend, because Core runs
* a module's queries through its own Drizzle instance over tables the module defined with its own
* copy. That is an implementation constraint pending a Module Database ABI decision, **not** a
* database contract a module may rely on.
*/
const modulePackageTransitionalSchema = z.strictObject({
databaseRuntime: z
.strictObject({
"drizzle-orm": semanticVersionSchema.optional(),
})
.optional(),
});
export const modulePackageMetadataSchema = z
.strictObject({
packageFormat: z.literal(MODULE_PACKAGE_FORMAT),
module: z.strictObject({ id: moduleIdSchema, version: semanticVersionSchema }),
target: modulePackageTargetSchema,
runtime: modulePackageRuntimeSchema,
provenance: z.strictObject({
/** The exact source revision the package was built from: a full SHA-1 or SHA-256 commit id. */
revision: z.string().regex(/^(?:[0-9a-f]{40}|[0-9a-f]{64})$/u),
/** Informational only: nothing trusts it, and it ties the package to no host. */
repository: z.string().min(1).max(2048).optional(),
packager: z.strictObject({
name: z.string().min(1).max(214),
version: semanticVersionSchema,
}),
}),
/** The exact versions of the Ekklesia SDK and runtime libraries bundled into the package. */
incorporates: z.record(ekklesiaPackageNameSchema, semanticVersionSchema),
transitional: modulePackageTransitionalSchema.optional(),
/** Every regular file in the module directory except this metadata file, in byte-wise path
* order. The set is exact: nothing missing, nothing extra. */
files: z.array(modulePackageFileSchema),
})
.superRefine((metadata, context) => {
if (
metadata.target === MODULE_PACKAGE_ANY_TARGET &&
(metadata.runtime.nodeApi !== undefined ||
metadata.runtime.nodeAbi !== undefined ||
metadata.runtime.libcVersion !== undefined)
) {
context.addIssue({
code: "custom",
message: "An any-target package carries no native runtime requirement.",
path: ["runtime"],
});
}
for (let index = 1; index < metadata.files.length; index += 1) {
const previous = metadata.files[index - 1]?.path ?? "";
const current = metadata.files[index]?.path ?? "";
if (!(previous < current)) {
context.addIssue({
code: "custom",
message: "Files must be listed once each, in byte-wise path order.",
path: ["files", index, "path"],
});
}
}
});
export type ModulePackageMetadata = z.output<typeof modulePackageMetadataSchema>;
export type ModulePackageFile = z.output<typeof modulePackageFileSchema>;
export type ModulePackageTarget = z.output<typeof modulePackageTargetSchema>;
export function safeParseModulePackageMetadata(input: unknown) {
return modulePackageMetadataSchema.safeParse(input);
}
export function safeParseModulePackageTarget(input: unknown) {
return modulePackageTargetSchema.safeParse(input);
}
/**
* The runtime `package.json` of an installed module: exactly what module discovery reads, generated
* by the packager and never copied from a development workspace. No dependencies, scripts, exports,
* or imports, because nothing at run time resolves through them.
*/
export const modulePackageRuntimePackageSchema = z.strictObject({
name: z.string().min(1).max(214),
version: semanticVersionSchema,
private: z.literal(true),
type: z.literal("module"),
ekklesia: z.strictObject({ layer: z.literal("module") }),
});
export type ModulePackageRuntimePackage = z.output<typeof modulePackageRuntimePackageSchema>;
export function modulePackageRuntimePackage(identity: {
readonly name: string;
readonly version: string;
}): ModulePackageRuntimePackage {
return {
name: identity.name,
version: identity.version,
private: true,
type: "module",
ekklesia: { layer: "module" },
};
}
export function safeParseModulePackageRuntimePackage(input: unknown) {
return modulePackageRuntimePackageSchema.safeParse(input);
}
/** The recommended archive file name. A convenience only: a file name is never trusted. */
export function modulePackageArchiveFileName(
moduleId: string,
version: string,
target: string,
): string {
return `${moduleId}-${version}-${target}.tar.gz`;
}
@@ -0,0 +1,167 @@
import { describe, expect, it } from "vitest";
import {
MODULE_PACKAGE_FORMAT,
MODULE_PACKAGE_METADATA_FILENAME,
modulePackageArchiveFileName,
modulePackageRuntimePackage,
safeParseModulePackageMetadata,
safeParseModulePackageRuntimePackage,
safeParseModulePackageTarget,
} from "../src/index.js";
const digest = "a".repeat(64);
function metadata(overrides: Record<string, unknown> = {}): Record<string, unknown> {
return {
packageFormat: 1,
module: { id: "example", version: "0.1.0" },
target: "any",
runtime: { node: ">=24.11.0 <25" },
provenance: {
revision: "86ae907f59458508e3277666cdd214f5bd7c1078",
packager: { name: "@ekklesia/module-pack", version: "0.1.2" },
},
incorporates: { "@ekklesia/contracts": "0.1.2", "@ekklesia/module-sdk": "0.1.2" },
transitional: { databaseRuntime: { "drizzle-orm": "0.45.2" } },
files: [
{ path: "THIRD_PARTY_NOTICES", size: 10, sha256: digest },
{ path: "dist/server/index.js", size: 20, sha256: digest },
{ path: "ekklesia.module.json", size: 30, sha256: digest },
{ path: "package.json", size: 40, sha256: digest },
],
...overrides,
};
}
describe("Ekklesia Module Package v1 metadata (ADR 0049)", () => {
it("names the format, its metadata file, and the recommended archive file name", () => {
expect(MODULE_PACKAGE_FORMAT).toBe(1);
expect(MODULE_PACKAGE_METADATA_FILENAME).toBe("ekklesia.package.json");
expect(modulePackageArchiveFileName("example", "0.1.0", "any")).toBe(
"example-0.1.0-any.tar.gz",
);
});
it("accepts a complete any-target package description", () => {
expect(safeParseModulePackageMetadata(metadata()).success).toBe(true);
});
it("accepts a package that incorporates nothing and needs no transitional facts", () => {
const { transitional: _transitional, ...rest } = metadata();
expect(safeParseModulePackageMetadata({ ...rest, incorporates: {} }).success).toBe(true);
});
it.each([
["an unknown top-level field", { signature: "x" }],
["format 2", { packageFormat: 2 }],
["a missing source revision", { provenance: { packager: { name: "p", version: "1.0.0" } } }],
[
"an abbreviated revision",
{ provenance: { revision: "86ae907", packager: { name: "p", version: "1.0.0" } } },
],
["a non-semver module version", { module: { id: "example", version: "1.0" } }],
["an invalid module id", { module: { id: "Example", version: "0.1.0" } }],
["an invalid Node range", { runtime: { node: "soon" } }],
["native runtime facts on an any target", { runtime: { node: ">=24", nodeApi: 9 } }],
["a non-Ekklesia incorporated package", { incorporates: { zod: "4.4.3" } }],
[
"an incorporated range instead of a version",
{ incorporates: { "@ekklesia/module-sdk": "^0.1.0" } },
],
["an unknown transitional key", { transitional: { orm: {} } }],
["an unknown database runtime", { transitional: { databaseRuntime: { prisma: "6.0.0" } } }],
])("rejects %s", (_label, overrides) => {
expect(safeParseModulePackageMetadata(metadata(overrides)).success).toBe(false);
});
it.each([
["an absolute path", "/etc/passwd"],
["a parent segment", "dist/../../secret"],
["a dot segment", "./package.json"],
["an empty segment", "dist//index.js"],
["a backslash", "dist\\index.js"],
["a NUL byte", "dist/\u0000"],
["the metadata file itself", "ekklesia.package.json"],
])("rejects a file entry with %s", (_label, path) => {
const files = [{ path, size: 1, sha256: digest }];
expect(safeParseModulePackageMetadata(metadata({ files })).success).toBe(false);
});
it("requires file entries in byte-wise path order, without duplicates", () => {
const entry = (path: string) => ({ path, size: 1, sha256: digest });
expect(
safeParseModulePackageMetadata(metadata({ files: [entry("b"), entry("a")] })).success,
).toBe(false);
expect(
safeParseModulePackageMetadata(metadata({ files: [entry("a"), entry("a")] })).success,
).toBe(false);
// Byte-wise, not locale order: upper case sorts before lower case.
expect(
safeParseModulePackageMetadata(metadata({ files: [entry("THIRD"), entry("dist")] })).success,
).toBe(true);
});
it.each([
["an uppercase digest", { sha256: "A".repeat(64) }],
["a short digest", { sha256: "a".repeat(63) }],
["a negative size", { size: -1 }],
["a fractional size", { size: 1.5 }],
])("rejects a file entry with %s", (_label, override) => {
const files = [{ path: "package.json", size: 1, sha256: digest, ...override }];
expect(safeParseModulePackageMetadata(metadata({ files })).success).toBe(false);
});
});
describe("module package targets", () => {
it.each([
"any",
"linux-x64-glibc",
"linux-arm64-glibc",
"linux-x64-musl",
"darwin-arm64",
])("accepts %s", (target) => {
expect(safeParseModulePackageTarget(target).success).toBe(true);
});
it.each([
"",
"ANY",
"linux-x64",
"linux-x64-gnu",
"darwin-arm64-glibc",
"linux",
"any-x64",
])("rejects %j", (target) => {
expect(safeParseModulePackageTarget(target).success).toBe(false);
});
});
describe("the generated runtime package.json", () => {
it("holds exactly what module discovery reads", () => {
expect(
modulePackageRuntimePackage({ name: "@ekklesia/module-template", version: "0.1.0" }),
).toEqual({
name: "@ekklesia/module-template",
version: "0.1.0",
private: true,
type: "module",
ekklesia: { layer: "module" },
});
});
it("accepts the generated shape and nothing more", () => {
const generated = modulePackageRuntimePackage({ name: "@scope/example", version: "1.2.3" });
expect(safeParseModulePackageRuntimePackage(generated).success).toBe(true);
for (const extra of [
{ dependencies: { "@ekklesia/module-sdk": "workspace:*" } },
{ scripts: { build: "tsc" } },
{ exports: { ".": "./dist/server/index.js" } },
{ imports: { "#x": "./x.js" } },
]) {
expect(safeParseModulePackageRuntimePackage({ ...generated, ...extra }).success).toBe(false);
}
expect(safeParseModulePackageRuntimePackage({ ...generated, type: "commonjs" }).success).toBe(
false,
);
});
});
+2 -1
View File
@@ -1,6 +1,7 @@
{ {
"name": "@ekklesia/design-tokens", "name": "@ekklesia/design-tokens",
"version": "0.1.1", "version": "0.1.2",
"license": "AGPL-3.0-or-later",
"type": "module", "type": "module",
"files": [ "files": [
"dist" "dist"
+25
View File
@@ -0,0 +1,25 @@
#!/usr/bin/env node
// Committed launcher, deliberately not generated.
//
// pnpm creates `.bin` links during `pnpm install`, which runs before Turborepo builds anything. A
// `bin` pointing straight at `dist/cli.js` therefore fails to link on a fresh clone — the compiled
// file does not exist yet — and the packager would not link in CI. This file always exists,
// so the link always succeeds; it defers to the compiled CLI at run time, by which point
// `dependsOn: ["^build"]` has produced it.
import { fileURLToPath } from "node:url";
const compiledCli = new URL("../dist/cli.js", import.meta.url);
try {
await import(compiledCli.href);
} catch (error) {
if (error?.code === "ERR_MODULE_NOT_FOUND") {
process.stderr.write(
`@ekklesia/module-pack is not built yet (${fileURLToPath(compiledCli)}).\n` +
"Run `pnpm build` from the repository root first.\n",
);
process.exitCode = 1;
} else {
throw error;
}
}
@@ -0,0 +1,202 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
+47
View File
@@ -0,0 +1,47 @@
{
"name": "@ekklesia/module-pack",
"version": "0.1.2",
"license": "AGPL-3.0-or-later",
"type": "module",
"files": [
"bin",
"dist",
"licenses"
],
"bin": {
"ekklesia-pack-module": "./bin/ekklesia-pack-module.mjs"
},
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
},
"scripts": {
"build": "tsc --project tsconfig.build.json",
"lint": "biome lint .",
"prepack": "pnpm --workspace-root exec turbo run build --filter=@ekklesia/module-pack",
"test": "vitest run",
"typecheck": "tsc --noEmit --project tsconfig.json"
},
"dependencies": {
"@ekklesia/contracts": "workspace:*",
"@ekklesia/module-ui-build": "workspace:*",
"esbuild": "0.28.1",
"semver": "7.8.5"
},
"devDependencies": {
"@biomejs/biome": "2.5.3",
"@ekklesia/core": "workspace:*",
"@ekklesia/module-sdk": "workspace:*",
"@ekklesia/module-template": "workspace:*",
"@types/node": "24.13.3",
"@types/semver": "7.7.1",
"drizzle-orm": "0.45.2",
"typescript": "7.0.2",
"vitest": "4.1.10"
},
"ekklesia": {
"layer": "tooling"
}
}
+102
View File
@@ -0,0 +1,102 @@
#!/usr/bin/env node
import path from "node:path";
import process from "node:process";
import { pathToFileURL } from "node:url";
import { formatMessages } from "esbuild";
import { ModulePackError, type PackModuleOptions, packModule } from "./pack-module.js";
/**
* `ekklesia-pack-module` — the canonical Ekklesia module packager (ADR 0049).
*
* ekklesia-pack-module --module <built module directory> --target any --out <directory>
* --source-revision <full commit id> [--repository <url>]
*
* Writes `<id>-<version>-any.tar.gz` and its `.sha256` line to the output directory, and nothing
* else anywhere. Run it in the module's CI after building the module.
*/
class PackArgumentsError extends Error {}
const USAGE =
"Usage: ekklesia-pack-module --module <directory> --target any --out <directory> --source-revision <commit> [--repository <url>]";
const OPTIONS: Readonly<Record<string, keyof PackModuleOptions>> = Object.freeze({
"--module": "moduleDirectory",
"--out": "outDirectory",
"--repository": "repository",
"--source-revision": "sourceRevision",
"--target": "target",
});
export function parsePackModuleArguments(argv: readonly string[]): PackModuleOptions {
const values = new Map<keyof PackModuleOptions, string>();
for (let index = 0; index < argv.length; index += 2) {
const option = OPTIONS[argv[index] ?? ""];
const value = argv[index + 1];
if (
option === undefined ||
value === undefined ||
value.startsWith("--") ||
values.has(option)
) {
throw new PackArgumentsError(USAGE);
}
values.set(option, value);
}
const required = ["moduleDirectory", "outDirectory", "sourceRevision", "target"] as const;
if (required.some((option) => !values.has(option))) {
throw new PackArgumentsError(USAGE);
}
const repository = values.get("repository");
return {
moduleDirectory: values.get("moduleDirectory") ?? "",
outDirectory: values.get("outDirectory") ?? "",
...(repository === undefined ? {} : { repository }),
sourceRevision: values.get("sourceRevision") ?? "",
target: values.get("target") ?? "",
};
}
async function main(): Promise<number> {
try {
const result = await packModule(parsePackModuleArguments(process.argv.slice(2)));
process.stdout.write(
`Packed ${result.moduleId}@${result.version} (target ${result.target}): ${path.basename(result.archivePath)}, ${result.archiveBytes} bytes, ${result.fileCount} files\n`,
);
process.stdout.write(`sha256 ${result.sha256}\n`);
return 0;
} catch (error) {
if (error instanceof PackArgumentsError) {
process.stderr.write(`${error.message}\n`);
return 64;
}
if (error instanceof ModulePackError) {
process.stderr.write(`[${error.code}] ${error.message}\n`);
if (error.diagnostics.length > 0) {
const formatted = await formatMessages([...error.diagnostics], {
color: false,
kind: "error",
});
process.stderr.write(formatted.join(""));
}
return 1;
}
process.stderr.write("Module packaging failed unexpectedly.\n");
return 1;
}
}
function isDirectExecution(): boolean {
const entrypoint = process.argv[1];
// Run through the committed launcher, which imports this file, or directly.
return (
entrypoint !== undefined &&
(import.meta.url === pathToFileURL(path.resolve(entrypoint)).href ||
path.basename(entrypoint) === "ekklesia-pack-module.mjs" ||
path.basename(entrypoint) === "ekklesia-pack-module")
);
}
if (isDirectExecution()) {
process.exitCode = await main();
}
+1
View File
@@ -0,0 +1 @@
export * from "./pack-module.js";
+176
View File
@@ -0,0 +1,176 @@
import { readdir, readFile } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import type { BundledPackage } from "./packages.js";
/**
* `THIRD_PARTY_NOTICES` (ADR 0049): the licence and notice text that redistributing a module
* package requires, generated from what was actually bundled.
*
* - **Discovery.** Every source file in every bundle (the backend, and each browser artifact) is
* traced to the package it belongs to. Packages installed under `node_modules` are third-party
* software and are credited here. The Ekklesia platform's own packages, and the module itself,
* are not third-party software: the platform packages appear in `ekklesia.package.json` under
* `incorporates`.
* - **Content.** For each third-party package: its name, version, declared licence, where in the
* package it is used, and the complete text of every licence, copying, copyright, and notice file
* it ships at its root, plus the file a `SEE LICENSE IN` declaration names.
* - **Duplicates.** A package bundled into several artifacts is credited once, listing every
* artifact kind it is used in. Different packages are never merged, even when their texts match,
* so each component keeps its own notice.
* - **Gaps fail.** A package that declares no licence, or whose licence cannot be established,
* stops packaging. When a package ships no licence file, the standard text of its declared licence
* is supplied only if that licence's standard text is complete without project-specific lines
* (Apache-2.0). An MIT, ISC, or BSD package without its file is refused, because its copyright line
* exists only in that file.
*
* Everything is sorted, and line endings are normalized, so the file is byte-identical for identical
* inputs.
*/
export type NoticeErrorCode = "MODULE_PACK_LICENCE_TEXT_MISSING" | "MODULE_PACK_LICENCE_UNKNOWN";
export class NoticeError extends Error {
readonly code: NoticeErrorCode;
readonly component: string;
constructor(code: NoticeErrorCode, component: string) {
super(code);
this.name = "NoticeError";
this.code = code;
this.component = component;
}
}
export type NoticeUse = "backend" | "browser";
export interface NoticeComponent {
readonly bundled: BundledPackage;
readonly uses: ReadonlySet<NoticeUse>;
}
/** Licences whose standard text is complete without any project-specific line, so supplying it
* for a package that ships none is faithful. Checked into this package (`licenses/`). */
const STANDARD_TEXTS: Readonly<Record<string, string>> = Object.freeze({
"Apache-2.0": "Apache-2.0.txt",
});
const LICENCE_FILE =
/^(?:licen[cs]e(?:[-._][a-z0-9.-]+)?|copying(?:[-._][a-z0-9.-]+)?|notice(?:[-._][a-z0-9.-]+)?|copyright(?:[-._][a-z0-9.-]+)?)$/iu;
const RULE = "-".repeat(80);
function licenceOf(bundled: BundledPackage): string | undefined {
const declared = bundled.manifest.license;
if (typeof declared === "string" && declared.trim() !== "") {
return declared.trim();
}
if (typeof declared === "object" && declared !== null && "type" in declared) {
const type = (declared as { type?: unknown }).type;
return typeof type === "string" && type.trim() !== "" ? type.trim() : undefined;
}
return undefined;
}
function normalized(text: string): string {
return `${text.replaceAll("\r\n", "\n").replaceAll("\r", "\n").replace(/\s+$/u, "")}\n`;
}
function compare(left: string, right: string): number {
return left < right ? -1 : left > right ? 1 : 0;
}
async function licenceTexts(
bundled: BundledPackage,
licence: string,
): Promise<readonly { readonly title: string; readonly text: string }[]> {
const names = (await readdir(bundled.root, { withFileTypes: true }))
.filter((entry) => entry.isFile() && LICENCE_FILE.test(entry.name))
.map((entry) => entry.name)
.sort(compare);
const seeFile = /^SEE LICEN[CS]E IN (.+)$/iu.exec(licence)?.[1]?.trim();
if (seeFile && !names.includes(seeFile)) {
const resolved = path.resolve(bundled.root, seeFile);
if (resolved.startsWith(`${bundled.root}${path.sep}`)) {
names.push(seeFile);
}
}
const texts: { title: string; text: string }[] = [];
for (const name of names) {
texts.push({
text: normalized(await readFile(path.join(bundled.root, name), "utf8")),
title: name,
});
}
return texts;
}
const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
async function standardText(licence: string): Promise<string | undefined> {
const file = STANDARD_TEXTS[licence];
return file === undefined
? undefined
: normalized(await readFile(path.join(packageRoot, "licenses", file), "utf8"));
}
export async function thirdPartyNotices(
components: ReadonlyMap<string, NoticeComponent>,
): Promise<string> {
const ordered = [...components.values()].sort(
(left, right) =>
compare(left.bundled.name, right.bundled.name) ||
compare(left.bundled.version, right.bundled.version),
);
const sections: string[] = [];
for (const { bundled, uses } of ordered) {
const component = `${bundled.name}@${bundled.version}`;
const licence = licenceOf(bundled);
if (licence === undefined || /^UNLICEN[CS]ED$/iu.test(licence)) {
throw new NoticeError("MODULE_PACK_LICENCE_UNKNOWN", component);
}
let texts = await licenceTexts(bundled, licence);
// A NOTICE file alone is not the licence: the licence text must still come from somewhere.
if (!texts.some((text) => !/^notice/iu.test(text.title))) {
const standard = await standardText(licence);
if (standard === undefined) {
throw new NoticeError("MODULE_PACK_LICENCE_TEXT_MISSING", component);
}
texts = [
...texts,
{
text: `The package ships no licence file. This is the standard text of the licence it declares (${licence}).\n\n${standard}`,
title: `Standard ${licence} text`,
},
];
}
sections.push(
[
RULE,
`${bundled.name} ${bundled.version}`,
`License: ${licence}`,
`Used in: ${[...uses].sort(compare).join(", ")}`,
RULE,
"",
...texts.flatMap((text) => [`[${text.title}]`, "", text.text]),
].join("\n"),
);
}
const header = [
"THIRD-PARTY SOFTWARE NOTICES",
"",
"This module package includes the third-party software listed below. Each entry names the",
"component, its version, its declared licence, where in the package it is used, and the licence",
"and notice text that came with it.",
"",
"The Ekklesia platform's own packages are not third-party software. They are listed in",
'ekklesia.package.json under "incorporates".',
"",
ordered.length === 0
? "This module package includes no third-party software."
: `${ordered.length} ${ordered.length === 1 ? "component" : "components"}.`,
"",
].join("\n");
return `${[header, ...sections].join("\n").replace(/\n+$/u, "")}\n`;
}
+588
View File
@@ -0,0 +1,588 @@
import { createHash } from "node:crypto";
import { lstat, mkdir, readdir, readFile, realpath, rename, writeFile } from "node:fs/promises";
import { isBuiltin } from "node:module";
import path from "node:path";
import { fileURLToPath } from "node:url";
import {
MODULE_PACKAGE_ANY_TARGET,
MODULE_PACKAGE_FORMAT,
MODULE_PACKAGE_METADATA_FILENAME,
MODULE_PACKAGE_NOTICES_FILENAME,
type ModuleManifest,
modulePackageArchiveFileName,
modulePackageRuntimePackage,
safeParseModuleManifest,
safeParseModulePackageMetadata,
} from "@ekklesia/contracts";
import { analyzeModuleUi, ModuleUiBuildError } from "@ekklesia/module-ui-build";
import { build, type Message, type Plugin } from "esbuild";
import { minVersion, satisfies, validRange } from "semver";
import { NoticeError, type NoticeComponent, type NoticeUse, thirdPartyNotices } from "./notices.js";
import { BundledPackageIndex, isNativeOrPlatformSpecific } from "./packages.js";
import { deterministicTarGz, TarPathError } from "./tar.js";
/**
* The canonical Ekklesia module packager (ADR 0049): turns a built module directory into one
* deterministic Module Package v1 archive and its `.sha256` line.
*
* It reads the module directory and the dependencies its workspace installed, and writes only the
* two output files. It never installs, links, or deploys anything, so the workspace it runs in is
* left exactly as it was. In this release it produces the portable `any` target only.
*/
export type ModulePackErrorCode =
| "MODULE_PACK_BUNDLE_FAILED"
| "MODULE_PACK_DATABASE_RUNTIME_CONFLICT"
| "MODULE_PACK_ENTRYPOINT_MISSING"
| "MODULE_PACK_ENTRYPOINT_UNSUPPORTED"
| "MODULE_PACK_EXTERNAL_DEPENDENCY"
| "MODULE_PACK_FORBIDDEN_FILE"
| "MODULE_PACK_IDENTITY_MISMATCH"
| "MODULE_PACK_INCORPORATED_CONFLICT"
| "MODULE_PACK_LAYOUT_CONFLICT"
| "MODULE_PACK_LICENCE_TEXT_MISSING"
| "MODULE_PACK_LICENCE_UNKNOWN"
| "MODULE_PACK_MANIFEST_INVALID"
| "MODULE_PACK_METADATA_INVALID"
| "MODULE_PACK_MIGRATIONS_INVALID"
| "MODULE_PACK_NATIVE_DEPENDENCY"
| "MODULE_PACK_OUTPUT_FAILED"
| "MODULE_PACK_PATH_UNSUPPORTED"
| "MODULE_PACK_REVISION_INVALID"
| "MODULE_PACK_RUNTIME_UNKNOWN"
| "MODULE_PACK_SDK_INCOMPATIBLE"
| "MODULE_PACK_TARGET_UNSUPPORTED"
| "MODULE_PACK_UI_STALE";
const messages: Readonly<Record<ModulePackErrorCode, string>> = Object.freeze({
MODULE_PACK_BUNDLE_FAILED:
"The backend could not be bundled into self-contained files. Every import must resolve and bundle cleanly.",
MODULE_PACK_DATABASE_RUNTIME_CONFLICT: "The backend bundles more than one Drizzle version.",
MODULE_PACK_ENTRYPOINT_MISSING:
"A declared backend entrypoint does not exist. Build the module before packaging it.",
MODULE_PACK_ENTRYPOINT_UNSUPPORTED:
"Backend entrypoints must be compiled .js files to be bundled into a Module Package v1.",
MODULE_PACK_EXTERNAL_DEPENDENCY:
"The backend bundle would import a dependency at run time; an any-target package bundles everything.",
MODULE_PACK_FORBIDDEN_FILE:
"The package would contain a source, test, declaration, source-map, or dependency file.",
MODULE_PACK_IDENTITY_MISMATCH:
"package.json must declare a name and the same version as ekklesia.module.json.",
MODULE_PACK_INCORPORATED_CONFLICT:
"The package bundles more than one version of an Ekklesia package.",
MODULE_PACK_LAYOUT_CONFLICT: "Two package files would have the same path.",
MODULE_PACK_LICENCE_TEXT_MISSING:
"A bundled package ships no licence text, and its licence has no complete standard text to supply.",
MODULE_PACK_LICENCE_UNKNOWN: "A bundled package declares no licence that permits redistribution.",
MODULE_PACK_MANIFEST_INVALID: "ekklesia.module.json is missing or invalid.",
MODULE_PACK_METADATA_INVALID: "The generated package metadata failed its own contract.",
MODULE_PACK_MIGRATIONS_INVALID:
"The migrations directory must hold only regular files and directories.",
MODULE_PACK_NATIVE_DEPENDENCY:
"The backend bundles a native or platform-specific dependency, which an any-target package cannot contain.",
MODULE_PACK_OUTPUT_FAILED: "The package could not be written to the output directory.",
MODULE_PACK_PATH_UNSUPPORTED:
"A package path is not portable printable ASCII that fits the archive format.",
MODULE_PACK_REVISION_INVALID: "The source revision must be a full 40- or 64-character commit id.",
MODULE_PACK_RUNTIME_UNKNOWN:
"The Node.js range could not be determined. Run inside an Ekklesia workspace, or pass one.",
MODULE_PACK_SDK_INCOMPATIBLE:
"The bundled Module SDK is outside the manifest's own compatibility.moduleSdk range.",
MODULE_PACK_TARGET_UNSUPPORTED: "This packager produces the any target only.",
MODULE_PACK_UI_STALE:
"A declared browser artifact is missing or out of date. Build the module before packaging it.",
});
export class ModulePackError extends Error {
readonly code: ModulePackErrorCode;
/** The package or path the failure concerns, when there is one. A module author's own build
* input, never a secret. */
readonly detail?: string;
/** esbuild's structured diagnostics, for a command line to print. */
readonly diagnostics: readonly Message[];
constructor(code: ModulePackErrorCode, detail?: string, diagnostics: readonly Message[] = []) {
super(detail === undefined ? messages[code] : `${messages[code]} (${detail})`);
this.name = "ModulePackError";
this.code = code;
if (detail !== undefined) {
this.detail = detail;
}
this.diagnostics = diagnostics;
}
}
export interface PackModuleOptions {
/** A built module directory: its manifest, package.json, and compiled entrypoints. */
readonly moduleDirectory: string;
/** `any` only, in this release. */
readonly target: string;
readonly outDirectory: string;
/** The full commit id the module was built from. */
readonly sourceRevision: string;
/** Informational only; recorded as given. */
readonly repository?: string;
/** The Node.js range the package is built for. Defaults to the enclosing workspace's
* `engines.node`, the toolchain the module was built and tested with. */
readonly nodeRange?: string;
}
export interface ModulePackResult {
readonly archivePath: string;
readonly sha256Path: string;
readonly sha256: string;
readonly archiveBytes: number;
readonly moduleId: string;
readonly version: string;
readonly target: string;
/** Payload files, the metadata file not included. */
readonly fileCount: number;
}
const NATIVE_GUARD = "ekklesia-native-addon-guard";
/** Refuses a compiled addon the moment the bundle reaches one. */
const nativeAddonGuard: Plugin = {
name: NATIVE_GUARD,
setup(pluginBuild) {
pluginBuild.onLoad({ filter: /\.node$/i }, () => ({
errors: [{ text: "A compiled Node.js addon cannot be bundled into an any-target package." }],
}));
},
};
/** Bundled CommonJS code that requires a Node built-in needs a `require` in module scope. Nothing
* else can be required at run time: every other dependency is inside the bundle, and the packager
* refuses a bundle that would import anything but a built-in. */
const REQUIRE_BANNER =
'import { createRequire as __ekklesiaCreateRequire } from "node:module";\nconst require = __ekklesiaCreateRequire(import.meta.url);';
function compare(left: string, right: string): number {
return left < right ? -1 : left > right ? 1 : 0;
}
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);
}
async function readJson(file: string): Promise<unknown> {
return JSON.parse(await readFile(file, "utf8")) as unknown;
}
const ownManifestPath = path.resolve(
path.dirname(fileURLToPath(import.meta.url)),
"..",
"package.json",
);
async function packagerIdentity(): Promise<{ readonly name: string; readonly version: string }> {
const manifest = (await readJson(ownManifestPath)) as Record<string, unknown>;
return { name: String(manifest.name), version: String(manifest.version) };
}
async function workspaceNodeRange(moduleDirectory: string): Promise<string> {
let directory = moduleDirectory;
for (;;) {
try {
await lstat(path.join(directory, "pnpm-workspace.yaml"));
const manifest = await readJson(path.join(directory, "package.json"));
const engines = isRecord(manifest) ? manifest.engines : undefined;
const range = isRecord(engines) ? engines.node : undefined;
if (typeof range === "string") {
return range;
}
break;
} catch {
const parent = path.dirname(directory);
if (parent === directory) {
break;
}
directory = parent;
}
}
throw new ModulePackError("MODULE_PACK_RUNTIME_UNKNOWN");
}
/** `./dist/server/index.js` → `dist/server/index.js`, inside the module directory. */
function moduleRelative(declared: string): string {
return path.posix.normalize(declared.replace(/^\.\//u, ""));
}
function portable(relative: string): boolean {
const segments = relative.split("/");
return (
/^[\x20-\x7e]+$/u.test(relative) &&
!relative.includes("\\") &&
segments.every((segment) => segment !== "" && segment !== "." && segment !== "..")
);
}
function forbidden(relative: string): boolean {
return (
/(^|\/)(src|tests?|node_modules)\//u.test(relative) ||
/\.(map|d\.ts|d\.mts|d\.cts)$/u.test(relative)
);
}
async function readTree(root: string, relative: string, files: Map<string, Buffer>): Promise<void> {
const directory = path.join(root, relative);
for (const entry of (await readdir(directory, { withFileTypes: true })).sort((left, right) =>
compare(left.name, right.name),
)) {
const childRelative = `${relative}/${entry.name}`;
const stats = await lstat(path.join(directory, entry.name));
if (stats.isDirectory()) {
await readTree(root, childRelative, files);
} else if (stats.isFile()) {
files.set(childRelative, await readFile(path.join(directory, entry.name)));
} else {
throw new ModulePackError("MODULE_PACK_MIGRATIONS_INVALID", childRelative);
}
}
}
interface BackendBundle {
readonly outputs: ReadonlyMap<string, Buffer>;
readonly inputs: readonly string[];
}
async function bundleBackend(
moduleDirectory: string,
manifest: ModuleManifest,
nodeRange: string,
): Promise<BackendBundle> {
const declared = [
manifest.entrypoints.server,
manifest.entrypoints.schema,
manifest.entrypoints.jobs,
].filter((entrypoint): entrypoint is string => entrypoint !== undefined);
const entryPoints: { in: string; out: string }[] = [];
for (const entrypoint of declared) {
const relative = moduleRelative(entrypoint);
if (!relative.endsWith(".js")) {
throw new ModulePackError("MODULE_PACK_ENTRYPOINT_UNSUPPORTED", relative);
}
try {
if (!(await lstat(path.join(moduleDirectory, relative))).isFile()) {
throw new Error("not a file");
}
} catch {
throw new ModulePackError("MODULE_PACK_ENTRYPOINT_MISSING", relative);
}
entryPoints.push({ in: `./${relative}`, out: relative.slice(0, -".js".length) });
}
const minimum = minVersion(nodeRange);
let result: Awaited<ReturnType<typeof build>>;
try {
result = await build({
absWorkingDir: moduleDirectory,
banner: { js: REQUIRE_BANNER },
bundle: true,
chunkNames: "dist/chunks/[name]-[hash]",
entryPoints,
format: "esm",
// Licence comments stay in the code as well; THIRD_PARTY_NOTICES is what satisfies the
// licences, never these comments alone.
legalComments: "eof",
logLevel: "silent",
metafile: true,
minify: false,
outdir: moduleDirectory,
platform: "node",
plugins: [nativeAddonGuard],
sourcemap: false,
splitting: true,
target: `node${minimum?.version ?? "24"}`,
write: false,
});
} catch (error) {
const errors =
isRecord(error) && Array.isArray(error.errors) ? (error.errors as Message[]) : [];
const native = errors.some((message) => message.pluginName === NATIVE_GUARD);
throw new ModulePackError(
native ? "MODULE_PACK_NATIVE_DEPENDENCY" : "MODULE_PACK_BUNDLE_FAILED",
undefined,
errors,
);
}
// A warning is a bundle that might not run: a require it could not follow, an import it could
// not see. An installed module has nothing to fall back on, so a warning fails packaging.
if (result.warnings.length > 0) {
throw new ModulePackError("MODULE_PACK_BUNDLE_FAILED", undefined, result.warnings);
}
const { metafile, outputFiles } = result;
if (metafile === undefined || outputFiles === undefined) {
throw new ModulePackError("MODULE_PACK_BUNDLE_FAILED");
}
for (const output of Object.values(metafile.outputs)) {
for (const imported of output.imports) {
if (imported.external && !isBuiltin(imported.path)) {
throw new ModulePackError("MODULE_PACK_EXTERNAL_DEPENDENCY", imported.path);
}
}
}
const outputs = new Map<string, Buffer>();
for (const outputFile of outputFiles) {
outputs.set(
path.relative(moduleDirectory, outputFile.path).split(path.sep).join("/"),
Buffer.from(outputFile.contents),
);
}
return {
inputs: Object.keys(metafile.inputs).map((input) => path.resolve(moduleDirectory, input)),
outputs,
};
}
export async function packModule(options: PackModuleOptions): Promise<ModulePackResult> {
if (options.target !== MODULE_PACKAGE_ANY_TARGET) {
throw new ModulePackError("MODULE_PACK_TARGET_UNSUPPORTED", options.target);
}
if (!/^(?:[0-9a-f]{40}|[0-9a-f]{64})$/u.test(options.sourceRevision)) {
throw new ModulePackError("MODULE_PACK_REVISION_INVALID");
}
const moduleDirectory = await realpath(path.resolve(options.moduleDirectory)).catch(() => {
throw new ModulePackError("MODULE_PACK_MANIFEST_INVALID");
});
const manifestBytes = await readFile(path.join(moduleDirectory, "ekklesia.module.json")).catch(
() => {
throw new ModulePackError("MODULE_PACK_MANIFEST_INVALID");
},
);
let manifestInput: unknown;
try {
manifestInput = JSON.parse(manifestBytes.toString("utf8"));
} catch {
throw new ModulePackError("MODULE_PACK_MANIFEST_INVALID");
}
const parsedManifest = safeParseModuleManifest(manifestInput);
if (!parsedManifest.success) {
throw new ModulePackError("MODULE_PACK_MANIFEST_INVALID");
}
const manifest = parsedManifest.data;
const developmentPackage = await readJson(path.join(moduleDirectory, "package.json")).catch(
() => undefined,
);
if (
!isRecord(developmentPackage) ||
typeof developmentPackage.name !== "string" ||
developmentPackage.name === "" ||
developmentPackage.version !== manifest.version
) {
throw new ModulePackError("MODULE_PACK_IDENTITY_MISMATCH");
}
const nodeRange = options.nodeRange ?? (await workspaceNodeRange(moduleDirectory));
if (validRange(nodeRange) === null) {
throw new ModulePackError("MODULE_PACK_RUNTIME_UNKNOWN");
}
// 1. The backend, bundled with every pure-JavaScript dependency it uses.
const backend = await bundleBackend(moduleDirectory, manifest, nodeRange);
// 2. The browser artifacts, exactly as built, with what went into each.
let ui: Awaited<ReturnType<typeof analyzeModuleUi>>;
try {
ui = await analyzeModuleUi({ moduleDirectory });
} catch (error) {
throw new ModulePackError(
"MODULE_PACK_UI_STALE",
error instanceof ModuleUiBuildError ? error.code : undefined,
);
}
// 3. Every bundled source file traced to its package.
const index = new BundledPackageIndex();
const components = new Map<
string,
{ bundled: Awaited<ReturnType<typeof index.packageOf>>; uses: Set<NoticeUse> }
>();
const incorporates = new Map<string, string>();
const drizzleVersions = new Set<string>();
const sources: { readonly file: string; readonly use: NoticeUse }[] = [
...backend.inputs.map((file) => ({ file, use: "backend" as const })),
...ui.artifacts.flatMap((artifact) =>
artifact.inputs.map((file) => ({ file, use: "browser" as const })),
),
];
for (const { file, use } of sources) {
const bundled = await index.packageOf(file);
if (bundled === undefined || bundled.root === moduleDirectory) {
continue;
}
if (!bundled.thirdParty) {
if (bundled.name.startsWith("@ekklesia/")) {
const known = incorporates.get(bundled.name);
if (known !== undefined && known !== bundled.version) {
throw new ModulePackError("MODULE_PACK_INCORPORATED_CONFLICT", bundled.name);
}
incorporates.set(bundled.name, bundled.version);
}
continue;
}
if (use === "backend" && bundled.name === "drizzle-orm") {
drizzleVersions.add(bundled.version);
}
const key = `${bundled.name}@${bundled.version}`;
const existing = components.get(key);
if (existing) {
existing.uses.add(use);
} else {
if (use === "backend" && (await isNativeOrPlatformSpecific(bundled))) {
throw new ModulePackError("MODULE_PACK_NATIVE_DEPENDENCY", key);
}
components.set(key, { bundled, uses: new Set([use]) });
}
}
if (drizzleVersions.size > 1) {
throw new ModulePackError("MODULE_PACK_DATABASE_RUNTIME_CONFLICT");
}
const sdk = incorporates.get("@ekklesia/module-sdk");
if (sdk !== undefined && !satisfies(sdk, manifest.compatibility.moduleSdk)) {
throw new ModulePackError("MODULE_PACK_SDK_INCOMPATIBLE", sdk);
}
// 4. The payload.
const files = new Map<string, Buffer>();
const add = (relative: string, data: Buffer): void => {
if (!portable(relative)) {
throw new ModulePackError("MODULE_PACK_PATH_UNSUPPORTED", relative);
}
if (forbidden(relative)) {
throw new ModulePackError("MODULE_PACK_FORBIDDEN_FILE", relative);
}
if (files.has(relative) || relative === MODULE_PACKAGE_METADATA_FILENAME) {
throw new ModulePackError("MODULE_PACK_LAYOUT_CONFLICT", relative);
}
files.set(relative, data);
};
for (const [relative, data] of backend.outputs) {
add(relative, data);
}
for (const artifact of ui.artifacts) {
if (!artifact.builtArtifactCurrent) {
throw new ModulePackError(
"MODULE_PACK_UI_STALE",
path.relative(moduleDirectory, artifact.outputFile),
);
}
add(
path.relative(moduleDirectory, artifact.outputFile).split(path.sep).join("/"),
await readFile(artifact.outputFile),
);
}
if (manifest.entrypoints.migrations !== undefined) {
const migrations = new Map<string, Buffer>();
try {
await readTree(moduleDirectory, moduleRelative(manifest.entrypoints.migrations), migrations);
} catch (error) {
if (error instanceof ModulePackError) {
throw error;
}
throw new ModulePackError("MODULE_PACK_MIGRATIONS_INVALID");
}
for (const [relative, data] of migrations) {
add(relative, data);
}
}
add("ekklesia.module.json", manifestBytes);
add(
"package.json",
Buffer.from(
`${JSON.stringify(
modulePackageRuntimePackage({ name: developmentPackage.name, version: manifest.version }),
null,
2,
)}\n`,
),
);
try {
add(
MODULE_PACKAGE_NOTICES_FILENAME,
Buffer.from(await thirdPartyNotices(components as ReadonlyMap<string, NoticeComponent>)),
);
} catch (error) {
if (error instanceof NoticeError) {
throw new ModulePackError(error.code, error.component);
}
throw error;
}
// 5. The metadata, checked against its own contract before anything is written.
const listed = [...files.entries()]
.sort(([left], [right]) => compare(left, right))
.map(([relative, data]) => ({ path: relative, sha256: sha256(data), size: data.length }));
const metadata = {
packageFormat: MODULE_PACKAGE_FORMAT,
module: { id: manifest.id, version: manifest.version },
target: MODULE_PACKAGE_ANY_TARGET,
runtime: { node: nodeRange },
provenance: {
revision: options.sourceRevision,
...(options.repository === undefined ? {} : { repository: options.repository }),
packager: await packagerIdentity(),
},
incorporates: Object.fromEntries(
[...incorporates].sort(([left], [right]) => compare(left, right)),
),
...(drizzleVersions.size === 1
? { transitional: { databaseRuntime: { "drizzle-orm": [...drizzleVersions][0] } } }
: {}),
files: listed.map((file) => ({ path: file.path, size: file.size, sha256: file.sha256 })),
};
if (!safeParseModulePackageMetadata(metadata).success) {
throw new ModulePackError("MODULE_PACK_METADATA_INVALID");
}
const payload = new Map(files);
payload.set(
MODULE_PACKAGE_METADATA_FILENAME,
Buffer.from(`${JSON.stringify(metadata, null, 2)}\n`),
);
// 6. The archive and its digest.
let archive: Buffer;
try {
archive = deterministicTarGz(manifest.id, payload);
} catch (error) {
if (error instanceof TarPathError) {
throw new ModulePackError("MODULE_PACK_PATH_UNSUPPORTED", error.path);
}
throw error;
}
const digest = sha256(archive);
const fileName = modulePackageArchiveFileName(
manifest.id,
manifest.version,
MODULE_PACKAGE_ANY_TARGET,
);
const outDirectory = path.resolve(options.outDirectory);
const archivePath = path.join(outDirectory, fileName);
const sha256Path = `${archivePath}.sha256`;
try {
await mkdir(outDirectory, { recursive: true });
const staging = `${archivePath}.partial-${process.pid}`;
await writeFile(staging, archive);
await rename(staging, archivePath);
await writeFile(`${sha256Path}.partial-${process.pid}`, `${digest} ${fileName}\n`);
await rename(`${sha256Path}.partial-${process.pid}`, sha256Path);
} catch {
throw new ModulePackError("MODULE_PACK_OUTPUT_FAILED");
}
return Object.freeze({
archiveBytes: archive.length,
archivePath,
fileCount: listed.length,
moduleId: manifest.id,
sha256: digest,
sha256Path,
target: MODULE_PACKAGE_ANY_TARGET,
version: manifest.version,
});
}
+154
View File
@@ -0,0 +1,154 @@
import { access, readdir, readFile, realpath } from "node:fs/promises";
import path from "node:path";
/**
* What the packager knows about a package whose files were bundled: where it is, what it is called,
* and whether it is third-party software. A package installed under a `node_modules` directory is
* third-party; one that is not (a workspace package, or the module itself) is the platform's or the
* module's own code.
*/
export interface BundledPackage {
readonly root: string;
readonly name: string;
readonly version: string;
readonly thirdParty: boolean;
readonly manifest: Readonly<Record<string, unknown>>;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
async function readManifest(directory: string): Promise<Record<string, unknown> | undefined> {
try {
const parsed = JSON.parse(
await readFile(path.join(directory, "package.json"), "utf8"),
) as unknown;
return isRecord(parsed) ? parsed : undefined;
} catch {
return undefined;
}
}
/** Finds the package a bundled file belongs to: the nearest enclosing directory whose package.json
* names a package and its version. Nested `package.json` files that only set a module type, as
* some libraries ship in subdirectories, are skipped. */
export class BundledPackageIndex {
readonly #byDirectory = new Map<string, BundledPackage | undefined>();
async packageOf(file: string): Promise<BundledPackage | undefined> {
let directory = path.dirname(await realpath(file).catch(() => file));
const visited: string[] = [];
let found: BundledPackage | undefined;
for (;;) {
if (this.#byDirectory.has(directory)) {
found = this.#byDirectory.get(directory);
break;
}
visited.push(directory);
const manifest = await readManifest(directory);
if (manifest && typeof manifest.name === "string" && typeof manifest.version === "string") {
found = Object.freeze({
manifest: Object.freeze(manifest),
name: manifest.name,
root: directory,
thirdParty: directory.split(path.sep).includes("node_modules"),
version: manifest.version,
});
break;
}
const parent = path.dirname(directory);
if (parent === directory) {
break;
}
directory = parent;
}
for (const seen of visited) {
this.#byDirectory.set(seen, found);
}
return found;
}
}
async function exists(file: string): Promise<boolean> {
try {
await access(file);
return true;
} catch {
return false;
}
}
function declaresPlatform(manifest: Readonly<Record<string, unknown>>): boolean {
return ["os", "cpu", "libc"].some((field) => manifest[field] !== undefined);
}
async function containsCompiledAddon(directory: string): Promise<boolean> {
const entries = await readdir(directory, { withFileTypes: true }).catch(() => []);
for (const entry of entries) {
if (entry.isDirectory()) {
if (
entry.name !== "node_modules" &&
(await containsCompiledAddon(path.join(directory, entry.name)))
) {
return true;
}
} else if (entry.name.toLowerCase().endsWith(".node")) {
return true;
}
}
return false;
}
/** The installed package a dependency name resolves to from `root`, by Node's own directory walk. */
async function installedDependency(
root: string,
name: string,
): Promise<Record<string, unknown> | undefined> {
let directory = root;
for (;;) {
if (path.basename(directory) !== "node_modules") {
const manifest = await readManifest(path.join(directory, "node_modules", ...name.split("/")));
if (manifest) {
return manifest;
}
}
const parent = path.dirname(directory);
if (parent === directory) {
return undefined;
}
directory = parent;
}
}
/**
* Whether a bundled package is native or platform-specific, which an `any` package may never
* contain (ADR 0049): it declares an operating system, CPU, or C library; it is built with node-gyp;
* it ships a compiled `.node` addon; or one of its installed dependencies declares a platform, as
* packages that load a per-platform binary do.
*/
export async function isNativeOrPlatformSpecific(bundled: BundledPackage): Promise<boolean> {
const manifest = bundled.manifest;
if (declaresPlatform(manifest) || manifest.gypfile === true) {
return true;
}
if (await exists(path.join(bundled.root, "binding.gyp"))) {
return true;
}
if (await containsCompiledAddon(bundled.root)) {
return true;
}
for (const field of ["dependencies", "optionalDependencies"]) {
const dependencies = manifest[field];
if (!isRecord(dependencies)) {
continue;
}
for (const name of Object.keys(dependencies)) {
const dependency = await installedDependency(bundled.root, name);
if (dependency && declaresPlatform(dependency)) {
return true;
}
}
}
return false;
}
+109
View File
@@ -0,0 +1,109 @@
import { gzipSync } from "node:zlib";
/**
* The deterministic archive encoding of Ekklesia Module Package v1 (ADR 0049).
*
* POSIX ustar, written by hand rather than by a general tar library, so every byte is decided here:
* - one top-level directory, then every directory and regular file in byte-wise path order, which
* puts every directory before what it contains;
* - uid and gid 0, empty user and group names, modification time 0, device fields 0;
* - directories 0755 and files 0644; no links, no special files, no extended or long-name headers;
* - exactly two zero blocks at the end;
* - gzip at a fixed level, with Node's header: no file name and a modification time of 0.
*
* The same packager version and the same input bytes therefore always produce the same archive.
*/
export class TarPathError extends Error {
readonly path: string;
constructor(entryPath: string) {
super("A path does not fit a portable ustar header.");
this.name = "TarPathError";
this.path = entryPath;
}
}
const BLOCK = 512;
const GZIP_LEVEL = 9;
function octal(value: number, width: number): string {
return `${value.toString(8).padStart(width - 1, "0")}\u0000`;
}
/** Splits a path into ustar's 155-byte prefix and 100-byte name, at a `/`. */
function splitPath(entryPath: string): { readonly name: string; readonly prefix: string } {
if (entryPath.length <= 100) {
return { name: entryPath, prefix: "" };
}
for (let index = entryPath.length - 1; index > 0; index -= 1) {
if (entryPath[index] === "/") {
const prefix = entryPath.slice(0, index);
const name = entryPath.slice(index + 1);
if (prefix.length <= 155 && name.length > 0 && name.length <= 100) {
return { name, prefix };
}
}
}
throw new TarPathError(entryPath);
}
function header(entryPath: string, directory: boolean, size: number): Buffer {
const block = Buffer.alloc(BLOCK);
const { name, prefix } = splitPath(directory ? `${entryPath}/` : entryPath);
block.write(name, 0, 100, "latin1");
block.write(octal(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(directory ? "5" : "0", 156, "latin1");
block.write("ustar\u000000", 257, "latin1");
block.write(octal(0, 8), 329, "latin1");
block.write(octal(0, 8), 337, "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;
}
function compareBytes(left: string, right: string): number {
return left < right ? -1 : left > right ? 1 : 0;
}
/**
* Archives `files` (paths relative to the module directory, printable ASCII) under `topLevel`.
* Directories are implied by the file paths.
*/
export function deterministicTarGz(topLevel: string, files: ReadonlyMap<string, Buffer>): Buffer {
const directories = new Set<string>([topLevel]);
for (const filePath of files.keys()) {
const segments = filePath.split("/");
for (let index = 1; index < segments.length; index += 1) {
directories.add(`${topLevel}/${segments.slice(0, index).join("/")}`);
}
}
const entries: { readonly path: string; readonly data?: Buffer }[] = [
...[...directories].map((directory) => ({ path: directory })),
...[...files].map(([filePath, data]) => ({ data, path: `${topLevel}/${filePath}` })),
].sort((left, right) => compareBytes(left.path, right.path));
const parts: Buffer[] = [];
for (const entry of entries) {
if (entry.data === undefined) {
parts.push(header(entry.path, true, 0));
} else {
parts.push(header(entry.path, false, entry.data.length), entry.data);
const padding = (BLOCK - (entry.data.length % BLOCK)) % BLOCK;
if (padding > 0) {
parts.push(Buffer.alloc(padding));
}
}
}
parts.push(Buffer.alloc(BLOCK * 2));
return gzipSync(Buffer.concat(parts), { level: GZIP_LEVEL });
}
+74
View File
@@ -0,0 +1,74 @@
import { describe, expect, it } from "vitest";
import { parsePackModuleArguments } from "../src/cli.js";
const revision = "86ae907f59458508e3277666cdd214f5bd7c1078";
describe("ekklesia-pack-module arguments", () => {
it("takes the module, the target, the output directory, and the source revision", () => {
expect(
parsePackModuleArguments([
"--module",
"templates/module",
"--target",
"any",
"--out",
"dist/packages",
"--source-revision",
revision,
]),
).toEqual({
moduleDirectory: "templates/module",
outDirectory: "dist/packages",
sourceRevision: revision,
target: "any",
});
});
it("records a repository only when one is given", () => {
expect(
parsePackModuleArguments([
"--module",
".",
"--target",
"any",
"--out",
"out",
"--source-revision",
revision,
"--repository",
"https://example.org/acme/example.git",
]).repository,
).toBe("https://example.org/acme/example.git");
});
it.each([
["no arguments", []],
["a missing target", ["--module", ".", "--out", "o", "--source-revision", revision]],
["a missing revision", ["--module", ".", "--target", "any", "--out", "o"]],
[
"an unknown option",
["--module", ".", "--target", "any", "--out", "o", "--source-revision", revision, "--legacy"],
],
[
"a repeated option",
[
"--module",
".",
"--module",
"..",
"--target",
"any",
"--out",
"o",
"--source-revision",
revision,
],
],
[
"a stray positional argument",
["module", "--target", "any", "--out", "o", "--source-revision", revision],
],
])("refuses %s", (_label, argv) => {
expect(() => parsePackModuleArguments(argv)).toThrow();
});
});
@@ -0,0 +1,434 @@
import { createHash } from "node:crypto";
import { readFile } from "node:fs/promises";
import { isBuiltin } from "node:module";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { type ModulePackErrorCode, ModulePackError, packModule } from "../src/index.js";
import {
cleanupFixtures,
type FixtureModuleOptions,
fixtureModule,
SOURCE_REVISION,
scratchDirectory,
} from "./support/fixture-module.js";
import { fileEntries, readTarGz } from "./support/tar.js";
afterEach(cleanupFixtures);
const MIT = "MIT License\n\nCopyright (c) 2026 Example Author\n\nPermission is hereby granted.\n";
async function pack(options: FixtureModuleOptions = {}, overrides: Record<string, unknown> = {}) {
const moduleDirectory = await fixtureModule(options);
const outDirectory = await scratchDirectory("ekklesia-pack-out-");
const result = await packModule({
moduleDirectory,
nodeRange: ">=24.11.0 <25",
outDirectory,
sourceRevision: SOURCE_REVISION,
target: "any",
...overrides,
});
return { archive: await readFile(result.archivePath), moduleDirectory, outDirectory, result };
}
async function packRejection(
options: FixtureModuleOptions,
code: ModulePackErrorCode,
overrides: Record<string, unknown> = {},
): Promise<ModulePackError> {
try {
await pack(options, overrides);
} catch (error) {
expect(error).toBeInstanceOf(ModulePackError);
expect((error as ModulePackError).code).toBe(code);
return error as ModulePackError;
}
throw new Error(`Expected ${code}.`);
}
describe("packModule: what a package contains (ADR 0049)", () => {
it("produces one archive and its sha256sum line, under the recommended name", async () => {
const { archive, outDirectory, result } = await pack();
const digest = createHash("sha256").update(archive).digest("hex");
expect(path.basename(result.archivePath)).toBe("example-0.1.0-any.tar.gz");
expect(result.sha256).toBe(digest);
expect(await readFile(path.join(outDirectory, "example-0.1.0-any.tar.gz.sha256"), "utf8")).toBe(
`${digest} example-0.1.0-any.tar.gz\n`,
);
});
it("holds exactly the runtime payload, under one top-level directory named with the module id", async () => {
const { archive } = await pack({ withSchema: true });
const files = [...fileEntries(archive).keys()].sort();
expect(files).toEqual([
"example/THIRD_PARTY_NOTICES",
expect.stringMatching(/^example\/dist\/chunks\/chunk-[A-Z0-9]+\.js$/u),
"example/dist/schema/index.js",
"example/dist/server/index.js",
"example/drizzle/0000_initial.sql",
"example/drizzle/meta/0000_snapshot.json",
"example/drizzle/meta/_ekklesia.json",
"example/drizzle/meta/_journal.json",
"example/ekklesia.module.json",
"example/ekklesia.package.json",
"example/package.json",
]);
for (const file of files) {
expect(file).not.toMatch(/\/src\/|\/tests\/|\.d\.ts$|\.map$|README|node_modules/u);
}
expect(new Set(readTarGz(archive).map((entry) => entry.path.split("/")[0]))).toEqual(
new Set(["example"]),
);
});
it("generates the runtime package.json instead of copying the development one", async () => {
const { archive } = await pack();
const runtimePackage = fileEntries(archive).get("example/package.json")?.toString("utf8");
expect(JSON.parse(runtimePackage ?? "")).toEqual({
name: "@test/module-example",
version: "0.1.0",
private: true,
type: "module",
ekklesia: { layer: "module" },
});
for (const [file, data] of fileEntries(archive)) {
if (file.endsWith(".json")) {
expect(data.toString("utf8"), file).not.toContain("workspace:");
}
}
});
it("copies the manifest byte for byte", async () => {
const { archive, moduleDirectory } = await pack();
expect(fileEntries(archive).get("example/ekklesia.module.json")).toEqual(
await readFile(path.join(moduleDirectory, "ekklesia.module.json")),
);
});
it("describes the package completely in ekklesia.package.json, never hashing itself", async () => {
const { archive } = await pack({ withSchema: true });
const files = fileEntries(archive);
const metadata = JSON.parse(files.get("example/ekklesia.package.json")?.toString("utf8") ?? "");
expect(metadata).toMatchObject({
packageFormat: 1,
module: { id: "example", version: "0.1.0" },
target: "any",
runtime: { node: ">=24.11.0 <25" },
provenance: {
revision: SOURCE_REVISION,
packager: { name: "@ekklesia/module-pack", version: expect.any(String) },
},
incorporates: {
"@ekklesia/contracts": expect.any(String),
"@ekklesia/module-sdk": expect.any(String),
},
transitional: { databaseRuntime: { "drizzle-orm": "0.45.2" } },
});
const listed = metadata.files.map((file: { path: string }) => `example/${file.path}`);
expect(listed).not.toContain("example/ekklesia.package.json");
expect(new Set(listed)).toEqual(
new Set([...files.keys()].filter((file) => file !== "example/ekklesia.package.json")),
);
for (const file of metadata.files as { path: string; size: number; sha256: string }[]) {
const data = files.get(`example/${file.path}`);
expect(data?.length).toBe(file.size);
expect(
createHash("sha256")
.update(data ?? "")
.digest("hex"),
).toBe(file.sha256);
}
});
it("records only the provenance it was given, and a repository only when asked", async () => {
const { archive } = await pack({}, { repository: "https://example.org/acme/example.git" });
const metadata = JSON.parse(
fileEntries(archive).get("example/ekklesia.package.json")?.toString("utf8") ?? "",
);
expect(metadata.provenance.repository).toBe("https://example.org/acme/example.git");
});
it("omits transitional database facts from a module that bundles no Drizzle", async () => {
const { archive } = await pack({
serverSource: 'export default { id: "example", version: "0.1.0", register() {} };\n',
});
const metadata = JSON.parse(
fileEntries(archive).get("example/ekklesia.package.json")?.toString("utf8") ?? "",
);
expect(metadata.transitional).toBeUndefined();
expect(metadata.incorporates).toEqual({});
});
});
describe("packModule: a self-contained backend", () => {
it("bundles the SDK, contracts, and Drizzle, leaving only Node built-ins to import", async () => {
const { archive } = await pack({ withSchema: true });
for (const [file, data] of fileEntries(archive)) {
if (!file.endsWith(".js")) {
continue;
}
const code = data.toString("utf8");
const specifiers = [
...code.matchAll(/(?:^|\n)\s*(?:import|export)\s[^;]*?from\s*"([^"]+)"/gu),
...code.matchAll(/(?:^|\n)\s*import\s*"([^"]+)"/gu),
...code.matchAll(/\bimport\(\s*"([^"]+)"\s*\)/gu),
].map((match) => match[1] ?? "");
for (const specifier of specifiers) {
expect(specifier.startsWith(".") || isBuiltin(specifier), `${file}: ${specifier}`).toBe(
true,
);
}
}
});
it("refuses a dependency the bundle cannot resolve rather than leaving it external", async () => {
await packRejection(
{
serverSource:
'import pad from "ekklesia-not-installed";\nexport default { id: "example", version: "0.1.0", register() { pad(); } };\n',
},
"MODULE_PACK_BUNDLE_FAILED",
);
});
it.each([
[
"a package that declares an operating system",
{
name: "fake-native",
packageJson: { os: ["linux"], cpu: ["x64"] },
files: { "index.js": "export default 1;\n" },
},
],
[
"a package built with node-gyp",
{
name: "fake-gyp",
packageJson: { gypfile: true },
files: { "index.js": "export default 1;\n", "binding.gyp": "{}" },
},
],
[
"a package that ships a compiled addon",
{
name: "fake-addon",
files: {
"index.js": "export default 1;\n",
"build/Release/addon.node": new Uint8Array([0x7f, 0x45, 0x4c, 0x46]),
},
},
],
])("refuses %s in an any-target package", async (_label, fake) => {
await packRejection(
{
fakePackages: [fake],
serverSource: `import value from "${fake.name}";\nexport default { id: "example", version: "0.1.0", register() { return value; } };\n`,
},
"MODULE_PACK_NATIVE_DEPENDENCY",
);
});
it("refuses a package whose installed dependency is platform-specific", async () => {
await packRejection(
{
fakePackages: [
{
name: "fake-wrapper",
packageJson: { optionalDependencies: { "fake-wrapper-linux-x64": "1.0.0" } },
files: { "index.js": "export default 1;\n", LICENSE: MIT },
},
{
name: "fake-wrapper-linux-x64",
packageJson: { os: ["linux"], cpu: ["x64"] },
files: { "index.js": "export default 2;\n" },
},
],
serverSource:
'import value from "fake-wrapper";\nexport default { id: "example", version: "0.1.0", register() { return value; } };\n',
},
"MODULE_PACK_NATIVE_DEPENDENCY",
);
});
it("refuses any target but any in this release", async () => {
await packRejection({}, "MODULE_PACK_TARGET_UNSUPPORTED", { target: "linux-x64-glibc" });
});
});
describe("packModule: identity and inputs", () => {
it("refuses a development package.json whose version differs from the manifest", async () => {
await packRejection(
{ manifest: (manifest) => ({ ...manifest, version: "0.2.0" }) },
"MODULE_PACK_IDENTITY_MISMATCH",
);
});
it("refuses a module whose backend was never built", async () => {
await packRejection(
{
manifest: (manifest) => ({
...manifest,
entrypoints: { server: "./dist/missing/index.js" },
}),
},
"MODULE_PACK_ENTRYPOINT_MISSING",
);
});
it("refuses an abbreviated or missing source revision", async () => {
await packRejection({}, "MODULE_PACK_REVISION_INVALID", { sourceRevision: "86ae907" });
});
it("refuses an incorporated SDK outside the manifest's own range", async () => {
await packRejection(
{
manifest: (manifest) => ({
...manifest,
compatibility: { platform: "^0.1.0", moduleSdk: "^9.0.0" },
}),
},
"MODULE_PACK_SDK_INCOMPATIBLE",
);
});
});
describe("packModule: THIRD_PARTY_NOTICES", () => {
const usesFake = (name: string) =>
`import value from "${name}";\nexport default { id: "example", version: "0.1.0", register() { return value; } };\n`;
it("credits every bundled third-party package once, with its licence and notice text", async () => {
const { archive } = await pack({
fakePackages: [
{
name: "fake-mit",
version: "2.3.4",
files: {
"index.js": 'import other from "fake-apache-notice";\nexport default other;\n',
LICENSE: MIT,
},
},
{
name: "fake-apache-notice",
version: "1.0.0",
packageJson: { license: "Apache-2.0" },
files: {
"index.js": "export default 1;\n",
"LICENSE.txt": "Apache License text shipped by the package.\n",
NOTICE: "Fake Apache Notice\nCopyright 2026 The Fake Foundation\n",
},
},
],
serverSource: usesFake("fake-mit"),
});
const notices = fileEntries(archive).get("example/THIRD_PARTY_NOTICES")?.toString("utf8") ?? "";
expect(notices).toContain("fake-mit 2.3.4");
expect(notices).toContain("Copyright (c) 2026 Example Author");
expect(notices).toContain("fake-apache-notice 1.0.0");
expect(notices).toContain("Copyright 2026 The Fake Foundation");
expect(notices.match(/^fake-mit 2\.3\.4$/gmu)).toHaveLength(1);
// Only what the bundle actually contains is credited: this backend uses no Drizzle code.
expect(notices).not.toContain("drizzle-orm");
// The Ekklesia platform's own packages are credited in ekklesia.package.json, not here.
expect(notices).not.toMatch(/^@ekklesia\//mu);
});
it("supplies the standard Apache-2.0 text only for a package that ships none, and says so", async () => {
const { archive } = await pack({ withSchema: true });
const notices = fileEntries(archive).get("example/THIRD_PARTY_NOTICES")?.toString("utf8") ?? "";
const drizzle = notices.slice(notices.indexOf("drizzle-orm 0.45.2"));
expect(drizzle).toContain("License: Apache-2.0");
expect(drizzle).toContain("ships no licence file");
expect(drizzle).toContain("TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION");
});
it("is byte-identical across packages of the same inputs", async () => {
const options = { withSchema: true };
const first = await pack(options);
const second = await pack(options);
expect(fileEntries(first.archive).get("example/THIRD_PARTY_NOTICES")).toEqual(
fileEntries(second.archive).get("example/THIRD_PARTY_NOTICES"),
);
});
it("refuses a bundled package that declares no licence", async () => {
await packRejection(
{
fakePackages: [
{
name: "fake-unlicensed",
packageJson: { license: undefined },
files: { "index.js": "export default 1;\n" },
},
],
serverSource: usesFake("fake-unlicensed"),
},
"MODULE_PACK_LICENCE_UNKNOWN",
);
});
it("refuses an MIT package that ships no licence text, because only it holds the copyright line", async () => {
await packRejection(
{
fakePackages: [{ name: "fake-textless", files: { "index.js": "export default 1;\n" } }],
serverSource: usesFake("fake-textless"),
},
"MODULE_PACK_LICENCE_TEXT_MISSING",
);
});
});
describe("packModule: deterministic archives", () => {
it("produces byte-identical archives and digests for identical inputs", async () => {
const moduleDirectory = await fixtureModule({ withSchema: true });
const runs = [];
for (let run = 0; run < 2; run += 1) {
const outDirectory = await scratchDirectory("ekklesia-pack-out-");
const result = await packModule({
moduleDirectory,
nodeRange: ">=24.11.0 <25",
outDirectory,
sourceRevision: SOURCE_REVISION,
target: "any",
});
runs.push({ bytes: await readFile(result.archivePath), sha256: result.sha256 });
}
expect(runs[0]?.sha256).toBe(runs[1]?.sha256);
expect(runs[0]?.bytes.equals(runs[1]?.bytes ?? Buffer.alloc(0))).toBe(true);
});
it("normalizes every entry and the gzip header, and contains no links", async () => {
const { archive } = await pack({ withSchema: true });
// gzip: no file name flag, no modification time.
expect(archive[3]).toBe(0);
expect(archive.readUInt32LE(4)).toBe(0);
const entries = readTarGz(archive);
const paths = entries.map((entry) => entry.path);
expect(paths).toEqual(
[...paths].sort((left, right) => (left < right ? -1 : left > right ? 1 : 0)),
);
for (const entry of entries) {
expect(["0", "5"], entry.path).toContain(entry.typeflag);
expect(entry.mode, entry.path).toBe(entry.typeflag === "5" ? 0o755 : 0o644);
expect([entry.uid, entry.gid, entry.mtime]).toEqual([0, 0, 0]);
expect([entry.uname, entry.gname]).toEqual(["", ""]);
expect(entry.magic).toBe("ustar/00");
}
// Every directory precedes what it contains.
for (const entry of entries) {
const parent = entry.path.split("/").slice(0, -1).join("/");
if (parent !== "") {
expect(paths.indexOf(parent), entry.path).toBeLessThan(paths.indexOf(entry.path));
}
}
});
it("refers to no absolute path of the build machine", async () => {
const { archive, moduleDirectory } = await pack({ withSchema: true });
const root = path.dirname(path.dirname(moduleDirectory));
for (const [file, data] of fileEntries(archive)) {
expect(data.toString("utf8"), file).not.toContain(moduleDirectory);
expect(data.toString("utf8"), file).not.toContain(root);
}
});
});
@@ -0,0 +1,193 @@
import { mkdir, mkdtemp, realpath, rm, symlink, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
/**
* Generated module fixtures for the packager's tests: a built module directory in a temporary
* location, whose `node_modules` links to the real Module SDK and Drizzle this package depends on.
* Everything a development module directory holds is there, including what must never ship
* (`src/`, `tests/`, declarations, source maps, `workspace:` ranges), so a test can prove the
* packager leaves it out.
*/
const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..");
export interface FakePackage {
readonly name: string;
readonly version?: string;
readonly packageJson?: Record<string, unknown>;
/** Files relative to the fake package's root. */
readonly files?: Record<string, string | Uint8Array>;
}
export interface FixtureModuleOptions {
readonly id?: string;
readonly version?: string;
readonly serverSource?: string;
readonly withSchema?: boolean;
readonly fakePackages?: readonly FakePackage[];
readonly manifest?: (manifest: Record<string, unknown>) => Record<string, unknown>;
}
const temporaryDirectories: string[] = [];
export async function cleanupFixtures(): Promise<void> {
await Promise.all(
temporaryDirectories
.splice(0)
.map((directory) => rm(directory, { force: true, recursive: true })),
);
}
export async function scratchDirectory(prefix = "ekklesia-pack-"): Promise<string> {
const directory = await realpath(await mkdtemp(path.join(tmpdir(), prefix)));
temporaryDirectories.push(directory);
return directory;
}
async function write(file: string, contents: string | Uint8Array): Promise<void> {
await mkdir(path.dirname(file), { recursive: true });
await writeFile(file, contents);
}
const defaultServer = (
id: string,
version: string,
) => `import { defineModuleRuntime } from "@ekklesia/module-sdk";
export default defineModuleRuntime({ id: ${JSON.stringify(id)}, version: ${JSON.stringify(version)}, register() {} });
`;
const schemaSource = (
id: string,
) => `import { createModuleTable, defineModuleSchema } from "@ekklesia/module-sdk";
import { text, uuid } from "drizzle-orm/pg-core";
export const records = createModuleTable(${JSON.stringify(id)})("records", {
id: uuid("id").primaryKey(),
value: text("value").notNull(),
});
export default defineModuleSchema({ moduleId: ${JSON.stringify(id)}, tables: { records } });
`;
export async function fixtureModule(options: FixtureModuleOptions = {}): Promise<string> {
const id = options.id ?? "example";
const version = options.version ?? "0.1.0";
const root = await scratchDirectory("ekklesia-pack-fixture-");
const moduleDirectory = path.join(root, id);
const tableName = `${id.replaceAll("-", "_")}_records`;
const baseManifest: Record<string, unknown> = {
manifestVersion: 7,
id,
name: `${id} module`,
version,
compatibility: { platform: "^0.1.0", moduleSdk: "^0.1.0" },
capabilities: [],
permissions: [],
entrypoints: {
server: "./dist/server/index.js",
...(options.withSchema ? { schema: "./dist/schema/index.js" } : {}),
migrations: "./drizzle",
},
};
const manifest = options.manifest ? options.manifest(baseManifest) : baseManifest;
await write(
path.join(moduleDirectory, "ekklesia.module.json"),
`${JSON.stringify(manifest, null, 2)}\n`,
);
await write(
path.join(moduleDirectory, "package.json"),
`${JSON.stringify(
{
name: `@test/module-${id}`,
version,
private: true,
type: "module",
scripts: { build: "tsc" },
dependencies: { "@ekklesia/module-sdk": "workspace:*", "drizzle-orm": "0.45.2" },
devDependencies: { typescript: "7.0.2" },
exports: { ".": "./dist/server/index.js" },
ekklesia: { layer: "module" },
},
null,
2,
)}\n`,
);
await write(
path.join(moduleDirectory, "dist/server/index.js"),
options.serverSource ?? defaultServer(id, version),
);
await write(path.join(moduleDirectory, "dist/server/index.js.map"), '{"version":3}\n');
await write(path.join(moduleDirectory, "dist/server/index.d.ts"), "export {};\n");
if (options.withSchema) {
await write(path.join(moduleDirectory, "dist/schema/index.js"), schemaSource(id));
}
await write(path.join(moduleDirectory, "src/server/index.ts"), "export {};\n");
await write(path.join(moduleDirectory, "tests/server.test.ts"), "export {};\n");
await write(path.join(moduleDirectory, "README.md"), "# Development notes\n");
await write(
path.join(moduleDirectory, "drizzle/0000_initial.sql"),
`CREATE TABLE "${tableName}" ("id" uuid PRIMARY KEY NOT NULL, "value" text NOT NULL);\n`,
);
await write(
path.join(moduleDirectory, "drizzle/meta/_journal.json"),
JSON.stringify({
version: "7",
dialect: "postgresql",
entries: [
{ idx: 0, version: "7", when: 1_700_000_000_000, tag: "0000_initial", breakpoints: true },
],
}),
);
await write(
path.join(moduleDirectory, "drizzle/meta/_ekklesia.json"),
JSON.stringify({
version: 1,
moduleId: id,
entries: [{ tag: "0000_initial", destructive: { mode: "none" } }],
}),
);
await write(
path.join(moduleDirectory, "drizzle/meta/0000_snapshot.json"),
JSON.stringify({
version: "7",
dialect: "postgresql",
id: "10000000-0000-4000-8000-000000000001",
prevId: "00000000-0000-0000-0000-000000000000",
}),
);
// The module's dependencies, as a workspace links them: the real packages this package depends on.
const modules = path.join(moduleDirectory, "node_modules");
await mkdir(path.join(modules, "@ekklesia"), { recursive: true });
await symlink(
await realpath(path.join(packageRoot, "node_modules", "@ekklesia", "module-sdk")),
path.join(modules, "@ekklesia", "module-sdk"),
);
await symlink(
await realpath(path.join(packageRoot, "node_modules", "drizzle-orm")),
path.join(modules, "drizzle-orm"),
);
for (const fake of options.fakePackages ?? []) {
const fakeRoot = path.join(modules, ...fake.name.split("/"));
await write(
path.join(fakeRoot, "package.json"),
JSON.stringify({
name: fake.name,
version: fake.version ?? "1.0.0",
type: "module",
main: "index.js",
license: "MIT",
...fake.packageJson,
}),
);
for (const [relative, contents] of Object.entries(fake.files ?? {})) {
await write(path.join(fakeRoot, relative), contents);
}
}
return moduleDirectory;
}
export const SOURCE_REVISION = "86ae907f59458508e3277666cdd214f5bd7c1078";
+62
View File
@@ -0,0 +1,62 @@
import { gunzipSync } from "node:zlib";
/** A test-side view of every ustar header field the determinism rules constrain (ADR 0049). */
export interface TarEntry {
readonly path: string;
readonly typeflag: string;
readonly mode: number;
readonly uid: number;
readonly gid: number;
readonly uname: string;
readonly gname: string;
readonly mtime: number;
readonly magic: string;
readonly data: Buffer;
}
function text(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 octal(block: Buffer, offset: number, length: number): number {
return Number.parseInt(text(block, offset, length).trim() || "0", 8);
}
export function readTarGz(archive: Buffer): readonly TarEntry[] {
const raw = gunzipSync(archive);
const entries: TarEntry[] = [];
let offset = 0;
while (offset + 512 <= raw.length) {
const block = raw.subarray(offset, offset + 512);
if (block.every((byte) => byte === 0)) {
break;
}
const name = text(block, 0, 100);
const prefix = text(block, 345, 155);
const size = octal(block, 124, 12);
entries.push({
data: Buffer.from(raw.subarray(offset + 512, offset + 512 + size)),
gid: octal(block, 116, 8),
gname: text(block, 297, 32),
magic: `${text(block, 257, 6)}/${text(block, 263, 2)}`,
mode: octal(block, 100, 8),
mtime: octal(block, 136, 12),
path: prefix === "" ? name : `${prefix}/${name}`,
typeflag: text(block, 156, 1),
uid: octal(block, 108, 8),
uname: text(block, 265, 32),
});
offset += 512 + Math.ceil(size / 512) * 512;
}
return entries;
}
export function fileEntries(archive: Buffer): Map<string, Buffer> {
return new Map(
readTarGz(archive)
.filter((entry) => entry.typeflag === "0")
.map((entry) => [entry.path, entry.data]),
);
}
@@ -0,0 +1,195 @@
import { execFile } from "node:child_process";
import { createHash } from "node:crypto";
import { lstat, readdir, readFile, realpath } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { promisify } from "node:util";
import { verifyModulePackage } from "@ekklesia/core/modules";
import { afterEach, describe, expect, it } from "vitest";
import { packModule } from "../src/index.js";
import { cleanupFixtures, SOURCE_REVISION, scratchDirectory } from "./support/fixture-module.js";
import { fileEntries } from "./support/tar.js";
/**
* The canonical template module, packaged end to end (ADR 0049): the real built template, the real
* packager, and Core's real verifier. Turbo builds the template and the verifier before this suite
* runs (both are development dependencies of this package), and runs the template's own tests, which
* rebuild its UI in place, before this one (turbo.json).
*/
const execFileAsync = promisify(execFile);
const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const repositoryRoot = path.resolve(packageRoot, "..", "..");
afterEach(cleanupFixtures);
async function templateDirectory(): Promise<string> {
return realpath(path.join(packageRoot, "node_modules", "@ekklesia", "module-template"));
}
async function readJson(file: string): Promise<Record<string, unknown>> {
return JSON.parse(await readFile(file, "utf8")) as Record<string, unknown>;
}
async function platform() {
const core = await readJson(path.join(repositoryRoot, "core", "package.json"));
return {
databaseRuntime: {
"drizzle-orm": (core.dependencies as Record<string, string>)["drizzle-orm"] ?? "",
},
moduleSdkVersion: String(
(await readJson(path.join(repositoryRoot, "packages", "module-sdk", "package.json"))).version,
),
platformVersion: String((await readJson(path.join(repositoryRoot, "package.json"))).version),
};
}
async function fingerprint(file: string): Promise<string> {
try {
return createHash("sha256")
.update(await readFile(file))
.digest("hex");
} catch {
return "absent";
}
}
async function treeListing(directory: string): Promise<readonly string[]> {
const entries = await readdir(directory, { recursive: true, withFileTypes: true });
return entries
.filter((entry) => !path.join(entry.parentPath, entry.name).includes(`${path.sep}node_modules`))
.map((entry) => path.relative(directory, path.join(entry.parentPath, entry.name)))
.sort();
}
async function packTemplate() {
const outDirectory = await scratchDirectory("ekklesia-template-package-");
const result = await packModule({
moduleDirectory: await templateDirectory(),
outDirectory,
sourceRevision: SOURCE_REVISION,
target: "any",
});
return { archive: await readFile(result.archivePath), result };
}
describe("the module template as a Module Package v1", () => {
it("packs deterministically, without touching the workspace it was built in", async () => {
const template = await templateDirectory();
const watched = [
path.join(repositoryRoot, "node_modules", ".pnpm-workspace-state-v1.json"),
path.join(repositoryRoot, "node_modules", ".modules.yaml"),
path.join(repositoryRoot, "pnpm-lock.yaml"),
];
const before = await Promise.all(watched.map(fingerprint));
const templateBefore = await treeListing(template);
const first = await packTemplate();
const second = await packTemplate();
expect(first.result.sha256).toBe(second.result.sha256);
expect(first.archive.equals(second.archive)).toBe(true);
expect(path.basename(first.result.archivePath)).toMatch(
/^example-\d+\.\d+\.\d+-any\.tar\.gz$/u,
);
expect(await Promise.all(watched.map(fingerprint))).toEqual(before);
expect(await treeListing(template)).toEqual(templateBefore);
});
it("verifies against this platform and extracts to an unrelated directory that holds no link", async () => {
const { result } = await packTemplate();
const target = path.join(await scratchDirectory("ekklesia-installed-elsewhere-"), "example");
const verified = await verifyModulePackage({
archivePath: result.archivePath,
expectedSha256: result.sha256,
extractTo: target,
platform: await platform(),
});
expect(verified).toMatchObject({ moduleId: "example", target: "any" });
for (const entry of await readdir(target, { recursive: true, withFileTypes: true })) {
const stats = await lstat(path.join(entry.parentPath, entry.name));
expect(stats.isSymbolicLink(), entry.name).toBe(false);
expect(stats.isFile() ? stats.nlink : 1, entry.name).toBe(1);
}
const listing = await treeListing(target);
expect(listing).not.toContain("node_modules");
expect(listing.some((file) => /(^|\/)(src|tests)(\/|$)|\.d\.ts$|\.map$/u.test(file))).toBe(
false,
);
});
it("loads from its new location without resolving anything outside itself", async () => {
const { result } = await packTemplate();
const target = path.join(await scratchDirectory("ekklesia-portable-"), "example");
await verifyModulePackage({
archivePath: result.archivePath,
extractTo: target,
platform: await platform(),
});
// Every specifier the backend resolves while loading, recorded by Node itself.
const script = `
import { registerHooks } from "node:module";
import { pathToFileURL } from "node:url";
const resolved = [];
registerHooks({ resolve(specifier, context, next) { const result = next(specifier, context); resolved.push(result.url); return result; } });
for (const file of ["dist/server/index.js", "dist/schema/index.js"]) {
await import(pathToFileURL(${JSON.stringify(target)} + "/" + file).href);
}
process.stdout.write(JSON.stringify(resolved));
`;
const { stdout } = await execFileAsync(
process.execPath,
["--input-type=module", "-e", script],
{
cwd: path.dirname(target),
env: {},
},
);
const resolved = JSON.parse(stdout) as string[];
expect(resolved.length).toBeGreaterThan(0);
for (const url of resolved) {
const inside = url.startsWith(`file://${target}/`);
expect(inside || url.startsWith("node:"), url).toBe(true);
}
});
it("credits every third-party package its bundles say they contain", async () => {
const { archive } = await packTemplate();
const files = fileEntries(archive);
const notices = files.get("example/THIRD_PARTY_NOTICES")?.toString("utf8") ?? "";
const bundled = new Set<string>();
for (const [file, data] of files) {
if (file.endsWith(".js")) {
// esbuild names every bundled source file in a path comment.
for (const match of data
.toString("utf8")
.matchAll(/node_modules\/((?:@[^/]+\/)?[^/.][^/]*)\//gu)) {
if (match[1] && !match[1].startsWith("@ekklesia/")) {
bundled.add(match[1]);
}
}
}
}
expect([...bundled]).toEqual(
expect.arrayContaining(["drizzle-orm", "react", "react-dom", "zod"]),
);
for (const name of bundled) {
expect(notices, name).toMatch(
new RegExp(`^${name.replace(/[.*+?^${}()|[\]\\/]/gu, "\\$&")} \\S+$`, "mu"),
);
}
const licence = await readFile(path.join(packageRoot, "licenses", "Apache-2.0.txt"));
expect(createHash("sha256").update(licence).digest("hex")).toBe(
"cfc7749b96f63bd31c3c42b5c471bf756814053e847c10f3eb003417bc523d30",
);
});
it("refers to no absolute path of the checkout it was built in", async () => {
const { archive } = await packTemplate();
for (const [file, data] of fileEntries(archive)) {
expect(data.toString("utf8"), file).not.toContain(repositoryRoot);
}
});
});
+14
View File
@@ -0,0 +1,14 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"declaration": true,
"declarationMap": true,
"noEmit": false,
"outDir": "dist",
"rootDir": "src",
"sourceMap": true,
"types": ["node"]
},
"include": ["src/**/*.ts"],
"exclude": ["src/**/*.test.ts", "tests"]
}
+8
View File
@@ -0,0 +1,8 @@
{
"extends": "../typescript-config/node.json",
"compilerOptions": {
"noEmit": true,
"types": ["node", "vitest/globals"]
},
"include": ["src/**/*.ts", "tests/**/*.ts"]
}
+2 -1
View File
@@ -1,6 +1,7 @@
{ {
"name": "@ekklesia/module-sdk", "name": "@ekklesia/module-sdk",
"version": "0.1.1", "version": "0.1.2",
"license": "AGPL-3.0-or-later",
"type": "module", "type": "module",
"files": [ "files": [
"dist" "dist"
+2 -1
View File
@@ -1,6 +1,7 @@
{ {
"name": "@ekklesia/module-ui-build", "name": "@ekklesia/module-ui-build",
"version": "0.1.1", "version": "0.1.2",
"license": "AGPL-3.0-or-later",
"type": "module", "type": "module",
"files": [ "files": [
"bin", "bin",
+105 -12
View File
@@ -6,7 +6,7 @@ import {
moduleDisplayEntrypointSchema, moduleDisplayEntrypointSchema,
packageRelativePathSchema, packageRelativePathSchema,
} from "@ekklesia/contracts"; } from "@ekklesia/contracts";
import { build, type Plugin } from "esbuild"; import { type BuildOptions, build, type Plugin } from "esbuild";
/** /**
* The one supported way to turn a module's React/TSX UI sources into the compiled browser artifacts * The one supported way to turn a module's React/TSX UI sources into the compiled browser artifacts
@@ -108,7 +108,7 @@ const SURVIVING_IMPORT = /@import\s/iu;
*/ */
const STYLESHEET_PLUGIN_NAME = "ekklesia-compile-stylesheet-imports"; const STYLESHEET_PLUGIN_NAME = "ekklesia-compile-stylesheet-imports";
function compileStylesheetImports(): Plugin { function compileStylesheetImports(recordInputs?: (paths: readonly string[]) => void): Plugin {
const decoder = new TextDecoder(); const decoder = new TextDecoder();
return { return {
name: STYLESHEET_PLUGIN_NAME, name: STYLESHEET_PLUGIN_NAME,
@@ -127,15 +127,30 @@ function compileStylesheetImports(): Plugin {
], ],
})); }));
// The nested build works from the module directory, exactly like the outer one. Without it
// esbuild names each stylesheet relative to whatever directory the caller ran from, and the
// artifact's bytes would depend on the caller (ADR 0049 requires a deterministic package).
const workingDirectory = pluginBuild.initialOptions.absWorkingDir ?? process.cwd();
pluginBuild.onLoad({ filter: /\.css$/i }, async (args) => { pluginBuild.onLoad({ filter: /\.css$/i }, async (args) => {
const compiled = await build({ const compiled = await build({
absWorkingDir: workingDirectory,
bundle: true, bundle: true,
entryPoints: [args.path], entryPoints: [args.path],
logLevel: "silent", logLevel: "silent",
metafile: recordInputs !== undefined,
minify: false, minify: false,
target: MODULE_UI_BUILD_TARGET, target: MODULE_UI_BUILD_TARGET,
write: false, write: false,
}); });
// A stylesheet's own @imports are bundled by this nested build, so the outer build never
// sees them. An analysis must, because they ship inside the artifact.
if (recordInputs && compiled.metafile) {
recordInputs(
Object.keys(compiled.metafile.inputs).map((input) =>
path.resolve(workingDirectory, input),
),
);
}
const stylesheet = compiled.outputFiles const stylesheet = compiled.outputFiles
.map((outputFile) => decoder.decode(outputFile.contents)) .map((outputFile) => decoder.decode(outputFile.contents))
.join("\n"); .join("\n");
@@ -323,14 +338,14 @@ async function resolveDeclaredArtifacts(
* Never runs during discovery, startup, or install: this is an authoring-time and packaging-time * Never runs during discovery, startup, or install: this is an authoring-time and packaging-time
* step only. Core validates the artifact a module ships; it never produces one (ADR 0021). * step only. Core validates the artifact a module ships; it never produces one (ADR 0021).
*/ */
export async function buildModuleUi(options: BuildModuleUiOptions): Promise<ModuleUiBuildResult> { /** The one esbuild configuration for a declared artifact, shared by building and analysing so the
const moduleDirectory = path.resolve(options.moduleDirectory); * two can never describe different builds. */
const artifacts = await resolveDeclaredArtifacts(moduleDirectory); function moduleUiBuildOptions(
const pendingWrites: Array<readonly { path: string; contents: Uint8Array }[]> = []; moduleDirectory: string,
artifact: DeclaredModuleUiArtifact,
for (const artifact of artifacts) { recordInputs?: (paths: readonly string[]) => void,
try { ): BuildOptions & { write: false } {
const result = await build({ return {
absWorkingDir: moduleDirectory, absWorkingDir: moduleDirectory,
// Every dependency, React included, is bundled. The artifact must run in any host, including // Every dependency, React included, is bundled. The artifact must run in any host, including
// one that uses no React at all, so nothing is externalized (ADR 0023). // one that uses no React at all, so nothing is externalized (ADR 0023).
@@ -350,7 +365,7 @@ export async function buildModuleUi(options: BuildModuleUiOptions): Promise<Modu
minify: false, minify: false,
outfile: artifact.outputFile, outfile: artifact.outputFile,
platform: "browser", platform: "browser",
plugins: [compileStylesheetImports()], plugins: [compileStylesheetImports(recordInputs)],
sourcemap: true, sourcemap: true,
target: MODULE_UI_BUILD_TARGET, target: MODULE_UI_BUILD_TARGET,
// Fail rather than silently shipping a partial bundle. // Fail rather than silently shipping a partial bundle.
@@ -358,7 +373,17 @@ export async function buildModuleUi(options: BuildModuleUiOptions): Promise<Modu
// Compile every role successfully before writing any role. This makes a two-artifact build // Compile every role successfully before writing any role. This makes a two-artifact build
// atomic with respect to parse/resolve/bundle failures rather than leaving a stale partial set. // atomic with respect to parse/resolve/bundle failures rather than leaving a stale partial set.
write: false, write: false,
}); };
}
export async function buildModuleUi(options: BuildModuleUiOptions): Promise<ModuleUiBuildResult> {
const moduleDirectory = path.resolve(options.moduleDirectory);
const artifacts = await resolveDeclaredArtifacts(moduleDirectory);
const pendingWrites: Array<readonly { path: string; contents: Uint8Array }[]> = [];
for (const artifact of artifacts) {
try {
const result = await build(moduleUiBuildOptions(moduleDirectory, artifact));
// Belt and braces on the stylesheet contract. The plugin above turns every `.css` import into // Belt and braces on the stylesheet contract. The plugin above turns every `.css` import into
// JavaScript, so esbuild has nothing left to emit a stylesheet from — but a future option or // JavaScript, so esbuild has nothing left to emit a stylesheet from — but a future option or
// loader change could reintroduce a sidecar, and a sidecar is precisely the failure this // loader change could reintroduce a sidecar, and a sidecar is precisely the failure this
@@ -414,3 +439,71 @@ export async function buildModuleUi(options: BuildModuleUiOptions): Promise<Modu
artifacts: Object.freeze(artifacts.map((artifact) => Object.freeze({ ...artifact }))), artifacts: Object.freeze(artifacts.map((artifact) => Object.freeze({ ...artifact }))),
}); });
} }
export interface ModuleUiAnalysis {
/** Every declared artifact, in the same stable role order `buildModuleUi` produces them. */
readonly artifacts: readonly ModuleUiArtifactAnalysis[];
}
export interface ModuleUiArtifactAnalysis {
readonly kind: ModuleUiArtifactKind;
readonly contributionId?: string;
/** Absolute path of the artifact the manifest declares. */
readonly outputFile: string;
/** Absolute paths of every source file bundled into it, stylesheets and their imports included,
* sorted and unique. What a packager credits for the code it redistributes. */
readonly inputs: readonly string[];
/** Whether the artifact on disk is byte-for-byte what these sources build today. False when it
* was never built or is stale, so a caller never describes one artifact and ships another. */
readonly builtArtifactCurrent: boolean;
}
/**
* Runs the canonical module UI build in memory, exactly as `buildModuleUi` would, and reports what
* went into each declared artifact. Writes nothing. Used by the module packager (ADR 0049) to find
* the third-party code inside already-built browser artifacts, and to refuse a stale build.
*/
export async function analyzeModuleUi(options: BuildModuleUiOptions): Promise<ModuleUiAnalysis> {
const moduleDirectory = path.resolve(options.moduleDirectory);
const artifacts = await resolveDeclaredArtifacts(moduleDirectory);
const analyses: ModuleUiArtifactAnalysis[] = [];
for (const artifact of artifacts) {
const inputs = new Set<string>();
let result: Awaited<ReturnType<typeof build>>;
try {
result = await build({
...moduleUiBuildOptions(moduleDirectory, artifact, (paths) => {
for (const input of paths) {
inputs.add(input);
}
}),
metafile: true,
});
} catch {
throw new ModuleUiBuildError("MODULE_UI_BUILD_FAILED", artifact.kind);
}
for (const input of Object.keys(result.metafile?.inputs ?? {})) {
inputs.add(path.resolve(moduleDirectory, input));
}
const built = result.outputFiles?.find((outputFile) => outputFile.path === artifact.outputFile);
let onDisk: Buffer | undefined;
try {
onDisk = await readFile(artifact.outputFile);
} catch {
onDisk = undefined;
}
analyses.push(
Object.freeze({
...(artifact.contributionId === undefined
? {}
: { contributionId: artifact.contributionId }),
builtArtifactCurrent:
built !== undefined && onDisk !== undefined && Buffer.from(built.contents).equals(onDisk),
inputs: Object.freeze([...inputs].sort()),
kind: artifact.kind,
outputFile: artifact.outputFile,
}),
);
}
return Object.freeze({ artifacts: Object.freeze(analyses) });
}
@@ -2,7 +2,7 @@ import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import path from "node:path"; import path from "node:path";
import { afterEach, describe, expect, it } from "vitest"; import { afterEach, describe, expect, it } from "vitest";
import { buildModuleUi, ModuleUiBuildError } from "../src/index.js"; import { analyzeModuleUi, buildModuleUi, ModuleUiBuildError } from "../src/index.js";
const temporaryDirectories: string[] = []; const temporaryDirectories: string[] = [];
@@ -502,3 +502,87 @@ describe("module UI build", () => {
expect(artifact).not.toContain("process.env.NODE_ENV"); expect(artifact).not.toContain("process.env.NODE_ENV");
}); });
}); });
describe("module UI build analysis", () => {
const styledSource = `import styles from "./styles.css";
${plainUiSource.replace("const definition = {", "const definition = {\n styles,")}`;
it("reports every source file behind each artifact, stylesheets included, and writes nothing", async () => {
const directory = await createModule({
ui: "./dist/ui/index.js",
files: {
"src/ui/index.tsx": styledSource,
"src/ui/styles.css":
'@import "./base.css";\n.example { color: var(--ekklesia-color-text); }\n',
"src/ui/base.css": ".example-base { margin: 0; }\n",
},
});
await buildModuleUi({ moduleDirectory: directory });
const built = await readFile(path.join(directory, "dist/ui/index.js"));
const analysis = await analyzeModuleUi({ moduleDirectory: directory });
expect(analysis.artifacts).toHaveLength(1);
const [artifact] = analysis.artifacts;
expect(artifact?.kind).toBe("ui");
expect(artifact?.outputFile).toBe(path.join(directory, "dist/ui/index.js"));
expect(artifact?.builtArtifactCurrent).toBe(true);
expect(artifact?.inputs).toEqual(
expect.arrayContaining([
path.join(directory, "src/ui/index.tsx"),
path.join(directory, "src/ui/styles.css"),
path.join(directory, "src/ui/base.css"),
]),
);
expect([...(artifact?.inputs ?? [])]).toEqual([...new Set(artifact?.inputs)].sort());
// Nothing was written: the built artifact is byte-for-byte what it was.
expect(await readFile(path.join(directory, "dist/ui/index.js"))).toEqual(built);
});
it("compiles stylesheets relative to the module, whatever directory the build runs from", async () => {
const directory = await createModule({
ui: "./dist/ui/index.js",
files: {
"src/ui/index.tsx": styledSource,
"src/ui/styles.css": '@import "./base.css";\n.example { margin: 0; }\n',
"src/ui/base.css": ".example-base { margin: 0; }\n",
},
});
// This test runs from the package directory, never from the module's own: the artifact must
// still name its stylesheets relative to the module, or its bytes would depend on the caller.
expect(process.cwd()).not.toBe(directory);
await buildModuleUi({ moduleDirectory: directory });
const artifact = await readFile(path.join(directory, "dist/ui/index.js"), "utf8");
expect(artifact).toContain("/* src/ui/styles.css */");
expect(artifact).toContain("/* src/ui/base.css */");
expect(artifact).not.toContain(path.basename(directory));
});
it("says when a built artifact no longer matches its sources", async () => {
const directory = await createModule({
ui: "./dist/ui/index.js",
files: { "src/ui/index.tsx": plainUiSource },
});
await buildModuleUi({ moduleDirectory: directory });
await writeFile(path.join(directory, "dist/ui/index.js"), "export default {};\n");
const analysis = await analyzeModuleUi({ moduleDirectory: directory });
expect(analysis.artifacts[0]?.builtArtifactCurrent).toBe(false);
});
it("says when a declared artifact was never built", async () => {
const directory = await createModule({
ui: "./dist/ui/index.js",
files: { "src/ui/index.tsx": plainUiSource },
});
const analysis = await analyzeModuleUi({ moduleDirectory: directory });
expect(analysis.artifacts[0]?.builtArtifactCurrent).toBe(false);
});
it("returns an empty analysis for a backend-only module", async () => {
const directory = await createModule();
await expect(analyzeModuleUi({ moduleDirectory: directory })).resolves.toEqual({
artifacts: [],
});
});
});
+2 -1
View File
@@ -1,6 +1,7 @@
{ {
"name": "@ekklesia/module-ui-host", "name": "@ekklesia/module-ui-host",
"version": "0.1.1", "version": "0.1.2",
"license": "AGPL-3.0-or-later",
"type": "module", "type": "module",
"files": [ "files": [
"dist" "dist"
+2 -1
View File
@@ -1,6 +1,7 @@
{ {
"name": "@ekklesia/module-ui-sdk", "name": "@ekklesia/module-ui-sdk",
"version": "0.1.1", "version": "0.1.2",
"license": "AGPL-3.0-or-later",
"type": "module", "type": "module",
"files": [ "files": [
"dist" "dist"
+2 -1
View File
@@ -1,6 +1,7 @@
{ {
"name": "@ekklesia/typescript-config", "name": "@ekklesia/typescript-config",
"version": "0.1.1", "version": "0.1.2",
"license": "AGPL-3.0-or-later",
"private": true, "private": true,
"files": [ "files": [
"base.json", "base.json",
+2 -1
View File
@@ -1,6 +1,7 @@
{ {
"name": "@ekklesia/ui", "name": "@ekklesia/ui",
"version": "0.1.1", "version": "0.1.2",
"license": "AGPL-3.0-or-later",
"type": "module", "type": "module",
"files": [ "files": [
"dist" "dist"
+43
View File
@@ -196,6 +196,49 @@ importers:
specifier: 4.1.10 specifier: 4.1.10
version: 4.1.10(@types/[email protected])([email protected])([email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected])) version: 4.1.10(@types/[email protected])([email protected])([email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected]))
packages/module-pack:
dependencies:
'@ekklesia/contracts':
specifier: workspace:*
version: link:../contracts
'@ekklesia/module-ui-build':
specifier: workspace:*
version: link:../module-ui-build
esbuild:
specifier: 0.28.1
version: 0.28.1
semver:
specifier: 7.8.5
version: 7.8.5
devDependencies:
'@biomejs/biome':
specifier: 2.5.3
version: 2.5.3
'@ekklesia/core':
specifier: workspace:*
version: link:../../core
'@ekklesia/module-sdk':
specifier: workspace:*
version: link:../module-sdk
'@ekklesia/module-template':
specifier: workspace:*
version: link:../../templates/module
'@types/node':
specifier: 24.13.3
version: 24.13.3
'@types/semver':
specifier: 7.7.1
version: 7.7.1
drizzle-orm:
specifier: 0.45.2
version: 0.45.2(@types/[email protected])([email protected])([email protected])
typescript:
specifier: 7.0.2
version: 7.0.2
vitest:
specifier: 4.1.10
version: 4.1.10(@types/[email protected])([email protected])([email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected]))
packages/module-sdk: packages/module-sdk:
dependencies: dependencies:
'@ekklesia/contracts': '@ekklesia/contracts':
+146
View File
@@ -0,0 +1,146 @@
#!/usr/bin/env bash
# Proves that Ekklesia's image is the one generic platform artifact ADR 0048 requires:
#
# - it contains zero feature modules: no /opt/ekklesia/modules, no module template, and no
# ekklesia.module.json anywhere outside installed dependencies;
# - its module storage mount point, /var/lib/ekklesia/modules, exists, is root-owned, and is empty;
# - EKKLESIA_MODULES_DIR names that directory, in the image configuration and in a running process;
# - a checkout with a populated modules/ directory builds the same image: the runtime file system
# of both builds is identical, and no module from that directory enters it.
#
# bash scripts/check-image.sh builds both images from this checkout
# EKKLESIA_CHECK_IMAGE=<image> bash scripts/check-image.sh checks one existing image's contents
#
# Both builds use a copy of the files Git would track (committed, staged, or untracked but not
# ignored), so they differ in exactly one thing: the second context also has modules/example. Images
# are built and inspected locally only; nothing is pushed, published, or logged in to.
set -Eeuo pipefail
repository_root="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$repository_root"
# shellcheck source=scripts/lib/ci-project.sh
source "$repository_root/scripts/lib/ci-project.sh"
command -v docker >/dev/null || {
echo "check-image: docker is required." >&2
exit 1
}
project="$(ci_project_name image)"
modules_directory="/var/lib/ekklesia/modules"
work="$(mktemp -d)"
built_images=()
cleanup() {
local status=$?
trap - EXIT INT TERM
for built in "${built_images[@]}"; do
docker image rm --force "$built" >/dev/null 2>&1 || true
done
rm -rf "$work" || true
exit "$status"
}
trap cleanup EXIT INT TERM
step() {
echo "==> $*"
}
fail() {
echo "check-image: $*" >&2
exit 1
}
# check_contents <image>: every content guarantee, inside the image and in its configuration.
check_contents() {
local image="$1" configured
configured="$(docker image inspect --format '{{range .Config.Env}}{{println .}}{{end}}' "$image")" ||
fail "could not inspect ${image}."
grep -qx "EKKLESIA_MODULES_DIR=${modules_directory}" <<<"$configured" ||
fail "the image does not set EKKLESIA_MODULES_DIR=${modules_directory}."
# As root, so a directory the runtime user cannot read cannot hide a manifest. No network.
docker run --rm --network none --user 0:0 --entrypoint sh "$image" -ec '
fail() { echo "check-image: $*" >&2; exit 1; }
[ ! -e /opt/ekklesia/modules ] || fail "/opt/ekklesia/modules exists in the image."
[ ! -e /opt/ekklesia/templates ] || fail "the module template is in the image."
[ -d /var/lib/ekklesia/modules ] || fail "/var/lib/ekklesia/modules is missing."
[ "$(stat -c %U:%G:%a /var/lib/ekklesia/modules)" = "root:root:755" ] ||
fail "/var/lib/ekklesia/modules is not root-owned with mode 755."
[ -z "$(ls -A /var/lib/ekklesia/modules)" ] || fail "/var/lib/ekklesia/modules is not empty."
manifests="$(find / -xdev -name ekklesia.module.json -not -path "*/node_modules/*" 2>/dev/null)"
[ -z "$manifests" ] || fail "a module manifest is in the image: ${manifests}"
[ "$EKKLESIA_MODULES_DIR" = /var/lib/ekklesia/modules ] ||
fail "a process in the image does not see EKKLESIA_MODULES_DIR."
' || fail "${image} is not the generic platform image."
echo "ok ${image}: no modules, no template, empty root-owned module storage, EKKLESIA_MODULES_DIR set"
}
# runtime_listing <image>: every path the platform and its module storage consist of, with its
# type, mode, and size, in a stable order. Two builds that differ here differ in content.
runtime_listing() {
docker run --rm --network none --user 0:0 --entrypoint sh "$1" -ec \
'find /opt/ekklesia /var/lib/ekklesia -xdev -printf "%y %m %s %p\n" | LC_ALL=C sort'
}
if [[ -n "${EKKLESIA_CHECK_IMAGE:-}" ]]; then
check_contents "$EKKLESIA_CHECK_IMAGE"
exit 0
fi
command -v git >/dev/null || fail "git is required to copy the build context."
# context_copy <directory>: the files Git would track, and nothing it ignores.
context_copy() {
mkdir -p "$1"
git ls-files -z --cached --others --exclude-standard |
tar --null --files-from=- --create --file=- |
tar -C "$1" -xf -
}
tag_base="ekklesia-ci:${GITHUB_SHA:-local}-${GITHUB_RUN_ID:-0}-${GITHUB_RUN_ATTEMPT:-1}-"
generic_image="${tag_base}generic-$$"
populated_image="${tag_base}populated-$$"
# build_image <context> <tag>
build_image() {
built_images+=("$2")
DOCKER_BUILDKIT=1 docker build --quiet --file "$1/deploy/Dockerfile" \
--build-arg REVISION="${GITHUB_SHA:-}" \
--label "${EKKLESIA_CI_LABEL}=${project}" --label "${EKKLESIA_CI_CREATED_LABEL}=$(date +%s)" \
--tag "$2" "$1" >/dev/null || fail "the image did not build from ${1##*/}."
}
step "Building the platform image from this checkout"
context_copy "$work/generic"
[[ ! -e "$work/generic/modules" ]] || fail "this checkout itself has a modules/ directory."
build_image "$work/generic" "$generic_image"
check_contents "$generic_image"
step "Building again from the same checkout with a populated modules/ directory"
context_copy "$work/populated"
mkdir -p "$work/populated/modules"
cp -R "$work/populated/templates/module" "$work/populated/modules/example"
printf 'must never reach an image\n' >"$work/populated/modules/example/canary.txt"
build_image "$work/populated" "$populated_image"
check_contents "$populated_image"
step "Comparing the two images' runtime file systems"
runtime_listing "$generic_image" >"$work/generic.listing"
runtime_listing "$populated_image" >"$work/populated.listing"
[[ -s "$work/generic.listing" ]] || fail "could not list the image's file system."
if ! diff -u "$work/generic.listing" "$work/populated.listing" >"$work/listing.diff"; then
head -40 "$work/listing.diff" >&2
fail "a populated modules/ directory changed the image."
fi
echo "ok both builds have the identical runtime file system ($(wc -l <"$work/generic.listing") paths)"
if [[ "$(docker image inspect --format '{{json .RootFS.Layers}}' "$generic_image")" == \
"$(docker image inspect --format '{{json .RootFS.Layers}}' "$populated_image")" ]]; then
echo "ok both builds share every layer"
else
echo "note the two builds' layers differ (a cold build cache); their file systems do not"
fi
echo "Generic platform image check passed."
+206
View File
@@ -0,0 +1,206 @@
#!/usr/bin/env bash
# Removes disposable CI stacks from the Docker daemon: containers, networks, volumes, images, and
# staging directories on the Docker host.
#
# bash scripts/ci-sweep.sh --stale [--max-age-seconds N] any CI stack older than N (default 3 h)
# bash scripts/ci-sweep.sh --job this job's own stacks, at any age
#
# Every CI script already removes its own stack in an EXIT trap, but a trap never runs when the runner
# kills the job container: a cancelled or superseded job (the server honours `concurrency`), or
# the runner's own time limit. So each workflow job that starts stacks ends with `--job` in an
# `if: always()` step, and begins with `--stale`, which catches whatever a killed job left before
# it could clean up.
#
# What a stack is, and how it is found, is defined in scripts/lib/ci-project.sh: a Compose project
# named with the `ekklesia-ci-` prefix (Compose labels everything it creates with that name), plus
# anything labeled EKKLESIA_CI_LABEL. Nothing outside that prefix is ever touched, at any age.
#
# --stale's threshold is far above any job's run time, so a stack another job on this daemon is using
# right now is never young enough to match. --job refuses to run outside CI: locally, its prefix would
# match a developer's own stacks.
#
# Best effort by design: a resource that cannot be removed is reported, and the sweep goes on. Output
# names resources only; nothing a stack holds is printed.
set -Eeuo pipefail
repository_root="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)"
# shellcheck source=scripts/lib/ci-project.sh
source "$repository_root/scripts/lib/ci-project.sh"
compose_label="com.docker.compose.project"
mode=""
max_age_seconds="${EKKLESIA_CI_SWEEP_MAX_AGE_SECONDS:-10800}"
usage() {
echo "usage: ci-sweep.sh --stale [--max-age-seconds N] | --job" >&2
exit 2
}
while (($# > 0)); do
case "$1" in
--stale | --job)
[[ -z "$mode" ]] || usage
mode="${1#--}"
;;
--max-age-seconds)
[[ "${2:-}" =~ ^[0-9]+$ ]] || usage
max_age_seconds="$2"
shift
;;
*) usage ;;
esac
shift
done
[[ -n "$mode" ]] || usage
if [[ "$mode" == job ]]; then
[[ -n "${GITHUB_RUN_ID:-}" ]] || {
echo "ci-sweep: --job runs only in CI (GITHUB_RUN_ID is unset); locally it would match your own stacks." >&2
exit 2
}
prefix="$(ci_job_prefix)"
cutoff=""
else
prefix="$EKKLESIA_CI_PREFIX"
cutoff="$(($(date +%s) - max_age_seconds))"
fi
if ! docker info >/dev/null 2>&1; then
echo "ci-sweep: no Docker daemon reachable; nothing to sweep."
exit 0
fi
removed=0
warn() {
echo "ci-sweep: warning: $*" >&2
}
# selected <project> <created>: whether a resource belongs to the sweep.
selected() {
local project="$1" created="$2" seconds
[[ -n "$project" && "$project" == "$prefix"* ]] || return 1
[[ -z "$cutoff" ]] && return 0
seconds="$(date -d "$created" +%s 2>/dev/null)" || return 1
((seconds <= cutoff))
}
# --- Containers: Compose's own, and those a script started itself --------------------------------
mapfile -t containers < <(
{
docker ps --all --quiet --no-trunc --filter "label=${compose_label}"
docker ps --all --quiet --no-trunc --filter "label=${EKKLESIA_CI_LABEL}"
} | sort -u
)
for id in "${containers[@]}"; do
[[ -n "$id" ]] || continue
record="$(docker inspect --format \
"{{index .Config.Labels \"${compose_label}\"}}|{{index .Config.Labels \"${EKKLESIA_CI_LABEL}\"}}|{{.Created}}|{{.Name}}" \
"$id" 2>/dev/null)" || continue
IFS='|' read -r compose_project ci_project created name <<<"$record"
project="${compose_project:-$ci_project}"
selected "$project" "$created" || continue
if docker rm --force --volumes "$id" >/dev/null 2>&1; then
echo "ci-sweep: removed container ${name#/} (${project})"
removed=$((removed + 1))
else
warn "could not remove container ${name#/}"
fi
done
# --- Networks: anything still attached (a job container) is disconnected, never removed ---------
mapfile -t networks < <(docker network ls --quiet --no-trunc --filter "label=${compose_label}")
for id in "${networks[@]}"; do
[[ -n "$id" ]] || continue
# A network's creation time is a time value, not a string: ask for RFC 3339, which `date` reads.
record="$(docker network inspect --format \
"{{index .Labels \"${compose_label}\"}}|{{.Created.Format \"2006-01-02T15:04:05Z07:00\"}}|{{.Name}}" \
"$id" 2>/dev/null)" || continue
IFS='|' read -r project created name <<<"$record"
selected "$project" "$created" || continue
for endpoint in $(docker network inspect --format '{{range $id, $_ := .Containers}}{{$id}} {{end}}' "$id" 2>/dev/null); do
docker network disconnect --force "$id" "$endpoint" >/dev/null 2>&1 ||
warn "could not disconnect a container from network ${name}"
done
if docker network rm "$id" >/dev/null 2>&1; then
echo "ci-sweep: removed network ${name} (${project})"
removed=$((removed + 1))
else
warn "could not remove network ${name}"
fi
done
# --- Volumes ---------------------------------------------------------------------------------------
mapfile -t volumes < <(
{
docker volume ls --quiet --filter "label=${compose_label}"
docker volume ls --quiet --filter "label=${EKKLESIA_CI_LABEL}"
} | sort -u
)
for name in "${volumes[@]}"; do
[[ -n "$name" ]] || continue
record="$(docker volume inspect --format \
"{{index .Labels \"${compose_label}\"}}|{{index .Labels \"${EKKLESIA_CI_LABEL}\"}}|{{.CreatedAt}}" \
"$name" 2>/dev/null)" || continue
IFS='|' read -r compose_project ci_project created <<<"$record"
project="${compose_project:-$ci_project}"
selected "$project" "$created" || continue
if docker volume rm --force "$name" >/dev/null 2>&1; then
echo "ci-sweep: removed volume ${name} (${project})"
removed=$((removed + 1))
else
warn "could not remove volume ${name}"
fi
done
# --- Images built for a stack ------------------------------------------------------------------------
mapfile -t images < <(docker image ls --quiet --no-trunc --filter "label=${EKKLESIA_CI_LABEL}" | sort -u)
for id in "${images[@]}"; do
[[ -n "$id" ]] || continue
# Judged by when CI built it (EKKLESIA_CI_CREATED_LABEL), never by the image's own creation time,
# which BuildKit may have taken from its base. An image without that label is never swept by age.
record="$(docker image inspect --format \
"{{index .Config.Labels \"${EKKLESIA_CI_LABEL}\"}}|{{index .Config.Labels \"${EKKLESIA_CI_CREATED_LABEL}\"}}" \
"$id" 2>/dev/null)" || continue
IFS='|' read -r project built <<<"$record"
if [[ -n "$cutoff" ]]; then
[[ "$built" =~ ^[0-9]+$ ]] || continue
selected "$project" "@${built}" || continue
else
selected "$project" "" || continue
fi
if docker image rm --force "$id" >/dev/null 2>&1; then
echo "ci-sweep: removed image ${id#sha256:} (${project})"
removed=$((removed + 1))
else
warn "could not remove image ${id#sha256:}"
fi
done
# --- Staging directories on the Docker host ------------------------------------------------------
# A deploy smoke run inside a job container stages its mounted files under the Docker host's /tmp,
# named for its project, as root-owned files; they hold that run's disposable secrets and keys. They
# are removed with the same pinned image that created them. If that image is not on this daemon, no
# smoke has staged anything here.
image="$(sed -n 's/^[[:space:]]*image: \(postgres:[^[:space:]]*\)$/\1/p' "$repository_root/deploy/compose.yaml" | head -1)"
if [[ -n "$image" ]] && docker image inspect "$image" >/dev/null 2>&1; then
age=()
# `find -mmin +0` does not match something younger than a minute, so a threshold under a minute
# sweeps without an age filter, like the rest of this script.
if [[ -n "$cutoff" ]] && ((max_age_seconds >= 60)); then
age=(-mmin "+$((max_age_seconds / 60))")
fi
if staged="$(docker run --rm --entrypoint find -v /tmp:/host-tmp "$image" \
/host-tmp -mindepth 1 -maxdepth 1 -type d -name "${prefix}*" "${age[@]}" \
-print -exec rm -rf {} + 2>/dev/null)"; then
while IFS= read -r directory; do
[[ -n "$directory" ]] || continue
echo "ci-sweep: removed staging directory /tmp/${directory#/host-tmp/}"
removed=$((removed + 1))
done <<<"$staged"
else
warn "could not sweep staging directories on the Docker host"
fi
fi
echo "ci-sweep: ${mode} sweep removed ${removed} resource(s)."
@@ -0,0 +1,31 @@
# Builds the repository's module template and packages it with the canonical Ekklesia packager
# (`@ekklesia/module-pack`, ADR 0049), in a clean container from a clean copy of the checkout. The
# deploy smoke test uses it because a CI job that drives Docker has no Node.js of its own. The
# packaging itself is exactly what a module's CI runs; this file only provides the environment.
#
# DOCKER_BUILDKIT=1 docker build --file scripts/lib/build-module-package.Dockerfile \
# --build-arg SOURCE_REVISION=<full commit id> --output type=local,dest=<directory> .
#
# writes <directory>/example-<version>-any.tar.gz and its .sha256 line. No image is produced.
#
# The same pinned Node.js base as deploy/Dockerfile, so the package is built by the runtime it is
# installed into.
FROM node:24.18.0-trixie-slim@sha256:ae91dcc111a68c9d2d81ff2a17bda61be126426176fde6fe7d08ab13b7f50573 AS build
ENV COREPACK_ENABLE_DOWNLOAD_PROMPT=0 \
PNPM_HOME=/pnpm \
TURBO_TELEMETRY_DISABLED=1 \
DO_NOT_TRACK=1
WORKDIR /opt/source
COPY package.json ./
RUN corepack enable && corepack install
COPY . .
RUN --mount=type=cache,id=ekklesia-pnpm-store,target=/pnpm/store \
pnpm install --frozen-lockfile --store-dir /pnpm/store
RUN pnpm exec turbo run build --filter=@ekklesia/module-template --filter=@ekklesia/module-pack
ARG SOURCE_REVISION
RUN test -n "$SOURCE_REVISION" && \
node packages/module-pack/bin/ekklesia-pack-module.mjs --module templates/module --target any \
--out /package --source-revision "$SOURCE_REVISION"
FROM scratch AS package
COPY --from=build /package/ /
+40
View File
@@ -0,0 +1,40 @@
# shellcheck shell=bash
# How CI names, labels, and later finds the disposable stacks it starts.
#
# Every Compose project a CI script starts is named
#
# ekklesia-ci-<run id>-<run attempt>-<job key>-<kind>-<pid><random>
#
# and Compose labels every container, network, and volume it creates with that name. What a script
# creates outside Compose (a browser container, a volume, an image) gets EKKLESIA_CI_LABEL with the
# same name. scripts/ci-sweep.sh finds stacks by those labels: this job's own, by the prefix up to
# the job key (in a final `if: always()` step), and any CI stack by age (before a job starts).
#
# The job key is EKKLESIA_CI_JOB_KEY, which the workflow sets per job, and per matrix leg where a job
# has one, so that one leg's cleanup cannot match another leg's running stack. Outside CI the
# identifiers are "local".
# shellcheck disable=SC2034 # read by the scripts that source this
EKKLESIA_CI_LABEL="org.riverofglorymn.ekklesia.ci.project"
# On images, when they were built, in Unix seconds. An image's own creation time is not that: BuildKit
# gives an image that only adds metadata its base's time, and a reproducible build a fixed one. A
# stale sweep that trusted it could remove an image another job is using right now.
EKKLESIA_CI_CREATED_LABEL="org.riverofglorymn.ekklesia.ci.created"
EKKLESIA_CI_PREFIX="ekklesia-ci-"
# ci_name_safe <text>: lower case, and only what a Compose project name may contain.
ci_name_safe() {
local text="${1//[^a-zA-Z0-9_-]/-}"
printf '%s' "${text,,}"
}
# ci_job_prefix: every project this job starts begins with exactly this.
ci_job_prefix() {
ci_name_safe "ekklesia-ci-${GITHUB_RUN_ID:-local}-${GITHUB_RUN_ATTEMPT:-0}-${EKKLESIA_CI_JOB_KEY:-${GITHUB_JOB:-local}}-"
}
# ci_project_name <kind>: a project name unique to this invocation. Not only per run: several runs,
# and several jobs of one run, share a runner's Docker daemon, and job containers often share a PID.
ci_project_name() {
ci_name_safe "ekklesia-ci-${GITHUB_RUN_ID:-local}-${GITHUB_RUN_ATTEMPT:-0}-${EKKLESIA_CI_JOB_KEY:-${GITHUB_JOB:-local}}-${1:?ci_project_name needs a kind}-$$-${RANDOM}${RANDOM}"
}
+177 -1
View File
@@ -6,6 +6,9 @@
// node deploy-smoke-client.mjs edge an ordinary, untrusted peer of the proxy // node deploy-smoke-client.mjs edge an ordinary, untrusted peer of the proxy
// node deploy-smoke-client.mjs tunnel the Cloudflare Tunnel connector's fixed address // node deploy-smoke-client.mjs tunnel the Cloudflare Tunnel connector's fixed address
// node deploy-smoke-client.mjs session the initial owner signing in and out through the proxy // node deploy-smoke-client.mjs session the initial owner signing in and out through the proxy
// node deploy-smoke-client.mjs module the test module `example` as the initial owner sees it:
// SMOKE_MODULE_EXPECT is "absent", or a JSON array of the
// exact values its records route must return (ADR 0048)
// //
// Either topology (ADR 0045): SMOKE_HOSTNAME is the API's hostname, and SMOKE_DASHBOARD_HOSTNAME, // Either topology (ADR 0045): SMOKE_HOSTNAME is the API's hostname, and SMOKE_DASHBOARD_HOSTNAME,
// when set, the Dashboard's. Unset means the same-origin topology, where both are one hostname. Every // when set, the Dashboard's. Unset means the same-origin topology, where both are one hostname. Every
@@ -14,6 +17,8 @@
// Output is one line per check; any failed check exits non-zero. Nothing printed carries a // Output is one line per check; any failed check exits non-zero. Nothing printed carries a
// credential, cookie, signed URL, or response body beyond the fixed shapes asserted here. // credential, cookie, signed URL, or response body beyond the fixed shapes asserted here.
import { connect as connectHttp2 } from "node:http2";
const mode = process.argv[2]; const mode = process.argv[2];
const apiOrigin = `https://${required("SMOKE_HOSTNAME")}`; const apiOrigin = `https://${required("SMOKE_HOSTNAME")}`;
const dashboardOrigin = process.env.SMOKE_DASHBOARD_HOSTNAME const dashboardOrigin = process.env.SMOKE_DASHBOARD_HOSTNAME
@@ -192,6 +197,46 @@ async function splitCorsChecks() {
); );
} }
/**
* One GET over a real HTTP/2 connection. Node's fetch speaks HTTP/1.1, and the failure this guards
* against exists only on HTTP/2: a reset stream, which a browser reports as ERR_HTTP2_PROTOCOL_ERROR.
*/
function http2Get(url, headers = {}) {
return new Promise((resolve) => {
const session = connectHttp2(url.origin);
const finish = (result) => {
clearTimeout(timer);
session.close();
resolve(result);
};
const timer = setTimeout(() => finish({ error: "timeout" }), 10_000);
session.on("error", () => finish({ error: "session" }));
const stream = session.request({
":method": "GET",
":path": `${url.pathname}${url.search}`,
...headers,
});
let status;
let responseHeaders = {};
let bytes = 0;
stream.on("response", (received) => {
status = received[":status"];
responseHeaders = received;
});
stream.on("data", (chunk) => {
bytes += chunk.length;
});
stream.on("error", (error) => finish({ error: error.code ?? "stream" }));
stream.on("end", () => finish({ bytes, headers: responseHeaders, status }));
stream.end();
});
}
/** The CORS headers of a response, by name: what a browser would act on. */
function corsOf(response) {
return [...response.headers.keys()].filter((name) => name.startsWith("access-control-"));
}
async function storageDeliveryCheck() { async function storageDeliveryCheck() {
// Core's own provider, from the image, exactly as the API composes it: it reaches object-storage // Core's own provider, from the image, exactly as the API composes it: it reaches object-storage
// over HTTPS on the private network and signs read URLs for the storage hostname. // over HTTPS on the private network and signs read URLs for the storage hostname.
@@ -238,6 +283,79 @@ async function storageDeliveryCheck() {
"a delivered object is Cache-Control: private", "a delivered object is Cache-Control: private",
); );
await provider.delete(bucket, "smoke/object.txt"); await provider.delete(bucket, "smoke/object.txt");
// The same image, twice, over HTTP/2: a browser's second load of a signed URL it has cached is a
// conditional request, and must come back as a clean 304, not a reset stream.
const png = Buffer.from(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==",
"base64",
);
await provider.put(bucket, "smoke/image.png", png, {
contentLength: png.byteLength,
contentType: "image/png",
});
const image = new URL(
(await provider.createReadUrl(bucket, "smoke/image.png", { expiresInSeconds: 300 })).url,
);
const first = await http2Get(image);
check(
first.status === 200 && first.bytes === png.byteLength && Boolean(first.headers?.etag),
"an image loads over HTTP/2 through the proxy, with a validator (200, ETag)",
);
const revalidated = await http2Get(image, { "if-none-match": first.headers?.etag ?? "" });
check(
revalidated.status === 304 && revalidated.error === undefined,
`the same image loaded again over HTTP/2 revalidates cleanly (${revalidated.status ?? revalidated.error})`,
);
const sinceModified = await http2Get(image, {
"if-modified-since": first.headers?.["last-modified"] ?? "",
});
check(
sinceModified.status === 304 && sinceModified.error === undefined,
`a revalidation by date over HTTP/2 is a clean 304 too (${sinceModified.status ?? sinceModified.error})`,
);
// Delivery-layer CORS is the deployment's own, set by the proxy: exactly the origins whose pages
// read objects with fetch(), GET and HEAD only, and nothing of the object store's own answer.
const fromApi = await fetch(image, { headers: { origin: apiOrigin } });
check(
fromApi.headers.get("access-control-allow-origin") === apiOrigin &&
(fromApi.headers.get("vary") ?? "").toLowerCase().includes("origin") &&
corsOf(fromApi).length === 1,
"a read from the API origin is allowed for exactly that origin, with Vary: Origin and nothing else",
);
if (split) {
const fromDashboard = await fetch(image, { headers: { origin: dashboardOrigin } });
check(
fromDashboard.headers.get("access-control-allow-origin") === dashboardOrigin,
"a read from the Dashboard origin is allowed for exactly that origin",
);
}
const fromElsewhere = await fetch(image, { headers: { origin: untrustedOrigin } });
check(
fromElsewhere.status === 200 && corsOf(fromElsewhere).length === 0,
"a read from any other origin gets no CORS header at all",
);
const preflight = await fetch(image, {
headers: { "access-control-request-method": "GET", origin: apiOrigin },
method: "OPTIONS",
});
check(
preflight.status === 204 &&
preflight.headers.get("access-control-allow-origin") === apiOrigin &&
preflight.headers.get("access-control-allow-methods") === "GET, HEAD" &&
preflight.headers.get("x-amz-request-id") === null,
"the proxy answers a preflight itself (204, GET and HEAD only), never the object store",
);
const foreignPreflight = await fetch(image, {
headers: { "access-control-request-method": "GET", origin: untrustedOrigin },
method: "OPTIONS",
});
check(
foreignPreflight.status === 204 && corsOf(foreignPreflight).length === 0,
"a preflight from any other origin is answered without permission",
);
await provider.delete(bucket, "smoke/image.png");
} finally { } finally {
await provider.close(); await provider.close();
} }
@@ -328,14 +446,72 @@ async function sessionChecks() {
); );
} }
/** Signs the initial owner in, through the proxy, and returns the session cookie. */
async function ownerCookie() {
const signIn = await fetch(`${apiOrigin}/api/auth/sign-in/email`, {
body: JSON.stringify({
email: required("SMOKE_OWNER_EMAIL"),
password: required("SMOKE_OWNER_PASSWORD"),
}),
headers: { "content-type": "application/json", origin: dashboardOrigin },
method: "POST",
});
await signIn.arrayBuffer();
check(signIn.status === 200, "the initial owner signs in");
return (signIn.headers.get("set-cookie") ?? "").split(";")[0] ?? "";
}
/**
* The installed-module checks (ADR 0048): whether `example` is in the module inventory, and what
* its own route returns. Absent, it is in no inventory and its routes are not mounted at all;
* present, its records route returns exactly the expected values, which proves its tables, its
* dependencies, and the platform's database facade all work from module storage.
*/
async function moduleChecks() {
const expectation = required("SMOKE_MODULE_EXPECT");
const cookie = await ownerCookie();
const headers = { cookie, origin: dashboardOrigin };
const inventory = await fetch(`${apiOrigin}/api/v1/module-ui`, { headers });
const inventoryBody = await inventory.json();
const installed =
inventory.status === 200 && inventoryBody.modules.some((module) => module.id === "example");
const records = await fetch(`${apiOrigin}/api/v1/modules/example/records`, { headers });
if (expectation === "absent") {
check(inventory.status === 200 && !installed, "the module inventory has no `example` module");
check(
records.status === 404 && (await records.text()) === '{"code":"NOT_FOUND"}',
"the absent module's routes are not mounted",
);
} else {
const expected = JSON.parse(expectation);
check(installed, "the module inventory lists the `example` module");
const values = records.status === 200 ? (await records.json()).map((row) => row.value) : null;
check(
JSON.stringify(values) === JSON.stringify(expected),
`the module's records route returns ${JSON.stringify(expected)}`,
);
}
const signOut = await fetch(`${apiOrigin}/api/auth/sign-out`, {
body: "{}",
headers: { ...headers, "content-type": "application/json" },
method: "POST",
});
await signOut.arrayBuffer();
}
if (mode === "edge") { if (mode === "edge") {
await edgeChecks(); await edgeChecks();
} else if (mode === "tunnel") { } else if (mode === "tunnel") {
await tunnelChecks(); await tunnelChecks();
} else if (mode === "session") { } else if (mode === "session") {
await sessionChecks(); await sessionChecks();
} else if (mode === "module") {
await moduleChecks();
} else { } else {
throw new Error('Mode must be "edge", "tunnel", or "session".'); throw new Error('Mode must be "edge", "tunnel", "session", or "module".');
} }
if (failures > 0) { if (failures > 0) {
+979
View File
@@ -0,0 +1,979 @@
// Real-browser display-device scenarios for scripts/smoke-deploy.sh in the split topology (ADR 0036,
// ADR 0045). Runs inside a disposable container on the deployment's `edge` network and drives two
// headless Chromiums over the Chrome DevTools Protocol, with Node.js's own fetch and WebSocket and no
// other dependency: the smoke run's browser, where the owner signs in, and a "kiosk" browser whose
// profile directory is a Docker volume that outlives the kiosk container.
//
// node deploy-smoke-display.mjs <step>
//
// pair seed content, pair the kiosk, then close its browser cleanly
// restart same profile, new browser process: still paired and live?
// two-pages two pages, one device credential: does either flap?
// connectivity-blip the browser reports offline then online: is a live display remounted?
// session-blip Signage's first viewer-session request fails once: does the display recover?
// stream-failure the first realtime connection is refused: is realtime re-established?
// caching-probe can a display on the API origin store storage-origin images in Cache Storage?
// stream-recycle one page past the five-minute realtime recycle, with a timestamped timeline
// revoke revoke the paired kiosk while its display is open
//
// Every DevTools command and every page-side request has its own deadline, and a step has an overall
// budget. A call that never answers fails the step with a line naming that exact call, because a call
// that can hang the driver may be a request that can hang a real display.
//
// Output is one line per check or observation; nothing printed carries a credential, cookie value,
// signed URL, query string, or response body.
function required(name) {
const value = process.env[name];
if (!value) {
throw new Error(`${name} is required.`);
}
return value;
}
/** A positive whole number of milliseconds from the environment, or the default. */
function milliseconds(name, fallback) {
const raw = process.env[name];
const value = raw === undefined || raw === "" ? fallback : Number(raw);
if (!Number.isSafeInteger(value) || value <= 0) {
throw new Error(`${name} must be a positive whole number of milliseconds.`);
}
return value;
}
const mode = process.argv[2];
const apiOrigin = `https://${required("SMOKE_HOSTNAME")}`;
const dashboardOrigin = `https://${required("SMOKE_DASHBOARD_HOSTNAME")}`;
const ownerAddress = required("SMOKE_CDP_ADDRESS");
const kioskAddress = required("SMOKE_KIOSK_CDP_ADDRESS");
const watchMs = milliseconds("SMOKE_WATCH_MS", 120_000);
const recycleWatchMs = milliseconds("SMOKE_RECYCLE_WATCH_MS", 330_000);
const stepBudgetMs = milliseconds("SMOKE_STEP_BUDGET_MS", 180_000);
/** How long one DevTools command may go unanswered. */
const callTimeoutMs = milliseconds("SMOKE_CALL_TIMEOUT_MS", 10_000);
/** How long one page-side HTTP request may go unanswered; shorter than the call deadline, so a hung
* request is reported as the request rather than as the evaluation that awaited it. */
const requestTimeoutMs = Math.max(1_000, callTimeoutMs - 2_000);
const waitTimeoutMs = 30_000;
const deviceCookie = "ekklesia_device_session";
/** Far above what three cached slides need, which is one object URL each. */
const MAX_LIVE_OBJECT_URLS = 50;
const stepStartedAt = Date.now();
let failures = 0;
function check(condition, description) {
console.log(`${condition ? "ok " : "FAIL"} ${description}`);
if (!condition) {
failures += 1;
}
}
function note(description) {
console.log(` ${description}`);
}
const clock = (ms = Date.now()) => new Date(ms).toISOString().slice(11, 19);
const since = (from) => `+${Math.round((Date.now() - from) / 1000)}s`;
function progress(description) {
console.log(`···· ${clock()} ${since(stepStartedAt)} ${description}`);
}
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
/** A URL's path only: a signed delivery URL's query is a credential. */
const pathOf = (url) => {
try {
return new URL(url).pathname;
} catch {
return "(unparseable)";
}
};
// --- Hang guards ---------------------------------------------------------------------------------
class HangError extends Error {
constructor(label, ms) {
super(`HANG ${label}: no answer within ${ms / 1000}s`);
this.name = "HangError";
}
}
/** Everything awaited right now, by name, so a step that overruns its budget says what it was
* waiting for. */
const inFlight = new Map();
let nextOperation = 1;
function guard(label, promise, ms = callTimeoutMs) {
const id = nextOperation;
nextOperation += 1;
inFlight.set(id, { at: Date.now(), label });
let timer;
return Promise.race([
promise,
new Promise((_, reject) => {
timer = setTimeout(() => reject(new HangError(label, ms)), ms);
}),
]).finally(() => {
clearTimeout(timer);
inFlight.delete(id);
});
}
function describeInFlight() {
const pending = [...inFlight.values()].map(
(operation) => `${operation.label} (${Math.round((Date.now() - operation.at) / 1000)}s)`,
);
return pending.length === 0 ? "nothing — the step itself overran" : pending.join("; ");
}
setTimeout(() => {
console.log(
`FAIL ${mode}: step budget of ${stepBudgetMs / 1000}s exceeded; in flight: ${describeInFlight()}`,
);
process.exit(2);
}, stepBudgetMs).unref();
// --- DevTools ------------------------------------------------------------------------------------
/** The same minimal DevTools client as deploy-smoke-browser.mjs, with a deadline on every command. */
async function connect(devtools, name) {
const deadline = Date.now() + 30_000;
let version;
while (version === undefined) {
try {
const response = await fetch(`http://${devtools}/json/version`, {
signal: AbortSignal.timeout(2_000),
});
version = await response.json();
} catch {
if (Date.now() > deadline) {
throw new HangError(`the ${name} browser's DevTools endpoint`, 30_000);
}
await sleep(500);
}
}
const socketUrl = version.webSocketDebuggerUrl.replace(/^ws:\/\/[^/]+/u, `ws://${devtools}`);
const socket = new WebSocket(socketUrl);
await guard(
`the ${name} browser's DevTools socket`,
new Promise((resolve, reject) => {
socket.addEventListener("open", resolve, { once: true });
socket.addEventListener("error", () => reject(new Error("DevTools connection failed.")), {
once: true,
});
}),
);
let nextId = 1;
const pending = new Map();
const listeners = [];
socket.addEventListener("message", (event) => {
const message = JSON.parse(String(event.data));
if (message.id !== undefined) {
const waiter = pending.get(message.id);
pending.delete(message.id);
if (message.error) {
waiter?.reject(new Error(`DevTools ${waiter.method} failed.`));
} else {
waiter?.resolve(message.result);
}
return;
}
for (const listener of listeners) {
listener(message);
}
});
return {
close: () => socket.close(),
on: (listener) => listeners.push(listener),
send(method, params = {}, sessionId = undefined, where = name) {
const id = nextId;
nextId += 1;
socket.send(JSON.stringify({ id, method, params, ...(sessionId ? { sessionId } : {}) }));
return guard(
`DevTools ${method} on ${where}`,
new Promise((resolve, reject) => pending.set(id, { method, reject, resolve })),
);
},
};
}
/** Finds a test id anywhere in the document, including inside module artifacts' open shadow roots
* (ADR 0040). */
const deepFind = `((testId) => {
const walk = (root) => {
const found = root.querySelector('[data-testid="' + testId + '"]');
if (found) return found;
for (const element of root.querySelectorAll("*")) {
if (element.shadowRoot) {
const inner = walk(element.shadowRoot);
if (inner) return inner;
}
}
return null;
};
return walk(document);
})`;
const find = (testId) => `${deepFind}(${JSON.stringify(testId)})`;
/** Installed before any page script: the browser's own connectivity events and the display service
* worker's fallback reports, which are what the display host remounts on (ADR 0043). */
const instrumentation = `(() => {
const events = [];
// Object URLs created and revoked in this document. Each live one pins its blob in the browser's
// blob storage until it is revoked or the document goes away.
const blobs = { created: 0, revoked: 0 };
Object.defineProperty(window, "__displayRepro", { value: { blobs, events } });
const create = URL.createObjectURL.bind(URL);
const revoke = URL.revokeObjectURL.bind(URL);
URL.createObjectURL = (object) => { blobs.created += 1; return create(object); };
URL.revokeObjectURL = (url) => { blobs.revoked += 1; return revoke(url); };
const record = (kind) => events.push({ at: Date.now(), kind });
addEventListener("online", () => record("online"));
addEventListener("offline", () => record("offline"));
navigator.serviceWorker?.addEventListener("message", (event) => {
const status = event.data && event.data.ekklesiaDisplayOffline;
if (status) record(status.usingFallback ? "sw-fallback:" + (status.kind || "") : "sw-network");
});
})()`;
/** One page target with its own flat session, and what it does on the network. */
async function openPage(cdp, url, label) {
const { targetId } = await cdp.send("Target.createTarget", { url: "about:blank" });
const { sessionId } = await cdp.send("Target.attachToTarget", { flatten: true, targetId });
const send = (method, params) => cdp.send(method, params, sessionId, `page "${label}"`);
const requests = new Map();
const page = {
label,
/** Every realtime connection: when it opened, its status, and when and how it ended. */
streams: [],
/** Signage viewer-session requests: one per module mount. */
sessionRequests: [],
/** Heartbeat statuses, by status code. */
heartbeats: new Map(),
/** Channel-state fetches and how they went. */
stateFetches: [],
/** Non-2xx responses and failed requests, by path only. */
problems: [],
};
cdp.on((message) => {
if (message.sessionId !== sessionId) {
return;
}
const params = message.params ?? {};
if (message.method === "Network.requestWillBeSent") {
const path = pathOf(params.request.url);
const entry = { at: Date.now(), path, type: params.type };
requests.set(params.requestId, entry);
if (params.type === "EventSource") {
entry.stream = {
openedAt: Date.now(),
path,
status: undefined,
endedAt: undefined,
how: undefined,
};
page.streams.push(entry.stream);
} else if (path.endsWith("/viewers/session")) {
page.sessionRequests.push(Date.now());
} else if (path.endsWith("/display-state") && !path.includes("module-realtime")) {
entry.state = { at: Date.now(), outcome: "pending" };
page.stateFetches.push(entry.state);
}
} else if (message.method === "Network.responseReceived") {
const entry = requests.get(params.requestId);
const { status } = params.response;
if (entry?.stream) {
entry.stream.status = status;
}
if (entry?.state) {
entry.state.outcome = String(status);
}
if (entry?.path.endsWith("/heartbeat")) {
page.heartbeats.set(status, (page.heartbeats.get(status) ?? 0) + 1);
}
if (status >= 400 || status === 0) {
page.problems.push(`${clock()} ${status} ${pathOf(params.response.url)}`);
}
} else if (message.method === "Network.loadingFinished") {
const entry = requests.get(params.requestId);
if (entry?.stream) {
entry.stream.endedAt = Date.now();
entry.stream.how = "finished";
}
} else if (message.method === "Network.loadingFailed") {
const entry = requests.get(params.requestId);
if (entry?.stream) {
entry.stream.endedAt = Date.now();
entry.stream.how = params.canceled ? "canceled" : `failed ${params.errorText}`;
}
if (entry?.state) {
entry.state.outcome = params.canceled ? "canceled" : `failed ${params.errorText}`;
}
if (entry && !params.canceled) {
page.problems.push(`${clock()} failed ${params.errorText} ${entry.path}`);
}
}
});
await send("Network.enable");
await send("Page.enable");
await send("Runtime.enable");
await send("Page.addScriptToEvaluateOnNewDocument", { source: instrumentation });
await send("Emulation.setDeviceMetricsOverride", {
deviceScaleFactor: 1,
height: 1080,
mobile: false,
width: 1920,
});
page.send = send;
/** Runs an expression in the page. `what` names it if it never comes back. */
page.evaluate = async (expression, what = "an evaluation") => {
const result = await guard(
`page "${label}": ${what}`,
send("Runtime.evaluate", { awaitPromise: true, expression, returnByValue: true }),
);
if (result.exceptionDetails) {
throw new Error(`A page expression threw (${what}).`);
}
return result.result.value;
};
page.waitFor = async (expression, description, ms = waitTimeoutMs) => {
const deadline = Date.now() + ms;
while (Date.now() < deadline) {
if (
await page
.evaluate(`Boolean(${expression})`, `waiting for ${description}`)
.catch((error) => {
if (error instanceof HangError) throw error;
return false;
})
) {
return true;
}
await sleep(250);
}
note(`timed out waiting for ${description}`);
return false;
};
/** A credentialed request from this page, bounded in the page itself, so a request that never
* answers is reported as that request. */
page.request = async (method, url, body) => {
const result = await page.evaluate(
`fetch(${JSON.stringify(url)}, {
credentials: "include",
method: ${JSON.stringify(method)},
signal: AbortSignal.timeout(${requestTimeoutMs}),
${body === undefined ? "" : `headers: { "content-type": "application/json" }, body: ${JSON.stringify(JSON.stringify(body))},`}
}).then(
async (response) => ({ status: response.status, body: await response.json().catch(() => null) }),
(error) => ({ status: error && error.name === "TimeoutError" ? "timeout" : "network-error" }),
)`,
`${method} ${pathOf(url)}`,
);
if (result.status === "timeout") {
throw new HangError(`HTTP ${method} ${pathOf(url)} from page "${label}"`, requestTimeoutMs);
}
return result;
};
page.close = () => cdp.send("Target.closeTarget", { targetId });
page.navigate = (to) => send("Page.navigate", { url: to });
if (url) {
await page.navigate(url);
}
return page;
}
/** The owner, signed in on the Dashboard hostname of the smoke run's own browser. */
async function ownerPage() {
const cdp = await connect(ownerAddress, "owner");
const page = await openPage(cdp, `${dashboardOrigin}/`, "owner");
await page.waitFor(`${find("login-email")} || ${find("host-shell")}`, "the Dashboard");
if (!(await page.evaluate(`Boolean(${find("host-shell")})`, "checking the session"))) {
await page.evaluate(
`(() => {
const setValue = (element, value) => {
Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value").set.call(element, value);
element.dispatchEvent(new Event("input", { bubbles: true }));
};
setValue(${find("login-email")}, ${JSON.stringify(required("SMOKE_OWNER_EMAIL"))});
setValue(${find("login-password")}, ${JSON.stringify(required("SMOKE_OWNER_PASSWORD"))});
${find("login-email")}.form.requestSubmit();
})()`,
"submitting the sign-in form",
);
check(await page.waitFor(find("host-shell"), "the signed-in Dashboard"), "the owner signs in");
}
page.api = (method, path, body) => page.request(method, `${apiOrigin}${path}`, body);
return { cdp, page };
}
/** Closes the owner's tab as well as the connection: the owner browser is shared by every step, and a
* tab left behind by each one accumulates until the Dashboard no longer renders in time. */
async function finishOwner(owner) {
await owner.page.close().catch(() => undefined);
owner.cdp.close();
}
async function displayModuleId(owner) {
const navigation = await owner.api("GET", "/api/v1/dashboard/navigation");
const entries = [
...navigation.body.ungrouped,
...navigation.body.groups.flatMap((group) => group.modules),
];
const withDisplay = entries.filter((entry) => entry.module.display !== null);
const signage = withDisplay.find((entry) => entry.module.id === "signage");
return (signage ?? withDisplay[0])?.module.id;
}
/** Three slides of plain colour, on a five-second rotation, so a display has real content to persist
* and continue from, as the production screen did. Signage only. */
async function seedSignage(owner) {
const base = "/api/v1/modules/signage";
const images = [];
for (const [index, colour] of ["#b03030", "#3060b0", "#30a050"].entries()) {
const uploaded = await owner.evaluate(
`(async () => {
const canvas = document.createElement("canvas");
canvas.width = 640;
canvas.height = 360;
const context = canvas.getContext("2d");
context.fillStyle = ${JSON.stringify(colour)};
context.fillRect(0, 0, 640, 360);
const blob = await new Promise((resolve) => canvas.toBlob(resolve, "image/png"));
const response = await fetch(${JSON.stringify(`${apiOrigin}${base}/images?filename=slide-${index}.png&displayName=Slide%20${index}`)}, {
body: blob, credentials: "include", headers: { "content-type": "image/png" }, method: "POST",
signal: AbortSignal.timeout(${requestTimeoutMs}),
});
return { status: response.status, body: await response.json().catch(() => null) };
})()`,
`uploading slide ${index}`,
);
images.push(uploaded.body?.image?.id);
}
check(
images.every((id) => typeof id === "string"),
"three slides are uploaded to Signage",
);
const timeline = await owner.api("POST", `${base}/timelines`, { name: "Display repro" });
const timelineId = timeline.body?.timeline?.id ?? timeline.body?.id;
for (const itemId of images) {
await owner.api("POST", `${base}/timelines/${timelineId}/slots`, {
durationOverrideMs: 5_000,
itemId,
providerId: "signage.native-images",
});
}
const groups = await owner.api("GET", `${base}/channel-groups`);
const groupId = groups.body?.groups?.[0]?.id;
const activated = await owner.api("POST", `${base}/playback/${groupId}/activate`, {
timelineId,
});
check(activated.status === 200, "a three-slide timeline is playing on the default channel group");
}
async function signageViewers(owner) {
const listed = await owner.api("GET", "/api/v1/modules/signage/viewers");
return listed.body?.viewers ?? [];
}
async function coreDevices(owner) {
const listed = await owner.api("GET", "/api/v1/devices");
return listed.body?.devices ?? [];
}
/** The display's own view of itself: which host state is showing, and Signage's offline flag. */
function displayState(page) {
return page.evaluate(
`(() => {
const stage = ${find("signage-stage")};
return {
pairing: Boolean(${find("display-pairing")}),
hostLoading: Boolean(${find("display-host-loading")}) || Boolean(${find("display-loading")}),
hostError: Boolean(${find("display-host-error")}) || Boolean(${find("display-error")}),
hostOffline: Boolean(${find("display-offline")}),
mounted: (${find("display-module-container")}?.childElementCount ?? 0) > 0,
signageOffline: stage ? stage.dataset.offline === "true" : null,
};
})()`,
"reading the display state",
);
}
async function blobCounts(page) {
return page.evaluate(
"window.__displayRepro ? window.__displayRepro.blobs : { created: 0, revoked: 0 }",
"reading object-URL counts",
);
}
async function connectivityEvents(page) {
return page.evaluate(
"window.__displayRepro ? window.__displayRepro.events : []",
"reading connectivity events",
);
}
async function describeDeviceCookie(cdp) {
const { cookies } = await cdp.send("Storage.getCookies");
const cookie = cookies.find((candidate) => candidate.name === deviceCookie);
if (!cookie) {
note(`${deviceCookie}: absent from the profile`);
return undefined;
}
const days = cookie.session
? "session"
: `${((cookie.expires * 1000 - Date.now()) / 86_400_000).toFixed(1)} days`;
note(
`${deviceCookie}: domain=${cookie.domain} session=${cookie.session} lifetime=${days} httpOnly=${cookie.httpOnly} secure=${cookie.secure} sameSite=${cookie.sameSite}`,
);
return cookie;
}
const liveExpression = `(() => {
const stage = ${find("signage-stage")};
const mounted = (${find("display-module-container")}?.childElementCount ?? 0) > 0;
return mounted && !${find("display-pairing")} && (stage === null || stage.dataset.offline === "false");
})()`;
function waitLive(page, description, ms = 45_000) {
return page.waitFor(liveExpression, description, ms);
}
/** A page's network story, one line each, by path only. */
function summarize(page, from) {
const at = (ms) => (ms === undefined ? "-" : `${clock(ms)} (${Math.round((ms - from) / 1000)}s)`);
note(`${page.label}: viewer sessions opened ${page.sessionRequests.length} (= module mounts)`);
note(
`${page.label}: heartbeats ${[...page.heartbeats].map(([status, count]) => `${status}×${count}`).join(" ") || "none"}`,
);
const states = page.stateFetches.reduce((tally, fetch) => {
tally[fetch.outcome] = (tally[fetch.outcome] ?? 0) + 1;
return tally;
}, {});
note(`${page.label}: channel-state fetches ${JSON.stringify(states)}`);
for (const stream of page.streams) {
const lived =
stream.endedAt === undefined
? "open"
: `${Math.round((stream.endedAt - stream.openedAt) / 1000)}s`;
note(
`${page.label}: stream opened ${at(stream.openedAt)} status ${stream.status ?? "-"} ended ${at(stream.endedAt)} ${stream.how ?? ""} lived ${lived}`,
);
}
note(`${page.label}: problems ${page.problems.slice(-12).join(" | ") || "none"}`);
}
/** Samples display state on an interval for a fixed time, printing progress, and returns a tally. */
async function watch(pages, ms, { onSample, viewerOwner } = {}) {
const from = Date.now();
const tally = new Map(
pages.map((page) => [
page.label,
{ loading: 0, offline: 0, pairing: 0, samples: 0, transitions: [] },
]),
);
const last = new Map();
let nextProgress = from + 15_000;
const roster = [];
while (Date.now() - from < ms) {
for (const page of pages) {
const state = await displayState(page);
const t = tally.get(page.label);
t.samples += 1;
if (state.signageOffline || state.hostOffline) t.offline += 1;
if (state.pairing) t.pairing += 1;
if (state.hostLoading) t.loading += 1;
const key = JSON.stringify(state);
if (last.get(page.label) !== key) {
last.set(page.label, key);
t.transitions.push(`${clock()} ${since(from)} ${key}`);
}
if (onSample && (await onSample(page, state))) {
return { from, roster, stopped: true, tally };
}
}
if (Date.now() >= nextProgress) {
nextProgress = Date.now() + 15_000;
if (viewerOwner) {
const viewers = await signageViewers(viewerOwner);
roster.push(
`${since(from)} ${viewers.map((v) => `${v.name}:${v.online ? "Online" : "Offline"}`).join(",")}`,
);
}
const lines = [];
for (const page of pages) {
const t = tally.get(page.label);
const blobs = await blobCounts(page);
lines.push(
`${page.label}: ${t.samples} samples, offline ${t.offline}, mounts ${page.sessionRequests.length}, streams ${page.streams.length}, live object URLs ${blobs.created - blobs.revoked}`,
);
}
progress(lines.join(" · "));
}
await sleep(2_000);
}
return { from, roster, stopped: false, tally };
}
function printTally(result) {
for (const [label, t] of result.tally) {
note(
`${label}: ${t.samples} samples, offline in ${t.offline}, pairing in ${t.pairing}, loading in ${t.loading}`,
);
for (const transition of t.transitions.slice(0, 30)) {
note(` ${label} ${transition}`);
}
}
if (result.roster.length > 0) {
note(`roster over time: ${result.roster.join(" ; ")}`);
}
}
// --- Steps ---------------------------------------------------------------------------------------
async function run() {
if (mode === "pair") {
const owner = await ownerPage();
const moduleId = await displayModuleId(owner.page);
check(moduleId !== undefined, `a module publishes a display (${moduleId})`);
if (moduleId === "signage") {
await seedSignage(owner.page);
}
const kiosk = await connect(kioskAddress, "kiosk");
const display = await openPage(kiosk, `${apiOrigin}/display/${moduleId}`, "kiosk");
check(
await display.waitFor(find("display-pairing-code"), "a pairing code"),
"an unpaired kiosk shows a pairing code",
);
const code = (
await display.evaluate(`${find("display-pairing-code")}.textContent`, "reading the code")
).replace(/\D/gu, "");
const approved = await owner.page.api("POST", "/api/v1/device-pairing/approvals", {
code,
deviceName: "Repro kiosk",
});
check(approved.status === 200, "the owner approves the code");
check(
await waitLive(display, "the paired display to go live"),
"the paired kiosk mounts the display and is live",
);
await describeDeviceCookie(kiosk);
progress("letting heartbeats, a persisted snapshot, and media prefetch happen (20s)");
await sleep(20_000);
for (const viewer of await signageViewers(owner.page)) {
note(
`signage viewer "${viewer.name}" online=${viewer.online} offlineReady=${viewer.offlineReady} cachedAssets=${viewer.cachedAssetCount} error=${viewer.lastError?.code ?? "none"}`,
);
}
summarize(display, stepStartedAt);
// A clean exit: the browser shuts down normally, exactly as a systemd stop of a well-behaved kiosk.
await kiosk.send("Browser.close").catch(() => undefined);
kiosk.close();
await finishOwner(owner);
} else if (mode === "restart") {
const owner = await ownerPage();
const moduleId = await displayModuleId(owner.page);
const kiosk = await connect(kioskAddress, "kiosk");
const cookie = await describeDeviceCookie(kiosk);
check(cookie !== undefined, "the device credential survived a clean browser exit");
const display = await openPage(kiosk, `${apiOrigin}/display/${moduleId}`, "kiosk");
const live = await waitLive(display, "the restarted display to go live");
const state = await displayState(display);
check(
live && !state.pairing,
`after a clean restart the kiosk is still paired and live (${JSON.stringify(state)})`,
);
summarize(display, stepStartedAt);
await display.close();
kiosk.close();
await finishOwner(owner);
} else if (mode === "two-pages") {
const owner = await ownerPage();
const moduleId = await displayModuleId(owner.page);
const kiosk = await connect(kioskAddress, "kiosk");
const url = `${apiOrigin}/display/${moduleId}`;
const first = await openPage(kiosk, url, "first");
check(await waitLive(first, "the first page"), "the first page is live");
const second = await openPage(kiosk, url, "second");
check(await waitLive(second, "the second page"), "the second page is live");
const result = await watch([first, second], watchMs, { viewerOwner: owner.page });
printTally(result);
for (const page of [first, second]) {
summarize(page, result.from);
note(`${page.label}: connectivity events ${JSON.stringify(await connectivityEvents(page))}`);
}
check(
[...result.tally.values()].every((t) => t.offline === 0),
`neither page showed an offline state while both were open (${watchMs / 1000}s)`,
);
check(
[first, second].every((page) => page.sessionRequests.length === 1),
"neither page was remounted while both were open",
);
await first.close();
await second.close();
kiosk.close();
await finishOwner(owner);
} else if (mode === "connectivity-blip") {
// The browser's own offline/online events, as a Wi-Fi reassociation or a link flap produces. The
// display host re-resolves its descriptor on "online" (ADR 0043); this measures what that costs.
const owner = await ownerPage();
const moduleId = await displayModuleId(owner.page);
const kiosk = await connect(kioskAddress, "kiosk");
const page = await openPage(kiosk, `${apiOrigin}/display/${moduleId}`, "blip");
check(await waitLive(page, "the display"), "the display is live");
const mountsBefore = page.sessionRequests.length;
await page.evaluate(
`dispatchEvent(new Event("offline")); setTimeout(() => dispatchEvent(new Event("online")), 1000)`,
"dispatching offline then online",
);
let firstOfflineAt;
let lastOfflineAt;
const result = await watch([page], 30_000, {
onSample: (_page, state) => {
if (state.signageOffline || state.hostOffline || state.hostLoading || !state.mounted) {
firstOfflineAt ??= Date.now();
lastOfflineAt = Date.now();
}
return false;
},
});
printTally(result);
const remounts = page.sessionRequests.length - mountsBefore;
note(`remounts caused by one offline/online pair: ${remounts}`);
note(
firstOfflineAt === undefined
? "nothing visible changed"
: `visible disruption from ${clock(firstOfflineAt)} to ${clock(lastOfflineAt)} (sampled every 2s)`,
);
summarize(page, result.from);
check(remounts === 0, "a connectivity blip does not remount a live display");
check(
firstOfflineAt === undefined,
"a connectivity blip does not show an offline state on a live display",
);
await page.close();
kiosk.close();
await finishOwner(owner);
} else if (mode === "session-blip") {
// One transient failure of Signage's viewer-session request at start-up, as a kiosk booting
// before its network settles, or an API restart, would produce. Everything else reaches the
// server.
const owner = await ownerPage();
const moduleId = await displayModuleId(owner.page);
const kiosk = await connect(kioskAddress, "kiosk");
const page = await openPage(kiosk, undefined, "session-blip");
let blocked = 0;
kiosk.on((message) => {
if (message.method === "Fetch.requestPaused") {
const { requestId, request } = message.params;
if (blocked === 0 && pathOf(request.url).endsWith("/viewers/session")) {
blocked += 1;
page
.send("Fetch.failRequest", { errorReason: "ConnectionRefused", requestId })
.catch((error) => note(`interception reply failed: ${error.message}`));
} else {
page
.send("Fetch.continueRequest", { requestId })
.catch((error) => note(`interception reply failed: ${error.message}`));
}
}
});
await page.send("Fetch.enable", { patterns: [{ urlPattern: "*/api/v1/modules/*" }] });
await page.navigate(`${apiOrigin}/display/${moduleId}`);
await page.waitFor(
`(${find("display-module-container")}?.childElementCount ?? 0) > 0`,
"the module to mount",
);
note(`viewer-session requests failed on purpose: ${blocked}`);
const recovered = await page.waitFor(
`${find("signage-stage")}?.dataset.offline === "false"`,
"recovery from one failed viewer-session request",
90_000,
);
summarize(page, stepStartedAt);
check(
recovered,
`one failed viewer-session request does not leave the display offline (${JSON.stringify(await displayState(page))})`,
);
await page.close();
kiosk.close();
await finishOwner(owner);
} else if (mode === "stream-failure") {
// The first realtime connection is refused with a 503, as an API mid-restart or a proxy with no
// upstream answers. A non-200 ends an EventSource for good; the question is whether anything
// re-establishes realtime without a reload.
const owner = await ownerPage();
const moduleId = await displayModuleId(owner.page);
const kiosk = await connect(kioskAddress, "kiosk");
const page = await openPage(kiosk, undefined, "stream-failure");
let refused = 0;
kiosk.on((message) => {
if (message.method === "Fetch.requestPaused") {
const { requestId } = message.params;
if (refused === 0) {
refused += 1;
page
.send("Fetch.fulfillRequest", {
body: Buffer.from('{"code":"REALTIME_UNAVAILABLE"}').toString("base64"),
requestId,
responseCode: 503,
responseHeaders: [
{ name: "content-type", value: "application/json" },
{ name: "cache-control", value: "no-store" },
],
})
.catch((error) => note(`interception reply failed: ${error.message}`));
} else {
page
.send("Fetch.continueRequest", { requestId })
.catch((error) => note(`interception reply failed: ${error.message}`));
}
}
});
await page.send("Fetch.enable", { patterns: [{ urlPattern: "*/api/v1/module-realtime/*" }] });
await page.navigate(`${apiOrigin}/display/${moduleId}`);
await page.waitFor(
`(${find("display-module-container")}?.childElementCount ?? 0) > 0`,
"the module to mount",
);
note(`realtime connections refused on purpose: ${refused}`);
const result = await watch([page], 90_000, {
onSample: () => page.streams.some((stream) => stream.status === 200),
});
printTally(result);
summarize(page, result.from);
check(
page.streams.some((stream) => stream.status === 200),
"after one refused realtime connection, realtime is re-established without a reload",
);
await page.close();
kiosk.close();
await finishOwner(owner);
} else if (mode === "caching-probe") {
// Whether a display on the API origin can store an image from the storage origin in Cache
// Storage, which is what Signage's offline media relies on. Reports only statuses, response
// types, and whether CORS headers were present — never a URL.
const owner = await ownerPage();
const moduleId = await displayModuleId(owner.page);
const groups = await owner.page.api("GET", "/api/v1/modules/signage/channel-groups");
const channelId = groups.body?.groups?.[0]?.channels?.[0]?.id;
const kiosk = await connect(kioskAddress, "kiosk");
const page = await openPage(kiosk, `${apiOrigin}/display/${moduleId}`, "probe");
check(await waitLive(page, "the display"), "the display is live");
progress("letting the display prefetch its slides (15s)");
await sleep(15_000);
const probe = await page.evaluate(
`(async () => {
const result = {};
const state = await fetch(${JSON.stringify(`${apiOrigin}/api/v1/modules/signage/channels/${channelId}/display-state`)}, { credentials: "include", signal: AbortSignal.timeout(${requestTimeoutMs}) }).then((r) => r.json());
const url = (state.selections || []).map((s) => s.media && s.media.display && s.media.display.url).find(Boolean);
result.selections = (state.selections || []).length;
result.haveUrl = Boolean(url);
if (!url) return result;
result.sameOrigin = new URL(url).origin === location.origin;
try {
const response = await fetch(url, { signal: AbortSignal.timeout(${requestTimeoutMs}) });
result.cors = { status: response.status, type: response.type, allowOrigin: response.headers.get("access-control-allow-origin") === null ? "absent" : (response.headers.get("access-control-allow-origin") === location.origin || response.headers.get("access-control-allow-origin") === "*" ? "matches" : "other") };
try {
const cache = await caches.open("display-repro-probe");
await cache.put("/display-repro-probe/cors", response.clone());
result.cors.cachePut = "stored";
} catch (error) { result.cors.cachePut = "refused: " + error.name; }
} catch (error) { result.cors = { error: error.name }; }
try {
const response = await fetch(url, { mode: "no-cors", signal: AbortSignal.timeout(${requestTimeoutMs}) });
result.noCors = { status: response.status, type: response.type, ok: response.ok };
try {
const cache = await caches.open("display-repro-probe");
await cache.put("/display-repro-probe/opaque", response.clone());
result.noCors.cachePut = "stored";
} catch (error) { result.noCors.cachePut = "refused: " + error.name; }
} catch (error) { result.noCors = { error: error.name }; }
result.imgPlain = await new Promise((resolve) => { const i = new Image(); i.onload = () => resolve("loaded"); i.onerror = () => resolve("error"); i.src = url; });
result.imgCors = await new Promise((resolve) => { const i = new Image(); i.crossOrigin = "anonymous"; i.onload = () => resolve("loaded"); i.onerror = () => resolve("error"); i.src = url + (url.includes("?") ? "&" : "?") + "cors-probe=1"; });
await caches.delete("display-repro-probe");
result.signageCachedEntries = await caches.open("ekklesia-signage-media-v1").then((c) => c.keys()).then((k) => k.length).catch(() => "unavailable");
return result;
})()`,
"probing cross-origin image caching",
);
note(`caching probe: ${JSON.stringify(probe)}`);
summarize(page, stepStartedAt);
check(probe.signageCachedEntries > 0, "Signage has stored slide images in Cache Storage");
await page.close();
kiosk.close();
await finishOwner(owner);
} else if (mode === "stream-recycle") {
// One page, past the five-minute realtime connection age, with every stream's open and end
// time in UTC so they can be laid against the proxy's own log.
const owner = await ownerPage();
const moduleId = await displayModuleId(owner.page);
const kiosk = await connect(kioskAddress, "kiosk");
const page = await openPage(kiosk, `${apiOrigin}/display/${moduleId}`, "recycle");
check(await waitLive(page, "the display"), "the display is live");
const result = await watch([page], recycleWatchMs, { viewerOwner: owner.page });
printTally(result);
summarize(page, result.from);
note(`connectivity events ${JSON.stringify(await connectivityEvents(page))}`);
const blobs = await blobCounts(page);
const exhausted = page.stateFetches.filter((fetch) =>
fetch.outcome.includes("INSUFFICIENT_RESOURCES"),
);
note(
`object URLs: created ${blobs.created}, revoked ${blobs.revoked}, live ${blobs.created - blobs.revoked}; channel-state fetches refused for insufficient resources: ${exhausted.length}`,
);
check(
blobs.created - blobs.revoked <= MAX_LIVE_OBJECT_URLS,
`live object URLs stay bounded (at most ${MAX_LIVE_OBJECT_URLS}; ${blobs.created - blobs.revoked} live)`,
);
check(
result.tally.get("recycle").offline === 0,
`the display stayed live for the whole ${recycleWatchMs / 1000}s watch${recycleWatchMs > 300_000 ? ", across a realtime recycle" : ""}`,
);
check(page.sessionRequests.length === 1, "the display was never remounted during the watch");
await page.close();
kiosk.close();
await finishOwner(owner);
} else if (mode === "revoke") {
const owner = await ownerPage();
const moduleId = await displayModuleId(owner.page);
const kiosk = await connect(kioskAddress, "kiosk");
const page = await openPage(kiosk, `${apiOrigin}/display/${moduleId}`, "revoked");
check(
await waitLive(page, "the display before revocation"),
"the display is live before revocation",
);
const devices = (await coreDevices(owner.page)).filter((device) => !device.revoked);
for (const device of devices) {
await owner.page.api("POST", `/api/v1/devices/${device.id}/revoke`);
}
note(`revoked ${devices.length} device(s) at ${clock()}; watching the open page`);
const result = await watch([page], watchMs, { onSample: (_page, state) => state.pairing });
printTally(result);
summarize(page, result.from);
check(
result.stopped,
`a revoked display returns to the pairing screen without a reload (within ${watchMs / 1000}s)`,
);
await page.close();
kiosk.close();
await finishOwner(owner);
} else {
throw new Error(
"usage: deploy-smoke-display.mjs pair|restart|two-pages|connectivity-blip|session-blip|stream-failure|caching-probe|stream-recycle|revoke",
);
}
}
try {
await run();
} catch (error) {
if (error instanceof HangError) {
console.log(`FAIL ${mode}: ${error.message}; also in flight: ${describeInFlight()}`);
} else {
console.log(`FAIL ${mode}: ${error instanceof Error ? error.message : "an unexpected error"}`);
}
process.exit(2);
}
if (failures > 0) {
console.error(`${failures} check(s) failed`);
process.exit(1);
}
process.exit(0);
+53
View File
@@ -0,0 +1,53 @@
# shellcheck shell=bash
# The deploy smoke test's module package release pair (ADR 0049).
#
# A package archive and its `.sha256` are one logical release: they travel together, and the
# verifier reads both. This turns one build's output into exactly that pair, in a directory any user
# can traverse, because the verifier runs as the image's unprivileged user.
#
# BuildKit's local exporter creates its output directory with mode 0700, owned by whoever ran the
# build. On a workstation that is usually uid 1000, which is also the image's `node` user, so the
# verifier happened to read it; in CI the build runs as root, and the verifier could read neither file.
# Nothing here depends on who built the package.
# module_package_release <build output directory> <new release directory>
#
# Checks the build output holds exactly one archive and its digest and nothing else, and that the
# digest line names that archive. Then creates the release directory, which must not exist yet (so an
# earlier run's files can never be mixed in), mode 0755, and copies both files into it, mode 0644.
# Prints the archive's file name. Fails, creating nothing, on any deviation.
module_package_release() {
local output="$1" release="$2" archives=() archive digest_line digest named entries
shopt -s nullglob
archives=("$output"/*.tar.gz)
shopt -u nullglob
if ((${#archives[@]} != 1)); then
echo "module_package_release: the build output must hold exactly one package archive." >&2
return 1
fi
archive="$(basename -- "${archives[0]}")"
if [[ ! -f "$output/$archive.sha256" ]]; then
echo "module_package_release: the package's .sha256 is missing." >&2
return 1
fi
entries="$(find "$output" -mindepth 1 -maxdepth 1 | wc -l)"
if ((entries != 2)); then
echo "module_package_release: the build output holds more than the archive and its digest." >&2
return 1
fi
digest_line="$(<"$output/$archive.sha256")"
digest="${digest_line%% *}"
named="${digest_line#* }"
if [[ ! "$digest" =~ ^[0-9a-f]{64}$ || "$named" != "$archive" ]]; then
echo "module_package_release: the .sha256 line does not name this archive." >&2
return 1
fi
mkdir -m 755 -- "$release" || return 1
if ! install -m 0644 -- "$output/$archive" "$output/$archive.sha256" "$release/"; then
rm -rf -- "$release"
return 1
fi
printf '%s\n' "$archive"
}

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