Compare commits
72
Commits
v0.1.2
...
ae976ca229
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ae976ca229 | ||
|
|
f9585cc7ec | ||
|
|
6dae29412f | ||
|
|
40ef0ff802 | ||
|
|
a0e9948820 | ||
|
|
6e64b3c5f9 | ||
|
|
fa27603133 | ||
|
|
9e01679fcb | ||
|
|
227c92e35d | ||
|
|
505c9e8c0a | ||
|
|
4be046c8d0 | ||
|
|
c5a6cdaf6a | ||
|
|
c4fded6ecf | ||
|
|
9ad2d5b564 | ||
|
|
98d93cc12d | ||
|
|
e083c44e05 | ||
|
|
4919db6168 | ||
|
|
6e76293620 | ||
|
|
3b54a7f023 | ||
|
|
afaf3a853c | ||
|
|
1788d17f0a | ||
|
|
1caffc5f26 | ||
|
|
debb8df3ce | ||
|
|
98e59d4ba0 | ||
|
|
22404ea2d4 | ||
|
|
486356a01e | ||
|
|
1f30c86d4f | ||
|
|
d3c62e63bf | ||
|
|
57df2e15f2 | ||
|
|
4f1b1d68c7 | ||
|
|
9debe4e891 | ||
|
|
7ea2d7883f | ||
|
|
aaa7ebfd84 | ||
|
|
89d5d0f2da | ||
|
|
9426c83109 | ||
|
|
47e64c6f39 | ||
|
|
5803757153 | ||
|
|
44d385cd7f | ||
|
|
f53506b3f0 | ||
|
|
951453c735 | ||
|
|
99f145a80c | ||
|
|
104a5145c8 | ||
|
|
796ad4f16e | ||
|
|
b1e3244c1d | ||
|
|
85fae9e07e | ||
|
|
30f61094e6 | ||
|
|
1c7755d320 | ||
|
|
42acdeb9e8 | ||
|
|
70854d234c | ||
|
|
20ad1ae208 | ||
|
|
49b4d4165d | ||
|
|
a1cdcb3ad1 | ||
|
|
859675c3c7 | ||
|
|
4a3958c616 | ||
|
|
317a4ce410 | ||
|
|
6db50516b7 | ||
|
|
d43031997b | ||
|
|
11e2495484 | ||
|
|
759872834d | ||
|
|
9ddb5e2796 | ||
|
|
52a665502f | ||
|
|
9a328f647e | ||
|
|
22caa1dceb | ||
|
|
27d85e0162 | ||
|
|
35ef31df9f | ||
|
|
45d6506c10 | ||
|
|
1119cc5fdf | ||
|
|
fbf8f6edac | ||
|
|
f122e910fc | ||
|
|
e36b70d8d4 | ||
|
|
4d28a82937 | ||
|
|
cefdde36a2 |
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -6,3 +6,7 @@
|
|||||||
*.gif binary
|
*.gif binary
|
||||||
*.webp binary
|
*.webp binary
|
||||||
*.woff2 binary
|
*.woff2 binary
|
||||||
|
|
||||||
|
# Reviewed license supplements are named by the SHA-256 of their exact bytes (ADR 0052), so Git must
|
||||||
|
# never rewrite their line endings.
|
||||||
|
packages/module-pack/license-supplements/** -text
|
||||||
|
|||||||
+135
-20
@@ -128,6 +128,129 @@ jobs:
|
|||||||
if: always()
|
if: always()
|
||||||
run: bash scripts/ci-sweep.sh --job
|
run: bash scripts/ci-sweep.sh --job
|
||||||
|
|
||||||
|
package-release:
|
||||||
|
needs: verify
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 15
|
||||||
|
# What the gate proved, for the job that consumes its artifact to check it received exactly that.
|
||||||
|
outputs:
|
||||||
|
version: ${{ steps.release.outputs.version }}
|
||||||
|
revision: ${{ steps.release.outputs.revision }}
|
||||||
|
manifest_sha256: ${{ steps.release.outputs.manifest_sha256 }}
|
||||||
|
steps:
|
||||||
|
- name: Check out repository
|
||||||
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
|
with:
|
||||||
|
# `actions/checkout` defaults to leaving the job token in `.git/config`, where any later
|
||||||
|
# step — including one a pull request added to this file — could read or reuse it. Nothing
|
||||||
|
# in this workflow pushes, so the credential is never needed after checkout.
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
|
- name: Set up Node.js
|
||||||
|
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||||
|
with:
|
||||||
|
node-version-file: .node-version
|
||||||
|
|
||||||
|
- name: Activate pinned pnpm
|
||||||
|
run: |
|
||||||
|
corepack enable
|
||||||
|
corepack prepare [email protected] --activate
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
# ADR 0053's package release gate: build the public package train from this commit, build it
|
||||||
|
# again from a second checkout and require byte-identical artifacts, then install exactly those
|
||||||
|
# tarballs (offline, no registry) into a module outside this repository and prove it builds,
|
||||||
|
# packages, and verifies. Nothing is published and no credential exists here. A future publish
|
||||||
|
# job must depend on this job and publish only the artifacts it proved.
|
||||||
|
- name: Build, reproduce, and consume the public package train
|
||||||
|
run: pnpm test:package-release --keep package-train
|
||||||
|
|
||||||
|
- name: Describe the proven release train
|
||||||
|
id: release
|
||||||
|
run: |
|
||||||
|
release="$(find package-train -mindepth 1 -maxdepth 1 -type d)"
|
||||||
|
manifest="$release/ekklesia-package-train.json"
|
||||||
|
{
|
||||||
|
echo "version=$(node -p 'JSON.parse(require("fs").readFileSync(process.argv[1], "utf8")).version' "$manifest")"
|
||||||
|
echo "revision=$(node -p 'JSON.parse(require("fs").readFileSync(process.argv[1], "utf8")).source.revision' "$manifest")"
|
||||||
|
echo "manifest_sha256=$(sha256sum "$manifest" | cut -d' ' -f1)"
|
||||||
|
} >>"$GITHUB_OUTPUT"
|
||||||
|
cat "$manifest"
|
||||||
|
|
||||||
|
# The exact release artifacts, for the jobs that consume them, from a directory whose name does
|
||||||
|
# not start with a dot: the artifact actions leave hidden files out. Version 3 of the artifact
|
||||||
|
# actions: version 4 refuses any server that is not github.com, which this Gitea is not, while
|
||||||
|
# Gitea implements the protocol version 3 uses. A consumer checks the files, not the wrapper.
|
||||||
|
- name: Hand the release train on
|
||||||
|
uses: actions/upload-artifact@c6a3b2bd78b3985e4b2f15397fec357f0fd808de # v3.2.2-node20
|
||||||
|
with:
|
||||||
|
name: package-train
|
||||||
|
path: package-train/
|
||||||
|
if-no-files-found: error
|
||||||
|
retention-days: 7
|
||||||
|
|
||||||
|
# The publication tool, given exactly the artifact the package-release job proved, against a
|
||||||
|
# disposable Gitea 1.27.2 (scripts/test-package-publication.sh). Never the real registry, and no
|
||||||
|
# credential: the disposable registry's accounts are created and discarded inside the job.
|
||||||
|
package-publication:
|
||||||
|
needs: package-release
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 15
|
||||||
|
env:
|
||||||
|
# Names this job's stacks, so its final step removes exactly these (scripts/lib/ci-project.sh).
|
||||||
|
EKKLESIA_CI_JOB_KEY: package-publication
|
||||||
|
steps:
|
||||||
|
- name: Check out repository
|
||||||
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
|
# A job the runner killed (cancelled, or past the runner's own time limit) never ran its
|
||||||
|
# cleanup. Anything a CI job left on this shared daemon more than three hours ago goes first.
|
||||||
|
- name: Sweep stale CI stacks from the runner
|
||||||
|
run: bash scripts/ci-sweep.sh --stale
|
||||||
|
|
||||||
|
- name: Set up Node.js
|
||||||
|
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||||
|
with:
|
||||||
|
node-version-file: .node-version
|
||||||
|
|
||||||
|
- name: Activate pinned pnpm
|
||||||
|
run: |
|
||||||
|
corepack enable
|
||||||
|
corepack prepare [email protected] --activate
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
- name: Receive the release train the gate proved
|
||||||
|
uses: actions/download-artifact@ad191675b41f6a5b46da9a048cb6893812da158b # v3.1.0-node20
|
||||||
|
with:
|
||||||
|
name: package-train
|
||||||
|
path: package-train/
|
||||||
|
|
||||||
|
- name: Check it is exactly that train
|
||||||
|
env:
|
||||||
|
EXPECTED_MANIFEST_SHA256: ${{ needs.package-release.outputs.manifest_sha256 }}
|
||||||
|
run: |
|
||||||
|
release="$(find package-train -mindepth 1 -maxdepth 1 -type d)"
|
||||||
|
actual="$(sha256sum "$release/ekklesia-package-train.json" | cut -d' ' -f1)"
|
||||||
|
if [[ -z "$EXPECTED_MANIFEST_SHA256" || "$actual" != "$EXPECTED_MANIFEST_SHA256" ]]; then
|
||||||
|
echo "The received release manifest is not the one the package-release job proved." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
(cd "$release" && sha256sum --check --strict SHA256SUMS)
|
||||||
|
|
||||||
|
- name: Publish it to a disposable Gitea, and prove the round trip and recovery
|
||||||
|
run: bash scripts/test-package-publication.sh --release "$(find package-train -mindepth 1 -maxdepth 1 -type d)"
|
||||||
|
|
||||||
|
# Whatever happened above, this job's own stacks do not outlive it.
|
||||||
|
- name: Remove this job's CI stacks
|
||||||
|
if: always()
|
||||||
|
run: bash scripts/ci-sweep.sh --job
|
||||||
|
|
||||||
image-build:
|
image-build:
|
||||||
needs: verify
|
needs: verify
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
@@ -152,23 +275,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 +293,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 +321,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
|
||||||
|
|||||||
@@ -0,0 +1,133 @@
|
|||||||
|
name: Publish packages
|
||||||
|
|
||||||
|
# Publishes one public @ekklesia/* package train (ADR 0053, docs/development/releases.md) to the
|
||||||
|
# npm registry: exactly the artifacts this run's package-release gate proves, and nothing else.
|
||||||
|
#
|
||||||
|
# Only ever started by hand, on `main`, by someone with write access to this repository: no pull
|
||||||
|
# request or push can start it. It needs the version-set commit of a release (`version` must equal
|
||||||
|
# the platform version there), and the repository secret EKKLESIA_PACKAGES_PUBLISH_TOKEN, a token of
|
||||||
|
# the dedicated publisher account with only the `write:package` scope. Packages are publicly
|
||||||
|
# readable (ADR 0054); publishing is what this protects.
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
version:
|
||||||
|
description: The package train version to publish, equal to the platform version on main.
|
||||||
|
required: true
|
||||||
|
type: string
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
# Never cancel a publication part way, and never run two at once. A rerun resumes safely.
|
||||||
|
concurrency:
|
||||||
|
group: publish-packages
|
||||||
|
cancel-in-progress: false
|
||||||
|
|
||||||
|
env:
|
||||||
|
# The only registry-specific setting: where the train is published.
|
||||||
|
EKKLESIA_PACKAGES_REGISTRY: https://git.riverofglorymn.org/api/packages/River_of_Glory_Church/npm/
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
package-release:
|
||||||
|
if: github.ref == 'refs/heads/main'
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 20
|
||||||
|
outputs:
|
||||||
|
version: ${{ steps.release.outputs.version }}
|
||||||
|
revision: ${{ steps.release.outputs.revision }}
|
||||||
|
manifest_sha256: ${{ steps.release.outputs.manifest_sha256 }}
|
||||||
|
steps:
|
||||||
|
- name: Check out repository
|
||||||
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
|
- name: Set up Node.js
|
||||||
|
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||||
|
with:
|
||||||
|
node-version-file: .node-version
|
||||||
|
|
||||||
|
- name: Activate pinned pnpm
|
||||||
|
run: |
|
||||||
|
corepack enable
|
||||||
|
corepack prepare [email protected] --activate
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
# The same credential-free gate as CI: build, reproduce, and consume the train.
|
||||||
|
- name: Build, reproduce, and consume the public package train
|
||||||
|
run: pnpm test:package-release --keep package-train
|
||||||
|
|
||||||
|
- name: Describe the proven release train
|
||||||
|
id: release
|
||||||
|
run: |
|
||||||
|
release="$(find package-train -mindepth 1 -maxdepth 1 -type d)"
|
||||||
|
manifest="$release/ekklesia-package-train.json"
|
||||||
|
{
|
||||||
|
echo "version=$(node -p 'JSON.parse(require("fs").readFileSync(process.argv[1], "utf8")).version' "$manifest")"
|
||||||
|
echo "revision=$(node -p 'JSON.parse(require("fs").readFileSync(process.argv[1], "utf8")).source.revision' "$manifest")"
|
||||||
|
echo "manifest_sha256=$(sha256sum "$manifest" | cut -d' ' -f1)"
|
||||||
|
} >>"$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
# Artifact actions version 3: version 4 refuses any server that is not github.com. The directory
|
||||||
|
# name must not start with a dot: the artifact actions leave hidden files out.
|
||||||
|
- name: Hand the release train on
|
||||||
|
uses: actions/upload-artifact@c6a3b2bd78b3985e4b2f15397fec357f0fd808de # v3.2.2-node20
|
||||||
|
with:
|
||||||
|
name: package-train
|
||||||
|
path: package-train/
|
||||||
|
if-no-files-found: error
|
||||||
|
retention-days: 90
|
||||||
|
|
||||||
|
publish:
|
||||||
|
needs: package-release
|
||||||
|
if: github.ref == 'refs/heads/main'
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 20
|
||||||
|
steps:
|
||||||
|
# Only for the publication tool itself: nothing is built or installed in this job.
|
||||||
|
- name: Check out repository
|
||||||
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
|
- name: Set up Node.js
|
||||||
|
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||||
|
with:
|
||||||
|
node-version-file: .node-version
|
||||||
|
|
||||||
|
- name: Receive the release train the gate proved
|
||||||
|
uses: actions/download-artifact@ad191675b41f6a5b46da9a048cb6893812da158b # v3.1.0-node20
|
||||||
|
with:
|
||||||
|
name: package-train
|
||||||
|
path: package-train/
|
||||||
|
|
||||||
|
# The tool validates the whole train against its manifest, that manifest against the gate's,
|
||||||
|
# its version against the request, and its revision against this commit, before it sends
|
||||||
|
# anything; then publishes, verifies every package byte for byte, and moves `latest` last.
|
||||||
|
- name: Publish the exact release train
|
||||||
|
env:
|
||||||
|
EKKLESIA_PACKAGES_PUBLISH_TOKEN: ${{ secrets.EKKLESIA_PACKAGES_PUBLISH_TOKEN }}
|
||||||
|
TRAIN_VERSION: ${{ inputs.version }}
|
||||||
|
TRAIN_REVISION: ${{ github.sha }}
|
||||||
|
MANIFEST_SHA256: ${{ needs.package-release.outputs.manifest_sha256 }}
|
||||||
|
run: |
|
||||||
|
node scripts/publish-package-train.ts \
|
||||||
|
--release "$(find package-train -mindepth 1 -maxdepth 1 -type d)" \
|
||||||
|
--registry "$EKKLESIA_PACKAGES_REGISTRY" \
|
||||||
|
--version "$TRAIN_VERSION" \
|
||||||
|
--revision "$TRAIN_REVISION" \
|
||||||
|
--manifest-sha256 "$MANIFEST_SHA256" \
|
||||||
|
--record publication-record.json \
|
||||||
|
--require-public-read
|
||||||
|
|
||||||
|
- name: Keep the publication record
|
||||||
|
uses: actions/upload-artifact@c6a3b2bd78b3985e4b2f15397fec357f0fd808de # v3.2.2-node20
|
||||||
|
with:
|
||||||
|
name: package-train-publication-record
|
||||||
|
path: publication-record.json
|
||||||
|
if-no-files-found: error
|
||||||
|
retention-days: 90
|
||||||
+12
@@ -1,4 +1,8 @@
|
|||||||
node_modules/
|
node_modules/
|
||||||
|
# pnpm's content-addressed package store. pnpm keeps it beside node_modules on the same file system,
|
||||||
|
# so when the checkout is its own mount (a CI runner's workspace volume, a bind-mounted container),
|
||||||
|
# `pnpm install` creates it here, at the checkout's root. A package cache, never source.
|
||||||
|
.pnpm-store/
|
||||||
.turbo/
|
.turbo/
|
||||||
coverage/
|
coverage/
|
||||||
dist/
|
dist/
|
||||||
@@ -16,3 +20,11 @@ generated/*
|
|||||||
|
|
||||||
core/.identity-schema-reference.ts
|
core/.identity-schema-reference.ts
|
||||||
core/.identity-schema-check-*
|
core/.identity-schema-check-*
|
||||||
|
|
||||||
|
# The packager tests copy fixture modules here (packages/module-pack/tests/support/fixture-module.ts).
|
||||||
|
packages/module-pack/.fixtures/
|
||||||
|
|
||||||
|
# `pnpm packages:release` writes package release trains here (scripts/package-release.ts).
|
||||||
|
.package-release/
|
||||||
|
# CI keeps the proven release here to hand it on as a workflow artifact (scripts/test-package-release.sh --keep).
|
||||||
|
/package-train/
|
||||||
|
|||||||
@@ -78,6 +78,13 @@ ever disagree:
|
|||||||
- `pnpm test:migrations:integration` — live, disposable-PostgreSQL evidence: migration discovery,
|
- `pnpm test:migrations:integration` — live, disposable-PostgreSQL evidence: migration discovery,
|
||||||
planning, and execution, plus the `apps/api` HTTP transport (signup, sign-in, principal
|
planning, and execution, plus the `apps/api` HTTP transport (signup, sign-in, principal
|
||||||
resolution, sign-out). Requires Docker. Run this for any change touching either area.
|
resolution, sign-out). Requires Docker. Run this for any change touching either area.
|
||||||
|
- `pnpm test:package-release` — builds the public `@ekklesia/*` package train, proves it
|
||||||
|
reproducible, and proves a standalone module consumes exactly those tarballs. Publishes nothing.
|
||||||
|
Run it for any change to the public packages or their release tooling.
|
||||||
|
- `pnpm test:package-publication` — publishes a release train to a disposable Gitea (Docker) and
|
||||||
|
proves the publication tool. Never the real registry. The real publication is the manual
|
||||||
|
`Publish packages` workflow only; never run `scripts/publish-package-train.ts` against the real
|
||||||
|
registry without the user's explicit intent.
|
||||||
- `pnpm doctor` — read-only local readiness report. Never treat it as a substitute for `pnpm
|
- `pnpm doctor` — read-only local readiness report. Never treat it as a substitute for `pnpm
|
||||||
verify` or the integration suite.
|
verify` or the integration suite.
|
||||||
- `pnpm migrations:plan` / `pnpm migrations:apply` — read-only planning vs. mutating execution
|
- `pnpm migrations:plan` / `pnpm migrations:apply` — read-only planning vs. mutating execution
|
||||||
|
|||||||
@@ -0,0 +1,661 @@
|
|||||||
|
GNU AFFERO GENERAL PUBLIC LICENSE
|
||||||
|
Version 3, 19 November 2007
|
||||||
|
|
||||||
|
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||||
|
Everyone is permitted to copy and distribute verbatim copies
|
||||||
|
of this license document, but changing it is not allowed.
|
||||||
|
|
||||||
|
Preamble
|
||||||
|
|
||||||
|
The GNU Affero General Public License is a free, copyleft license for
|
||||||
|
software and other kinds of works, specifically designed to ensure
|
||||||
|
cooperation with the community in the case of network server software.
|
||||||
|
|
||||||
|
The licenses for most software and other practical works are designed
|
||||||
|
to take away your freedom to share and change the works. By contrast,
|
||||||
|
our General Public Licenses are intended to guarantee your freedom to
|
||||||
|
share and change all versions of a program--to make sure it remains free
|
||||||
|
software for all its users.
|
||||||
|
|
||||||
|
When we speak of free software, we are referring to freedom, not
|
||||||
|
price. Our General Public Licenses are designed to make sure that you
|
||||||
|
have the freedom to distribute copies of free software (and charge for
|
||||||
|
them if you wish), that you receive source code or can get it if you
|
||||||
|
want it, that you can change the software or use pieces of it in new
|
||||||
|
free programs, and that you know you can do these things.
|
||||||
|
|
||||||
|
Developers that use our General Public Licenses protect your rights
|
||||||
|
with two steps: (1) assert copyright on the software, and (2) offer
|
||||||
|
you this License which gives you legal permission to copy, distribute
|
||||||
|
and/or modify the software.
|
||||||
|
|
||||||
|
A secondary benefit of defending all users' freedom is that
|
||||||
|
improvements made in alternate versions of the program, if they
|
||||||
|
receive widespread use, become available for other developers to
|
||||||
|
incorporate. Many developers of free software are heartened and
|
||||||
|
encouraged by the resulting cooperation. However, in the case of
|
||||||
|
software used on network servers, this result may fail to come about.
|
||||||
|
The GNU General Public License permits making a modified version and
|
||||||
|
letting the public access it on a server without ever releasing its
|
||||||
|
source code to the public.
|
||||||
|
|
||||||
|
The GNU Affero General Public License is designed specifically to
|
||||||
|
ensure that, in such cases, the modified source code becomes available
|
||||||
|
to the community. It requires the operator of a network server to
|
||||||
|
provide the source code of the modified version running there to the
|
||||||
|
users of that server. Therefore, public use of a modified version, on
|
||||||
|
a publicly accessible server, gives the public access to the source
|
||||||
|
code of the modified version.
|
||||||
|
|
||||||
|
An older license, called the Affero General Public License and
|
||||||
|
published by Affero, was designed to accomplish similar goals. This is
|
||||||
|
a different license, not a version of the Affero GPL, but Affero has
|
||||||
|
released a new version of the Affero GPL which permits relicensing under
|
||||||
|
this license.
|
||||||
|
|
||||||
|
The precise terms and conditions for copying, distribution and
|
||||||
|
modification follow.
|
||||||
|
|
||||||
|
TERMS AND CONDITIONS
|
||||||
|
|
||||||
|
0. Definitions.
|
||||||
|
|
||||||
|
"This License" refers to version 3 of the GNU Affero General Public License.
|
||||||
|
|
||||||
|
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||||
|
works, such as semiconductor masks.
|
||||||
|
|
||||||
|
"The Program" refers to any copyrightable work licensed under this
|
||||||
|
License. Each licensee is addressed as "you". "Licensees" and
|
||||||
|
"recipients" may be individuals or organizations.
|
||||||
|
|
||||||
|
To "modify" a work means to copy from or adapt all or part of the work
|
||||||
|
in a fashion requiring copyright permission, other than the making of an
|
||||||
|
exact copy. The resulting work is called a "modified version" of the
|
||||||
|
earlier work or a work "based on" the earlier work.
|
||||||
|
|
||||||
|
A "covered work" means either the unmodified Program or a work based
|
||||||
|
on the Program.
|
||||||
|
|
||||||
|
To "propagate" a work means to do anything with it that, without
|
||||||
|
permission, would make you directly or secondarily liable for
|
||||||
|
infringement under applicable copyright law, except executing it on a
|
||||||
|
computer or modifying a private copy. Propagation includes copying,
|
||||||
|
distribution (with or without modification), making available to the
|
||||||
|
public, and in some countries other activities as well.
|
||||||
|
|
||||||
|
To "convey" a work means any kind of propagation that enables other
|
||||||
|
parties to make or receive copies. Mere interaction with a user through
|
||||||
|
a computer network, with no transfer of a copy, is not conveying.
|
||||||
|
|
||||||
|
An interactive user interface displays "Appropriate Legal Notices"
|
||||||
|
to the extent that it includes a convenient and prominently visible
|
||||||
|
feature that (1) displays an appropriate copyright notice, and (2)
|
||||||
|
tells the user that there is no warranty for the work (except to the
|
||||||
|
extent that warranties are provided), that licensees may convey the
|
||||||
|
work under this License, and how to view a copy of this License. If
|
||||||
|
the interface presents a list of user commands or options, such as a
|
||||||
|
menu, a prominent item in the list meets this criterion.
|
||||||
|
|
||||||
|
1. Source Code.
|
||||||
|
|
||||||
|
The "source code" for a work means the preferred form of the work
|
||||||
|
for making modifications to it. "Object code" means any non-source
|
||||||
|
form of a work.
|
||||||
|
|
||||||
|
A "Standard Interface" means an interface that either is an official
|
||||||
|
standard defined by a recognized standards body, or, in the case of
|
||||||
|
interfaces specified for a particular programming language, one that
|
||||||
|
is widely used among developers working in that language.
|
||||||
|
|
||||||
|
The "System Libraries" of an executable work include anything, other
|
||||||
|
than the work as a whole, that (a) is included in the normal form of
|
||||||
|
packaging a Major Component, but which is not part of that Major
|
||||||
|
Component, and (b) serves only to enable use of the work with that
|
||||||
|
Major Component, or to implement a Standard Interface for which an
|
||||||
|
implementation is available to the public in source code form. A
|
||||||
|
"Major Component", in this context, means a major essential component
|
||||||
|
(kernel, window system, and so on) of the specific operating system
|
||||||
|
(if any) on which the executable work runs, or a compiler used to
|
||||||
|
produce the work, or an object code interpreter used to run it.
|
||||||
|
|
||||||
|
The "Corresponding Source" for a work in object code form means all
|
||||||
|
the source code needed to generate, install, and (for an executable
|
||||||
|
work) run the object code and to modify the work, including scripts to
|
||||||
|
control those activities. However, it does not include the work's
|
||||||
|
System Libraries, or general-purpose tools or generally available free
|
||||||
|
programs which are used unmodified in performing those activities but
|
||||||
|
which are not part of the work. For example, Corresponding Source
|
||||||
|
includes interface definition files associated with source files for
|
||||||
|
the work, and the source code for shared libraries and dynamically
|
||||||
|
linked subprograms that the work is specifically designed to require,
|
||||||
|
such as by intimate data communication or control flow between those
|
||||||
|
subprograms and other parts of the work.
|
||||||
|
|
||||||
|
The Corresponding Source need not include anything that users
|
||||||
|
can regenerate automatically from other parts of the Corresponding
|
||||||
|
Source.
|
||||||
|
|
||||||
|
The Corresponding Source for a work in source code form is that
|
||||||
|
same work.
|
||||||
|
|
||||||
|
2. Basic Permissions.
|
||||||
|
|
||||||
|
All rights granted under this License are granted for the term of
|
||||||
|
copyright on the Program, and are irrevocable provided the stated
|
||||||
|
conditions are met. This License explicitly affirms your unlimited
|
||||||
|
permission to run the unmodified Program. The output from running a
|
||||||
|
covered work is covered by this License only if the output, given its
|
||||||
|
content, constitutes a covered work. This License acknowledges your
|
||||||
|
rights of fair use or other equivalent, as provided by copyright law.
|
||||||
|
|
||||||
|
You may make, run and propagate covered works that you do not
|
||||||
|
convey, without conditions so long as your license otherwise remains
|
||||||
|
in force. You may convey covered works to others for the sole purpose
|
||||||
|
of having them make modifications exclusively for you, or provide you
|
||||||
|
with facilities for running those works, provided that you comply with
|
||||||
|
the terms of this License in conveying all material for which you do
|
||||||
|
not control copyright. Those thus making or running the covered works
|
||||||
|
for you must do so exclusively on your behalf, under your direction
|
||||||
|
and control, on terms that prohibit them from making any copies of
|
||||||
|
your copyrighted material outside their relationship with you.
|
||||||
|
|
||||||
|
Conveying under any other circumstances is permitted solely under
|
||||||
|
the conditions stated below. Sublicensing is not allowed; section 10
|
||||||
|
makes it unnecessary.
|
||||||
|
|
||||||
|
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||||
|
|
||||||
|
No covered work shall be deemed part of an effective technological
|
||||||
|
measure under any applicable law fulfilling obligations under article
|
||||||
|
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
||||||
|
similar laws prohibiting or restricting circumvention of such
|
||||||
|
measures.
|
||||||
|
|
||||||
|
When you convey a covered work, you waive any legal power to forbid
|
||||||
|
circumvention of technological measures to the extent such circumvention
|
||||||
|
is effected by exercising rights under this License with respect to
|
||||||
|
the covered work, and you disclaim any intention to limit operation or
|
||||||
|
modification of the work as a means of enforcing, against the work's
|
||||||
|
users, your or third parties' legal rights to forbid circumvention of
|
||||||
|
technological measures.
|
||||||
|
|
||||||
|
4. Conveying Verbatim Copies.
|
||||||
|
|
||||||
|
You may convey verbatim copies of the Program's source code as you
|
||||||
|
receive it, in any medium, provided that you conspicuously and
|
||||||
|
appropriately publish on each copy an appropriate copyright notice;
|
||||||
|
keep intact all notices stating that this License and any
|
||||||
|
non-permissive terms added in accord with section 7 apply to the code;
|
||||||
|
keep intact all notices of the absence of any warranty; and give all
|
||||||
|
recipients a copy of this License along with the Program.
|
||||||
|
|
||||||
|
You may charge any price or no price for each copy that you convey,
|
||||||
|
and you may offer support or warranty protection for a fee.
|
||||||
|
|
||||||
|
5. Conveying Modified Source Versions.
|
||||||
|
|
||||||
|
You may convey a work based on the Program, or the modifications to
|
||||||
|
produce it from the Program, in the form of source code under the
|
||||||
|
terms of section 4, provided that you also meet all of these conditions:
|
||||||
|
|
||||||
|
a) The work must carry prominent notices stating that you modified
|
||||||
|
it, and giving a relevant date.
|
||||||
|
|
||||||
|
b) The work must carry prominent notices stating that it is
|
||||||
|
released under this License and any conditions added under section
|
||||||
|
7. This requirement modifies the requirement in section 4 to
|
||||||
|
"keep intact all notices".
|
||||||
|
|
||||||
|
c) You must license the entire work, as a whole, under this
|
||||||
|
License to anyone who comes into possession of a copy. This
|
||||||
|
License will therefore apply, along with any applicable section 7
|
||||||
|
additional terms, to the whole of the work, and all its parts,
|
||||||
|
regardless of how they are packaged. This License gives no
|
||||||
|
permission to license the work in any other way, but it does not
|
||||||
|
invalidate such permission if you have separately received it.
|
||||||
|
|
||||||
|
d) If the work has interactive user interfaces, each must display
|
||||||
|
Appropriate Legal Notices; however, if the Program has interactive
|
||||||
|
interfaces that do not display Appropriate Legal Notices, your
|
||||||
|
work need not make them do so.
|
||||||
|
|
||||||
|
A compilation of a covered work with other separate and independent
|
||||||
|
works, which are not by their nature extensions of the covered work,
|
||||||
|
and which are not combined with it such as to form a larger program,
|
||||||
|
in or on a volume of a storage or distribution medium, is called an
|
||||||
|
"aggregate" if the compilation and its resulting copyright are not
|
||||||
|
used to limit the access or legal rights of the compilation's users
|
||||||
|
beyond what the individual works permit. Inclusion of a covered work
|
||||||
|
in an aggregate does not cause this License to apply to the other
|
||||||
|
parts of the aggregate.
|
||||||
|
|
||||||
|
6. Conveying Non-Source Forms.
|
||||||
|
|
||||||
|
You may convey a covered work in object code form under the terms
|
||||||
|
of sections 4 and 5, provided that you also convey the
|
||||||
|
machine-readable Corresponding Source under the terms of this License,
|
||||||
|
in one of these ways:
|
||||||
|
|
||||||
|
a) Convey the object code in, or embodied in, a physical product
|
||||||
|
(including a physical distribution medium), accompanied by the
|
||||||
|
Corresponding Source fixed on a durable physical medium
|
||||||
|
customarily used for software interchange.
|
||||||
|
|
||||||
|
b) Convey the object code in, or embodied in, a physical product
|
||||||
|
(including a physical distribution medium), accompanied by a
|
||||||
|
written offer, valid for at least three years and valid for as
|
||||||
|
long as you offer spare parts or customer support for that product
|
||||||
|
model, to give anyone who possesses the object code either (1) a
|
||||||
|
copy of the Corresponding Source for all the software in the
|
||||||
|
product that is covered by this License, on a durable physical
|
||||||
|
medium customarily used for software interchange, for a price no
|
||||||
|
more than your reasonable cost of physically performing this
|
||||||
|
conveying of source, or (2) access to copy the
|
||||||
|
Corresponding Source from a network server at no charge.
|
||||||
|
|
||||||
|
c) Convey individual copies of the object code with a copy of the
|
||||||
|
written offer to provide the Corresponding Source. This
|
||||||
|
alternative is allowed only occasionally and noncommercially, and
|
||||||
|
only if you received the object code with such an offer, in accord
|
||||||
|
with subsection 6b.
|
||||||
|
|
||||||
|
d) Convey the object code by offering access from a designated
|
||||||
|
place (gratis or for a charge), and offer equivalent access to the
|
||||||
|
Corresponding Source in the same way through the same place at no
|
||||||
|
further charge. You need not require recipients to copy the
|
||||||
|
Corresponding Source along with the object code. If the place to
|
||||||
|
copy the object code is a network server, the Corresponding Source
|
||||||
|
may be on a different server (operated by you or a third party)
|
||||||
|
that supports equivalent copying facilities, provided you maintain
|
||||||
|
clear directions next to the object code saying where to find the
|
||||||
|
Corresponding Source. Regardless of what server hosts the
|
||||||
|
Corresponding Source, you remain obligated to ensure that it is
|
||||||
|
available for as long as needed to satisfy these requirements.
|
||||||
|
|
||||||
|
e) Convey the object code using peer-to-peer transmission, provided
|
||||||
|
you inform other peers where the object code and Corresponding
|
||||||
|
Source of the work are being offered to the general public at no
|
||||||
|
charge under subsection 6d.
|
||||||
|
|
||||||
|
A separable portion of the object code, whose source code is excluded
|
||||||
|
from the Corresponding Source as a System Library, need not be
|
||||||
|
included in conveying the object code work.
|
||||||
|
|
||||||
|
A "User Product" is either (1) a "consumer product", which means any
|
||||||
|
tangible personal property which is normally used for personal, family,
|
||||||
|
or household purposes, or (2) anything designed or sold for incorporation
|
||||||
|
into a dwelling. In determining whether a product is a consumer product,
|
||||||
|
doubtful cases shall be resolved in favor of coverage. For a particular
|
||||||
|
product received by a particular user, "normally used" refers to a
|
||||||
|
typical or common use of that class of product, regardless of the status
|
||||||
|
of the particular user or of the way in which the particular user
|
||||||
|
actually uses, or expects or is expected to use, the product. A product
|
||||||
|
is a consumer product regardless of whether the product has substantial
|
||||||
|
commercial, industrial or non-consumer uses, unless such uses represent
|
||||||
|
the only significant mode of use of the product.
|
||||||
|
|
||||||
|
"Installation Information" for a User Product means any methods,
|
||||||
|
procedures, authorization keys, or other information required to install
|
||||||
|
and execute modified versions of a covered work in that User Product from
|
||||||
|
a modified version of its Corresponding Source. The information must
|
||||||
|
suffice to ensure that the continued functioning of the modified object
|
||||||
|
code is in no case prevented or interfered with solely because
|
||||||
|
modification has been made.
|
||||||
|
|
||||||
|
If you convey an object code work under this section in, or with, or
|
||||||
|
specifically for use in, a User Product, and the conveying occurs as
|
||||||
|
part of a transaction in which the right of possession and use of the
|
||||||
|
User Product is transferred to the recipient in perpetuity or for a
|
||||||
|
fixed term (regardless of how the transaction is characterized), the
|
||||||
|
Corresponding Source conveyed under this section must be accompanied
|
||||||
|
by the Installation Information. But this requirement does not apply
|
||||||
|
if neither you nor any third party retains the ability to install
|
||||||
|
modified object code on the User Product (for example, the work has
|
||||||
|
been installed in ROM).
|
||||||
|
|
||||||
|
The requirement to provide Installation Information does not include a
|
||||||
|
requirement to continue to provide support service, warranty, or updates
|
||||||
|
for a work that has been modified or installed by the recipient, or for
|
||||||
|
the User Product in which it has been modified or installed. Access to a
|
||||||
|
network may be denied when the modification itself materially and
|
||||||
|
adversely affects the operation of the network or violates the rules and
|
||||||
|
protocols for communication across the network.
|
||||||
|
|
||||||
|
Corresponding Source conveyed, and Installation Information provided,
|
||||||
|
in accord with this section must be in a format that is publicly
|
||||||
|
documented (and with an implementation available to the public in
|
||||||
|
source code form), and must require no special password or key for
|
||||||
|
unpacking, reading or copying.
|
||||||
|
|
||||||
|
7. Additional Terms.
|
||||||
|
|
||||||
|
"Additional permissions" are terms that supplement the terms of this
|
||||||
|
License by making exceptions from one or more of its conditions.
|
||||||
|
Additional permissions that are applicable to the entire Program shall
|
||||||
|
be treated as though they were included in this License, to the extent
|
||||||
|
that they are valid under applicable law. If additional permissions
|
||||||
|
apply only to part of the Program, that part may be used separately
|
||||||
|
under those permissions, but the entire Program remains governed by
|
||||||
|
this License without regard to the additional permissions.
|
||||||
|
|
||||||
|
When you convey a copy of a covered work, you may at your option
|
||||||
|
remove any additional permissions from that copy, or from any part of
|
||||||
|
it. (Additional permissions may be written to require their own
|
||||||
|
removal in certain cases when you modify the work.) You may place
|
||||||
|
additional permissions on material, added by you to a covered work,
|
||||||
|
for which you have or can give appropriate copyright permission.
|
||||||
|
|
||||||
|
Notwithstanding any other provision of this License, for material you
|
||||||
|
add to a covered work, you may (if authorized by the copyright holders of
|
||||||
|
that material) supplement the terms of this License with terms:
|
||||||
|
|
||||||
|
a) Disclaiming warranty or limiting liability differently from the
|
||||||
|
terms of sections 15 and 16 of this License; or
|
||||||
|
|
||||||
|
b) Requiring preservation of specified reasonable legal notices or
|
||||||
|
author attributions in that material or in the Appropriate Legal
|
||||||
|
Notices displayed by works containing it; or
|
||||||
|
|
||||||
|
c) Prohibiting misrepresentation of the origin of that material, or
|
||||||
|
requiring that modified versions of such material be marked in
|
||||||
|
reasonable ways as different from the original version; or
|
||||||
|
|
||||||
|
d) Limiting the use for publicity purposes of names of licensors or
|
||||||
|
authors of the material; or
|
||||||
|
|
||||||
|
e) Declining to grant rights under trademark law for use of some
|
||||||
|
trade names, trademarks, or service marks; or
|
||||||
|
|
||||||
|
f) Requiring indemnification of licensors and authors of that
|
||||||
|
material by anyone who conveys the material (or modified versions of
|
||||||
|
it) with contractual assumptions of liability to the recipient, for
|
||||||
|
any liability that these contractual assumptions directly impose on
|
||||||
|
those licensors and authors.
|
||||||
|
|
||||||
|
All other non-permissive additional terms are considered "further
|
||||||
|
restrictions" within the meaning of section 10. If the Program as you
|
||||||
|
received it, or any part of it, contains a notice stating that it is
|
||||||
|
governed by this License along with a term that is a further
|
||||||
|
restriction, you may remove that term. If a license document contains
|
||||||
|
a further restriction but permits relicensing or conveying under this
|
||||||
|
License, you may add to a covered work material governed by the terms
|
||||||
|
of that license document, provided that the further restriction does
|
||||||
|
not survive such relicensing or conveying.
|
||||||
|
|
||||||
|
If you add terms to a covered work in accord with this section, you
|
||||||
|
must place, in the relevant source files, a statement of the
|
||||||
|
additional terms that apply to those files, or a notice indicating
|
||||||
|
where to find the applicable terms.
|
||||||
|
|
||||||
|
Additional terms, permissive or non-permissive, may be stated in the
|
||||||
|
form of a separately written license, or stated as exceptions;
|
||||||
|
the above requirements apply either way.
|
||||||
|
|
||||||
|
8. Termination.
|
||||||
|
|
||||||
|
You may not propagate or modify a covered work except as expressly
|
||||||
|
provided under this License. Any attempt otherwise to propagate or
|
||||||
|
modify it is void, and will automatically terminate your rights under
|
||||||
|
this License (including any patent licenses granted under the third
|
||||||
|
paragraph of section 11).
|
||||||
|
|
||||||
|
However, if you cease all violation of this License, then your
|
||||||
|
license from a particular copyright holder is reinstated (a)
|
||||||
|
provisionally, unless and until the copyright holder explicitly and
|
||||||
|
finally terminates your license, and (b) permanently, if the copyright
|
||||||
|
holder fails to notify you of the violation by some reasonable means
|
||||||
|
prior to 60 days after the cessation.
|
||||||
|
|
||||||
|
Moreover, your license from a particular copyright holder is
|
||||||
|
reinstated permanently if the copyright holder notifies you of the
|
||||||
|
violation by some reasonable means, this is the first time you have
|
||||||
|
received notice of violation of this License (for any work) from that
|
||||||
|
copyright holder, and you cure the violation prior to 30 days after
|
||||||
|
your receipt of the notice.
|
||||||
|
|
||||||
|
Termination of your rights under this section does not terminate the
|
||||||
|
licenses of parties who have received copies or rights from you under
|
||||||
|
this License. If your rights have been terminated and not permanently
|
||||||
|
reinstated, you do not qualify to receive new licenses for the same
|
||||||
|
material under section 10.
|
||||||
|
|
||||||
|
9. Acceptance Not Required for Having Copies.
|
||||||
|
|
||||||
|
You are not required to accept this License in order to receive or
|
||||||
|
run a copy of the Program. Ancillary propagation of a covered work
|
||||||
|
occurring solely as a consequence of using peer-to-peer transmission
|
||||||
|
to receive a copy likewise does not require acceptance. However,
|
||||||
|
nothing other than this License grants you permission to propagate or
|
||||||
|
modify any covered work. These actions infringe copyright if you do
|
||||||
|
not accept this License. Therefore, by modifying or propagating a
|
||||||
|
covered work, you indicate your acceptance of this License to do so.
|
||||||
|
|
||||||
|
10. Automatic Licensing of Downstream Recipients.
|
||||||
|
|
||||||
|
Each time you convey a covered work, the recipient automatically
|
||||||
|
receives a license from the original licensors, to run, modify and
|
||||||
|
propagate that work, subject to this License. You are not responsible
|
||||||
|
for enforcing compliance by third parties with this License.
|
||||||
|
|
||||||
|
An "entity transaction" is a transaction transferring control of an
|
||||||
|
organization, or substantially all assets of one, or subdividing an
|
||||||
|
organization, or merging organizations. If propagation of a covered
|
||||||
|
work results from an entity transaction, each party to that
|
||||||
|
transaction who receives a copy of the work also receives whatever
|
||||||
|
licenses to the work the party's predecessor in interest had or could
|
||||||
|
give under the previous paragraph, plus a right to possession of the
|
||||||
|
Corresponding Source of the work from the predecessor in interest, if
|
||||||
|
the predecessor has it or can get it with reasonable efforts.
|
||||||
|
|
||||||
|
You may not impose any further restrictions on the exercise of the
|
||||||
|
rights granted or affirmed under this License. For example, you may
|
||||||
|
not impose a license fee, royalty, or other charge for exercise of
|
||||||
|
rights granted under this License, and you may not initiate litigation
|
||||||
|
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
||||||
|
any patent claim is infringed by making, using, selling, offering for
|
||||||
|
sale, or importing the Program or any portion of it.
|
||||||
|
|
||||||
|
11. Patents.
|
||||||
|
|
||||||
|
A "contributor" is a copyright holder who authorizes use under this
|
||||||
|
License of the Program or a work on which the Program is based. The
|
||||||
|
work thus licensed is called the contributor's "contributor version".
|
||||||
|
|
||||||
|
A contributor's "essential patent claims" are all patent claims
|
||||||
|
owned or controlled by the contributor, whether already acquired or
|
||||||
|
hereafter acquired, that would be infringed by some manner, permitted
|
||||||
|
by this License, of making, using, or selling its contributor version,
|
||||||
|
but do not include claims that would be infringed only as a
|
||||||
|
consequence of further modification of the contributor version. For
|
||||||
|
purposes of this definition, "control" includes the right to grant
|
||||||
|
patent sublicenses in a manner consistent with the requirements of
|
||||||
|
this License.
|
||||||
|
|
||||||
|
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
||||||
|
patent license under the contributor's essential patent claims, to
|
||||||
|
make, use, sell, offer for sale, import and otherwise run, modify and
|
||||||
|
propagate the contents of its contributor version.
|
||||||
|
|
||||||
|
In the following three paragraphs, a "patent license" is any express
|
||||||
|
agreement or commitment, however denominated, not to enforce a patent
|
||||||
|
(such as an express permission to practice a patent or covenant not to
|
||||||
|
sue for patent infringement). To "grant" such a patent license to a
|
||||||
|
party means to make such an agreement or commitment not to enforce a
|
||||||
|
patent against the party.
|
||||||
|
|
||||||
|
If you convey a covered work, knowingly relying on a patent license,
|
||||||
|
and the Corresponding Source of the work is not available for anyone
|
||||||
|
to copy, free of charge and under the terms of this License, through a
|
||||||
|
publicly available network server or other readily accessible means,
|
||||||
|
then you must either (1) cause the Corresponding Source to be so
|
||||||
|
available, or (2) arrange to deprive yourself of the benefit of the
|
||||||
|
patent license for this particular work, or (3) arrange, in a manner
|
||||||
|
consistent with the requirements of this License, to extend the patent
|
||||||
|
license to downstream recipients. "Knowingly relying" means you have
|
||||||
|
actual knowledge that, but for the patent license, your conveying the
|
||||||
|
covered work in a country, or your recipient's use of the covered work
|
||||||
|
in a country, would infringe one or more identifiable patents in that
|
||||||
|
country that you have reason to believe are valid.
|
||||||
|
|
||||||
|
If, pursuant to or in connection with a single transaction or
|
||||||
|
arrangement, you convey, or propagate by procuring conveyance of, a
|
||||||
|
covered work, and grant a patent license to some of the parties
|
||||||
|
receiving the covered work authorizing them to use, propagate, modify
|
||||||
|
or convey a specific copy of the covered work, then the patent license
|
||||||
|
you grant is automatically extended to all recipients of the covered
|
||||||
|
work and works based on it.
|
||||||
|
|
||||||
|
A patent license is "discriminatory" if it does not include within
|
||||||
|
the scope of its coverage, prohibits the exercise of, or is
|
||||||
|
conditioned on the non-exercise of one or more of the rights that are
|
||||||
|
specifically granted under this License. You may not convey a covered
|
||||||
|
work if you are a party to an arrangement with a third party that is
|
||||||
|
in the business of distributing software, under which you make payment
|
||||||
|
to the third party based on the extent of your activity of conveying
|
||||||
|
the work, and under which the third party grants, to any of the
|
||||||
|
parties who would receive the covered work from you, a discriminatory
|
||||||
|
patent license (a) in connection with copies of the covered work
|
||||||
|
conveyed by you (or copies made from those copies), or (b) primarily
|
||||||
|
for and in connection with specific products or compilations that
|
||||||
|
contain the covered work, unless you entered into that arrangement,
|
||||||
|
or that patent license was granted, prior to 28 March 2007.
|
||||||
|
|
||||||
|
Nothing in this License shall be construed as excluding or limiting
|
||||||
|
any implied license or other defenses to infringement that may
|
||||||
|
otherwise be available to you under applicable patent law.
|
||||||
|
|
||||||
|
12. No Surrender of Others' Freedom.
|
||||||
|
|
||||||
|
If conditions are imposed on you (whether by court order, agreement or
|
||||||
|
otherwise) that contradict the conditions of this License, they do not
|
||||||
|
excuse you from the conditions of this License. If you cannot convey a
|
||||||
|
covered work so as to satisfy simultaneously your obligations under this
|
||||||
|
License and any other pertinent obligations, then as a consequence you may
|
||||||
|
not convey it at all. For example, if you agree to terms that obligate you
|
||||||
|
to collect a royalty for further conveying from those to whom you convey
|
||||||
|
the Program, the only way you could satisfy both those terms and this
|
||||||
|
License would be to refrain entirely from conveying the Program.
|
||||||
|
|
||||||
|
13. Remote Network Interaction; Use with the GNU General Public License.
|
||||||
|
|
||||||
|
Notwithstanding any other provision of this License, if you modify the
|
||||||
|
Program, your modified version must prominently offer all users
|
||||||
|
interacting with it remotely through a computer network (if your version
|
||||||
|
supports such interaction) an opportunity to receive the Corresponding
|
||||||
|
Source of your version by providing access to the Corresponding Source
|
||||||
|
from a network server at no charge, through some standard or customary
|
||||||
|
means of facilitating copying of software. This Corresponding Source
|
||||||
|
shall include the Corresponding Source for any work covered by version 3
|
||||||
|
of the GNU General Public License that is incorporated pursuant to the
|
||||||
|
following paragraph.
|
||||||
|
|
||||||
|
Notwithstanding any other provision of this License, you have
|
||||||
|
permission to link or combine any covered work with a work licensed
|
||||||
|
under version 3 of the GNU General Public License into a single
|
||||||
|
combined work, and to convey the resulting work. The terms of this
|
||||||
|
License will continue to apply to the part which is the covered work,
|
||||||
|
but the work with which it is combined will remain governed by version
|
||||||
|
3 of the GNU General Public License.
|
||||||
|
|
||||||
|
14. Revised Versions of this License.
|
||||||
|
|
||||||
|
The Free Software Foundation may publish revised and/or new versions of
|
||||||
|
the GNU Affero General Public License from time to time. Such new versions
|
||||||
|
will be similar in spirit to the present version, but may differ in detail to
|
||||||
|
address new problems or concerns.
|
||||||
|
|
||||||
|
Each version is given a distinguishing version number. If the
|
||||||
|
Program specifies that a certain numbered version of the GNU Affero General
|
||||||
|
Public License "or any later version" applies to it, you have the
|
||||||
|
option of following the terms and conditions either of that numbered
|
||||||
|
version or of any later version published by the Free Software
|
||||||
|
Foundation. If the Program does not specify a version number of the
|
||||||
|
GNU Affero General Public License, you may choose any version ever published
|
||||||
|
by the Free Software Foundation.
|
||||||
|
|
||||||
|
If the Program specifies that a proxy can decide which future
|
||||||
|
versions of the GNU Affero General Public License can be used, that proxy's
|
||||||
|
public statement of acceptance of a version permanently authorizes you
|
||||||
|
to choose that version for the Program.
|
||||||
|
|
||||||
|
Later license versions may give you additional or different
|
||||||
|
permissions. However, no additional obligations are imposed on any
|
||||||
|
author or copyright holder as a result of your choosing to follow a
|
||||||
|
later version.
|
||||||
|
|
||||||
|
15. Disclaimer of Warranty.
|
||||||
|
|
||||||
|
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
||||||
|
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
||||||
|
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
|
||||||
|
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
||||||
|
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
||||||
|
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
||||||
|
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
||||||
|
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||||
|
|
||||||
|
16. Limitation of Liability.
|
||||||
|
|
||||||
|
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||||
|
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
||||||
|
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
|
||||||
|
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
|
||||||
|
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
|
||||||
|
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
|
||||||
|
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
||||||
|
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
||||||
|
SUCH DAMAGES.
|
||||||
|
|
||||||
|
17. Interpretation of Sections 15 and 16.
|
||||||
|
|
||||||
|
If the disclaimer of warranty and limitation of liability provided
|
||||||
|
above cannot be given local legal effect according to their terms,
|
||||||
|
reviewing courts shall apply local law that most closely approximates
|
||||||
|
an absolute waiver of all civil liability in connection with the
|
||||||
|
Program, unless a warranty or assumption of liability accompanies a
|
||||||
|
copy of the Program in return for a fee.
|
||||||
|
|
||||||
|
END OF TERMS AND CONDITIONS
|
||||||
|
|
||||||
|
How to Apply These Terms to Your New Programs
|
||||||
|
|
||||||
|
If you develop a new program, and you want it to be of the greatest
|
||||||
|
possible use to the public, the best way to achieve this is to make it
|
||||||
|
free software which everyone can redistribute and change under these terms.
|
||||||
|
|
||||||
|
To do so, attach the following notices to the program. It is safest
|
||||||
|
to attach them to the start of each source file to most effectively
|
||||||
|
state the exclusion of warranty; and each file should have at least
|
||||||
|
the "copyright" line and a pointer to where the full notice is found.
|
||||||
|
|
||||||
|
<one line to give the program's name and a brief idea of what it does.>
|
||||||
|
Copyright (C) <year> <name of author>
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU Affero General Public License as published by
|
||||||
|
the Free Software Foundation, either version 3 of the License, or
|
||||||
|
(at your option) any later version.
|
||||||
|
|
||||||
|
This program is distributed in the hope that it will be useful,
|
||||||
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
GNU Affero General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU Affero General Public License
|
||||||
|
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
Also add information on how to contact you by electronic and paper mail.
|
||||||
|
|
||||||
|
If your software can interact with users remotely through a computer
|
||||||
|
network, you should also make sure that it provides a way for users to
|
||||||
|
get its source. For example, if your program is a web application, its
|
||||||
|
interface could display a "Source" link that leads users to an archive
|
||||||
|
of the code. There are many ways you could offer source, and different
|
||||||
|
solutions will be better for different programs; see section 13 for the
|
||||||
|
specific requirements.
|
||||||
|
|
||||||
|
You should also get your employer (if you work as a programmer) or school,
|
||||||
|
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||||
|
For more information on this, and how to apply and follow the GNU AGPL, see
|
||||||
|
<https://www.gnu.org/licenses/>.
|
||||||
@@ -35,6 +35,48 @@ 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. Two targets exist:
|
||||||
|
- the portable `any` target, for pure-JavaScript modules;
|
||||||
|
- `linux-x64-glibc`, for modules with a native dependency such as `sharp`. Its native runtime
|
||||||
|
packages are carried as real files under the package's `node_modules`, and the verifier checks the
|
||||||
|
host before anything is loaded.
|
||||||
|
|
||||||
|
Every package records the module's validated SPDX expression and carries its explicitly
|
||||||
|
supplied `LICENSE` bytes; dependency material remains separate in `THIRD_PARTY_NOTICES`. ABI-v1
|
||||||
|
packages may bundle a module-owned Drizzle version without coupling it to Core;
|
||||||
|
legacy-v8 packages retain their transitional exact-version check. Where a redistributed dependency
|
||||||
|
omits licence material, the packager may add reviewed licence supplements
|
||||||
|
([ADR 0052](docs/architecture/decisions/0052-reviewed-license-supplements.md)). A native package that
|
||||||
|
redistributes LGPL or MPL code is distributed publicly only with its corresponding-source artifact,
|
||||||
|
which `ekklesia-corresponding-source` builds and gates
|
||||||
|
([ADR 0055](docs/architecture/decisions/0055-corresponding-source-artifacts.md)). Purge, other
|
||||||
|
operating-system or CPU targets, signing, and any installer or marketplace do not exist yet.
|
||||||
|
|
||||||
|
A module can be built in its own repository from Ekklesia's public module-development packages
|
||||||
|
([ADR 0053](docs/architecture/decisions/0053-standalone-module-development-packages.md)):
|
||||||
|
a curated `@ekklesia/module-sdk`, `@ekklesia/module-database`, the UI packages, and the build and
|
||||||
|
packaging tools. They are released as one coordinated train of tarballs with a release manifest,
|
||||||
|
and CI proves on every run that two builds of a commit are byte-identical and that a module outside
|
||||||
|
the repository installs, builds, packages, and verifies from those artifacts alone. A manual
|
||||||
|
workflow, the only way to publish, publishes exactly those artifacts to the npm registry and
|
||||||
|
verifies every package byte for byte. Anyone may read and install the packages before launch
|
||||||
|
([ADR 0054](docs/architecture/decisions/0054-public-package-reads-before-launch.md)). The first train
|
||||||
|
published will be the next platform release
|
||||||
|
([Releases and the public package train](docs/development/releases.md)).
|
||||||
|
|
||||||
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
|
||||||
@@ -490,8 +532,8 @@ reported `blocked · configuration_missing · bucket` — never imported, never
|
|||||||
from inside its own `start` hook — and its required dependents block behind it through ADR 0028's
|
from inside its own `start` hook — and its required dependents block behind it through ADR 0028's
|
||||||
existing mechanism. Doctor and `pnpm modules:validate` report the same thing while still importing no
|
existing mechanism. Doctor and `pnpm modules:validate` report the same thing while still importing no
|
||||||
module code. That change introduced `manifestVersion: 5` as a hard cutover; the current strict
|
module code. That change introduced `manifestVersion: 5` as a hard cutover; the current strict
|
||||||
manifest is version 7 after ADR 0033 added the optional display declaration and ADR 0041 added
|
manifest is version 8 after ADR 0050 added the optional database ABI declaration. Version 8 is a
|
||||||
browser contributions.
|
hard cutover; a version 7 source module or package must be updated and rebuilt.
|
||||||
|
|
||||||
**Ekklesia does not store secrets, and does not pretend to.** There is no encryption at rest here and
|
**Ekklesia does not store secrets, and does not pretend to.** There is no encryption at rest here and
|
||||||
no key management, so a `secret` key's value is supplied by the deployment
|
no key management, so a `secret` key's value is supplied by the deployment
|
||||||
@@ -825,7 +867,7 @@ is versioned with no dual acceptance; migrating is one `pnpm build`.
|
|||||||
|
|
||||||
Phase 8 pass 3 lets one module contribute a browser renderer to a capability another module provides
|
Phase 8 pass 3 lets one module contribute a browser renderer to a capability another module provides
|
||||||
([ADR 0041](docs/architecture/decisions/0041-module-browser-contributions.md)) — the browser half of
|
([ADR 0041](docs/architecture/decisions/0041-module-browser-contributions.md)) — the browser half of
|
||||||
the capability system Core has had since Phase 1. `manifestVersion: 7` declares a namespaced id, its
|
the capability system Core has had since Phase 1. `manifestVersion: 8` declares a namespaced id, its
|
||||||
own version, a target capability, a compatible range, and one artifact, and nothing else: there is no
|
own version, a target capability, a compatible range, and one artifact, and nothing else: there is no
|
||||||
payload schema, slot, ordering, or rendering hint anywhere, so Core cannot learn what any of it
|
payload schema, slot, ordering, or rendering hint anywhere, so Core cannot learn what any of it
|
||||||
means. Publication requires that another eligible module provides the capability inside the declared
|
means. Publication requires that another eligible module provides the capability inside the declared
|
||||||
@@ -1056,5 +1098,27 @@ environment.
|
|||||||
- [Module development](docs/module-development/README.md)
|
- [Module development](docs/module-development/README.md)
|
||||||
- [Architecture decisions](docs/architecture/decisions/README.md)
|
- [Architecture decisions](docs/architecture/decisions/README.md)
|
||||||
|
|
||||||
No open-source license has been selected yet. A license should be added only after the project
|
## License
|
||||||
owner makes that decision.
|
|
||||||
|
Ekklesia is licensed under the **GNU Affero General Public License, version 3 or any later version**
|
||||||
|
(SPDX `AGPL-3.0-or-later`). The full text of version 3 is in [LICENSE](LICENSE). Its source is
|
||||||
|
available to you under the terms of that license.
|
||||||
|
|
||||||
|
> Ekklesia is free software: you can redistribute it and/or modify it under the terms of the GNU
|
||||||
|
> Affero General Public License as published by the Free Software Foundation, either version 3 of
|
||||||
|
> the License, or (at your option) any later version.
|
||||||
|
>
|
||||||
|
> Ekklesia is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
|
||||||
|
> the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero
|
||||||
|
> General Public License for more details.
|
||||||
|
>
|
||||||
|
> You should have received a copy of the GNU Affero General Public License along with Ekklesia. If
|
||||||
|
> not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
Section 13 of the license adds an obligation for network use: if you modify Ekklesia and run your
|
||||||
|
modified version for users who interact with it remotely over a network, your version must offer
|
||||||
|
those users the opportunity to receive its Corresponding Source.
|
||||||
|
|
||||||
|
Official Ekklesia modules, published by the Ekklesia project, are licensed under `AGPL-3.0-or-later`
|
||||||
|
as well. Each module's own repository declares its license. The module template declares it in its
|
||||||
|
`package.json`, so a module started from the template begins with the same license.
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "@ekklesia/api",
|
"name": "@ekklesia/api",
|
||||||
"version": "0.1.2",
|
"version": "0.2.4",
|
||||||
|
"license": "AGPL-3.0-or-later",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"files": [
|
"files": [
|
||||||
|
|||||||
+6
-6
@@ -1,6 +1,5 @@
|
|||||||
import {
|
import {
|
||||||
isAuthenticatedPrincipal,
|
isAuthenticatedPrincipal,
|
||||||
type ModuleRuntimeServices,
|
|
||||||
type ModuleSecretSource,
|
type ModuleSecretSource,
|
||||||
type RegisteredModuleHttpRoute,
|
type RegisteredModuleHttpRoute,
|
||||||
} from "@ekklesia/contracts";
|
} from "@ekklesia/contracts";
|
||||||
@@ -13,6 +12,7 @@ import type { DashboardStore } from "@ekklesia/core/dashboard";
|
|||||||
import type { DeviceStore } from "@ekklesia/core/devices";
|
import type { DeviceStore } from "@ekklesia/core/devices";
|
||||||
import { type EkklesiaIdentityRuntime, IdentityRuntimeError } from "@ekklesia/core/identity";
|
import { type EkklesiaIdentityRuntime, IdentityRuntimeError } from "@ekklesia/core/identity";
|
||||||
import type {
|
import type {
|
||||||
|
AnyModuleRuntimeServices,
|
||||||
DiscoveredModule,
|
DiscoveredModule,
|
||||||
ModuleConfigurationLookup,
|
ModuleConfigurationLookup,
|
||||||
ModuleConfigurationSettings,
|
ModuleConfigurationSettings,
|
||||||
@@ -20,6 +20,7 @@ import type {
|
|||||||
ModuleLifecycleSnapshot,
|
ModuleLifecycleSnapshot,
|
||||||
ModuleLifecycleStore,
|
ModuleLifecycleStore,
|
||||||
ModuleRealtimeRegistry,
|
ModuleRealtimeRegistry,
|
||||||
|
ModuleRuntimeServicesLookup,
|
||||||
ModuleStorageLookup,
|
ModuleStorageLookup,
|
||||||
ModuleUiPublicationRegistry,
|
ModuleUiPublicationRegistry,
|
||||||
} from "@ekklesia/core/modules";
|
} from "@ekklesia/core/modules";
|
||||||
@@ -62,11 +63,10 @@ export interface CreateApiAppOptions {
|
|||||||
/** The started runtime's immutable module-owned realtime registry. Required explicitly even when
|
/** The started runtime's immutable module-owned realtime registry. Required explicitly even when
|
||||||
* empty, for the same reason as module routes: application composition never guesses what ran. */
|
* empty, for the same reason as module routes: application composition never guesses what ran. */
|
||||||
readonly moduleRealtime: ModuleRealtimeRegistry;
|
readonly moduleRealtime: ModuleRealtimeRegistry;
|
||||||
/** The same frozen services object the caller's started module runtime already passed to every
|
/** The same per-module service lookup the caller's started module runtime used for every
|
||||||
* module's `start`/`stop` hooks (`apps/api/src/server.ts`) — reference-identical, never a fresh
|
* module's `start`/`stop` hooks (`apps/api/src/server.ts`) — each handler receives its module's
|
||||||
* object built here, so a module's HTTP handlers and lifecycle hooks share exactly one
|
* reference-identical service object, never a fresh facade or executor built per request. */
|
||||||
* `ModuleDatabase` for the lifetime of this API runtime. */
|
readonly moduleServices: ModuleRuntimeServicesLookup | AnyModuleRuntimeServices;
|
||||||
readonly moduleServices: ModuleRuntimeServices;
|
|
||||||
/** The platform's module UI publication policy — always the one Core builds from the exact
|
/** The platform's module UI publication policy — always the one Core builds from the exact
|
||||||
* discovered inventory this runtime started (`createModuleUiPublicationRegistry`,
|
* discovered inventory this runtime started (`createModuleUiPublicationRegistry`,
|
||||||
* `apps/api/src/server.ts`). Required rather than defaulted, for the same reason `moduleRoutes`
|
* `apps/api/src/server.ts`). Required rather than defaulted, for the same reason `moduleRoutes`
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { ModuleDatabase } from "@ekklesia/contracts";
|
import type { ModuleDatabaseExecutor, ModuleId } from "@ekklesia/contracts";
|
||||||
import {
|
import {
|
||||||
type AuthorizationDrizzleDatabase,
|
type AuthorizationDrizzleDatabase,
|
||||||
createAuthorizationDrizzleView,
|
createAuthorizationDrizzleView,
|
||||||
@@ -12,9 +12,11 @@ import { createDeviceDrizzleView, type DeviceDrizzleDatabase } from "@ekklesia/c
|
|||||||
import { createIdentityDrizzleView, type IdentityDrizzleDatabase } from "@ekklesia/core/identity";
|
import { createIdentityDrizzleView, type IdentityDrizzleDatabase } from "@ekklesia/core/identity";
|
||||||
import {
|
import {
|
||||||
createModuleConfigurationView,
|
createModuleConfigurationView,
|
||||||
|
createModuleDatabaseExecutor,
|
||||||
createModuleDatabaseView,
|
createModuleDatabaseView,
|
||||||
createModuleLifecycleView,
|
createModuleLifecycleView,
|
||||||
type ModuleConfigurationDrizzleDatabase,
|
type ModuleConfigurationDrizzleDatabase,
|
||||||
|
type ModuleDatabase,
|
||||||
type ModuleLifecycleDrizzleDatabase,
|
type ModuleLifecycleDrizzleDatabase,
|
||||||
} from "@ekklesia/core/modules";
|
} from "@ekklesia/core/modules";
|
||||||
|
|
||||||
@@ -24,6 +26,8 @@ export interface ApiDatabaseHandle {
|
|||||||
readonly dashboard: DashboardDrizzleDatabase;
|
readonly dashboard: DashboardDrizzleDatabase;
|
||||||
readonly identity: IdentityDrizzleDatabase;
|
readonly identity: IdentityDrizzleDatabase;
|
||||||
readonly module: ModuleDatabase;
|
readonly module: ModuleDatabase;
|
||||||
|
/** Builds one ABI-v1 executor attributed to exactly one module, over this handle's shared pool. */
|
||||||
|
readonly moduleExecutor: (moduleId: ModuleId) => ModuleDatabaseExecutor;
|
||||||
readonly moduleConfiguration: ModuleConfigurationDrizzleDatabase;
|
readonly moduleConfiguration: ModuleConfigurationDrizzleDatabase;
|
||||||
readonly moduleLifecycle: ModuleLifecycleDrizzleDatabase;
|
readonly moduleLifecycle: ModuleLifecycleDrizzleDatabase;
|
||||||
/**
|
/**
|
||||||
@@ -76,6 +80,7 @@ export function createApiDatabase(
|
|||||||
dashboard: createDashboardDrizzleView(pool),
|
dashboard: createDashboardDrizzleView(pool),
|
||||||
identity: createIdentityDrizzleView(pool),
|
identity: createIdentityDrizzleView(pool),
|
||||||
module: createModuleDatabaseView(pool),
|
module: createModuleDatabaseView(pool),
|
||||||
|
moduleExecutor: (moduleId: ModuleId) => createModuleDatabaseExecutor(pool, { moduleId }),
|
||||||
moduleConfiguration: createModuleConfigurationView(pool),
|
moduleConfiguration: createModuleConfigurationView(pool),
|
||||||
moduleLifecycle: createModuleLifecycleView(pool),
|
moduleLifecycle: createModuleLifecycleView(pool),
|
||||||
ping: async (statementTimeoutMs: number) => {
|
ping: async (statementTimeoutMs: number) => {
|
||||||
|
|||||||
@@ -1,14 +1,17 @@
|
|||||||
import {
|
import {
|
||||||
EMPTY_MODULE_CONFIGURATION,
|
EMPTY_MODULE_CONFIGURATION,
|
||||||
EMPTY_MODULE_STORAGE,
|
EMPTY_MODULE_STORAGE,
|
||||||
|
type ModuleDatabaseExecutor,
|
||||||
type ModuleId,
|
type ModuleId,
|
||||||
type ModuleRouteHandlerContext,
|
type ModuleRouteHandlerContext,
|
||||||
type ModuleRuntimeServices,
|
|
||||||
type RegisteredModuleHttpRoute,
|
type RegisteredModuleHttpRoute,
|
||||||
} from "@ekklesia/contracts";
|
} from "@ekklesia/contracts";
|
||||||
import type {
|
import type {
|
||||||
|
AnyModuleRuntimeServices,
|
||||||
ModuleConfigurationLookup,
|
ModuleConfigurationLookup,
|
||||||
|
ModuleDatabase,
|
||||||
ModuleRealtimeRegistry,
|
ModuleRealtimeRegistry,
|
||||||
|
ModuleRuntimeServicesLookup,
|
||||||
ModuleStorageLookup,
|
ModuleStorageLookup,
|
||||||
} from "@ekklesia/core/modules";
|
} from "@ekklesia/core/modules";
|
||||||
import type { Context, Hono } from "hono";
|
import type { Context, Hono } from "hono";
|
||||||
@@ -37,14 +40,14 @@ export interface ModuleHttpRoutesOptions extends AuthorizationMiddlewareOptions
|
|||||||
* received for this API runtime — never a fresh one built per request, and never touched for a
|
* received for this API runtime — never a fresh one built per request, and never touched for a
|
||||||
* denied, trusted-origin-rejected, or `OPTIONS` request, since a handler is only ever invoked
|
* denied, trusted-origin-rejected, or `OPTIONS` request, since a handler is only ever invoked
|
||||||
* after authorization succeeds. */
|
* after authorization succeeds. */
|
||||||
readonly services: ModuleRuntimeServices;
|
readonly services: ModuleRuntimeServicesLookup | AnyModuleRuntimeServices;
|
||||||
/**
|
/**
|
||||||
* Per-module frozen configuration snapshots — the exact map the module runtime started from
|
* Per-module frozen configuration snapshots — the exact map the module runtime started from
|
||||||
* (ADR 0029), so a handler and that module's `start`/`stop` hooks receive the identical object
|
* (ADR 0029), so a handler and that module's `start`/`stop` hooks receive the identical object
|
||||||
* rather than two independently resolved values. A module with no entry receives
|
* rather than two independently resolved values. A module with no entry receives
|
||||||
* `EMPTY_MODULE_CONFIGURATION`, so `context.config` is always present.
|
* `EMPTY_MODULE_CONFIGURATION`, so `context.config` is always present.
|
||||||
*
|
*
|
||||||
* Unlike `services`, which is one shared object, this is keyed by module id: a handler can only
|
* Like `services`, this is keyed by module id: a handler can only
|
||||||
* ever reach its own module's configuration, because that is the only entry looked up for it.
|
* ever reach its own module's configuration, because that is the only entry looked up for it.
|
||||||
*/
|
*/
|
||||||
readonly configurations: ModuleConfigurationLookup;
|
readonly configurations: ModuleConfigurationLookup;
|
||||||
@@ -232,6 +235,11 @@ export function mountModuleHttpRoutes(
|
|||||||
if (!options.registry.has(route.permission)) {
|
if (!options.registry.has(route.permission)) {
|
||||||
throw new ModuleRouteConfigurationError();
|
throw new ModuleRouteConfigurationError();
|
||||||
}
|
}
|
||||||
|
const services =
|
||||||
|
"get" in options.services ? options.services.get(route.moduleId) : options.services;
|
||||||
|
if (!services) {
|
||||||
|
throw new ModuleRouteConfigurationError();
|
||||||
|
}
|
||||||
|
|
||||||
const requireRoutePermission = requirePermission(middlewareOptions, route.permission);
|
const requireRoutePermission = requirePermission(middlewareOptions, route.permission);
|
||||||
// The route's own conscious body contract (`route.maxBodyBytes` — 0 means no body accepted at
|
// The route's own conscious body contract (`route.maxBodyBytes` — 0 means no body accepted at
|
||||||
@@ -243,19 +251,24 @@ export function mountModuleHttpRoutes(
|
|||||||
const routeBodyLimit = platformBodyLimit(route.maxBodyBytes, route.bodyMode);
|
const routeBodyLimit = platformBodyLimit(route.maxBodyBytes, route.bodyMode);
|
||||||
const path = moduleRoutePath(route.moduleId, route.path);
|
const path = moduleRoutePath(route.moduleId, route.path);
|
||||||
const finalHandler = async (c: Context): Promise<Response> => {
|
const finalHandler = async (c: Context): Promise<Response> => {
|
||||||
const context: ModuleRouteHandlerContext = Object.freeze({
|
const context = Object.freeze({
|
||||||
config: options.configurations.get(route.moduleId) ?? EMPTY_MODULE_CONFIGURATION,
|
config: options.configurations.get(route.moduleId) ?? EMPTY_MODULE_CONFIGURATION,
|
||||||
moduleId: route.moduleId,
|
moduleId: route.moduleId,
|
||||||
storage: options.storages.get(route.moduleId) ?? EMPTY_MODULE_STORAGE,
|
storage: options.storages.get(route.moduleId) ?? EMPTY_MODULE_STORAGE,
|
||||||
params: Object.freeze({ ...c.req.param() }),
|
params: Object.freeze({ ...c.req.param() }),
|
||||||
principal: getAuthorizedPrincipal(c),
|
principal: getAuthorizedPrincipal(c),
|
||||||
realtime: options.realtime.publisherFor(route.moduleId),
|
realtime: options.realtime.publisherFor(route.moduleId),
|
||||||
services: options.services,
|
services,
|
||||||
});
|
}) as ModuleRouteHandlerContext<ModuleDatabase | ModuleDatabaseExecutor>;
|
||||||
|
|
||||||
let response: Response;
|
let response: Response;
|
||||||
try {
|
try {
|
||||||
response = await route.handler(c.req.raw, context);
|
response = await (
|
||||||
|
route.handler as (
|
||||||
|
request: Request,
|
||||||
|
context: ModuleRouteHandlerContext<ModuleDatabase | ModuleDatabaseExecutor>,
|
||||||
|
) => PromiseLike<Response> | Response
|
||||||
|
)(c.req.raw, context);
|
||||||
} catch {
|
} catch {
|
||||||
return sanitizedModuleHandlerFailure(c);
|
return sanitizedModuleHandlerFailure(c);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
+31
-17
@@ -1,11 +1,7 @@
|
|||||||
import type { Socket } from "node:net";
|
import type { Socket } from "node:net";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { pathToFileURL } from "node:url";
|
import { pathToFileURL } from "node:url";
|
||||||
import {
|
import { EMPTY_MODULE_SECRET_SOURCE, type ModuleSecretSource } from "@ekklesia/contracts";
|
||||||
EMPTY_MODULE_SECRET_SOURCE,
|
|
||||||
type ModuleRuntimeServices,
|
|
||||||
type ModuleSecretSource,
|
|
||||||
} from "@ekklesia/contracts";
|
|
||||||
import {
|
import {
|
||||||
createAuthorizationEvaluator,
|
createAuthorizationEvaluator,
|
||||||
createAuthorizationStore,
|
createAuthorizationStore,
|
||||||
@@ -21,12 +17,14 @@ import {
|
|||||||
} from "@ekklesia/core/identity";
|
} from "@ekklesia/core/identity";
|
||||||
import { createSmtpMailProvider, type MailProvider } from "@ekklesia/core/mail";
|
import { createSmtpMailProvider, type MailProvider } from "@ekklesia/core/mail";
|
||||||
import {
|
import {
|
||||||
|
type AnyModuleRuntimeServices,
|
||||||
createModuleConfigurationStore,
|
createModuleConfigurationStore,
|
||||||
createModuleLifecycleStore,
|
createModuleLifecycleStore,
|
||||||
createModuleUiPublicationRegistry,
|
createModuleUiPublicationRegistry,
|
||||||
type DiscoveredModule,
|
type DiscoveredModule,
|
||||||
type ModuleConfigurationSettings,
|
type ModuleConfigurationSettings,
|
||||||
type ModuleEligibilityResolution,
|
type ModuleEligibilityResolution,
|
||||||
|
type ModuleRuntimeServicesLookup,
|
||||||
resolveModuleEligibility,
|
resolveModuleEligibility,
|
||||||
type StartedModuleRuntime,
|
type StartedModuleRuntime,
|
||||||
startDiscoveredModuleRuntime,
|
startDiscoveredModuleRuntime,
|
||||||
@@ -154,8 +152,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
|
||||||
@@ -389,16 +391,13 @@ function bindServer(
|
|||||||
* (`prepareStartupModuleInventory`, `./platform-permissions.js`).
|
* (`prepareStartupModuleInventory`, `./platform-permissions.js`).
|
||||||
* 2. Construct the one shared PostgreSQL pool (identity, authorization, and the module database
|
* 2. Construct the one shared PostgreSQL pool (identity, authorization, and the module database
|
||||||
* facade each get a typed Drizzle view over it — see `./database.js`).
|
* facade each get a typed Drizzle view over it — see `./database.js`).
|
||||||
* 3. Construct the frozen `ModuleRuntimeServices` (currently just `database`) from that same pool,
|
* 3. Construct the Core identity runtime, authorization store, and evaluator from that pool.
|
||||||
* then the Core identity runtime, authorization store, and evaluator.
|
|
||||||
* 4. Read the stored module enablement configuration and resolve, from that exact same discovered
|
* 4. Read the stored module enablement configuration and resolve, from that exact same discovered
|
||||||
* inventory, which modules are eligible to run (`resolveModuleEligibility`).
|
* inventory, which modules are eligible to run (`resolveModuleEligibility`).
|
||||||
* 5. Start the module runtime from the eligible subset (`startDiscoveredModuleRuntime`) — never
|
* 5. Start the module runtime from the eligible subset (`startDiscoveredModuleRuntime`) — never
|
||||||
* rediscovering modules a second time — passing it the same services object every module's
|
* rediscovering modules a second time — passing the manifest-selected service map.
|
||||||
* `start`/`stop` hook receives.
|
|
||||||
* 6. Create the Hono application, mounting the started module runtime's published routes, publishing
|
* 6. Create the Hono application, mounting the started module runtime's published routes, publishing
|
||||||
* UI for that same eligible set, and passing the exact same services object to every module HTTP
|
* UI for that same eligible set, and passing the same per-module service map to HTTP handlers.
|
||||||
* handler.
|
|
||||||
* 7. Bind the Node HTTP server.
|
* 7. Bind the Node HTTP server.
|
||||||
*
|
*
|
||||||
* Discovery and permission composition happen before any pool, Better Auth instance, module
|
* Discovery and permission composition happen before any pool, Better Auth instance, module
|
||||||
@@ -429,6 +428,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 }),
|
||||||
@@ -454,12 +454,10 @@ export async function startApi(
|
|||||||
const storageProvider = config.storage ? createS3StorageProvider(config.storage) : undefined;
|
const storageProvider = config.storage ? createS3StorageProvider(config.storage) : undefined;
|
||||||
const mailProvider = config.mail ? createSmtpMailProvider(config.mail) : undefined;
|
const mailProvider = config.mail ? createSmtpMailProvider(config.mail) : undefined;
|
||||||
|
|
||||||
let services: ModuleRuntimeServices;
|
|
||||||
let runtime: ReturnType<typeof createIdentityRuntime>;
|
let runtime: ReturnType<typeof createIdentityRuntime>;
|
||||||
let authorizationStore: ReturnType<typeof createAuthorizationStore>;
|
let authorizationStore: ReturnType<typeof createAuthorizationStore>;
|
||||||
let deviceStore: ReturnType<typeof createDeviceStore>;
|
let deviceStore: ReturnType<typeof createDeviceStore>;
|
||||||
try {
|
try {
|
||||||
services = Object.freeze({ database: databaseHandle.module });
|
|
||||||
runtime = createIdentityRuntime({
|
runtime = createIdentityRuntime({
|
||||||
config: config.identity,
|
config: config.identity,
|
||||||
db: databaseHandle.identity,
|
db: databaseHandle.identity,
|
||||||
@@ -527,6 +525,15 @@ export async function startApi(
|
|||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const services: ModuleRuntimeServicesLookup = new Map<string, AnyModuleRuntimeServices>(
|
||||||
|
eligibility.eligibleModules.map((module) => [
|
||||||
|
module.manifest.id,
|
||||||
|
module.manifest.database?.abi === 1
|
||||||
|
? Object.freeze({ database: databaseHandle.moduleExecutor(module.manifest.id) })
|
||||||
|
: Object.freeze({ database: databaseHandle.module }),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
|
||||||
// One scoped facade per eligible module, over the exact buckets that module's own manifest
|
// One scoped facade per eligible module, over the exact buckets that module's own manifest
|
||||||
// declares. Built from the eligible set so a blocked module never receives storage at all.
|
// declares. Built from the eligible set so a blocked module never receives storage at all.
|
||||||
const moduleStorages = createModuleStorageLookup(
|
const moduleStorages = createModuleStorageLookup(
|
||||||
@@ -769,6 +776,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 +792,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;
|
||||||
|
|||||||
@@ -221,9 +221,9 @@ async function applyCoreMigrations(url: string): Promise<void> {
|
|||||||
const aggregate = await discoverAllMigrations({
|
const aggregate = await discoverAllMigrations({
|
||||||
coreMigrationsDirectory,
|
coreMigrationsDirectory,
|
||||||
moduleDiscovery: {
|
moduleDiscovery: {
|
||||||
moduleSdkVersion: "0.1.0",
|
moduleSdkVersion: "0.2.0",
|
||||||
modulesDirectory: emptyModulesDirectory,
|
modulesDirectory: emptyModulesDirectory,
|
||||||
platformVersion: "0.1.0",
|
platformVersion: "0.2.0",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
// Phase 4A's and Phase 4C pass 1's committed migrations must not have drifted while building
|
// Phase 4A's and Phase 4C pass 1's committed migrations must not have drifted while building
|
||||||
@@ -339,10 +339,10 @@ async function createGatedRouteModule(
|
|||||||
writeFile(
|
writeFile(
|
||||||
path.join(moduleDirectory, "ekklesia.module.json"),
|
path.join(moduleDirectory, "ekklesia.module.json"),
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
compatibility: { moduleSdk: "^0.1.0", platform: "^0.1.0" },
|
compatibility: { moduleSdk: "^0.2.0", platform: "^0.2.0" },
|
||||||
entrypoints: { server: "./dist/server/index.js" },
|
entrypoints: { server: "./dist/server/index.js" },
|
||||||
id,
|
id,
|
||||||
manifestVersion: 7,
|
manifestVersion: 8,
|
||||||
name: `${id} module`,
|
name: `${id} module`,
|
||||||
permissions: [{ baseline: "public", description: "Gated drain probe.", id: permissionId }],
|
permissions: [{ baseline: "public", description: "Gated drain probe.", id: permissionId }],
|
||||||
version: "0.1.0",
|
version: "0.1.0",
|
||||||
@@ -386,11 +386,11 @@ async function createFixtureModule(
|
|||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const moduleDirectory = path.join(modulesDirectory, id);
|
const moduleDirectory = path.join(modulesDirectory, id);
|
||||||
const manifest = {
|
const manifest = {
|
||||||
manifestVersion: 7,
|
manifestVersion: 8,
|
||||||
id,
|
id,
|
||||||
name: `${id} module`,
|
name: `${id} module`,
|
||||||
version: "0.1.0",
|
version: "0.1.0",
|
||||||
compatibility: { platform: "^0.1.0", moduleSdk: "^0.1.0" },
|
compatibility: { platform: "^0.2.0", moduleSdk: "^0.2.0" },
|
||||||
permissions: [],
|
permissions: [],
|
||||||
entrypoints: { server: "./dist/server/index.js" },
|
entrypoints: { server: "./dist/server/index.js" },
|
||||||
...manifestOverrides,
|
...manifestOverrides,
|
||||||
@@ -521,10 +521,10 @@ async function createUiModule(
|
|||||||
writeFile(
|
writeFile(
|
||||||
path.join(moduleDirectory, "ekklesia.module.json"),
|
path.join(moduleDirectory, "ekklesia.module.json"),
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
compatibility: { moduleSdk: "^0.1.0", platform: "^0.1.0" },
|
compatibility: { moduleSdk: "^0.2.0", platform: "^0.2.0" },
|
||||||
entrypoints: { server: "./dist/server/index.js", ui: "./dist/ui/index.js" },
|
entrypoints: { server: "./dist/server/index.js", ui: "./dist/ui/index.js" },
|
||||||
id,
|
id,
|
||||||
manifestVersion: 7,
|
manifestVersion: 8,
|
||||||
name: `${id} module`,
|
name: `${id} module`,
|
||||||
version,
|
version,
|
||||||
}),
|
}),
|
||||||
@@ -2590,19 +2590,18 @@ 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.2.0",
|
||||||
modulesDirectory,
|
modulesDirectory,
|
||||||
platformVersion: "0.1.0",
|
platformVersion: "0.2.0",
|
||||||
},
|
},
|
||||||
runtimeRange: ">=0.0.0",
|
runtimeRange: ">=0.0.0",
|
||||||
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" });
|
||||||
@@ -3907,9 +3906,9 @@ describe.sequential("live API — module lifecycle governs what actually runs",
|
|||||||
const aggregate = await discoverAllMigrations({
|
const aggregate = await discoverAllMigrations({
|
||||||
coreMigrationsDirectory,
|
coreMigrationsDirectory,
|
||||||
moduleDiscovery: {
|
moduleDiscovery: {
|
||||||
moduleSdkVersion: "0.1.0",
|
moduleSdkVersion: "0.2.0",
|
||||||
modulesDirectory,
|
modulesDirectory,
|
||||||
platformVersion: "0.1.0",
|
platformVersion: "0.2.0",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
expect(aggregate.discoveredModules.map((module) => module.manifest.id)).toEqual(["calendar"]);
|
expect(aggregate.discoveredModules.map((module) => module.manifest.id)).toEqual(["calendar"]);
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
import {
|
import {
|
||||||
ANONYMOUS_PRINCIPAL,
|
ANONYMOUS_PRINCIPAL,
|
||||||
createAuthenticatedPrincipal,
|
createAuthenticatedPrincipal,
|
||||||
type ModuleDatabase,
|
|
||||||
type ModuleRuntimeServices,
|
|
||||||
moduleIdSchema,
|
moduleIdSchema,
|
||||||
parseEkklesiaUserId,
|
parseEkklesiaUserId,
|
||||||
parsePermissionId,
|
parsePermissionId,
|
||||||
@@ -20,6 +18,8 @@ import { IdentityRuntimeError } from "@ekklesia/core/identity";
|
|||||||
import {
|
import {
|
||||||
createModuleUiPublicationRegistry,
|
createModuleUiPublicationRegistry,
|
||||||
EMPTY_MODULE_REALTIME_REGISTRY,
|
EMPTY_MODULE_REALTIME_REGISTRY,
|
||||||
|
type LegacyModuleRuntimeServices,
|
||||||
|
type ModuleDatabase,
|
||||||
type ModuleUiPublicationRegistry,
|
type ModuleUiPublicationRegistry,
|
||||||
StagedModuleRealtimeRegistry,
|
StagedModuleRealtimeRegistry,
|
||||||
} from "@ekklesia/core/modules";
|
} from "@ekklesia/core/modules";
|
||||||
@@ -48,9 +48,9 @@ function fakeRuntime(overrides: Partial<EkklesiaIdentityRuntime> = {}): Ekklesia
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A small typed fake `ModuleRuntimeServices` — these tests exercise `createApiApp`'s own wiring,
|
/** A small typed fake `LegacyModuleRuntimeServices` — these tests exercise `createApiApp`'s own wiring,
|
||||||
* never real database behavior, so no PostgreSQL is required. */
|
* never real database behavior, so no PostgreSQL is required. */
|
||||||
function fakeModuleServices(): ModuleRuntimeServices {
|
function fakeModuleServices(): LegacyModuleRuntimeServices {
|
||||||
const database = Object.freeze({
|
const database = Object.freeze({
|
||||||
delete: vi.fn(),
|
delete: vi.fn(),
|
||||||
execute: vi.fn(),
|
execute: vi.fn(),
|
||||||
|
|||||||
@@ -1,9 +1,7 @@
|
|||||||
import { createHash } from "node:crypto";
|
import { createHash } from "node:crypto";
|
||||||
import {
|
import {
|
||||||
ANONYMOUS_PRINCIPAL,
|
ANONYMOUS_PRINCIPAL,
|
||||||
type ModuleDatabase,
|
|
||||||
type ModuleRouteHandler,
|
type ModuleRouteHandler,
|
||||||
type ModuleRuntimeServices,
|
|
||||||
parsePermissionId,
|
parsePermissionId,
|
||||||
type RegisteredModuleHttpRoute,
|
type RegisteredModuleHttpRoute,
|
||||||
} from "@ekklesia/contracts";
|
} from "@ekklesia/contracts";
|
||||||
@@ -16,6 +14,8 @@ import type { EkklesiaIdentityRuntime } from "@ekklesia/core/identity";
|
|||||||
import {
|
import {
|
||||||
createModuleUiPublicationRegistry,
|
createModuleUiPublicationRegistry,
|
||||||
EMPTY_MODULE_REALTIME_REGISTRY,
|
EMPTY_MODULE_REALTIME_REGISTRY,
|
||||||
|
type LegacyModuleRuntimeServices,
|
||||||
|
type ModuleDatabase,
|
||||||
} from "@ekklesia/core/modules";
|
} from "@ekklesia/core/modules";
|
||||||
import { describe, expect, it, vi } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
@@ -74,7 +74,7 @@ function fakeStore(): AuthorizationStore {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function fakeModuleServices(): ModuleRuntimeServices {
|
function fakeModuleServices(): LegacyModuleRuntimeServices {
|
||||||
return Object.freeze({ database: Object.freeze({}) as unknown as ModuleDatabase });
|
return Object.freeze({ database: Object.freeze({}) as unknown as ModuleDatabase });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,8 +5,6 @@ import {
|
|||||||
ANONYMOUS_PRINCIPAL,
|
ANONYMOUS_PRINCIPAL,
|
||||||
createAuthenticatedPrincipal,
|
createAuthenticatedPrincipal,
|
||||||
type EkklesiaPrincipal,
|
type EkklesiaPrincipal,
|
||||||
type ModuleDatabase,
|
|
||||||
type ModuleRuntimeServices,
|
|
||||||
parseEkklesiaUserId,
|
parseEkklesiaUserId,
|
||||||
} from "@ekklesia/contracts";
|
} from "@ekklesia/contracts";
|
||||||
import {
|
import {
|
||||||
@@ -20,6 +18,8 @@ import {
|
|||||||
createModuleUiPublicationRegistry,
|
createModuleUiPublicationRegistry,
|
||||||
discoverModules,
|
discoverModules,
|
||||||
EMPTY_MODULE_REALTIME_REGISTRY,
|
EMPTY_MODULE_REALTIME_REGISTRY,
|
||||||
|
type LegacyModuleRuntimeServices,
|
||||||
|
type ModuleDatabase,
|
||||||
} from "@ekklesia/core/modules";
|
} from "@ekklesia/core/modules";
|
||||||
import type { Hono } from "hono";
|
import type { Hono } from "hono";
|
||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
@@ -77,7 +77,7 @@ async function createModule(modulesDirectory: string, fixture: ModuleFixture): P
|
|||||||
...(fixture.withUi === false ? {} : { ui: "./dist/ui/index.js" }),
|
...(fixture.withUi === false ? {} : { ui: "./dist/ui/index.js" }),
|
||||||
},
|
},
|
||||||
id: fixture.id,
|
id: fixture.id,
|
||||||
manifestVersion: 7,
|
manifestVersion: 8,
|
||||||
name: fixture.name,
|
name: fixture.name,
|
||||||
version: "1.0.0",
|
version: "1.0.0",
|
||||||
}),
|
}),
|
||||||
@@ -147,7 +147,7 @@ async function install(options: InstallOptions = {}): Promise<{ app: Hono }> {
|
|||||||
moduleRealtime: EMPTY_MODULE_REALTIME_REGISTRY,
|
moduleRealtime: EMPTY_MODULE_REALTIME_REGISTRY,
|
||||||
moduleServices: Object.freeze({
|
moduleServices: Object.freeze({
|
||||||
database: Object.freeze({}) as unknown as ModuleDatabase,
|
database: Object.freeze({}) as unknown as ModuleDatabase,
|
||||||
}) as ModuleRuntimeServices,
|
}) as LegacyModuleRuntimeServices,
|
||||||
moduleUi: createModuleUiPublicationRegistry(modules),
|
moduleUi: createModuleUiPublicationRegistry(modules),
|
||||||
moduleLifecycle: {
|
moduleLifecycle: {
|
||||||
snapshot: moduleLifecycleSnapshotFor(modules),
|
snapshot: moduleLifecycleSnapshotFor(modules),
|
||||||
|
|||||||
@@ -118,13 +118,13 @@ async function createDisplayModuleDirectory(): Promise<string> {
|
|||||||
writeFile(
|
writeFile(
|
||||||
path.join(moduleDirectory, "ekklesia.module.json"),
|
path.join(moduleDirectory, "ekklesia.module.json"),
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
compatibility: { moduleSdk: "^0.1.0", platform: "^0.1.0" },
|
compatibility: { moduleSdk: "^0.2.0", platform: "^0.2.0" },
|
||||||
entrypoints: {
|
entrypoints: {
|
||||||
display: { artifact: "./dist/ui/display.js", permission: displayPermission },
|
display: { artifact: "./dist/ui/display.js", permission: displayPermission },
|
||||||
server: "./dist/server/index.js",
|
server: "./dist/server/index.js",
|
||||||
},
|
},
|
||||||
id: "announcements",
|
id: "announcements",
|
||||||
manifestVersion: 7,
|
manifestVersion: 8,
|
||||||
name: "Announcements fixture",
|
name: "Announcements fixture",
|
||||||
permissions: [
|
permissions: [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
import {
|
import {
|
||||||
ANONYMOUS_PRINCIPAL,
|
ANONYMOUS_PRINCIPAL,
|
||||||
type ModuleDatabase,
|
|
||||||
type ModuleRouteHandler,
|
type ModuleRouteHandler,
|
||||||
type ModuleRuntimeServices,
|
|
||||||
parsePermissionId,
|
parsePermissionId,
|
||||||
type RegisteredModuleHttpRoute,
|
type RegisteredModuleHttpRoute,
|
||||||
} from "@ekklesia/contracts";
|
} from "@ekklesia/contracts";
|
||||||
@@ -15,6 +13,8 @@ import type { EkklesiaIdentityRuntime } from "@ekklesia/core/identity";
|
|||||||
import {
|
import {
|
||||||
createModuleUiPublicationRegistry,
|
createModuleUiPublicationRegistry,
|
||||||
EMPTY_MODULE_REALTIME_REGISTRY,
|
EMPTY_MODULE_REALTIME_REGISTRY,
|
||||||
|
type LegacyModuleRuntimeServices,
|
||||||
|
type ModuleDatabase,
|
||||||
} from "@ekklesia/core/modules";
|
} from "@ekklesia/core/modules";
|
||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
import { createApiApp } from "../src/app.js";
|
import { createApiApp } from "../src/app.js";
|
||||||
@@ -56,7 +56,7 @@ function fakeStore(): AuthorizationStore {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function fakeModuleServices(): ModuleRuntimeServices {
|
function fakeModuleServices(): LegacyModuleRuntimeServices {
|
||||||
return Object.freeze({ database: Object.freeze({}) as unknown as ModuleDatabase });
|
return Object.freeze({ database: Object.freeze({}) as unknown as ModuleDatabase });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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" }),
|
||||||
|
|||||||
@@ -104,11 +104,11 @@ async function createFixtureModule(
|
|||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const moduleDirectory = path.join(modulesDirectory, id);
|
const moduleDirectory = path.join(modulesDirectory, id);
|
||||||
const manifest = {
|
const manifest = {
|
||||||
manifestVersion: 7,
|
manifestVersion: 8,
|
||||||
id,
|
id,
|
||||||
name: `${id} module`,
|
name: `${id} module`,
|
||||||
version: "0.1.0",
|
version: "0.1.0",
|
||||||
compatibility: { platform: "^0.1.0", moduleSdk: "^0.1.0" },
|
compatibility: { platform: "^0.2.0", moduleSdk: "^0.2.0" },
|
||||||
permissions: [],
|
permissions: [],
|
||||||
entrypoints: { server: "./dist/server/index.js" },
|
entrypoints: { server: "./dist/server/index.js" },
|
||||||
...manifestOverrides,
|
...manifestOverrides,
|
||||||
@@ -267,10 +267,10 @@ function fakeDiscoveredModule(
|
|||||||
): DiscoveredModule {
|
): DiscoveredModule {
|
||||||
const manifest = parseModuleManifest({
|
const manifest = parseModuleManifest({
|
||||||
capabilities: [],
|
capabilities: [],
|
||||||
compatibility: { moduleSdk: "^0.1.0", platform: "^0.1.0" },
|
compatibility: { moduleSdk: "^0.2.0", platform: "^0.2.0" },
|
||||||
entrypoints: { server: "./dist/does-not-exist/index.js" },
|
entrypoints: { server: "./dist/does-not-exist/index.js" },
|
||||||
id,
|
id,
|
||||||
manifestVersion: 7,
|
manifestVersion: 8,
|
||||||
name: `${id} module`,
|
name: `${id} module`,
|
||||||
permissions,
|
permissions,
|
||||||
version: "0.1.0",
|
version: "0.1.0",
|
||||||
@@ -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");
|
||||||
|
|
||||||
@@ -1368,9 +1435,9 @@ describe("Ekklesia API module runtime startup and shutdown", () => {
|
|||||||
const recorder = createEventRecorder();
|
const recorder = createEventRecorder();
|
||||||
await createWorkingFixtureModule(modulesDirectory, "calendar", recorder, { withRoute: true });
|
await createWorkingFixtureModule(modulesDirectory, "calendar", recorder, { withRoute: true });
|
||||||
const discovered = await (await import("@ekklesia/core/modules")).discoverModules({
|
const discovered = await (await import("@ekklesia/core/modules")).discoverModules({
|
||||||
moduleSdkVersion: "0.1.0",
|
moduleSdkVersion: "0.2.0",
|
||||||
modulesDirectory,
|
modulesDirectory,
|
||||||
platformVersion: "0.1.0",
|
platformVersion: "0.2.0",
|
||||||
});
|
});
|
||||||
mockPoolAndAuth();
|
mockPoolAndAuth();
|
||||||
mockSuccessfulServe();
|
mockSuccessfulServe();
|
||||||
|
|||||||
@@ -6,8 +6,6 @@ import {
|
|||||||
createAuthenticatedPrincipal,
|
createAuthenticatedPrincipal,
|
||||||
type EkklesiaPrincipal,
|
type EkklesiaPrincipal,
|
||||||
type ModuleConfigurationDeclarationInput,
|
type ModuleConfigurationDeclarationInput,
|
||||||
type ModuleDatabase,
|
|
||||||
type ModuleRuntimeServices,
|
|
||||||
parseEkklesiaUserId,
|
parseEkklesiaUserId,
|
||||||
} from "@ekklesia/contracts";
|
} from "@ekklesia/contracts";
|
||||||
import {
|
import {
|
||||||
@@ -22,10 +20,12 @@ import {
|
|||||||
createModuleUiPublicationRegistry,
|
createModuleUiPublicationRegistry,
|
||||||
type DiscoveredModule,
|
type DiscoveredModule,
|
||||||
discoverModules,
|
discoverModules,
|
||||||
|
type LegacyModuleRuntimeServices,
|
||||||
type ModuleConfigurationRecord,
|
type ModuleConfigurationRecord,
|
||||||
type ModuleConfigurationStore,
|
type ModuleConfigurationStore,
|
||||||
ModuleConfigurationStoreError,
|
ModuleConfigurationStoreError,
|
||||||
EMPTY_MODULE_REALTIME_REGISTRY,
|
EMPTY_MODULE_REALTIME_REGISTRY,
|
||||||
|
type ModuleDatabase,
|
||||||
resolveModuleEligibility,
|
resolveModuleEligibility,
|
||||||
} from "@ekklesia/core/modules";
|
} from "@ekklesia/core/modules";
|
||||||
import type { Hono } from "hono";
|
import type { Hono } from "hono";
|
||||||
@@ -108,7 +108,7 @@ async function createModule(modulesDirectory: string, fixture: ModuleFixture): P
|
|||||||
configuration: fixture.configuration ?? [],
|
configuration: fixture.configuration ?? [],
|
||||||
entrypoints: { server: "./dist/server/index.js" },
|
entrypoints: { server: "./dist/server/index.js" },
|
||||||
id: fixture.id,
|
id: fixture.id,
|
||||||
manifestVersion: 7,
|
manifestVersion: 8,
|
||||||
name: `${fixture.id} module`,
|
name: `${fixture.id} module`,
|
||||||
version: "1.0.0",
|
version: "1.0.0",
|
||||||
}),
|
}),
|
||||||
@@ -213,7 +213,7 @@ async function install(options: InstallOptions = {}): Promise<Installation> {
|
|||||||
moduleRealtime: EMPTY_MODULE_REALTIME_REGISTRY,
|
moduleRealtime: EMPTY_MODULE_REALTIME_REGISTRY,
|
||||||
moduleServices: Object.freeze({
|
moduleServices: Object.freeze({
|
||||||
database: Object.freeze({}) as unknown as ModuleDatabase,
|
database: Object.freeze({}) as unknown as ModuleDatabase,
|
||||||
}) as ModuleRuntimeServices,
|
}) as LegacyModuleRuntimeServices,
|
||||||
moduleUi: createModuleUiPublicationRegistry(eligibility.eligibleModules),
|
moduleUi: createModuleUiPublicationRegistry(eligibility.eligibleModules),
|
||||||
runtime,
|
runtime,
|
||||||
trustedOrigins: [trustedOrigin],
|
trustedOrigins: [trustedOrigin],
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
createPostgresMigrationDatabase,
|
createPostgresMigrationDatabase,
|
||||||
discoverAllMigrations,
|
discoverAllMigrations,
|
||||||
} from "@ekklesia/core/migrations";
|
} from "@ekklesia/core/migrations";
|
||||||
|
import { moduleDatabaseExecutorModuleId } from "@ekklesia/core/modules";
|
||||||
import { Client, type QueryResultRow } from "pg";
|
import { Client, type QueryResultRow } from "pg";
|
||||||
import { afterAll, describe, expect, it } from "vitest";
|
import { afterAll, describe, expect, it } from "vitest";
|
||||||
import { createApiRuntimeConfig } from "../src/config.js";
|
import { createApiRuntimeConfig } from "../src/config.js";
|
||||||
@@ -211,9 +212,9 @@ async function applyCoreMigrations(url: string): Promise<void> {
|
|||||||
const aggregate = await discoverAllMigrations({
|
const aggregate = await discoverAllMigrations({
|
||||||
coreMigrationsDirectory,
|
coreMigrationsDirectory,
|
||||||
moduleDiscovery: {
|
moduleDiscovery: {
|
||||||
moduleSdkVersion: "0.1.0",
|
moduleSdkVersion: "0.2.0",
|
||||||
modulesDirectory: emptyModulesDirectory,
|
modulesDirectory: emptyModulesDirectory,
|
||||||
platformVersion: "0.1.0",
|
platformVersion: "0.2.0",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
expect(aggregate.migrations.map((entry) => entry.identity)).toEqual([
|
expect(aggregate.migrations.map((entry) => entry.identity)).toEqual([
|
||||||
@@ -240,9 +241,9 @@ async function applyMigrationsWithModule(url: string): Promise<void> {
|
|||||||
const aggregate = await discoverAllMigrations({
|
const aggregate = await discoverAllMigrations({
|
||||||
coreMigrationsDirectory,
|
coreMigrationsDirectory,
|
||||||
moduleDiscovery: {
|
moduleDiscovery: {
|
||||||
moduleSdkVersion: "0.1.0",
|
moduleSdkVersion: "0.2.0",
|
||||||
modulesDirectory: fixtureModulesDirectory,
|
modulesDirectory: fixtureModulesDirectory,
|
||||||
platformVersion: "0.1.0",
|
platformVersion: "0.2.0",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
// Core migrations aggregate first, exactly once each, and always before any module migration.
|
// Core migrations aggregate first, exactly once each, and always before any module migration.
|
||||||
@@ -441,6 +442,11 @@ describe.sequential("live API — module database explicit migration ownership",
|
|||||||
|
|
||||||
const handle = createApiDatabase(database.url);
|
const handle = createApiDatabase(database.url);
|
||||||
expect(handle.module).toBeDefined();
|
expect(handle.module).toBeDefined();
|
||||||
|
const alpha = handle.moduleExecutor("alpha");
|
||||||
|
const beta = handle.moduleExecutor("beta");
|
||||||
|
expect(alpha).not.toBe(beta);
|
||||||
|
expect(moduleDatabaseExecutorModuleId(alpha)).toBe("alpha");
|
||||||
|
expect(moduleDatabaseExecutorModuleId(beta)).toBe("beta");
|
||||||
await handle.close();
|
await handle.close();
|
||||||
expect(await tableExists(database.url)).toBe(false);
|
expect(await tableExists(database.url)).toBe(false);
|
||||||
|
|
||||||
|
|||||||
@@ -5,8 +5,6 @@ import {
|
|||||||
ANONYMOUS_PRINCIPAL,
|
ANONYMOUS_PRINCIPAL,
|
||||||
createAuthenticatedPrincipal,
|
createAuthenticatedPrincipal,
|
||||||
type EkklesiaPrincipal,
|
type EkklesiaPrincipal,
|
||||||
type ModuleDatabase,
|
|
||||||
type ModuleRuntimeServices,
|
|
||||||
parseEkklesiaUserId,
|
parseEkklesiaUserId,
|
||||||
} from "@ekklesia/contracts";
|
} from "@ekklesia/contracts";
|
||||||
import {
|
import {
|
||||||
@@ -21,6 +19,8 @@ import {
|
|||||||
type DiscoveredModule,
|
type DiscoveredModule,
|
||||||
discoverModules,
|
discoverModules,
|
||||||
EMPTY_MODULE_REALTIME_REGISTRY,
|
EMPTY_MODULE_REALTIME_REGISTRY,
|
||||||
|
type LegacyModuleRuntimeServices,
|
||||||
|
type ModuleDatabase,
|
||||||
} from "@ekklesia/core/modules";
|
} from "@ekklesia/core/modules";
|
||||||
import type { Hono } from "hono";
|
import type { Hono } from "hono";
|
||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
@@ -105,7 +105,7 @@ async function createModule(modulesDirectory: string, fixture: ModuleFixture = {
|
|||||||
: {}),
|
: {}),
|
||||||
},
|
},
|
||||||
id,
|
id,
|
||||||
manifestVersion: 7,
|
manifestVersion: 8,
|
||||||
name: `${id} module`,
|
name: `${id} module`,
|
||||||
permissions: fixture.display
|
permissions: fixture.display
|
||||||
? [
|
? [
|
||||||
@@ -155,7 +155,7 @@ interface Installation {
|
|||||||
readonly modules: readonly DiscoveredModule[];
|
readonly modules: readonly DiscoveredModule[];
|
||||||
}
|
}
|
||||||
|
|
||||||
function fakeServices(): ModuleRuntimeServices {
|
function fakeServices(): LegacyModuleRuntimeServices {
|
||||||
return Object.freeze({ database: Object.freeze({}) as unknown as ModuleDatabase });
|
return Object.freeze({ database: Object.freeze({}) as unknown as ModuleDatabase });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,13 +1,11 @@
|
|||||||
import {
|
import {
|
||||||
ANONYMOUS_PRINCIPAL,
|
ANONYMOUS_PRINCIPAL,
|
||||||
createAuthenticatedPrincipal,
|
createAuthenticatedPrincipal,
|
||||||
type ModuleDatabase,
|
|
||||||
type ModuleRouteHandler,
|
type ModuleRouteHandler,
|
||||||
type ModuleRouteHandlerContext,
|
type ModuleRouteHandlerContext,
|
||||||
type ModuleRuntimeServices,
|
|
||||||
moduleRoutesOverlap,
|
moduleRoutesOverlap,
|
||||||
parsePermissionId,
|
|
||||||
type PermissionId,
|
type PermissionId,
|
||||||
|
parsePermissionId,
|
||||||
type RegisteredModuleHttpRoute,
|
type RegisteredModuleHttpRoute,
|
||||||
} from "@ekklesia/contracts";
|
} from "@ekklesia/contracts";
|
||||||
import {
|
import {
|
||||||
@@ -20,20 +18,23 @@ import type { EkklesiaIdentityRuntime } from "@ekklesia/core/identity";
|
|||||||
import {
|
import {
|
||||||
createModuleUiPublicationRegistry,
|
createModuleUiPublicationRegistry,
|
||||||
EMPTY_MODULE_REALTIME_REGISTRY,
|
EMPTY_MODULE_REALTIME_REGISTRY,
|
||||||
|
type LegacyModuleRuntimeServices,
|
||||||
|
type ModuleDatabase,
|
||||||
|
type ModuleRuntimeServicesLookup,
|
||||||
} from "@ekklesia/core/modules";
|
} from "@ekklesia/core/modules";
|
||||||
import { describe, expect, it, vi } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
|
|
||||||
import { testDashboardHostAssets } from "./support/dashboard-host-assets.js";
|
|
||||||
import { readyHealth } from "./support/ready-health.js";
|
|
||||||
import { createApiApp } from "../src/app.js";
|
import { createApiApp } from "../src/app.js";
|
||||||
import {
|
import {
|
||||||
canonicalizeVaryHeader,
|
canonicalizeVaryHeader,
|
||||||
mergedVaryHeader,
|
|
||||||
ModuleRouteConfigurationError,
|
ModuleRouteConfigurationError,
|
||||||
|
mergedVaryHeader,
|
||||||
} from "../src/module-http-routes.js";
|
} from "../src/module-http-routes.js";
|
||||||
import { emptyModuleLifecycle } from "./support/in-memory-module-lifecycle.js";
|
import { testDashboardHostAssets } from "./support/dashboard-host-assets.js";
|
||||||
|
import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
|
||||||
import { emptyModuleConfiguration } from "./support/in-memory-module-configuration.js";
|
import { emptyModuleConfiguration } from "./support/in-memory-module-configuration.js";
|
||||||
|
import { emptyModuleLifecycle } from "./support/in-memory-module-lifecycle.js";
|
||||||
import { emptyModuleStorage } from "./support/in-memory-module-storage.js";
|
import { emptyModuleStorage } from "./support/in-memory-module-storage.js";
|
||||||
|
import { readyHealth } from "./support/ready-health.js";
|
||||||
|
|
||||||
const trustedOrigin = "https://dashboard.example.test";
|
const trustedOrigin = "https://dashboard.example.test";
|
||||||
const untrustedOrigin = "https://evil.example.test";
|
const untrustedOrigin = "https://evil.example.test";
|
||||||
@@ -73,11 +74,11 @@ function moduleRoute(
|
|||||||
return Object.freeze({ bodyMode: "buffered", maxBodyBytes: 1024, ...fields });
|
return Object.freeze({ bodyMode: "buffered", maxBodyBytes: 1024, ...fields });
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A small typed fake `ModuleRuntimeServices` — these tests exercise route mounting,
|
/** A small typed fake `LegacyModuleRuntimeServices` — these tests exercise route mounting,
|
||||||
* authorization, and response sanitization, never real database behavior, so no PostgreSQL is
|
* authorization, and response sanitization, never real database behavior, so no PostgreSQL is
|
||||||
* required. Each `database` method stays a distinct spy so a test can assert none of them were
|
* required. Each `database` method stays a distinct spy so a test can assert none of them were
|
||||||
* ever called (denied, trusted-origin-rejected, and `OPTIONS` requests never reach a handler). */
|
* ever called (denied, trusted-origin-rejected, and `OPTIONS` requests never reach a handler). */
|
||||||
function fakeModuleServices(): ModuleRuntimeServices {
|
function fakeModuleServices(): LegacyModuleRuntimeServices {
|
||||||
const database = Object.freeze({
|
const database = Object.freeze({
|
||||||
delete: vi.fn(),
|
delete: vi.fn(),
|
||||||
execute: vi.fn(),
|
execute: vi.fn(),
|
||||||
@@ -91,7 +92,7 @@ function fakeModuleServices(): ModuleRuntimeServices {
|
|||||||
return Object.freeze({ database });
|
return Object.freeze({ database });
|
||||||
}
|
}
|
||||||
|
|
||||||
function expectDatabaseFakeUntouched(services: ModuleRuntimeServices): void {
|
function expectDatabaseFakeUntouched(services: LegacyModuleRuntimeServices): void {
|
||||||
expect(vi.mocked(services.database.select)).not.toHaveBeenCalled();
|
expect(vi.mocked(services.database.select)).not.toHaveBeenCalled();
|
||||||
expect(vi.mocked(services.database.selectDistinct)).not.toHaveBeenCalled();
|
expect(vi.mocked(services.database.selectDistinct)).not.toHaveBeenCalled();
|
||||||
expect(vi.mocked(services.database.selectDistinctOn)).not.toHaveBeenCalled();
|
expect(vi.mocked(services.database.selectDistinctOn)).not.toHaveBeenCalled();
|
||||||
@@ -107,7 +108,7 @@ function buildModuleApp(options: {
|
|||||||
readonly registry: PermissionRegistry;
|
readonly registry: PermissionRegistry;
|
||||||
readonly routes: readonly RegisteredModuleHttpRoute[];
|
readonly routes: readonly RegisteredModuleHttpRoute[];
|
||||||
readonly runtime?: EkklesiaIdentityRuntime;
|
readonly runtime?: EkklesiaIdentityRuntime;
|
||||||
readonly services?: ModuleRuntimeServices;
|
readonly services?: LegacyModuleRuntimeServices | ModuleRuntimeServicesLookup;
|
||||||
readonly trustedOrigins?: readonly string[];
|
readonly trustedOrigins?: readonly string[];
|
||||||
}) {
|
}) {
|
||||||
const evaluator = options.evaluator ?? {
|
const evaluator = options.evaluator ?? {
|
||||||
@@ -145,6 +146,36 @@ function permissionRegistry(
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("module HTTP route mounting", () => {
|
describe("module HTTP route mounting", () => {
|
||||||
|
it("selects the owning module's stable service entry for its handler", async () => {
|
||||||
|
const calendar = fakeModuleServices();
|
||||||
|
const people = fakeModuleServices();
|
||||||
|
const services: ModuleRuntimeServicesLookup = new Map([
|
||||||
|
["calendar", calendar],
|
||||||
|
["people", people],
|
||||||
|
]);
|
||||||
|
let received: unknown;
|
||||||
|
const { app } = buildModuleApp({
|
||||||
|
registry: permissionRegistry([{ id: readPermission, baseline: "public" }]),
|
||||||
|
routes: [
|
||||||
|
moduleRoute({
|
||||||
|
handler: (_request, context) => {
|
||||||
|
received = context.services;
|
||||||
|
return Response.json({ ok: true });
|
||||||
|
},
|
||||||
|
method: "GET",
|
||||||
|
moduleId: "calendar",
|
||||||
|
path: "/services",
|
||||||
|
permission: readPermission,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
services,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect((await app.request("/api/v1/modules/calendar/services")).status).toBe(200);
|
||||||
|
expect(received).toBe(calendar);
|
||||||
|
expect(received).not.toBe(people);
|
||||||
|
});
|
||||||
|
|
||||||
it('mounts a "/" route at exactly the module root, never with an added trailing slash', async () => {
|
it('mounts a "/" route at exactly the module root, never with an added trailing slash', async () => {
|
||||||
const handler = vi.fn<ModuleRouteHandler>(async () => new Response("root", { status: 200 }));
|
const handler = vi.fn<ModuleRouteHandler>(async () => new Response("root", { status: 200 }));
|
||||||
const { app } = buildModuleApp({
|
const { app } = buildModuleApp({
|
||||||
@@ -508,7 +539,7 @@ describe("module route authorization", () => {
|
|||||||
expect(response.status).toBe(403);
|
expect(response.status).toBe(403);
|
||||||
expect(await response.json()).toEqual({ code: "AUTHORIZATION_DENIED" });
|
expect(await response.json()).toEqual({ code: "AUTHORIZATION_DENIED" });
|
||||||
expect(handler).not.toHaveBeenCalled();
|
expect(handler).not.toHaveBeenCalled();
|
||||||
expectDatabaseFakeUntouched(services);
|
expectDatabaseFakeUntouched(services as LegacyModuleRuntimeServices);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does not invoke the handler when principal resolution fails", async () => {
|
it("does not invoke the handler when principal resolution fails", async () => {
|
||||||
@@ -637,7 +668,7 @@ describe("module route trusted-origin enforcement", () => {
|
|||||||
}
|
}
|
||||||
expect(handler).not.toHaveBeenCalled();
|
expect(handler).not.toHaveBeenCalled();
|
||||||
expect(evaluator.authorize).not.toHaveBeenCalled();
|
expect(evaluator.authorize).not.toHaveBeenCalled();
|
||||||
expectDatabaseFakeUntouched(services);
|
expectDatabaseFakeUntouched(services as LegacyModuleRuntimeServices);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("allows a trusted-origin state-changing request through to the handler", async () => {
|
it("allows a trusted-origin state-changing request through to the handler", async () => {
|
||||||
@@ -766,7 +797,7 @@ describe("module route CORS", () => {
|
|||||||
|
|
||||||
expect(handler).not.toHaveBeenCalled();
|
expect(handler).not.toHaveBeenCalled();
|
||||||
expect(evaluator.authorize).not.toHaveBeenCalled();
|
expect(evaluator.authorize).not.toHaveBeenCalled();
|
||||||
expectDatabaseFakeUntouched(services);
|
expectDatabaseFakeUntouched(services as LegacyModuleRuntimeServices);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("never reflects an untrusted origin for a module route", async () => {
|
it("never reflects an untrusted origin for a module route", async () => {
|
||||||
|
|||||||
@@ -5,8 +5,6 @@ import {
|
|||||||
ANONYMOUS_PRINCIPAL,
|
ANONYMOUS_PRINCIPAL,
|
||||||
createAuthenticatedPrincipal,
|
createAuthenticatedPrincipal,
|
||||||
type EkklesiaPrincipal,
|
type EkklesiaPrincipal,
|
||||||
type ModuleDatabase,
|
|
||||||
type ModuleRuntimeServices,
|
|
||||||
parseEkklesiaUserId,
|
parseEkklesiaUserId,
|
||||||
} from "@ekklesia/contracts";
|
} from "@ekklesia/contracts";
|
||||||
import {
|
import {
|
||||||
@@ -20,6 +18,8 @@ import {
|
|||||||
createModuleUiPublicationRegistry,
|
createModuleUiPublicationRegistry,
|
||||||
type DiscoveredModule,
|
type DiscoveredModule,
|
||||||
discoverModules,
|
discoverModules,
|
||||||
|
type LegacyModuleRuntimeServices,
|
||||||
|
type ModuleDatabase,
|
||||||
type ModuleLifecycleStore,
|
type ModuleLifecycleStore,
|
||||||
ModuleLifecycleStoreError,
|
ModuleLifecycleStoreError,
|
||||||
EMPTY_MODULE_REALTIME_REGISTRY,
|
EMPTY_MODULE_REALTIME_REGISTRY,
|
||||||
@@ -79,7 +79,7 @@ async function createModule(modulesDirectory: string, fixture: ModuleFixture): P
|
|||||||
...(fixture.dependencies ? { dependencies: fixture.dependencies } : {}),
|
...(fixture.dependencies ? { dependencies: fixture.dependencies } : {}),
|
||||||
entrypoints: { server: "./dist/server/index.js", ui: "./dist/ui/index.js" },
|
entrypoints: { server: "./dist/server/index.js", ui: "./dist/ui/index.js" },
|
||||||
id: fixture.id,
|
id: fixture.id,
|
||||||
manifestVersion: 7,
|
manifestVersion: 8,
|
||||||
name: `${fixture.id} module`,
|
name: `${fixture.id} module`,
|
||||||
version: "1.0.0",
|
version: "1.0.0",
|
||||||
}),
|
}),
|
||||||
@@ -189,7 +189,7 @@ async function install(options: InstallOptions = {}): Promise<Installation> {
|
|||||||
moduleRealtime: EMPTY_MODULE_REALTIME_REGISTRY,
|
moduleRealtime: EMPTY_MODULE_REALTIME_REGISTRY,
|
||||||
moduleServices: Object.freeze({
|
moduleServices: Object.freeze({
|
||||||
database: Object.freeze({}) as unknown as ModuleDatabase,
|
database: Object.freeze({}) as unknown as ModuleDatabase,
|
||||||
}) as ModuleRuntimeServices,
|
}) as LegacyModuleRuntimeServices,
|
||||||
moduleUi: createModuleUiPublicationRegistry(eligibility.eligibleModules),
|
moduleUi: createModuleUiPublicationRegistry(eligibility.eligibleModules),
|
||||||
runtime,
|
runtime,
|
||||||
trustedOrigins: [trustedOrigin],
|
trustedOrigins: [trustedOrigin],
|
||||||
|
|||||||
@@ -4,9 +4,7 @@ import path from "node:path";
|
|||||||
import {
|
import {
|
||||||
ANONYMOUS_PRINCIPAL,
|
ANONYMOUS_PRINCIPAL,
|
||||||
createAuthenticatedPrincipal,
|
createAuthenticatedPrincipal,
|
||||||
type ModuleDatabase,
|
|
||||||
type ModuleRealtimePublisher,
|
type ModuleRealtimePublisher,
|
||||||
type ModuleRuntimeServices,
|
|
||||||
parseEkklesiaUserId,
|
parseEkklesiaUserId,
|
||||||
} from "@ekklesia/contracts";
|
} from "@ekklesia/contracts";
|
||||||
import {
|
import {
|
||||||
@@ -18,6 +16,8 @@ import type { EkklesiaIdentityRuntime } from "@ekklesia/core/identity";
|
|||||||
import {
|
import {
|
||||||
composePlatformPermissionRegistry,
|
composePlatformPermissionRegistry,
|
||||||
discoverModules,
|
discoverModules,
|
||||||
|
type LegacyModuleRuntimeServices,
|
||||||
|
type ModuleDatabase,
|
||||||
type ModuleRealtimeRegistry,
|
type ModuleRealtimeRegistry,
|
||||||
type ModuleRealtimeSubscription,
|
type ModuleRealtimeSubscription,
|
||||||
startDiscoveredModuleRuntime,
|
startDiscoveredModuleRuntime,
|
||||||
@@ -188,7 +188,7 @@ async function createFixtureModule(control: FixtureControl): Promise<{
|
|||||||
compatibility: { moduleSdk: "^0.1.0", platform: "^0.1.0" },
|
compatibility: { moduleSdk: "^0.1.0", platform: "^0.1.0" },
|
||||||
entrypoints: { server: "./dist/server/index.js" },
|
entrypoints: { server: "./dist/server/index.js" },
|
||||||
id: "announcements",
|
id: "announcements",
|
||||||
manifestVersion: 7,
|
manifestVersion: 8,
|
||||||
name: "Announcements fixture",
|
name: "Announcements fixture",
|
||||||
permissions: [
|
permissions: [
|
||||||
{
|
{
|
||||||
@@ -226,7 +226,7 @@ async function createFixtureModule(control: FixtureControl): Promise<{
|
|||||||
return { directory, symbol };
|
return { directory, symbol };
|
||||||
}
|
}
|
||||||
|
|
||||||
function services(): ModuleRuntimeServices {
|
function services(): LegacyModuleRuntimeServices {
|
||||||
return Object.freeze({ database: Object.freeze({}) as ModuleDatabase });
|
return Object.freeze({ database: Object.freeze({}) as ModuleDatabase });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,8 +5,6 @@ import {
|
|||||||
ANONYMOUS_PRINCIPAL,
|
ANONYMOUS_PRINCIPAL,
|
||||||
createAuthenticatedPrincipal,
|
createAuthenticatedPrincipal,
|
||||||
type EkklesiaPrincipal,
|
type EkklesiaPrincipal,
|
||||||
type ModuleDatabase,
|
|
||||||
type ModuleRuntimeServices,
|
|
||||||
parseEkklesiaUserId,
|
parseEkklesiaUserId,
|
||||||
} from "@ekklesia/contracts";
|
} from "@ekklesia/contracts";
|
||||||
import {
|
import {
|
||||||
@@ -18,6 +16,8 @@ import {
|
|||||||
composePlatformPermissionRegistry,
|
composePlatformPermissionRegistry,
|
||||||
createModuleUiPublicationRegistry,
|
createModuleUiPublicationRegistry,
|
||||||
discoverModules,
|
discoverModules,
|
||||||
|
type LegacyModuleRuntimeServices,
|
||||||
|
type ModuleDatabase,
|
||||||
startDiscoveredModuleRuntime,
|
startDiscoveredModuleRuntime,
|
||||||
} from "@ekklesia/core/modules";
|
} from "@ekklesia/core/modules";
|
||||||
import {
|
import {
|
||||||
@@ -129,7 +129,7 @@ async function createFixtureModule(
|
|||||||
: {}),
|
: {}),
|
||||||
},
|
},
|
||||||
id,
|
id,
|
||||||
manifestVersion: 7,
|
manifestVersion: 8,
|
||||||
name,
|
name,
|
||||||
permissions: [
|
permissions: [
|
||||||
{
|
{
|
||||||
@@ -241,7 +241,7 @@ async function startHarness(options: HarnessOptions = {}): Promise<Harness> {
|
|||||||
modulesDirectory,
|
modulesDirectory,
|
||||||
platformVersion: "0.1.0",
|
platformVersion: "0.1.0",
|
||||||
});
|
});
|
||||||
const services: ModuleRuntimeServices = Object.freeze({
|
const services: LegacyModuleRuntimeServices = Object.freeze({
|
||||||
database: Object.freeze({}) as unknown as ModuleDatabase,
|
database: Object.freeze({}) as unknown as ModuleDatabase,
|
||||||
});
|
});
|
||||||
// The module's real runtime: its server entrypoint is imported, registered, and started, and the
|
// The module's real runtime: its server entrypoint is imported, registered, and started, and the
|
||||||
|
|||||||
@@ -5,8 +5,6 @@ import {
|
|||||||
ANONYMOUS_PRINCIPAL,
|
ANONYMOUS_PRINCIPAL,
|
||||||
createAuthenticatedPrincipal,
|
createAuthenticatedPrincipal,
|
||||||
type EkklesiaPrincipal,
|
type EkklesiaPrincipal,
|
||||||
type ModuleDatabase,
|
|
||||||
type ModuleRuntimeServices,
|
|
||||||
parseEkklesiaUserId,
|
parseEkklesiaUserId,
|
||||||
} from "@ekklesia/contracts";
|
} from "@ekklesia/contracts";
|
||||||
import {
|
import {
|
||||||
@@ -21,6 +19,8 @@ import {
|
|||||||
type DiscoveredModule,
|
type DiscoveredModule,
|
||||||
discoverModules,
|
discoverModules,
|
||||||
EMPTY_MODULE_REALTIME_REGISTRY,
|
EMPTY_MODULE_REALTIME_REGISTRY,
|
||||||
|
type LegacyModuleRuntimeServices,
|
||||||
|
type ModuleDatabase,
|
||||||
} from "@ekklesia/core/modules";
|
} from "@ekklesia/core/modules";
|
||||||
import type { Hono } from "hono";
|
import type { Hono } from "hono";
|
||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
@@ -99,7 +99,7 @@ async function createModule(modulesDirectory: string, fixture: ModuleFixture): P
|
|||||||
version: "1.0.0",
|
version: "1.0.0",
|
||||||
})),
|
})),
|
||||||
id: fixture.id,
|
id: fixture.id,
|
||||||
manifestVersion: 7,
|
manifestVersion: 8,
|
||||||
name: `${fixture.id} module`,
|
name: `${fixture.id} module`,
|
||||||
permissions: (fixture.permissions ?? []).map((permission) => ({
|
permissions: (fixture.permissions ?? []).map((permission) => ({
|
||||||
...permission,
|
...permission,
|
||||||
@@ -192,7 +192,7 @@ async function install(options: InstallOptions): Promise<Installation> {
|
|||||||
revokeGrant: vi.fn(),
|
revokeGrant: vi.fn(),
|
||||||
revokePlatformOwner: vi.fn(),
|
revokePlatformOwner: vi.fn(),
|
||||||
} as unknown as AuthorizationStore;
|
} as unknown as AuthorizationStore;
|
||||||
const services: ModuleRuntimeServices = Object.freeze({
|
const services: LegacyModuleRuntimeServices = Object.freeze({
|
||||||
database: Object.freeze({}) as unknown as ModuleDatabase,
|
database: Object.freeze({}) as unknown as ModuleDatabase,
|
||||||
});
|
});
|
||||||
const permissionRegistry = composePlatformPermissionRegistry(modules);
|
const permissionRegistry = composePlatformPermissionRegistry(modules);
|
||||||
|
|||||||
@@ -1,11 +1,7 @@
|
|||||||
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
import { mkdir, mkdtemp, 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 {
|
import { ANONYMOUS_PRINCIPAL } from "@ekklesia/contracts";
|
||||||
ANONYMOUS_PRINCIPAL,
|
|
||||||
type ModuleDatabase,
|
|
||||||
type ModuleRuntimeServices,
|
|
||||||
} from "@ekklesia/contracts";
|
|
||||||
import type { AuthorizationEvaluator, AuthorizationStore } from "@ekklesia/core/authorization";
|
import type { AuthorizationEvaluator, AuthorizationStore } from "@ekklesia/core/authorization";
|
||||||
import type { EkklesiaIdentityRuntime } from "@ekklesia/core/identity";
|
import type { EkklesiaIdentityRuntime } from "@ekklesia/core/identity";
|
||||||
import {
|
import {
|
||||||
@@ -14,6 +10,8 @@ import {
|
|||||||
type DiscoveredModule,
|
type DiscoveredModule,
|
||||||
discoverModules,
|
discoverModules,
|
||||||
EMPTY_MODULE_REALTIME_REGISTRY,
|
EMPTY_MODULE_REALTIME_REGISTRY,
|
||||||
|
type LegacyModuleRuntimeServices,
|
||||||
|
type ModuleDatabase,
|
||||||
} from "@ekklesia/core/modules";
|
} from "@ekklesia/core/modules";
|
||||||
import { loadModuleUi } from "@ekklesia/module-ui-host";
|
import { loadModuleUi } from "@ekklesia/module-ui-host";
|
||||||
import { MODULE_UI_ABI_VERSION, NO_MODULE_UI_CONTRIBUTIONS } from "@ekklesia/module-ui-sdk";
|
import { MODULE_UI_ABI_VERSION, NO_MODULE_UI_CONTRIBUTIONS } from "@ekklesia/module-ui-sdk";
|
||||||
@@ -139,7 +137,7 @@ async function createModule(modulesDirectory: string, fixture: ModuleFixture): P
|
|||||||
version: "1.0.0",
|
version: "1.0.0",
|
||||||
})),
|
})),
|
||||||
id: fixture.id,
|
id: fixture.id,
|
||||||
manifestVersion: 7,
|
manifestVersion: 8,
|
||||||
name: `${fixture.id} module`,
|
name: `${fixture.id} module`,
|
||||||
permissions:
|
permissions:
|
||||||
fixture.display === undefined
|
fixture.display === undefined
|
||||||
@@ -215,7 +213,7 @@ async function install(
|
|||||||
: modules.filter((module) => publicationModuleIds.includes(module.manifest.id));
|
: modules.filter((module) => publicationModuleIds.includes(module.manifest.id));
|
||||||
|
|
||||||
const database = Object.freeze({}) as unknown as ModuleDatabase;
|
const database = Object.freeze({}) as unknown as ModuleDatabase;
|
||||||
const services: ModuleRuntimeServices = Object.freeze({ database });
|
const services: LegacyModuleRuntimeServices = Object.freeze({ database });
|
||||||
const runtime: EkklesiaIdentityRuntime = {
|
const runtime: EkklesiaIdentityRuntime = {
|
||||||
handleAuthRequest: vi.fn().mockResolvedValue(new Response(null, { status: 200 })),
|
handleAuthRequest: vi.fn().mockResolvedValue(new Response(null, { status: 200 })),
|
||||||
// Anonymous, and every authorization decision denied: whatever this route does, it does it
|
// Anonymous, and every authorization decision denied: whatever this route does, it does it
|
||||||
|
|||||||
+2
-1
@@ -1,6 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "@ekklesia/core",
|
"name": "@ekklesia/core",
|
||||||
"version": "0.1.2",
|
"version": "0.2.4",
|
||||||
|
"license": "AGPL-3.0-or-later",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"files": [
|
"files": [
|
||||||
|
|||||||
+23
-3
@@ -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,
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -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,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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)) {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -0,0 +1,188 @@
|
|||||||
|
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 const MODULE_VERIFIER_SCRATCH_ENV = "EKKLESIA_MODULE_VERIFIER_SCRATCH_DIR";
|
||||||
|
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The CLI's operator-facing bridge to Core's otherwise explicit verifier option. */
|
||||||
|
export function moduleVerifierScratchDirectory(environment: NodeJS.ProcessEnv): string | undefined {
|
||||||
|
const configured = environment[MODULE_VERIFIER_SCRATCH_ENV];
|
||||||
|
if (configured === undefined) return undefined;
|
||||||
|
if (configured === "" || !path.isAbsolute(configured)) {
|
||||||
|
throw new VerifyArgumentsError(`${MODULE_VERIFIER_SCRATCH_ENV} must be an absolute path.`);
|
||||||
|
}
|
||||||
|
return configured;
|
||||||
|
}
|
||||||
|
|
||||||
|
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));
|
||||||
|
const executionScratchDirectory = moduleVerifierScratchDirectory(process.env);
|
||||||
|
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,
|
||||||
|
...(executionScratchDirectory === undefined ? {} : { executionScratchDirectory }),
|
||||||
|
...(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;
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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).
|
||||||
|
|||||||
@@ -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,7 +287,19 @@ function validateAppliedHistoryUnchecked(
|
|||||||
|
|
||||||
const migration = knownMigration(inventory, record.identity);
|
const migration = knownMigration(inventory, record.identity);
|
||||||
if (!migration) {
|
if (!migration) {
|
||||||
throw runnerError("MIGRATION_UNKNOWN_APPLIED");
|
if (installed.has(record.moduleId)) {
|
||||||
|
// Core, or a module that is installed now: its history is strict, exactly as ADR 0009
|
||||||
|
// established. A migration it no longer ships is a downgrade or a rewritten package.
|
||||||
|
throw runnerError("MIGRATION_UNKNOWN_APPLIED");
|
||||||
|
}
|
||||||
|
// An absent module's history (ADR 0048): retained and reported, never repaired, deleted, or
|
||||||
|
// matched against anything. It must still name its own module and tag, so a corrupt row is
|
||||||
|
// never mistaken for one.
|
||||||
|
if (record.identity !== `${record.moduleId}:${record.tag}`) {
|
||||||
|
throw runnerError("MIGRATION_HISTORY_INVALID");
|
||||||
|
}
|
||||||
|
retained.set(record.moduleId, (retained.get(record.moduleId) ?? 0) + 1);
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
record.moduleId !== migration.moduleId ||
|
record.moduleId !== migration.moduleId ||
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -0,0 +1,349 @@
|
|||||||
|
import {
|
||||||
|
type ModuleDatabaseField,
|
||||||
|
ModuleDatabaseError,
|
||||||
|
type ModuleDatabaseValue,
|
||||||
|
} from "@ekklesia/contracts";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The value contract of Module Database ABI v1 (ADR 0050): how parameters are encoded before they
|
||||||
|
* reach PostgreSQL, and how result text is decoded after. Both are Core's, and neither consults
|
||||||
|
* `pg`'s process-global type registry or any ORM, which module code could change or differ from.
|
||||||
|
*
|
||||||
|
* Every value travels in PostgreSQL's text format. The session settings the decoding depends on
|
||||||
|
* (`DateStyle`, `IntervalStyle`, `TimeZone`, `bytea_output`, `extra_float_digits`) are pinned by the
|
||||||
|
* executor for every ABI operation (`./database-abi.ts`).
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** PostgreSQL's limit on bound parameters in one statement (the protocol's 16-bit count). */
|
||||||
|
export const MODULE_DATABASE_MAX_PARAMETERS = 65_535;
|
||||||
|
/** PostgreSQL's limit on array dimensions (`MAXDIM`). */
|
||||||
|
const maximumArrayDimensions = 6;
|
||||||
|
|
||||||
|
function parameterInvalid(): ModuleDatabaseError {
|
||||||
|
return new ModuleDatabaseError("MODULE_DATABASE_PARAMETER_INVALID");
|
||||||
|
}
|
||||||
|
|
||||||
|
function isScalar(value: unknown): boolean {
|
||||||
|
return (
|
||||||
|
value === null ||
|
||||||
|
typeof value === "boolean" ||
|
||||||
|
typeof value === "number" ||
|
||||||
|
typeof value === "bigint" ||
|
||||||
|
typeof value === "string" ||
|
||||||
|
value instanceof Uint8Array
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function scalarText(value: boolean | number | bigint | string): string {
|
||||||
|
// `String` spells NaN, Infinity, and -Infinity exactly as PostgreSQL's float and numeric input
|
||||||
|
// accepts them.
|
||||||
|
return typeof value === "string" ? value : String(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function byteaText(value: Uint8Array): string {
|
||||||
|
return `\\x${Buffer.from(value.buffer, value.byteOffset, value.byteLength).toString("hex")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function quoteArrayElement(text: string): string {
|
||||||
|
return `"${text.replaceAll("\\", "\\\\").replaceAll('"', '\\"')}"`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates one array level and returns its PostgreSQL array literal. Every element of a level is
|
||||||
|
* either a sub-array or a scalar, and sibling sub-arrays have equal lengths, because PostgreSQL
|
||||||
|
* arrays are rectangular. Every non-null element is quoted, which PostgreSQL accepts for any element
|
||||||
|
* type.
|
||||||
|
*/
|
||||||
|
function arrayLiteral(values: readonly unknown[], depth: number): string {
|
||||||
|
if (depth > maximumArrayDimensions) {
|
||||||
|
throw parameterInvalid();
|
||||||
|
}
|
||||||
|
const elements: string[] = [];
|
||||||
|
let nestedLength: number | undefined;
|
||||||
|
let nested: boolean | undefined;
|
||||||
|
for (let index = 0; index < values.length; index += 1) {
|
||||||
|
if (!Object.hasOwn(values, index)) {
|
||||||
|
throw parameterInvalid();
|
||||||
|
}
|
||||||
|
const element = values[index];
|
||||||
|
const isNested = Array.isArray(element);
|
||||||
|
if (nested === undefined) {
|
||||||
|
nested = isNested;
|
||||||
|
} else if (nested !== isNested) {
|
||||||
|
throw parameterInvalid();
|
||||||
|
}
|
||||||
|
if (isNested) {
|
||||||
|
if (nestedLength === undefined) {
|
||||||
|
nestedLength = element.length;
|
||||||
|
} else if (nestedLength !== element.length) {
|
||||||
|
throw parameterInvalid();
|
||||||
|
}
|
||||||
|
elements.push(arrayLiteral(element, depth + 1));
|
||||||
|
} else if (element === null) {
|
||||||
|
elements.push("NULL");
|
||||||
|
} else if (element instanceof Uint8Array) {
|
||||||
|
elements.push(quoteArrayElement(byteaText(element)));
|
||||||
|
} else if (isScalar(element)) {
|
||||||
|
elements.push(quoteArrayElement(scalarText(element as boolean | number | bigint | string)));
|
||||||
|
} else {
|
||||||
|
throw parameterInvalid();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return `{${elements.join(",")}}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates `values` against the ABI's parameter set and encodes each one into the only forms the
|
||||||
|
* driver then passes through unchanged: `null`, a string, or a `Buffer` copy of a `Uint8Array`.
|
||||||
|
* Anything else, including `undefined`, `Date`, plain objects, other class instances, functions,
|
||||||
|
* symbols, sparse or ragged arrays, and arrays deeper than PostgreSQL allows, is refused before
|
||||||
|
* anything reaches PostgreSQL. The error never names the value.
|
||||||
|
*/
|
||||||
|
export function encodeModuleDatabaseParameters(values: unknown): (string | Buffer | null)[] {
|
||||||
|
if (values === undefined) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
if (!Array.isArray(values) || values.length > MODULE_DATABASE_MAX_PARAMETERS) {
|
||||||
|
throw parameterInvalid();
|
||||||
|
}
|
||||||
|
const encoded: (string | Buffer | null)[] = [];
|
||||||
|
for (let index = 0; index < values.length; index += 1) {
|
||||||
|
if (!Object.hasOwn(values, index)) {
|
||||||
|
throw parameterInvalid();
|
||||||
|
}
|
||||||
|
const value: unknown = values[index];
|
||||||
|
if (value === null) {
|
||||||
|
encoded.push(null);
|
||||||
|
} else if (value instanceof Uint8Array) {
|
||||||
|
// A copy: the driver serializes when the statement is sent, which may be after the caller
|
||||||
|
// has changed its own bytes.
|
||||||
|
encoded.push(Buffer.from(value));
|
||||||
|
} else if (Array.isArray(value)) {
|
||||||
|
encoded.push(arrayLiteral(value, 1));
|
||||||
|
} else if (isScalar(value)) {
|
||||||
|
encoded.push(scalarText(value as boolean | number | bigint | string));
|
||||||
|
} else {
|
||||||
|
throw parameterInvalid();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return encoded;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Results -----------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
type ScalarDecoder = (text: string) => ModuleDatabaseValue;
|
||||||
|
|
||||||
|
const asText: ScalarDecoder = (text) => text;
|
||||||
|
const asBoolean: ScalarDecoder = (text) => text === "t";
|
||||||
|
const asInteger: ScalarDecoder = (text) => Number.parseInt(text, 10);
|
||||||
|
// `Number` reads PostgreSQL's NaN, Infinity, -Infinity, and exponent forms. With
|
||||||
|
// `extra_float_digits` pinned to 1 the text is the shortest form that round-trips exactly.
|
||||||
|
const asFloat: ScalarDecoder = (text) => Number(text);
|
||||||
|
|
||||||
|
function hexDigit(code: number): number {
|
||||||
|
if (code >= 48 && code <= 57) {
|
||||||
|
return code - 48;
|
||||||
|
}
|
||||||
|
if (code >= 97 && code <= 102) {
|
||||||
|
return code - 87;
|
||||||
|
}
|
||||||
|
if (code >= 65 && code <= 70) {
|
||||||
|
return code - 55;
|
||||||
|
}
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `bytea` text to bytes. The executor pins `bytea_output` to `hex`; the older `escape` form is read
|
||||||
|
* too, so a transaction that changed the setting still decodes faithfully rather than failing.
|
||||||
|
* Always a plain `Uint8Array`, never a `Buffer`.
|
||||||
|
*/
|
||||||
|
const asBytes: ScalarDecoder = (text) => {
|
||||||
|
if (text.startsWith("\\x")) {
|
||||||
|
const length = (text.length - 2) / 2;
|
||||||
|
const bytes = new Uint8Array(length);
|
||||||
|
for (let index = 0; index < length; index += 1) {
|
||||||
|
const high = hexDigit(text.charCodeAt(2 + index * 2));
|
||||||
|
const low = hexDigit(text.charCodeAt(3 + index * 2));
|
||||||
|
bytes[index] = high * 16 + low;
|
||||||
|
}
|
||||||
|
return bytes;
|
||||||
|
}
|
||||||
|
const bytes: number[] = [];
|
||||||
|
for (let index = 0; index < text.length; index += 1) {
|
||||||
|
const code = text.charCodeAt(index);
|
||||||
|
if (code === 92 && text.charCodeAt(index + 1) === 92) {
|
||||||
|
bytes.push(92);
|
||||||
|
index += 1;
|
||||||
|
} else if (code === 92) {
|
||||||
|
bytes.push(Number.parseInt(text.slice(index + 1, index + 4), 8));
|
||||||
|
index += 3;
|
||||||
|
} else {
|
||||||
|
bytes.push(code);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Uint8Array.from(bytes);
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Built-in scalar type OIDs with a decoding other than "PostgreSQL's text output, unchanged". Every
|
||||||
|
* type not listed here, `int8`, `numeric`, date and time types, `json`, and `jsonb` included,
|
||||||
|
* arrives as its exact text. */
|
||||||
|
const scalarDecoders: ReadonlyMap<number, ScalarDecoder> = new Map([
|
||||||
|
[16, asBoolean], // bool
|
||||||
|
[17, asBytes], // bytea
|
||||||
|
[21, asInteger], // int2
|
||||||
|
[23, asInteger], // int4
|
||||||
|
[26, asInteger], // oid
|
||||||
|
[700, asFloat], // float4
|
||||||
|
[701, asFloat], // float8
|
||||||
|
]);
|
||||||
|
|
||||||
|
/** Built-in array type OIDs, each with its element type OID. An array of a type not listed here
|
||||||
|
* (an enum, a domain, an extension type) arrives as its text, like any other type. */
|
||||||
|
const arrayElementTypes: ReadonlyMap<number, number> = new Map([
|
||||||
|
[1000, 16], // bool[]
|
||||||
|
[1001, 17], // bytea[]
|
||||||
|
[1002, 18], // "char"[]
|
||||||
|
[1003, 19], // name[]
|
||||||
|
[1005, 21], // int2[]
|
||||||
|
[1007, 23], // int4[]
|
||||||
|
[1009, 25], // text[]
|
||||||
|
[1014, 1042], // bpchar[]
|
||||||
|
[1015, 1043], // varchar[]
|
||||||
|
[1016, 20], // int8[]
|
||||||
|
[1021, 700], // float4[]
|
||||||
|
[1022, 701], // float8[]
|
||||||
|
[1028, 26], // oid[]
|
||||||
|
[1115, 1114], // timestamp[]
|
||||||
|
[1182, 1082], // date[]
|
||||||
|
[1183, 1083], // time[]
|
||||||
|
[1185, 1184], // timestamptz[]
|
||||||
|
[1187, 1186], // interval[]
|
||||||
|
[1231, 1700], // numeric[]
|
||||||
|
[1270, 1266], // timetz[]
|
||||||
|
[2951, 2950], // uuid[]
|
||||||
|
[199, 114], // json[]
|
||||||
|
[3807, 3802], // jsonb[]
|
||||||
|
]);
|
||||||
|
|
||||||
|
class ArrayTextError extends Error {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parses PostgreSQL's array text output into nested arrays, each element decoded by `decode`.
|
||||||
|
* Unquoted `NULL` is null; a quoted `"NULL"` is the string. A non-default lower bound
|
||||||
|
* (`[0:1]={…}`) is not represented: a JavaScript array is a zero-based list. Every element type this
|
||||||
|
* decodes uses `,` as its delimiter.
|
||||||
|
*/
|
||||||
|
export function parsePostgresArrayText(text: string, decode: ScalarDecoder): ModuleDatabaseValue[] {
|
||||||
|
let position = 0;
|
||||||
|
if (text.startsWith("[")) {
|
||||||
|
const equals = text.indexOf("=");
|
||||||
|
if (equals === -1) {
|
||||||
|
throw new ArrayTextError();
|
||||||
|
}
|
||||||
|
position = equals + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseArray(): ModuleDatabaseValue[] {
|
||||||
|
if (text[position] !== "{") {
|
||||||
|
throw new ArrayTextError();
|
||||||
|
}
|
||||||
|
position += 1;
|
||||||
|
const items: ModuleDatabaseValue[] = [];
|
||||||
|
if (text[position] === "}") {
|
||||||
|
position += 1;
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
for (;;) {
|
||||||
|
const character = text[position];
|
||||||
|
if (character === "{") {
|
||||||
|
items.push(parseArray());
|
||||||
|
} else if (character === '"') {
|
||||||
|
position += 1;
|
||||||
|
let element = "";
|
||||||
|
for (;;) {
|
||||||
|
const next = text[position];
|
||||||
|
if (next === undefined) {
|
||||||
|
throw new ArrayTextError();
|
||||||
|
}
|
||||||
|
if (next === "\\") {
|
||||||
|
element += text[position + 1] ?? "";
|
||||||
|
position += 2;
|
||||||
|
} else if (next === '"') {
|
||||||
|
position += 1;
|
||||||
|
break;
|
||||||
|
} else {
|
||||||
|
element += next;
|
||||||
|
position += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
items.push(decode(element));
|
||||||
|
} else {
|
||||||
|
const start = position;
|
||||||
|
while (position < text.length && text[position] !== "," && text[position] !== "}") {
|
||||||
|
position += 1;
|
||||||
|
}
|
||||||
|
const element = text.slice(start, position);
|
||||||
|
items.push(element.toUpperCase() === "NULL" ? null : decode(element));
|
||||||
|
}
|
||||||
|
if (text[position] === ",") {
|
||||||
|
position += 1;
|
||||||
|
} else if (text[position] === "}") {
|
||||||
|
position += 1;
|
||||||
|
return items;
|
||||||
|
} else {
|
||||||
|
throw new ArrayTextError();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = parseArray();
|
||||||
|
if (position !== text.length) {
|
||||||
|
throw new ArrayTextError();
|
||||||
|
}
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
function scalarDecoder(typeOid: number): ScalarDecoder {
|
||||||
|
return scalarDecoders.get(typeOid) ?? asText;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Decodes one non-null column value from its PostgreSQL text by the column's type OID. */
|
||||||
|
export function decodeModuleDatabaseValue(typeOid: number, text: string): ModuleDatabaseValue {
|
||||||
|
const elementType = arrayElementTypes.get(typeOid);
|
||||||
|
if (elementType === undefined) {
|
||||||
|
return scalarDecoder(typeOid)(text);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return parsePostgresArrayText(text, scalarDecoder(elementType));
|
||||||
|
} catch {
|
||||||
|
// PostgreSQL produced this text, so it cannot be malformed; should it ever be, the column still
|
||||||
|
// arrives faithfully, as text, rather than as a guess.
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The `types` object handed to `pg` for every ABI statement. Its parser returns the raw text for
|
||||||
|
* every type, so the driver's global registry, which module code could change, is never consulted
|
||||||
|
* and the ABI decodes from text itself. It is an empty array as well because the driver also reads
|
||||||
|
* the same option as the Parse message's parameter-type list: empty, so PostgreSQL infers every
|
||||||
|
* parameter type from the statement.
|
||||||
|
*/
|
||||||
|
export const rawTextTypes: { getTypeParser: () => (text: string) => string } & never[] =
|
||||||
|
Object.freeze(
|
||||||
|
Object.assign([] as never[], {
|
||||||
|
getTypeParser: () => (text: string) => text,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
/** Decodes one positional row of raw driver text. */
|
||||||
|
export function decodeModuleDatabaseRow(
|
||||||
|
fields: readonly ModuleDatabaseField[],
|
||||||
|
raw: readonly unknown[],
|
||||||
|
): ModuleDatabaseValue[] {
|
||||||
|
return fields.map((field, index) => {
|
||||||
|
const value = raw[index];
|
||||||
|
return typeof value === "string" ? decodeModuleDatabaseValue(field.typeOid, value) : null;
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,596 @@
|
|||||||
|
import {
|
||||||
|
MODULE_DATABASE_ABI_VERSION,
|
||||||
|
type ModuleDatabaseArrayRow,
|
||||||
|
ModuleDatabaseError,
|
||||||
|
type ModuleDatabaseErrorCode,
|
||||||
|
type ModuleDatabaseExecutor,
|
||||||
|
type ModuleDatabaseField,
|
||||||
|
type ModuleDatabaseObjectRow,
|
||||||
|
type ModuleDatabaseQueryable,
|
||||||
|
type ModuleDatabaseQueryResult,
|
||||||
|
type ModuleDatabaseTransaction,
|
||||||
|
type ModuleId,
|
||||||
|
moduleIdSchema,
|
||||||
|
} from "@ekklesia/contracts";
|
||||||
|
import { DatabaseError, type Pool, type PoolClient, type QueryArrayConfig } from "pg";
|
||||||
|
import {
|
||||||
|
decodeModuleDatabaseRow,
|
||||||
|
encodeModuleDatabaseParameters,
|
||||||
|
rawTextTypes,
|
||||||
|
} from "./database-abi-values.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Core's Module Database ABI v1 executor (ADR 0050).
|
||||||
|
*
|
||||||
|
* A module hands Core SQL text, ordered parameter values, and a row mode; Core hands back decoded
|
||||||
|
* rows, column metadata, and stable errors. No ORM, driver, pool, client, or connection object ever
|
||||||
|
* crosses in either direction. The executor is built over the one pool the API already owns
|
||||||
|
* (ADR 0016) and never closes it.
|
||||||
|
*
|
||||||
|
* This is a correctness and compatibility boundary, **not a security sandbox**: a trusted module can
|
||||||
|
* still send any SQL its database role permits. Nothing here inspects table names.
|
||||||
|
*
|
||||||
|
* ### Connections and session state
|
||||||
|
*
|
||||||
|
* Every ABI operation, a top-level statement or a whole transaction, borrows a connection from the
|
||||||
|
* pool, pins the session settings the ABI's decoding depends on, does its work, and resets the
|
||||||
|
* session with `DISCARD ALL` before returning the connection. So no setting, temporary table,
|
||||||
|
* listener, advisory lock, prepared statement, or role change a module makes survives the operation
|
||||||
|
* that made it, and nothing the ABI pinned reaches the pool's other users. A connection that cannot
|
||||||
|
* be reset cleanly, or that is not idle when the operation ends, is destroyed rather than returned.
|
||||||
|
* `DISCARD ALL` also deallocates named prepared statements; Core uses none on the shared pool.
|
||||||
|
*
|
||||||
|
* ### One statement per `query`
|
||||||
|
*
|
||||||
|
* Every ABI statement is sent with PostgreSQL's extended query protocol, forced with the driver's
|
||||||
|
* `queryMode: "extended"` even when there are no parameters. PostgreSQL itself refuses a Parse
|
||||||
|
* message holding more than one statement (SQLSTATE 42601), using its own SQL grammar, so comments,
|
||||||
|
* quoted literals, and semicolons inside them are never mistaken for a second statement. Core does
|
||||||
|
* not parse SQL. A statement text that holds no statement at all is refused after PostgreSQL reports
|
||||||
|
* it empty.
|
||||||
|
*
|
||||||
|
* ### Transaction control belongs to the callbacks
|
||||||
|
*
|
||||||
|
* Transaction-control SQL (`BEGIN`, `START TRANSACTION`, `COMMIT`, `ROLLBACK`, `SAVEPOINT`,
|
||||||
|
* `RELEASE SAVEPOINT`, `PREPARE TRANSACTION`, and their aliases) is forbidden through `query`: a
|
||||||
|
* conformance rule for trusted module code, not a security guarantee. Core cannot refuse such a
|
||||||
|
* statement beforehand without parsing SQL, which it does not do, and it cannot undo one PostgreSQL
|
||||||
|
* has already executed: a `COMMIT` sent through a transaction's `query` has committed by the time
|
||||||
|
* Core sees it. What Core does is detect the violation afterwards, from what PostgreSQL itself
|
||||||
|
* reports (the statement's command tag, and the protocol's transaction status), and fail safely:
|
||||||
|
* the call rejects, an enclosing transaction is never committed by Core and rejects too, and the
|
||||||
|
* connection is destroyed rather than returned to the pool. The commit and rollback guarantees of
|
||||||
|
* the transaction API apply to conforming use.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** The settings the ABI's decoding and determinism depend on, pinned for every ABI operation. */
|
||||||
|
const pinSessionStatement =
|
||||||
|
"SET TimeZone TO 'UTC'; SET DateStyle TO 'ISO, MDY'; SET IntervalStyle TO 'postgres'; " +
|
||||||
|
"SET bytea_output TO 'hex'; SET extra_float_digits TO 1";
|
||||||
|
const resetSessionStatement = "DISCARD ALL";
|
||||||
|
|
||||||
|
const transactionControlCommands: ReadonlySet<string> = new Set([
|
||||||
|
"BEGIN",
|
||||||
|
"COMMIT",
|
||||||
|
"PREPARE",
|
||||||
|
"RELEASE",
|
||||||
|
"ROLLBACK",
|
||||||
|
"SAVEPOINT",
|
||||||
|
"START",
|
||||||
|
]);
|
||||||
|
|
||||||
|
const isolationStatements: ReadonlyMap<unknown, string> = new Map([
|
||||||
|
["read committed", "READ COMMITTED"],
|
||||||
|
["repeatable read", "REPEATABLE READ"],
|
||||||
|
["serializable", "SERIALIZABLE"],
|
||||||
|
]);
|
||||||
|
|
||||||
|
/** `pg` 8.22 reports the protocol's ReadyForQuery transaction status through this method, which
|
||||||
|
* its type declarations omit. `acquire` refuses a client without it rather than guessing. */
|
||||||
|
type AbiClient = PoolClient & { getTransactionStatus(): string | null };
|
||||||
|
|
||||||
|
interface PreparedQuery {
|
||||||
|
readonly config: QueryArrayConfig;
|
||||||
|
readonly rowMode: "array" | "object";
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RawResult {
|
||||||
|
readonly command: string | null;
|
||||||
|
readonly fields: readonly { readonly dataTypeID: number; readonly name: string }[];
|
||||||
|
readonly rowCount: number | null;
|
||||||
|
readonly rows: readonly unknown[][];
|
||||||
|
}
|
||||||
|
|
||||||
|
function databaseError(code: ModuleDatabaseErrorCode, cause?: unknown): ModuleDatabaseError {
|
||||||
|
// Only PostgreSQL's own SQLSTATE survives. The driver's message, detail, hint, position, and
|
||||||
|
// object names, and every non-PostgreSQL error, are dropped here.
|
||||||
|
if (cause instanceof DatabaseError && typeof cause.code === "string") {
|
||||||
|
return new ModuleDatabaseError(code, { sqlState: cause.code });
|
||||||
|
}
|
||||||
|
return new ModuleDatabaseError(code);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A PostgreSQL error leaves the connection usable; anything else (a dropped connection, a
|
||||||
|
* client-side timeout) may leave it mid-protocol, so it must be destroyed. */
|
||||||
|
function connectionSurvives(error: unknown): boolean {
|
||||||
|
return error instanceof DatabaseError;
|
||||||
|
}
|
||||||
|
|
||||||
|
function prepareQuery(request: unknown): PreparedQuery {
|
||||||
|
if (typeof request !== "object" || request === null || Array.isArray(request)) {
|
||||||
|
throw new ModuleDatabaseError("MODULE_DATABASE_REQUEST_INVALID");
|
||||||
|
}
|
||||||
|
for (const key of Object.keys(request)) {
|
||||||
|
if (key !== "text" && key !== "values" && key !== "rowMode") {
|
||||||
|
throw new ModuleDatabaseError("MODULE_DATABASE_REQUEST_INVALID");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const { rowMode, text, values } = request as Record<string, unknown>;
|
||||||
|
if (typeof text !== "string" || text.length === 0) {
|
||||||
|
throw new ModuleDatabaseError("MODULE_DATABASE_REQUEST_INVALID");
|
||||||
|
}
|
||||||
|
if (rowMode !== undefined && rowMode !== "object" && rowMode !== "array") {
|
||||||
|
throw new ModuleDatabaseError("MODULE_DATABASE_REQUEST_INVALID");
|
||||||
|
}
|
||||||
|
const config = {
|
||||||
|
queryMode: "extended",
|
||||||
|
rowMode: "array",
|
||||||
|
text,
|
||||||
|
types: rawTextTypes,
|
||||||
|
values: encodeModuleDatabaseParameters(values),
|
||||||
|
};
|
||||||
|
return { config: config as unknown as QueryArrayConfig, rowMode: rowMode ?? "object" };
|
||||||
|
}
|
||||||
|
|
||||||
|
function beginStatement(options: unknown): string {
|
||||||
|
if (options === undefined) {
|
||||||
|
return "BEGIN ISOLATION LEVEL READ COMMITTED READ WRITE";
|
||||||
|
}
|
||||||
|
if (typeof options !== "object" || options === null || Array.isArray(options)) {
|
||||||
|
throw new ModuleDatabaseError("MODULE_DATABASE_TRANSACTION_OPTION_INVALID");
|
||||||
|
}
|
||||||
|
for (const key of Object.keys(options)) {
|
||||||
|
if (key !== "isolation" && key !== "readOnly") {
|
||||||
|
throw new ModuleDatabaseError("MODULE_DATABASE_TRANSACTION_OPTION_INVALID");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const { isolation, readOnly } = options as Record<string, unknown>;
|
||||||
|
const level = isolation === undefined ? "READ COMMITTED" : isolationStatements.get(isolation);
|
||||||
|
if (level === undefined || (readOnly !== undefined && typeof readOnly !== "boolean")) {
|
||||||
|
throw new ModuleDatabaseError("MODULE_DATABASE_TRANSACTION_OPTION_INVALID");
|
||||||
|
}
|
||||||
|
return `BEGIN ISOLATION LEVEL ${level} ${readOnly === true ? "READ ONLY" : "READ WRITE"}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildResult(
|
||||||
|
raw: RawResult,
|
||||||
|
rowMode: "array" | "object",
|
||||||
|
): ModuleDatabaseQueryResult<ModuleDatabaseArrayRow | ModuleDatabaseObjectRow> {
|
||||||
|
if (typeof raw.command !== "string" || raw.command === "") {
|
||||||
|
// PostgreSQL answered with EmptyQueryResponse: the text held no statement.
|
||||||
|
throw new ModuleDatabaseError("MODULE_DATABASE_REQUEST_INVALID");
|
||||||
|
}
|
||||||
|
const fields: readonly ModuleDatabaseField[] = Object.freeze(
|
||||||
|
raw.fields.map((field) => Object.freeze({ name: field.name, typeOid: field.dataTypeID })),
|
||||||
|
);
|
||||||
|
const decoded = raw.rows.map((row) => decodeModuleDatabaseRow(fields, row));
|
||||||
|
let rows: readonly (ModuleDatabaseArrayRow | ModuleDatabaseObjectRow)[];
|
||||||
|
if (rowMode === "array") {
|
||||||
|
rows = decoded;
|
||||||
|
} else {
|
||||||
|
if (new Set(fields.map((field) => field.name)).size !== fields.length) {
|
||||||
|
// Object rows cannot hold two columns with one name; refusing beats dropping one.
|
||||||
|
throw new ModuleDatabaseError("MODULE_DATABASE_REQUEST_INVALID");
|
||||||
|
}
|
||||||
|
// `Object.fromEntries` defines properties, so a column named `__proto__` stays a column.
|
||||||
|
rows = decoded.map((row) =>
|
||||||
|
Object.fromEntries(fields.map((field, index) => [field.name, row[index] ?? null])),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return Object.freeze({
|
||||||
|
command: raw.command,
|
||||||
|
fields,
|
||||||
|
rowCount: raw.rowCount ?? null,
|
||||||
|
rows: Object.freeze(rows),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A borrowed client's `error` events are already reported through the statement they interrupt.
|
||||||
|
* The pool only listens to idle clients, so without this a connection the server terminates while
|
||||||
|
* borrowed would surface as an unhandled `error` event. */
|
||||||
|
function ignoreClientError(): void {}
|
||||||
|
|
||||||
|
function destroy(client: AbiClient): void {
|
||||||
|
client.removeListener("error", ignoreClientError);
|
||||||
|
client.release(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function acquire(pool: Pool, failure: ModuleDatabaseErrorCode): Promise<AbiClient> {
|
||||||
|
let client: AbiClient;
|
||||||
|
try {
|
||||||
|
client = (await pool.connect()) as AbiClient;
|
||||||
|
} catch (error) {
|
||||||
|
throw databaseError(failure, error);
|
||||||
|
}
|
||||||
|
client.on("error", ignoreClientError);
|
||||||
|
if (typeof client.getTransactionStatus !== "function") {
|
||||||
|
destroy(client);
|
||||||
|
throw new ModuleDatabaseError(failure);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await client.query(pinSessionStatement);
|
||||||
|
} catch (error) {
|
||||||
|
destroy(client);
|
||||||
|
throw databaseError(failure, error);
|
||||||
|
}
|
||||||
|
return client;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns a connection to the pool only when it is idle and resets cleanly; destroys it
|
||||||
|
* otherwise. Never throws. */
|
||||||
|
async function relinquish(client: AbiClient, healthy: boolean): Promise<void> {
|
||||||
|
if (!healthy || client.getTransactionStatus() !== "I") {
|
||||||
|
destroy(client);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await client.query(resetSessionStatement);
|
||||||
|
} catch {
|
||||||
|
destroy(client);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
client.removeListener("error", ignoreClientError);
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One open scope: a transaction or a savepoint inside it. */
|
||||||
|
interface Scope {
|
||||||
|
activeChild: Scope | undefined;
|
||||||
|
readonly children: Set<Scope>;
|
||||||
|
closed: boolean;
|
||||||
|
/** Operations started through this scope's executor that have not settled yet. */
|
||||||
|
pending: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TransactionState {
|
||||||
|
readonly client: AbiClient;
|
||||||
|
healthy: boolean;
|
||||||
|
/** Set when the transaction can no longer be committed: transaction control sent through
|
||||||
|
* `query`, or a savepoint that could not be undone. */
|
||||||
|
failure: ModuleDatabaseError | undefined;
|
||||||
|
finished: boolean;
|
||||||
|
/** Settled-never-rejecting shadows of every statement sent on the connection. */
|
||||||
|
readonly inflight: Set<Promise<void>>;
|
||||||
|
savepoints: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function newScope(parent?: Scope): Scope {
|
||||||
|
const scope: Scope = { activeChild: undefined, children: new Set(), closed: false, pending: 0 };
|
||||||
|
parent?.children.add(scope);
|
||||||
|
return scope;
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeScope(scope: Scope): void {
|
||||||
|
scope.closed = true;
|
||||||
|
for (const child of scope.children) {
|
||||||
|
closeScope(child);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertActive(scope: Scope): void {
|
||||||
|
if (scope.closed) {
|
||||||
|
throw new ModuleDatabaseError("MODULE_DATABASE_SCOPE_CLOSED");
|
||||||
|
}
|
||||||
|
if (scope.activeChild !== undefined) {
|
||||||
|
throw new ModuleDatabaseError("MODULE_DATABASE_SCOPE_INACTIVE");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Once a transaction can no longer be committed, its scopes send nothing more. After a
|
||||||
|
* transaction-control violation in particular, a later statement would otherwise run outside any
|
||||||
|
* transaction, as its own autocommitted statement. */
|
||||||
|
function assertUsable(state: TransactionState): void {
|
||||||
|
if (state.failure !== undefined) {
|
||||||
|
throw new ModuleDatabaseError(state.failure.code);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function track<T>(state: TransactionState, promise: Promise<T>): Promise<T> {
|
||||||
|
const shadow = promise.then(
|
||||||
|
() => undefined,
|
||||||
|
() => undefined,
|
||||||
|
);
|
||||||
|
state.inflight.add(shadow);
|
||||||
|
void shadow.then(() => state.inflight.delete(shadow));
|
||||||
|
return promise;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Waits until every statement already sent has settled, including any sent while waiting. */
|
||||||
|
async function drain(state: TransactionState): Promise<void> {
|
||||||
|
while (state.inflight.size > 0) {
|
||||||
|
await Promise.all([...state.inflight]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Runs one of Core's own transaction-control statements. Refuses, without sending anything, once
|
||||||
|
* the transaction has finished. */
|
||||||
|
async function control(state: TransactionState, statement: string): Promise<void> {
|
||||||
|
if (state.finished) {
|
||||||
|
throw new ModuleDatabaseError("MODULE_DATABASE_SCOPE_CLOSED");
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await track(state, state.client.query(statement));
|
||||||
|
} catch (error) {
|
||||||
|
if (!connectionSurvives(error)) {
|
||||||
|
state.healthy = false;
|
||||||
|
}
|
||||||
|
throw databaseError("MODULE_DATABASE_TRANSACTION_FAILED", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function rollback(state: TransactionState): Promise<void> {
|
||||||
|
try {
|
||||||
|
await state.client.query("ROLLBACK");
|
||||||
|
} catch {
|
||||||
|
state.healthy = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type Outcome<T> = { readonly value: T } | { readonly error: unknown };
|
||||||
|
|
||||||
|
async function settle<T>(run: () => Promise<T> | T): Promise<Outcome<T>> {
|
||||||
|
try {
|
||||||
|
return { value: await run() };
|
||||||
|
} catch (error) {
|
||||||
|
return { error };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const moduleByExecutor = new WeakMap<object, ModuleId>();
|
||||||
|
|
||||||
|
/** Which module an ABI executor, or a transaction or savepoint executor derived from it, belongs
|
||||||
|
* to. For Core's own logging, diagnostics, and attribution; never exposed to modules. */
|
||||||
|
export function moduleDatabaseExecutorModuleId(executor: object): ModuleId | undefined {
|
||||||
|
return moduleByExecutor.get(executor);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ModuleDatabaseExecutorOptions {
|
||||||
|
/** The module this executor serves. Carried for attribution only: the ABI enforces no table
|
||||||
|
* ownership at run time (ADR 0050). */
|
||||||
|
readonly moduleId: ModuleId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates one module's ABI v1 executor over the API's existing pool. Synchronous and free of I/O:
|
||||||
|
* nothing touches the database until the module calls it. The returned object, and every
|
||||||
|
* transaction and savepoint executor it hands out, is frozen and holds exactly the ABI's members.
|
||||||
|
*/
|
||||||
|
export function createModuleDatabaseExecutor(
|
||||||
|
pool: Pool,
|
||||||
|
options: ModuleDatabaseExecutorOptions,
|
||||||
|
): ModuleDatabaseExecutor {
|
||||||
|
const parsedModuleId = moduleIdSchema.safeParse(options.moduleId);
|
||||||
|
if (!parsedModuleId.success) {
|
||||||
|
throw new TypeError("A module database executor needs a valid module ID.");
|
||||||
|
}
|
||||||
|
const moduleId = parsedModuleId.data;
|
||||||
|
|
||||||
|
async function query(
|
||||||
|
request: unknown,
|
||||||
|
): Promise<ModuleDatabaseQueryResult<ModuleDatabaseArrayRow | ModuleDatabaseObjectRow>> {
|
||||||
|
const prepared = prepareQuery(request);
|
||||||
|
const client = await acquire(pool, "MODULE_DATABASE_QUERY_FAILED");
|
||||||
|
let healthy = true;
|
||||||
|
try {
|
||||||
|
let raw: RawResult;
|
||||||
|
try {
|
||||||
|
raw = (await client.query(prepared.config)) as unknown as RawResult;
|
||||||
|
} catch (error) {
|
||||||
|
healthy = connectionSurvives(error);
|
||||||
|
throw databaseError("MODULE_DATABASE_QUERY_FAILED", error);
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
(raw.command !== null && transactionControlCommands.has(raw.command)) ||
|
||||||
|
client.getTransactionStatus() !== "I"
|
||||||
|
) {
|
||||||
|
// A transaction-control violation. PostgreSQL has already run it; the connection is
|
||||||
|
// destroyed below rather than returned in a state Core did not choose.
|
||||||
|
healthy = false;
|
||||||
|
throw new ModuleDatabaseError("MODULE_DATABASE_REQUEST_INVALID");
|
||||||
|
}
|
||||||
|
return buildResult(raw, prepared.rowMode);
|
||||||
|
} finally {
|
||||||
|
await relinquish(client, healthy);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function scopeExecutor(state: TransactionState, scope: Scope): ModuleDatabaseTransaction {
|
||||||
|
async function scopeQuery(
|
||||||
|
request: unknown,
|
||||||
|
): Promise<ModuleDatabaseQueryResult<ModuleDatabaseArrayRow | ModuleDatabaseObjectRow>> {
|
||||||
|
assertActive(scope);
|
||||||
|
assertUsable(state);
|
||||||
|
const prepared = prepareQuery(request);
|
||||||
|
scope.pending += 1;
|
||||||
|
try {
|
||||||
|
let raw: RawResult;
|
||||||
|
try {
|
||||||
|
raw = (await track(state, state.client.query(prepared.config))) as unknown as RawResult;
|
||||||
|
} catch (error) {
|
||||||
|
if (!connectionSurvives(error)) {
|
||||||
|
state.healthy = false;
|
||||||
|
}
|
||||||
|
throw databaseError("MODULE_DATABASE_QUERY_FAILED", error);
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
(raw.command !== null && transactionControlCommands.has(raw.command)) ||
|
||||||
|
state.client.getTransactionStatus() === "I"
|
||||||
|
) {
|
||||||
|
// A transaction-control violation. PostgreSQL has already run it, and Core cannot undo
|
||||||
|
// that; it can only never commit this transaction itself, and destroy the connection.
|
||||||
|
state.failure ??= new ModuleDatabaseError("MODULE_DATABASE_REQUEST_INVALID");
|
||||||
|
state.healthy = false;
|
||||||
|
throw new ModuleDatabaseError("MODULE_DATABASE_REQUEST_INVALID");
|
||||||
|
}
|
||||||
|
return buildResult(raw, prepared.rowMode);
|
||||||
|
} finally {
|
||||||
|
scope.pending -= 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function savepoint<T>(
|
||||||
|
callback: (savepoint: ModuleDatabaseTransaction) => Promise<T> | T,
|
||||||
|
): Promise<T> {
|
||||||
|
assertActive(scope);
|
||||||
|
assertUsable(state);
|
||||||
|
if (typeof callback !== "function") {
|
||||||
|
throw new ModuleDatabaseError("MODULE_DATABASE_REQUEST_INVALID");
|
||||||
|
}
|
||||||
|
scope.pending += 1;
|
||||||
|
const child = newScope(scope);
|
||||||
|
scope.activeChild = child;
|
||||||
|
state.savepoints += 1;
|
||||||
|
// Core's own identifier: a fixed prefix and a counter, never anything a module supplied.
|
||||||
|
const name = `ekklesia_savepoint_${state.savepoints}`;
|
||||||
|
try {
|
||||||
|
await control(state, `SAVEPOINT ${name}`);
|
||||||
|
if (child.closed) {
|
||||||
|
throw new ModuleDatabaseError("MODULE_DATABASE_SCOPE_CLOSED");
|
||||||
|
}
|
||||||
|
const outcome = await settle(() => callback(scopeExecutor(state, child)));
|
||||||
|
const outstanding = child.pending > 0;
|
||||||
|
closeScope(child);
|
||||||
|
await drain(state);
|
||||||
|
if (scope.closed) {
|
||||||
|
// The enclosing transaction finished first; it rolls back, and nothing more is sent.
|
||||||
|
throw new ModuleDatabaseError("MODULE_DATABASE_SCOPE_CLOSED");
|
||||||
|
}
|
||||||
|
if ("error" in outcome) {
|
||||||
|
// A rejected callback's own error stays the error, even if sibling work it started was
|
||||||
|
// still settling: that work has been drained, and is undone here with everything else.
|
||||||
|
try {
|
||||||
|
await control(state, `ROLLBACK TO SAVEPOINT ${name}`);
|
||||||
|
await control(state, `RELEASE SAVEPOINT ${name}`);
|
||||||
|
} catch (error) {
|
||||||
|
state.failure ??= new ModuleDatabaseError("MODULE_DATABASE_TRANSACTION_FAILED");
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
throw outcome.error;
|
||||||
|
}
|
||||||
|
if (outstanding) {
|
||||||
|
// A callback that resolved with work outstanding forgot an await. Roll back to the
|
||||||
|
// savepoint and never release it.
|
||||||
|
try {
|
||||||
|
await control(state, `ROLLBACK TO SAVEPOINT ${name}`);
|
||||||
|
} catch (error) {
|
||||||
|
state.failure ??= new ModuleDatabaseError("MODULE_DATABASE_TRANSACTION_FAILED");
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
throw new ModuleDatabaseError("MODULE_DATABASE_SCOPE_WORK_OUTSTANDING");
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await control(state, `RELEASE SAVEPOINT ${name}`);
|
||||||
|
} catch (releaseError) {
|
||||||
|
// Typically a failed statement left the transaction aborted. Undo the savepoint so the
|
||||||
|
// transaction stays usable, and report that this savepoint did not complete.
|
||||||
|
try {
|
||||||
|
await control(state, `ROLLBACK TO SAVEPOINT ${name}`);
|
||||||
|
await control(state, `RELEASE SAVEPOINT ${name}`);
|
||||||
|
} catch {
|
||||||
|
state.failure ??= new ModuleDatabaseError("MODULE_DATABASE_TRANSACTION_FAILED");
|
||||||
|
}
|
||||||
|
throw releaseError;
|
||||||
|
}
|
||||||
|
return (outcome as { readonly value: T }).value;
|
||||||
|
} finally {
|
||||||
|
closeScope(child);
|
||||||
|
if (scope.activeChild === child) {
|
||||||
|
scope.activeChild = undefined;
|
||||||
|
}
|
||||||
|
scope.pending -= 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const executor = Object.freeze({
|
||||||
|
abiVersion: MODULE_DATABASE_ABI_VERSION,
|
||||||
|
query: scopeQuery as ModuleDatabaseQueryable["query"],
|
||||||
|
savepoint,
|
||||||
|
});
|
||||||
|
moduleByExecutor.set(executor, moduleId);
|
||||||
|
return executor;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function transaction<T>(
|
||||||
|
callback: (transaction: ModuleDatabaseTransaction) => Promise<T> | T,
|
||||||
|
options?: unknown,
|
||||||
|
): Promise<T> {
|
||||||
|
if (typeof callback !== "function") {
|
||||||
|
throw new ModuleDatabaseError("MODULE_DATABASE_REQUEST_INVALID");
|
||||||
|
}
|
||||||
|
const begin = beginStatement(options);
|
||||||
|
const client = await acquire(pool, "MODULE_DATABASE_TRANSACTION_FAILED");
|
||||||
|
const state: TransactionState = {
|
||||||
|
client,
|
||||||
|
failure: undefined,
|
||||||
|
finished: false,
|
||||||
|
healthy: true,
|
||||||
|
inflight: new Set(),
|
||||||
|
savepoints: 0,
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
try {
|
||||||
|
await client.query(begin);
|
||||||
|
} catch (error) {
|
||||||
|
state.healthy = connectionSurvives(error);
|
||||||
|
throw databaseError("MODULE_DATABASE_TRANSACTION_FAILED", error);
|
||||||
|
}
|
||||||
|
const scope = newScope();
|
||||||
|
const outcome = await settle(() => callback(scopeExecutor(state, scope)));
|
||||||
|
const outstanding = scope.pending > 0;
|
||||||
|
closeScope(scope);
|
||||||
|
await drain(state);
|
||||||
|
state.finished = true;
|
||||||
|
|
||||||
|
if ("error" in outcome) {
|
||||||
|
// The callback's own error stays primary, even when sibling work it started (say, the
|
||||||
|
// other half of a `Promise.all`) was still settling. That work has been drained, and rolls
|
||||||
|
// back with the rest; outstanding work never leads to a commit.
|
||||||
|
await rollback(state);
|
||||||
|
throw outcome.error;
|
||||||
|
}
|
||||||
|
if (outstanding) {
|
||||||
|
// The callback resolved with work it never awaited: a forgotten `await`.
|
||||||
|
await rollback(state);
|
||||||
|
throw new ModuleDatabaseError("MODULE_DATABASE_SCOPE_WORK_OUTSTANDING");
|
||||||
|
}
|
||||||
|
if (state.failure !== undefined) {
|
||||||
|
await rollback(state);
|
||||||
|
throw state.failure;
|
||||||
|
}
|
||||||
|
let committed: { readonly command: string | null };
|
||||||
|
try {
|
||||||
|
committed = await client.query("COMMIT");
|
||||||
|
} catch (error) {
|
||||||
|
state.healthy = connectionSurvives(error);
|
||||||
|
throw databaseError("MODULE_DATABASE_TRANSACTION_FAILED", error);
|
||||||
|
}
|
||||||
|
if (committed.command !== "COMMIT") {
|
||||||
|
// PostgreSQL answers COMMIT with ROLLBACK when a failed statement had aborted the
|
||||||
|
// transaction. Nothing was committed, and PostgreSQL supplied no SQLSTATE to report.
|
||||||
|
throw new ModuleDatabaseError("MODULE_DATABASE_TRANSACTION_FAILED");
|
||||||
|
}
|
||||||
|
return outcome.value;
|
||||||
|
} finally {
|
||||||
|
state.finished = true;
|
||||||
|
await relinquish(client, state.healthy);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const executor = Object.freeze({
|
||||||
|
abiVersion: MODULE_DATABASE_ABI_VERSION,
|
||||||
|
query: query as ModuleDatabaseQueryable["query"],
|
||||||
|
transaction: transaction as ModuleDatabaseExecutor["transaction"],
|
||||||
|
});
|
||||||
|
moduleByExecutor.set(executor, moduleId);
|
||||||
|
return executor;
|
||||||
|
}
|
||||||
@@ -1,7 +1,54 @@
|
|||||||
import type { ModuleDatabase } from "@ekklesia/contracts";
|
import type { ModuleDatabaseAbiRuntimeServices, ModuleRuntimeServices } from "@ekklesia/contracts";
|
||||||
import { drizzle } from "drizzle-orm/node-postgres";
|
import { drizzle, type NodePgDatabase } from "drizzle-orm/node-postgres";
|
||||||
import type { Pool } from "pg";
|
import type { Pool } from "pg";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A trusted backend module's typed Drizzle query surface — the same query-building methods a
|
||||||
|
* platform-owned Drizzle instance exposes, restricted to exactly the operations a module needs to
|
||||||
|
* read and write its own tables. Derived with `Pick` from the real `drizzle-orm/node-postgres`
|
||||||
|
* database type rather than hand-declared, so every method keeps Drizzle's own table-aware
|
||||||
|
* argument and result inference exactly as-is — a module calling `database.select().from(table)`
|
||||||
|
* or `database.insert(table).values(...)` gets the identical typed builder a direct Drizzle caller
|
||||||
|
* would, with no `any`/`unknown` erasure anywhere in the chain.
|
||||||
|
*
|
||||||
|
* Deliberately excludes everything `NodePgDatabase` or the `drizzle(...)` factory otherwise
|
||||||
|
* exposes: `$client` (the raw `pg` client/pool), `_` (internal session/dialect/schema state),
|
||||||
|
* `query` (a relational-query builder tied to one schema, which would have to span every
|
||||||
|
* installed module's tables to exist here at all), `$cache`, `$count`, `$with`, `with`, and
|
||||||
|
* `refreshMaterializedView`. There is no connection string, pool, session, or dialect object
|
||||||
|
* reachable through this type. Carries no schema generic — a module brings its own Drizzle table
|
||||||
|
* objects to each call, so there is no global relational schema spanning every installed module for
|
||||||
|
* this type to leak.
|
||||||
|
*
|
||||||
|
* Core's own, private type (ADR 0053): it moved here from `@ekklesia/contracts` so no published
|
||||||
|
* package references Drizzle. New modules use the Module Database ABI v1 instead (ADR 0050), and
|
||||||
|
* the Module SDK never exposes this facade's type.
|
||||||
|
*
|
||||||
|
* This is an integration boundary, not a security sandbox: modules are trusted, compiled backend
|
||||||
|
* plugins that already run arbitrary code in this process, exactly like every other lifecycle hook
|
||||||
|
* and HTTP handler they define. `ModuleDatabase` exists to keep a module's code Drizzle-typed and
|
||||||
|
* decoupled from how the platform physically connects to PostgreSQL — not to defend against a
|
||||||
|
* malicious module, which this facade cannot do and does not attempt to do.
|
||||||
|
*/
|
||||||
|
export type ModuleDatabase = Pick<
|
||||||
|
NodePgDatabase,
|
||||||
|
| "select"
|
||||||
|
| "selectDistinct"
|
||||||
|
| "selectDistinctOn"
|
||||||
|
| "insert"
|
||||||
|
| "update"
|
||||||
|
| "delete"
|
||||||
|
| "execute"
|
||||||
|
| "transaction"
|
||||||
|
>;
|
||||||
|
|
||||||
|
/** The services a legacy module's hooks and handlers receive: the legacy facade. */
|
||||||
|
export type LegacyModuleRuntimeServices = ModuleRuntimeServices<ModuleDatabase>;
|
||||||
|
/** The services Core hands any started module: the legacy facade or the ABI v1 executor. */
|
||||||
|
export type AnyModuleRuntimeServices =
|
||||||
|
| LegacyModuleRuntimeServices
|
||||||
|
| ModuleDatabaseAbiRuntimeServices;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A typed `ModuleDatabase` view over an externally owned pool — the module-facing counterpart to
|
* A typed `ModuleDatabase` view over an externally owned pool — the module-facing counterpart to
|
||||||
* `createIdentityDrizzleView` (`core/src/identity/database.ts`) and
|
* `createIdentityDrizzleView` (`core/src/identity/database.ts`) and
|
||||||
|
|||||||
@@ -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,11 +2,15 @@ export * from "./configuration.js";
|
|||||||
export * from "./configuration-schema.js";
|
export * from "./configuration-schema.js";
|
||||||
export * from "./configuration-store.js";
|
export * from "./configuration-store.js";
|
||||||
export * from "./database.js";
|
export * from "./database.js";
|
||||||
|
export * from "./database-abi.js";
|
||||||
export * from "./discovery.js";
|
export * from "./discovery.js";
|
||||||
export * from "./lifecycle.js";
|
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-host.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";
|
||||||
|
|||||||
+10
-31
@@ -1,19 +1,15 @@
|
|||||||
|
/**
|
||||||
|
* The legacy module schema definition (ADR 0007): a v8 module that declares no `database` default-
|
||||||
|
* exports `{ moduleId, tables }` with its Drizzle table objects themselves. Core alone validates it,
|
||||||
|
* for the legacy facade's deprecation window (ADR 0050). It lives here, beside Core's own Drizzle
|
||||||
|
* dependency, so that no package a module installs needs Drizzle at run time (ADR 0053).
|
||||||
|
*
|
||||||
|
* Erasable TypeScript with no relative imports: the verifier's probe imports this source file
|
||||||
|
* directly in tests (`module-package-probe.ts`).
|
||||||
|
*/
|
||||||
import { is } from "drizzle-orm";
|
import { is } from "drizzle-orm";
|
||||||
import { type AnyPgTable, PgTable } from "drizzle-orm/pg-core";
|
import { type AnyPgTable, PgTable } from "drizzle-orm/pg-core";
|
||||||
import { z } from "zod";
|
import { type ModuleId, moduleIdSchema } from "@ekklesia/contracts";
|
||||||
import { type ModuleId, moduleIdSchema } from "./identifiers.js";
|
|
||||||
|
|
||||||
export const POSTGRES_IDENTIFIER_MAX_LENGTH = 63;
|
|
||||||
|
|
||||||
export const moduleTableLocalNameSchema = z
|
|
||||||
.string()
|
|
||||||
.min(1)
|
|
||||||
.regex(/^[a-z][a-z0-9]*(?:_[a-z0-9]+)*$/u, "Must be a lowercase snake-case table name.");
|
|
||||||
|
|
||||||
export const physicalModuleTableNameSchema = moduleTableLocalNameSchema.max(
|
|
||||||
POSTGRES_IDENTIFIER_MAX_LENGTH,
|
|
||||||
`Must not exceed ${POSTGRES_IDENTIFIER_MAX_LENGTH} characters.`,
|
|
||||||
);
|
|
||||||
|
|
||||||
export interface ModuleSchemaDefinition<
|
export interface ModuleSchemaDefinition<
|
||||||
Tables extends Readonly<Record<string, AnyPgTable>> = Readonly<Record<string, AnyPgTable>>,
|
Tables extends Readonly<Record<string, AnyPgTable>> = Readonly<Record<string, AnyPgTable>>,
|
||||||
@@ -35,23 +31,6 @@ function hasOnlyStringKeys(value: object, allowedKeys?: ReadonlySet<string>): bo
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function moduleTableNamespace(moduleId: ModuleId): string {
|
|
||||||
return moduleIdSchema.parse(moduleId).replaceAll("-", "_");
|
|
||||||
}
|
|
||||||
|
|
||||||
export function moduleTablePrefix(moduleId: ModuleId): string {
|
|
||||||
return `${moduleTableNamespace(moduleId)}_`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function moduleTableName(moduleId: ModuleId, localName: string): string {
|
|
||||||
const parsedLocalName = moduleTableLocalNameSchema.parse(localName);
|
|
||||||
const tableName = `${moduleTablePrefix(moduleId)}${parsedLocalName}`;
|
|
||||||
if (!physicalModuleTableNameSchema.safeParse(tableName).success) {
|
|
||||||
throw new RangeError("The namespaced PostgreSQL table name is invalid or too long.");
|
|
||||||
}
|
|
||||||
return tableName;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function isModuleSchemaDefinition(value: unknown): value is ModuleSchemaDefinition {
|
export function isModuleSchemaDefinition(value: unknown): value is ModuleSchemaDefinition {
|
||||||
try {
|
try {
|
||||||
if (
|
if (
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
/**
|
||||||
|
* The facts about this host that decide whether a native Module Package v1 can run on it
|
||||||
|
* (ADR 0049): operating system, CPU, C library family and version, and Node-API version.
|
||||||
|
*
|
||||||
|
* Everything comes from Node.js itself (`process.platform`, `process.arch`, `process.versions`, and
|
||||||
|
* the diagnostic report's `glibcVersionRuntime`), never from running a system command. A fact Node
|
||||||
|
* cannot report is left undefined, and every check that needs it fails closed.
|
||||||
|
*/
|
||||||
|
export interface ModulePackageHost {
|
||||||
|
readonly platform: string;
|
||||||
|
readonly arch: string;
|
||||||
|
/** The C library on Linux: glibc with its runtime version, or undefined when it cannot be proven
|
||||||
|
* to be glibc (musl reports none). */
|
||||||
|
readonly libc?: { readonly family: "glibc"; readonly version: string };
|
||||||
|
/** `process.versions.napi`: the highest Node-API version this Node.js provides. */
|
||||||
|
readonly nodeApi?: number;
|
||||||
|
/** `process.versions.modules`: the ABI an ABI-specific addon must have been built for. */
|
||||||
|
readonly nodeAbi?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function glibcVersion(): string | undefined {
|
||||||
|
try {
|
||||||
|
const header = (process.report?.getReport() as { header?: { glibcVersionRuntime?: unknown } })
|
||||||
|
?.header;
|
||||||
|
const version = header?.glibcVersionRuntime;
|
||||||
|
return typeof version === "string" && /^\d+\.\d+(?:\.\d+)?$/u.test(version)
|
||||||
|
? version
|
||||||
|
: undefined;
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function currentModulePackageHost(): ModulePackageHost {
|
||||||
|
const libc = process.platform === "linux" ? glibcVersion() : undefined;
|
||||||
|
const nodeApi = Number(process.versions.napi);
|
||||||
|
return Object.freeze({
|
||||||
|
arch: process.arch,
|
||||||
|
...(libc === undefined
|
||||||
|
? {}
|
||||||
|
: { libc: Object.freeze({ family: "glibc" as const, version: libc }) }),
|
||||||
|
...(Number.isInteger(nodeApi) && nodeApi > 0 ? { nodeApi } : {}),
|
||||||
|
...(typeof process.versions.modules === "string" ? { nodeAbi: process.versions.modules } : {}),
|
||||||
|
platform: process.platform,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The native targets this platform can verify, with what each requires of a host. */
|
||||||
|
export const MODULE_PACKAGE_NATIVE_TARGETS: ReadonlyMap<
|
||||||
|
string,
|
||||||
|
{
|
||||||
|
readonly platform: string;
|
||||||
|
readonly arch: string;
|
||||||
|
readonly libc: "glibc";
|
||||||
|
readonly elfMachine: number;
|
||||||
|
}
|
||||||
|
> = new Map([
|
||||||
|
["linux-x64-glibc", { arch: "x64", elfMachine: 62, libc: "glibc", platform: "linux" }],
|
||||||
|
]);
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
// 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, so Node can run the source file directly in tests as well as the
|
||||||
|
// compiled file in the image. Its one relative import names the source file's own extension, which
|
||||||
|
// the build rewrites to `.js` (`rewriteRelativeImportExtensions`); that file follows the same rules.
|
||||||
|
import { pathToFileURL } from "node:url";
|
||||||
|
import {
|
||||||
|
parseModuleRuntimeDefinition,
|
||||||
|
parseModuleSchemaMetadataDefinition,
|
||||||
|
} from "@ekklesia/contracts";
|
||||||
|
import { parseModuleSchemaDefinition } from "./legacy-schema-definition.ts";
|
||||||
|
|
||||||
|
interface ProbeRequest {
|
||||||
|
readonly databaseAbi?: 1;
|
||||||
|
readonly moduleId: string;
|
||||||
|
readonly version: string;
|
||||||
|
readonly entrypoints: Readonly<Record<string, string>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ProbeFailure {
|
||||||
|
readonly role: string;
|
||||||
|
readonly reason: "contract" | "export" | "identity" | "load";
|
||||||
|
}
|
||||||
|
|
||||||
|
function defaultExport(namespace: unknown): unknown {
|
||||||
|
return typeof namespace === "object" && namespace !== null && "default" in namespace
|
||||||
|
? (namespace as { default: unknown }).default
|
||||||
|
: undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function probe(request: ProbeRequest): Promise<readonly ProbeFailure[]> {
|
||||||
|
const failures: ProbeFailure[] = [];
|
||||||
|
for (const role of Object.keys(request.entrypoints).sort()) {
|
||||||
|
const entrypoint = request.entrypoints[role];
|
||||||
|
if (entrypoint === undefined) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let namespace: unknown;
|
||||||
|
try {
|
||||||
|
namespace = await import(pathToFileURL(entrypoint).href);
|
||||||
|
} catch {
|
||||||
|
failures.push({ reason: "load", role });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
if (role === "server") {
|
||||||
|
const definition = parseModuleRuntimeDefinition(defaultExport(namespace));
|
||||||
|
if (definition.id !== request.moduleId || definition.version !== request.version) {
|
||||||
|
failures.push({ reason: "identity", role });
|
||||||
|
}
|
||||||
|
if ((definition.database?.abi === 1) !== (request.databaseAbi === 1)) {
|
||||||
|
failures.push({ reason: "contract", role });
|
||||||
|
}
|
||||||
|
} else if (role === "schema") {
|
||||||
|
const definition =
|
||||||
|
request.databaseAbi === 1
|
||||||
|
? parseModuleSchemaMetadataDefinition(defaultExport(namespace))
|
||||||
|
: parseModuleSchemaDefinition(defaultExport(namespace));
|
||||||
|
if (definition.moduleId !== request.moduleId) {
|
||||||
|
failures.push({ reason: "identity", role });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
failures.push({ reason: "export", role });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return failures;
|
||||||
|
}
|
||||||
|
|
||||||
|
function report(message: unknown): void {
|
||||||
|
if (typeof process.send !== "function") {
|
||||||
|
process.exitCode = 2;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
process.send(message, () => {
|
||||||
|
// A module may leave timers or handles behind; the probe's work is done either way.
|
||||||
|
process.exit(0);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let request: ProbeRequest | undefined;
|
||||||
|
try {
|
||||||
|
request = JSON.parse(process.argv[2] ?? "") as ProbeRequest;
|
||||||
|
} catch {
|
||||||
|
report({ failures: [{ reason: "load", role: "request" }] });
|
||||||
|
}
|
||||||
|
if (request !== undefined) {
|
||||||
|
probe(request).then(
|
||||||
|
(failures) => report({ failures }),
|
||||||
|
() => report({ failures: [{ reason: "load", role: "probe" }] }),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,650 @@
|
|||||||
|
import { fork } from "node:child_process";
|
||||||
|
import { createHash } from "node:crypto";
|
||||||
|
import { chmod, mkdir, mkdtemp, readFile, realpath, rm, writeFile } from "node:fs/promises";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import {
|
||||||
|
isVersionCompatible,
|
||||||
|
type ModuleManifest,
|
||||||
|
safeParseModuleManifest,
|
||||||
|
} from "@ekklesia/contracts";
|
||||||
|
import {
|
||||||
|
MODULE_PACKAGE_ANY_TARGET,
|
||||||
|
MODULE_PACKAGE_FORMAT,
|
||||||
|
MODULE_PACKAGE_METADATA_FILENAME,
|
||||||
|
type ModulePackageMetadata,
|
||||||
|
safeParseModulePackageMetadata,
|
||||||
|
safeParseModulePackageRuntimePackage,
|
||||||
|
} from "@ekklesia/contracts/module-package";
|
||||||
|
import { discoverModules, MODULE_MANIFEST_FILENAME, ModuleDiscoveryError } from "./discovery.js";
|
||||||
|
import {
|
||||||
|
currentModulePackageHost,
|
||||||
|
MODULE_PACKAGE_NATIVE_TARGETS,
|
||||||
|
type ModulePackageHost,
|
||||||
|
} from "./module-package-host.js";
|
||||||
|
import { discoverModuleMigrations, ModuleMigrationDiscoveryError } from "./migration-discovery.js";
|
||||||
|
import {
|
||||||
|
type ModulePackageArchive,
|
||||||
|
ModulePackageArchiveError,
|
||||||
|
type ModulePackageArchiveErrorCode,
|
||||||
|
readModulePackageArchive,
|
||||||
|
} from "./module-package-archive.js";
|
||||||
|
import { discoverModuleSchemas, ModuleSchemaDiscoveryError } from "./schema-discovery.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verifies an Ekklesia Module Package v1 archive (ADR 0049) against the platform it runs on, without
|
||||||
|
* ever installing it.
|
||||||
|
*
|
||||||
|
* Order matters: everything that can be decided from bytes is decided before anything is written,
|
||||||
|
* and nothing is executed until the package has proven itself well-formed. The archive is read and
|
||||||
|
* checked for safety in memory; its metadata, every payload digest, and every identity statement
|
||||||
|
* are checked next; only then is it written to a fresh temporary directory, validated by the same
|
||||||
|
* discovery the platform uses, and its backend loaded once in a separate, short-lived process with
|
||||||
|
* an empty environment. A caller that asks for extraction gets the verified tree written to a new
|
||||||
|
* directory only after all of that succeeded.
|
||||||
|
*
|
||||||
|
* Errors carry a stable code and a fixed message. Paths, archive entry names, file contents, module
|
||||||
|
* output, and exception messages never appear in them: an archive is untrusted input, and none of
|
||||||
|
* it is ever echoed.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type ModulePackageVerificationErrorCode =
|
||||||
|
| ModulePackageArchiveErrorCode
|
||||||
|
| "MODULE_PACKAGE_DATABASE_RUNTIME_INCOMPATIBLE"
|
||||||
|
| "MODULE_PACKAGE_DATABASE_RUNTIME_UNEXPECTED"
|
||||||
|
| "MODULE_PACKAGE_DIGEST_MISMATCH"
|
||||||
|
| "MODULE_PACKAGE_ENTRYPOINT_LOAD_FAILED"
|
||||||
|
| "MODULE_PACKAGE_EXTRACTION_FAILED"
|
||||||
|
| "MODULE_PACKAGE_FILE_MISMATCH"
|
||||||
|
| "MODULE_PACKAGE_FILE_MISSING"
|
||||||
|
| "MODULE_PACKAGE_FILE_UNLISTED"
|
||||||
|
| "MODULE_PACKAGE_FORMAT_UNSUPPORTED"
|
||||||
|
| "MODULE_PACKAGE_IDENTITY_MISMATCH"
|
||||||
|
| "MODULE_PACKAGE_LIBC_INCOMPATIBLE"
|
||||||
|
| "MODULE_PACKAGE_MANIFEST_INVALID"
|
||||||
|
| "MODULE_PACKAGE_METADATA_INVALID"
|
||||||
|
| "MODULE_PACKAGE_METADATA_MISSING"
|
||||||
|
| "MODULE_PACKAGE_MODULE_INVALID"
|
||||||
|
| "MODULE_PACKAGE_NATIVE_FILE"
|
||||||
|
| "MODULE_PACKAGE_NATIVE_FILE_INCOMPATIBLE"
|
||||||
|
| "MODULE_PACKAGE_NODE_API_INCOMPATIBLE"
|
||||||
|
| "MODULE_PACKAGE_RUNTIME_INCOMPATIBLE"
|
||||||
|
| "MODULE_PACKAGE_RUNTIME_PACKAGE_INVALID"
|
||||||
|
| "MODULE_PACKAGE_SDK_INCOMPATIBLE"
|
||||||
|
| "MODULE_PACKAGE_TARGET_INCOMPATIBLE"
|
||||||
|
| "MODULE_PACKAGE_TRANSITIONAL_UNSUPPORTED"
|
||||||
|
| "MODULE_PACKAGE_VERIFIER_SCRATCH_UNAVAILABLE";
|
||||||
|
|
||||||
|
const messages: Readonly<Record<ModulePackageVerificationErrorCode, string>> = Object.freeze({
|
||||||
|
MODULE_PACKAGE_DATABASE_RUNTIME_INCOMPATIBLE:
|
||||||
|
"The package's bundled database runtime does not match this platform's (a transitional constraint).",
|
||||||
|
MODULE_PACKAGE_DATABASE_RUNTIME_UNEXPECTED:
|
||||||
|
"A Module Database ABI package must not carry a transitional database runtime.",
|
||||||
|
MODULE_PACKAGE_DIGEST_MISMATCH: "The archive does not match the SHA-256 it was given.",
|
||||||
|
MODULE_PACKAGE_ENTRYPOINT_LOAD_FAILED:
|
||||||
|
"A backend entrypoint could not be loaded from the package alone.",
|
||||||
|
MODULE_PACKAGE_EXTRACTION_FAILED:
|
||||||
|
"The verified package could not be extracted to a new directory.",
|
||||||
|
MODULE_PACKAGE_FILE_MISMATCH: "A payload file does not match its listed size or SHA-256.",
|
||||||
|
MODULE_PACKAGE_FILE_MISSING: "A file listed in the package metadata is missing from the archive.",
|
||||||
|
MODULE_PACKAGE_FILE_UNLISTED: "The archive contains a file the package metadata does not list.",
|
||||||
|
MODULE_PACKAGE_FORMAT_UNSUPPORTED: "The package format is not one this platform implements.",
|
||||||
|
MODULE_PACKAGE_IDENTITY_MISMATCH:
|
||||||
|
"The package metadata, runtime package.json, and manifest disagree about the module's identity.",
|
||||||
|
MODULE_PACKAGE_LAYOUT_INVALID:
|
||||||
|
"The archive must contain exactly one top-level module directory, with each path once.",
|
||||||
|
MODULE_PACKAGE_LIBC_INCOMPATIBLE:
|
||||||
|
"The package needs a newer C library than this host provides, or one it cannot establish.",
|
||||||
|
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 or a node_modules directory.",
|
||||||
|
MODULE_PACKAGE_NATIVE_FILE_INCOMPATIBLE:
|
||||||
|
"A native file in the package is not built for the package's target.",
|
||||||
|
MODULE_PACKAGE_NODE_API_INCOMPATIBLE:
|
||||||
|
"The package's addons need a Node-API version or Node.js ABI this host does not provide.",
|
||||||
|
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_VERIFIER_SCRATCH_UNAVAILABLE:
|
||||||
|
"The verifier execution scratch directory is missing, not writable, or mounted noexec.",
|
||||||
|
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;
|
||||||
|
/** Defaults to this process's own host (`currentModulePackageHost`). */
|
||||||
|
readonly host?: ModulePackageHost;
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
/**
|
||||||
|
* Existing writable directory where backend candidates may execute. Defaults to `os.tmpdir()`.
|
||||||
|
* A hardened caller whose system temporary directory is `noexec` must provide a dedicated
|
||||||
|
* executable scratch mount; each verification creates and removes its own child directory.
|
||||||
|
*/
|
||||||
|
readonly executionScratchDirectory?: 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 {
|
||||||
|
if (metadata.target === MODULE_PACKAGE_ANY_TARGET) {
|
||||||
|
// An any-target package bundles everything: no addon, no ELF object, no node_modules.
|
||||||
|
for (const [filePath, data] of files) {
|
||||||
|
if (
|
||||||
|
filePath.toLowerCase().endsWith(".node") ||
|
||||||
|
filePath.startsWith("node_modules/") ||
|
||||||
|
isElf(data)
|
||||||
|
) {
|
||||||
|
fail("MODULE_PACKAGE_NATIVE_FILE");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
checkNativeTarget(metadata, files, platform.host ?? currentModulePackageHost());
|
||||||
|
}
|
||||||
|
if (!isVersionCompatible(platform.nodeVersion ?? process.versions.node, metadata.runtime.node)) {
|
||||||
|
fail("MODULE_PACKAGE_RUNTIME_INCOMPATIBLE");
|
||||||
|
}
|
||||||
|
const sdk = metadata.incorporates["@ekklesia/module-sdk"];
|
||||||
|
if (sdk !== undefined && !isVersionCompatible(sdk, manifest.compatibility.moduleSdk)) {
|
||||||
|
fail("MODULE_PACKAGE_SDK_INCOMPATIBLE");
|
||||||
|
}
|
||||||
|
if (manifest.database?.abi === 1) {
|
||||||
|
if (metadata.transitional?.databaseRuntime !== undefined) {
|
||||||
|
fail("MODULE_PACKAGE_DATABASE_RUNTIME_UNEXPECTED");
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Transitional legacy-v8 path (ADR 0049): Core queries the module's Drizzle tables with its own
|
||||||
|
// Drizzle instance, so a recorded module runtime must match. ABI-v1 packages return above: their
|
||||||
|
// bundled ORM is a private implementation detail and is never compared with Core's.
|
||||||
|
for (const [runtime, version] of Object.entries(metadata.transitional?.databaseRuntime ?? {})) {
|
||||||
|
if (platform.databaseRuntime[runtime] !== version) {
|
||||||
|
fail("MODULE_PACKAGE_DATABASE_RUNTIME_INCOMPATIBLE");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isElf(data: Uint8Array): boolean {
|
||||||
|
return (
|
||||||
|
data.length >= 4 && data[0] === 0x7f && data[1] === 0x45 && data[2] === 0x4c && data[3] === 0x46
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `2.41` → `2.41.0`, for comparison against a semantic-version range. */
|
||||||
|
function asSemver(version: string): string {
|
||||||
|
const parts = version.split(".");
|
||||||
|
return [...parts, "0", "0"].slice(0, 3).join(".");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A native target (ADR 0049): accepted only for a target this platform implements, on a host that
|
||||||
|
* provably matches it. Platform, CPU, C library family and minimum version, and the Node-API version
|
||||||
|
* or ABI the addons need are all checked; a fact the host cannot establish fails closed. Every ELF
|
||||||
|
* object in the package must be built for the target's machine, and every `.node` addon must be one.
|
||||||
|
*/
|
||||||
|
function checkNativeTarget(
|
||||||
|
metadata: ModulePackageMetadata,
|
||||||
|
files: ReadonlyMap<string, Buffer>,
|
||||||
|
host: ModulePackageHost,
|
||||||
|
): void {
|
||||||
|
const target = MODULE_PACKAGE_NATIVE_TARGETS.get(metadata.target);
|
||||||
|
if (
|
||||||
|
target === undefined ||
|
||||||
|
host.platform !== target.platform ||
|
||||||
|
host.arch !== target.arch ||
|
||||||
|
host.libc?.family !== target.libc
|
||||||
|
) {
|
||||||
|
fail("MODULE_PACKAGE_TARGET_INCOMPATIBLE");
|
||||||
|
}
|
||||||
|
const required = metadata.runtime.libcVersion;
|
||||||
|
if (
|
||||||
|
required === undefined ||
|
||||||
|
host.libc === undefined ||
|
||||||
|
!isVersionCompatible(asSemver(host.libc.version), required)
|
||||||
|
) {
|
||||||
|
fail("MODULE_PACKAGE_LIBC_INCOMPATIBLE");
|
||||||
|
}
|
||||||
|
const { nodeAbi, nodeApi } = metadata.runtime;
|
||||||
|
if (
|
||||||
|
(nodeApi === undefined && nodeAbi === undefined) ||
|
||||||
|
(nodeApi !== undefined && (host.nodeApi === undefined || host.nodeApi < nodeApi)) ||
|
||||||
|
(nodeAbi !== undefined && host.nodeAbi !== nodeAbi)
|
||||||
|
) {
|
||||||
|
fail("MODULE_PACKAGE_NODE_API_INCOMPATIBLE");
|
||||||
|
}
|
||||||
|
for (const [filePath, data] of files) {
|
||||||
|
const addon = filePath.toLowerCase().endsWith(".node");
|
||||||
|
if (!isElf(data)) {
|
||||||
|
if (addon) {
|
||||||
|
fail("MODULE_PACKAGE_NATIVE_FILE_INCOMPATIBLE");
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// ELF64, little-endian, for the target's machine (e_machine at offset 18).
|
||||||
|
if (
|
||||||
|
data.length < 20 ||
|
||||||
|
data[4] !== 2 ||
|
||||||
|
data[5] !== 1 ||
|
||||||
|
data.readUInt16LE(18) !== target.elfMachine
|
||||||
|
) {
|
||||||
|
fail("MODULE_PACKAGE_NATIVE_FILE_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)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function decodedMountPath(value: string): string {
|
||||||
|
return value.replace(/\\([0-7]{3})/gu, (_match, octal: string) =>
|
||||||
|
String.fromCharCode(Number.parseInt(octal, 8)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Linux exposes the effective options of every mount, including a container's tmpfs mounts. A
|
||||||
|
* verifier cannot infer `noexec` from file permissions: native addons are mapped executable rather
|
||||||
|
* than invoked as files. On systems without mountinfo, the load remains the final authority. */
|
||||||
|
async function isNoExecMount(directory: string): Promise<boolean> {
|
||||||
|
if (process.platform !== "linux") return false;
|
||||||
|
let mountinfo: string;
|
||||||
|
let canonicalDirectory: string;
|
||||||
|
try {
|
||||||
|
[mountinfo, canonicalDirectory] = await Promise.all([
|
||||||
|
readFile("/proc/self/mountinfo", "utf8"),
|
||||||
|
realpath(directory),
|
||||||
|
]);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const mounts = mountinfo
|
||||||
|
.split("\n")
|
||||||
|
.map((line) => line.split(" "))
|
||||||
|
.filter((fields) => fields.length > 6 && fields.includes("-"))
|
||||||
|
.map((fields) => ({
|
||||||
|
mountPoint: decodedMountPath(fields[4] ?? ""),
|
||||||
|
options: (fields[5] ?? "").split(","),
|
||||||
|
}))
|
||||||
|
.filter(
|
||||||
|
({ mountPoint }) =>
|
||||||
|
canonicalDirectory === mountPoint ||
|
||||||
|
canonicalDirectory.startsWith(mountPoint === "/" ? "/" : `${mountPoint}/`),
|
||||||
|
)
|
||||||
|
.sort((left, right) => right.mountPoint.length - left.mountPoint.length);
|
||||||
|
return mounts[0]?.options.includes("noexec") ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createExecutionScratch(baseDirectory: string): Promise<string> {
|
||||||
|
if (!path.isAbsolute(baseDirectory)) {
|
||||||
|
fail("MODULE_PACKAGE_VERIFIER_SCRATCH_UNAVAILABLE");
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
if (await isNoExecMount(baseDirectory)) {
|
||||||
|
fail("MODULE_PACKAGE_VERIFIER_SCRATCH_UNAVAILABLE");
|
||||||
|
}
|
||||||
|
return await mkdtemp(path.join(baseDirectory, "ekklesia-package-verify-"));
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof ModulePackageVerificationError) throw error;
|
||||||
|
fail("MODULE_PACKAGE_VERIFIER_SCRATCH_UNAVAILABLE");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Loads every backend entrypoint in a short-lived child with an empty environment, and reports
|
||||||
|
* only whether each loaded and exported a definition naming this module. */
|
||||||
|
async function loadEntrypoints(
|
||||||
|
manifest: Pick<ModuleManifest, "database" | "id" | "version">,
|
||||||
|
entrypoints: Readonly<Record<string, string>>,
|
||||||
|
timeoutMs: number,
|
||||||
|
executionDirectory: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const outcome = await new Promise<"ok" | "failed">((resolve) => {
|
||||||
|
let settled = false;
|
||||||
|
let child: ReturnType<typeof fork> | undefined;
|
||||||
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
const finish = (value: "ok" | "failed"): void => {
|
||||||
|
if (!settled) {
|
||||||
|
settled = true;
|
||||||
|
clearTimeout(timer);
|
||||||
|
child?.kill("SIGKILL");
|
||||||
|
resolve(value);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
child = fork(
|
||||||
|
probeEntrypointPath(),
|
||||||
|
[
|
||||||
|
JSON.stringify({
|
||||||
|
...(manifest.database?.abi === 1 ? { databaseAbi: 1 } : {}),
|
||||||
|
entrypoints,
|
||||||
|
moduleId: manifest.id,
|
||||||
|
version: manifest.version,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
{
|
||||||
|
cwd: executionDirectory,
|
||||||
|
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 createExecutionScratch(options.executionScratchDirectory ?? tmpdir());
|
||||||
|
try {
|
||||||
|
// The candidate alone, in a scratch inventory of its own: never module storage.
|
||||||
|
const modulesDirectory = path.join(scratch, "candidates");
|
||||||
|
await mkdir(modulesDirectory, { mode: 0o755 });
|
||||||
|
await writeTree(path.join(modulesDirectory, metadata.module.id), archive);
|
||||||
|
|
||||||
|
const discoveryOptions = {
|
||||||
|
moduleSdkVersion: options.platform.moduleSdkVersion,
|
||||||
|
modulesDirectory,
|
||||||
|
modulesDirectoryRequired: true,
|
||||||
|
platformVersion: options.platform.platformVersion,
|
||||||
|
} as const;
|
||||||
|
let entrypoints: Record<string, string>;
|
||||||
|
try {
|
||||||
|
// The same static validation the platform runs, against this package alone. A module that
|
||||||
|
// requires another module is validated with its dependencies when it is installed.
|
||||||
|
const [discovered] = await discoverModules(discoveryOptions);
|
||||||
|
if (discovered === undefined) {
|
||||||
|
fail("MODULE_PACKAGE_MODULE_INVALID");
|
||||||
|
}
|
||||||
|
await discoverModuleMigrations(discoveryOptions);
|
||||||
|
entrypoints = {
|
||||||
|
server: discovered.entrypoints.server,
|
||||||
|
...(discovered.entrypoints.schema ? { schema: discovered.entrypoints.schema } : {}),
|
||||||
|
...(discovered.entrypoints.jobs ? { jobs: discovered.entrypoints.jobs } : {}),
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof ModulePackageVerificationError) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
fail("MODULE_PACKAGE_MODULE_INVALID", discoveryIssueCodes(error));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Executed only now, and first in isolation: a schema is imported in this process below only
|
||||||
|
// after the probe proved it loads on its own.
|
||||||
|
await loadEntrypoints(manifest, entrypoints, options.loadTimeoutMs ?? 30_000, scratch);
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
});
|
||||||
|
}
|
||||||
+68
-26
@@ -7,15 +7,16 @@ import {
|
|||||||
EMPTY_MODULE_CONFIGURATION,
|
EMPTY_MODULE_CONFIGURATION,
|
||||||
EMPTY_MODULE_STORAGE,
|
EMPTY_MODULE_STORAGE,
|
||||||
type ModuleConfiguration,
|
type ModuleConfiguration,
|
||||||
|
type ModuleDatabaseExecutor,
|
||||||
type ModuleHttpRouteRegistrar,
|
type ModuleHttpRouteRegistrar,
|
||||||
type ModuleId,
|
type ModuleId,
|
||||||
ModuleRealtimeError,
|
ModuleRealtimeError,
|
||||||
type ModuleRealtimePublisher,
|
type ModuleRealtimePublisher,
|
||||||
type ModuleRegistrationContext,
|
type ModuleRegistrationContext,
|
||||||
type ModuleRouteDefinitionInput,
|
type ModuleRouteDefinitionInput,
|
||||||
|
type ModuleRouteHandler,
|
||||||
type ModuleRuntimeContext,
|
type ModuleRuntimeContext,
|
||||||
type ModuleRuntimeDefinition,
|
type ModuleRuntimeDefinition,
|
||||||
type ModuleRuntimeServices,
|
|
||||||
type ModuleStorage,
|
type ModuleStorage,
|
||||||
moduleRouteShapeKey,
|
moduleRouteShapeKey,
|
||||||
moduleRoutesOverlap,
|
moduleRoutesOverlap,
|
||||||
@@ -24,6 +25,7 @@ import {
|
|||||||
type RegisteredModuleHttpRoute,
|
type RegisteredModuleHttpRoute,
|
||||||
safeParseModuleRouteDefinition,
|
safeParseModuleRouteDefinition,
|
||||||
} from "@ekklesia/contracts";
|
} from "@ekklesia/contracts";
|
||||||
|
import type { AnyModuleRuntimeServices, ModuleDatabase } from "./database.js";
|
||||||
import {
|
import {
|
||||||
type DiscoveredModule,
|
type DiscoveredModule,
|
||||||
discoverModules,
|
discoverModules,
|
||||||
@@ -68,6 +70,7 @@ export type ModuleRuntimeIssueCode =
|
|||||||
| "MODULE_ROUTE_PERMISSION_INVALID"
|
| "MODULE_ROUTE_PERMISSION_INVALID"
|
||||||
| "MODULE_ROUTE_PERMISSION_UNDECLARED"
|
| "MODULE_ROUTE_PERMISSION_UNDECLARED"
|
||||||
| "MODULE_RUNTIME_EXPORT_INVALID"
|
| "MODULE_RUNTIME_EXPORT_INVALID"
|
||||||
|
| "MODULE_RUNTIME_DATABASE_MISMATCH"
|
||||||
| "MODULE_RUNTIME_ID_MISMATCH"
|
| "MODULE_RUNTIME_ID_MISMATCH"
|
||||||
| "MODULE_RUNTIME_IMPORT_FAILED"
|
| "MODULE_RUNTIME_IMPORT_FAILED"
|
||||||
| "MODULE_RUNTIME_VERSION_MISMATCH"
|
| "MODULE_RUNTIME_VERSION_MISMATCH"
|
||||||
@@ -105,9 +108,8 @@ export interface StartedModuleRuntime {
|
|||||||
/**
|
/**
|
||||||
* Per-module frozen configuration snapshots, keyed by module id (ADR 0029).
|
* Per-module frozen configuration snapshots, keyed by module id (ADR 0029).
|
||||||
*
|
*
|
||||||
* A lookup rather than a field on `ModuleRuntimeServices`, because `services` is deliberately **one
|
* A lookup rather than a field on `ModuleRuntimeServices`, because configuration is resolved and
|
||||||
* shared object identity every module receives** — a property this runtime has a test for — while
|
* frozen per module independently of its live service object. A module with no entry receives
|
||||||
* configuration is per-module by definition. A module with no entry receives
|
|
||||||
* `EMPTY_MODULE_CONFIGURATION` rather than `undefined`, so `context.config` is always present and
|
* `EMPTY_MODULE_CONFIGURATION` rather than `undefined`, so `context.config` is always present and
|
||||||
* module code never branches on whether the platform supplied one.
|
* module code never branches on whether the platform supplied one.
|
||||||
*/
|
*/
|
||||||
@@ -124,12 +126,33 @@ function configurationFor(
|
|||||||
* Per-module scoped object storage, keyed by module id (ADR 0030).
|
* Per-module scoped object storage, keyed by module id (ADR 0030).
|
||||||
*
|
*
|
||||||
* A lookup for the same reason configuration is one, and emphatically **not** a field on
|
* A lookup for the same reason configuration is one, and emphatically **not** a field on
|
||||||
* `ModuleRuntimeServices`: `services` is one shared object identity every module receives, while a
|
* `ModuleRuntimeServices`: a storage facade is scoped to exactly the buckets one module declared.
|
||||||
* storage facade is scoped to exactly the buckets one module declared. Sharing it would hand every
|
* Sharing it would hand every
|
||||||
* module every other module's storage.
|
* module every other module's storage.
|
||||||
*/
|
*/
|
||||||
export type ModuleStorageLookup = ReadonlyMap<string, ModuleStorage>;
|
export type ModuleStorageLookup = ReadonlyMap<string, ModuleStorage>;
|
||||||
|
|
||||||
|
/** One frozen service object per module id. Legacy modules may point at objects wrapping the same
|
||||||
|
* facade; ABI-v1 modules each point at their own attributed executor. */
|
||||||
|
export type ModuleRuntimeServicesLookup = ReadonlyMap<string, AnyModuleRuntimeServices>;
|
||||||
|
export type ModuleRuntimeServicesSource = ModuleRuntimeServicesLookup | AnyModuleRuntimeServices;
|
||||||
|
|
||||||
|
function servicesFor(
|
||||||
|
services: ModuleRuntimeServicesSource,
|
||||||
|
module: DiscoveredModule,
|
||||||
|
): AnyModuleRuntimeServices {
|
||||||
|
const selected = "get" in services ? services.get(module.manifest.id) : services;
|
||||||
|
if (!selected) {
|
||||||
|
throw new TypeError(`Module ${module.manifest.id} has no runtime services.`);
|
||||||
|
}
|
||||||
|
const expectsAbi = module.manifest.database?.abi === 1;
|
||||||
|
const isAbi = "abiVersion" in selected.database && selected.database.abiVersion === 1;
|
||||||
|
if (expectsAbi !== isAbi) {
|
||||||
|
throw new TypeError(`Module ${module.manifest.id} has the wrong database service.`);
|
||||||
|
}
|
||||||
|
return selected;
|
||||||
|
}
|
||||||
|
|
||||||
function storageFor(storages: ModuleStorageLookup, moduleId: string): ModuleStorage {
|
function storageFor(storages: ModuleStorageLookup, moduleId: string): ModuleStorage {
|
||||||
return storages.get(moduleId) ?? EMPTY_MODULE_STORAGE;
|
return storages.get(moduleId) ?? EMPTY_MODULE_STORAGE;
|
||||||
}
|
}
|
||||||
@@ -164,10 +187,9 @@ export interface ModuleRuntimeCompositionOptions extends ModuleRuntimeTimeoutOpt
|
|||||||
export interface StartModuleRuntimeOptions
|
export interface StartModuleRuntimeOptions
|
||||||
extends ModuleDiscoveryOptions,
|
extends ModuleDiscoveryOptions,
|
||||||
ModuleRuntimeCompositionOptions {
|
ModuleRuntimeCompositionOptions {
|
||||||
/** The same frozen services object every started module's `start`/`stop` hooks and (once
|
/** The manifest-selected service object for each module. One module's `start`/`stop` hooks and
|
||||||
* `apps/api` mounts routes) HTTP handlers receive for this runtime — a required production
|
* handlers receive its same entry; ABI-v1 entries carry distinct attributed executors. */
|
||||||
* dependency, never an optional test-only global. See `ModuleRuntimeServices`. */
|
readonly services: ModuleRuntimeServicesSource;
|
||||||
readonly services: ModuleRuntimeServices;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface LoadedModule {
|
interface LoadedModule {
|
||||||
@@ -175,6 +197,13 @@ interface LoadedModule {
|
|||||||
readonly discovered: DiscoveredModule;
|
readonly discovered: DiscoveredModule;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type AnyRegistrationHook = (
|
||||||
|
context: ModuleRegistrationContext<ModuleDatabase | ModuleDatabaseExecutor>,
|
||||||
|
) => unknown;
|
||||||
|
type AnyRuntimeHook = (
|
||||||
|
context: ModuleRuntimeContext<ModuleDatabase | ModuleDatabaseExecutor>,
|
||||||
|
) => unknown;
|
||||||
|
|
||||||
interface CapabilityRegistrationStage {
|
interface CapabilityRegistrationStage {
|
||||||
readonly capabilities: CapabilityRegistrar;
|
readonly capabilities: CapabilityRegistrar;
|
||||||
commit(): readonly ModuleRuntimeIssue[];
|
commit(): readonly ModuleRuntimeIssue[];
|
||||||
@@ -316,11 +345,11 @@ class StagedCapabilityRegistry {
|
|||||||
createRuntimeContext(
|
createRuntimeContext(
|
||||||
module: DiscoveredModule,
|
module: DiscoveredModule,
|
||||||
phase: "rollback" | "start" | "stop",
|
phase: "rollback" | "start" | "stop",
|
||||||
services: ModuleRuntimeServices,
|
services: AnyModuleRuntimeServices,
|
||||||
config: ModuleConfiguration,
|
config: ModuleConfiguration,
|
||||||
storage: ModuleStorage,
|
storage: ModuleStorage,
|
||||||
realtime: ModuleRealtimePublisher,
|
realtime: ModuleRealtimePublisher,
|
||||||
): ModuleRuntimeContext {
|
): ModuleRuntimeContext<ModuleDatabase | ModuleDatabaseExecutor> {
|
||||||
const capabilities: CapabilityResolver = Object.freeze({
|
const capabilities: CapabilityResolver = Object.freeze({
|
||||||
resolve: (capabilityId: CapabilityId): RegisteredCapability | undefined => {
|
resolve: (capabilityId: CapabilityId): RegisteredCapability | undefined => {
|
||||||
this.#assertAccess(module, capabilityId, phase);
|
this.#assertAccess(module, capabilityId, phase);
|
||||||
@@ -469,7 +498,7 @@ class StagedModuleHttpRoutes {
|
|||||||
local.push(
|
local.push(
|
||||||
Object.freeze({
|
Object.freeze({
|
||||||
bodyMode: parsed.data.bodyMode,
|
bodyMode: parsed.data.bodyMode,
|
||||||
handler: parsed.data.handler,
|
handler: parsed.data.handler as ModuleRouteHandler,
|
||||||
maxBodyBytes: parsed.data.maxBodyBytes,
|
maxBodyBytes: parsed.data.maxBodyBytes,
|
||||||
method: parsed.data.method,
|
method: parsed.data.method,
|
||||||
moduleId: module.manifest.id,
|
moduleId: module.manifest.id,
|
||||||
@@ -555,6 +584,19 @@ async function loadRuntimeDefinitions(
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
const runtimeDeclaresAbi = definition.database?.abi === 1;
|
||||||
|
const manifestDeclaresAbi = discovered.manifest.database?.abi === 1;
|
||||||
|
if (runtimeDeclaresAbi !== manifestDeclaresAbi) {
|
||||||
|
matchesManifest = false;
|
||||||
|
issues.push(
|
||||||
|
runtimeIssue(
|
||||||
|
"MODULE_RUNTIME_DATABASE_MISMATCH",
|
||||||
|
"load",
|
||||||
|
discovered.manifest.id,
|
||||||
|
`Module runtime database declaration does not match manifest ${discovered.manifest.id}.`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
if (matchesManifest) {
|
if (matchesManifest) {
|
||||||
loaded.push({ definition, discovered });
|
loaded.push({ definition, discovered });
|
||||||
}
|
}
|
||||||
@@ -580,15 +622,15 @@ async function registerModules(
|
|||||||
module.discovered.manifest.id,
|
module.discovered.manifest.id,
|
||||||
new Set(module.discovered.manifest.permissions.map((declaration) => declaration.id)),
|
new Set(module.discovered.manifest.permissions.map((declaration) => declaration.id)),
|
||||||
);
|
);
|
||||||
const context: ModuleRegistrationContext = Object.freeze({
|
const context = Object.freeze({
|
||||||
capabilities: capabilityStage.capabilities,
|
capabilities: capabilityStage.capabilities,
|
||||||
config: configurationFor(configurations, module.discovered.manifest.id),
|
config: configurationFor(configurations, module.discovered.manifest.id),
|
||||||
http: routeStage.http,
|
http: routeStage.http,
|
||||||
moduleId: module.discovered.manifest.id,
|
moduleId: module.discovered.manifest.id,
|
||||||
realtime: realtimeStage.realtime,
|
realtime: realtimeStage.realtime,
|
||||||
});
|
}) as ModuleRegistrationContext<ModuleDatabase | ModuleDatabaseExecutor>;
|
||||||
try {
|
try {
|
||||||
await module.definition.register?.(context);
|
await (module.definition.register as AnyRegistrationHook | undefined)?.(context);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof ModuleRuntimeSignal) {
|
if (error instanceof ModuleRuntimeSignal) {
|
||||||
throw new ModuleRuntimeError([error.issue]);
|
throw new ModuleRuntimeError([error.issue]);
|
||||||
@@ -685,7 +727,7 @@ async function stopModules(
|
|||||||
modules: readonly LoadedModule[],
|
modules: readonly LoadedModule[],
|
||||||
registry: StagedCapabilityRegistry,
|
registry: StagedCapabilityRegistry,
|
||||||
phase: "rollback" | "stop",
|
phase: "rollback" | "stop",
|
||||||
services: ModuleRuntimeServices,
|
services: ModuleRuntimeServicesSource,
|
||||||
configurations: ModuleConfigurationLookup,
|
configurations: ModuleConfigurationLookup,
|
||||||
storages: ModuleStorageLookup,
|
storages: ModuleStorageLookup,
|
||||||
realtimeRegistry: StagedModuleRealtimeRegistry,
|
realtimeRegistry: StagedModuleRealtimeRegistry,
|
||||||
@@ -698,7 +740,7 @@ async function stopModules(
|
|||||||
// A dependency may keep doing ordinary work while its dependents stop. Deactivate only the
|
// A dependency may keep doing ordinary work while its dependents stop. Deactivate only the
|
||||||
// module whose stop hook is about to run, not every publisher at the beginning of the sequence.
|
// module whose stop hook is about to run, not every publisher at the beginning of the sequence.
|
||||||
realtimeRegistry.deactivateModule(moduleId);
|
realtimeRegistry.deactivateModule(moduleId);
|
||||||
const stop = module.definition.stop;
|
const stop = module.definition.stop as AnyRuntimeHook | undefined;
|
||||||
if (!stop) {
|
if (!stop) {
|
||||||
// No synthetic callback and no timer for a module that declares no stop hook.
|
// No synthetic callback and no timer for a module that declares no stop hook.
|
||||||
continue;
|
continue;
|
||||||
@@ -710,13 +752,13 @@ async function stopModules(
|
|||||||
registry.createRuntimeContext(
|
registry.createRuntimeContext(
|
||||||
module.discovered,
|
module.discovered,
|
||||||
phase,
|
phase,
|
||||||
services,
|
servicesFor(services, module.discovered),
|
||||||
configurationFor(configurations, moduleId),
|
configurationFor(configurations, moduleId),
|
||||||
storageFor(storages, moduleId),
|
storageFor(storages, moduleId),
|
||||||
realtimeRegistry.publisherFor(moduleId),
|
realtimeRegistry.publisherFor(moduleId),
|
||||||
),
|
) as ModuleRuntimeContext<ModuleDatabase | ModuleDatabaseExecutor>,
|
||||||
),
|
),
|
||||||
),
|
).then(() => undefined),
|
||||||
timeoutMs,
|
timeoutMs,
|
||||||
);
|
);
|
||||||
if (outcome === "timed-out") {
|
if (outcome === "timed-out") {
|
||||||
@@ -750,7 +792,7 @@ async function stopModules(
|
|||||||
async function startModules(
|
async function startModules(
|
||||||
modules: readonly LoadedModule[],
|
modules: readonly LoadedModule[],
|
||||||
registry: StagedCapabilityRegistry,
|
registry: StagedCapabilityRegistry,
|
||||||
services: ModuleRuntimeServices,
|
services: ModuleRuntimeServicesSource,
|
||||||
configurations: ModuleConfigurationLookup,
|
configurations: ModuleConfigurationLookup,
|
||||||
storages: ModuleStorageLookup,
|
storages: ModuleStorageLookup,
|
||||||
realtimeRegistry: StagedModuleRealtimeRegistry,
|
realtimeRegistry: StagedModuleRealtimeRegistry,
|
||||||
@@ -766,15 +808,15 @@ async function startModules(
|
|||||||
for (const module of modules) {
|
for (const module of modules) {
|
||||||
rollbackCandidates.push(module);
|
rollbackCandidates.push(module);
|
||||||
try {
|
try {
|
||||||
await module.definition.start?.(
|
await (module.definition.start as AnyRuntimeHook | undefined)?.(
|
||||||
registry.createRuntimeContext(
|
registry.createRuntimeContext(
|
||||||
module.discovered,
|
module.discovered,
|
||||||
"start",
|
"start",
|
||||||
services,
|
servicesFor(services, module.discovered),
|
||||||
configurationFor(configurations, module.discovered.manifest.id),
|
configurationFor(configurations, module.discovered.manifest.id),
|
||||||
storageFor(storages, module.discovered.manifest.id),
|
storageFor(storages, module.discovered.manifest.id),
|
||||||
realtimeRegistry.publisherFor(module.discovered.manifest.id),
|
realtimeRegistry.publisherFor(module.discovered.manifest.id),
|
||||||
),
|
) as ModuleRuntimeContext<ModuleDatabase | ModuleDatabaseExecutor>,
|
||||||
);
|
);
|
||||||
const missingSnapshots = realtimeRegistry.missingSnapshots(module.discovered.manifest.id);
|
const missingSnapshots = realtimeRegistry.missingSnapshots(module.discovered.manifest.id);
|
||||||
if (missingSnapshots.length > 0) {
|
if (missingSnapshots.length > 0) {
|
||||||
@@ -831,7 +873,7 @@ async function startModules(
|
|||||||
*/
|
*/
|
||||||
export async function startDiscoveredModuleRuntime(
|
export async function startDiscoveredModuleRuntime(
|
||||||
discoveredModules: readonly DiscoveredModule[],
|
discoveredModules: readonly DiscoveredModule[],
|
||||||
services: ModuleRuntimeServices,
|
services: ModuleRuntimeServicesSource,
|
||||||
timeouts: ModuleRuntimeCompositionOptions = {},
|
timeouts: ModuleRuntimeCompositionOptions = {},
|
||||||
): Promise<StartedModuleRuntime> {
|
): Promise<StartedModuleRuntime> {
|
||||||
const modules = await loadRuntimeDefinitions(discoveredModules);
|
const modules = await loadRuntimeDefinitions(discoveredModules);
|
||||||
|
|||||||
@@ -1,12 +1,16 @@
|
|||||||
import { pathToFileURL } from "node:url";
|
import { pathToFileURL } from "node:url";
|
||||||
import {
|
import {
|
||||||
type ModuleId,
|
type ModuleId,
|
||||||
type ModuleSchemaDefinition,
|
type ModuleSchemaMetadataDefinition,
|
||||||
moduleTableLocalNameSchema,
|
moduleTableLocalNameSchema,
|
||||||
moduleTablePrefix,
|
moduleTablePrefix,
|
||||||
parseModuleSchemaDefinition,
|
parseModuleSchemaMetadataDefinition,
|
||||||
physicalModuleTableNameSchema,
|
physicalModuleTableNameSchema,
|
||||||
} from "@ekklesia/contracts";
|
} from "@ekklesia/contracts";
|
||||||
|
import {
|
||||||
|
type ModuleSchemaDefinition,
|
||||||
|
parseModuleSchemaDefinition,
|
||||||
|
} from "./legacy-schema-definition.js";
|
||||||
import { is } from "drizzle-orm";
|
import { is } from "drizzle-orm";
|
||||||
import { type AnyPgTable, getTableConfig, PgColumn, PgTable } from "drizzle-orm/pg-core";
|
import { type AnyPgTable, getTableConfig, PgColumn, PgTable } from "drizzle-orm/pg-core";
|
||||||
import {
|
import {
|
||||||
@@ -40,7 +44,9 @@ export interface DiscoveredModuleTable {
|
|||||||
readonly exportName: string;
|
readonly exportName: string;
|
||||||
readonly moduleId: ModuleId;
|
readonly moduleId: ModuleId;
|
||||||
readonly name: string;
|
readonly name: string;
|
||||||
readonly table: AnyPgTable;
|
/** Present only for a temporary legacy-facade module. ABI-v1 schema discovery publishes no ORM
|
||||||
|
* object and contributes nothing to the legacy aggregate below. */
|
||||||
|
readonly table?: AnyPgTable;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DiscoveredModuleSchema {
|
export interface DiscoveredModuleSchema {
|
||||||
@@ -55,21 +61,40 @@ export interface ModuleSchemaAggregate {
|
|||||||
readonly tables: readonly DiscoveredModuleTable[];
|
readonly tables: readonly DiscoveredModuleTable[];
|
||||||
}
|
}
|
||||||
|
|
||||||
interface LoadedModuleSchema {
|
interface LoadedLegacyModuleSchema {
|
||||||
readonly definition: ModuleSchemaDefinition;
|
readonly definition: ModuleSchemaDefinition;
|
||||||
readonly discovered: DiscoveredModule;
|
readonly discovered: DiscoveredModule;
|
||||||
|
readonly mode: "legacy";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface LoadedAbiModuleSchema {
|
||||||
|
readonly definition: ModuleSchemaMetadataDefinition;
|
||||||
|
readonly discovered: DiscoveredModule;
|
||||||
|
readonly mode: "abi-v1";
|
||||||
|
}
|
||||||
|
|
||||||
|
type LoadedModuleSchema = LoadedLegacyModuleSchema | LoadedAbiModuleSchema;
|
||||||
|
|
||||||
interface InspectedModuleSchema {
|
interface InspectedModuleSchema {
|
||||||
readonly discovered: DiscoveredModule;
|
readonly discovered: DiscoveredModule;
|
||||||
readonly tables: ModuleTableCandidate[];
|
readonly tables: ModuleTableCandidate[];
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ModuleTableCandidate extends DiscoveredModuleTable {
|
interface LegacyModuleTableCandidate extends DiscoveredModuleTable {
|
||||||
|
readonly mode: "legacy";
|
||||||
readonly foreignKeys: ReturnType<typeof getTableConfig>["foreignKeys"];
|
readonly foreignKeys: ReturnType<typeof getTableConfig>["foreignKeys"];
|
||||||
readonly path: string;
|
readonly path: string;
|
||||||
|
readonly table: AnyPgTable;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface AbiModuleTableCandidate extends DiscoveredModuleTable {
|
||||||
|
readonly mode: "abi-v1";
|
||||||
|
readonly foreignKeys: ModuleSchemaMetadataDefinition["tables"][number]["foreignKeys"];
|
||||||
|
readonly path: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
type ModuleTableCandidate = LegacyModuleTableCandidate | AbiModuleTableCandidate;
|
||||||
|
|
||||||
const drizzleTableIsAlias = Symbol.for("drizzle:IsAlias");
|
const drizzleTableIsAlias = Symbol.for("drizzle:IsAlias");
|
||||||
|
|
||||||
export class ModuleSchemaDiscoveryError extends Error {
|
export class ModuleSchemaDiscoveryError extends Error {
|
||||||
@@ -156,9 +181,13 @@ async function loadSchemaDefinitions(discoveredModules: readonly DiscoveredModul
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
let definition: ModuleSchemaDefinition;
|
let definition: ModuleSchemaDefinition | ModuleSchemaMetadataDefinition;
|
||||||
|
const mode = discovered.manifest.database?.abi === 1 ? "abi-v1" : "legacy";
|
||||||
try {
|
try {
|
||||||
definition = parseModuleSchemaDefinition(defaultExport(moduleNamespace));
|
definition =
|
||||||
|
mode === "abi-v1"
|
||||||
|
? parseModuleSchemaMetadataDefinition(defaultExport(moduleNamespace))
|
||||||
|
: parseModuleSchemaDefinition(defaultExport(moduleNamespace));
|
||||||
} catch {
|
} catch {
|
||||||
issues.push(
|
issues.push(
|
||||||
issue(
|
issue(
|
||||||
@@ -182,7 +211,11 @@ async function loadSchemaDefinitions(discoveredModules: readonly DiscoveredModul
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
loaded.push({ definition, discovered });
|
loaded.push(
|
||||||
|
mode === "abi-v1"
|
||||||
|
? { definition: definition as ModuleSchemaMetadataDefinition, discovered, mode }
|
||||||
|
: { definition: definition as ModuleSchemaDefinition, discovered, mode },
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return { issues, loaded };
|
return { issues, loaded };
|
||||||
@@ -197,6 +230,58 @@ function inspectTables(
|
|||||||
|
|
||||||
for (const loaded of loadedSchemas) {
|
for (const loaded of loadedSchemas) {
|
||||||
const moduleId = loaded.discovered.manifest.id;
|
const moduleId = loaded.discovered.manifest.id;
|
||||||
|
if (loaded.mode === "abi-v1") {
|
||||||
|
const candidates: AbiModuleTableCandidate[] = [];
|
||||||
|
loaded.definition.tables.forEach((table, index) => {
|
||||||
|
const path = `tables.${index}`;
|
||||||
|
const candidate: AbiModuleTableCandidate = {
|
||||||
|
exportName: table.name,
|
||||||
|
foreignKeys: table.foreignKeys,
|
||||||
|
mode: "abi-v1",
|
||||||
|
moduleId,
|
||||||
|
name: table.name,
|
||||||
|
path,
|
||||||
|
};
|
||||||
|
const tablePrefix = moduleTablePrefix(moduleId);
|
||||||
|
if (!table.name.startsWith(tablePrefix)) {
|
||||||
|
issues.push(
|
||||||
|
issue(
|
||||||
|
"MODULE_SCHEMA_TABLE_NAMESPACE_INVALID",
|
||||||
|
moduleId,
|
||||||
|
`Table ${table.name} is not namespaced for module ${moduleId}.`,
|
||||||
|
{ path, tableName: table.name },
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} else if (
|
||||||
|
!moduleTableLocalNameSchema.safeParse(table.name.slice(tablePrefix.length)).success
|
||||||
|
) {
|
||||||
|
issues.push(
|
||||||
|
issue(
|
||||||
|
"MODULE_SCHEMA_TABLE_NAME_INVALID",
|
||||||
|
moduleId,
|
||||||
|
`Module ${moduleId} has an invalid local table name.`,
|
||||||
|
{ path },
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const existing = firstTableByName.get(table.name);
|
||||||
|
if (existing) {
|
||||||
|
issues.push(
|
||||||
|
issue(
|
||||||
|
"MODULE_SCHEMA_TABLE_DUPLICATE",
|
||||||
|
moduleId,
|
||||||
|
`Table ${table.name} is declared more than once.`,
|
||||||
|
{ path, tableName: table.name },
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
firstTableByName.set(table.name, candidate);
|
||||||
|
}
|
||||||
|
candidates.push(candidate);
|
||||||
|
});
|
||||||
|
inspectedModules.push({ discovered: loaded.discovered, tables: candidates });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
const seenTables = new Set<AnyPgTable>();
|
const seenTables = new Set<AnyPgTable>();
|
||||||
const candidates: ModuleTableCandidate[] = [];
|
const candidates: ModuleTableCandidate[] = [];
|
||||||
const entries = Object.entries(loaded.definition.tables).sort(([left], [right]) =>
|
const entries = Object.entries(loaded.definition.tables).sort(([left], [right]) =>
|
||||||
@@ -293,6 +378,7 @@ function inspectTables(
|
|||||||
const candidate: ModuleTableCandidate = {
|
const candidate: ModuleTableCandidate = {
|
||||||
exportName,
|
exportName,
|
||||||
foreignKeys: config.foreignKeys,
|
foreignKeys: config.foreignKeys,
|
||||||
|
mode: "legacy",
|
||||||
moduleId,
|
moduleId,
|
||||||
name: config.name,
|
name: config.name,
|
||||||
path,
|
path,
|
||||||
@@ -321,8 +407,8 @@ function inspectTables(
|
|||||||
}
|
}
|
||||||
|
|
||||||
function validForeignKeyTarget(
|
function validForeignKeyTarget(
|
||||||
foreignKey: ModuleTableCandidate["foreignKeys"][number],
|
foreignKey: LegacyModuleTableCandidate["foreignKeys"][number],
|
||||||
candidate: ModuleTableCandidate,
|
candidate: LegacyModuleTableCandidate,
|
||||||
): AnyPgTable | undefined {
|
): AnyPgTable | undefined {
|
||||||
try {
|
try {
|
||||||
const reference = foreignKey.reference();
|
const reference = foreignKey.reference();
|
||||||
@@ -356,7 +442,9 @@ function inspectForeignKeys(
|
|||||||
const ownerByTable = new Map<AnyPgTable, ModuleId>();
|
const ownerByTable = new Map<AnyPgTable, ModuleId>();
|
||||||
for (const inspected of inspectedModules) {
|
for (const inspected of inspectedModules) {
|
||||||
for (const candidate of inspected.tables) {
|
for (const candidate of inspected.tables) {
|
||||||
ownerByTable.set(candidate.table, candidate.moduleId);
|
if (candidate.mode === "legacy") {
|
||||||
|
ownerByTable.set(candidate.table, candidate.moduleId);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -364,6 +452,32 @@ function inspectForeignKeys(
|
|||||||
const moduleId = inspected.discovered.manifest.id;
|
const moduleId = inspected.discovered.manifest.id;
|
||||||
const ownedTables = new Set(inspected.tables.map((candidate) => candidate.table));
|
const ownedTables = new Set(inspected.tables.map((candidate) => candidate.table));
|
||||||
for (const candidate of inspected.tables) {
|
for (const candidate of inspected.tables) {
|
||||||
|
if (candidate.mode === "abi-v1") {
|
||||||
|
const ownedNames = new Set(inspected.tables.map((table) => table.name));
|
||||||
|
candidate.foreignKeys.forEach((foreignKey, foreignKeyIndex) => {
|
||||||
|
const path = `${candidate.path}.foreignKeys.${foreignKeyIndex}`;
|
||||||
|
if (!foreignKey.table.startsWith(moduleTablePrefix(moduleId))) {
|
||||||
|
issues.push(
|
||||||
|
issue(
|
||||||
|
"MODULE_SCHEMA_CROSS_MODULE_FOREIGN_KEY",
|
||||||
|
moduleId,
|
||||||
|
`Table ${candidate.name} has a foreign key outside module ${moduleId}.`,
|
||||||
|
{ path, tableName: candidate.name },
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} else if (!ownedNames.has(foreignKey.table)) {
|
||||||
|
issues.push(
|
||||||
|
issue(
|
||||||
|
"MODULE_SCHEMA_FOREIGN_KEY_TARGET_MISSING",
|
||||||
|
moduleId,
|
||||||
|
`Table ${candidate.name} references a table not exported by module ${moduleId}.`,
|
||||||
|
{ path, tableName: candidate.name },
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
candidate.foreignKeys.forEach((foreignKey, foreignKeyIndex) => {
|
candidate.foreignKeys.forEach((foreignKey, foreignKeyIndex) => {
|
||||||
const path = `${candidate.path}.foreignKeys.${foreignKeyIndex}`;
|
const path = `${candidate.path}.foreignKeys.${foreignKeyIndex}`;
|
||||||
const foreignTable = validForeignKeyTarget(foreignKey, candidate);
|
const foreignTable = validForeignKeyTarget(foreignKey, candidate);
|
||||||
@@ -446,7 +560,9 @@ function publishAggregate(
|
|||||||
moduleId: inspected.discovered.manifest.id,
|
moduleId: inspected.discovered.manifest.id,
|
||||||
tables: Object.freeze(
|
tables: Object.freeze(
|
||||||
inspected.tables
|
inspected.tables
|
||||||
.map(({ foreignKeys: _foreignKeys, path: _path, ...table }) => Object.freeze(table))
|
.map(({ foreignKeys: _foreignKeys, mode: _mode, path: _path, ...table }) =>
|
||||||
|
Object.freeze(table),
|
||||||
|
)
|
||||||
.sort((left, right) => compareText(left.exportName, right.exportName)),
|
.sort((left, right) => compareText(left.exportName, right.exportName)),
|
||||||
),
|
),
|
||||||
}),
|
}),
|
||||||
@@ -458,7 +574,11 @@ function publishAggregate(
|
|||||||
.sort((left, right) => compareText(left.name, right.name)),
|
.sort((left, right) => compareText(left.name, right.name)),
|
||||||
);
|
);
|
||||||
const drizzleSchema = Object.freeze(
|
const drizzleSchema = Object.freeze(
|
||||||
Object.fromEntries(tables.map((table) => [table.name, table.table] as const)),
|
Object.fromEntries(
|
||||||
|
tables.flatMap((table) =>
|
||||||
|
table.table === undefined ? [] : [[table.name, table.table] as const],
|
||||||
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
return Object.freeze({ discoveredModules, drizzleSchema, modules, tables });
|
return Object.freeze({ discoveredModules, drizzleSchema, modules, tables });
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { describe, expect, it, vi } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import { IMPORT_SAFETY_TEST_TIMEOUT_MS } from "./support/import-safety.js";
|
||||||
|
|
||||||
const poolConstructor = vi.hoisted(() => vi.fn());
|
const poolConstructor = vi.hoisted(() => vi.fn());
|
||||||
|
|
||||||
@@ -7,16 +8,20 @@ vi.mock("pg", () => ({ Pool: poolConstructor }));
|
|||||||
describe("bootstrap-platform-owner CLI import safety", () => {
|
describe("bootstrap-platform-owner CLI import safety", () => {
|
||||||
const originalEnv = { ...process.env };
|
const originalEnv = { ...process.env };
|
||||||
|
|
||||||
it("does not read DATABASE_URL, connect to PostgreSQL, or run bootstrap merely by importing the module", async () => {
|
it(
|
||||||
delete process.env.DATABASE_URL;
|
"does not read DATABASE_URL, connect to PostgreSQL, or run bootstrap merely by importing the module",
|
||||||
|
async () => {
|
||||||
|
delete process.env.DATABASE_URL;
|
||||||
|
|
||||||
// Under Vitest, process.argv[1] is never this file's own path, so isDirectExecution() is
|
// Under Vitest, process.argv[1] is never this file's own path, so isDirectExecution() is
|
||||||
// false the same way it naturally is for apps/api/src/server.ts under its own test suite —
|
// false the same way it naturally is for apps/api/src/server.ts under its own test suite —
|
||||||
// no argv manipulation is needed to prove the guard holds.
|
// no argv manipulation is needed to prove the guard holds.
|
||||||
await expect(import("../src/cli/bootstrap-platform-owner.js")).resolves.toBeDefined();
|
await expect(import("../src/cli/bootstrap-platform-owner.js")).resolves.toBeDefined();
|
||||||
|
|
||||||
expect(poolConstructor).not.toHaveBeenCalled();
|
expect(poolConstructor).not.toHaveBeenCalled();
|
||||||
|
|
||||||
process.env = originalEnv;
|
process.env = originalEnv;
|
||||||
});
|
},
|
||||||
|
IMPORT_SAFETY_TEST_TIMEOUT_MS,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { describe, expect, it, vi } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import { IMPORT_SAFETY_TEST_TIMEOUT_MS } from "./support/import-safety.js";
|
||||||
|
|
||||||
const poolConstructor = vi.hoisted(() => vi.fn());
|
const poolConstructor = vi.hoisted(() => vi.fn());
|
||||||
|
|
||||||
@@ -7,18 +8,22 @@ vi.mock("pg", () => ({ Pool: poolConstructor }));
|
|||||||
describe("create-initial-owner CLI import safety", () => {
|
describe("create-initial-owner CLI import safety", () => {
|
||||||
const originalEnv = { ...process.env };
|
const originalEnv = { ...process.env };
|
||||||
|
|
||||||
it("does not read DATABASE_URL, connect to PostgreSQL, or create anything merely by importing the module", async () => {
|
it(
|
||||||
delete process.env.DATABASE_URL;
|
"does not read DATABASE_URL, connect to PostgreSQL, or create anything merely by importing the module",
|
||||||
|
async () => {
|
||||||
|
delete process.env.DATABASE_URL;
|
||||||
|
|
||||||
// Under Vitest, process.argv[1] is never this file's own path, so isDirectExecution() is false
|
// Under Vitest, process.argv[1] is never this file's own path, so isDirectExecution() is false
|
||||||
// exactly as it is for `bootstrap-platform-owner.ts` — no argv manipulation is needed to prove
|
// exactly as it is for `bootstrap-platform-owner.ts` — no argv manipulation is needed to prove
|
||||||
// the guard holds. Importing the command must never prompt, connect, or write.
|
// the guard holds. Importing the command must never prompt, connect, or write.
|
||||||
await expect(import("../src/cli/create-initial-owner.js")).resolves.toBeDefined();
|
await expect(import("../src/cli/create-initial-owner.js")).resolves.toBeDefined();
|
||||||
|
|
||||||
expect(poolConstructor).not.toHaveBeenCalled();
|
expect(poolConstructor).not.toHaveBeenCalled();
|
||||||
|
|
||||||
process.env = originalEnv;
|
process.env = originalEnv;
|
||||||
});
|
},
|
||||||
|
IMPORT_SAFETY_TEST_TIMEOUT_MS,
|
||||||
|
);
|
||||||
|
|
||||||
it("reads no environment variable for the password", async () => {
|
it("reads no environment variable for the password", async () => {
|
||||||
// The password is prompted for or read from standard input; it is deliberately never taken from
|
// The password is prompted for or read from standard input; it is deliberately never taken from
|
||||||
|
|||||||
@@ -91,7 +91,7 @@ async function createModule(
|
|||||||
writeFile(
|
writeFile(
|
||||||
path.join(moduleDirectory, "ekklesia.module.json"),
|
path.join(moduleDirectory, "ekklesia.module.json"),
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
manifestVersion: 7,
|
manifestVersion: 8,
|
||||||
id,
|
id,
|
||||||
name: `${id} module`,
|
name: `${id} module`,
|
||||||
version: "0.1.0",
|
version: "0.1.0",
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ async function createModule(modulesDirectory: string, fixture: ModuleFixture): P
|
|||||||
: {}),
|
: {}),
|
||||||
entrypoints: { server: "./dist/server/index.js", ui: "./dist/ui/index.js" },
|
entrypoints: { server: "./dist/server/index.js", ui: "./dist/ui/index.js" },
|
||||||
id: fixture.id,
|
id: fixture.id,
|
||||||
manifestVersion: 7,
|
manifestVersion: 8,
|
||||||
name: fixture.name,
|
name: fixture.name,
|
||||||
version: "1.0.0",
|
version: "1.0.0",
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -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",
|
||||||
});
|
});
|
||||||
@@ -82,7 +83,7 @@ function discoveredModule(id: string, overrides: Record<string, unknown> = {}):
|
|||||||
},
|
},
|
||||||
entrypoints: { server: "./dist/server.js" },
|
entrypoints: { server: "./dist/server.js" },
|
||||||
id,
|
id,
|
||||||
manifestVersion: 7,
|
manifestVersion: 8,
|
||||||
name: `${id} module`,
|
name: `${id} module`,
|
||||||
permissions: [],
|
permissions: [],
|
||||||
storage: { buckets: [] },
|
storage: { buckets: [] },
|
||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { describe, expect, it, vi } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import { IMPORT_SAFETY_TEST_TIMEOUT_MS } from "./support/import-safety.js";
|
||||||
|
|
||||||
const discoverModules = vi.fn();
|
const discoverModules = vi.fn();
|
||||||
const discoverModuleSchemas = vi.fn();
|
const discoverModuleSchemas = vi.fn();
|
||||||
@@ -22,24 +23,28 @@ vi.mock("../src/migrations/index.js", async (importOriginal) => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
describe("Doctor import safety", () => {
|
describe("Doctor import safety", () => {
|
||||||
it("does not discover, connect, plan, or print when imported", async () => {
|
it(
|
||||||
const log = vi.spyOn(console, "log").mockImplementation(() => undefined);
|
"does not discover, connect, plan, or print when imported",
|
||||||
const error = vi.spyOn(console, "error").mockImplementation(() => undefined);
|
async () => {
|
||||||
|
const log = vi.spyOn(console, "log").mockImplementation(() => undefined);
|
||||||
|
const error = vi.spyOn(console, "error").mockImplementation(() => undefined);
|
||||||
|
|
||||||
await import("../src/diagnostics/index.js");
|
await import("../src/diagnostics/index.js");
|
||||||
await import("../src/cli/doctor.js");
|
await import("../src/cli/doctor.js");
|
||||||
await import("../src/index.js");
|
await import("../src/index.js");
|
||||||
|
|
||||||
expect(discoverModules).not.toHaveBeenCalled();
|
expect(discoverModules).not.toHaveBeenCalled();
|
||||||
expect(discoverModuleSchemas).not.toHaveBeenCalled();
|
expect(discoverModuleSchemas).not.toHaveBeenCalled();
|
||||||
expect(discoverModuleMigrations).not.toHaveBeenCalled();
|
expect(discoverModuleMigrations).not.toHaveBeenCalled();
|
||||||
expect(startModuleRuntime).not.toHaveBeenCalled();
|
expect(startModuleRuntime).not.toHaveBeenCalled();
|
||||||
expect(createPostgresMigrationDatabase).not.toHaveBeenCalled();
|
expect(createPostgresMigrationDatabase).not.toHaveBeenCalled();
|
||||||
expect(planModuleMigrations).not.toHaveBeenCalled();
|
expect(planModuleMigrations).not.toHaveBeenCalled();
|
||||||
expect(log).not.toHaveBeenCalled();
|
expect(log).not.toHaveBeenCalled();
|
||||||
expect(error).not.toHaveBeenCalled();
|
expect(error).not.toHaveBeenCalled();
|
||||||
|
|
||||||
log.mockRestore();
|
log.mockRestore();
|
||||||
error.mockRestore();
|
error.mockRestore();
|
||||||
});
|
},
|
||||||
|
IMPORT_SAFETY_TEST_TIMEOUT_MS,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
+130
-5
@@ -1,5 +1,9 @@
|
|||||||
|
import { mkdtemp, rm } from "node:fs/promises";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
import { type ModuleManifest, parsePermissionId } from "@ekklesia/contracts";
|
import { type ModuleManifest, parsePermissionId } from "@ekklesia/contracts";
|
||||||
import { describe, expect, it, vi } from "vitest";
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
import { createPermissionRegistry, PermissionRegistryError } from "../src/authorization/index.js";
|
import { createPermissionRegistry, PermissionRegistryError } from "../src/authorization/index.js";
|
||||||
import {
|
import {
|
||||||
type DoctorCheck,
|
type DoctorCheck,
|
||||||
@@ -28,7 +32,11 @@ const checksumA = "a".repeat(64);
|
|||||||
const checksumB = "b".repeat(64);
|
const checksumB = "b".repeat(64);
|
||||||
const checksumC = "c".repeat(64);
|
const checksumC = "c".repeat(64);
|
||||||
|
|
||||||
function discoveredModule(id: string, version = "0.1.0"): DiscoveredModule {
|
function discoveredModule(
|
||||||
|
id: string,
|
||||||
|
version = "0.1.0",
|
||||||
|
database?: { readonly abi: 1 },
|
||||||
|
): DiscoveredModule {
|
||||||
return {
|
return {
|
||||||
contributions: [],
|
contributions: [],
|
||||||
directory: `/unpublished/${id}`,
|
directory: `/unpublished/${id}`,
|
||||||
@@ -37,13 +45,14 @@ function discoveredModule(id: string, version = "0.1.0"): DiscoveredModule {
|
|||||||
capabilities: [],
|
capabilities: [],
|
||||||
compatibility: { moduleSdk: "^0.1.0", platform: "^0.1.0" },
|
compatibility: { moduleSdk: "^0.1.0", platform: "^0.1.0" },
|
||||||
configuration: [],
|
configuration: [],
|
||||||
|
...(database ? { database } : {}),
|
||||||
dependencies: {
|
dependencies: {
|
||||||
capabilities: { optional: {}, required: {} },
|
capabilities: { optional: {}, required: {} },
|
||||||
modules: { optional: {}, required: {} },
|
modules: { optional: {}, required: {} },
|
||||||
},
|
},
|
||||||
entrypoints: { server: "./dist/server.js" },
|
entrypoints: { server: "./dist/server.js" },
|
||||||
id,
|
id,
|
||||||
manifestVersion: 7,
|
manifestVersion: 8,
|
||||||
name: `${id} module`,
|
name: `${id} module`,
|
||||||
permissions: [],
|
permissions: [],
|
||||||
storage: { buckets: [] },
|
storage: { buckets: [] },
|
||||||
@@ -114,7 +123,10 @@ function emptyDatabase(): MigrationDatabase {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function plan(entries: MigrationPlanResult["entries"] = []): MigrationPlanResult {
|
function plan(
|
||||||
|
entries: MigrationPlanResult["entries"] = [],
|
||||||
|
retained: MigrationPlanResult["retained"] = [],
|
||||||
|
): MigrationPlanResult {
|
||||||
const appliedCount = entries.filter((entry) => entry.status === "applied").length;
|
const 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 +136,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 +164,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",
|
||||||
@@ -285,7 +299,7 @@ describe("Ekklesia Doctor outbound email reporting", () => {
|
|||||||
describe("Ekklesia Doctor diagnostics", () => {
|
describe("Ekklesia Doctor diagnostics", () => {
|
||||||
it("publishes a healthy deterministic static report without database configuration", async () => {
|
it("publishes a healthy deterministic static report without database configuration", async () => {
|
||||||
const people = discoveredModule("people");
|
const people = discoveredModule("people");
|
||||||
const calendar = discoveredModule("calendar", "0.2.0");
|
const calendar = discoveredModule("calendar", "0.2.0", { abi: 1 });
|
||||||
const inventory = migrationAggregate(
|
const inventory = migrationAggregate(
|
||||||
[migration("people", "0000_initial", checksumA)],
|
[migration("people", "0000_initial", checksumA)],
|
||||||
[people, calendar],
|
[people, calendar],
|
||||||
@@ -318,6 +332,7 @@ describe("Ekklesia Doctor diagnostics", () => {
|
|||||||
]);
|
]);
|
||||||
expect(check(report, "modules.discovery").details).toEqual(["people 0.1.0", "calendar 0.2.0"]);
|
expect(check(report, "modules.discovery").details).toEqual(["people 0.1.0", "calendar 0.2.0"]);
|
||||||
expect(check(report, "modules.dependencies").details).toEqual(["Order: people → calendar"]);
|
expect(check(report, "modules.dependencies").details).toEqual(["Order: people → calendar"]);
|
||||||
|
expect(check(report, "schemas.discovery")).toMatchObject({ status: "pass" });
|
||||||
expect(check(report, "modules.permissions")).toMatchObject({
|
expect(check(report, "modules.permissions")).toMatchObject({
|
||||||
details: ["0 permissions registered"],
|
details: ["0 permissions registered"],
|
||||||
status: "pass",
|
status: "pass",
|
||||||
@@ -1657,3 +1672,113 @@ describe("Doctor reports declared browser contributions", () => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("Doctor with installation-owned module storage (ADR 0048)", () => {
|
||||||
|
const platformCoreMigrations = path.join(
|
||||||
|
path.dirname(fileURLToPath(import.meta.url)),
|
||||||
|
"..",
|
||||||
|
"drizzle",
|
||||||
|
);
|
||||||
|
const temporaryDirectories: string[] = [];
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await Promise.all(
|
||||||
|
temporaryDirectories.splice(0).map((directory) => rm(directory, { recursive: true })),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("finds Core's migrations where the platform is installed, not beside the modules", async () => {
|
||||||
|
// A modules directory far from the platform, as the image mounts /var/lib/ekklesia/modules. Its
|
||||||
|
// parent has no `core/drizzle`, so a Doctor that still derived Core's migrations from the
|
||||||
|
// modules directory would fail migration discovery here.
|
||||||
|
const external = await mkdtemp(path.join(tmpdir(), "ekklesia-external-modules-"));
|
||||||
|
temporaryDirectories.push(external);
|
||||||
|
const report = await diagnoseEkklesia({
|
||||||
|
coreMigrationsDirectory: platformCoreMigrations,
|
||||||
|
moduleDiscovery: {
|
||||||
|
moduleSdkVersion: "0.1.0",
|
||||||
|
modulesDirectory: external,
|
||||||
|
modulesDirectoryRequired: true,
|
||||||
|
platformVersion: "0.1.0",
|
||||||
|
},
|
||||||
|
runtimeRange: ">=24.11.0 <25",
|
||||||
|
runtimeVersion: "24.15.0",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(check(report, "modules.discovery").status).toBe("pass");
|
||||||
|
const migrations = check(report, "migrations.discovery");
|
||||||
|
expect(migrations.status).toBe("pass");
|
||||||
|
expect(migrations.details.some((detail) => detail.startsWith("core:0000_"))).toBe(true);
|
||||||
|
expect(JSON.stringify(report)).not.toContain(external);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports an unusable configured modules directory as a discovery failure, naming no path", async () => {
|
||||||
|
const report = await diagnoseEkklesia({
|
||||||
|
coreMigrationsDirectory: platformCoreMigrations,
|
||||||
|
moduleDiscovery: {
|
||||||
|
moduleSdkVersion: "0.1.0",
|
||||||
|
modulesDirectory: "relative/private-mount-3c9d",
|
||||||
|
modulesDirectoryRequired: true,
|
||||||
|
platformVersion: "0.1.0",
|
||||||
|
},
|
||||||
|
runtimeRange: ">=24.11.0 <25",
|
||||||
|
runtimeVersion: "24.15.0",
|
||||||
|
});
|
||||||
|
|
||||||
|
const discovery = check(report, "modules.discovery");
|
||||||
|
expect(discovery.status).toBe("failure");
|
||||||
|
expect(discovery.details).toEqual(["The configured modules directory is not usable."]);
|
||||||
|
expect(check(report, "migrations.discovery").status).toBe("skipped");
|
||||||
|
expect(JSON.stringify(report)).not.toContain("private-mount-3c9d");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports a configured modules directory that does not exist, naming no path", async () => {
|
||||||
|
const parent = await mkdtemp(path.join(tmpdir(), "ekklesia-missing-modules-"));
|
||||||
|
temporaryDirectories.push(parent);
|
||||||
|
const report = await diagnoseEkklesia({
|
||||||
|
coreMigrationsDirectory: platformCoreMigrations,
|
||||||
|
moduleDiscovery: {
|
||||||
|
moduleSdkVersion: "0.1.0",
|
||||||
|
modulesDirectory: path.join(parent, "absent"),
|
||||||
|
modulesDirectoryRequired: true,
|
||||||
|
platformVersion: "0.1.0",
|
||||||
|
},
|
||||||
|
runtimeRange: ">=24.11.0 <25",
|
||||||
|
runtimeVersion: "24.15.0",
|
||||||
|
});
|
||||||
|
|
||||||
|
const discovery = check(report, "modules.discovery");
|
||||||
|
expect(discovery.status).toBe("failure");
|
||||||
|
expect(discovery.details).toEqual(["The configured modules directory could not be read."]);
|
||||||
|
expect(JSON.stringify(report)).not.toContain(parent);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports an absent module's retained history without blocking readiness", async () => {
|
||||||
|
const report = await diagnoseEkklesia(
|
||||||
|
options("postgres://[email protected]/ekklesia"),
|
||||||
|
dependencies({
|
||||||
|
planMigrations: vi.fn(async () =>
|
||||||
|
plan(
|
||||||
|
[planEntry("core:0000_identity_core_tables", "a".repeat(64), "applied")],
|
||||||
|
[Object.freeze({ appliedCount: 2, moduleId: "calendar" })],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const planCheck = check(report, "postgresql.plan");
|
||||||
|
expect(planCheck.status).toBe("pass");
|
||||||
|
expect(planCheck.details).toContain(
|
||||||
|
"retained · calendar · 2 applied migrations kept; the module is not installed",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("passes Core's own migration directory to migration discovery", async () => {
|
||||||
|
const discoverMigrations = vi.fn(async () => migrationAggregate());
|
||||||
|
await diagnoseEkklesia(options(), dependencies({ discoverMigrations }));
|
||||||
|
expect(discoverMigrations).toHaveBeenCalledWith({
|
||||||
|
coreMigrationsDirectory: "/not-rendered/core/drizzle",
|
||||||
|
moduleDiscovery: options().moduleDiscovery,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
+3
-3
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"manifestVersion": 7,
|
"manifestVersion": 8,
|
||||||
"id": "db-facade",
|
"id": "db-facade",
|
||||||
"name": "Database Facade Integration Test Module",
|
"name": "Database Facade Integration Test Module",
|
||||||
"description": "Test-only installed module fixture proving ModuleDatabase against real PostgreSQL. Never discovered by production startup, Doctor, or normal migration aggregation — only apps/api/tests/module-database.integration.ts points discovery at it explicitly.",
|
"description": "Test-only installed module fixture proving ModuleDatabase against real PostgreSQL. Never discovered by production startup, Doctor, or normal migration aggregation — only apps/api/tests/module-database.integration.ts points discovery at it explicitly.",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"compatibility": {
|
"compatibility": {
|
||||||
"platform": "^0.1.0",
|
"platform": "^0.2.0",
|
||||||
"moduleSdk": "^0.1.0"
|
"moduleSdk": "^0.2.0"
|
||||||
},
|
},
|
||||||
"permissions": [
|
"permissions": [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { alias, getTableConfig, integer, pgTable } from "drizzle-orm/pg-core";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { parseModuleSchemaDefinition } from "../src/modules/legacy-schema-definition.js";
|
||||||
|
|
||||||
|
describe("the legacy module schema definition", () => {
|
||||||
|
it("normalizes and freezes a typed schema definition without freezing its tables", () => {
|
||||||
|
const records = pgTable("example_records", { id: integer().primaryKey() });
|
||||||
|
const definition = parseModuleSchemaDefinition({
|
||||||
|
moduleId: "example",
|
||||||
|
tables: { records },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(definition.tables.records).toBe(records);
|
||||||
|
expect(getTableConfig(records).name).toBe("example_records");
|
||||||
|
expect(Object.isFrozen(definition)).toBe(true);
|
||||||
|
expect(Object.isFrozen(definition.tables)).toBe(true);
|
||||||
|
expect(Object.isFrozen(records)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects malformed, empty, unknown, and non-PostgreSQL table definitions", () => {
|
||||||
|
const records = pgTable("example_records", { id: integer() });
|
||||||
|
const disguisedRecords = alias(records, "example_disguised_records");
|
||||||
|
expect(() => parseModuleSchemaDefinition({ moduleId: "example", tables: {} })).toThrow();
|
||||||
|
expect(() =>
|
||||||
|
parseModuleSchemaDefinition({ moduleId: "example", tables: { records: {} } }),
|
||||||
|
).toThrow();
|
||||||
|
expect(() =>
|
||||||
|
parseModuleSchemaDefinition({ moduleId: "example", tables: { records }, extra: true }),
|
||||||
|
).toThrow();
|
||||||
|
expect(() =>
|
||||||
|
parseModuleSchemaDefinition({ moduleId: "example", tables: { disguisedRecords } }),
|
||||||
|
).toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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,
|
||||||
|
]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ function fakeModule(
|
|||||||
} = {},
|
} = {},
|
||||||
): DiscoveredModule {
|
): DiscoveredModule {
|
||||||
const manifest = parseModuleManifest({
|
const manifest = parseModuleManifest({
|
||||||
manifestVersion: 7,
|
manifestVersion: 8,
|
||||||
id,
|
id,
|
||||||
name: `${id} module`,
|
name: `${id} module`,
|
||||||
version: "1.0.0",
|
version: "1.0.0",
|
||||||
|
|||||||
@@ -194,7 +194,7 @@ afterAll(async () => {
|
|||||||
|
|
||||||
function manifestWith(configuration: readonly Record<string, unknown>[], id = "announcements") {
|
function manifestWith(configuration: readonly Record<string, unknown>[], id = "announcements") {
|
||||||
return parseModuleManifest({
|
return parseModuleManifest({
|
||||||
manifestVersion: 7,
|
manifestVersion: 8,
|
||||||
id,
|
id,
|
||||||
name: `${id} module`,
|
name: `${id} module`,
|
||||||
version: "1.0.0",
|
version: "1.0.0",
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ function manifestWith(
|
|||||||
id = "announcements",
|
id = "announcements",
|
||||||
) {
|
) {
|
||||||
return parseModuleManifest({
|
return parseModuleManifest({
|
||||||
manifestVersion: 7,
|
manifestVersion: 8,
|
||||||
id,
|
id,
|
||||||
name: `${id} module`,
|
name: `${id} module`,
|
||||||
version: "1.0.0",
|
version: "1.0.0",
|
||||||
@@ -92,7 +92,7 @@ describe("module configuration declarations", () => {
|
|||||||
|
|
||||||
it("rejects two declarations of the same key, so an ordinary key and a secret can never collide", () => {
|
it("rejects two declarations of the same key, so an ordinary key and a secret can never collide", () => {
|
||||||
const result = safeParseModuleManifest({
|
const result = safeParseModuleManifest({
|
||||||
manifestVersion: 7,
|
manifestVersion: 8,
|
||||||
id: "announcements",
|
id: "announcements",
|
||||||
name: "announcements module",
|
name: "announcements module",
|
||||||
version: "1.0.0",
|
version: "1.0.0",
|
||||||
@@ -114,7 +114,7 @@ describe("module configuration declarations", () => {
|
|||||||
|
|
||||||
it("rejects a default on a secret, because a checked-in default credential is a leaked one", () => {
|
it("rejects a default on a secret, because a checked-in default credential is a leaked one", () => {
|
||||||
const result = safeParseModuleManifest({
|
const result = safeParseModuleManifest({
|
||||||
manifestVersion: 7,
|
manifestVersion: 8,
|
||||||
id: "announcements",
|
id: "announcements",
|
||||||
name: "announcements module",
|
name: "announcements module",
|
||||||
version: "1.0.0",
|
version: "1.0.0",
|
||||||
@@ -130,7 +130,7 @@ describe("module configuration declarations", () => {
|
|||||||
|
|
||||||
it("rejects a key that is both required and defaulted, which states an intent nothing can honour", () => {
|
it("rejects a key that is both required and defaulted, which states an intent nothing can honour", () => {
|
||||||
const result = safeParseModuleManifest({
|
const result = safeParseModuleManifest({
|
||||||
manifestVersion: 7,
|
manifestVersion: 8,
|
||||||
id: "announcements",
|
id: "announcements",
|
||||||
name: "announcements module",
|
name: "announcements module",
|
||||||
version: "1.0.0",
|
version: "1.0.0",
|
||||||
@@ -169,7 +169,7 @@ describe("module configuration declarations", () => {
|
|||||||
],
|
],
|
||||||
])("rejects %s", (_label, declaration) => {
|
])("rejects %s", (_label, declaration) => {
|
||||||
const result = safeParseModuleManifest({
|
const result = safeParseModuleManifest({
|
||||||
manifestVersion: 7,
|
manifestVersion: 8,
|
||||||
id: "announcements",
|
id: "announcements",
|
||||||
name: "announcements module",
|
name: "announcements module",
|
||||||
version: "1.0.0",
|
version: "1.0.0",
|
||||||
@@ -189,7 +189,7 @@ describe("module configuration declarations", () => {
|
|||||||
"valueOf",
|
"valueOf",
|
||||||
])("rejects the reserved object property name %s as a configuration key", (key) => {
|
])("rejects the reserved object property name %s as a configuration key", (key) => {
|
||||||
const result = safeParseModuleManifest({
|
const result = safeParseModuleManifest({
|
||||||
manifestVersion: 7,
|
manifestVersion: 8,
|
||||||
id: "announcements",
|
id: "announcements",
|
||||||
name: "announcements module",
|
name: "announcements module",
|
||||||
version: "1.0.0",
|
version: "1.0.0",
|
||||||
@@ -210,7 +210,7 @@ describe("module configuration declarations", () => {
|
|||||||
"",
|
"",
|
||||||
])("rejects the malformed configuration key %s", (key) => {
|
])("rejects the malformed configuration key %s", (key) => {
|
||||||
const result = safeParseModuleManifest({
|
const result = safeParseModuleManifest({
|
||||||
manifestVersion: 7,
|
manifestVersion: 8,
|
||||||
id: "announcements",
|
id: "announcements",
|
||||||
name: "announcements module",
|
name: "announcements module",
|
||||||
version: "1.0.0",
|
version: "1.0.0",
|
||||||
@@ -591,7 +591,7 @@ describe("configuration over a real discovered inventory", () => {
|
|||||||
writeFile(
|
writeFile(
|
||||||
path.join(moduleDirectory, "ekklesia.module.json"),
|
path.join(moduleDirectory, "ekklesia.module.json"),
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
manifestVersion: 7,
|
manifestVersion: 8,
|
||||||
id,
|
id,
|
||||||
name: `${id} module`,
|
name: `${id} module`,
|
||||||
version: "1.0.0",
|
version: "1.0.0",
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,867 @@
|
|||||||
|
import { readFile } from "node:fs/promises";
|
||||||
|
import {
|
||||||
|
isModuleDatabaseError,
|
||||||
|
type ModuleDatabaseErrorCode,
|
||||||
|
type ModuleDatabaseTransaction,
|
||||||
|
} from "@ekklesia/contracts";
|
||||||
|
import { DatabaseError, type Pool } from "pg";
|
||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import {
|
||||||
|
createModuleDatabaseExecutor,
|
||||||
|
moduleDatabaseExecutorModuleId,
|
||||||
|
} from "../src/modules/database-abi.js";
|
||||||
|
import {
|
||||||
|
decodeModuleDatabaseValue,
|
||||||
|
encodeModuleDatabaseParameters,
|
||||||
|
MODULE_DATABASE_MAX_PARAMETERS,
|
||||||
|
parsePostgresArrayText,
|
||||||
|
} from "../src/modules/database-abi-values.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Module Database ABI v1 (ADR 0050) without a database: parameter validation and encoding, result
|
||||||
|
* decoding from PostgreSQL text, option validation, error sanitization, and scope bookkeeping.
|
||||||
|
*
|
||||||
|
* Everything that depends on PostgreSQL's own behaviour (type OIDs, the protocol, SQLSTATEs,
|
||||||
|
* transactions, savepoints, session settings) is proven against a real database in
|
||||||
|
* `module-database-abi.integration.ts`. The scripted client here only replays command tags and
|
||||||
|
* transaction status, so the executor's own bookkeeping can be observed statement by statement.
|
||||||
|
*/
|
||||||
|
|
||||||
|
async function rejection(promise: Promise<unknown>): Promise<unknown> {
|
||||||
|
try {
|
||||||
|
await promise;
|
||||||
|
} catch (error) {
|
||||||
|
return error;
|
||||||
|
}
|
||||||
|
throw new Error("Expected the operation to reject.");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function expectAbiError(
|
||||||
|
promise: Promise<unknown>,
|
||||||
|
code: ModuleDatabaseErrorCode,
|
||||||
|
sqlState?: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const error = await rejection(promise);
|
||||||
|
expect(isModuleDatabaseError(error)).toBe(true);
|
||||||
|
expect(error).toMatchObject({ code });
|
||||||
|
expect((error as { sqlState?: string }).sqlState).toBe(sqlState);
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncThrows(run: () => unknown): unknown {
|
||||||
|
try {
|
||||||
|
run();
|
||||||
|
} catch (error) {
|
||||||
|
return error;
|
||||||
|
}
|
||||||
|
throw new Error("Expected the call to throw.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Parameters --------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe("encodeModuleDatabaseParameters", () => {
|
||||||
|
it("encodes every scalar category as text, or bytes, and nothing else", () => {
|
||||||
|
const bytes = new Uint8Array([0, 1, 255]);
|
||||||
|
const encoded = encodeModuleDatabaseParameters([
|
||||||
|
null,
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
1.5,
|
||||||
|
Number.NaN,
|
||||||
|
Number.POSITIVE_INFINITY,
|
||||||
|
Number.NEGATIVE_INFINITY,
|
||||||
|
9_007_199_254_740_993n,
|
||||||
|
'it\'s \\ "quoted"',
|
||||||
|
bytes,
|
||||||
|
]);
|
||||||
|
expect(encoded.slice(0, 9)).toEqual([
|
||||||
|
null,
|
||||||
|
"true",
|
||||||
|
"false",
|
||||||
|
"1.5",
|
||||||
|
"NaN",
|
||||||
|
"Infinity",
|
||||||
|
"-Infinity",
|
||||||
|
"9007199254740993",
|
||||||
|
'it\'s \\ "quoted"',
|
||||||
|
]);
|
||||||
|
expect(Buffer.isBuffer(encoded[9])).toBe(true);
|
||||||
|
expect([...(encoded[9] as Buffer)]).toEqual([0, 1, 255]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("copies bytes, so a later change by the caller cannot reach the statement", () => {
|
||||||
|
const bytes = new Uint8Array([1, 2, 3]);
|
||||||
|
const view = bytes.subarray(1);
|
||||||
|
const [encoded] = encodeModuleDatabaseParameters([view]);
|
||||||
|
bytes[1] = 9;
|
||||||
|
expect([...(encoded as Buffer)]).toEqual([2, 3]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("encodes arrays as quoted PostgreSQL array literals, nested for more dimensions", () => {
|
||||||
|
expect(
|
||||||
|
encodeModuleDatabaseParameters([
|
||||||
|
[1, null, 3],
|
||||||
|
['a"b', "c\\d", "", "NULL", "{,}"],
|
||||||
|
[
|
||||||
|
[1, 2],
|
||||||
|
[3, 4],
|
||||||
|
],
|
||||||
|
[new Uint8Array([0, 255])],
|
||||||
|
[true, 2n],
|
||||||
|
[],
|
||||||
|
]),
|
||||||
|
).toEqual([
|
||||||
|
'{"1",NULL,"3"}',
|
||||||
|
'{"a\\"b","c\\\\d","","NULL","{,}"}',
|
||||||
|
'{{"1","2"},{"3","4"}}',
|
||||||
|
'{"\\\\x00ff"}',
|
||||||
|
'{"true","2"}',
|
||||||
|
"{}",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("treats omitted values as no parameters", () => {
|
||||||
|
expect(encodeModuleDatabaseParameters(undefined)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
class Point {
|
||||||
|
readonly x = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
const refused: readonly [string, unknown][] = [
|
||||||
|
["a Date", new Date(0)],
|
||||||
|
["a plain object", { a: 1 }],
|
||||||
|
["a null-prototype object", Object.create(null)],
|
||||||
|
["undefined", undefined],
|
||||||
|
["a function", () => 1],
|
||||||
|
["a symbol", Symbol("s")],
|
||||||
|
["a class instance", new Point()],
|
||||||
|
["a Map", new Map()],
|
||||||
|
["another typed array", new Int16Array([1])],
|
||||||
|
["an ArrayBuffer", new ArrayBuffer(1)],
|
||||||
|
["a boxed string", new String("x")],
|
||||||
|
["an array holding a Date", [new Date(0)]],
|
||||||
|
["an array holding undefined", [1, undefined]],
|
||||||
|
["an array holding an object", [{ a: 1 }]],
|
||||||
|
// biome-ignore lint/suspicious/noSparseArray: the hole is the case under test.
|
||||||
|
["a sparse array", [1, , 3]],
|
||||||
|
["scalars mixed with sub-arrays", [1, [2]]],
|
||||||
|
["a null where a sub-array belongs", [[1], null]],
|
||||||
|
["ragged sub-arrays", [[1], [1, 2]]],
|
||||||
|
["more dimensions than PostgreSQL allows", [[[[[[[1]]]]]]]],
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const [label, value] of refused) {
|
||||||
|
it(`refuses ${label}, without naming the value`, () => {
|
||||||
|
const error = syncThrows(() => encodeModuleDatabaseParameters(["fine", value]));
|
||||||
|
expect(isModuleDatabaseError(error)).toBe(true);
|
||||||
|
expect(error).toMatchObject({ code: "MODULE_DATABASE_PARAMETER_INVALID" });
|
||||||
|
expect((error as Error).message).not.toMatch(/fine|1970|Point|Symbol/u);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
it("accepts exactly PostgreSQL's six array dimensions", () => {
|
||||||
|
expect(encodeModuleDatabaseParameters([[[[[[[1]]]]]]])).toEqual(['{{{{{{"1"}}}}}}']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses a values list that is not an array, or longer than the protocol allows", () => {
|
||||||
|
for (const values of [
|
||||||
|
"x",
|
||||||
|
{ 0: "x", length: 1 },
|
||||||
|
new Array(MODULE_DATABASE_MAX_PARAMETERS + 1).fill(1),
|
||||||
|
]) {
|
||||||
|
const error = syncThrows(() => encodeModuleDatabaseParameters(values));
|
||||||
|
expect(error).toMatchObject({ code: "MODULE_DATABASE_PARAMETER_INVALID" });
|
||||||
|
}
|
||||||
|
expect(
|
||||||
|
encodeModuleDatabaseParameters(new Array(MODULE_DATABASE_MAX_PARAMETERS).fill(1)),
|
||||||
|
).toHaveLength(MODULE_DATABASE_MAX_PARAMETERS);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses a self-referencing array instead of recursing forever", () => {
|
||||||
|
const cyclic: unknown[] = [];
|
||||||
|
cyclic.push(cyclic);
|
||||||
|
expect(syncThrows(() => encodeModuleDatabaseParameters([cyclic]))).toMatchObject({
|
||||||
|
code: "MODULE_DATABASE_PARAMETER_INVALID",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Results -----------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe("decodeModuleDatabaseValue", () => {
|
||||||
|
it("decodes the scalar types the table names, and keeps everything else as exact text", () => {
|
||||||
|
expect(decodeModuleDatabaseValue(16, "t")).toBe(true);
|
||||||
|
expect(decodeModuleDatabaseValue(16, "f")).toBe(false);
|
||||||
|
expect(decodeModuleDatabaseValue(21, "-32768")).toBe(-32_768);
|
||||||
|
expect(decodeModuleDatabaseValue(23, "2147483647")).toBe(2_147_483_647);
|
||||||
|
expect(decodeModuleDatabaseValue(26, "4294967295")).toBe(4_294_967_295);
|
||||||
|
expect(decodeModuleDatabaseValue(700, "1.5")).toBe(1.5);
|
||||||
|
expect(decodeModuleDatabaseValue(701, "NaN")).toBeNaN();
|
||||||
|
expect(decodeModuleDatabaseValue(701, "Infinity")).toBe(Number.POSITIVE_INFINITY);
|
||||||
|
expect(decodeModuleDatabaseValue(701, "-Infinity")).toBe(Number.NEGATIVE_INFINITY);
|
||||||
|
expect(decodeModuleDatabaseValue(701, "1e+300")).toBe(1e300);
|
||||||
|
// Precision-sensitive and zone-sensitive types stay exactly as PostgreSQL wrote them.
|
||||||
|
expect(decodeModuleDatabaseValue(20, "9223372036854775807")).toBe("9223372036854775807");
|
||||||
|
expect(decodeModuleDatabaseValue(1700, "0.1000000000000000000001")).toBe(
|
||||||
|
"0.1000000000000000000001",
|
||||||
|
);
|
||||||
|
expect(decodeModuleDatabaseValue(3802, '{"a": 12345678901234567890}')).toBe(
|
||||||
|
'{"a": 12345678901234567890}',
|
||||||
|
);
|
||||||
|
expect(decodeModuleDatabaseValue(1184, "2026-10-07 12:00:00+00")).toBe(
|
||||||
|
"2026-10-07 12:00:00+00",
|
||||||
|
);
|
||||||
|
// An unknown OID (an enum, a domain, an extension type) is text.
|
||||||
|
expect(decodeModuleDatabaseValue(987_654, "anything")).toBe("anything");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("decodes bytea to a plain Uint8Array, from hex or the older escape form", () => {
|
||||||
|
const hex = decodeModuleDatabaseValue(17, "\\x00ff10");
|
||||||
|
expect(Object.getPrototypeOf(hex)).toBe(Uint8Array.prototype);
|
||||||
|
expect([...(hex as Uint8Array)]).toEqual([0, 255, 16]);
|
||||||
|
expect([...(decodeModuleDatabaseValue(17, "\\x") as Uint8Array)]).toEqual([]);
|
||||||
|
const escaped = decodeModuleDatabaseValue(17, "a\\000\\\\\\377");
|
||||||
|
expect([...(escaped as Uint8Array)]).toEqual([97, 0, 92, 255]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("decodes built-in arrays element by element", () => {
|
||||||
|
expect(decodeModuleDatabaseValue(1016, "{1,NULL,9223372036854775807}")).toEqual([
|
||||||
|
"1",
|
||||||
|
null,
|
||||||
|
"9223372036854775807",
|
||||||
|
]);
|
||||||
|
expect(decodeModuleDatabaseValue(1231, "{1.10,2}")).toEqual(["1.10", "2"]);
|
||||||
|
expect(decodeModuleDatabaseValue(1000, "{t,f}")).toEqual([true, false]);
|
||||||
|
expect(decodeModuleDatabaseValue(1007, "{{1,2},{3,4}}")).toEqual([
|
||||||
|
[1, 2],
|
||||||
|
[3, 4],
|
||||||
|
]);
|
||||||
|
const bytes = decodeModuleDatabaseValue(1001, '{"\\\\x00ff"}') as Uint8Array[];
|
||||||
|
expect([...(bytes[0] as Uint8Array)]).toEqual([0, 255]);
|
||||||
|
expect(decodeModuleDatabaseValue(1009, "{}")).toEqual([]);
|
||||||
|
// An array of a type the table does not know arrives as text, like the type itself.
|
||||||
|
expect(decodeModuleDatabaseValue(987_655, "{a,b}")).toBe("{a,b}");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("parsePostgresArrayText", () => {
|
||||||
|
const text = (value: string) => value;
|
||||||
|
|
||||||
|
it('honours quoting, escapes, and the difference between NULL and "NULL"', () => {
|
||||||
|
expect(parsePostgresArrayText('{"a\\"b","c,d",NULL,"NULL","","{}",null}', text)).toEqual([
|
||||||
|
'a"b',
|
||||||
|
"c,d",
|
||||||
|
null,
|
||||||
|
"NULL",
|
||||||
|
"",
|
||||||
|
"{}",
|
||||||
|
null,
|
||||||
|
]);
|
||||||
|
expect(parsePostgresArrayText('{"back\\\\slash"}', text)).toEqual(["back\\slash"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("drops a non-default lower bound: a JavaScript array is a zero-based list", () => {
|
||||||
|
expect(parsePostgresArrayText("[0:1]={5,6}", text)).toEqual(["5", "6"]);
|
||||||
|
expect(parsePostgresArrayText("[1:1][2:3]={{7,8}}", text)).toEqual([["7", "8"]]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses text that is not an array literal", () => {
|
||||||
|
for (const malformed of ["", "{", "{1", '{"a', "{1}x", "[1:2]{1,2}", "1"]) {
|
||||||
|
expect(() => parsePostgresArrayText(malformed, text)).toThrow();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- The executor, over a scripted client -------------------------------------------------------
|
||||||
|
|
||||||
|
interface ScriptedStatement {
|
||||||
|
readonly text: string;
|
||||||
|
readonly values?: readonly unknown[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Script {
|
||||||
|
readonly pool: Pool;
|
||||||
|
readonly statements: ScriptedStatement[];
|
||||||
|
readonly released: (boolean | undefined)[];
|
||||||
|
/** Resolve a statement only when `release` is called, to model in-flight work. */
|
||||||
|
holdNext(): () => void;
|
||||||
|
failNext(error: unknown): void;
|
||||||
|
/** Fail the next statement whose text is exactly `text`. */
|
||||||
|
failWhen(text: string, error: unknown): void;
|
||||||
|
connectCount(): number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function commandOf(text: string): string {
|
||||||
|
const word = /^\s*([A-Za-z]+)/u.exec(text)?.[1]?.toUpperCase() ?? "";
|
||||||
|
return word === "END" ? "COMMIT" : word;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A pool whose one client replays PostgreSQL's command tags and transaction status for simple SQL.
|
||||||
|
* It is not PostgreSQL: it exists only so the executor's bookkeeping can be observed.
|
||||||
|
*/
|
||||||
|
function scriptedPool(): Script {
|
||||||
|
const statements: ScriptedStatement[] = [];
|
||||||
|
const released: (boolean | undefined)[] = [];
|
||||||
|
let status = "I";
|
||||||
|
let connects = 0;
|
||||||
|
let hold: Promise<void> | undefined;
|
||||||
|
let failure: unknown;
|
||||||
|
const failures = new Map<string, unknown>();
|
||||||
|
|
||||||
|
const client = {
|
||||||
|
getTransactionStatus: () => status,
|
||||||
|
on: () => client,
|
||||||
|
removeListener: () => client,
|
||||||
|
async query(config: string | { text: string; values?: unknown[] }) {
|
||||||
|
const text = typeof config === "string" ? config : config.text;
|
||||||
|
statements.push(
|
||||||
|
typeof config === "string" ? { text } : { text, values: config.values ?? [] },
|
||||||
|
);
|
||||||
|
const held = hold;
|
||||||
|
hold = undefined;
|
||||||
|
if (held !== undefined) {
|
||||||
|
await held;
|
||||||
|
}
|
||||||
|
if (failure !== undefined || failures.has(text)) {
|
||||||
|
const error = failure ?? failures.get(text);
|
||||||
|
failure = undefined;
|
||||||
|
failures.delete(text);
|
||||||
|
if (status === "T") {
|
||||||
|
status = "E";
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
const command = commandOf(text);
|
||||||
|
if (command === "BEGIN" || command === "START") {
|
||||||
|
status = "T";
|
||||||
|
} else if (command === "COMMIT" || (command === "ROLLBACK" && !/\bTO\b/iu.test(text))) {
|
||||||
|
const aborted = status === "E";
|
||||||
|
status = "I";
|
||||||
|
return { command: aborted ? "ROLLBACK" : command, fields: [], rowCount: null, rows: [] };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
command,
|
||||||
|
fields: [{ dataTypeID: 23, name: "n" }],
|
||||||
|
rowCount: 1,
|
||||||
|
rows: [["1"]],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
release: (destroy?: boolean) => {
|
||||||
|
released.push(destroy);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
connectCount: () => connects,
|
||||||
|
failNext(error) {
|
||||||
|
failure = error;
|
||||||
|
},
|
||||||
|
failWhen(text, error) {
|
||||||
|
failures.set(text, error);
|
||||||
|
},
|
||||||
|
holdNext() {
|
||||||
|
let release!: () => void;
|
||||||
|
hold = new Promise<void>((resolve) => {
|
||||||
|
release = resolve;
|
||||||
|
});
|
||||||
|
return release;
|
||||||
|
},
|
||||||
|
pool: {
|
||||||
|
connect: vi.fn(async () => {
|
||||||
|
connects += 1;
|
||||||
|
return client;
|
||||||
|
}),
|
||||||
|
} as unknown as Pool,
|
||||||
|
released,
|
||||||
|
statements,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function pgError(code: string): DatabaseError {
|
||||||
|
const error = new DatabaseError(
|
||||||
|
'duplicate key value violates unique constraint "example_secret_key"',
|
||||||
|
100,
|
||||||
|
"error",
|
||||||
|
);
|
||||||
|
error.code = code;
|
||||||
|
error.detail = "Key (secret)=(hunter2) already exists.";
|
||||||
|
error.hint = "a hint";
|
||||||
|
error.table = "example_records";
|
||||||
|
error.constraint = "example_secret_key";
|
||||||
|
return error;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pin =
|
||||||
|
"SET TimeZone TO 'UTC'; SET DateStyle TO 'ISO, MDY'; SET IntervalStyle TO 'postgres'; SET bytea_output TO 'hex'; SET extra_float_digits TO 1";
|
||||||
|
|
||||||
|
function texts(script: Script): string[] {
|
||||||
|
return script.statements.map((statement) => statement.text);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("createModuleDatabaseExecutor", () => {
|
||||||
|
it("builds a frozen executor holding exactly the ABI's members, without touching the pool", () => {
|
||||||
|
const script = scriptedPool();
|
||||||
|
const executor = createModuleDatabaseExecutor(script.pool, { moduleId: "example" });
|
||||||
|
|
||||||
|
expect(Object.keys(executor).sort()).toEqual(["abiVersion", "query", "transaction"]);
|
||||||
|
expect(executor.abiVersion).toBe(1);
|
||||||
|
expect(Object.isFrozen(executor)).toBe(true);
|
||||||
|
expect(Object.getPrototypeOf(executor)).toBe(Object.prototype);
|
||||||
|
for (const forbidden of ["pool", "client", "$client", "_", "session", "dialect", "select"]) {
|
||||||
|
expect(forbidden in executor).toBe(false);
|
||||||
|
}
|
||||||
|
expect(script.connectCount()).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("carries its module for Core's attribution, and refuses an invalid module ID", () => {
|
||||||
|
const executor = createModuleDatabaseExecutor(scriptedPool().pool, { moduleId: "care-team" });
|
||||||
|
expect(moduleDatabaseExecutorModuleId(executor)).toBe("care-team");
|
||||||
|
expect(moduleDatabaseExecutorModuleId({})).toBeUndefined();
|
||||||
|
expect(() =>
|
||||||
|
createModuleDatabaseExecutor(scriptedPool().pool, { moduleId: "Not A Module" }),
|
||||||
|
).toThrow(TypeError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("pins the session, runs one extended-protocol statement, and resets before releasing", async () => {
|
||||||
|
const script = scriptedPool();
|
||||||
|
const executor = createModuleDatabaseExecutor(script.pool, { moduleId: "example" });
|
||||||
|
|
||||||
|
const result = await executor.query({ text: "SELECT 1 AS n", values: [5n] });
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
command: "SELECT",
|
||||||
|
fields: [{ name: "n", typeOid: 23 }],
|
||||||
|
rowCount: 1,
|
||||||
|
rows: [{ n: 1 }],
|
||||||
|
});
|
||||||
|
expect(texts(script)).toEqual([pin, "SELECT 1 AS n", "DISCARD ALL"]);
|
||||||
|
expect(script.statements[1]).toEqual({ text: "SELECT 1 AS n", values: ["5"] });
|
||||||
|
expect(script.released).toEqual([undefined]);
|
||||||
|
expect(Object.isFrozen(result)).toBe(true);
|
||||||
|
expect(Object.isFrozen(result.rows)).toBe(true);
|
||||||
|
expect(Object.isFrozen(result.fields)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses a malformed request before borrowing a connection", async () => {
|
||||||
|
const script = scriptedPool();
|
||||||
|
const executor = createModuleDatabaseExecutor(script.pool, { moduleId: "example" });
|
||||||
|
const query = executor.query as (request: unknown) => Promise<unknown>;
|
||||||
|
|
||||||
|
for (const request of [
|
||||||
|
undefined,
|
||||||
|
null,
|
||||||
|
"SELECT 1",
|
||||||
|
[],
|
||||||
|
{ text: "" },
|
||||||
|
{ text: 1 },
|
||||||
|
{ text: "SELECT 1", rowMode: "rows" },
|
||||||
|
{ text: "SELECT 1", name: "prepared" },
|
||||||
|
{ text: "SELECT 1", types: [] },
|
||||||
|
]) {
|
||||||
|
await expectAbiError(query(request), "MODULE_DATABASE_REQUEST_INVALID");
|
||||||
|
}
|
||||||
|
await expectAbiError(
|
||||||
|
query({ text: "SELECT $1", values: [new Date()] }),
|
||||||
|
"MODULE_DATABASE_PARAMETER_INVALID",
|
||||||
|
);
|
||||||
|
expect(script.connectCount()).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports only PostgreSQL's SQLSTATE from a database error, never its text", async () => {
|
||||||
|
const script = scriptedPool();
|
||||||
|
const executor = createModuleDatabaseExecutor(script.pool, { moduleId: "example" });
|
||||||
|
script.failWhen("SELECT 1", pgError("23505"));
|
||||||
|
const error = await rejection(executor.query({ text: "SELECT 1" }));
|
||||||
|
|
||||||
|
expect(error).toMatchObject({ code: "MODULE_DATABASE_QUERY_FAILED", sqlState: "23505" });
|
||||||
|
const serialized = `${JSON.stringify(error)} ${String(error)} ${(error as Error).stack}`;
|
||||||
|
for (const secret of [
|
||||||
|
"hunter2",
|
||||||
|
"example_secret_key",
|
||||||
|
"example_records",
|
||||||
|
"duplicate",
|
||||||
|
"hint",
|
||||||
|
]) {
|
||||||
|
expect(serialized).not.toContain(secret);
|
||||||
|
}
|
||||||
|
expect((error as Error).cause).toBeUndefined();
|
||||||
|
// A PostgreSQL error leaves the connection usable, so it is reset and returned.
|
||||||
|
expect(script.released).toEqual([undefined]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("drops a non-PostgreSQL failure's code and destroys the connection it happened on", async () => {
|
||||||
|
const script = scriptedPool();
|
||||||
|
const executor = createModuleDatabaseExecutor(script.pool, { moduleId: "example" });
|
||||||
|
script.failWhen(
|
||||||
|
"SELECT 1",
|
||||||
|
Object.assign(new Error("Connection terminated: postgres://u:p@h/db"), { code: "EPIPE" }),
|
||||||
|
);
|
||||||
|
|
||||||
|
const error = await rejection(executor.query({ text: "SELECT 1" }));
|
||||||
|
expect(error).toMatchObject({ code: "MODULE_DATABASE_QUERY_FAILED" });
|
||||||
|
expect((error as { sqlState?: string }).sqlState).toBeUndefined();
|
||||||
|
expect(JSON.stringify(error)).not.toContain("postgres://");
|
||||||
|
expect(script.released).toEqual([true]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses a top-level transaction-control statement and destroys a connection it left open", async () => {
|
||||||
|
const script = scriptedPool();
|
||||||
|
const executor = createModuleDatabaseExecutor(script.pool, { moduleId: "example" });
|
||||||
|
|
||||||
|
await expectAbiError(executor.query({ text: "BEGIN" }), "MODULE_DATABASE_REQUEST_INVALID");
|
||||||
|
expect(script.released).toEqual([true]);
|
||||||
|
expect(texts(script)).not.toContain("DISCARD ALL");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("transaction options", () => {
|
||||||
|
const invalid: readonly unknown[] = [
|
||||||
|
null,
|
||||||
|
"serializable",
|
||||||
|
[],
|
||||||
|
{ isolation: "read uncommitted" },
|
||||||
|
{ isolation: "SERIALIZABLE" },
|
||||||
|
{ isolation: "constructor" },
|
||||||
|
{ readOnly: "yes" },
|
||||||
|
{ readOnly: 1 },
|
||||||
|
{ deferrable: true },
|
||||||
|
{ isolation: "serializable", retries: 3 },
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const options of invalid) {
|
||||||
|
it(`refuses ${JSON.stringify(options)} before borrowing a connection`, async () => {
|
||||||
|
const script = scriptedPool();
|
||||||
|
const executor = createModuleDatabaseExecutor(script.pool, { moduleId: "example" });
|
||||||
|
const callback = vi.fn();
|
||||||
|
await expectAbiError(
|
||||||
|
executor.transaction(callback, options as never),
|
||||||
|
"MODULE_DATABASE_TRANSACTION_OPTION_INVALID",
|
||||||
|
);
|
||||||
|
expect(callback).not.toHaveBeenCalled();
|
||||||
|
expect(script.connectCount()).toBe(0);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
it("refuses a callback that is not a function", async () => {
|
||||||
|
const script = scriptedPool();
|
||||||
|
const executor = createModuleDatabaseExecutor(script.pool, { moduleId: "example" });
|
||||||
|
await expectAbiError(
|
||||||
|
executor.transaction("SELECT 1" as never),
|
||||||
|
"MODULE_DATABASE_REQUEST_INVALID",
|
||||||
|
);
|
||||||
|
expect(script.connectCount()).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("begins every transaction with an explicit isolation level and access mode", async () => {
|
||||||
|
const cases: readonly [unknown, string][] = [
|
||||||
|
[undefined, "BEGIN ISOLATION LEVEL READ COMMITTED READ WRITE"],
|
||||||
|
[{}, "BEGIN ISOLATION LEVEL READ COMMITTED READ WRITE"],
|
||||||
|
[{ readOnly: true }, "BEGIN ISOLATION LEVEL READ COMMITTED READ ONLY"],
|
||||||
|
[{ isolation: "repeatable read" }, "BEGIN ISOLATION LEVEL REPEATABLE READ READ WRITE"],
|
||||||
|
[
|
||||||
|
{ isolation: "serializable", readOnly: false },
|
||||||
|
"BEGIN ISOLATION LEVEL SERIALIZABLE READ WRITE",
|
||||||
|
],
|
||||||
|
];
|
||||||
|
for (const [options, begin] of cases) {
|
||||||
|
const script = scriptedPool();
|
||||||
|
const executor = createModuleDatabaseExecutor(script.pool, { moduleId: "example" });
|
||||||
|
await executor.transaction(async () => undefined, options as never);
|
||||||
|
expect(texts(script)).toEqual([pin, begin, "COMMIT", "DISCARD ALL"]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("transaction scopes", () => {
|
||||||
|
it("commits a resolved callback and returns its value", async () => {
|
||||||
|
const script = scriptedPool();
|
||||||
|
const executor = createModuleDatabaseExecutor(script.pool, { moduleId: "example" });
|
||||||
|
|
||||||
|
const value = await executor.transaction(async (tx) => {
|
||||||
|
expect(Object.keys(tx).sort()).toEqual(["abiVersion", "query", "savepoint"]);
|
||||||
|
expect(Object.isFrozen(tx)).toBe(true);
|
||||||
|
expect(moduleDatabaseExecutorModuleId(tx)).toBe("example");
|
||||||
|
await tx.query({ text: "INSERT INTO example_records VALUES (1)" });
|
||||||
|
return "done";
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(value).toBe("done");
|
||||||
|
expect(texts(script).slice(1)).toEqual([
|
||||||
|
"BEGIN ISOLATION LEVEL READ COMMITTED READ WRITE",
|
||||||
|
"INSERT INTO example_records VALUES (1)",
|
||||||
|
"COMMIT",
|
||||||
|
"DISCARD ALL",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rolls back a rejected callback and rejects with the callback's own error, untouched", async () => {
|
||||||
|
const script = scriptedPool();
|
||||||
|
const executor = createModuleDatabaseExecutor(script.pool, { moduleId: "example" });
|
||||||
|
const own = new Error("module failure");
|
||||||
|
|
||||||
|
const error = await rejection(
|
||||||
|
executor.transaction(async (tx) => {
|
||||||
|
await tx.query({ text: "INSERT INTO example_records VALUES (1)" });
|
||||||
|
throw own;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(error).toBe(own);
|
||||||
|
expect(texts(script)).toContain("ROLLBACK");
|
||||||
|
expect(texts(script)).not.toContain("COMMIT");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("revokes the executor when the callback settles; later use sends nothing", async () => {
|
||||||
|
const script = scriptedPool();
|
||||||
|
const executor = createModuleDatabaseExecutor(script.pool, { moduleId: "example" });
|
||||||
|
let escaped: ModuleDatabaseTransaction | undefined;
|
||||||
|
await executor.transaction((tx) => {
|
||||||
|
escaped = tx;
|
||||||
|
});
|
||||||
|
const sent = script.statements.length;
|
||||||
|
|
||||||
|
await expectAbiError(
|
||||||
|
(escaped as ModuleDatabaseTransaction).query({ text: "SELECT 1" }),
|
||||||
|
"MODULE_DATABASE_SCOPE_CLOSED",
|
||||||
|
);
|
||||||
|
await expectAbiError(
|
||||||
|
(escaped as ModuleDatabaseTransaction).savepoint(async () => undefined),
|
||||||
|
"MODULE_DATABASE_SCOPE_CLOSED",
|
||||||
|
);
|
||||||
|
expect(script.statements).toHaveLength(sent);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never commits a callback that returned with a query still outstanding, even once it succeeds", async () => {
|
||||||
|
const script = scriptedPool();
|
||||||
|
const executor = createModuleDatabaseExecutor(script.pool, { moduleId: "example" });
|
||||||
|
let forgotten: Promise<unknown> | undefined;
|
||||||
|
let releaseHeld: (() => void) | undefined;
|
||||||
|
|
||||||
|
const outcome = executor.transaction((tx) => {
|
||||||
|
releaseHeld = script.holdNext();
|
||||||
|
forgotten = tx.query({ text: "INSERT INTO example_records VALUES (1)" });
|
||||||
|
});
|
||||||
|
await vi.waitFor(() => expect(releaseHeld).toBeDefined());
|
||||||
|
// Core waits for the started statement to settle before rolling back the connection.
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||||
|
expect(texts(script)).not.toContain("ROLLBACK");
|
||||||
|
releaseHeld?.();
|
||||||
|
|
||||||
|
await expectAbiError(outcome, "MODULE_DATABASE_SCOPE_WORK_OUTSTANDING");
|
||||||
|
await expect(forgotten).resolves.toBeDefined();
|
||||||
|
expect(texts(script).slice(-2)).toEqual(["ROLLBACK", "DISCARD ALL"]);
|
||||||
|
expect(texts(script)).not.toContain("COMMIT");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps a rejected callback's own error when sibling work is still settling, and still never commits", async () => {
|
||||||
|
const script = scriptedPool();
|
||||||
|
const executor = createModuleDatabaseExecutor(script.pool, { moduleId: "example" });
|
||||||
|
const own = new Error("module failure");
|
||||||
|
let releaseHeld: (() => void) | undefined;
|
||||||
|
|
||||||
|
const outcome = executor.transaction(async (tx) => {
|
||||||
|
releaseHeld = script.holdNext();
|
||||||
|
void tx.query({ text: "INSERT INTO example_records VALUES (1)" });
|
||||||
|
throw own;
|
||||||
|
});
|
||||||
|
await vi.waitFor(() => expect(releaseHeld).toBeDefined());
|
||||||
|
// The sibling statement is drained before Core rolls back.
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||||
|
expect(texts(script)).not.toContain("ROLLBACK");
|
||||||
|
releaseHeld?.();
|
||||||
|
|
||||||
|
expect(await rejection(outcome)).toBe(own);
|
||||||
|
expect(texts(script).slice(-2)).toEqual(["ROLLBACK", "DISCARD ALL"]);
|
||||||
|
expect(texts(script)).not.toContain("COMMIT");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses a transaction-control statement inside a transaction and never commits it", async () => {
|
||||||
|
const script = scriptedPool();
|
||||||
|
const executor = createModuleDatabaseExecutor(script.pool, { moduleId: "example" });
|
||||||
|
|
||||||
|
const error = await rejection(
|
||||||
|
executor.transaction(async (tx) => {
|
||||||
|
await expectAbiError(
|
||||||
|
tx.query({ text: "SAVEPOINT mine" }),
|
||||||
|
"MODULE_DATABASE_REQUEST_INVALID",
|
||||||
|
);
|
||||||
|
return "carried on";
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(error).toMatchObject({ code: "MODULE_DATABASE_REQUEST_INVALID" });
|
||||||
|
expect(texts(script)).not.toContain("COMMIT");
|
||||||
|
expect(texts(script)).toContain("ROLLBACK");
|
||||||
|
// The connection is destroyed, never reset and reused.
|
||||||
|
expect(texts(script)).not.toContain("DISCARD ALL");
|
||||||
|
expect(script.released).toEqual([true]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports a COMMIT that PostgreSQL answered with ROLLBACK as a failed transaction", async () => {
|
||||||
|
const script = scriptedPool();
|
||||||
|
const executor = createModuleDatabaseExecutor(script.pool, { moduleId: "example" });
|
||||||
|
|
||||||
|
const outcome = executor.transaction(async (tx) => {
|
||||||
|
script.failNext(pgError("22012"));
|
||||||
|
await expectAbiError(
|
||||||
|
tx.query({ text: "SELECT 1/0" }),
|
||||||
|
"MODULE_DATABASE_QUERY_FAILED",
|
||||||
|
"22012",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
await expectAbiError(outcome, "MODULE_DATABASE_TRANSACTION_FAILED");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("savepoint scopes", () => {
|
||||||
|
it("names savepoints itself, releases a resolved one, and rolls back a rejected one", async () => {
|
||||||
|
const script = scriptedPool();
|
||||||
|
const executor = createModuleDatabaseExecutor(script.pool, { moduleId: "example" });
|
||||||
|
const own = new Error("savepoint failure");
|
||||||
|
|
||||||
|
await executor.transaction(async (tx) => {
|
||||||
|
await tx.savepoint(async (sp) => {
|
||||||
|
await sp.query({ text: "INSERT INTO example_records VALUES (1)" });
|
||||||
|
});
|
||||||
|
expect(await rejection(tx.savepoint(async () => Promise.reject(own)))).toBe(own);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(texts(script).slice(2, -2)).toEqual([
|
||||||
|
"SAVEPOINT ekklesia_savepoint_1",
|
||||||
|
"INSERT INTO example_records VALUES (1)",
|
||||||
|
"RELEASE SAVEPOINT ekklesia_savepoint_1",
|
||||||
|
"SAVEPOINT ekklesia_savepoint_2",
|
||||||
|
"ROLLBACK TO SAVEPOINT ekklesia_savepoint_2",
|
||||||
|
"RELEASE SAVEPOINT ekklesia_savepoint_2",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("makes the parent inactive while a savepoint is open, so scopes cannot overlap", async () => {
|
||||||
|
const script = scriptedPool();
|
||||||
|
const executor = createModuleDatabaseExecutor(script.pool, { moduleId: "example" });
|
||||||
|
|
||||||
|
await executor.transaction(async (tx) => {
|
||||||
|
await tx.savepoint(async (sp) => {
|
||||||
|
await expectAbiError(tx.query({ text: "SELECT 1" }), "MODULE_DATABASE_SCOPE_INACTIVE");
|
||||||
|
await expectAbiError(
|
||||||
|
tx.savepoint(async () => undefined),
|
||||||
|
"MODULE_DATABASE_SCOPE_INACTIVE",
|
||||||
|
);
|
||||||
|
await sp.savepoint(async (inner) => {
|
||||||
|
await expectAbiError(sp.query({ text: "SELECT 1" }), "MODULE_DATABASE_SCOPE_INACTIVE");
|
||||||
|
await inner.query({ text: "SELECT 2" });
|
||||||
|
});
|
||||||
|
await sp.query({ text: "SELECT 3" });
|
||||||
|
});
|
||||||
|
await tx.query({ text: "SELECT 4" });
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(texts(script)).toContain("SAVEPOINT ekklesia_savepoint_2");
|
||||||
|
expect(texts(script)).toContain("COMMIT");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("revokes an escaped savepoint executor", async () => {
|
||||||
|
const script = scriptedPool();
|
||||||
|
const executor = createModuleDatabaseExecutor(script.pool, { moduleId: "example" });
|
||||||
|
|
||||||
|
await executor.transaction(async (tx) => {
|
||||||
|
let escaped: ModuleDatabaseTransaction | undefined;
|
||||||
|
await tx.savepoint((sp) => {
|
||||||
|
escaped = sp;
|
||||||
|
});
|
||||||
|
await expectAbiError(
|
||||||
|
(escaped as ModuleDatabaseTransaction).query({ text: "SELECT 1" }),
|
||||||
|
"MODULE_DATABASE_SCOPE_CLOSED",
|
||||||
|
);
|
||||||
|
await tx.query({ text: "SELECT 2" });
|
||||||
|
});
|
||||||
|
expect(texts(script)).toContain("COMMIT");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rolls back, and never releases, a savepoint whose callback left work outstanding", async () => {
|
||||||
|
const script = scriptedPool();
|
||||||
|
const executor = createModuleDatabaseExecutor(script.pool, { moduleId: "example" });
|
||||||
|
|
||||||
|
await executor.transaction(async (tx) => {
|
||||||
|
await expectAbiError(
|
||||||
|
tx.savepoint((sp) => {
|
||||||
|
const release = script.holdNext();
|
||||||
|
void sp.query({ text: "INSERT INTO example_records VALUES (1)" });
|
||||||
|
setTimeout(release, 10);
|
||||||
|
}),
|
||||||
|
"MODULE_DATABASE_SCOPE_WORK_OUTSTANDING",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(texts(script).slice(2, -2)).toEqual([
|
||||||
|
"SAVEPOINT ekklesia_savepoint_1",
|
||||||
|
"INSERT INTO example_records VALUES (1)",
|
||||||
|
"ROLLBACK TO SAVEPOINT ekklesia_savepoint_1",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps a rejected savepoint callback's own error, rolling back and releasing after its sibling work settles", async () => {
|
||||||
|
const script = scriptedPool();
|
||||||
|
const executor = createModuleDatabaseExecutor(script.pool, { moduleId: "example" });
|
||||||
|
const own = new Error("savepoint failure");
|
||||||
|
|
||||||
|
await executor.transaction(async (tx) => {
|
||||||
|
const error = await rejection(
|
||||||
|
tx.savepoint(async (sp) => {
|
||||||
|
const release = script.holdNext();
|
||||||
|
void sp.query({ text: "INSERT INTO example_records VALUES (1)" });
|
||||||
|
setTimeout(release, 10);
|
||||||
|
throw own;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(error).toBe(own);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(texts(script).slice(2, -2)).toEqual([
|
||||||
|
"SAVEPOINT ekklesia_savepoint_1",
|
||||||
|
"INSERT INTO example_records VALUES (1)",
|
||||||
|
"ROLLBACK TO SAVEPOINT ekklesia_savepoint_1",
|
||||||
|
"RELEASE SAVEPOINT ekklesia_savepoint_1",
|
||||||
|
]);
|
||||||
|
expect(texts(script)).toContain("COMMIT");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("counts an unawaited savepoint as the transaction's outstanding work", async () => {
|
||||||
|
const script = scriptedPool();
|
||||||
|
const executor = createModuleDatabaseExecutor(script.pool, { moduleId: "example" });
|
||||||
|
let forgotten: Promise<unknown> | undefined;
|
||||||
|
|
||||||
|
await expectAbiError(
|
||||||
|
executor.transaction((tx) => {
|
||||||
|
forgotten = tx.savepoint(async (sp) => {
|
||||||
|
await sp.query({ text: "INSERT INTO example_records VALUES (1)" });
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
"MODULE_DATABASE_SCOPE_WORK_OUTSTANDING",
|
||||||
|
);
|
||||||
|
await expectAbiError(forgotten as Promise<unknown>, "MODULE_DATABASE_SCOPE_CLOSED");
|
||||||
|
expect(texts(script)).not.toContain("COMMIT");
|
||||||
|
expect(texts(script).filter((text) => text.startsWith("RELEASE"))).toEqual([]);
|
||||||
|
expect(texts(script).at(-1)).toBe("DISCARD ALL");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("the ABI's own sources", () => {
|
||||||
|
it("import no ORM, query builder, or ORM type", async () => {
|
||||||
|
for (const file of [
|
||||||
|
"../src/modules/database-abi.ts",
|
||||||
|
"../src/modules/database-abi-values.ts",
|
||||||
|
]) {
|
||||||
|
const source = await readFile(new URL(file, import.meta.url), "utf8");
|
||||||
|
const specifiers = [...source.matchAll(/\bfrom\s+"([^"]+)"/gu)].map((match) => match[1]);
|
||||||
|
expect(
|
||||||
|
specifiers.every((specifier) =>
|
||||||
|
["@ekklesia/contracts", "pg", "./database-abi-values.js"].includes(specifier ?? ""),
|
||||||
|
),
|
||||||
|
).toBe(true);
|
||||||
|
expect(source).not.toMatch(/drizzle|kysely|NodePg|PgTable|PgColumn/iu);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
+9
-8
@@ -1,8 +1,8 @@
|
|||||||
import { integer, pgTable, text } from "drizzle-orm/pg-core";
|
import { integer, pgTable, text } from "drizzle-orm/pg-core";
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import type { ModuleDatabase, ModuleRuntimeServices } from "../src/index.js";
|
import type { LegacyModuleRuntimeServices, ModuleDatabase } from "../src/modules/database.js";
|
||||||
|
|
||||||
const testTable = pgTable("contracts_module_database_test", {
|
const testTable = pgTable("core_module_database_type_test", {
|
||||||
id: integer("id").primaryKey(),
|
id: integer("id").primaryKey(),
|
||||||
name: text("name").notNull(),
|
name: text("name").notNull(),
|
||||||
});
|
});
|
||||||
@@ -68,16 +68,17 @@ function typeOnlyExclusions(database: ModuleDatabase): void {
|
|||||||
}
|
}
|
||||||
void typeOnlyExclusions;
|
void typeOnlyExclusions;
|
||||||
|
|
||||||
/** Never invoked — proves `ModuleRuntimeServices` has exactly one field, `database`, typed as
|
/** Never invoked — proves a legacy module's `ModuleRuntimeServices` has exactly one field,
|
||||||
* `ModuleDatabase`, with no storage/jobs/configuration/secrets/authorization field this phase. */
|
* `database`, typed as `ModuleDatabase`, with no storage/jobs/configuration/secrets/authorization
|
||||||
function typeOnlyServicesShape(services: ModuleRuntimeServices): void {
|
* field this phase. */
|
||||||
|
function typeOnlyServicesShape(services: LegacyModuleRuntimeServices): void {
|
||||||
const database: ModuleDatabase = services.database;
|
const database: ModuleDatabase = services.database;
|
||||||
void database;
|
void database;
|
||||||
// @ts-expect-error `storage` does not exist on `ModuleRuntimeServices` yet.
|
// @ts-expect-error `storage` does not exist on the services yet.
|
||||||
void services.storage;
|
void services.storage;
|
||||||
// @ts-expect-error `jobs` does not exist on `ModuleRuntimeServices` yet.
|
// @ts-expect-error `jobs` does not exist on the services yet.
|
||||||
void services.jobs;
|
void services.jobs;
|
||||||
// @ts-expect-error `authorization` does not exist on `ModuleRuntimeServices` yet.
|
// @ts-expect-error `authorization` does not exist on the services yet.
|
||||||
void services.authorization;
|
void services.authorization;
|
||||||
}
|
}
|
||||||
void typeOnlyServicesShape;
|
void typeOnlyServicesShape;
|
||||||
@@ -34,7 +34,7 @@ async function createModulesDirectory(): Promise<string> {
|
|||||||
|
|
||||||
function manifestFor(id: string): Record<string, unknown> {
|
function manifestFor(id: string): Record<string, unknown> {
|
||||||
return {
|
return {
|
||||||
manifestVersion: 7,
|
manifestVersion: 8,
|
||||||
id,
|
id,
|
||||||
name: `${id} module`,
|
name: `${id} module`,
|
||||||
version: "0.1.0",
|
version: "0.1.0",
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { describe, expect, it, vi } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import { IMPORT_SAFETY_TEST_TIMEOUT_MS } from "./support/import-safety.js";
|
||||||
|
|
||||||
const readdir = vi.fn();
|
const readdir = vi.fn();
|
||||||
const readFile = vi.fn();
|
const readFile = vi.fn();
|
||||||
@@ -14,13 +15,17 @@ vi.mock("node:fs/promises", async (importOriginal) => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
describe("module HTTP contract and runtime import safety", () => {
|
describe("module HTTP contract and runtime import safety", () => {
|
||||||
it("performs no filesystem I/O merely by importing the module runtime or the module-http contract", async () => {
|
it(
|
||||||
await import("../src/modules/index.js");
|
"performs no filesystem I/O merely by importing the module runtime or the module-http contract",
|
||||||
await import("@ekklesia/contracts");
|
async () => {
|
||||||
|
await import("../src/modules/index.js");
|
||||||
|
await import("@ekklesia/contracts");
|
||||||
|
|
||||||
expect(readdir).not.toHaveBeenCalled();
|
expect(readdir).not.toHaveBeenCalled();
|
||||||
expect(readFile).not.toHaveBeenCalled();
|
expect(readFile).not.toHaveBeenCalled();
|
||||||
expect(realpath).not.toHaveBeenCalled();
|
expect(realpath).not.toHaveBeenCalled();
|
||||||
expect(stat).not.toHaveBeenCalled();
|
expect(stat).not.toHaveBeenCalled();
|
||||||
});
|
},
|
||||||
|
IMPORT_SAFETY_TEST_TIMEOUT_MS,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -200,7 +200,7 @@ afterAll(async () => {
|
|||||||
* by eligibility resolution. */
|
* by eligibility resolution. */
|
||||||
function fakeModule(id: string, dependencies?: Record<string, unknown>): DiscoveredModule {
|
function fakeModule(id: string, dependencies?: Record<string, unknown>): DiscoveredModule {
|
||||||
const manifest = parseModuleManifest({
|
const manifest = parseModuleManifest({
|
||||||
manifestVersion: 7,
|
manifestVersion: 8,
|
||||||
id,
|
id,
|
||||||
name: `${id} module`,
|
name: `${id} module`,
|
||||||
version: "1.0.0",
|
version: "1.0.0",
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ function fakeModule(
|
|||||||
} = {},
|
} = {},
|
||||||
): DiscoveredModule {
|
): DiscoveredModule {
|
||||||
const manifest = parseModuleManifest({
|
const manifest = parseModuleManifest({
|
||||||
manifestVersion: 7,
|
manifestVersion: 8,
|
||||||
id,
|
id,
|
||||||
name: `${id} module`,
|
name: `${id} module`,
|
||||||
version: "0.1.0",
|
version: "0.1.0",
|
||||||
@@ -344,7 +344,7 @@ describe("resolveModuleEligibility — over a real discovered inventory", () =>
|
|||||||
writeFile(
|
writeFile(
|
||||||
path.join(moduleDirectory, "ekklesia.module.json"),
|
path.join(moduleDirectory, "ekklesia.module.json"),
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
manifestVersion: 7,
|
manifestVersion: 8,
|
||||||
id,
|
id,
|
||||||
name: `${id} module`,
|
name: `${id} module`,
|
||||||
version: "0.1.0",
|
version: "0.1.0",
|
||||||
|
|||||||
@@ -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,144 +33,11 @@ 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.2.0",
|
||||||
modulesDirectory,
|
modulesDirectory,
|
||||||
platformVersion: "0.1.0",
|
platformVersion: "0.2.0",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,748 @@
|
|||||||
|
import { mkdir, 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 {
|
||||||
|
currentModulePackageHost,
|
||||||
|
type ModulePackageHost,
|
||||||
|
ModulePackageVerificationError,
|
||||||
|
type ModulePackageVerificationErrorCode,
|
||||||
|
verifyModulePackage,
|
||||||
|
} from "../src/modules/index.js";
|
||||||
|
import {
|
||||||
|
type FixtureEntry,
|
||||||
|
modulePackageFixture,
|
||||||
|
type PackageFixtureOptions,
|
||||||
|
sha256,
|
||||||
|
} from "./support/module-package-fixture.js";
|
||||||
|
|
||||||
|
const platform = {
|
||||||
|
databaseRuntime: { "drizzle-orm": "0.45.2" },
|
||||||
|
moduleSdkVersion: "0.2.0",
|
||||||
|
platformVersion: "0.2.0",
|
||||||
|
} 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 executionScratch(): Promise<string> {
|
||||||
|
const directory = path.join(await scratch(), "execution");
|
||||||
|
await mkdir(directory);
|
||||||
|
return directory;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function rejection(
|
||||||
|
options: PackageFixtureOptions,
|
||||||
|
code: ModulePackageVerificationErrorCode,
|
||||||
|
extra: { readonly expectedSha256?: string } = {},
|
||||||
|
): Promise<ModulePackageVerificationError> {
|
||||||
|
const { archivePath } = await archiveFile(options);
|
||||||
|
try {
|
||||||
|
await verifyModulePackage({ archivePath, platform, ...extra });
|
||||||
|
} catch (error) {
|
||||||
|
expect(error).toBeInstanceOf(ModulePackageVerificationError);
|
||||||
|
const verificationError = error as ModulePackageVerificationError;
|
||||||
|
expect(verificationError.code).toBe(code);
|
||||||
|
// Sanitized: never the archive's location, a temporary directory, or text from inside it.
|
||||||
|
const rendered = JSON.stringify({ ...verificationError, message: verificationError.message });
|
||||||
|
expect(rendered).not.toContain("private-host-name-5e1d");
|
||||||
|
expect(rendered).not.toContain(tmpdir());
|
||||||
|
expect(rendered).not.toContain("attacker-chosen");
|
||||||
|
return verificationError;
|
||||||
|
}
|
||||||
|
throw new Error(`Expected ${code}.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function withEntry(entry: FixtureEntry): PackageFixtureOptions {
|
||||||
|
return { extraEntries: [entry] };
|
||||||
|
}
|
||||||
|
|
||||||
|
function abiPackage(overrides: PackageFixtureOptions = {}): PackageFixtureOptions {
|
||||||
|
return {
|
||||||
|
manifest: (manifest) => ({ ...manifest, database: { abi: 1 } }),
|
||||||
|
schemaSource: `export default {
|
||||||
|
moduleId: "example",
|
||||||
|
tables: [{ name: "example_records", foreignKeys: [] }]
|
||||||
|
};\n`,
|
||||||
|
serverSource:
|
||||||
|
'export default { database: { abi: 1 }, id: "example", version: "0.1.0", register() {} };\n',
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("Module Package v1 verification (ADR 0049)", () => {
|
||||||
|
it("verifies a valid any-target package, its digest, and its module", async () => {
|
||||||
|
const { archivePath, sha256: digest } = await archiveFile();
|
||||||
|
const result = await verifyModulePackage({ archivePath, expectedSha256: digest, platform });
|
||||||
|
expect(result).toEqual({
|
||||||
|
archiveSha256: digest,
|
||||||
|
fileCount: 9,
|
||||||
|
moduleId: "example",
|
||||||
|
target: "any",
|
||||||
|
version: "0.1.0",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses an explicit execution scratch for pure JavaScript and cleans concurrent runs", async () => {
|
||||||
|
const { archivePath } = await archiveFile();
|
||||||
|
const executionScratchDirectory = await executionScratch();
|
||||||
|
const runs = Array.from({ length: 4 }, () =>
|
||||||
|
verifyModulePackage({ archivePath, executionScratchDirectory, platform }),
|
||||||
|
);
|
||||||
|
await expect(Promise.all(runs)).resolves.toHaveLength(4);
|
||||||
|
expect(await readdir(executionScratchDirectory)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("cleans its execution scratch after a backend load failure", async () => {
|
||||||
|
const { archivePath } = await archiveFile({
|
||||||
|
serverSource:
|
||||||
|
'import "dependency-not-in-this-package";\nexport default { id: "example", version: "0.1.0" };\n',
|
||||||
|
});
|
||||||
|
const executionScratchDirectory = await executionScratch();
|
||||||
|
await expect(
|
||||||
|
verifyModulePackage({ archivePath, executionScratchDirectory, platform }),
|
||||||
|
).rejects.toMatchObject({ code: "MODULE_PACKAGE_ENTRYPOINT_LOAD_FAILED" });
|
||||||
|
expect(await readdir(executionScratchDirectory)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports a missing execution scratch as a verifier environment error", async () => {
|
||||||
|
const { archivePath } = await archiveFile();
|
||||||
|
const executionScratchDirectory = path.join(await scratch(), "missing");
|
||||||
|
await expect(
|
||||||
|
verifyModulePackage({ archivePath, executionScratchDirectory, platform }),
|
||||||
|
).rejects.toMatchObject({
|
||||||
|
code: "MODULE_PACKAGE_VERIFIER_SCRATCH_UNAVAILABLE",
|
||||||
|
message:
|
||||||
|
"The verifier execution scratch directory is missing, not writable, or mounted noexec.",
|
||||||
|
});
|
||||||
|
await expect(stat(executionScratchDirectory)).rejects.toMatchObject({ code: "ENOENT" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects an unsafe archive before consulting the execution scratch", async () => {
|
||||||
|
const { archivePath } = await archiveFile(
|
||||||
|
withEntry({ path: "example/../attacker-chosen", data: "x" }),
|
||||||
|
);
|
||||||
|
const executionScratchDirectory = path.join(await scratch(), "missing");
|
||||||
|
await expect(
|
||||||
|
verifyModulePackage({ archivePath, executionScratchDirectory, platform }),
|
||||||
|
).rejects.toMatchObject({ code: "MODULE_PACKAGE_UNSAFE_PATH" });
|
||||||
|
await expect(stat(executionScratchDirectory)).rejects.toMatchObject({ code: "ENOENT" });
|
||||||
|
});
|
||||||
|
|
||||||
|
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("LICENSE");
|
||||||
|
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.each([
|
||||||
|
["a missing license declaration", undefined],
|
||||||
|
["an empty license declaration", ""],
|
||||||
|
["UNLICENSED", "UNLICENSED"],
|
||||||
|
["a malformed SPDX expression", "MIT OR"],
|
||||||
|
])("fails package metadata with %s", async (_label, license) => {
|
||||||
|
await rejection(
|
||||||
|
{
|
||||||
|
metadata: (metadata) => {
|
||||||
|
const changed: Record<string, unknown> = { ...metadata, license };
|
||||||
|
if (license === undefined) {
|
||||||
|
delete changed.license;
|
||||||
|
}
|
||||||
|
return changed;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"MODULE_PACKAGE_METADATA_INVALID",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fails closed on pre-ADR-0051 metadata", async () => {
|
||||||
|
await rejection(
|
||||||
|
{
|
||||||
|
metadata: (metadata) => {
|
||||||
|
const old: Record<string, unknown> = { ...metadata };
|
||||||
|
delete old.license;
|
||||||
|
return old;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"MODULE_PACKAGE_METADATA_INVALID",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("requires LICENSE at the canonical path in both payload and inventory", async () => {
|
||||||
|
await rejection(
|
||||||
|
{ tamper: (files) => void files.delete("LICENSE") },
|
||||||
|
"MODULE_PACKAGE_FILE_MISSING",
|
||||||
|
);
|
||||||
|
await rejection(
|
||||||
|
{
|
||||||
|
metadata: (metadata) => ({
|
||||||
|
...metadata,
|
||||||
|
files: (metadata.files as { path: string }[]).filter((file) => file.path !== "LICENSE"),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
"MODULE_PACKAGE_METADATA_INVALID",
|
||||||
|
);
|
||||||
|
await rejection(
|
||||||
|
withEntry({ path: "example/licenses/LICENSE", data: "attacker-chosen" }),
|
||||||
|
"MODULE_PACKAGE_FILE_UNLISTED",
|
||||||
|
);
|
||||||
|
await rejection(
|
||||||
|
withEntry({ path: "example/COPYING", data: "attacker-chosen" }),
|
||||||
|
"MODULE_PACKAGE_FILE_UNLISTED",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("checks the listed LICENSE bytes, size, and digest", async () => {
|
||||||
|
await rejection(
|
||||||
|
{ tamper: (files) => files.set("LICENSE", Buffer.from("attacker-chosen")) },
|
||||||
|
"MODULE_PACKAGE_FILE_MISMATCH",
|
||||||
|
);
|
||||||
|
await rejection(
|
||||||
|
{
|
||||||
|
metadata: (metadata) => ({
|
||||||
|
...metadata,
|
||||||
|
files: (metadata.files as { path: string; size: number; sha256: string }[]).map((file) =>
|
||||||
|
file.path === "LICENSE" ? { ...file, size: file.size + 1 } : file,
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
"MODULE_PACKAGE_FILE_MISMATCH",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a duplicate LICENSE archive entry", async () => {
|
||||||
|
await rejection(
|
||||||
|
withEntry({ path: "example/LICENSE", data: "attacker-chosen" }),
|
||||||
|
"MODULE_PACKAGE_LAYOUT_INVALID",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fails when the manifest and the package disagree about the version", async () => {
|
||||||
|
await rejection(
|
||||||
|
{ manifest: (manifest) => ({ ...manifest, version: "0.2.0" }) },
|
||||||
|
"MODULE_PACKAGE_IDENTITY_MISMATCH",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fails when the metadata names another module", async () => {
|
||||||
|
await rejection(
|
||||||
|
{ metadata: (metadata) => ({ ...metadata, module: { id: "other", version: "0.1.0" } }) },
|
||||||
|
"MODULE_PACKAGE_IDENTITY_MISMATCH",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fails a runtime package.json that is not the generated shape", async () => {
|
||||||
|
await rejection(
|
||||||
|
{
|
||||||
|
runtimePackage: {
|
||||||
|
name: "@test/module-example",
|
||||||
|
version: "0.1.0",
|
||||||
|
private: true,
|
||||||
|
type: "module",
|
||||||
|
ekklesia: { layer: "module" },
|
||||||
|
dependencies: { "@ekklesia/module-sdk": "workspace:*" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"MODULE_PACKAGE_RUNTIME_PACKAGE_INVALID",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fails an unsupported package format", async () => {
|
||||||
|
await rejection(
|
||||||
|
{ metadata: (metadata) => ({ ...metadata, packageFormat: 2 }) },
|
||||||
|
"MODULE_PACKAGE_FORMAT_UNSUPPORTED",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fails transitional metadata it does not understand", async () => {
|
||||||
|
await rejection(
|
||||||
|
{ metadata: (metadata) => ({ ...metadata, transitional: { orm: { prisma: "6.0.0" } } }) },
|
||||||
|
"MODULE_PACKAGE_TRANSITIONAL_UNSUPPORTED",
|
||||||
|
);
|
||||||
|
await rejection(
|
||||||
|
{
|
||||||
|
metadata: (metadata) => ({
|
||||||
|
...metadata,
|
||||||
|
transitional: { databaseRuntime: { "drizzle-orm": "0.45.2", prisma: "6.0.0" } },
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
"MODULE_PACKAGE_TRANSITIONAL_UNSUPPORTED",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("enforces today's database runtime agreement, and accepts it when it holds", async () => {
|
||||||
|
await rejection(
|
||||||
|
{
|
||||||
|
metadata: (metadata) => ({
|
||||||
|
...metadata,
|
||||||
|
transitional: { databaseRuntime: { "drizzle-orm": "0.44.0" } },
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
"MODULE_PACKAGE_DATABASE_RUNTIME_INCOMPATIBLE",
|
||||||
|
);
|
||||||
|
const { archivePath } = await archiveFile({
|
||||||
|
metadata: (metadata) => ({
|
||||||
|
...metadata,
|
||||||
|
transitional: { databaseRuntime: { "drizzle-orm": "0.45.2" } },
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
await expect(verifyModulePackage({ archivePath, platform })).resolves.toMatchObject({
|
||||||
|
moduleId: "example",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("verifies ABI-v1 plain schema metadata without comparing Drizzle with Core", async () => {
|
||||||
|
const { archivePath } = await archiveFile(abiPackage());
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
verifyModulePackage({
|
||||||
|
archivePath,
|
||||||
|
platform: { ...platform, databaseRuntime: { "drizzle-orm": "99.0.0" } },
|
||||||
|
}),
|
||||||
|
).resolves.toMatchObject({ moduleId: "example" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects transitional database runtime metadata from an ABI-v1 package", async () => {
|
||||||
|
await rejection(
|
||||||
|
abiPackage({
|
||||||
|
metadata: (metadata) => ({
|
||||||
|
...metadata,
|
||||||
|
transitional: { databaseRuntime: { "drizzle-orm": "0.45.2" } },
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
"MODULE_PACKAGE_DATABASE_RUNTIME_UNEXPECTED",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects an unsupported database ABI and malformed ABI-v1 schema metadata", async () => {
|
||||||
|
await rejection(
|
||||||
|
abiPackage({ manifest: (manifest) => ({ ...manifest, database: { abi: 2 } }) }),
|
||||||
|
"MODULE_PACKAGE_MANIFEST_INVALID",
|
||||||
|
);
|
||||||
|
const schemaError = await rejection(
|
||||||
|
abiPackage({
|
||||||
|
schemaSource:
|
||||||
|
'export default { moduleId: "example", tables: { records: "attacker-chosen" } };\n',
|
||||||
|
}),
|
||||||
|
"MODULE_PACKAGE_ENTRYPOINT_LOAD_FAILED",
|
||||||
|
);
|
||||||
|
expect(schemaError.message).not.toContain("attacker-chosen");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fails a target this platform cannot run", async () => {
|
||||||
|
await rejection(
|
||||||
|
{
|
||||||
|
metadata: (metadata) => ({
|
||||||
|
...metadata,
|
||||||
|
target: "linux-arm64-musl",
|
||||||
|
runtime: { libcVersion: ">=1.2.0", 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",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("native targets (ADR 0049)", () => {
|
||||||
|
/** The first bytes of a 64-bit little-endian ELF object for `machine` (62 is x86-64). The
|
||||||
|
* verifier reads only the identification and machine fields. */
|
||||||
|
function elfHeader(machine = 62): Buffer {
|
||||||
|
const header = Buffer.alloc(64);
|
||||||
|
Buffer.from([0x7f, 0x45, 0x4c, 0x46, 2, 1, 1]).copy(header);
|
||||||
|
header.writeUInt16LE(3, 16);
|
||||||
|
header.writeUInt16LE(machine, 18);
|
||||||
|
return header;
|
||||||
|
}
|
||||||
|
|
||||||
|
const linuxHost = {
|
||||||
|
arch: "x64",
|
||||||
|
libc: { family: "glibc", version: "2.41" },
|
||||||
|
nodeApi: 10,
|
||||||
|
nodeAbi: "137",
|
||||||
|
platform: "linux",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
function nativePackage(
|
||||||
|
overrides: {
|
||||||
|
readonly files?: Record<string, Uint8Array | string>;
|
||||||
|
readonly target?: string;
|
||||||
|
readonly runtime?: Record<string, unknown>;
|
||||||
|
} = {},
|
||||||
|
): PackageFixtureOptions {
|
||||||
|
return {
|
||||||
|
files: overrides.files ?? {
|
||||||
|
"node_modules/native-thing/index.js": "module.exports = {};\n",
|
||||||
|
"node_modules/native-thing/lib/native-thing.node": elfHeader(),
|
||||||
|
"node_modules/native-thing/package.json": '{"name":"native-thing","version":"1.0.0"}\n',
|
||||||
|
},
|
||||||
|
metadata: (metadata) => ({
|
||||||
|
...metadata,
|
||||||
|
runtime: overrides.runtime ?? {
|
||||||
|
libcVersion: ">=2.28.0",
|
||||||
|
node: ">=20",
|
||||||
|
nodeApi: 9,
|
||||||
|
},
|
||||||
|
target: overrides.target ?? "linux-x64-glibc",
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function verifyOn(
|
||||||
|
options: PackageFixtureOptions,
|
||||||
|
host: ModulePackageHost,
|
||||||
|
): Promise<unknown> {
|
||||||
|
const { archivePath } = await archiveFile(options);
|
||||||
|
return verifyModulePackage({ archivePath, platform: { ...platform, host } });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refusedOn(
|
||||||
|
options: PackageFixtureOptions,
|
||||||
|
host: ModulePackageHost,
|
||||||
|
code: ModulePackageVerificationErrorCode,
|
||||||
|
): Promise<void> {
|
||||||
|
await expect(verifyOn(options, host)).rejects.toMatchObject({ code });
|
||||||
|
}
|
||||||
|
|
||||||
|
it("accepts linux-x64-glibc on a matching host, native node_modules and addon included", async () => {
|
||||||
|
await expect(verifyOn(nativePackage(), linuxHost)).resolves.toMatchObject({
|
||||||
|
moduleId: "example",
|
||||||
|
target: "linux-x64-glibc",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses another operating system, CPU, or C library family", async () => {
|
||||||
|
await refusedOn(
|
||||||
|
nativePackage(),
|
||||||
|
{ ...linuxHost, platform: "darwin" },
|
||||||
|
"MODULE_PACKAGE_TARGET_INCOMPATIBLE",
|
||||||
|
);
|
||||||
|
await refusedOn(
|
||||||
|
nativePackage(),
|
||||||
|
{ ...linuxHost, arch: "arm64" },
|
||||||
|
"MODULE_PACKAGE_TARGET_INCOMPATIBLE",
|
||||||
|
);
|
||||||
|
// musl reports no glibc version, so the host cannot be proven glibc.
|
||||||
|
const { libc: _libc, ...musl } = linuxHost;
|
||||||
|
await refusedOn(nativePackage(), musl, "MODULE_PACKAGE_TARGET_INCOMPATIBLE");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses a target this platform does not implement", async () => {
|
||||||
|
await refusedOn(
|
||||||
|
nativePackage({ target: "linux-arm64-glibc" }),
|
||||||
|
{ ...linuxHost, arch: "arm64" },
|
||||||
|
"MODULE_PACKAGE_TARGET_INCOMPATIBLE",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses a C library older than the package needs", async () => {
|
||||||
|
await refusedOn(
|
||||||
|
nativePackage(),
|
||||||
|
{ ...linuxHost, libc: { family: "glibc", version: "2.17" } },
|
||||||
|
"MODULE_PACKAGE_LIBC_INCOMPATIBLE",
|
||||||
|
);
|
||||||
|
await expect(
|
||||||
|
verifyOn(nativePackage(), { ...linuxHost, libc: { family: "glibc", version: "2.28" } }),
|
||||||
|
).resolves.toMatchObject({ target: "linux-x64-glibc" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses a Node-API version or Node.js ABI the host does not provide", async () => {
|
||||||
|
await refusedOn(
|
||||||
|
nativePackage(),
|
||||||
|
{ ...linuxHost, nodeApi: 8 },
|
||||||
|
"MODULE_PACKAGE_NODE_API_INCOMPATIBLE",
|
||||||
|
);
|
||||||
|
const { nodeApi: _nodeApi, ...unknownApi } = linuxHost;
|
||||||
|
await refusedOn(nativePackage(), unknownApi, "MODULE_PACKAGE_NODE_API_INCOMPATIBLE");
|
||||||
|
await refusedOn(
|
||||||
|
nativePackage({ runtime: { libcVersion: ">=2.28.0", node: ">=20", nodeAbi: "115" } }),
|
||||||
|
linuxHost,
|
||||||
|
"MODULE_PACKAGE_NODE_API_INCOMPATIBLE",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses a native file built for another machine, or an addon that is not one", async () => {
|
||||||
|
await refusedOn(
|
||||||
|
nativePackage({
|
||||||
|
files: { "node_modules/native-thing/lib/native-thing.node": elfHeader(183) },
|
||||||
|
}),
|
||||||
|
linuxHost,
|
||||||
|
"MODULE_PACKAGE_NATIVE_FILE_INCOMPATIBLE",
|
||||||
|
);
|
||||||
|
await refusedOn(
|
||||||
|
nativePackage({
|
||||||
|
files: { "node_modules/native-thing/lib/libthing.so": elfHeader(183) },
|
||||||
|
}),
|
||||||
|
linuxHost,
|
||||||
|
"MODULE_PACKAGE_NATIVE_FILE_INCOMPATIBLE",
|
||||||
|
);
|
||||||
|
await refusedOn(
|
||||||
|
nativePackage({
|
||||||
|
files: { "node_modules/native-thing/lib/native-thing.node": "not an ELF object" },
|
||||||
|
}),
|
||||||
|
linuxHost,
|
||||||
|
"MODULE_PACKAGE_NATIVE_FILE_INCOMPATIBLE",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("requires a native package to state its Node.js interface and, on Linux, its C library", async () => {
|
||||||
|
await rejection(
|
||||||
|
nativePackage({ runtime: { libcVersion: ">=2.28.0", node: ">=20" } }),
|
||||||
|
"MODULE_PACKAGE_METADATA_INVALID",
|
||||||
|
);
|
||||||
|
await rejection(
|
||||||
|
nativePackage({ runtime: { node: ">=20", nodeApi: 9 } }),
|
||||||
|
"MODULE_PACKAGE_METADATA_INVALID",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps any-target packages free of node_modules, addons, and ELF objects", async () => {
|
||||||
|
await rejection(
|
||||||
|
{ files: { "node_modules/thing/index.js": "module.exports = {};\n" } },
|
||||||
|
"MODULE_PACKAGE_NATIVE_FILE",
|
||||||
|
);
|
||||||
|
await rejection({ files: { "dist/thing.node": elfHeader() } }, "MODULE_PACKAGE_NATIVE_FILE");
|
||||||
|
await rejection({ files: { "dist/libthing.so": elfHeader() } }, "MODULE_PACKAGE_NATIVE_FILE");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("establishes this host's facts from Node.js alone", () => {
|
||||||
|
const host = currentModulePackageHost();
|
||||||
|
expect(host.platform).toBe(process.platform);
|
||||||
|
expect(host.arch).toBe(process.arch);
|
||||||
|
expect(host.nodeApi).toBe(Number(process.versions.napi));
|
||||||
|
if (process.platform === "linux") {
|
||||||
|
const report = process.report.getReport() as { header?: { glibcVersionRuntime?: string } };
|
||||||
|
expect(host.libc?.version).toBe(report.header?.glibcVersionRuntime);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
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",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -28,7 +28,7 @@ interface PermissionInput {
|
|||||||
* is read by `composePlatformPermissionRegistry`; the rest exist only to satisfy the type. */
|
* is read by `composePlatformPermissionRegistry`; the rest exist only to satisfy the type. */
|
||||||
function fakeModule(id: string, permissions: readonly PermissionInput[] = []): DiscoveredModule {
|
function fakeModule(id: string, permissions: readonly PermissionInput[] = []): DiscoveredModule {
|
||||||
const manifest = parseModuleManifest({
|
const manifest = parseModuleManifest({
|
||||||
manifestVersion: 7,
|
manifestVersion: 8,
|
||||||
id,
|
id,
|
||||||
name: `${id} module`,
|
name: `${id} module`,
|
||||||
version: "0.1.0",
|
version: "0.1.0",
|
||||||
|
|||||||
@@ -1,11 +1,8 @@
|
|||||||
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
import { mkdir, mkdtemp, 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 type {
|
import type { ModuleRealtimePublisher } from "@ekklesia/contracts";
|
||||||
ModuleDatabase,
|
import type { LegacyModuleRuntimeServices, ModuleDatabase } from "../src/modules/database.js";
|
||||||
ModuleRealtimePublisher,
|
|
||||||
ModuleRuntimeServices,
|
|
||||||
} from "@ekklesia/contracts";
|
|
||||||
import { afterEach, describe, expect, it } from "vitest";
|
import { afterEach, describe, expect, it } from "vitest";
|
||||||
import { ModuleRuntimeError, startModuleRuntime } from "../src/modules/runtime.js";
|
import { ModuleRuntimeError, startModuleRuntime } from "../src/modules/runtime.js";
|
||||||
|
|
||||||
@@ -22,7 +19,7 @@ afterEach(async () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
function fakeServices(): ModuleRuntimeServices {
|
function fakeServices(): LegacyModuleRuntimeServices {
|
||||||
return Object.freeze({ database: Object.freeze({}) as ModuleDatabase });
|
return Object.freeze({ database: Object.freeze({}) as ModuleDatabase });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -31,7 +28,7 @@ function manifest(
|
|||||||
permissions: readonly Record<string, string>[] = [],
|
permissions: readonly Record<string, string>[] = [],
|
||||||
): Record<string, unknown> {
|
): Record<string, unknown> {
|
||||||
return {
|
return {
|
||||||
manifestVersion: 7,
|
manifestVersion: 8,
|
||||||
id,
|
id,
|
||||||
name: `${id} module`,
|
name: `${id} module`,
|
||||||
version: "0.1.0",
|
version: "0.1.0",
|
||||||
|
|||||||
@@ -1,21 +1,23 @@
|
|||||||
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
import { mkdir, mkdtemp, 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 type { ModuleConfiguration, ModuleDatabaseExecutor } from "@ekklesia/contracts";
|
||||||
import type {
|
import type {
|
||||||
ModuleConfiguration,
|
AnyModuleRuntimeServices,
|
||||||
|
LegacyModuleRuntimeServices,
|
||||||
ModuleDatabase,
|
ModuleDatabase,
|
||||||
ModuleRuntimeServices,
|
} from "../src/modules/database.js";
|
||||||
} from "@ekklesia/contracts";
|
|
||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
import { discoverModules } from "../src/modules/discovery.js";
|
import { discoverModules } from "../src/modules/discovery.js";
|
||||||
import { resolveModuleEligibility } from "../src/modules/lifecycle.js";
|
|
||||||
import {
|
import {
|
||||||
ModuleDiscoveryError,
|
ModuleDiscoveryError,
|
||||||
ModuleRuntimeError,
|
ModuleRuntimeError,
|
||||||
type ModuleRuntimeIssueCode,
|
type ModuleRuntimeIssueCode,
|
||||||
|
type ModuleRuntimeServicesLookup,
|
||||||
startDiscoveredModuleRuntime,
|
startDiscoveredModuleRuntime,
|
||||||
startModuleRuntime,
|
startModuleRuntime,
|
||||||
} from "../src/modules/index.js";
|
} from "../src/modules/index.js";
|
||||||
|
import { resolveModuleEligibility } from "../src/modules/lifecycle.js";
|
||||||
|
|
||||||
vi.mock("../src/modules/discovery.js", async (importOriginal) => {
|
vi.mock("../src/modules/discovery.js", async (importOriginal) => {
|
||||||
const actual = await importOriginal<typeof import("../src/modules/discovery.js")>();
|
const actual = await importOriginal<typeof import("../src/modules/discovery.js")>();
|
||||||
@@ -52,12 +54,13 @@ function manifestFor(
|
|||||||
id: string,
|
id: string,
|
||||||
options: {
|
options: {
|
||||||
capabilities?: Array<{ id: string; version: string }>;
|
capabilities?: Array<{ id: string; version: string }>;
|
||||||
|
database?: { abi: 1 };
|
||||||
dependencies?: Record<string, unknown>;
|
dependencies?: Record<string, unknown>;
|
||||||
permissions?: Array<{ id: string; baseline: string; description: string }>;
|
permissions?: Array<{ id: string; baseline: string; description: string }>;
|
||||||
} = {},
|
} = {},
|
||||||
): Record<string, unknown> {
|
): Record<string, unknown> {
|
||||||
return {
|
return {
|
||||||
manifestVersion: 7,
|
manifestVersion: 8,
|
||||||
id,
|
id,
|
||||||
name: `${id} module`,
|
name: `${id} module`,
|
||||||
version: "0.1.0",
|
version: "0.1.0",
|
||||||
@@ -67,6 +70,7 @@ function manifestFor(
|
|||||||
},
|
},
|
||||||
capabilities: options.capabilities ?? [],
|
capabilities: options.capabilities ?? [],
|
||||||
permissions: options.permissions ?? [],
|
permissions: options.permissions ?? [],
|
||||||
|
...(options.database ? { database: options.database } : {}),
|
||||||
...(options.dependencies ? { dependencies: options.dependencies } : {}),
|
...(options.dependencies ? { dependencies: options.dependencies } : {}),
|
||||||
entrypoints: {
|
entrypoints: {
|
||||||
server: "./dist/server/index.js",
|
server: "./dist/server/index.js",
|
||||||
@@ -145,13 +149,29 @@ function fakeModuleDatabase(): ModuleDatabase {
|
|||||||
}) as unknown as ModuleDatabase;
|
}) as unknown as ModuleDatabase;
|
||||||
}
|
}
|
||||||
|
|
||||||
function fakeModuleRuntimeServices(): ModuleRuntimeServices {
|
function fakeModuleRuntimeServices(): ModuleRuntimeServicesLookup {
|
||||||
return Object.freeze({ database: fakeModuleDatabase() });
|
const services = new Map<string, LegacyModuleRuntimeServices>();
|
||||||
|
const database = fakeModuleDatabase();
|
||||||
|
return new Proxy(services, {
|
||||||
|
get(target, property, receiver) {
|
||||||
|
if (property === "get") {
|
||||||
|
return (moduleId: string) => {
|
||||||
|
let value = target.get(moduleId);
|
||||||
|
if (!value) {
|
||||||
|
value = Object.freeze({ database });
|
||||||
|
target.set(moduleId, value);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return Reflect.get(target, property, receiver);
|
||||||
|
},
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function runtimeOptions(
|
function runtimeOptions(
|
||||||
modulesDirectory: string,
|
modulesDirectory: string,
|
||||||
services: ModuleRuntimeServices = fakeModuleRuntimeServices(),
|
services: ModuleRuntimeServicesLookup = fakeModuleRuntimeServices(),
|
||||||
) {
|
) {
|
||||||
return {
|
return {
|
||||||
moduleSdkVersion: "0.1.0",
|
moduleSdkVersion: "0.1.0",
|
||||||
@@ -1067,6 +1087,54 @@ describe("already-discovered module runtime", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("module runtime services", () => {
|
describe("module runtime services", () => {
|
||||||
|
it("delivers only the manifest-selected database mode and distinct ABI executors", async () => {
|
||||||
|
const modulesDirectory = await createModulesDirectory();
|
||||||
|
const recorder = createReferenceRecorder();
|
||||||
|
const legacyDatabase = fakeModuleDatabase();
|
||||||
|
const alphaExecutor = Object.freeze({
|
||||||
|
abiVersion: 1,
|
||||||
|
query: vi.fn(),
|
||||||
|
transaction: vi.fn(),
|
||||||
|
}) as unknown as ModuleDatabaseExecutor;
|
||||||
|
const betaExecutor = Object.freeze({
|
||||||
|
abiVersion: 1,
|
||||||
|
query: vi.fn(),
|
||||||
|
transaction: vi.fn(),
|
||||||
|
}) as unknown as ModuleDatabaseExecutor;
|
||||||
|
const services: ModuleRuntimeServicesLookup = new Map<string, AnyModuleRuntimeServices>([
|
||||||
|
["legacy", Object.freeze({ database: legacyDatabase })],
|
||||||
|
["alpha", Object.freeze({ database: alphaExecutor })],
|
||||||
|
["beta", Object.freeze({ database: betaExecutor })],
|
||||||
|
]);
|
||||||
|
await createRuntimeModule(modulesDirectory, "legacy", {
|
||||||
|
serverSource: `export default { id: "legacy", version: "0.1.0", start({ services }) { ${recorder.expression}.push(services.database); } };`,
|
||||||
|
});
|
||||||
|
for (const id of ["alpha", "beta"]) {
|
||||||
|
await createRuntimeModule(modulesDirectory, id, {
|
||||||
|
manifest: manifestFor(id, { database: { abi: 1 } }),
|
||||||
|
serverSource: `export default { database: { abi: 1 }, id: ${JSON.stringify(id)}, version: "0.1.0", start({ services }) { ${recorder.expression}.push(services.database); } };`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const runtime = await startModuleRuntime(runtimeOptions(modulesDirectory, services));
|
||||||
|
|
||||||
|
expect(recorder.values).toEqual([alphaExecutor, betaExecutor, legacyDatabase]);
|
||||||
|
expect("select" in alphaExecutor).toBe(false);
|
||||||
|
expect(alphaExecutor).not.toBe(betaExecutor);
|
||||||
|
await runtime.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a runtime database declaration that disagrees with its manifest", async () => {
|
||||||
|
const modulesDirectory = await createModulesDirectory();
|
||||||
|
await createRuntimeModule(modulesDirectory, "mismatch", {
|
||||||
|
manifest: manifestFor("mismatch", { database: { abi: 1 } }),
|
||||||
|
serverSource: `export default { id: "mismatch", version: "0.1.0" };`,
|
||||||
|
});
|
||||||
|
|
||||||
|
const error = await captureRuntimeError(modulesDirectory);
|
||||||
|
expect(runtimeIssueCodes(error)).toContain("MODULE_RUNTIME_DATABASE_MISMATCH");
|
||||||
|
});
|
||||||
|
|
||||||
it("never gives register a services field, and gives start and stop the exact same services object", async () => {
|
it("never gives register a services field, and gives start and stop the exact same services object", async () => {
|
||||||
const modulesDirectory = await createModulesDirectory();
|
const modulesDirectory = await createModulesDirectory();
|
||||||
const services = fakeModuleRuntimeServices();
|
const services = fakeModuleRuntimeServices();
|
||||||
@@ -1086,11 +1154,11 @@ describe("module runtime services", () => {
|
|||||||
|
|
||||||
expect(registerServices.values).toEqual([undefined]);
|
expect(registerServices.values).toEqual([undefined]);
|
||||||
expect(lifecycleServices.values).toHaveLength(2);
|
expect(lifecycleServices.values).toHaveLength(2);
|
||||||
expect(lifecycleServices.values[0]).toBe(services);
|
expect(lifecycleServices.values[0]).toBe(services.get("wired"));
|
||||||
expect(lifecycleServices.values[1]).toBe(services);
|
expect(lifecycleServices.values[1]).toBe(services.get("wired"));
|
||||||
});
|
});
|
||||||
|
|
||||||
it("shares the exact same services object identity across every module's start hook", async () => {
|
it("selects a stable services object per module rather than sharing one across modules", async () => {
|
||||||
const modulesDirectory = await createModulesDirectory();
|
const modulesDirectory = await createModulesDirectory();
|
||||||
const services = fakeModuleRuntimeServices();
|
const services = fakeModuleRuntimeServices();
|
||||||
const recorder = createReferenceRecorder();
|
const recorder = createReferenceRecorder();
|
||||||
@@ -1112,7 +1180,8 @@ describe("module runtime services", () => {
|
|||||||
|
|
||||||
const runtime = await startModuleRuntime(runtimeOptions(modulesDirectory, services));
|
const runtime = await startModuleRuntime(runtimeOptions(modulesDirectory, services));
|
||||||
|
|
||||||
expect(recorder.values).toEqual([services, services]);
|
expect(recorder.values).toEqual([services.get("alpha"), services.get("beta")]);
|
||||||
|
expect(recorder.values[0]).not.toBe(recorder.values[1]);
|
||||||
await runtime.stop();
|
await runtime.stop();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1141,23 +1210,26 @@ describe("module runtime services", () => {
|
|||||||
startModuleRuntime(runtimeOptions(modulesDirectory, services)),
|
startModuleRuntime(runtimeOptions(modulesDirectory, services)),
|
||||||
).rejects.toBeInstanceOf(ModuleRuntimeError);
|
).rejects.toBeInstanceOf(ModuleRuntimeError);
|
||||||
|
|
||||||
expect(recorder.values).toEqual([services, services]);
|
expect(recorder.values).toEqual([services.get("started"), services.get("started")]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("starts and stops a zero-module runtime without ever touching the provided services", async () => {
|
it("starts and stops a zero-module runtime without ever touching the provided services", async () => {
|
||||||
const modulesDirectory = await createModulesDirectory();
|
const modulesDirectory = await createModulesDirectory();
|
||||||
const services = fakeModuleRuntimeServices();
|
const database = fakeModuleDatabase();
|
||||||
|
const services: ModuleRuntimeServicesLookup = new Map([
|
||||||
|
["unused", Object.freeze({ database })],
|
||||||
|
]);
|
||||||
|
|
||||||
const runtime = await startModuleRuntime(runtimeOptions(modulesDirectory, services));
|
const runtime = await startModuleRuntime(runtimeOptions(modulesDirectory, services));
|
||||||
await runtime.stop();
|
await runtime.stop();
|
||||||
|
|
||||||
expect(runtime.modules).toEqual([]);
|
expect(runtime.modules).toEqual([]);
|
||||||
expect(vi.mocked(services.database.select)).not.toHaveBeenCalled();
|
expect(vi.mocked(database.select)).not.toHaveBeenCalled();
|
||||||
expect(vi.mocked(services.database.insert)).not.toHaveBeenCalled();
|
expect(vi.mocked(database.insert)).not.toHaveBeenCalled();
|
||||||
expect(vi.mocked(services.database.update)).not.toHaveBeenCalled();
|
expect(vi.mocked(database.update)).not.toHaveBeenCalled();
|
||||||
expect(vi.mocked(services.database.delete)).not.toHaveBeenCalled();
|
expect(vi.mocked(database.delete)).not.toHaveBeenCalled();
|
||||||
expect(vi.mocked(services.database.execute)).not.toHaveBeenCalled();
|
expect(vi.mocked(database.execute)).not.toHaveBeenCalled();
|
||||||
expect(vi.mocked(services.database.transaction)).not.toHaveBeenCalled();
|
expect(vi.mocked(database.transaction)).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1463,7 +1535,7 @@ describe("module configuration delivery (ADR 0029)", () => {
|
|||||||
expect(recorder.values).toEqual([["object", []]]);
|
expect(recorder.values).toEqual([["object", []]]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("keeps the services object one shared identity even though configuration is per-module", async () => {
|
it("keeps each module's services object stable while configuration remains per-module", async () => {
|
||||||
const modulesDirectory = await createModulesDirectory();
|
const modulesDirectory = await createModulesDirectory();
|
||||||
const services = fakeModuleRuntimeServices();
|
const services = fakeModuleRuntimeServices();
|
||||||
const recorder = createReferenceRecorder();
|
const recorder = createReferenceRecorder();
|
||||||
@@ -1492,11 +1564,11 @@ describe("module configuration delivery (ADR 0029)", () => {
|
|||||||
await runtime.stop();
|
await runtime.stop();
|
||||||
|
|
||||||
const [alphaServices, alphaConfig, bravoServices, bravoConfig] = recorder.values;
|
const [alphaServices, alphaConfig, bravoServices, bravoConfig] = recorder.values;
|
||||||
// This is the reason configuration is a sibling of `services` rather than a field inside it:
|
// Configuration remains a sibling of `services`: both are selected per module, but they are
|
||||||
// `services` is deliberately one shared object for the whole runtime, while configuration must
|
// independently composed snapshots with separate contracts and lifetimes.
|
||||||
// differ per module. Both properties hold simultaneously.
|
expect(alphaServices).toBe(services.get("alpha"));
|
||||||
expect(alphaServices).toBe(services);
|
expect(bravoServices).toBe(services.get("bravo"));
|
||||||
expect(bravoServices).toBe(services);
|
expect(alphaServices).not.toBe(bravoServices);
|
||||||
expect(alphaConfig).not.toBe(bravoConfig);
|
expect(alphaConfig).not.toBe(bravoConfig);
|
||||||
expect((alphaConfig as ModuleConfiguration).getString("label")).toBe("alpha");
|
expect((alphaConfig as ModuleConfiguration).getString("label")).toBe("alpha");
|
||||||
expect((bravoConfig as ModuleConfiguration).getString("label")).toBe("bravo");
|
expect((bravoConfig as ModuleConfiguration).getString("label")).toBe("bravo");
|
||||||
|
|||||||
@@ -38,10 +38,14 @@ async function createModulesDirectory(): Promise<string> {
|
|||||||
|
|
||||||
function manifestFor(
|
function manifestFor(
|
||||||
id: string,
|
id: string,
|
||||||
options: { dependencies?: Record<string, unknown>; schema?: string | false } = {},
|
options: {
|
||||||
|
database?: { abi: 1 };
|
||||||
|
dependencies?: Record<string, unknown>;
|
||||||
|
schema?: string | false;
|
||||||
|
} = {},
|
||||||
): Record<string, unknown> {
|
): Record<string, unknown> {
|
||||||
return {
|
return {
|
||||||
manifestVersion: 7,
|
manifestVersion: 8,
|
||||||
id,
|
id,
|
||||||
name: `${id} module`,
|
name: `${id} module`,
|
||||||
version: "0.1.0",
|
version: "0.1.0",
|
||||||
@@ -49,6 +53,7 @@ function manifestFor(
|
|||||||
...(options.dependencies ? { dependencies: options.dependencies } : {}),
|
...(options.dependencies ? { dependencies: options.dependencies } : {}),
|
||||||
capabilities: [],
|
capabilities: [],
|
||||||
permissions: [],
|
permissions: [],
|
||||||
|
...(options.database ? { database: options.database } : {}),
|
||||||
entrypoints: {
|
entrypoints: {
|
||||||
server: "./dist/server/index.js",
|
server: "./dist/server/index.js",
|
||||||
...(options.schema === false ? {} : { schema: options.schema ?? "./dist/schema/index.js" }),
|
...(options.schema === false ? {} : { schema: options.schema ?? "./dist/schema/index.js" }),
|
||||||
@@ -126,6 +131,62 @@ function issueCodes(error: ModuleSchemaDiscoveryError): ModuleSchemaDiscoveryIss
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("module schema discovery", () => {
|
describe("module schema discovery", () => {
|
||||||
|
it("loads ABI-v1 schema entrypoints as plain metadata without importing an ORM", async () => {
|
||||||
|
const modulesDirectory = await createModulesDirectory();
|
||||||
|
await createSchemaModule(modulesDirectory, "calendar", {
|
||||||
|
manifest: manifestFor("calendar", { database: { abi: 1 } }),
|
||||||
|
schemaSource: `
|
||||||
|
export default {
|
||||||
|
moduleId: "calendar",
|
||||||
|
tables: [
|
||||||
|
{ name: "calendar_events", foreignKeys: [] },
|
||||||
|
{ name: "calendar_attendees", foreignKeys: [{ table: "calendar_events" }] }
|
||||||
|
]
|
||||||
|
};
|
||||||
|
`,
|
||||||
|
});
|
||||||
|
|
||||||
|
const aggregate = await discoverModuleSchemas(discoveryOptions(modulesDirectory));
|
||||||
|
|
||||||
|
expect(aggregate.tables.map((table) => table.name)).toEqual([
|
||||||
|
"calendar_attendees",
|
||||||
|
"calendar_events",
|
||||||
|
]);
|
||||||
|
expect(aggregate.tables.every((table) => table.table === undefined)).toBe(true);
|
||||||
|
expect(aggregate.drizzleSchema).toEqual({});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("aggregates ABI-v1 prefix, duplicate, cross-module FK, missing target, and malformed errors", async () => {
|
||||||
|
const modulesDirectory = await createModulesDirectory();
|
||||||
|
await createSchemaModule(modulesDirectory, "alpha", {
|
||||||
|
manifest: manifestFor("alpha", { database: { abi: 1 } }),
|
||||||
|
schemaSource: `export default {
|
||||||
|
moduleId: "alpha",
|
||||||
|
tables: [
|
||||||
|
{ name: "wrong_records", foreignKeys: [] },
|
||||||
|
{ name: "alpha_records", foreignKeys: [{ table: "beta_records" }] },
|
||||||
|
{ name: "alpha_records", foreignKeys: [{ table: "alpha_missing" }] }
|
||||||
|
]
|
||||||
|
};`,
|
||||||
|
});
|
||||||
|
await createSchemaModule(modulesDirectory, "malformed", {
|
||||||
|
manifest: manifestFor("malformed", { database: { abi: 1 } }),
|
||||||
|
schemaSource: `export default { moduleId: "malformed", tables: [{ name: "malformed_rows", unknown: true }] };`,
|
||||||
|
});
|
||||||
|
|
||||||
|
const error = await captureSchemaError(modulesDirectory);
|
||||||
|
|
||||||
|
expect(issueCodes(error)).toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
"MODULE_SCHEMA_CROSS_MODULE_FOREIGN_KEY",
|
||||||
|
"MODULE_SCHEMA_EXPORT_INVALID",
|
||||||
|
"MODULE_SCHEMA_FOREIGN_KEY_TARGET_MISSING",
|
||||||
|
"MODULE_SCHEMA_TABLE_DUPLICATE",
|
||||||
|
"MODULE_SCHEMA_TABLE_NAMESPACE_INVALID",
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it("returns a frozen empty aggregate without importing module servers", async () => {
|
it("returns a frozen empty aggregate without importing module servers", async () => {
|
||||||
const modulesDirectory = await createModulesDirectory();
|
const modulesDirectory = await createModulesDirectory();
|
||||||
const recorder = createGlobalEventRecorder();
|
const recorder = createGlobalEventRecorder();
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ function fakeModule(
|
|||||||
} = {},
|
} = {},
|
||||||
): DiscoveredModule {
|
): DiscoveredModule {
|
||||||
const manifest = parseModuleManifest({
|
const manifest = parseModuleManifest({
|
||||||
manifestVersion: 7,
|
manifestVersion: 8,
|
||||||
id,
|
id,
|
||||||
name: `${id} module`,
|
name: `${id} module`,
|
||||||
version: "1.0.0",
|
version: "1.0.0",
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import {
|
import {
|
||||||
EMPTY_MODULE_STORAGE,
|
EMPTY_MODULE_STORAGE,
|
||||||
|
isModuleStorageError,
|
||||||
isValidModuleStorageKey,
|
isValidModuleStorageKey,
|
||||||
MODULE_STORAGE_KEY_MAX_BYTES,
|
MODULE_STORAGE_KEY_MAX_BYTES,
|
||||||
MODULE_STORAGE_READ_URL_DEFAULT_SECONDS,
|
MODULE_STORAGE_READ_URL_DEFAULT_SECONDS,
|
||||||
@@ -322,6 +323,17 @@ describe("module storage scoping", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("recognizes a storage error from an installed module's separate package copy", () => {
|
||||||
|
const external = Object.assign(new Error("The storage service could not be reached."), {
|
||||||
|
code: "STORAGE_PROVIDER_UNAVAILABLE",
|
||||||
|
name: "ModuleStorageError",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(external).not.toBeInstanceOf(ModuleStorageError);
|
||||||
|
expect(isModuleStorageError(external)).toBe(true);
|
||||||
|
expect(isModuleStorageError(Object.assign(new Error("no"), { code: "UNKNOWN" }))).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
it("gives a module no way to reach another module's storage by knowing its name", async () => {
|
it("gives a module no way to reach another module's storage by knowing its name", async () => {
|
||||||
const shared = fakeProvider();
|
const shared = fakeProvider();
|
||||||
const announcements = createModuleStorage({
|
const announcements = createModuleStorage({
|
||||||
|
|||||||
@@ -79,7 +79,7 @@ async function createModule(modulesDirectory: string, fixture: ModuleFixture): P
|
|||||||
version: contribution.version,
|
version: contribution.version,
|
||||||
})),
|
})),
|
||||||
id: fixture.id,
|
id: fixture.id,
|
||||||
manifestVersion: 7,
|
manifestVersion: 8,
|
||||||
name: `${fixture.id} module`,
|
name: `${fixture.id} module`,
|
||||||
permissions:
|
permissions:
|
||||||
fixture.display === undefined
|
fixture.display === undefined
|
||||||
|
|||||||
@@ -0,0 +1,119 @@
|
|||||||
|
import { createHash } from "node:crypto";
|
||||||
|
import { mkdtemp, readdir, readFile, rm } from "node:fs/promises";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import { afterEach, describe, expect, it } from "vitest";
|
||||||
|
import { discoverRepositoryMigrations } from "../src/cli/migration-command-support.js";
|
||||||
|
import { resolveCommandModulesDirectory } from "../src/cli/modules-directory-environment.js";
|
||||||
|
import { discoverCoreMigrations } from "../src/migrations/index.js";
|
||||||
|
import { ModuleDiscoveryError } from "../src/modules/index.js";
|
||||||
|
import { createMigrationModule } from "./support/migration-module-fixture.js";
|
||||||
|
|
||||||
|
const repositoryRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..");
|
||||||
|
const temporaryDirectories: string[] = [];
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await Promise.all(
|
||||||
|
temporaryDirectories.splice(0).map((directory) => rm(directory, { recursive: true })),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
async function externalModulesDirectory(): Promise<string> {
|
||||||
|
const directory = await mkdtemp(path.join(tmpdir(), "ekklesia-installed-modules-"));
|
||||||
|
temporaryDirectories.push(directory);
|
||||||
|
return directory;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("operator commands select the modules directory through host configuration", () => {
|
||||||
|
it("defaults to the repository's own modules directory", () => {
|
||||||
|
expect(resolveCommandModulesDirectory({}, "/opt/ekklesia")).toEqual({
|
||||||
|
modulesDirectory: "/opt/ekklesia/modules",
|
||||||
|
modulesDirectoryRequired: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("honours EKKLESIA_MODULES_DIR", () => {
|
||||||
|
expect(
|
||||||
|
resolveCommandModulesDirectory(
|
||||||
|
{ EKKLESIA_MODULES_DIR: "/var/lib/ekklesia/modules" },
|
||||||
|
"/opt/ekklesia",
|
||||||
|
),
|
||||||
|
).toEqual({ modulesDirectory: "/var/lib/ekklesia/modules", modulesDirectoryRequired: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses a relative EKKLESIA_MODULES_DIR without echoing it", () => {
|
||||||
|
expect(() =>
|
||||||
|
resolveCommandModulesDirectory({ EKKLESIA_MODULES_DIR: "modules-9e1f" }, "/opt/ekklesia"),
|
||||||
|
).toThrow(ModuleDiscoveryError);
|
||||||
|
try {
|
||||||
|
resolveCommandModulesDirectory({ EKKLESIA_MODULES_DIR: "modules-9e1f" }, "/opt/ekklesia");
|
||||||
|
} catch (error) {
|
||||||
|
expect(JSON.stringify((error as ModuleDiscoveryError).issues)).not.toContain("9e1f");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("plans migrations for modules installed outside the repository, with Core's from the platform", async () => {
|
||||||
|
// A minimal installed module generated from nothing in the repository, so this test never
|
||||||
|
// depends on build output or ignored files: a clean checkout runs it before anything is built.
|
||||||
|
const modulesDirectory = await externalModulesDirectory();
|
||||||
|
const installed = await createMigrationModule(modulesDirectory, "example");
|
||||||
|
expect(path.relative(repositoryRoot, modulesDirectory).startsWith("..")).toBe(true);
|
||||||
|
|
||||||
|
const aggregate = await discoverRepositoryMigrations(repositoryRoot, {
|
||||||
|
EKKLESIA_MODULES_DIR: modulesDirectory,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(aggregate.modules.map((module) => module.moduleId)).toEqual(["core", "example"]);
|
||||||
|
|
||||||
|
// Core's migrations are exactly the platform's own, byte for byte, from `core/drizzle`.
|
||||||
|
const platformCore = await discoverCoreMigrations({
|
||||||
|
coreMigrationsDirectory: path.join(repositoryRoot, "core", "drizzle"),
|
||||||
|
});
|
||||||
|
const fingerprint = (migrations: readonly { identity: string; checksum: string }[]) =>
|
||||||
|
migrations.map((migration) => `${migration.identity} ${migration.checksum}`);
|
||||||
|
expect(platformCore.length).toBeGreaterThan(0);
|
||||||
|
expect(fingerprint(aggregate.modules[0]?.migrations ?? [])).toEqual(fingerprint(platformCore));
|
||||||
|
|
||||||
|
// The module's migration is the one installed outside the repository, byte for byte.
|
||||||
|
const installedSql = await readFile(
|
||||||
|
path.join(installed.migrationDirectory ?? "", "0000_initial.sql"),
|
||||||
|
);
|
||||||
|
expect(fingerprint(aggregate.modules[1]?.migrations ?? [])).toEqual([
|
||||||
|
`example:0000_initial ${createHash("sha256").update(installedSql).digest("hex")}`,
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fails closed when the configured directory does not exist", async () => {
|
||||||
|
const parent = await externalModulesDirectory();
|
||||||
|
await expect(
|
||||||
|
discoverRepositoryMigrations(repositoryRoot, {
|
||||||
|
EKKLESIA_MODULES_DIR: path.join(parent, "absent"),
|
||||||
|
}),
|
||||||
|
).rejects.toBeInstanceOf(ModuleDiscoveryError);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("no source chooses a modules directory except the resolver", () => {
|
||||||
|
async function sourceFiles(directory: string): Promise<readonly string[]> {
|
||||||
|
const entries = await readdir(directory, { recursive: true, withFileTypes: true });
|
||||||
|
return entries
|
||||||
|
.filter((entry) => entry.isFile() && entry.name.endsWith(".ts"))
|
||||||
|
.map((entry) => path.join(entry.parentPath, entry.name));
|
||||||
|
}
|
||||||
|
|
||||||
|
it("never joins `modules` onto a root anywhere else in Core or the API", async () => {
|
||||||
|
const files = [
|
||||||
|
...(await sourceFiles(path.join(repositoryRoot, "core", "src"))),
|
||||||
|
...(await sourceFiles(path.join(repositoryRoot, "apps", "api", "src"))),
|
||||||
|
];
|
||||||
|
const resolver = path.join(repositoryRoot, "core", "src", "modules", "modules-directory.ts");
|
||||||
|
for (const file of files) {
|
||||||
|
if (file === resolver) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const text = await readFile(file, "utf8");
|
||||||
|
expect(text, path.relative(repositoryRoot, file)).not.toMatch(/,\s*"modules"\s*\)/u);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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.2.0", platformVersion: "0.2.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,19 @@
|
|||||||
|
/**
|
||||||
|
* The per-test timeout for Core's import-safety tests, and only those.
|
||||||
|
*
|
||||||
|
* Each of these tests proves that importing a module does nothing (no I/O, no connection, no output)
|
||||||
|
* by performing a cold dynamic `import()` of a large Core source graph inside the test body. Vitest
|
||||||
|
* transforms and evaluates that whole graph on demand, and that work is CPU-bound. An ordinary test
|
||||||
|
* file's own top-level imports run before its tests start and fall under no per-test timeout; these
|
||||||
|
* deliberately do not, so the cost lands inside Vitest's default 5-second test timeout.
|
||||||
|
*
|
||||||
|
* Alone they take about 0.7–1.2 s. During a full `pnpm verify`, Turbo runs every package's suite at
|
||||||
|
* once and the CPU is saturated. Reproduced locally (six cores, Core's suite alongside five other
|
||||||
|
* packages' suites), the same tests took 3.0–5.4 s, and so timed out intermittently.
|
||||||
|
*
|
||||||
|
* 30 seconds is about five times the slowest time measured under load, which leaves room for slower
|
||||||
|
* shared CI runners. It matches the repository's integration-test timeout. It is still bounded, so a
|
||||||
|
* module that genuinely hangs while being imported, for example on a network call, still fails. The
|
||||||
|
* assertions themselves are unchanged: these tests fail on what an import does, never on speed.
|
||||||
|
*/
|
||||||
|
export const IMPORT_SAFETY_TEST_TIMEOUT_MS = 30_000;
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
import { mkdir, writeFile } from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A minimal, valid installed module written into a directory the caller owns: `package.json`,
|
||||||
|
* `ekklesia.module.json`, a compiled-ESM server stub at `dist/server/index.js`, and, unless
|
||||||
|
* disabled, a native Drizzle migration directory (journal, `_ekklesia.json` policy, snapshot chain,
|
||||||
|
* and SQL) that passes Core's migration discovery.
|
||||||
|
*
|
||||||
|
* Everything is generated here, from nothing in the repository: no build output, no ignored file,
|
||||||
|
* and no `node_modules`. Migration discovery imports no module code, so the stub is never run.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const originSnapshotId = "00000000-0000-0000-0000-000000000000";
|
||||||
|
|
||||||
|
export interface MigrationFixture {
|
||||||
|
readonly breakpoints?: boolean;
|
||||||
|
readonly destructive?:
|
||||||
|
| { readonly mode: "none" }
|
||||||
|
| { readonly mode: "approval-required"; readonly reason: string };
|
||||||
|
readonly snapshotId?: string;
|
||||||
|
readonly sql: string | Uint8Array;
|
||||||
|
readonly tag: string;
|
||||||
|
readonly timestamp?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MigrationModuleFixture {
|
||||||
|
readonly dependencies?: Record<string, unknown>;
|
||||||
|
readonly manifest?: Record<string, unknown>;
|
||||||
|
readonly migrations?: readonly MigrationFixture[] | false;
|
||||||
|
readonly schemaSource?: string;
|
||||||
|
readonly serverSource?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreatedMigrationModule {
|
||||||
|
readonly migrationDirectory?: string;
|
||||||
|
readonly moduleDirectory: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function snapshotId(index: number): string {
|
||||||
|
return `10000000-0000-4000-8000-${(index + 1).toString(16).padStart(12, "0")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function manifestFor(
|
||||||
|
id: string,
|
||||||
|
options: {
|
||||||
|
readonly dependencies?: Record<string, unknown>;
|
||||||
|
readonly migrations?: boolean;
|
||||||
|
readonly schema?: boolean;
|
||||||
|
} = {},
|
||||||
|
): Record<string, unknown> {
|
||||||
|
return {
|
||||||
|
manifestVersion: 8,
|
||||||
|
id,
|
||||||
|
name: `${id} module`,
|
||||||
|
version: "0.1.0",
|
||||||
|
compatibility: { platform: "^0.2.0", moduleSdk: "^0.2.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,214 @@
|
|||||||
|
import { createHash } from "node:crypto";
|
||||||
|
import { mkdtemp, readdir, readFile, rm } from "node:fs/promises";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import { gzipSync } from "node:zlib";
|
||||||
|
import { createMigrationModule } from "./migration-module-fixture.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test-only builders for Ekklesia Module Package v1 archives (ADR 0049). They write ustar archives
|
||||||
|
* by hand so a test can produce exactly what the canonical packager never would: links, device
|
||||||
|
* nodes, traversal, a second top-level directory, a corrupted payload.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type FixtureEntryType = "file" | "directory" | "symlink" | "hardlink" | "character" | "fifo";
|
||||||
|
|
||||||
|
export interface FixtureEntry {
|
||||||
|
readonly path: string;
|
||||||
|
readonly type?: FixtureEntryType;
|
||||||
|
readonly mode?: number;
|
||||||
|
readonly data?: Uint8Array | string;
|
||||||
|
readonly linkname?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const typeflags: Readonly<Record<FixtureEntryType, string>> = {
|
||||||
|
character: "3",
|
||||||
|
directory: "5",
|
||||||
|
fifo: "6",
|
||||||
|
file: "0",
|
||||||
|
hardlink: "1",
|
||||||
|
symlink: "2",
|
||||||
|
};
|
||||||
|
|
||||||
|
function octal(value: number, width: number): string {
|
||||||
|
return `${value.toString(8).padStart(width - 1, "0")}\u0000`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function header(entry: FixtureEntry, size: number): Buffer {
|
||||||
|
const block = Buffer.alloc(512);
|
||||||
|
const name =
|
||||||
|
entry.type === "directory" && !entry.path.endsWith("/") ? `${entry.path}/` : entry.path;
|
||||||
|
let prefix = "";
|
||||||
|
let base = name;
|
||||||
|
if (Buffer.byteLength(name) > 100) {
|
||||||
|
const split = name.lastIndexOf("/", name.length - 2);
|
||||||
|
prefix = name.slice(0, split);
|
||||||
|
base = name.slice(split + 1);
|
||||||
|
}
|
||||||
|
block.write(base, 0, 100, "latin1");
|
||||||
|
block.write(octal(entry.mode ?? (entry.type === "directory" ? 0o755 : 0o644), 8), 100, "latin1");
|
||||||
|
block.write(octal(0, 8), 108, "latin1");
|
||||||
|
block.write(octal(0, 8), 116, "latin1");
|
||||||
|
block.write(octal(size, 12), 124, "latin1");
|
||||||
|
block.write(octal(0, 12), 136, "latin1");
|
||||||
|
block.write(" ", 148, "latin1");
|
||||||
|
block.write(typeflags[entry.type ?? "file"], 156, "latin1");
|
||||||
|
block.write(entry.linkname ?? "", 157, 100, "latin1");
|
||||||
|
block.write("ustar\u000000", 257, "latin1");
|
||||||
|
block.write(prefix, 345, 155, "latin1");
|
||||||
|
let checksum = 0;
|
||||||
|
for (const byte of block) {
|
||||||
|
checksum += byte;
|
||||||
|
}
|
||||||
|
block.write(`${checksum.toString(8).padStart(6, "0")}\u0000 `, 148, "latin1");
|
||||||
|
return block;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A gzip-compressed ustar archive of exactly these entries, in this order. */
|
||||||
|
export function tarGz(entries: readonly FixtureEntry[]): Buffer {
|
||||||
|
const parts: Buffer[] = [];
|
||||||
|
for (const entry of entries) {
|
||||||
|
const data =
|
||||||
|
entry.type === undefined || entry.type === "file"
|
||||||
|
? Buffer.from(entry.data ?? "")
|
||||||
|
: Buffer.alloc(0);
|
||||||
|
parts.push(header(entry, data.length), data, Buffer.alloc((512 - (data.length % 512)) % 512));
|
||||||
|
}
|
||||||
|
parts.push(Buffer.alloc(1024));
|
||||||
|
return gzipSync(Buffer.concat(parts), { level: 9 });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sha256(data: Uint8Array | string): string {
|
||||||
|
return createHash("sha256").update(data).digest("hex");
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PackageFixtureOptions {
|
||||||
|
readonly id?: string;
|
||||||
|
readonly version?: string;
|
||||||
|
readonly serverSource?: string;
|
||||||
|
readonly schemaSource?: string;
|
||||||
|
readonly manifest?: (manifest: Record<string, unknown>) => Record<string, unknown>;
|
||||||
|
readonly runtimePackage?: Record<string, unknown>;
|
||||||
|
readonly metadata?: (metadata: Record<string, unknown>) => Record<string, unknown>;
|
||||||
|
/** Changes the payload after its digests were computed. */
|
||||||
|
readonly tamper?: (files: Map<string, Buffer>) => void;
|
||||||
|
/** Entries appended after the package's own, under any path. */
|
||||||
|
readonly extraEntries?: readonly FixtureEntry[];
|
||||||
|
/** Extra payload files, listed in the metadata like every other file. */
|
||||||
|
readonly files?: Readonly<Record<string, Uint8Array | string>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PackageFixture {
|
||||||
|
readonly archive: Buffer;
|
||||||
|
readonly sha256: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readTree(root: string): Promise<Map<string, Buffer>> {
|
||||||
|
const files = new Map<string, Buffer>();
|
||||||
|
for (const entry of await readdir(root, { recursive: true, withFileTypes: true })) {
|
||||||
|
if (entry.isFile()) {
|
||||||
|
const absolute = path.join(entry.parentPath, entry.name);
|
||||||
|
files.set(path.relative(root, absolute).split(path.sep).join("/"), await readFile(absolute));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return files;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A valid any-target package of a minimal module: a server stub with no imports, one migration, a
|
||||||
|
* notices file, the generated runtime package.json, and complete metadata. Every option changes one
|
||||||
|
* thing, so a test can break exactly the rule it is about.
|
||||||
|
*/
|
||||||
|
export async function modulePackageFixture(
|
||||||
|
options: PackageFixtureOptions = {},
|
||||||
|
): Promise<PackageFixture> {
|
||||||
|
const id = options.id ?? "example";
|
||||||
|
const version = options.version ?? "0.1.0";
|
||||||
|
const scratch = await mkdtemp(path.join(tmpdir(), "ekklesia-package-fixture-"));
|
||||||
|
let files: Map<string, Buffer>;
|
||||||
|
try {
|
||||||
|
const created = await createMigrationModule(scratch, id, {
|
||||||
|
...(options.schemaSource === undefined ? {} : { schemaSource: options.schemaSource }),
|
||||||
|
serverSource:
|
||||||
|
options.serverSource ??
|
||||||
|
`export default { id: ${JSON.stringify(id)}, version: ${JSON.stringify(version)}, register() {} };\n`,
|
||||||
|
});
|
||||||
|
files = await readTree(created.moduleDirectory);
|
||||||
|
} finally {
|
||||||
|
await rm(scratch, { force: true, recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
const manifest = JSON.parse(
|
||||||
|
files.get("ekklesia.module.json")?.toString("utf8") ?? "{}",
|
||||||
|
) as Record<string, unknown>;
|
||||||
|
files.set(
|
||||||
|
"ekklesia.module.json",
|
||||||
|
Buffer.from(
|
||||||
|
`${JSON.stringify(options.manifest ? options.manifest(manifest) : { ...manifest, version }, null, 2)}\n`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
files.set(
|
||||||
|
"package.json",
|
||||||
|
Buffer.from(
|
||||||
|
`${JSON.stringify(
|
||||||
|
options.runtimePackage ?? {
|
||||||
|
name: `@test/module-${id}`,
|
||||||
|
version,
|
||||||
|
private: true,
|
||||||
|
type: "module",
|
||||||
|
ekklesia: { layer: "module" },
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
)}\n`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
files.set("THIRD_PARTY_NOTICES", Buffer.from("No third-party software is included.\n"));
|
||||||
|
files.set("LICENSE", Buffer.from("Example module license text.\n"));
|
||||||
|
for (const [filePath, data] of Object.entries(options.files ?? {})) {
|
||||||
|
files.set(filePath, Buffer.from(data));
|
||||||
|
}
|
||||||
|
|
||||||
|
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 },
|
||||||
|
license: "MIT",
|
||||||
|
target: "any",
|
||||||
|
runtime: { node: ">=20" },
|
||||||
|
provenance: {
|
||||||
|
revision: "86ae907f59458508e3277666cdd214f5bd7c1078",
|
||||||
|
packager: { name: "@ekklesia/module-pack", version: "0.2.0" },
|
||||||
|
},
|
||||||
|
incorporates: { "@ekklesia/module-sdk": "0.2.0" },
|
||||||
|
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,92 @@
|
|||||||
|
import { readFile } from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
MODULE_VERIFIER_SCRATCH_ENV,
|
||||||
|
moduleVerifierScratchDirectory,
|
||||||
|
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 execution scratch", () => {
|
||||||
|
it("accepts an absolute operator-configured directory and otherwise leaves the library default", () => {
|
||||||
|
expect(moduleVerifierScratchDirectory({})).toBeUndefined();
|
||||||
|
expect(
|
||||||
|
moduleVerifierScratchDirectory({ [MODULE_VERIFIER_SCRATCH_ENV]: "/run/ekklesia-verifier" }),
|
||||||
|
).toBe("/run/ekklesia-verifier");
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(["", "relative/path"])("refuses the invalid configured path %j", (configured) => {
|
||||||
|
expect(() =>
|
||||||
|
moduleVerifierScratchDirectory({ [MODULE_VERIFIER_SCRATCH_ENV]: configured }),
|
||||||
|
).toThrow(`${MODULE_VERIFIER_SCRATCH_ENV} must be an absolute path.`);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
+2
-1
@@ -2,7 +2,8 @@
|
|||||||
"extends": "../packages/typescript-config/node.json",
|
"extends": "../packages/typescript-config/node.json",
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"noEmit": true,
|
"noEmit": true,
|
||||||
"types": ["node", "vitest/globals"]
|
"types": ["node", "vitest/globals"],
|
||||||
|
"rewriteRelativeImportExtensions": true
|
||||||
},
|
},
|
||||||
"include": [
|
"include": [
|
||||||
"drizzle.config.ts",
|
"drizzle.config.ts",
|
||||||
|
|||||||
+37
-29
@@ -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
|
||||||
|
|||||||
+189
-52
@@ -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,125 @@ 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:
|
||||||
|
|
||||||
|
The backend probe executes the package's server-side code; the verifier container is its isolation
|
||||||
|
boundary. Keep the general-purpose `/tmp` mount `noexec`, and give only this one-shot verifier a
|
||||||
|
separate executable scratch tmpfs. The mount below is ephemeral, bounded at 1100 MiB (just above the
|
||||||
|
package format's 1 GiB expanded limit), `nodev`, `nosuid`, and writable only by the image's
|
||||||
|
unprivileged `node` user. `EKKLESIA_MODULE_VERIFIER_SCRATCH_DIR` tells the CLI where that mount is;
|
||||||
|
the verifier creates a unique child for each run and removes it on success or failure.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
mkdir -m 777 staging # writable by the image's unprivileged user; nothing else is
|
||||||
|
docker run --rm --network none --read-only \
|
||||||
|
--tmpfs /tmp:rw,nosuid,nodev,noexec,size=64m \
|
||||||
|
--tmpfs /run/ekklesia-verifier:rw,nosuid,nodev,exec,size=1100m,mode=0700,uid=1000,gid=1000 \
|
||||||
|
--cap-drop ALL --security-opt no-new-privileges \
|
||||||
|
-e EKKLESIA_MODULE_VERIFIER_SCRATCH_DIR=/run/ekklesia-verifier \
|
||||||
|
-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 +419,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 +438,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,44 +474,47 @@ is no second image and no second build.
|
|||||||
|
|
||||||
### What it builds
|
### What it builds
|
||||||
|
|
||||||
The build context is an **already-assembled workspace**: this repository, plus zero or more module
|
The build context is **this repository**, and the image is the same for every installation
|
||||||
packages already present in `modules/<id>`, plus a `pnpm-lock.yaml` describing exactly that set.
|
([ADR 0048](../docs/architecture/decisions/0048-generic-image-and-installed-module-storage.md)). It
|
||||||
The Dockerfile clones nothing and chooses no modules. It builds what it is given. With no
|
contains **zero feature modules**: installed modules live in module storage outside it
|
||||||
`modules/` directory, it builds the platform alone; CI builds that case on every change.
|
([Installed modules](#installed-modules)). No input is specific to an installation: no module list,
|
||||||
|
no lockfile of its own, no branding, no configuration.
|
||||||
|
|
||||||
Assembling the context (checking out the platform at a release tag, each module at a pinned tag or
|
A `modules/` directory in the checkout never reaches the image. `.dockerignore` keeps it out of the
|
||||||
commit, and the instance's own lockfile) belongs to the installation's instance composition, not to
|
build context, and the build refuses a context that still contains one. The module template is a
|
||||||
this file.
|
workspace package, so it is built and validated with everything else, but neither its files nor its
|
||||||
|
dependencies reach the runtime image. `scripts/check-image.sh` proves all of this on every change
|
||||||
|
([Testing this deployment](#testing-this-deployment)).
|
||||||
|
|
||||||
| Stage | Does |
|
| Stage | Does |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `base` | `node:24.18.0-trixie-slim`, pinned by its multi-architecture index digest. Debian, so glibc, because module dependencies such as sharp ship glibc prebuilt binaries. Every stage starts here. |
|
| `base` | `node:24.18.0-trixie-slim`, pinned by its multi-architecture index digest. Debian, so glibc, because module dependencies such as sharp ship glibc prebuilt binaries. Every stage starts here. |
|
||||||
| `toolchain` | Enables pnpm through Corepack, at exactly the root `packageManager` version. |
|
| `toolchain` | Enables pnpm through Corepack, at exactly the root `packageManager` version. |
|
||||||
| `manifests` | Collects the lockfile, workspace configuration, `.npmrc`, and every workspace `package.json` at its repository path. |
|
| `manifests` | Collects the lockfile, workspace configuration, `.npmrc`, and the `package.json` of every platform package (not the module template's) at its repository path. |
|
||||||
| `build` | `pnpm install --frozen-lockfile`, then `pnpm boundaries`, `pnpm modules:validate`, `pnpm migrations:validate`, and `pnpm build`, against exactly the modules in the context. Never uses host `node_modules`: `.dockerignore` excludes them. |
|
| `build` | Refuses a context containing `modules/`, then `pnpm install --frozen-lockfile`, `pnpm boundaries`, `pnpm modules:validate`, `pnpm migrations:validate`, and `pnpm build`. Never uses host `node_modules`: `.dockerignore` excludes them. |
|
||||||
| `prod-deps` | The same lockfile with `--prod`: production dependencies only. |
|
| `prod-deps` | The same lockfile with `--prod`, for the platform packages only: production dependencies only. |
|
||||||
| `runtime` | Production dependencies plus built outputs, owned by root and run as the unprivileged `node` user (uid 1000). No pnpm, no compiler, no curl. |
|
| `runtime` | Production dependencies plus built outputs, owned by root and run as the unprivileged `node` user (uid 1000), and an empty, root-owned `/var/lib/ekklesia/modules`. No pnpm, no compiler, no curl. |
|
||||||
|
|
||||||
The runtime keeps the repository layout at `/opt/ekklesia`, because that layout is how the platform
|
The runtime keeps the repository layout at `/opt/ekklesia`, because that layout is how the platform
|
||||||
finds its files:
|
finds its files:
|
||||||
|
|
||||||
- the API takes the repository root as three directories above `apps/api/dist/`, and reads
|
- the API takes the repository root as three directories above `apps/api/dist/`, and reads
|
||||||
`package.json`, `packages/module-sdk/package.json`, and `modules/` beneath it;
|
`package.json` and `packages/module-sdk/package.json` beneath it;
|
||||||
- every CLI takes its **working directory** as the root and also reads Core's migrations in
|
- every CLI takes its **working directory** as the root and also reads Core's migrations in
|
||||||
`core/drizzle/`, so commands must run from `/opt/ekklesia`, the image's `WORKDIR`;
|
`core/drizzle/`, so commands must run from `/opt/ekklesia`, the image's `WORKDIR`;
|
||||||
- the Dashboard and display host files are read from `surfaces/shell/dist/browser/`;
|
- the Dashboard and display host files are read from `surfaces/shell/dist/browser/`;
|
||||||
- each module directory is copied whole, minus `node_modules`, because each manifest names its
|
- installed modules are read from `EKKLESIA_MODULES_DIR`, `/var/lib/ekklesia/modules`, never from
|
||||||
own entrypoints, UI artifacts, and migrations directory.
|
under `/opt/ekklesia`. With nothing mounted there, the image runs with no modules.
|
||||||
|
|
||||||
### Building
|
### Building
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
docker build \
|
docker build \
|
||||||
--file deploy/Dockerfile \
|
--file deploy/Dockerfile \
|
||||||
--build-arg VERSION=0.1.2 \
|
--build-arg VERSION=0.2.4 \
|
||||||
--build-arg REVISION="$(git rev-parse HEAD)" \
|
--build-arg REVISION="$(git rev-parse HEAD)" \
|
||||||
--build-arg SOURCE=https://git.example.org/your-org/ekklesia \
|
--build-arg SOURCE=https://git.example.org/your-org/ekklesia \
|
||||||
--tag ekklesia:0.1.2 \
|
--tag ekklesia:0.2.4 \
|
||||||
.
|
.
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -399,30 +526,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 +577,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 +616,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
|
||||||
|
|||||||
@@ -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: {}
|
||||||
@@ -120,6 +132,10 @@ services:
|
|||||||
command:
|
command:
|
||||||
- mini
|
- mini
|
||||||
- -dir=/data
|
- -dir=/data
|
||||||
|
# A bucket is a SeaweedFS collection and claims a block of volumes. The ~1 GB automatic
|
||||||
|
# volume size leaves modest hosts with room for only a few collections; after that, writes
|
||||||
|
# stall instead of failing. This changes allocation granularity, not object or total capacity.
|
||||||
|
- -master.volumeSizeLimitMB=32
|
||||||
- -s3.port.https=8443
|
- -s3.port.https=8443
|
||||||
- -s3.cert.file=/run/ekklesia/tls/object-storage.crt
|
- -s3.cert.file=/run/ekklesia/tls/object-storage.crt
|
||||||
- -s3.key.file=/run/ekklesia/tls/object-storage.key
|
- -s3.key.file=/run/ekklesia/tls/object-storage.key
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user