Compare commits
155
Commits
a835215eb8
..
v0.2.5
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
437720f228 | ||
|
|
d3148d45f7 | ||
|
|
303d455315 | ||
|
|
39f48dc7c9 | ||
|
|
9d515cd56f | ||
|
|
b8a826f8be | ||
|
|
0c9b8f7af9 | ||
|
|
5e9433d96b | ||
|
|
650932a170 | ||
|
|
2d861c6bf0 | ||
|
|
bae4ff3c6a | ||
|
|
9331a8f7df | ||
|
|
8f90871be0 | ||
|
|
85b1377fd3 | ||
|
|
4bcdb578e6 | ||
|
|
06bad2fe36 | ||
|
|
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 | ||
|
|
fc504eee0a | ||
|
|
5abc8be167 | ||
|
|
12dca3e8a4 | ||
|
|
0ec1755279 | ||
|
|
874deabea3 | ||
|
|
62951975da | ||
|
|
703d92436b | ||
|
|
43911a088d | ||
|
|
3a2f5d68f6 | ||
|
|
3827841205 | ||
|
|
2152eb036a | ||
|
|
e1ac1da416 | ||
|
|
dcbd7613ee | ||
|
|
dbb4648a35 | ||
|
|
f1d6501239 | ||
|
|
54ac6ab83f | ||
|
|
fa493365bd | ||
|
|
f50e8f4201 | ||
|
|
ff8a3e8097 | ||
|
|
b2efb4b029 | ||
|
|
f63f80f2f0 | ||
|
|
cbf5962d04 | ||
|
|
38cf52757d | ||
|
|
5162b4cc77 | ||
|
|
8ea4d4d2e4 | ||
|
|
55accd0e65 | ||
|
|
a56a853319 | ||
|
|
b642d8fe30 | ||
|
|
278e66049f | ||
|
|
c737ebb472 | ||
|
|
7cbe4e1b2c | ||
|
|
481e80118b | ||
|
|
167b4c4f14 | ||
|
|
b593abe7a5 | ||
|
|
e12ef10a65 | ||
|
|
4468335e8f | ||
|
|
756e9b821c | ||
|
|
ef1383d080 | ||
|
|
fc422b6d8d | ||
|
|
d5d4928b9f | ||
|
|
a12cbe9600 | ||
|
|
220b7b152a | ||
|
|
aaec9789c4 | ||
|
|
d593c10f1d | ||
|
|
06e55cebd5 | ||
|
|
f3cd5a4152 | ||
|
|
3a26ef49ad | ||
|
|
3ca6b2cb32 | ||
|
|
75324af045 | ||
|
|
f445a83ea5 | ||
|
|
a130eb78ea | ||
|
|
8127cc87b4 | ||
|
|
2969ed4992 | ||
|
|
14ac46bd3d | ||
|
|
bb7a9eb351 | ||
|
|
20d5128bad | ||
|
|
ca3f37d5f9 | ||
|
|
e36485bf99 | ||
|
|
c444ff570b | ||
|
|
07835b7b56 | ||
|
|
e03d38fcab | ||
|
|
b14613decd | ||
|
|
3563549e86 | ||
|
|
450d65116f | ||
|
|
ea28f8aa2e | ||
|
|
3581ad7c8e | ||
|
|
2b4f0afb72 |
@@ -0,0 +1,30 @@
|
|||||||
|
# Build context for deploy/Dockerfile (ADR 0044). The image installs its own dependencies with
|
||||||
|
# `pnpm install --frozen-lockfile` and builds its own outputs, so nothing installed or built on the
|
||||||
|
# host may enter the context, and no secret or environment file may enter any layer.
|
||||||
|
|
||||||
|
# Installed modules never enter the platform image (ADR 0048): an installation keeps them in its own
|
||||||
|
# module storage, mounted at run time. Excluding the checkout's `modules/` is what makes a build
|
||||||
|
# identical whether or not modules happen to be present in the source tree.
|
||||||
|
/modules
|
||||||
|
|
||||||
|
# Host-installed dependencies and build outputs: the image produces its own.
|
||||||
|
**/node_modules
|
||||||
|
**/dist
|
||||||
|
**/.turbo
|
||||||
|
**/coverage
|
||||||
|
**/*.tsbuildinfo
|
||||||
|
|
||||||
|
# Environment and secret material. Configuration reaches the container only through its runtime
|
||||||
|
# environment; `.env.example` is excluded too, since nothing in the image reads it.
|
||||||
|
**/.env
|
||||||
|
**/.env.*
|
||||||
|
|
||||||
|
# Repository and tooling metadata the image never reads.
|
||||||
|
.git
|
||||||
|
.github
|
||||||
|
.gitea
|
||||||
|
.forgejo
|
||||||
|
.vscode
|
||||||
|
.idea
|
||||||
|
**/*.log
|
||||||
|
**/.DS_Store
|
||||||
@@ -1,4 +1,8 @@
|
|||||||
# Local development only. Replace every credential outside a developer workstation.
|
# Local development only. Replace every credential outside a developer workstation.
|
||||||
|
#
|
||||||
|
# `pnpm doctor` loads this file's uncommitted `.env` counterpart and evaluates exactly the host
|
||||||
|
# infrastructure configuration `pnpm api:start` would use, so the readiness report reflects the real
|
||||||
|
# deployment without the API process running.
|
||||||
|
|
||||||
# Required for a local workstation, and only ever correct there. Anything else — including an
|
# Required for a local workstation, and only ever correct there. Anything else — including an
|
||||||
# unset value — is validated as production, which requires certificate-verified PostgreSQL TLS
|
# unset value — is validated as production, which requires certificate-verified PostgreSQL TLS
|
||||||
@@ -6,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
|
||||||
@@ -77,3 +87,30 @@ S3_BUCKET=ekklesia-development
|
|||||||
# EKKLESIA_SMTP_PASSWORD=
|
# EKKLESIA_SMTP_PASSWORD=
|
||||||
# [email protected]
|
# [email protected]
|
||||||
# EKKLESIA_SMTP_FROM_NAME=Ekklesia
|
# EKKLESIA_SMTP_FROM_NAME=Ekklesia
|
||||||
|
#
|
||||||
|
# EKKLESIA_SMTP_HELO_NAME is the name Ekklesia announces itself as in the SMTP EHLO command.
|
||||||
|
#
|
||||||
|
# Leave it unset unless a relay refuses the connection. An SMTP client has to identify itself, and
|
||||||
|
# with nothing configured Nodemailer derives that from the machine hostname — substituting the
|
||||||
|
# address literal [127.0.0.1] whenever the hostname is not fully qualified, which is the normal
|
||||||
|
# state of a container or an unnamed VM. Some relays close the connection on that greeting with a
|
||||||
|
# 421 before any message is offered, which looks exactly like the relay being unreachable.
|
||||||
|
#
|
||||||
|
# Set it to a fully qualified domain name your deployment owns — typically the sending domain.
|
||||||
|
# No scheme, no port, no brackets, and at least one dot; anything else is refused at startup.
|
||||||
|
# EKKLESIA_SMTP_HELO_NAME=example.org
|
||||||
|
|
||||||
|
# Where an emailed verification or password-reset link sends a browser (ADR 0038).
|
||||||
|
#
|
||||||
|
# Absent means the Dashboard is served from EKKLESIA_IDENTITY_BASE_URL, which is the ordinary
|
||||||
|
# same-origin production shape. Set it only when a deployment separates the two, for example an API
|
||||||
|
# at https://api.example.org and the Dashboard at https://dash.example.org. It must also appear in
|
||||||
|
# EKKLESIA_IDENTITY_TRUSTED_ORIGINS, or the identity system refuses the emailed link — Ekklesia
|
||||||
|
# checks that at startup rather than letting every recipient discover it.
|
||||||
|
#
|
||||||
|
# This is the authenticated Dashboard host, never the public church website.
|
||||||
|
# EKKLESIA_DASHBOARD_ORIGIN=https://dash.example.org
|
||||||
|
#
|
||||||
|
# Verification and password recovery need a working relay above. The initial platform-owner
|
||||||
|
# bootstrap (pnpm identity:create-initial-owner) deliberately does not: it creates that one account
|
||||||
|
# already verified, so a fresh server can be signed in to before any mail exists.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
+244
-14
@@ -1,18 +1,13 @@
|
|||||||
name: CI
|
name: CI
|
||||||
|
|
||||||
# IMPORTANT — the currently inventoried server is Gitea 1.24.2, which *ignores* several keys used
|
# The server runs Gitea 1.27.2 (docs/development/security.md, "What this server honours"):
|
||||||
# below. Per https://docs.gitea.com/1.24/usage/actions/comparison, its unsupported workflow syntax
|
|
||||||
# includes `permissions` / `jobs.<job_id>.permissions`, `concurrency`, and
|
|
||||||
# `jobs.<job_id>.timeout-minutes`. They are declared here deliberately, so the workflow is already
|
|
||||||
# correct on a supported release and on GitHub Actions, but on this server they are inert:
|
|
||||||
#
|
#
|
||||||
# * `permissions:` does NOT restrict the in-job `GITEA_TOKEN`. Job-token permission controls,
|
# * `concurrency:` is honoured: a newer push to a pull request cancels the run it supersedes, so a
|
||||||
# repository/owner defaults, and server-side maximum-permission clamping arrive in the 1.26
|
# cancelled run's own cleanup is never guaranteed (see the CI stack sweep in each stack job).
|
||||||
# feature line (https://docs.gitea.com/1.26/usage/actions/token-permissions). Until the server
|
# * `permissions:` is supported by 1.27 with Gitea's own scopes, but what the job token can do on
|
||||||
# is upgraded and configured, the token's authority is whatever the server grants — not what
|
# this server is not yet proven, so nothing here relies on it being read-only.
|
||||||
# this file requests.
|
# * `timeout-minutes:` is no longer listed as unsupported, but is not verified here either;
|
||||||
# * `concurrency:` and `timeout-minutes:` are NOT bounded-execution guarantees here. Cancellation,
|
# teardown relies on the stack sweep and the runner's own reaper.
|
||||||
# timeout, and teardown must be enforced by the trusted runner provisioner/reaper.
|
|
||||||
#
|
#
|
||||||
# `persist-credentials: false` below is unaffected by any of this and is enforced by the action
|
# `persist-credentials: false` below is unaffected by any of this and is enforced by the action
|
||||||
# itself. See docs/development/security.md.
|
# itself. See docs/development/security.md.
|
||||||
@@ -63,6 +58,9 @@ jobs:
|
|||||||
needs: verify
|
needs: verify
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 10
|
timeout-minutes: 10
|
||||||
|
env:
|
||||||
|
# Names this job's stacks, so its final step removes exactly these (scripts/lib/ci-project.sh).
|
||||||
|
EKKLESIA_CI_JOB_KEY: infrastructure-smoke
|
||||||
steps:
|
steps:
|
||||||
- name: Check out repository
|
- name: Check out repository
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
@@ -72,13 +70,73 @@ jobs:
|
|||||||
# in this workflow pushes, so the credential is never needed after checkout.
|
# in this workflow pushes, so the credential is never needed after checkout.
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
|
|
||||||
|
# A job the runner killed (cancelled, or past the runner's own time limit) never ran its
|
||||||
|
# cleanup. Anything a CI job left on this shared daemon more than three hours ago goes first.
|
||||||
|
- name: Sweep stale CI stacks from the runner
|
||||||
|
run: bash scripts/ci-sweep.sh --stale
|
||||||
|
|
||||||
|
- name: Prove the sweep removes exactly what it should, against real Docker resources
|
||||||
|
run: bash scripts/test-ci-sweep.sh
|
||||||
|
|
||||||
- name: Exercise PostgreSQL and S3 infrastructure
|
- name: Exercise PostgreSQL and S3 infrastructure
|
||||||
run: bash scripts/smoke-infrastructure.sh
|
run: bash scripts/smoke-infrastructure.sh
|
||||||
|
|
||||||
|
# Whatever happened above, this job's own stacks do not outlive it.
|
||||||
|
- name: Remove this job's CI stacks
|
||||||
|
if: always()
|
||||||
|
run: bash scripts/ci-sweep.sh --job
|
||||||
|
|
||||||
migration-smoke:
|
migration-smoke:
|
||||||
needs: verify
|
needs: verify
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 15
|
timeout-minutes: 15
|
||||||
|
env:
|
||||||
|
# Names this job's stacks, so its final step removes exactly these (scripts/lib/ci-project.sh).
|
||||||
|
EKKLESIA_CI_JOB_KEY: migration-smoke
|
||||||
|
steps:
|
||||||
|
- 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
|
||||||
|
|
||||||
|
# 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: Exercise transactional migrations against PostgreSQL
|
||||||
|
run: pnpm test:migrations:integration
|
||||||
|
|
||||||
|
# Whatever happened above, this job's own stacks do not outlive it.
|
||||||
|
- name: Remove this job's CI stacks
|
||||||
|
if: always()
|
||||||
|
run: bash scripts/ci-sweep.sh --job
|
||||||
|
|
||||||
|
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:
|
steps:
|
||||||
- name: Check out repository
|
- name: Check out repository
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
@@ -101,5 +159,177 @@ jobs:
|
|||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: pnpm install --frozen-lockfile
|
run: pnpm install --frozen-lockfile
|
||||||
|
|
||||||
- name: Exercise transactional migrations against PostgreSQL
|
# ADR 0053's package release gate: build the public package train from this commit, build it
|
||||||
run: pnpm test:migrations:integration
|
# 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:
|
||||||
|
needs: verify
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 20
|
||||||
|
env:
|
||||||
|
# Names this job's stacks, so its final step removes exactly these (scripts/lib/ci-project.sh).
|
||||||
|
EKKLESIA_CI_JOB_KEY: image-build
|
||||||
|
# Cache mounts in deploy/Dockerfile need BuildKit; this selects it even where the Docker CLI
|
||||||
|
# has no buildx plugin.
|
||||||
|
DOCKER_BUILDKIT: "1"
|
||||||
|
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
|
||||||
|
|
||||||
|
# A job the runner killed (cancelled, or past the runner's own time limit) never ran its
|
||||||
|
# cleanup. Anything a CI job left on this shared daemon more than three hours ago goes first.
|
||||||
|
- name: Sweep stale CI stacks from the runner
|
||||||
|
run: bash scripts/ci-sweep.sh --stale
|
||||||
|
|
||||||
|
# The one generic platform image (ADR 0044, ADR 0048), proved rather than assumed: it contains no
|
||||||
|
# module, no module template, and no module manifest; its module storage mount point is empty;
|
||||||
|
# EKKLESIA_MODULES_DIR names it; and a checkout with a populated modules/ directory builds an
|
||||||
|
# identical runtime file system. Build only — no registry login, no credentials, no push, no tag
|
||||||
|
# anywhere but this runner's local image store. Tagged per run and attempt, and labeled like
|
||||||
|
# every CI stack, so this job's final step removes the images even if a build is killed.
|
||||||
|
- name: Build the generic platform image and prove it holds no modules
|
||||||
|
run: bash scripts/check-image.sh
|
||||||
|
|
||||||
|
# Whatever happened above, this job's own stacks do not outlive it.
|
||||||
|
- name: Remove this job's CI stacks
|
||||||
|
if: always()
|
||||||
|
run: bash scripts/ci-sweep.sh --job
|
||||||
|
|
||||||
|
deploy-smoke:
|
||||||
|
needs: verify
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 30
|
||||||
|
# Both topologies (ADR 0045), each on the one generic image: same-origin, the default every
|
||||||
|
# installation gets, and split, which installs a test-only module into module storage, removes
|
||||||
|
# it, and restores it (ADR 0048), and drives a real headless browser across the two origins.
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
topology:
|
||||||
|
- same-origin
|
||||||
|
- split
|
||||||
|
env:
|
||||||
|
# Names this job's stacks, so its final step removes exactly these (scripts/lib/ci-project.sh).
|
||||||
|
EKKLESIA_CI_JOB_KEY: deploy-smoke-${{ matrix.topology }}
|
||||||
|
DOCKER_BUILDKIT: "1"
|
||||||
|
EKKLESIA_SMOKE_TOPOLOGY: ${{ matrix.topology }}
|
||||||
|
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
|
||||||
|
|
||||||
|
# A job the runner killed (cancelled, or past the runner's own time limit) never ran its
|
||||||
|
# cleanup. Anything a CI job left on this shared daemon more than three hours ago goes first.
|
||||||
|
- name: Sweep stale CI stacks from the runner
|
||||||
|
run: bash scripts/ci-sweep.sh --stale
|
||||||
|
|
||||||
|
# The reference deployment end to end (ADR 0044): the generic platform image, base + proxy with
|
||||||
|
# Caddy's internal CA (test-only), an explicit migration apply against this job's own fresh
|
||||||
|
# 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
|
||||||
|
# it; nothing is pushed, published, or logged in to.
|
||||||
|
- name: Smoke-test the reference deployment (${{ matrix.topology }})
|
||||||
|
run: bash scripts/smoke-deploy.sh
|
||||||
|
|
||||||
|
# Whatever happened above, this job's own stacks do not outlive it.
|
||||||
|
- name: Remove this job's CI stacks
|
||||||
|
if: always()
|
||||||
|
run: bash scripts/ci-sweep.sh --job
|
||||||
|
|||||||
@@ -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.zayin.net/api/packages/Zayin/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/
|
||||||
|
|||||||
@@ -6,27 +6,60 @@ contents, because restated details drift.
|
|||||||
|
|
||||||
## What this repository is
|
## What this repository is
|
||||||
|
|
||||||
Ekklesia is a modular, self-hosted church platform built as a strictly layered pnpm/TypeScript
|
Ekklesia is a modular, self-hosted church platform. **This repository is the platform** — Core, the
|
||||||
monorepo:
|
API, the Dashboard and display shell, and the public `@ekklesia/*` module-development packages — built
|
||||||
|
as a strictly layered pnpm/TypeScript monorepo:
|
||||||
|
|
||||||
- **Core** (`core/`) owns platform infrastructure: identity, database connectivity, migration
|
- **Core** (`core/`) owns platform infrastructure: identity, database connectivity, migration
|
||||||
execution, module discovery/lifecycle, and other feature-agnostic services. Core never imports a
|
execution, module discovery/lifecycle, devices, and other feature-agnostic services. Core never
|
||||||
feature module, a surface, or an app.
|
imports a feature module, a surface, or an app.
|
||||||
- **Modules** (`modules/*`, template at `templates/module`) are independently versioned product
|
- **Modules** are independently versioned product packages. First-party modules live in **their own
|
||||||
packages. They own their own data and migrations, declare capabilities/permissions through
|
repositories** (Signage is `Zayin/Signage`), build against the published `@ekklesia/*` packages
|
||||||
`@ekklesia/contracts` and `@ekklesia/module-sdk`, and must never import Core, another module, or a
|
(ADR 0053), and reach an installation as a Module Package (ADR 0049) installed into module storage
|
||||||
surface directly. Per ADR 0021 a module owns its UI; manifest v6 permits independently optional
|
outside the image (ADR 0048). No module source lives in this repository; `templates/module` is the
|
||||||
primary and full-viewport display artifacts, authored in React against the same role-neutral
|
example, and `modules/` is only an optional, ignored local installed-modules directory. A module owns
|
||||||
`@ekklesia/module-ui-sdk` ABI. Ekklesia mounts each through a separate surface role, while
|
its data, migrations, and **its own domain UI** (ADR 0021), declares capabilities/permissions through
|
||||||
`pnpm boundaries` still rejects every non-React UI framework and non-`.tsx` presentation source.
|
`@ekklesia/contracts` and `@ekklesia/module-sdk`, and never imports Core, another module, or a
|
||||||
|
surface. Manifest v8 permits independently optional primary and full-viewport display artifacts,
|
||||||
|
authored in React against the role-neutral `@ekklesia/module-ui-sdk` ABI; database access is the
|
||||||
|
Module Database ABI v1 (ADR 0050).
|
||||||
- **Surfaces** (`surfaces/*`) own the containing application shell, navigation, and every
|
- **Surfaces** (`surfaces/*`) own the containing application shell, navigation, and every
|
||||||
presentation concern a module does not. They consume public API/client contracts only, never Core
|
presentation concern a module does not. The shell **hosts** module UI through the generic loader;
|
||||||
or module internals directly.
|
it never rebuilds a module's domain screens. Surfaces consume public API/client contracts only.
|
||||||
|
|
||||||
These boundaries are enforced by `pnpm boundaries` (`scripts/check-boundaries.ts`), not just
|
These boundaries are enforced by `pnpm boundaries` (`scripts/check-boundaries.ts`), not just
|
||||||
documented. Read `docs/architecture/repository-boundaries.md` and
|
documented. Read `docs/architecture/repository-boundaries.md` and
|
||||||
`docs/architecture/platform-architecture.md` before proposing any change that crosses a layer.
|
`docs/architecture/platform-architecture.md` before proposing any change that crosses a layer.
|
||||||
|
|
||||||
|
## Product direction lives beside this repository
|
||||||
|
|
||||||
|
The current roadmap and product/interface design contract are `ROADMAP.md` and `DESIGN.md` in the
|
||||||
|
parent development directory (`../ROADMAP.md` and `../DESIGN.md` from a checkout in the standard
|
||||||
|
`~/development/ekklesia/` layout). They are deliberately **not** committed here; do not copy them into
|
||||||
|
this repository to make them available. They are authoritative for product direction: read the
|
||||||
|
relevant stages and sections before designing a change. If this repository's documentation disagrees
|
||||||
|
with them, flag the conflict, correct the repository-local documentation where appropriate, and never
|
||||||
|
silently follow the stale text. Architectural decisions still change only through ADRs (below).
|
||||||
|
|
||||||
|
Rules from those documents that every change here must respect:
|
||||||
|
|
||||||
|
- **Ownership.** Modules own their domain data, behaviour, and UI. Shared resources — displays and
|
||||||
|
devices, accounts and identity, roles and authorization, branding, mail, push, storage providers,
|
||||||
|
and module installation/lifecycle — belong to the platform, and modules consume them.
|
||||||
|
- **Branding.** Normal user-facing UI never hard-codes "Ekklesia"; product identity comes from
|
||||||
|
installation branding (ADR 0046). Package names, diagnostics, logs, and developer tooling may.
|
||||||
|
Existing hard-coded shell strings are known debt for Stage 1, not a pattern to copy.
|
||||||
|
- **Visual direction.** Simple, minimal, and calm; responsive for phone, tablet, and desktop; light,
|
||||||
|
dark, and system modes. Normal UI colours come only from the shared design tokens
|
||||||
|
(`@ekklesia/design-tokens`, ADR 0039) — never a hard-coded colour.
|
||||||
|
- **No browser-native dialogs.** `window.alert`, `window.confirm`, and `window.prompt` are prohibited
|
||||||
|
in product UI. Destructive actions use in-product confirmation proportional to their impact.
|
||||||
|
- **Displays are transitional.** Displays are platform-owned. Today's device pairing (the Dashboard
|
||||||
|
sidebar's *Display devices* panel) and Signage's own display roster are a transitional
|
||||||
|
implementation that Stage 1 migrates to **Settings → Displays**. Fix defects in them, but do not
|
||||||
|
build the Stage 1 Settings, global Displays, theme editor, module installer, or responsive shell
|
||||||
|
redesign early, and keep existing paired displays working across any change.
|
||||||
|
|
||||||
## ADRs are authoritative
|
## ADRs are authoritative
|
||||||
|
|
||||||
`docs/architecture/decisions/` records durable architectural decisions. **Accepted ADRs are
|
`docs/architecture/decisions/` records durable architectural decisions. **Accepted ADRs are
|
||||||
@@ -43,6 +76,7 @@ At minimum, read the current versions of:
|
|||||||
- `docs/architecture/decisions/README.md` and every ADR relevant to your change
|
- `docs/architecture/decisions/README.md` and every ADR relevant to your change
|
||||||
- `docs/architecture/platform-architecture.md` and `docs/architecture/repository-boundaries.md`
|
- `docs/architecture/platform-architecture.md` and `docs/architecture/repository-boundaries.md`
|
||||||
- `README.md` (current platform status — what is and is not implemented)
|
- `README.md` (current platform status — what is and is not implemented)
|
||||||
|
- `../ROADMAP.md` and `../DESIGN.md` (current product direction; see above)
|
||||||
- `docs/development/repository-workflow.md` and `docs/development/testing.md`
|
- `docs/development/repository-workflow.md` and `docs/development/testing.md`
|
||||||
- `CONTRIBUTING.md`
|
- `CONTRIBUTING.md`
|
||||||
|
|
||||||
@@ -52,8 +86,16 @@ your own memory of a prior session** — treat stale assumptions as a bug to cat
|
|||||||
|
|
||||||
## Working conventions
|
## Working conventions
|
||||||
|
|
||||||
- Work on a clean branch off an up-to-date `main`; never commit or push directly to `main`; never
|
- The canonical repositories are on the church Gitea under the `Zayin` organization:
|
||||||
change branch-protection settings.
|
`ssh://[email protected]:2222/Zayin/Ekklesia.git` (this one), `Zayin/Signage.git`, and
|
||||||
|
`Zayin/Classroom_Desktop.git`. A local clone may be behind the server: `git fetch --prune --tags`
|
||||||
|
and branch from `origin/main`, never from whatever is checked out.
|
||||||
|
- One branch per PR, off an up-to-date `origin/main`. Never commit or push directly to `main`, never
|
||||||
|
merge your own PR, and never change branch-protection settings.
|
||||||
|
- Never create or push a release tag. Release tags belong to the human maintainer.
|
||||||
|
- A change that needs both a platform contract change and a module change is two PRs: Core first, on
|
||||||
|
its own branch, with the compatibility boundary stated explicitly; then the module. Do not copy
|
||||||
|
platform logic into a module to avoid making the correct Core change.
|
||||||
- Keep PRs focused on one bounded slice. Record major architectural changes in an ADR rather than
|
- Keep PRs focused on one bounded slice. Record major architectural changes in an ADR rather than
|
||||||
burying them in an implementation.
|
burying them in an implementation.
|
||||||
- Add or update tests when you change a contract or architectural rule. New behavior needs new
|
- Add or update tests when you change a contract or architectural rule. New behavior needs new
|
||||||
@@ -78,6 +120,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
|
||||||
|
|||||||
+22
-7
@@ -35,13 +35,21 @@ pnpm infrastructure:config
|
|||||||
private source paths.
|
private source paths.
|
||||||
- Use `workspace:` ranges for internal package dependencies.
|
- Use `workspace:` ranges for internal package dependencies.
|
||||||
- Modules own their own UI and author it in React and TypeScript (ADR 0022). A module package may
|
- Modules own their own UI and author it in React and TypeScript (ADR 0022). A module package may
|
||||||
contain `.tsx` and depend on `react`, `react-dom`, and `@ekklesia/module-ui-sdk`. Every other UI
|
contain `.tsx` and depend on `react`, `react-dom`, and `@ekklesia/module-ui-sdk`, and may author
|
||||||
framework, `.jsx`, stylesheets, and other presentation formats remain rejected by
|
plain `.css` inside a browser-artifact source root (ADR 0040). Every other UI framework, `.jsx`,
|
||||||
`pnpm boundaries`. Primary and display sources live at the exact `src/ui/index.tsx` and
|
other stylesheet dialects, and other presentation formats remain rejected by `pnpm boundaries`. Primary and display sources live at the exact `src/ui/index.tsx` and
|
||||||
`src/display/index.tsx` conventions; one `pnpm build` invocation atomically compiles every role
|
`src/display/index.tsx` conventions; one `pnpm build` invocation atomically compiles every role
|
||||||
the v6 manifest declares via `@ekklesia/module-ui-build` (ADR 0023/0033). Module authors write no
|
the v7 manifest declares via `@ekklesia/module-ui-build` (ADR 0023/0033), including every declared
|
||||||
bundler configuration, and styling is React inline style objects only for now. Both roles use the
|
browser contribution at `src/contributions/<local-name>/index.tsx` (ADR 0041). Module authors write no
|
||||||
same UI SDK context with no presentation-mode flag.
|
bundler configuration. A module's stylesheets are compiled into its declared artifact as text and
|
||||||
|
installed by the host into that artifact's own Shadow DOM root, so module styles cannot leak into
|
||||||
|
the Dashboard or another module (ADR 0040). Both roles use the same UI SDK context with no
|
||||||
|
presentation-mode flag, and the same versioned mount ABI.
|
||||||
|
- A module may also consume exactly two shared design packages — `@ekklesia/design-tokens` and
|
||||||
|
`@ekklesia/ui` (ADR 0039) — so two modules' screens look like one product. That is a named
|
||||||
|
allowlist: every other shared role (`api-client`, `auth-client`, `surface-sdk`, `module-ui-host`)
|
||||||
|
stays rejected inside a module. Reference registered semantic tokens; never define an
|
||||||
|
`--ekklesia-*` custom property of your own.
|
||||||
- Module UI reaches its host only through `@ekklesia/module-ui-sdk` — never
|
- Module UI reaches its host only through `@ekklesia/module-ui-sdk` — never
|
||||||
`@ekklesia/module-ui-host`, which is the host-side loader — and asks the host for its own
|
`@ekklesia/module-ui-host`, which is the host-side loader — and asks the host for its own
|
||||||
API by relative path — never by constructing `/api/v1/modules/<id>/...`, which is Ekklesia's
|
API by relative path — never by constructing `/api/v1/modules/<id>/...`, which is Ekklesia's
|
||||||
@@ -51,10 +59,17 @@ pnpm infrastructure:config
|
|||||||
primary module UIs through `GET /api/v1/dashboard/navigation`, resolves a dedicated display
|
primary module UIs through `GET /api/v1/dashboard/navigation`, resolves a dedicated display
|
||||||
through `GET /api/v1/module-displays/<module-id>`, mounts both with
|
through `GET /api/v1/module-displays/<module-id>`, mounts both with
|
||||||
`@ekklesia/module-ui-host`, and statically imports no module (ADR 0026/0033).
|
`@ekklesia/module-ui-host`, and statically imports no module (ADR 0026/0033).
|
||||||
|
- A module may contribute a browser renderer to a capability another module provides
|
||||||
|
(`manifest.contributions`, ADR 0041). The consumer discovers and mounts it through
|
||||||
|
`ModuleUiHostContext.contributions`, never by building a publication URL, and owns the input
|
||||||
|
contract entirely: the platform carries opaque JSON and defines no payload schema, slot, or
|
||||||
|
ordering. A contributed renderer gets its own id and that input, and nothing else.
|
||||||
- Dedicated displays are module-owned renderers mounted by a separate full-viewport surface at
|
- Dedicated displays are module-owned renderers mounted by a separate full-viewport surface at
|
||||||
`/display/<module-id>`, never Dashboard UI with hidden chrome. Its page and descriptor require the
|
`/display/<module-id>`, never Dashboard UI with hidden chrome. Its page and descriptor require the
|
||||||
module-owned, non-public permission declared by `entrypoints.display`; artifact bytes remain
|
module-owned, non-public permission declared by `entrypoints.display`; artifact bytes remain
|
||||||
anonymous trusted code. Public/device identity, display inventory, and variants are deferred.
|
anonymous trusted code. Public/device identity, display inventory, and variants are deferred. A
|
||||||
|
display-scoped service worker caches the shell and declared artifacts only, network-first, and
|
||||||
|
never a descriptor, an API response, or anything else authorization-bearing (ADR 0043).
|
||||||
- Dashboard groups are presentation configuration, never module structure. Do not add a parent
|
- Dashboard groups are presentation configuration, never module structure. Do not add a parent
|
||||||
module, child module, owning module, or group-derived dependency: real module relationships are
|
module, child module, owning module, or group-derived dependency: real module relationships are
|
||||||
the dependency contract's job alone, and grouping must never influence discovery, dependency
|
the dependency contract's job alone, and grouping must never influence discovery, dependency
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -52,7 +94,8 @@ order never grants elevation — but is now **disabled by default, and cannot be
|
|||||||
production at all** until durable abuse controls exist (Phase 5D pass 1, below). See
|
production at all** until durable abuse controls exist (Phase 5D pass 1, below). See
|
||||||
[API](docs/development/api.md) and
|
[API](docs/development/api.md) and
|
||||||
[ADR 0011](docs/architecture/decisions/0011-identity-http-transport-and-public-enrollment.md).
|
[ADR 0011](docs/architecture/decisions/0011-identity-http-transport-and-public-enrollment.md).
|
||||||
Email verification, password recovery, and invitation/operator enrollment still do not exist.
|
Email verification and password recovery now exist (Phase 7C pass 2, below); invitation and operator
|
||||||
|
enrollment still do not.
|
||||||
Public registration remains disabled by default and prohibited in production — and a fresh
|
Public registration remains disabled by default and prohibited in production — and a fresh
|
||||||
production installation now has a supported way to get its first administrator anyway: Phase 7B
|
production installation now has a supported way to get its first administrator anyway: Phase 7B
|
||||||
pass 1B's `pnpm identity:create-initial-owner` (below). The Dashboard now has a minimal authenticated shell and login/logout flow,
|
pass 1B's `pnpm identity:create-initial-owner` (below). The Dashboard now has a minimal authenticated shell and login/logout flow,
|
||||||
@@ -217,9 +260,10 @@ undeployed state, and the launch checklist in a single place. **CI is accepted a
|
|||||||
trusted-code-only boundary**: the self-hosted Gitea runner executes only code controlled and
|
trusted-code-only boundary**: the self-hosted Gitea runner executes only code controlled and
|
||||||
reviewed by trusted repository collaborators, and unknown or untrusted pull requests must not
|
reviewed by trusted repository collaborators, and unknown or untrusted pull requests must not
|
||||||
execute on it automatically. What remains is operational and cannot be proven from this repository —
|
execute on it automatically. What remains is operational and cannot be proven from this repository —
|
||||||
upgrading Gitea past 1.24.2 (which *ignores* the workflow's `permissions`, `concurrency`, and
|
configuring and proving job-token authority server-side (the server now runs Gitea 1.27.2, which
|
||||||
`timeout-minutes` declarations), clamping job-token authority server-side, enforcing timeout and
|
honours `concurrency` and documents `permissions`, but neither token limits nor `timeout-minutes` are
|
||||||
teardown in a trusted provisioner/reaper, and proving disposable runner isolation. Those are
|
verified on it yet), enforcing timeout and teardown in a trusted provisioner/reaper, and proving
|
||||||
|
disposable runner isolation. Those are
|
||||||
prerequisites for **executing untrusted contributions**, alongside the deployment checklist that
|
prerequisites for **executing untrusted contributions**, alongside the deployment checklist that
|
||||||
gates a **public launch**; moving future public contribution workflows to a service with isolated
|
gates a **public launch**; moving future public contribution workflows to a service with isolated
|
||||||
hosted runners is an acceptable alternative. **Neither set of requirements blocks Phase 6** — Surface
|
hosted runners is an acceptable alternative. **Neither set of requirements blocks Phase 6** — Surface
|
||||||
@@ -288,7 +332,8 @@ artifacts cannot drift.
|
|||||||
externalizing it would make every host supply a compatible React — defeating standalone hosting. The
|
externalizing it would make every host supply a compatible React — defeating standalone hosting. The
|
||||||
accepted cost is duplicate React runtimes and large unminified artifacts (~570 KB for the template);
|
accepted cost is duplicate React runtimes and large unminified artifacts (~570 KB for the template);
|
||||||
sharing one runtime later is a build optimization that does not change the contract. Styling is React
|
sharing one runtime later is a build optimization that does not change the contract. Styling is React
|
||||||
inline style objects only: a stylesheet import **fails the build** rather than emitting a sidecar
|
inline style objects only at that checkpoint (Phase 8 pass 2 replaced that with compiled
|
||||||
|
stylesheets): a stylesheet import then **failed the build** rather than emitting a sidecar
|
||||||
`.css` that no manifest declares and no host loads. Building is an authoring step — Core never builds,
|
`.css` that no manifest declares and no host loads. Building is an authoring step — Core never builds,
|
||||||
and installing a module or starting the API never triggers one.
|
and installing a module or starting the API never triggers one.
|
||||||
|
|
||||||
@@ -487,7 +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 6 after ADR 0033 added the optional display declaration.
|
manifest is version 8 after ADR 0050 added the optional database ABI declaration. Version 8 is a
|
||||||
|
hard cutover; a version 7 source module or package must be updated and rebuilt.
|
||||||
|
|
||||||
**Ekklesia does not store secrets, and does not pretend to.** There is no encryption at rest here and
|
**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
|
||||||
@@ -719,12 +765,31 @@ ambiguous, and retrying it would send someone several copies of the same reset e
|
|||||||
single bounded attempt and deliberately not an exactly-once promise. There is **no
|
single bounded attempt and deliberately not an exactly-once promise. There is **no
|
||||||
`POST /api/v1/send-email`** and no route that touches the provider.
|
`POST /api/v1/send-email`** and no route that touches the provider.
|
||||||
|
|
||||||
Email remains **optional**: an installation with no SMTP configured is a complete deployment, since
|
Email remains **optional**: an installation with no SMTP configured is a complete deployment.
|
||||||
nothing sends mail yet. `pnpm doctor` reports the configuration and can probe connectivity **without
|
`pnpm doctor` reports the configuration and can probe connectivity **without sending anything**,
|
||||||
sending anything**, while stating plainly that a successful connection does not establish
|
while stating plainly that a successful connection does not establish deliverability. See
|
||||||
deliverability. **Email verification, password reset, and invitations are Pass 2** — Better Auth's
|
[Outbound email](docs/development/email.md).
|
||||||
`sendResetPassword` and `sendVerificationEmail` hooks are still unimplemented, and this transport is
|
|
||||||
the seam they will use. See [Outbound email](docs/development/email.md).
|
Phase 7C pass 2 completes the human identity lifecycle over that transport
|
||||||
|
([ADR 0038](docs/architecture/decisions/0038-email-verification-and-password-recovery.md)). **A
|
||||||
|
verified email address is a precondition for holding a session**: the identity system refuses an
|
||||||
|
unverified sign-in after checking the password and before creating one, so a correct password on an
|
||||||
|
unverified address carries exactly the authority of no password at all — which is why no
|
||||||
|
"unverified principal" exists and `/api/v1/principal` is unchanged. Better Auth owns every token;
|
||||||
|
Ekklesia adds no token table, no token hashing, and no expiry clock. Verification and reset links are
|
||||||
|
rebuilt from a validated Dashboard origin rather than from anything a request supplied, so no caller
|
||||||
|
can steer where a recipient lands, and following a verification link deliberately mints **no
|
||||||
|
session** — mail scanners dereference links. Password reset revokes that person's other sessions and
|
||||||
|
leaves managed devices untouched. Password recovery answers identically for an address with an
|
||||||
|
account, without one, already verified, or undeliverable — including when SMTP is broken, which is
|
||||||
|
why the delivery adapter never throws.
|
||||||
|
|
||||||
|
**The initial platform owner is the one exception**: `pnpm identity:create-initial-owner` creates
|
||||||
|
that account already verified and has no mail prerequisite at all, so a freshly provisioned server
|
||||||
|
can be signed in to before any relay exists. Normal production use of verification and recovery does
|
||||||
|
require configured outbound email. **Public signup remains disabled** — verification is not a licence
|
||||||
|
to self-register — and invitations and user administration are still not built. See
|
||||||
|
[Identity](docs/development/identity.md).
|
||||||
|
|
||||||
Ekklesia publishes the display bundle at
|
Ekklesia publishes the display bundle at
|
||||||
`GET /api/v1/module-ui/<module-id>/<version>/display`, serves a stable permission-gated page at
|
`GET /api/v1/module-ui/<module-id>/<version>/display`, serves a stable permission-gated page at
|
||||||
@@ -772,6 +837,74 @@ unproven launch-blockers rather than existing guarantees. Read
|
|||||||
[Security model and deployment trust boundaries](docs/development/security.md) before installing a
|
[Security model and deployment trust boundaries](docs/development/security.md) before installing a
|
||||||
module or exposing the API.
|
module or exposing the API.
|
||||||
|
|
||||||
|
**Phase 8 adds the five generic platform prerequisites the first production feature module needs.**
|
||||||
|
None of them is a feature module, and none of them names one.
|
||||||
|
|
||||||
|
Phase 8 pass 1 gives every Ekklesia screen one design vocabulary
|
||||||
|
([ADR 0039](docs/architecture/decisions/0039-shared-design-tokens-and-elements.md)).
|
||||||
|
`@ekklesia/design-tokens` is a validated, enumerable registry of **semantic** tokens whose category
|
||||||
|
and `--ekklesia-*` identifier are derived from the token's name, and whose values must be bounded
|
||||||
|
literals — so a registry that constructs can be emitted as CSS without escaping, and a future theme
|
||||||
|
editor has something labelled, described, and categorised to enumerate. `@ekklesia/ui` is a small set
|
||||||
|
of foundational elements (button, inputs, field, panel, dialog, tabs, table and list primitives,
|
||||||
|
badge, toast, and empty/error/loading states), every value a token reference, with real
|
||||||
|
`:focus-visible` treatment and status that never signals by colour alone. `pnpm boundaries` opens
|
||||||
|
exactly two shared roles to modules by name and pins both design packages beneath every other role.
|
||||||
|
The Dashboard and display install the defaults at their document root; custom properties inherit into
|
||||||
|
module artifacts, so a module writes `var(--ekklesia-…)` with nothing to import at run time. **No
|
||||||
|
theme editor, brand builder, second theme, or component catalogue exists.**
|
||||||
|
|
||||||
|
Phase 8 pass 2 gives module UI real stylesheets and gives every browser artifact its own root
|
||||||
|
([ADR 0040](docs/architecture/decisions/0040-module-stylesheets-and-artifact-isolation.md)). A module
|
||||||
|
imports plain `.css` and the canonical builder compiles it **into** the one declared artifact as
|
||||||
|
text — no sidecar can exist, which is the failure ADR 0023 refused stylesheets to avoid. The build
|
||||||
|
rejects a module stylesheet that *declares* an `--ekklesia-*` variable or leaves an unresolved
|
||||||
|
`@import`. The generic loader then mounts every artifact — primary, display, and contributed — inside
|
||||||
|
its own open Shadow DOM root with its stylesheet installed there, so one module's rules cannot reach
|
||||||
|
the Dashboard or another module and theirs cannot reach in. That root is a correctness boundary for
|
||||||
|
styles and **is not a sandbox**: module code remains trusted, same-realm code. The browser mount ABI
|
||||||
|
is versioned with no dual acceptance; migrating is one `pnpm build`.
|
||||||
|
|
||||||
|
Phase 8 pass 3 lets one module contribute a browser renderer to a capability another module provides
|
||||||
|
([ADR 0041](docs/architecture/decisions/0041-module-browser-contributions.md)) — the browser half of
|
||||||
|
the capability system Core has had since Phase 1. `manifestVersion: 8` declares a namespaced id, its
|
||||||
|
own version, a target capability, a compatible range, and one artifact, and nothing else: there is no
|
||||||
|
payload schema, slot, ordering, or rendering hint anywhere, so Core cannot learn what any of it
|
||||||
|
means. Publication requires that another eligible module provides the capability inside the declared
|
||||||
|
range; a contribution that fails is absent rather than broken. A consumer discovers through
|
||||||
|
`ModuleUiHostContext.contributions` and mounts by id, never by building a publication URL, and can
|
||||||
|
only see contributions aimed at a capability it itself provides. A contributed renderer receives its
|
||||||
|
own id and the consumer's opaque JSON input — no API client, realtime, principal, configuration,
|
||||||
|
database, storage, or credential — and loading one grants its module nothing.
|
||||||
|
|
||||||
|
Phase 8 pass 4 closes the object-storage write path
|
||||||
|
([ADR 0042](docs/architecture/decisions/0042-bounded-streaming-request-bodies.md)). A module route
|
||||||
|
declares `bodyMode`: `buffered` (the default, and what every route already meant) keeps the 1 MiB
|
||||||
|
ceiling and materializes the body before the handler runs; `streamed` raises it to 32 MiB and counts
|
||||||
|
bytes as the handler consumes them, holding nothing. 32 MiB is also the platform's absolute ceiling
|
||||||
|
in every mode — sized for one 25 MiB image plus bounded overhead, one file per request, with no
|
||||||
|
unbounded option anywhere. The platform's own limiter replaces the buffering dependency: a declared
|
||||||
|
oversize is refused before authorization, the buffered path now counts what arrived rather than
|
||||||
|
trusting a header, and a streamed overflow errors the stream, cancels the source, and replaces the
|
||||||
|
response with the same sanitized `413` even if the handler swallowed it. Signed write URLs,
|
||||||
|
resumable uploads, quotas, and anything above the ceiling remain deliberately absent.
|
||||||
|
|
||||||
|
Phase 8 pass 5 gives the display host-owned offline resilience for **the shell and the declared
|
||||||
|
code** ([ADR 0043](docs/architecture/decisions/0043-display-offline-code-fallback.md)). A service
|
||||||
|
worker scoped to `/display/` caches the page, the host script, the module's display artifact, and
|
||||||
|
every contributed renderer that display mounted, network-first so a screen can never be trapped on
|
||||||
|
obsolete code. It caches **nothing else, ever**: not the descriptor, `/api/v1/principal`, any API
|
||||||
|
response, the realtime stream, a pairing exchange, or any cross-origin request. The consequence is
|
||||||
|
stated rather than papered over — a display cannot render module content while offline, because
|
||||||
|
mounting needs an authorization-bearing descriptor that is never cached, so a revoked device stays
|
||||||
|
revoked and cached code can never manufacture authorization. What it buys is that a kiosk rebooting
|
||||||
|
during an outage boots into Ekklesia's own offline state and recovers by itself, and that
|
||||||
|
reconnecting mounts with nothing left to download. **Module-owned offline content and media remain
|
||||||
|
the module's responsibility and do not exist yet.**
|
||||||
|
|
||||||
|
Ekklesia Lite, a theme editor, a module store or installer, a standalone host, and every feature
|
||||||
|
module — Signage included — still do not exist, and nothing in Phase 8 claims otherwise.
|
||||||
|
|
||||||
## Quick start
|
## Quick start
|
||||||
|
|
||||||
Prerequisites: Node.js 24 LTS, pnpm 11.12, and Docker with Compose.
|
Prerequisites: Node.js 24 LTS, pnpm 11.12, and Docker with Compose.
|
||||||
@@ -791,8 +924,18 @@ pnpm verify
|
|||||||
|
|
||||||
`pnpm doctor` builds the workspace first, then reports the supported Node.js runtime, installed
|
`pnpm doctor` builds the workspace first, then reports the supported Node.js runtime, installed
|
||||||
modules and dependency order, schema and migration inventories, the effective PostgreSQL transport
|
modules and dependency order, schema and migration inventories, the effective PostgreSQL transport
|
||||||
(TLS) policy, optional read-only PostgreSQL planning, and the current object-storage integration
|
(TLS) policy, optional read-only PostgreSQL planning, and the current object-storage and
|
||||||
state. `.env.example` sets `NODE_ENV=development` deliberately: anything else, including an unset
|
outbound-email integration state.
|
||||||
|
|
||||||
|
Doctor reads its host infrastructure configuration through the same
|
||||||
|
`loadHostConfiguration(process.env)` the API executable uses, so both derive identical settings and
|
||||||
|
identical defaults from one deployment environment: configured object storage is reported as
|
||||||
|
configured, a module secret the deployment has set is reported as satisfied, and a value neither
|
||||||
|
process could accept is reported as a configuration failure rather than silently interpreted one way
|
||||||
|
by the diagnostic and another way at startup. An optional service that is genuinely absent — no
|
||||||
|
`S3_ENDPOINT`, no `EKKLESIA_SMTP_HOST` — is still a complete deployment and reported as skipped.
|
||||||
|
|
||||||
|
`.env.example` sets `NODE_ENV=development` deliberately: anything else, including an unset
|
||||||
value, is validated as production, and production requires certificate-verified PostgreSQL TLS and
|
value, is validated as production, and production requires certificate-verified PostgreSQL TLS and
|
||||||
rejects loopback/`http` identity origins — so the plaintext loopback database this repository's own
|
rejects loopback/`http` identity origins — so the plaintext loopback database this repository's own
|
||||||
development infrastructure provides is only usable with it set.
|
development infrastructure provides is only usable with it set.
|
||||||
@@ -817,7 +960,7 @@ Modules
|
|||||||
✓ Dependency graph valid
|
✓ Dependency graph valid
|
||||||
Dependency order is empty.
|
Dependency order is empty.
|
||||||
✓ Permission registry composed
|
✓ Permission registry composed
|
||||||
5 permissions registered
|
6 permissions registered
|
||||||
○ Module lifecycle skipped
|
○ Module lifecycle skipped
|
||||||
DATABASE_URL is not configured.
|
DATABASE_URL is not configured.
|
||||||
○ Module configuration skipped
|
○ Module configuration skipped
|
||||||
@@ -827,13 +970,14 @@ Data definitions
|
|||||||
✓ Schemas validated
|
✓ Schemas validated
|
||||||
0 modules · 0 tables
|
0 modules · 0 tables
|
||||||
✓ Migrations validated
|
✓ Migrations validated
|
||||||
6 migrations · checksums valid
|
7 migrations · checksums valid
|
||||||
core:0000_identity_core_tables · f8f2086ef06f
|
core:0000_identity_core_tables · f8f2086ef06f
|
||||||
core:0001_authorization_core_tables · 830e1fbc26d0
|
core:0001_authorization_core_tables · 830e1fbc26d0
|
||||||
core:0002_authorization_integrity · 634294731180
|
core:0002_authorization_integrity · 634294731180
|
||||||
core:0003_dashboard_navigation_tables · 2cde27807aa4
|
core:0003_dashboard_navigation_tables · 2cde27807aa4
|
||||||
core:0004_module_lifecycle_state · c8b5d8b3d48b
|
core:0004_module_lifecycle_state · c8b5d8b3d48b
|
||||||
core:0005_module_configuration · c8d57f24f84b
|
core:0005_module_configuration · c8d57f24f84b
|
||||||
|
core:0006_device_identity · 1db97b7e3ac5
|
||||||
|
|
||||||
PostgreSQL
|
PostgreSQL
|
||||||
✓ Database TLS verified
|
✓ Database TLS verified
|
||||||
@@ -856,6 +1000,10 @@ Object storage
|
|||||||
✓ No module requires object storage
|
✓ No module requires object storage
|
||||||
0 modules declare a logical storage bucket.
|
0 modules declare a logical storage bucket.
|
||||||
|
|
||||||
|
Outbound email
|
||||||
|
○ Outbound email not configured
|
||||||
|
No EKKLESIA_SMTP_HOST is set. The platform runs and the bootstrap owner can sign in, but email verification and account recovery cannot deliver.
|
||||||
|
|
||||||
Result: READY WITH WARNINGS
|
Result: READY WITH WARNINGS
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -950,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.0",
|
"version": "0.2.5",
|
||||||
|
"license": "AGPL-3.0-or-later",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"files": [
|
"files": [
|
||||||
@@ -28,11 +29,15 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@biomejs/biome": "2.5.3",
|
"@biomejs/biome": "2.5.3",
|
||||||
"@ekklesia/module-ui-host": "workspace:*",
|
"@ekklesia/module-ui-host": "workspace:*",
|
||||||
|
"@ekklesia/module-ui-sdk": "workspace:*",
|
||||||
"@ekklesia/typescript-config": "workspace:*",
|
"@ekklesia/typescript-config": "workspace:*",
|
||||||
"@types/node": "24.13.3",
|
"@types/node": "24.13.3",
|
||||||
"@types/pg": "8.20.0",
|
"@types/pg": "8.20.0",
|
||||||
|
"@types/smtp-server": "3.5.13",
|
||||||
"better-auth": "1.6.23",
|
"better-auth": "1.6.23",
|
||||||
|
"happy-dom": "20.0.11",
|
||||||
"pg": "8.22.0",
|
"pg": "8.22.0",
|
||||||
|
"smtp-server": "3.19.3",
|
||||||
"typescript": "7.0.2",
|
"typescript": "7.0.2",
|
||||||
"vitest": "4.1.10"
|
"vitest": "4.1.10"
|
||||||
},
|
},
|
||||||
|
|||||||
+40
-7
@@ -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";
|
||||||
@@ -27,10 +28,13 @@ import type { Context, MiddlewareHandler } from "hono";
|
|||||||
import { Hono } from "hono";
|
import { Hono } from "hono";
|
||||||
import { cors } from "hono/cors";
|
import { cors } from "hono/cors";
|
||||||
import { AUTH_REQUEST_MAX_BODY_BYTES, platformBodyLimit } from "./body-limits.js";
|
import { AUTH_REQUEST_MAX_BODY_BYTES, platformBodyLimit } from "./body-limits.js";
|
||||||
|
import type { DashboardHostAssets } from "./dashboard-host-assets.js";
|
||||||
|
import { createDashboardHostRoutes } from "./dashboard-host-routes.js";
|
||||||
import { createDashboardRoutes } from "./dashboard-routes.js";
|
import { createDashboardRoutes } from "./dashboard-routes.js";
|
||||||
import { createDeviceRoutes } from "./device-routes.js";
|
import { createDeviceRoutes, renewDeviceSessionCookie } from "./device-routes.js";
|
||||||
import type { DisplayHostAssets } from "./display-host-assets.js";
|
import type { DisplayHostAssets } from "./display-host-assets.js";
|
||||||
import { createGrantsRoutes } from "./grants-routes.js";
|
import { createGrantsRoutes } from "./grants-routes.js";
|
||||||
|
import { type ApiHealthOptions, createHealthRoutes } from "./health-routes.js";
|
||||||
import { createModuleConfigurationRoutes } from "./module-configuration-routes.js";
|
import { createModuleConfigurationRoutes } from "./module-configuration-routes.js";
|
||||||
import { mountModuleDisplayRoutes } from "./module-display-routes.js";
|
import { mountModuleDisplayRoutes } from "./module-display-routes.js";
|
||||||
import { canonicalizeVaryHeader, mountModuleHttpRoutes } from "./module-http-routes.js";
|
import { canonicalizeVaryHeader, mountModuleHttpRoutes } from "./module-http-routes.js";
|
||||||
@@ -59,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`
|
||||||
@@ -137,6 +140,20 @@ export interface CreateApiAppOptions {
|
|||||||
* presentation only — nothing reachable through this store influences discovery, dependency
|
* presentation only — nothing reachable through this store influences discovery, dependency
|
||||||
* ordering, module startup, or any authorization decision. */
|
* ordering, module startup, or any authorization decision. */
|
||||||
readonly dashboard: DashboardStore;
|
readonly dashboard: DashboardStore;
|
||||||
|
/**
|
||||||
|
* The built Dashboard's two files, read once by production startup (ADR 0044). Required rather
|
||||||
|
* than optional, unlike `displayHostAssets`: every installation has a Dashboard, and a missing
|
||||||
|
* shell build fails startup instead of leaving this origin answering `/` with a 404. See
|
||||||
|
* `./dashboard-host-routes.js`.
|
||||||
|
*/
|
||||||
|
readonly dashboardHostAssets: DashboardHostAssets;
|
||||||
|
/**
|
||||||
|
* The readiness inputs behind the unauthenticated health routes (ADR 0044). Required rather than
|
||||||
|
* defaulted, like every other dependency here: a composition that cannot reach a database says so
|
||||||
|
* with a check that rejects, never falls through to an implicitly healthy one. `checkDatabase` is
|
||||||
|
* the bare query; the routes themselves bound and serialize it. See `./health-routes.js`.
|
||||||
|
*/
|
||||||
|
readonly health: ApiHealthOptions;
|
||||||
readonly runtime: EkklesiaIdentityRuntime;
|
readonly runtime: EkklesiaIdentityRuntime;
|
||||||
/** Exact allowed browser origins. Never a wildcard; combined with `credentials: true`, only an
|
/** Exact allowed browser origins. Never a wildcard; combined with `credentials: true`, only an
|
||||||
* exact, reflected match is ever returned in `Access-Control-Allow-Origin`. Also the trusted set
|
* exact, reflected match is ever returned in `Access-Control-Allow-Origin`. Also the trusted set
|
||||||
@@ -341,6 +358,16 @@ export function createApiApp(options: CreateApiAppOptions): Hono {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Unauthenticated liveness/readiness under a platform-owned sibling prefix: like every route below,
|
||||||
|
// it is mounted before module routes and never inside `/api/v1/modules/`, which installed modules
|
||||||
|
// own entirely. See `./health-routes.js`.
|
||||||
|
app.route("/", createHealthRoutes(options.health));
|
||||||
|
|
||||||
|
// The Dashboard page and its one script, at exact literal paths outside `/api/` (ADR 0044). They
|
||||||
|
// grant no authority and resolve no principal; the page asks `/api/v1/principal` itself. See
|
||||||
|
// `./dashboard-host-routes.js`.
|
||||||
|
app.route("/", createDashboardHostRoutes(options.dashboardHostAssets));
|
||||||
|
|
||||||
app.route(
|
app.route(
|
||||||
"/",
|
"/",
|
||||||
createGrantsRoutes({
|
createGrantsRoutes({
|
||||||
@@ -380,6 +407,12 @@ export function createApiApp(options: CreateApiAppOptions): Hono {
|
|||||||
registry: options.authorization.registry,
|
registry: options.authorization.registry,
|
||||||
runtime,
|
runtime,
|
||||||
...(options.displayHostAssets ? { assets: options.displayHostAssets } : {}),
|
...(options.displayHostAssets ? { assets: options.displayHostAssets } : {}),
|
||||||
|
...(options.devices
|
||||||
|
? {
|
||||||
|
renewDeviceCredential: (c: Context) =>
|
||||||
|
renewDeviceSessionCookie(c, options.devices?.secureCookies ?? true),
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (options.devices) {
|
if (options.devices) {
|
||||||
|
|||||||
+187
-17
@@ -1,6 +1,10 @@
|
|||||||
import { MODULE_ROUTE_MAX_BODY_BYTES_CEILING } from "@ekklesia/contracts";
|
import {
|
||||||
import type { Context } from "hono";
|
MODULE_ROUTE_ABSOLUTE_MAX_BODY_BYTES,
|
||||||
import { bodyLimit } from "hono/body-limit";
|
MODULE_ROUTE_MAX_BODY_BYTES_CEILING,
|
||||||
|
MODULE_ROUTE_STREAMED_MAX_BODY_BYTES_CEILING,
|
||||||
|
type ModuleRouteBodyMode,
|
||||||
|
} from "@ekklesia/contracts";
|
||||||
|
import type { Context, MiddlewareHandler } from "hono";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The platform's one request-body policy, centralized here rather than scattered as magic numbers
|
* The platform's one request-body policy, centralized here rather than scattered as magic numbers
|
||||||
@@ -19,15 +23,20 @@ import { bodyLimit } from "hono/body-limit";
|
|||||||
* field individually bounded well under 1 KiB combined; 16 KiB leaves headroom without inviting
|
* field individually bounded well under 1 KiB combined; 16 KiB leaves headroom without inviting
|
||||||
* abuse.
|
* abuse.
|
||||||
* - `MODULE_ROUTE_MAX_BODY_BYTES_CEILING` (1 MiB, re-exported from `@ekklesia/contracts` — the same
|
* - `MODULE_ROUTE_MAX_BODY_BYTES_CEILING` (1 MiB, re-exported from `@ekklesia/contracts` — the same
|
||||||
* constant a module's own route declaration is validated against at registration) is the hard
|
* constant a module's own **buffered** route declaration is validated against at registration) is
|
||||||
* ceiling no module route may exceed and doubles as the platform's overall request-body ceiling:
|
* the ceiling for every body the platform materializes before a handler runs. It has not moved.
|
||||||
* nothing in this platform ever declares, or enforces, a larger single request body than this.
|
* - `MODULE_ROUTE_STREAMED_MAX_BODY_BYTES_CEILING` (32 MiB) is what a route may declare when it
|
||||||
* Large media transfer is explicitly out of scope for ordinary module HTTP bodies — it belongs to
|
* explicitly opts into `bodyMode: "streamed"` (ADR 0042), and doubles as
|
||||||
* a future dedicated storage design, not to widening this ceiling.
|
* `MODULE_ROUTE_ABSOLUTE_MAX_BODY_BYTES`: no route, in any mode, may declare or accept a larger
|
||||||
|
* single request body, and no unbounded option exists anywhere in the contract.
|
||||||
*/
|
*/
|
||||||
export const AUTH_REQUEST_MAX_BODY_BYTES = 8 * 1024;
|
export const AUTH_REQUEST_MAX_BODY_BYTES = 8 * 1024;
|
||||||
export const CORE_MUTATION_MAX_BODY_BYTES = 16 * 1024;
|
export const CORE_MUTATION_MAX_BODY_BYTES = 16 * 1024;
|
||||||
export { MODULE_ROUTE_MAX_BODY_BYTES_CEILING };
|
export {
|
||||||
|
MODULE_ROUTE_ABSOLUTE_MAX_BODY_BYTES,
|
||||||
|
MODULE_ROUTE_MAX_BODY_BYTES_CEILING,
|
||||||
|
MODULE_ROUTE_STREAMED_MAX_BODY_BYTES_CEILING,
|
||||||
|
};
|
||||||
|
|
||||||
/** The one stable, sanitized public contract for every request-body-size rejection across the
|
/** The one stable, sanitized public contract for every request-body-size rejection across the
|
||||||
* platform — auth, Core, and module routes alike. Never leaks the configured limit, the offending
|
* platform — auth, Core, and module routes alike. Never leaks the configured limit, the offending
|
||||||
@@ -37,12 +46,173 @@ export function requestBodyTooLargeResponse(c: Context): Response {
|
|||||||
return c.json({ code: "REQUEST_BODY_TOO_LARGE" }, 413);
|
return c.json({ code: "REQUEST_BODY_TOO_LARGE" }, 413);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A single shared `hono/body-limit` factory so every call site uses the identical sanitized 413
|
/**
|
||||||
* contract rather than redefining `onError` per route. Covers both a declared oversized
|
* A declared `Content-Length`, or `undefined` when there is not exactly one well-formed one.
|
||||||
* `Content-Length` and an oversized chunked/streamed body with no declared length — see
|
*
|
||||||
* `hono/body-limit`'s pinned implementation, which checks `Content-Length` synchronously before
|
* A malformed or contradictory value is treated as *absent* rather than as a rejection, deliberately:
|
||||||
* ever reading the stream, and otherwise reads and counts the stream incrementally, rejecting as
|
* the byte counting below bounds the request either way, so refusing here could only ever turn a
|
||||||
* soon as the running total exceeds `maxSize` rather than buffering the whole body first. */
|
* legitimate request away without making an illegitimate one any less bounded. Node's own HTTP
|
||||||
export function platformBodyLimit(maxSize: number) {
|
* parser rejects genuinely malformed framing before this code sees the request at all.
|
||||||
return bodyLimit({ maxSize, onError: requestBodyTooLargeResponse });
|
*/
|
||||||
|
function declaredContentLength(request: Request): number | undefined {
|
||||||
|
const header = request.headers.get("content-length");
|
||||||
|
if (header === null || !/^\d{1,15}$/u.test(header.trim())) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return Number.parseInt(header.trim(), 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Releases a body nothing consumed, so an abandoned or oversized upload does not hold a socket
|
||||||
|
* open waiting to be read. Safe on a stream that is locked, already drained, or already errored. */
|
||||||
|
function releaseUnreadBody(request: Request): void {
|
||||||
|
const body = request.body;
|
||||||
|
if (!body || body.locked) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
void body.cancel().catch(() => undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads at most `maxSize` bytes, stopping and cancelling the source the moment the running total
|
||||||
|
* exceeds it. Never allocates beyond what it has already accepted, so an over-limit body costs one
|
||||||
|
* chunk past the limit rather than the whole upload.
|
||||||
|
*/
|
||||||
|
async function readBounded(
|
||||||
|
body: ReadableStream<Uint8Array>,
|
||||||
|
maxSize: number,
|
||||||
|
): Promise<readonly Uint8Array[] | "over-limit"> {
|
||||||
|
const reader = body.getReader();
|
||||||
|
const chunks: Uint8Array[] = [];
|
||||||
|
let size = 0;
|
||||||
|
try {
|
||||||
|
for (;;) {
|
||||||
|
const { done, value } = await reader.read();
|
||||||
|
if (done) {
|
||||||
|
return chunks;
|
||||||
|
}
|
||||||
|
size += value.byteLength;
|
||||||
|
if (size > maxSize) {
|
||||||
|
await reader.cancel().catch(() => undefined);
|
||||||
|
return "over-limit";
|
||||||
|
}
|
||||||
|
chunks.push(value);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
reader.releaseLock();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function streamOf(chunks: readonly Uint8Array[]): ReadableStream<Uint8Array> {
|
||||||
|
return new ReadableStream<Uint8Array>({
|
||||||
|
start(controller) {
|
||||||
|
for (const chunk of chunks) {
|
||||||
|
controller.enqueue(chunk);
|
||||||
|
}
|
||||||
|
controller.close();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The platform's one request-body limiter, for every route in the application.
|
||||||
|
*
|
||||||
|
* ## Both modes reject a declared oversize before anything else happens
|
||||||
|
*
|
||||||
|
* A `Content-Length` above the limit is refused synchronously, before authorization, before the
|
||||||
|
* handler, and before a single body byte is read. That is the cheap rejection an ordinary
|
||||||
|
* too-large upload takes.
|
||||||
|
*
|
||||||
|
* ## `buffered` — the body is materialized, bounded, before the handler runs
|
||||||
|
*
|
||||||
|
* The exact behaviour every route had before streaming existed, now with the counting done here
|
||||||
|
* rather than by a dependency. Bytes are counted as they arrive and the request is rejected the
|
||||||
|
* moment the running total passes the limit, so a lying or absent `Content-Length` is bounded by
|
||||||
|
* what actually arrived rather than by what was claimed. The handler then receives a request whose
|
||||||
|
* body is fully available and provably within its declared allowance — which is what makes
|
||||||
|
* `await request.json()` safe in a module handler.
|
||||||
|
*
|
||||||
|
* The platform holds at most the route's own declared allowance in memory, and a buffered route may
|
||||||
|
* declare at most 1 MiB. That bound is the whole reason the buffered ceiling has not moved.
|
||||||
|
*
|
||||||
|
* ## `streamed` — the body is counted as the handler consumes it, and never materialized
|
||||||
|
*
|
||||||
|
* Nothing is buffered. The body is wrapped in a counting transform and handed to the handler, which
|
||||||
|
* reads it as a stream; the moment the running total passes the limit the stream errors, the source
|
||||||
|
* is cancelled, and the response is replaced with the same sanitized `413` — even if the handler
|
||||||
|
* caught the error and tried to answer anyway. That last part matters: a limit a handler can swallow
|
||||||
|
* is not a limit.
|
||||||
|
*
|
||||||
|
* The consequence a module author must know is that in this mode a `413` can arrive **after** the
|
||||||
|
* handler has started, so a handler that persists as it reads has to be prepared for a partially
|
||||||
|
* consumed upload. Writing to a temporary key and committing only after the read completes is the
|
||||||
|
* pattern; the platform cannot do it for you, because only the module knows what a partial write
|
||||||
|
* means to its own data.
|
||||||
|
*
|
||||||
|
* ## Ordering
|
||||||
|
*
|
||||||
|
* This runs before permission evaluation for both modes, so a request that is *declared* too large
|
||||||
|
* never reaches authorization work. In `streamed` mode no body byte is read until the handler reads
|
||||||
|
* it, and the handler only runs after authorization succeeds — so an unauthorized caller can never
|
||||||
|
* push a large upload through the platform at all.
|
||||||
|
*
|
||||||
|
* ## Timeouts
|
||||||
|
*
|
||||||
|
* Body reading is bounded in time by the server's own `headersTimeout`/`requestTimeout`
|
||||||
|
* (`apps/api/src/server.ts`), not by anything here. A slow upload is a slow *request*, and the
|
||||||
|
* platform already has one answer for that rather than a second per-route one.
|
||||||
|
*/
|
||||||
|
export function platformBodyLimit(
|
||||||
|
maxSize: number,
|
||||||
|
mode: ModuleRouteBodyMode = "buffered",
|
||||||
|
): MiddlewareHandler {
|
||||||
|
return async function enforceBodyLimit(c, next) {
|
||||||
|
const request = c.req.raw;
|
||||||
|
const declared = declaredContentLength(request);
|
||||||
|
if (declared !== undefined && declared > maxSize) {
|
||||||
|
return requestBodyTooLargeResponse(c);
|
||||||
|
}
|
||||||
|
if (!request.body) {
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mode === "buffered") {
|
||||||
|
const chunks = await readBounded(request.body, maxSize);
|
||||||
|
if (chunks === "over-limit") {
|
||||||
|
return requestBodyTooLargeResponse(c);
|
||||||
|
}
|
||||||
|
c.req.raw = new Request(request, {
|
||||||
|
body: streamOf(chunks),
|
||||||
|
// Required by undici whenever a `Request` is constructed from a stream.
|
||||||
|
duplex: "half",
|
||||||
|
} as RequestInit);
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
|
||||||
|
let overLimit = false;
|
||||||
|
let total = 0;
|
||||||
|
const counted = request.body.pipeThrough(
|
||||||
|
new TransformStream<Uint8Array, Uint8Array>({
|
||||||
|
transform(chunk, controller) {
|
||||||
|
total += chunk.byteLength;
|
||||||
|
if (total > maxSize) {
|
||||||
|
overLimit = true;
|
||||||
|
// Erroring the transform propagates back to the source and cancels it, so the socket is
|
||||||
|
// released rather than left draining an upload nobody will read.
|
||||||
|
controller.error(new Error("REQUEST_BODY_TOO_LARGE"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
controller.enqueue(chunk);
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
c.req.raw = new Request(request, { body: counted, duplex: "half" } as RequestInit);
|
||||||
|
|
||||||
|
await next();
|
||||||
|
|
||||||
|
if (overLimit) {
|
||||||
|
c.res = requestBodyTooLargeResponse(c);
|
||||||
|
}
|
||||||
|
releaseUnreadBody(c.req.raw);
|
||||||
|
return;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
/**
|
||||||
|
* RFC 9110 weak comparison for a GET/HEAD `If-None-Match` precondition, shared by the host-owned
|
||||||
|
* asset routes that answer `304` against a strong validator they computed from their own bytes.
|
||||||
|
*/
|
||||||
|
export function etagMatches(headerValue: string | undefined, etag: string): boolean {
|
||||||
|
if (headerValue === undefined) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (headerValue.trim() === "*") {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return headerValue.split(",").some((candidate) => {
|
||||||
|
const value = candidate.trim();
|
||||||
|
return (value.startsWith("W/") ? value.slice(2) : value) === etag;
|
||||||
|
});
|
||||||
|
}
|
||||||
+21
-3
@@ -79,8 +79,10 @@ export interface ApiRuntimeConfig {
|
|||||||
* The validated host outbound-email provider, or `undefined` when this installation has none
|
* The validated host outbound-email provider, or `undefined` when this installation has none
|
||||||
* (ADR 0037).
|
* (ADR 0037).
|
||||||
*
|
*
|
||||||
* `undefined` is a complete, valid deployment: nothing in the platform sends email yet, and a
|
* `undefined` is a valid deployment: the platform starts, every existing feature works, and the
|
||||||
* future feature that needs to discovers a structured `MAIL_NOT_CONFIGURED` rather than crashing.
|
* initial platform owner — created already verified by an operator command — can still sign in
|
||||||
|
* (ADR 0038). What it cannot do is verify a new account or deliver a password reset; each attempt
|
||||||
|
* reports a sanitized `MAIL_NOT_CONFIGURED` out of band rather than changing any HTTP response.
|
||||||
* These values reach Core's provider and nowhere else — no module, browser bundle, diagnostic, or
|
* These values reach Core's provider and nowhere else — no module, browser bundle, diagnostic, or
|
||||||
* API response ever sees the host or the password.
|
* API response ever sees the host or the password.
|
||||||
*/
|
*/
|
||||||
@@ -90,7 +92,7 @@ export interface ApiRuntimeConfig {
|
|||||||
export interface ApiRuntimeConfigInput {
|
export interface ApiRuntimeConfigInput {
|
||||||
/** Optional. Defaults to direct mode (trusts only the actual immediate socket peer) — no proxy
|
/** Optional. Defaults to direct mode (trusts only the actual immediate socket peer) — no proxy
|
||||||
* trust unless explicitly configured. See `./client-address.js`. */
|
* trust unless explicitly configured. See `./client-address.js`. */
|
||||||
readonly clientAddress?: ClientAddressConfigInput;
|
readonly clientAddress?: ClientAddressConfigInput | undefined;
|
||||||
readonly databaseUrl?: unknown;
|
readonly databaseUrl?: unknown;
|
||||||
/** Optional. Defaults to certificate-verified TLS in production and plaintext in development;
|
/** Optional. Defaults to certificate-verified TLS in production and plaintext in development;
|
||||||
* production can never select plaintext. See `@ekklesia/core/database`'s transport policy. */
|
* production can never select plaintext. See `@ekklesia/core/database`'s transport policy. */
|
||||||
@@ -198,3 +200,19 @@ export function createApiRuntimeConfig(
|
|||||||
storage,
|
storage,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The exact browser origins the API trusts: CORS on `/api/auth/*` and `/api/v1/*`, and every
|
||||||
|
* mutation route's origin check (ADR 0045).
|
||||||
|
*
|
||||||
|
* The configured trusted origins plus the identity base URL's own origin, which is where the API
|
||||||
|
* lives. Better Auth already trusts its base URL implicitly; this makes the API's own checks agree,
|
||||||
|
* so a page the API itself serves (the display host, and the Dashboard in the same-origin topology)
|
||||||
|
* passes without an operator listing the API's origin. Order is preserved and duplicates removed;
|
||||||
|
* every entry is already a canonical origin.
|
||||||
|
*/
|
||||||
|
export function apiTrustedOrigins(
|
||||||
|
identity: Pick<IdentityConfig, "baseUrl" | "trustedOrigins">,
|
||||||
|
): readonly string[] {
|
||||||
|
return Object.freeze([...new Set([...identity.trustedOrigins, identity.baseUrl])]);
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,108 @@
|
|||||||
|
import { createHash } from "node:crypto";
|
||||||
|
import { readFile } from "node:fs/promises";
|
||||||
|
import { dashboardHostAssetUrls } from "@ekklesia/shell/dashboard-assets";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The two immutable, platform-owned files that make up the built Ekklesia Dashboard (ADR 0044).
|
||||||
|
*
|
||||||
|
* Exactly the model `./display-host-assets.js` uses: read once, before the HTTP listener is bound,
|
||||||
|
* from the exact URLs the surface package exports. Request input is never involved in this read, the
|
||||||
|
* package is never exposed as a static directory, and the source map beside the script is never
|
||||||
|
* read. Text rather than a mutable `Buffer` keeps the frozen snapshot a genuinely immutable value
|
||||||
|
* every response can reuse.
|
||||||
|
*/
|
||||||
|
export interface DashboardHostAssets {
|
||||||
|
readonly html: string;
|
||||||
|
readonly htmlEtag: string;
|
||||||
|
readonly script: string;
|
||||||
|
readonly scriptEtag: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A startup failure with a fixed, non-secret shape. The originating filesystem exception, package
|
||||||
|
* location, and machine path are intentionally not retained or surfaced. */
|
||||||
|
export class DashboardHostAssetError extends Error {
|
||||||
|
readonly code = "DASHBOARD_HOST_ASSET_UNAVAILABLE" as const;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super("The Dashboard host browser assets are unavailable.");
|
||||||
|
this.name = "DashboardHostAssetError";
|
||||||
|
this.stack = `${this.name}: ${this.message}`;
|
||||||
|
Object.freeze(this);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function strongEtag(text: string): string {
|
||||||
|
return `"${createHash("sha256").update(text).digest("base64url")}"`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Captures one immutable, internally consistent Dashboard asset snapshot.
|
||||||
|
*
|
||||||
|
* Each strong validator is derived from the exact text in the same snapshot, so bytes from one build
|
||||||
|
* can never be paired with an ETag from another. Empty output is a failed shell build rather than a
|
||||||
|
* valid page that can never render anything.
|
||||||
|
*/
|
||||||
|
export function createDashboardHostAssets(html: string, script: string): DashboardHostAssets {
|
||||||
|
if (html.trim().length === 0 || script.trim().length === 0) {
|
||||||
|
throw new DashboardHostAssetError();
|
||||||
|
}
|
||||||
|
return Object.freeze({
|
||||||
|
html,
|
||||||
|
htmlEtag: strongEtag(html),
|
||||||
|
script,
|
||||||
|
scriptEtag: strongEtag(script),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The exact element the built Dashboard page carries for its API origin (ADR 0045), empty. The shell
|
||||||
|
* reads the filled-in value at run time; empty means the page's own origin.
|
||||||
|
*/
|
||||||
|
export const DASHBOARD_API_ORIGIN_PLACEHOLDER = '<meta content="" name="ekklesia-api-origin" />';
|
||||||
|
|
||||||
|
function isCanonicalHttpOrigin(value: string): boolean {
|
||||||
|
try {
|
||||||
|
const url = new URL(value);
|
||||||
|
return (url.protocol === "https:" || url.protocol === "http:") && url.origin === value;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Writes the API's own origin into the Dashboard page, once, before the page's `ETag` is computed.
|
||||||
|
*
|
||||||
|
* The origin is the identity base URL's, which is where `/api` lives in either topology: the page's
|
||||||
|
* own origin when the Dashboard and the API share one, the API hostname when they are split. The
|
||||||
|
* built page must carry exactly one empty placeholder; anything else is a shell build this API does
|
||||||
|
* not understand, and startup fails rather than serving a page that might send credentials somewhere
|
||||||
|
* unintended. The value is escaped for an attribute even though a canonical origin cannot contain
|
||||||
|
* any character that needs it.
|
||||||
|
*/
|
||||||
|
export function renderDashboardPage(html: string, apiOrigin: string): string {
|
||||||
|
const parts = html.split(DASHBOARD_API_ORIGIN_PLACEHOLDER);
|
||||||
|
if (parts.length !== 2 || !isCanonicalHttpOrigin(apiOrigin)) {
|
||||||
|
throw new DashboardHostAssetError();
|
||||||
|
}
|
||||||
|
const escaped = apiOrigin
|
||||||
|
.replaceAll("&", "&")
|
||||||
|
.replaceAll('"', """)
|
||||||
|
.replaceAll("<", "<")
|
||||||
|
.replaceAll(">", ">");
|
||||||
|
return parts.join(`<meta content="${escaped}" name="ekklesia-api-origin" />`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Loads only the two surface-declared files, and renders the API origin into the page. A missing,
|
||||||
|
* unreadable, or incompatible build fails startup rather than leaving an API whose own origin answers
|
||||||
|
* `/` with a 404 or a page that cannot find its API. */
|
||||||
|
export async function loadDashboardHostAssets(apiOrigin: string): Promise<DashboardHostAssets> {
|
||||||
|
try {
|
||||||
|
// Sequential deliberately, as for the display host: diagnostics stay deterministic and no
|
||||||
|
// rejected sibling read is left running after this function has already failed.
|
||||||
|
const html = await readFile(new URL(dashboardHostAssetUrls.html), "utf8");
|
||||||
|
const script = await readFile(new URL(dashboardHostAssetUrls.script), "utf8");
|
||||||
|
return createDashboardHostAssets(renderDashboardPage(html, apiOrigin), script);
|
||||||
|
} catch {
|
||||||
|
throw new DashboardHostAssetError();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
import { Hono } from "hono";
|
||||||
|
import { etagMatches } from "./conditional-request.js";
|
||||||
|
import { createDashboardHostAssets, type DashboardHostAssets } from "./dashboard-host-assets.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The Dashboard's two host-owned routes (ADR 0044). Neither can collide with anything else the API
|
||||||
|
* serves: `/` is the only page the Dashboard has — it has no client router, and every emailed
|
||||||
|
* verification or reset link lands on `/` with a query (ADR 0038) — and the script lives beside, never
|
||||||
|
* inside, the display host's `/assets/ekklesia-display/` namespace. Module routes all live under
|
||||||
|
* `/api/v1/modules/<module-id>`.
|
||||||
|
*/
|
||||||
|
export const DASHBOARD_HOST_PAGE_PATH = "/";
|
||||||
|
export const DASHBOARD_HOST_SCRIPT_PATH = "/assets/ekklesia-dashboard/app.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Revalidate-always with a strong `ETag`, exactly the module UI and display-host script policy. The
|
||||||
|
* shell emits fixed, unhashed file names, so no response here may be cached as `immutable`: a
|
||||||
|
* rebuilt Dashboard must reach a browser on its next load, and an unchanged one costs a `304`.
|
||||||
|
*/
|
||||||
|
const DASHBOARD_HOST_CACHE_CONTROL = "no-cache";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Headers the Dashboard page carries on every answer, including a `304`, so a revalidated cache
|
||||||
|
* entry never keeps an older set.
|
||||||
|
*
|
||||||
|
* The page holds the platform sign-in form, so it refuses to be framed by anyone — `X-Frame-Options`
|
||||||
|
* for older browsers, and CSP `frame-ancestors`, the one directive a policy here carries until the
|
||||||
|
* full Content-Security-Policy is decided separately. `no-referrer` keeps the page's URL, which can
|
||||||
|
* briefly hold an emailed reset token before the shell strips it, out of every request it makes.
|
||||||
|
*/
|
||||||
|
const DASHBOARD_PAGE_SECURITY_HEADERS = Object.freeze({
|
||||||
|
"Content-Security-Policy": "frame-ancestors 'none'",
|
||||||
|
"Referrer-Policy": "no-referrer",
|
||||||
|
"X-Content-Type-Options": "nosniff",
|
||||||
|
"X-Frame-Options": "DENY",
|
||||||
|
});
|
||||||
|
|
||||||
|
/** A composition error, detected before any Dashboard route is registered. Carries no file path,
|
||||||
|
* package location, or asset content. */
|
||||||
|
export class DashboardHostConfigurationError extends Error {
|
||||||
|
constructor() {
|
||||||
|
super("The Dashboard host configuration is inconsistent.");
|
||||||
|
this.name = "DashboardHostConfigurationError";
|
||||||
|
this.stack = `${this.name}: ${this.message}`;
|
||||||
|
Object.freeze(this);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Copies and validates injected assets before registering any route, as the display host does, so
|
||||||
|
* a composition can never pair bytes with a validator computed from different bytes. */
|
||||||
|
function snapshotAssets(input: DashboardHostAssets): DashboardHostAssets {
|
||||||
|
try {
|
||||||
|
const snapshot = createDashboardHostAssets(input.html, input.script);
|
||||||
|
if (snapshot.htmlEtag !== input.htmlEtag || snapshot.scriptEtag !== input.scriptEtag) {
|
||||||
|
throw new DashboardHostConfigurationError();
|
||||||
|
}
|
||||||
|
return snapshot;
|
||||||
|
} catch {
|
||||||
|
throw new DashboardHostConfigurationError();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function fixedAsset(
|
||||||
|
ifNoneMatch: string | undefined,
|
||||||
|
body: string,
|
||||||
|
etag: string,
|
||||||
|
contentType: string,
|
||||||
|
extraHeaders: Readonly<Record<string, string>>,
|
||||||
|
): Response {
|
||||||
|
if (etagMatches(ifNoneMatch, etag)) {
|
||||||
|
return new Response(null, {
|
||||||
|
headers: { ...extraHeaders, "Cache-Control": DASHBOARD_HOST_CACHE_CONTROL, ETag: etag },
|
||||||
|
status: 304,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return new Response(body, {
|
||||||
|
headers: {
|
||||||
|
"X-Content-Type-Options": "nosniff",
|
||||||
|
...extraHeaders,
|
||||||
|
"Cache-Control": DASHBOARD_HOST_CACHE_CONTROL,
|
||||||
|
"Content-Type": contentType,
|
||||||
|
ETag: etag,
|
||||||
|
},
|
||||||
|
status: 200,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Serves the built Dashboard from the same origin as `/api`, so the identity system keeps its
|
||||||
|
* default same-origin shape.
|
||||||
|
*
|
||||||
|
* Two exact literal routes over one frozen snapshot: never a static directory, never a path or file
|
||||||
|
* name taken from the request, and never the source map. Any other path — `/index.html`, `/app.js`,
|
||||||
|
* the `.map`, a traversal spelling — has no route and falls through to the application's ordinary
|
||||||
|
* sanitized `404`. Like the display host's code, these bytes grant no authority: the page renders
|
||||||
|
* the sign-in form until `/api/v1/principal` says otherwise.
|
||||||
|
*/
|
||||||
|
export function createDashboardHostRoutes(assets: DashboardHostAssets): Hono {
|
||||||
|
const snapshot = snapshotAssets(assets);
|
||||||
|
const routes = new Hono();
|
||||||
|
|
||||||
|
routes.get(DASHBOARD_HOST_PAGE_PATH, (c) =>
|
||||||
|
fixedAsset(
|
||||||
|
c.req.header("if-none-match"),
|
||||||
|
snapshot.html,
|
||||||
|
snapshot.htmlEtag,
|
||||||
|
"text/html; charset=utf-8",
|
||||||
|
DASHBOARD_PAGE_SECURITY_HEADERS,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
routes.get(DASHBOARD_HOST_SCRIPT_PATH, (c) =>
|
||||||
|
fixedAsset(
|
||||||
|
c.req.header("if-none-match"),
|
||||||
|
snapshot.script,
|
||||||
|
snapshot.scriptEtag,
|
||||||
|
"text/javascript; charset=utf-8",
|
||||||
|
{},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
return routes;
|
||||||
|
}
|
||||||
@@ -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,13 +26,35 @@ 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;
|
||||||
|
/**
|
||||||
|
* One trivial round trip on the shared pool, for readiness (`./health-routes.js`). The `SELECT 1`
|
||||||
|
* runs in a read-only transaction carrying its own `SET LOCAL statement_timeout`, so PostgreSQL
|
||||||
|
* cancels a stalled statement itself rather than the caller merely abandoning it; the pool's
|
||||||
|
* session-wide `statement_timeout`/`query_timeout` remain the outer bound. The four statements
|
||||||
|
* travel as one simple-protocol message, so the check costs a single round trip. On any failure
|
||||||
|
* the pool discards that connection instead of returning it, so a transaction left open by a
|
||||||
|
* cancelled statement never reaches another caller. Rejects with whatever the driver raised; no
|
||||||
|
* caller may surface it.
|
||||||
|
*/
|
||||||
|
readonly ping: (statementTimeoutMs: number) => Promise<void>;
|
||||||
/** Closes the one shared pool exactly once, regardless of how many typed views were created
|
/** Closes the one shared pool exactly once, regardless of how many typed views were created
|
||||||
* over it. Safe to call from both a startup-failure cleanup path and normal shutdown. */
|
* over it. Safe to call from both a startup-failure cleanup path and normal shutdown. */
|
||||||
readonly close: () => Promise<void>;
|
readonly close: () => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function pingStatement(statementTimeoutMs: number): string {
|
||||||
|
// Interpolated rather than bound: neither SET nor a multi-statement simple query accepts
|
||||||
|
// parameters. Only a positive safe integer can ever reach the SQL text.
|
||||||
|
if (!Number.isSafeInteger(statementTimeoutMs) || statementTimeoutMs <= 0) {
|
||||||
|
throw new RangeError("The ping statement timeout must be a positive integer.");
|
||||||
|
}
|
||||||
|
return `BEGIN READ ONLY; SET LOCAL statement_timeout = ${statementTimeoutMs}; SELECT 1; COMMIT`;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Composes the one physical PostgreSQL pool this process uses for Core's identity and
|
* Composes the one physical PostgreSQL pool this process uses for Core's identity and
|
||||||
* authorization schemas plus the module database facade: one validated, timeout-bounded
|
* authorization schemas plus the module database facade: one validated, timeout-bounded
|
||||||
@@ -56,7 +80,11 @@ 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) => {
|
||||||
|
await pool.query(pingStatement(statementTimeoutMs));
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -92,7 +92,7 @@ function readString(body: Record<string, unknown>, key: string, max: number): st
|
|||||||
*/
|
*/
|
||||||
function writeDeviceCookie(
|
function writeDeviceCookie(
|
||||||
c: Context,
|
c: Context,
|
||||||
options: DeviceRoutesOptions,
|
options: Pick<DeviceRoutesOptions, "secureCookies">,
|
||||||
name: string,
|
name: string,
|
||||||
value: string,
|
value: string,
|
||||||
maxAgeSeconds: number,
|
maxAgeSeconds: number,
|
||||||
@@ -106,6 +106,27 @@ function writeDeviceCookie(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Re-issues the device cookie a request just authenticated with, with a fresh lifetime (ADR 0056).
|
||||||
|
*
|
||||||
|
* The value is unchanged — no rotation — so a renewal lost in transit costs nothing: the screen still
|
||||||
|
* holds a working credential. Only the browser's own expiry is pushed forward, matching the
|
||||||
|
* server's sliding window. Called only after the request resolved to a live, unrevoked device.
|
||||||
|
*/
|
||||||
|
export function renewDeviceSessionCookie(c: Context, secureCookies: boolean): void {
|
||||||
|
const credential = getCookie(c, DEVICE_SESSION_COOKIE);
|
||||||
|
if (credential === undefined || credential.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
writeDeviceCookie(
|
||||||
|
c,
|
||||||
|
{ secureCookies },
|
||||||
|
DEVICE_SESSION_COOKIE,
|
||||||
|
credential,
|
||||||
|
Math.floor(DEVICE_CREDENTIAL_TTL_MS / 1000),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function createDeviceRoutes(options: DeviceRoutesOptions): Hono {
|
export function createDeviceRoutes(options: DeviceRoutesOptions): Hono {
|
||||||
const routes = new HonoApp();
|
const routes = new HonoApp();
|
||||||
const now = options.now ?? ((): Date => new Date());
|
const now = options.now ?? ((): Date => new Date());
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { readFile } from "node:fs/promises";
|
|||||||
import { displayHostAssetUrls } from "@ekklesia/shell/display-assets";
|
import { displayHostAssetUrls } from "@ekklesia/shell/display-assets";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The two immutable, platform-owned files that make up Ekklesia's display browser host.
|
* The three immutable, platform-owned files that make up Ekklesia's display browser host.
|
||||||
*
|
*
|
||||||
* They are read once, before the HTTP listener is bound, from the exact URLs exported by the
|
* They are read once, before the HTTP listener is bound, from the exact URLs exported by the
|
||||||
* surface package. Request input is never involved in this read and the package is never exposed as
|
* surface package. Request input is never involved in this read and the package is never exposed as
|
||||||
@@ -14,6 +14,10 @@ export interface DisplayHostAssets {
|
|||||||
readonly html: string;
|
readonly html: string;
|
||||||
readonly script: string;
|
readonly script: string;
|
||||||
readonly scriptEtag: string;
|
readonly scriptEtag: string;
|
||||||
|
/** The display's offline service worker (ADR 0043) — a third fixed, platform-owned file, served
|
||||||
|
* at a path whose directory is its scope so it controls display pages and nothing else. */
|
||||||
|
readonly serviceWorker: string;
|
||||||
|
readonly serviceWorkerEtag: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A startup failure with a fixed, non-secret shape. The originating filesystem exception, package
|
/** A startup failure with a fixed, non-secret shape. The originating filesystem exception, package
|
||||||
@@ -36,18 +40,24 @@ export class DisplayHostAssetError extends Error {
|
|||||||
* cannot accidentally pair bytes from one build with an ETag from another. Empty output is a
|
* cannot accidentally pair bytes from one build with an ETag from another. Empty output is a
|
||||||
* failed host build rather than a valid page that can never mount anything.
|
* failed host build rather than a valid page that can never mount anything.
|
||||||
*/
|
*/
|
||||||
export function createDisplayHostAssets(html: string, script: string): DisplayHostAssets {
|
export function createDisplayHostAssets(
|
||||||
if (html.trim().length === 0 || script.trim().length === 0) {
|
html: string,
|
||||||
|
script: string,
|
||||||
|
serviceWorker: string,
|
||||||
|
): DisplayHostAssets {
|
||||||
|
if (html.trim().length === 0 || script.trim().length === 0 || serviceWorker.trim().length === 0) {
|
||||||
throw new DisplayHostAssetError();
|
throw new DisplayHostAssetError();
|
||||||
}
|
}
|
||||||
return Object.freeze({
|
return Object.freeze({
|
||||||
html,
|
html,
|
||||||
script,
|
script,
|
||||||
scriptEtag: `"${createHash("sha256").update(script).digest("base64url")}"`,
|
scriptEtag: `"${createHash("sha256").update(script).digest("base64url")}"`,
|
||||||
|
serviceWorker,
|
||||||
|
serviceWorkerEtag: `"${createHash("sha256").update(serviceWorker).digest("base64url")}"`,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Loads only the two surface-declared files. A missing or unreadable build fails startup rather
|
/** Loads only the three surface-declared files. A missing or unreadable build fails startup rather
|
||||||
* than leaving a display route that can authorize successfully and then return a broken shell. */
|
* than leaving a display route that can authorize successfully and then return a broken shell. */
|
||||||
export async function loadDisplayHostAssets(): Promise<DisplayHostAssets> {
|
export async function loadDisplayHostAssets(): Promise<DisplayHostAssets> {
|
||||||
try {
|
try {
|
||||||
@@ -55,7 +65,8 @@ export async function loadDisplayHostAssets(): Promise<DisplayHostAssets> {
|
|||||||
// rejected sibling promise is left running after this function has already failed.
|
// rejected sibling promise is left running after this function has already failed.
|
||||||
const html = await readFile(new URL(displayHostAssetUrls.html), "utf8");
|
const html = await readFile(new URL(displayHostAssetUrls.html), "utf8");
|
||||||
const script = await readFile(new URL(displayHostAssetUrls.script), "utf8");
|
const script = await readFile(new URL(displayHostAssetUrls.script), "utf8");
|
||||||
return createDisplayHostAssets(html, script);
|
const serviceWorker = await readFile(new URL(displayHostAssetUrls.serviceWorker), "utf8");
|
||||||
|
return createDisplayHostAssets(html, script, serviceWorker);
|
||||||
} catch {
|
} catch {
|
||||||
throw new DisplayHostAssetError();
|
throw new DisplayHostAssetError();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,121 @@
|
|||||||
|
import { Hono } from "hono";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The longest a readiness probe waits on the database before answering `503` (ADR 0044). Short
|
||||||
|
* enough for a Compose health check or an external monitor to treat a stall as "not ready" rather
|
||||||
|
* than as a hung probe, and well inside the shared pool's own 10s connection and operation
|
||||||
|
* timeouts, which bound the underlying query independently.
|
||||||
|
*/
|
||||||
|
export const READINESS_DATABASE_TIMEOUT_MS = 2_000;
|
||||||
|
|
||||||
|
export const HEALTH_LIVE_PATH = "/api/v1/health/live";
|
||||||
|
export const HEALTH_READY_PATH = "/api/v1/health/ready";
|
||||||
|
|
||||||
|
export interface ApiHealthOptions {
|
||||||
|
/**
|
||||||
|
* One trivial database round trip. Resolves when the database answered; rejects on any failure.
|
||||||
|
* The rejection is never read — not its message, code, or cause — so nothing a driver says can
|
||||||
|
* reach a response. The route bounds the wait and never overlaps two calls; this function only
|
||||||
|
* has to perform the query.
|
||||||
|
*/
|
||||||
|
readonly checkDatabase: () => Promise<void>;
|
||||||
|
/** Whether module startup has completed. `false` again from the moment shutdown begins, so a
|
||||||
|
* draining instance stops reporting ready while it finishes in-flight requests. */
|
||||||
|
readonly isModuleStartupComplete: () => boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const LIVE_BODY = Object.freeze({ status: "live" });
|
||||||
|
const READY_BODY = Object.freeze({ status: "ready" });
|
||||||
|
const NOT_READY_BODY = Object.freeze({ code: "NOT_READY" });
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wraps `checkDatabase` so at most one call is ever unsettled, and no probe waits longer than
|
||||||
|
* `timeoutMs`.
|
||||||
|
*
|
||||||
|
* Every probe arriving while a check is unsettled shares that check's outcome, including after its
|
||||||
|
* deadline has passed: the deadline resolves the shared outcome to `false`, so those later probes
|
||||||
|
* answer immediately. A new check starts only once the previous call has actually settled. A timed-
|
||||||
|
* out query still occupies a pool connection until the database or the pool's own timeouts end it,
|
||||||
|
* so starting a fresh query on every probe would let a stalled database drain the pool one health
|
||||||
|
* check at a time.
|
||||||
|
*/
|
||||||
|
function createBoundedDatabaseCheck(
|
||||||
|
checkDatabase: () => Promise<void>,
|
||||||
|
timeoutMs: number,
|
||||||
|
): () => Promise<boolean> {
|
||||||
|
let inFlight: Promise<boolean> | undefined;
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (inFlight !== undefined) {
|
||||||
|
return inFlight;
|
||||||
|
}
|
||||||
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
// The executor runs `checkDatabase` synchronously, so a synchronous throw is a rejection like
|
||||||
|
// any other rather than an exception escaping the route.
|
||||||
|
const settled = new Promise<void>((resolve) => resolve(checkDatabase())).then(
|
||||||
|
() => true,
|
||||||
|
() => false,
|
||||||
|
);
|
||||||
|
const outcome = Promise.race([
|
||||||
|
settled,
|
||||||
|
new Promise<false>((resolve) => {
|
||||||
|
timer = setTimeout(() => resolve(false), timeoutMs);
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
inFlight = outcome;
|
||||||
|
void settled.then(() => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
inFlight = undefined;
|
||||||
|
});
|
||||||
|
return outcome;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The same fixed refusal `./module-realtime-routes.js` uses: Hono lets a GET handler answer HEAD,
|
||||||
|
* and these routes accept GET alone. */
|
||||||
|
function methodNotAllowed(): Response {
|
||||||
|
return new Response(null, {
|
||||||
|
headers: { Allow: "GET", "Cache-Control": "no-store" },
|
||||||
|
status: 405,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unauthenticated liveness and readiness (ADR 0044), for Compose health checks and external
|
||||||
|
* monitoring. Neither route resolves a principal or reads a cookie, and both return fixed bodies
|
||||||
|
* that name no module, version, error, or connection detail; `pnpm doctor` remains the detailed
|
||||||
|
* operator report. They live under `/api/v1/health/`, a sibling of the `/api/v1/modules/` prefix that
|
||||||
|
* installed modules own entirely, so no module route can shadow them.
|
||||||
|
*/
|
||||||
|
export function createHealthRoutes(options: ApiHealthOptions): Hono {
|
||||||
|
const checkDatabase = createBoundedDatabaseCheck(
|
||||||
|
options.checkDatabase,
|
||||||
|
READINESS_DATABASE_TIMEOUT_MS,
|
||||||
|
);
|
||||||
|
const routes = new Hono();
|
||||||
|
|
||||||
|
routes.all(HEALTH_LIVE_PATH, (c) => {
|
||||||
|
if (c.req.method !== "GET") {
|
||||||
|
return methodNotAllowed();
|
||||||
|
}
|
||||||
|
c.header("Cache-Control", "no-store");
|
||||||
|
return c.json(LIVE_BODY, 200);
|
||||||
|
});
|
||||||
|
|
||||||
|
routes.all(HEALTH_READY_PATH, async (c) => {
|
||||||
|
if (c.req.method !== "GET") {
|
||||||
|
return methodNotAllowed();
|
||||||
|
}
|
||||||
|
c.header("Cache-Control", "no-store");
|
||||||
|
if (!options.isModuleStartupComplete()) {
|
||||||
|
return c.json(NOT_READY_BODY, 503);
|
||||||
|
}
|
||||||
|
const databaseReady = await checkDatabase();
|
||||||
|
// Checked again afterwards: shutdown may have begun while the query was in flight.
|
||||||
|
return databaseReady && options.isModuleStartupComplete()
|
||||||
|
? c.json(READY_BODY, 200)
|
||||||
|
: c.json(NOT_READY_BODY, 503);
|
||||||
|
});
|
||||||
|
|
||||||
|
return routes;
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
/**
|
||||||
|
* Canonical browser routes owned by the platform's display host.
|
||||||
|
*
|
||||||
|
* Kept separate from the route registration module so both the display host and module-UI
|
||||||
|
* inventory can publish the same path without introducing a dependency cycle.
|
||||||
|
*/
|
||||||
|
export const MODULE_DISPLAY_DESCRIPTOR_PREFIX = "/api/v1/module-displays";
|
||||||
|
export const MODULE_DISPLAY_PAGE_PREFIX = "/display";
|
||||||
|
|
||||||
|
export function moduleDisplayPageUrl(moduleId: string): string {
|
||||||
|
return `${MODULE_DISPLAY_PAGE_PREFIX}/${encodeURIComponent(moduleId)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function moduleDisplayDescriptorUrl(moduleId: string): string {
|
||||||
|
return `${MODULE_DISPLAY_DESCRIPTOR_PREFIX}/${encodeURIComponent(moduleId)}`;
|
||||||
|
}
|
||||||
@@ -6,25 +6,39 @@ import {
|
|||||||
getAuthorizedPrincipal,
|
getAuthorizedPrincipal,
|
||||||
requirePermission,
|
requirePermission,
|
||||||
} from "./authorization-middleware.js";
|
} from "./authorization-middleware.js";
|
||||||
|
import { etagMatches } from "./conditional-request.js";
|
||||||
import { createDisplayHostAssets, type DisplayHostAssets } from "./display-host-assets.js";
|
import { createDisplayHostAssets, type DisplayHostAssets } from "./display-host-assets.js";
|
||||||
|
export {
|
||||||
|
MODULE_DISPLAY_DESCRIPTOR_PREFIX,
|
||||||
|
MODULE_DISPLAY_PAGE_PREFIX,
|
||||||
|
moduleDisplayDescriptorUrl,
|
||||||
|
moduleDisplayPageUrl,
|
||||||
|
} from "./module-display-path.js";
|
||||||
|
import { moduleDisplayDescriptorUrl, moduleDisplayPageUrl } from "./module-display-path.js";
|
||||||
import { moduleApiBaseUrl } from "./module-http-routes.js";
|
import { moduleApiBaseUrl } from "./module-http-routes.js";
|
||||||
import { moduleRealtimeBaseUrl } from "./module-realtime-routes.js";
|
import { moduleRealtimeBaseUrl } from "./module-realtime-routes.js";
|
||||||
import { grantedModulePermissionIds } from "./module-ui-inventory.js";
|
import {
|
||||||
|
describeModuleContributions,
|
||||||
|
grantedModulePermissionIds,
|
||||||
|
type HostModuleContributionDescriptor,
|
||||||
|
} from "./module-ui-inventory.js";
|
||||||
import { moduleDisplayUiArtifactUrl } from "./module-ui-routes.js";
|
import { moduleDisplayUiArtifactUrl } from "./module-ui-routes.js";
|
||||||
|
|
||||||
/** Host-owned namespaces. Neither can collide with module HTTP routes, all of which live under
|
/** Host-owned namespaces. Neither can collide with module HTTP routes, all of which live under
|
||||||
* `/api/v1/modules/<module-id>`, and neither string enters the module-facing UI ABI. */
|
* `/api/v1/modules/<module-id>`, and neither string enters the module-facing UI ABI. */
|
||||||
export const MODULE_DISPLAY_DESCRIPTOR_PREFIX = "/api/v1/module-displays";
|
|
||||||
export const MODULE_DISPLAY_PAGE_PREFIX = "/display";
|
|
||||||
export const DISPLAY_HOST_SCRIPT_PATH = "/assets/ekklesia-display/app.js";
|
export const DISPLAY_HOST_SCRIPT_PATH = "/assets/ekklesia-display/app.js";
|
||||||
|
/**
|
||||||
export function moduleDisplayPageUrl(moduleId: string): string {
|
* Where the display's offline service worker is served (ADR 0043).
|
||||||
return `${MODULE_DISPLAY_PAGE_PREFIX}/${encodeURIComponent(moduleId)}`;
|
*
|
||||||
}
|
* The path is chosen for its **directory**: a service worker's default scope is the directory it was
|
||||||
|
* served from, so `/display/sw.js` controls display pages and nothing else. Serving it from
|
||||||
export function moduleDisplayDescriptorUrl(moduleId: string): string {
|
* `/assets/…` beside the host script would scope it to `/assets/`, where no page lives, and widening
|
||||||
return `${MODULE_DISPLAY_DESCRIPTOR_PREFIX}/${encodeURIComponent(moduleId)}`;
|
* it with `Service-Worker-Allowed` would put the Dashboard under a worker it has no use for.
|
||||||
}
|
*
|
||||||
|
* `sw.js` cannot collide with a display page: a display page is registered at the exact literal
|
||||||
|
* `/display/<module-id>` for each display-capable module, and `sw.js` is not a valid module id.
|
||||||
|
*/
|
||||||
|
export const DISPLAY_SERVICE_WORKER_PATH = "/display/sw.js";
|
||||||
|
|
||||||
/** The portable facts a browser host needs to mount this module's one display artifact. It mirrors
|
/** The portable facts a browser host needs to mount this module's one display artifact. It mirrors
|
||||||
* the primary UI descriptor while naming the role explicitly, so a host cannot accidentally load a
|
* the primary UI descriptor while naming the role explicitly, so a host cannot accidentally load a
|
||||||
@@ -34,9 +48,15 @@ export interface HostModuleDisplayDescriptor {
|
|||||||
readonly id: string;
|
readonly id: string;
|
||||||
readonly version: string;
|
readonly version: string;
|
||||||
readonly display: { readonly url: string };
|
readonly display: { readonly url: string };
|
||||||
|
/** The public page a screen opens to mount this display. This is distinct from `display.url`,
|
||||||
|
* which is the compiled artifact imported by the platform host. */
|
||||||
|
readonly page: { readonly url: string };
|
||||||
readonly api: { readonly baseUrl: string };
|
readonly api: { readonly baseUrl: string };
|
||||||
readonly realtime: { readonly baseUrl: string };
|
readonly realtime: { readonly baseUrl: string };
|
||||||
readonly permissions: readonly string[];
|
readonly permissions: readonly string[];
|
||||||
|
/** Renderers other modules contributed to capabilities this module provides (ADR 0041) — the
|
||||||
|
* mechanism a signage-style display uses to show another module's content without importing it. */
|
||||||
|
readonly contributions: readonly HostModuleContributionDescriptor[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -63,6 +83,9 @@ export interface ModuleDisplayRoutesOptions extends AuthorizationMiddlewareOptio
|
|||||||
/** Omitted only when the registry contains no display entrypoints. Production reads the exact
|
/** Omitted only when the registry contains no display entrypoints. Production reads the exact
|
||||||
* surface-owned files before binding and supplies their frozen contents here. */
|
* surface-owned files before binding and supplies their frozen contents here. */
|
||||||
readonly assets?: DisplayHostAssets;
|
readonly assets?: DisplayHostAssets;
|
||||||
|
/** Re-issues a paired device's cookie with a fresh lifetime (ADR 0056). Composed only when the
|
||||||
|
* deployment has device support; called only for a request that resolved to a device. */
|
||||||
|
readonly renewDeviceCredential?: (c: Context) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A composition error, detected before any display route is registered. Carries no module id,
|
/** A composition error, detected before any display route is registered. Carries no module id,
|
||||||
@@ -81,27 +104,16 @@ function displayFailure(c: Context): Response {
|
|||||||
return c.json({ code: "AUTHORIZATION_EVALUATION_FAILED" }, 503);
|
return c.json({ code: "AUTHORIZATION_EVALUATION_FAILED" }, 503);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** RFC 9110 weak comparison for a GET/HEAD `If-None-Match` precondition. */
|
|
||||||
function etagMatches(headerValue: string | undefined, etag: string): boolean {
|
|
||||||
if (headerValue === undefined) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (headerValue.trim() === "*") {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return headerValue.split(",").some((candidate) => {
|
|
||||||
const value = candidate.trim();
|
|
||||||
return (value.startsWith("W/") ? value.slice(2) : value) === etag;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Copies and validates injected assets before registering any route. Production's loader already
|
/** Copies and validates injected assets before registering any route. Production's loader already
|
||||||
* creates this exact shape, while this check keeps a test/composition caller from pairing mutable
|
* creates this exact shape, while this check keeps a test/composition caller from pairing mutable
|
||||||
* bytes with a stale validator. */
|
* bytes with a stale validator. */
|
||||||
function snapshotAssets(input: DisplayHostAssets): DisplayHostAssets {
|
function snapshotAssets(input: DisplayHostAssets): DisplayHostAssets {
|
||||||
try {
|
try {
|
||||||
const snapshot = createDisplayHostAssets(input.html, input.script);
|
const snapshot = createDisplayHostAssets(input.html, input.script, input.serviceWorker);
|
||||||
if (snapshot.scriptEtag !== input.scriptEtag) {
|
if (
|
||||||
|
snapshot.scriptEtag !== input.scriptEtag ||
|
||||||
|
snapshot.serviceWorkerEtag !== input.serviceWorkerEtag
|
||||||
|
) {
|
||||||
throw new ModuleDisplayConfigurationError();
|
throw new ModuleDisplayConfigurationError();
|
||||||
}
|
}
|
||||||
return snapshot;
|
return snapshot;
|
||||||
@@ -132,8 +144,10 @@ async function describeDisplay(
|
|||||||
viewer,
|
viewer,
|
||||||
module: {
|
module: {
|
||||||
api: { baseUrl: moduleApiBaseUrl(display.moduleId) },
|
api: { baseUrl: moduleApiBaseUrl(display.moduleId) },
|
||||||
|
contributions: describeModuleContributions(options.moduleUi, display.moduleId),
|
||||||
display: { url: moduleDisplayUiArtifactUrl(display) },
|
display: { url: moduleDisplayUiArtifactUrl(display) },
|
||||||
id: display.moduleId,
|
id: display.moduleId,
|
||||||
|
page: { url: moduleDisplayPageUrl(display.moduleId) },
|
||||||
permissions: await grantedModulePermissionIds(
|
permissions: await grantedModulePermissionIds(
|
||||||
options.evaluator,
|
options.evaluator,
|
||||||
principal,
|
principal,
|
||||||
@@ -188,6 +202,11 @@ function mountDisplay(
|
|||||||
if (!viewer) {
|
if (!viewer) {
|
||||||
return displayFailure(c);
|
return displayFailure(c);
|
||||||
}
|
}
|
||||||
|
// Every screen asks for this on start, on reconnect, and periodically while running, so it
|
||||||
|
// is where an in-use credential's browser expiry is pushed forward.
|
||||||
|
if (principal.kind === "device") {
|
||||||
|
options.renewDeviceCredential?.(c);
|
||||||
|
}
|
||||||
return c.json(await describeDisplay(options, principal, display, viewer), 200);
|
return c.json(await describeDisplay(options, principal, display, viewer), 200);
|
||||||
} catch {
|
} catch {
|
||||||
return displayFailure(c);
|
return displayFailure(c);
|
||||||
@@ -244,6 +263,33 @@ export function mountModuleDisplayRoutes(app: Hono, options: ModuleDisplayRoutes
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The worker itself is `no-cache` with a strong validator, exactly like the host script: a browser
|
||||||
|
* re-checks it on its own schedule, and the worker deliberately never caches *itself*, which is
|
||||||
|
* how a screen avoids being trapped on an obsolete worker.
|
||||||
|
*
|
||||||
|
* Like the host script and module artifact bytes, fetching this code grants no authority. It
|
||||||
|
* caches only public code and never a permission decision, so serving it anonymously withholds
|
||||||
|
* nothing the platform was protecting.
|
||||||
|
*/
|
||||||
|
app.get(DISPLAY_SERVICE_WORKER_PATH, (c) => {
|
||||||
|
if (etagMatches(c.req.header("if-none-match"), assets.serviceWorkerEtag)) {
|
||||||
|
return new Response(null, {
|
||||||
|
headers: { "Cache-Control": "no-cache", ETag: assets.serviceWorkerEtag },
|
||||||
|
status: 304,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return new Response(assets.serviceWorker, {
|
||||||
|
headers: {
|
||||||
|
"Cache-Control": "no-cache",
|
||||||
|
"Content-Type": "text/javascript; charset=utf-8",
|
||||||
|
ETag: assets.serviceWorkerEtag,
|
||||||
|
"X-Content-Type-Options": "nosniff",
|
||||||
|
},
|
||||||
|
status: 200,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
for (const display of displays) {
|
for (const display of displays) {
|
||||||
mountDisplay(app, { ...options, assets }, display);
|
mountDisplay(app, { ...options, assets }, display);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,27 +235,40 @@ 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
|
||||||
// all) is enforced before permission evaluation ever runs, so an oversized request never
|
// all) is enforced before permission evaluation ever runs, so a request *declared* oversized
|
||||||
// reaches authorization work, let alone the handler or the module database.
|
// never reaches authorization work, let alone the handler or the module database. In
|
||||||
const routeBodyLimit = platformBodyLimit(route.maxBodyBytes);
|
// `streamed` mode no body byte is read until the handler reads it, and the handler runs only
|
||||||
|
// after authorization succeeds, so an unauthorized caller can never push an upload through
|
||||||
|
// (ADR 0042).
|
||||||
|
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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
import type { EkklesiaPrincipal, PermissionDeclaration } from "@ekklesia/contracts";
|
import type { EkklesiaPrincipal, PermissionDeclaration } from "@ekklesia/contracts";
|
||||||
import { CORE_MODULES_UI_READ } from "@ekklesia/core/authorization";
|
import { CORE_MODULES_UI_READ } from "@ekklesia/core/authorization";
|
||||||
import type { ModuleUiPublicationRegistry, PublishableModuleUi } from "@ekklesia/core/modules";
|
import type {
|
||||||
|
ModuleUiPublicationRegistry,
|
||||||
|
PublishableModuleContribution,
|
||||||
|
PublishableModuleUi,
|
||||||
|
} from "@ekklesia/core/modules";
|
||||||
import { Hono } from "hono";
|
import { Hono } from "hono";
|
||||||
import type { Context } from "hono";
|
import type { Context } from "hono";
|
||||||
import {
|
import {
|
||||||
@@ -9,8 +13,13 @@ import {
|
|||||||
requirePermission,
|
requirePermission,
|
||||||
} from "./authorization-middleware.js";
|
} from "./authorization-middleware.js";
|
||||||
import { moduleApiBaseUrl } from "./module-http-routes.js";
|
import { moduleApiBaseUrl } from "./module-http-routes.js";
|
||||||
|
import { moduleDisplayPageUrl } from "./module-display-path.js";
|
||||||
import { moduleRealtimeBaseUrl } from "./module-realtime-routes.js";
|
import { moduleRealtimeBaseUrl } from "./module-realtime-routes.js";
|
||||||
import { MODULE_UI_ASSET_PREFIX, moduleUiArtifactUrl } from "./module-ui-routes.js";
|
import {
|
||||||
|
MODULE_UI_ASSET_PREFIX,
|
||||||
|
moduleContributionArtifactUrl,
|
||||||
|
moduleUiArtifactUrl,
|
||||||
|
} from "./module-ui-routes.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The collection root of the module UI resource: `/api/v1/module-ui` lists what a browser host may
|
* The collection root of the module UI resource: `/api/v1/module-ui` lists what a browser host may
|
||||||
@@ -40,6 +49,10 @@ export interface HostModuleUiDescriptor {
|
|||||||
* canonical helper (`moduleUiArtifactUrl`), never re-spelled in the browser, so publication
|
* canonical helper (`moduleUiArtifactUrl`), never re-spelled in the browser, so publication
|
||||||
* routing can change without touching a single line of host or module code. */
|
* routing can change without touching a single line of host or module code. */
|
||||||
readonly ui: { readonly url: string };
|
readonly ui: { readonly url: string };
|
||||||
|
/** Canonical page for this module's separately declared display artifact, or `null` when the
|
||||||
|
* module publishes no display. Server-produced so neither shell nor module spells platform
|
||||||
|
* routing or infers deployment topology. */
|
||||||
|
readonly display: { readonly url: string } | null;
|
||||||
/** The root this module's own HTTP API is mounted at, so the host can resolve the relative paths
|
/** The root this module's own HTTP API is mounted at, so the host can resolve the relative paths
|
||||||
* a module UI asks for (`context.api.request("records")`). Server-produced for the same reason
|
* a module UI asks for (`context.api.request("records")`). Server-produced for the same reason
|
||||||
* `ui.url` is: `/api/v1/modules/<id>` is Ekklesia's private deployment detail, and neither the
|
* `ui.url` is: `/api/v1/modules/<id>` is Ekklesia's private deployment detail, and neither the
|
||||||
@@ -64,12 +77,56 @@ export interface HostModuleUiDescriptor {
|
|||||||
* nor trusts anything in this array.
|
* nor trusts anything in this array.
|
||||||
*/
|
*/
|
||||||
readonly permissions: readonly string[];
|
readonly permissions: readonly string[];
|
||||||
|
/** Renderers other modules contributed to capabilities this module provides (ADR 0041). Empty for
|
||||||
|
* the overwhelming majority of modules, which provide no extension point. */
|
||||||
|
readonly contributions: readonly HostModuleContributionDescriptor[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One renderer another module contributed to a capability **this** module provides (ADR 0041).
|
||||||
|
*
|
||||||
|
* The URL is server-produced, exactly like `ui.url`, `api.baseUrl`, and `realtime.baseUrl`: a
|
||||||
|
* consuming module names a contribution and the host resolves it, so no module ever builds
|
||||||
|
* Ekklesia's publication URL and every one of them stays hostable elsewhere.
|
||||||
|
*
|
||||||
|
* There is no payload schema, slot, order, or rendering hint here, and there will not be: what a
|
||||||
|
* contributed renderer draws and what shape its input takes are the consuming module's contract,
|
||||||
|
* carried in the browser between two modules without passing through the platform.
|
||||||
|
*/
|
||||||
|
export interface HostModuleContributionDescriptor {
|
||||||
|
readonly id: string;
|
||||||
|
readonly version: string;
|
||||||
|
/** The capability it was contributed to — one this module declares. */
|
||||||
|
readonly capability: string;
|
||||||
|
readonly url: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface HostModuleUiInventory {
|
export interface HostModuleUiInventory {
|
||||||
readonly modules: readonly HostModuleUiDescriptor[];
|
readonly modules: readonly HostModuleUiDescriptor[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The one place a publishable contribution becomes a browser descriptor, shared by the primary-UI
|
||||||
|
* inventory and the display descriptor so the two can never disagree about what a consuming module
|
||||||
|
* may mount.
|
||||||
|
*/
|
||||||
|
export function describeModuleContributions(
|
||||||
|
registry: ModuleUiPublicationRegistry,
|
||||||
|
moduleId: string,
|
||||||
|
): readonly HostModuleContributionDescriptor[] {
|
||||||
|
return registry
|
||||||
|
.contributionsForModule(moduleId)
|
||||||
|
.map((contribution: PublishableModuleContribution) => ({
|
||||||
|
capability: contribution.capability,
|
||||||
|
id: contribution.contributionId,
|
||||||
|
url: moduleContributionArtifactUrl(
|
||||||
|
{ moduleId: contribution.moduleId, version: contribution.moduleVersion },
|
||||||
|
contribution.contributionId,
|
||||||
|
),
|
||||||
|
version: contribution.version,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
export interface CreateModuleUiInventoryRoutesOptions extends AuthorizationMiddlewareOptions {
|
export interface CreateModuleUiInventoryRoutesOptions extends AuthorizationMiddlewareOptions {
|
||||||
/** The same publication registry the artifact route serves from — the single accepted/publishable
|
/** The same publication registry the artifact route serves from — the single accepted/publishable
|
||||||
* module set (`createModuleUiPublicationRegistry`, `@ekklesia/core/modules`). This route performs
|
* module set (`createModuleUiPublicationRegistry`, `@ekklesia/core/modules`). This route performs
|
||||||
@@ -116,9 +173,13 @@ async function describeModule(
|
|||||||
evaluator: AuthorizationMiddlewareOptions["evaluator"],
|
evaluator: AuthorizationMiddlewareOptions["evaluator"],
|
||||||
principal: EkklesiaPrincipal,
|
principal: EkklesiaPrincipal,
|
||||||
published: PublishableModuleUi,
|
published: PublishableModuleUi,
|
||||||
|
registry: ModuleUiPublicationRegistry,
|
||||||
): Promise<HostModuleUiDescriptor> {
|
): Promise<HostModuleUiDescriptor> {
|
||||||
|
const display = registry.resolveDisplay(published.moduleId, published.version);
|
||||||
return {
|
return {
|
||||||
api: { baseUrl: moduleApiBaseUrl(published.moduleId) },
|
api: { baseUrl: moduleApiBaseUrl(published.moduleId) },
|
||||||
|
contributions: describeModuleContributions(registry, published.moduleId),
|
||||||
|
display: display === undefined ? null : { url: moduleDisplayPageUrl(published.moduleId) },
|
||||||
id: published.moduleId,
|
id: published.moduleId,
|
||||||
permissions: await grantedModulePermissionIds(evaluator, principal, published.permissions),
|
permissions: await grantedModulePermissionIds(evaluator, principal, published.permissions),
|
||||||
realtime: { baseUrl: moduleRealtimeBaseUrl(published.moduleId) },
|
realtime: { baseUrl: moduleRealtimeBaseUrl(published.moduleId) },
|
||||||
@@ -153,7 +214,7 @@ export async function describeMountableModules(
|
|||||||
): Promise<readonly MountableModuleDescription[]> {
|
): Promise<readonly MountableModuleDescription[]> {
|
||||||
return Promise.all(
|
return Promise.all(
|
||||||
registry.list().map(async (published) => ({
|
registry.list().map(async (published) => ({
|
||||||
descriptor: await describeModule(evaluator, principal, published),
|
descriptor: await describeModule(evaluator, principal, published, registry),
|
||||||
name: published.name,
|
name: published.name,
|
||||||
})),
|
})),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -89,6 +89,26 @@ export function moduleDisplayUiArtifactUrl(identity: ModuleUiIdentity): string {
|
|||||||
return `${moduleUiArtifactUrl(identity)}/display`;
|
return `${moduleUiArtifactUrl(identity)}/display`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Canonical browser URL for one of a module's declared browser contributions (ADR 0041).
|
||||||
|
*
|
||||||
|
* The literal `contributions` segment is host-owned and cannot collide with the two-segment artifact
|
||||||
|
* route or the `display` sibling. The contribution id is percent-encoded for the same reason the
|
||||||
|
* module id and version are: a value that somehow did not come from a validated manifest can only
|
||||||
|
* ever become one segment's worth of text — never a new segment, a traversal, a query, or a
|
||||||
|
* fragment — and a value that needed encoding simply fails to resolve against the registry.
|
||||||
|
*
|
||||||
|
* Like every other publication helper, this takes **identity only**. There is no parameter for a
|
||||||
|
* filename or any other artifact selector, so no caller can use it to reach a second file inside a
|
||||||
|
* module package.
|
||||||
|
*/
|
||||||
|
export function moduleContributionArtifactUrl(
|
||||||
|
identity: ModuleUiIdentity,
|
||||||
|
contributionId: string,
|
||||||
|
): string {
|
||||||
|
return `${moduleUiArtifactUrl(identity)}/contributions/${encodeURIComponent(contributionId)}`;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* One 404 for every "there is nothing to publish here" case: the module is not installed, the
|
* One 404 for every "there is nothing to publish here" case: the module is not installed, the
|
||||||
* installed module declares no artifact for the requested UI role, or the requested version is not
|
* installed module declares no artifact for the requested UI role, or the requested version is not
|
||||||
@@ -191,9 +211,9 @@ export interface CreateModuleUiRoutesOptions {
|
|||||||
*
|
*
|
||||||
* ## The request never touches the filesystem
|
* ## The request never touches the filesystem
|
||||||
*
|
*
|
||||||
* `:moduleId` and `:version` are **lookup keys, not path components**. They are handed to
|
* `:moduleId`, `:version`, and (for a contribution) `:contributionId` are **lookup keys, not path
|
||||||
* `registry.resolve` and, on an exact match, the already-canonical `artifactPath` that Core's
|
* components**. They are handed to the registry and, on an exact match, the already-canonical
|
||||||
* discovery resolved and validated is read directly. No request value is ever joined, resolved,
|
* `artifactPath` that Core's discovery resolved and validated is read directly. No request value is ever joined, resolved,
|
||||||
* normalized, or concatenated into a path, and there is no wildcard segment for one to hide in — so
|
* normalized, or concatenated into a path, and there is no wildcard segment for one to hide in — so
|
||||||
* traversal, an encoded separator, a symlink, a sibling file, a source map, and a package-root file
|
* traversal, an encoded separator, a symlink, a sibling file, a source map, and a package-root file
|
||||||
* are all unreachable by construction rather than by filtering. An installed module directory never
|
* are all unreachable by construction rather than by filtering. An installed module directory never
|
||||||
@@ -228,6 +248,24 @@ export interface CreateModuleUiRoutesOptions {
|
|||||||
export function createModuleUiRoutes(options: CreateModuleUiRoutesOptions): Hono {
|
export function createModuleUiRoutes(options: CreateModuleUiRoutesOptions): Hono {
|
||||||
const routes = new Hono();
|
const routes = new Hono();
|
||||||
|
|
||||||
|
routes.get(
|
||||||
|
`${MODULE_UI_ASSET_PREFIX}/:moduleId/:version/contributions/:contributionId`,
|
||||||
|
async (c) => {
|
||||||
|
// Three lookup keys, one exact registry match, one already-validated canonical path. Nothing
|
||||||
|
// is composed, so a contribution artifact is reachable on exactly the same terms as a module
|
||||||
|
// UI artifact and on no others.
|
||||||
|
const published = options.registry.resolveContribution(
|
||||||
|
c.req.param("moduleId"),
|
||||||
|
c.req.param("version"),
|
||||||
|
c.req.param("contributionId"),
|
||||||
|
);
|
||||||
|
if (!published) {
|
||||||
|
return notPublished(c);
|
||||||
|
}
|
||||||
|
return serveArtifact(c, published);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
routes.get(`${MODULE_UI_ASSET_PREFIX}/:moduleId/:version/display`, async (c) => {
|
routes.get(`${MODULE_UI_ASSET_PREFIX}/:moduleId/:version/display`, async (c) => {
|
||||||
const published = options.registry.resolveDisplay(
|
const published = options.registry.resolveDisplay(
|
||||||
c.req.param("moduleId"),
|
c.req.param("moduleId"),
|
||||||
|
|||||||
@@ -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,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
+108
-114
@@ -1,27 +1,30 @@
|
|||||||
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,
|
||||||
} from "@ekklesia/core/authorization";
|
} from "@ekklesia/core/authorization";
|
||||||
import { createDashboardStore } from "@ekklesia/core/dashboard";
|
import { createDashboardStore } from "@ekklesia/core/dashboard";
|
||||||
import { createDeviceStore } from "@ekklesia/core/devices";
|
import { createDeviceStore } from "@ekklesia/core/devices";
|
||||||
import { createIdentityRuntime, type IdentityRateLimitOverride } from "@ekklesia/core/identity";
|
import { type HostEnvironment, loadHostConfiguration } from "@ekklesia/core/host";
|
||||||
|
import {
|
||||||
|
createIdentityNotifications,
|
||||||
|
createIdentityRuntime,
|
||||||
|
type IdentityNotificationReporter,
|
||||||
|
type IdentityRateLimitOverride,
|
||||||
|
} from "@ekklesia/core/identity";
|
||||||
import { createSmtpMailProvider, type MailProvider } from "@ekklesia/core/mail";
|
import { createSmtpMailProvider, type MailProvider } from "@ekklesia/core/mail";
|
||||||
import {
|
import {
|
||||||
createEnvironmentModuleSecretSource,
|
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,
|
||||||
@@ -35,15 +38,16 @@ import { type ServerType, serve } from "@hono/node-server";
|
|||||||
import type { Hono } from "hono";
|
import type { Hono } from "hono";
|
||||||
import { createApiApp } from "./app.js";
|
import { createApiApp } from "./app.js";
|
||||||
import {
|
import {
|
||||||
type ClientAddressConfigInput,
|
|
||||||
type ClientAddressResolver,
|
type ClientAddressResolver,
|
||||||
createClientAddressResolver,
|
createClientAddressResolver,
|
||||||
wrapFetchWithClientAddress,
|
wrapFetchWithClientAddress,
|
||||||
} from "./client-address.js";
|
} from "./client-address.js";
|
||||||
import { type ApiRuntimeConfig, createApiRuntimeConfig } from "./config.js";
|
import { type ApiRuntimeConfig, apiTrustedOrigins, createApiRuntimeConfig } from "./config.js";
|
||||||
|
import { loadDashboardHostAssets } from "./dashboard-host-assets.js";
|
||||||
import { type ApiDatabaseHandle, createApiDatabase } from "./database.js";
|
import { type ApiDatabaseHandle, createApiDatabase } from "./database.js";
|
||||||
import { withDevicePrincipals } from "./device-principal.js";
|
import { withDevicePrincipals } from "./device-principal.js";
|
||||||
import { loadDisplayHostAssets } from "./display-host-assets.js";
|
import { loadDisplayHostAssets } from "./display-host-assets.js";
|
||||||
|
import { READINESS_DATABASE_TIMEOUT_MS } from "./health-routes.js";
|
||||||
import { prepareStartupModuleInventory } from "./platform-permissions.js";
|
import { prepareStartupModuleInventory } from "./platform-permissions.js";
|
||||||
|
|
||||||
const connectionTimeoutMs = 10_000;
|
const connectionTimeoutMs = 10_000;
|
||||||
@@ -139,8 +143,21 @@ export interface StartApiOptions {
|
|||||||
/** Test-only escape hatch: never set by the executable entry point. See
|
/** Test-only escape hatch: never set by the executable entry point. See
|
||||||
* `IdentityRateLimitOverride` for why production must leave this unset. */
|
* `IdentityRateLimitOverride` for why production must leave this unset. */
|
||||||
readonly rateLimitOverride?: IdentityRateLimitOverride;
|
readonly rateLimitOverride?: IdentityRateLimitOverride;
|
||||||
/** Test-only escape hatch: overrides the directory module discovery scans. Never set by the
|
/**
|
||||||
* executable entry point. See `prepareStartupModuleInventory`. */
|
* Where a verification or reset message that could not be delivered is reported.
|
||||||
|
*
|
||||||
|
* Production composition leaves this unset and one fixed sanitized line is written. It exists as a
|
||||||
|
* seam because the *only* externally observable difference between "the relay is down" and "there
|
||||||
|
* is no such account" must be this report — every HTTP response is deliberately identical — so a
|
||||||
|
* test needs somewhere to observe the operational failure the user is never shown.
|
||||||
|
*/
|
||||||
|
readonly reportNotificationFailure?: IdentityNotificationReporter;
|
||||||
|
/** The deployment's raw `EKKLESIA_MODULES_DIR` (ADR 0048), a production input the executable
|
||||||
|
* entry point always passes from host configuration. See `prepareStartupModuleInventory`. */
|
||||||
|
readonly configuredModulesDirectory?: string | undefined;
|
||||||
|
/** Test-only escape hatch: overrides the directory module discovery scans, taking precedence
|
||||||
|
* over `configuredModulesDirectory`. Never set by the executable entry point. See
|
||||||
|
* `prepareStartupModuleInventory`. */
|
||||||
readonly modulesDirectoryOverride?: string;
|
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
|
||||||
@@ -374,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
|
||||||
@@ -414,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 }),
|
||||||
@@ -431,20 +446,37 @@ export async function startApi(
|
|||||||
transport: config.databaseTransport,
|
transport: config.databaseTransport,
|
||||||
});
|
});
|
||||||
|
|
||||||
let services: ModuleRuntimeServices;
|
// Both host providers are constructed from validated configuration **before** anything that uses
|
||||||
|
// them, and neither touches the network while being built: an S3 client opens no connection and a
|
||||||
|
// Nodemailer transport opens no socket, so an unreachable relay or storage service can never stop
|
||||||
|
// the platform from booting (ADR 0030, ADR 0037). An installation with neither configured gets
|
||||||
|
// `undefined` for both, which is a complete, valid deployment.
|
||||||
|
const storageProvider = config.storage ? createS3StorageProvider(config.storage) : undefined;
|
||||||
|
const mailProvider = config.mail ? createSmtpMailProvider(config.mail) : undefined;
|
||||||
|
|
||||||
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,
|
||||||
|
// Identity's two outbound messages, composed here — the one place that knows both the
|
||||||
|
// validated identity configuration and the host's mail provider. Core's identity code learns
|
||||||
|
// a recipient, a subject, and two bodies and nothing whatsoever about SMTP (ADR 0038).
|
||||||
|
notifications: createIdentityNotifications({
|
||||||
|
authBaseUrl: config.identity.baseUrl,
|
||||||
|
dashboardOrigin: config.identity.dashboardOrigin,
|
||||||
|
mail: mailProvider,
|
||||||
|
...(options.reportNotificationFailure ? { report: options.reportNotificationFailure } : {}),
|
||||||
|
}),
|
||||||
...(options.rateLimitOverride ? { rateLimitOverride: options.rateLimitOverride } : {}),
|
...(options.rateLimitOverride ? { rateLimitOverride: options.rateLimitOverride } : {}),
|
||||||
});
|
});
|
||||||
authorizationStore = createAuthorizationStore(databaseHandle.authorization);
|
authorizationStore = createAuthorizationStore(databaseHandle.authorization);
|
||||||
deviceStore = createDeviceStore(databaseHandle.devices);
|
deviceStore = createDeviceStore(databaseHandle.devices);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
await safeCloseStorage(storageProvider);
|
||||||
|
await safeCloseMail(mailProvider);
|
||||||
await safeCloseDatabase(databaseHandle);
|
await safeCloseDatabase(databaseHandle);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
@@ -461,21 +493,6 @@ export async function startApi(
|
|||||||
// start, before a single module entrypoint is imported. A failure here fails startup rather than
|
// start, before a single module entrypoint is imported. A failure here fails startup rather than
|
||||||
// falling back to "start everything": the platform cannot know what it was told, and starting a
|
// falling back to "start everything": the platform cannot know what it was told, and starting a
|
||||||
// module an administrator disabled is worse than not starting at all.
|
// module an administrator disabled is worse than not starting at all.
|
||||||
// The host's object-storage provider, constructed from validated configuration before any module
|
|
||||||
// runs — and **without touching the network**. Building the client opens no connection, so a
|
|
||||||
// storage outage can never stop the platform from booting; a bucket is ensured lazily on first
|
|
||||||
// use instead (ADR 0030). An installation with no storage configured gets `undefined` here, which
|
|
||||||
// is a complete, valid deployment.
|
|
||||||
const storageProvider = config.storage ? createS3StorageProvider(config.storage) : undefined;
|
|
||||||
|
|
||||||
// The host's outbound mail provider, constructed from validated configuration and — like the
|
|
||||||
// storage client above — **without touching the network**. Building a Nodemailer transport opens
|
|
||||||
// no socket and sends nothing, so an unreachable relay can never stop the platform from booting;
|
|
||||||
// the first connection happens when something actually sends (ADR 0037). Nothing sends yet: this
|
|
||||||
// exists so Pass 2 can hand it to Better Auth's verification and reset hooks. An installation with
|
|
||||||
// no SMTP configured gets `undefined`, which is a complete, valid deployment.
|
|
||||||
const mailProvider = config.mail ? createSmtpMailProvider(config.mail) : undefined;
|
|
||||||
|
|
||||||
const moduleLifecycleStore = createModuleLifecycleStore(databaseHandle.moduleLifecycle);
|
const moduleLifecycleStore = createModuleLifecycleStore(databaseHandle.moduleLifecycle);
|
||||||
const moduleConfigurationStore = createModuleConfigurationStore(
|
const moduleConfigurationStore = createModuleConfigurationStore(
|
||||||
databaseHandle.moduleConfiguration,
|
databaseHandle.moduleConfiguration,
|
||||||
@@ -508,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(
|
||||||
@@ -525,9 +551,15 @@ export async function startApi(
|
|||||||
// starts and application composition succeeds.
|
// starts and application composition succeeds.
|
||||||
const moduleUi = createModuleUiPublicationRegistry(eligibility.eligibleModules);
|
const moduleUi = createModuleUiPublicationRegistry(eligibility.eligibleModules);
|
||||||
let displayHostAssets: Awaited<ReturnType<typeof loadDisplayHostAssets>> | undefined;
|
let displayHostAssets: Awaited<ReturnType<typeof loadDisplayHostAssets>> | undefined;
|
||||||
|
let dashboardHostAssets: Awaited<ReturnType<typeof loadDashboardHostAssets>>;
|
||||||
try {
|
try {
|
||||||
displayHostAssets =
|
displayHostAssets =
|
||||||
moduleUi.listDisplays().length === 0 ? undefined : await loadDisplayHostAssets();
|
moduleUi.listDisplays().length === 0 ? undefined : await loadDisplayHostAssets();
|
||||||
|
// Unconditional, unlike the display host: every installation serves its Dashboard (ADR 0044),
|
||||||
|
// so a missing shell build fails here, before any module code is imported.
|
||||||
|
// The page learns its API origin from the API that serves it, so one build serves either
|
||||||
|
// topology (ADR 0045).
|
||||||
|
dashboardHostAssets = await loadDashboardHostAssets(config.identity.baseUrl);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
await safeCloseStorage(storageProvider);
|
await safeCloseStorage(storageProvider);
|
||||||
await safeCloseMail(mailProvider);
|
await safeCloseMail(mailProvider);
|
||||||
@@ -559,6 +591,10 @@ export async function startApi(
|
|||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Set by the first `shutdown()` call, before anything is drained or stopped, so readiness turns
|
||||||
|
// `503` while the instance still finishes in-flight requests.
|
||||||
|
let shuttingDown = false;
|
||||||
|
|
||||||
let app: Hono;
|
let app: Hono;
|
||||||
try {
|
try {
|
||||||
app = createApiApp({
|
app = createApiApp({
|
||||||
@@ -603,6 +639,13 @@ export async function startApi(
|
|||||||
},
|
},
|
||||||
// A typed view over the same single pool as identity, authorization, and the module facade.
|
// A typed view over the same single pool as identity, authorization, and the module facade.
|
||||||
dashboard: createDashboardStore(databaseHandle.dashboard),
|
dashboard: createDashboardStore(databaseHandle.dashboard),
|
||||||
|
dashboardHostAssets,
|
||||||
|
// The app is only composed after `startDiscoveredModuleRuntime` resolved, so module startup
|
||||||
|
// has completed whenever it can answer; it stops counting as complete once shutdown begins.
|
||||||
|
health: {
|
||||||
|
checkDatabase: () => databaseHandle.ping(READINESS_DATABASE_TIMEOUT_MS),
|
||||||
|
isModuleStartupComplete: () => !shuttingDown,
|
||||||
|
},
|
||||||
devices: {
|
devices: {
|
||||||
// `Secure` follows the deployment's own scheme, read from the already-validated identity
|
// `Secure` follows the deployment's own scheme, read from the already-validated identity
|
||||||
// base URL rather than from any request. Production is https by configuration policy, so a
|
// base URL rather than from any request. Production is https by configuration policy, so a
|
||||||
@@ -614,7 +657,8 @@ export async function startApi(
|
|||||||
// Principal resolution, extended with managed devices. A human session still wins, and this
|
// Principal resolution, extended with managed devices. A human session still wins, and this
|
||||||
// is the only place a device credential ever becomes a principal.
|
// is the only place a device credential ever becomes a principal.
|
||||||
runtime: withDevicePrincipals({ devices: deviceStore, runtime }),
|
runtime: withDevicePrincipals({ devices: deviceStore, runtime }),
|
||||||
trustedOrigins: config.identity.trustedOrigins,
|
// The configured origins plus the API's own (ADR 0045).
|
||||||
|
trustedOrigins: apiTrustedOrigins(config.identity),
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
await safeStopModuleRuntime(moduleRuntime);
|
await safeStopModuleRuntime(moduleRuntime);
|
||||||
@@ -660,6 +704,7 @@ export async function startApi(
|
|||||||
let shutdownPromise: Promise<void> | undefined;
|
let shutdownPromise: Promise<void> | undefined;
|
||||||
const shutdown = (): Promise<void> => {
|
const shutdown = (): Promise<void> => {
|
||||||
if (!shutdownPromise) {
|
if (!shutdownPromise) {
|
||||||
|
shuttingDown = true;
|
||||||
shutdownPromise = (async () => {
|
shutdownPromise = (async () => {
|
||||||
// Close long-lived streams so HTTP can drain, but leave publishers active until the drain
|
// Close long-lived streams so HTTP can drain, but leave publishers active until the drain
|
||||||
// completes: an in-flight module command may still commit and publish its resulting state.
|
// completes: an in-flight module command may still commit and publish its resulting state.
|
||||||
@@ -719,97 +764,46 @@ export function installShutdownSignalHandlers(
|
|||||||
process.on("SIGTERM", handleSignal);
|
process.on("SIGTERM", handleSignal);
|
||||||
}
|
}
|
||||||
|
|
||||||
function readClientAddressInputFromEnvironment(): ClientAddressConfigInput | undefined {
|
/**
|
||||||
const mode = process.env.EKKLESIA_CLIENT_ADDRESS_MODE;
|
* Turns one deployment environment into this process's startup inputs.
|
||||||
if (mode === undefined) {
|
*
|
||||||
// Omitted entirely: `createClientAddressConfig` defaults to direct mode. Reading `header`/
|
* Every host infrastructure value comes from `loadHostConfiguration` — the same function
|
||||||
// `trustedProxies` only when `mode` is actually set keeps an operator from accidentally
|
* `pnpm doctor` calls with the same environment — so the runtime and the diagnostic that reports on
|
||||||
// half-configuring proxy mode by setting only one of the three variables.
|
* it can never disagree about what this deployment is configured to use. Only the two values that
|
||||||
return undefined;
|
* describe *this listener* rather than the host's infrastructure are read here.
|
||||||
}
|
*/
|
||||||
const trustedProxiesValue = process.env.EKKLESIA_CLIENT_ADDRESS_TRUSTED_PROXIES;
|
export function readConfigFromEnvironment(processEnvironment: HostEnvironment): {
|
||||||
return {
|
|
||||||
header: process.env.EKKLESIA_CLIENT_ADDRESS_HEADER,
|
|
||||||
mode,
|
|
||||||
trustedProxies: trustedProxiesValue
|
|
||||||
?.split(",")
|
|
||||||
.map((entry) => entry.trim())
|
|
||||||
.filter((entry) => entry.length > 0),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function readConfigFromEnvironment(): {
|
|
||||||
readonly input: Parameters<typeof createApiRuntimeConfig>[0];
|
readonly input: Parameters<typeof createApiRuntimeConfig>[0];
|
||||||
readonly environment: "development" | "production";
|
readonly environment: "development" | "production";
|
||||||
|
readonly moduleSecrets: ModuleSecretSource;
|
||||||
|
readonly modulesDirectory: string | undefined;
|
||||||
} {
|
} {
|
||||||
const trustedOriginsValue = process.env.EKKLESIA_IDENTITY_TRUSTED_ORIGINS;
|
const host = loadHostConfiguration(processEnvironment);
|
||||||
const trustedOrigins = trustedOriginsValue
|
|
||||||
?.split(",")
|
|
||||||
.map((origin) => origin.trim())
|
|
||||||
.filter((origin) => origin.length > 0);
|
|
||||||
const clientAddress = readClientAddressInputFromEnvironment();
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
environment: process.env.NODE_ENV === "development" ? "development" : "production",
|
environment: host.environment,
|
||||||
input: {
|
input: {
|
||||||
...(clientAddress === undefined ? {} : { clientAddress }),
|
clientAddress: host.clientAddress,
|
||||||
databaseTransport: {
|
databaseTransport: host.databaseTransport,
|
||||||
...(process.env.EKKLESIA_DATABASE_CA === undefined
|
databaseUrl: host.databaseUrl,
|
||||||
? {}
|
host: processEnvironment.EKKLESIA_API_HOST,
|
||||||
: { certificateAuthority: process.env.EKKLESIA_DATABASE_CA }),
|
identity: host.identity,
|
||||||
...(process.env.EKKLESIA_DATABASE_TLS_MODE === undefined
|
mail: host.mail,
|
||||||
? {}
|
port: processEnvironment.EKKLESIA_API_PORT,
|
||||||
: { mode: process.env.EKKLESIA_DATABASE_TLS_MODE }),
|
storage: host.storage,
|
||||||
},
|
|
||||||
databaseUrl: process.env.DATABASE_URL,
|
|
||||||
host: process.env.EKKLESIA_API_HOST,
|
|
||||||
identity: {
|
|
||||||
baseUrl: process.env.EKKLESIA_IDENTITY_BASE_URL,
|
|
||||||
publicSignupEnabled: process.env.EKKLESIA_PUBLIC_SIGNUP_ENABLED,
|
|
||||||
secret: process.env.EKKLESIA_IDENTITY_SECRET,
|
|
||||||
trustedOrigins,
|
|
||||||
},
|
|
||||||
port: process.env.EKKLESIA_API_PORT,
|
|
||||||
// The one place this process turns its environment into storage configuration. The names are
|
|
||||||
// the `S3_*` ones ADR 0004 already established for this repository's Compose stack, and Core's
|
|
||||||
// validator never reads `process.env` itself.
|
|
||||||
// The one place this process turns its environment into mail configuration. Names follow the
|
|
||||||
// platform's own `EKKLESIA_<DOMAIN>_<FIELD>` convention rather than a provider's, and Core's
|
|
||||||
// validator never reads `process.env` itself. Absent host means this installation sends no
|
|
||||||
// outbound email, which is a complete deployment (ADR 0037).
|
|
||||||
mail: {
|
|
||||||
fromAddress: process.env.EKKLESIA_SMTP_FROM_ADDRESS,
|
|
||||||
fromName: process.env.EKKLESIA_SMTP_FROM_NAME,
|
|
||||||
host: process.env.EKKLESIA_SMTP_HOST,
|
|
||||||
// Optional as a pair: a relay authorized by source IP has neither.
|
|
||||||
password: process.env.EKKLESIA_SMTP_PASSWORD,
|
|
||||||
port: process.env.EKKLESIA_SMTP_PORT,
|
|
||||||
security: process.env.EKKLESIA_SMTP_SECURITY,
|
|
||||||
username: process.env.EKKLESIA_SMTP_USERNAME,
|
|
||||||
},
|
|
||||||
storage: {
|
|
||||||
accessKey: process.env.S3_ACCESS_KEY,
|
|
||||||
bucketPrefix: process.env.S3_BUCKET_PREFIX,
|
|
||||||
// Optional, and unset is the ordinary case: it matters only where the name Core reaches
|
|
||||||
// storage by is not a name a browser could resolve.
|
|
||||||
deliveryEndpoint: process.env.S3_DELIVERY_ENDPOINT,
|
|
||||||
endpoint: process.env.S3_ENDPOINT,
|
|
||||||
forcePathStyle: process.env.S3_FORCE_PATH_STYLE,
|
|
||||||
region: process.env.S3_REGION,
|
|
||||||
secretKey: process.env.S3_SECRET_KEY,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
|
moduleSecrets: host.moduleSecrets,
|
||||||
|
modulesDirectory: host.modulesDirectory,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function main(): Promise<void> {
|
export async function main(): Promise<void> {
|
||||||
const { environment, input } = readConfigFromEnvironment();
|
const { environment, input, moduleSecrets, modulesDirectory } = readConfigFromEnvironment(
|
||||||
|
process.env,
|
||||||
|
);
|
||||||
const config = createApiRuntimeConfig(input, { environment });
|
const config = createApiRuntimeConfig(input, { environment });
|
||||||
// The one place this process turns its environment into module secrets. Core's factory takes the
|
|
||||||
// environment as an argument and never touches `process.env` itself, preserving the rule that
|
|
||||||
// only executables read the environment.
|
|
||||||
const running = await startApi(config, {
|
const running = await startApi(config, {
|
||||||
moduleSecrets: createEnvironmentModuleSecretSource(process.env),
|
configuredModulesDirectory: modulesDirectory,
|
||||||
|
moduleSecrets,
|
||||||
});
|
});
|
||||||
|
|
||||||
const address = running.server.address();
|
const address = running.server.address();
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { randomBytes } from "node:crypto";
|
import { randomBytes } from "node:crypto";
|
||||||
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||||
import { request as httpRequest } from "node:http";
|
import { request as httpRequest } from "node:http";
|
||||||
import net from "node:net";
|
import net from "node:net";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
@@ -26,7 +26,19 @@ import {
|
|||||||
import { Client, type QueryResultRow } from "pg";
|
import { Client, type QueryResultRow } from "pg";
|
||||||
import { afterAll, describe, expect, it, vi } from "vitest";
|
import { afterAll, describe, expect, it, vi } from "vitest";
|
||||||
import type { ClientAddressConfigInput } from "../src/client-address.js";
|
import type { ClientAddressConfigInput } from "../src/client-address.js";
|
||||||
|
import { dashboardHostAssetUrls } from "@ekklesia/shell/dashboard-assets";
|
||||||
import { createApiRuntimeConfig } from "../src/config.js";
|
import { createApiRuntimeConfig } from "../src/config.js";
|
||||||
|
import { createApiDatabase } from "../src/database.js";
|
||||||
|
import { DASHBOARD_API_ORIGIN_PLACEHOLDER } from "../src/dashboard-host-assets.js";
|
||||||
|
import {
|
||||||
|
DASHBOARD_HOST_PAGE_PATH,
|
||||||
|
DASHBOARD_HOST_SCRIPT_PATH,
|
||||||
|
} from "../src/dashboard-host-routes.js";
|
||||||
|
import {
|
||||||
|
HEALTH_LIVE_PATH,
|
||||||
|
HEALTH_READY_PATH,
|
||||||
|
READINESS_DATABASE_TIMEOUT_MS,
|
||||||
|
} from "../src/health-routes.js";
|
||||||
import {
|
import {
|
||||||
MODULE_UI_CACHE_CONTROL,
|
MODULE_UI_CACHE_CONTROL,
|
||||||
MODULE_UI_CONTENT_TYPE,
|
MODULE_UI_CONTENT_TYPE,
|
||||||
@@ -209,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
|
||||||
@@ -224,6 +236,7 @@ async function applyCoreMigrations(url: string): Promise<void> {
|
|||||||
"core:0004_module_lifecycle_state",
|
"core:0004_module_lifecycle_state",
|
||||||
"core:0005_module_configuration",
|
"core:0005_module_configuration",
|
||||||
"core:0006_device_identity",
|
"core:0006_device_identity",
|
||||||
|
"core:0007_verified_bootstrap_owners",
|
||||||
]);
|
]);
|
||||||
expect(aggregate.migrations[0]?.checksum).toBe(
|
expect(aggregate.migrations[0]?.checksum).toBe(
|
||||||
"f8f2086ef06f7a6943e8ffc4350bb010b37aed04c0a72030d2b153c986b1325f",
|
"f8f2086ef06f7a6943e8ffc4350bb010b37aed04c0a72030d2b153c986b1325f",
|
||||||
@@ -326,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: 6,
|
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",
|
||||||
@@ -373,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: 6,
|
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,
|
||||||
@@ -431,6 +444,39 @@ async function createFixtureModule(
|
|||||||
async handler(request, context) {
|
async handler(request, context) {
|
||||||
return Response.json({ created: true, principal: context.principal }, { status: 201 });
|
return Response.json({ created: true, principal: context.principal }, { status: 201 });
|
||||||
},
|
},
|
||||||
|
});
|
||||||
|
// An image-sized streamed upload (ADR 0042). The handler counts bytes as they
|
||||||
|
// arrive and never materializes the body, which is exactly what a real module
|
||||||
|
// storing an upload would do.
|
||||||
|
http.route({
|
||||||
|
method: "POST",
|
||||||
|
path: "/uploads",
|
||||||
|
permission: "calendar.events.read",
|
||||||
|
maxBodyBytes: 33554432,
|
||||||
|
bodyMode: "streamed",
|
||||||
|
async handler(request) {
|
||||||
|
let bytes = 0;
|
||||||
|
if (request.body) {
|
||||||
|
const reader = request.body.getReader();
|
||||||
|
for (;;) {
|
||||||
|
const chunk = await reader.read();
|
||||||
|
if (chunk.done) { break; }
|
||||||
|
bytes += chunk.value.byteLength;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Response.json({ bytes }, { status: 201 });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
http.route({
|
||||||
|
method: "POST",
|
||||||
|
path: "/small-uploads",
|
||||||
|
permission: "calendar.events.read",
|
||||||
|
maxBodyBytes: 64,
|
||||||
|
bodyMode: "streamed",
|
||||||
|
async handler(request) {
|
||||||
|
await request.arrayBuffer().catch(() => undefined);
|
||||||
|
return Response.json({ swallowed: true }, { status: 201 });
|
||||||
|
},
|
||||||
});`
|
});`
|
||||||
: ""
|
: ""
|
||||||
}
|
}
|
||||||
@@ -475,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: 6,
|
manifestVersion: 8,
|
||||||
name: `${id} module`,
|
name: `${id} module`,
|
||||||
version,
|
version,
|
||||||
}),
|
}),
|
||||||
@@ -522,6 +568,32 @@ function apiOrigin(running: RunningApi): string {
|
|||||||
return `http://127.0.0.1:${address.port}`;
|
return `http://127.0.0.1:${address.port}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Marks an address verified out of band, the same trusted step the initial-owner bootstrap performs
|
||||||
|
* (ADR 0038).
|
||||||
|
*
|
||||||
|
* Deliberately not a weakening of the policy: it is the test-harness equivalent of an operator with
|
||||||
|
* database credentials, used only so scenarios that are about something else are not obliged to run
|
||||||
|
* an SMTP server. That the policy itself is enforced — that an unverified account genuinely cannot
|
||||||
|
* sign in — is asserted separately, both in this file and in `email-verification.integration.ts`.
|
||||||
|
*/
|
||||||
|
async function markEmailVerified(origin: string, email: string): Promise<void> {
|
||||||
|
const url = databaseUrlByOrigin.get(origin);
|
||||||
|
if (!url) {
|
||||||
|
throw harnessError("No database is registered for that API origin.");
|
||||||
|
}
|
||||||
|
const client = new Client({ connectionString: url });
|
||||||
|
await client.connect();
|
||||||
|
try {
|
||||||
|
await client.query(
|
||||||
|
'update "ekklesia_core_user" set "email_verified" = true where lower("email") = $1',
|
||||||
|
[email.trim().toLowerCase()],
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
await client.end().catch(() => undefined);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
interface StartConfiguredApiOptions {
|
interface StartConfiguredApiOptions {
|
||||||
readonly clientAddress?: ClientAddressConfigInput;
|
readonly clientAddress?: ClientAddressConfigInput;
|
||||||
/** Test-only narrowing of the platform's 30-second graceful HTTP drain deadline. */
|
/** Test-only narrowing of the platform's 30-second graceful HTTP drain deadline. */
|
||||||
@@ -537,6 +609,16 @@ interface StartConfiguredApiOptions {
|
|||||||
readonly rateLimitOverride?: { readonly max: number; readonly window: number } | undefined;
|
readonly rateLimitOverride?: { readonly max: number; readonly window: number } | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The database each running API in this suite is attached to, keyed by its bound origin.
|
||||||
|
*
|
||||||
|
* Needed because Phase 7C Pass 2 made a verified address a precondition for holding a session
|
||||||
|
* (ADR 0038), so `signUp` below has to mark the account verified before these scenarios — which are
|
||||||
|
* about cookies, CSRF, principals, sessions, and authorization, not about verification — can sign
|
||||||
|
* in. Verification itself has its own dedicated end-to-end suite.
|
||||||
|
*/
|
||||||
|
const databaseUrlByOrigin = new Map<string, string>();
|
||||||
|
|
||||||
async function startConfiguredApi(
|
async function startConfiguredApi(
|
||||||
url: string,
|
url: string,
|
||||||
options: StartConfiguredApiOptions = {},
|
options: StartConfiguredApiOptions = {},
|
||||||
@@ -571,7 +653,7 @@ async function startConfiguredApi(
|
|||||||
},
|
},
|
||||||
{ environment },
|
{ environment },
|
||||||
);
|
);
|
||||||
return startApi(config, {
|
const running = await startApi(config, {
|
||||||
...("rateLimitOverride" in options
|
...("rateLimitOverride" in options
|
||||||
? { ...(options.rateLimitOverride ? { rateLimitOverride: options.rateLimitOverride } : {}) }
|
? { ...(options.rateLimitOverride ? { rateLimitOverride: options.rateLimitOverride } : {}) }
|
||||||
: { rateLimitOverride: { max: 1000, window: 1 } }),
|
: { rateLimitOverride: { max: 1000, window: 1 } }),
|
||||||
@@ -585,6 +667,8 @@ async function startConfiguredApi(
|
|||||||
? {}
|
? {}
|
||||||
: { drainTimeoutMsOverride: options.drainTimeoutMsOverride }),
|
: { drainTimeoutMsOverride: options.drainTimeoutMsOverride }),
|
||||||
});
|
});
|
||||||
|
databaseUrlByOrigin.set(apiOrigin(running), url);
|
||||||
|
return running;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function startMigratedApi(
|
async function startMigratedApi(
|
||||||
@@ -763,6 +847,12 @@ async function signUp(
|
|||||||
headers: { "content-type": "application/json", origin: dashboardOrigin, ...headers },
|
headers: { "content-type": "application/json", origin: dashboardOrigin, ...headers },
|
||||||
method: "POST",
|
method: "POST",
|
||||||
});
|
});
|
||||||
|
if (response.ok) {
|
||||||
|
// A verified address is a precondition for holding a session (ADR 0038). Sign-up itself always
|
||||||
|
// writes `emailVerified: false`, so these scenarios — none of which are about verification —
|
||||||
|
// complete that step out of band rather than standing up an SMTP server apiece.
|
||||||
|
await markEmailVerified(origin, email);
|
||||||
|
}
|
||||||
return { body: (await response.clone().json()) as SignUpResult["body"], response };
|
return { body: (await response.clone().json()) as SignUpResult["body"], response };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2500,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" });
|
||||||
@@ -2640,6 +2729,73 @@ describe.sequential("live API — installed module permission end-to-end", () =>
|
|||||||
expect(await trustedResponse.json()).toMatchObject({ created: true });
|
expect(await trustedResponse.json()).toMatchObject({ created: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("streams one image-sized upload end to end over real HTTP", async () => {
|
||||||
|
// 25 MiB — an ordinary photograph, and the size the streamed ceiling exists to accommodate. It
|
||||||
|
// travels through a real socket, a real Node server, and the platform's byte counter, and the
|
||||||
|
// platform holds none of it (ADR 0042).
|
||||||
|
const image = new Uint8Array(25 * 1024 * 1024).fill(0x41);
|
||||||
|
|
||||||
|
const response = await fetch(`${origin}/api/v1/modules/calendar/uploads`, {
|
||||||
|
body: image,
|
||||||
|
headers: {
|
||||||
|
"content-type": "application/octet-stream",
|
||||||
|
cookie: ownerCookie,
|
||||||
|
origin: dashboardOrigin,
|
||||||
|
},
|
||||||
|
method: "POST",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.status).toBe(201);
|
||||||
|
expect(await response.json()).toEqual({ bytes: image.byteLength });
|
||||||
|
}, 60_000);
|
||||||
|
|
||||||
|
it("refuses a declared oversize on a streamed route with a sanitized 413", async () => {
|
||||||
|
const response = await fetch(`${origin}/api/v1/modules/calendar/small-uploads`, {
|
||||||
|
body: "a".repeat(65),
|
||||||
|
headers: {
|
||||||
|
"content-type": "text/plain",
|
||||||
|
cookie: ownerCookie,
|
||||||
|
origin: dashboardOrigin,
|
||||||
|
},
|
||||||
|
method: "POST",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.status).toBe(413);
|
||||||
|
expect(await response.json()).toEqual({ code: "REQUEST_BODY_TOO_LARGE" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("terminates a chunked streamed body that exceeds the limit, even when the handler swallowed it", async () => {
|
||||||
|
// No declared `Content-Length`, so only the running byte count can bound this — and the
|
||||||
|
// handler deliberately ignores the stream error and answers `201`. The platform's `413` still
|
||||||
|
// wins: a limit a handler can swallow is not a limit.
|
||||||
|
const oversize = new ReadableStream<Uint8Array>({
|
||||||
|
pull(controller) {
|
||||||
|
controller.enqueue(new Uint8Array(512).fill(0x41));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await fetch(`${origin}/api/v1/modules/calendar/small-uploads`, {
|
||||||
|
body: oversize,
|
||||||
|
duplex: "half",
|
||||||
|
headers: { cookie: ownerCookie, origin: dashboardOrigin },
|
||||||
|
method: "POST",
|
||||||
|
} as RequestInit);
|
||||||
|
|
||||||
|
expect(response.status).toBe(413);
|
||||||
|
expect(await response.json()).toEqual({ code: "REQUEST_BODY_TOO_LARGE" });
|
||||||
|
}, 30_000);
|
||||||
|
|
||||||
|
it("keeps the server healthy after an oversized upload", async () => {
|
||||||
|
// The socket was released rather than left draining, so the very next request is served
|
||||||
|
// normally.
|
||||||
|
const response = await fetch(`${origin}/api/v1/modules/calendar/events/evt-43`, {
|
||||||
|
headers: { cookie: ownerCookie },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(await response.json()).toMatchObject({ eventId: "evt-43" });
|
||||||
|
});
|
||||||
|
|
||||||
it("rejects a well-formed permission absent from the composed registry, writing no grant or audit row", async () => {
|
it("rejects a well-formed permission absent from the composed registry, writing no grant or audit row", async () => {
|
||||||
const admin = await openAdministrativeClient(database.url);
|
const admin = await openAdministrativeClient(database.url);
|
||||||
try {
|
try {
|
||||||
@@ -2832,6 +2988,106 @@ describe.sequential("live API — published module UI artifact", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe.sequential("live API — the built Dashboard on the API's own origin", () => {
|
||||||
|
it("serves the built shell's index.html at /, with only its API origin written in, and its exact script, and nothing beside them", async () => {
|
||||||
|
const database = await createDisposableDatabase("dashboard_host");
|
||||||
|
await applyCoreMigrations(database.url);
|
||||||
|
const running = await startMigratedApi(database.url);
|
||||||
|
try {
|
||||||
|
const origin = apiOrigin(running);
|
||||||
|
const builtHtml = await readFile(new URL(dashboardHostAssetUrls.html), "utf8");
|
||||||
|
const builtScript = await readFile(new URL(dashboardHostAssetUrls.script), "utf8");
|
||||||
|
|
||||||
|
const page = await fetch(`${origin}${DASHBOARD_HOST_PAGE_PATH}`);
|
||||||
|
const html = await page.text();
|
||||||
|
expect(page.status).toBe(200);
|
||||||
|
expect(page.headers.get("content-type")).toBe("text/html; charset=utf-8");
|
||||||
|
expect(page.headers.get("cache-control")).toBe("no-cache");
|
||||||
|
expect(page.headers.get("x-frame-options")).toBe("DENY");
|
||||||
|
expect(page.headers.get("content-security-policy")).toBe("frame-ancestors 'none'");
|
||||||
|
expect(page.headers.get("referrer-policy")).toBe("no-referrer");
|
||||||
|
// The built page carries one empty API-origin element; the API fills in its own origin
|
||||||
|
// (the identity base URL) and changes nothing else (ADR 0045).
|
||||||
|
expect(builtHtml.split(DASHBOARD_API_ORIGIN_PLACEHOLDER)).toHaveLength(2);
|
||||||
|
expect(html).toBe(
|
||||||
|
builtHtml.replace(
|
||||||
|
DASHBOARD_API_ORIGIN_PLACEHOLDER,
|
||||||
|
`<meta content="${identityBaseUrl}" name="ekklesia-api-origin" />`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(html).toContain('id="ekklesia-host"');
|
||||||
|
expect(html).toContain(`<script src="${DASHBOARD_HOST_SCRIPT_PATH}" type="module"></script>`);
|
||||||
|
|
||||||
|
const revalidated = await fetch(`${origin}${DASHBOARD_HOST_PAGE_PATH}`, {
|
||||||
|
headers: { "if-none-match": page.headers.get("etag") ?? "" },
|
||||||
|
});
|
||||||
|
expect(revalidated.status).toBe(304);
|
||||||
|
|
||||||
|
const script = await fetch(`${origin}${DASHBOARD_HOST_SCRIPT_PATH}`);
|
||||||
|
expect(script.status).toBe(200);
|
||||||
|
expect(script.headers.get("content-type")).toBe("text/javascript; charset=utf-8");
|
||||||
|
expect(await script.text()).toBe(builtScript);
|
||||||
|
|
||||||
|
// The source map sits beside the script on disk and is never published.
|
||||||
|
const sourceMap = await fetch(`${origin}${DASHBOARD_HOST_SCRIPT_PATH}.map`);
|
||||||
|
expect(sourceMap.status).toBe(404);
|
||||||
|
expect(await sourceMap.json()).toEqual({ code: "NOT_FOUND" });
|
||||||
|
} finally {
|
||||||
|
await running.shutdown();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe.sequential("live API — health", () => {
|
||||||
|
it("answers liveness and readiness with 200 against a migrated disposable PostgreSQL", async () => {
|
||||||
|
const database = await createDisposableDatabase("health");
|
||||||
|
await applyCoreMigrations(database.url);
|
||||||
|
const running = await startMigratedApi(database.url);
|
||||||
|
try {
|
||||||
|
const origin = apiOrigin(running);
|
||||||
|
|
||||||
|
const live = await fetch(`${origin}${HEALTH_LIVE_PATH}`);
|
||||||
|
expect(live.status).toBe(200);
|
||||||
|
expect(live.headers.get("cache-control")).toBe("no-store");
|
||||||
|
expect(await live.json()).toEqual({ status: "live" });
|
||||||
|
|
||||||
|
const ready = await fetch(`${origin}${HEALTH_READY_PATH}`);
|
||||||
|
expect(ready.status).toBe(200);
|
||||||
|
expect(ready.headers.get("cache-control")).toBe("no-store");
|
||||||
|
expect(await ready.json()).toEqual({ status: "ready" });
|
||||||
|
} finally {
|
||||||
|
await running.shutdown();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("pings through the shared pool, and PostgreSQL itself cancels a statement that outlives the ping's transaction-local timeout", async () => {
|
||||||
|
const database = await createDisposableDatabase("health_ping");
|
||||||
|
const handle = createApiDatabase(database.url);
|
||||||
|
try {
|
||||||
|
await expect(handle.ping(READINESS_DATABASE_TIMEOUT_MS)).resolves.toBeUndefined();
|
||||||
|
} finally {
|
||||||
|
await handle.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
// The ping's exact statement shape, with a statement that cannot finish inside its timeout: the
|
||||||
|
// server cancels it (query_canceled) long before the sleep would end, on a client that has no
|
||||||
|
// client-side timeout of its own.
|
||||||
|
const client = new Client({ connectionString: database.url });
|
||||||
|
await client.connect();
|
||||||
|
try {
|
||||||
|
const startedAt = Date.now();
|
||||||
|
await expect(
|
||||||
|
client.query(
|
||||||
|
"BEGIN READ ONLY; SET LOCAL statement_timeout = 50; SELECT pg_sleep(5); COMMIT",
|
||||||
|
),
|
||||||
|
).rejects.toMatchObject({ code: "57014" });
|
||||||
|
expect(Date.now() - startedAt).toBeLessThan(2_000);
|
||||||
|
} finally {
|
||||||
|
await client.end();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe.sequential("live API — public signup is disabled by default", () => {
|
describe.sequential("live API — public signup is disabled by default", () => {
|
||||||
it("rejects sign-up with a stable sanitized 400 when publicSignupEnabled is not set, creating no user row", async () => {
|
it("rejects sign-up with a stable sanitized 400 when publicSignupEnabled is not set, creating no user row", async () => {
|
||||||
const database = await createDisposableDatabase("signup_disabled");
|
const database = await createDisposableDatabase("signup_disabled");
|
||||||
@@ -3650,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"]);
|
||||||
|
|||||||
+154
-4
@@ -1,13 +1,16 @@
|
|||||||
import {
|
import {
|
||||||
ANONYMOUS_PRINCIPAL,
|
ANONYMOUS_PRINCIPAL,
|
||||||
createAuthenticatedPrincipal,
|
createAuthenticatedPrincipal,
|
||||||
type ModuleDatabase,
|
moduleIdSchema,
|
||||||
type ModuleRuntimeServices,
|
parseEkklesiaUserId,
|
||||||
|
parsePermissionId,
|
||||||
} from "@ekklesia/contracts";
|
} from "@ekklesia/contracts";
|
||||||
import {
|
import {
|
||||||
type AuthorizationEvaluator,
|
type AuthorizationEvaluator,
|
||||||
type AuthorizationStore,
|
type AuthorizationStore,
|
||||||
|
CORE_PERMISSION_DECLARATIONS,
|
||||||
CORE_PERMISSION_REGISTRY,
|
CORE_PERMISSION_REGISTRY,
|
||||||
|
createPermissionRegistry,
|
||||||
type PermissionRegistry,
|
type PermissionRegistry,
|
||||||
} from "@ekklesia/core/authorization";
|
} from "@ekklesia/core/authorization";
|
||||||
import type { EkklesiaIdentityRuntime } from "@ekklesia/core/identity";
|
import type { EkklesiaIdentityRuntime } from "@ekklesia/core/identity";
|
||||||
@@ -15,7 +18,10 @@ 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,
|
||||||
} from "@ekklesia/core/modules";
|
} from "@ekklesia/core/modules";
|
||||||
import { describe, expect, it, vi } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
import {
|
import {
|
||||||
@@ -25,6 +31,8 @@ import {
|
|||||||
} from "../src/app.js";
|
} from "../src/app.js";
|
||||||
import { AUTH_REQUEST_MAX_BODY_BYTES } from "../src/body-limits.js";
|
import { AUTH_REQUEST_MAX_BODY_BYTES } from "../src/body-limits.js";
|
||||||
import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
|
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 { 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 { 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";
|
||||||
@@ -40,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(),
|
||||||
@@ -104,6 +112,8 @@ function createAuthWiringApp(runtime: EkklesiaIdentityRuntime) {
|
|||||||
moduleConfiguration: emptyModuleConfiguration(),
|
moduleConfiguration: emptyModuleConfiguration(),
|
||||||
moduleStorage: emptyModuleStorage(),
|
moduleStorage: emptyModuleStorage(),
|
||||||
dashboard: createInMemoryDashboardStore(),
|
dashboard: createInMemoryDashboardStore(),
|
||||||
|
dashboardHostAssets: testDashboardHostAssets(),
|
||||||
|
health: readyHealth(),
|
||||||
moduleServices: fakeModuleServices(),
|
moduleServices: fakeModuleServices(),
|
||||||
authorization: fakeAuthorization({}),
|
authorization: fakeAuthorization({}),
|
||||||
runtime,
|
runtime,
|
||||||
@@ -121,6 +131,8 @@ describe("Ekklesia API Hono application", () => {
|
|||||||
moduleConfiguration: emptyModuleConfiguration(),
|
moduleConfiguration: emptyModuleConfiguration(),
|
||||||
moduleStorage: emptyModuleStorage(),
|
moduleStorage: emptyModuleStorage(),
|
||||||
dashboard: createInMemoryDashboardStore(),
|
dashboard: createInMemoryDashboardStore(),
|
||||||
|
dashboardHostAssets: testDashboardHostAssets(),
|
||||||
|
health: readyHealth(),
|
||||||
moduleServices: fakeModuleServices(),
|
moduleServices: fakeModuleServices(),
|
||||||
authorization: fakeAuthorization({}),
|
authorization: fakeAuthorization({}),
|
||||||
runtime,
|
runtime,
|
||||||
@@ -355,6 +367,8 @@ describe("Ekklesia API Hono application", () => {
|
|||||||
moduleConfiguration: emptyModuleConfiguration(),
|
moduleConfiguration: emptyModuleConfiguration(),
|
||||||
moduleStorage: emptyModuleStorage(),
|
moduleStorage: emptyModuleStorage(),
|
||||||
dashboard: createInMemoryDashboardStore(),
|
dashboard: createInMemoryDashboardStore(),
|
||||||
|
dashboardHostAssets: testDashboardHostAssets(),
|
||||||
|
health: readyHealth(),
|
||||||
moduleServices: fakeModuleServices(),
|
moduleServices: fakeModuleServices(),
|
||||||
authorization: fakeAuthorization({}),
|
authorization: fakeAuthorization({}),
|
||||||
runtime,
|
runtime,
|
||||||
@@ -378,6 +392,8 @@ describe("Ekklesia API Hono application", () => {
|
|||||||
moduleConfiguration: emptyModuleConfiguration(),
|
moduleConfiguration: emptyModuleConfiguration(),
|
||||||
moduleStorage: emptyModuleStorage(),
|
moduleStorage: emptyModuleStorage(),
|
||||||
dashboard: createInMemoryDashboardStore(),
|
dashboard: createInMemoryDashboardStore(),
|
||||||
|
dashboardHostAssets: testDashboardHostAssets(),
|
||||||
|
health: readyHealth(),
|
||||||
moduleServices: fakeModuleServices(),
|
moduleServices: fakeModuleServices(),
|
||||||
authorization: fakeAuthorization({}),
|
authorization: fakeAuthorization({}),
|
||||||
runtime,
|
runtime,
|
||||||
@@ -403,6 +419,8 @@ describe("Ekklesia API Hono application", () => {
|
|||||||
moduleConfiguration: emptyModuleConfiguration(),
|
moduleConfiguration: emptyModuleConfiguration(),
|
||||||
moduleStorage: emptyModuleStorage(),
|
moduleStorage: emptyModuleStorage(),
|
||||||
dashboard: createInMemoryDashboardStore(),
|
dashboard: createInMemoryDashboardStore(),
|
||||||
|
dashboardHostAssets: testDashboardHostAssets(),
|
||||||
|
health: readyHealth(),
|
||||||
moduleServices: fakeModuleServices(),
|
moduleServices: fakeModuleServices(),
|
||||||
authorization: fakeAuthorization({}),
|
authorization: fakeAuthorization({}),
|
||||||
runtime,
|
runtime,
|
||||||
@@ -430,6 +448,8 @@ describe("Ekklesia API Hono application", () => {
|
|||||||
moduleConfiguration: emptyModuleConfiguration(),
|
moduleConfiguration: emptyModuleConfiguration(),
|
||||||
moduleStorage: emptyModuleStorage(),
|
moduleStorage: emptyModuleStorage(),
|
||||||
dashboard: createInMemoryDashboardStore(),
|
dashboard: createInMemoryDashboardStore(),
|
||||||
|
dashboardHostAssets: testDashboardHostAssets(),
|
||||||
|
health: readyHealth(),
|
||||||
moduleServices: fakeModuleServices(),
|
moduleServices: fakeModuleServices(),
|
||||||
authorization: fakeAuthorization({}),
|
authorization: fakeAuthorization({}),
|
||||||
runtime,
|
runtime,
|
||||||
@@ -457,6 +477,8 @@ describe("Ekklesia API Hono application", () => {
|
|||||||
moduleConfiguration: emptyModuleConfiguration(),
|
moduleConfiguration: emptyModuleConfiguration(),
|
||||||
moduleStorage: emptyModuleStorage(),
|
moduleStorage: emptyModuleStorage(),
|
||||||
dashboard: createInMemoryDashboardStore(),
|
dashboard: createInMemoryDashboardStore(),
|
||||||
|
dashboardHostAssets: testDashboardHostAssets(),
|
||||||
|
health: readyHealth(),
|
||||||
moduleServices: fakeModuleServices(),
|
moduleServices: fakeModuleServices(),
|
||||||
authorization: fakeAuthorization({}),
|
authorization: fakeAuthorization({}),
|
||||||
runtime,
|
runtime,
|
||||||
@@ -481,6 +503,8 @@ describe("Ekklesia API Hono application", () => {
|
|||||||
moduleConfiguration: emptyModuleConfiguration(),
|
moduleConfiguration: emptyModuleConfiguration(),
|
||||||
moduleStorage: emptyModuleStorage(),
|
moduleStorage: emptyModuleStorage(),
|
||||||
dashboard: createInMemoryDashboardStore(),
|
dashboard: createInMemoryDashboardStore(),
|
||||||
|
dashboardHostAssets: testDashboardHostAssets(),
|
||||||
|
health: readyHealth(),
|
||||||
moduleServices: fakeModuleServices(),
|
moduleServices: fakeModuleServices(),
|
||||||
authorization: fakeAuthorization({}),
|
authorization: fakeAuthorization({}),
|
||||||
runtime,
|
runtime,
|
||||||
@@ -507,6 +531,8 @@ describe("Ekklesia API Hono application", () => {
|
|||||||
moduleConfiguration: emptyModuleConfiguration(),
|
moduleConfiguration: emptyModuleConfiguration(),
|
||||||
moduleStorage: emptyModuleStorage(),
|
moduleStorage: emptyModuleStorage(),
|
||||||
dashboard: createInMemoryDashboardStore(),
|
dashboard: createInMemoryDashboardStore(),
|
||||||
|
dashboardHostAssets: testDashboardHostAssets(),
|
||||||
|
health: readyHealth(),
|
||||||
moduleServices: fakeModuleServices(),
|
moduleServices: fakeModuleServices(),
|
||||||
authorization: fakeAuthorization({}),
|
authorization: fakeAuthorization({}),
|
||||||
runtime,
|
runtime,
|
||||||
@@ -530,6 +556,8 @@ describe("Ekklesia API Hono application", () => {
|
|||||||
moduleConfiguration: emptyModuleConfiguration(),
|
moduleConfiguration: emptyModuleConfiguration(),
|
||||||
moduleStorage: emptyModuleStorage(),
|
moduleStorage: emptyModuleStorage(),
|
||||||
dashboard: createInMemoryDashboardStore(),
|
dashboard: createInMemoryDashboardStore(),
|
||||||
|
dashboardHostAssets: testDashboardHostAssets(),
|
||||||
|
health: readyHealth(),
|
||||||
moduleServices: fakeModuleServices(),
|
moduleServices: fakeModuleServices(),
|
||||||
authorization: fakeAuthorization({}),
|
authorization: fakeAuthorization({}),
|
||||||
runtime,
|
runtime,
|
||||||
@@ -555,6 +583,8 @@ describe("Ekklesia API Hono application", () => {
|
|||||||
moduleConfiguration: emptyModuleConfiguration(),
|
moduleConfiguration: emptyModuleConfiguration(),
|
||||||
moduleStorage: emptyModuleStorage(),
|
moduleStorage: emptyModuleStorage(),
|
||||||
dashboard: createInMemoryDashboardStore(),
|
dashboard: createInMemoryDashboardStore(),
|
||||||
|
dashboardHostAssets: testDashboardHostAssets(),
|
||||||
|
health: readyHealth(),
|
||||||
moduleServices: fakeModuleServices(),
|
moduleServices: fakeModuleServices(),
|
||||||
authorization: fakeAuthorization({}),
|
authorization: fakeAuthorization({}),
|
||||||
runtime,
|
runtime,
|
||||||
@@ -587,6 +617,8 @@ describe("Ekklesia API Hono application", () => {
|
|||||||
moduleConfiguration: emptyModuleConfiguration(),
|
moduleConfiguration: emptyModuleConfiguration(),
|
||||||
moduleStorage: emptyModuleStorage(),
|
moduleStorage: emptyModuleStorage(),
|
||||||
dashboard: createInMemoryDashboardStore(),
|
dashboard: createInMemoryDashboardStore(),
|
||||||
|
dashboardHostAssets: testDashboardHostAssets(),
|
||||||
|
health: readyHealth(),
|
||||||
moduleServices: fakeModuleServices(),
|
moduleServices: fakeModuleServices(),
|
||||||
authorization: fakeAuthorization({}),
|
authorization: fakeAuthorization({}),
|
||||||
runtime,
|
runtime,
|
||||||
@@ -620,6 +652,8 @@ describe("Ekklesia API Hono application", () => {
|
|||||||
moduleConfiguration: emptyModuleConfiguration(),
|
moduleConfiguration: emptyModuleConfiguration(),
|
||||||
moduleStorage: emptyModuleStorage(),
|
moduleStorage: emptyModuleStorage(),
|
||||||
dashboard: createInMemoryDashboardStore(),
|
dashboard: createInMemoryDashboardStore(),
|
||||||
|
dashboardHostAssets: testDashboardHostAssets(),
|
||||||
|
health: readyHealth(),
|
||||||
moduleServices: fakeModuleServices(),
|
moduleServices: fakeModuleServices(),
|
||||||
authorization: fakeAuthorization({}),
|
authorization: fakeAuthorization({}),
|
||||||
runtime,
|
runtime,
|
||||||
@@ -658,6 +692,8 @@ describe("Ekklesia API Hono application", () => {
|
|||||||
moduleConfiguration: emptyModuleConfiguration(),
|
moduleConfiguration: emptyModuleConfiguration(),
|
||||||
moduleStorage: emptyModuleStorage(),
|
moduleStorage: emptyModuleStorage(),
|
||||||
dashboard: createInMemoryDashboardStore(),
|
dashboard: createInMemoryDashboardStore(),
|
||||||
|
dashboardHostAssets: testDashboardHostAssets(),
|
||||||
|
health: readyHealth(),
|
||||||
moduleServices: fakeModuleServices(),
|
moduleServices: fakeModuleServices(),
|
||||||
authorization: fakeAuthorization({}),
|
authorization: fakeAuthorization({}),
|
||||||
runtime: fakeRuntime(),
|
runtime: fakeRuntime(),
|
||||||
@@ -678,6 +714,8 @@ describe("Ekklesia API Hono application", () => {
|
|||||||
moduleConfiguration: emptyModuleConfiguration(),
|
moduleConfiguration: emptyModuleConfiguration(),
|
||||||
moduleStorage: emptyModuleStorage(),
|
moduleStorage: emptyModuleStorage(),
|
||||||
dashboard: createInMemoryDashboardStore(),
|
dashboard: createInMemoryDashboardStore(),
|
||||||
|
dashboardHostAssets: testDashboardHostAssets(),
|
||||||
|
health: readyHealth(),
|
||||||
moduleServices: fakeModuleServices(),
|
moduleServices: fakeModuleServices(),
|
||||||
authorization: fakeAuthorization({}),
|
authorization: fakeAuthorization({}),
|
||||||
runtime: fakeRuntime(),
|
runtime: fakeRuntime(),
|
||||||
@@ -700,6 +738,8 @@ describe("Ekklesia API Hono application", () => {
|
|||||||
moduleConfiguration: emptyModuleConfiguration(),
|
moduleConfiguration: emptyModuleConfiguration(),
|
||||||
moduleStorage: emptyModuleStorage(),
|
moduleStorage: emptyModuleStorage(),
|
||||||
dashboard: createInMemoryDashboardStore(),
|
dashboard: createInMemoryDashboardStore(),
|
||||||
|
dashboardHostAssets: testDashboardHostAssets(),
|
||||||
|
health: readyHealth(),
|
||||||
moduleServices: fakeModuleServices(),
|
moduleServices: fakeModuleServices(),
|
||||||
authorization: fakeAuthorization({}),
|
authorization: fakeAuthorization({}),
|
||||||
runtime: fakeRuntime(),
|
runtime: fakeRuntime(),
|
||||||
@@ -721,6 +761,8 @@ describe("Ekklesia API Hono application", () => {
|
|||||||
moduleConfiguration: emptyModuleConfiguration(),
|
moduleConfiguration: emptyModuleConfiguration(),
|
||||||
moduleStorage: emptyModuleStorage(),
|
moduleStorage: emptyModuleStorage(),
|
||||||
dashboard: createInMemoryDashboardStore(),
|
dashboard: createInMemoryDashboardStore(),
|
||||||
|
dashboardHostAssets: testDashboardHostAssets(),
|
||||||
|
health: readyHealth(),
|
||||||
moduleServices: fakeModuleServices(),
|
moduleServices: fakeModuleServices(),
|
||||||
authorization: fakeAuthorization({}),
|
authorization: fakeAuthorization({}),
|
||||||
runtime: fakeRuntime(),
|
runtime: fakeRuntime(),
|
||||||
@@ -740,6 +782,8 @@ describe("Ekklesia API Hono application", () => {
|
|||||||
moduleConfiguration: emptyModuleConfiguration(),
|
moduleConfiguration: emptyModuleConfiguration(),
|
||||||
moduleStorage: emptyModuleStorage(),
|
moduleStorage: emptyModuleStorage(),
|
||||||
dashboard: createInMemoryDashboardStore(),
|
dashboard: createInMemoryDashboardStore(),
|
||||||
|
dashboardHostAssets: testDashboardHostAssets(),
|
||||||
|
health: readyHealth(),
|
||||||
moduleServices: fakeModuleServices(),
|
moduleServices: fakeModuleServices(),
|
||||||
authorization: fakeAuthorization({}),
|
authorization: fakeAuthorization({}),
|
||||||
runtime: fakeRuntime(),
|
runtime: fakeRuntime(),
|
||||||
@@ -766,6 +810,8 @@ describe("Ekklesia API Hono application", () => {
|
|||||||
moduleConfiguration: emptyModuleConfiguration(),
|
moduleConfiguration: emptyModuleConfiguration(),
|
||||||
moduleStorage: emptyModuleStorage(),
|
moduleStorage: emptyModuleStorage(),
|
||||||
dashboard: createInMemoryDashboardStore(),
|
dashboard: createInMemoryDashboardStore(),
|
||||||
|
dashboardHostAssets: testDashboardHostAssets(),
|
||||||
|
health: readyHealth(),
|
||||||
moduleServices: fakeModuleServices(),
|
moduleServices: fakeModuleServices(),
|
||||||
authorization: fakeAuthorization({}),
|
authorization: fakeAuthorization({}),
|
||||||
runtime: fakeRuntime(),
|
runtime: fakeRuntime(),
|
||||||
@@ -795,6 +841,8 @@ describe("Ekklesia API Hono application", () => {
|
|||||||
moduleConfiguration: emptyModuleConfiguration(),
|
moduleConfiguration: emptyModuleConfiguration(),
|
||||||
moduleStorage: emptyModuleStorage(),
|
moduleStorage: emptyModuleStorage(),
|
||||||
dashboard: createInMemoryDashboardStore(),
|
dashboard: createInMemoryDashboardStore(),
|
||||||
|
dashboardHostAssets: testDashboardHostAssets(),
|
||||||
|
health: readyHealth(),
|
||||||
moduleServices: fakeModuleServices(),
|
moduleServices: fakeModuleServices(),
|
||||||
authorization: fakeAuthorization({ evaluator: { authorize: evaluatorAuthorize } }),
|
authorization: fakeAuthorization({ evaluator: { authorize: evaluatorAuthorize } }),
|
||||||
runtime: fakeRuntime(),
|
runtime: fakeRuntime(),
|
||||||
@@ -827,6 +875,8 @@ describe("Ekklesia API Hono application", () => {
|
|||||||
moduleConfiguration: emptyModuleConfiguration(),
|
moduleConfiguration: emptyModuleConfiguration(),
|
||||||
moduleStorage: emptyModuleStorage(),
|
moduleStorage: emptyModuleStorage(),
|
||||||
dashboard: createInMemoryDashboardStore(),
|
dashboard: createInMemoryDashboardStore(),
|
||||||
|
dashboardHostAssets: testDashboardHostAssets(),
|
||||||
|
health: readyHealth(),
|
||||||
moduleServices: fakeModuleServices(),
|
moduleServices: fakeModuleServices(),
|
||||||
authorization: fakeAuthorization({
|
authorization: fakeAuthorization({
|
||||||
evaluator: { authorize: evaluatorAuthorize },
|
evaluator: { authorize: evaluatorAuthorize },
|
||||||
@@ -850,6 +900,8 @@ describe("Ekklesia API Hono application", () => {
|
|||||||
moduleConfiguration: emptyModuleConfiguration(),
|
moduleConfiguration: emptyModuleConfiguration(),
|
||||||
moduleStorage: emptyModuleStorage(),
|
moduleStorage: emptyModuleStorage(),
|
||||||
dashboard: createInMemoryDashboardStore(),
|
dashboard: createInMemoryDashboardStore(),
|
||||||
|
dashboardHostAssets: testDashboardHostAssets(),
|
||||||
|
health: readyHealth(),
|
||||||
moduleServices: fakeModuleServices(),
|
moduleServices: fakeModuleServices(),
|
||||||
authorization: fakeAuthorization({}),
|
authorization: fakeAuthorization({}),
|
||||||
runtime: fakeRuntime(),
|
runtime: fakeRuntime(),
|
||||||
@@ -864,3 +916,101 @@ describe("Ekklesia API Hono application", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("split Dashboard and API origins (ADR 0045)", () => {
|
||||||
|
const moduleId = moduleIdSchema.parse("announcements");
|
||||||
|
const permission = parsePermissionId("announcements.slideshow.view");
|
||||||
|
const endpoint = "/api/v1/module-realtime/announcements/state";
|
||||||
|
|
||||||
|
/** The full application with one live channel, a signed-in caller, and an evaluator that allows
|
||||||
|
* it: the same composition a Dashboard on another origin reaches. */
|
||||||
|
function realtimeApp() {
|
||||||
|
const staged = new StagedModuleRealtimeRegistry();
|
||||||
|
const registration = staged.createRegistrationStage(moduleId, new Set([permission]));
|
||||||
|
registration.realtime.channel({ channel: "state", permission });
|
||||||
|
registration.commit();
|
||||||
|
staged.publisherFor(moduleId).publish("state", { mode: "on" } as never);
|
||||||
|
const moduleRealtime = staged.publish();
|
||||||
|
const app = createApiAppWithModules({
|
||||||
|
moduleRoutes: Object.freeze([]),
|
||||||
|
moduleUi: emptyModuleUiPublication(),
|
||||||
|
moduleLifecycle: emptyModuleLifecycle(),
|
||||||
|
moduleConfiguration: emptyModuleConfiguration(),
|
||||||
|
moduleStorage: emptyModuleStorage(),
|
||||||
|
moduleRealtime,
|
||||||
|
dashboard: createInMemoryDashboardStore(),
|
||||||
|
dashboardHostAssets: testDashboardHostAssets(),
|
||||||
|
health: readyHealth(),
|
||||||
|
moduleServices: fakeModuleServices(),
|
||||||
|
authorization: fakeAuthorization({
|
||||||
|
evaluator: { authorize: vi.fn().mockResolvedValue({ allowed: true }) },
|
||||||
|
registry: createPermissionRegistry([
|
||||||
|
...CORE_PERMISSION_DECLARATIONS,
|
||||||
|
{
|
||||||
|
baseline: "restricted",
|
||||||
|
description: "View the announcement slideshow.",
|
||||||
|
id: permission,
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
}),
|
||||||
|
runtime: fakeRuntime({
|
||||||
|
resolvePrincipal: vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValue(
|
||||||
|
createAuthenticatedPrincipal(
|
||||||
|
parseEkklesiaUserId("88888888-8888-4888-8888-888888888888"),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
trustedOrigins: [trustedOrigin],
|
||||||
|
});
|
||||||
|
return { app, moduleRealtime };
|
||||||
|
}
|
||||||
|
|
||||||
|
it("lets the Dashboard's origin open a credentialed realtime stream", async () => {
|
||||||
|
const { app, moduleRealtime } = realtimeApp();
|
||||||
|
|
||||||
|
const response = await app.request(endpoint, {
|
||||||
|
headers: { cookie: "session=1", origin: trustedOrigin },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(response.headers.get("content-type")).toBe("text/event-stream; charset=utf-8");
|
||||||
|
expect(response.headers.get("access-control-allow-origin")).toBe(trustedOrigin);
|
||||||
|
expect(response.headers.get("access-control-allow-credentials")).toBe("true");
|
||||||
|
expect(response.headers.get("vary")).toContain("Origin");
|
||||||
|
await response.body?.cancel();
|
||||||
|
moduleRealtime.closeSubscriptions();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("gives an untrusted origin's realtime request no CORS permission to read the stream", async () => {
|
||||||
|
const { app, moduleRealtime } = realtimeApp();
|
||||||
|
|
||||||
|
const response = await app.request(endpoint, {
|
||||||
|
headers: { cookie: "session=1", origin: untrustedOrigin },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.headers.get("access-control-allow-origin")).toBeNull();
|
||||||
|
await response.body?.cancel();
|
||||||
|
moduleRealtime.closeSubscriptions();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows only Content-Type beyond the CORS-safelisted request headers, and exposes none", async () => {
|
||||||
|
const app = createAuthWiringApp(fakeRuntime());
|
||||||
|
|
||||||
|
const response = await app.request("/api/v1/principal", {
|
||||||
|
headers: {
|
||||||
|
"access-control-request-headers": "content-type, x-module-custom",
|
||||||
|
"access-control-request-method": "PATCH",
|
||||||
|
origin: trustedOrigin,
|
||||||
|
},
|
||||||
|
method: "OPTIONS",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.headers.get("access-control-allow-origin")).toBe(trustedOrigin);
|
||||||
|
expect(response.headers.get("access-control-allow-headers")?.toLowerCase()).toBe(
|
||||||
|
"content-type",
|
||||||
|
);
|
||||||
|
expect(response.headers.get("access-control-expose-headers")).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { ApiConfigError, createApiRuntimeConfig } from "../src/config.js";
|
import { ApiConfigError, apiTrustedOrigins, createApiRuntimeConfig } from "../src/config.js";
|
||||||
|
|
||||||
const validSecret = "correct-horse-battery-staple-0123456789";
|
const validSecret = "correct-horse-battery-staple-0123456789";
|
||||||
|
|
||||||
@@ -28,6 +28,9 @@ describe("API runtime configuration", () => {
|
|||||||
host: "127.0.0.1",
|
host: "127.0.0.1",
|
||||||
identity: {
|
identity: {
|
||||||
baseUrl: "https://identity.example.test",
|
baseUrl: "https://identity.example.test",
|
||||||
|
// Defaults to the identity base URL: the Dashboard and the API share one origin unless a
|
||||||
|
// deployment separates them (ADR 0038).
|
||||||
|
dashboardOrigin: "https://identity.example.test",
|
||||||
publicSignupEnabled: false,
|
publicSignupEnabled: false,
|
||||||
secret: validSecret,
|
secret: validSecret,
|
||||||
trustedOrigins: ["https://identity.example.test"],
|
trustedOrigins: ["https://identity.example.test"],
|
||||||
@@ -334,3 +337,24 @@ describe("API runtime configuration", () => {
|
|||||||
expect(publicText).not.toContain("db.internal.example");
|
expect(publicText).not.toContain("db.internal.example");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("the API's trusted browser origins (ADR 0045)", () => {
|
||||||
|
it("adds the API's own origin to the configured ones, as Better Auth does", () => {
|
||||||
|
const origins = apiTrustedOrigins({
|
||||||
|
baseUrl: "https://api.example.test",
|
||||||
|
trustedOrigins: ["https://dash.example.test"],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(origins).toEqual(["https://dash.example.test", "https://api.example.test"]);
|
||||||
|
expect(Object.isFrozen(origins)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not duplicate an API origin already listed (the same-origin topology)", () => {
|
||||||
|
expect(
|
||||||
|
apiTrustedOrigins({
|
||||||
|
baseUrl: "https://ekklesia.example.test",
|
||||||
|
trustedOrigins: ["https://ekklesia.example.test"],
|
||||||
|
}),
|
||||||
|
).toEqual(["https://ekklesia.example.test"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -0,0 +1,131 @@
|
|||||||
|
import { createHash } from "node:crypto";
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
const readFileMock = vi.hoisted(() => vi.fn());
|
||||||
|
|
||||||
|
vi.mock("node:fs/promises", () => ({ readFile: readFileMock }));
|
||||||
|
vi.mock("@ekklesia/shell/dashboard-assets", () => ({
|
||||||
|
dashboardHostAssetUrls: Object.freeze({
|
||||||
|
html: "file:///surface/browser/index.html",
|
||||||
|
script: "file:///surface/browser/assets/ekklesia-dashboard/app.js",
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import {
|
||||||
|
createDashboardHostAssets,
|
||||||
|
DASHBOARD_API_ORIGIN_PLACEHOLDER,
|
||||||
|
DashboardHostAssetError,
|
||||||
|
loadDashboardHostAssets,
|
||||||
|
renderDashboardPage,
|
||||||
|
} from "../src/dashboard-host-assets.js";
|
||||||
|
|
||||||
|
const API_ORIGIN = "https://api.example.test";
|
||||||
|
|
||||||
|
function sha256Etag(text: string): string {
|
||||||
|
return `"${createHash("sha256").update(text).digest("base64url")}"`;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("Dashboard host asset loading", () => {
|
||||||
|
beforeEach(() => readFileMock.mockReset());
|
||||||
|
|
||||||
|
it("reads only the two declared file URLs in order and freezes one consistent snapshot", async () => {
|
||||||
|
const html = `<!doctype html>${DASHBOARD_API_ORIGIN_PLACEHOLDER}<div id="ekklesia-host"></div>`;
|
||||||
|
const script = "export const dashboardHost = true;\n";
|
||||||
|
readFileMock.mockResolvedValueOnce(html).mockResolvedValueOnce(script);
|
||||||
|
|
||||||
|
const assets = await loadDashboardHostAssets(API_ORIGIN);
|
||||||
|
|
||||||
|
expect(readFileMock.mock.calls).toEqual([
|
||||||
|
[new URL("file:///surface/browser/index.html"), "utf8"],
|
||||||
|
[new URL("file:///surface/browser/assets/ekklesia-dashboard/app.js"), "utf8"],
|
||||||
|
]);
|
||||||
|
const rendered = `<!doctype html><meta content="${API_ORIGIN}" name="ekklesia-api-origin" /><div id="ekklesia-host"></div>`;
|
||||||
|
expect(assets).toEqual({
|
||||||
|
html: rendered,
|
||||||
|
htmlEtag: sha256Etag(rendered),
|
||||||
|
script,
|
||||||
|
scriptEtag: sha256Etag(script),
|
||||||
|
});
|
||||||
|
expect(Object.isFrozen(assets)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fails deterministically without attempting a neighboring file or exposing the read error", async () => {
|
||||||
|
readFileMock.mockRejectedValueOnce(
|
||||||
|
new Error("ENOENT /home/operator/private/surface-build secret-token"),
|
||||||
|
);
|
||||||
|
|
||||||
|
const failure = await loadDashboardHostAssets(API_ORIGIN).catch((error: unknown) => error);
|
||||||
|
|
||||||
|
expect(readFileMock).toHaveBeenCalledTimes(1);
|
||||||
|
expect(failure).toBeInstanceOf(DashboardHostAssetError);
|
||||||
|
expect(failure).toMatchObject({
|
||||||
|
code: "DASHBOARD_HOST_ASSET_UNAVAILABLE",
|
||||||
|
message: "The Dashboard host browser assets are unavailable.",
|
||||||
|
name: "DashboardHostAssetError",
|
||||||
|
});
|
||||||
|
expect((failure as Error).stack).toBe(
|
||||||
|
"DashboardHostAssetError: The Dashboard host browser assets are unavailable.",
|
||||||
|
);
|
||||||
|
const publicText = `${String(failure)}\n${JSON.stringify(failure)}\n${(failure as Error).stack}`;
|
||||||
|
for (const secret of ["/home/", "operator", "ENOENT", "secret-token"]) {
|
||||||
|
expect(publicText).not.toContain(secret);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fails when the script is missing even though the page was read", async () => {
|
||||||
|
readFileMock
|
||||||
|
.mockResolvedValueOnce(`<!doctype html>${DASHBOARD_API_ORIGIN_PLACEHOLDER}`)
|
||||||
|
.mockRejectedValueOnce(new Error("ENOENT app.js"));
|
||||||
|
|
||||||
|
await expect(loadDashboardHostAssets(API_ORIGIN)).rejects.toBeInstanceOf(
|
||||||
|
DashboardHostAssetError,
|
||||||
|
);
|
||||||
|
expect(readFileMock).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects an empty page or script as an unavailable shell build", () => {
|
||||||
|
for (const [html, script] of [
|
||||||
|
["", "export {};"],
|
||||||
|
["<html></html>", " \n"],
|
||||||
|
] as const) {
|
||||||
|
expect(() => createDashboardHostAssets(html, script)).toThrow(DashboardHostAssetError);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fails startup on a page without exactly one empty API-origin element (ADR 0045)", async () => {
|
||||||
|
for (const html of [
|
||||||
|
"<!doctype html>",
|
||||||
|
`<!doctype html>${DASHBOARD_API_ORIGIN_PLACEHOLDER}${DASHBOARD_API_ORIGIN_PLACEHOLDER}`,
|
||||||
|
'<!doctype html><meta content="https://elsewhere.test" name="ekklesia-api-origin" />',
|
||||||
|
]) {
|
||||||
|
readFileMock.mockReset();
|
||||||
|
readFileMock.mockResolvedValueOnce(html).mockResolvedValueOnce("export {};\n");
|
||||||
|
await expect(loadDashboardHostAssets(API_ORIGIN)).rejects.toBeInstanceOf(
|
||||||
|
DashboardHostAssetError,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Dashboard page API origin (ADR 0045)", () => {
|
||||||
|
const page = `<head>${DASHBOARD_API_ORIGIN_PLACEHOLDER}</head>`;
|
||||||
|
|
||||||
|
it("writes the API's origin into the one placeholder, in either topology", () => {
|
||||||
|
expect(renderDashboardPage(page, "https://api.example.test")).toBe(
|
||||||
|
'<head><meta content="https://api.example.test" name="ekklesia-api-origin" /></head>',
|
||||||
|
);
|
||||||
|
expect(renderDashboardPage(page, "http://127.0.0.1:3000")).toBe(
|
||||||
|
'<head><meta content="http://127.0.0.1:3000" name="ekklesia-api-origin" /></head>',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
"https://api.example.test/",
|
||||||
|
"https://api.example.test/api",
|
||||||
|
'https://api.example.test"><script>',
|
||||||
|
"javascript:alert(1)",
|
||||||
|
"",
|
||||||
|
])("refuses a value that is not a canonical http(s) origin: %s", (origin) => {
|
||||||
|
expect(() => renderDashboardPage(page, origin)).toThrow(DashboardHostAssetError);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,347 @@
|
|||||||
|
import { createHash } from "node:crypto";
|
||||||
|
import {
|
||||||
|
ANONYMOUS_PRINCIPAL,
|
||||||
|
type ModuleRouteHandler,
|
||||||
|
parsePermissionId,
|
||||||
|
type RegisteredModuleHttpRoute,
|
||||||
|
} from "@ekklesia/contracts";
|
||||||
|
import {
|
||||||
|
type AuthorizationEvaluator,
|
||||||
|
type AuthorizationStore,
|
||||||
|
createPermissionRegistry,
|
||||||
|
} from "@ekklesia/core/authorization";
|
||||||
|
import type { EkklesiaIdentityRuntime } from "@ekklesia/core/identity";
|
||||||
|
import {
|
||||||
|
createModuleUiPublicationRegistry,
|
||||||
|
EMPTY_MODULE_REALTIME_REGISTRY,
|
||||||
|
type LegacyModuleRuntimeServices,
|
||||||
|
type ModuleDatabase,
|
||||||
|
} from "@ekklesia/core/modules";
|
||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
// Every byte these routes serve must come from the injected startup snapshot. Any filesystem read
|
||||||
|
// while answering a request — the hallmark of a static directory — fails these tests.
|
||||||
|
const readFileMock = vi.hoisted(() => vi.fn());
|
||||||
|
vi.mock("node:fs/promises", () => ({ readFile: readFileMock }));
|
||||||
|
|
||||||
|
import { createApiApp } from "../src/app.js";
|
||||||
|
import {
|
||||||
|
createDashboardHostAssets,
|
||||||
|
type DashboardHostAssets,
|
||||||
|
} from "../src/dashboard-host-assets.js";
|
||||||
|
import {
|
||||||
|
DASHBOARD_HOST_PAGE_PATH,
|
||||||
|
DASHBOARD_HOST_SCRIPT_PATH,
|
||||||
|
DashboardHostConfigurationError,
|
||||||
|
} from "../src/dashboard-host-routes.js";
|
||||||
|
import { DISPLAY_HOST_SCRIPT_PATH } from "../src/module-display-routes.js";
|
||||||
|
import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
|
||||||
|
import { emptyModuleConfiguration } from "./support/in-memory-module-configuration.js";
|
||||||
|
import { emptyModuleLifecycle } from "./support/in-memory-module-lifecycle.js";
|
||||||
|
import { emptyModuleStorage } from "./support/in-memory-module-storage.js";
|
||||||
|
import { readyHealth } from "./support/ready-health.js";
|
||||||
|
|
||||||
|
const trustedOrigin = "https://dashboard.example.test";
|
||||||
|
const modulePermission = parsePermissionId("dashboard.page.read");
|
||||||
|
|
||||||
|
const PAGE_HTML =
|
||||||
|
'<!doctype html><html lang="en"><body><div id="ekklesia-host"></div><script src="/assets/ekklesia-dashboard/app.js" type="module"></script></body></html>\n';
|
||||||
|
const SCRIPT = "export const dashboard = 'built shell';\n";
|
||||||
|
|
||||||
|
function sha256Etag(text: string): string {
|
||||||
|
return `"${createHash("sha256").update(text).digest("base64url")}"`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PAGE_ETAG = sha256Etag(PAGE_HTML);
|
||||||
|
const SCRIPT_ETAG = sha256Etag(SCRIPT);
|
||||||
|
|
||||||
|
function fakeRuntime(): EkklesiaIdentityRuntime {
|
||||||
|
return {
|
||||||
|
handleAuthRequest: vi.fn(),
|
||||||
|
resolvePrincipal: vi.fn().mockResolvedValue(ANONYMOUS_PRINCIPAL),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function fakeStore(): AuthorizationStore {
|
||||||
|
return {
|
||||||
|
createGrant: vi.fn(),
|
||||||
|
grantPlatformOwner: vi.fn(),
|
||||||
|
hasActiveGrant: vi.fn().mockResolvedValue(false),
|
||||||
|
isPlatformOwner: vi.fn().mockResolvedValue(false),
|
||||||
|
listGrantsForUser: vi.fn().mockResolvedValue({ grants: [] }),
|
||||||
|
revokeGrant: vi.fn(),
|
||||||
|
revokePlatformOwner: vi.fn(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function fakeModuleServices(): LegacyModuleRuntimeServices {
|
||||||
|
return Object.freeze({ database: Object.freeze({}) as unknown as ModuleDatabase });
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildApp(
|
||||||
|
options: {
|
||||||
|
readonly assets?: DashboardHostAssets;
|
||||||
|
readonly moduleRoutes?: readonly RegisteredModuleHttpRoute[];
|
||||||
|
} = {},
|
||||||
|
) {
|
||||||
|
const evaluator: AuthorizationEvaluator = {
|
||||||
|
authorize: vi.fn().mockResolvedValue({ allowed: true }),
|
||||||
|
};
|
||||||
|
const runtime = fakeRuntime();
|
||||||
|
const app = createApiApp({
|
||||||
|
authorization: {
|
||||||
|
evaluator,
|
||||||
|
registry: createPermissionRegistry([
|
||||||
|
{ baseline: "public", description: "Test permission.", id: modulePermission },
|
||||||
|
]),
|
||||||
|
store: fakeStore(),
|
||||||
|
},
|
||||||
|
moduleRoutes: options.moduleRoutes ?? Object.freeze([]),
|
||||||
|
moduleRealtime: EMPTY_MODULE_REALTIME_REGISTRY,
|
||||||
|
moduleServices: fakeModuleServices(),
|
||||||
|
moduleUi: createModuleUiPublicationRegistry([]),
|
||||||
|
moduleLifecycle: emptyModuleLifecycle(),
|
||||||
|
moduleConfiguration: emptyModuleConfiguration(),
|
||||||
|
moduleStorage: emptyModuleStorage(),
|
||||||
|
dashboard: createInMemoryDashboardStore(),
|
||||||
|
dashboardHostAssets: options.assets ?? createDashboardHostAssets(PAGE_HTML, SCRIPT),
|
||||||
|
health: readyHealth(),
|
||||||
|
runtime,
|
||||||
|
trustedOrigins: [trustedOrigin],
|
||||||
|
});
|
||||||
|
return { app, evaluator, runtime };
|
||||||
|
}
|
||||||
|
|
||||||
|
function expectPageSecurityHeaders(response: Response): void {
|
||||||
|
expect(response.headers.get("x-frame-options")).toBe("DENY");
|
||||||
|
expect(response.headers.get("content-security-policy")).toBe("frame-ancestors 'none'");
|
||||||
|
expect(response.headers.get("referrer-policy")).toBe("no-referrer");
|
||||||
|
expect(response.headers.get("x-content-type-options")).toBe("nosniff");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function expectSanitizedNotFound(response: Response, label: string): Promise<void> {
|
||||||
|
expect(response.status, label).toBe(404);
|
||||||
|
expect(response.headers.get("content-type"), label).toMatch(/^application\/json/u);
|
||||||
|
expect(await response.json(), label).toEqual({ code: "NOT_FOUND" });
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("GET / (the Dashboard page)", () => {
|
||||||
|
it("serves the exact startup HTML bytes with revalidation, a strong ETag, and the page security headers", async () => {
|
||||||
|
const { app } = buildApp();
|
||||||
|
|
||||||
|
const response = await app.request(DASHBOARD_HOST_PAGE_PATH);
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(await response.text()).toBe(PAGE_HTML);
|
||||||
|
expect(response.headers.get("content-type")).toBe("text/html; charset=utf-8");
|
||||||
|
expect(response.headers.get("cache-control")).toBe("no-cache");
|
||||||
|
expect(response.headers.get("etag")).toBe(PAGE_ETAG);
|
||||||
|
expectPageSecurityHeaders(response);
|
||||||
|
// The one CSP directive is the only one: the full policy is a separate decision.
|
||||||
|
expect(response.headers.get("content-security-policy")).not.toContain(";");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("serves the same page for an emailed-link landing, since the query never selects a file", async () => {
|
||||||
|
const { app } = buildApp();
|
||||||
|
|
||||||
|
const response = await app.request("/?ekklesia_auth=reset-password&token=abc123");
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(await response.text()).toBe(PAGE_HTML);
|
||||||
|
expect(response.headers.get("etag")).toBe(PAGE_ETAG);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("answers a matching If-None-Match with an empty 304 that keeps the validator and every page security header", async () => {
|
||||||
|
const { app } = buildApp();
|
||||||
|
|
||||||
|
for (const ifNoneMatch of [PAGE_ETAG, `W/${PAGE_ETAG}`, `"other", ${PAGE_ETAG}`, "*"]) {
|
||||||
|
const response = await app.request(DASHBOARD_HOST_PAGE_PATH, {
|
||||||
|
headers: { "If-None-Match": ifNoneMatch },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.status, ifNoneMatch).toBe(304);
|
||||||
|
expect(await response.text(), ifNoneMatch).toBe("");
|
||||||
|
expect(response.headers.get("etag")).toBe(PAGE_ETAG);
|
||||||
|
expect(response.headers.get("cache-control")).toBe("no-cache");
|
||||||
|
expectPageSecurityHeaders(response);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("serves the full page again when the browser holds a different build's validator", async () => {
|
||||||
|
const { app } = buildApp();
|
||||||
|
|
||||||
|
const response = await app.request(DASHBOARD_HOST_PAGE_PATH, {
|
||||||
|
headers: { "If-None-Match": sha256Etag("an older build") },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(await response.text()).toBe(PAGE_HTML);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is unauthenticated and outside the API's CORS policy: it resolves no principal and reflects no origin", async () => {
|
||||||
|
const { app, evaluator, runtime } = buildApp();
|
||||||
|
|
||||||
|
const response = await app.request(DASHBOARD_HOST_PAGE_PATH, {
|
||||||
|
headers: { Cookie: "better-auth.session_token=anything", Origin: trustedOrigin },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(runtime.resolvePrincipal).not.toHaveBeenCalled();
|
||||||
|
expect(evaluator.authorize).not.toHaveBeenCalled();
|
||||||
|
expect(response.headers.get("access-control-allow-origin")).toBeNull();
|
||||||
|
expect(response.headers.get("set-cookie")).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("GET /assets/ekklesia-dashboard/app.js (the Dashboard script)", () => {
|
||||||
|
it("serves the exact startup script bytes as JavaScript with revalidation and a strong ETag", async () => {
|
||||||
|
const { app } = buildApp();
|
||||||
|
|
||||||
|
const response = await app.request(DASHBOARD_HOST_SCRIPT_PATH);
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(await response.text()).toBe(SCRIPT);
|
||||||
|
expect(response.headers.get("content-type")).toBe("text/javascript; charset=utf-8");
|
||||||
|
expect(response.headers.get("cache-control")).toBe("no-cache");
|
||||||
|
expect(response.headers.get("etag")).toBe(SCRIPT_ETAG);
|
||||||
|
expect(response.headers.get("x-content-type-options")).toBe("nosniff");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("answers a matching If-None-Match with an empty 304 carrying the script's own validator", async () => {
|
||||||
|
const { app } = buildApp();
|
||||||
|
|
||||||
|
const response = await app.request(DASHBOARD_HOST_SCRIPT_PATH, {
|
||||||
|
headers: { "If-None-Match": SCRIPT_ETAG },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.status).toBe(304);
|
||||||
|
expect(await response.text()).toBe("");
|
||||||
|
expect(response.headers.get("etag")).toBe(SCRIPT_ETAG);
|
||||||
|
expect(response.headers.get("cache-control")).toBe("no-cache");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not accept the page's validator as the script's", async () => {
|
||||||
|
const { app } = buildApp();
|
||||||
|
|
||||||
|
const response = await app.request(DASHBOARD_HOST_SCRIPT_PATH, {
|
||||||
|
headers: { "If-None-Match": PAGE_ETAG },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Dashboard host boundaries", () => {
|
||||||
|
it("serves nothing but its two exact paths, answering every other spelling with the sanitized 404", async () => {
|
||||||
|
const { app } = buildApp();
|
||||||
|
|
||||||
|
for (const path of [
|
||||||
|
"/index.html",
|
||||||
|
"/app.js",
|
||||||
|
"/app.js.map",
|
||||||
|
"/assets/ekklesia-dashboard/app.js.map",
|
||||||
|
"/assets/ekklesia-dashboard/",
|
||||||
|
"/assets/ekklesia-dashboard",
|
||||||
|
"/assets/ekklesia-dashboard/app.js/",
|
||||||
|
"/assets/ekklesia-dashboard/index.html",
|
||||||
|
"/assets/",
|
||||||
|
"/assets/ekklesia-dashboard/APP.JS",
|
||||||
|
"/dashboard",
|
||||||
|
"/login",
|
||||||
|
]) {
|
||||||
|
await expectSanitizedNotFound(await app.request(path), path);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("cannot be walked out of: encoded traversal, separators, and double slashes reach no file", async () => {
|
||||||
|
const { app } = buildApp();
|
||||||
|
|
||||||
|
for (const path of [
|
||||||
|
"/assets/ekklesia-dashboard/%2e%2e/%2e%2e/package.json",
|
||||||
|
"/assets/ekklesia-dashboard/..%2fapp.js.map",
|
||||||
|
"/assets/ekklesia-dashboard/%2e%2e%2fapp.js.map",
|
||||||
|
"/assets/ekklesia-dashboard/app.js%2f..%2f..%2fpackage.json",
|
||||||
|
"/assets/ekklesia-dashboard/..%5c..%5cpackage.json",
|
||||||
|
"/%2e%2e/%2e%2e/etc/passwd",
|
||||||
|
"/../../etc/passwd",
|
||||||
|
"//assets/ekklesia-dashboard/app.js",
|
||||||
|
"/assets//ekklesia-dashboard/app.js",
|
||||||
|
"/assets/ekklesia-dashboard/app.js%00.map",
|
||||||
|
]) {
|
||||||
|
const response = await app.request(path);
|
||||||
|
expect(await response.text(), path).not.toContain(SCRIPT);
|
||||||
|
expect(response.status, path).toBe(404);
|
||||||
|
}
|
||||||
|
expect(readFileMock).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never touches the filesystem while answering, even across every served path", async () => {
|
||||||
|
const { app } = buildApp();
|
||||||
|
|
||||||
|
await app.request(DASHBOARD_HOST_PAGE_PATH);
|
||||||
|
await app.request(DASHBOARD_HOST_SCRIPT_PATH);
|
||||||
|
await app.request("/assets/ekklesia-dashboard/app.js.map");
|
||||||
|
|
||||||
|
expect(readFileMock).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("serves GET (and Hono's implicit HEAD) only; every other method is the ordinary 404", async () => {
|
||||||
|
const { app } = buildApp();
|
||||||
|
|
||||||
|
const head = await app.request(DASHBOARD_HOST_PAGE_PATH, { method: "HEAD" });
|
||||||
|
expect(head.status).toBe(200);
|
||||||
|
expect(head.headers.get("etag")).toBe(PAGE_ETAG);
|
||||||
|
expect(await head.text()).toBe("");
|
||||||
|
|
||||||
|
for (const path of [DASHBOARD_HOST_PAGE_PATH, DASHBOARD_HOST_SCRIPT_PATH]) {
|
||||||
|
for (const method of ["POST", "PUT", "PATCH", "DELETE"]) {
|
||||||
|
await expectSanitizedNotFound(await app.request(path, { method }), `${method} ${path}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves the API, the display host's namespace, and module routes exactly where they were", async () => {
|
||||||
|
const moduleHandler = vi.fn<ModuleRouteHandler>(
|
||||||
|
async () => new Response("module", { status: 200 }),
|
||||||
|
);
|
||||||
|
const { app } = buildApp({
|
||||||
|
moduleRoutes: [
|
||||||
|
Object.freeze({
|
||||||
|
bodyMode: "buffered",
|
||||||
|
handler: moduleHandler,
|
||||||
|
maxBodyBytes: 1024,
|
||||||
|
method: "GET",
|
||||||
|
moduleId: "dashboard",
|
||||||
|
path: "/",
|
||||||
|
permission: modulePermission,
|
||||||
|
}) as RegisteredModuleHttpRoute,
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(await (await app.request("/api/v1/principal")).json()).toEqual({ kind: "anonymous" });
|
||||||
|
expect(await (await app.request("/api/v1/health/live")).json()).toEqual({ status: "live" });
|
||||||
|
expect(await (await app.request("/api/v1/modules/dashboard")).text()).toBe("module");
|
||||||
|
await expectSanitizedNotFound(await app.request("/api/v1/unknown"), "/api/v1/unknown");
|
||||||
|
// No display is published here, so the display host's script is absent rather than answered
|
||||||
|
// by the Dashboard's.
|
||||||
|
await expectSanitizedNotFound(
|
||||||
|
await app.request(DISPLAY_HOST_SCRIPT_PATH),
|
||||||
|
DISPLAY_HOST_SCRIPT_PATH,
|
||||||
|
);
|
||||||
|
expect(DASHBOARD_HOST_SCRIPT_PATH.startsWith("/assets/ekklesia-display/")).toBe(false);
|
||||||
|
expect(await (await app.request(DASHBOARD_HOST_PAGE_PATH)).text()).toBe(PAGE_HTML);
|
||||||
|
expect(moduleHandler).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses at composition a snapshot whose validator does not match its bytes, or empty bytes", () => {
|
||||||
|
const consistent = createDashboardHostAssets(PAGE_HTML, SCRIPT);
|
||||||
|
|
||||||
|
for (const assets of [
|
||||||
|
{ ...consistent, html: `${PAGE_HTML}<!-- tampered -->` },
|
||||||
|
{ ...consistent, scriptEtag: PAGE_ETAG },
|
||||||
|
{ html: "", htmlEtag: sha256Etag(""), script: SCRIPT, scriptEtag: SCRIPT_ETAG },
|
||||||
|
]) {
|
||||||
|
expect(() => buildApp({ assets })).toThrow(DashboardHostConfigurationError);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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";
|
||||||
@@ -27,6 +27,8 @@ import { createApiApp } from "../src/app.js";
|
|||||||
import { DASHBOARD_NAVIGATION_PATH } from "../src/dashboard-routes.js";
|
import { DASHBOARD_NAVIGATION_PATH } from "../src/dashboard-routes.js";
|
||||||
import { MODULE_UI_INVENTORY_PATH } from "../src/module-ui-inventory.js";
|
import { MODULE_UI_INVENTORY_PATH } from "../src/module-ui-inventory.js";
|
||||||
import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
|
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 {
|
import {
|
||||||
createInMemoryModuleLifecycleStore,
|
createInMemoryModuleLifecycleStore,
|
||||||
moduleLifecycleSnapshotFor,
|
moduleLifecycleSnapshotFor,
|
||||||
@@ -75,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: 6,
|
manifestVersion: 8,
|
||||||
name: fixture.name,
|
name: fixture.name,
|
||||||
version: "1.0.0",
|
version: "1.0.0",
|
||||||
}),
|
}),
|
||||||
@@ -139,11 +141,13 @@ async function install(options: InstallOptions = {}): Promise<{ app: Hono }> {
|
|||||||
const app = createApiApp({
|
const app = createApiApp({
|
||||||
authorization: { evaluator, registry: composePlatformPermissionRegistry(modules), store },
|
authorization: { evaluator, registry: composePlatformPermissionRegistry(modules), store },
|
||||||
dashboard: createInMemoryDashboardStore(options.configuration ?? {}),
|
dashboard: createInMemoryDashboardStore(options.configuration ?? {}),
|
||||||
|
dashboardHostAssets: testDashboardHostAssets(),
|
||||||
|
health: readyHealth(),
|
||||||
moduleRoutes: Object.freeze([]),
|
moduleRoutes: Object.freeze([]),
|
||||||
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),
|
||||||
@@ -269,7 +273,9 @@ describe("Dashboard navigation — reading", () => {
|
|||||||
|
|
||||||
// Presentation wraps the host-neutral descriptor; it never rewrites it.
|
// Presentation wraps the host-neutral descriptor; it never rewrites it.
|
||||||
expect(livestream?.module).toEqual({
|
expect(livestream?.module).toEqual({
|
||||||
|
contributions: [],
|
||||||
api: { baseUrl: "/api/v1/modules/livestream" },
|
api: { baseUrl: "/api/v1/modules/livestream" },
|
||||||
|
display: null,
|
||||||
id: "livestream",
|
id: "livestream",
|
||||||
permissions: [],
|
permissions: [],
|
||||||
realtime: { baseUrl: "/api/v1/module-realtime/livestream" },
|
realtime: { baseUrl: "/api/v1/module-realtime/livestream" },
|
||||||
|
|||||||
@@ -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: 6,
|
manifestVersion: 8,
|
||||||
name: "Announcements fixture",
|
name: "Announcements fixture",
|
||||||
permissions: [
|
permissions: [
|
||||||
{
|
{
|
||||||
@@ -782,6 +782,127 @@ describe.sequential("display device pairing, end to end", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ADR 0056: a credential lasts while the screen is in use, and dies 180 days after it was last
|
||||||
|
* seen. Ages are set directly in the database, exactly as months of real use would leave them.
|
||||||
|
*/
|
||||||
|
describe("credential lifetime", () => {
|
||||||
|
const ttlSeconds = 180 * 24 * 60 * 60;
|
||||||
|
const descriptor = "/api/v1/module-displays/announcements";
|
||||||
|
|
||||||
|
function renewal(response: Response): string | undefined {
|
||||||
|
return setCookieFor(response, "ekklesia_device_session");
|
||||||
|
}
|
||||||
|
|
||||||
|
it("keeps a screen that is in use paired long past 180 days, renewing its cookie", async () => {
|
||||||
|
const running = await platform("renewal");
|
||||||
|
const device = new DeviceBrowser(running.origin);
|
||||||
|
try {
|
||||||
|
await pairDevice(running, device);
|
||||||
|
const credential = device.header().replace(/^.*ekklesia_device_session=/u, "");
|
||||||
|
// Paired 200 days ago, and seen yesterday: a screen that has simply been working.
|
||||||
|
await query(
|
||||||
|
running.url,
|
||||||
|
`update ekklesia_core_device set created_at = now() - interval '200 days', last_seen_at = now() - interval '1 day'`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const response = await device.request(descriptor);
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
// The same credential, re-issued with a full lifetime and the same protections.
|
||||||
|
const header = renewal(response);
|
||||||
|
expect(header).toBeDefined();
|
||||||
|
expect(header).toContain(`ekklesia_device_session=${credential};`);
|
||||||
|
expect(header).toMatch(new RegExp(`Max-Age=${ttlSeconds}(;|$)`, "u"));
|
||||||
|
expect(header).toMatch(/HttpOnly/u);
|
||||||
|
expect(header).toMatch(/SameSite=Strict/u);
|
||||||
|
expect(header).toMatch(/Path=\//u);
|
||||||
|
expect(header).not.toMatch(/Domain=/iu);
|
||||||
|
|
||||||
|
// Using it moved the server's window forward too.
|
||||||
|
const [row] = await query<{ recent: boolean }>(
|
||||||
|
running.url,
|
||||||
|
`select last_seen_at > now() - interval '1 minute' as recent from ekklesia_core_device`,
|
||||||
|
);
|
||||||
|
expect(row?.recent).toBe(true);
|
||||||
|
} finally {
|
||||||
|
await running.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps a credential paired under the fixed 180-day rule working once it is renewed", async () => {
|
||||||
|
const running = await platform("legacy");
|
||||||
|
const device = new DeviceBrowser(running.origin);
|
||||||
|
try {
|
||||||
|
await pairDevice(running, device);
|
||||||
|
// A production screen paired 179 days ago that checked in this morning: under the old
|
||||||
|
// rule it had one day left. It must carry straight on.
|
||||||
|
await query(
|
||||||
|
running.url,
|
||||||
|
`update ekklesia_core_device set created_at = now() - interval '179 days', last_seen_at = now() - interval '2 hours'`,
|
||||||
|
);
|
||||||
|
expect((await device.request(descriptor)).status).toBe(200);
|
||||||
|
await query(
|
||||||
|
running.url,
|
||||||
|
`update ekklesia_core_device set created_at = now() - interval '190 days'`,
|
||||||
|
);
|
||||||
|
expect((await device.request(descriptor)).status).toBe(200);
|
||||||
|
} finally {
|
||||||
|
await running.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lets an abandoned screen stop being an identity 180 days after it was last seen", async () => {
|
||||||
|
const running = await platform("abandoned");
|
||||||
|
const device = new DeviceBrowser(running.origin);
|
||||||
|
try {
|
||||||
|
await pairDevice(running, device);
|
||||||
|
await query(
|
||||||
|
running.url,
|
||||||
|
`update ekklesia_core_device set created_at = now() - interval '400 days', last_seen_at = now() - interval '181 days'`,
|
||||||
|
);
|
||||||
|
const response = await device.request(descriptor);
|
||||||
|
// Anonymous, exactly as revoked or wrong credentials are: the screen shows a pairing code.
|
||||||
|
expect(response.status).toBe(403);
|
||||||
|
expect(renewal(response)).toBeUndefined();
|
||||||
|
} finally {
|
||||||
|
await running.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never renews a revoked screen", async () => {
|
||||||
|
const running = await platform("revoked-renewal");
|
||||||
|
const device = new DeviceBrowser(running.origin);
|
||||||
|
try {
|
||||||
|
await pairDevice(running, device);
|
||||||
|
const listed = (await (
|
||||||
|
await adminRequest(running.origin, running.ownerCookie, "/api/v1/devices")
|
||||||
|
).json()) as { devices: Array<{ id: string }> };
|
||||||
|
await adminRequest(
|
||||||
|
running.origin,
|
||||||
|
running.ownerCookie,
|
||||||
|
`/api/v1/devices/${listed.devices[0]?.id}/revoke`,
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
const response = await device.request(descriptor);
|
||||||
|
expect(response.status).toBe(403);
|
||||||
|
expect(renewal(response)).toBeUndefined();
|
||||||
|
} finally {
|
||||||
|
await running.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not hand a device cookie to a person viewing the display", async () => {
|
||||||
|
const running = await platform("human-renewal");
|
||||||
|
try {
|
||||||
|
const response = await adminRequest(running.origin, running.ownerCookie, descriptor);
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(renewal(response)).toBeUndefined();
|
||||||
|
} finally {
|
||||||
|
await running.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("keeps a human administrator's own session unaffected by a device cookie", async () => {
|
it("keeps a human administrator's own session unaffected by a device cookie", async () => {
|
||||||
const running = await platform("mixed");
|
const running = await platform("mixed");
|
||||||
const device = new DeviceBrowser(running.origin);
|
const device = new DeviceBrowser(running.origin);
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ vi.mock("@ekklesia/shell/display-assets", () => ({
|
|||||||
displayHostAssetUrls: Object.freeze({
|
displayHostAssetUrls: Object.freeze({
|
||||||
html: "file:///surface/browser/display/index.html",
|
html: "file:///surface/browser/display/index.html",
|
||||||
script: "file:///surface/browser/display/app.js",
|
script: "file:///surface/browser/display/app.js",
|
||||||
|
serviceWorker: "file:///surface/browser/display/sw.js",
|
||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -20,21 +21,28 @@ import {
|
|||||||
describe("display host asset loading", () => {
|
describe("display host asset loading", () => {
|
||||||
beforeEach(() => readFileMock.mockReset());
|
beforeEach(() => readFileMock.mockReset());
|
||||||
|
|
||||||
it("reads only the two declared file URLs in order and freezes one consistent snapshot", async () => {
|
it("reads only the three declared file URLs in order and freezes one consistent snapshot", async () => {
|
||||||
const html = '<!doctype html><div id="ekklesia-display"></div>';
|
const html = '<!doctype html><div id="ekklesia-display"></div>';
|
||||||
const script = "export const displayHost = true;\n";
|
const script = "export const displayHost = true;\n";
|
||||||
readFileMock.mockResolvedValueOnce(html).mockResolvedValueOnce(script);
|
const serviceWorker = "export const displayWorker = true;\n";
|
||||||
|
readFileMock
|
||||||
|
.mockResolvedValueOnce(html)
|
||||||
|
.mockResolvedValueOnce(script)
|
||||||
|
.mockResolvedValueOnce(serviceWorker);
|
||||||
|
|
||||||
const assets = await loadDisplayHostAssets();
|
const assets = await loadDisplayHostAssets();
|
||||||
|
|
||||||
expect(readFileMock.mock.calls).toEqual([
|
expect(readFileMock.mock.calls).toEqual([
|
||||||
[new URL("file:///surface/browser/display/index.html"), "utf8"],
|
[new URL("file:///surface/browser/display/index.html"), "utf8"],
|
||||||
[new URL("file:///surface/browser/display/app.js"), "utf8"],
|
[new URL("file:///surface/browser/display/app.js"), "utf8"],
|
||||||
|
[new URL("file:///surface/browser/display/sw.js"), "utf8"],
|
||||||
]);
|
]);
|
||||||
expect(assets).toEqual({
|
expect(assets).toEqual({
|
||||||
html,
|
html,
|
||||||
script,
|
script,
|
||||||
scriptEtag: `"${createHash("sha256").update(script).digest("base64url")}"`,
|
scriptEtag: `"${createHash("sha256").update(script).digest("base64url")}"`,
|
||||||
|
serviceWorker,
|
||||||
|
serviceWorkerEtag: `"${createHash("sha256").update(serviceWorker).digest("base64url")}"`,
|
||||||
});
|
});
|
||||||
expect(Object.isFrozen(assets)).toBe(true);
|
expect(Object.isFrozen(assets)).toBe(true);
|
||||||
});
|
});
|
||||||
@@ -62,12 +70,15 @@ describe("display host asset loading", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it("rejects an empty page or script as an unavailable host build", () => {
|
it("rejects an empty page, script, or service worker as an unavailable host build", () => {
|
||||||
for (const [html, script] of [
|
for (const [html, script, serviceWorker] of [
|
||||||
["", "export {};"],
|
["", "export {};", "export {};"],
|
||||||
["<html></html>", " \n"],
|
["<html></html>", " \n", "export {};"],
|
||||||
|
["<html></html>", "export {};", " \n"],
|
||||||
] as const) {
|
] as const) {
|
||||||
expect(() => createDisplayHostAssets(html, script)).toThrow(DisplayHostAssetError);
|
expect(() => createDisplayHostAssets(html, script, serviceWorker)).toThrow(
|
||||||
|
DisplayHostAssetError,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,816 @@
|
|||||||
|
import { createHmac, randomBytes } from "node:crypto";
|
||||||
|
import { mkdtemp, rm } from "node:fs/promises";
|
||||||
|
import type { AddressInfo } from "node:net";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import {
|
||||||
|
createBootstrapIdentityCreator,
|
||||||
|
createIdentityDrizzleDatabase,
|
||||||
|
type IdentityNotificationFailure,
|
||||||
|
} from "@ekklesia/core/identity";
|
||||||
|
import {
|
||||||
|
applyModuleMigrations,
|
||||||
|
createPostgresMigrationDatabase,
|
||||||
|
discoverAllMigrations,
|
||||||
|
} from "@ekklesia/core/migrations";
|
||||||
|
import { Client, type QueryResultRow } from "pg";
|
||||||
|
import { SMTPServer } from "smtp-server";
|
||||||
|
import { afterAll, afterEach, describe, expect, it } from "vitest";
|
||||||
|
import { createApiRuntimeConfig } from "../src/config.js";
|
||||||
|
import { type RunningApi, startApi } from "../src/server.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Email verification and password recovery, end to end (ADR 0038).
|
||||||
|
*
|
||||||
|
* Everything here is real: a disposable PostgreSQL, the actual HTTP transport, the actual Better
|
||||||
|
* Auth endpoints, the actual SMTP provider, and an actual SMTP server accepting the message. The
|
||||||
|
* verification and reset links are **extracted from the delivered message** and then followed, which
|
||||||
|
* is the only way to prove the whole path rather than the pieces — a fake mail function would prove
|
||||||
|
* that a callback was invoked and nothing about whether the resulting link works.
|
||||||
|
*
|
||||||
|
* `smtp-server` runs in-process and is never given a live credential, so nothing here reaches the
|
||||||
|
* internet and no real email is ever sent.
|
||||||
|
*
|
||||||
|
* Public signup is disabled in every scenario, matching production. Accounts that are not the
|
||||||
|
* bootstrap owner are created through the same trusted server-side creator the bootstrap CLI uses,
|
||||||
|
* which is deliberately not reachable over HTTP.
|
||||||
|
*/
|
||||||
|
const integrationDatabaseUrlEnvironment = "EKKLESIA_MIGRATION_TEST_DATABASE_URL";
|
||||||
|
const repositoryRoot = path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "..", "..");
|
||||||
|
const coreMigrationsDirectory = path.join(repositoryRoot, "core", "drizzle");
|
||||||
|
const trackedDatabases = new Set<string>();
|
||||||
|
let databaseCounter = 0;
|
||||||
|
|
||||||
|
const dashboardOrigin = "http://dashboard.localhost:5173";
|
||||||
|
const integrationSecret = "integration-test-secret-0123456789abcdefgh";
|
||||||
|
const initialPassword = "correct horse battery staple";
|
||||||
|
const replacementPassword = "an entirely different passphrase";
|
||||||
|
|
||||||
|
class IntegrationHarnessError extends Error {
|
||||||
|
constructor(message: string) {
|
||||||
|
super(message);
|
||||||
|
this.name = "IntegrationHarnessError";
|
||||||
|
this.stack = `${this.name}: ${this.message}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function baseDatabaseUrl(): string {
|
||||||
|
const url = process.env[integrationDatabaseUrlEnvironment];
|
||||||
|
if (typeof url !== "string" || url.trim() === "") {
|
||||||
|
throw new IntegrationHarnessError("The integration database URL is not configured.");
|
||||||
|
}
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
|
||||||
|
function databaseUrl(name: string): string {
|
||||||
|
const url = new URL(baseDatabaseUrl());
|
||||||
|
url.pathname = `/${name}`;
|
||||||
|
return url.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openAdministrativeClient(): Promise<Client> {
|
||||||
|
const client = new Client({ connectionString: baseDatabaseUrl() });
|
||||||
|
await client.connect();
|
||||||
|
return client;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function safeQuery<T extends QueryResultRow>(
|
||||||
|
client: Client,
|
||||||
|
text: string,
|
||||||
|
values: readonly unknown[] = [],
|
||||||
|
): Promise<T[]> {
|
||||||
|
try {
|
||||||
|
const result = await client.query<T>(text, [...values]);
|
||||||
|
return result.rows;
|
||||||
|
} catch {
|
||||||
|
// Never rethrows the driver error: it can carry SQL, a connection string, and a host.
|
||||||
|
throw new IntegrationHarnessError("An integration database statement failed.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function applyCoreMigrations(url: string): Promise<void> {
|
||||||
|
const emptyModules = await mkdtemp(path.join(tmpdir(), "ekklesia-verify-no-modules-"));
|
||||||
|
try {
|
||||||
|
const aggregate = await discoverAllMigrations({
|
||||||
|
coreMigrationsDirectory,
|
||||||
|
moduleDiscovery: {
|
||||||
|
moduleSdkVersion: "0.1.0",
|
||||||
|
modulesDirectory: emptyModules,
|
||||||
|
platformVersion: "0.1.0",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await applyModuleMigrations({ aggregate, database: createPostgresMigrationDatabase(url) });
|
||||||
|
} finally {
|
||||||
|
await rm(emptyModules, { force: true, recursive: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function freshInstallation(label: string): Promise<string> {
|
||||||
|
databaseCounter += 1;
|
||||||
|
const name =
|
||||||
|
`ekk_verify_${label}_${databaseCounter}_${randomBytes(4).toString("hex")}`.toLowerCase();
|
||||||
|
const admin = await openAdministrativeClient();
|
||||||
|
try {
|
||||||
|
await safeQuery(admin, `create database "${name}"`);
|
||||||
|
} finally {
|
||||||
|
await admin.end().catch(() => undefined);
|
||||||
|
}
|
||||||
|
trackedDatabases.add(name);
|
||||||
|
const url = databaseUrl(name);
|
||||||
|
await applyCoreMigrations(url);
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A delivered message, as the SMTP server actually received it. */
|
||||||
|
interface DeliveredMessage {
|
||||||
|
readonly to: readonly string[];
|
||||||
|
readonly data: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MailHarness {
|
||||||
|
readonly port: number;
|
||||||
|
readonly delivered: DeliveredMessage[];
|
||||||
|
readonly close: () => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const openResources: Array<() => Promise<void>> = [];
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
for (const close of openResources.splice(0).reverse()) {
|
||||||
|
await close().catch(() => undefined);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Picks a free port before the API is configured.
|
||||||
|
*
|
||||||
|
* The identity base URL is what the emailed link is built from, so it has to be known *before*
|
||||||
|
* `startApi` binds. Without this the test could only assert the link's shape and would have to
|
||||||
|
* rewrite its host before following it — which is exactly the part worth proving. Reserving and
|
||||||
|
* releasing a port leaves a theoretical race, but nothing else on the loopback interface is
|
||||||
|
* competing for ephemeral ports inside this suite.
|
||||||
|
*/
|
||||||
|
async function reservePort(): Promise<number> {
|
||||||
|
const { createServer } = await import("node:net");
|
||||||
|
const server = createServer();
|
||||||
|
const port = await new Promise<number>((resolve, reject) => {
|
||||||
|
server.once("error", reject);
|
||||||
|
server.listen(0, "127.0.0.1", () => {
|
||||||
|
const address = server.address() as AddressInfo;
|
||||||
|
resolve(address.port);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||||
|
return port;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startMailServer(): Promise<MailHarness> {
|
||||||
|
const delivered: DeliveredMessage[] = [];
|
||||||
|
const server = new SMTPServer({
|
||||||
|
authOptional: true,
|
||||||
|
disabledCommands: ["STARTTLS"],
|
||||||
|
onData(stream, session, callback) {
|
||||||
|
let data = "";
|
||||||
|
stream.on("data", (chunk: Buffer) => {
|
||||||
|
data += chunk.toString("utf8");
|
||||||
|
});
|
||||||
|
stream.on("end", () => {
|
||||||
|
delivered.push({
|
||||||
|
data,
|
||||||
|
to: session.envelope.rcptTo.map((recipient) => recipient.address),
|
||||||
|
});
|
||||||
|
callback();
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
server.once("error", reject);
|
||||||
|
server.listen(0, "127.0.0.1", () => resolve());
|
||||||
|
});
|
||||||
|
const address = server.server.address() as AddressInfo;
|
||||||
|
const harness: MailHarness = {
|
||||||
|
close: async () =>
|
||||||
|
await new Promise<void>((resolve) => {
|
||||||
|
server.close(() => resolve());
|
||||||
|
}),
|
||||||
|
delivered,
|
||||||
|
port: address.port,
|
||||||
|
};
|
||||||
|
openResources.push(harness.close);
|
||||||
|
return harness;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Installation {
|
||||||
|
readonly url: string;
|
||||||
|
readonly api: RunningApi;
|
||||||
|
readonly origin: string;
|
||||||
|
readonly mail: MailHarness | undefined;
|
||||||
|
readonly notificationFailures: IdentityNotificationFailure[];
|
||||||
|
readonly createIdentity: (input: {
|
||||||
|
readonly email: string;
|
||||||
|
readonly password: string;
|
||||||
|
readonly name: string;
|
||||||
|
}) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A migrated installation with the API running, optionally with a working SMTP relay.
|
||||||
|
*
|
||||||
|
* `plaintext` SMTP and an `http` Dashboard origin are only acceptable because this is explicitly the
|
||||||
|
* development environment; production configuration refuses both, which `identity-config` and
|
||||||
|
* `mail-contract` assert separately.
|
||||||
|
*/
|
||||||
|
async function installation(
|
||||||
|
label: string,
|
||||||
|
options: { readonly mail?: boolean } = {},
|
||||||
|
): Promise<Installation> {
|
||||||
|
const url = await freshInstallation(label);
|
||||||
|
const mail = options.mail === false ? undefined : await startMailServer();
|
||||||
|
const notificationFailures: IdentityNotificationFailure[] = [];
|
||||||
|
const identity = createIdentityDrizzleDatabase(url, {});
|
||||||
|
openResources.push(async () => {
|
||||||
|
await identity.close().catch(() => undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
const apiPort = await reservePort();
|
||||||
|
const identityBaseUrl = `http://127.0.0.1:${apiPort}`;
|
||||||
|
const config = createApiRuntimeConfig(
|
||||||
|
{
|
||||||
|
databaseUrl: url,
|
||||||
|
host: "127.0.0.1",
|
||||||
|
identity: {
|
||||||
|
baseUrl: identityBaseUrl,
|
||||||
|
dashboardOrigin,
|
||||||
|
publicSignupEnabled: false,
|
||||||
|
secret: integrationSecret,
|
||||||
|
trustedOrigins: [dashboardOrigin],
|
||||||
|
},
|
||||||
|
...(mail
|
||||||
|
? {
|
||||||
|
mail: {
|
||||||
|
fromAddress: "[email protected]",
|
||||||
|
fromName: "Ekklesia",
|
||||||
|
host: "127.0.0.1",
|
||||||
|
port: String(mail.port),
|
||||||
|
security: "plaintext",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
port: apiPort,
|
||||||
|
},
|
||||||
|
{ environment: "development" },
|
||||||
|
);
|
||||||
|
const api = await startApi(config, {
|
||||||
|
rateLimitOverride: { max: 1000, window: 1 },
|
||||||
|
reportNotificationFailure: (failure) => notificationFailures.push(failure),
|
||||||
|
});
|
||||||
|
openResources.push(async () => {
|
||||||
|
await api.shutdown().catch(() => undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
const address = api.server.address();
|
||||||
|
if (!address || typeof address !== "object" || address.port !== apiPort) {
|
||||||
|
throw new IntegrationHarnessError("The API integration server did not bind the reserved port.");
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
api,
|
||||||
|
createIdentity: createBootstrapIdentityCreator(identity.db),
|
||||||
|
mail,
|
||||||
|
notificationFailures,
|
||||||
|
origin: identityBaseUrl,
|
||||||
|
url,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function post(
|
||||||
|
origin: string,
|
||||||
|
path: string,
|
||||||
|
body: unknown,
|
||||||
|
headers: Readonly<Record<string, string>> = {},
|
||||||
|
): Promise<Response> {
|
||||||
|
return await fetch(`${origin}${path}`, {
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
headers: { "content-type": "application/json", origin: dashboardOrigin, ...headers },
|
||||||
|
method: "POST",
|
||||||
|
redirect: "manual",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function firstCookiePair(response: Response): string | undefined {
|
||||||
|
const setCookie = response.headers.get("set-cookie");
|
||||||
|
return setCookie === null ? undefined : setCookie.split(",")[0]?.split(";")[0]?.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function signIn(
|
||||||
|
origin: string,
|
||||||
|
email: string,
|
||||||
|
password: string,
|
||||||
|
): Promise<{ readonly response: Response; readonly cookiePair: string | undefined }> {
|
||||||
|
const response = await post(origin, "/api/auth/sign-in/email", { email, password });
|
||||||
|
return { cookiePair: firstCookiePair(response), response };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function principalKind(origin: string, cookiePair: string | undefined): Promise<string> {
|
||||||
|
const response = await fetch(`${origin}/api/v1/principal`, {
|
||||||
|
headers: cookiePair ? { cookie: cookiePair } : {},
|
||||||
|
});
|
||||||
|
const body = (await response.json()) as { readonly kind?: unknown };
|
||||||
|
return String(body.kind);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The one place a test reads a token, and only ever out of a message this suite's own SMTP server
|
||||||
|
* received. Nothing in the platform exposes one.
|
||||||
|
*
|
||||||
|
* Quoted-printable soft line breaks are undone first: a URL longer than 76 characters is split by
|
||||||
|
* the encoder, and reassembling it here is what makes the extracted link the one a mail client would
|
||||||
|
* actually resolve.
|
||||||
|
*/
|
||||||
|
function extractLink(message: DeliveredMessage, contains: string): URL {
|
||||||
|
const unwrapped = message.data.replaceAll("=\r\n", "").replaceAll("=\n", "");
|
||||||
|
const decoded = unwrapped.replaceAll(/=([0-9A-F]{2})/gu, (_match, hex: string) =>
|
||||||
|
String.fromCharCode(Number.parseInt(hex, 16)),
|
||||||
|
);
|
||||||
|
const match = decoded.match(/https?:\/\/[^\s"<>]+/gu)?.find((url) => url.includes(contains));
|
||||||
|
if (!match) {
|
||||||
|
throw new IntegrationHarnessError(`No ${contains} link was delivered.`);
|
||||||
|
}
|
||||||
|
return new URL(match);
|
||||||
|
}
|
||||||
|
|
||||||
|
function lastMessage(mail: MailHarness): DeliveredMessage {
|
||||||
|
const message = mail.delivered.at(-1);
|
||||||
|
if (!message) {
|
||||||
|
throw new IntegrationHarnessError("No message was delivered.");
|
||||||
|
}
|
||||||
|
return message;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function emailVerifiedFlag(url: string, email: string): Promise<boolean | undefined> {
|
||||||
|
const client = new Client({ connectionString: url });
|
||||||
|
await client.connect();
|
||||||
|
try {
|
||||||
|
const rows = await safeQuery<{ email_verified: boolean }>(
|
||||||
|
client,
|
||||||
|
'select "email_verified" from "ekklesia_core_user" where lower("email") = $1',
|
||||||
|
[email.toLowerCase()],
|
||||||
|
);
|
||||||
|
return rows[0]?.email_verified;
|
||||||
|
} finally {
|
||||||
|
await client.end().catch(() => undefined);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An account that has completed the real verification round trip.
|
||||||
|
*
|
||||||
|
* Recovery is only meaningful for a verified account, and building one through the actual flow —
|
||||||
|
* rather than by writing `email_verified` directly — keeps every recovery test standing on a
|
||||||
|
* verification path that genuinely works.
|
||||||
|
*/
|
||||||
|
async function verifiedAccountFor(install: Installation, email: string): Promise<void> {
|
||||||
|
await install.createIdentity({ email, name: "Member", password: initialPassword });
|
||||||
|
await signIn(install.origin, email, initialPassword);
|
||||||
|
const link = extractLink(lastMessage(install.mail as MailHarness), "/verify-email");
|
||||||
|
await fetch(link, { redirect: "manual" });
|
||||||
|
(install.mail as MailHarness).delivered.length = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
const admin = await openAdministrativeClient().catch(() => undefined);
|
||||||
|
if (!admin) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
for (const name of trackedDatabases) {
|
||||||
|
await safeQuery(admin, `drop database if exists "${name}" with (force)`).catch(
|
||||||
|
() => undefined,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await admin.end().catch(() => undefined);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Forges a verification token that is structurally valid but already expired.
|
||||||
|
*
|
||||||
|
* Better Auth's verification token is a plain HS256 JWS over the auth secret (`signJWT`, pinned
|
||||||
|
* release) — not a database row — so an expired one can be produced here from the same secret this
|
||||||
|
* suite configured, with no library internals and no waiting an hour. This is the only place any
|
||||||
|
* test constructs a token rather than reading one out of a delivered message.
|
||||||
|
*/
|
||||||
|
function forgeExpiredVerificationToken(email: string): string {
|
||||||
|
const encode = (value: object): string =>
|
||||||
|
Buffer.from(JSON.stringify(value)).toString("base64url");
|
||||||
|
const issued = Math.floor(Date.now() / 1000) - 7200;
|
||||||
|
const signingInput = `${encode({ alg: "HS256", typ: "JWT" })}.${encode({
|
||||||
|
email: email.toLowerCase(),
|
||||||
|
exp: issued + 3600,
|
||||||
|
iat: issued,
|
||||||
|
})}`;
|
||||||
|
const signature = createHmac("sha256", integrationSecret)
|
||||||
|
.update(signingInput)
|
||||||
|
.digest("base64url");
|
||||||
|
return `${signingInput}.${signature}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("email verification, end to end", () => {
|
||||||
|
it("takes a new human account from unverified to signed in through a real emailed link", async () => {
|
||||||
|
const install = await installation("flow");
|
||||||
|
const email = "[email protected]";
|
||||||
|
await install.createIdentity({ email, name: "Member", password: initialPassword });
|
||||||
|
|
||||||
|
// 1. Unverified. A correct password does not produce a session — the identity system refuses
|
||||||
|
// before creating one, so there is no half-authenticated state anywhere (ADR 0038).
|
||||||
|
expect(await emailVerifiedFlag(install.url, email)).toBe(false);
|
||||||
|
const refused = await signIn(install.origin, email, initialPassword);
|
||||||
|
expect(refused.response.status).toBe(403);
|
||||||
|
expect(await refused.response.clone().json()).toMatchObject({ code: "EMAIL_NOT_VERIFIED" });
|
||||||
|
expect(refused.cookiePair).toBeUndefined();
|
||||||
|
expect(await principalKind(install.origin, refused.cookiePair)).toBe("anonymous");
|
||||||
|
|
||||||
|
// 2. That attempt delivered a verification message through the real SMTP provider.
|
||||||
|
const mail = install.mail as MailHarness;
|
||||||
|
expect(mail.delivered).toHaveLength(1);
|
||||||
|
expect(lastMessage(mail).to).toEqual([email]);
|
||||||
|
expect(lastMessage(mail).data).toContain("Verify your Ekklesia email address");
|
||||||
|
|
||||||
|
// 3. Follow the link exactly as it was delivered. Nothing rewrites it.
|
||||||
|
const link = extractLink(lastMessage(mail), "/verify-email");
|
||||||
|
expect(link.origin).toBe(install.origin);
|
||||||
|
const verified = await fetch(link, { redirect: "manual" });
|
||||||
|
|
||||||
|
// 4. Better Auth redirects to the Dashboard landing this deployment configured — never to the
|
||||||
|
// API origin, and never to anything a request supplied.
|
||||||
|
expect(verified.status).toBe(302);
|
||||||
|
const landing = new URL(verified.headers.get("location") ?? "");
|
||||||
|
expect(landing.origin).toBe(dashboardOrigin);
|
||||||
|
expect(landing.searchParams.get("ekklesia_auth")).toBe("verify-email");
|
||||||
|
expect(landing.searchParams.get("error")).toBeNull();
|
||||||
|
// No session was minted by following an emailed link.
|
||||||
|
expect(verified.headers.get("set-cookie")).toBeNull();
|
||||||
|
expect(await emailVerifiedFlag(install.url, email)).toBe(true);
|
||||||
|
|
||||||
|
// 5. The same credentials now work, and the principal is a fully authenticated human.
|
||||||
|
const accepted = await signIn(install.origin, email, initialPassword);
|
||||||
|
expect(accepted.response.status).toBe(200);
|
||||||
|
expect(accepted.cookiePair).toBeDefined();
|
||||||
|
expect(await principalKind(install.origin, accepted.cookiePair)).toBe("authenticated");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses a malformed token and an expired token, each with its own reason", async () => {
|
||||||
|
const install = await installation("bad-token");
|
||||||
|
const email = "[email protected]";
|
||||||
|
await install.createIdentity({ email, name: "Expired", password: initialPassword });
|
||||||
|
const callback = encodeURIComponent(`${dashboardOrigin}/?ekklesia_auth=verify-email`);
|
||||||
|
|
||||||
|
for (const [token, reason] of [
|
||||||
|
["not-a-jwt", "INVALID_TOKEN"],
|
||||||
|
[forgeExpiredVerificationToken(email), "TOKEN_EXPIRED"],
|
||||||
|
] as const) {
|
||||||
|
const response = await fetch(
|
||||||
|
`${install.origin}/api/auth/verify-email?token=${encodeURIComponent(token)}&callbackURL=${callback}`,
|
||||||
|
{ redirect: "manual" },
|
||||||
|
);
|
||||||
|
expect(response.status).toBe(302);
|
||||||
|
const landing = new URL(response.headers.get("location") ?? "");
|
||||||
|
// The failure still lands on the host's own screen rather than a raw JSON error page.
|
||||||
|
expect(landing.origin).toBe(dashboardOrigin);
|
||||||
|
expect(landing.searchParams.get("error")).toBe(reason);
|
||||||
|
expect(await emailVerifiedFlag(install.url, email)).toBe(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is idempotent when a link is followed twice, because the token is a signed claim", async () => {
|
||||||
|
const install = await installation("replay");
|
||||||
|
const email = "[email protected]";
|
||||||
|
await install.createIdentity({ email, name: "Replay", password: initialPassword });
|
||||||
|
await signIn(install.origin, email, initialPassword);
|
||||||
|
const link = extractLink(lastMessage(install.mail as MailHarness), "/verify-email");
|
||||||
|
|
||||||
|
const first = await fetch(link, { redirect: "manual" });
|
||||||
|
const second = await fetch(link, { redirect: "manual" });
|
||||||
|
|
||||||
|
// Documented honestly (ADR 0038): the verification token is a signed JWT, not a consumed
|
||||||
|
// database row, so it is **not** single-use — it is valid until it expires. Replaying it is a
|
||||||
|
// no-op on an already-verified address and mints no session, which is what makes that safe.
|
||||||
|
// Mail scanners and prefetchers dereference links, so idempotence is a requirement here.
|
||||||
|
expect(first.status).toBe(302);
|
||||||
|
expect(second.status).toBe(302);
|
||||||
|
expect(second.headers.get("set-cookie")).toBeNull();
|
||||||
|
expect(await emailVerifiedFlag(install.url, email)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resends on request without revealing whether the address has an account", async () => {
|
||||||
|
const install = await installation("resend");
|
||||||
|
const email = "[email protected]";
|
||||||
|
await install.createIdentity({ email, name: "Resend", password: initialPassword });
|
||||||
|
const mail = install.mail as MailHarness;
|
||||||
|
|
||||||
|
const known = await post(install.origin, "/api/auth/send-verification-email", { email });
|
||||||
|
const unknown = await post(install.origin, "/api/auth/send-verification-email", {
|
||||||
|
email: "[email protected]",
|
||||||
|
});
|
||||||
|
const verifiedAlready = await post(install.origin, "/api/auth/send-verification-email", {
|
||||||
|
email: "[email protected]",
|
||||||
|
});
|
||||||
|
|
||||||
|
// Identical answers. The only difference anywhere is that a message was delivered, which the
|
||||||
|
// requester cannot observe.
|
||||||
|
expect(known.status).toBe(200);
|
||||||
|
expect(unknown.status).toBe(200);
|
||||||
|
expect(verifiedAlready.status).toBe(200);
|
||||||
|
expect(await known.clone().json()).toEqual(await unknown.clone().json());
|
||||||
|
expect(mail.delivered.map((message) => message.to)).toEqual([[email]]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("password recovery, end to end", () => {
|
||||||
|
it("replaces the password through a real emailed link and invalidates the old one", async () => {
|
||||||
|
const install = await installation("reset");
|
||||||
|
const email = "[email protected]";
|
||||||
|
await verifiedAccountFor(install, email);
|
||||||
|
const mail = install.mail as MailHarness;
|
||||||
|
|
||||||
|
const requested = await post(install.origin, "/api/auth/request-password-reset", { email });
|
||||||
|
expect(requested.status).toBe(200);
|
||||||
|
expect(mail.delivered).toHaveLength(1);
|
||||||
|
expect(lastMessage(mail).to).toEqual([email]);
|
||||||
|
expect(lastMessage(mail).data).toContain("Reset your Ekklesia password");
|
||||||
|
|
||||||
|
// Follow the emailed link. Better Auth validates the token and hands the browser on to the
|
||||||
|
// Dashboard landing with it attached — which is where the host's reset form picks it up.
|
||||||
|
const link = extractLink(lastMessage(mail), "/reset-password/");
|
||||||
|
expect(link.origin).toBe(install.origin);
|
||||||
|
const handoff = await fetch(link, { redirect: "manual" });
|
||||||
|
expect(handoff.status).toBe(302);
|
||||||
|
const landing = new URL(handoff.headers.get("location") ?? "");
|
||||||
|
expect(landing.origin).toBe(dashboardOrigin);
|
||||||
|
expect(landing.searchParams.get("ekklesia_auth")).toBe("reset-password");
|
||||||
|
const token = landing.searchParams.get("token") ?? "";
|
||||||
|
expect(token).not.toBe("");
|
||||||
|
|
||||||
|
const completed = await post(install.origin, "/api/auth/reset-password", {
|
||||||
|
newPassword: replacementPassword,
|
||||||
|
token,
|
||||||
|
});
|
||||||
|
expect(completed.status).toBe(200);
|
||||||
|
|
||||||
|
const oldCredential = await signIn(install.origin, email, initialPassword);
|
||||||
|
expect(oldCredential.response.status).toBe(401);
|
||||||
|
const newCredential = await signIn(install.origin, email, replacementPassword);
|
||||||
|
expect(newCredential.response.status).toBe(200);
|
||||||
|
expect(await principalKind(install.origin, newCredential.cookiePair)).toBe("authenticated");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("consumes the reset token, so a captured link cannot be replayed", async () => {
|
||||||
|
const install = await installation("reset-replay");
|
||||||
|
const email = "[email protected]";
|
||||||
|
await verifiedAccountFor(install, email);
|
||||||
|
await post(install.origin, "/api/auth/request-password-reset", { email });
|
||||||
|
const link = extractLink(lastMessage(install.mail as MailHarness), "/reset-password/");
|
||||||
|
const handoff = await fetch(link, { redirect: "manual" });
|
||||||
|
const token = new URL(handoff.headers.get("location") ?? "").searchParams.get("token") ?? "";
|
||||||
|
|
||||||
|
const first = await post(install.origin, "/api/auth/reset-password", {
|
||||||
|
newPassword: replacementPassword,
|
||||||
|
token,
|
||||||
|
});
|
||||||
|
const second = await post(install.origin, "/api/auth/reset-password", {
|
||||||
|
newPassword: "yet another different passphrase",
|
||||||
|
token,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Unlike the verification token, this one is a database row Better Auth consumes.
|
||||||
|
expect(first.status).toBe(200);
|
||||||
|
expect(second.status).toBe(400);
|
||||||
|
expect(await second.json()).toMatchObject({ code: "INVALID_TOKEN" });
|
||||||
|
expect((await signIn(install.origin, email, replacementPassword)).response.status).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("signs out every existing session for that account", async () => {
|
||||||
|
const install = await installation("reset-sessions");
|
||||||
|
const email = "[email protected]";
|
||||||
|
await verifiedAccountFor(install, email);
|
||||||
|
const before = await signIn(install.origin, email, initialPassword);
|
||||||
|
expect(await principalKind(install.origin, before.cookiePair)).toBe("authenticated");
|
||||||
|
|
||||||
|
await post(install.origin, "/api/auth/request-password-reset", { email });
|
||||||
|
const link = extractLink(lastMessage(install.mail as MailHarness), "/reset-password/");
|
||||||
|
const handoff = await fetch(link, { redirect: "manual" });
|
||||||
|
const token = new URL(handoff.headers.get("location") ?? "").searchParams.get("token") ?? "";
|
||||||
|
await post(install.origin, "/api/auth/reset-password", {
|
||||||
|
newPassword: replacementPassword,
|
||||||
|
token,
|
||||||
|
});
|
||||||
|
|
||||||
|
// The whole point of recovery is that it works when the credential is already compromised, so
|
||||||
|
// an attacker's existing session must not survive it (`revokeSessionsOnPasswordReset`).
|
||||||
|
expect(await principalKind(install.origin, before.cookiePair)).toBe("anonymous");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("enforces the identity system's own password policy, with no reset-specific rule", async () => {
|
||||||
|
const install = await installation("reset-policy");
|
||||||
|
const email = "[email protected]";
|
||||||
|
await verifiedAccountFor(install, email);
|
||||||
|
await post(install.origin, "/api/auth/request-password-reset", { email });
|
||||||
|
const link = extractLink(lastMessage(install.mail as MailHarness), "/reset-password/");
|
||||||
|
const handoff = await fetch(link, { redirect: "manual" });
|
||||||
|
const token = new URL(handoff.headers.get("location") ?? "").searchParams.get("token") ?? "";
|
||||||
|
|
||||||
|
const tooShort = await post(install.origin, "/api/auth/reset-password", {
|
||||||
|
newPassword: "short",
|
||||||
|
token,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(tooShort.status).toBe(400);
|
||||||
|
expect(await tooShort.json()).toMatchObject({ code: "PASSWORD_TOO_SHORT" });
|
||||||
|
// The old password still works: a rejected reset changes nothing.
|
||||||
|
expect((await signIn(install.origin, email, initialPassword)).response.status).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("answers a request for an address with no account exactly as it answers a real one", async () => {
|
||||||
|
const install = await installation("enumeration");
|
||||||
|
const email = "[email protected]";
|
||||||
|
await verifiedAccountFor(install, email);
|
||||||
|
const mail = install.mail as MailHarness;
|
||||||
|
|
||||||
|
const known = await post(install.origin, "/api/auth/request-password-reset", { email });
|
||||||
|
const unknown = await post(install.origin, "/api/auth/request-password-reset", {
|
||||||
|
email: "[email protected]",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(known.status).toBe(unknown.status);
|
||||||
|
expect(await known.clone().json()).toEqual(await unknown.clone().json());
|
||||||
|
expect(JSON.stringify(await unknown.json())).not.toContain("stranger");
|
||||||
|
// Only the real account received anything.
|
||||||
|
expect(mail.delivered.map((message) => message.to)).toEqual([[email]]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("recovery when the installation has no outbound email", () => {
|
||||||
|
it("answers identically and records the operational failure out of band", async () => {
|
||||||
|
const install = await installation("no-mail", { mail: false });
|
||||||
|
const email = "[email protected]";
|
||||||
|
await install.createIdentity({ email, name: "No Mail", password: initialPassword });
|
||||||
|
|
||||||
|
const reset = await post(install.origin, "/api/auth/request-password-reset", { email });
|
||||||
|
const resend = await post(install.origin, "/api/auth/send-verification-email", { email });
|
||||||
|
|
||||||
|
// The user-facing answer must not change: "we could not send it" for a real address and a
|
||||||
|
// generic success for an unknown one is an enumeration oracle that appears when SMTP breaks.
|
||||||
|
expect(reset.status).toBe(200);
|
||||||
|
expect(resend.status).toBe(200);
|
||||||
|
// The operator still learns about it, with a sanitized code and no address or token.
|
||||||
|
expect(install.notificationFailures).toEqual([
|
||||||
|
{ kind: "password-reset", reason: "MAIL_NOT_CONFIGURED" },
|
||||||
|
{ kind: "email-verification", reason: "MAIL_NOT_CONFIGURED" },
|
||||||
|
]);
|
||||||
|
expect(JSON.stringify(install.notificationFailures)).not.toContain("[email protected]");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still starts, serves the API, and refuses an unverified sign-in", async () => {
|
||||||
|
const install = await installation("no-mail-runs", { mail: false });
|
||||||
|
const email = "[email protected]";
|
||||||
|
await install.createIdentity({ email, name: "No Mail", password: initialPassword });
|
||||||
|
|
||||||
|
// Absent mail is a complete deployment (ADR 0037) and does **not** relax the verification
|
||||||
|
// policy — which is exactly why the initial owner is created already verified.
|
||||||
|
expect((await fetch(`${install.origin}/api/v1/principal`)).status).toBe(200);
|
||||||
|
expect((await signIn(install.origin, email, initialPassword)).response.status).toBe(403);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("recovery cannot be turned into a redirect or an account factory", () => {
|
||||||
|
it("refuses an attacker-supplied reset destination", async () => {
|
||||||
|
const install = await installation("redirect");
|
||||||
|
const email = "[email protected]";
|
||||||
|
await install.createIdentity({ email, name: "Redirect", password: initialPassword });
|
||||||
|
|
||||||
|
const external = await post(install.origin, "/api/auth/request-password-reset", {
|
||||||
|
email,
|
||||||
|
redirectTo: "https://evil.example.test/steal",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(external.status).toBe(403);
|
||||||
|
expect(await external.json()).toMatchObject({ code: "INVALID_REDIRECT_URL" });
|
||||||
|
expect((install.mail as MailHarness).delivered).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses an attacker-supplied verification destination", async () => {
|
||||||
|
const install = await installation("redirect-verify");
|
||||||
|
|
||||||
|
for (const callbackURL of [
|
||||||
|
"https://evil.example.test",
|
||||||
|
"//evil.example.test",
|
||||||
|
"/\\evil.example.test",
|
||||||
|
"javascript:alert(1)",
|
||||||
|
]) {
|
||||||
|
const response = await fetch(
|
||||||
|
`${install.origin}/api/auth/verify-email?token=x&callbackURL=${encodeURIComponent(callbackURL)}`,
|
||||||
|
{ redirect: "manual" },
|
||||||
|
);
|
||||||
|
expect(response.status).toBe(403);
|
||||||
|
expect(response.headers.get("location")).toBeNull();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores a supplied destination even where one is accepted, because the link is rebuilt", async () => {
|
||||||
|
const install = await installation("redirect-ignored");
|
||||||
|
const email = "[email protected]";
|
||||||
|
await verifiedAccountFor(install, email);
|
||||||
|
|
||||||
|
// A *trusted* origin passes Better Auth's own check, so this is the case where the emailed
|
||||||
|
// link's destination would otherwise be caller-chosen. It is not: the message is built from
|
||||||
|
// configuration and the supplied value never reaches it.
|
||||||
|
await post(install.origin, "/api/auth/request-password-reset", {
|
||||||
|
email,
|
||||||
|
redirectTo: `${dashboardOrigin}/somewhere-else`,
|
||||||
|
});
|
||||||
|
|
||||||
|
const link = extractLink(lastMessage(install.mail as MailHarness), "/reset-password/");
|
||||||
|
const callback = new URL(link.searchParams.get("callbackURL") ?? "");
|
||||||
|
expect(callback.origin).toBe(dashboardOrigin);
|
||||||
|
expect(callback.pathname).toBe("/");
|
||||||
|
expect(callback.searchParams.get("ekklesia_auth")).toBe("reset-password");
|
||||||
|
expect(link.href).not.toContain("somewhere-else");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps public signup disabled now that verification exists", async () => {
|
||||||
|
const install = await installation("signup");
|
||||||
|
|
||||||
|
const response = await post(install.origin, "/api/auth/sign-up/email", {
|
||||||
|
email: "[email protected]",
|
||||||
|
name: "Intruder",
|
||||||
|
password: initialPassword,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Email verification is not a licence to self-register. Future accounts arrive through
|
||||||
|
// deliberate administration, which this pass does not build.
|
||||||
|
expect(response.status).toBe(400);
|
||||||
|
expect(await response.json()).toMatchObject({ code: "EMAIL_PASSWORD_SIGN_UP_DISABLED" });
|
||||||
|
expect(await emailVerifiedFlag(install.url, "[email protected]")).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("managed devices are untouched by human email identity", () => {
|
||||||
|
it("leaves a paired device intact across a human password reset", async () => {
|
||||||
|
const install = await installation("devices");
|
||||||
|
const email = "[email protected]";
|
||||||
|
await verifiedAccountFor(install, email);
|
||||||
|
|
||||||
|
// A device is a row with a credential digest and no email, no password, and no verification
|
||||||
|
// state (ADR 0036). It is inserted directly here because pairing it through HTTP would prove
|
||||||
|
// something this test is not about.
|
||||||
|
const client = new Client({ connectionString: install.url });
|
||||||
|
await client.connect();
|
||||||
|
try {
|
||||||
|
await safeQuery(
|
||||||
|
client,
|
||||||
|
'insert into "ekklesia_core_device" ("name", "credential_hash") values ($1, $2)',
|
||||||
|
["Sanctuary display", "a".repeat(64)],
|
||||||
|
);
|
||||||
|
|
||||||
|
await post(install.origin, "/api/auth/request-password-reset", { email });
|
||||||
|
const link = extractLink(lastMessage(install.mail as MailHarness), "/reset-password/");
|
||||||
|
const handoff = await fetch(link, { redirect: "manual" });
|
||||||
|
const token = new URL(handoff.headers.get("location") ?? "").searchParams.get("token") ?? "";
|
||||||
|
await post(install.origin, "/api/auth/reset-password", {
|
||||||
|
newPassword: replacementPassword,
|
||||||
|
token,
|
||||||
|
});
|
||||||
|
|
||||||
|
const devices = await safeQuery<{ credential_hash: string; revoked_at: Date | null }>(
|
||||||
|
client,
|
||||||
|
'select "credential_hash", "revoked_at" from "ekklesia_core_device"',
|
||||||
|
);
|
||||||
|
expect(devices).toEqual([{ credential_hash: "a".repeat(64), revoked_at: null }]);
|
||||||
|
|
||||||
|
// The device table has no email or verification concept for a human flow to reach.
|
||||||
|
const columns = await safeQuery<{ column_name: string }>(
|
||||||
|
client,
|
||||||
|
"select column_name from information_schema.columns where table_name = 'ekklesia_core_device'",
|
||||||
|
);
|
||||||
|
const names = columns.map((column) => column.column_name).join(" ");
|
||||||
|
expect(names).not.toContain("email");
|
||||||
|
expect(names).not.toContain("verified");
|
||||||
|
expect(names).not.toContain("password");
|
||||||
|
} finally {
|
||||||
|
await client.end().catch(() => undefined);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sends a device no mail, because a device has no address to send to", async () => {
|
||||||
|
const install = await installation("devices-mail");
|
||||||
|
|
||||||
|
await post(install.origin, "/api/auth/request-password-reset", {
|
||||||
|
email: "[email protected]",
|
||||||
|
});
|
||||||
|
|
||||||
|
// Nothing about device identity participates in either flow.
|
||||||
|
expect((install.mail as MailHarness).delivered).toHaveLength(0);
|
||||||
|
expect(install.notificationFailures).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,373 @@
|
|||||||
|
import {
|
||||||
|
ANONYMOUS_PRINCIPAL,
|
||||||
|
type ModuleRouteHandler,
|
||||||
|
parsePermissionId,
|
||||||
|
type RegisteredModuleHttpRoute,
|
||||||
|
} from "@ekklesia/contracts";
|
||||||
|
import {
|
||||||
|
type AuthorizationEvaluator,
|
||||||
|
type AuthorizationStore,
|
||||||
|
createPermissionRegistry,
|
||||||
|
} from "@ekklesia/core/authorization";
|
||||||
|
import type { EkklesiaIdentityRuntime } from "@ekklesia/core/identity";
|
||||||
|
import {
|
||||||
|
createModuleUiPublicationRegistry,
|
||||||
|
EMPTY_MODULE_REALTIME_REGISTRY,
|
||||||
|
type LegacyModuleRuntimeServices,
|
||||||
|
type ModuleDatabase,
|
||||||
|
} from "@ekklesia/core/modules";
|
||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { createApiApp } from "../src/app.js";
|
||||||
|
import {
|
||||||
|
type ApiHealthOptions,
|
||||||
|
HEALTH_LIVE_PATH,
|
||||||
|
HEALTH_READY_PATH,
|
||||||
|
READINESS_DATABASE_TIMEOUT_MS,
|
||||||
|
} from "../src/health-routes.js";
|
||||||
|
import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
|
||||||
|
import { testDashboardHostAssets } from "./support/dashboard-host-assets.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";
|
||||||
|
|
||||||
|
const trustedOrigin = "https://dashboard.example.test";
|
||||||
|
const modulePermission = parsePermissionId("health.status.read");
|
||||||
|
|
||||||
|
/** What a real driver failure can carry, and what must therefore never reach a response. */
|
||||||
|
const DRIVER_FAILURE_DETAIL =
|
||||||
|
"connect ECONNREFUSED postgresql://ekklesia:[email protected]:5432/ekklesia SELECT 1";
|
||||||
|
|
||||||
|
function fakeRuntime(): EkklesiaIdentityRuntime {
|
||||||
|
return {
|
||||||
|
handleAuthRequest: vi.fn(),
|
||||||
|
resolvePrincipal: vi.fn().mockResolvedValue(ANONYMOUS_PRINCIPAL),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function fakeStore(): AuthorizationStore {
|
||||||
|
return {
|
||||||
|
createGrant: vi.fn(),
|
||||||
|
grantPlatformOwner: vi.fn(),
|
||||||
|
hasActiveGrant: vi.fn().mockResolvedValue(false),
|
||||||
|
isPlatformOwner: vi.fn().mockResolvedValue(false),
|
||||||
|
listGrantsForUser: vi.fn().mockResolvedValue({ grants: [] }),
|
||||||
|
revokeGrant: vi.fn(),
|
||||||
|
revokePlatformOwner: vi.fn(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function fakeModuleServices(): LegacyModuleRuntimeServices {
|
||||||
|
return Object.freeze({ database: Object.freeze({}) as unknown as ModuleDatabase });
|
||||||
|
}
|
||||||
|
|
||||||
|
function healthOptions(overrides: Partial<ApiHealthOptions> = {}) {
|
||||||
|
return {
|
||||||
|
checkDatabase: vi.fn<() => Promise<void>>().mockResolvedValue(undefined),
|
||||||
|
isModuleStartupComplete: vi.fn<() => boolean>().mockReturnValue(true),
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildApp(
|
||||||
|
health: ApiHealthOptions,
|
||||||
|
moduleRoutes: readonly RegisteredModuleHttpRoute[] = Object.freeze([]),
|
||||||
|
) {
|
||||||
|
const evaluator: AuthorizationEvaluator = {
|
||||||
|
authorize: vi.fn().mockResolvedValue({ allowed: true }),
|
||||||
|
};
|
||||||
|
const runtime = fakeRuntime();
|
||||||
|
const app = createApiApp({
|
||||||
|
authorization: {
|
||||||
|
evaluator,
|
||||||
|
registry: createPermissionRegistry([
|
||||||
|
{ baseline: "public", description: "Test permission.", id: modulePermission },
|
||||||
|
]),
|
||||||
|
store: fakeStore(),
|
||||||
|
},
|
||||||
|
moduleRoutes,
|
||||||
|
moduleRealtime: EMPTY_MODULE_REALTIME_REGISTRY,
|
||||||
|
moduleServices: fakeModuleServices(),
|
||||||
|
moduleUi: createModuleUiPublicationRegistry([]),
|
||||||
|
moduleLifecycle: emptyModuleLifecycle(),
|
||||||
|
moduleConfiguration: emptyModuleConfiguration(),
|
||||||
|
moduleStorage: emptyModuleStorage(),
|
||||||
|
dashboard: createInMemoryDashboardStore(),
|
||||||
|
dashboardHostAssets: testDashboardHostAssets(),
|
||||||
|
health,
|
||||||
|
runtime,
|
||||||
|
trustedOrigins: [trustedOrigin],
|
||||||
|
});
|
||||||
|
return { app, evaluator, runtime };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Resolves once every already-queued microtask has run, using the real `setImmediate` — the only
|
||||||
|
* timer functions these tests fake are `setTimeout` and `clearTimeout`. */
|
||||||
|
function drainMicrotasks(): Promise<void> {
|
||||||
|
return new Promise((resolve) => setImmediate(resolve));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether `promise` settled with no fake time advanced at all: "promptly", made exact. */
|
||||||
|
async function settlesWithoutWaiting(promise: Response | Promise<Response>): Promise<boolean> {
|
||||||
|
let settled = false;
|
||||||
|
void Promise.resolve(promise).then(
|
||||||
|
() => {
|
||||||
|
settled = true;
|
||||||
|
},
|
||||||
|
() => {
|
||||||
|
settled = true;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
await drainMicrotasks();
|
||||||
|
return settled;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function expectNotReady(response: Response): Promise<void> {
|
||||||
|
expect(response.status).toBe(503);
|
||||||
|
expect(response.headers.get("cache-control")).toBe("no-store");
|
||||||
|
expect(await response.json()).toEqual({ code: "NOT_READY" });
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("GET /api/v1/health/live", () => {
|
||||||
|
it("returns 200 with a fixed body and Cache-Control: no-store", async () => {
|
||||||
|
const health = healthOptions();
|
||||||
|
const { app } = buildApp(health);
|
||||||
|
|
||||||
|
const response = await app.request(HEALTH_LIVE_PATH);
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(response.headers.get("cache-control")).toBe("no-store");
|
||||||
|
expect(response.headers.get("content-type")).toMatch(/^application\/json/u);
|
||||||
|
expect(await response.json()).toEqual({ status: "live" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not consult the database or module startup, so it stays 200 while readiness is 503", async () => {
|
||||||
|
const health = healthOptions({
|
||||||
|
checkDatabase: vi.fn().mockRejectedValue(new Error(DRIVER_FAILURE_DETAIL)),
|
||||||
|
isModuleStartupComplete: vi.fn().mockReturnValue(false),
|
||||||
|
});
|
||||||
|
const { app } = buildApp(health);
|
||||||
|
|
||||||
|
const response = await app.request(HEALTH_LIVE_PATH);
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(health.checkDatabase).not.toHaveBeenCalled();
|
||||||
|
expect(health.isModuleStartupComplete).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("GET /api/v1/health/ready", () => {
|
||||||
|
it("returns 200 with a fixed body when the database answers and module startup completed", async () => {
|
||||||
|
const health = healthOptions();
|
||||||
|
const { app } = buildApp(health);
|
||||||
|
|
||||||
|
const response = await app.request(HEALTH_READY_PATH);
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(response.headers.get("cache-control")).toBe("no-store");
|
||||||
|
expect(await response.json()).toEqual({ status: "ready" });
|
||||||
|
expect(health.checkDatabase).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns a fixed 503 when the database query fails, carrying nothing from the failure", async () => {
|
||||||
|
const health = healthOptions({
|
||||||
|
checkDatabase: vi.fn().mockRejectedValue(new Error(DRIVER_FAILURE_DETAIL)),
|
||||||
|
});
|
||||||
|
const { app } = buildApp(health);
|
||||||
|
|
||||||
|
const response = await app.request(HEALTH_READY_PATH);
|
||||||
|
const text = await response.clone().text();
|
||||||
|
|
||||||
|
await expectNotReady(response);
|
||||||
|
for (const fragment of ["ECONNREFUSED", "hunter2", "db.internal", "SELECT", "Error"]) {
|
||||||
|
expect(text).not.toContain(fragment);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("treats a check that throws synchronously exactly like one that rejects", async () => {
|
||||||
|
const health = healthOptions({
|
||||||
|
checkDatabase: vi.fn(() => {
|
||||||
|
throw new Error(DRIVER_FAILURE_DETAIL);
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const { app } = buildApp(health);
|
||||||
|
|
||||||
|
await expectNotReady(await app.request(HEALTH_READY_PATH));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 503 without touching the database before module startup completed", async () => {
|
||||||
|
const health = healthOptions({ isModuleStartupComplete: vi.fn().mockReturnValue(false) });
|
||||||
|
const { app } = buildApp(health);
|
||||||
|
|
||||||
|
await expectNotReady(await app.request(HEALTH_READY_PATH));
|
||||||
|
expect(health.checkDatabase).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 503 when shutdown began while the database query was in flight", async () => {
|
||||||
|
let startupComplete = true;
|
||||||
|
const health = healthOptions({
|
||||||
|
checkDatabase: vi.fn(async () => {
|
||||||
|
startupComplete = false;
|
||||||
|
}),
|
||||||
|
isModuleStartupComplete: () => startupComplete,
|
||||||
|
});
|
||||||
|
const { app } = buildApp(health);
|
||||||
|
|
||||||
|
await expectNotReady(await app.request(HEALTH_READY_PATH));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("starts a fresh check for each sequential probe rather than caching a success", async () => {
|
||||||
|
const health = healthOptions();
|
||||||
|
const { app } = buildApp(health);
|
||||||
|
|
||||||
|
await app.request(HEALTH_READY_PATH);
|
||||||
|
await app.request(HEALTH_READY_PATH);
|
||||||
|
|
||||||
|
expect(health.checkDatabase).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports ready for a slow query that still answers inside the bound", async () => {
|
||||||
|
vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
|
||||||
|
let answer: () => void = () => undefined;
|
||||||
|
const health = healthOptions({
|
||||||
|
checkDatabase: vi.fn(
|
||||||
|
() =>
|
||||||
|
new Promise<void>((resolve) => {
|
||||||
|
answer = resolve;
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
const { app } = buildApp(health);
|
||||||
|
|
||||||
|
const pending = app.request(HEALTH_READY_PATH);
|
||||||
|
await vi.advanceTimersByTimeAsync(READINESS_DATABASE_TIMEOUT_MS - 1);
|
||||||
|
answer();
|
||||||
|
|
||||||
|
expect((await pending).status).toBe(200);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("readiness never piles up database checks", () => {
|
||||||
|
it("keeps exactly one check in flight for a query that never settles, while every probe answers 503 promptly", async () => {
|
||||||
|
vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
|
||||||
|
const health = healthOptions({
|
||||||
|
checkDatabase: vi.fn(() => new Promise<void>(() => undefined)),
|
||||||
|
});
|
||||||
|
const { app } = buildApp(health);
|
||||||
|
|
||||||
|
// Probes arriving inside the first check's window share it and its single deadline.
|
||||||
|
const first = app.request(HEALTH_READY_PATH);
|
||||||
|
const concurrent = app.request(HEALTH_READY_PATH);
|
||||||
|
await drainMicrotasks();
|
||||||
|
expect(health.checkDatabase).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
await vi.advanceTimersByTimeAsync(READINESS_DATABASE_TIMEOUT_MS);
|
||||||
|
await expectNotReady(await first);
|
||||||
|
await expectNotReady(await concurrent);
|
||||||
|
|
||||||
|
// After the deadline the query is still unsettled: no new check starts, and each probe answers
|
||||||
|
// without waiting out another bound.
|
||||||
|
for (let probe = 0; probe < 20; probe += 1) {
|
||||||
|
const pending = app.request(HEALTH_READY_PATH);
|
||||||
|
expect(await settlesWithoutWaiting(pending)).toBe(true);
|
||||||
|
await expectNotReady(await pending);
|
||||||
|
}
|
||||||
|
expect(health.checkDatabase).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("starts a new check only once the timed-out query has actually settled", async () => {
|
||||||
|
vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
|
||||||
|
let settleHungQuery: (error: Error) => void = () => undefined;
|
||||||
|
const checkDatabase = vi
|
||||||
|
.fn<() => Promise<void>>()
|
||||||
|
.mockImplementationOnce(
|
||||||
|
() =>
|
||||||
|
new Promise<void>((_resolve, reject) => {
|
||||||
|
settleHungQuery = reject;
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.mockResolvedValue(undefined);
|
||||||
|
const { app } = buildApp(healthOptions({ checkDatabase }));
|
||||||
|
|
||||||
|
const first = app.request(HEALTH_READY_PATH);
|
||||||
|
await vi.advanceTimersByTimeAsync(READINESS_DATABASE_TIMEOUT_MS);
|
||||||
|
await expectNotReady(await first);
|
||||||
|
await expectNotReady(await app.request(HEALTH_READY_PATH));
|
||||||
|
expect(checkDatabase).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
// The database cancels the abandoned statement; the next probe may finally ask again.
|
||||||
|
settleHungQuery(new Error(DRIVER_FAILURE_DETAIL));
|
||||||
|
await drainMicrotasks();
|
||||||
|
|
||||||
|
const recovered = await app.request(HEALTH_READY_PATH);
|
||||||
|
expect(recovered.status).toBe(200);
|
||||||
|
expect(checkDatabase).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("health route boundaries", () => {
|
||||||
|
it.each([
|
||||||
|
HEALTH_LIVE_PATH,
|
||||||
|
HEALTH_READY_PATH,
|
||||||
|
])("%s accepts GET only, answering every other method with a fixed 405 and never checking readiness", async (path) => {
|
||||||
|
const health = healthOptions();
|
||||||
|
const { app } = buildApp(health);
|
||||||
|
|
||||||
|
for (const method of ["HEAD", "POST", "PUT", "PATCH", "DELETE"]) {
|
||||||
|
const response = await app.request(path, { method });
|
||||||
|
expect(response.status, method).toBe(405);
|
||||||
|
expect(response.headers.get("allow")).toBe("GET");
|
||||||
|
expect(response.headers.get("cache-control")).toBe("no-store");
|
||||||
|
expect(await response.text()).toBe("");
|
||||||
|
}
|
||||||
|
expect(health.checkDatabase).not.toHaveBeenCalled();
|
||||||
|
expect(health.isModuleStartupComplete).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
HEALTH_LIVE_PATH,
|
||||||
|
HEALTH_READY_PATH,
|
||||||
|
])("%s is unauthenticated: it never resolves a principal or evaluates authorization, even with a cookie", async (path) => {
|
||||||
|
const { app, evaluator, runtime } = buildApp(healthOptions());
|
||||||
|
|
||||||
|
const response = await app.request(path, {
|
||||||
|
headers: { Cookie: "better-auth.session_token=anything", Origin: trustedOrigin },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(runtime.resolvePrincipal).not.toHaveBeenCalled();
|
||||||
|
expect(runtime.handleAuthRequest).not.toHaveBeenCalled();
|
||||||
|
expect(evaluator.authorize).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('cannot be shadowed by a module, even one whose id is "health"', async () => {
|
||||||
|
const moduleHandler = vi.fn<ModuleRouteHandler>(
|
||||||
|
async () => new Response("module", { status: 200 }),
|
||||||
|
);
|
||||||
|
const moduleRoutes = ["/live", "/ready"].map(
|
||||||
|
(path) =>
|
||||||
|
Object.freeze({
|
||||||
|
bodyMode: "buffered",
|
||||||
|
handler: moduleHandler,
|
||||||
|
maxBodyBytes: 1024,
|
||||||
|
method: "GET",
|
||||||
|
moduleId: "health",
|
||||||
|
path,
|
||||||
|
permission: modulePermission,
|
||||||
|
}) as RegisteredModuleHttpRoute,
|
||||||
|
);
|
||||||
|
const { app } = buildApp(healthOptions(), moduleRoutes);
|
||||||
|
|
||||||
|
expect(await (await app.request(HEALTH_LIVE_PATH)).json()).toEqual({ status: "live" });
|
||||||
|
expect(await (await app.request(HEALTH_READY_PATH)).json()).toEqual({ status: "ready" });
|
||||||
|
expect(moduleHandler).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
// The module's own routes still live where every module route lives, and only there.
|
||||||
|
const moduleResponse = await app.request("/api/v1/modules/health/ready");
|
||||||
|
expect(await moduleResponse.text()).toBe("module");
|
||||||
|
expect(moduleHandler).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,184 @@
|
|||||||
|
import { loadHostConfiguration } from "@ekklesia/core/host";
|
||||||
|
import { createMailProviderConfig } from "@ekklesia/core/mail";
|
||||||
|
import { createStorageProviderConfig } from "@ekklesia/core/storage";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { createApiRuntimeConfig } from "../src/config.js";
|
||||||
|
import { readConfigFromEnvironment } from "../src/server.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `pnpm doctor` and `startApi` must reach the same conclusions from the same environment. They
|
||||||
|
* previously could not: each executable read its own subset of the variables, and Doctor's subset
|
||||||
|
* omitted `S3_*` and the module secrets entirely.
|
||||||
|
*
|
||||||
|
* These tests assert the property directly — the API's startup inputs and the inputs Doctor
|
||||||
|
* validates come from one `loadHostConfiguration` call and produce identical validated
|
||||||
|
* configuration, including every default.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const secretKey = "sk-example-secret-material";
|
||||||
|
const smtpPassword = "sup3r-secret-relay-password";
|
||||||
|
|
||||||
|
function environment(
|
||||||
|
overrides: Readonly<Record<string, string | undefined>> = {},
|
||||||
|
): Readonly<Record<string, string | undefined>> {
|
||||||
|
return {
|
||||||
|
DATABASE_URL: "postgres://[email protected]:5432/ekklesia",
|
||||||
|
EKKLESIA_IDENTITY_BASE_URL: "https://ekklesia.example.org",
|
||||||
|
EKKLESIA_IDENTITY_SECRET: "b7QwZ2rTn4Xs9LmVpKdF6yHc3JgEuNaW",
|
||||||
|
EKKLESIA_IDENTITY_TRUSTED_ORIGINS: "https://ekklesia.example.org",
|
||||||
|
NODE_ENV: "production",
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const configuredInfrastructure = Object.freeze({
|
||||||
|
EKKLESIA_SMTP_FROM_ADDRESS: "[email protected]",
|
||||||
|
EKKLESIA_SMTP_HELO_NAME: "mail.example.org",
|
||||||
|
EKKLESIA_SMTP_HOST: "smtp.example.org",
|
||||||
|
EKKLESIA_SMTP_PASSWORD: smtpPassword,
|
||||||
|
EKKLESIA_SMTP_USERNAME: "[email protected]",
|
||||||
|
S3_ACCESS_KEY: "AKIAEXAMPLEACCESSID",
|
||||||
|
S3_ENDPOINT: "https://storage.example.org",
|
||||||
|
S3_SECRET_KEY: secretKey,
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Exactly what Doctor validates, from exactly the same load the API performs. */
|
||||||
|
function doctorView(env: Readonly<Record<string, string | undefined>>) {
|
||||||
|
const host = loadHostConfiguration(env);
|
||||||
|
return {
|
||||||
|
mail: createMailProviderConfig(host.mail, { environment: host.environment }),
|
||||||
|
storage: createStorageProviderConfig(host.storage, { environment: host.environment }),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function apiView(env: Readonly<Record<string, string | undefined>>) {
|
||||||
|
const { environment: resolved, input } = readConfigFromEnvironment(env);
|
||||||
|
return createApiRuntimeConfig(input, { environment: resolved });
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("host configuration parity between startApi and Doctor", () => {
|
||||||
|
it("derives identical object-storage configuration, defaults included", () => {
|
||||||
|
const env = environment(configuredInfrastructure);
|
||||||
|
|
||||||
|
expect(apiView(env).storage).toEqual(doctorView(env).storage);
|
||||||
|
// Named explicitly rather than only compared, so a change to either default fails here loudly.
|
||||||
|
expect(apiView(env).storage).toEqual({
|
||||||
|
accessKey: "AKIAEXAMPLEACCESSID",
|
||||||
|
bucketPrefix: "ekklesia",
|
||||||
|
deliveryEndpoint: "https://storage.example.org",
|
||||||
|
endpoint: "https://storage.example.org",
|
||||||
|
forcePathStyle: true,
|
||||||
|
region: "us-east-1",
|
||||||
|
secretKey,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("derives identical outbound-email configuration, defaults included", () => {
|
||||||
|
const env = environment(configuredInfrastructure);
|
||||||
|
|
||||||
|
expect(apiView(env).mail).toEqual(doctorView(env).mail);
|
||||||
|
expect(apiView(env).mail).toMatchObject({
|
||||||
|
// The SMTP client identity is part of that equivalence: a relay that refuses the greeting
|
||||||
|
// must fail in Doctor for the same reason it fails at runtime.
|
||||||
|
heloName: "mail.example.org",
|
||||||
|
host: "smtp.example.org",
|
||||||
|
port: 587,
|
||||||
|
security: "starttls",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("agrees that an installation with neither service is complete", () => {
|
||||||
|
const env = environment();
|
||||||
|
|
||||||
|
expect(apiView(env).storage).toBeUndefined();
|
||||||
|
expect(doctorView(env).storage).toBeUndefined();
|
||||||
|
expect(apiView(env).mail).toBeUndefined();
|
||||||
|
expect(doctorView(env).mail).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("carries every storage field the direct-delivery pass established", () => {
|
||||||
|
const env = environment({
|
||||||
|
...configuredInfrastructure,
|
||||||
|
S3_BUCKET_PREFIX: "rogc",
|
||||||
|
S3_DELIVERY_ENDPOINT: "https://files.example.org",
|
||||||
|
S3_FORCE_PATH_STYLE: "false",
|
||||||
|
S3_REGION: "eu-west-1",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(apiView(env).storage).toEqual(doctorView(env).storage);
|
||||||
|
expect(apiView(env).storage).toMatchObject({
|
||||||
|
bucketPrefix: "rogc",
|
||||||
|
deliveryEndpoint: "https://files.example.org",
|
||||||
|
forcePathStyle: false,
|
||||||
|
region: "eu-west-1",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects the same malformed environment with the same sanitized code", () => {
|
||||||
|
for (const [overrides, code] of [
|
||||||
|
[{ S3_ENDPOINT: "https://storage.example.org" }, "STORAGE_ACCESS_KEY_MISSING"],
|
||||||
|
[{ ...configuredInfrastructure, S3_ENDPOINT: "not-a-url" }, "STORAGE_ENDPOINT_INVALID"],
|
||||||
|
[
|
||||||
|
{ ...configuredInfrastructure, S3_ENDPOINT: "http://storage.example.org" },
|
||||||
|
"STORAGE_ENDPOINT_INSECURE_IN_PRODUCTION",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
{ ...configuredInfrastructure, EKKLESIA_SMTP_PASSWORD: undefined },
|
||||||
|
"MAIL_USERNAME_WITHOUT_PASSWORD",
|
||||||
|
],
|
||||||
|
[{ ...configuredInfrastructure, EKKLESIA_SMTP_PORT: "70000" }, "MAIL_PORT_INVALID"],
|
||||||
|
[
|
||||||
|
{ ...configuredInfrastructure, EKKLESIA_SMTP_HELO_NAME: "localhost" },
|
||||||
|
"MAIL_HELO_NAME_INVALID",
|
||||||
|
],
|
||||||
|
] as const) {
|
||||||
|
const env = environment(overrides);
|
||||||
|
expect(() => apiView(env)).toThrowError(expect.objectContaining({ code }));
|
||||||
|
expect(() => doctorView(env)).toThrowError(expect.objectContaining({ code }));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resolves module secrets from the same source the runtime is given", () => {
|
||||||
|
const env = environment({
|
||||||
|
EKKLESIA_MODULE_SECRET__ANNOUNCEMENTS__STORAGE_CREDENTIAL: "module-secret-material",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(
|
||||||
|
readConfigFromEnvironment(env).moduleSecrets.get("announcements", "storageCredential"),
|
||||||
|
).toBe("module-secret-material");
|
||||||
|
expect(loadHostConfiguration(env).moduleSecrets.get("announcements", "storageCredential")).toBe(
|
||||||
|
"module-secret-material",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hands the API the same modules directory Doctor and the operator commands read", () => {
|
||||||
|
expect(readConfigFromEnvironment(environment()).modulesDirectory).toBeUndefined();
|
||||||
|
const env = environment({ EKKLESIA_MODULES_DIR: "/var/lib/ekklesia/modules" });
|
||||||
|
expect(readConfigFromEnvironment(env).modulesDirectory).toBe("/var/lib/ekklesia/modules");
|
||||||
|
expect(loadHostConfiguration(env).modulesDirectory).toBe("/var/lib/ekklesia/modules");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reads only this listener's own address and port outside the shared loader", () => {
|
||||||
|
const config = apiView(
|
||||||
|
environment({ EKKLESIA_API_HOST: "0.0.0.0", EKKLESIA_API_PORT: "8080" }),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(config.host).toBe("0.0.0.0");
|
||||||
|
expect(config.port).toBe(8080);
|
||||||
|
// Defaults, when unset, stay where they always were.
|
||||||
|
const defaults = apiView(environment());
|
||||||
|
expect(defaults.host).toBe("127.0.0.1");
|
||||||
|
expect(defaults.port).toBe(3000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("interprets a set-but-empty flag identically instead of one side ignoring it", () => {
|
||||||
|
// Doctor used to drop any falsy identity value before validating, so `startApi` refused to
|
||||||
|
// start on `EKKLESIA_PUBLIC_SIGNUP_ENABLED=` while Doctor reported the deployment ready.
|
||||||
|
const env = environment({ EKKLESIA_PUBLIC_SIGNUP_ENABLED: "" });
|
||||||
|
|
||||||
|
expect(loadHostConfiguration(env).identity.publicSignupEnabled).toBe("");
|
||||||
|
expect(() => apiView(env)).toThrowError(
|
||||||
|
expect.objectContaining({ code: "PUBLIC_SIGNUP_ENABLED_INVALID" }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -170,9 +170,13 @@ function bootstrapAgainst(url: string): BootstrapHandles {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The production posture: public signup disabled, exactly as a real deployment must run. */
|
/**
|
||||||
async function startApiWithoutSignup(url: string): Promise<RunningApi> {
|
* The production posture: public signup disabled, exactly as a real deployment must run — and, just
|
||||||
const config = createApiRuntimeConfig(
|
* as deliberately, **no outbound email configured at all**. A fresh installation has no relay yet,
|
||||||
|
* which is the whole reason the initial owner is created already verified (ADR 0038).
|
||||||
|
*/
|
||||||
|
function ownerApiConfig(url: string): ReturnType<typeof createApiRuntimeConfig> {
|
||||||
|
return createApiRuntimeConfig(
|
||||||
{
|
{
|
||||||
databaseUrl: url,
|
databaseUrl: url,
|
||||||
host: "127.0.0.1",
|
host: "127.0.0.1",
|
||||||
@@ -186,7 +190,10 @@ async function startApiWithoutSignup(url: string): Promise<RunningApi> {
|
|||||||
},
|
},
|
||||||
{ environment: "development" },
|
{ environment: "development" },
|
||||||
);
|
);
|
||||||
return startApi(config, { rateLimitOverride: { max: 1000, window: 1 } });
|
}
|
||||||
|
|
||||||
|
async function startApiWithoutSignup(url: string): Promise<RunningApi> {
|
||||||
|
return startApi(ownerApiConfig(url), { rateLimitOverride: { max: 1000, window: 1 } });
|
||||||
}
|
}
|
||||||
|
|
||||||
function apiOrigin(running: RunningApi): string {
|
function apiOrigin(running: RunningApi): string {
|
||||||
@@ -270,6 +277,22 @@ async function countRows(url: string, table: string): Promise<number> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Better Auth's own `emailVerified` column — the same value its `/verify-email` endpoint writes. */
|
||||||
|
async function emailVerifiedFlag(url: string, email: string): Promise<boolean | undefined> {
|
||||||
|
const client = new Client({ connectionString: url });
|
||||||
|
await client.connect();
|
||||||
|
try {
|
||||||
|
const rows = await safeQuery<{ email_verified: boolean }>(
|
||||||
|
client,
|
||||||
|
'select "email_verified" from "ekklesia_core_user" where lower("email") = $1',
|
||||||
|
[email.trim().toLowerCase()],
|
||||||
|
);
|
||||||
|
return rows[0]?.email_verified;
|
||||||
|
} finally {
|
||||||
|
await client.end().catch(() => undefined);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
afterAll(async () => {
|
afterAll(async () => {
|
||||||
const admin = await openAdministrativeClient().catch(() => undefined);
|
const admin = await openAdministrativeClient().catch(() => undefined);
|
||||||
if (!admin) {
|
if (!admin) {
|
||||||
@@ -685,3 +708,141 @@ describe.sequential("initial platform-owner bootstrap", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe.sequential("initial platform-owner bootstrap and email verification", () => {
|
||||||
|
/**
|
||||||
|
* The trusted-bootstrap verification exception (ADR 0038).
|
||||||
|
*
|
||||||
|
* A verified address became a precondition for holding a session in Phase 7C Pass 2. The initial
|
||||||
|
* owner is created on a freshly provisioned server, by an operator holding the database
|
||||||
|
* credentials, at a moment when SMTP may not exist — so requiring an email round trip here would
|
||||||
|
* produce a working installation nobody can ever sign in to. These scenarios exist to prove that
|
||||||
|
* the exception is real, that it is narrow, and that mail remains genuinely absent throughout.
|
||||||
|
*/
|
||||||
|
it("creates the owner already verified, proven in the database and in the browser", async () => {
|
||||||
|
const url = await freshInstallation("verifiedowner");
|
||||||
|
const email = `owner-${randomBytes(4).toString("hex")}@example.test`;
|
||||||
|
|
||||||
|
const bootstrap = bootstrapAgainst(url);
|
||||||
|
try {
|
||||||
|
await bootstrap.run(email);
|
||||||
|
} finally {
|
||||||
|
await bootstrap.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Database state: the exact column Better Auth's own verification endpoint writes, so the row
|
||||||
|
// is indistinguishable from one that verified normally.
|
||||||
|
expect(await emailVerifiedFlag(url, email)).toBe(true);
|
||||||
|
|
||||||
|
// ...and the browser-facing consequence, which is the only thing that actually matters.
|
||||||
|
const api = await startApiWithoutSignup(url);
|
||||||
|
try {
|
||||||
|
const origin = apiOrigin(api);
|
||||||
|
const session = await signIn(origin, email, ownerPassword);
|
||||||
|
expect(session.response.status).toBe(200);
|
||||||
|
expect(session.cookiePair).toBeDefined();
|
||||||
|
} finally {
|
||||||
|
await api.shutdown();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("needs no outbound email configured, at bootstrap or at sign-in", async () => {
|
||||||
|
const url = await freshInstallation("nomailowner");
|
||||||
|
const email = `owner-${randomBytes(4).toString("hex")}@example.test`;
|
||||||
|
|
||||||
|
const bootstrap = bootstrapAgainst(url);
|
||||||
|
try {
|
||||||
|
// No SMTP host, no relay, no network: `createBootstrapIdentityCreator` constructs its own
|
||||||
|
// Better Auth instance with no `emailVerification` configuration at all.
|
||||||
|
await bootstrap.run(email);
|
||||||
|
} finally {
|
||||||
|
await bootstrap.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
const api = await startApiWithoutSignup(url);
|
||||||
|
try {
|
||||||
|
// The API this suite starts configures no mail either, so nothing in this scenario could have
|
||||||
|
// sent or received a message.
|
||||||
|
expect(ownerApiConfig(url).mail).toBeUndefined();
|
||||||
|
expect((await signIn(apiOrigin(api), email, ownerPassword)).response.status).toBe(200);
|
||||||
|
} finally {
|
||||||
|
await api.shutdown();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("marks an adopted identity verified too, so bootstrap never yields an owner who cannot sign in", async () => {
|
||||||
|
const url = await freshInstallation("adoptverified");
|
||||||
|
const email = `owner-${randomBytes(4).toString("hex")}@example.test`;
|
||||||
|
|
||||||
|
// An identity that exists before bootstrap runs — created here through the same trusted creator,
|
||||||
|
// which always writes `emailVerified: false`.
|
||||||
|
const first = bootstrapAgainst(url);
|
||||||
|
try {
|
||||||
|
await first.run(email);
|
||||||
|
} finally {
|
||||||
|
await first.close();
|
||||||
|
}
|
||||||
|
const client = new Client({ connectionString: url });
|
||||||
|
await client.connect();
|
||||||
|
try {
|
||||||
|
await safeQuery(client, 'update "ekklesia_core_user" set "email_verified" = false');
|
||||||
|
await safeQuery(client, 'delete from "ekklesia_core_platform_owner_bootstrap"');
|
||||||
|
await safeQuery(client, 'delete from "ekklesia_core_platform_owner"');
|
||||||
|
} finally {
|
||||||
|
await client.end().catch(() => undefined);
|
||||||
|
}
|
||||||
|
expect(await emailVerifiedFlag(url, email)).toBe(false);
|
||||||
|
|
||||||
|
const second = bootstrapAgainst(url);
|
||||||
|
let adopted: Awaited<ReturnType<typeof createInitialPlatformOwner>>;
|
||||||
|
try {
|
||||||
|
adopted = await second.run(email);
|
||||||
|
} finally {
|
||||||
|
await second.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(adopted.identity).toBe("adopted");
|
||||||
|
expect(await emailVerifiedFlag(url, email)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("verifies only the owner, leaving every other account subject to the normal policy", async () => {
|
||||||
|
const url = await freshInstallation("narrowexception");
|
||||||
|
const ownerEmail = `owner-${randomBytes(4).toString("hex")}@example.test`;
|
||||||
|
const otherEmail = `member-${randomBytes(4).toString("hex")}@example.test`;
|
||||||
|
|
||||||
|
const bootstrap = bootstrapAgainst(url);
|
||||||
|
try {
|
||||||
|
await bootstrap.run(ownerEmail);
|
||||||
|
// A second identity created through the identical trusted creator, but never bootstrapped.
|
||||||
|
const identity = createIdentityDrizzleDatabase(url, {});
|
||||||
|
try {
|
||||||
|
await createBootstrapIdentityCreator(identity.db)({
|
||||||
|
email: otherEmail,
|
||||||
|
name: "Ordinary Member",
|
||||||
|
password: ownerPassword,
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
await identity.close().catch(() => undefined);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await bootstrap.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(await emailVerifiedFlag(url, ownerEmail)).toBe(true);
|
||||||
|
expect(await emailVerifiedFlag(url, otherEmail)).toBe(false);
|
||||||
|
|
||||||
|
const api = await startApiWithoutSignup(url);
|
||||||
|
try {
|
||||||
|
const origin = apiOrigin(api);
|
||||||
|
expect((await signIn(origin, ownerEmail, ownerPassword)).response.status).toBe(200);
|
||||||
|
// The exception did not widen: an ordinary account still has to verify, and on this
|
||||||
|
// mail-less installation it simply cannot sign in yet.
|
||||||
|
const refused = await signIn(origin, otherEmail, ownerPassword);
|
||||||
|
expect(refused.response.status).toBe(403);
|
||||||
|
expect(await refused.response.json()).toMatchObject({ code: "EMAIL_NOT_VERIFIED" });
|
||||||
|
expect(refused.cookiePair).toBeUndefined();
|
||||||
|
} finally {
|
||||||
|
await api.shutdown();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -9,11 +9,21 @@ const drizzleAdapterMock = vi.hoisted(() => vi.fn());
|
|||||||
const serveMock = vi.hoisted(() => vi.fn());
|
const serveMock = vi.hoisted(() => vi.fn());
|
||||||
const processOnSpy = vi.hoisted(() => vi.fn());
|
const processOnSpy = vi.hoisted(() => vi.fn());
|
||||||
const loadDisplayHostAssetsMock = vi.hoisted(() => vi.fn());
|
const loadDisplayHostAssetsMock = vi.hoisted(() => vi.fn());
|
||||||
|
const loadDashboardHostAssetsMock = vi.hoisted(() => vi.fn());
|
||||||
|
|
||||||
vi.mock("pg", () => ({ Pool: poolConstructor }));
|
vi.mock("pg", () => ({ Pool: poolConstructor }));
|
||||||
vi.mock("better-auth", () => ({ betterAuth: betterAuthMock }));
|
vi.mock("better-auth", () => ({ betterAuth: betterAuthMock }));
|
||||||
vi.mock("better-auth/adapters/drizzle", () => ({ drizzleAdapter: drizzleAdapterMock }));
|
vi.mock("better-auth/adapters/drizzle", () => ({ drizzleAdapter: drizzleAdapterMock }));
|
||||||
vi.mock("@hono/node-server", () => ({ serve: serveMock }));
|
vi.mock("@hono/node-server", () => ({ serve: serveMock }));
|
||||||
|
// Every startup reads the built Dashboard (ADR 0044), and this file never depends on a built shell,
|
||||||
|
// so the loader answers a small consistent snapshot unless a test makes it fail.
|
||||||
|
vi.mock("../src/dashboard-host-assets.js", async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<typeof import("../src/dashboard-host-assets.js")>();
|
||||||
|
loadDashboardHostAssetsMock.mockImplementation(async () =>
|
||||||
|
actual.createDashboardHostAssets("<!doctype html>", "export {};\n"),
|
||||||
|
);
|
||||||
|
return { ...actual, loadDashboardHostAssets: loadDashboardHostAssetsMock };
|
||||||
|
});
|
||||||
vi.mock("../src/display-host-assets.js", async (importOriginal) => {
|
vi.mock("../src/display-host-assets.js", async (importOriginal) => {
|
||||||
const actual = await importOriginal<typeof import("../src/display-host-assets.js")>();
|
const actual = await importOriginal<typeof import("../src/display-host-assets.js")>();
|
||||||
return { ...actual, loadDisplayHostAssets: loadDisplayHostAssetsMock };
|
return { ...actual, loadDisplayHostAssets: loadDisplayHostAssetsMock };
|
||||||
@@ -35,7 +45,13 @@ import {
|
|||||||
import { PermissionRegistryError } from "@ekklesia/core/authorization";
|
import { PermissionRegistryError } from "@ekklesia/core/authorization";
|
||||||
import { createApiApp } from "../src/app.js";
|
import { createApiApp } from "../src/app.js";
|
||||||
import { createApiRuntimeConfig } from "../src/config.js";
|
import { createApiRuntimeConfig } from "../src/config.js";
|
||||||
|
import { DashboardHostAssetError } from "../src/dashboard-host-assets.js";
|
||||||
import { DisplayHostAssetError } from "../src/display-host-assets.js";
|
import { DisplayHostAssetError } from "../src/display-host-assets.js";
|
||||||
|
import {
|
||||||
|
HEALTH_LIVE_PATH,
|
||||||
|
HEALTH_READY_PATH,
|
||||||
|
READINESS_DATABASE_TIMEOUT_MS,
|
||||||
|
} from "../src/health-routes.js";
|
||||||
import { installShutdownSignalHandlers, startApi } from "../src/server.js";
|
import { installShutdownSignalHandlers, startApi } from "../src/server.js";
|
||||||
|
|
||||||
function validConfig() {
|
function validConfig() {
|
||||||
@@ -88,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: 6,
|
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,
|
||||||
@@ -251,15 +267,16 @@ 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: 6,
|
manifestVersion: 8,
|
||||||
name: `${id} module`,
|
name: `${id} module`,
|
||||||
permissions,
|
permissions,
|
||||||
version: "0.1.0",
|
version: "0.1.0",
|
||||||
});
|
});
|
||||||
return {
|
return {
|
||||||
|
contributions: [],
|
||||||
directory: `/modules/${id}`,
|
directory: `/modules/${id}`,
|
||||||
entrypoints: { server: `/modules/${id}/dist/does-not-exist/index.js` },
|
entrypoints: { server: `/modules/${id}/dist/does-not-exist/index.js` },
|
||||||
manifest,
|
manifest,
|
||||||
@@ -446,6 +463,36 @@ describe("Ekklesia API shutdown lifecycle", () => {
|
|||||||
expect(poolEnd).toHaveBeenCalledTimes(1);
|
expect(poolEnd).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("answers readiness with one bounded ping on the shared pool, and turns it 503 the moment shutdown begins", async () => {
|
||||||
|
const query = emptyQuery();
|
||||||
|
poolConstructor.mockImplementation(function fakePool() {
|
||||||
|
return { end: vi.fn().mockResolvedValue(undefined), on: vi.fn(), query };
|
||||||
|
});
|
||||||
|
betterAuthMock.mockReturnValue({ api: { getSession: vi.fn() }, handler: vi.fn() });
|
||||||
|
mockSuccessfulServe();
|
||||||
|
|
||||||
|
const running = await startApi(validConfig());
|
||||||
|
query.mockClear();
|
||||||
|
|
||||||
|
const ready = await running.app.request(HEALTH_READY_PATH);
|
||||||
|
expect(ready.status).toBe(200);
|
||||||
|
expect(query).toHaveBeenCalledTimes(1);
|
||||||
|
// The statement timeout is server-side and scoped to this one transaction, so PostgreSQL itself
|
||||||
|
// cancels a stalled ping rather than the route merely abandoning it.
|
||||||
|
expect(query).toHaveBeenCalledWith(
|
||||||
|
`BEGIN READ ONLY; SET LOCAL statement_timeout = ${READINESS_DATABASE_TIMEOUT_MS}; SELECT 1; COMMIT`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const shutdown = running.shutdown();
|
||||||
|
const draining = await running.app.request(HEALTH_READY_PATH);
|
||||||
|
expect(draining.status).toBe(503);
|
||||||
|
expect(await draining.json()).toEqual({ code: "NOT_READY" });
|
||||||
|
// Draining is not dead: liveness still answers while in-flight requests finish.
|
||||||
|
expect((await running.app.request(HEALTH_LIVE_PATH)).status).toBe(200);
|
||||||
|
expect(query).toHaveBeenCalledTimes(1);
|
||||||
|
await shutdown;
|
||||||
|
});
|
||||||
|
|
||||||
it("keeps shutdown idempotent for calls made after the first has already resolved", async () => {
|
it("keeps shutdown idempotent for calls made after the first has already resolved", async () => {
|
||||||
const poolEnd = vi.fn().mockResolvedValue(undefined);
|
const poolEnd = vi.fn().mockResolvedValue(undefined);
|
||||||
poolConstructor.mockImplementation(function fakePool() {
|
poolConstructor.mockImplementation(function fakePool() {
|
||||||
@@ -957,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");
|
||||||
|
|
||||||
@@ -1021,6 +1135,30 @@ describe("Ekklesia API module runtime startup and shutdown", () => {
|
|||||||
expect(serveMock).not.toHaveBeenCalled();
|
expect(serveMock).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("fails on missing Dashboard assets before importing module code and still closes startup resources", async () => {
|
||||||
|
const modulesDirectory = await createTemporaryModulesDirectory();
|
||||||
|
const recorder = createEventRecorder();
|
||||||
|
await createWorkingFixtureModule(modulesDirectory, "calendar", recorder);
|
||||||
|
const { poolEnd } = mockPoolAndAuth();
|
||||||
|
loadDashboardHostAssetsMock.mockRejectedValueOnce(new DashboardHostAssetError());
|
||||||
|
|
||||||
|
const failure = await startApi(validConfig(), {
|
||||||
|
modulesDirectoryOverride: modulesDirectory,
|
||||||
|
}).catch((error: unknown) => error);
|
||||||
|
|
||||||
|
expect(failure).toMatchObject({
|
||||||
|
code: "DASHBOARD_HOST_ASSET_UNAVAILABLE",
|
||||||
|
message: "The Dashboard host browser assets are unavailable.",
|
||||||
|
name: "DashboardHostAssetError",
|
||||||
|
});
|
||||||
|
expect((failure as Error).stack).toBe(
|
||||||
|
"DashboardHostAssetError: The Dashboard host browser assets are unavailable.",
|
||||||
|
);
|
||||||
|
expect(recorder.events).toEqual([]);
|
||||||
|
expect(poolEnd).toHaveBeenCalledTimes(1);
|
||||||
|
expect(serveMock).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
it("does not read display-host assets when the eligible set declares no display", async () => {
|
it("does not read display-host assets when the eligible set declares no display", async () => {
|
||||||
const modulesDirectory = await createTemporaryModulesDirectory();
|
const modulesDirectory = await createTemporaryModulesDirectory();
|
||||||
const recorder = createEventRecorder();
|
const recorder = createEventRecorder();
|
||||||
@@ -1297,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";
|
||||||
@@ -35,6 +35,8 @@ import { CORE_MUTATION_MAX_BODY_BYTES } from "../src/body-limits.js";
|
|||||||
import { MODULE_CONFIGURATION_PATH } from "../src/module-configuration-routes.js";
|
import { MODULE_CONFIGURATION_PATH } from "../src/module-configuration-routes.js";
|
||||||
import { MODULE_LIFECYCLE_PATH } from "../src/module-lifecycle-routes.js";
|
import { MODULE_LIFECYCLE_PATH } from "../src/module-lifecycle-routes.js";
|
||||||
import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
|
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 {
|
import {
|
||||||
createFakeModuleSecretSource,
|
createFakeModuleSecretSource,
|
||||||
createInMemoryModuleConfigurationStore,
|
createInMemoryModuleConfigurationStore,
|
||||||
@@ -106,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: 6,
|
manifestVersion: 8,
|
||||||
name: `${fixture.id} module`,
|
name: `${fixture.id} module`,
|
||||||
version: "1.0.0",
|
version: "1.0.0",
|
||||||
}),
|
}),
|
||||||
@@ -189,6 +191,8 @@ async function install(options: InstallOptions = {}): Promise<Installation> {
|
|||||||
store: authorizationStore,
|
store: authorizationStore,
|
||||||
},
|
},
|
||||||
dashboard: createInMemoryDashboardStore(),
|
dashboard: createInMemoryDashboardStore(),
|
||||||
|
dashboardHostAssets: testDashboardHostAssets(),
|
||||||
|
health: readyHealth(),
|
||||||
moduleConfiguration: {
|
moduleConfiguration: {
|
||||||
bootSettings,
|
bootSettings,
|
||||||
discoveredModules: modules,
|
discoveredModules: modules,
|
||||||
@@ -209,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([
|
||||||
@@ -224,6 +225,7 @@ async function applyCoreMigrations(url: string): Promise<void> {
|
|||||||
"core:0004_module_lifecycle_state",
|
"core:0004_module_lifecycle_state",
|
||||||
"core:0005_module_configuration",
|
"core:0005_module_configuration",
|
||||||
"core:0006_device_identity",
|
"core:0006_device_identity",
|
||||||
|
"core:0007_verified_bootstrap_owners",
|
||||||
]);
|
]);
|
||||||
await applyModuleMigrations({ aggregate, database: createPostgresMigrationDatabase(url) });
|
await applyModuleMigrations({ aggregate, database: createPostgresMigrationDatabase(url) });
|
||||||
} finally {
|
} finally {
|
||||||
@@ -239,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.
|
||||||
@@ -253,6 +255,7 @@ async function applyMigrationsWithModule(url: string): Promise<void> {
|
|||||||
"core:0004_module_lifecycle_state",
|
"core:0004_module_lifecycle_state",
|
||||||
"core:0005_module_configuration",
|
"core:0005_module_configuration",
|
||||||
"core:0006_device_identity",
|
"core:0006_device_identity",
|
||||||
|
"core:0007_verified_bootstrap_owners",
|
||||||
"db-facade:0000_initial",
|
"db-facade:0000_initial",
|
||||||
]);
|
]);
|
||||||
expect(aggregate.migrations[0]?.checksum).toBe(
|
expect(aggregate.migrations[0]?.checksum).toBe(
|
||||||
@@ -319,6 +322,27 @@ async function signUp(origin: string, email: string, password: string): Promise<
|
|||||||
return { userId: body.user.id };
|
return { userId: body.user.id };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Marks an address verified out of band, the same trusted step the initial-owner bootstrap performs.
|
||||||
|
*
|
||||||
|
* A verified address is a precondition for holding a session (ADR 0038), and this suite is about
|
||||||
|
* module database access rather than identity, so it completes that step directly rather than
|
||||||
|
* standing up an SMTP server. That the policy is genuinely enforced is asserted in
|
||||||
|
* `email-verification.integration.ts`.
|
||||||
|
*/
|
||||||
|
async function markEmailVerified(url: string, email: string): Promise<void> {
|
||||||
|
const client = new Client({ connectionString: url });
|
||||||
|
await client.connect();
|
||||||
|
try {
|
||||||
|
await client.query(
|
||||||
|
'update "ekklesia_core_user" set "email_verified" = true where lower("email") = $1',
|
||||||
|
[email.trim().toLowerCase()],
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
await client.end().catch(() => undefined);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function signIn(origin: string, email: string, password: string): Promise<string> {
|
async function signIn(origin: string, email: string, password: string): Promise<string> {
|
||||||
const response = await fetch(`${origin}/api/auth/sign-in/email`, {
|
const response = await fetch(`${origin}/api/auth/sign-in/email`, {
|
||||||
body: JSON.stringify({ email, password }),
|
body: JSON.stringify({ email, password }),
|
||||||
@@ -418,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);
|
||||||
|
|
||||||
@@ -507,6 +536,7 @@ describe.sequential("live API — module database access through ModuleRuntimeSe
|
|||||||
expect(startupRow).toEqual({ id: STARTUP_RECORD_ID, value: "startup-value" });
|
expect(startupRow).toEqual({ id: STARTUP_RECORD_ID, value: "startup-value" });
|
||||||
|
|
||||||
const signedUp = await signUp(origin, "[email protected]", "correct horse battery");
|
const signedUp = await signUp(origin, "[email protected]", "correct horse battery");
|
||||||
|
await markEmailVerified(database.url, "[email protected]");
|
||||||
ownerCookie = await signIn(origin, "[email protected]", "correct horse battery");
|
ownerCookie = await signIn(origin, "[email protected]", "correct horse battery");
|
||||||
expect(signedUp.userId).toBeDefined();
|
expect(signedUp.userId).toBeDefined();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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";
|
||||||
@@ -28,6 +28,7 @@ import { createApiApp } from "../src/app.js";
|
|||||||
import { createDisplayHostAssets, type DisplayHostAssets } from "../src/display-host-assets.js";
|
import { createDisplayHostAssets, type DisplayHostAssets } from "../src/display-host-assets.js";
|
||||||
import {
|
import {
|
||||||
DISPLAY_HOST_SCRIPT_PATH,
|
DISPLAY_HOST_SCRIPT_PATH,
|
||||||
|
DISPLAY_SERVICE_WORKER_PATH,
|
||||||
ModuleDisplayConfigurationError,
|
ModuleDisplayConfigurationError,
|
||||||
moduleDisplayDescriptorUrl,
|
moduleDisplayDescriptorUrl,
|
||||||
moduleDisplayPageUrl,
|
moduleDisplayPageUrl,
|
||||||
@@ -37,6 +38,8 @@ import { moduleRealtimeBaseUrl } from "../src/module-realtime-routes.js";
|
|||||||
import { MODULE_UI_INVENTORY_PATH } from "../src/module-ui-inventory.js";
|
import { MODULE_UI_INVENTORY_PATH } from "../src/module-ui-inventory.js";
|
||||||
import { moduleDisplayUiArtifactUrl } from "../src/module-ui-routes.js";
|
import { moduleDisplayUiArtifactUrl } from "../src/module-ui-routes.js";
|
||||||
import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
|
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 { moduleConfigurationOptionFor } from "./support/in-memory-module-configuration.js";
|
import { moduleConfigurationOptionFor } from "./support/in-memory-module-configuration.js";
|
||||||
import {
|
import {
|
||||||
createInMemoryModuleLifecycleStore,
|
createInMemoryModuleLifecycleStore,
|
||||||
@@ -51,6 +54,7 @@ const displayPermission = "announcements.display.view";
|
|||||||
const displayHostAssets: DisplayHostAssets = createDisplayHostAssets(
|
const displayHostAssets: DisplayHostAssets = createDisplayHostAssets(
|
||||||
`<!doctype html><html><body><div id="ekklesia-display"></div><script type="module" src="${DISPLAY_HOST_SCRIPT_PATH}"></script></body></html>`,
|
`<!doctype html><html><body><div id="ekklesia-display"></div><script type="module" src="${DISPLAY_HOST_SCRIPT_PATH}"></script></body></html>`,
|
||||||
"globalThis.__ekklesiaDisplayHostLoaded = true;\n",
|
"globalThis.__ekklesiaDisplayHostLoaded = true;\n",
|
||||||
|
"globalThis.__ekklesiaDisplayWorkerLoaded = true;\n",
|
||||||
);
|
);
|
||||||
|
|
||||||
const temporaryDirectories: string[] = [];
|
const temporaryDirectories: string[] = [];
|
||||||
@@ -101,7 +105,7 @@ async function createModule(modulesDirectory: string, fixture: ModuleFixture = {
|
|||||||
: {}),
|
: {}),
|
||||||
},
|
},
|
||||||
id,
|
id,
|
||||||
manifestVersion: 6,
|
manifestVersion: 8,
|
||||||
name: `${id} module`,
|
name: `${id} module`,
|
||||||
permissions: fixture.display
|
permissions: fixture.display
|
||||||
? [
|
? [
|
||||||
@@ -151,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 });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -196,6 +200,8 @@ async function install(options: InstallOptions): Promise<Installation> {
|
|||||||
store: fakeAuthorizationStore(),
|
store: fakeAuthorizationStore(),
|
||||||
},
|
},
|
||||||
dashboard: createInMemoryDashboardStore(),
|
dashboard: createInMemoryDashboardStore(),
|
||||||
|
dashboardHostAssets: testDashboardHostAssets(),
|
||||||
|
health: readyHealth(),
|
||||||
...(moduleUi.listDisplays().length > 0 && options.withAssets !== false
|
...(moduleUi.listDisplays().length > 0 && options.withAssets !== false
|
||||||
? { displayHostAssets: options.assets ?? displayHostAssets }
|
? { displayHostAssets: options.assets ?? displayHostAssets }
|
||||||
: {}),
|
: {}),
|
||||||
@@ -266,11 +272,13 @@ describe("module display routes — authorization and descriptor", () => {
|
|||||||
expect(await descriptor.json()).toEqual({
|
expect(await descriptor.json()).toEqual({
|
||||||
viewer: { kind: "authenticated", userId: viewerId },
|
viewer: { kind: "authenticated", userId: viewerId },
|
||||||
module: {
|
module: {
|
||||||
|
contributions: [],
|
||||||
api: { baseUrl: moduleApiBaseUrl("announcements") },
|
api: { baseUrl: moduleApiBaseUrl("announcements") },
|
||||||
display: {
|
display: {
|
||||||
url: moduleDisplayUiArtifactUrl({ moduleId: "announcements", version: "1.4.2" }),
|
url: moduleDisplayUiArtifactUrl({ moduleId: "announcements", version: "1.4.2" }),
|
||||||
},
|
},
|
||||||
id: "announcements",
|
id: "announcements",
|
||||||
|
page: { url: moduleDisplayPageUrl("announcements") },
|
||||||
permissions: [displayPermission],
|
permissions: [displayPermission],
|
||||||
realtime: { baseUrl: moduleRealtimeBaseUrl("announcements") },
|
realtime: { baseUrl: moduleRealtimeBaseUrl("announcements") },
|
||||||
version: "1.4.2",
|
version: "1.4.2",
|
||||||
@@ -348,9 +356,14 @@ describe("module display routes — role and lifecycle separation", () => {
|
|||||||
expect((await app.request(moduleDisplayPageUrl("announcements"))).status).toBe(200);
|
expect((await app.request(moduleDisplayPageUrl("announcements"))).status).toBe(200);
|
||||||
expect((await app.request(MODULE_UI_INVENTORY_PATH)).status).toBe(200);
|
expect((await app.request(MODULE_UI_INVENTORY_PATH)).status).toBe(200);
|
||||||
expect(
|
expect(
|
||||||
((await (await app.request(MODULE_UI_INVENTORY_PATH)).json()) as { modules: unknown[] })
|
(
|
||||||
.modules,
|
(await (await app.request(MODULE_UI_INVENTORY_PATH)).json()) as {
|
||||||
).toHaveLength(1);
|
modules: Array<{ display: { url: string } | null }>;
|
||||||
|
}
|
||||||
|
).modules,
|
||||||
|
).toEqual([
|
||||||
|
expect.objectContaining({ display: { url: moduleDisplayPageUrl("announcements") } }),
|
||||||
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("has no display page, descriptor, or artifact when the module is outside the eligible set", async () => {
|
it("has no display page, descriptor, or artifact when the module is outside the eligible set", async () => {
|
||||||
@@ -476,7 +489,11 @@ describe("module display host asset publication", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("snapshots injected assets and rejects a validator that does not describe its script", async () => {
|
it("snapshots injected assets and rejects a validator that does not describe its script", async () => {
|
||||||
const source = createDisplayHostAssets("<html>display host</html>", "export const host = 1;\n");
|
const source = createDisplayHostAssets(
|
||||||
|
"<html>display host</html>",
|
||||||
|
"export const host = 1;\n",
|
||||||
|
"export const worker = 1;\n",
|
||||||
|
);
|
||||||
const mutable = { ...source };
|
const mutable = { ...source };
|
||||||
const { app } = await install({ assets: mutable, display: true });
|
const { app } = await install({ assets: mutable, display: true });
|
||||||
mutable.html = "<html>mutated secret</html>";
|
mutable.html = "<html>mutated secret</html>";
|
||||||
@@ -497,4 +514,55 @@ describe("module display host asset publication", () => {
|
|||||||
|
|
||||||
expect((await app.request(DISPLAY_HOST_SCRIPT_PATH)).status).toBe(404);
|
expect((await app.request(DISPLAY_HOST_SCRIPT_PATH)).status).toBe(404);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("serves the offline service worker from the path whose directory is its scope", async () => {
|
||||||
|
const { app } = await install({ display: true });
|
||||||
|
|
||||||
|
const response = await app.request(DISPLAY_SERVICE_WORKER_PATH);
|
||||||
|
|
||||||
|
// `/display/sw.js`, so the worker's default scope is `/display/` — display pages and nothing
|
||||||
|
// else. The Dashboard is never controlled by it (ADR 0043).
|
||||||
|
expect(DISPLAY_SERVICE_WORKER_PATH).toBe("/display/sw.js");
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(await response.text()).toBe(displayHostAssets.serviceWorker);
|
||||||
|
expect(response.headers.get("content-type")).toBe("text/javascript; charset=utf-8");
|
||||||
|
// `no-cache` with a strong validator, exactly like the host script: a browser re-checks the
|
||||||
|
// worker on its own schedule, which is how a screen avoids being trapped on an obsolete one.
|
||||||
|
expect(response.headers.get("cache-control")).toBe("no-cache");
|
||||||
|
expect(response.headers.get("etag")).toBe(displayHostAssets.serviceWorkerEtag);
|
||||||
|
expect(response.headers.get("x-content-type-options")).toBe("nosniff");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("revalidates the service worker with its own validator, never the host script's", async () => {
|
||||||
|
const { app } = await install({ display: true });
|
||||||
|
|
||||||
|
const notModified = await app.request(DISPLAY_SERVICE_WORKER_PATH, {
|
||||||
|
headers: { "if-none-match": displayHostAssets.serviceWorkerEtag },
|
||||||
|
});
|
||||||
|
expect(notModified.status).toBe(304);
|
||||||
|
|
||||||
|
const wrongValidator = await app.request(DISPLAY_SERVICE_WORKER_PATH, {
|
||||||
|
headers: { "if-none-match": displayHostAssets.scriptEtag },
|
||||||
|
});
|
||||||
|
expect(wrongValidator.status).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not publish the service worker when no eligible display uses it", async () => {
|
||||||
|
const { app } = await install({ ui: true, withAssets: false });
|
||||||
|
|
||||||
|
expect((await app.request(DISPLAY_SERVICE_WORKER_PATH)).status).toBe(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("cannot be reached through a neighbouring or request-selected path", async () => {
|
||||||
|
const { app } = await install({ display: true });
|
||||||
|
|
||||||
|
for (const candidate of [
|
||||||
|
`${DISPLAY_SERVICE_WORKER_PATH}.map`,
|
||||||
|
"/display/sw.js/",
|
||||||
|
"/sw.js",
|
||||||
|
"/assets/ekklesia-display/sw.js",
|
||||||
|
]) {
|
||||||
|
expect((await app.request(candidate)).status, candidate).toBe(404);
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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,18 +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 { 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";
|
||||||
@@ -58,21 +61,24 @@ function fakeStore(): AuthorizationStore {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Defaults `maxBodyBytes` to a generous value for tests that are not themselves exercising the
|
/** Defaults `maxBodyBytes` and `bodyMode` for tests that are not themselves exercising the
|
||||||
* body-size contract, so the many pre-existing routing/authorization/CORS scenarios below do not
|
* body-size contract, so the many pre-existing routing/authorization/CORS scenarios below do not
|
||||||
* each need to restate an unrelated body-size choice; the dedicated "module route body limits"
|
* each need to restate an unrelated body-size choice; the dedicated "module route body limits"
|
||||||
* suite further down overrides it explicitly to prove the contract itself. */
|
* suite further down overrides them explicitly to prove the contract itself. */
|
||||||
function moduleRoute(
|
function moduleRoute(
|
||||||
fields: Omit<RegisteredModuleHttpRoute, "maxBodyBytes"> & { maxBodyBytes?: number },
|
fields: Omit<RegisteredModuleHttpRoute, "bodyMode" | "maxBodyBytes"> & {
|
||||||
|
bodyMode?: RegisteredModuleHttpRoute["bodyMode"];
|
||||||
|
maxBodyBytes?: number;
|
||||||
|
},
|
||||||
): RegisteredModuleHttpRoute {
|
): RegisteredModuleHttpRoute {
|
||||||
return Object.freeze({ 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(),
|
||||||
@@ -86,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();
|
||||||
@@ -102,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 ?? {
|
||||||
@@ -120,6 +126,8 @@ function buildModuleApp(options: {
|
|||||||
moduleConfiguration: emptyModuleConfiguration(),
|
moduleConfiguration: emptyModuleConfiguration(),
|
||||||
moduleStorage: emptyModuleStorage(),
|
moduleStorage: emptyModuleStorage(),
|
||||||
dashboard: createInMemoryDashboardStore(),
|
dashboard: createInMemoryDashboardStore(),
|
||||||
|
dashboardHostAssets: testDashboardHostAssets(),
|
||||||
|
health: readyHealth(),
|
||||||
runtime,
|
runtime,
|
||||||
trustedOrigins: options.trustedOrigins ?? [trustedOrigin],
|
trustedOrigins: options.trustedOrigins ?? [trustedOrigin],
|
||||||
});
|
});
|
||||||
@@ -138,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({
|
||||||
@@ -501,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 () => {
|
||||||
@@ -630,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 () => {
|
||||||
@@ -759,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 () => {
|
||||||
@@ -1634,3 +1672,375 @@ describe("module route overlap can never reach mounting", () => {
|
|||||||
expect(moduleRoutesOverlap(paramRoute, staticRoute)).toBe(true);
|
expect(moduleRoutesOverlap(paramRoute, staticRoute)).toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Explicit, bounded streaming uploads (ADR 0042).
|
||||||
|
*
|
||||||
|
* The distinction being proven throughout is *when* the platform knows a body is too large, and what
|
||||||
|
* it holds while finding out. In `buffered` mode the answer is "before the handler runs, holding at
|
||||||
|
* most the route's own declared allowance". In `streamed` mode it is "as the handler consumes it,
|
||||||
|
* holding nothing" — and the `413` still wins, even when the handler tried to answer anyway.
|
||||||
|
*/
|
||||||
|
describe("module route streamed body limits", () => {
|
||||||
|
const oneMebibyte = 1024 * 1024;
|
||||||
|
|
||||||
|
/** A body of `size` bytes delivered in realistic chunks, with no declared `Content-Length` — the
|
||||||
|
* chunked-transfer shape a browser `fetch` of a `ReadableStream` actually produces. */
|
||||||
|
function chunkedBody(size: number, chunkSize = 64 * 1024): ReadableStream<Uint8Array> {
|
||||||
|
let sent = 0;
|
||||||
|
return new ReadableStream<Uint8Array>({
|
||||||
|
pull(controller) {
|
||||||
|
if (sent >= size) {
|
||||||
|
controller.close();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const next = Math.min(chunkSize, size - sent);
|
||||||
|
sent += next;
|
||||||
|
controller.enqueue(new Uint8Array(next).fill(97));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function streamingApp(options: {
|
||||||
|
readonly handler: ModuleRouteHandler;
|
||||||
|
readonly maxBodyBytes: number;
|
||||||
|
readonly evaluator?: AuthorizationEvaluator;
|
||||||
|
}) {
|
||||||
|
return buildModuleApp({
|
||||||
|
...(options.evaluator ? { evaluator: options.evaluator } : {}),
|
||||||
|
registry: permissionRegistry([{ id: writePermission, baseline: "public" }]),
|
||||||
|
routes: [
|
||||||
|
moduleRoute({
|
||||||
|
bodyMode: "streamed",
|
||||||
|
handler: options.handler,
|
||||||
|
maxBodyBytes: options.maxBodyBytes,
|
||||||
|
method: "POST",
|
||||||
|
moduleId: "calendar",
|
||||||
|
path: "/uploads",
|
||||||
|
permission: writePermission,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Counts what the handler actually received, which is the only honest way to tell "streamed" from
|
||||||
|
* "buffered then handed over". */
|
||||||
|
async function countingHandler(received: { bytes: number }): Promise<ModuleRouteHandler> {
|
||||||
|
return async (request) => {
|
||||||
|
const body = request.body;
|
||||||
|
if (!body) {
|
||||||
|
return Response.json({ bytes: 0 });
|
||||||
|
}
|
||||||
|
const reader = body.getReader();
|
||||||
|
for (;;) {
|
||||||
|
const { done, value } = await reader.read();
|
||||||
|
if (done) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
received.bytes += value.byteLength;
|
||||||
|
}
|
||||||
|
return Response.json({ bytes: received.bytes });
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
it("accepts a body of exactly the declared limit", async () => {
|
||||||
|
const received = { bytes: 0 };
|
||||||
|
const { app } = streamingApp({
|
||||||
|
handler: await countingHandler(received),
|
||||||
|
maxBodyBytes: oneMebibyte,
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await app.request("/api/v1/modules/calendar/uploads", {
|
||||||
|
body: chunkedBody(oneMebibyte),
|
||||||
|
duplex: "half",
|
||||||
|
headers: { origin: trustedOrigin },
|
||||||
|
method: "POST",
|
||||||
|
} as RequestInit);
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(await response.json()).toEqual({ bytes: oneMebibyte });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a declared Content-Length over the limit before the handler runs", async () => {
|
||||||
|
const received = { bytes: 0 };
|
||||||
|
const counting = await countingHandler(received);
|
||||||
|
const handler = vi.fn<ModuleRouteHandler>(counting);
|
||||||
|
const { app } = streamingApp({ handler, maxBodyBytes: 1024 });
|
||||||
|
|
||||||
|
const response = await app.request("/api/v1/modules/calendar/uploads", {
|
||||||
|
body: chunkedBody(2048, 16),
|
||||||
|
duplex: "half",
|
||||||
|
headers: { "content-length": String(1025), origin: trustedOrigin },
|
||||||
|
method: "POST",
|
||||||
|
} as RequestInit);
|
||||||
|
|
||||||
|
expect(response.status).toBe(413);
|
||||||
|
expect(await response.json()).toEqual({ code: "REQUEST_BODY_TOO_LARGE" });
|
||||||
|
// The cheap rejection: the declared size alone decided it, so no handler work happened and no
|
||||||
|
// byte of the upload reached module code.
|
||||||
|
expect(handler).not.toHaveBeenCalled();
|
||||||
|
expect(received.bytes).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("terminates a body whose actual size exceeds a smaller declared Content-Length", async () => {
|
||||||
|
// A misleading `Content-Length` buys nothing: the running byte count is what bounds the request,
|
||||||
|
// so a header claiming 8 bytes cannot smuggle a megabyte past the limit.
|
||||||
|
const received = { bytes: 0 };
|
||||||
|
const { app } = streamingApp({
|
||||||
|
handler: await countingHandler(received),
|
||||||
|
maxBodyBytes: 64,
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await app.request("/api/v1/modules/calendar/uploads", {
|
||||||
|
body: chunkedBody(4096, 32),
|
||||||
|
duplex: "half",
|
||||||
|
headers: { "content-length": "8", origin: trustedOrigin },
|
||||||
|
method: "POST",
|
||||||
|
} as RequestInit);
|
||||||
|
|
||||||
|
expect(response.status).toBe(413);
|
||||||
|
expect(await response.json()).toEqual({ code: "REQUEST_BODY_TOO_LARGE" });
|
||||||
|
// It stopped promptly rather than draining the whole upload first.
|
||||||
|
expect(received.bytes).toBeLessThanOrEqual(96);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("answers 413 even when the handler swallowed the stream error and tried to respond", async () => {
|
||||||
|
// A limit a handler can swallow is not a limit.
|
||||||
|
const handler = vi.fn<ModuleRouteHandler>(async (request) => {
|
||||||
|
try {
|
||||||
|
await request.arrayBuffer();
|
||||||
|
} catch {
|
||||||
|
// Deliberately ignored, exactly as a careless module might.
|
||||||
|
}
|
||||||
|
return Response.json({ ok: "handler won" });
|
||||||
|
});
|
||||||
|
const { app } = streamingApp({ handler, maxBodyBytes: 64 });
|
||||||
|
|
||||||
|
const response = await app.request("/api/v1/modules/calendar/uploads", {
|
||||||
|
body: chunkedBody(4096, 32),
|
||||||
|
duplex: "half",
|
||||||
|
headers: { origin: trustedOrigin },
|
||||||
|
method: "POST",
|
||||||
|
} as RequestInit);
|
||||||
|
|
||||||
|
expect(handler).toHaveBeenCalledTimes(1);
|
||||||
|
expect(response.status).toBe(413);
|
||||||
|
expect(await response.json()).toEqual({ code: "REQUEST_BODY_TOO_LARGE" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never buffers the body: the handler sees the first bytes before the last are sent", async () => {
|
||||||
|
let released: (() => void) | undefined;
|
||||||
|
const finished = new Promise<void>((resolve) => {
|
||||||
|
released = resolve;
|
||||||
|
});
|
||||||
|
let firstChunkSeen = false;
|
||||||
|
let secondChunkQueued = false;
|
||||||
|
const body = new ReadableStream<Uint8Array>({
|
||||||
|
async pull(controller) {
|
||||||
|
if (!secondChunkQueued) {
|
||||||
|
secondChunkQueued = true;
|
||||||
|
controller.enqueue(new Uint8Array(16).fill(97));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Only produce the rest once the handler has demonstrably consumed the first chunk. A
|
||||||
|
// buffering platform would deadlock here rather than reach the assertion below.
|
||||||
|
await finished;
|
||||||
|
controller.close();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const handler = vi.fn<ModuleRouteHandler>(async (request) => {
|
||||||
|
const reader = (request.body as ReadableStream<Uint8Array>).getReader();
|
||||||
|
const first = await reader.read();
|
||||||
|
firstChunkSeen = !first.done;
|
||||||
|
released?.();
|
||||||
|
for (;;) {
|
||||||
|
const next = await reader.read();
|
||||||
|
if (next.done) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Response.json({ ok: true });
|
||||||
|
});
|
||||||
|
const { app } = streamingApp({ handler, maxBodyBytes: oneMebibyte });
|
||||||
|
|
||||||
|
const response = await app.request("/api/v1/modules/calendar/uploads", {
|
||||||
|
body,
|
||||||
|
duplex: "half",
|
||||||
|
headers: { origin: trustedOrigin },
|
||||||
|
method: "POST",
|
||||||
|
} as RequestInit);
|
||||||
|
|
||||||
|
expect(firstChunkSeen).toBe(true);
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("releases an aborted upload without taking the platform down", async () => {
|
||||||
|
const aborted = new ReadableStream<Uint8Array>({
|
||||||
|
start(controller) {
|
||||||
|
controller.enqueue(new Uint8Array(16).fill(97));
|
||||||
|
controller.error(new Error("client went away"));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const handler = vi.fn<ModuleRouteHandler>(async (request) => {
|
||||||
|
await request.arrayBuffer();
|
||||||
|
return Response.json({ ok: true });
|
||||||
|
});
|
||||||
|
const { app } = streamingApp({ handler, maxBodyBytes: oneMebibyte });
|
||||||
|
|
||||||
|
const response = await app.request("/api/v1/modules/calendar/uploads", {
|
||||||
|
body: aborted,
|
||||||
|
duplex: "half",
|
||||||
|
headers: { origin: trustedOrigin },
|
||||||
|
method: "POST",
|
||||||
|
} as RequestInit);
|
||||||
|
|
||||||
|
// An abort is a failed request, never an unhandled rejection or a 413: nothing exceeded a limit.
|
||||||
|
expect(response.status).toBe(500);
|
||||||
|
expect(await response.json()).toEqual({ code: "MODULE_HANDLER_FAILED" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("releases a body the handler never read", async () => {
|
||||||
|
let cancelled = false;
|
||||||
|
const body = new ReadableStream<Uint8Array>({
|
||||||
|
cancel() {
|
||||||
|
cancelled = true;
|
||||||
|
},
|
||||||
|
pull(controller) {
|
||||||
|
controller.enqueue(new Uint8Array(16).fill(97));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const { app } = streamingApp({
|
||||||
|
handler: async () => Response.json({ ok: true }),
|
||||||
|
maxBodyBytes: oneMebibyte,
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await app.request("/api/v1/modules/calendar/uploads", {
|
||||||
|
body,
|
||||||
|
duplex: "half",
|
||||||
|
headers: { origin: trustedOrigin },
|
||||||
|
method: "POST",
|
||||||
|
} as RequestInit);
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(cancelled).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses a declared oversize before authorization, and never streams for a denied caller", async () => {
|
||||||
|
const evaluator: AuthorizationEvaluator = {
|
||||||
|
authorize: vi.fn().mockResolvedValue({ allowed: false }),
|
||||||
|
};
|
||||||
|
const handler = vi.fn<ModuleRouteHandler>(async () => Response.json({ ok: true }));
|
||||||
|
const { app } = buildModuleApp({
|
||||||
|
evaluator,
|
||||||
|
registry: permissionRegistry([{ id: writePermission, baseline: "restricted" }]),
|
||||||
|
routes: [
|
||||||
|
moduleRoute({
|
||||||
|
bodyMode: "streamed",
|
||||||
|
handler,
|
||||||
|
maxBodyBytes: 64,
|
||||||
|
method: "POST",
|
||||||
|
moduleId: "calendar",
|
||||||
|
path: "/uploads",
|
||||||
|
permission: writePermission,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const declaredOversize = await app.request("/api/v1/modules/calendar/uploads", {
|
||||||
|
body: "a".repeat(65),
|
||||||
|
headers: { "content-length": "65", origin: trustedOrigin },
|
||||||
|
method: "POST",
|
||||||
|
});
|
||||||
|
expect(declaredOversize.status).toBe(413);
|
||||||
|
expect(evaluator.authorize).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
// A within-limit body from a denied caller is refused by authorization, and the handler — the
|
||||||
|
// only thing that ever reads a byte in streamed mode — never runs.
|
||||||
|
const response = await app.request("/api/v1/modules/calendar/uploads", {
|
||||||
|
body: chunkedBody(32, 8),
|
||||||
|
duplex: "half",
|
||||||
|
headers: { origin: trustedOrigin },
|
||||||
|
method: "POST",
|
||||||
|
} as RequestInit);
|
||||||
|
expect(response.status).toBe(403);
|
||||||
|
expect(handler).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still rejects an untrusted Origin before any body handling", async () => {
|
||||||
|
const handler = vi.fn<ModuleRouteHandler>(async () => Response.json({ ok: true }));
|
||||||
|
const { app } = streamingApp({ handler, maxBodyBytes: oneMebibyte });
|
||||||
|
|
||||||
|
const response = await app.request("/api/v1/modules/calendar/uploads", {
|
||||||
|
body: chunkedBody(4096),
|
||||||
|
duplex: "half",
|
||||||
|
headers: { origin: untrustedOrigin },
|
||||||
|
method: "POST",
|
||||||
|
} as RequestInit);
|
||||||
|
|
||||||
|
expect(response.status).toBe(403);
|
||||||
|
expect(await response.json()).toEqual({ code: "UNTRUSTED_ORIGIN" });
|
||||||
|
expect(handler).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps every rejection sanitized, naming no limit, size, or body content", async () => {
|
||||||
|
const { app } = streamingApp({
|
||||||
|
handler: async () => Response.json({ ok: true }),
|
||||||
|
maxBodyBytes: 64,
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await app.request("/api/v1/modules/calendar/uploads", {
|
||||||
|
body: "secret-payload-".repeat(64),
|
||||||
|
headers: { "content-length": String(15 * 64), origin: trustedOrigin },
|
||||||
|
method: "POST",
|
||||||
|
});
|
||||||
|
const body = await response.text();
|
||||||
|
|
||||||
|
expect(response.status).toBe(413);
|
||||||
|
expect(body).toBe('{"code":"REQUEST_BODY_TOO_LARGE"}');
|
||||||
|
expect(body).not.toContain("secret-payload");
|
||||||
|
expect(body).not.toContain("64");
|
||||||
|
expect(response.headers.get("cache-control")).toBe("no-store");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still materializes a buffered body before the handler runs", async () => {
|
||||||
|
// The pre-existing promise, unchanged: a buffered handler may call `request.json()` and know the
|
||||||
|
// body is already there and already within its declared allowance.
|
||||||
|
const handler = vi.fn<ModuleRouteHandler>(async (request) => {
|
||||||
|
const text = await request.text();
|
||||||
|
return Response.json({ length: text.length });
|
||||||
|
});
|
||||||
|
const { app } = buildModuleApp({
|
||||||
|
registry: permissionRegistry([{ id: writePermission, baseline: "public" }]),
|
||||||
|
routes: [
|
||||||
|
moduleRoute({
|
||||||
|
bodyMode: "buffered",
|
||||||
|
handler,
|
||||||
|
maxBodyBytes: 128,
|
||||||
|
method: "POST",
|
||||||
|
moduleId: "calendar",
|
||||||
|
path: "/events",
|
||||||
|
permission: writePermission,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const withinLimit = await app.request("/api/v1/modules/calendar/events", {
|
||||||
|
body: chunkedBody(128, 16),
|
||||||
|
duplex: "half",
|
||||||
|
headers: { origin: trustedOrigin },
|
||||||
|
method: "POST",
|
||||||
|
} as RequestInit);
|
||||||
|
expect(withinLimit.status).toBe(200);
|
||||||
|
expect(await withinLimit.json()).toEqual({ length: 128 });
|
||||||
|
|
||||||
|
const overLimit = await app.request("/api/v1/modules/calendar/events", {
|
||||||
|
body: chunkedBody(129, 16),
|
||||||
|
duplex: "half",
|
||||||
|
headers: { origin: trustedOrigin },
|
||||||
|
method: "POST",
|
||||||
|
} as RequestInit);
|
||||||
|
expect(overLimit.status).toBe(413);
|
||||||
|
// A buffered route's handler never sees an over-limit request at all.
|
||||||
|
expect(handler).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -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,
|
||||||
@@ -33,6 +33,8 @@ import { CORE_MUTATION_MAX_BODY_BYTES } from "../src/body-limits.js";
|
|||||||
import { MODULE_LIFECYCLE_PATH } from "../src/module-lifecycle-routes.js";
|
import { MODULE_LIFECYCLE_PATH } from "../src/module-lifecycle-routes.js";
|
||||||
import { MODULE_UI_INVENTORY_PATH } from "../src/module-ui-inventory.js";
|
import { MODULE_UI_INVENTORY_PATH } from "../src/module-ui-inventory.js";
|
||||||
import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
|
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 { createInMemoryModuleLifecycleStore } from "./support/in-memory-module-lifecycle.js";
|
import { createInMemoryModuleLifecycleStore } from "./support/in-memory-module-lifecycle.js";
|
||||||
import { moduleConfigurationOptionFor } from "./support/in-memory-module-configuration.js";
|
import { moduleConfigurationOptionFor } from "./support/in-memory-module-configuration.js";
|
||||||
import { moduleStorageOptionFor } from "./support/in-memory-module-storage.js";
|
import { moduleStorageOptionFor } from "./support/in-memory-module-storage.js";
|
||||||
@@ -77,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: 6,
|
manifestVersion: 8,
|
||||||
name: `${fixture.id} module`,
|
name: `${fixture.id} module`,
|
||||||
version: "1.0.0",
|
version: "1.0.0",
|
||||||
}),
|
}),
|
||||||
@@ -171,6 +173,8 @@ async function install(options: InstallOptions = {}): Promise<Installation> {
|
|||||||
store: authorizationStore,
|
store: authorizationStore,
|
||||||
},
|
},
|
||||||
dashboard: createInMemoryDashboardStore(),
|
dashboard: createInMemoryDashboardStore(),
|
||||||
|
dashboardHostAssets: testDashboardHostAssets(),
|
||||||
|
health: readyHealth(),
|
||||||
moduleConfiguration: moduleConfigurationOptionFor(modules),
|
moduleConfiguration: moduleConfigurationOptionFor(modules),
|
||||||
moduleStorage: moduleStorageOptionFor(modules),
|
moduleStorage: moduleStorageOptionFor(modules),
|
||||||
moduleLifecycle: {
|
moduleLifecycle: {
|
||||||
@@ -185,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: 6,
|
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,9 +16,17 @@ import {
|
|||||||
composePlatformPermissionRegistry,
|
composePlatformPermissionRegistry,
|
||||||
createModuleUiPublicationRegistry,
|
createModuleUiPublicationRegistry,
|
||||||
discoverModules,
|
discoverModules,
|
||||||
|
type LegacyModuleRuntimeServices,
|
||||||
|
type ModuleDatabase,
|
||||||
startDiscoveredModuleRuntime,
|
startDiscoveredModuleRuntime,
|
||||||
} from "@ekklesia/core/modules";
|
} from "@ekklesia/core/modules";
|
||||||
import { loadModuleUi } from "@ekklesia/module-ui-host";
|
import {
|
||||||
|
loadModuleUi,
|
||||||
|
MODULE_UI_ISOLATION_ATTRIBUTE,
|
||||||
|
MODULE_UI_ISOLATION_ROOT_ATTRIBUTE,
|
||||||
|
} from "@ekklesia/module-ui-host";
|
||||||
|
import { MODULE_UI_ABI_VERSION } from "@ekklesia/module-ui-sdk";
|
||||||
|
import { Window } from "happy-dom";
|
||||||
import {
|
import {
|
||||||
bootstrapDisplayHost,
|
bootstrapDisplayHost,
|
||||||
bootstrapHost,
|
bootstrapHost,
|
||||||
@@ -30,6 +36,8 @@ import {
|
|||||||
} from "@ekklesia/shell";
|
} from "@ekklesia/shell";
|
||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
|
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 { createDisplayHostAssets } from "../src/display-host-assets.js";
|
import { createDisplayHostAssets } from "../src/display-host-assets.js";
|
||||||
import {
|
import {
|
||||||
@@ -121,7 +129,7 @@ async function createFixtureModule(
|
|||||||
: {}),
|
: {}),
|
||||||
},
|
},
|
||||||
id,
|
id,
|
||||||
manifestVersion: 6,
|
manifestVersion: 8,
|
||||||
name,
|
name,
|
||||||
permissions: [
|
permissions: [
|
||||||
{
|
{
|
||||||
@@ -165,6 +173,7 @@ async function createFixtureModule(
|
|||||||
await writeFile(
|
await writeFile(
|
||||||
path.join(moduleDirectory, "dist/ui/index.js"),
|
path.join(moduleDirectory, "dist/ui/index.js"),
|
||||||
`export default {
|
`export default {
|
||||||
|
abi: ${MODULE_UI_ABI_VERSION},
|
||||||
id: ${JSON.stringify(id)},
|
id: ${JSON.stringify(id)},
|
||||||
version: "0.1.0",
|
version: "0.1.0",
|
||||||
async mount(container, context) {
|
async mount(container, context) {
|
||||||
@@ -184,6 +193,7 @@ async function createFixtureModule(
|
|||||||
await writeFile(
|
await writeFile(
|
||||||
path.join(moduleDirectory, "dist/display/index.js"),
|
path.join(moduleDirectory, "dist/display/index.js"),
|
||||||
`export default {
|
`export default {
|
||||||
|
abi: ${MODULE_UI_ABI_VERSION},
|
||||||
id: ${JSON.stringify(id)},
|
id: ${JSON.stringify(id)},
|
||||||
version: "0.1.0",
|
version: "0.1.0",
|
||||||
async mount(container, context) {
|
async mount(container, context) {
|
||||||
@@ -231,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
|
||||||
@@ -270,6 +280,7 @@ async function startHarness(options: HarnessOptions = {}): Promise<Harness> {
|
|||||||
displayHostAssets: createDisplayHostAssets(
|
displayHostAssets: createDisplayHostAssets(
|
||||||
'<!doctype html><div id="ekklesia-display"></div><script type="module" src="/assets/ekklesia-display/app.js"></script>',
|
'<!doctype html><div id="ekklesia-display"></div><script type="module" src="/assets/ekklesia-display/app.js"></script>',
|
||||||
"export const displayHost = true;\n",
|
"export const displayHost = true;\n",
|
||||||
|
"export const displayWorker = true;\n",
|
||||||
),
|
),
|
||||||
}),
|
}),
|
||||||
moduleLifecycle: {
|
moduleLifecycle: {
|
||||||
@@ -279,6 +290,8 @@ async function startHarness(options: HarnessOptions = {}): Promise<Harness> {
|
|||||||
moduleConfiguration: moduleConfigurationOptionFor(modules),
|
moduleConfiguration: moduleConfigurationOptionFor(modules),
|
||||||
moduleStorage: moduleStorageOptionFor(modules),
|
moduleStorage: moduleStorageOptionFor(modules),
|
||||||
dashboard: createInMemoryDashboardStore(),
|
dashboard: createInMemoryDashboardStore(),
|
||||||
|
dashboardHostAssets: testDashboardHostAssets(),
|
||||||
|
health: readyHealth(),
|
||||||
runtime,
|
runtime,
|
||||||
trustedOrigins: ["https://dashboard.example.test"],
|
trustedOrigins: ["https://dashboard.example.test"],
|
||||||
});
|
});
|
||||||
@@ -310,10 +323,35 @@ async function publishedArtifactUrl(harness: Harness, sourceUrl: string): Promis
|
|||||||
return `data:text/javascript;base64,${bytes.toString("base64")}`;
|
return `data:text/javascript;base64,${bytes.toString("base64")}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The loader hands the container straight to the module and never inspects it, so a plain sink is
|
/**
|
||||||
* enough and no DOM implementation is needed in this Node-environment suite. */
|
* A real host-owned container, built from `happy-dom` **as a library** rather than as this suite's
|
||||||
function containerSink(): { textContent: string } {
|
* test environment.
|
||||||
return { textContent: "" };
|
*
|
||||||
|
* Since ADR 0040 the generic loader builds an isolated Shadow DOM root inside whatever container it
|
||||||
|
* is given, so a plain object sink is no longer a faithful stand-in — and the isolation is exactly
|
||||||
|
* what an end-to-end proof should exercise rather than stub out. Installing happy-dom as the
|
||||||
|
* *environment* is not an option here: it replaces the module runner's resolution, and this suite
|
||||||
|
* dynamically imports real module server entrypoints from temporary directories, which then fail to
|
||||||
|
* resolve. Constructing one `Window` gives the DOM these few assertions need and leaves every global
|
||||||
|
* this server-side suite depends on exactly as Node provides it.
|
||||||
|
*/
|
||||||
|
function containerSink(): HostContainer {
|
||||||
|
return new Window().document.createElement("div") as unknown as HostContainer;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface HostContainer {
|
||||||
|
readonly children: { readonly length: number };
|
||||||
|
querySelector(selectors: string): HostContainer | null;
|
||||||
|
readonly shadowRoot: { querySelector(selectors: string): { textContent: string } | null } | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What the mounted artifact rendered, read through its open isolated root. */
|
||||||
|
function mountedText(container: HostContainer): string {
|
||||||
|
const shadowHost = container.querySelector(`[${MODULE_UI_ISOLATION_ATTRIBUTE}]`);
|
||||||
|
return (
|
||||||
|
shadowHost?.shadowRoot?.querySelector(`[${MODULE_UI_ISOLATION_ROOT_ATTRIBUTE}]`)?.textContent ??
|
||||||
|
""
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
type LoaderContainer = Parameters<typeof loadModuleUi>[0]["container"];
|
type LoaderContainer = Parameters<typeof loadModuleUi>[0]["container"];
|
||||||
@@ -340,6 +378,7 @@ describe("end to end: a discovered module's UI mounts in the Ekklesia host", ()
|
|||||||
// 2. The URLs came from the server, not from anything the browser composed.
|
// 2. The URLs came from the server, not from anything the browser composed.
|
||||||
expect(descriptor).toEqual({
|
expect(descriptor).toEqual({
|
||||||
apiBaseUrl: "/api/v1/modules/example",
|
apiBaseUrl: "/api/v1/modules/example",
|
||||||
|
contributions: [],
|
||||||
id: "example",
|
id: "example",
|
||||||
permissions: ["example.records.read"],
|
permissions: ["example.records.read"],
|
||||||
realtimeBaseUrl: "/api/v1/module-realtime/example",
|
realtimeBaseUrl: "/api/v1/module-realtime/example",
|
||||||
@@ -365,13 +404,13 @@ describe("end to end: a discovered module's UI mounts in the Ekklesia host", ()
|
|||||||
});
|
});
|
||||||
|
|
||||||
// 5. The module rendered, having reached its own backend through the host.
|
// 5. The module rendered, having reached its own backend through the host.
|
||||||
expect(container.textContent).toBe("example:200:2");
|
expect(mountedText(container)).toBe("example:200:2");
|
||||||
// 6. It asked for "records"; the host resolved that onto Ekklesia's module mount prefix.
|
// 6. It asked for "records"; the host resolved that onto Ekklesia's module mount prefix.
|
||||||
expect(harness.moduleApiCalls).toEqual(["GET /api/v1/modules/example/records"]);
|
expect(harness.moduleApiCalls).toEqual(["GET /api/v1/modules/example/records"]);
|
||||||
|
|
||||||
// 7. And it tears down cleanly.
|
// 7. And it tears down cleanly.
|
||||||
await mounted.unmount();
|
await mounted.unmount();
|
||||||
expect(container.textContent).toBe("");
|
expect(mountedText(container)).toBe("");
|
||||||
expect(mounted.mounted).toBe(false);
|
expect(mounted.mounted).toBe(false);
|
||||||
} finally {
|
} finally {
|
||||||
await harness.stop();
|
await harness.stop();
|
||||||
@@ -403,6 +442,8 @@ describe("end to end: a discovered module's UI mounts in the Ekklesia host", ()
|
|||||||
}
|
}
|
||||||
expect(bootstrap.descriptor).toEqual({
|
expect(bootstrap.descriptor).toEqual({
|
||||||
apiBaseUrl: "/api/v1/modules/example",
|
apiBaseUrl: "/api/v1/modules/example",
|
||||||
|
contributions: [],
|
||||||
|
displayPageUrl: "/display/example",
|
||||||
id: "example",
|
id: "example",
|
||||||
permissions: ["example.records.read", "example.display.view"],
|
permissions: ["example.records.read", "example.display.view"],
|
||||||
realtimeBaseUrl: "/api/v1/module-realtime/example",
|
realtimeBaseUrl: "/api/v1/module-realtime/example",
|
||||||
@@ -418,8 +459,8 @@ describe("end to end: a discovered module's UI mounts in the Ekklesia host", ()
|
|||||||
);
|
);
|
||||||
const artifactUrl = await publishedArtifactUrl(harness, sourceUrl);
|
const artifactUrl = await publishedArtifactUrl(harness, sourceUrl);
|
||||||
|
|
||||||
// 4. The generic loader receives the unchanged five-member browser context and mounts the
|
// 4. The generic loader receives the host-owned browser context, including the canonical
|
||||||
// display artifact independently of the Dashboard artifact.
|
// display page, and mounts the display artifact independently of the Dashboard artifact.
|
||||||
const context = createModuleUiContext({
|
const context = createModuleUiContext({
|
||||||
config: harness.config,
|
config: harness.config,
|
||||||
descriptor: bootstrap.descriptor,
|
descriptor: bootstrap.descriptor,
|
||||||
@@ -427,6 +468,8 @@ describe("end to end: a discovered module's UI mounts in the Ekklesia host", ()
|
|||||||
});
|
});
|
||||||
expect(Object.keys(context).sort()).toEqual([
|
expect(Object.keys(context).sort()).toEqual([
|
||||||
"api",
|
"api",
|
||||||
|
"contributions",
|
||||||
|
"display",
|
||||||
"moduleId",
|
"moduleId",
|
||||||
"permissions",
|
"permissions",
|
||||||
"principal",
|
"principal",
|
||||||
@@ -443,10 +486,10 @@ describe("end to end: a discovered module's UI mounts in the Ekklesia host", ()
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(container.textContent).toBe("example:display:200:2");
|
expect(mountedText(container)).toBe("example:display:200:2");
|
||||||
expect(harness.moduleApiCalls).toEqual(["GET /api/v1/modules/example/records"]);
|
expect(harness.moduleApiCalls).toEqual(["GET /api/v1/modules/example/records"]);
|
||||||
await mounted.unmount();
|
await mounted.unmount();
|
||||||
expect(container.textContent).toBe("");
|
expect(mountedText(container)).toBe("");
|
||||||
expect(mounted.mounted).toBe(false);
|
expect(mounted.mounted).toBe(false);
|
||||||
} finally {
|
} finally {
|
||||||
await harness.stop();
|
await harness.stop();
|
||||||
@@ -514,7 +557,7 @@ describe("end to end: a discovered module's UI mounts in the Ekklesia host", ()
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Hiding a control is a usability improvement: the module simply did not ask.
|
// Hiding a control is a usability improvement: the module simply did not ask.
|
||||||
expect(container.textContent).toBe("example:denied");
|
expect(mountedText(container)).toBe("example:denied");
|
||||||
expect(harness.moduleApiCalls).toEqual([]);
|
expect(harness.moduleApiCalls).toEqual([]);
|
||||||
} finally {
|
} finally {
|
||||||
await harness.stop();
|
await harness.stop();
|
||||||
@@ -593,7 +636,7 @@ describe("end to end: a discovered module's UI mounts in the Ekklesia host", ()
|
|||||||
version: groupedEntry.descriptor.version,
|
version: groupedEntry.descriptor.version,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
expect(container.textContent).toBe("example:200:2");
|
expect(mountedText(container)).toBe("example:200:2");
|
||||||
await mounted.unmount();
|
await mounted.unmount();
|
||||||
|
|
||||||
// Now move it somewhere else entirely.
|
// Now move it somewhere else entirely.
|
||||||
@@ -645,7 +688,7 @@ describe("end to end: a discovered module's UI mounts in the Ekklesia host", ()
|
|||||||
version: movedEntry.descriptor.version,
|
version: movedEntry.descriptor.version,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
expect(movedContainer.textContent).toBe("example:200:2");
|
expect(mountedText(movedContainer)).toBe("example:200:2");
|
||||||
expect(harness.moduleApiCalls.slice(callsBeforeRemount)).toEqual([
|
expect(harness.moduleApiCalls.slice(callsBeforeRemount)).toEqual([
|
||||||
"GET /api/v1/modules/example/records",
|
"GET /api/v1/modules/example/records",
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -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,10 +19,14 @@ 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";
|
||||||
import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
|
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 { moduleApiBaseUrl } from "../src/module-http-routes.js";
|
import { moduleApiBaseUrl } from "../src/module-http-routes.js";
|
||||||
import { moduleRealtimeBaseUrl } from "../src/module-realtime-routes.js";
|
import { moduleRealtimeBaseUrl } from "../src/module-realtime-routes.js";
|
||||||
@@ -59,6 +61,12 @@ interface ModuleFixture {
|
|||||||
readonly id: string;
|
readonly id: string;
|
||||||
readonly baseline: "authenticated" | "public" | "restricted";
|
readonly baseline: "authenticated" | "public" | "restricted";
|
||||||
}>;
|
}>;
|
||||||
|
readonly capabilities?: readonly { readonly id: string; readonly version: string }[];
|
||||||
|
readonly contributions?: readonly {
|
||||||
|
readonly localName: string;
|
||||||
|
readonly capability: string;
|
||||||
|
readonly capabilityRange: string;
|
||||||
|
}[];
|
||||||
}
|
}
|
||||||
|
|
||||||
async function createModule(modulesDirectory: string, fixture: ModuleFixture): Promise<void> {
|
async function createModule(modulesDirectory: string, fixture: ModuleFixture): Promise<void> {
|
||||||
@@ -82,8 +90,16 @@ async function createModule(modulesDirectory: string, fixture: ModuleFixture): P
|
|||||||
server: "./dist/server/index.js",
|
server: "./dist/server/index.js",
|
||||||
...(fixture.withUi ? { ui: "./dist/ui/index.js" } : {}),
|
...(fixture.withUi ? { ui: "./dist/ui/index.js" } : {}),
|
||||||
},
|
},
|
||||||
|
capabilities: fixture.capabilities ?? [],
|
||||||
|
contributions: (fixture.contributions ?? []).map((contribution) => ({
|
||||||
|
artifact: `./dist/contributions/${contribution.localName}/index.js`,
|
||||||
|
capability: contribution.capability,
|
||||||
|
capabilityRange: contribution.capabilityRange,
|
||||||
|
id: `${fixture.id}.${contribution.localName}`,
|
||||||
|
version: "1.0.0",
|
||||||
|
})),
|
||||||
id: fixture.id,
|
id: fixture.id,
|
||||||
manifestVersion: 6,
|
manifestVersion: 8,
|
||||||
name: `${fixture.id} module`,
|
name: `${fixture.id} module`,
|
||||||
permissions: (fixture.permissions ?? []).map((permission) => ({
|
permissions: (fixture.permissions ?? []).map((permission) => ({
|
||||||
...permission,
|
...permission,
|
||||||
@@ -103,6 +119,15 @@ async function createModule(modulesDirectory: string, fixture: ModuleFixture): P
|
|||||||
`export default { id: ${JSON.stringify(fixture.id)}, version: ${JSON.stringify(version)}, mount() {} };\n`,
|
`export default { id: ${JSON.stringify(fixture.id)}, version: ${JSON.stringify(version)}, mount() {} };\n`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
for (const contribution of fixture.contributions ?? []) {
|
||||||
|
const contributionDirectory = path.join(
|
||||||
|
moduleDirectory,
|
||||||
|
"dist/contributions",
|
||||||
|
contribution.localName,
|
||||||
|
);
|
||||||
|
await mkdir(contributionDirectory, { recursive: true });
|
||||||
|
await writeFile(path.join(contributionDirectory, "index.js"), "export default {};\n");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
interface InstallOptions {
|
interface InstallOptions {
|
||||||
@@ -167,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);
|
||||||
@@ -185,6 +210,8 @@ async function install(options: InstallOptions): Promise<Installation> {
|
|||||||
moduleConfiguration: moduleConfigurationOptionFor(modules),
|
moduleConfiguration: moduleConfigurationOptionFor(modules),
|
||||||
moduleStorage: moduleStorageOptionFor(modules),
|
moduleStorage: moduleStorageOptionFor(modules),
|
||||||
dashboard: createInMemoryDashboardStore(),
|
dashboard: createInMemoryDashboardStore(),
|
||||||
|
dashboardHostAssets: testDashboardHostAssets(),
|
||||||
|
health: readyHealth(),
|
||||||
runtime,
|
runtime,
|
||||||
trustedOrigins: [trustedOrigin],
|
trustedOrigins: [trustedOrigin],
|
||||||
});
|
});
|
||||||
@@ -219,7 +246,9 @@ describe("module UI inventory — what it lists", () => {
|
|||||||
expect(response.status).toBe(200);
|
expect(response.status).toBe(200);
|
||||||
expect(modules).toEqual([
|
expect(modules).toEqual([
|
||||||
{
|
{
|
||||||
|
contributions: [],
|
||||||
api: { baseUrl: "/api/v1/modules/example" },
|
api: { baseUrl: "/api/v1/modules/example" },
|
||||||
|
display: null,
|
||||||
id: "example",
|
id: "example",
|
||||||
permissions: [],
|
permissions: [],
|
||||||
realtime: { baseUrl: "/api/v1/module-realtime/example" },
|
realtime: { baseUrl: "/api/v1/module-realtime/example" },
|
||||||
@@ -315,6 +344,8 @@ describe("module UI inventory — what it lists", () => {
|
|||||||
// The exact key set, so a future field cannot be added without a deliberate decision here.
|
// The exact key set, so a future field cannot be added without a deliberate decision here.
|
||||||
expect(Object.keys(modules[0] ?? {}).sort()).toEqual([
|
expect(Object.keys(modules[0] ?? {}).sort()).toEqual([
|
||||||
"api",
|
"api",
|
||||||
|
"contributions",
|
||||||
|
"display",
|
||||||
"id",
|
"id",
|
||||||
"permissions",
|
"permissions",
|
||||||
"realtime",
|
"realtime",
|
||||||
@@ -520,3 +551,84 @@ describe("module UI inventory — it is not a module management API", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The inventory is how a consuming module learns what was contributed to its own capabilities
|
||||||
|
* (ADR 0041). It is the *only* way: a module never queries a registry and never builds a URL.
|
||||||
|
*/
|
||||||
|
describe("module UI inventory — contributions", () => {
|
||||||
|
const signage = {
|
||||||
|
capabilities: [{ id: "signage.slide-renderer", version: "1.2.0" }],
|
||||||
|
id: "signage",
|
||||||
|
permissions: [],
|
||||||
|
withUi: true,
|
||||||
|
};
|
||||||
|
const announcements = (capabilityRange = "^1.0.0") => ({
|
||||||
|
contributions: [
|
||||||
|
{ capability: "signage.slide-renderer", capabilityRange, localName: "signage-slide" },
|
||||||
|
],
|
||||||
|
id: "announcements",
|
||||||
|
permissions: [],
|
||||||
|
withUi: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
it("gives the providing module the contribution and its server-produced URL", async () => {
|
||||||
|
const { app } = await install({
|
||||||
|
fixtures: [signage, announcements()],
|
||||||
|
principal: signedIn(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { modules } = await inventoryOf(app);
|
||||||
|
const consumer = modules.find((module) => module.id === "signage");
|
||||||
|
|
||||||
|
expect(consumer?.contributions).toEqual([
|
||||||
|
{
|
||||||
|
capability: "signage.slide-renderer",
|
||||||
|
id: "announcements.signage-slide",
|
||||||
|
url: "/api/v1/module-ui/announcements/0.1.0/contributions/announcements.signage-slide",
|
||||||
|
version: "1.0.0",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("gives the contributing module nothing: it provides no capability that was targeted", async () => {
|
||||||
|
const { app } = await install({
|
||||||
|
fixtures: [signage, announcements()],
|
||||||
|
principal: signedIn(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { modules } = await inventoryOf(app);
|
||||||
|
|
||||||
|
expect(modules.find((module) => module.id === "announcements")?.contributions).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("withholds a contribution whose provider is outside its declared range", async () => {
|
||||||
|
const { app } = await install({
|
||||||
|
fixtures: [signage, announcements("^2.0.0")],
|
||||||
|
principal: signedIn(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { modules } = await inventoryOf(app);
|
||||||
|
|
||||||
|
expect(modules.find((module) => module.id === "signage")?.contributions).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("carries no filesystem path or domain vocabulary in a contribution descriptor", async () => {
|
||||||
|
const { app, modulesDirectory } = await install({
|
||||||
|
fixtures: [signage, announcements()],
|
||||||
|
principal: signedIn(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { body, modules } = await inventoryOf(app);
|
||||||
|
const consumer = modules.find((module) => module.id === "signage");
|
||||||
|
const contributions = (consumer?.contributions ?? []) as ReadonlyArray<Record<string, unknown>>;
|
||||||
|
|
||||||
|
expect(body).not.toContain(modulesDirectory);
|
||||||
|
expect(Object.keys(contributions[0] ?? {}).sort()).toEqual([
|
||||||
|
"capability",
|
||||||
|
"id",
|
||||||
|
"url",
|
||||||
|
"version",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -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,17 +10,24 @@ 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 { Window } from "happy-dom";
|
||||||
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";
|
||||||
import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
|
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 { createDisplayHostAssets } from "../src/display-host-assets.js";
|
import { createDisplayHostAssets } from "../src/display-host-assets.js";
|
||||||
import {
|
import {
|
||||||
MODULE_UI_ASSET_PREFIX,
|
MODULE_UI_ASSET_PREFIX,
|
||||||
MODULE_UI_CACHE_CONTROL,
|
MODULE_UI_CACHE_CONTROL,
|
||||||
MODULE_UI_CONTENT_TYPE,
|
MODULE_UI_CONTENT_TYPE,
|
||||||
|
moduleContributionArtifactUrl,
|
||||||
moduleDisplayUiArtifactUrl,
|
moduleDisplayUiArtifactUrl,
|
||||||
moduleUiArtifactUrl,
|
moduleUiArtifactUrl,
|
||||||
} from "../src/module-ui-routes.js";
|
} from "../src/module-ui-routes.js";
|
||||||
@@ -59,6 +62,7 @@ async function createModulesDirectory(): Promise<string> {
|
|||||||
* sequence whose exact publication and loader-compatibility can be asserted. */
|
* sequence whose exact publication and loader-compatibility can be asserted. */
|
||||||
function artifactSource(id: string, version: string): string {
|
function artifactSource(id: string, version: string): string {
|
||||||
return `export default {
|
return `export default {
|
||||||
|
abi: ${MODULE_UI_ABI_VERSION},
|
||||||
id: ${JSON.stringify(id)},
|
id: ${JSON.stringify(id)},
|
||||||
version: ${JSON.stringify(version)},
|
version: ${JSON.stringify(version)},
|
||||||
mount(container, context) {
|
mount(container, context) {
|
||||||
@@ -83,6 +87,15 @@ interface ModuleFixture {
|
|||||||
* chunk, an operator secret — so "only the declared artifact is reachable" is asserted against
|
* chunk, an operator secret — so "only the declared artifact is reachable" is asserted against
|
||||||
* files that genuinely exist on disk rather than against hypothetical ones. */
|
* files that genuinely exist on disk rather than against hypothetical ones. */
|
||||||
readonly extraFiles?: Readonly<Record<string, string>>;
|
readonly extraFiles?: Readonly<Record<string, string>>;
|
||||||
|
/** Capabilities this module provides — what another module's contributions target. */
|
||||||
|
readonly capabilities?: readonly { readonly id: string; readonly version: string }[];
|
||||||
|
/** Browser renderers this module contributes to another module's capability. */
|
||||||
|
readonly contributions?: readonly {
|
||||||
|
readonly localName: string;
|
||||||
|
readonly capability: string;
|
||||||
|
readonly capabilityRange: string;
|
||||||
|
readonly source: string;
|
||||||
|
}[];
|
||||||
}
|
}
|
||||||
|
|
||||||
async function createModule(modulesDirectory: string, fixture: ModuleFixture): Promise<void> {
|
async function createModule(modulesDirectory: string, fixture: ModuleFixture): Promise<void> {
|
||||||
@@ -115,8 +128,16 @@ async function createModule(modulesDirectory: string, fixture: ModuleFixture): P
|
|||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
|
capabilities: fixture.capabilities ?? [],
|
||||||
|
contributions: (fixture.contributions ?? []).map((contribution) => ({
|
||||||
|
artifact: `./dist/contributions/${contribution.localName}/index.js`,
|
||||||
|
capability: contribution.capability,
|
||||||
|
capabilityRange: contribution.capabilityRange,
|
||||||
|
id: `${fixture.id}.${contribution.localName}`,
|
||||||
|
version: "1.0.0",
|
||||||
|
})),
|
||||||
id: fixture.id,
|
id: fixture.id,
|
||||||
manifestVersion: 6,
|
manifestVersion: 8,
|
||||||
name: `${fixture.id} module`,
|
name: `${fixture.id} module`,
|
||||||
permissions:
|
permissions:
|
||||||
fixture.display === undefined
|
fixture.display === undefined
|
||||||
@@ -139,6 +160,16 @@ async function createModule(modulesDirectory: string, fixture: ModuleFixture): P
|
|||||||
await mkdir(path.join(moduleDirectory, "dist/ui"), { recursive: true });
|
await mkdir(path.join(moduleDirectory, "dist/ui"), { recursive: true });
|
||||||
await writeFile(path.join(moduleDirectory, "dist/ui/index.js"), fixture.ui);
|
await writeFile(path.join(moduleDirectory, "dist/ui/index.js"), fixture.ui);
|
||||||
}
|
}
|
||||||
|
for (const contribution of fixture.contributions ?? []) {
|
||||||
|
const contributionDirectory = path.join(
|
||||||
|
moduleDirectory,
|
||||||
|
"dist/contributions",
|
||||||
|
contribution.localName,
|
||||||
|
);
|
||||||
|
await mkdir(contributionDirectory, { recursive: true });
|
||||||
|
await writeFile(path.join(contributionDirectory, "index.js"), contribution.source);
|
||||||
|
await writeFile(path.join(contributionDirectory, "index.js.map"), '{"sources":["secret"]}');
|
||||||
|
}
|
||||||
if (fixture.display !== undefined) {
|
if (fixture.display !== undefined) {
|
||||||
await mkdir(path.join(moduleDirectory, "dist/display"), { recursive: true });
|
await mkdir(path.join(moduleDirectory, "dist/display"), { recursive: true });
|
||||||
await writeFile(path.join(moduleDirectory, "dist/display/index.js"), fixture.display);
|
await writeFile(path.join(moduleDirectory, "dist/display/index.js"), fixture.display);
|
||||||
@@ -182,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
|
||||||
@@ -209,6 +240,7 @@ async function install(
|
|||||||
displayHostAssets: createDisplayHostAssets(
|
displayHostAssets: createDisplayHostAssets(
|
||||||
'<!doctype html><div id="display-root"></div>',
|
'<!doctype html><div id="display-root"></div>',
|
||||||
"export {};\n",
|
"export {};\n",
|
||||||
|
"export {};\n",
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
: {}),
|
: {}),
|
||||||
@@ -223,6 +255,8 @@ async function install(
|
|||||||
moduleConfiguration: moduleConfigurationOptionFor(modules),
|
moduleConfiguration: moduleConfigurationOptionFor(modules),
|
||||||
moduleStorage: moduleStorageOptionFor(modules),
|
moduleStorage: moduleStorageOptionFor(modules),
|
||||||
dashboard: createInMemoryDashboardStore(),
|
dashboard: createInMemoryDashboardStore(),
|
||||||
|
dashboardHostAssets: testDashboardHostAssets(),
|
||||||
|
health: readyHealth(),
|
||||||
runtime,
|
runtime,
|
||||||
trustedOrigins: [trustedOrigin],
|
trustedOrigins: [trustedOrigin],
|
||||||
});
|
});
|
||||||
@@ -862,6 +896,12 @@ describe("module UI artifact URL generation", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
interface HostContainer {
|
||||||
|
readonly children: { readonly length: number };
|
||||||
|
querySelector(selectors: string): HostContainer | null;
|
||||||
|
readonly shadowRoot: { querySelector(selectors: string): { textContent: string } | null } | null;
|
||||||
|
}
|
||||||
|
|
||||||
describe("module UI artifact publication — the generic loader consumes what is published", () => {
|
describe("module UI artifact publication — the generic loader consumes what is published", () => {
|
||||||
/**
|
/**
|
||||||
* Node cannot `import()` an `http:` URL, so the two halves of the chain are proven separately and
|
* Node cannot `import()` an `http:` URL, so the two halves of the chain are proven separately and
|
||||||
@@ -880,21 +920,36 @@ describe("module UI artifact publication — the generic loader consumes what is
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The loader forwards the container untouched to the module's own `mount` and never inspects it,
|
* A real host-owned container, because since ADR 0040 the loader builds an isolated Shadow DOM
|
||||||
* so a plain sink is enough and no DOM implementation is required. The container's type is read
|
* root inside whatever it is given. The container's type is read back off `loadModuleUi` itself
|
||||||
* back off `loadModuleUi` itself rather than written as `Element`, because `apps/api` compiles
|
* rather than written as `Element`, because `apps/api` compiles against Node libs only — naming a
|
||||||
* against Node libs only — naming a DOM type here would be the same browser/server confusion this
|
* DOM type here would be the same browser/server confusion this route exists to keep apart.
|
||||||
* route exists to keep apart.
|
|
||||||
*/
|
*/
|
||||||
type LoaderContainer = Parameters<typeof loadModuleUi>[0]["container"];
|
type LoaderContainer = Parameters<typeof loadModuleUi>[0]["container"];
|
||||||
|
|
||||||
function containerSink(): { textContent: string } {
|
/**
|
||||||
return { textContent: "" };
|
* `happy-dom` used as a library rather than as this suite's environment: the loader now builds a
|
||||||
|
* real isolated root inside the container, and installing a DOM environment here would replace
|
||||||
|
* the module runner's resolution for a server-side suite that dynamically imports real module
|
||||||
|
* entrypoints.
|
||||||
|
*/
|
||||||
|
function containerSink(): HostContainer {
|
||||||
|
return new Window().document.createElement("div") as unknown as HostContainer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What the mounted artifact rendered, read through its open isolated root. */
|
||||||
|
function mountedText(container: HostContainer): string {
|
||||||
|
const shadowHost = container.querySelector("[data-ekklesia-module-ui]");
|
||||||
|
return (
|
||||||
|
shadowHost?.shadowRoot?.querySelector("[data-ekklesia-module-ui-root]")?.textContent ?? ""
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function hostContext() {
|
function hostContext() {
|
||||||
return {
|
return {
|
||||||
api: { request: async () => new Response(null, { status: 200 }) },
|
api: { request: async () => new Response(null, { status: 200 }) },
|
||||||
|
contributions: NO_MODULE_UI_CONTRIBUTIONS,
|
||||||
|
display: undefined,
|
||||||
moduleId: "announcements",
|
moduleId: "announcements",
|
||||||
permissions: { has: () => false },
|
permissions: { has: () => false },
|
||||||
principal: { kind: "anonymous" as const },
|
principal: { kind: "anonymous" as const },
|
||||||
@@ -915,9 +970,9 @@ describe("module UI artifact publication — the generic loader consumes what is
|
|||||||
source: { moduleId: "announcements", url, version: "1.4.2" },
|
source: { moduleId: "announcements", url, version: "1.4.2" },
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(container.textContent).toBe("announcements:mounted");
|
expect(mountedText(container)).toBe("announcements:mounted");
|
||||||
await mounted.unmount();
|
await mounted.unmount();
|
||||||
expect(container.textContent).toBe("");
|
expect(container.children).toHaveLength(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("still rejects the published artifact when the host expects a different identity", async () => {
|
it("still rejects the published artifact when the host expects a different identity", async () => {
|
||||||
@@ -935,3 +990,109 @@ describe("module UI artifact publication — the generic loader consumes what is
|
|||||||
).rejects.toMatchObject({ code: "MODULE_UI_IDENTITY_MISMATCH" });
|
).rejects.toMatchObject({ code: "MODULE_UI_IDENTITY_MISMATCH" });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Contribution artifact publication (ADR 0041), on exactly the same terms as every other declared
|
||||||
|
* artifact: three lookup keys, one exact registry match, one already-validated canonical path.
|
||||||
|
*/
|
||||||
|
describe("module contribution artifact publication", () => {
|
||||||
|
const signage = {
|
||||||
|
capabilities: [{ id: "signage.slide-renderer", version: "1.2.0" }],
|
||||||
|
id: "signage",
|
||||||
|
};
|
||||||
|
const contributionSource = "export default { contributed: true };\n";
|
||||||
|
const announcements = (capabilityRange = "^1.0.0") => ({
|
||||||
|
contributions: [
|
||||||
|
{
|
||||||
|
capability: "signage.slide-renderer",
|
||||||
|
capabilityRange,
|
||||||
|
localName: "signage-slide",
|
||||||
|
source: contributionSource,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
id: "announcements",
|
||||||
|
});
|
||||||
|
const contributionUrl = moduleContributionArtifactUrl(
|
||||||
|
{ moduleId: "announcements", version: "0.1.0" },
|
||||||
|
"announcements.signage-slide",
|
||||||
|
);
|
||||||
|
|
||||||
|
it("serves the declared contribution artifact byte for byte", async () => {
|
||||||
|
const { app } = await install([signage, announcements()]);
|
||||||
|
|
||||||
|
const response = await app.request(contributionUrl);
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(response.headers.get("content-type")).toBe(MODULE_UI_CONTENT_TYPE);
|
||||||
|
expect(response.headers.get("cache-control")).toBe(MODULE_UI_CACHE_CONTROL);
|
||||||
|
expect(response.headers.get("x-content-type-options")).toBe("nosniff");
|
||||||
|
expect(await response.text()).toBe(contributionSource);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("builds a URL that cannot select a neighbouring file", () => {
|
||||||
|
expect(contributionUrl).toBe(
|
||||||
|
"/api/v1/module-ui/announcements/0.1.0/contributions/announcements.signage-slide",
|
||||||
|
);
|
||||||
|
// The contribution id is one segment's worth of text, always — a value that needed encoding
|
||||||
|
// simply fails to resolve.
|
||||||
|
expect(
|
||||||
|
moduleContributionArtifactUrl(
|
||||||
|
{ moduleId: "announcements", version: "0.1.0" },
|
||||||
|
"../../../etc/passwd",
|
||||||
|
),
|
||||||
|
).toBe("/api/v1/module-ui/announcements/0.1.0/contributions/..%2F..%2F..%2Fetc%2Fpasswd");
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
["an unknown contribution id", "announcements.nope"],
|
||||||
|
["another module's contribution id", "signage.slide-renderer"],
|
||||||
|
["a path traversal", "../../../etc/passwd"],
|
||||||
|
["the artifact's source map", "announcements.signage-slide.map"],
|
||||||
|
])("refuses %s with the same indistinguishable 404", async (_label, contributionId) => {
|
||||||
|
const { app } = await install([signage, announcements()]);
|
||||||
|
|
||||||
|
const response = await app.request(
|
||||||
|
moduleContributionArtifactUrl(
|
||||||
|
{ moduleId: "announcements", version: "0.1.0" },
|
||||||
|
contributionId,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(response.status).toBe(404);
|
||||||
|
expect(await response.json()).toEqual({ code: "MODULE_UI_NOT_PUBLISHED" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses a version the platform does not have installed", async () => {
|
||||||
|
const { app } = await install([signage, announcements()]);
|
||||||
|
|
||||||
|
const response = await app.request(
|
||||||
|
moduleContributionArtifactUrl(
|
||||||
|
{ moduleId: "announcements", version: "9.9.9" },
|
||||||
|
"announcements.signage-slide",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(response.status).toBe(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("publishes nothing when no installed module provides the target capability", async () => {
|
||||||
|
const { app } = await install([announcements()]);
|
||||||
|
|
||||||
|
expect((await app.request(contributionUrl)).status).toBe(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("publishes nothing when the provider is outside the declared compatible range", async () => {
|
||||||
|
const { app } = await install([signage, announcements("^2.0.0")]);
|
||||||
|
|
||||||
|
expect((await app.request(contributionUrl)).status).toBe(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the sibling source map and the module's own directory unreachable", async () => {
|
||||||
|
const { app } = await install([signage, announcements()]);
|
||||||
|
|
||||||
|
for (const suffix of [".map", "/index.js.map", "/../ui/index.js"]) {
|
||||||
|
const response = await app.request(`${contributionUrl}${suffix}`);
|
||||||
|
expect(response.status).toBe(404);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import {
|
||||||
|
createDashboardHostAssets,
|
||||||
|
type DashboardHostAssets,
|
||||||
|
} from "../../src/dashboard-host-assets.js";
|
||||||
|
|
||||||
|
/** A small, internally consistent Dashboard snapshot for compositions whose tests do not exercise
|
||||||
|
* the Dashboard host. Its own behavior is tested in `../dashboard-host-routes.test.ts`. */
|
||||||
|
export function testDashboardHostAssets(): DashboardHostAssets {
|
||||||
|
return createDashboardHostAssets(
|
||||||
|
'<!doctype html><div id="ekklesia-host"></div><script src="/assets/ekklesia-dashboard/app.js" type="module"></script>',
|
||||||
|
"export const dashboardHost = true;\n",
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import type { ApiHealthOptions } from "../../src/health-routes.js";
|
||||||
|
|
||||||
|
/** Readiness inputs for a composition whose tests do not exercise health: the database answers and
|
||||||
|
* module startup has completed. Health behavior itself is tested in `../health-routes.test.ts`. */
|
||||||
|
export function readyHealth(): ApiHealthOptions {
|
||||||
|
return {
|
||||||
|
checkDatabase: async () => undefined,
|
||||||
|
isModuleStartupComplete: () => true,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
-- Email verification becomes a precondition for holding a session (ADR 0038). Any platform owner
|
||||||
|
-- that already exists was created by the trusted operator-run bootstrap command before that command
|
||||||
|
-- marked addresses verified, so without this they would be locked out of their own installation by
|
||||||
|
-- an upgrade.
|
||||||
|
--
|
||||||
|
-- Deliberately narrow. It marks exactly the identities the authorization tables already record as
|
||||||
|
-- currently-granted platform owners: an authority the deployment established through a command that
|
||||||
|
-- required database credentials, which is the same authority the bootstrap verification exception
|
||||||
|
-- rests on. Every other account is left untouched and must verify by email like any other.
|
||||||
|
--
|
||||||
|
-- Data-only and idempotent. It adds no column, drops nothing, and re-running it changes no row that
|
||||||
|
-- is not already false.
|
||||||
|
UPDATE "ekklesia_core_user"
|
||||||
|
SET "email_verified" = true
|
||||||
|
WHERE "email_verified" = false
|
||||||
|
AND "id" IN (
|
||||||
|
SELECT "user_id"
|
||||||
|
FROM "ekklesia_core_platform_owner"
|
||||||
|
WHERE "revoked_at" IS NULL
|
||||||
|
);
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -43,6 +43,12 @@
|
|||||||
"destructive": {
|
"destructive": {
|
||||||
"mode": "none"
|
"mode": "none"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tag": "0007_verified_bootstrap_owners",
|
||||||
|
"destructive": {
|
||||||
|
"mode": "none"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,6 +50,13 @@
|
|||||||
"when": 1787543773057,
|
"when": 1787543773057,
|
||||||
"tag": "0006_device_identity",
|
"tag": "0006_device_identity",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 7,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1787543773058,
|
||||||
|
"tag": "0007_verified_bootstrap_owners",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
+6
-1
@@ -1,6 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "@ekklesia/core",
|
"name": "@ekklesia/core",
|
||||||
"version": "0.1.0",
|
"version": "0.2.5",
|
||||||
|
"license": "AGPL-3.0-or-later",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"files": [
|
"files": [
|
||||||
@@ -50,6 +51,10 @@
|
|||||||
"./mail": {
|
"./mail": {
|
||||||
"types": "./dist/mail/index.d.ts",
|
"types": "./dist/mail/index.d.ts",
|
||||||
"import": "./dist/mail/index.js"
|
"import": "./dist/mail/index.js"
|
||||||
|
},
|
||||||
|
"./host": {
|
||||||
|
"types": "./dist/host/index.d.ts",
|
||||||
|
"import": "./dist/host/index.js"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ export type InitialPlatformOwnerErrorCode =
|
|||||||
| "INITIAL_OWNER_EMAIL_INVALID"
|
| "INITIAL_OWNER_EMAIL_INVALID"
|
||||||
| "INITIAL_OWNER_IDENTITY_NOT_CREATED"
|
| "INITIAL_OWNER_IDENTITY_NOT_CREATED"
|
||||||
| "INITIAL_OWNER_IDENTITY_UNUSABLE"
|
| "INITIAL_OWNER_IDENTITY_UNUSABLE"
|
||||||
|
| "INITIAL_OWNER_IDENTITY_NOT_VERIFIED"
|
||||||
| "INITIAL_OWNER_OWNERSHIP_GRANT_FAILED"
|
| "INITIAL_OWNER_OWNERSHIP_GRANT_FAILED"
|
||||||
| "INITIAL_OWNER_PASSWORD_TOO_LONG"
|
| "INITIAL_OWNER_PASSWORD_TOO_LONG"
|
||||||
| "INITIAL_OWNER_PASSWORD_TOO_SHORT"
|
| "INITIAL_OWNER_PASSWORD_TOO_SHORT"
|
||||||
@@ -28,6 +29,8 @@ const initialOwnerMessages: Readonly<Record<InitialPlatformOwnerErrorCode, strin
|
|||||||
"The identity system reported success but no account exists for that address. Nothing was changed; the bootstrap is still available.",
|
"The identity system reported success but no account exists for that address. Nothing was changed; the bootstrap is still available.",
|
||||||
INITIAL_OWNER_IDENTITY_UNUSABLE:
|
INITIAL_OWNER_IDENTITY_UNUSABLE:
|
||||||
"An account already exists for that address but has no password and could never sign in. Choose a different address, or remove that account before retrying.",
|
"An account already exists for that address but has no password and could never sign in. Choose a different address, or remove that account before retrying.",
|
||||||
|
INITIAL_OWNER_IDENTITY_NOT_VERIFIED:
|
||||||
|
"The identity exists but could not be marked as a verified address, so it could not sign in. Nothing was granted; the bootstrap is still available.",
|
||||||
INITIAL_OWNER_OWNERSHIP_GRANT_FAILED:
|
INITIAL_OWNER_OWNERSHIP_GRANT_FAILED:
|
||||||
"The identity exists but platform ownership was not granted. Bootstrap is still available: run this command again with the same email address to finish it.",
|
"The identity exists but platform ownership was not granted. Bootstrap is still available: run this command again with the same email address to finish it.",
|
||||||
INITIAL_OWNER_PASSWORD_TOO_LONG: "The password is longer than the identity system allows.",
|
INITIAL_OWNER_PASSWORD_TOO_LONG: "The password is longer than the identity system allows.",
|
||||||
@@ -131,6 +134,25 @@ async function findIdentityByEmail(
|
|||||||
return { hasCredential: credentials.length > 0, storedEmail: found.email, userId: found.id };
|
return { hasCredential: credentials.length > 0, storedEmail: found.email, userId: found.id };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Marks one identity's address verified.
|
||||||
|
*
|
||||||
|
* A direct column update rather than an identity-system call, because the pinned release exposes no
|
||||||
|
* supported way to mark an address verified other than consuming a token it emailed — which is the
|
||||||
|
* one thing a pre-mail installation cannot do. The column is Better Auth's own `emailVerified`, the
|
||||||
|
* exact value its `/verify-email` endpoint writes, so the resulting row is indistinguishable from
|
||||||
|
* one that verified normally; nothing here invents a parallel notion of verification.
|
||||||
|
*/
|
||||||
|
async function markIdentityEmailVerified(
|
||||||
|
db: IdentityDrizzleDatabase,
|
||||||
|
userId: string,
|
||||||
|
): Promise<void> {
|
||||||
|
await db
|
||||||
|
.update(ekklesiaCoreUser)
|
||||||
|
.set({ emailVerified: true })
|
||||||
|
.where(eq(ekklesiaCoreUser.id, userId));
|
||||||
|
}
|
||||||
|
|
||||||
function translateIdentityCreationError(error: unknown): InitialPlatformOwnerError {
|
function translateIdentityCreationError(error: unknown): InitialPlatformOwnerError {
|
||||||
if (error instanceof BootstrapIdentityError) {
|
if (error instanceof BootstrapIdentityError) {
|
||||||
if (error.code === "BOOTSTRAP_IDENTITY_PASSWORD_TOO_SHORT") {
|
if (error.code === "BOOTSTRAP_IDENTITY_PASSWORD_TOO_SHORT") {
|
||||||
@@ -252,6 +274,29 @@ export async function createInitialPlatformOwner(
|
|||||||
throw new InitialPlatformOwnerError("INITIAL_OWNER_IDENTITY_NOT_CREATED");
|
throw new InitialPlatformOwnerError("INITIAL_OWNER_IDENTITY_NOT_CREATED");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The trusted-bootstrap verification exception (ADR 0038).
|
||||||
|
*
|
||||||
|
* Every other human account must verify its address by email before it may hold a session.
|
||||||
|
* This one cannot: it is created on a freshly provisioned server by an operator who already
|
||||||
|
* holds the database credentials, at a moment when SMTP may not be configured, may not be
|
||||||
|
* reachable, or may not yet have a DNS record to send from. Requiring an email round trip here
|
||||||
|
* would mean a working installation whose administrator can never get in — the platform's
|
||||||
|
* outbound mail would have to be configured and correct before anyone could sign in to
|
||||||
|
* configure anything.
|
||||||
|
*
|
||||||
|
* The exception is narrow and its authority is the same authority that already makes this
|
||||||
|
* command safe: possession of the database, not possession of the mailbox. It marks exactly one
|
||||||
|
* address, exactly once per installation, and it is deliberately performed **before** ownership
|
||||||
|
* is granted — if it fails, the sentinel and the grant roll back together and re-running the
|
||||||
|
* command adopts the identity and finishes, rather than leaving an owner who cannot sign in.
|
||||||
|
*/
|
||||||
|
try {
|
||||||
|
await markIdentityEmailVerified(dependencies.identityDb, parsedUserId.data);
|
||||||
|
} catch {
|
||||||
|
throw new InitialPlatformOwnerError("INITIAL_OWNER_IDENTITY_NOT_VERIFIED");
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const grant =
|
const grant =
|
||||||
dependencies.grantOwnership ??
|
dependencies.grantOwnership ??
|
||||||
|
|||||||
@@ -4,35 +4,28 @@ import {
|
|||||||
type DatabaseTransportPolicy,
|
type DatabaseTransportPolicy,
|
||||||
createDatabaseTransportPolicy,
|
createDatabaseTransportPolicy,
|
||||||
} from "../database/transport.js";
|
} from "../database/transport.js";
|
||||||
|
import { loadHostConfiguration } from "../host/index.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The one place every Core-owned executable reads its database transport policy from the process
|
* The database transport policy for the small Core-owned executables — `pnpm migrations:plan`,
|
||||||
* environment, so `pnpm doctor`, `pnpm migrations:plan`, `pnpm migrations:apply`, and
|
* `pnpm migrations:apply`, `pnpm modules:enable`, and the owner-bootstrap commands — resolved from
|
||||||
* `pnpm authorization:bootstrap-owner` cannot drift from each other or from `apps/api`.
|
* the process environment.
|
||||||
*
|
*
|
||||||
* Environment reading lives here, in the executable layer, deliberately: the reusable Core
|
* The mapping itself belongs to `loadHostConfiguration`, which `startApi` and `pnpm doctor` also
|
||||||
* database/migration constructors keep taking explicit typed inputs and never touch `process.env`.
|
* call, so `NODE_ENV` and the two TLS variables have exactly one interpretation across every entry
|
||||||
* `NODE_ENV` is interpreted exactly as `apps/api` interprets it — only the literal string
|
* point. Environment *reading* still happens here, in the executable layer: the reusable Core
|
||||||
* `"development"` relaxes anything, so an unset or misspelled value validates as production.
|
* database and migration constructors keep taking explicit typed inputs and never touch
|
||||||
|
* `process.env`.
|
||||||
*/
|
*/
|
||||||
export function readDatabaseTransportFromEnvironment(): {
|
export function readDatabaseTransportFromEnvironment(): {
|
||||||
readonly environment: DatabaseTransportEnvironment;
|
readonly environment: DatabaseTransportEnvironment;
|
||||||
readonly transport: DatabaseTransportPolicy;
|
readonly transport: DatabaseTransportPolicy;
|
||||||
} {
|
} {
|
||||||
const environment: DatabaseTransportEnvironment =
|
const host = loadHostConfiguration(process.env);
|
||||||
process.env.NODE_ENV === "development" ? "development" : "production";
|
const transport = createDatabaseTransportPolicy(host.databaseTransport, {
|
||||||
const transport = createDatabaseTransportPolicy(
|
environment: host.environment,
|
||||||
{
|
});
|
||||||
...(process.env.EKKLESIA_DATABASE_CA === undefined
|
return { environment: host.environment, transport };
|
||||||
? {}
|
|
||||||
: { certificateAuthority: process.env.EKKLESIA_DATABASE_CA }),
|
|
||||||
...(process.env.EKKLESIA_DATABASE_TLS_MODE === undefined
|
|
||||||
? {}
|
|
||||||
: { mode: process.env.EKKLESIA_DATABASE_TLS_MODE }),
|
|
||||||
},
|
|
||||||
{ environment },
|
|
||||||
);
|
|
||||||
return { environment, transport };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
+69
-61
@@ -7,6 +7,8 @@ import {
|
|||||||
doctorExitCode,
|
doctorExitCode,
|
||||||
formatDoctorReport,
|
formatDoctorReport,
|
||||||
} from "../diagnostics/index.js";
|
} from "../diagnostics/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;
|
||||||
@@ -18,6 +20,20 @@ interface RootPackageDocument extends ModuleSdkPackageDocument {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Everything Doctor needs that comes from the repository itself rather than the deployment
|
||||||
|
* environment, resolved by the caller so the environment mapping below stays pure and testable.
|
||||||
|
*/
|
||||||
|
export interface DoctorCommandMetadata {
|
||||||
|
readonly moduleSdkVersion: string;
|
||||||
|
readonly platformVersion: string;
|
||||||
|
/** Where the platform is installed: Core's migrations live beneath it, and so does the
|
||||||
|
* development default modules directory. Installed modules may live anywhere else. */
|
||||||
|
readonly repositoryRoot: string;
|
||||||
|
readonly runtimeRange: string;
|
||||||
|
readonly runtimeVersion: string;
|
||||||
|
}
|
||||||
|
|
||||||
async function readJson(pathname: string): Promise<unknown> {
|
async function readJson(pathname: string): Promise<unknown> {
|
||||||
return JSON.parse(await readFile(pathname, "utf8")) as unknown;
|
return JSON.parse(await readFile(pathname, "utf8")) as unknown;
|
||||||
}
|
}
|
||||||
@@ -36,40 +52,57 @@ function requiredString(value: unknown): string {
|
|||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
|
|
||||||
function readIdentityConfigurationFromEnvironment(): DoctorOptions["identityConfiguration"] {
|
/**
|
||||||
const secret = process.env.EKKLESIA_IDENTITY_SECRET;
|
* The modules directory exactly as `startApi` would choose it. An unusable configured value is not
|
||||||
const baseUrl = process.env.EKKLESIA_IDENTITY_BASE_URL;
|
* thrown here, because Doctor must still produce a report: it is handed on as a required directory
|
||||||
const publicSignupEnabled = process.env.EKKLESIA_PUBLIC_SIGNUP_ENABLED;
|
* for discovery to refuse, which reports it as a sanitized module-discovery failure.
|
||||||
const trustedOriginsValue = process.env.EKKLESIA_IDENTITY_TRUSTED_ORIGINS;
|
*/
|
||||||
const trustedOrigins = trustedOriginsValue
|
function doctorModulesDirectory(
|
||||||
? trustedOriginsValue
|
configured: string | undefined,
|
||||||
.split(",")
|
repositoryRoot: string,
|
||||||
.map((origin) => origin.trim())
|
): ModulesDirectorySelection {
|
||||||
.filter((origin) => origin.length > 0)
|
try {
|
||||||
: undefined;
|
return resolveModulesDirectory({ configured, repositoryRoot });
|
||||||
|
} catch {
|
||||||
return {
|
return { modulesDirectory: configured ?? "", modulesDirectoryRequired: true };
|
||||||
...(baseUrl ? { baseUrl } : {}),
|
}
|
||||||
environment: process.env.NODE_ENV === "development" ? "development" : "production",
|
|
||||||
...(publicSignupEnabled ? { publicSignupEnabled } : {}),
|
|
||||||
...(secret ? { secret } : {}),
|
|
||||||
...(trustedOrigins ? { trustedOrigins } : {}),
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function readClientAddressFromEnvironment(): DoctorOptions["clientAddress"] {
|
/**
|
||||||
const mode = process.env.EKKLESIA_CLIENT_ADDRESS_MODE;
|
* Turns one deployment environment into Doctor's inputs.
|
||||||
if (mode === undefined) {
|
*
|
||||||
return undefined;
|
* Every infrastructure value comes from `loadHostConfiguration` — the same function `apps/api` calls
|
||||||
}
|
* with the same environment — so `pnpm doctor` and `startApi` cannot disagree about what this
|
||||||
const trustedProxiesValue = process.env.EKKLESIA_CLIENT_ADDRESS_TRUSTED_PROXIES;
|
* deployment is configured to use. Doctor previously read its own subset here and silently omitted
|
||||||
|
* object storage and module secrets entirely, which made it report a correctly configured
|
||||||
|
* installation as having neither.
|
||||||
|
*
|
||||||
|
* Everything is handed over **raw**. Doctor validates it and reports a sanitized code, so a
|
||||||
|
* malformed value appears in the report — which is the whole reason an operator runs this command —
|
||||||
|
* instead of throwing before a report exists.
|
||||||
|
*/
|
||||||
|
export function createDoctorOptions(
|
||||||
|
environment: HostEnvironment,
|
||||||
|
metadata: DoctorCommandMetadata,
|
||||||
|
): DoctorOptions {
|
||||||
|
const host = loadHostConfiguration(environment);
|
||||||
return {
|
return {
|
||||||
header: process.env.EKKLESIA_CLIENT_ADDRESS_HEADER,
|
clientAddress: host.clientAddress,
|
||||||
mode,
|
coreMigrationsDirectory: path.join(metadata.repositoryRoot, "core", "drizzle"),
|
||||||
trustedProxies: trustedProxiesValue
|
databaseEnvironment: host.environment,
|
||||||
?.split(",")
|
databaseTransport: host.databaseTransport,
|
||||||
.map((entry) => entry.trim())
|
databaseUrl: host.databaseUrl,
|
||||||
.filter((entry) => entry.length > 0),
|
identityConfiguration: { ...host.identity, environment: host.environment },
|
||||||
|
mailConfiguration: host.mail,
|
||||||
|
moduleDiscovery: {
|
||||||
|
moduleSdkVersion: metadata.moduleSdkVersion,
|
||||||
|
...doctorModulesDirectory(host.modulesDirectory, metadata.repositoryRoot),
|
||||||
|
platformVersion: metadata.platformVersion,
|
||||||
|
},
|
||||||
|
moduleSecrets: host.moduleSecrets,
|
||||||
|
runtimeRange: metadata.runtimeRange,
|
||||||
|
runtimeVersion: metadata.runtimeVersion,
|
||||||
|
storageConfiguration: host.storage,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -81,40 +114,15 @@ export async function runDoctorCommand(repositoryRoot = process.cwd()): Promise<
|
|||||||
const rootPackage = rootPackageDocument(rootValue);
|
const rootPackage = rootPackageDocument(rootValue);
|
||||||
const moduleSdkPackage = rootPackageDocument(moduleSdkValue);
|
const moduleSdkPackage = rootPackageDocument(moduleSdkValue);
|
||||||
|
|
||||||
const databaseEnvironment =
|
const report = await diagnoseEkklesia(
|
||||||
process.env.NODE_ENV === "development" ? ("development" as const) : ("production" as const);
|
createDoctorOptions(process.env, {
|
||||||
const report = await diagnoseEkklesia({
|
|
||||||
clientAddress: readClientAddressFromEnvironment(),
|
|
||||||
databaseEnvironment,
|
|
||||||
databaseTransport: {
|
|
||||||
...(process.env.EKKLESIA_DATABASE_CA === undefined
|
|
||||||
? {}
|
|
||||||
: { certificateAuthority: process.env.EKKLESIA_DATABASE_CA }),
|
|
||||||
...(process.env.EKKLESIA_DATABASE_TLS_MODE === undefined
|
|
||||||
? {}
|
|
||||||
: { mode: process.env.EKKLESIA_DATABASE_TLS_MODE }),
|
|
||||||
},
|
|
||||||
databaseUrl: process.env.DATABASE_URL,
|
|
||||||
identityConfiguration: readIdentityConfigurationFromEnvironment(),
|
|
||||||
// Raw, exactly like the identity settings above: Doctor validates them and reports a sanitized
|
|
||||||
// code, so a malformed SMTP value appears in the report instead of crashing the command.
|
|
||||||
mailConfiguration: {
|
|
||||||
fromAddress: process.env.EKKLESIA_SMTP_FROM_ADDRESS,
|
|
||||||
fromName: process.env.EKKLESIA_SMTP_FROM_NAME,
|
|
||||||
host: process.env.EKKLESIA_SMTP_HOST,
|
|
||||||
password: process.env.EKKLESIA_SMTP_PASSWORD,
|
|
||||||
port: process.env.EKKLESIA_SMTP_PORT,
|
|
||||||
security: process.env.EKKLESIA_SMTP_SECURITY,
|
|
||||||
username: process.env.EKKLESIA_SMTP_USERNAME,
|
|
||||||
},
|
|
||||||
moduleDiscovery: {
|
|
||||||
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,
|
||||||
});
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
console.log(formatDoctorReport(report).join("\n"));
|
console.log(formatDoctorReport(report).join("\n"));
|
||||||
return doctorExitCode(report);
|
return doctorExitCode(report);
|
||||||
|
|||||||
@@ -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)) {
|
||||||
|
|||||||
@@ -3,11 +3,13 @@ import path from "node:path";
|
|||||||
import { PermissionRegistryError } from "../authorization/index.js";
|
import { PermissionRegistryError } from "../authorization/index.js";
|
||||||
import {
|
import {
|
||||||
composePlatformPermissionRegistry,
|
composePlatformPermissionRegistry,
|
||||||
|
createModuleUiPublicationRegistry,
|
||||||
discoverModuleSchemas,
|
discoverModuleSchemas,
|
||||||
ModuleDiscoveryError,
|
ModuleDiscoveryError,
|
||||||
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;
|
||||||
@@ -29,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;
|
||||||
@@ -45,11 +47,32 @@ async function main(): Promise<void> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Built from the same registry the API publishes from, so this command can never disagree with
|
||||||
|
// what a running platform would actually serve. A contribution whose target capability nobody
|
||||||
|
// provides — or whose provider moved outside its declared range — is otherwise silent: the
|
||||||
|
// manifest is valid, the artifact exists, and nothing renders (ADR 0041).
|
||||||
|
const publication = createModuleUiPublicationRegistry(modules);
|
||||||
|
const compatibleContributions = new Set(
|
||||||
|
publication
|
||||||
|
.listContributions()
|
||||||
|
.map((contribution) => `${contribution.moduleId}\u0000${contribution.contributionId}`),
|
||||||
|
);
|
||||||
|
|
||||||
console.log(
|
console.log(
|
||||||
`Module validation passed (${modules.length} modules, ${schemaAggregate.tables.length} tables):`,
|
`Module validation passed (${modules.length} modules, ${schemaAggregate.tables.length} tables):`,
|
||||||
);
|
);
|
||||||
for (const discoveredModule of modules) {
|
for (const discoveredModule of modules) {
|
||||||
console.log(`- ${discoveredModule.manifest.id}@${discoveredModule.manifest.version}`);
|
console.log(`- ${discoveredModule.manifest.id}@${discoveredModule.manifest.version}`);
|
||||||
|
for (const contribution of discoveredModule.contributions) {
|
||||||
|
const resolved = compatibleContributions.has(
|
||||||
|
`${discoveredModule.manifest.id}\u0000${contribution.id}`,
|
||||||
|
);
|
||||||
|
console.log(
|
||||||
|
` contributes ${contribution.id}@${contribution.version} to ${contribution.capability} ${contribution.capabilityRange} — ${
|
||||||
|
resolved ? "compatible provider installed" : "no compatible provider installed"
|
||||||
|
}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -44,17 +44,26 @@ export class DeviceStoreError extends Error {
|
|||||||
export const DEVICE_PAIRING_REQUEST_TTL_MS = 10 * 60_000;
|
export const DEVICE_PAIRING_REQUEST_TTL_MS = 10 * 60_000;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* How long a device credential is accepted before the device must pair again.
|
* How long a device credential may go **unused** before the device must pair again (ADR 0056).
|
||||||
*
|
*
|
||||||
* A display runs unattended for months; forcing a signage player to be re-paired every fortnight
|
* The window slides: every request from the device moves `lastSeenAt` forward, and the host
|
||||||
* because human sessions expire that fast would mean someone climbing to a ceiling-mounted screen
|
* re-issues the cookie with a fresh lifetime, so a screen that is in use stays paired until an
|
||||||
* on a schedule. What makes a long horizon safe is that it is not the security boundary: the server
|
* administrator revokes it. Revocation, not expiry, is the security boundary — the server checks it
|
||||||
* decides on every single request whether the device is still revoked, so an administrator's revoke
|
* on every single request. The window exists only so an abandoned, forgotten screen eventually stops
|
||||||
* takes effect immediately regardless of how long the credential would otherwise have lived. The
|
* being an identity at all: 180 days after it was last seen, its credential is dead.
|
||||||
* expiry exists so an abandoned, forgotten screen eventually stops being an identity at all.
|
|
||||||
*/
|
*/
|
||||||
export const DEVICE_CREDENTIAL_TTL_MS = 180 * 24 * 60 * 60_000;
|
export const DEVICE_CREDENTIAL_TTL_MS = 180 * 24 * 60 * 60_000;
|
||||||
|
|
||||||
|
/** When a device credential expires: the idle window, measured from the last time the device was
|
||||||
|
* seen, or from pairing if it never has been. */
|
||||||
|
export function deviceCredentialExpiresAt(input: {
|
||||||
|
readonly createdAt: Date;
|
||||||
|
readonly lastSeenAt?: Date | null | undefined;
|
||||||
|
}): Date {
|
||||||
|
const anchor = Math.max(input.createdAt.getTime(), input.lastSeenAt?.getTime() ?? 0);
|
||||||
|
return new Date(anchor + DEVICE_CREDENTIAL_TTL_MS);
|
||||||
|
}
|
||||||
|
|
||||||
/** How stale `lastSeenAt` may become before the resolver spends a write on it. A display polling
|
/** How stale `lastSeenAt` may become before the resolver spends a write on it. A display polling
|
||||||
* realtime and assets would otherwise turn an operational nicety into a continuous write load. */
|
* realtime and assets would otherwise turn an operational nicety into a continuous write load. */
|
||||||
export const DEVICE_LAST_SEEN_THROTTLE_MS = 15 * 60_000;
|
export const DEVICE_LAST_SEEN_THROTTLE_MS = 15 * 60_000;
|
||||||
@@ -475,7 +484,8 @@ export function createDeviceStore(db: DeviceDrizzleDatabase): DeviceStore {
|
|||||||
}
|
}
|
||||||
// The credential's own horizon, measured from when the device was paired. Revocation is
|
// The credential's own horizon, measured from when the device was paired. Revocation is
|
||||||
// checked above and is what makes a long horizon acceptable.
|
// checked above and is what makes a long horizon acceptable.
|
||||||
if (row.createdAt.getTime() + DEVICE_CREDENTIAL_TTL_MS <= now.getTime()) {
|
// Expiry is measured from last use, not from pairing, so a screen in use never ages out.
|
||||||
|
if (deviceCredentialExpiresAt(row).getTime() <= now.getTime()) {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
return Object.freeze({
|
return Object.freeze({
|
||||||
|
|||||||
@@ -22,8 +22,22 @@ const sections: readonly DoctorSection[] = Object.freeze([
|
|||||||
section("PostgreSQL", ["database.transport", "postgresql.connection", "postgresql.plan"]),
|
section("PostgreSQL", ["database.transport", "postgresql.connection", "postgresql.plan"]),
|
||||||
section("Identity", ["identity.configuration", "identity.client-address"]),
|
section("Identity", ["identity.configuration", "identity.client-address"]),
|
||||||
section("Object storage", ["object-storage.integration", "object-storage.bindings"]),
|
section("Object storage", ["object-storage.integration", "object-storage.bindings"]),
|
||||||
|
section("Outbound email", ["outbound-email.configuration"]),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every check id, exactly once.
|
||||||
|
*
|
||||||
|
* A check absent from `sections` is still computed and still counted toward the overall result, but
|
||||||
|
* is never shown — so a configuration failure would flip an operator's report to NOT READY with
|
||||||
|
* nothing on screen explaining why. `outbound-email.configuration` was in precisely that state, and
|
||||||
|
* `doctor-report.test.ts` now compares this list against the ids of a real report, so a new check
|
||||||
|
* cannot be added without being rendered.
|
||||||
|
*/
|
||||||
|
export const RENDERED_DOCTOR_CHECK_IDS: readonly DoctorCheckId[] = Object.freeze(
|
||||||
|
sections.flatMap((entry) => [...entry.checkIds]),
|
||||||
|
);
|
||||||
|
|
||||||
const statusSymbols: Readonly<Record<DoctorCheck["status"], string>> = Object.freeze({
|
const statusSymbols: Readonly<Record<DoctorCheck["status"], string>> = Object.freeze({
|
||||||
failure: "✗",
|
failure: "✗",
|
||||||
pass: "✓",
|
pass: "✓",
|
||||||
|
|||||||
+192
-39
@@ -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,
|
||||||
@@ -57,12 +57,14 @@ import {
|
|||||||
createModuleConfigurationStore,
|
createModuleConfigurationStore,
|
||||||
createModuleLifecycleDatabase,
|
createModuleLifecycleDatabase,
|
||||||
createModuleLifecycleStore,
|
createModuleLifecycleStore,
|
||||||
|
createModuleUiPublicationRegistry,
|
||||||
type DiscoveredModule,
|
type DiscoveredModule,
|
||||||
discoverModuleSchemas,
|
discoverModuleSchemas,
|
||||||
discoverModules,
|
discoverModules,
|
||||||
isConfigurationBlockedDetail,
|
isConfigurationBlockedDetail,
|
||||||
isStorageBlockedDetail,
|
isStorageBlockedDetail,
|
||||||
type ModuleConfigurationSettings,
|
type ModuleConfigurationSettings,
|
||||||
|
ModuleDiscoveryError,
|
||||||
type ModuleDiscoveryOptions,
|
type ModuleDiscoveryOptions,
|
||||||
type ModuleEligibility,
|
type ModuleEligibility,
|
||||||
type ModuleLifecycleConfiguration,
|
type ModuleLifecycleConfiguration,
|
||||||
@@ -73,9 +75,12 @@ import {
|
|||||||
} from "../modules/index.js";
|
} from "../modules/index.js";
|
||||||
import {
|
import {
|
||||||
createS3StorageProvider,
|
createS3StorageProvider,
|
||||||
|
createStorageProviderConfig,
|
||||||
describeStorageProvider,
|
describeStorageProvider,
|
||||||
physicalBucketName,
|
physicalBucketName,
|
||||||
|
StorageConfigError,
|
||||||
type StorageProviderConfig,
|
type StorageProviderConfig,
|
||||||
|
type StorageProviderConfigInput,
|
||||||
} from "../storage/index.js";
|
} from "../storage/index.js";
|
||||||
|
|
||||||
export type DoctorCheckId =
|
export type DoctorCheckId =
|
||||||
@@ -113,6 +118,7 @@ export interface DoctorReport {
|
|||||||
|
|
||||||
export interface DoctorIdentityConfigurationOptions {
|
export interface DoctorIdentityConfigurationOptions {
|
||||||
readonly baseUrl?: string | undefined;
|
readonly baseUrl?: string | undefined;
|
||||||
|
readonly dashboardOrigin?: string | undefined;
|
||||||
readonly environment?: CreateIdentityConfigOptions["environment"];
|
readonly environment?: CreateIdentityConfigOptions["environment"];
|
||||||
readonly publicSignupEnabled?: string | undefined;
|
readonly publicSignupEnabled?: string | undefined;
|
||||||
readonly secret?: string | undefined;
|
readonly secret?: string | undefined;
|
||||||
@@ -121,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;
|
||||||
@@ -129,19 +138,27 @@ export interface DoctorOptions {
|
|||||||
readonly identityConfiguration?: DoctorIdentityConfigurationOptions | undefined;
|
readonly identityConfiguration?: DoctorIdentityConfigurationOptions | undefined;
|
||||||
readonly moduleDiscovery: ModuleDiscoveryOptions;
|
readonly moduleDiscovery: ModuleDiscoveryOptions;
|
||||||
/** Where deployment-supplied module secrets come from, so Doctor's answer about a required secret
|
/** Where deployment-supplied module secrets come from, so Doctor's answer about a required secret
|
||||||
* matches what `startApi` would resolve. Omitted supplies nothing — Doctor then reports a required
|
* matches what `startApi` would resolve. Omitted supplies nothing — Doctor then reports every
|
||||||
* secret as missing, which is the honest answer for a deployment that has not set it. */
|
* required secret as missing, which is only the honest answer for a deployment that has actually
|
||||||
|
* set none, so an executable must pass the same source it would give the runtime. */
|
||||||
readonly moduleSecrets?: ModuleSecretSource;
|
readonly moduleSecrets?: ModuleSecretSource;
|
||||||
/** The validated host storage provider, or `undefined` when this installation has none. Doctor
|
/**
|
||||||
* never receives raw environment values and never prints a credential. */
|
* The host's object-storage settings as the executable read them, validated here.
|
||||||
readonly storage?: StorageProviderConfig | undefined;
|
*
|
||||||
|
* **Raw input**, for the same reason `mailConfiguration` below is raw: a half-configured provider
|
||||||
|
* is precisely the thing an operator runs Doctor to find, so Doctor has to be the one that
|
||||||
|
* validates it and reports the sanitized code. A pre-validated option meant a bad value threw
|
||||||
|
* before a report existed, and — because the executable then had to validate it itself — meant
|
||||||
|
* `pnpm doctor` simply never passed one at all, reporting configured storage as absent.
|
||||||
|
*/
|
||||||
|
readonly storageConfiguration?: StorageProviderConfigInput | undefined;
|
||||||
/**
|
/**
|
||||||
* The host's outbound mail settings as the executable read them, validated here.
|
* The host's outbound mail settings as the executable read them, validated here.
|
||||||
*
|
*
|
||||||
* Deliberately **raw input** rather than a validated config, exactly like `identityConfiguration`:
|
* Deliberately **raw input**, exactly like `identityConfiguration` and `storageConfiguration`: a
|
||||||
* a malformed SMTP setting is precisely the thing an operator runs Doctor to find, so Doctor has
|
* malformed SMTP setting is precisely the thing an operator runs Doctor to find, so Doctor has to
|
||||||
* to be the one that validates it and reports the sanitized code. A pre-validated option would
|
* be the one that validates it and reports the sanitized code. A pre-validated option would mean
|
||||||
* mean a bad value crashed the command instead of appearing in its report.
|
* a bad value crashed the command instead of appearing in its report.
|
||||||
*/
|
*/
|
||||||
readonly mailConfiguration?: MailProviderConfigInput | undefined;
|
readonly mailConfiguration?: MailProviderConfigInput | undefined;
|
||||||
readonly runtimeRange: string;
|
readonly runtimeRange: string;
|
||||||
@@ -157,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,
|
||||||
@@ -194,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(
|
||||||
@@ -260,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,
|
||||||
@@ -304,7 +312,26 @@ function publishReport(checks: readonly DoctorCheck[]): DoctorReport {
|
|||||||
return Object.freeze({ checks: frozenChecks, result: deriveDoctorResult(frozenChecks) });
|
return Object.freeze({ checks: frozenChecks, result: deriveDoctorResult(frozenChecks) });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One line per installed module, plus — for a module that declares browser contributions (ADR 0041)
|
||||||
|
* — how many of them have a compatible provider among the installed set.
|
||||||
|
*
|
||||||
|
* Worth reporting because the failure it names is otherwise silent: a contribution whose target
|
||||||
|
* capability nobody provides, or whose provider moved outside its declared range, is simply not
|
||||||
|
* published, and an operator sees a screen that renders nothing with no error anywhere. The counts
|
||||||
|
* are derived from the same publication registry the API serves from, so this can never disagree
|
||||||
|
* with what the running platform would actually publish.
|
||||||
|
*
|
||||||
|
* Static only, like every other Doctor check: it reports what the *manifests* support and never
|
||||||
|
* claims a module is running. Enablement and dependency blocking are the lifecycle check's job.
|
||||||
|
*/
|
||||||
function safeModuleDetails(modules: readonly DiscoveredModule[]): readonly string[] {
|
function safeModuleDetails(modules: readonly DiscoveredModule[]): readonly string[] {
|
||||||
|
const publication = createModuleUiPublicationRegistry(modules);
|
||||||
|
const compatible = new Set(
|
||||||
|
publication
|
||||||
|
.listContributions()
|
||||||
|
.map((contribution) => `${contribution.moduleId}\u0000${contribution.contributionId}`),
|
||||||
|
);
|
||||||
return modules.map((module) => {
|
return modules.map((module) => {
|
||||||
const id = module.manifest.id;
|
const id = module.manifest.id;
|
||||||
const version = module.manifest.version;
|
const version = module.manifest.version;
|
||||||
@@ -314,7 +341,16 @@ function safeModuleDetails(modules: readonly DiscoveredModule[]): readonly strin
|
|||||||
) {
|
) {
|
||||||
throw new TypeError("Unsafe module diagnostic value.");
|
throw new TypeError("Unsafe module diagnostic value.");
|
||||||
}
|
}
|
||||||
|
const declared = module.contributions.length;
|
||||||
|
if (declared === 0) {
|
||||||
return `${id} ${version}`;
|
return `${id} ${version}`;
|
||||||
|
}
|
||||||
|
const resolved = module.contributions.filter((contribution) =>
|
||||||
|
compatible.has(`${id}\u0000${contribution.id}`),
|
||||||
|
).length;
|
||||||
|
return `${id} ${version} · ${declared} browser contribution${
|
||||||
|
declared === 1 ? "" : "s"
|
||||||
|
} (${resolved} with a compatible provider)`;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -592,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);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -692,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) {
|
||||||
@@ -716,6 +772,35 @@ function identitySchemaSummary(): string {
|
|||||||
return `Core identity schema defines ${tableNames.length} ${plural(tableNames.length, "table")} (${tableNames.join(", ")}).`;
|
return `Core identity schema defines ${tableNames.length} ${plural(tableNames.length, "table")} (${tableNames.join(", ")}).`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A heuristic for "same site" between two canonical origins, for a warning only (ADR 0045).
|
||||||
|
*
|
||||||
|
* The same hostname on another port is the same site. Otherwise the two hostnames must be siblings:
|
||||||
|
* identical after their first label, with that parent itself holding at least one dot
|
||||||
|
* (`dash.example.org` and `api.example.org`). Without the Public Suffix List this can be wrong in
|
||||||
|
* both directions for unusual names, which is why its result is a warning and never a failure.
|
||||||
|
*/
|
||||||
|
function plausiblySameSite(dashboardOrigin: string, apiOrigin: string): boolean {
|
||||||
|
const dashboard = new URL(dashboardOrigin).hostname;
|
||||||
|
const api = new URL(apiOrigin).hostname;
|
||||||
|
if (dashboard === api) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
// An IP address is a site of its own; two different ones never share one.
|
||||||
|
const isIpAddress = (hostname: string): boolean =>
|
||||||
|
hostname.startsWith("[") || /^[0-9]+(\.[0-9]+){3}$/u.test(hostname);
|
||||||
|
if (isIpAddress(dashboard) || isIpAddress(api)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const parent = (hostname: string): string => hostname.slice(hostname.indexOf(".") + 1);
|
||||||
|
return (
|
||||||
|
dashboard.includes(".") &&
|
||||||
|
api.includes(".") &&
|
||||||
|
parent(dashboard) === parent(api) &&
|
||||||
|
parent(dashboard).includes(".")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Pure configuration validation only: never constructs Better Auth, never opens a database
|
* Pure configuration validation only: never constructs Better Auth, never opens a database
|
||||||
* connection, and never prints the configured secret, base URL, or trusted origins. Missing
|
* connection, and never prints the configured secret, base URL, or trusted origins. Missing
|
||||||
@@ -728,6 +813,7 @@ function identityConfigurationCheck(
|
|||||||
const hasAnyValue =
|
const hasAnyValue =
|
||||||
input?.secret !== undefined ||
|
input?.secret !== undefined ||
|
||||||
input?.baseUrl !== undefined ||
|
input?.baseUrl !== undefined ||
|
||||||
|
(input?.dashboardOrigin ?? "") !== "" ||
|
||||||
input?.publicSignupEnabled !== undefined ||
|
input?.publicSignupEnabled !== undefined ||
|
||||||
(input?.trustedOrigins?.length ?? 0) > 0;
|
(input?.trustedOrigins?.length ?? 0) > 0;
|
||||||
|
|
||||||
@@ -742,17 +828,43 @@ function identityConfigurationCheck(
|
|||||||
const config = createIdentityConfig(
|
const config = createIdentityConfig(
|
||||||
{
|
{
|
||||||
baseUrl: input?.baseUrl,
|
baseUrl: input?.baseUrl,
|
||||||
|
// Validated exactly as the API validates it at startup, so a Dashboard origin the API would
|
||||||
|
// refuse (untrusted, insecure, malformed) fails here first.
|
||||||
|
dashboardOrigin: input?.dashboardOrigin,
|
||||||
publicSignupEnabled: input?.publicSignupEnabled,
|
publicSignupEnabled: input?.publicSignupEnabled,
|
||||||
secret: input?.secret,
|
secret: input?.secret,
|
||||||
trustedOrigins: input?.trustedOrigins,
|
trustedOrigins: input?.trustedOrigins,
|
||||||
},
|
},
|
||||||
{ environment: input?.environment ?? "production" },
|
{ environment: input?.environment ?? "production" },
|
||||||
);
|
);
|
||||||
|
const signupDetail = config.publicSignupEnabled
|
||||||
|
? "Public signup is explicitly ENABLED."
|
||||||
|
: "Public signup is disabled (safe default).";
|
||||||
|
if (config.dashboardOrigin === config.baseUrl) {
|
||||||
return freezeCheck("identity.configuration", "pass", "Identity configuration valid", [
|
return freezeCheck("identity.configuration", "pass", "Identity configuration valid", [
|
||||||
schemaDetail,
|
schemaDetail,
|
||||||
config.publicSignupEnabled
|
signupDetail,
|
||||||
? "Public signup is explicitly ENABLED."
|
"Topology: same-origin (the Dashboard is served from the API's origin).",
|
||||||
: "Public signup is disabled (safe default).",
|
]);
|
||||||
|
}
|
||||||
|
const topologyDetail = "Topology: split (the Dashboard and the API are on separate origins).";
|
||||||
|
if (!plausiblySameSite(config.dashboardOrigin, config.baseUrl)) {
|
||||||
|
return freezeCheck(
|
||||||
|
"identity.configuration",
|
||||||
|
"warning",
|
||||||
|
"Identity configuration valid; split origins may not share a site",
|
||||||
|
[
|
||||||
|
schemaDetail,
|
||||||
|
signupDetail,
|
||||||
|
topologyDetail,
|
||||||
|
"The Dashboard and API hostnames are not siblings under one parent domain. Browsers send the session cookie across split origins only within one registrable domain (ADR 0045); Doctor cannot check the Public Suffix List, so confirm it.",
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return freezeCheck("identity.configuration", "pass", "Identity configuration valid", [
|
||||||
|
schemaDetail,
|
||||||
|
signupDetail,
|
||||||
|
topologyDetail,
|
||||||
]);
|
]);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const code =
|
const code =
|
||||||
@@ -808,17 +920,25 @@ function databaseTransportCheck(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Doctor reads only its own process's environment, so it says so: run as the reference
|
||||||
|
* deployment's `migrate`, it is given the API's values by the proxy overlay, but run any other way
|
||||||
|
* it may be checking a configuration the API is not running. */
|
||||||
|
const CLIENT_ADDRESS_SCOPE =
|
||||||
|
"Checked: the environment Doctor runs with. In the reference deployment `migrate` is given the API's client-address values; a running API's own value is shown by `docker compose exec api printenv EKKLESIA_CLIENT_ADDRESS_MODE`.";
|
||||||
|
|
||||||
function clientAddressCheck(input: ClientAddressConfigInput | undefined): DoctorCheck {
|
function clientAddressCheck(input: ClientAddressConfigInput | undefined): DoctorCheck {
|
||||||
try {
|
try {
|
||||||
const config = createClientAddressConfig(input, TRUSTED_CLIENT_ADDRESS_HEADER);
|
const config = createClientAddressConfig(input, TRUSTED_CLIENT_ADDRESS_HEADER);
|
||||||
if (config.mode === "direct") {
|
if (config.mode === "direct") {
|
||||||
return freezeCheck("identity.client-address", "pass", "Direct client-address mode", [
|
return freezeCheck("identity.client-address", "pass", "Direct client-address mode", [
|
||||||
"Trusts only the actual immediate socket peer for rate limiting; no forwarded header is trusted.",
|
"Trusts only the actual immediate socket peer for rate limiting; no forwarded header is trusted.",
|
||||||
|
CLIENT_ADDRESS_SCOPE,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
return freezeCheck("identity.client-address", "pass", "Trusted-proxy client-address mode", [
|
return freezeCheck("identity.client-address", "pass", "Trusted-proxy client-address mode", [
|
||||||
`Trusts header "${config.header}" only from a request whose immediate socket peer matches one of ${config.trustedProxies.length} configured trusted ${plural(config.trustedProxies.length, "proxy")}.`,
|
`Trusts header "${config.header}" only from a request whose immediate socket peer matches one of ${config.trustedProxies.length} configured trusted ${plural(config.trustedProxies.length, "proxy")}.`,
|
||||||
"The reverse proxy must overwrite (never append to) this header, and the origin must not be reachable except through it.",
|
"The reverse proxy must overwrite (never append to) this header, and the origin must not be reachable except through it.",
|
||||||
|
CLIENT_ADDRESS_SCOPE,
|
||||||
]);
|
]);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const code =
|
const code =
|
||||||
@@ -829,7 +949,7 @@ function clientAddressCheck(input: ClientAddressConfigInput | undefined): Doctor
|
|||||||
"identity.client-address",
|
"identity.client-address",
|
||||||
"failure",
|
"failure",
|
||||||
"Client-address configuration invalid",
|
"Client-address configuration invalid",
|
||||||
[`[${code}] Configured client-address values failed validation.`],
|
[`[${code}] Configured client-address values failed validation.`, CLIENT_ADDRESS_SCOPE],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -851,6 +971,23 @@ export async function diagnoseEkklesia(
|
|||||||
options: DoctorOptions,
|
options: DoctorOptions,
|
||||||
dependencies: DoctorDependencies = productionDependencies,
|
dependencies: DoctorDependencies = productionDependencies,
|
||||||
): Promise<DoctorReport> {
|
): Promise<DoctorReport> {
|
||||||
|
// Resolved first, before module eligibility is decided: whether a provider is configured is an
|
||||||
|
// input to whether a module that declares a bucket can run, so validating it later would mean
|
||||||
|
// deciding eligibility against a question that had not been answered yet.
|
||||||
|
const hostEnvironment =
|
||||||
|
options.databaseEnvironment === "development" ? "development" : "production";
|
||||||
|
let storageConfig: StorageProviderConfig | undefined;
|
||||||
|
let storageConfigurationError: string | undefined;
|
||||||
|
try {
|
||||||
|
storageConfig = createStorageProviderConfig(options.storageConfiguration, {
|
||||||
|
environment: hostEnvironment,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
// The sanitized code only — never the endpoint, the access key, or the secret key.
|
||||||
|
storageConfigurationError =
|
||||||
|
error instanceof StorageConfigError ? error.code : "STORAGE_CONFIGURATION_INVALID";
|
||||||
|
}
|
||||||
|
|
||||||
let runtimeCheck: DoctorCheck;
|
let runtimeCheck: DoctorCheck;
|
||||||
try {
|
try {
|
||||||
runtimeCheck = dependencies.runtimeSatisfies(options.runtimeVersion, options.runtimeRange)
|
runtimeCheck = dependencies.runtimeSatisfies(options.runtimeVersion, options.runtimeRange)
|
||||||
@@ -960,7 +1097,7 @@ export async function diagnoseEkklesia(
|
|||||||
discoveredModules,
|
discoveredModules,
|
||||||
configuration,
|
configuration,
|
||||||
{ secrets: options.moduleSecrets ?? EMPTY_MODULE_SECRET_SOURCE, settings },
|
{ secrets: options.moduleSecrets ?? EMPTY_MODULE_SECRET_SOURCE, settings },
|
||||||
{ providerConfigured: options.storage !== undefined },
|
{ providerConfigured: storageConfig !== undefined },
|
||||||
);
|
);
|
||||||
eligibilityEntries = resolution.entries;
|
eligibilityEntries = resolution.entries;
|
||||||
lifecycleCheck = moduleLifecycleCheck(resolution.entries);
|
lifecycleCheck = moduleLifecycleCheck(resolution.entries);
|
||||||
@@ -1010,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),
|
||||||
);
|
);
|
||||||
@@ -1075,7 +1215,20 @@ export async function diagnoseEkklesia(
|
|||||||
// whether modules run. Conflating them would let a momentary network blip read as a module
|
// whether modules run. Conflating them would let a momentary network blip read as a module
|
||||||
// problem (ADR 0030).
|
// problem (ADR 0030).
|
||||||
let storageCheck: DoctorCheck;
|
let storageCheck: DoctorCheck;
|
||||||
if (!options.storage) {
|
if (storageConfigurationError !== undefined) {
|
||||||
|
// A failure, unlike an unreachable provider: this is not a network condition an operator can
|
||||||
|
// wait out. `startApi` refuses to start on exactly this input, so a deployment carrying it is
|
||||||
|
// genuinely not ready, and saying so is what stops the operator from discovering it at boot.
|
||||||
|
storageCheck = freezeCheck(
|
||||||
|
"object-storage.integration",
|
||||||
|
"failure",
|
||||||
|
"Object storage configuration invalid",
|
||||||
|
[
|
||||||
|
`The object-storage settings were rejected: ${storageConfigurationError}.`,
|
||||||
|
"Nothing was connected to and no credential is shown. Correct the S3_* values and run Doctor again.",
|
||||||
|
],
|
||||||
|
);
|
||||||
|
} else if (!storageConfig) {
|
||||||
storageCheck = freezeCheck(
|
storageCheck = freezeCheck(
|
||||||
"object-storage.integration",
|
"object-storage.integration",
|
||||||
"skipped",
|
"skipped",
|
||||||
@@ -1084,14 +1237,14 @@ export async function diagnoseEkklesia(
|
|||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
try {
|
try {
|
||||||
await dependencies.probeStorage(options.storage);
|
await dependencies.probeStorage(storageConfig);
|
||||||
storageCheck = freezeCheck("object-storage.integration", "pass", "Object storage reachable", [
|
storageCheck = freezeCheck("object-storage.integration", "pass", "Object storage reachable", [
|
||||||
describeStorageProvider(options.storage),
|
describeStorageProvider(storageConfig),
|
||||||
// Said plainly rather than implied: this probe used the endpoint Core talks to, and a
|
// Said plainly rather than implied: this probe used the endpoint Core talks to, and a
|
||||||
// separate browser-facing origin is by definition something a browser reaches and this
|
// separate browser-facing origin is by definition something a browser reaches and this
|
||||||
// process may not. Reporting "reachable" without that caveat would let an operator conclude
|
// process may not. Reporting "reachable" without that caveat would let an operator conclude
|
||||||
// direct delivery works when only half of it was tested.
|
// direct delivery works when only half of it was tested.
|
||||||
...(options.storage.deliveryEndpoint === options.storage.endpoint
|
...(storageConfig.deliveryEndpoint === storageConfig.endpoint
|
||||||
? []
|
? []
|
||||||
: [
|
: [
|
||||||
"The browser delivery origin is configured separately and was not probed; verify it from a browser. Any reverse proxy in front of it must pass the original Host header through unchanged, or signed read URLs will be rejected.",
|
"The browser delivery origin is configured separately and was not probed; verify it from a browser. Any reverse proxy in front of it must pass the original Host header through unchanged, or signed read URLs will be rejected.",
|
||||||
@@ -1106,7 +1259,7 @@ export async function diagnoseEkklesia(
|
|||||||
"warning",
|
"warning",
|
||||||
"Object storage unreachable",
|
"Object storage unreachable",
|
||||||
[
|
[
|
||||||
describeStorageProvider(options.storage),
|
describeStorageProvider(storageConfig),
|
||||||
"The configured storage service did not respond. Modules already running are unaffected.",
|
"The configured storage service did not respond. Modules already running are unaffected.",
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
@@ -1114,7 +1267,7 @@ export async function diagnoseEkklesia(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const storageBindings = discoveredModules
|
const storageBindings = discoveredModules
|
||||||
? storageBindingsCheck(discoveredModules, eligibilityEntries, options.storage)
|
? storageBindingsCheck(discoveredModules, eligibilityEntries, storageConfig)
|
||||||
: freezeCheck("object-storage.bindings", "skipped", "Storage bindings skipped", [
|
: freezeCheck("object-storage.bindings", "skipped", "Storage bindings skipped", [
|
||||||
"Complete module discovery is required.",
|
"Complete module discovery is required.",
|
||||||
]);
|
]);
|
||||||
@@ -1128,7 +1281,7 @@ export async function diagnoseEkklesia(
|
|||||||
let mailConfigurationError: string | undefined;
|
let mailConfigurationError: string | undefined;
|
||||||
try {
|
try {
|
||||||
mailConfig = createMailProviderConfig(options.mailConfiguration, {
|
mailConfig = createMailProviderConfig(options.mailConfiguration, {
|
||||||
environment: options.databaseEnvironment === "development" ? "development" : "production",
|
environment: hostEnvironment,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// The sanitized code only — never the host, the username, or the password.
|
// The sanitized code only — never the host, the username, or the password.
|
||||||
@@ -1152,7 +1305,7 @@ export async function diagnoseEkklesia(
|
|||||||
"skipped",
|
"skipped",
|
||||||
"Outbound email not configured",
|
"Outbound email not configured",
|
||||||
[
|
[
|
||||||
"No EKKLESIA_SMTP_HOST is set. This is a complete deployment: nothing in the platform sends email yet.",
|
"No EKKLESIA_SMTP_HOST is set. The platform runs and the bootstrap owner can sign in, but email verification and account recovery cannot deliver.",
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -0,0 +1,167 @@
|
|||||||
|
import type { ModuleSecretSource } from "@ekklesia/contracts";
|
||||||
|
import type { DatabaseTransportPolicyInput } from "../database/index.js";
|
||||||
|
import type { ClientAddressConfigInput } from "../identity/index.js";
|
||||||
|
import type { MailProviderConfigInput } from "../mail/index.js";
|
||||||
|
import { createEnvironmentModuleSecretSource } from "../modules/index.js";
|
||||||
|
import type { StorageProviderConfigInput } from "../storage/index.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A deployment's environment, passed **as an argument**.
|
||||||
|
*
|
||||||
|
* `process.env` satisfies this, but nothing here reads it: the repository rule that only
|
||||||
|
* executables touch the process environment is preserved exactly as
|
||||||
|
* `createEnvironmentModuleSecretSource` preserves it, and it is what makes every variable this
|
||||||
|
* module understands testable without mutating global state.
|
||||||
|
*/
|
||||||
|
export type HostEnvironment = Readonly<Record<string, string | undefined>>;
|
||||||
|
|
||||||
|
export type HostEnvironmentName = "development" | "production";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The identity settings as they appear in the environment — strings, unvalidated.
|
||||||
|
*
|
||||||
|
* Typed as `string | undefined` rather than `unknown` so this one object can be handed to both
|
||||||
|
* `createIdentityConfig` (which validates it) and Doctor's `identityConfiguration` (which validates
|
||||||
|
* it and reports the sanitized code) without either side re-deriving it.
|
||||||
|
*/
|
||||||
|
export interface HostIdentityEnvironmentInput {
|
||||||
|
readonly baseUrl: string | undefined;
|
||||||
|
/** Where an emailed verification or reset link sends a browser. Unset means the Dashboard is
|
||||||
|
* served from `baseUrl`, which is the ordinary same-origin production shape (ADR 0038). */
|
||||||
|
readonly dashboardOrigin: string | undefined;
|
||||||
|
readonly publicSignupEnabled: string | undefined;
|
||||||
|
readonly secret: string | undefined;
|
||||||
|
readonly trustedOrigins: readonly string[] | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every host infrastructure input an Ekklesia executable resolves from its environment.
|
||||||
|
*
|
||||||
|
* Deliberately **unvalidated**: each consumer runs the same Core validator over these values, but
|
||||||
|
* they do so at different moments and want different failure behaviour. `startApi` wants a
|
||||||
|
* malformed value to abort startup; Doctor wants it reported as a sanitized code in a check. If this
|
||||||
|
* type were pre-validated, Doctor could never report a configuration error — the load would have
|
||||||
|
* thrown before a report existed.
|
||||||
|
*
|
||||||
|
* The API listener's own `host`/`port` are deliberately absent: they describe the API process, not
|
||||||
|
* the host infrastructure a diagnostic evaluates, and Doctor has no use for them.
|
||||||
|
*/
|
||||||
|
export interface HostConfiguration {
|
||||||
|
/** `"development"` only when `NODE_ENV` is exactly that string, so an unset or misspelled value
|
||||||
|
* validates as production and never silently relaxes a security rule. */
|
||||||
|
readonly environment: HostEnvironmentName;
|
||||||
|
/** `undefined` when no mode is set at all, which selects direct mode. */
|
||||||
|
readonly clientAddress: ClientAddressConfigInput | undefined;
|
||||||
|
readonly databaseTransport: DatabaseTransportPolicyInput;
|
||||||
|
readonly databaseUrl: string | undefined;
|
||||||
|
readonly identity: HostIdentityEnvironmentInput;
|
||||||
|
readonly mail: MailProviderConfigInput;
|
||||||
|
/** The deployment's module secrets, resolvable only under names Core derives from a module's own
|
||||||
|
* declared keys — there is no arbitrary variable lookup. */
|
||||||
|
readonly moduleSecrets: ModuleSecretSource;
|
||||||
|
/** `EKKLESIA_MODULES_DIR`, raw and unvalidated: `undefined` only when the variable is unset. Every
|
||||||
|
* executable passes it to `resolveModulesDirectory`, the one place it is interpreted (ADR 0048),
|
||||||
|
* so a relative or blank value is refused there rather than repaired here. */
|
||||||
|
readonly modulesDirectory: string | undefined;
|
||||||
|
readonly storage: StorageProviderConfigInput;
|
||||||
|
}
|
||||||
|
|
||||||
|
function splitList(value: string | undefined): readonly string[] | undefined {
|
||||||
|
if (value === undefined) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
.split(",")
|
||||||
|
.map((entry) => entry.trim())
|
||||||
|
.filter((entry) => entry.length > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Omitted entirely when no mode is set: reading `header`/`trustedProxies` only when `mode` is
|
||||||
|
* actually present keeps an operator from half-configuring proxy mode by setting one of three
|
||||||
|
* variables. */
|
||||||
|
function readClientAddress(environment: HostEnvironment): ClientAddressConfigInput | undefined {
|
||||||
|
const mode = environment.EKKLESIA_CLIENT_ADDRESS_MODE;
|
||||||
|
if (mode === undefined) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
header: environment.EKKLESIA_CLIENT_ADDRESS_HEADER,
|
||||||
|
mode,
|
||||||
|
trustedProxies: splitList(environment.EKKLESIA_CLIENT_ADDRESS_TRUSTED_PROXIES),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The one place any Ekklesia executable turns a deployment environment into host infrastructure
|
||||||
|
* configuration.
|
||||||
|
*
|
||||||
|
* It exists because the alternative was demonstrably wrong: `apps/api` and `pnpm doctor` each read
|
||||||
|
* their own subset of the same variables, and the subsets drifted. Doctor never read `S3_*` at all,
|
||||||
|
* so a correctly configured installation was told its object storage was "not configured" and every
|
||||||
|
* module requiring a bucket was reported blocked — while `startApi`, from the identical environment,
|
||||||
|
* bound those buckets and ran the modules. A diagnostic that disagrees with the runtime it
|
||||||
|
* diagnoses is worse than no diagnostic.
|
||||||
|
*
|
||||||
|
* So there is now exactly one mapping from variable names to typed inputs, and both executables call
|
||||||
|
* it. Validation stays where it already lives — `createIdentityConfig`,
|
||||||
|
* `createDatabaseTransportPolicy`, `createStorageProviderConfig`, `createMailProviderConfig` — and
|
||||||
|
* is deliberately *not* performed here, so each caller keeps its own failure behaviour.
|
||||||
|
*
|
||||||
|
* Nothing is read from `process.env`, and nothing here logs, prints, or serializes: the returned
|
||||||
|
* object carries the identity secret, both storage credentials, and the SMTP password.
|
||||||
|
*/
|
||||||
|
export function loadHostConfiguration(environment: HostEnvironment): HostConfiguration {
|
||||||
|
return Object.freeze({
|
||||||
|
clientAddress: readClientAddress(environment),
|
||||||
|
databaseTransport: {
|
||||||
|
...(environment.EKKLESIA_DATABASE_CA === undefined
|
||||||
|
? {}
|
||||||
|
: { certificateAuthority: environment.EKKLESIA_DATABASE_CA }),
|
||||||
|
...(environment.EKKLESIA_DATABASE_TLS_MODE === undefined
|
||||||
|
? {}
|
||||||
|
: { mode: environment.EKKLESIA_DATABASE_TLS_MODE }),
|
||||||
|
},
|
||||||
|
databaseUrl: environment.DATABASE_URL,
|
||||||
|
environment: environment.NODE_ENV === "development" ? "development" : "production",
|
||||||
|
identity: {
|
||||||
|
baseUrl: environment.EKKLESIA_IDENTITY_BASE_URL,
|
||||||
|
dashboardOrigin: environment.EKKLESIA_DASHBOARD_ORIGIN,
|
||||||
|
publicSignupEnabled: environment.EKKLESIA_PUBLIC_SIGNUP_ENABLED,
|
||||||
|
secret: environment.EKKLESIA_IDENTITY_SECRET,
|
||||||
|
trustedOrigins: splitList(environment.EKKLESIA_IDENTITY_TRUSTED_ORIGINS),
|
||||||
|
},
|
||||||
|
// Names follow the platform's own `EKKLESIA_<DOMAIN>_<FIELD>` convention rather than a
|
||||||
|
// provider's. Absent host means this installation sends no outbound email, which is a complete
|
||||||
|
// deployment (ADR 0037); username and password are optional as a pair, because a relay
|
||||||
|
// authorized by source IP has neither.
|
||||||
|
mail: {
|
||||||
|
fromAddress: environment.EKKLESIA_SMTP_FROM_ADDRESS,
|
||||||
|
fromName: environment.EKKLESIA_SMTP_FROM_NAME,
|
||||||
|
// The name this client announces in `EHLO`. Optional, and unset is the ordinary case: it
|
||||||
|
// matters only where the machine's own hostname is not a fully qualified name the relay will
|
||||||
|
// accept.
|
||||||
|
heloName: environment.EKKLESIA_SMTP_HELO_NAME,
|
||||||
|
host: environment.EKKLESIA_SMTP_HOST,
|
||||||
|
password: environment.EKKLESIA_SMTP_PASSWORD,
|
||||||
|
port: environment.EKKLESIA_SMTP_PORT,
|
||||||
|
security: environment.EKKLESIA_SMTP_SECURITY,
|
||||||
|
username: environment.EKKLESIA_SMTP_USERNAME,
|
||||||
|
},
|
||||||
|
moduleSecrets: createEnvironmentModuleSecretSource(environment),
|
||||||
|
modulesDirectory: environment.EKKLESIA_MODULES_DIR,
|
||||||
|
// The `S3_*` names ADR 0004 already established for this repository's Compose stack. Absent
|
||||||
|
// endpoint means this installation has no object storage, which is a complete deployment
|
||||||
|
// (ADR 0030).
|
||||||
|
storage: {
|
||||||
|
accessKey: environment.S3_ACCESS_KEY,
|
||||||
|
bucketPrefix: environment.S3_BUCKET_PREFIX,
|
||||||
|
// Optional, and unset is the ordinary case: it matters only where the name Core reaches
|
||||||
|
// storage by is not a name a browser could resolve.
|
||||||
|
deliveryEndpoint: environment.S3_DELIVERY_ENDPOINT,
|
||||||
|
endpoint: environment.S3_ENDPOINT,
|
||||||
|
forcePathStyle: environment.S3_FORCE_PATH_STYLE,
|
||||||
|
region: environment.S3_REGION,
|
||||||
|
secretKey: environment.S3_SECRET_KEY,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
export {
|
||||||
|
type HostConfiguration,
|
||||||
|
type HostEnvironment,
|
||||||
|
type HostEnvironmentName,
|
||||||
|
type HostIdentityEnvironmentInput,
|
||||||
|
loadHostConfiguration,
|
||||||
|
} from "./configuration.js";
|
||||||
@@ -5,6 +5,10 @@ export type IdentityConfigErrorCode =
|
|||||||
| "BASE_URL_INVALID"
|
| "BASE_URL_INVALID"
|
||||||
| "BASE_URL_LOOPBACK_IN_PRODUCTION"
|
| "BASE_URL_LOOPBACK_IN_PRODUCTION"
|
||||||
| "BASE_URL_MISSING"
|
| "BASE_URL_MISSING"
|
||||||
|
| "DASHBOARD_ORIGIN_INSECURE_IN_PRODUCTION"
|
||||||
|
| "DASHBOARD_ORIGIN_INVALID"
|
||||||
|
| "DASHBOARD_ORIGIN_LOOPBACK_IN_PRODUCTION"
|
||||||
|
| "DASHBOARD_ORIGIN_UNTRUSTED"
|
||||||
| "PUBLIC_SIGNUP_ENABLED_INVALID"
|
| "PUBLIC_SIGNUP_ENABLED_INVALID"
|
||||||
| "PUBLIC_SIGNUP_UNSUPPORTED_IN_PRODUCTION"
|
| "PUBLIC_SIGNUP_UNSUPPORTED_IN_PRODUCTION"
|
||||||
| "SECRET_LOW_ENTROPY"
|
| "SECRET_LOW_ENTROPY"
|
||||||
@@ -127,6 +131,13 @@ const errorMessages: Readonly<Record<IdentityConfigErrorCode, string>> = Object.
|
|||||||
BASE_URL_LOOPBACK_IN_PRODUCTION:
|
BASE_URL_LOOPBACK_IN_PRODUCTION:
|
||||||
"The identity base URL must not resolve to a loopback host in production.",
|
"The identity base URL must not resolve to a loopback host in production.",
|
||||||
BASE_URL_MISSING: "The identity base URL is required.",
|
BASE_URL_MISSING: "The identity base URL is required.",
|
||||||
|
DASHBOARD_ORIGIN_INSECURE_IN_PRODUCTION: "The Dashboard origin must use https in production.",
|
||||||
|
DASHBOARD_ORIGIN_INVALID:
|
||||||
|
"The Dashboard origin must be an absolute http or https origin with no path, query, or fragment.",
|
||||||
|
DASHBOARD_ORIGIN_LOOPBACK_IN_PRODUCTION:
|
||||||
|
"The Dashboard origin must not resolve to a loopback host in production.",
|
||||||
|
DASHBOARD_ORIGIN_UNTRUSTED:
|
||||||
|
"The Dashboard origin must also be listed as a trusted origin, or emailed links will be refused.",
|
||||||
PUBLIC_SIGNUP_ENABLED_INVALID:
|
PUBLIC_SIGNUP_ENABLED_INVALID:
|
||||||
'Public signup enablement must be an explicit boolean or the exact string "true"/"false".',
|
'Public signup enablement must be an explicit boolean or the exact string "true"/"false".',
|
||||||
PUBLIC_SIGNUP_UNSUPPORTED_IN_PRODUCTION:
|
PUBLIC_SIGNUP_UNSUPPORTED_IN_PRODUCTION:
|
||||||
@@ -163,6 +174,21 @@ export class IdentityConfigError extends Error {
|
|||||||
|
|
||||||
export interface IdentityConfig {
|
export interface IdentityConfig {
|
||||||
readonly baseUrl: string;
|
readonly baseUrl: string;
|
||||||
|
/**
|
||||||
|
* The browser origin an emailed verification or password-reset link sends a person to.
|
||||||
|
*
|
||||||
|
* Defaults to `baseUrl`, which is the ordinary production shape: the Dashboard bundle and the API
|
||||||
|
* are served from one origin. It exists as its own value because they are not the same *concept* —
|
||||||
|
* `baseUrl` is where the `/api/auth` transport lives, this is where a human's browser lands — and
|
||||||
|
* a deployment may separate them (an API at `https://api.example.org`, the Dashboard at
|
||||||
|
* `https://dash.example.org`). It is deliberately **not** the public church website origin: this
|
||||||
|
* is the authenticated Dashboard host and nothing else.
|
||||||
|
*
|
||||||
|
* Validated to be one of `trustedOrigins` (or `baseUrl`, which Better Auth trusts implicitly), so
|
||||||
|
* a misconfiguration fails at startup rather than producing emailed links every recipient is
|
||||||
|
* refused at.
|
||||||
|
*/
|
||||||
|
readonly dashboardOrigin: string;
|
||||||
/** Whether `POST /api/auth/sign-up/email` accepts new accounts. Defaults to `false` (disabled) —
|
/** Whether `POST /api/auth/sign-up/email` accepts new accounts. Defaults to `false` (disabled) —
|
||||||
* public signup never becomes reachable merely because this input was omitted, misspelled, or
|
* public signup never becomes reachable merely because this input was omitted, misspelled, or
|
||||||
* loosely parsed. Development and test composition may enable it explicitly; production
|
* loosely parsed. Development and test composition may enable it explicitly; production
|
||||||
@@ -176,6 +202,8 @@ export interface IdentityConfig {
|
|||||||
|
|
||||||
export interface IdentityConfigInput {
|
export interface IdentityConfigInput {
|
||||||
readonly baseUrl?: unknown;
|
readonly baseUrl?: unknown;
|
||||||
|
/** Optional. Absent or empty means the Dashboard is served from `baseUrl`. */
|
||||||
|
readonly dashboardOrigin?: unknown;
|
||||||
readonly publicSignupEnabled?: unknown;
|
readonly publicSignupEnabled?: unknown;
|
||||||
readonly secret?: unknown;
|
readonly secret?: unknown;
|
||||||
readonly trustedOrigins?: unknown;
|
readonly trustedOrigins?: unknown;
|
||||||
@@ -261,6 +289,45 @@ function validateBaseUrl(baseUrl: unknown, environment: IdentityEnvironment): st
|
|||||||
return url.origin;
|
return url.origin;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves the origin an emailed link points a browser at.
|
||||||
|
*
|
||||||
|
* Unset falls back to the identity base URL — the same-origin production shape — rather than
|
||||||
|
* failing, because a deployment that has not separated its Dashboard from its API has nothing to
|
||||||
|
* configure. When it *is* set it is held to exactly the base URL's rules and then to one more: it
|
||||||
|
* must be an origin Better Auth already trusts, because Better Auth validates the emailed callback
|
||||||
|
* against `trustedOrigins` before redirecting. Checking it here converts a broken link every
|
||||||
|
* recipient would hit into a startup failure the operator sees once.
|
||||||
|
*/
|
||||||
|
function validateDashboardOrigin(
|
||||||
|
dashboardOrigin: unknown,
|
||||||
|
baseUrl: string,
|
||||||
|
trustedOrigins: readonly string[],
|
||||||
|
environment: IdentityEnvironment,
|
||||||
|
): string {
|
||||||
|
if (dashboardOrigin === undefined || dashboardOrigin === null || dashboardOrigin === "") {
|
||||||
|
return baseUrl;
|
||||||
|
}
|
||||||
|
const url = parseCanonicalHttpOrigin(dashboardOrigin);
|
||||||
|
if (!url) {
|
||||||
|
throw new IdentityConfigError("DASHBOARD_ORIGIN_INVALID");
|
||||||
|
}
|
||||||
|
if (environment === "production") {
|
||||||
|
if (url.protocol !== "https:") {
|
||||||
|
throw new IdentityConfigError("DASHBOARD_ORIGIN_INSECURE_IN_PRODUCTION");
|
||||||
|
}
|
||||||
|
if (isLoopbackOrUnspecifiedHostname(url.hostname)) {
|
||||||
|
throw new IdentityConfigError("DASHBOARD_ORIGIN_LOOPBACK_IN_PRODUCTION");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// `baseUrl` is accepted without appearing in `trustedOrigins` because Better Auth itself adds the
|
||||||
|
// base URL's origin to its trusted set (`getTrustedOrigins`, pinned release).
|
||||||
|
if (url.origin !== baseUrl && !trustedOrigins.includes(url.origin)) {
|
||||||
|
throw new IdentityConfigError("DASHBOARD_ORIGIN_UNTRUSTED");
|
||||||
|
}
|
||||||
|
return url.origin;
|
||||||
|
}
|
||||||
|
|
||||||
function validateTrustedOrigin(origin: unknown, environment: IdentityEnvironment): string {
|
function validateTrustedOrigin(origin: unknown, environment: IdentityEnvironment): string {
|
||||||
if (typeof origin !== "string" || origin.length === 0) {
|
if (typeof origin !== "string" || origin.length === 0) {
|
||||||
throw new IdentityConfigError("TRUSTED_ORIGIN_INVALID");
|
throw new IdentityConfigError("TRUSTED_ORIGIN_INVALID");
|
||||||
@@ -355,7 +422,19 @@ export function createIdentityConfig(
|
|||||||
const secret = validateSecret(input.secret);
|
const secret = validateSecret(input.secret);
|
||||||
const baseUrl = validateBaseUrl(input.baseUrl, environment);
|
const baseUrl = validateBaseUrl(input.baseUrl, environment);
|
||||||
const trustedOrigins = validateTrustedOrigins(input.trustedOrigins, environment);
|
const trustedOrigins = validateTrustedOrigins(input.trustedOrigins, environment);
|
||||||
|
const dashboardOrigin = validateDashboardOrigin(
|
||||||
|
input.dashboardOrigin,
|
||||||
|
baseUrl,
|
||||||
|
trustedOrigins,
|
||||||
|
environment,
|
||||||
|
);
|
||||||
const publicSignupEnabled = validatePublicSignupEnabled(input.publicSignupEnabled, environment);
|
const publicSignupEnabled = validatePublicSignupEnabled(input.publicSignupEnabled, environment);
|
||||||
|
|
||||||
return Object.freeze({ baseUrl, publicSignupEnabled, secret, trustedOrigins });
|
return Object.freeze({
|
||||||
|
baseUrl,
|
||||||
|
dashboardOrigin,
|
||||||
|
publicSignupEnabled,
|
||||||
|
secret,
|
||||||
|
trustedOrigins,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,4 +2,5 @@ export * from "./bootstrap-identity.js";
|
|||||||
export * from "./client-address.js";
|
export * from "./client-address.js";
|
||||||
export * from "./config.js";
|
export * from "./config.js";
|
||||||
export * from "./database.js";
|
export * from "./database.js";
|
||||||
|
export * from "./notifications.js";
|
||||||
export * from "./runtime.js";
|
export * from "./runtime.js";
|
||||||
|
|||||||
@@ -0,0 +1,291 @@
|
|||||||
|
import { MailError, type MailProvider, validateMailMessage } from "../mail/index.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The identity feature's outbound messages, and the seam between Better Auth's callbacks and the
|
||||||
|
* host's mail transport (ADR 0038).
|
||||||
|
*
|
||||||
|
* ## What this owns and what it deliberately does not
|
||||||
|
*
|
||||||
|
* It owns the recipient, the subject, and the two bodies. It does **not** know the SMTP host, the
|
||||||
|
* port, the TLS mode, a credential, or the sender: those are host configuration held by the mail
|
||||||
|
* provider, and there is no field on the message contract through which a caller could choose any
|
||||||
|
* of them (ADR 0037). Swapping SMTP for a provider HTTP API would not change a line of this file.
|
||||||
|
*
|
||||||
|
* It also does not own tokens. Better Auth mints, persists (for reset), expires, and consumes them;
|
||||||
|
* this receives one already-minted token and puts it in a link. There is no Ekklesia token table,
|
||||||
|
* no Ekklesia token hashing, and no Ekklesia expiry clock — a second implementation of any of those
|
||||||
|
* would be a second thing to get wrong.
|
||||||
|
*
|
||||||
|
* ## Why the link is rebuilt rather than used as given
|
||||||
|
*
|
||||||
|
* Better Auth hands these callbacks a prebuilt `url`, and that URL embeds whatever `callbackURL` or
|
||||||
|
* `redirectTo` the *requesting client* supplied. Better Auth does validate those against its trusted
|
||||||
|
* origins, and that validation is sound — but an emailed link is the one artifact a person is most
|
||||||
|
* likely to click without looking, so its destination is derived here from validated configuration
|
||||||
|
* alone and from nothing a request could influence. The token is the only value taken from Better
|
||||||
|
* Auth. No request parameter can steer where a recipient lands, independently of whether the origin
|
||||||
|
* check behaves as expected.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** What could not be delivered, for an operator — never who it was for, and never the link. */
|
||||||
|
export type IdentityNotificationKind = "password-reset" | "email-verification";
|
||||||
|
|
||||||
|
export interface IdentityNotificationFailure {
|
||||||
|
readonly kind: IdentityNotificationKind;
|
||||||
|
/** A sanitized `MailError` code, or `MAIL_SEND_FAILED` for anything unrecognized. */
|
||||||
|
readonly reason: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where a delivery failure is reported.
|
||||||
|
*
|
||||||
|
* It exists because these callbacks must **never throw**. Better Auth's own
|
||||||
|
* `/send-verification-email` rethrows whatever the callback threw, and a thrown error there turns
|
||||||
|
* "this address has an unverified account" into a distinguishable response — an account-enumeration
|
||||||
|
* oracle that appears the moment SMTP breaks. So failure is swallowed at this boundary and surfaced
|
||||||
|
* out of band instead, where no HTTP response shape depends on it.
|
||||||
|
*
|
||||||
|
* The default implementation writes one fixed line per failure. It carries the flow and a sanitized
|
||||||
|
* code and nothing else: no address, no token, no URL, no provider text.
|
||||||
|
*/
|
||||||
|
export type IdentityNotificationReporter = (failure: IdentityNotificationFailure) => void;
|
||||||
|
|
||||||
|
export interface IdentityNotificationRecipient {
|
||||||
|
readonly email: string;
|
||||||
|
readonly name?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateIdentityNotificationsOptions {
|
||||||
|
/**
|
||||||
|
* The host mail provider, or `undefined` when this installation has none.
|
||||||
|
*
|
||||||
|
* `undefined` is a complete deployment (ADR 0037): the platform still runs, the initial owner
|
||||||
|
* still signs in, and only verification and recovery are unavailable — reported as
|
||||||
|
* `MAIL_NOT_CONFIGURED` through the reporter, never as a different HTTP response.
|
||||||
|
*/
|
||||||
|
readonly mail: MailProvider | undefined;
|
||||||
|
/** Where the `/api/auth` transport lives — the origin Better Auth's own endpoints are on. */
|
||||||
|
readonly authBaseUrl: string;
|
||||||
|
/** Where a human's browser lands. Validated to be a trusted origin (`./config.js`). */
|
||||||
|
readonly dashboardOrigin: string;
|
||||||
|
/** Defaults to a single fixed sanitized console line. */
|
||||||
|
readonly report?: IdentityNotificationReporter;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IdentityNotifications {
|
||||||
|
/** Shaped for Better Auth's `emailVerification.sendVerificationEmail`. Never throws. */
|
||||||
|
readonly sendVerificationEmail: (data: {
|
||||||
|
readonly user: IdentityNotificationRecipient;
|
||||||
|
readonly token: string;
|
||||||
|
}) => Promise<void>;
|
||||||
|
/** Shaped for Better Auth's `emailAndPassword.sendResetPassword`. Never throws. */
|
||||||
|
readonly sendResetPassword: (data: {
|
||||||
|
readonly user: IdentityNotificationRecipient;
|
||||||
|
readonly token: string;
|
||||||
|
}) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The two query values the Dashboard bundle reads to decide which host-owned auth screen to show.
|
||||||
|
*
|
||||||
|
* They are query parameters on the Dashboard's root path rather than distinct paths deliberately:
|
||||||
|
* the shell is a static single-page bundle, and a deep path would require every deployment's static
|
||||||
|
* host to rewrite unknown paths to `index.html` before an emailed link worked at all. A query
|
||||||
|
* parameter works on any static host, including the repository's own development server.
|
||||||
|
*/
|
||||||
|
export const DASHBOARD_AUTH_LANDING_PARAMETER = "ekklesia_auth";
|
||||||
|
export const DASHBOARD_VERIFY_EMAIL_LANDING = "verify-email";
|
||||||
|
export const DASHBOARD_RESET_PASSWORD_LANDING = "reset-password";
|
||||||
|
|
||||||
|
function landingUrl(dashboardOrigin: string, landing: string): string {
|
||||||
|
const url = new URL(dashboardOrigin);
|
||||||
|
url.searchParams.set(DASHBOARD_AUTH_LANDING_PARAMETER, landing);
|
||||||
|
return url.href;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Better Auth's own verification URL, rebuilt from configuration.
|
||||||
|
*
|
||||||
|
* The shape is the pinned release's protocol exactly — `GET {authBase}/api/auth/verify-email?
|
||||||
|
* token=...&callbackURL=...` — so Better Auth remains the only thing that inspects, expires, or acts
|
||||||
|
* on the token. Only the destination is ours.
|
||||||
|
*/
|
||||||
|
function verificationUrl(options: CreateIdentityNotificationsOptions, token: string): string {
|
||||||
|
const callback = landingUrl(options.dashboardOrigin, DASHBOARD_VERIFY_EMAIL_LANDING);
|
||||||
|
return `${options.authBaseUrl}/api/auth/verify-email?token=${encodeURIComponent(
|
||||||
|
token,
|
||||||
|
)}&callbackURL=${encodeURIComponent(callback)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Better Auth's reset callback, rebuilt the same way. It validates the token, then redirects the
|
||||||
|
* browser to the Dashboard landing with the token attached. */
|
||||||
|
function resetUrl(options: CreateIdentityNotificationsOptions, token: string): string {
|
||||||
|
const callback = landingUrl(options.dashboardOrigin, DASHBOARD_RESET_PASSWORD_LANDING);
|
||||||
|
return `${options.authBaseUrl}/api/auth/reset-password/${encodeURIComponent(
|
||||||
|
token,
|
||||||
|
)}?callbackURL=${encodeURIComponent(callback)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Escapes the four characters that could otherwise close an element or attribute in the HTML body.
|
||||||
|
* Only the link and the greeting name are interpolated, and the name comes from the user record. */
|
||||||
|
function escapeHtml(value: string): string {
|
||||||
|
return value
|
||||||
|
.replaceAll("&", "&")
|
||||||
|
.replaceAll("<", "<")
|
||||||
|
.replaceAll(">", ">")
|
||||||
|
.replaceAll('"', """);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The same rule `validateMailMessage` applies, expressed the same way it expresses it: a code-point
|
||||||
|
* scan rather than a regular expression, because a regex over a control-character range is itself
|
||||||
|
* something a linter (rightly) objects to.
|
||||||
|
*/
|
||||||
|
function hasControlCharacter(value: string): boolean {
|
||||||
|
for (const character of value) {
|
||||||
|
const codePoint = character.codePointAt(0);
|
||||||
|
if (codePoint === undefined || codePoint <= 0x1f || (codePoint >= 0x7f && codePoint <= 0x9f)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A greeting that is safe to place in a message body.
|
||||||
|
*
|
||||||
|
* A stored display name is not trusted to be free of control characters — the message validator
|
||||||
|
* would reject the whole message for one stray carriage return, which would turn a cosmetic problem
|
||||||
|
* into an undeliverable password reset. So an unusable name simply falls back to no greeting.
|
||||||
|
*/
|
||||||
|
function greeting(name: string | undefined): string {
|
||||||
|
if (typeof name !== "string") {
|
||||||
|
return "Hello,";
|
||||||
|
}
|
||||||
|
const trimmed = name.trim();
|
||||||
|
if (trimmed.length === 0 || trimmed.length > 64 || hasControlCharacter(trimmed)) {
|
||||||
|
return "Hello,";
|
||||||
|
}
|
||||||
|
return `Hello ${trimmed},`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const EXPIRY_SENTENCE = "This link expires in one hour.";
|
||||||
|
const UNEXPECTED_SENTENCE =
|
||||||
|
"If you did not expect this message, you can ignore it and nothing will change.";
|
||||||
|
|
||||||
|
interface MessageBodies {
|
||||||
|
readonly subject: string;
|
||||||
|
readonly text: string;
|
||||||
|
readonly html: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function verificationBodies(name: string | undefined, url: string): MessageBodies {
|
||||||
|
const lead = greeting(name);
|
||||||
|
return {
|
||||||
|
subject: "Verify your Ekklesia email address",
|
||||||
|
html: [
|
||||||
|
`<p>${escapeHtml(lead)}</p>`,
|
||||||
|
"<p>Confirm this email address so it can be used to sign in to Ekklesia.</p>",
|
||||||
|
`<p><a href="${escapeHtml(url)}">Verify this email address</a></p>`,
|
||||||
|
`<p>${EXPIRY_SENTENCE} ${escapeHtml(UNEXPECTED_SENTENCE)}</p>`,
|
||||||
|
].join("\n"),
|
||||||
|
text: [
|
||||||
|
lead,
|
||||||
|
"",
|
||||||
|
"Confirm this email address so it can be used to sign in to Ekklesia:",
|
||||||
|
"",
|
||||||
|
url,
|
||||||
|
"",
|
||||||
|
EXPIRY_SENTENCE,
|
||||||
|
UNEXPECTED_SENTENCE,
|
||||||
|
].join("\n"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetBodies(name: string | undefined, url: string): MessageBodies {
|
||||||
|
const lead = greeting(name);
|
||||||
|
const closing = `${UNEXPECTED_SENTENCE} Your current password stays in place until a new one is chosen.`;
|
||||||
|
return {
|
||||||
|
subject: "Reset your Ekklesia password",
|
||||||
|
html: [
|
||||||
|
`<p>${escapeHtml(lead)}</p>`,
|
||||||
|
"<p>Someone asked to reset the password for this Ekklesia account.</p>",
|
||||||
|
`<p><a href="${escapeHtml(url)}">Choose a new password</a></p>`,
|
||||||
|
`<p>${EXPIRY_SENTENCE} ${escapeHtml(closing)}</p>`,
|
||||||
|
].join("\n"),
|
||||||
|
text: [
|
||||||
|
lead,
|
||||||
|
"",
|
||||||
|
"Someone asked to reset the password for this Ekklesia account. Choose a new one here:",
|
||||||
|
"",
|
||||||
|
url,
|
||||||
|
"",
|
||||||
|
EXPIRY_SENTENCE,
|
||||||
|
closing,
|
||||||
|
].join("\n"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function defaultReporter(failure: IdentityNotificationFailure): void {
|
||||||
|
// One fixed line. The flow and a sanitized code are exactly what an operator needs in order to go
|
||||||
|
// look at `pnpm doctor`; the address, the token, and the link are exactly what must never be here.
|
||||||
|
console.error(`[identity] ${failure.kind} email was not delivered: ${failure.reason}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function failureReason(error: unknown): string {
|
||||||
|
return error instanceof MailError ? error.code : "MAIL_SEND_FAILED";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds the two Better Auth callbacks.
|
||||||
|
*
|
||||||
|
* Neither rejects. A delivery failure is reported through `report` and the callback resolves, so the
|
||||||
|
* HTTP response an unauthenticated caller sees is identical whether the address exists, whether it
|
||||||
|
* was already verified, and whether the relay was reachable.
|
||||||
|
*/
|
||||||
|
export function createIdentityNotifications(
|
||||||
|
options: CreateIdentityNotificationsOptions,
|
||||||
|
): IdentityNotifications {
|
||||||
|
const report = options.report ?? defaultReporter;
|
||||||
|
|
||||||
|
const deliver = async (
|
||||||
|
kind: IdentityNotificationKind,
|
||||||
|
recipient: IdentityNotificationRecipient,
|
||||||
|
bodies: MessageBodies,
|
||||||
|
): Promise<void> => {
|
||||||
|
if (!options.mail) {
|
||||||
|
report({ kind, reason: "MAIL_NOT_CONFIGURED" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await options.mail.send(
|
||||||
|
validateMailMessage({
|
||||||
|
html: bodies.html,
|
||||||
|
subject: bodies.subject,
|
||||||
|
text: bodies.text,
|
||||||
|
to: recipient.email,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
report({ kind, reason: failureReason(error) });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const notifications: IdentityNotifications = {
|
||||||
|
sendResetPassword: async (data) => {
|
||||||
|
await deliver(
|
||||||
|
"password-reset",
|
||||||
|
data.user,
|
||||||
|
resetBodies(data.user.name, resetUrl(options, data.token)),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
sendVerificationEmail: async (data) => {
|
||||||
|
await deliver(
|
||||||
|
"email-verification",
|
||||||
|
data.user,
|
||||||
|
verificationBodies(data.user.name, verificationUrl(options, data.token)),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return Object.freeze(notifications);
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ import { getCookies } from "better-auth/cookies";
|
|||||||
import { sql } from "drizzle-orm";
|
import { sql } from "drizzle-orm";
|
||||||
import type { IdentityConfig } from "./config.js";
|
import type { IdentityConfig } from "./config.js";
|
||||||
import type { IdentityDrizzleDatabase } from "./database.js";
|
import type { IdentityDrizzleDatabase } from "./database.js";
|
||||||
|
import type { IdentityNotifications } from "./notifications.js";
|
||||||
import { ekklesiaCoreSession, identityAdapterSchema } from "./schema.js";
|
import { ekklesiaCoreSession, identityAdapterSchema } from "./schema.js";
|
||||||
|
|
||||||
export type IdentityRuntimeErrorCode =
|
export type IdentityRuntimeErrorCode =
|
||||||
@@ -46,6 +47,16 @@ export const TRUSTED_CLIENT_ADDRESS_HEADER = "x-ekklesia-client-address";
|
|||||||
*/
|
*/
|
||||||
const sanitizedBetterAuthLogMessage = "[identity] Better Auth reported an error.";
|
const sanitizedBetterAuthLogMessage = "[identity] Better Auth reported an error.";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One hour for both, matching the pinned release's own defaults but stated rather than inherited.
|
||||||
|
*
|
||||||
|
* Short enough that a link sitting in a forwarded or archived mailbox stops working, long enough
|
||||||
|
* that someone who reads their mail after lunch is not sent back to the start. The message bodies
|
||||||
|
* state the same hour, so the two can be checked against each other.
|
||||||
|
*/
|
||||||
|
const EMAIL_VERIFICATION_TOKEN_TTL_SECONDS = 3600;
|
||||||
|
const RESET_PASSWORD_TOKEN_TTL_SECONDS = 3600;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Explicitly replaces Better Auth's default logger so its own message text and arguments — which
|
* Explicitly replaces Better Auth's default logger so its own message text and arguments — which
|
||||||
* may include a raw exception, SQL, a connection string, a host/port, a filesystem path, a
|
* may include a raw exception, SQL, a connection string, a host/port, a filesystem path, a
|
||||||
@@ -82,10 +93,29 @@ export interface CreateIdentityRuntimeOptions {
|
|||||||
readonly config: IdentityConfig;
|
readonly config: IdentityConfig;
|
||||||
readonly db: IdentityDrizzleDatabase;
|
readonly db: IdentityDrizzleDatabase;
|
||||||
/**
|
/**
|
||||||
* A narrow, explicit override for the sign-in/sign-up rate-limit window used only by test
|
* How verification and password-reset messages are delivered.
|
||||||
* composition that issues many such requests quickly. Rate limiting itself is always enabled;
|
*
|
||||||
* production composition must leave this unset so Better Auth's own strict built-in default
|
* Injected rather than constructed here, for the same reason the database is: this file composes
|
||||||
* (3 requests / 10 seconds on sign-in and sign-up) applies unmodified.
|
* Better Auth and must not decide what a message says or how it travels. Omitting it is a
|
||||||
|
* complete, valid deployment — email verification and password recovery are then configured
|
||||||
|
* exactly as they otherwise are, and each attempt reports `MAIL_NOT_CONFIGURED` out of band while
|
||||||
|
* every HTTP response keeps its usual shape (ADR 0038).
|
||||||
|
*
|
||||||
|
* Note what omitting it does **not** do: it does not relax `requireEmailVerification`. An
|
||||||
|
* installation with no mail cannot verify a new account, which is why the initial platform owner
|
||||||
|
* is created already verified by a trusted operator command rather than by an email round trip.
|
||||||
|
*/
|
||||||
|
readonly notifications?: IdentityNotifications;
|
||||||
|
/**
|
||||||
|
* A narrow, explicit override for the rate-limit windows on the four paths Better Auth treats
|
||||||
|
* more strictly than its global default, used only by test composition that issues many such
|
||||||
|
* requests quickly. Rate limiting itself is always enabled; production composition must leave this
|
||||||
|
* unset so Better Auth's own built-in defaults apply unmodified — 3 requests / 10 seconds on
|
||||||
|
* sign-in and sign-up, and 3 requests / 60 seconds on password-reset requests and verification
|
||||||
|
* resends, which are the two paths an attacker could otherwise use to send mail repeatedly.
|
||||||
|
*
|
||||||
|
* That limiter is per-process and in-memory (ADR 0038): it is a real abuse control for a single
|
||||||
|
* deployment and is deliberately **not** claimed to be a distributed one.
|
||||||
*/
|
*/
|
||||||
readonly rateLimitOverride?: IdentityRateLimitOverride;
|
readonly rateLimitOverride?: IdentityRateLimitOverride;
|
||||||
}
|
}
|
||||||
@@ -264,9 +294,81 @@ export function createIdentityRuntime(
|
|||||||
enabled: true,
|
enabled: true,
|
||||||
// Pinned explicitly rather than left on Better Auth's fallback default (`|| 8` / `|| 128`
|
// Pinned explicitly rather than left on Better Auth's fallback default (`|| 8` / `|| 128`
|
||||||
// in the pinned release): an implicit default can change silently on a future Better Auth
|
// in the pinned release): an implicit default can change silently on a future Better Auth
|
||||||
// upgrade, and Ekklesia's own tests assert this exact 8–128 boundary.
|
// upgrade, and Ekklesia's own tests assert this exact 8–128 boundary. The reset endpoint
|
||||||
|
// enforces the identical pair from the same configuration, so there is deliberately no
|
||||||
|
// separate reset-time password policy.
|
||||||
maxPasswordLength: 128,
|
maxPasswordLength: 128,
|
||||||
minPasswordLength: 8,
|
minPasswordLength: 8,
|
||||||
|
/**
|
||||||
|
* A person may not hold a session until their address is verified (ADR 0038).
|
||||||
|
*
|
||||||
|
* In the pinned release this is enforced *after* the password is checked and *before* any
|
||||||
|
* session is created: the attempt fails with `403 EMAIL_NOT_VERIFIED` and no cookie is set.
|
||||||
|
* That is the whole reason no "unverified principal" exists anywhere in this platform — a
|
||||||
|
* correct password on an unverified account produces no session at all, so there is no
|
||||||
|
* half-authenticated state for the Dashboard to misread. `/api/v1/principal` continues to
|
||||||
|
* describe only anonymous, authenticated human, and device.
|
||||||
|
*/
|
||||||
|
requireEmailVerification: true,
|
||||||
|
/** Pinned rather than left on the release's `|| 3600` fallback, for the same reason the
|
||||||
|
* password bounds are pinned. */
|
||||||
|
resetPasswordTokenExpiresIn: RESET_PASSWORD_TOKEN_TTL_SECONDS,
|
||||||
|
/**
|
||||||
|
* Every existing session for that account is deleted when its password is reset.
|
||||||
|
*
|
||||||
|
* Password reset is the recovery path someone uses precisely when they suspect their
|
||||||
|
* credential is compromised, and leaving the attacker's session alive would make the recovery
|
||||||
|
* cosmetic. This is Better Auth's own supported option (`deleteUserSessions` in the pinned
|
||||||
|
* release), not a Core reach into the session table.
|
||||||
|
*
|
||||||
|
* It deletes *all* of that human's sessions, including the browser that performed the reset —
|
||||||
|
* so the completion state returns to the sign-in form rather than pretending to be signed in.
|
||||||
|
* Managed device sessions are a separate Ekklesia store keyed by device, hold no email and no
|
||||||
|
* password, and are entirely unaffected (ADR 0036).
|
||||||
|
*/
|
||||||
|
revokeSessionsOnPasswordReset: true,
|
||||||
|
...(options.notifications
|
||||||
|
? { sendResetPassword: options.notifications.sendResetPassword }
|
||||||
|
: {}),
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* Verification is configured identically whether or not mail is deliverable, so the policy an
|
||||||
|
* installation enforces never depends on the state of its relay.
|
||||||
|
*/
|
||||||
|
emailVerification: {
|
||||||
|
/**
|
||||||
|
* No session is minted from following an emailed link.
|
||||||
|
*
|
||||||
|
* A verification URL travels through mail infrastructure and is routinely fetched by things
|
||||||
|
* that are not the recipient — link scanners, prefetchers, corporate mail gateways. Turning
|
||||||
|
* that GET into a logged-in browser session would hand a session to whoever, or whatever,
|
||||||
|
* dereferenced the link. Verification therefore only flips the flag, and the person signs in
|
||||||
|
* afterwards through the ordinary form.
|
||||||
|
*/
|
||||||
|
autoSignInAfterVerification: false,
|
||||||
|
/** Pinned rather than left on the release's `|| 3600` fallback. */
|
||||||
|
expiresIn: EMAIL_VERIFICATION_TOKEN_TTL_SECONDS,
|
||||||
|
/**
|
||||||
|
* A sign-in attempt against an unverified account re-sends the verification message.
|
||||||
|
*
|
||||||
|
* This is the resend path that costs nothing to secure: reaching it requires the correct
|
||||||
|
* password, so it reveals nothing an attacker did not already know, and it means the common
|
||||||
|
* case — the first link expired — is handled by the thing the person was already trying to do.
|
||||||
|
* The standalone `/send-verification-email` endpoint remains available for the case where they
|
||||||
|
* would rather not retype a password, and is enumeration-safe on its own.
|
||||||
|
*/
|
||||||
|
sendOnSignIn: true,
|
||||||
|
/**
|
||||||
|
* Pinned rather than left on the release's "follow `requireEmailVerification`" fallback.
|
||||||
|
*
|
||||||
|
* It reaches nothing in production, where signup is refused outright — but stating it means a
|
||||||
|
* development or test deployment that does enable signup behaves the way this platform
|
||||||
|
* intends, rather than the way an inherited default happens to.
|
||||||
|
*/
|
||||||
|
sendOnSignUp: true,
|
||||||
|
...(options.notifications
|
||||||
|
? { sendVerificationEmail: options.notifications.sendVerificationEmail }
|
||||||
|
: {}),
|
||||||
},
|
},
|
||||||
logger: {
|
logger: {
|
||||||
disabled: false,
|
disabled: false,
|
||||||
@@ -293,6 +395,18 @@ export function createIdentityRuntime(
|
|||||||
...(rateLimitOverride
|
...(rateLimitOverride
|
||||||
? {
|
? {
|
||||||
customRules: {
|
customRules: {
|
||||||
|
// The four paths Better Auth applies a stricter-than-global default to. Production
|
||||||
|
// composition never sets this override, so those defaults — 3 requests / 10 seconds
|
||||||
|
// on sign-in and sign-up, 3 requests / 60 seconds on the two paths that trigger an
|
||||||
|
// email — remain in force there, unmodified.
|
||||||
|
"/request-password-reset": {
|
||||||
|
max: rateLimitOverride.max ?? 100,
|
||||||
|
window: rateLimitOverride.window ?? 10,
|
||||||
|
},
|
||||||
|
"/send-verification-email": {
|
||||||
|
max: rateLimitOverride.max ?? 100,
|
||||||
|
window: rateLimitOverride.window ?? 10,
|
||||||
|
},
|
||||||
"/sign-in/email": {
|
"/sign-in/email": {
|
||||||
max: rateLimitOverride.max ?? 100,
|
max: rateLimitOverride.max ?? 100,
|
||||||
window: rateLimitOverride.window ?? 10,
|
window: rateLimitOverride.window ?? 10,
|
||||||
|
|||||||
+61
-1
@@ -8,6 +8,7 @@ export type MailConfigErrorCode =
|
|||||||
| "MAIL_FROM_ADDRESS_INVALID"
|
| "MAIL_FROM_ADDRESS_INVALID"
|
||||||
| "MAIL_FROM_ADDRESS_MISSING"
|
| "MAIL_FROM_ADDRESS_MISSING"
|
||||||
| "MAIL_FROM_NAME_INVALID"
|
| "MAIL_FROM_NAME_INVALID"
|
||||||
|
| "MAIL_HELO_NAME_INVALID"
|
||||||
| "MAIL_HOST_INVALID"
|
| "MAIL_HOST_INVALID"
|
||||||
| "MAIL_PASSWORD_WITHOUT_USERNAME"
|
| "MAIL_PASSWORD_WITHOUT_USERNAME"
|
||||||
| "MAIL_PORT_INVALID"
|
| "MAIL_PORT_INVALID"
|
||||||
@@ -19,6 +20,8 @@ const mailConfigMessages: Readonly<Record<MailConfigErrorCode, string>> = Object
|
|||||||
MAIL_FROM_ADDRESS_INVALID: "The outbound email sender address is not a valid email address.",
|
MAIL_FROM_ADDRESS_INVALID: "The outbound email sender address is not a valid email address.",
|
||||||
MAIL_FROM_ADDRESS_MISSING: "A sender address is required when an SMTP host is set.",
|
MAIL_FROM_ADDRESS_MISSING: "A sender address is required when an SMTP host is set.",
|
||||||
MAIL_FROM_NAME_INVALID: `The sender display name must be at most ${MAIL_MAX_DISPLAY_NAME_LENGTH} characters and contain no quotes, backslashes, or control characters.`,
|
MAIL_FROM_NAME_INVALID: `The sender display name must be at most ${MAIL_MAX_DISPLAY_NAME_LENGTH} characters and contain no quotes, backslashes, or control characters.`,
|
||||||
|
MAIL_HELO_NAME_INVALID:
|
||||||
|
"The SMTP HELO name must be a fully qualified domain name, with no scheme, port, brackets, or spaces.",
|
||||||
MAIL_HOST_INVALID: "The SMTP host must be a bare hostname or IP address, with no scheme or port.",
|
MAIL_HOST_INVALID: "The SMTP host must be a bare hostname or IP address, with no scheme or port.",
|
||||||
MAIL_PASSWORD_WITHOUT_USERNAME:
|
MAIL_PASSWORD_WITHOUT_USERNAME:
|
||||||
"An SMTP password requires a username; supply both or neither for an unauthenticated relay.",
|
"An SMTP password requires a username; supply both or neither for an unauthenticated relay.",
|
||||||
@@ -70,6 +73,7 @@ export type MailSecurityMode = (typeof MAIL_SECURITY_MODES)[number];
|
|||||||
|
|
||||||
export interface MailProviderConfigInput {
|
export interface MailProviderConfigInput {
|
||||||
readonly host?: string | undefined;
|
readonly host?: string | undefined;
|
||||||
|
readonly heloName?: string | undefined;
|
||||||
readonly port?: string | number | undefined;
|
readonly port?: string | number | undefined;
|
||||||
readonly security?: string | undefined;
|
readonly security?: string | undefined;
|
||||||
readonly username?: string | undefined;
|
readonly username?: string | undefined;
|
||||||
@@ -87,6 +91,24 @@ export interface MailProviderConfigInput {
|
|||||||
*/
|
*/
|
||||||
export interface MailProviderConfig {
|
export interface MailProviderConfig {
|
||||||
readonly host: string;
|
readonly host: string;
|
||||||
|
/**
|
||||||
|
* The name this client identifies itself as in the SMTP `EHLO` command, when the deployment sets
|
||||||
|
* one.
|
||||||
|
*
|
||||||
|
* This exists because of a real failure, and the failure is generic rather than tied to any one
|
||||||
|
* provider. An SMTP client must greet the server with its own identity, and when nothing is
|
||||||
|
* configured Nodemailer derives it from the machine's hostname — falling back to the address
|
||||||
|
* literal `[127.0.0.1]` whenever that hostname is not fully qualified, which is the normal state
|
||||||
|
* of a container or a VM nobody has named. Some relays refuse that greeting outright, closing the
|
||||||
|
* connection with a `421` before any message is offered, which surfaces here as a bare
|
||||||
|
* `MAIL_PROVIDER_UNAVAILABLE` and looks indistinguishable from the relay being down.
|
||||||
|
*
|
||||||
|
* Absent means Nodemailer's own derivation is used, unchanged. It is deliberately not defaulted to
|
||||||
|
* the sender's domain: a host whose machine hostname *is* a valid FQDN may already be greeting
|
||||||
|
* correctly — some relays check it against the connecting address's reverse DNS — and silently
|
||||||
|
* replacing a working identity would trade one deployment's bug for another's.
|
||||||
|
*/
|
||||||
|
readonly heloName?: string;
|
||||||
readonly port: number;
|
readonly port: number;
|
||||||
readonly security: MailSecurityMode;
|
readonly security: MailSecurityMode;
|
||||||
/** Present only for an authenticated relay. A relay authorized by source IP — the common Google
|
/** Present only for an authenticated relay. A relay authorized by source IP — the common Google
|
||||||
@@ -119,6 +141,29 @@ function validateHost(host: string): string {
|
|||||||
return trimmed;
|
return trimmed;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A fully qualified domain name, and deliberately nothing else.
|
||||||
|
*
|
||||||
|
* Stricter than `HOST_PATTERN` on purpose, in two ways. It requires **at least one dot**, because a
|
||||||
|
* single unqualified label is precisely the input Nodemailer already refuses to use — configuring
|
||||||
|
* one would reproduce the failure this setting exists to fix. And it requires the final label to
|
||||||
|
* begin with a letter, which rejects a bare IPv4 address: `EHLO 192.0.2.1` is not valid SMTP syntax,
|
||||||
|
* and the bracketed address-literal form that *is* valid is the greeting the affected relays reject.
|
||||||
|
*
|
||||||
|
* Everything the requirement rules out falls out of the anchors and the character class rather than
|
||||||
|
* from a list of special cases: a scheme, a port, a path, whitespace, brackets, a comma, a
|
||||||
|
* carriage return or line feed, and any other prose all fail to match.
|
||||||
|
*/
|
||||||
|
const HELO_NAME_PATTERN =
|
||||||
|
/^(?=.{1,253}$)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z](?:[a-z0-9-]{0,61}[a-z0-9])?$/iu;
|
||||||
|
|
||||||
|
function validateHeloName(heloName: string): string {
|
||||||
|
if (!HELO_NAME_PATTERN.test(heloName)) {
|
||||||
|
throw new MailConfigError("MAIL_HELO_NAME_INVALID");
|
||||||
|
}
|
||||||
|
return heloName;
|
||||||
|
}
|
||||||
|
|
||||||
function validatePort(port: string | number | undefined, security: MailSecurityMode): number {
|
function validatePort(port: string | number | undefined, security: MailSecurityMode): number {
|
||||||
if (port === undefined || (typeof port === "string" && port.trim() === "")) {
|
if (port === undefined || (typeof port === "string" && port.trim() === "")) {
|
||||||
return DEFAULT_PORTS[security];
|
return DEFAULT_PORTS[security];
|
||||||
@@ -174,6 +219,16 @@ export function createMailProviderConfig(
|
|||||||
const validatedHost = validateHost(host);
|
const validatedHost = validateHost(host);
|
||||||
const port = validatePort(input?.port, security);
|
const port = validatePort(input?.port, security);
|
||||||
|
|
||||||
|
// Validated exactly as supplied, with no trimming. Every other value here tolerates surrounding
|
||||||
|
// whitespace as a copy-paste artefact; this one must not, because a stray character in the value
|
||||||
|
// goes straight onto the wire in a protocol command. It is refused rather than repaired, the same
|
||||||
|
// rule an address follows.
|
||||||
|
const heloNameInput = input?.heloName;
|
||||||
|
const heloName =
|
||||||
|
heloNameInput === undefined || heloNameInput === ""
|
||||||
|
? undefined
|
||||||
|
: validateHeloName(heloNameInput);
|
||||||
|
|
||||||
const username = input?.username?.trim() === "" ? undefined : input?.username?.trim();
|
const username = input?.username?.trim() === "" ? undefined : input?.username?.trim();
|
||||||
const password = input?.password === "" ? undefined : input?.password;
|
const password = input?.password === "" ? undefined : input?.password;
|
||||||
if (username !== undefined && password === undefined) {
|
if (username !== undefined && password === undefined) {
|
||||||
@@ -201,6 +256,7 @@ export function createMailProviderConfig(
|
|||||||
host: validatedHost,
|
host: validatedHost,
|
||||||
port,
|
port,
|
||||||
security,
|
security,
|
||||||
|
...(heloName === undefined ? {} : { heloName }),
|
||||||
...(fromName === undefined ? {} : { fromName }),
|
...(fromName === undefined ? {} : { fromName }),
|
||||||
...(username === undefined || password === undefined ? {} : { password, username }),
|
...(username === undefined || password === undefined ? {} : { password, username }),
|
||||||
});
|
});
|
||||||
@@ -219,7 +275,11 @@ export function describeMailProvider(config: MailProviderConfig): string {
|
|||||||
config.fromName === undefined
|
config.fromName === undefined
|
||||||
? config.fromAddress
|
? config.fromAddress
|
||||||
: `${config.fromName} <${config.fromAddress}>`;
|
: `${config.fromName} <${config.fromAddress}>`;
|
||||||
|
// The HELO name is host infrastructure, not a credential: it is the identity this client announces
|
||||||
|
// in the clear on every connection, so printing it costs nothing and is exactly what an operator
|
||||||
|
// needs when a relay has refused the greeting.
|
||||||
|
const helo = config.heloName === undefined ? "" : ` · HELO ${config.heloName}`;
|
||||||
return `SMTP ${config.host}:${config.port} · ${config.security} · from ${sender} · ${
|
return `SMTP ${config.host}:${config.port} · ${config.security} · from ${sender} · ${
|
||||||
config.username === undefined ? "unauthenticated relay" : "authenticated"
|
config.username === undefined ? "unauthenticated relay" : "authenticated"
|
||||||
}`;
|
}${helo}`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -126,6 +126,11 @@ function transportOptions(
|
|||||||
disableUrlAccess: true,
|
disableUrlAccess: true,
|
||||||
greetingTimeout: bounds.greetingTimeoutMs,
|
greetingTimeout: bounds.greetingTimeoutMs,
|
||||||
host: config.host,
|
host: config.host,
|
||||||
|
// The identity this client announces in `EHLO`. Left to Nodemailer when the deployment
|
||||||
|
// configures none — it derives one from the machine hostname, falling back to the address
|
||||||
|
// literal `[127.0.0.1]` when that hostname is not fully qualified, which some relays refuse
|
||||||
|
// with a `421` before a message is ever offered (ADR 0037).
|
||||||
|
...(config.heloName === undefined ? {} : { name: config.heloName }),
|
||||||
// Deliberately not a pooled transport: `pool` is absent, which is Nodemailer's default and is
|
// Deliberately not a pooled transport: `pool` is absent, which is Nodemailer's default and is
|
||||||
// what this type permits. A pool keeps sockets open and reuses them across sends, which for a
|
// what this type permits. A pool keeps sockets open and reuses them across sends, which for a
|
||||||
// platform sending a handful of transactional messages buys nothing and makes the failure modes
|
// platform sending a handful of transactional messages buys nothing and makes the failure modes
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import type {
|
|||||||
DiscoveredModuleMigration,
|
DiscoveredModuleMigration,
|
||||||
ModuleMigrationAggregate,
|
ModuleMigrationAggregate,
|
||||||
} from "../modules/migration-discovery.js";
|
} from "../modules/migration-discovery.js";
|
||||||
|
import { CORE_MIGRATION_SOURCE_ID } from "./core-source.js";
|
||||||
|
|
||||||
const DEFAULT_MIGRATION_LOCK_TIMEOUT_MS = 10_000;
|
const DEFAULT_MIGRATION_LOCK_TIMEOUT_MS = 10_000;
|
||||||
|
|
||||||
@@ -86,11 +87,24 @@ export interface MigrationPlanEntry {
|
|||||||
readonly tag: string;
|
readonly tag: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Applied history kept for a module that is not installed (ADR 0048): the module id and how many of
|
||||||
|
* its migrations were applied. It is reported, never matched against an inventory, and never a
|
||||||
|
* reason to refuse planning or applying anything else.
|
||||||
|
*/
|
||||||
|
export interface RetainedMigrationHistory {
|
||||||
|
readonly appliedCount: number;
|
||||||
|
readonly moduleId: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface MigrationPlanResult {
|
export interface MigrationPlanResult {
|
||||||
readonly appliedCount: number;
|
readonly appliedCount: number;
|
||||||
readonly approvalRequiredCount: number;
|
readonly approvalRequiredCount: number;
|
||||||
readonly entries: readonly MigrationPlanEntry[];
|
readonly entries: readonly MigrationPlanEntry[];
|
||||||
readonly pendingCount: number;
|
readonly pendingCount: number;
|
||||||
|
/** Absent modules' applied history, ordered by module id; empty when every applied migration
|
||||||
|
* belongs to Core or an installed module. */
|
||||||
|
readonly retained: readonly RetainedMigrationHistory[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AppliedMigrationResult {
|
export interface AppliedMigrationResult {
|
||||||
@@ -105,6 +119,8 @@ export interface MigrationApplyResult {
|
|||||||
readonly applied: readonly AppliedMigrationResult[];
|
readonly applied: readonly AppliedMigrationResult[];
|
||||||
readonly appliedCount: number;
|
readonly appliedCount: number;
|
||||||
readonly noOp: boolean;
|
readonly noOp: boolean;
|
||||||
|
/** The same report as `MigrationPlanResult.retained`, read under the migration lock. */
|
||||||
|
readonly retained: readonly RetainedMigrationHistory[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PlanModuleMigrationsOptions {
|
export interface PlanModuleMigrationsOptions {
|
||||||
@@ -204,10 +220,38 @@ function validateHistoryRecordShape(record: AppliedMigrationRecord): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface ValidatedAppliedHistory {
|
||||||
|
/** Identities of applied migrations that belong to Core or an installed module. */
|
||||||
|
readonly applied: ReadonlySet<string>;
|
||||||
|
readonly retained: readonly RetainedMigrationHistory[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every module id this inventory treats as installed: each discovered module, whether or not it
|
||||||
|
* owns migrations, every module that contributed migrations, and always Core. A module counts as
|
||||||
|
* absent only when it is none of these, so a module that is installed but no longer ships its old
|
||||||
|
* migrations stays strict, and Core's history can never be classified as an absent module's.
|
||||||
|
*/
|
||||||
|
function installedMigrationSources(aggregate: ModuleMigrationAggregate): ReadonlySet<string> {
|
||||||
|
return new Set<string>([
|
||||||
|
CORE_MIGRATION_SOURCE_ID,
|
||||||
|
...aggregate.discoveredModules.map((module) => module.manifest.id),
|
||||||
|
...aggregate.modules.map((module) => module.moduleId),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function freezeRetained(counts: ReadonlyMap<string, number>): readonly RetainedMigrationHistory[] {
|
||||||
|
return Object.freeze(
|
||||||
|
[...counts.entries()]
|
||||||
|
.sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0))
|
||||||
|
.map(([moduleId, appliedCount]) => Object.freeze({ appliedCount, moduleId })),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function validateAppliedHistoryUnchecked(
|
function validateAppliedHistoryUnchecked(
|
||||||
aggregate: ModuleMigrationAggregate,
|
aggregate: ModuleMigrationAggregate,
|
||||||
history: readonly AppliedMigrationRecord[],
|
history: readonly AppliedMigrationRecord[],
|
||||||
): ReadonlySet<string> {
|
): ValidatedAppliedHistory {
|
||||||
if (!Array.isArray(history)) {
|
if (!Array.isArray(history)) {
|
||||||
throw runnerError("MIGRATION_HISTORY_INVALID");
|
throw runnerError("MIGRATION_HISTORY_INVALID");
|
||||||
}
|
}
|
||||||
@@ -215,6 +259,9 @@ function validateAppliedHistoryUnchecked(
|
|||||||
const inventory = new Map(
|
const inventory = new Map(
|
||||||
aggregate.migrations.map((migration) => [migration.identity, migration]),
|
aggregate.migrations.map((migration) => [migration.identity, migration]),
|
||||||
);
|
);
|
||||||
|
const installed = installedMigrationSources(aggregate);
|
||||||
|
const applied = new Set<string>();
|
||||||
|
const retained = new Map<string, number>();
|
||||||
const seenOrders = new Set<number>();
|
const seenOrders = new Set<number>();
|
||||||
const seenIdentities = new Set<string>();
|
const seenIdentities = new Set<string>();
|
||||||
const seenModuleIndexes = new Set<string>();
|
const seenModuleIndexes = new Set<string>();
|
||||||
@@ -240,8 +287,20 @@ function validateAppliedHistoryUnchecked(
|
|||||||
|
|
||||||
const migration = knownMigration(inventory, record.identity);
|
const migration = knownMigration(inventory, record.identity);
|
||||||
if (!migration) {
|
if (!migration) {
|
||||||
|
if (installed.has(record.moduleId)) {
|
||||||
|
// Core, or a module that is installed now: its history is strict, exactly as ADR 0009
|
||||||
|
// established. A migration it no longer ships is a downgrade or a rewritten package.
|
||||||
throw runnerError("MIGRATION_UNKNOWN_APPLIED");
|
throw runnerError("MIGRATION_UNKNOWN_APPLIED");
|
||||||
}
|
}
|
||||||
|
// An absent module's history (ADR 0048): retained and reported, never repaired, deleted, or
|
||||||
|
// matched against anything. It must still name its own module and tag, so a corrupt row is
|
||||||
|
// never mistaken for one.
|
||||||
|
if (record.identity !== `${record.moduleId}:${record.tag}`) {
|
||||||
|
throw runnerError("MIGRATION_HISTORY_INVALID");
|
||||||
|
}
|
||||||
|
retained.set(record.moduleId, (retained.get(record.moduleId) ?? 0) + 1);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
if (
|
if (
|
||||||
record.moduleId !== migration.moduleId ||
|
record.moduleId !== migration.moduleId ||
|
||||||
record.tag !== migration.tag ||
|
record.tag !== migration.tag ||
|
||||||
@@ -252,11 +311,12 @@ function validateAppliedHistoryUnchecked(
|
|||||||
if (record.checksum !== migration.checksum) {
|
if (record.checksum !== migration.checksum) {
|
||||||
throw runnerError("MIGRATION_CHECKSUM_MISMATCH", migration.identity);
|
throw runnerError("MIGRATION_CHECKSUM_MISMATCH", migration.identity);
|
||||||
}
|
}
|
||||||
|
applied.add(record.identity);
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const module of aggregate.modules) {
|
for (const module of aggregate.modules) {
|
||||||
const applied = history.filter((record) => record.moduleId === module.moduleId);
|
const moduleHistory = history.filter((record) => record.moduleId === module.moduleId);
|
||||||
for (const [position, record] of applied.entries()) {
|
for (const [position, record] of moduleHistory.entries()) {
|
||||||
if (record.identity !== module.migrations[position]?.identity) {
|
if (record.identity !== module.migrations[position]?.identity) {
|
||||||
const migration = knownMigration(inventory, record.identity);
|
const migration = knownMigration(inventory, record.identity);
|
||||||
throw runnerError("MIGRATION_HISTORY_INVALID", migration?.identity);
|
throw runnerError("MIGRATION_HISTORY_INVALID", migration?.identity);
|
||||||
@@ -264,13 +324,13 @@ function validateAppliedHistoryUnchecked(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return seenIdentities;
|
return Object.freeze({ applied, retained: freezeRetained(retained) });
|
||||||
}
|
}
|
||||||
|
|
||||||
function validateAppliedHistory(
|
function validateAppliedHistory(
|
||||||
aggregate: ModuleMigrationAggregate,
|
aggregate: ModuleMigrationAggregate,
|
||||||
history: readonly AppliedMigrationRecord[],
|
history: readonly AppliedMigrationRecord[],
|
||||||
): ReadonlySet<string> {
|
): ValidatedAppliedHistory {
|
||||||
try {
|
try {
|
||||||
return validateAppliedHistoryUnchecked(aggregate, history);
|
return validateAppliedHistoryUnchecked(aggregate, history);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -285,7 +345,7 @@ function planFromHistory(
|
|||||||
aggregate: ModuleMigrationAggregate,
|
aggregate: ModuleMigrationAggregate,
|
||||||
history: readonly AppliedMigrationRecord[],
|
history: readonly AppliedMigrationRecord[],
|
||||||
): MigrationPlanResult {
|
): MigrationPlanResult {
|
||||||
const applied = validateAppliedHistory(aggregate, history);
|
const { applied, retained } = validateAppliedHistory(aggregate, history);
|
||||||
const entries = Object.freeze(
|
const entries = Object.freeze(
|
||||||
aggregate.migrations.map((migration): MigrationPlanEntry => {
|
aggregate.migrations.map((migration): MigrationPlanEntry => {
|
||||||
const isApplied = applied.has(migration.identity);
|
const isApplied = applied.has(migration.identity);
|
||||||
@@ -314,6 +374,7 @@ function planFromHistory(
|
|||||||
approvalRequiredCount,
|
approvalRequiredCount,
|
||||||
entries,
|
entries,
|
||||||
pendingCount: entries.length - appliedCount,
|
pendingCount: entries.length - appliedCount,
|
||||||
|
retained,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -472,14 +533,14 @@ export async function applyModuleMigrations(
|
|||||||
} catch {
|
} catch {
|
||||||
throw runnerError("MIGRATION_HISTORY_READ_FAILED");
|
throw runnerError("MIGRATION_HISTORY_READ_FAILED");
|
||||||
}
|
}
|
||||||
const applied = validateAppliedHistory(options.aggregate, history);
|
const { applied, retained } = validateAppliedHistory(options.aggregate, history);
|
||||||
const pending = Object.freeze(
|
const pending = Object.freeze(
|
||||||
options.aggregate.migrations.filter((migration) => !applied.has(migration.identity)),
|
options.aggregate.migrations.filter((migration) => !applied.has(migration.identity)),
|
||||||
);
|
);
|
||||||
await preflightApprovals(pending, options.approvals ?? denyAllMigrationApprovals);
|
await preflightApprovals(pending, options.approvals ?? denyAllMigrationApprovals);
|
||||||
|
|
||||||
if (pending.length === 0) {
|
if (pending.length === 0) {
|
||||||
return Object.freeze({ applied: Object.freeze([]), appliedCount: 0, noOp: true });
|
return Object.freeze({ applied: Object.freeze([]), appliedCount: 0, noOp: true, retained });
|
||||||
}
|
}
|
||||||
|
|
||||||
let executingIdentity: string | undefined;
|
let executingIdentity: string | undefined;
|
||||||
@@ -508,6 +569,7 @@ export async function applyModuleMigrations(
|
|||||||
applied: resultMigrations,
|
applied: resultMigrations,
|
||||||
appliedCount: resultMigrations.length,
|
appliedCount: resultMigrations.length,
|
||||||
noOp: false,
|
noOp: false,
|
||||||
|
retained,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
primaryError = error;
|
primaryError = error;
|
||||||
|
|||||||
@@ -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 {
|
||||||
@@ -57,6 +58,19 @@ export interface ModuleDiscoveryIssue {
|
|||||||
readonly path?: string;
|
readonly path?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One declared browser contribution, with its artifact resolved and validated exactly like every
|
||||||
|
* other compiled entrypoint (ADR 0041). Discovery never imports or evaluates it, and never learns
|
||||||
|
* what it renders.
|
||||||
|
*/
|
||||||
|
export interface ResolvedModuleContribution {
|
||||||
|
readonly id: string;
|
||||||
|
readonly version: string;
|
||||||
|
readonly capability: string;
|
||||||
|
readonly capabilityRange: string;
|
||||||
|
readonly artifactPath: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ResolvedModuleEntrypoints {
|
export interface ResolvedModuleEntrypoints {
|
||||||
readonly server: string;
|
readonly server: string;
|
||||||
readonly schema?: string;
|
readonly schema?: string;
|
||||||
@@ -92,6 +106,9 @@ export type ReadonlyModuleManifest = DeepReadonly<ModuleManifest>;
|
|||||||
|
|
||||||
export interface DiscoveredModule {
|
export interface DiscoveredModule {
|
||||||
readonly directory: string;
|
readonly directory: string;
|
||||||
|
/** Declared browser contributions, in manifest order, each resolved to a canonical artifact path
|
||||||
|
* inside this module package. Empty for a module that contributes nothing. */
|
||||||
|
readonly contributions: readonly ResolvedModuleContribution[];
|
||||||
readonly entrypoints: ResolvedModuleEntrypoints;
|
readonly entrypoints: ResolvedModuleEntrypoints;
|
||||||
readonly manifest: ReadonlyModuleManifest;
|
readonly manifest: ReadonlyModuleManifest;
|
||||||
readonly manifestPath: string;
|
readonly manifestPath: string;
|
||||||
@@ -101,15 +118,42 @@ 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ModuleCandidate {
|
interface ModuleCandidate {
|
||||||
|
contributions: readonly ResolvedModuleContribution[];
|
||||||
directory: string;
|
directory: string;
|
||||||
directoryName: string;
|
directoryName: string;
|
||||||
entrypoints: ResolvedModuleEntrypoints;
|
entrypoints: ResolvedModuleEntrypoints;
|
||||||
@@ -257,13 +301,19 @@ function validatePackageData(
|
|||||||
return { name, version };
|
return { name, version };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface ResolvedModuleArtifacts {
|
||||||
|
readonly contributions: readonly ResolvedModuleContribution[];
|
||||||
|
readonly entrypoints: ResolvedModuleEntrypoints;
|
||||||
|
}
|
||||||
|
|
||||||
async function resolveEntrypoints(
|
async function resolveEntrypoints(
|
||||||
moduleDirectory: string,
|
moduleDirectory: string,
|
||||||
directoryName: string,
|
directoryName: string,
|
||||||
manifest: ModuleManifest,
|
manifest: ModuleManifest,
|
||||||
issues: ModuleDiscoveryIssue[],
|
issues: ModuleDiscoveryIssue[],
|
||||||
): Promise<ResolvedModuleEntrypoints | undefined> {
|
): Promise<ResolvedModuleArtifacts | undefined> {
|
||||||
const resolved: Partial<Record<keyof ModuleManifest["entrypoints"], string>> = {};
|
const resolved: Partial<Record<keyof ModuleManifest["entrypoints"], string>> = {};
|
||||||
|
const resolvedContributions = new Map<string, string>();
|
||||||
const canonicalModuleDirectory = await realpath(moduleDirectory);
|
const canonicalModuleDirectory = await realpath(moduleDirectory);
|
||||||
const { display, ...ordinaryEntrypoints } = manifest.entrypoints;
|
const { display, ...ordinaryEntrypoints } = manifest.entrypoints;
|
||||||
const ordinaryConfiguredEntrypoints = Object.entries(ordinaryEntrypoints)
|
const ordinaryConfiguredEntrypoints = Object.entries(ordinaryEntrypoints)
|
||||||
@@ -273,6 +323,15 @@ async function resolveEntrypoints(
|
|||||||
issuePath: `entrypoints.${entrypointName}`,
|
issuePath: `entrypoints.${entrypointName}`,
|
||||||
relativeEntrypoint,
|
relativeEntrypoint,
|
||||||
}));
|
}));
|
||||||
|
// A contribution artifact is resolved through the identical loop as every other compiled
|
||||||
|
// entrypoint, deliberately: the path, symlink, containment, extension, and file-kind protections
|
||||||
|
// are one implementation, so a contribution can never quietly receive weaker checks than
|
||||||
|
// `entrypoints.ui` (ADR 0041).
|
||||||
|
const configuredContributions = manifest.contributions.map((contribution, index) => ({
|
||||||
|
entrypointName: `contribution:${contribution.id}`,
|
||||||
|
issuePath: `contributions.${index}.artifact`,
|
||||||
|
relativeEntrypoint: contribution.artifact,
|
||||||
|
}));
|
||||||
const configuredEntrypoints = [
|
const configuredEntrypoints = [
|
||||||
...ordinaryConfiguredEntrypoints,
|
...ordinaryConfiguredEntrypoints,
|
||||||
...(display === undefined
|
...(display === undefined
|
||||||
@@ -284,6 +343,7 @@ async function resolveEntrypoints(
|
|||||||
relativeEntrypoint: display.artifact,
|
relativeEntrypoint: display.artifact,
|
||||||
},
|
},
|
||||||
]),
|
]),
|
||||||
|
...configuredContributions,
|
||||||
];
|
];
|
||||||
let entrypointCollision = false;
|
let entrypointCollision = false;
|
||||||
|
|
||||||
@@ -361,7 +421,14 @@ async function resolveEntrypoints(
|
|||||||
});
|
});
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
if (entrypointName.startsWith("contribution:")) {
|
||||||
|
resolvedContributions.set(
|
||||||
|
entrypointName.slice("contribution:".length),
|
||||||
|
canonicalEntrypoint,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
resolved[entrypointName as keyof ModuleManifest["entrypoints"]] = canonicalEntrypoint;
|
resolved[entrypointName as keyof ModuleManifest["entrypoints"]] = canonicalEntrypoint;
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const code = errorCode(error);
|
const code = errorCode(error);
|
||||||
issues.push({
|
issues.push({
|
||||||
@@ -394,11 +461,46 @@ async function resolveEntrypoints(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!resolved.server || entrypointCollision) {
|
// A contribution artifact must be its own file. Two declarations resolving to one path — through
|
||||||
|
// a link, since the manifest already rejects two identical declared paths — would make one
|
||||||
|
// contribution silently serve another's bytes, and the loader's identity check would then reject
|
||||||
|
// whichever one lost.
|
||||||
|
const canonicalContributionPaths = new Map<string, string>();
|
||||||
|
for (const [contributionId, canonicalPath] of resolvedContributions) {
|
||||||
|
const claimedBy = canonicalContributionPaths.get(canonicalPath);
|
||||||
|
if (claimedBy !== undefined || Object.values(resolved).includes(canonicalPath)) {
|
||||||
|
entrypointCollision = true;
|
||||||
|
issues.push({
|
||||||
|
code: "MODULE_ENTRYPOINT_INVALID",
|
||||||
|
message: `contribution ${contributionId} must not resolve to the same file as ${claimedBy ?? "another entrypoint"}.`,
|
||||||
|
moduleDirectory: directoryName,
|
||||||
|
moduleId: manifest.id,
|
||||||
|
path: "contributions",
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
canonicalContributionPaths.set(canonicalPath, contributionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
!resolved.server ||
|
||||||
|
entrypointCollision ||
|
||||||
|
resolvedContributions.size !== manifest.contributions.length
|
||||||
|
) {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
const contributions = manifest.contributions.map((contribution) =>
|
||||||
|
Object.freeze({
|
||||||
|
artifactPath: resolvedContributions.get(contribution.id) as string,
|
||||||
|
capability: contribution.capability,
|
||||||
|
capabilityRange: contribution.capabilityRange,
|
||||||
|
id: contribution.id,
|
||||||
|
version: contribution.version,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const entrypoints: ResolvedModuleEntrypoints = {
|
||||||
server: resolved.server,
|
server: resolved.server,
|
||||||
...(resolved.schema ? { schema: resolved.schema } : {}),
|
...(resolved.schema ? { schema: resolved.schema } : {}),
|
||||||
...(resolved.migrations ? { migrations: resolved.migrations } : {}),
|
...(resolved.migrations ? { migrations: resolved.migrations } : {}),
|
||||||
@@ -413,6 +515,8 @@ async function resolveEntrypoints(
|
|||||||
}
|
}
|
||||||
: {}),
|
: {}),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
return { contributions: Object.freeze(contributions), entrypoints };
|
||||||
}
|
}
|
||||||
|
|
||||||
async function scanModuleDirectory(
|
async function scanModuleDirectory(
|
||||||
@@ -510,15 +614,16 @@ async function scanModuleDirectory(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const entrypoints = await resolveEntrypoints(moduleDirectory, directoryName, manifest, issues);
|
const artifacts = await resolveEntrypoints(moduleDirectory, directoryName, manifest, issues);
|
||||||
if (!entrypoints) {
|
if (!artifacts) {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
contributions: artifacts.contributions,
|
||||||
directory: canonicalModuleDirectory,
|
directory: canonicalModuleDirectory,
|
||||||
directoryName,
|
directoryName,
|
||||||
entrypoints,
|
entrypoints: artifacts.entrypoints,
|
||||||
manifest,
|
manifest,
|
||||||
manifestPath: canonicalManifestPath,
|
manifestPath: canonicalManifestPath,
|
||||||
packageName: packageData.name,
|
packageName: packageData.name,
|
||||||
@@ -617,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);
|
||||||
}
|
}
|
||||||
@@ -625,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,
|
||||||
|
});
|
||||||
|
}
|
||||||
+70
-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,
|
||||||
@@ -60,6 +62,7 @@ export type ModuleRuntimeIssueCode =
|
|||||||
| "MODULE_ROLLBACK_TIMEOUT"
|
| "MODULE_ROLLBACK_TIMEOUT"
|
||||||
| "MODULE_ROUTE_DEFINITION_INVALID"
|
| "MODULE_ROUTE_DEFINITION_INVALID"
|
||||||
| "MODULE_ROUTE_DUPLICATE"
|
| "MODULE_ROUTE_DUPLICATE"
|
||||||
|
| "MODULE_ROUTE_BODY_MODE_INVALID"
|
||||||
| "MODULE_ROUTE_MAX_BODY_BYTES_INVALID"
|
| "MODULE_ROUTE_MAX_BODY_BYTES_INVALID"
|
||||||
| "MODULE_ROUTE_METHOD_INVALID"
|
| "MODULE_ROUTE_METHOD_INVALID"
|
||||||
| "MODULE_ROUTE_OVERLAP"
|
| "MODULE_ROUTE_OVERLAP"
|
||||||
@@ -67,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"
|
||||||
@@ -104,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.
|
||||||
*/
|
*/
|
||||||
@@ -123,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;
|
||||||
}
|
}
|
||||||
@@ -163,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 {
|
||||||
@@ -174,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[];
|
||||||
@@ -315,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);
|
||||||
@@ -467,7 +497,8 @@ class StagedModuleHttpRoutes {
|
|||||||
shapes.add(shapeKey);
|
shapes.add(shapeKey);
|
||||||
local.push(
|
local.push(
|
||||||
Object.freeze({
|
Object.freeze({
|
||||||
handler: parsed.data.handler,
|
bodyMode: parsed.data.bodyMode,
|
||||||
|
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,
|
||||||
@@ -553,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 });
|
||||||
}
|
}
|
||||||
@@ -578,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]);
|
||||||
@@ -683,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,
|
||||||
@@ -696,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;
|
||||||
@@ -708,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") {
|
||||||
@@ -748,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,
|
||||||
@@ -764,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) {
|
||||||
@@ -829,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,14 +442,42 @@ 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) {
|
||||||
|
if (candidate.mode === "legacy") {
|
||||||
ownerByTable.set(candidate.table, candidate.moduleId);
|
ownerByTable.set(candidate.table, candidate.moduleId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
for (const inspected of inspectedModules) {
|
for (const inspected of inspectedModules) {
|
||||||
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,8 @@
|
|||||||
import type { PermissionDeclaration, PermissionId } from "@ekklesia/contracts";
|
import {
|
||||||
|
isVersionCompatible,
|
||||||
|
type PermissionDeclaration,
|
||||||
|
type PermissionId,
|
||||||
|
} from "@ekklesia/contracts";
|
||||||
import type { DiscoveredModule } from "./discovery.js";
|
import type { DiscoveredModule } from "./discovery.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -60,6 +64,34 @@ export interface PublishableModuleDisplay {
|
|||||||
readonly permissions: readonly PermissionDeclaration[];
|
readonly permissions: readonly PermissionDeclaration[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One installed module's browser contribution that the platform is willing to publish (ADR 0041).
|
||||||
|
*
|
||||||
|
* Publishable means three things, and nothing beyond them: the contributing module survived
|
||||||
|
* discovery and is in the eligible set, some **other** eligible module provides the targeted
|
||||||
|
* capability, and that provider's declared capability version satisfies the contribution's declared
|
||||||
|
* range. A contribution whose capability nobody provides, or whose provider moved outside its range,
|
||||||
|
* is simply absent — never published-but-broken, and never mounted with a shrug.
|
||||||
|
*
|
||||||
|
* Note what is *not* here. There is no payload schema, no slot, no ordering, no rendering hint, and
|
||||||
|
* no domain vocabulary of any kind: Core carries identity, version, compatibility, and one file
|
||||||
|
* path. What a contributed renderer draws, and what shape its input takes, are the consumer module's
|
||||||
|
* business and travel between the two modules in the browser without passing through the platform.
|
||||||
|
*/
|
||||||
|
export interface PublishableModuleContribution {
|
||||||
|
/** The module that *contributes* the renderer. */
|
||||||
|
readonly moduleId: string;
|
||||||
|
readonly moduleVersion: string;
|
||||||
|
readonly contributionId: string;
|
||||||
|
/** The contribution's own version, independent of its module's. */
|
||||||
|
readonly version: string;
|
||||||
|
readonly capability: string;
|
||||||
|
/** The version the providing module declares for that capability — the value the contribution's
|
||||||
|
* declared range was actually checked against. */
|
||||||
|
readonly capabilityVersion: string;
|
||||||
|
readonly artifactPath: string;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The platform's answer to "may this module's UI artifact be served, and if so, which exact file?"
|
* The platform's answer to "may this module's UI artifact be served, and if so, which exact file?"
|
||||||
*
|
*
|
||||||
@@ -89,6 +121,38 @@ export interface ModuleUiPublicationRegistry {
|
|||||||
* a display merely because a caller appends `/display` to its publication URL.
|
* a display merely because a caller appends `/display` to its publication URL.
|
||||||
*/
|
*/
|
||||||
resolveDisplay(moduleId: unknown, version: unknown): PublishableModuleDisplay | undefined;
|
resolveDisplay(moduleId: unknown, version: unknown): PublishableModuleDisplay | undefined;
|
||||||
|
/** Every publishable browser contribution, in discovery order then manifest order. Frozen. */
|
||||||
|
listContributions(): readonly PublishableModuleContribution[];
|
||||||
|
/**
|
||||||
|
* Every publishable contribution targeting one capability, in the same stable order.
|
||||||
|
*
|
||||||
|
* This is the lookup a *consumer* module's descriptor is built from, and the reason a consumer can
|
||||||
|
* only ever learn about renderers aimed at a capability it actually provides: the caller passes
|
||||||
|
* its own declared capability id and gets back exactly the contributions for it.
|
||||||
|
*/
|
||||||
|
contributionsForCapability(capabilityId: unknown): readonly PublishableModuleContribution[];
|
||||||
|
/**
|
||||||
|
* Every publishable contribution aimed at **any** capability the named module provides.
|
||||||
|
*
|
||||||
|
* This is the one lookup a descriptor for a consuming module is built from. Framing it per module
|
||||||
|
* rather than per capability is what keeps capability ownership inside Core: a caller hands over a
|
||||||
|
* module id and gets that module's own extension points filled in, with no way to ask about
|
||||||
|
* someone else's.
|
||||||
|
*/
|
||||||
|
contributionsForModule(moduleId: unknown): readonly PublishableModuleContribution[];
|
||||||
|
/**
|
||||||
|
* The publishable record for an exact contributing module id, installed module version, and
|
||||||
|
* contribution id, or `undefined`.
|
||||||
|
*
|
||||||
|
* All three must match. A contribution that is not installed, one whose module is at a different
|
||||||
|
* version, one whose capability has no compatible provider, and an id that was never declared are
|
||||||
|
* all indistinguishable here — each returns `undefined`.
|
||||||
|
*/
|
||||||
|
resolveContribution(
|
||||||
|
moduleId: unknown,
|
||||||
|
moduleVersion: unknown,
|
||||||
|
contributionId: unknown,
|
||||||
|
): PublishableModuleContribution | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -124,6 +188,41 @@ export function createModuleUiPublicationRegistry(
|
|||||||
): ModuleUiPublicationRegistry {
|
): ModuleUiPublicationRegistry {
|
||||||
const byModuleId = new Map<string, PublishableModuleUi>();
|
const byModuleId = new Map<string, PublishableModuleUi>();
|
||||||
const displaysByModuleId = new Map<string, PublishableModuleDisplay>();
|
const displaysByModuleId = new Map<string, PublishableModuleDisplay>();
|
||||||
|
const contributions: PublishableModuleContribution[] = [];
|
||||||
|
// Every capability the eligible set actually provides, at the version its provider declares. Built
|
||||||
|
// from the same list everything else here is built from, so a contribution can never be checked
|
||||||
|
// against a provider the platform is not running.
|
||||||
|
const capabilityVersions = new Map<string, string>();
|
||||||
|
for (const module of modules) {
|
||||||
|
for (const capability of module.manifest.capabilities) {
|
||||||
|
capabilityVersions.set(capability.id, capability.version);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const module of modules) {
|
||||||
|
for (const contribution of module.contributions) {
|
||||||
|
const capabilityVersion = capabilityVersions.get(contribution.capability);
|
||||||
|
// Fails closed on both halves: an absent provider and an incompatible one are equally
|
||||||
|
// unpublishable, so a range mismatch can never be papered over by mounting anyway.
|
||||||
|
if (
|
||||||
|
capabilityVersion === undefined ||
|
||||||
|
!isVersionCompatible(capabilityVersion, contribution.capabilityRange)
|
||||||
|
) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
contributions.push(
|
||||||
|
Object.freeze({
|
||||||
|
artifactPath: contribution.artifactPath,
|
||||||
|
capability: contribution.capability,
|
||||||
|
capabilityVersion,
|
||||||
|
contributionId: contribution.id,
|
||||||
|
moduleId: module.manifest.id,
|
||||||
|
moduleVersion: module.manifest.version,
|
||||||
|
version: contribution.version,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
for (const module of modules) {
|
for (const module of modules) {
|
||||||
const artifactPath = module.entrypoints.ui;
|
const artifactPath = module.entrypoints.ui;
|
||||||
@@ -160,8 +259,63 @@ export function createModuleUiPublicationRegistry(
|
|||||||
|
|
||||||
const published = Object.freeze([...byModuleId.values()]);
|
const published = Object.freeze([...byModuleId.values()]);
|
||||||
const publishedDisplays = Object.freeze([...displaysByModuleId.values()]);
|
const publishedDisplays = Object.freeze([...displaysByModuleId.values()]);
|
||||||
|
const publishedContributions = Object.freeze([...contributions]);
|
||||||
|
const contributionsByCapability = new Map<string, PublishableModuleContribution[]>();
|
||||||
|
const contributionsByKey = new Map<string, PublishableModuleContribution>();
|
||||||
|
for (const contribution of publishedContributions) {
|
||||||
|
const forCapability = contributionsByCapability.get(contribution.capability) ?? [];
|
||||||
|
forCapability.push(contribution);
|
||||||
|
contributionsByCapability.set(contribution.capability, forCapability);
|
||||||
|
// Module id and contribution id are both namespaced and unique, so this key cannot collide.
|
||||||
|
contributionsByKey.set(
|
||||||
|
`${contribution.moduleId}\u0000${contribution.contributionId}`,
|
||||||
|
contribution,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const frozenByCapability = new Map(
|
||||||
|
[...contributionsByCapability].map(([capability, list]) => [capability, Object.freeze(list)]),
|
||||||
|
);
|
||||||
|
const noContributions: readonly PublishableModuleContribution[] = Object.freeze([]);
|
||||||
|
const byConsumingModule = new Map<string, readonly PublishableModuleContribution[]>(
|
||||||
|
modules.map((module) => [
|
||||||
|
module.manifest.id,
|
||||||
|
Object.freeze(
|
||||||
|
module.manifest.capabilities.flatMap(
|
||||||
|
(capability) => contributionsByCapability.get(capability.id) ?? [],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
|
||||||
return Object.freeze({
|
return Object.freeze({
|
||||||
|
contributionsForCapability: (
|
||||||
|
capabilityId: unknown,
|
||||||
|
): readonly PublishableModuleContribution[] =>
|
||||||
|
typeof capabilityId === "string"
|
||||||
|
? (frozenByCapability.get(capabilityId) ?? noContributions)
|
||||||
|
: noContributions,
|
||||||
|
contributionsForModule: (moduleId: unknown): readonly PublishableModuleContribution[] =>
|
||||||
|
typeof moduleId === "string"
|
||||||
|
? (byConsumingModule.get(moduleId) ?? noContributions)
|
||||||
|
: noContributions,
|
||||||
|
listContributions: (): readonly PublishableModuleContribution[] => publishedContributions,
|
||||||
|
resolveContribution: (
|
||||||
|
moduleId: unknown,
|
||||||
|
moduleVersion: unknown,
|
||||||
|
contributionId: unknown,
|
||||||
|
): PublishableModuleContribution | undefined => {
|
||||||
|
if (
|
||||||
|
typeof moduleId !== "string" ||
|
||||||
|
typeof moduleVersion !== "string" ||
|
||||||
|
typeof contributionId !== "string"
|
||||||
|
) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const candidate = contributionsByKey.get(`${moduleId}\u0000${contributionId}`);
|
||||||
|
// Exact installed-version equality, exactly as the UI and display lookups require: a request
|
||||||
|
// naming a version the platform does not have installed resolves to nothing at all.
|
||||||
|
return candidate?.moduleVersion === moduleVersion ? candidate : undefined;
|
||||||
|
},
|
||||||
list: (): readonly PublishableModuleUi[] => published,
|
list: (): readonly PublishableModuleUi[] => published,
|
||||||
listDisplays: (): readonly PublishableModuleDisplay[] => publishedDisplays,
|
listDisplays: (): readonly PublishableModuleDisplay[] => publishedDisplays,
|
||||||
resolve: (moduleId: unknown, version: unknown): PublishableModuleUi | undefined => {
|
resolve: (moduleId: unknown, version: unknown): PublishableModuleUi | undefined => {
|
||||||
|
|||||||
@@ -177,6 +177,7 @@ async function applyCoreMigrations(url: string): Promise<void> {
|
|||||||
"core:0004_module_lifecycle_state",
|
"core:0004_module_lifecycle_state",
|
||||||
"core:0005_module_configuration",
|
"core:0005_module_configuration",
|
||||||
"core:0006_device_identity",
|
"core:0006_device_identity",
|
||||||
|
"core:0007_verified_bootstrap_owners",
|
||||||
]);
|
]);
|
||||||
await applyModuleMigrations({ aggregate, database: createPostgresMigrationDatabase(url) });
|
await applyModuleMigrations({ aggregate, database: createPostgresMigrationDatabase(url) });
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -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,7 +8,9 @@ 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(
|
||||||
|
"does not read DATABASE_URL, connect to PostgreSQL, or run bootstrap merely by importing the module",
|
||||||
|
async () => {
|
||||||
delete process.env.DATABASE_URL;
|
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
|
||||||
@@ -18,5 +21,7 @@ describe("bootstrap-platform-owner CLI import safety", () => {
|
|||||||
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,7 +8,9 @@ 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(
|
||||||
|
"does not read DATABASE_URL, connect to PostgreSQL, or create anything merely by importing the module",
|
||||||
|
async () => {
|
||||||
delete process.env.DATABASE_URL;
|
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
|
||||||
@@ -18,7 +21,9 @@ describe("create-initial-owner CLI import safety", () => {
|
|||||||
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: 6,
|
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: 6,
|
manifestVersion: 8,
|
||||||
name: fixture.name,
|
name: fixture.name,
|
||||||
version: "1.0.0",
|
version: "1.0.0",
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -176,6 +176,7 @@ async function applyCoreMigrations(url: string): Promise<void> {
|
|||||||
"core:0004_module_lifecycle_state",
|
"core:0004_module_lifecycle_state",
|
||||||
"core:0005_module_configuration",
|
"core:0005_module_configuration",
|
||||||
"core:0006_device_identity",
|
"core:0006_device_identity",
|
||||||
|
"core:0007_verified_bootstrap_owners",
|
||||||
]);
|
]);
|
||||||
await applyModuleMigrations({ aggregate, database: createPostgresMigrationDatabase(url) });
|
await applyModuleMigrations({ aggregate, database: createPostgresMigrationDatabase(url) });
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -0,0 +1,487 @@
|
|||||||
|
import type { ModuleManifest } from "@ekklesia/contracts";
|
||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import { createPermissionRegistry } from "../src/authorization/index.js";
|
||||||
|
import { createDoctorOptions } from "../src/cli/doctor.js";
|
||||||
|
import {
|
||||||
|
type DoctorCheck,
|
||||||
|
type DoctorDependencies,
|
||||||
|
diagnoseEkklesia,
|
||||||
|
formatDoctorReport,
|
||||||
|
} from "../src/diagnostics/index.js";
|
||||||
|
import {
|
||||||
|
type DiscoveredModule,
|
||||||
|
discoverModules,
|
||||||
|
type ModuleMigrationAggregate,
|
||||||
|
type ModuleSchemaAggregate,
|
||||||
|
} from "../src/modules/index.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The bug this suite exists for was operational, not theoretical: `pnpm doctor` built its own
|
||||||
|
* `DoctorOptions` and simply never included object storage or module secrets, so a correctly
|
||||||
|
* configured installation was told its storage was "not configured" and every module requiring a
|
||||||
|
* bucket was reported blocked — while `startApi`, from the identical environment, ran them.
|
||||||
|
*
|
||||||
|
* These tests therefore go through `createDoctorOptions`, the exact function the CLI calls, with an
|
||||||
|
* environment fixture rather than a hand-built options object. A helper-only test would have passed
|
||||||
|
* happily on the broken code.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const databaseUrl = "postgres://[email protected]:5432/ekklesia";
|
||||||
|
const accessKey = "AKIAEXAMPLEACCESSID";
|
||||||
|
const secretKey = "sk-example-secret-material";
|
||||||
|
const smtpPassword = "sup3r-secret-relay-password";
|
||||||
|
const identitySecret = "b7QwZ2rTn4Xs9LmVpKdF6yHc3JgEuNaW";
|
||||||
|
const moduleSecret = "module-secret-material";
|
||||||
|
|
||||||
|
const metadata = Object.freeze({
|
||||||
|
moduleSdkVersion: "0.1.0",
|
||||||
|
platformVersion: "0.1.0",
|
||||||
|
repositoryRoot: "/not-rendered",
|
||||||
|
runtimeRange: ">=24.11.0 <25",
|
||||||
|
runtimeVersion: "24.15.0",
|
||||||
|
});
|
||||||
|
|
||||||
|
function environment(
|
||||||
|
overrides: Readonly<Record<string, string | undefined>> = {},
|
||||||
|
): Readonly<Record<string, string | undefined>> {
|
||||||
|
return {
|
||||||
|
DATABASE_URL: databaseUrl,
|
||||||
|
EKKLESIA_IDENTITY_BASE_URL: "https://ekklesia.example.org",
|
||||||
|
EKKLESIA_IDENTITY_SECRET: identitySecret,
|
||||||
|
EKKLESIA_IDENTITY_TRUSTED_ORIGINS: "https://ekklesia.example.org",
|
||||||
|
NODE_ENV: "production",
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const storageEnvironment = Object.freeze({
|
||||||
|
S3_ACCESS_KEY: accessKey,
|
||||||
|
S3_ENDPOINT: "https://storage.example.org",
|
||||||
|
S3_SECRET_KEY: secretKey,
|
||||||
|
});
|
||||||
|
|
||||||
|
const mailEnvironment = Object.freeze({
|
||||||
|
EKKLESIA_SMTP_FROM_ADDRESS: "[email protected]",
|
||||||
|
EKKLESIA_SMTP_HOST: "smtp.example.org",
|
||||||
|
EKKLESIA_SMTP_PASSWORD: smtpPassword,
|
||||||
|
EKKLESIA_SMTP_SECURITY: "starttls",
|
||||||
|
EKKLESIA_SMTP_USERNAME: "[email protected]",
|
||||||
|
});
|
||||||
|
|
||||||
|
function discoveredModule(id: string, overrides: Record<string, unknown> = {}): DiscoveredModule {
|
||||||
|
return {
|
||||||
|
contributions: [],
|
||||||
|
directory: `/unpublished/${id}`,
|
||||||
|
entrypoints: { server: `/unpublished/${id}/server.js` },
|
||||||
|
manifest: {
|
||||||
|
capabilities: [],
|
||||||
|
compatibility: { moduleSdk: "^0.1.0", platform: "^0.1.0" },
|
||||||
|
configuration: [],
|
||||||
|
dependencies: {
|
||||||
|
capabilities: { optional: {}, required: {} },
|
||||||
|
modules: { optional: {}, required: {} },
|
||||||
|
},
|
||||||
|
entrypoints: { server: "./dist/server.js" },
|
||||||
|
id,
|
||||||
|
manifestVersion: 8,
|
||||||
|
name: `${id} module`,
|
||||||
|
permissions: [],
|
||||||
|
storage: { buckets: [] },
|
||||||
|
version: "0.1.0",
|
||||||
|
...overrides,
|
||||||
|
} as unknown as ModuleManifest,
|
||||||
|
manifestPath: `/unpublished/${id}/ekklesia.module.json`,
|
||||||
|
packageName: `@test/${id}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const storingModule = discoveredModule("announcements", {
|
||||||
|
storage: { buckets: [{ id: "announcement-assets" }] },
|
||||||
|
});
|
||||||
|
|
||||||
|
const secretRequiringModule = discoveredModule("announcements", {
|
||||||
|
configuration: [{ key: "storageCredential", required: true, type: "secret" }],
|
||||||
|
});
|
||||||
|
|
||||||
|
function schemaAggregate(): ModuleSchemaAggregate {
|
||||||
|
return Object.freeze({
|
||||||
|
discoveredModules: Object.freeze([]),
|
||||||
|
drizzleSchema: Object.freeze({}),
|
||||||
|
modules: Object.freeze([]),
|
||||||
|
tables: Object.freeze([]),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function migrationAggregate(): ModuleMigrationAggregate {
|
||||||
|
return Object.freeze({
|
||||||
|
discoveredModules: Object.freeze([]),
|
||||||
|
migrations: Object.freeze([]),
|
||||||
|
modules: Object.freeze([]),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function dependencies(overrides: Partial<DoctorDependencies> = {}): DoctorDependencies {
|
||||||
|
return {
|
||||||
|
composePermissions: vi.fn((modules: readonly DiscoveredModule[]) =>
|
||||||
|
createPermissionRegistry(
|
||||||
|
modules.flatMap((discovered) => [...discovered.manifest.permissions]),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
createMigrationDatabase: vi.fn(() => {
|
||||||
|
throw new Error("not used");
|
||||||
|
}),
|
||||||
|
discoverMigrations: vi.fn(async () => migrationAggregate()),
|
||||||
|
discoverModules: vi.fn(async () => Object.freeze([])),
|
||||||
|
discoverSchemas: vi.fn(async () => schemaAggregate()),
|
||||||
|
planMigrations: vi.fn(async () => {
|
||||||
|
throw new Error("not used");
|
||||||
|
}),
|
||||||
|
probeMail: vi.fn(async () => undefined),
|
||||||
|
probeStorage: vi.fn(async () => undefined),
|
||||||
|
readModuleConfiguration: vi.fn(async () => Object.freeze({ records: Object.freeze([]) })),
|
||||||
|
readModuleLifecycle: vi.fn(async () => Object.freeze({ modules: Object.freeze([]) })),
|
||||||
|
runtimeSatisfies: vi.fn(() => true),
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function diagnose(
|
||||||
|
env: Readonly<Record<string, string | undefined>>,
|
||||||
|
services: DoctorDependencies = dependencies(),
|
||||||
|
): Promise<{ readonly report: Awaited<ReturnType<typeof diagnoseEkklesia>> }> {
|
||||||
|
return { report: await diagnoseEkklesia(createDoctorOptions(env, metadata), services) };
|
||||||
|
}
|
||||||
|
|
||||||
|
function check(
|
||||||
|
report: Awaited<ReturnType<typeof diagnoseEkklesia>>,
|
||||||
|
id: DoctorCheck["id"],
|
||||||
|
): DoctorCheck {
|
||||||
|
const found = report.checks.find((candidate) => candidate.id === id);
|
||||||
|
if (!found) {
|
||||||
|
throw new Error(`Missing test check ${id}.`);
|
||||||
|
}
|
||||||
|
return found;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("Doctor host-configuration parity — object storage", () => {
|
||||||
|
it("recognizes configured object storage from the deployment environment", async () => {
|
||||||
|
const services = dependencies();
|
||||||
|
|
||||||
|
const { report } = await diagnose(environment(storageEnvironment), services);
|
||||||
|
const integration = check(report, "object-storage.integration");
|
||||||
|
|
||||||
|
// The regression itself: before this pass the CLI passed no storage at all and this read
|
||||||
|
// "skipped · Object storage not configured" from exactly this environment.
|
||||||
|
expect(integration.status).toBe("pass");
|
||||||
|
expect(integration.summary).toBe("Object storage reachable");
|
||||||
|
expect(services.probeStorage).toHaveBeenCalledWith({
|
||||||
|
accessKey,
|
||||||
|
bucketPrefix: "ekklesia",
|
||||||
|
deliveryEndpoint: "https://storage.example.org",
|
||||||
|
endpoint: "https://storage.example.org",
|
||||||
|
forcePathStyle: true,
|
||||||
|
region: "us-east-1",
|
||||||
|
secretKey,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still reports genuinely absent storage as absent", async () => {
|
||||||
|
const services = dependencies();
|
||||||
|
|
||||||
|
const { report } = await diagnose(environment(), services);
|
||||||
|
const integration = check(report, "object-storage.integration");
|
||||||
|
|
||||||
|
expect(integration.status).toBe("skipped");
|
||||||
|
expect(integration.summary).toBe("Object storage not configured");
|
||||||
|
expect(services.probeStorage).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports a half-configured provider as a configuration failure, naming no value", async () => {
|
||||||
|
const services = dependencies();
|
||||||
|
|
||||||
|
const { report } = await diagnose(
|
||||||
|
environment({ S3_ENDPOINT: "https://storage.example.org" }),
|
||||||
|
services,
|
||||||
|
);
|
||||||
|
const integration = check(report, "object-storage.integration");
|
||||||
|
|
||||||
|
// `startApi` refuses to start on this input, so the deployment is genuinely not ready — this is
|
||||||
|
// a failure rather than the warning an unreachable-but-valid provider gets.
|
||||||
|
expect(integration.status).toBe("failure");
|
||||||
|
expect(integration.details[0]).toBe(
|
||||||
|
"The object-storage settings were rejected: STORAGE_ACCESS_KEY_MISSING.",
|
||||||
|
);
|
||||||
|
expect(report.result).toBe("not-ready");
|
||||||
|
// Nothing was connected to: a malformed configuration must not produce network traffic.
|
||||||
|
expect(services.probeStorage).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports a malformed endpoint safely rather than crashing the command", async () => {
|
||||||
|
const { report } = await diagnose(
|
||||||
|
environment({ ...storageEnvironment, S3_ENDPOINT: "not-a-url" }),
|
||||||
|
);
|
||||||
|
const integration = check(report, "object-storage.integration");
|
||||||
|
|
||||||
|
expect(integration.status).toBe("failure");
|
||||||
|
expect(integration.details[0]).toBe(
|
||||||
|
"The object-storage settings were rejected: STORAGE_ENDPOINT_INVALID.",
|
||||||
|
);
|
||||||
|
expect(JSON.stringify(integration)).not.toContain("not-a-url");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses a plaintext production endpoint exactly as startup would", async () => {
|
||||||
|
const { report } = await diagnose(
|
||||||
|
environment({ ...storageEnvironment, S3_ENDPOINT: "http://storage.example.org" }),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(check(report, "object-storage.integration").details[0]).toBe(
|
||||||
|
"The object-storage settings were rejected: STORAGE_ENDPOINT_INSECURE_IN_PRODUCTION.",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("applies the same region and bucket-prefix defaults the runtime applies", async () => {
|
||||||
|
const services = dependencies();
|
||||||
|
|
||||||
|
await diagnose(environment(storageEnvironment), services);
|
||||||
|
|
||||||
|
expect(services.probeStorage).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
bucketPrefix: "ekklesia",
|
||||||
|
forcePathStyle: true,
|
||||||
|
region: "us-east-1",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("defaults the browser delivery origin to the endpoint, and flags a separate one", async () => {
|
||||||
|
const { report: shared } = await diagnose(environment(storageEnvironment));
|
||||||
|
expect(check(shared, "object-storage.integration").details).toHaveLength(1);
|
||||||
|
|
||||||
|
const { report: separate } = await diagnose(
|
||||||
|
environment({ ...storageEnvironment, S3_DELIVERY_ENDPOINT: "https://files.example.org" }),
|
||||||
|
);
|
||||||
|
const integration = check(separate, "object-storage.integration");
|
||||||
|
expect(integration.details[0]).toContain("browser delivery https://files.example.org");
|
||||||
|
expect(integration.details[1]).toContain("was not probed");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("binds a module's declared bucket instead of reporting it blocked", async () => {
|
||||||
|
const services = dependencies({
|
||||||
|
discoverModules: vi.fn(async () => Object.freeze([storingModule])),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { report } = await diagnose(environment(storageEnvironment), services);
|
||||||
|
|
||||||
|
// The operator-visible consequence of the bug: this module was reported blocked by a Doctor run
|
||||||
|
// against the very environment that ran it.
|
||||||
|
expect(check(report, "object-storage.bindings").status).toBe("pass");
|
||||||
|
expect(check(report, "modules.lifecycle").details).toContain("announcements 0.1.0 · enabled");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still reports a bucket as unbound when no provider is configured", async () => {
|
||||||
|
const services = dependencies({
|
||||||
|
discoverModules: vi.fn(async () => Object.freeze([storingModule])),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { report } = await diagnose(environment(), services);
|
||||||
|
|
||||||
|
expect(check(report, "object-storage.bindings").status).toBe("failure");
|
||||||
|
expect(check(report, "modules.lifecycle").details).toContain(
|
||||||
|
"announcements 0.1.0 · blocked · storage_unavailable · announcement-assets",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("treats a malformed provider as unusable when deciding module eligibility", async () => {
|
||||||
|
const services = dependencies({
|
||||||
|
discoverModules: vi.fn(async () => Object.freeze([storingModule])),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { report } = await diagnose(
|
||||||
|
environment({ S3_ENDPOINT: "https://storage.example.org" }),
|
||||||
|
services,
|
||||||
|
);
|
||||||
|
|
||||||
|
// A provider that cannot be constructed cannot bind a bucket, and saying otherwise would report
|
||||||
|
// a module as runnable that startup would refuse to start at all.
|
||||||
|
expect(check(report, "object-storage.bindings").status).toBe("failure");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Doctor host-configuration parity — module secrets", () => {
|
||||||
|
it("sees a secret the deployment actually set", async () => {
|
||||||
|
const services = dependencies({
|
||||||
|
discoverModules: vi.fn(async () => Object.freeze([secretRequiringModule])),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { report } = await diagnose(
|
||||||
|
environment({
|
||||||
|
EKKLESIA_MODULE_SECRET__ANNOUNCEMENTS__STORAGE_CREDENTIAL: moduleSecret,
|
||||||
|
}),
|
||||||
|
services,
|
||||||
|
);
|
||||||
|
const configuration = check(report, "modules.configuration");
|
||||||
|
|
||||||
|
// Same class of bug as storage: the CLI never passed a secret source, so a configured secret
|
||||||
|
// was always reported missing.
|
||||||
|
expect(configuration.status).toBe("pass");
|
||||||
|
expect(check(report, "modules.lifecycle").details).toContain("announcements 0.1.0 · enabled");
|
||||||
|
expect(JSON.stringify(report)).not.toContain(moduleSecret);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still reports a genuinely unset secret as missing", async () => {
|
||||||
|
const services = dependencies({
|
||||||
|
discoverModules: vi.fn(async () => Object.freeze([secretRequiringModule])),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { report } = await diagnose(environment(), services);
|
||||||
|
|
||||||
|
expect(check(report, "modules.configuration").status).toBe("warning");
|
||||||
|
expect(check(report, "modules.lifecycle").details).toContain(
|
||||||
|
"announcements 0.1.0 · blocked · secret_missing · storageCredential",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Doctor host-configuration parity — outbound email regression", () => {
|
||||||
|
it("leaves an installation with no SMTP host reported as complete", async () => {
|
||||||
|
const services = dependencies();
|
||||||
|
|
||||||
|
const { report } = await diagnose(environment(), services);
|
||||||
|
const mailCheck = check(report, "outbound-email.configuration");
|
||||||
|
|
||||||
|
expect(mailCheck.status).toBe("skipped");
|
||||||
|
expect(mailCheck.summary).toBe("Outbound email not configured");
|
||||||
|
expect(services.probeMail).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves a configured relay reported as reachable, without its password", async () => {
|
||||||
|
const services = dependencies();
|
||||||
|
|
||||||
|
const { report } = await diagnose(environment(mailEnvironment), services);
|
||||||
|
const mailCheck = check(report, "outbound-email.configuration");
|
||||||
|
|
||||||
|
expect(mailCheck.status).toBe("pass");
|
||||||
|
expect(services.probeMail).toHaveBeenCalledTimes(1);
|
||||||
|
expect(JSON.stringify(mailCheck)).not.toContain(smtpPassword);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("probes with the SMTP client identity the runtime would use, and reports it", async () => {
|
||||||
|
const services = dependencies();
|
||||||
|
|
||||||
|
const { report } = await diagnose(
|
||||||
|
environment({ ...mailEnvironment, EKKLESIA_SMTP_HELO_NAME: "mail.example.org" }),
|
||||||
|
services,
|
||||||
|
);
|
||||||
|
const mailCheck = check(report, "outbound-email.configuration");
|
||||||
|
|
||||||
|
// Doctor builds its provider from the same validated configuration `startApi` does, so a relay
|
||||||
|
// that refuses the greeting fails here rather than only when a reset email silently never
|
||||||
|
// arrives. The name is announced in the clear on every connection, so showing it leaks nothing.
|
||||||
|
expect(services.probeMail).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ heloName: "mail.example.org" }),
|
||||||
|
);
|
||||||
|
expect(mailCheck.details[0]).toContain("HELO mail.example.org");
|
||||||
|
expect(JSON.stringify(mailCheck)).not.toContain(smtpPassword);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports a malformed SMTP client identity as a typed failure, naming no value", async () => {
|
||||||
|
const { report } = await diagnose(
|
||||||
|
environment({ ...mailEnvironment, EKKLESIA_SMTP_HELO_NAME: "smtp://internal-host:2525" }),
|
||||||
|
);
|
||||||
|
const mailCheck = check(report, "outbound-email.configuration");
|
||||||
|
|
||||||
|
expect(mailCheck.status).toBe("failure");
|
||||||
|
expect(mailCheck.details[0]).toBe("The SMTP settings were rejected: MAIL_HELO_NAME_INVALID.");
|
||||||
|
expect(JSON.stringify(mailCheck)).not.toContain("internal-host");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves a malformed SMTP setting reported as a typed configuration failure", async () => {
|
||||||
|
const { report } = await diagnose(
|
||||||
|
environment({ ...mailEnvironment, EKKLESIA_SMTP_PORT: "70000" }),
|
||||||
|
);
|
||||||
|
const mailCheck = check(report, "outbound-email.configuration");
|
||||||
|
|
||||||
|
expect(mailCheck.status).toBe("failure");
|
||||||
|
expect(mailCheck.details[0]).toBe("The SMTP settings were rejected: MAIL_PORT_INVALID.");
|
||||||
|
expect(JSON.stringify(mailCheck)).not.toContain("70000");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still refuses a plaintext production relay", async () => {
|
||||||
|
const { report } = await diagnose(
|
||||||
|
environment({ ...mailEnvironment, EKKLESIA_SMTP_SECURITY: "plaintext" }),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(check(report, "outbound-email.configuration").details[0]).toBe(
|
||||||
|
"The SMTP settings were rejected: MAIL_SECURITY_INSECURE_IN_PRODUCTION.",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Doctor host-configuration parity — sanitization", () => {
|
||||||
|
it("prints no secret material anywhere in a fully configured report", async () => {
|
||||||
|
const services = dependencies({
|
||||||
|
discoverModules: vi.fn(async () => Object.freeze([storingModule])),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { report } = await diagnose(
|
||||||
|
environment({
|
||||||
|
...mailEnvironment,
|
||||||
|
...storageEnvironment,
|
||||||
|
EKKLESIA_MODULE_SECRET__ANNOUNCEMENTS__STORAGE_CREDENTIAL: moduleSecret,
|
||||||
|
}),
|
||||||
|
services,
|
||||||
|
);
|
||||||
|
const rendered = formatDoctorReport(report).join("\n");
|
||||||
|
|
||||||
|
for (const secret of [accessKey, secretKey, smtpPassword, identitySecret, moduleSecret]) {
|
||||||
|
expect(rendered).not.toContain(secret);
|
||||||
|
expect(JSON.stringify(report)).not.toContain(secret);
|
||||||
|
}
|
||||||
|
// The infrastructure host names are deliberately still shown: they carry no credential and are
|
||||||
|
// what an operator needs in order to act on the report.
|
||||||
|
expect(rendered).toContain("https://storage.example.org");
|
||||||
|
expect(rendered).toContain("smtp.example.org");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never serializes the environment object wholesale", async () => {
|
||||||
|
const services = dependencies();
|
||||||
|
|
||||||
|
const { report } = await diagnose(
|
||||||
|
environment({ ...storageEnvironment, TOTALLY_UNRELATED_SECRET: "unrelated-value" }),
|
||||||
|
services,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(JSON.stringify(report)).not.toContain("unrelated-value");
|
||||||
|
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,7 +23,9 @@ 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(
|
||||||
|
"does not discover, connect, plan, or print when imported",
|
||||||
|
async () => {
|
||||||
const log = vi.spyOn(console, "log").mockImplementation(() => undefined);
|
const log = vi.spyOn(console, "log").mockImplementation(() => undefined);
|
||||||
const error = vi.spyOn(console, "error").mockImplementation(() => undefined);
|
const error = vi.spyOn(console, "error").mockImplementation(() => undefined);
|
||||||
|
|
||||||
@@ -41,5 +44,7 @@ describe("Doctor import safety", () => {
|
|||||||
|
|
||||||
log.mockRestore();
|
log.mockRestore();
|
||||||
error.mockRestore();
|
error.mockRestore();
|
||||||
});
|
},
|
||||||
|
IMPORT_SAFETY_TEST_TIMEOUT_MS,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user