10 Commits
Author SHA1 Message Date
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
89 changed files with 8166 additions and 409 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
+12 -20
View File
@@ -152,23 +152,14 @@ jobs:
- name: Sweep stale CI stacks from the runner - name: Sweep stale CI stacks from the runner
run: bash scripts/ci-sweep.sh --stale run: bash scripts/ci-sweep.sh --stale
# The platform-only image (ADR 0044): a checkout has no `modules/`, so this proves the image # The one generic platform image (ADR 0044, ADR 0048), proved rather than assumed: it contains no
# builds, validates, and assembles with zero modules. Build only — no registry login, no # module, no module template, and no module manifest; its module storage mount point is empty;
# credentials, no push, no tag anywhere but this runner's local image store. # EKKLESIA_MODULES_DIR names it; and a checkout with a populated modules/ directory builds an
# Tagged per run and attempt, not only per commit: a pull request and a push of the same commit # identical runtime file system. Build only — no registry login, no credentials, no push, no tag
# can build at once on one runner's Docker daemon, and neither may remove the other's image. # anywhere but this runner's local image store. Tagged per run and attempt, and labeled like
# Labeled like every CI stack, so this job's final step removes it even if the build is killed. # every CI stack, so this job's final step removes the images even if a build is killed.
- name: Build the production image without modules - name: Build the generic platform image and prove it holds no modules
shell: bash run: bash scripts/check-image.sh
run: |
source scripts/lib/ci-project.sh
docker build \
--file deploy/Dockerfile \
--build-arg REVISION="$GITHUB_SHA" \
--label "${EKKLESIA_CI_LABEL}=$(ci_project_name image)" \
--label "${EKKLESIA_CI_CREATED_LABEL}=$(date +%s)" \
--tag ekklesia-ci:"$GITHUB_SHA-${GITHUB_RUN_ID:-0}-${GITHUB_RUN_ATTEMPT:-1}" \
.
# Whatever happened above, this job's own stacks do not outlive it. # Whatever happened above, this job's own stacks do not outlive it.
- name: Remove this job's CI stacks - name: Remove this job's CI stacks
@@ -179,8 +170,9 @@ jobs:
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:
@@ -206,7 +198,7 @@ jobs:
- name: Sweep stale CI stacks from the runner - name: Sweep stale CI stacks from the runner
run: bash scripts/ci-sweep.sh --stale run: bash scripts/ci-sweep.sh --stale
# The reference deployment end to end (ADR 0044): a platform-only image, base + proxy with # The reference deployment end to end (ADR 0044): the generic platform image, base + proxy with
# Caddy's internal CA (test-only), an explicit migration apply against this job's own fresh # 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
+16
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
+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");
+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;
});
}
+35 -17
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) {
@@ -1132,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");
});
});
+4
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",
@@ -135,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",
@@ -159,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",
@@ -207,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",
+121 -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",
@@ -1657,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,
});
});
});
+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
+176 -50
View File
@@ -64,6 +64,7 @@ TLS is everywhere, without any exception in Core:
| `/etc/ekklesia/object-storage.env` | The S3 key pair | `object-storage` | | `/etc/ekklesia/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
@@ -309,9 +408,13 @@ 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, and the same image loaded twice over - 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`; HTTP/2, the second time as a conditional request that must be a clean `304`;
@@ -324,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
@@ -350,34 +463,37 @@ 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
@@ -399,30 +515,37 @@ set `DOCKER_BUILDKIT=1` if your Docker CLI has no buildx plugin.
No secret is a build argument, and none enters any layer. `.env` files are excluded from the build 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
@@ -443,8 +566,8 @@ Never publish the API's port to the host or the Internet. Only the proxy publish
- **Read-only:** the image was verified under exactly these flags. The API reached `healthy`, and - **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
@@ -482,14 +605,17 @@ reports direct mode for an API that trusts its proxy. The running API's own valu
| Apply migrations | `docker compose run --rm migrate node core/dist/cli/apply-migrations.js` | | 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
+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: {}
+6
View File
@@ -15,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.
@@ -13,6 +13,12 @@
[ADR 0043](0043-display-offline-code-fallback.md), [ADR 0043](0043-display-offline-code-fallback.md),
[ADR 0044](0044-self-hosted-reference-deployment.md), [ADR 0044](0044-self-hosted-reference-deployment.md),
[ADR 0045](0045-split-dashboard-and-api-origins.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 ## Context
@@ -0,0 +1,311 @@
# 0048: Ekklesia ships one generic image, and installed modules live outside it
- Status: Accepted
- Date: 2026-10-07
- Supersedes (partial): [0044](0044-self-hosted-reference-deployment.md) — only its "Composition
from pinned sources" section, its topology table's statement that the `api` image contains "the
enabled modules", and its consequence that modules are built inside a platform workspace with a
per-instance lockfile. The one reference deployment, its topology, TLS everywhere, object storage,
explicit migrations, health endpoints, backups, and secrets remain accepted and unchanged.
- Supersedes (partial): [0009](0009-transactional-module-migrations.md) — only its rule that an
applied migration absent from the current inventory is rejected, which that ADR left to "a future
explicit platform policy". This ADR is that policy, for modules only. The fixed history contract,
the per-module exact-prefix rule, checksums, locking, the single transaction, approvals, and
forward-only execution remain accepted and unchanged.
- Amends: [0006](0006-versioned-capabilities-and-compiled-module-runtime.md) — installing a module
"by cloning it into `modules/`, installing workspace dependencies, building the workspace" becomes
the development workflow only. Inert manifests, atomic discovery, compiled ESM entrypoints, and
the trusted-plugin model remain accepted and unchanged.
- Extends: [0028](0028-module-operational-lifecycle.md),
[0030](0030-host-provided-module-object-storage.md)
- Related: [ADR 0007](0007-module-owned-drizzle-schemas.md),
[ADR 0008](0008-module-owned-migration-contracts.md),
[ADR 0016](0016-module-database-access.md),
[ADR 0021](0021-modules-as-hostable-products.md),
[ADR 0027](0027-dashboard-navigation-and-flat-modules.md),
[ADR 0029](0029-module-configuration-and-secrets.md),
[ADR 0047](0047-module-ci-harness.md)
## Context
ADR 0044 builds an installation's image from an **instance composition**: a separate repository
per installation pins the platform and each module, commits its own lockfile, assembles them into
one workspace, and builds the platform's Dockerfile over it. River of Glory Church runs exactly
that. In practice:
- Every module change, however small, is a new image build and a new image. The image an
installation runs is never the image the platform's CI built and tested.
- The Dockerfile copies whatever `modules/` contains into the image. What a platform build
produces therefore depends on what happens to be in the source checkout.
- A module resolves its runtime dependencies only through the platform's own workspace install. No
module can run anywhere except inside an Ekklesia workspace built together with it.
- Each installation needs its own assembly repository, lockfile, and build pipeline.
Removing a module has a further problem. Its stored platform state (enablement, configuration,
Dashboard placement, grants) and its data (tables, buckets) are already retained by design
(ADR 0027, ADR 0028, ADR 0029, ADR 0030). Its migration history is not: ADR 0009 rejects any
applied migration that is missing from the current inventory. Once a module with migrations is
removed, no migration can be planned or applied again, including Core's own on a platform upgrade,
until the module's code is put back.
Ekklesia will eventually install modules from a marketplace. That needs a platform whose image does
not change when its modules do. Designing the marketplace is not part of this decision.
## Decision
### One generic platform image, with no modules in it
Ekklesia produces **one** platform image for every installation. It contains Core, `apps/api`, the
Dashboard and display host, the operator commands, and the platform packages they need. It contains
**zero feature modules**: not the module template, and not a test module.
Building the image does not depend on which modules exist in the source checkout. A `modules/`
directory in the build context is never a build input: a platform build produces the same platform
whether or not one is present.
The build takes no installation-specific input: no module list, no per-installation lockfile, no
branding, no configuration. Per-installation application images and per-installation assembly
repositories are not part of the deployment model. An installation that already runs one may keep
it as a rollback path until it has moved; the platform does not support building new ones.
### Installed modules live in installation-owned storage
Installed modules live **outside the image**, in persistent storage the installation owns.
- **`EKKLESIA_MODULES_DIR`** selects the modules directory, as an absolute path. It is read once,
through the host configuration the API and Doctor already share. The API and every operator
command (migration plan and apply, module and migration validation, module enablement, Doctor)
honour the same value. There is one modules directory and one discovery path; discovery itself is
unchanged.
- **The shipping image sets it to `/var/lib/ekklesia/modules`.**
- **Unset**, as in a development checkout, it defaults to `<repository>/modules`. Workspace
development, module CI, and the platform's tests keep working as they do now.
- **A configured directory that is missing or unreadable fails closed** with a sanitized error. A
mistyped mount must never quietly start the platform with no modules.
- **Nothing in the platform locates its own files relative to the modules directory.** Core's
migrations and the platform's package metadata are found where the platform is installed, never
beside the modules.
In the reference deployment, the modules directory is persistent host storage mounted into the
`api` and `migrate` containers, and the mount is **read-only to Ekklesia**. The Ekklesia process
never writes to module storage. Whatever installs a module writes to that storage from outside the
process: today an operator, later perhaps an installer. Giving Ekklesia itself write access is a
separate, future decision. Module storage is installation state, so it belongs in the
installation's backups beside the database and the buckets.
Changes to module storage are discovered **only when a process starts**. The API sees them at a
controlled restart, and an operator command sees them when it runs. There is no file watching, hot
loading, or hot unloading. Changing modules is therefore a sequence, never an edit to a running
API: stop the API, change module storage, plan and explicitly apply migrations, start the API. A
running API may still read browser artifacts from disk, so changing files under it is unsupported.
Discovery's existing rules carry over unchanged. Core scans the immediate, non-hidden directories,
rejects a symbolic-link module directory, and rejects the whole inventory if any entry is invalid.
Two consequences matter for anything that writes module storage:
- the directory holds nothing but module directories;
- hidden entries are ignored, so a module can be staged under a hidden name and then renamed into
place in one step.
### An installed module directory is self-contained
An installed module directory is **self-contained for runtime use**: every file the platform reads
or loads from it, and everything those files import at run time, resolves inside that directory.
It contains:
- `package.json`, whose version matches the manifest's;
- `ekklesia.module.json`;
- every built artifact its manifest declares: the server entrypoint, the schema entrypoint when it
owns tables, the jobs entrypoint, and the primary, display, and contribution browser artifacts;
- its migrations directory, with the journal and Ekklesia metadata, when it has migration history;
- the **production runtime dependencies** of its server, schema, and jobs code, inside the module
directory. That includes `@ekklesia/module-sdk`, `@ekklesia/contracts`, Drizzle, and every
third-party package the module imports.
Copying `dist/` alone does not produce an installed module. Source, tests, and development
dependencies are not required.
**The platform lends a module nothing.** Node resolves a module's imports from the module's own
directory upwards, and the platform's own `node_modules` is never on that path. Ekklesia does not
add it there through links, `NODE_PATH`, loader hooks, aliases, or import maps. A module whose
imports do not resolve fails to load, and because startup is atomic, the API does not start. That
failure is sanitized like any other.
**Native dependencies** must be built for the platform image's runtime: its Node.js version and ABI,
its C library, and its CPU architecture. The platform documents that runtime for each image.
**Browser artifacts** are already self-contained bundles (ADR 0023, ADR 0040). Nothing changes for
them.
**Drizzle, for now.** A module defines its tables with its own copy of Drizzle, and Core runs that
module's queries through the platform's Drizzle instance (ADR 0016). Objects from two copies of the
library only interoperate when the copies are interchangeable. **The current implementation may
therefore require a module to carry exactly the Drizzle version the platform uses.** This is a
constraint of the current implementation, not a permanent part of the module contract. A stable
module database ABI, or a compatibility rule that permits a range, replaces it when one exists.
Core already validates a module's runtime and schema definitions structurally, against its own
contracts. It never relies on object identity with the module's copy of the SDK. The module's
`compatibility.moduleSdk` range continues to be checked against the platform's SDK version.
The archive or package format a module is delivered in, how a package is signed, and how it is
transported to an installation are deferred.
### Disable, uninstall, and purge are different operations
| Operation | Module code | Stored platform state | Module data |
| --- | --- | --- | --- |
| **Disable** (ADR 0028) | Stays installed; not loaded | Enablement record set to disabled | Kept |
| **Uninstall** | Removed from module storage | Kept and ignored: enablement, configuration, Dashboard placement, grants | Kept: tables and rows, buckets and objects, migration history |
| **Purge** | Not installed | Deleted | Deleted |
**Purge is deferred.** Until it exists, nothing in the platform deletes module data, and removing
module code never does. No table is dropped, no bucket or object is deleted (ADR 0030), and no
migration history or stored platform record is removed just because a module is absent. There are
no down migrations (ADR 0008, ADR 0009).
**A removed module may be reinstalled** with the same module id, and it recovers its retained state:
- its enablement decision, configuration, Dashboard placement, and grants apply again;
- its tables and objects are where it left them;
- its migration history is validated strictly again (next section).
Reinstalling a newer version plans that version's new migrations on top of the retained history.
**Dependencies are unchanged.** A present module whose *required* dependency is absent still fails
discovery, and the API does not start. Dependents are removed first. An optional dependency may be
absent, exactly as before.
### Migration history for an absent module is retained and reported
A module is **absent** when no module in the current discovered inventory has its module id.
**Applied history belonging to an absent module is retained and reported, not treated as fatal.**
- Its rows are still checked for shape and for global consistency: unique identities, and unique,
increasing applied order. A corrupt row still fails.
- They are not matched against any inventory. Migration plan, apply, and Doctor report them as
retained history for an absent module: the module id and how many migrations it has.
- Their presence never blocks planning or applying Core's migrations, or those of any present
module.
**History belonging to a present module stays strict, including a module that has just returned.**
Its applied history must be an exact prefix of its current journal, and every row must match its
migration's module, tag, index, and checksum. Therefore:
- a previously applied migration missing from the package fails;
- a checksum mismatch fails;
- a downgrade to a version with fewer migrations, or any incomplete or gapped history, fails.
A different package that claims a retained module id is held to that history in exactly the same
way.
**Core's history is never treated as absent.** Rows belonging to Core's migration source are always
validated strictly; an applied Core migration missing from Core's inventory is still fatal.
Applying migrations never touches an absent module's database objects, and nothing runs a down
migration or deletes data because a module is absent. Deleting retained history belongs to purge.
This is safe because nothing applied in the platform can depend on an absent module's objects:
- a module may not hold a foreign key into another module's tables (ADR 0007);
- a module's migrations may change only its own objects (ADR 0008);
- Core never depends on a module's tables.
### The trust boundary does not move
Installed modules remain **trusted backend code** (ADR 0006, ADR 0015, ADR 0021). They run inside
the API process with its database role. Module storage, the read-only mount, and self-contained
packages are integrity and operational properties. **They are not a security sandbox.** The
read-only mount stops the Ekklesia process from modifying installed code. It does not limit what
installed code can do.
What the platform can still check against an installed directory is unchanged:
- discovery: the manifest, the package, entrypoint containment, compatibility ranges, and the
dependency graph;
- module, schema, and migration validation, run against module storage.
The repository's boundary checks (`pnpm boundaries`) can only prove **source** modules present in a
workspace. That includes the rule that every runtime import is a declared production dependency. An
installed package normally ships no source, so the platform cannot run those checks on it.
Conformance of a published or installed module must therefore be enforced:
- by the module's own CI;
- by packaging validation;
- by the module harness (ADR 0047).
Until packaging validation exists, installing a module means trusting that module's CI and the
operator who installed it, exactly as installing trusted code always has.
### Development keeps the workspace
In a development checkout, `<repository>/modules` remains the default, and a module is still
developed as a workspace package there. Module CI and the module harness may still assemble a module
into a platform workspace to build and test it. A deployment image is never built from such a
workspace.
## Consequences
- Every installation runs the same platform image, and that image is the one the platform's CI
tested. Installing, updating, or removing a module needs no image build. It needs only a
controlled restart and, when the module has migrations, an explicit migration plan and apply.
- Per-installation assembly repositories become unnecessary. River of Glory Church's instance
repository keeps running its pinned image as a rollback path until the new model is proven there.
Once the platform image stops accepting modules from its build context, that repository cannot
move to a newer platform release, which is intended.
- Module authors gain a packaging step: a self-contained directory with production dependencies and,
where needed, native binaries built for the image's runtime. Packages grow, and modules that share
a library each carry their own copy. That cost is accepted.
- Removing a module no longer blocks migrations. Its data stays, and reinstalling restores it.
- Operators own module storage: they create it, keep it in backups, and keep anything but module
directories out of it.
- ADR 0047, still proposed, assembles a module into the platform and builds a deployment image from
it. Before it is accepted, it must instead run the module from module storage in the generic
image.
- This ADR records the decision; it changes no code. Selecting `EKKLESIA_MODULES_DIR`, the image
build, the retained-history policy, the read-only mount, and the CI proof that the image is
independent of installed modules follow in separate changes. Until they land, the repository
behaves as `deploy/README.md` and the development guides describe.
## Alternatives
- **Keep per-installation images (ADR 0044).** Rejected: every module change is an image build, no
two installations run the same image, and a platform build depends on its checkout.
- **Lend modules the platform's dependencies** through links, `NODE_PATH`, loader hooks, or import
maps. Rejected:
- it couples every installed module to the platform's private dependency graph, so upgrading a
platform dependency could silently break an installed module;
- ESM ignores `NODE_PATH`;
- a resolution hook would be a second module-loading mechanism beside Node's own.
- **Require one bundled file per entrypoint as the package format.** Not adopted as a requirement:
native dependencies cannot be bundled, and the package format is deferred. Bundling is one
permitted way to produce a self-contained directory, not the definition of one.
- **Let the API write module storage, so it can install modules itself.** Deferred, not adopted:
code that can rewrite the code it executes is a larger trust decision, and it belongs with the
marketplace.
- **Hot loading and unloading.** Rejected: ES modules cannot be unloaded, and atomic discovery,
dependency-ordered startup, and immutable permission composition all assume a fresh process.
- **Delete data, or run down migrations, on uninstall.** Rejected: migrations are forward-only, and
a mistaken removal must never destroy data. Deleting data is purge, and purge is explicit.
- **Keep rejecting history for absent modules (ADR 0009 as written).** Rejected: removing any module
with migrations would block every later migration, including Core's.
- **Accept history for absent modules only behind an operator flag.** Rejected: applying migrations
with a module absent cannot harm that module's data, and the plan already reports what is
retained. A flag would turn every routine uninstall into a special case.
## Deferred
- The module archive or package transport format.
- Package signatures and trust roots.
- A marketplace or catalog.
- An installer UI.
- Installation by the running platform, and any write access for Ekklesia to module storage.
- Hot loading, hot unloading, and file watching.
- The purge workflow: deleting a module's tables, buckets and objects, migration history, and stored
platform records.
- A stable module database ABI, or a compatibility rule wider than the current exact Drizzle
requirement.
- A platform-provided packaging and package-validation command.
- Publishing the platform image and automated deployment from CI, still deferred from ADR 0044.
@@ -0,0 +1,426 @@
# 0049: A module release is one immutable, self-contained package: Ekklesia Module Package v1
- Status: Accepted
- Date: 2026-10-07
- Supersedes: none
- Extends: [0048](0048-generic-image-and-installed-module-storage.md)
- Related: [ADR 0006](0006-versioned-capabilities-and-compiled-module-runtime.md),
[ADR 0007](0007-module-owned-drizzle-schemas.md),
[ADR 0008](0008-module-owned-migration-contracts.md),
[ADR 0009](0009-transactional-module-migrations.md),
[ADR 0016](0016-module-database-access.md),
[ADR 0021](0021-modules-as-hostable-products.md),
[ADR 0023](0023-module-ui-build-pipeline.md),
[ADR 0028](0028-module-operational-lifecycle.md),
[ADR 0040](0040-module-stylesheets-and-artifact-isolation.md),
[ADR 0041](0041-module-browser-contributions.md),
[ADR 0044](0044-self-hosted-reference-deployment.md),
[ADR 0047](0047-module-ci-harness.md)
## Context
ADR 0048 took modules out of the platform image. An installation keeps them in its own module
storage, mounted read-only, and discovers them at a controlled restart. Each installed module must
be a self-contained directory: the platform lends it no dependencies. ADR 0048 deferred the format
a module is delivered in.
That gap is now the obstacle. Nothing yet produces an installed module directory except a test-only
Dockerfile, so a module release cannot be built, distributed, and installed on its own. An
investigation against the module template and Signage 0.1.2 established:
- **`dist/` alone does not run.** Compiled server and schema code imports `@ekklesia/module-sdk`
(and through it `@ekklesia/contracts`, zod, and semver) and `drizzle-orm` by name, and Node never
resolves those from the platform's `node_modules`.
- **Bundling works.** Bundling the template's server and schema with esbuild gave about 720 KB of
backend code and no `node_modules` at all. Inside the generic image, run as its unprivileged
user from a read-only mount at `/var/lib/ekklesia/modules`, it passed module, schema, and
migration validation, and its server entrypoint imported. Core's own Drizzle instance built a
query over the bundled module's table.
- **Native dependencies cannot be bundled.** Signage needs `sharp`, a Node-API addon whose
platform packages carry an 18.6 MB `libvips` shared library for one operating system, CPU, and
C library (Linux, x64, glibc ≥ 2.28). With its backend bundled and only `sharp` and its own
dependencies kept beside it, Signage was 22 MB and contained no link. It validated and processed
an image inside the same image.
- **`pnpm deploy` is the wrong tool.** Non-legacy mode refuses unless the whole workspace switches
to injected workspace packages. Legacy mode leaves a link escaping the package. Every mode
ships packages nothing loads at run time (React and its DOM, type packages, database drivers
pulled in as peers) and leaves `workspace:` ranges in copied manifests. Running it inside a
workspace rewrites that workspace's install state to production-only, which then breaks the
workspace's own builds. It did so in the investigation's scratch workspaces, and earlier in a
developer's own checkout.
- **A deterministic archive is cheap.** A tar archive with sorted paths, fixed owner and
timestamps, and a gzip header without a name or time produced the same SHA-256 twice. Gzip packed
Signage to 8.5 MB.
Module releases also need a contract that does not depend on where they are hosted. Ekklesia's
source and CI live on a Gitea server today; a marketplace, another host, object storage, or offline
media may carry the same release tomorrow.
## Decision
### Purpose
A separately versioned Ekklesia module is distributed as an **Ekklesia Module Package**: one
immutable, self-contained archive. Installing, updating, or removing a module never rebuilds the
platform image (ADR 0048).
The package contract is independent of marketplace discovery, of the source-code host, of any
artifact registry, of the church installing it, and of the URL it was fetched from. Catalogues,
marketplaces, and signing are separate concerns, and none of them is decided here.
### The artifact is a deterministic `.tar.gz`
Module Package v1 is a gzip-compressed tar archive. Its identity is its contents, never its
transport: the same archive may travel through Gitea, GitHub, an OCI registry, object storage,
offline media, or a future Ekklesia marketplace without any change to what it installs. No registry,
host, or OCI concept is part of the package format.
The canonical packager produces the archive **deterministically**: the same packager version and
the same inputs produce byte-identical output. At minimum:
- **Entries.** Every directory and regular file appears in byte-wise sorted path order, directories
before their contents.
- **Ownership.** uid and gid are 0 and the user and group names are empty.
- **Timestamps.** Every entry's modification time is 0, and the archive holds no access, change, or
creation time.
- **Modes.** Directories are `0755` and files `0644` (see [Archive safety](#archive-safety)).
- **No host metadata.** No extended attributes, ACLs, or archive-level headers that carry host or
time information.
- **gzip.** The header has no file name and a modification time of 0, at a fixed compression level.
The SHA-256 of the complete archive is distributed beside it, as a separate `.sha256` file in the
`sha256sum` format. The **recommended** file name is `<module id>-<module version>-<target>.tar.gz`,
for example `signage-0.1.2-linux-x64-glibc.tar.gz`. A file name is a convenience, never trusted:
what the archive is comes from its contents and its digest.
### What a package contains
A v1 archive contains exactly one top-level directory, named with the module id. Its contents are
the installed module directory ADR 0048 requires:
```text
<module id>/
package.json generated runtime metadata
ekklesia.module.json the module's manifest, unchanged
ekklesia.package.json package metadata (below)
THIRD_PARTY_NOTICES licence and notice material for everything bundled or included
dist/ bundled backend entrypoints, their shared chunks, and the declared
UI, display, and contribution artifacts
drizzle/ migration SQL, journal, snapshots, and _ekklesia.json, when declared
node_modules/ only when the package needs a native runtime dependency (below)
```
Every entrypoint the manifest declares exists at its declared path.
It contains nothing else, and in particular:
- source files, unless something at run time reads them;
- tests;
- TypeScript declarations;
- source maps, until a later decision permits them;
- development dependencies;
- any `workspace:` reference;
- any link (see [Archive safety](#archive-safety)).
**The runtime `package.json` is generated by the packager, never copied** from the module's
development workspace. It holds only what module discovery reads:
- `name` and `version` (equal to the manifest's version);
- `"private": true`;
- `"type": "module"`;
- `"ekklesia": { "layer": "module" }`.
It declares no dependencies, scripts, exports, or imports, because nothing at run time resolves
through them.
The manifest is copied unchanged. It stays the module's authoritative runtime contract, and its
`compatibility.platform` and `compatibility.moduleSdk` ranges stay the only place those are stated.
### Backend code is bundled
The canonical packager bundles the module's backend entrypoints (server, schema, and jobs) with
every pure-JavaScript dependency they use. That includes `@ekklesia/module-sdk`,
`@ekklesia/contracts`, and ordinary libraries. The bundles are ES modules at the manifest's declared
paths, and they may share chunks under `dist/`. Browser artifacts are already self-contained bundles
(ADR 0023, ADR 0040, ADR 0041) and are packaged as built.
The platform lends an installed module nothing (ADR 0048). **`node_modules` in a package is the
exception, not the default.** A dependency stays outside the bundle only when it must exist as
separate files at run time: a native Node addon, or a package that is equally platform-specific.
Then the package carries it, and exactly its own runtime dependencies, flat under
`node_modules/<name>`.
**The canonical packager decides what stays external, and validates it.** It refuses to package a
module whose bundled code still needs a file it did not include, or whose externals are not native
or platform-specific. Choosing which dependencies to leave unbundled is not part of the module
author's interface.
### Targets
Every package has one **target**:
- **`any`**: the package contains no native or platform-specific file. It runs wherever its declared
Node.js range is met.
- **`<platform>-<arch>[-<libc>]`**: the package contains native code for exactly one environment.
- `<platform>` and `<arch>` use Node.js's own `process.platform` and `process.arch` names.
- On Linux, `<libc>` is required and is `glibc` or `musl`. On other platforms it is omitted.
Examples: `linux-x64-glibc`, `linux-arm64-glibc`, `linux-x64-musl`. Signage, because of `sharp`, is
`linux-x64-glibc` for the current production image.
A package has exactly one target. A module that ships for several environments publishes one
package per target. Packages carrying several architectures at once are not part of v1.
### `ekklesia.package.json`
For example (the values are illustrative):
```json
{
"packageFormat": 1,
"module": { "id": "signage", "version": "0.1.2" },
"target": "linux-x64-glibc",
"runtime": {
"node": ">=24.11.0 <25",
"nodeApi": 9,
"libcVersion": ">=2.28"
},
"provenance": {
"revision": "86ae907f59458508e3277666cdd214f5bd7c1078",
"repository": "https://example.org/your-org/signage.git",
"packager": { "name": "@ekklesia/module-pack", "version": "0.2.0" }
},
"incorporates": {
"@ekklesia/module-sdk": "0.1.2",
"@ekklesia/contracts": "0.1.2"
},
"transitional": {
"databaseRuntime": { "drizzle-orm": "0.45.2" }
},
"files": [
{ "path": "THIRD_PARTY_NOTICES", "size": 48211, "sha256": "…" },
{ "path": "dist/server/index.js", "size": 1346, "sha256": "…" }
]
}
```
The fields are:
- **`packageFormat`**: the integer `1`. An installer refuses a format it does not implement. Format
versions are strict cutovers, like manifest versions.
- **`module.id`, `module.version`**: must equal the manifest's and the runtime `package.json`'s.
The top-level directory must be named with the id.
- **`target`**: as defined above.
- **`runtime`**: what the package needs from the Node.js runtime that will load it.
- `node`, a semantic-version range, is always present.
- A native package also states:
- `nodeApi`: the minimum Node-API version, for Node-API addons;
- or `nodeAbi`: the exact `process.versions.modules`, for an addon built against one Node
ABI;
- and on Linux, `libcVersion`: the minimum C library version.
- **`provenance`**: how the package was built.
- `revision`: the exact source revision, always present.
- `repository`: informational only. Nothing trusts it, and it ties the package to no host.
- `packager`: the packager's name and version.
- **`incorporates`**: the exact versions of the Ekklesia SDK and runtime libraries bundled into the
package. The packager refuses a package whose incorporated `@ekklesia/module-sdk` does not satisfy
the manifest's own `compatibility.moduleSdk` range, so the two can never disagree.
- **`transitional`**: compatibility facts that today's implementation needs and that are not part
of the long-term contract. See [Database runtime compatibility](#database-runtime-compatibility).
An installer must understand every key it finds there or refuse the package.
- **`files`**: one entry for every regular file in the module directory **except
`ekklesia.package.json` itself**, sorted by path. Each entry has its path relative to the module
directory, its size in bytes, and its SHA-256. The set is exact: a payload file missing from the
list, or a listed file missing from the payload, invalidates the package.
The archive's `.sha256` covers the whole archive, `ekklesia.package.json` included. The per-file
digests let an installer, or an operator later, check an extracted directory without the archive.
Platform and Module SDK compatibility stay in `ekklesia.module.json` and are not repeated here.
### Database runtime compatibility
Core runs a module's queries through the platform's Drizzle instance over tables the module defined
with its own copy of Drizzle (ADR 0016, ADR 0048). Those objects only interoperate when the two
copies are interchangeable. So while it is needed, a package bundles Drizzle with its backend code,
records the version in `transitional.databaseRuntime`, and an installer refuses a package whose
Drizzle version differs from the platform's.
**This is an implementation constraint of today's platform, not the Ekklesia Module Database ABI.**
Object identity and version agreement between two Drizzle copies are not a contract this ADR
establishes or that module authors may rely on. A separate Module Database ABI decision will replace
it, and that ABI will make the platform's ORM an implementation detail. When it lands, the
`transitional.databaseRuntime` requirement goes away under the format-versioning rules here.
### Licence obligations
Bundling copies third-party code into the module's own files, where the licence headers that came
with it may not survive. **Every package therefore carries `THIRD_PARTY_NOTICES`**: a generated
plain-text file at the module directory's root. It names each third-party package bundled into
or included in the package, with its version and licence, and holds the licence and notice text
that redistribution requires.
The packager's implementation must establish exactly how licence and notice material is collected
and preserved, including licence comments that bundling would otherwise strip. Until it does, no
package is treated as a distributable release.
### Archive safety
**v1 packages contain no links of any kind.** Neither symbolic nor hard links are permitted, so no
link can escape the package, and there is no case where one is allowed. Native dependencies were
packaged without any.
A conforming archive also contains no:
- absolute path;
- `..` path segment;
- path outside the single top-level module directory;
- device node, FIFO, or socket;
- setuid, setgid, or sticky bit.
It holds only directories and regular files.
**Extraction normalizes ownership and modes.** Everything is owned by the installation, not by the
uid or gid in the archive. Directories are `0755` and files `0644`, readable by the API's
unprivileged user and writable by nobody but the installer. No file in a v1 package is executable.
Loading JavaScript and native addons needs no execute bit.
An installer refuses an archive that breaks any of these rules, and never extracts it partially into
module storage.
### Installation model
This ADR records the intended installation lifecycle; it does not implement it.
```text
acquire the archive
→ verify the archive's SHA-256
→ extract into a hidden staging directory inside module storage
→ validate ekklesia.package.json, then every file's size and SHA-256
→ validate the target and runtime against this platform and host
→ validate the manifest and the complete candidate inventory: the installed modules, with this
candidate in place of any version of the same module
→ load the backend entrypoints once in an isolated, short-lived validation process
→ produce the migration plan
→ stop the API
→ swap the candidate into the active module directory, keeping the previous version
→ apply migrations, only with explicit operator approval
→ start the API
→ verify readiness
```
Details:
- **Hidden directories.** Staging and previous versions live in dot-prefixed directories inside
module storage. Module discovery ignores hidden entries (ADR 0048), so neither is ever loaded.
- **The swap.** It happens only while the API is stopped. It is as close to atomic as the
filesystem allows, and an interrupted swap is recoverable from the kept previous version.
- **Who writes.** Module storage stays read-only to Ekklesia. Whatever performs the installation
writes it from outside the API process (ADR 0048).
- **No hot loading.** Discovery still happens only when a process starts.
- **Migrations stay explicit.** Nothing in this lifecycle applies a migration on its own (ADR 0009,
ADR 0044).
### Rollback
- **Before any migration from the new version is applied,** restoring the previous module directory
and restarting is a supported rollback.
- **After forward migrations are applied,** restoring older code is **not** a supported rollback.
The older package lacks the newer migrations, so migration planning refuses it as a downgrade
(ADR 0048). Even running it would mean old code against a newer schema. A true rollback then
means restoring the matching database and storage backup, unless a future, module-specific
compatibility mechanism says otherwise.
There are no down migrations (ADR 0008, ADR 0009). An installer must make the point of no return
explicit: back up before applying, and offer code-only rollback only while no new migration has been
applied.
### Trust
The SHA-256 digest proves integrity: the archive is the one whose digest you were given. It says
nothing about who published it. Package signatures, publisher trust roots, marketplace verification,
and trust levels for third-party modules are deferred.
Installed modules remain trusted backend code under ADR 0048. A conforming package is not sandboxed,
and passing validation does not make a module safe; it makes it well-formed.
### Canonical tooling
Packages are produced by one Ekklesia-owned packager, proposed as the workspace package
`packages/module-pack` with the command `ekklesia-pack-module`, run in a module's CI. Its command
line is an implementation detail; this ADR defines what it must produce.
**`pnpm deploy` is not the packaging mechanism**, for the reasons in Context:
- it includes runtime dependencies nothing loads;
- it leaves `workspace:` metadata;
- it leaves links that escape the package, or needs a workspace-wide linking change to avoid them;
- it rewrites the install state of the workspace it runs in.
The repository's test-only packaging of the module template
(`scripts/lib/test-module-package.Dockerfile`) is replaced by the canonical packager when one
exists.
### Relationship to the module CI harness
ADR 0047, still proposed, is already marked for revision against ADR 0048. Its harness should
consume canonical module packages: a module's CI builds its package, and the harness installs that
exact archive into module storage of the generic image. The harness then tests the artifact a church
would install, not a workspace build of it. Until the packager exists, the harness and smoke tests
may keep their current test-only packaging.
## Consequences
- A module release becomes one file plus its digest, built once by the module's CI and installable
on any compatible Ekklesia installation without touching the platform image.
- Module authors gain a packaging step and a target per native environment. A pure-JavaScript
module ships one `any` package; Signage ships `linux-x64-glibc` packages for the current fleet.
- Packages are small and auditable: bundled code plus, only where needed, a native dependency
tree. There are no links and no development dependencies, and every file is listed with its
digest.
- Each package carries its own copy of the SDK and contracts. Version agreement with the platform is
checked when the package is built, against the manifest's `moduleSdk` range, and Core keeps
validating module definitions structurally (ADR 0048).
- Rolling back after a schema-changing update is a backup restore. Operators must take a backup
before approving migrations.
- Licence handling becomes the packager's responsibility, and until it is proven no package
counts as a release.
- The Drizzle requirement is visible in package metadata as `transitional`, so its eventual removal
is a recorded format change and not a silent behaviour change.
## Alternatives
- **`pnpm deploy` as the packager.** Rejected for the reasons in Context.
- **Copying the workspace's dependency tree without bundling.** Rejected as the default. It ships
far more than runs (49 MB against 22 MB for Signage), keeps `workspace:` ranges, and makes every
pure-JavaScript dependency a file-layout concern. Bundling is already how browser artifacts are
built.
- **Platform-lent runtime dependencies.** Rejected by ADR 0048.
- **Installing the SDK from a registry when the module is installed.** Rejected: installation would
need network access and a resolver on every installation, and an installation's code would depend
on what a registry served that day. Publishing the SDK packages for module *builds* is a separate,
compatible future step.
- **Module-author-chosen externals.** Rejected: an arbitrary external is an undeclared runtime
dependency the platform cannot verify, and it reintroduces the `node_modules` layout as a
contract.
- **An OCI artifact as the package format.** Not adopted for v1: it needs registry tooling and login
to install, and adds nothing to integrity that the digest does not already give. Nothing here
prevents carrying the same `.tar.gz` inside an OCI artifact.
- **`.tar.zst`.** Not adopted for v1: about 17 % smaller for Signage at a high compression level,
but about three times slower to produce there, and less universally available than gzip. A later
format version may add it.
- **Packages carrying several architectures at once.** Not adopted for v1: each extra architecture
adds about 18.5 MB for Signage, for a fleet that runs one architecture. A later format version may
allow it.
- **Permitting internal symbolic links.** Rejected: links are where escape checks are subtle, and
the native dependencies that motivated the question were packaged without any.
## Deferred
- Package signatures and publisher trust roots.
- A marketplace or catalogue, and registry or hosting-provider integration.
- An installer, and any installer UI; an automatic update service.
- The purge workflow (ADR 0048).
- Packages carrying several architectures at once.
- The stable Module Database ABI that retires `transitional.databaseRuntime`.
- Source maps and other debugging material in or beside packages.
- Publishing the Ekklesia SDK packages for module builds outside a platform workspace.
- Moving Signage to the new package format.
+2
View File
@@ -56,3 +56,5 @@ ADRs capture durable choices that affect more than one package or phase.
- [0045: The Dashboard and the API may live on separate origins](0045-split-dashboard-and-api-origins.md) — Accepted - [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 - [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
+14
View File
@@ -472,6 +472,20 @@ CI builds and smoke-tests end to end on every change against disposable services
publishes, or runs it against a real installation. No `.env` has ever been committed; only 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
+60
View File
@@ -171,6 +171,16 @@ whole-batch DDL rollback, Drizzle breakpoint execution, real advisory-lock concu
timeouts, lock release after failure, exact destructive approvals, and public-error sanitization. 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
@@ -554,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:
+110 -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,110 @@ 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)).
### 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 +1554,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.
+2
View File
@@ -34,7 +34,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",
+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,
);
});
});
+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.
+46
View File
@@ -0,0 +1,46 @@
{
"name": "@ekklesia/module-pack",
"version": "0.1.2",
"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"]
}
+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: [],
});
});
});
+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."
@@ -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/ /
+62 -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
@@ -443,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) {
+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"
}
+165 -38
View File
@@ -1,19 +1,32 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# End-to-end smoke test of the reference deployment (ADR 0044): deploy/compose.yaml with # End-to-end smoke test of the reference deployment (ADR 0044, ADR 0048): deploy/compose.yaml with
# deploy/compose.proxy.yaml, a platform-only image, and a disposable PostgreSQL and SeaweedFS that # deploy/compose.proxy.yaml, the one generic platform image, installation-owned module storage, and
# exist only for this run. CI runs it; it is safe to run on a workstation. # a disposable PostgreSQL and SeaweedFS that exist only for this run. CI runs it; it is safe to run
# on a workstation.
# #
# bash scripts/smoke-deploy.sh builds the image first # bash scripts/smoke-deploy.sh builds the image first
# EKKLESIA_SMOKE_IMAGE=<image> bash scripts/smoke-deploy.sh # EKKLESIA_SMOKE_IMAGE=<image> bash scripts/smoke-deploy.sh
# EKKLESIA_SMOKE_TOPOLOGY=split bash scripts/smoke-deploy.sh # EKKLESIA_SMOKE_TOPOLOGY=split bash scripts/smoke-deploy.sh
# #
# Either topology (ADR 0045). `same-origin` (the default) serves the Dashboard, the API, and displays # Either topology (ADR 0045), and in both the same generic image, built once from deploy/Dockerfile.
# from one hostname with Caddyfile.example and a platform-only image. `split` serves the Dashboard on # It contains no module; module storage starts empty, as on a fresh installation. `same-origin` (the
# its own hostname with Caddyfile.split.example, builds an image with the repository's module # default) serves the Dashboard, the API, and displays from one hostname with Caddyfile.example.
# template installed as a test-only module (so a module UI, its API routes, and a display exist), and # `split` serves the Dashboard on its own hostname with Caddyfile.split.example and then exercises
# then drives a real headless Chromium through the Dashboard, the module, the display, and sign-out. # ADR 0048 end to end with that same image:
# With EKKLESIA_SMOKE_IMAGE, the given image is used as is in either topology; a split run then #
# exercises whichever of its modules publishes UI and a display. # - it boots with no modules;
# - the repository's module template is packaged by the canonical packager as an Ekklesia Module
# Package v1 (ADR 0049; scripts/lib/build-module-package.Dockerfile only provides the build
# environment), verified by the same image's own verifier, extracted to staging, installed into
# module storage, its migrations planned and explicitly applied, and the API restarted;
# - a real headless Chromium drives the Dashboard, the module, its display, and sign-out;
# - the module is removed and the API restarted: Core still runs, migration planning reports the
# module's retained history, and its data survives;
# - the exact same package is verified and installed again, and the API restarted: the module
# returns with its previous data.
#
# EKKLESIA_SMOKE_IMAGE uses the given image instead of building one. It must be a generic image
# (ADR 0048): modules come from module storage, never from the image.
# #
# Opt-in, split topology only: EKKLESIA_SMOKE_DISPLAY_DEVICE=1 adds the display-device scenarios in # Opt-in, split topology only: EKKLESIA_SMOKE_DISPLAY_DEVICE=1 adds the display-device scenarios in
# scripts/lib/deploy-smoke-display.mjs (pairing a kiosk browser with a persistent profile, a clean # scripts/lib/deploy-smoke-display.mjs (pairing a kiosk browser with a persistent profile, a clean
@@ -41,6 +54,8 @@ cd "$repository_root"
source "$repository_root/scripts/lib/compose-diagnostics.sh" source "$repository_root/scripts/lib/compose-diagnostics.sh"
# shellcheck source=scripts/lib/ci-project.sh # shellcheck source=scripts/lib/ci-project.sh
source "$repository_root/scripts/lib/ci-project.sh" source "$repository_root/scripts/lib/ci-project.sh"
# shellcheck source=scripts/lib/module-package-release.sh
source "$repository_root/scripts/lib/module-package-release.sh"
for command in docker openssl awk; do for command in docker openssl awk; do
command -v "$command" >/dev/null || { command -v "$command" >/dev/null || {
@@ -113,6 +128,8 @@ choose_edge_network() {
session_client_address="10.249.${octet}.252" session_client_address="10.249.${octet}.252"
# The headless browser's fixed address, likewise (split topology only). # The headless browser's fixed address, likewise (split topology only).
browser_address="10.249.${octet}.251" browser_address="10.249.${octet}.251"
# The installed-module checks' own client address, so their sign-ins have their own bucket.
module_client_address="10.249.${octet}.249"
return 0 return 0
fi fi
done done
@@ -166,7 +183,7 @@ cleanup() {
# job itself runs as root), so only the staged copy on the Docker host needs this. # job itself runs as root), so only the staged copy on the Docker host needs this.
if [[ "$daemon_work" == "$work" ]]; then if [[ "$daemon_work" == "$work" ]]; then
docker run --rm --entrypoint rm -v "$work:/work" "$postgres_image" -rf /work/tls /work/env \ docker run --rm --entrypoint rm -v "$work:/work" "$postgres_image" -rf /work/tls /work/env \
>/dev/null 2>&1 || true /work/modules /work/staging >/dev/null 2>&1 || true
else else
docker run --rm --entrypoint rm -v /tmp:/host-tmp "$postgres_image" -rf "/host-tmp/${project}" \ docker run --rm --entrypoint rm -v /tmp:/host-tmp "$postgres_image" -rf "/host-tmp/${project}" \
>/dev/null 2>&1 || true >/dev/null 2>&1 || true
@@ -188,41 +205,53 @@ fail() {
exit 1 exit 1
} }
if [[ -z "$image" && "$topology" == same-origin ]]; then if [[ -z "$image" ]]; then
step "Building the platform-only image" # The one generic platform image, for both topologies. Nothing about either topology, and no
# module, is built into it.
step "Building the generic platform image"
built_image="ekklesia-deploy-smoke:${run_id}" built_image="ekklesia-deploy-smoke:${run_id}"
DOCKER_BUILDKIT=1 docker build --quiet --file deploy/Dockerfile --tag "$built_image" \ DOCKER_BUILDKIT=1 docker build --quiet --file deploy/Dockerfile --tag "$built_image" \
--label "${EKKLESIA_CI_LABEL}=${project}" --label "${EKKLESIA_CI_CREATED_LABEL}=$(date +%s)" \ --label "${EKKLESIA_CI_LABEL}=${project}" --label "${EKKLESIA_CI_CREATED_LABEL}=$(date +%s)" \
. >/dev/null . >/dev/null
image="$built_image" image="$built_image"
elif [[ -z "$image" ]]; then fi
# TEST-ONLY: the repository's module template, installed as the module `example`, in a copy of the
# working tree. Its lockfile importer moves with it; the depth is the same, so every relative link if [[ "$topology" == split ]]; then
# in that importer stays correct and the install stays frozen. # The module template as a Module Package v1, from the canonical packager. Never a second platform
step "Building an image with the module template installed as a test-only module" # image: the package is installed into module storage below, after the image verifies it.
command -v git >/dev/null || fail "git is required to build the split topology's test image." step "Packaging the module template with the canonical Ekklesia packager"
context="$work/build-context" source_revision="${GITHUB_SHA:-$(git rev-parse HEAD 2>/dev/null || true)}"
mkdir -p "$context" [[ "$source_revision" =~ ^[0-9a-f]{40}$ ]] || fail "no source revision to record in the package."
git ls-files -z --cached --others --exclude-standard | DOCKER_BUILDKIT=1 docker build --quiet --file scripts/lib/build-module-package.Dockerfile \
tar --null --files-from=- --create --file=- | --label "${EKKLESIA_CI_LABEL}=${project}" --build-arg SOURCE_REVISION="$source_revision" \
tar -C "$context" -xf - --output "type=local,dest=${work}/module-package" . >/dev/null ||
mkdir -p "$context/modules" fail "the module package did not build."
mv "$context/templates/module" "$context/modules/example" # The archive and its digest are one release: copied together, from this run's own build output,
[[ "$(grep -c '^ templates/module:$' "$context/pnpm-lock.yaml")" == 1 ]] || # into a fresh directory the verifier's unprivileged user can read. BuildKit's output directory is
fail "the lockfile has no single templates/module importer to move." # mode 0700 and owned by whoever built it, so it is never handed to the verifier itself.
sed -i 's|^ templates/module:$| modules/example:|' "$context/pnpm-lock.yaml" package_archive="$(module_package_release "$work/module-package" "$work/package")" ||
built_image="ekklesia-deploy-smoke:${run_id}" fail "the packager did not produce exactly one archive and its digest."
DOCKER_BUILDKIT=1 docker build --quiet --file "$context/deploy/Dockerfile" --tag "$built_image" \ [[ "$package_archive" =~ ^example-[0-9]+\.[0-9]+\.[0-9]+-any\.tar\.gz$ ]] ||
--label "${EKKLESIA_CI_LABEL}=${project}" --label "${EKKLESIA_CI_CREATED_LABEL}=$(date +%s)" \ fail "the packager produced an unexpected archive name."
"$context" >/dev/null || fail "the test-module image did not build." echo "ok packaged ${package_archive} ($(wc -c <"$work/package/$package_archive") bytes, sha256 $(cut -d' ' -f1 "$work/package/${package_archive}.sha256"))"
rm -rf "$context"
image="$built_image"
fi fi
# The compose project directory: its relative bind mount (postgres/pg_hba.conf) resolves here. # The compose project directory: its relative bind mount (postgres/pg_hba.conf) resolves here.
cp -R deploy "$work/deploy" cp -R deploy "$work/deploy"
stage deploy stage deploy
# Installation-owned module storage (ADR 0048), empty, as an operator creates it before the first
# `up`: Compose refuses to start `api` or `migrate` without it, and never creates it.
mkdir -m 755 "$work/modules"
stage modules
if [[ "$topology" == split ]]; then
stage package
# TEST-ONLY staging the verifier extracts into, as the image's unprivileged user. Nothing here is
# module storage: the install step below moves a verified tree into module storage as root.
mkdir -m 777 "$work/staging"
stage staging
fi
step "Creating the installation-private CA and server certificates" step "Creating the installation-private CA and server certificates"
init_status=0 init_status=0
sh deploy/scripts/init-tls.sh "$work/tls" >/dev/null 2>&1 || init_status=$? sh deploy/scripts/init-tls.sh "$work/tls" >/dev/null 2>&1 || init_status=$?
@@ -309,6 +338,7 @@ EKKLESIA_EDGE_SUBNET=${edge_subnet}
EKKLESIA_EDGE_DYNAMIC_RANGE=${edge_range} EKKLESIA_EDGE_DYNAMIC_RANGE=${edge_range}
EKKLESIA_PROXY_ADDRESS=${proxy_address} EKKLESIA_PROXY_ADDRESS=${proxy_address}
EKKLESIA_TUNNEL_ADDRESS=${tunnel_address} EKKLESIA_TUNNEL_ADDRESS=${tunnel_address}
EKKLESIA_MODULES_HOST_DIR=${daemon_work}/modules
EOF EOF
step "Starting PostgreSQL and object storage" step "Starting PostgreSQL and object storage"
@@ -398,7 +428,82 @@ client session --ip "$session_client_address" \
-e SMOKE_OWNER_EMAIL="$owner_email" -e SMOKE_OWNER_PASSWORD="$owner_password" || -e SMOKE_OWNER_EMAIL="$owner_email" -e SMOKE_OWNER_PASSWORD="$owner_password" ||
fail "session checks failed." fail "session checks failed."
psql_as_superuser() {
"${compose[@]}" exec -T postgres psql -U ekklesia -d ekklesia -Atc "$1"
}
# module_storage <script>: runs a root shell over this run's module storage, as an operator changing
# installed modules on the host would. The API never writes it; it is mounted read-only.
module_storage() {
docker run --rm -i --network none --entrypoint sh -v "$daemon_work/modules:/modules" \
"$postgres_image" -ec "$1"
}
# install_test_module: verifies the package with the generic image's own verifier, which writes the
# verified module directory to staging, then moves it into module storage under a hidden name, which
# discovery ignores, and renames it into place in one step, owned by root and readable by the API's
# unprivileged user. Nothing unverified ever reaches module storage.
install_test_module() {
local verified
verified="$(
docker run --rm --network none --read-only --tmpfs /tmp --cap-drop ALL \
--security-opt no-new-privileges \
-v "$daemon_work/package:/package:ro" -v "$daemon_work/staging:/staging" \
--entrypoint node "$image" core/dist/cli/verify-module-package.js "/package/${package_archive}" \
--sha256-file "/package/${package_archive}.sha256" --extract-to /staging/example 2>&1
)" || fail "the module package did not verify: ${verified}"
echo "ok ${verified%%$'\n'*}"
docker run --rm --network none --entrypoint sh -v "$daemon_work/modules:/modules" \
-v "$daemon_work/staging:/staging" "$postgres_image" -ec '
rm -rf /modules/.incoming-example && cp -R /staging/example /modules/.incoming-example &&
rm -rf /staging/example && chown -R 0:0 /modules/.incoming-example &&
chmod -R a+rX,go-w /modules/.incoming-example &&
mv /modules/.incoming-example /modules/example' ||
fail "the verified module could not be installed into module storage."
}
# restart_api <command...>: stops the API, runs the command, and starts the API again. A controlled
# restart is the only way a module change is picked up; nothing is ever hot loaded.
restart_api() {
"${compose[@]}" stop api >/dev/null 2>&1 || fail "the API did not stop."
"$@"
if ! up_output="$("${compose[@]}" up --detach --wait --wait-timeout 180 api 2>&1)"; then
compose_report_not_ready "$up_output" "${compose[@]}"
fail "the API did not become healthy after a restart."
fi
}
# module_check <expectation>: "absent", or the exact JSON array of values `example` must return.
module_check() {
client module --ip "$module_client_address" -e SMOKE_MODULE_EXPECT="$1" \
-e SMOKE_OWNER_EMAIL="$owner_email" -e SMOKE_OWNER_PASSWORD="$owner_password"
}
# plan_output: the read-only migration plan, as `docker compose run --rm migrate` prints it.
plan_output() {
"${compose[@]}" run --rm --no-deps migrate 2>&1 || fail "migration planning failed."
}
if [[ "$topology" == split ]]; then if [[ "$topology" == split ]]; then
step "Booting the generic image with empty module storage"
module_check absent || fail "the image without modules did not run module-free."
step "Installing the test module into module storage, migrating explicitly, and restarting the API"
install_module_and_migrate() {
install_test_module
local plan
plan="$(plan_output)"
grep -q '^- \[pending\] example:0000_initial ' <<<"$plan" ||
fail "the plan does not list the installed module's migration as pending."
"${compose[@]}" run --rm --no-deps migrate node core/dist/cli/apply-migrations.js >/dev/null ||
fail "applying the installed module's migration failed."
}
restart_api install_module_and_migrate
module_check '[]' || fail "the installed module did not run from module storage."
psql_as_superuser "INSERT INTO example_records (id, value) VALUES (gen_random_uuid(), 'kept')" \
>/dev/null || fail "could not write the module's own data."
module_check '["Hello, kept"]' || fail "the installed module did not read its own data."
step "Driving a real browser through the split Dashboard, a module, its display, and sign-out" step "Driving a real browser through the split Dashboard, a module, its display, and sign-out"
# Chromium trusts this run's proxy through the public-key hash of Caddy's internal intermediate CA, # Chromium trusts this run's proxy through the public-key hash of Caddy's internal intermediate CA,
# which signs every certificate the test proxy serves: no CA is installed anywhere, and nothing but # which signs every certificate the test proxy serves: no CA is installed anywhere, and nothing but
@@ -501,6 +606,31 @@ if [[ "$topology" == split ]]; then
while read -r ts uri; do echo " $(date -u -d "@${ts}" +%T) ${uri}"; done || true while read -r ts uri; do echo " $(date -u -d "@${ts}" +%T) ${uri}"; done || true
((display_status == 0)) || fail "display-device checks failed." ((display_status == 0)) || fail "display-device checks failed."
fi fi
step "Removing the module from module storage and restarting: Core runs, its data stays"
restart_api module_storage 'rm -rf /modules/example'
module_check absent || fail "the removed module is still running."
plan="$(plan_output)"
grep -q '^- \[retained\] example: 1 applied migration kept; the module is not installed$' \
<<<"$plan" || fail "migration planning did not report the removed module's retained history."
grep -q '^Migration plan (.*0 pending, 0 approval-blocked, 1 retained):$' <<<"$plan" ||
fail "migration planning did not succeed cleanly with the module absent."
"${compose[@]}" run --rm --no-deps migrate node core/dist/cli/apply-migrations.js >/dev/null ||
fail "applying migrations failed with the module absent."
[[ "$(psql_as_superuser "SELECT value FROM example_records")" == "kept" ]] ||
fail "the removed module's data did not survive."
echo "ok the module is gone, Core runs, its history is retained, and its data survives"
step "Reinstalling the exact same package and restarting: the module returns with its previous data"
restart_api install_test_module
plan="$(plan_output)"
grep -q '^- \[applied\] example:0000_initial ' <<<"$plan" ||
fail "the restored module's migration history is not valid."
if grep -q '\[retained\]\|\[pending\]' <<<"$plan"; then
fail "the restored module left retained or pending migrations."
fi
module_check '["Hello, kept"]' || fail "the restored module did not return with its data."
echo "ok the restored module runs again, with its history strict and its data intact"
fi fi
step "Proving the API's database connection is certificate-verified TLS" step "Proving the API's database connection is certificate-verified TLS"
@@ -510,9 +640,6 @@ api_data_address="$(
"$api_container" "$api_container"
)" )"
[[ -n "$api_data_address" ]] || fail "could not find the API's database-network address." [[ -n "$api_data_address" ]] || fail "could not find the API's database-network address."
psql_as_superuser() {
"${compose[@]}" exec -T postgres psql -U ekklesia -d ekklesia -Atc "$1"
}
tls_connections="$(psql_as_superuser "SELECT count(*) FROM pg_stat_ssl s JOIN pg_stat_activity a USING (pid) WHERE a.client_addr = '${api_data_address}' AND s.ssl AND s.version = 'TLSv1.3'")" tls_connections="$(psql_as_superuser "SELECT count(*) FROM pg_stat_ssl s JOIN pg_stat_activity a USING (pid) WHERE a.client_addr = '${api_data_address}' AND s.ssl AND s.version = 'TLSv1.3'")"
plain_connections="$(psql_as_superuser "SELECT count(*) FROM pg_stat_ssl s JOIN pg_stat_activity a USING (pid) WHERE a.client_addr = '${api_data_address}' AND NOT s.ssl")" plain_connections="$(psql_as_superuser "SELECT count(*) FROM pg_stat_ssl s JOIN pg_stat_activity a USING (pid) WHERE a.client_addr = '${api_data_address}' AND NOT s.ssl")"
((tls_connections > 0)) || fail "the API holds no TLS 1.3 database connection." ((tls_connections > 0)) || fail "the API holds no TLS 1.3 database connection."
+13
View File
@@ -53,6 +53,19 @@ To create a module inside this repository:
root. The declared production entrypoint is the compiled `dist/server/index.js`, not source root. The declared production entrypoint is the compiled `dist/server/index.js`, not source
TypeScript; the schema entrypoint is likewise compiled to `dist/schema/index.js`. TypeScript; the schema entrypoint is likewise compiled to `dist/schema/index.js`.
That `modules/<module-id>` directory is where a module is **developed**. An installation never runs
it from there: Ekklesia's image contains no modules, and an installation installs a self-contained
copy of the module into its module storage
([An installed module is self-contained](../../docs/module-development/README.md#an-installed-module-is-self-contained),
ADR 0048). Copying `dist/` alone is not enough, because the compiled server code imports the Module
SDK and Drizzle by name. After building, package the module with the canonical packager, which
bundles the backend into one deterministic Ekklesia Module Package (ADR 0049):
```bash
pnpm modules:pack --module templates/module --target any --out <directory> \
--source-revision "$(git rev-parse HEAD)"
```
The module owns its feature screens and display renderer. Do not add platform navigation, the The module owns its feature screens and display renderer. Do not add platform navigation, the
containing shell, global theme, authentication screens, or top-level routing; those belong to containing shell, global theme, authentication screens, or top-level routing; those belong to
surfaces and Core. A module UI asks its host for relative API paths and realtime state, and never surfaces and Core. A module UI asks its host for relative API paths and realtime state, and never
+63 -3
View File
@@ -97,7 +97,7 @@ describe("CI scripts on a shared Docker daemon", () => {
>; >;
}; };
const startsStacks = const startsStacks =
/smoke-infrastructure\.sh|test:migrations:integration|smoke-deploy\.sh|docker build/u; /smoke-infrastructure\.sh|test:migrations:integration|smoke-deploy\.sh|check-image\.sh|docker build/u;
const jobs = Object.entries(workflow.jobs).filter(([, job]) => const jobs = Object.entries(workflow.jobs).filter(([, job]) =>
job.steps.some((step) => startsStacks.test(step.run ?? "")), job.steps.some((step) => startsStacks.test(step.run ?? "")),
); );
@@ -132,12 +132,72 @@ describe("CI scripts on a shared Docker daemon", () => {
it("tag the CI image per run and attempt, so concurrent runs of one commit cannot remove it", async () => { it("tag the CI image per run and attempt, so concurrent runs of one commit cannot remove it", async () => {
const workflow = await source(".github/workflows/ci.yml"); const workflow = await source(".github/workflows/ci.yml");
expect(workflow).toContain( const imageCheck = await source("scripts/check-image.sh");
'ekklesia-ci:"$GITHUB_SHA-${GITHUB_RUN_ID:-0}-${GITHUB_RUN_ATTEMPT:-1}"', expect(workflow).toContain("bash scripts/check-image.sh");
expect(imageCheck).toContain(
'tag_base="ekklesia-ci:${GITHUB_SHA:-local}-${GITHUB_RUN_ID:-0}-${GITHUB_RUN_ATTEMPT:-1}-"',
); );
expect(workflow).not.toContain('ekklesia-ci:"$GITHUB_SHA"'); expect(workflow).not.toContain('ekklesia-ci:"$GITHUB_SHA"');
}); });
it("label every image the image check builds, so a sweep can find it", async () => {
const imageCheck = (await source("scripts/check-image.sh")).replace(/\\\n\s*/gu, " ");
expect(imageCheck).toContain('source "$repository_root/scripts/lib/ci-project.sh"');
expect(imageCheck).toMatch(/^project="\$\(ci_project_name image\)"$/mu);
const builds = imageCheck.split("\n").filter((line) => /\bdocker build\b/u.test(line));
expect(builds.length).toBeGreaterThanOrEqual(1);
for (const line of builds) {
expect(line).toContain('--label "${EKKLESIA_CI_LABEL}=${project}"');
}
});
it("install the smoke test's module only as a canonical package the image itself verified (ADR 0049)", async () => {
const deploy = (await source("scripts/smoke-deploy.sh")).replace(/\\\n\s*/gu, " ");
const packaging = await source("scripts/lib/build-module-package.Dockerfile");
expect(packaging).toContain("packages/module-pack/bin/ekklesia-pack-module.mjs");
expect(packaging).not.toMatch(/pnpm[^\n]*\bdeploy\b/u);
expect(deploy).toContain("scripts/lib/build-module-package.Dockerfile");
expect(deploy).toMatch(
/--entrypoint node "\$image" core\/dist\/cli\/verify-module-package\.js[^\n]*--sha256-file[^\n]*--extract-to \/staging\/example/u,
);
// Module storage only ever receives the verified tree from staging.
expect(deploy).toContain("cp -R /staging/example /modules/.incoming-example");
});
it("hand the verifier the package's archive and digest together, from a fresh release pair, never from build output", async () => {
const deploy = (await source("scripts/smoke-deploy.sh")).replace(/\\\n\s*/gu, " ");
expect(deploy).toContain('source "$repository_root/scripts/lib/module-package-release.sh"');
// The pair is made from this run's own build output, into a directory that must not exist yet.
expect(deploy).toContain('--output "type=local,dest=${work}/module-package"');
expect(deploy).toContain(
'package_archive="$(module_package_release "$work/module-package" "$work/package")"',
);
expect(deploy).toMatch(/^\s*stage package$/mu);
// BuildKit's output directory is 0700 and owned by whoever built it: it is never mounted.
expect(deploy).not.toContain('"$daemon_work/module-package:');
expect(deploy).not.toMatch(/^\s*stage module-package$/mu);
// The verifier reads both files from the one mount, and always checks the digest file.
const verifier = deploy
.split("\n")
.find((line) => line.includes("core/dist/cli/verify-module-package.js"));
expect(verifier).toContain('-v "$daemon_work/package:/package:ro"');
expect(verifier).toContain('"/package/${package_archive}"');
expect(verifier).toContain('--sha256-file "/package/${package_archive}.sha256"');
expect(verifier).not.toMatch(/--sha256\s/u);
});
it("run every deploy smoke against the one generic image, never a second image with a module in it", async () => {
// ADR 0048: the split topology installs its test module into module storage at run time.
const deploy = (await source("scripts/smoke-deploy.sh")).replace(/\\\n\s*/gu, " ");
const imageBuilds = deploy
.split("\n")
.filter((line) => /\bdocker build\b/u.test(line) && line.includes("deploy/Dockerfile"));
expect(imageBuilds).toHaveLength(1);
expect(deploy).not.toContain('mv "$context/templates/module" "$context/modules/');
expect(deploy).not.toMatch(/sed -i 's\|\^ {2}templates\/module:/u);
expect(deploy).toContain("EKKLESIA_MODULES_HOST_DIR=");
});
it("report every readiness failure with the project's containers and logs, through the redactor only", async () => { it("report every readiness failure with the project's containers and logs, through the redactor only", async () => {
for (const relativePath of ciScripts) { for (const relativePath of ciScripts) {
const text = await source(relativePath); const text = await source(relativePath);
+74
View File
@@ -26,6 +26,15 @@ interface ComposeService {
read_only?: boolean; read_only?: boolean;
security_opt?: string[]; security_opt?: string[];
tmpfs?: string[]; tmpfs?: string[];
volumes?: (string | ComposeVolume)[];
}
interface ComposeVolume {
bind?: { create_host_path?: boolean };
read_only?: boolean;
source?: string;
target?: string;
type?: string;
} }
interface ComposeDocument { interface ComposeDocument {
@@ -234,3 +243,68 @@ describe("reference deployment env examples", () => {
expect(example).toContain("EKKLESIA_MODULE_SECRET__<MODULE_ID>__<KEY>="); expect(example).toContain("EKKLESIA_MODULE_SECRET__<MODULE_ID>__<KEY>=");
}); });
}); });
describe("installation-owned module storage (ADR 0048)", () => {
const modulesTarget = "/var/lib/ekklesia/modules";
function moduleMount(service: ComposeService | undefined): ComposeVolume | undefined {
return (service?.volumes ?? []).find(
(volume): volume is ComposeVolume =>
typeof volume === "object" && volume.target === modulesTarget,
);
}
it.each([
"api",
"migrate",
])("mounts host module storage read-only into %s, and never lets Compose create it", async (name) => {
const service = (await compose("compose.yaml")).services?.[name];
expect(moduleMount(service)).toEqual({
bind: { create_host_path: false },
read_only: true,
source: "${EKKLESIA_MODULES_HOST_DIR:-/var/lib/ekklesia/modules}",
target: modulesTarget,
type: "bind",
});
// The process reads exactly the directory the mount provides.
expect(service?.environment?.EKKLESIA_MODULES_DIR).toBe(modulesTarget);
// Read-only everywhere else too: the mount is the only module storage there is.
expect(service?.read_only).toBe(true);
});
it("mounts module storage into nothing but the API and the operator commands", async () => {
const services = (await compose("compose.yaml")).services ?? {};
for (const [name, service] of Object.entries(services)) {
if (name !== "api" && name !== "migrate") {
expect(moduleMount(service), name).toBeUndefined();
}
}
for (const overlay of ["compose.proxy.yaml", "compose.tunnel.yaml"]) {
for (const [name, service] of Object.entries((await compose(overlay)).services ?? {})) {
expect(moduleMount(service), `${overlay} ${name}`).toBeUndefined();
}
}
});
it("names the host-side directory with its own variable, never EKKLESIA_MODULES_DIR", async () => {
const composeEnvironment = await text("env/compose.env.example");
expect(composeEnvironment).toMatch(
/^# ?EKKLESIA_MODULES_HOST_DIR=\/var\/lib\/ekklesia\/modules$/mu,
);
expect(composeEnvironment).not.toMatch(/^#? ?EKKLESIA_MODULES_DIR=/mu);
// The API's own env file must not move the directory away from the mount.
expect(envAssignments(await text("env/ekklesia.env.example")).has("EKKLESIA_MODULES_DIR")).toBe(
false,
);
});
it("builds an image that holds no modules and points at empty module storage", async () => {
const dockerfile = await text("Dockerfile");
const ignore = await readFile(path.join(repositoryRoot, ".dockerignore"), "utf8");
expect(ignore).toMatch(/^\/modules$/mu);
expect(dockerfile).toMatch(/EKKLESIA_MODULES_DIR=\/var\/lib\/ekklesia\/modules/u);
expect(dockerfile).toContain("install -d -o root -g root -m 0755 /var/lib/ekklesia/modules");
// Nothing from a module or the module template is copied into, or installed for, the runtime.
expect(dockerfile).not.toMatch(/\/runtime\/modules|modules\/\*\/package\.json|templates\/\*/u);
});
});
+127
View File
@@ -0,0 +1,127 @@
import { execFile } from "node:child_process";
import { chmod, mkdir, mkdtemp, readdir, readFile, rm, stat, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { promisify } from "node:util";
import { afterEach, describe, expect, it } from "vitest";
/**
* The deploy smoke test's release pair (ADR 0049): a package archive and its `.sha256` are one
* logical release and reach the verifier together, from a directory every user can traverse.
*
* The regression this exists for: BuildKit's local exporter creates its output directory with mode
* 0700, owned by whoever ran the build. On a workstation that is uid 1000, which is also the image's
* unprivileged `node` user, so the verifier could read it; in CI the build runs as root, and the
* verifier could read neither file ("The SHA-256 file could not be read").
*/
const execFileAsync = promisify(execFile);
const helper = path.resolve(
path.dirname(fileURLToPath(import.meta.url)),
"..",
"scripts",
"lib",
"module-package-release.sh",
);
const archive = "example-0.1.0-any.tar.gz";
const digest = "91e5dfee80880027bd1c2e1796513f07023c03847e9ca3870b51f8272c3accf7";
const temporaryDirectories: string[] = [];
afterEach(async () => {
await Promise.all(
temporaryDirectories
.splice(0)
.map((directory) => rm(directory, { force: true, recursive: true })),
);
});
/** A build output exactly as BuildKit leaves it: mode 0700, holding the archive and its digest. */
async function buildOutput(
files: Readonly<Record<string, string>> = {
[archive]: "archive bytes",
[`${archive}.sha256`]: `${digest} ${archive}\n`,
},
): Promise<{ readonly root: string; readonly output: string }> {
const root = await mkdtemp(path.join(tmpdir(), "ekklesia-release-pair-"));
temporaryDirectories.push(root);
const output = path.join(root, "module-package");
await mkdir(output);
for (const [name, contents] of Object.entries(files)) {
await writeFile(path.join(output, name), contents, { mode: 0o600 });
}
await chmod(output, 0o700);
return { output, root };
}
async function release(output: string, target: string) {
return execFileAsync("bash", [
"-c",
'source "$1"; module_package_release "$2" "$3"',
"release",
helper,
output,
target,
]);
}
describe("the smoke test's package release pair", () => {
it("copies the archive and its digest, together, into a fresh world-traversable directory", async () => {
const { output, root } = await buildOutput();
const target = path.join(root, "package");
const { stdout } = await release(output, target);
expect(stdout.trim()).toBe(archive);
expect((await readdir(target)).sort()).toEqual([archive, `${archive}.sha256`]);
expect((await stat(target)).mode & 0o777).toBe(0o755);
for (const name of [archive, `${archive}.sha256`]) {
expect((await stat(path.join(target, name))).mode & 0o777, name).toBe(0o644);
expect(await readFile(path.join(target, name))).toEqual(
await readFile(path.join(output, name)),
);
}
});
it.each([
["the digest is missing", { [archive]: "archive bytes" }],
[
"the digest names another file",
{ [archive]: "a", [`${archive}.sha256`]: `${digest} other.tar.gz\n` },
],
["the digest is malformed", { [archive]: "a", [`${archive}.sha256`]: "not-a-digest x\n" }],
[
"the build left something else beside the pair",
{
[archive]: "a",
[`${archive}.sha256`]: `${digest} ${archive}\n`,
"stale.tar.gz.sha256": "x",
},
],
[
"there is more than one archive",
{
[archive]: "a",
[`${archive}.sha256`]: `${digest} ${archive}\n`,
"example-0.0.9-any.tar.gz": "old",
},
],
["there is no archive at all", {}],
])("refuses when %s", async (_label, files) => {
const { output, root } = await buildOutput(files);
const target = path.join(root, "package");
await expect(release(output, target)).rejects.toThrow();
await expect(stat(target)).rejects.toMatchObject({ code: "ENOENT" });
});
it("never reuses a release directory that already exists", async () => {
const { output, root } = await buildOutput();
const target = path.join(root, "package");
await mkdir(target);
await writeFile(path.join(target, `${archive}.sha256`), `${"0".repeat(64)} ${archive}\n`);
await expect(release(output, target)).rejects.toThrow();
// The stale digest is left exactly as it was; nothing was mixed into it.
expect(await readdir(target)).toEqual([`${archive}.sha256`]);
});
});
+4
View File
@@ -14,6 +14,10 @@
"dependsOn": ["^build"], "dependsOn": ["^build"],
"outputs": [] "outputs": []
}, },
"@ekklesia/module-pack#test": {
"dependsOn": ["^build", "@ekklesia/module-template#test"],
"outputs": []
},
"typecheck": { "typecheck": {
"dependsOn": ["^build", "^typecheck"], "dependsOn": ["^build", "^typecheck"],
"outputs": [] "outputs": []