Compare commits
56
Commits
v0.1.0
..
9ddb5e2796
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
@@ -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
|
||||||
@@ -10,6 +10,12 @@
|
|||||||
# The plaintext loopback DATABASE_URL below is therefore only usable with this set.
|
# The plaintext loopback DATABASE_URL below is therefore only usable with this set.
|
||||||
NODE_ENV=development
|
NODE_ENV=development
|
||||||
|
|
||||||
|
# Installed modules (ADR 0048). Unset, the checkout's own modules/ is used, as module development
|
||||||
|
# and the platform's tests expect. Set, it must be an absolute path to an existing directory;
|
||||||
|
# anything else stops the API and every module command with a sanitized error. The production
|
||||||
|
# image sets /var/lib/ekklesia/modules.
|
||||||
|
# EKKLESIA_MODULES_DIR=/absolute/path/to/modules
|
||||||
|
|
||||||
POSTGRES_DB=ekklesia
|
POSTGRES_DB=ekklesia
|
||||||
POSTGRES_USER=ekklesia
|
POSTGRES_USER=ekklesia
|
||||||
POSTGRES_PASSWORD=ekklesia-development-only
|
POSTGRES_PASSWORD=ekklesia-development-only
|
||||||
|
|||||||
+119
-12
@@ -1,18 +1,13 @@
|
|||||||
name: CI
|
name: CI
|
||||||
|
|
||||||
# IMPORTANT — the currently inventoried server is Gitea 1.24.2, which *ignores* several keys used
|
# The server runs Gitea 1.27.2 (docs/development/security.md, "What this server honours"):
|
||||||
# below. Per https://docs.gitea.com/1.24/usage/actions/comparison, its unsupported workflow syntax
|
|
||||||
# includes `permissions` / `jobs.<job_id>.permissions`, `concurrency`, and
|
|
||||||
# `jobs.<job_id>.timeout-minutes`. They are declared here deliberately, so the workflow is already
|
|
||||||
# correct on a supported release and on GitHub Actions, but on this server they are inert:
|
|
||||||
#
|
#
|
||||||
# * `permissions:` does NOT restrict the in-job `GITEA_TOKEN`. Job-token permission controls,
|
# * `concurrency:` is honoured: a newer push to a pull request cancels the run it supersedes, so a
|
||||||
# repository/owner defaults, and server-side maximum-permission clamping arrive in the 1.26
|
# cancelled run's own cleanup is never guaranteed (see the CI stack sweep in each stack job).
|
||||||
# feature line (https://docs.gitea.com/1.26/usage/actions/token-permissions). Until the server
|
# * `permissions:` is supported by 1.27 with Gitea's own scopes, but what the job token can do on
|
||||||
# is upgraded and configured, the token's authority is whatever the server grants — not what
|
# this server is not yet proven, so nothing here relies on it being read-only.
|
||||||
# this file requests.
|
# * `timeout-minutes:` is no longer listed as unsupported, but is not verified here either;
|
||||||
# * `concurrency:` and `timeout-minutes:` are NOT bounded-execution guarantees here. Cancellation,
|
# teardown relies on the stack sweep and the runner's own reaper.
|
||||||
# timeout, and teardown must be enforced by the trusted runner provisioner/reaper.
|
|
||||||
#
|
#
|
||||||
# `persist-credentials: false` below is unaffected by any of this and is enforced by the action
|
# `persist-credentials: false` below is unaffected by any of this and is enforced by the action
|
||||||
# itself. See docs/development/security.md.
|
# itself. See docs/development/security.md.
|
||||||
@@ -63,6 +58,9 @@ jobs:
|
|||||||
needs: verify
|
needs: verify
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 10
|
timeout-minutes: 10
|
||||||
|
env:
|
||||||
|
# Names this job's stacks, so its final step removes exactly these (scripts/lib/ci-project.sh).
|
||||||
|
EKKLESIA_CI_JOB_KEY: infrastructure-smoke
|
||||||
steps:
|
steps:
|
||||||
- name: Check out repository
|
- name: Check out repository
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
@@ -72,13 +70,29 @@ jobs:
|
|||||||
# in this workflow pushes, so the credential is never needed after checkout.
|
# in this workflow pushes, so the credential is never needed after checkout.
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
|
|
||||||
|
# A job the runner killed (cancelled, or past the runner's own time limit) never ran its
|
||||||
|
# cleanup. Anything a CI job left on this shared daemon more than three hours ago goes first.
|
||||||
|
- name: Sweep stale CI stacks from the runner
|
||||||
|
run: bash scripts/ci-sweep.sh --stale
|
||||||
|
|
||||||
|
- name: Prove the sweep removes exactly what it should, against real Docker resources
|
||||||
|
run: bash scripts/test-ci-sweep.sh
|
||||||
|
|
||||||
- name: Exercise PostgreSQL and S3 infrastructure
|
- name: Exercise PostgreSQL and S3 infrastructure
|
||||||
run: bash scripts/smoke-infrastructure.sh
|
run: bash scripts/smoke-infrastructure.sh
|
||||||
|
|
||||||
|
# Whatever happened above, this job's own stacks do not outlive it.
|
||||||
|
- name: Remove this job's CI stacks
|
||||||
|
if: always()
|
||||||
|
run: bash scripts/ci-sweep.sh --job
|
||||||
|
|
||||||
migration-smoke:
|
migration-smoke:
|
||||||
needs: verify
|
needs: verify
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 15
|
timeout-minutes: 15
|
||||||
|
env:
|
||||||
|
# Names this job's stacks, so its final step removes exactly these (scripts/lib/ci-project.sh).
|
||||||
|
EKKLESIA_CI_JOB_KEY: migration-smoke
|
||||||
steps:
|
steps:
|
||||||
- name: Check out repository
|
- name: Check out repository
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
@@ -88,6 +102,11 @@ jobs:
|
|||||||
# in this workflow pushes, so the credential is never needed after checkout.
|
# in this workflow pushes, so the credential is never needed after checkout.
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
|
|
||||||
|
# A job the runner killed (cancelled, or past the runner's own time limit) never ran its
|
||||||
|
# cleanup. Anything a CI job left on this shared daemon more than three hours ago goes first.
|
||||||
|
- name: Sweep stale CI stacks from the runner
|
||||||
|
run: bash scripts/ci-sweep.sh --stale
|
||||||
|
|
||||||
- name: Set up Node.js
|
- name: Set up Node.js
|
||||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||||
with:
|
with:
|
||||||
@@ -103,3 +122,91 @@ jobs:
|
|||||||
|
|
||||||
- name: Exercise transactional migrations against PostgreSQL
|
- name: Exercise transactional migrations against PostgreSQL
|
||||||
run: pnpm test:migrations:integration
|
run: pnpm test:migrations:integration
|
||||||
|
|
||||||
|
# Whatever happened above, this job's own stacks do not outlive it.
|
||||||
|
- name: Remove this job's CI stacks
|
||||||
|
if: always()
|
||||||
|
run: bash scripts/ci-sweep.sh --job
|
||||||
|
|
||||||
|
image-build:
|
||||||
|
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,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,22 @@ concurrency and release, exact destructive approvals, and sanitization against t
|
|||||||
adapter. A Core-owned `pnpm doctor` dashboard combines the existing static discovery and read-only
|
adapter. A Core-owned `pnpm doctor` dashboard combines the existing static discovery and read-only
|
||||||
migration-planning services into one concise readiness report.
|
migration-planning services into one concise readiness report.
|
||||||
|
|
||||||
|
**Ekklesia ships one generic image, and installed modules live outside it
|
||||||
|
([ADR 0048](docs/architecture/decisions/0048-generic-image-and-installed-module-storage.md)).** The
|
||||||
|
production image built from [`deploy/Dockerfile`](deploy/Dockerfile) contains zero feature modules
|
||||||
|
and is the same for every installation. Installed modules live in installation-owned module storage,
|
||||||
|
selected by `EKKLESIA_MODULES_DIR` (`/var/lib/ekklesia/modules` in the image, the checkout's
|
||||||
|
`modules/` when unset) and mounted read-only by the reference deployment. Each installed module is a
|
||||||
|
self-contained directory carrying its own production dependencies. Installing, updating, or removing
|
||||||
|
a module needs only a controlled restart and, when it has migrations, an explicit plan and apply.
|
||||||
|
Nothing is hot loaded. Removing a module's code keeps its data and its stored state; its applied
|
||||||
|
migration history is retained and reported, and blocks no other migration. A module is distributed
|
||||||
|
as one **Ekklesia Module Package v1**
|
||||||
|
([ADR 0049](docs/architecture/decisions/0049-module-package-v1.md)): a deterministic `.tar.gz` and
|
||||||
|
its SHA-256, produced by the canonical packager (`@ekklesia/module-pack`) with the module's backend
|
||||||
|
bundled, and checked by Core's verifier before it is installed. Only the portable `any` target exists
|
||||||
|
so far. Purge, native-target packages, signing, and any installer or marketplace do not exist yet.
|
||||||
|
|
||||||
Phase 4A added Core-owned identity foundations: a Core-owned Better Auth database schema
|
Phase 4A added Core-owned identity foundations: a Core-owned Better Auth database schema
|
||||||
(`ekklesia_core_user`, `ekklesia_core_session`, `ekklesia_core_account`,
|
(`ekklesia_core_user`, `ekklesia_core_session`, `ekklesia_core_account`,
|
||||||
`ekklesia_core_verification`), a first-class Core-owned migration source that coexists with
|
`ekklesia_core_verification`), a first-class Core-owned migration source that coexists with
|
||||||
@@ -218,9 +234,10 @@ undeployed state, and the launch checklist in a single place. **CI is accepted a
|
|||||||
trusted-code-only boundary**: the self-hosted Gitea runner executes only code controlled and
|
trusted-code-only boundary**: the self-hosted Gitea runner executes only code controlled and
|
||||||
reviewed by trusted repository collaborators, and unknown or untrusted pull requests must not
|
reviewed by trusted repository collaborators, and unknown or untrusted pull requests must not
|
||||||
execute on it automatically. What remains is operational and cannot be proven from this repository —
|
execute on it automatically. What remains is operational and cannot be proven from this repository —
|
||||||
upgrading Gitea past 1.24.2 (which *ignores* the workflow's `permissions`, `concurrency`, and
|
configuring and proving job-token authority server-side (the server now runs Gitea 1.27.2, which
|
||||||
`timeout-minutes` declarations), clamping job-token authority server-side, enforcing timeout and
|
honours `concurrency` and documents `permissions`, but neither token limits nor `timeout-minutes` are
|
||||||
teardown in a trusted provisioner/reaper, and proving disposable runner isolation. Those are
|
verified on it yet), enforcing timeout and teardown in a trusted provisioner/reaper, and proving
|
||||||
|
disposable runner isolation. Those are
|
||||||
prerequisites for **executing untrusted contributions**, alongside the deployment checklist that
|
prerequisites for **executing untrusted contributions**, alongside the deployment checklist that
|
||||||
gates a **public launch**; moving future public contribution workflows to a service with isolated
|
gates a **public launch**; moving future public contribution workflows to a service with isolated
|
||||||
hosted runners is an acceptable alternative. **Neither set of requirements blocks Phase 6** — Surface
|
hosted runners is an acceptable alternative. **Neither set of requirements blocks Phase 6** — Surface
|
||||||
@@ -1055,5 +1072,27 @@ environment.
|
|||||||
- [Module development](docs/module-development/README.md)
|
- [Module development](docs/module-development/README.md)
|
||||||
- [Architecture decisions](docs/architecture/decisions/README.md)
|
- [Architecture decisions](docs/architecture/decisions/README.md)
|
||||||
|
|
||||||
No open-source license has been selected yet. A license should be added only after the project
|
## License
|
||||||
owner makes that decision.
|
|
||||||
|
Ekklesia is licensed under the **GNU Affero General Public License, version 3 or any later version**
|
||||||
|
(SPDX `AGPL-3.0-or-later`). The full text of version 3 is in [LICENSE](LICENSE). Its source is
|
||||||
|
available to you under the terms of that license.
|
||||||
|
|
||||||
|
> Ekklesia is free software: you can redistribute it and/or modify it under the terms of the GNU
|
||||||
|
> Affero General Public License as published by the Free Software Foundation, either version 3 of
|
||||||
|
> the License, or (at your option) any later version.
|
||||||
|
>
|
||||||
|
> Ekklesia is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
|
||||||
|
> the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero
|
||||||
|
> General Public License for more details.
|
||||||
|
>
|
||||||
|
> You should have received a copy of the GNU Affero General Public License along with Ekklesia. If
|
||||||
|
> not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
Section 13 of the license adds an obligation for network use: if you modify Ekklesia and run your
|
||||||
|
modified version for users who interact with it remotely over a network, your version must offer
|
||||||
|
those users the opportunity to receive its Corresponding Source.
|
||||||
|
|
||||||
|
Official Ekklesia modules, published by the Ekklesia project, are licensed under `AGPL-3.0-or-later`
|
||||||
|
as well. Each module's own repository declares its license. The module template declares it in its
|
||||||
|
`package.json`, so a module started from the template begins with the same license.
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "@ekklesia/api",
|
"name": "@ekklesia/api",
|
||||||
"version": "0.1.0",
|
"version": "0.1.2",
|
||||||
|
"license": "AGPL-3.0-or-later",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"files": [
|
"files": [
|
||||||
|
|||||||
@@ -27,10 +27,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 } 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";
|
||||||
@@ -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({
|
||||||
|
|||||||
@@ -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;
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -200,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;
|
||||||
|
}
|
||||||
@@ -26,11 +26,31 @@ export interface ApiDatabaseHandle {
|
|||||||
readonly module: ModuleDatabase;
|
readonly module: ModuleDatabase;
|
||||||
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
|
||||||
@@ -58,5 +78,8 @@ export function createApiDatabase(
|
|||||||
module: createModuleDatabaseView(pool),
|
module: createModuleDatabaseView(pool),
|
||||||
moduleConfiguration: createModuleConfigurationView(pool),
|
moduleConfiguration: createModuleConfigurationView(pool),
|
||||||
moduleLifecycle: createModuleLifecycleView(pool),
|
moduleLifecycle: createModuleLifecycleView(pool),
|
||||||
|
ping: async (statementTimeoutMs: number) => {
|
||||||
|
await pool.query(pingStatement(statementTimeoutMs));
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ 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 {
|
export {
|
||||||
MODULE_DISPLAY_DESCRIPTOR_PREFIX,
|
MODULE_DISPLAY_DESCRIPTOR_PREFIX,
|
||||||
@@ -100,20 +101,6 @@ 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. */
|
||||||
|
|||||||
@@ -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,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
+39
-6
@@ -44,10 +44,12 @@ import {
|
|||||||
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;
|
||||||
@@ -152,8 +154,12 @@ export interface StartApiOptions {
|
|||||||
* test needs somewhere to observe the operational failure the user is never shown.
|
* test needs somewhere to observe the operational failure the user is never shown.
|
||||||
*/
|
*/
|
||||||
readonly reportNotificationFailure?: IdentityNotificationReporter;
|
readonly reportNotificationFailure?: IdentityNotificationReporter;
|
||||||
/** Test-only escape hatch: overrides the directory module discovery scans. Never set by the
|
/** The deployment's raw `EKKLESIA_MODULES_DIR` (ADR 0048), a production input the executable
|
||||||
* executable entry point. See `prepareStartupModuleInventory`. */
|
* entry point always passes from host configuration. See `prepareStartupModuleInventory`. */
|
||||||
|
readonly configuredModulesDirectory?: string | undefined;
|
||||||
|
/** Test-only escape hatch: overrides the directory module discovery scans, taking precedence
|
||||||
|
* over `configuredModulesDirectory`. Never set by the executable entry point. See
|
||||||
|
* `prepareStartupModuleInventory`. */
|
||||||
readonly modulesDirectoryOverride?: string;
|
readonly modulesDirectoryOverride?: string;
|
||||||
/** Test-only escape hatch: supplies an already-discovered module list directly, skipping
|
/** Test-only escape hatch: supplies an already-discovered module list directly, skipping
|
||||||
* filesystem discovery entirely — this exact list drives both permission composition and module
|
* filesystem discovery entirely — this exact list drives both permission composition and module
|
||||||
@@ -427,6 +433,7 @@ export async function startApi(
|
|||||||
options: StartApiOptions = {},
|
options: StartApiOptions = {},
|
||||||
): Promise<RunningApi> {
|
): Promise<RunningApi> {
|
||||||
const inventory = await prepareStartupModuleInventory({
|
const inventory = await prepareStartupModuleInventory({
|
||||||
|
configuredModulesDirectory: options.configuredModulesDirectory,
|
||||||
...(options.modulesDirectoryOverride === undefined
|
...(options.modulesDirectoryOverride === undefined
|
||||||
? {}
|
? {}
|
||||||
: { modulesDirectoryOverride: options.modulesDirectoryOverride }),
|
: { modulesDirectoryOverride: options.modulesDirectoryOverride }),
|
||||||
@@ -542,9 +549,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);
|
||||||
@@ -576,6 +589,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({
|
||||||
@@ -620,6 +637,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
|
||||||
@@ -631,7 +655,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);
|
||||||
@@ -677,6 +702,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.
|
||||||
@@ -748,6 +774,7 @@ export function readConfigFromEnvironment(processEnvironment: HostEnvironment):
|
|||||||
readonly input: Parameters<typeof createApiRuntimeConfig>[0];
|
readonly input: Parameters<typeof createApiRuntimeConfig>[0];
|
||||||
readonly environment: "development" | "production";
|
readonly environment: "development" | "production";
|
||||||
readonly moduleSecrets: ModuleSecretSource;
|
readonly moduleSecrets: ModuleSecretSource;
|
||||||
|
readonly modulesDirectory: string | undefined;
|
||||||
} {
|
} {
|
||||||
const host = loadHostConfiguration(processEnvironment);
|
const host = loadHostConfiguration(processEnvironment);
|
||||||
return {
|
return {
|
||||||
@@ -763,13 +790,19 @@ export function readConfigFromEnvironment(processEnvironment: HostEnvironment):
|
|||||||
storage: host.storage,
|
storage: host.storage,
|
||||||
},
|
},
|
||||||
moduleSecrets: host.moduleSecrets,
|
moduleSecrets: host.moduleSecrets,
|
||||||
|
modulesDirectory: host.modulesDirectory,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function main(): Promise<void> {
|
export async function main(): Promise<void> {
|
||||||
const { environment, input, moduleSecrets } = readConfigFromEnvironment(process.env);
|
const { environment, input, moduleSecrets, modulesDirectory } = readConfigFromEnvironment(
|
||||||
|
process.env,
|
||||||
|
);
|
||||||
const config = createApiRuntimeConfig(input, { environment });
|
const config = createApiRuntimeConfig(input, { environment });
|
||||||
const running = await startApi(config, { moduleSecrets });
|
const running = await startApi(config, {
|
||||||
|
configuredModulesDirectory: modulesDirectory,
|
||||||
|
moduleSecrets,
|
||||||
|
});
|
||||||
|
|
||||||
const address = running.server.address();
|
const address = running.server.address();
|
||||||
const boundPort = address && typeof address === "object" ? address.port : config.port;
|
const boundPort = address && typeof address === "object" ? address.port : config.port;
|
||||||
|
|||||||
@@ -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,
|
||||||
@@ -2578,6 +2590,7 @@ describe.sequential("live API — installed module permission end-to-end", () =>
|
|||||||
});
|
});
|
||||||
|
|
||||||
const report = await diagnoseEkklesia({
|
const report = await diagnoseEkklesia({
|
||||||
|
coreMigrationsDirectory,
|
||||||
moduleDiscovery: {
|
moduleDiscovery: {
|
||||||
moduleSdkVersion: "0.1.0",
|
moduleSdkVersion: "0.1.0",
|
||||||
modulesDirectory,
|
modulesDirectory,
|
||||||
@@ -2587,10 +2600,8 @@ describe.sequential("live API — installed module permission end-to-end", () =>
|
|||||||
runtimeVersion: process.versions.node,
|
runtimeVersion: process.versions.node,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Only the module-discovery and permission-composition checks are asserted here — this
|
// Only the module-discovery and permission-composition checks are asserted here; nothing else
|
||||||
// fixture's temporary directory has no `core/drizzle` sibling for migrations discovery to
|
// in the report bears on what this scenario proves.
|
||||||
// find, which is unrelated to what this scenario proves and would otherwise make the check
|
|
||||||
// fail for a reason that has nothing to do with permission composition.
|
|
||||||
const moduleCheck = report.checks.find((entry) => entry.id === "modules.discovery");
|
const moduleCheck = report.checks.find((entry) => entry.id === "modules.discovery");
|
||||||
const permissionsCheck = report.checks.find((entry) => entry.id === "modules.permissions");
|
const permissionsCheck = report.checks.find((entry) => entry.id === "modules.permissions");
|
||||||
expect(moduleCheck).toMatchObject({ status: "pass" });
|
expect(moduleCheck).toMatchObject({ status: "pass" });
|
||||||
@@ -2977,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");
|
||||||
|
|||||||
@@ -3,11 +3,16 @@ import {
|
|||||||
createAuthenticatedPrincipal,
|
createAuthenticatedPrincipal,
|
||||||
type ModuleDatabase,
|
type ModuleDatabase,
|
||||||
type ModuleRuntimeServices,
|
type ModuleRuntimeServices,
|
||||||
|
moduleIdSchema,
|
||||||
|
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";
|
||||||
@@ -16,6 +21,7 @@ import {
|
|||||||
createModuleUiPublicationRegistry,
|
createModuleUiPublicationRegistry,
|
||||||
EMPTY_MODULE_REALTIME_REGISTRY,
|
EMPTY_MODULE_REALTIME_REGISTRY,
|
||||||
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";
|
||||||
@@ -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";
|
||||||
|
|
||||||
@@ -337,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 ModuleDatabase,
|
||||||
|
type ModuleRouteHandler,
|
||||||
|
type ModuleRuntimeServices,
|
||||||
|
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,
|
||||||
|
} 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(): ModuleRuntimeServices {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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,
|
||||||
@@ -139,6 +141,8 @@ 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({
|
||||||
|
|||||||
@@ -0,0 +1,373 @@
|
|||||||
|
import {
|
||||||
|
ANONYMOUS_PRINCIPAL,
|
||||||
|
type ModuleDatabase,
|
||||||
|
type ModuleRouteHandler,
|
||||||
|
type ModuleRuntimeServices,
|
||||||
|
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,
|
||||||
|
} 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(): ModuleRuntimeServices {
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -151,6 +151,13 @@ describe("host configuration parity between startApi and Doctor", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("hands the API the same modules directory Doctor and the operator commands read", () => {
|
||||||
|
expect(readConfigFromEnvironment(environment()).modulesDirectory).toBeUndefined();
|
||||||
|
const env = environment({ EKKLESIA_MODULES_DIR: "/var/lib/ekklesia/modules" });
|
||||||
|
expect(readConfigFromEnvironment(env).modulesDirectory).toBe("/var/lib/ekklesia/modules");
|
||||||
|
expect(loadHostConfiguration(env).modulesDirectory).toBe("/var/lib/ekklesia/modules");
|
||||||
|
});
|
||||||
|
|
||||||
it("reads only this listener's own address and port outside the shared loader", () => {
|
it("reads only this listener's own address and port outside the shared loader", () => {
|
||||||
const config = apiView(
|
const config = apiView(
|
||||||
environment({ EKKLESIA_API_HOST: "0.0.0.0", EKKLESIA_API_PORT: "8080" }),
|
environment({ EKKLESIA_API_HOST: "0.0.0.0", EKKLESIA_API_PORT: "8080" }),
|
||||||
|
|||||||
@@ -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() {
|
||||||
@@ -447,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() {
|
||||||
@@ -958,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");
|
||||||
|
|
||||||
@@ -1022,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();
|
||||||
|
|||||||
@@ -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,
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -38,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,
|
||||||
@@ -198,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 }
|
||||||
: {}),
|
: {}),
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ import {
|
|||||||
} 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 { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
|
||||||
|
import { testDashboardHostAssets } from "./support/dashboard-host-assets.js";
|
||||||
|
import { readyHealth } from "./support/ready-health.js";
|
||||||
import { createApiApp } from "../src/app.js";
|
import { createApiApp } from "../src/app.js";
|
||||||
import {
|
import {
|
||||||
canonicalizeVaryHeader,
|
canonicalizeVaryHeader,
|
||||||
@@ -123,6 +125,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],
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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";
|
||||||
@@ -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: {
|
||||||
|
|||||||
@@ -36,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 {
|
||||||
@@ -288,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"],
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ import {
|
|||||||
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";
|
||||||
@@ -208,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],
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ 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 {
|
||||||
@@ -255,6 +257,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],
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
+2
-1
@@ -1,6 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "@ekklesia/core",
|
"name": "@ekklesia/core",
|
||||||
"version": "0.1.0",
|
"version": "0.1.2",
|
||||||
|
"license": "AGPL-3.0-or-later",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"files": [
|
"files": [
|
||||||
|
|||||||
+23
-3
@@ -8,6 +8,7 @@ import {
|
|||||||
formatDoctorReport,
|
formatDoctorReport,
|
||||||
} from "../diagnostics/index.js";
|
} from "../diagnostics/index.js";
|
||||||
import { type HostEnvironment, loadHostConfiguration } from "../host/index.js";
|
import { type HostEnvironment, loadHostConfiguration } from "../host/index.js";
|
||||||
|
import { type ModulesDirectorySelection, resolveModulesDirectory } from "../modules/index.js";
|
||||||
|
|
||||||
interface ModuleSdkPackageDocument {
|
interface ModuleSdkPackageDocument {
|
||||||
readonly version?: unknown;
|
readonly version?: unknown;
|
||||||
@@ -25,8 +26,10 @@ interface RootPackageDocument extends ModuleSdkPackageDocument {
|
|||||||
*/
|
*/
|
||||||
export interface DoctorCommandMetadata {
|
export interface DoctorCommandMetadata {
|
||||||
readonly moduleSdkVersion: string;
|
readonly moduleSdkVersion: string;
|
||||||
readonly modulesDirectory: string;
|
|
||||||
readonly platformVersion: string;
|
readonly platformVersion: string;
|
||||||
|
/** Where the platform is installed: Core's migrations live beneath it, and so does the
|
||||||
|
* development default modules directory. Installed modules may live anywhere else. */
|
||||||
|
readonly repositoryRoot: string;
|
||||||
readonly runtimeRange: string;
|
readonly runtimeRange: string;
|
||||||
readonly runtimeVersion: string;
|
readonly runtimeVersion: string;
|
||||||
}
|
}
|
||||||
@@ -49,6 +52,22 @@ function requiredString(value: unknown): string {
|
|||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The modules directory exactly as `startApi` would choose it. An unusable configured value is not
|
||||||
|
* thrown here, because Doctor must still produce a report: it is handed on as a required directory
|
||||||
|
* for discovery to refuse, which reports it as a sanitized module-discovery failure.
|
||||||
|
*/
|
||||||
|
function doctorModulesDirectory(
|
||||||
|
configured: string | undefined,
|
||||||
|
repositoryRoot: string,
|
||||||
|
): ModulesDirectorySelection {
|
||||||
|
try {
|
||||||
|
return resolveModulesDirectory({ configured, repositoryRoot });
|
||||||
|
} catch {
|
||||||
|
return { modulesDirectory: configured ?? "", modulesDirectoryRequired: true };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Turns one deployment environment into Doctor's inputs.
|
* Turns one deployment environment into Doctor's inputs.
|
||||||
*
|
*
|
||||||
@@ -69,6 +88,7 @@ export function createDoctorOptions(
|
|||||||
const host = loadHostConfiguration(environment);
|
const host = loadHostConfiguration(environment);
|
||||||
return {
|
return {
|
||||||
clientAddress: host.clientAddress,
|
clientAddress: host.clientAddress,
|
||||||
|
coreMigrationsDirectory: path.join(metadata.repositoryRoot, "core", "drizzle"),
|
||||||
databaseEnvironment: host.environment,
|
databaseEnvironment: host.environment,
|
||||||
databaseTransport: host.databaseTransport,
|
databaseTransport: host.databaseTransport,
|
||||||
databaseUrl: host.databaseUrl,
|
databaseUrl: host.databaseUrl,
|
||||||
@@ -76,7 +96,7 @@ export function createDoctorOptions(
|
|||||||
mailConfiguration: host.mail,
|
mailConfiguration: host.mail,
|
||||||
moduleDiscovery: {
|
moduleDiscovery: {
|
||||||
moduleSdkVersion: metadata.moduleSdkVersion,
|
moduleSdkVersion: metadata.moduleSdkVersion,
|
||||||
modulesDirectory: metadata.modulesDirectory,
|
...doctorModulesDirectory(host.modulesDirectory, metadata.repositoryRoot),
|
||||||
platformVersion: metadata.platformVersion,
|
platformVersion: metadata.platformVersion,
|
||||||
},
|
},
|
||||||
moduleSecrets: host.moduleSecrets,
|
moduleSecrets: host.moduleSecrets,
|
||||||
@@ -97,8 +117,8 @@ export async function runDoctorCommand(repositoryRoot = process.cwd()): Promise<
|
|||||||
const report = await diagnoseEkklesia(
|
const report = await diagnoseEkklesia(
|
||||||
createDoctorOptions(process.env, {
|
createDoctorOptions(process.env, {
|
||||||
moduleSdkVersion: requiredString(moduleSdkPackage.version),
|
moduleSdkVersion: requiredString(moduleSdkPackage.version),
|
||||||
modulesDirectory: path.join(repositoryRoot, "modules"),
|
|
||||||
platformVersion: requiredString(rootPackage.version),
|
platformVersion: requiredString(rootPackage.version),
|
||||||
|
repositoryRoot,
|
||||||
runtimeRange: requiredString(rootPackage.engines?.node),
|
runtimeRange: requiredString(rootPackage.engines?.node),
|
||||||
runtimeVersion: process.versions.node,
|
runtimeVersion: process.versions.node,
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import { readFile } from "node:fs/promises";
|
import { readFile } from "node:fs/promises";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
|
import type { HostEnvironment } from "../host/index.js";
|
||||||
import { discoverAllMigrations } from "../migrations/index.js";
|
import { discoverAllMigrations } from "../migrations/index.js";
|
||||||
import type { ModuleMigrationAggregate } from "../modules/index.js";
|
import type { ModuleMigrationAggregate } from "../modules/index.js";
|
||||||
|
import { resolveCommandModulesDirectory } from "./modules-directory-environment.js";
|
||||||
|
|
||||||
interface PackageVersion {
|
interface PackageVersion {
|
||||||
readonly version?: unknown;
|
readonly version?: unknown;
|
||||||
@@ -15,9 +17,16 @@ async function readPackageVersion(packagePath: string): Promise<string> {
|
|||||||
return packageData.version;
|
return packageData.version;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Core's migrations come from the platform (`<repositoryRoot>/core/drizzle`); installed modules'
|
||||||
|
* come from the configured modules directory, wherever it is (ADR 0048). Neither is ever derived
|
||||||
|
* from the other.
|
||||||
|
*/
|
||||||
export async function discoverRepositoryMigrations(
|
export async function discoverRepositoryMigrations(
|
||||||
repositoryRoot = process.cwd(),
|
repositoryRoot = process.cwd(),
|
||||||
|
environment: HostEnvironment = process.env,
|
||||||
): Promise<ModuleMigrationAggregate> {
|
): Promise<ModuleMigrationAggregate> {
|
||||||
|
const modulesDirectory = resolveCommandModulesDirectory(environment, repositoryRoot);
|
||||||
const [platformVersion, moduleSdkVersion] = await Promise.all([
|
const [platformVersion, moduleSdkVersion] = await Promise.all([
|
||||||
readPackageVersion(path.join(repositoryRoot, "package.json")),
|
readPackageVersion(path.join(repositoryRoot, "package.json")),
|
||||||
readPackageVersion(path.join(repositoryRoot, "packages/module-sdk/package.json")),
|
readPackageVersion(path.join(repositoryRoot, "packages/module-sdk/package.json")),
|
||||||
@@ -27,7 +36,7 @@ export async function discoverRepositoryMigrations(
|
|||||||
coreMigrationsDirectory: path.join(repositoryRoot, "core", "drizzle"),
|
coreMigrationsDirectory: path.join(repositoryRoot, "core", "drizzle"),
|
||||||
moduleDiscovery: {
|
moduleDiscovery: {
|
||||||
moduleSdkVersion,
|
moduleSdkVersion,
|
||||||
modulesDirectory: path.join(repositoryRoot, "modules"),
|
...modulesDirectory,
|
||||||
platformVersion,
|
platformVersion,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
import type { ModuleMigrationAggregate } from "../modules/index.js";
|
import type { ModuleMigrationAggregate } from "../modules/index.js";
|
||||||
import type { MigrationApplyResult, MigrationPlanResult } from "../migrations/index.js";
|
import type {
|
||||||
|
MigrationApplyResult,
|
||||||
|
MigrationPlanResult,
|
||||||
|
RetainedMigrationHistory,
|
||||||
|
} from "../migrations/index.js";
|
||||||
|
|
||||||
function plural(count: number, singular: string): string {
|
function plural(count: number, singular: string): string {
|
||||||
return count === 1 ? singular : `${singular}s`;
|
return count === 1 ? singular : `${singular}s`;
|
||||||
@@ -28,10 +32,19 @@ export function formatMigrationValidationReport(
|
|||||||
return Object.freeze(lines);
|
return Object.freeze(lines);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** An absent module's applied history (ADR 0048): kept, reported, and never a reason to stop. */
|
||||||
|
function retainedLines(retained: readonly RetainedMigrationHistory[]): readonly string[] {
|
||||||
|
return retained.map(
|
||||||
|
(module) =>
|
||||||
|
`- [retained] ${module.moduleId}: ${module.appliedCount} applied ${plural(module.appliedCount, "migration")} kept; the module is not installed`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function formatMigrationPlanReport(plan: MigrationPlanResult): readonly string[] {
|
export function formatMigrationPlanReport(plan: MigrationPlanResult): readonly string[] {
|
||||||
const readyCount = plan.pendingCount - plan.approvalRequiredCount;
|
const readyCount = plan.pendingCount - plan.approvalRequiredCount;
|
||||||
|
const retainedSummary = plan.retained.length === 0 ? "" : `, ${plan.retained.length} retained`;
|
||||||
const lines = [
|
const lines = [
|
||||||
`Migration plan (${plan.appliedCount} applied, ${readyCount} pending, ${plan.approvalRequiredCount} approval-blocked):`,
|
`Migration plan (${plan.appliedCount} applied, ${readyCount} pending, ${plan.approvalRequiredCount} approval-blocked${retainedSummary}):`,
|
||||||
];
|
];
|
||||||
|
|
||||||
for (const entry of plan.entries) {
|
for (const entry of plan.entries) {
|
||||||
@@ -40,6 +53,7 @@ export function formatMigrationPlanReport(plan: MigrationPlanResult): readonly s
|
|||||||
if (plan.entries.length === 0) {
|
if (plan.entries.length === 0) {
|
||||||
lines.push("- No installed module migrations.");
|
lines.push("- No installed module migrations.");
|
||||||
}
|
}
|
||||||
|
lines.push(...retainedLines(plan.retained));
|
||||||
lines.push(
|
lines.push(
|
||||||
"Informational only: this plan is read-only and may become stale; apply revalidates history under the migration lock.",
|
"Informational only: this plan is read-only and may become stale; apply revalidates history under the migration lock.",
|
||||||
);
|
);
|
||||||
@@ -49,7 +63,10 @@ export function formatMigrationPlanReport(plan: MigrationPlanResult): readonly s
|
|||||||
|
|
||||||
export function formatMigrationApplyReport(result: MigrationApplyResult): readonly string[] {
|
export function formatMigrationApplyReport(result: MigrationApplyResult): readonly string[] {
|
||||||
if (result.noOp) {
|
if (result.noOp) {
|
||||||
return Object.freeze(["Migration apply completed: database is already current (no changes)."]);
|
return Object.freeze([
|
||||||
|
"Migration apply completed: database is already current (no changes).",
|
||||||
|
...retainedLines(result.retained),
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
const lines = [
|
const lines = [
|
||||||
@@ -58,5 +75,6 @@ export function formatMigrationApplyReport(result: MigrationApplyResult): readon
|
|||||||
for (const migration of result.applied) {
|
for (const migration of result.applied) {
|
||||||
lines.push(`- ${migration.identity} sha256=${migration.checksum}`);
|
lines.push(`- ${migration.identity} sha256=${migration.checksum}`);
|
||||||
}
|
}
|
||||||
|
lines.push(...retainedLines(result.retained));
|
||||||
return Object.freeze(lines);
|
return Object.freeze(lines);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { type HostEnvironment, loadHostConfiguration } from "../host/index.js";
|
||||||
|
import { type ModulesDirectorySelection, resolveModulesDirectory } from "../modules/index.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The modules directory an operator command discovers, read through the same host configuration
|
||||||
|
* `startApi` and Doctor use and resolved by the one Core resolver (ADR 0048). Throws the resolver's
|
||||||
|
* sanitized `ModuleDiscoveryError` for an unusable value, which every command already reports.
|
||||||
|
*/
|
||||||
|
export function resolveCommandModulesDirectory(
|
||||||
|
environment: HostEnvironment,
|
||||||
|
repositoryRoot: string,
|
||||||
|
): ModulesDirectorySelection {
|
||||||
|
return resolveModulesDirectory({
|
||||||
|
configured: loadHostConfiguration(environment).modulesDirectory,
|
||||||
|
repositoryRoot,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
resolveModuleEligibility,
|
resolveModuleEligibility,
|
||||||
} from "../modules/index.js";
|
} from "../modules/index.js";
|
||||||
import { resolveExecutableDatabaseTransport } from "./database-transport-environment.js";
|
import { resolveExecutableDatabaseTransport } from "./database-transport-environment.js";
|
||||||
|
import { resolveCommandModulesDirectory } from "./modules-directory-environment.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Enables or disables one installed module, out of band from the running API.
|
* Enables or disables one installed module, out of band from the running API.
|
||||||
@@ -99,7 +100,7 @@ async function main(): Promise<void> {
|
|||||||
// configurable here, which is the case this command exists for.
|
// configurable here, which is the case this command exists for.
|
||||||
const discovered = await discoverModules({
|
const discovered = await discoverModules({
|
||||||
moduleSdkVersion,
|
moduleSdkVersion,
|
||||||
modulesDirectory: path.join(repositoryRoot, "modules"),
|
...resolveCommandModulesDirectory(process.env, repositoryRoot),
|
||||||
platformVersion,
|
platformVersion,
|
||||||
});
|
});
|
||||||
if (!discovered.some((module) => module.manifest.id === moduleId)) {
|
if (!discovered.some((module) => module.manifest.id === moduleId)) {
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
ModulePermissionCompositionError,
|
ModulePermissionCompositionError,
|
||||||
ModuleSchemaDiscoveryError,
|
ModuleSchemaDiscoveryError,
|
||||||
} from "../modules/index.js";
|
} from "../modules/index.js";
|
||||||
|
import { resolveCommandModulesDirectory } from "./modules-directory-environment.js";
|
||||||
|
|
||||||
interface PackageVersion {
|
interface PackageVersion {
|
||||||
version?: unknown;
|
version?: unknown;
|
||||||
@@ -30,7 +31,7 @@ async function main(): Promise<void> {
|
|||||||
]);
|
]);
|
||||||
const schemaAggregate = await discoverModuleSchemas({
|
const schemaAggregate = await discoverModuleSchemas({
|
||||||
moduleSdkVersion,
|
moduleSdkVersion,
|
||||||
modulesDirectory: path.join(repositoryRoot, "modules"),
|
...resolveCommandModulesDirectory(process.env, repositoryRoot),
|
||||||
platformVersion,
|
platformVersion,
|
||||||
});
|
});
|
||||||
const modules = schemaAggregate.discoveredModules;
|
const modules = schemaAggregate.discoveredModules;
|
||||||
|
|||||||
@@ -0,0 +1,175 @@
|
|||||||
|
import { readFile } from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
import { pathToFileURL } from "node:url";
|
||||||
|
import {
|
||||||
|
type ModulePackagePlatform,
|
||||||
|
ModulePackageVerificationError,
|
||||||
|
verifyModulePackage,
|
||||||
|
} from "../modules/index.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `verify-module-package` — verifies an Ekklesia Module Package v1 archive against this platform
|
||||||
|
* (ADR 0049), and never installs it.
|
||||||
|
*
|
||||||
|
* node core/dist/cli/verify-module-package.js <archive>
|
||||||
|
* [--sha256 <hex> | --sha256-file <file>] the archive's expected SHA-256
|
||||||
|
* [--extract-to <new directory>] write the verified module directory there
|
||||||
|
*
|
||||||
|
* Run from the platform's root (`/opt/ekklesia` in the image), like every operator command: the
|
||||||
|
* platform's own versions are read from there. `--extract-to` writes only after every check passed,
|
||||||
|
* into a directory that must not exist yet; it is the staging step of an installation, not the
|
||||||
|
* installation itself.
|
||||||
|
*/
|
||||||
|
|
||||||
|
class VerifyArgumentsError extends Error {}
|
||||||
|
|
||||||
|
const USAGE =
|
||||||
|
"Usage: verify-module-package <archive> [--sha256 <hex> | --sha256-file <file>] [--extract-to <new directory>]";
|
||||||
|
|
||||||
|
export interface VerifyModulePackageArguments {
|
||||||
|
readonly archivePath: string;
|
||||||
|
readonly expectedSha256?: string;
|
||||||
|
readonly sha256File?: string;
|
||||||
|
readonly extractTo?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizedDigest(value: string): string {
|
||||||
|
const digest = value.trim().toLowerCase();
|
||||||
|
if (!/^[0-9a-f]{64}$/u.test(digest)) {
|
||||||
|
throw new VerifyArgumentsError("The SHA-256 must be 64 hexadecimal characters.");
|
||||||
|
}
|
||||||
|
return digest;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseVerifyModulePackageArguments(
|
||||||
|
argv: readonly string[],
|
||||||
|
): VerifyModulePackageArguments {
|
||||||
|
const positional: string[] = [];
|
||||||
|
const options = new Map<string, string>();
|
||||||
|
for (let index = 0; index < argv.length; index += 1) {
|
||||||
|
const argument = argv[index] ?? "";
|
||||||
|
if (argument.startsWith("--")) {
|
||||||
|
if (!["--sha256", "--sha256-file", "--extract-to"].includes(argument)) {
|
||||||
|
throw new VerifyArgumentsError(USAGE);
|
||||||
|
}
|
||||||
|
const value = argv[index + 1];
|
||||||
|
if (value === undefined || value.startsWith("--") || options.has(argument)) {
|
||||||
|
throw new VerifyArgumentsError(USAGE);
|
||||||
|
}
|
||||||
|
options.set(argument, value);
|
||||||
|
index += 1;
|
||||||
|
} else {
|
||||||
|
positional.push(argument);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const archivePath = positional[0];
|
||||||
|
if (positional.length !== 1 || archivePath === undefined || archivePath === "") {
|
||||||
|
throw new VerifyArgumentsError(USAGE);
|
||||||
|
}
|
||||||
|
if (options.has("--sha256") && options.has("--sha256-file")) {
|
||||||
|
throw new VerifyArgumentsError(USAGE);
|
||||||
|
}
|
||||||
|
const sha256 = options.get("--sha256");
|
||||||
|
const sha256File = options.get("--sha256-file");
|
||||||
|
const extractTo = options.get("--extract-to");
|
||||||
|
return {
|
||||||
|
archivePath,
|
||||||
|
...(sha256 === undefined ? {} : { expectedSha256: normalizedDigest(sha256) }),
|
||||||
|
...(sha256File === undefined ? {} : { sha256File }),
|
||||||
|
...(extractTo === undefined ? {} : { extractTo }),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The `sha256sum` format: one line, the digest, whitespace, and a file name that is never trusted. */
|
||||||
|
export function parseSha256File(contents: string): string {
|
||||||
|
const lines = contents.split("\n").filter((line) => line.trim() !== "");
|
||||||
|
const match = lines.length === 1 ? /^([0-9a-fA-F]{64})\s+\*?\S/u.exec(lines[0] ?? "") : null;
|
||||||
|
if (!match?.[1]) {
|
||||||
|
throw new VerifyArgumentsError("The SHA-256 file must hold one sha256sum line.");
|
||||||
|
}
|
||||||
|
return match[1].toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readJson(file: string): Promise<Record<string, unknown>> {
|
||||||
|
return JSON.parse(await readFile(file, "utf8")) as Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function requiredString(value: unknown): string {
|
||||||
|
if (typeof value !== "string" || value === "") {
|
||||||
|
throw new VerifyArgumentsError("The platform's own package metadata could not be read.");
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What this platform is, read from where it is installed. The transitional database runtime is
|
||||||
|
* the Drizzle version Core itself depends on (ADR 0049). */
|
||||||
|
export async function readModulePackagePlatform(
|
||||||
|
repositoryRoot: string,
|
||||||
|
): Promise<ModulePackagePlatform> {
|
||||||
|
const [root, sdk, core] = await Promise.all([
|
||||||
|
readJson(path.join(repositoryRoot, "package.json")),
|
||||||
|
readJson(path.join(repositoryRoot, "packages", "module-sdk", "package.json")),
|
||||||
|
readJson(path.join(repositoryRoot, "core", "package.json")),
|
||||||
|
]);
|
||||||
|
const dependencies = core.dependencies as Record<string, unknown> | undefined;
|
||||||
|
return {
|
||||||
|
databaseRuntime: { "drizzle-orm": requiredString(dependencies?.["drizzle-orm"]) },
|
||||||
|
moduleSdkVersion: requiredString(sdk.version),
|
||||||
|
platformVersion: requiredString(root.version),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main(): Promise<void> {
|
||||||
|
const parsed = parseVerifyModulePackageArguments(process.argv.slice(2));
|
||||||
|
let expectedSha256 = parsed.expectedSha256;
|
||||||
|
if (parsed.sha256File !== undefined) {
|
||||||
|
let contents: string;
|
||||||
|
try {
|
||||||
|
contents = await readFile(parsed.sha256File, "utf8");
|
||||||
|
} catch {
|
||||||
|
throw new VerifyArgumentsError("The SHA-256 file could not be read.");
|
||||||
|
}
|
||||||
|
expectedSha256 = parseSha256File(contents);
|
||||||
|
}
|
||||||
|
const platform = await readModulePackagePlatform(process.cwd()).catch(() => {
|
||||||
|
throw new VerifyArgumentsError("The platform's own package metadata could not be read.");
|
||||||
|
});
|
||||||
|
const result = await verifyModulePackage({
|
||||||
|
archivePath: parsed.archivePath,
|
||||||
|
platform,
|
||||||
|
...(expectedSha256 === undefined ? {} : { expectedSha256 }),
|
||||||
|
...(parsed.extractTo === undefined ? {} : { extractTo: parsed.extractTo }),
|
||||||
|
});
|
||||||
|
console.log(
|
||||||
|
`Module package verified: ${result.moduleId}@${result.version} (target ${result.target}), ${result.fileCount} files, sha256 ${result.archiveSha256}.`,
|
||||||
|
);
|
||||||
|
if (expectedSha256 === undefined) {
|
||||||
|
console.log("The archive's SHA-256 was not checked: no expected digest was given.");
|
||||||
|
}
|
||||||
|
if (parsed.extractTo !== undefined) {
|
||||||
|
console.log("The verified module directory was extracted. Nothing was installed.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isDirectExecution(): boolean {
|
||||||
|
const entrypoint = process.argv[1];
|
||||||
|
return (
|
||||||
|
entrypoint !== undefined && import.meta.url === pathToFileURL(path.resolve(entrypoint)).href
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isDirectExecution()) {
|
||||||
|
main().catch((error: unknown) => {
|
||||||
|
if (error instanceof ModulePackageVerificationError) {
|
||||||
|
console.error(`[${error.code}] ${error.message}`);
|
||||||
|
if (error.issueCodes.length > 0) {
|
||||||
|
console.error(`Module validation issues: ${error.issueCodes.join(", ")}`);
|
||||||
|
}
|
||||||
|
} else if (error instanceof VerifyArgumentsError) {
|
||||||
|
console.error(error.message);
|
||||||
|
} else {
|
||||||
|
console.error("Module package verification failed unexpectedly.");
|
||||||
|
}
|
||||||
|
process.exitCode = 1;
|
||||||
|
});
|
||||||
|
}
|
||||||
+103
-21
@@ -1,4 +1,3 @@
|
|||||||
import path from "node:path";
|
|
||||||
import {
|
import {
|
||||||
capabilityIdSchema,
|
capabilityIdSchema,
|
||||||
EMPTY_MODULE_SECRET_SOURCE,
|
EMPTY_MODULE_SECRET_SOURCE,
|
||||||
@@ -43,6 +42,7 @@ import {
|
|||||||
} from "../mail/index.js";
|
} from "../mail/index.js";
|
||||||
import {
|
import {
|
||||||
createPostgresMigrationDatabase,
|
createPostgresMigrationDatabase,
|
||||||
|
type DiscoverAllMigrationsOptions,
|
||||||
discoverAllMigrations,
|
discoverAllMigrations,
|
||||||
type MigrationDatabase,
|
type MigrationDatabase,
|
||||||
type MigrationPlanResult,
|
type MigrationPlanResult,
|
||||||
@@ -64,6 +64,7 @@ import {
|
|||||||
isConfigurationBlockedDetail,
|
isConfigurationBlockedDetail,
|
||||||
isStorageBlockedDetail,
|
isStorageBlockedDetail,
|
||||||
type ModuleConfigurationSettings,
|
type ModuleConfigurationSettings,
|
||||||
|
ModuleDiscoveryError,
|
||||||
type ModuleDiscoveryOptions,
|
type ModuleDiscoveryOptions,
|
||||||
type ModuleEligibility,
|
type ModuleEligibility,
|
||||||
type ModuleLifecycleConfiguration,
|
type ModuleLifecycleConfiguration,
|
||||||
@@ -126,6 +127,9 @@ export interface DoctorIdentityConfigurationOptions {
|
|||||||
|
|
||||||
export interface DoctorOptions {
|
export interface DoctorOptions {
|
||||||
readonly clientAddress?: ClientAddressConfigInput | undefined;
|
readonly clientAddress?: ClientAddressConfigInput | undefined;
|
||||||
|
/** Core's own migrations, where the platform is installed. Never derived from the modules
|
||||||
|
* directory: installed modules live in installation-owned storage, anywhere at all (ADR 0048). */
|
||||||
|
readonly coreMigrationsDirectory: string;
|
||||||
/** The environment the executable resolved, used only for transport policy evaluation; identity
|
/** The environment the executable resolved, used only for transport policy evaluation; identity
|
||||||
* configuration keeps its own `identityConfiguration.environment` for the same purpose. */
|
* configuration keeps its own `identityConfiguration.environment` for the same purpose. */
|
||||||
readonly databaseEnvironment?: DatabaseTransportEnvironment | undefined;
|
readonly databaseEnvironment?: DatabaseTransportEnvironment | undefined;
|
||||||
@@ -170,7 +174,7 @@ export interface DoctorDependencies {
|
|||||||
options: PostgresMigrationDatabaseOptions,
|
options: PostgresMigrationDatabaseOptions,
|
||||||
) => MigrationDatabase;
|
) => MigrationDatabase;
|
||||||
readonly discoverMigrations: (
|
readonly discoverMigrations: (
|
||||||
options: ModuleDiscoveryOptions,
|
options: DiscoverAllMigrationsOptions,
|
||||||
) => Promise<ModuleMigrationAggregate>;
|
) => Promise<ModuleMigrationAggregate>;
|
||||||
readonly discoverModules: (
|
readonly discoverModules: (
|
||||||
options: ModuleDiscoveryOptions,
|
options: ModuleDiscoveryOptions,
|
||||||
@@ -207,15 +211,6 @@ const checksumPattern = /^[a-f0-9]{64}$/u;
|
|||||||
const migrationIdentityPattern =
|
const migrationIdentityPattern =
|
||||||
/^[a-z][a-z0-9]*(?:-[a-z0-9]+)*:\d{4,}_[a-z][a-z0-9]*(?:_[a-z0-9]+)*$/u;
|
/^[a-z][a-z0-9]*(?:-[a-z0-9]+)*:\d{4,}_[a-z][a-z0-9]*(?:_[a-z0-9]+)*$/u;
|
||||||
|
|
||||||
async function discoverAllMigrationsForRuntime(
|
|
||||||
options: ModuleDiscoveryOptions,
|
|
||||||
): Promise<ModuleMigrationAggregate> {
|
|
||||||
return discoverAllMigrations({
|
|
||||||
coreMigrationsDirectory: path.join(path.dirname(options.modulesDirectory), "core", "drizzle"),
|
|
||||||
moduleDiscovery: options,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Opens its own short-lived connection, reads, and always closes it — Doctor never leaves a pool
|
/** Opens its own short-lived connection, reads, and always closes it — Doctor never leaves a pool
|
||||||
* behind, and never shares one with the migration planner's, whose lifetime it does not control. */
|
* behind, and never shares one with the migration planner's, whose lifetime it does not control. */
|
||||||
async function readModuleLifecycleForRuntime(
|
async function readModuleLifecycleForRuntime(
|
||||||
@@ -273,7 +268,7 @@ async function probeMailForRuntime(config: MailProviderConfig): Promise<void> {
|
|||||||
const productionDependencies: DoctorDependencies = Object.freeze({
|
const productionDependencies: DoctorDependencies = Object.freeze({
|
||||||
composePermissions: composePlatformPermissionRegistry,
|
composePermissions: composePlatformPermissionRegistry,
|
||||||
createMigrationDatabase: createPostgresMigrationDatabase,
|
createMigrationDatabase: createPostgresMigrationDatabase,
|
||||||
discoverMigrations: discoverAllMigrationsForRuntime,
|
discoverMigrations: discoverAllMigrations,
|
||||||
discoverModules,
|
discoverModules,
|
||||||
discoverSchemas: discoverModuleSchemas,
|
discoverSchemas: discoverModuleSchemas,
|
||||||
planMigrations: planModuleMigrations,
|
planMigrations: planModuleMigrations,
|
||||||
@@ -633,11 +628,23 @@ function storageBindingsCheck(
|
|||||||
details,
|
details,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
function moduleDiscoveryFailureDetail(error: unknown): string {
|
||||||
|
if (!(error instanceof ModuleDiscoveryError)) {
|
||||||
|
return "Static module discovery could not be completed.";
|
||||||
|
}
|
||||||
|
// The modules directory itself, rather than anything in it (ADR 0048). Fixed wording, never the
|
||||||
|
// configured value: a path is machine detail.
|
||||||
|
if (error.issues.some((issue) => issue.code === "MODULES_DIRECTORY_INVALID")) {
|
||||||
|
return "The configured modules directory is not usable.";
|
||||||
|
}
|
||||||
|
if (error.issues.some((issue) => issue.code === "MODULES_DIRECTORY_UNREADABLE")) {
|
||||||
|
return "The configured modules directory could not be read.";
|
||||||
|
}
|
||||||
|
return "The installed module inventory is invalid.";
|
||||||
|
}
|
||||||
|
|
||||||
function moduleDiscoveryFailure(error: unknown): DoctorCheck {
|
function moduleDiscoveryFailure(error: unknown): DoctorCheck {
|
||||||
const details =
|
const details = [moduleDiscoveryFailureDetail(error)];
|
||||||
error instanceof Error && error.name === "ModuleDiscoveryError"
|
|
||||||
? ["The installed module inventory is invalid."]
|
|
||||||
: ["Static module discovery could not be completed."];
|
|
||||||
return freezeCheck("modules.discovery", "failure", "Module discovery failed", details);
|
return freezeCheck("modules.discovery", "failure", "Module discovery failed", details);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -733,6 +740,14 @@ function migrationPlanCheck(plan: MigrationPlanResult): DoctorCheck {
|
|||||||
}
|
}
|
||||||
return `${entry.status} · ${safeMigrationReference(entry.identity, entry.checksum)}`;
|
return `${entry.status} · ${safeMigrationReference(entry.identity, entry.checksum)}`;
|
||||||
}),
|
}),
|
||||||
|
// An absent module's applied history (ADR 0048) is information, never attention: removing a
|
||||||
|
// module's code keeps its data, and nothing here is pending.
|
||||||
|
...plan.retained.map((module) => {
|
||||||
|
if (!/^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/u.test(module.moduleId)) {
|
||||||
|
throw new TypeError("Unsafe migration plan diagnostic value.");
|
||||||
|
}
|
||||||
|
return `retained · ${module.moduleId} · ${module.appliedCount} applied ${plural(module.appliedCount, "migration")} kept; the module is not installed`;
|
||||||
|
}),
|
||||||
];
|
];
|
||||||
|
|
||||||
if (pendingCount + blockedCount === 0) {
|
if (pendingCount + blockedCount === 0) {
|
||||||
@@ -757,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
|
||||||
@@ -769,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;
|
||||||
|
|
||||||
@@ -783,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 =
|
||||||
@@ -849,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 =
|
||||||
@@ -870,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],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1068,7 +1147,10 @@ export async function diagnoseEkklesia(
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
migrations = await dependencies.discoverMigrations(options.moduleDiscovery);
|
migrations = await dependencies.discoverMigrations({
|
||||||
|
coreMigrationsDirectory: options.coreMigrationsDirectory,
|
||||||
|
moduleDiscovery: options.moduleDiscovery,
|
||||||
|
});
|
||||||
const inventory = migrations.migrations.map((migration) =>
|
const inventory = migrations.migrations.map((migration) =>
|
||||||
safeMigrationReference(migration.identity, migration.checksum),
|
safeMigrationReference(migration.identity, migration.checksum),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -59,6 +59,10 @@ export interface HostConfiguration {
|
|||||||
/** The deployment's module secrets, resolvable only under names Core derives from a module's own
|
/** The deployment's module secrets, resolvable only under names Core derives from a module's own
|
||||||
* declared keys — there is no arbitrary variable lookup. */
|
* declared keys — there is no arbitrary variable lookup. */
|
||||||
readonly moduleSecrets: ModuleSecretSource;
|
readonly moduleSecrets: ModuleSecretSource;
|
||||||
|
/** `EKKLESIA_MODULES_DIR`, raw and unvalidated: `undefined` only when the variable is unset. Every
|
||||||
|
* executable passes it to `resolveModulesDirectory`, the one place it is interpreted (ADR 0048),
|
||||||
|
* so a relative or blank value is refused there rather than repaired here. */
|
||||||
|
readonly modulesDirectory: string | undefined;
|
||||||
readonly storage: StorageProviderConfigInput;
|
readonly storage: StorageProviderConfigInput;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -144,6 +148,7 @@ export function loadHostConfiguration(environment: HostEnvironment): HostConfigu
|
|||||||
username: environment.EKKLESIA_SMTP_USERNAME,
|
username: environment.EKKLESIA_SMTP_USERNAME,
|
||||||
},
|
},
|
||||||
moduleSecrets: createEnvironmentModuleSecretSource(environment),
|
moduleSecrets: createEnvironmentModuleSecretSource(environment),
|
||||||
|
modulesDirectory: environment.EKKLESIA_MODULES_DIR,
|
||||||
// The `S3_*` names ADR 0004 already established for this repository's Compose stack. Absent
|
// The `S3_*` names ADR 0004 already established for this repository's Compose stack. Absent
|
||||||
// endpoint means this installation has no object storage, which is a complete deployment
|
// endpoint means this installation has no object storage, which is a complete deployment
|
||||||
// (ADR 0030).
|
// (ADR 0030).
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import type {
|
|||||||
DiscoveredModuleMigration,
|
DiscoveredModuleMigration,
|
||||||
ModuleMigrationAggregate,
|
ModuleMigrationAggregate,
|
||||||
} from "../modules/migration-discovery.js";
|
} from "../modules/migration-discovery.js";
|
||||||
|
import { CORE_MIGRATION_SOURCE_ID } from "./core-source.js";
|
||||||
|
|
||||||
const DEFAULT_MIGRATION_LOCK_TIMEOUT_MS = 10_000;
|
const DEFAULT_MIGRATION_LOCK_TIMEOUT_MS = 10_000;
|
||||||
|
|
||||||
@@ -86,11 +87,24 @@ export interface MigrationPlanEntry {
|
|||||||
readonly tag: string;
|
readonly tag: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Applied history kept for a module that is not installed (ADR 0048): the module id and how many of
|
||||||
|
* its migrations were applied. It is reported, never matched against an inventory, and never a
|
||||||
|
* reason to refuse planning or applying anything else.
|
||||||
|
*/
|
||||||
|
export interface RetainedMigrationHistory {
|
||||||
|
readonly appliedCount: number;
|
||||||
|
readonly moduleId: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface MigrationPlanResult {
|
export interface MigrationPlanResult {
|
||||||
readonly appliedCount: number;
|
readonly appliedCount: number;
|
||||||
readonly approvalRequiredCount: number;
|
readonly approvalRequiredCount: number;
|
||||||
readonly entries: readonly MigrationPlanEntry[];
|
readonly entries: readonly MigrationPlanEntry[];
|
||||||
readonly pendingCount: number;
|
readonly pendingCount: number;
|
||||||
|
/** Absent modules' applied history, ordered by module id; empty when every applied migration
|
||||||
|
* belongs to Core or an installed module. */
|
||||||
|
readonly retained: readonly RetainedMigrationHistory[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AppliedMigrationResult {
|
export interface AppliedMigrationResult {
|
||||||
@@ -105,6 +119,8 @@ export interface MigrationApplyResult {
|
|||||||
readonly applied: readonly AppliedMigrationResult[];
|
readonly applied: readonly AppliedMigrationResult[];
|
||||||
readonly appliedCount: number;
|
readonly appliedCount: number;
|
||||||
readonly noOp: boolean;
|
readonly noOp: boolean;
|
||||||
|
/** The same report as `MigrationPlanResult.retained`, read under the migration lock. */
|
||||||
|
readonly retained: readonly RetainedMigrationHistory[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PlanModuleMigrationsOptions {
|
export interface PlanModuleMigrationsOptions {
|
||||||
@@ -204,10 +220,38 @@ function validateHistoryRecordShape(record: AppliedMigrationRecord): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface ValidatedAppliedHistory {
|
||||||
|
/** Identities of applied migrations that belong to Core or an installed module. */
|
||||||
|
readonly applied: ReadonlySet<string>;
|
||||||
|
readonly retained: readonly RetainedMigrationHistory[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every module id this inventory treats as installed: each discovered module, whether or not it
|
||||||
|
* owns migrations, every module that contributed migrations, and always Core. A module counts as
|
||||||
|
* absent only when it is none of these, so a module that is installed but no longer ships its old
|
||||||
|
* migrations stays strict, and Core's history can never be classified as an absent module's.
|
||||||
|
*/
|
||||||
|
function installedMigrationSources(aggregate: ModuleMigrationAggregate): ReadonlySet<string> {
|
||||||
|
return new Set<string>([
|
||||||
|
CORE_MIGRATION_SOURCE_ID,
|
||||||
|
...aggregate.discoveredModules.map((module) => module.manifest.id),
|
||||||
|
...aggregate.modules.map((module) => module.moduleId),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function freezeRetained(counts: ReadonlyMap<string, number>): readonly RetainedMigrationHistory[] {
|
||||||
|
return Object.freeze(
|
||||||
|
[...counts.entries()]
|
||||||
|
.sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0))
|
||||||
|
.map(([moduleId, appliedCount]) => Object.freeze({ appliedCount, moduleId })),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function validateAppliedHistoryUnchecked(
|
function validateAppliedHistoryUnchecked(
|
||||||
aggregate: ModuleMigrationAggregate,
|
aggregate: ModuleMigrationAggregate,
|
||||||
history: readonly AppliedMigrationRecord[],
|
history: readonly AppliedMigrationRecord[],
|
||||||
): ReadonlySet<string> {
|
): ValidatedAppliedHistory {
|
||||||
if (!Array.isArray(history)) {
|
if (!Array.isArray(history)) {
|
||||||
throw runnerError("MIGRATION_HISTORY_INVALID");
|
throw runnerError("MIGRATION_HISTORY_INVALID");
|
||||||
}
|
}
|
||||||
@@ -215,6 +259,9 @@ function validateAppliedHistoryUnchecked(
|
|||||||
const inventory = new Map(
|
const inventory = new Map(
|
||||||
aggregate.migrations.map((migration) => [migration.identity, migration]),
|
aggregate.migrations.map((migration) => [migration.identity, migration]),
|
||||||
);
|
);
|
||||||
|
const installed = installedMigrationSources(aggregate);
|
||||||
|
const applied = new Set<string>();
|
||||||
|
const retained = new Map<string, number>();
|
||||||
const seenOrders = new Set<number>();
|
const seenOrders = new Set<number>();
|
||||||
const seenIdentities = new Set<string>();
|
const seenIdentities = new Set<string>();
|
||||||
const seenModuleIndexes = new Set<string>();
|
const seenModuleIndexes = new Set<string>();
|
||||||
@@ -240,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;
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ export type ModuleDiscoveryIssueCode =
|
|||||||
| "MODULE_PLATFORM_INCOMPATIBLE"
|
| "MODULE_PLATFORM_INCOMPATIBLE"
|
||||||
| "MODULE_SDK_INCOMPATIBLE"
|
| "MODULE_SDK_INCOMPATIBLE"
|
||||||
| "MODULE_VERSION_MISMATCH"
|
| "MODULE_VERSION_MISMATCH"
|
||||||
|
| "MODULES_DIRECTORY_INVALID"
|
||||||
| "MODULES_DIRECTORY_UNREADABLE";
|
| "MODULES_DIRECTORY_UNREADABLE";
|
||||||
|
|
||||||
export interface ModuleDiscoveryIssue {
|
export interface ModuleDiscoveryIssue {
|
||||||
@@ -117,9 +118,35 @@ export interface DiscoveredModule {
|
|||||||
export interface ModuleDiscoveryOptions {
|
export interface ModuleDiscoveryOptions {
|
||||||
readonly moduleSdkVersion: string;
|
readonly moduleSdkVersion: string;
|
||||||
readonly modulesDirectory: string;
|
readonly modulesDirectory: string;
|
||||||
|
/**
|
||||||
|
* True when an operator configured the directory explicitly (`EKKLESIA_MODULES_DIR`, ADR 0048).
|
||||||
|
* A configured directory that is missing, not a directory, or not an absolute path then fails
|
||||||
|
* discovery closed rather than reading as zero modules: a mistyped mount must never quietly start
|
||||||
|
* the platform with nothing installed. False or omitted keeps the development default lenient,
|
||||||
|
* where a checkout with no `modules/` simply has no modules.
|
||||||
|
*/
|
||||||
|
readonly modulesDirectoryRequired?: boolean;
|
||||||
readonly platformVersion: string;
|
readonly platformVersion: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The one sanitized message for an unusable configured modules directory. It never carries the
|
||||||
|
* value: a path is machine detail, and a misplaced value could be anything at all. */
|
||||||
|
export const MODULES_DIRECTORY_INVALID_MESSAGE = "EKKLESIA_MODULES_DIR must be an absolute path.";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether a configured modules directory is usable as given: a non-empty absolute path with no
|
||||||
|
* surrounding whitespace and no NUL byte. Deliberately strict rather than forgiving, because
|
||||||
|
* trimming or resolving a near-miss would silently pick a directory the operator did not name.
|
||||||
|
*/
|
||||||
|
export function isUsableModulesDirectoryPath(value: string): boolean {
|
||||||
|
return (
|
||||||
|
value.length > 0 &&
|
||||||
|
value.trim() === value &&
|
||||||
|
!value.includes("\u0000") &&
|
||||||
|
path.isAbsolute(value)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
interface ModulePackageData {
|
interface ModulePackageData {
|
||||||
name: string;
|
name: string;
|
||||||
version: string;
|
version: string;
|
||||||
@@ -695,6 +722,13 @@ export async function discoverModules(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (options.modulesDirectoryRequired && !isUsableModulesDirectoryPath(options.modulesDirectory)) {
|
||||||
|
issues.push({
|
||||||
|
code: "MODULES_DIRECTORY_INVALID",
|
||||||
|
message: MODULES_DIRECTORY_INVALID_MESSAGE,
|
||||||
|
moduleDirectory: ".",
|
||||||
|
});
|
||||||
|
}
|
||||||
if (issues.length > 0) {
|
if (issues.length > 0) {
|
||||||
throw new ModuleDiscoveryError(issues);
|
throw new ModuleDiscoveryError(issues);
|
||||||
}
|
}
|
||||||
@@ -703,7 +737,7 @@ export async function discoverModules(
|
|||||||
try {
|
try {
|
||||||
directoryEntries = await readdir(options.modulesDirectory, { withFileTypes: true });
|
directoryEntries = await readdir(options.modulesDirectory, { withFileTypes: true });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (errorCode(error) === "ENOENT") {
|
if (errorCode(error) === "ENOENT" && !options.modulesDirectoryRequired) {
|
||||||
return Object.freeze([]);
|
return Object.freeze([]);
|
||||||
}
|
}
|
||||||
throw new ModuleDiscoveryError([
|
throw new ModuleDiscoveryError([
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ export * from "./lifecycle.js";
|
|||||||
export * from "./lifecycle-schema.js";
|
export * from "./lifecycle-schema.js";
|
||||||
export * from "./lifecycle-store.js";
|
export * from "./lifecycle-store.js";
|
||||||
export * from "./migration-discovery.js";
|
export * from "./migration-discovery.js";
|
||||||
|
export * from "./modules-directory.js";
|
||||||
|
export * from "./module-package-verification.js";
|
||||||
export * from "./permissions.js";
|
export * from "./permissions.js";
|
||||||
export * from "./realtime.js";
|
export * from "./realtime.js";
|
||||||
export * from "./runtime.js";
|
export * from "./runtime.js";
|
||||||
|
|||||||
@@ -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,89 @@
|
|||||||
|
// The isolated, short-lived validation process of Module Package v1 verification (ADR 0049).
|
||||||
|
//
|
||||||
|
// The verifier forks this file with an empty environment and one JSON argument naming a candidate
|
||||||
|
// module's backend entrypoints. It imports each one, so a package whose code cannot load, or whose
|
||||||
|
// bundle still reaches for a dependency it does not contain, is refused before it is ever installed.
|
||||||
|
// Whatever the module's top-level code does happens here, in a process with no database URL, no
|
||||||
|
// secret, and no platform state, and that is discarded as soon as it reports.
|
||||||
|
//
|
||||||
|
// The result travels over the IPC channel, never stdout, so nothing a module prints can forge it.
|
||||||
|
// It carries fixed role and reason names only: never an error message, a path, or module output.
|
||||||
|
//
|
||||||
|
// Written as erasable TypeScript with no relative imports, so Node can run the source file directly
|
||||||
|
// in tests as well as the compiled file in the image.
|
||||||
|
import { pathToFileURL } from "node:url";
|
||||||
|
import { parseModuleRuntimeDefinition, parseModuleSchemaDefinition } from "@ekklesia/contracts";
|
||||||
|
|
||||||
|
interface ProbeRequest {
|
||||||
|
readonly moduleId: string;
|
||||||
|
readonly version: string;
|
||||||
|
readonly entrypoints: Readonly<Record<string, string>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ProbeFailure {
|
||||||
|
readonly role: string;
|
||||||
|
readonly reason: "export" | "identity" | "load";
|
||||||
|
}
|
||||||
|
|
||||||
|
function defaultExport(namespace: unknown): unknown {
|
||||||
|
return typeof namespace === "object" && namespace !== null && "default" in namespace
|
||||||
|
? (namespace as { default: unknown }).default
|
||||||
|
: undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function probe(request: ProbeRequest): Promise<readonly ProbeFailure[]> {
|
||||||
|
const failures: ProbeFailure[] = [];
|
||||||
|
for (const role of Object.keys(request.entrypoints).sort()) {
|
||||||
|
const entrypoint = request.entrypoints[role];
|
||||||
|
if (entrypoint === undefined) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let namespace: unknown;
|
||||||
|
try {
|
||||||
|
namespace = await import(pathToFileURL(entrypoint).href);
|
||||||
|
} catch {
|
||||||
|
failures.push({ reason: "load", role });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
if (role === "server") {
|
||||||
|
const definition = parseModuleRuntimeDefinition(defaultExport(namespace));
|
||||||
|
if (definition.id !== request.moduleId || definition.version !== request.version) {
|
||||||
|
failures.push({ reason: "identity", role });
|
||||||
|
}
|
||||||
|
} else if (role === "schema") {
|
||||||
|
const definition = parseModuleSchemaDefinition(defaultExport(namespace));
|
||||||
|
if (definition.moduleId !== request.moduleId) {
|
||||||
|
failures.push({ reason: "identity", role });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
failures.push({ reason: "export", role });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return failures;
|
||||||
|
}
|
||||||
|
|
||||||
|
function report(message: unknown): void {
|
||||||
|
if (typeof process.send !== "function") {
|
||||||
|
process.exitCode = 2;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
process.send(message, () => {
|
||||||
|
// A module may leave timers or handles behind; the probe's work is done either way.
|
||||||
|
process.exit(0);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let request: ProbeRequest | undefined;
|
||||||
|
try {
|
||||||
|
request = JSON.parse(process.argv[2] ?? "") as ProbeRequest;
|
||||||
|
} catch {
|
||||||
|
report({ failures: [{ reason: "load", role: "request" }] });
|
||||||
|
}
|
||||||
|
if (request !== undefined) {
|
||||||
|
probe(request).then(
|
||||||
|
(failures) => report({ failures }),
|
||||||
|
() => report({ failures: [{ reason: "load", role: "probe" }] }),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,479 @@
|
|||||||
|
import { fork } from "node:child_process";
|
||||||
|
import { createHash } from "node:crypto";
|
||||||
|
import { chmod, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import {
|
||||||
|
isVersionCompatible,
|
||||||
|
MODULE_PACKAGE_ANY_TARGET,
|
||||||
|
MODULE_PACKAGE_FORMAT,
|
||||||
|
MODULE_PACKAGE_METADATA_FILENAME,
|
||||||
|
type ModuleManifest,
|
||||||
|
type ModulePackageMetadata,
|
||||||
|
safeParseModuleManifest,
|
||||||
|
safeParseModulePackageMetadata,
|
||||||
|
safeParseModulePackageRuntimePackage,
|
||||||
|
} from "@ekklesia/contracts";
|
||||||
|
import { discoverModules, MODULE_MANIFEST_FILENAME, ModuleDiscoveryError } from "./discovery.js";
|
||||||
|
import { discoverModuleMigrations, ModuleMigrationDiscoveryError } from "./migration-discovery.js";
|
||||||
|
import {
|
||||||
|
type ModulePackageArchive,
|
||||||
|
ModulePackageArchiveError,
|
||||||
|
type ModulePackageArchiveErrorCode,
|
||||||
|
readModulePackageArchive,
|
||||||
|
} from "./module-package-archive.js";
|
||||||
|
import { discoverModuleSchemas, ModuleSchemaDiscoveryError } from "./schema-discovery.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verifies an Ekklesia Module Package v1 archive (ADR 0049) against the platform it runs on, without
|
||||||
|
* ever installing it.
|
||||||
|
*
|
||||||
|
* Order matters: everything that can be decided from bytes is decided before anything is written,
|
||||||
|
* and nothing is executed until the package has proven itself well-formed. The archive is read and
|
||||||
|
* checked for safety in memory; its metadata, every payload digest, and every identity statement
|
||||||
|
* are checked next; only then is it written to a fresh temporary directory, validated by the same
|
||||||
|
* discovery the platform uses, and its backend loaded once in a separate, short-lived process with
|
||||||
|
* an empty environment. A caller that asks for extraction gets the verified tree written to a new
|
||||||
|
* directory only after all of that succeeded.
|
||||||
|
*
|
||||||
|
* Errors carry a stable code and a fixed message. Paths, archive entry names, file contents, module
|
||||||
|
* output, and exception messages never appear in them: an archive is untrusted input, and none of
|
||||||
|
* it is ever echoed.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type ModulePackageVerificationErrorCode =
|
||||||
|
| ModulePackageArchiveErrorCode
|
||||||
|
| "MODULE_PACKAGE_DATABASE_RUNTIME_INCOMPATIBLE"
|
||||||
|
| "MODULE_PACKAGE_DIGEST_MISMATCH"
|
||||||
|
| "MODULE_PACKAGE_ENTRYPOINT_LOAD_FAILED"
|
||||||
|
| "MODULE_PACKAGE_EXTRACTION_FAILED"
|
||||||
|
| "MODULE_PACKAGE_FILE_MISMATCH"
|
||||||
|
| "MODULE_PACKAGE_FILE_MISSING"
|
||||||
|
| "MODULE_PACKAGE_FILE_UNLISTED"
|
||||||
|
| "MODULE_PACKAGE_FORMAT_UNSUPPORTED"
|
||||||
|
| "MODULE_PACKAGE_IDENTITY_MISMATCH"
|
||||||
|
| "MODULE_PACKAGE_MANIFEST_INVALID"
|
||||||
|
| "MODULE_PACKAGE_METADATA_INVALID"
|
||||||
|
| "MODULE_PACKAGE_METADATA_MISSING"
|
||||||
|
| "MODULE_PACKAGE_MODULE_INVALID"
|
||||||
|
| "MODULE_PACKAGE_NATIVE_FILE"
|
||||||
|
| "MODULE_PACKAGE_RUNTIME_INCOMPATIBLE"
|
||||||
|
| "MODULE_PACKAGE_RUNTIME_PACKAGE_INVALID"
|
||||||
|
| "MODULE_PACKAGE_SDK_INCOMPATIBLE"
|
||||||
|
| "MODULE_PACKAGE_TARGET_INCOMPATIBLE"
|
||||||
|
| "MODULE_PACKAGE_TRANSITIONAL_UNSUPPORTED";
|
||||||
|
|
||||||
|
const messages: Readonly<Record<ModulePackageVerificationErrorCode, string>> = Object.freeze({
|
||||||
|
MODULE_PACKAGE_DATABASE_RUNTIME_INCOMPATIBLE:
|
||||||
|
"The package's bundled database runtime does not match this platform's (a transitional constraint).",
|
||||||
|
MODULE_PACKAGE_DIGEST_MISMATCH: "The archive does not match the SHA-256 it was given.",
|
||||||
|
MODULE_PACKAGE_ENTRYPOINT_LOAD_FAILED:
|
||||||
|
"A backend entrypoint could not be loaded from the package alone.",
|
||||||
|
MODULE_PACKAGE_EXTRACTION_FAILED:
|
||||||
|
"The verified package could not be extracted to a new directory.",
|
||||||
|
MODULE_PACKAGE_FILE_MISMATCH: "A payload file does not match its listed size or SHA-256.",
|
||||||
|
MODULE_PACKAGE_FILE_MISSING: "A file listed in the package metadata is missing from the archive.",
|
||||||
|
MODULE_PACKAGE_FILE_UNLISTED: "The archive contains a file the package metadata does not list.",
|
||||||
|
MODULE_PACKAGE_FORMAT_UNSUPPORTED: "The package format is not one this platform implements.",
|
||||||
|
MODULE_PACKAGE_IDENTITY_MISMATCH:
|
||||||
|
"The package metadata, runtime package.json, and manifest disagree about the module's identity.",
|
||||||
|
MODULE_PACKAGE_LAYOUT_INVALID:
|
||||||
|
"The archive must contain exactly one top-level module directory, with each path once.",
|
||||||
|
MODULE_PACKAGE_LINK: "The archive contains a link; module packages may contain none.",
|
||||||
|
MODULE_PACKAGE_MANIFEST_INVALID: "The package's module manifest is missing or invalid.",
|
||||||
|
MODULE_PACKAGE_METADATA_INVALID: "The package metadata is invalid.",
|
||||||
|
MODULE_PACKAGE_METADATA_MISSING: "The archive contains no package metadata.",
|
||||||
|
MODULE_PACKAGE_MODULE_INVALID: "The packaged module failed module validation.",
|
||||||
|
MODULE_PACKAGE_NATIVE_FILE: "An any-target package contains a native file.",
|
||||||
|
MODULE_PACKAGE_RUNTIME_INCOMPATIBLE:
|
||||||
|
"The package needs a Node.js version this host does not run.",
|
||||||
|
MODULE_PACKAGE_RUNTIME_PACKAGE_INVALID:
|
||||||
|
"The package's runtime package.json is not the generated runtime shape.",
|
||||||
|
MODULE_PACKAGE_SDK_INCOMPATIBLE:
|
||||||
|
"The Module SDK bundled into the package is outside the manifest's own moduleSdk range.",
|
||||||
|
MODULE_PACKAGE_SPECIAL_ENTRY:
|
||||||
|
"The archive contains an entry that is neither a directory nor a regular file.",
|
||||||
|
MODULE_PACKAGE_TARGET_INCOMPATIBLE: "The package's target is not one this platform can run.",
|
||||||
|
MODULE_PACKAGE_TOO_LARGE: "The archive exceeds the module package size limits.",
|
||||||
|
MODULE_PACKAGE_TRANSITIONAL_UNSUPPORTED:
|
||||||
|
"The package metadata contains transitional facts this platform does not understand.",
|
||||||
|
MODULE_PACKAGE_UNREADABLE: "The file is not a readable gzip-compressed ustar archive.",
|
||||||
|
MODULE_PACKAGE_UNSAFE_MODE: "The archive contains a setuid, setgid, or sticky entry.",
|
||||||
|
MODULE_PACKAGE_UNSAFE_PATH: "The archive contains an absolute, escaping, or non-portable path.",
|
||||||
|
});
|
||||||
|
|
||||||
|
export class ModulePackageVerificationError extends Error {
|
||||||
|
readonly code: ModulePackageVerificationErrorCode;
|
||||||
|
/** Stable discovery issue codes, when module validation failed; never their messages. */
|
||||||
|
readonly issueCodes: readonly string[];
|
||||||
|
|
||||||
|
constructor(code: ModulePackageVerificationErrorCode, issueCodes: readonly string[] = []) {
|
||||||
|
super(messages[code]);
|
||||||
|
this.name = "ModulePackageVerificationError";
|
||||||
|
this.code = code;
|
||||||
|
this.issueCodes = Object.freeze([...new Set(issueCodes)].sort());
|
||||||
|
this.stack = `${this.name}: ${this.message}`;
|
||||||
|
Object.freeze(this);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What this platform is, for the checks that compare a package against it. */
|
||||||
|
export interface ModulePackagePlatform {
|
||||||
|
readonly platformVersion: string;
|
||||||
|
readonly moduleSdkVersion: string;
|
||||||
|
/** The transitional database runtime facts this platform requires a package to agree with. */
|
||||||
|
readonly databaseRuntime: Readonly<Record<string, string>>;
|
||||||
|
/** Defaults to the running process's Node.js version. */
|
||||||
|
readonly nodeVersion?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VerifyModulePackageOptions {
|
||||||
|
readonly archivePath: string;
|
||||||
|
/** When given, the archive's SHA-256 must equal it (lowercase hexadecimal). */
|
||||||
|
readonly expectedSha256?: string;
|
||||||
|
readonly platform: ModulePackagePlatform;
|
||||||
|
/** A directory that must not exist yet; the verified module directory is written there. */
|
||||||
|
readonly extractTo?: string;
|
||||||
|
readonly loadTimeoutMs?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ModulePackageVerificationResult {
|
||||||
|
readonly archiveSha256: string;
|
||||||
|
/** Payload files, the metadata file not included. */
|
||||||
|
readonly fileCount: number;
|
||||||
|
readonly moduleId: string;
|
||||||
|
readonly target: string;
|
||||||
|
readonly version: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fail(code: ModulePackageVerificationErrorCode, issueCodes?: readonly string[]): never {
|
||||||
|
throw new ModulePackageVerificationError(code, issueCodes);
|
||||||
|
}
|
||||||
|
|
||||||
|
function sha256(data: Uint8Array): string {
|
||||||
|
return createHash("sha256").update(data).digest("hex");
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseJson(data: Buffer | undefined): unknown {
|
||||||
|
if (data === undefined) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return JSON.parse(data.toString("utf8")) as unknown;
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The transitional facts this verifier understands, and nothing else (ADR 0049). */
|
||||||
|
const UNDERSTOOD_TRANSITIONAL: Readonly<Record<string, readonly string[]>> = Object.freeze({
|
||||||
|
databaseRuntime: Object.freeze(["drizzle-orm"]),
|
||||||
|
});
|
||||||
|
|
||||||
|
function checkTransitional(raw: Record<string, unknown>): void {
|
||||||
|
const transitional = raw.transitional;
|
||||||
|
if (transitional === undefined) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!isRecord(transitional)) {
|
||||||
|
fail("MODULE_PACKAGE_TRANSITIONAL_UNSUPPORTED");
|
||||||
|
}
|
||||||
|
for (const [key, value] of Object.entries(transitional)) {
|
||||||
|
const understood = UNDERSTOOD_TRANSITIONAL[key];
|
||||||
|
if (!understood || !isRecord(value)) {
|
||||||
|
fail("MODULE_PACKAGE_TRANSITIONAL_UNSUPPORTED");
|
||||||
|
}
|
||||||
|
if (Object.keys(value).some((name) => !understood.includes(name))) {
|
||||||
|
fail("MODULE_PACKAGE_TRANSITIONAL_UNSUPPORTED");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function readMetadata(files: ReadonlyMap<string, Buffer>): ModulePackageMetadata {
|
||||||
|
const raw = parseJson(files.get(MODULE_PACKAGE_METADATA_FILENAME));
|
||||||
|
if (!files.has(MODULE_PACKAGE_METADATA_FILENAME)) {
|
||||||
|
fail("MODULE_PACKAGE_METADATA_MISSING");
|
||||||
|
}
|
||||||
|
if (!isRecord(raw)) {
|
||||||
|
fail("MODULE_PACKAGE_METADATA_INVALID");
|
||||||
|
}
|
||||||
|
if (raw.packageFormat !== MODULE_PACKAGE_FORMAT) {
|
||||||
|
fail("MODULE_PACKAGE_FORMAT_UNSUPPORTED");
|
||||||
|
}
|
||||||
|
checkTransitional(raw);
|
||||||
|
const parsed = safeParseModulePackageMetadata(raw);
|
||||||
|
if (!parsed.success) {
|
||||||
|
fail("MODULE_PACKAGE_METADATA_INVALID");
|
||||||
|
}
|
||||||
|
return parsed.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkPayload(metadata: ModulePackageMetadata, files: ReadonlyMap<string, Buffer>): void {
|
||||||
|
const listed = new Set(metadata.files.map((file) => file.path));
|
||||||
|
for (const filePath of files.keys()) {
|
||||||
|
if (filePath !== MODULE_PACKAGE_METADATA_FILENAME && !listed.has(filePath)) {
|
||||||
|
fail("MODULE_PACKAGE_FILE_UNLISTED");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const file of metadata.files) {
|
||||||
|
const data = files.get(file.path);
|
||||||
|
if (data === undefined) {
|
||||||
|
fail("MODULE_PACKAGE_FILE_MISSING");
|
||||||
|
}
|
||||||
|
if (data.length !== file.size || sha256(data) !== file.sha256) {
|
||||||
|
fail("MODULE_PACKAGE_FILE_MISMATCH");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkIdentity(
|
||||||
|
archive: ModulePackageArchive,
|
||||||
|
metadata: ModulePackageMetadata,
|
||||||
|
files: ReadonlyMap<string, Buffer>,
|
||||||
|
): ModuleManifest {
|
||||||
|
const runtimePackage = safeParseModulePackageRuntimePackage(parseJson(files.get("package.json")));
|
||||||
|
if (!runtimePackage.success) {
|
||||||
|
fail("MODULE_PACKAGE_RUNTIME_PACKAGE_INVALID");
|
||||||
|
}
|
||||||
|
const manifest = safeParseModuleManifest(parseJson(files.get(MODULE_MANIFEST_FILENAME)));
|
||||||
|
if (!manifest.success) {
|
||||||
|
fail("MODULE_PACKAGE_MANIFEST_INVALID");
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
archive.topLevel !== metadata.module.id ||
|
||||||
|
manifest.data.id !== metadata.module.id ||
|
||||||
|
manifest.data.version !== metadata.module.version ||
|
||||||
|
runtimePackage.data.version !== metadata.module.version
|
||||||
|
) {
|
||||||
|
fail("MODULE_PACKAGE_IDENTITY_MISMATCH");
|
||||||
|
}
|
||||||
|
return manifest.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkCompatibility(
|
||||||
|
metadata: ModulePackageMetadata,
|
||||||
|
manifest: ModuleManifest,
|
||||||
|
files: ReadonlyMap<string, Buffer>,
|
||||||
|
platform: ModulePackagePlatform,
|
||||||
|
): void {
|
||||||
|
// This verifier implements the portable target only. Native targets are a later slice.
|
||||||
|
if (metadata.target !== MODULE_PACKAGE_ANY_TARGET) {
|
||||||
|
fail("MODULE_PACKAGE_TARGET_INCOMPATIBLE");
|
||||||
|
}
|
||||||
|
if ([...files.keys()].some((filePath) => filePath.toLowerCase().endsWith(".node"))) {
|
||||||
|
fail("MODULE_PACKAGE_NATIVE_FILE");
|
||||||
|
}
|
||||||
|
if (!isVersionCompatible(platform.nodeVersion ?? process.versions.node, metadata.runtime.node)) {
|
||||||
|
fail("MODULE_PACKAGE_RUNTIME_INCOMPATIBLE");
|
||||||
|
}
|
||||||
|
const sdk = metadata.incorporates["@ekklesia/module-sdk"];
|
||||||
|
if (sdk !== undefined && !isVersionCompatible(sdk, manifest.compatibility.moduleSdk)) {
|
||||||
|
fail("MODULE_PACKAGE_SDK_INCOMPATIBLE");
|
||||||
|
}
|
||||||
|
// Transitional (ADR 0049): today a module's Drizzle copy must be interchangeable with the
|
||||||
|
// platform's, because Core queries the module's tables with its own Drizzle instance. This is an
|
||||||
|
// implementation constraint pending a Module Database ABI, not that ABI.
|
||||||
|
for (const [runtime, version] of Object.entries(metadata.transitional?.databaseRuntime ?? {})) {
|
||||||
|
if (platform.databaseRuntime[runtime] !== version) {
|
||||||
|
fail("MODULE_PACKAGE_DATABASE_RUNTIME_INCOMPATIBLE");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Writes the archive's directories (`0755`) and files (`0644`) under `root`, which must not exist.
|
||||||
|
* Safe by construction: every path was validated as relative and non-escaping, and only directories
|
||||||
|
* and regular files exist. Ownership is the writing process's, never the archive's. */
|
||||||
|
async function writeTree(root: string, archive: ModulePackageArchive): Promise<void> {
|
||||||
|
await mkdir(root, { mode: 0o755 });
|
||||||
|
await chmod(root, 0o755);
|
||||||
|
for (const entry of archive.entries) {
|
||||||
|
const target = path.join(root, ...entry.path.split("/"));
|
||||||
|
if (entry.type === "directory") {
|
||||||
|
await mkdir(target, { mode: 0o755, recursive: true });
|
||||||
|
await chmod(target, 0o755);
|
||||||
|
} else {
|
||||||
|
await mkdir(path.dirname(target), { mode: 0o755, recursive: true });
|
||||||
|
await writeFile(target, entry.data, { flag: "wx", mode: 0o644 });
|
||||||
|
await chmod(target, 0o644);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function probeEntrypointPath(): string {
|
||||||
|
// The probe sits beside this file: compiled `.js` in an installation, source `.ts` under a test
|
||||||
|
// runner, which Node runs directly. Either way it is a separate process.
|
||||||
|
const self = fileURLToPath(import.meta.url);
|
||||||
|
return path.join(path.dirname(self), `module-package-probe${path.extname(self)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Loads every backend entrypoint in a short-lived child with an empty environment, and reports
|
||||||
|
* only whether each loaded and exported a definition naming this module. */
|
||||||
|
async function loadEntrypoints(
|
||||||
|
moduleId: string,
|
||||||
|
version: string,
|
||||||
|
entrypoints: Readonly<Record<string, string>>,
|
||||||
|
timeoutMs: number,
|
||||||
|
): Promise<void> {
|
||||||
|
const outcome = await new Promise<"ok" | "failed">((resolve) => {
|
||||||
|
let settled = false;
|
||||||
|
let child: ReturnType<typeof fork> | undefined;
|
||||||
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
const finish = (value: "ok" | "failed"): void => {
|
||||||
|
if (!settled) {
|
||||||
|
settled = true;
|
||||||
|
clearTimeout(timer);
|
||||||
|
child?.kill("SIGKILL");
|
||||||
|
resolve(value);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
child = fork(probeEntrypointPath(), [JSON.stringify({ entrypoints, moduleId, version })], {
|
||||||
|
cwd: tmpdir(),
|
||||||
|
env: {},
|
||||||
|
execArgv: [],
|
||||||
|
serialization: "json",
|
||||||
|
stdio: ["ignore", "ignore", "ignore", "ipc"],
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
finish("failed");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
timer = setTimeout(() => finish("failed"), timeoutMs);
|
||||||
|
child.on("message", (message: unknown) => {
|
||||||
|
const failures = isRecord(message) ? message.failures : undefined;
|
||||||
|
finish(Array.isArray(failures) && failures.length === 0 ? "ok" : "failed");
|
||||||
|
});
|
||||||
|
child.on("error", () => finish("failed"));
|
||||||
|
child.on("exit", () => finish("failed"));
|
||||||
|
});
|
||||||
|
if (outcome !== "ok") {
|
||||||
|
fail("MODULE_PACKAGE_ENTRYPOINT_LOAD_FAILED");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function discoveryIssueCodes(error: unknown): readonly string[] {
|
||||||
|
if (
|
||||||
|
error instanceof ModuleDiscoveryError ||
|
||||||
|
error instanceof ModuleMigrationDiscoveryError ||
|
||||||
|
error instanceof ModuleSchemaDiscoveryError
|
||||||
|
) {
|
||||||
|
return error.issues.map((issue) => issue.code);
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function verifyModulePackage(
|
||||||
|
options: VerifyModulePackageOptions,
|
||||||
|
): Promise<ModulePackageVerificationResult> {
|
||||||
|
let compressed: Buffer;
|
||||||
|
try {
|
||||||
|
compressed = await readFile(options.archivePath);
|
||||||
|
} catch {
|
||||||
|
fail("MODULE_PACKAGE_UNREADABLE");
|
||||||
|
}
|
||||||
|
const archiveSha256 = sha256(compressed);
|
||||||
|
if (options.expectedSha256 !== undefined && options.expectedSha256 !== archiveSha256) {
|
||||||
|
fail("MODULE_PACKAGE_DIGEST_MISMATCH");
|
||||||
|
}
|
||||||
|
|
||||||
|
let archive: ModulePackageArchive;
|
||||||
|
try {
|
||||||
|
archive = readModulePackageArchive(compressed);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof ModulePackageArchiveError) {
|
||||||
|
fail(error.code);
|
||||||
|
}
|
||||||
|
fail("MODULE_PACKAGE_UNREADABLE");
|
||||||
|
}
|
||||||
|
const files = new Map(
|
||||||
|
archive.entries
|
||||||
|
.filter((entry) => entry.type === "file")
|
||||||
|
.map((entry) => [entry.path, entry.data] as const),
|
||||||
|
);
|
||||||
|
|
||||||
|
const metadata = readMetadata(files);
|
||||||
|
checkPayload(metadata, files);
|
||||||
|
const manifest = checkIdentity(archive, metadata, files);
|
||||||
|
checkCompatibility(metadata, manifest, files, options.platform);
|
||||||
|
|
||||||
|
const scratch = await mkdtemp(path.join(tmpdir(), "ekklesia-package-verify-"));
|
||||||
|
try {
|
||||||
|
// The candidate alone, in a scratch inventory of its own: never module storage.
|
||||||
|
const modulesDirectory = path.join(scratch, "candidates");
|
||||||
|
await mkdir(modulesDirectory, { mode: 0o755 });
|
||||||
|
await writeTree(path.join(modulesDirectory, metadata.module.id), archive);
|
||||||
|
|
||||||
|
const discoveryOptions = {
|
||||||
|
moduleSdkVersion: options.platform.moduleSdkVersion,
|
||||||
|
modulesDirectory,
|
||||||
|
modulesDirectoryRequired: true,
|
||||||
|
platformVersion: options.platform.platformVersion,
|
||||||
|
} as const;
|
||||||
|
let entrypoints: Record<string, string>;
|
||||||
|
try {
|
||||||
|
// The same static validation the platform runs, against this package alone. A module that
|
||||||
|
// requires another module is validated with its dependencies when it is installed.
|
||||||
|
const [discovered] = await discoverModules(discoveryOptions);
|
||||||
|
if (discovered === undefined) {
|
||||||
|
fail("MODULE_PACKAGE_MODULE_INVALID");
|
||||||
|
}
|
||||||
|
await discoverModuleMigrations(discoveryOptions);
|
||||||
|
entrypoints = {
|
||||||
|
server: discovered.entrypoints.server,
|
||||||
|
...(discovered.entrypoints.schema ? { schema: discovered.entrypoints.schema } : {}),
|
||||||
|
...(discovered.entrypoints.jobs ? { jobs: discovered.entrypoints.jobs } : {}),
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof ModulePackageVerificationError) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
fail("MODULE_PACKAGE_MODULE_INVALID", discoveryIssueCodes(error));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Executed only now, and first in isolation: a schema is imported in this process below only
|
||||||
|
// after the probe proved it loads on its own.
|
||||||
|
await loadEntrypoints(
|
||||||
|
metadata.module.id,
|
||||||
|
metadata.module.version,
|
||||||
|
entrypoints,
|
||||||
|
options.loadTimeoutMs ?? 30_000,
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
await discoverModuleSchemas(discoveryOptions);
|
||||||
|
} catch (error) {
|
||||||
|
fail("MODULE_PACKAGE_MODULE_INVALID", discoveryIssueCodes(error));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.extractTo !== undefined) {
|
||||||
|
const extractTo = options.extractTo;
|
||||||
|
let created = false;
|
||||||
|
try {
|
||||||
|
await mkdir(extractTo, { mode: 0o755 });
|
||||||
|
created = true;
|
||||||
|
await rm(extractTo, { recursive: true });
|
||||||
|
await writeTree(extractTo, archive);
|
||||||
|
} catch {
|
||||||
|
// Never leave a partial module directory behind; never remove one this call did not create.
|
||||||
|
if (created) {
|
||||||
|
await rm(extractTo, { force: true, recursive: true }).catch(() => undefined);
|
||||||
|
}
|
||||||
|
fail("MODULE_PACKAGE_EXTRACTION_FAILED");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await rm(scratch, { force: true, recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
return Object.freeze({
|
||||||
|
archiveSha256,
|
||||||
|
fileCount: metadata.files.length,
|
||||||
|
moduleId: metadata.module.id,
|
||||||
|
target: metadata.target,
|
||||||
|
version: metadata.module.version,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -8,10 +8,11 @@ import {
|
|||||||
diagnoseEkklesia,
|
diagnoseEkklesia,
|
||||||
formatDoctorReport,
|
formatDoctorReport,
|
||||||
} from "../src/diagnostics/index.js";
|
} from "../src/diagnostics/index.js";
|
||||||
import type {
|
import {
|
||||||
DiscoveredModule,
|
type DiscoveredModule,
|
||||||
ModuleMigrationAggregate,
|
discoverModules,
|
||||||
ModuleSchemaAggregate,
|
type ModuleMigrationAggregate,
|
||||||
|
type ModuleSchemaAggregate,
|
||||||
} from "../src/modules/index.js";
|
} from "../src/modules/index.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -34,8 +35,8 @@ const moduleSecret = "module-secret-material";
|
|||||||
|
|
||||||
const metadata = Object.freeze({
|
const metadata = Object.freeze({
|
||||||
moduleSdkVersion: "0.1.0",
|
moduleSdkVersion: "0.1.0",
|
||||||
modulesDirectory: "/not-rendered/modules",
|
|
||||||
platformVersion: "0.1.0",
|
platformVersion: "0.1.0",
|
||||||
|
repositoryRoot: "/not-rendered",
|
||||||
runtimeRange: ">=24.11.0 <25",
|
runtimeRange: ">=24.11.0 <25",
|
||||||
runtimeVersion: "24.15.0",
|
runtimeVersion: "24.15.0",
|
||||||
});
|
});
|
||||||
@@ -454,3 +455,33 @@ describe("Doctor host-configuration parity — sanitization", () => {
|
|||||||
expect(JSON.stringify(report)).not.toContain("S3_ACCESS_KEY");
|
expect(JSON.stringify(report)).not.toContain("S3_ACCESS_KEY");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("Doctor reads the modules directory from the same host configuration (ADR 0048)", () => {
|
||||||
|
it("uses the repository's own modules directory when EKKLESIA_MODULES_DIR is unset", () => {
|
||||||
|
const doctorOptions = createDoctorOptions(environment(), metadata);
|
||||||
|
expect(doctorOptions.moduleDiscovery.modulesDirectory).toBe("/not-rendered/modules");
|
||||||
|
expect(doctorOptions.moduleDiscovery.modulesDirectoryRequired).toBe(false);
|
||||||
|
expect(doctorOptions.coreMigrationsDirectory).toBe("/not-rendered/core/drizzle");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses a configured directory, and still finds Core's migrations under the platform", () => {
|
||||||
|
const doctorOptions = createDoctorOptions(
|
||||||
|
environment({ EKKLESIA_MODULES_DIR: "/var/lib/ekklesia/modules" }),
|
||||||
|
metadata,
|
||||||
|
);
|
||||||
|
expect(doctorOptions.moduleDiscovery.modulesDirectory).toBe("/var/lib/ekklesia/modules");
|
||||||
|
expect(doctorOptions.moduleDiscovery.modulesDirectoryRequired).toBe(true);
|
||||||
|
expect(doctorOptions.coreMigrationsDirectory).toBe("/not-rendered/core/drizzle");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hands an unusable value to discovery to report, rather than throwing before a report exists", async () => {
|
||||||
|
const doctorOptions = createDoctorOptions(
|
||||||
|
environment({ EKKLESIA_MODULES_DIR: "relative/private-mount-91aa" }),
|
||||||
|
metadata,
|
||||||
|
);
|
||||||
|
expect(doctorOptions.moduleDiscovery.modulesDirectoryRequired).toBe(true);
|
||||||
|
const report = await diagnoseEkklesia(doctorOptions, dependencies({ discoverModules }));
|
||||||
|
expect(report.checks.find((entry) => entry.id === "modules.discovery")?.status).toBe("failure");
|
||||||
|
expect(JSON.stringify(report)).not.toContain("private-mount-91aa");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -68,6 +68,7 @@ describe("Ekklesia Doctor terminal report", () => {
|
|||||||
it("renders the real zero-module and no-database shape exactly", async () => {
|
it("renders the real zero-module and no-database shape exactly", async () => {
|
||||||
const report = await diagnoseEkklesia(
|
const report = await diagnoseEkklesia(
|
||||||
{
|
{
|
||||||
|
coreMigrationsDirectory: "/not-rendered/core/drizzle",
|
||||||
moduleDiscovery: {
|
moduleDiscovery: {
|
||||||
moduleSdkVersion: "0.1.0",
|
moduleSdkVersion: "0.1.0",
|
||||||
modulesDirectory: "/not-rendered/modules",
|
modulesDirectory: "/not-rendered/modules",
|
||||||
@@ -117,6 +118,7 @@ Identity
|
|||||||
Identity runtime configuration (signing key, base URL, trusted origins) is not set.
|
Identity runtime configuration (signing key, base URL, trusted origins) is not set.
|
||||||
✓ Direct client-address mode
|
✓ Direct client-address mode
|
||||||
Trusts only the actual immediate socket peer for rate limiting; no forwarded header is trusted.
|
Trusts only the actual immediate socket peer for rate limiting; no forwarded header is trusted.
|
||||||
|
Checked: the environment Doctor runs with. In the reference deployment \`migrate\` is given the API's client-address values; a running API's own value is shown by \`docker compose exec api printenv EKKLESIA_CLIENT_ADDRESS_MODE\`.
|
||||||
|
|
||||||
Object storage
|
Object storage
|
||||||
○ Object storage not configured
|
○ Object storage not configured
|
||||||
@@ -134,6 +136,7 @@ Result: READY WITH WARNINGS`);
|
|||||||
it("renders every check the report contains, so none can silently affect the result", async () => {
|
it("renders every check the report contains, so none can silently affect the result", async () => {
|
||||||
const report = await diagnoseEkklesia(
|
const report = await diagnoseEkklesia(
|
||||||
{
|
{
|
||||||
|
coreMigrationsDirectory: "/not-rendered/core/drizzle",
|
||||||
moduleDiscovery: {
|
moduleDiscovery: {
|
||||||
moduleSdkVersion: "0.1.0",
|
moduleSdkVersion: "0.1.0",
|
||||||
modulesDirectory: "/not-rendered/modules",
|
modulesDirectory: "/not-rendered/modules",
|
||||||
@@ -158,6 +161,7 @@ Result: READY WITH WARNINGS`);
|
|||||||
it("does not depend on NO_COLOR or terminal interactivity", async () => {
|
it("does not depend on NO_COLOR or terminal interactivity", async () => {
|
||||||
const report = await diagnoseEkklesia(
|
const report = await diagnoseEkklesia(
|
||||||
{
|
{
|
||||||
|
coreMigrationsDirectory: "/not-rendered/core/drizzle",
|
||||||
moduleDiscovery: {
|
moduleDiscovery: {
|
||||||
moduleSdkVersion: "0.1.0",
|
moduleSdkVersion: "0.1.0",
|
||||||
modulesDirectory: "/not-rendered/modules",
|
modulesDirectory: "/not-rendered/modules",
|
||||||
@@ -206,6 +210,7 @@ Result: READY WITH WARNINGS`);
|
|||||||
const report = await diagnoseEkklesia(
|
const report = await diagnoseEkklesia(
|
||||||
{
|
{
|
||||||
databaseUrl: "postgresql://another:[email protected]/db",
|
databaseUrl: "postgresql://another:[email protected]/db",
|
||||||
|
coreMigrationsDirectory: "/not-rendered/core/drizzle",
|
||||||
moduleDiscovery: {
|
moduleDiscovery: {
|
||||||
moduleSdkVersion: "0.1.0",
|
moduleSdkVersion: "0.1.0",
|
||||||
modulesDirectory: "/not-rendered/modules",
|
modulesDirectory: "/not-rendered/modules",
|
||||||
|
|||||||
+218
-2
@@ -1,5 +1,9 @@
|
|||||||
import { type ModuleManifest, parsePermissionId } from "@ekklesia/contracts";
|
import { type ModuleManifest, parsePermissionId } from "@ekklesia/contracts";
|
||||||
import { describe, expect, it, vi } from "vitest";
|
import { mkdtemp, rm } from "node:fs/promises";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
import { createPermissionRegistry, PermissionRegistryError } from "../src/authorization/index.js";
|
import { createPermissionRegistry, PermissionRegistryError } from "../src/authorization/index.js";
|
||||||
import {
|
import {
|
||||||
type DoctorCheck,
|
type DoctorCheck,
|
||||||
@@ -114,7 +118,10 @@ function emptyDatabase(): MigrationDatabase {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function plan(entries: MigrationPlanResult["entries"] = []): MigrationPlanResult {
|
function plan(
|
||||||
|
entries: MigrationPlanResult["entries"] = [],
|
||||||
|
retained: MigrationPlanResult["retained"] = [],
|
||||||
|
): MigrationPlanResult {
|
||||||
const appliedCount = entries.filter((entry) => entry.status === "applied").length;
|
const appliedCount = entries.filter((entry) => entry.status === "applied").length;
|
||||||
const approvalRequiredCount = entries.filter(
|
const approvalRequiredCount = entries.filter(
|
||||||
(entry) => entry.status === "approval-blocked",
|
(entry) => entry.status === "approval-blocked",
|
||||||
@@ -124,6 +131,7 @@ function plan(entries: MigrationPlanResult["entries"] = []): MigrationPlanResult
|
|||||||
approvalRequiredCount,
|
approvalRequiredCount,
|
||||||
entries: Object.freeze([...entries]),
|
entries: Object.freeze([...entries]),
|
||||||
pendingCount: entries.length - appliedCount,
|
pendingCount: entries.length - appliedCount,
|
||||||
|
retained: Object.freeze([...retained]),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -151,6 +159,7 @@ function dependencies(overrides: Partial<DoctorDependencies> = {}): DoctorDepend
|
|||||||
function options(databaseUrl?: string): DoctorOptions {
|
function options(databaseUrl?: string): DoctorOptions {
|
||||||
return {
|
return {
|
||||||
...(databaseUrl === undefined ? {} : { databaseUrl }),
|
...(databaseUrl === undefined ? {} : { databaseUrl }),
|
||||||
|
coreMigrationsDirectory: "/not-rendered/core/drizzle",
|
||||||
moduleDiscovery: {
|
moduleDiscovery: {
|
||||||
moduleSdkVersion: "0.1.0",
|
moduleSdkVersion: "0.1.0",
|
||||||
modulesDirectory: "/not-rendered/modules",
|
modulesDirectory: "/not-rendered/modules",
|
||||||
@@ -705,6 +714,88 @@ describe("Ekklesia Doctor diagnostics", () => {
|
|||||||
expect(report.result).toBe("ready-with-warnings"); // PostgreSQL is still skipped in this fixture
|
expect(report.result).toBe("ready-with-warnings"); // PostgreSQL is still skipped in this fixture
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("Dashboard and API topology (ADR 0045)", () => {
|
||||||
|
const secret = "correct-horse-battery-staple-0123456789";
|
||||||
|
async function identityCheck(identity: {
|
||||||
|
readonly baseUrl: string;
|
||||||
|
readonly dashboardOrigin?: string;
|
||||||
|
readonly environment?: "development" | "production";
|
||||||
|
readonly trustedOrigins: readonly string[];
|
||||||
|
}) {
|
||||||
|
const report = await diagnoseEkklesia(
|
||||||
|
{
|
||||||
|
...options(),
|
||||||
|
identityConfiguration: { environment: "production", secret, ...identity },
|
||||||
|
},
|
||||||
|
dependencies(),
|
||||||
|
);
|
||||||
|
return { check: check(report, "identity.configuration"), report };
|
||||||
|
}
|
||||||
|
|
||||||
|
it("reports the same-origin topology when no Dashboard origin is set", async () => {
|
||||||
|
const { check: identity } = await identityCheck({
|
||||||
|
baseUrl: "https://ekklesia.example.test",
|
||||||
|
trustedOrigins: ["https://ekklesia.example.test"],
|
||||||
|
});
|
||||||
|
expect(identity.status).toBe("pass");
|
||||||
|
expect(identity.details).toContain(
|
||||||
|
"Topology: same-origin (the Dashboard is served from the API's origin).",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports the split topology for sibling hostnames without printing either", async () => {
|
||||||
|
const { check: identity, report } = await identityCheck({
|
||||||
|
baseUrl: "https://api.example.test",
|
||||||
|
dashboardOrigin: "https://dash.example.test",
|
||||||
|
trustedOrigins: ["https://dash.example.test"],
|
||||||
|
});
|
||||||
|
expect(identity.status).toBe("pass");
|
||||||
|
expect(identity.details).toContain(
|
||||||
|
"Topology: split (the Dashboard and the API are on separate origins).",
|
||||||
|
);
|
||||||
|
expect(JSON.stringify(report)).not.toContain("dash.example.test");
|
||||||
|
expect(JSON.stringify(report)).not.toContain("api.example.test");
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
["different registrable domains", "https://dash.example.test", "https://api.example.org"],
|
||||||
|
["two IP addresses", "https://10.0.0.2", "https://10.0.0.1"],
|
||||||
|
["a parent that is only a top-level domain", "https://dash.test", "https://api.test"],
|
||||||
|
])("warns, without failing, when split hostnames may not share a site: %s", async (_case, dashboardOrigin, baseUrl) => {
|
||||||
|
const { check: identity, report } = await identityCheck({
|
||||||
|
baseUrl,
|
||||||
|
dashboardOrigin,
|
||||||
|
trustedOrigins: [dashboardOrigin],
|
||||||
|
});
|
||||||
|
expect(identity.status).toBe("warning");
|
||||||
|
expect(identity.details.join("\n")).toContain("not siblings under one parent domain");
|
||||||
|
expect(doctorExitCode(report)).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("treats another port on the same host as the same site (split development)", async () => {
|
||||||
|
const { check: identity } = await identityCheck({
|
||||||
|
baseUrl: "http://localhost:3000",
|
||||||
|
dashboardOrigin: "http://localhost:5173",
|
||||||
|
environment: "development",
|
||||||
|
trustedOrigins: ["http://localhost:5173"],
|
||||||
|
});
|
||||||
|
expect(identity.status).toBe("pass");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fails, as the API's startup would, for a Dashboard origin the API does not trust", async () => {
|
||||||
|
const { check: identity, report } = await identityCheck({
|
||||||
|
baseUrl: "https://api.example.test",
|
||||||
|
dashboardOrigin: "https://dash.example.test",
|
||||||
|
trustedOrigins: ["https://other.example.test"],
|
||||||
|
});
|
||||||
|
expect(identity.status).toBe("failure");
|
||||||
|
expect(identity.details.some((detail) => detail.includes("DASHBOARD_ORIGIN_UNTRUSTED"))).toBe(
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
expect(doctorExitCode(report)).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("fails identity configuration for an invalid secret without leaking it", async () => {
|
it("fails identity configuration for an invalid secret without leaking it", async () => {
|
||||||
const hostileSecret = "s3cr3t-do-not-log";
|
const hostileSecret = "s3cr3t-do-not-log";
|
||||||
const report = await diagnoseEkklesia(
|
const report = await diagnoseEkklesia(
|
||||||
@@ -876,6 +967,21 @@ describe("Ekklesia Doctor diagnostics", () => {
|
|||||||
expect(report.result).toBe("ready-with-warnings");
|
expect(report.result).toBe("ready-with-warnings");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("says whose client-address configuration it checked: this process's, not a running API's", async () => {
|
||||||
|
for (const clientAddress of [
|
||||||
|
undefined,
|
||||||
|
{ header: "x-forwarded-for", mode: "trusted-proxy" as const, trustedProxies: ["10.0.0.1"] },
|
||||||
|
{ header: "x-forwarded-for", mode: "trusted-proxy" as const, trustedProxies: ["bad"] },
|
||||||
|
]) {
|
||||||
|
const report = await diagnoseEkklesia(
|
||||||
|
{ ...options(), ...(clientAddress ? { clientAddress } : {}) },
|
||||||
|
dependencies(),
|
||||||
|
);
|
||||||
|
const details = check(report, "identity.client-address").details;
|
||||||
|
expect(details.some((detail) => detail.includes("environment Doctor runs with"))).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it("passes client-address configuration with an explicitly configured trusted-proxy mode", async () => {
|
it("passes client-address configuration with an explicitly configured trusted-proxy mode", async () => {
|
||||||
const report = await diagnoseEkklesia(
|
const report = await diagnoseEkklesia(
|
||||||
{
|
{
|
||||||
@@ -1560,3 +1666,113 @@ describe("Doctor reports declared browser contributions", () => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("Doctor with installation-owned module storage (ADR 0048)", () => {
|
||||||
|
const platformCoreMigrations = path.join(
|
||||||
|
path.dirname(fileURLToPath(import.meta.url)),
|
||||||
|
"..",
|
||||||
|
"drizzle",
|
||||||
|
);
|
||||||
|
const temporaryDirectories: string[] = [];
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await Promise.all(
|
||||||
|
temporaryDirectories.splice(0).map((directory) => rm(directory, { recursive: true })),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("finds Core's migrations where the platform is installed, not beside the modules", async () => {
|
||||||
|
// A modules directory far from the platform, as the image mounts /var/lib/ekklesia/modules. Its
|
||||||
|
// parent has no `core/drizzle`, so a Doctor that still derived Core's migrations from the
|
||||||
|
// modules directory would fail migration discovery here.
|
||||||
|
const external = await mkdtemp(path.join(tmpdir(), "ekklesia-external-modules-"));
|
||||||
|
temporaryDirectories.push(external);
|
||||||
|
const report = await diagnoseEkklesia({
|
||||||
|
coreMigrationsDirectory: platformCoreMigrations,
|
||||||
|
moduleDiscovery: {
|
||||||
|
moduleSdkVersion: "0.1.0",
|
||||||
|
modulesDirectory: external,
|
||||||
|
modulesDirectoryRequired: true,
|
||||||
|
platformVersion: "0.1.0",
|
||||||
|
},
|
||||||
|
runtimeRange: ">=24.11.0 <25",
|
||||||
|
runtimeVersion: "24.15.0",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(check(report, "modules.discovery").status).toBe("pass");
|
||||||
|
const migrations = check(report, "migrations.discovery");
|
||||||
|
expect(migrations.status).toBe("pass");
|
||||||
|
expect(migrations.details.some((detail) => detail.startsWith("core:0000_"))).toBe(true);
|
||||||
|
expect(JSON.stringify(report)).not.toContain(external);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports an unusable configured modules directory as a discovery failure, naming no path", async () => {
|
||||||
|
const report = await diagnoseEkklesia({
|
||||||
|
coreMigrationsDirectory: platformCoreMigrations,
|
||||||
|
moduleDiscovery: {
|
||||||
|
moduleSdkVersion: "0.1.0",
|
||||||
|
modulesDirectory: "relative/private-mount-3c9d",
|
||||||
|
modulesDirectoryRequired: true,
|
||||||
|
platformVersion: "0.1.0",
|
||||||
|
},
|
||||||
|
runtimeRange: ">=24.11.0 <25",
|
||||||
|
runtimeVersion: "24.15.0",
|
||||||
|
});
|
||||||
|
|
||||||
|
const discovery = check(report, "modules.discovery");
|
||||||
|
expect(discovery.status).toBe("failure");
|
||||||
|
expect(discovery.details).toEqual(["The configured modules directory is not usable."]);
|
||||||
|
expect(check(report, "migrations.discovery").status).toBe("skipped");
|
||||||
|
expect(JSON.stringify(report)).not.toContain("private-mount-3c9d");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports a configured modules directory that does not exist, naming no path", async () => {
|
||||||
|
const parent = await mkdtemp(path.join(tmpdir(), "ekklesia-missing-modules-"));
|
||||||
|
temporaryDirectories.push(parent);
|
||||||
|
const report = await diagnoseEkklesia({
|
||||||
|
coreMigrationsDirectory: platformCoreMigrations,
|
||||||
|
moduleDiscovery: {
|
||||||
|
moduleSdkVersion: "0.1.0",
|
||||||
|
modulesDirectory: path.join(parent, "absent"),
|
||||||
|
modulesDirectoryRequired: true,
|
||||||
|
platformVersion: "0.1.0",
|
||||||
|
},
|
||||||
|
runtimeRange: ">=24.11.0 <25",
|
||||||
|
runtimeVersion: "24.15.0",
|
||||||
|
});
|
||||||
|
|
||||||
|
const discovery = check(report, "modules.discovery");
|
||||||
|
expect(discovery.status).toBe("failure");
|
||||||
|
expect(discovery.details).toEqual(["The configured modules directory could not be read."]);
|
||||||
|
expect(JSON.stringify(report)).not.toContain(parent);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports an absent module's retained history without blocking readiness", async () => {
|
||||||
|
const report = await diagnoseEkklesia(
|
||||||
|
options("postgres://[email protected]/ekklesia"),
|
||||||
|
dependencies({
|
||||||
|
planMigrations: vi.fn(async () =>
|
||||||
|
plan(
|
||||||
|
[planEntry("core:0000_identity_core_tables", "a".repeat(64), "applied")],
|
||||||
|
[Object.freeze({ appliedCount: 2, moduleId: "calendar" })],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const planCheck = check(report, "postgresql.plan");
|
||||||
|
expect(planCheck.status).toBe("pass");
|
||||||
|
expect(planCheck.details).toContain(
|
||||||
|
"retained · calendar · 2 applied migrations kept; the module is not installed",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("passes Core's own migration directory to migration discovery", async () => {
|
||||||
|
const discoverMigrations = vi.fn(async () => migrationAggregate());
|
||||||
|
await diagnoseEkklesia(options(), dependencies({ discoverMigrations }));
|
||||||
|
expect(discoverMigrations).toHaveBeenCalledWith({
|
||||||
|
coreMigrationsDirectory: "/not-rendered/core/drizzle",
|
||||||
|
moduleDiscovery: options().moduleDiscovery,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -68,6 +68,7 @@ const INTERPRETED_VARIABLES = Object.freeze([
|
|||||||
"EKKLESIA_IDENTITY_BASE_URL",
|
"EKKLESIA_IDENTITY_BASE_URL",
|
||||||
"EKKLESIA_IDENTITY_SECRET",
|
"EKKLESIA_IDENTITY_SECRET",
|
||||||
"EKKLESIA_IDENTITY_TRUSTED_ORIGINS",
|
"EKKLESIA_IDENTITY_TRUSTED_ORIGINS",
|
||||||
|
"EKKLESIA_MODULES_DIR",
|
||||||
"EKKLESIA_PUBLIC_SIGNUP_ENABLED",
|
"EKKLESIA_PUBLIC_SIGNUP_ENABLED",
|
||||||
"EKKLESIA_SMTP_FROM_ADDRESS",
|
"EKKLESIA_SMTP_FROM_ADDRESS",
|
||||||
"EKKLESIA_SMTP_FROM_NAME",
|
"EKKLESIA_SMTP_FROM_NAME",
|
||||||
|
|||||||
@@ -48,6 +48,19 @@ describe("host configuration loading", () => {
|
|||||||
expect(host.identity.secret).toBeUndefined();
|
expect(host.identity.secret).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("carries the configured modules directory verbatim, and nothing when it is unset", () => {
|
||||||
|
expect(loadHostConfiguration({}).modulesDirectory).toBeUndefined();
|
||||||
|
expect(
|
||||||
|
loadHostConfiguration({ EKKLESIA_MODULES_DIR: "/var/lib/ekklesia/modules" }).modulesDirectory,
|
||||||
|
).toBe("/var/lib/ekklesia/modules");
|
||||||
|
// Validation is the resolver's job (ADR 0048). The loader neither repairs a relative or blank
|
||||||
|
// value nor treats a set-but-empty variable as unset, so the resolver can refuse it.
|
||||||
|
expect(
|
||||||
|
loadHostConfiguration({ EKKLESIA_MODULES_DIR: "relative/modules" }).modulesDirectory,
|
||||||
|
).toBe("relative/modules");
|
||||||
|
expect(loadHostConfiguration({ EKKLESIA_MODULES_DIR: "" }).modulesDirectory).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
it("carries every object-storage field the storage passes established", () => {
|
it("carries every object-storage field the storage passes established", () => {
|
||||||
const host = loadHostConfiguration(productionEnvironment);
|
const host = loadHostConfiguration(productionEnvironment);
|
||||||
|
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ describe("migration command reports", () => {
|
|||||||
}),
|
}),
|
||||||
]),
|
]),
|
||||||
pendingCount: 2,
|
pendingCount: 2,
|
||||||
|
retained: Object.freeze([]),
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(formatMigrationPlanReport(plan)).toEqual([
|
expect(formatMigrationPlanReport(plan)).toEqual([
|
||||||
@@ -64,6 +65,7 @@ describe("migration command reports", () => {
|
|||||||
]),
|
]),
|
||||||
appliedCount: 1,
|
appliedCount: 1,
|
||||||
noOp: false,
|
noOp: false,
|
||||||
|
retained: Object.freeze([]),
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(formatMigrationApplyReport(applied)).toEqual([
|
expect(formatMigrationApplyReport(applied)).toEqual([
|
||||||
@@ -72,7 +74,12 @@ describe("migration command reports", () => {
|
|||||||
]);
|
]);
|
||||||
expect(
|
expect(
|
||||||
formatMigrationApplyReport(
|
formatMigrationApplyReport(
|
||||||
Object.freeze({ applied: Object.freeze([]), appliedCount: 0, noOp: true }),
|
Object.freeze({
|
||||||
|
applied: Object.freeze([]),
|
||||||
|
appliedCount: 0,
|
||||||
|
noOp: true,
|
||||||
|
retained: Object.freeze([]),
|
||||||
|
}),
|
||||||
),
|
),
|
||||||
).toEqual(["Migration apply completed: database is already current (no changes)."]);
|
).toEqual(["Migration apply completed: database is already current (no changes)."]);
|
||||||
});
|
});
|
||||||
@@ -85,6 +92,7 @@ describe("migration command reports", () => {
|
|||||||
approvalRequiredCount: 0,
|
approvalRequiredCount: 0,
|
||||||
entries: Object.freeze([]),
|
entries: Object.freeze([]),
|
||||||
pendingCount: 0,
|
pendingCount: 0,
|
||||||
|
retained: Object.freeze([]),
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
).toEqual([
|
).toEqual([
|
||||||
@@ -93,4 +101,62 @@ describe("migration command reports", () => {
|
|||||||
"Informational only: this plan is read-only and may become stale; apply revalidates history under the migration lock.",
|
"Informational only: this plan is read-only and may become stale; apply revalidates history under the migration lock.",
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("reports an absent module's retained history without treating it as work (ADR 0048)", () => {
|
||||||
|
const retained = Object.freeze([
|
||||||
|
Object.freeze({ appliedCount: 1, moduleId: "calendar" }),
|
||||||
|
Object.freeze({ appliedCount: 3, moduleId: "signage" }),
|
||||||
|
]);
|
||||||
|
const retainedLines = [
|
||||||
|
"- [retained] calendar: 1 applied migration kept; the module is not installed",
|
||||||
|
"- [retained] signage: 3 applied migrations kept; the module is not installed",
|
||||||
|
];
|
||||||
|
|
||||||
|
expect(
|
||||||
|
formatMigrationPlanReport(
|
||||||
|
Object.freeze({
|
||||||
|
appliedCount: 0,
|
||||||
|
approvalRequiredCount: 0,
|
||||||
|
entries: Object.freeze([]),
|
||||||
|
pendingCount: 0,
|
||||||
|
retained,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
).toEqual([
|
||||||
|
"Migration plan (0 applied, 0 pending, 0 approval-blocked, 2 retained):",
|
||||||
|
"- No installed module migrations.",
|
||||||
|
...retainedLines,
|
||||||
|
"Informational only: this plan is read-only and may become stale; apply revalidates history under the migration lock.",
|
||||||
|
]);
|
||||||
|
expect(
|
||||||
|
formatMigrationApplyReport(
|
||||||
|
Object.freeze({ applied: Object.freeze([]), appliedCount: 0, noOp: true, retained }),
|
||||||
|
),
|
||||||
|
).toEqual([
|
||||||
|
"Migration apply completed: database is already current (no changes).",
|
||||||
|
...retainedLines,
|
||||||
|
]);
|
||||||
|
expect(
|
||||||
|
formatMigrationApplyReport(
|
||||||
|
Object.freeze({
|
||||||
|
applied: Object.freeze([
|
||||||
|
Object.freeze({
|
||||||
|
checksum: "e".repeat(64),
|
||||||
|
identity: "core:0007_later",
|
||||||
|
index: 7,
|
||||||
|
moduleId: "core",
|
||||||
|
tag: "0007_later",
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
appliedCount: 1,
|
||||||
|
noOp: false,
|
||||||
|
retained,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
).toEqual([
|
||||||
|
"Migration apply completed (1 migration applied):",
|
||||||
|
`- core:0007_later sha256=${"e".repeat(64)}`,
|
||||||
|
...retainedLines,
|
||||||
|
]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { createHash } from "node:crypto";
|
import { createHash } from "node:crypto";
|
||||||
import { mkdir, mkdtemp, rm, symlink, writeFile } from "node:fs/promises";
|
import { mkdtemp, rm, symlink, writeFile } from "node:fs/promises";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { afterEach, describe, expect, it } from "vitest";
|
import { afterEach, describe, expect, it } from "vitest";
|
||||||
@@ -10,34 +10,15 @@ import {
|
|||||||
ModuleMigrationDiscoveryError,
|
ModuleMigrationDiscoveryError,
|
||||||
type ModuleMigrationDiscoveryIssueCode,
|
type ModuleMigrationDiscoveryIssueCode,
|
||||||
} from "../src/modules/index.js";
|
} from "../src/modules/index.js";
|
||||||
|
import {
|
||||||
|
createMigrationModule,
|
||||||
|
manifestFor,
|
||||||
|
originSnapshotId,
|
||||||
|
snapshotId,
|
||||||
|
} from "./support/migration-module-fixture.js";
|
||||||
|
|
||||||
const originSnapshotId = "00000000-0000-0000-0000-000000000000";
|
|
||||||
const temporaryDirectories: string[] = [];
|
const temporaryDirectories: string[] = [];
|
||||||
|
|
||||||
interface MigrationFixture {
|
|
||||||
readonly breakpoints?: boolean;
|
|
||||||
readonly destructive?:
|
|
||||||
| { readonly mode: "none" }
|
|
||||||
| { readonly mode: "approval-required"; readonly reason: string };
|
|
||||||
readonly snapshotId?: string;
|
|
||||||
readonly sql: string | Uint8Array;
|
|
||||||
readonly tag: string;
|
|
||||||
readonly timestamp?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface MigrationModuleFixture {
|
|
||||||
readonly dependencies?: Record<string, unknown>;
|
|
||||||
readonly manifest?: Record<string, unknown>;
|
|
||||||
readonly migrations?: readonly MigrationFixture[] | false;
|
|
||||||
readonly schemaSource?: string;
|
|
||||||
readonly serverSource?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface CreatedMigrationModule {
|
|
||||||
readonly migrationDirectory?: string;
|
|
||||||
readonly moduleDirectory: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
afterEach(async () => {
|
afterEach(async () => {
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
temporaryDirectories
|
temporaryDirectories
|
||||||
@@ -52,139 +33,6 @@ async function createModulesDirectory(): Promise<string> {
|
|||||||
return directory;
|
return directory;
|
||||||
}
|
}
|
||||||
|
|
||||||
function snapshotId(index: number): string {
|
|
||||||
return `10000000-0000-4000-8000-${(index + 1).toString(16).padStart(12, "0")}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function manifestFor(
|
|
||||||
id: string,
|
|
||||||
options: {
|
|
||||||
readonly dependencies?: Record<string, unknown>;
|
|
||||||
readonly migrations?: boolean;
|
|
||||||
readonly schema?: boolean;
|
|
||||||
} = {},
|
|
||||||
): Record<string, unknown> {
|
|
||||||
return {
|
|
||||||
manifestVersion: 7,
|
|
||||||
id,
|
|
||||||
name: `${id} module`,
|
|
||||||
version: "0.1.0",
|
|
||||||
compatibility: { platform: "^0.1.0", moduleSdk: "^0.1.0" },
|
|
||||||
...(options.dependencies ? { dependencies: options.dependencies } : {}),
|
|
||||||
capabilities: [],
|
|
||||||
permissions: [],
|
|
||||||
entrypoints: {
|
|
||||||
server: "./dist/server/index.js",
|
|
||||||
...(options.schema ? { schema: "./dist/schema/index.js" } : {}),
|
|
||||||
...(options.migrations === false ? {} : { migrations: "./drizzle" }),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function defaultMigrations(id: string): readonly MigrationFixture[] {
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
sql: `CREATE TABLE "${id.replaceAll("-", "_")}_records" ("id" integer);\n`,
|
|
||||||
tag: "0000_initial",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
async function createMigrationModule(
|
|
||||||
modulesDirectory: string,
|
|
||||||
id: string,
|
|
||||||
fixture: MigrationModuleFixture = {},
|
|
||||||
): Promise<CreatedMigrationModule> {
|
|
||||||
const migrations =
|
|
||||||
fixture.migrations === false ? undefined : (fixture.migrations ?? defaultMigrations(id));
|
|
||||||
const moduleDirectory = path.join(modulesDirectory, id);
|
|
||||||
const migrationDirectory = migrations ? path.join(moduleDirectory, "drizzle") : undefined;
|
|
||||||
await mkdir(path.join(moduleDirectory, "dist/server"), { recursive: true });
|
|
||||||
if (fixture.schemaSource !== undefined) {
|
|
||||||
await mkdir(path.join(moduleDirectory, "dist/schema"), { recursive: true });
|
|
||||||
}
|
|
||||||
if (migrationDirectory) {
|
|
||||||
await mkdir(path.join(migrationDirectory, "meta"), { recursive: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
await Promise.all([
|
|
||||||
writeFile(
|
|
||||||
path.join(moduleDirectory, "package.json"),
|
|
||||||
JSON.stringify({
|
|
||||||
name: `@test/module-${id}`,
|
|
||||||
version: "0.1.0",
|
|
||||||
type: "module",
|
|
||||||
ekklesia: { layer: "module" },
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
writeFile(
|
|
||||||
path.join(moduleDirectory, "ekklesia.module.json"),
|
|
||||||
JSON.stringify(
|
|
||||||
fixture.manifest ??
|
|
||||||
manifestFor(id, {
|
|
||||||
...(fixture.dependencies ? { dependencies: fixture.dependencies } : {}),
|
|
||||||
migrations: migrations !== undefined,
|
|
||||||
schema: fixture.schemaSource !== undefined,
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
writeFile(
|
|
||||||
path.join(moduleDirectory, "dist/server/index.js"),
|
|
||||||
fixture.serverSource ?? `export default { id: ${JSON.stringify(id)}, version: "0.1.0" };`,
|
|
||||||
),
|
|
||||||
...(fixture.schemaSource === undefined
|
|
||||||
? []
|
|
||||||
: [writeFile(path.join(moduleDirectory, "dist/schema/index.js"), fixture.schemaSource)]),
|
|
||||||
]);
|
|
||||||
|
|
||||||
if (!migrationDirectory || !migrations) {
|
|
||||||
return { moduleDirectory };
|
|
||||||
}
|
|
||||||
|
|
||||||
const journalEntries = migrations.map((migration, index) => ({
|
|
||||||
idx: index,
|
|
||||||
version: "7",
|
|
||||||
when: migration.timestamp ?? 1_700_000_000_000 + index,
|
|
||||||
tag: migration.tag,
|
|
||||||
breakpoints: migration.breakpoints ?? true,
|
|
||||||
}));
|
|
||||||
const policyEntries = migrations.map((migration) => ({
|
|
||||||
tag: migration.tag,
|
|
||||||
destructive: migration.destructive ?? { mode: "none" },
|
|
||||||
}));
|
|
||||||
await Promise.all([
|
|
||||||
writeFile(
|
|
||||||
path.join(migrationDirectory, "meta/_journal.json"),
|
|
||||||
JSON.stringify({ version: "7", dialect: "postgresql", entries: journalEntries }),
|
|
||||||
),
|
|
||||||
writeFile(
|
|
||||||
path.join(migrationDirectory, "meta/_ekklesia.json"),
|
|
||||||
JSON.stringify({ version: 1, moduleId: id, entries: policyEntries }),
|
|
||||||
),
|
|
||||||
...migrations.flatMap((migration, index) => {
|
|
||||||
const idForSnapshot = migration.snapshotId ?? snapshotId(index);
|
|
||||||
const previousId =
|
|
||||||
index === 0
|
|
||||||
? originSnapshotId
|
|
||||||
: (migrations[index - 1]?.snapshotId ?? snapshotId(index - 1));
|
|
||||||
return [
|
|
||||||
writeFile(path.join(migrationDirectory, `${migration.tag}.sql`), migration.sql),
|
|
||||||
writeFile(
|
|
||||||
path.join(migrationDirectory, `meta/${index.toString().padStart(4, "0")}_snapshot.json`),
|
|
||||||
JSON.stringify({
|
|
||||||
version: "7",
|
|
||||||
dialect: "postgresql",
|
|
||||||
id: idForSnapshot,
|
|
||||||
prevId: previousId,
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
];
|
|
||||||
}),
|
|
||||||
]);
|
|
||||||
|
|
||||||
return { migrationDirectory, moduleDirectory };
|
|
||||||
}
|
|
||||||
|
|
||||||
function discoveryOptions(modulesDirectory: string) {
|
function discoveryOptions(modulesDirectory: string) {
|
||||||
return {
|
return {
|
||||||
moduleSdkVersion: "0.1.0",
|
moduleSdkVersion: "0.1.0",
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
type MigrationTransaction,
|
type MigrationTransaction,
|
||||||
planModuleMigrations,
|
planModuleMigrations,
|
||||||
} from "../src/migrations/runner.js";
|
} from "../src/migrations/runner.js";
|
||||||
|
import type { DiscoveredModule } from "../src/modules/discovery.js";
|
||||||
import type {
|
import type {
|
||||||
DiscoveredModuleMigration,
|
DiscoveredModuleMigration,
|
||||||
ModuleMigrationAggregate,
|
ModuleMigrationAggregate,
|
||||||
@@ -323,6 +324,7 @@ describe("module migration planning", () => {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
pendingCount: 1,
|
pendingCount: 1,
|
||||||
|
retained: [],
|
||||||
});
|
});
|
||||||
expect(database.events).toEqual(["session:open", "history:read", "session:close"]);
|
expect(database.events).toEqual(["session:open", "history:read", "session:close"]);
|
||||||
expect(database.historyTableExists).toBe(false);
|
expect(database.historyTableExists).toBe(false);
|
||||||
@@ -344,10 +346,11 @@ describe("module migration planning", () => {
|
|||||||
approvalRequiredCount: 0,
|
approvalRequiredCount: 0,
|
||||||
entries: [],
|
entries: [],
|
||||||
pendingCount: 0,
|
pendingCount: 0,
|
||||||
|
retained: [],
|
||||||
});
|
});
|
||||||
expect(Object.isFrozen(result)).toBe(true);
|
expect(Object.isFrozen(result)).toBe(true);
|
||||||
expect(Object.isFrozen(result.entries)).toBe(true);
|
expect(Object.isFrozen(result.entries)).toBe(true);
|
||||||
expect(applyResult).toEqual({ applied: [], appliedCount: 0, noOp: true });
|
expect(applyResult).toEqual({ applied: [], appliedCount: 0, noOp: true, retained: [] });
|
||||||
expect(database.historyTableExists).toBe(false);
|
expect(database.historyTableExists).toBe(false);
|
||||||
expect(database.events).not.toContain("transaction:begin");
|
expect(database.events).not.toContain("transaction:begin");
|
||||||
});
|
});
|
||||||
@@ -548,6 +551,7 @@ describe("module migration apply", () => {
|
|||||||
],
|
],
|
||||||
appliedCount: 1,
|
appliedCount: 1,
|
||||||
noOp: false,
|
noOp: false,
|
||||||
|
retained: [],
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -580,7 +584,7 @@ describe("module migration apply", () => {
|
|||||||
database,
|
database,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(result).toEqual({ applied: [], appliedCount: 0, noOp: true });
|
expect(result).toEqual({ applied: [], appliedCount: 0, noOp: true, retained: [] });
|
||||||
expect(Object.isFrozen(result)).toBe(true);
|
expect(Object.isFrozen(result)).toBe(true);
|
||||||
expect(Object.isFrozen(result.applied)).toBe(true);
|
expect(Object.isFrozen(result.applied)).toBe(true);
|
||||||
expectNoPersistentMutation(database);
|
expectNoPersistentMutation(database);
|
||||||
@@ -601,7 +605,7 @@ describe("module migration apply", () => {
|
|||||||
database,
|
database,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(result).toEqual({ applied: [], appliedCount: 0, noOp: true });
|
expect(result).toEqual({ applied: [], appliedCount: 0, noOp: true, retained: [] });
|
||||||
expect(database.historyTableExists).toBe(false);
|
expect(database.historyTableExists).toBe(false);
|
||||||
expect(database.events).toEqual([
|
expect(database.events).toEqual([
|
||||||
"session:open",
|
"session:open",
|
||||||
@@ -980,3 +984,250 @@ describe("module migration failures and side effects", () => {
|
|||||||
expect(database.committedHistory).toEqual([]);
|
expect(database.committedHistory).toEqual([]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Marks modules as present in the discovered inventory without giving them migrations, the way a
|
||||||
|
* real aggregate lists every discovered module whether or not it owns migration history. Only the
|
||||||
|
* manifest id is read by the runner.
|
||||||
|
*/
|
||||||
|
function withDiscovered(
|
||||||
|
inventory: ModuleMigrationAggregate,
|
||||||
|
...moduleIds: readonly string[]
|
||||||
|
): ModuleMigrationAggregate {
|
||||||
|
return Object.freeze({
|
||||||
|
...inventory,
|
||||||
|
discoveredModules: Object.freeze(
|
||||||
|
moduleIds.map((id) => ({ manifest: { id } }) as unknown as DiscoveredModule),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("retained history for absent modules (ADR 0048)", () => {
|
||||||
|
it("retains and reports an absent module's applied history without failing the plan", async () => {
|
||||||
|
const coreInitial = migration("core", 0);
|
||||||
|
const calendarInitial = migration("calendar", 0);
|
||||||
|
const calendarLater = migration("calendar", 1);
|
||||||
|
const history = [
|
||||||
|
historyRecord(coreInitial, 1),
|
||||||
|
historyRecord(calendarInitial, 2),
|
||||||
|
historyRecord(calendarLater, 3),
|
||||||
|
];
|
||||||
|
const database = new FakeMigrationDatabase({ history });
|
||||||
|
|
||||||
|
const result = await planModuleMigrations({
|
||||||
|
aggregate: aggregate({ id: "core", migrations: [coreInitial] }),
|
||||||
|
database,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.appliedCount).toBe(1);
|
||||||
|
expect(result.pendingCount).toBe(0);
|
||||||
|
expect(result.entries.map((entry) => entry.identity)).toEqual([coreInitial.identity]);
|
||||||
|
expect(result.retained).toEqual([{ appliedCount: 2, moduleId: "calendar" }]);
|
||||||
|
expect(Object.isFrozen(result.retained)).toBe(true);
|
||||||
|
expect(Object.isFrozen(result.retained[0])).toBe(true);
|
||||||
|
expect(database.committedHistory).toEqual(history);
|
||||||
|
expectNoPersistentMutation(database);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("applies a later Core migration while a module is absent, leaving its history intact", async () => {
|
||||||
|
const coreInitial = migration("core", 0);
|
||||||
|
const coreLater = migration("core", 1);
|
||||||
|
const calendarInitial = migration("calendar", 0);
|
||||||
|
const history = [historyRecord(coreInitial, 1), historyRecord(calendarInitial, 2)];
|
||||||
|
const database = new FakeMigrationDatabase({ history });
|
||||||
|
|
||||||
|
const result = await applyModuleMigrations({
|
||||||
|
aggregate: aggregate({ id: "core", migrations: [coreInitial, coreLater] }),
|
||||||
|
database,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.applied.map((applied) => applied.identity)).toEqual([coreLater.identity]);
|
||||||
|
expect(result.retained).toEqual([{ appliedCount: 1, moduleId: "calendar" }]);
|
||||||
|
expect(database.executedStatements).toEqual([coreLater.sql]);
|
||||||
|
expect(database.committedHistory.map((record) => record.identity)).toEqual([
|
||||||
|
coreInitial.identity,
|
||||||
|
calendarInitial.identity,
|
||||||
|
coreLater.identity,
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("applies a present module's migrations while another module is absent", async () => {
|
||||||
|
const peopleInitial = migration("people", 0);
|
||||||
|
const calendarInitial = migration("calendar", 0);
|
||||||
|
const database = new FakeMigrationDatabase({ history: [historyRecord(calendarInitial, 1)] });
|
||||||
|
|
||||||
|
const result = await applyModuleMigrations({
|
||||||
|
aggregate: aggregate({ id: "people", migrations: [peopleInitial] }),
|
||||||
|
database,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.applied.map((applied) => applied.identity)).toEqual([peopleInitial.identity]);
|
||||||
|
expect(result.retained).toEqual([{ appliedCount: 1, moduleId: "calendar" }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports retained modules in a stable order with exact counts", async () => {
|
||||||
|
const zeta = [migration("zeta", 0), migration("zeta", 1), migration("zeta", 2)];
|
||||||
|
const alpha = migration("alpha", 0);
|
||||||
|
const database = new FakeMigrationDatabase({
|
||||||
|
history: [
|
||||||
|
historyRecord(zeta[0] as DiscoveredModuleMigration, 1),
|
||||||
|
historyRecord(alpha, 2),
|
||||||
|
historyRecord(zeta[1] as DiscoveredModuleMigration, 3),
|
||||||
|
historyRecord(zeta[2] as DiscoveredModuleMigration, 4),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await planModuleMigrations({ aggregate: aggregate(), database });
|
||||||
|
|
||||||
|
expect(result.retained).toEqual([
|
||||||
|
{ appliedCount: 1, moduleId: "alpha" },
|
||||||
|
{ appliedCount: 3, moduleId: "zeta" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns a module to strict validation: valid history plans cleanly and nothing is retained", async () => {
|
||||||
|
const initial = migration("people", 0);
|
||||||
|
const later = migration("people", 1);
|
||||||
|
const database = new FakeMigrationDatabase({ history: [historyRecord(initial, 1)] });
|
||||||
|
|
||||||
|
const result = await planModuleMigrations({
|
||||||
|
aggregate: aggregate({ id: "people", migrations: [initial, later] }),
|
||||||
|
database,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.retained).toEqual([]);
|
||||||
|
expect(result.entries.map((entry) => [entry.identity, entry.status])).toEqual([
|
||||||
|
[initial.identity, "applied"],
|
||||||
|
[later.identity, "pending"],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fails when a returning module no longer ships a previously applied migration", async () => {
|
||||||
|
const initial = migration("people", 0);
|
||||||
|
const later = migration("people", 1);
|
||||||
|
const database = new FakeMigrationDatabase({
|
||||||
|
history: [historyRecord(initial, 1), historyRecord(later, 2)],
|
||||||
|
});
|
||||||
|
const downgraded = aggregate({ id: "people", migrations: [initial] });
|
||||||
|
|
||||||
|
await captureRunnerError(
|
||||||
|
planModuleMigrations({ aggregate: downgraded, database }),
|
||||||
|
"MIGRATION_UNKNOWN_APPLIED",
|
||||||
|
);
|
||||||
|
await captureRunnerError(
|
||||||
|
applyModuleMigrations({ aggregate: downgraded, database }),
|
||||||
|
"MIGRATION_UNKNOWN_APPLIED",
|
||||||
|
);
|
||||||
|
expect(database.executedStatements).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fails when a returning module's applied migration has different contents", async () => {
|
||||||
|
const initial = migration("people", 0);
|
||||||
|
const database = new FakeMigrationDatabase({ history: [historyRecord(initial, 1)] });
|
||||||
|
const edited = aggregate({
|
||||||
|
id: "people",
|
||||||
|
migrations: [migration("people", 0, { sql: "SELECT 'edited';\n" })],
|
||||||
|
});
|
||||||
|
|
||||||
|
const error = await captureRunnerError(
|
||||||
|
planModuleMigrations({ aggregate: edited, database }),
|
||||||
|
"MIGRATION_CHECKSUM_MISMATCH",
|
||||||
|
);
|
||||||
|
expect(error.migrationIdentity).toBe(initial.identity);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fails when a returning module's history is no longer a prefix of its journal", async () => {
|
||||||
|
const initial = migration("people", 0);
|
||||||
|
const later = migration("people", 1);
|
||||||
|
const database = new FakeMigrationDatabase({ history: [historyRecord(later, 1)] });
|
||||||
|
|
||||||
|
await captureRunnerError(
|
||||||
|
planModuleMigrations({
|
||||||
|
aggregate: aggregate({ id: "people", migrations: [initial, later] }),
|
||||||
|
database,
|
||||||
|
}),
|
||||||
|
"MIGRATION_HISTORY_INVALID",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps a present module that no longer ships any migrations strict", async () => {
|
||||||
|
const initial = migration("people", 0);
|
||||||
|
const database = new FakeMigrationDatabase({ history: [historyRecord(initial, 1)] });
|
||||||
|
|
||||||
|
await captureRunnerError(
|
||||||
|
planModuleMigrations({ aggregate: withDiscovered(aggregate(), "people"), database }),
|
||||||
|
"MIGRATION_UNKNOWN_APPLIED",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never treats Core's history as an absent module's", async () => {
|
||||||
|
const coreInitial = migration("core", 0);
|
||||||
|
const coreGhost = migration("core", 1);
|
||||||
|
|
||||||
|
// An inventory with no Core source at all: Core's rows are still strict, never retained.
|
||||||
|
await captureRunnerError(
|
||||||
|
planModuleMigrations({
|
||||||
|
aggregate: aggregate({ id: "people", migrations: [] }),
|
||||||
|
database: new FakeMigrationDatabase({ history: [historyRecord(coreInitial, 1)] }),
|
||||||
|
}),
|
||||||
|
"MIGRATION_UNKNOWN_APPLIED",
|
||||||
|
);
|
||||||
|
// A Core row missing from Core's own inventory.
|
||||||
|
await captureRunnerError(
|
||||||
|
planModuleMigrations({
|
||||||
|
aggregate: aggregate({ id: "core", migrations: [coreInitial] }),
|
||||||
|
database: new FakeMigrationDatabase({
|
||||||
|
history: [historyRecord(coreInitial, 1), historyRecord(coreGhost, 2)],
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
"MIGRATION_UNKNOWN_APPLIED",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
[
|
||||||
|
"an identity that does not name its own module and tag",
|
||||||
|
(source: DiscoveredModuleMigration) => [
|
||||||
|
historyRecord(source, 1, { identity: "calendar:0099_elsewhere" }),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"a duplicate applied order",
|
||||||
|
(source: DiscoveredModuleMigration) => [
|
||||||
|
historyRecord(source, 1),
|
||||||
|
historyRecord(migration("calendar", 1), 1),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"a duplicate index",
|
||||||
|
(source: DiscoveredModuleMigration) => [
|
||||||
|
historyRecord(source, 1),
|
||||||
|
historyRecord(migration("calendar", 1), 2, { index: 0 }),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
])("still rejects retained rows with %s", async (_label, makeHistory) => {
|
||||||
|
const database = new FakeMigrationDatabase({
|
||||||
|
history: makeHistory(migration("calendar", 0)),
|
||||||
|
});
|
||||||
|
|
||||||
|
await captureRunnerError(
|
||||||
|
planModuleMigrations({ aggregate: aggregate(), database }),
|
||||||
|
"MIGRATION_HISTORY_INVALID",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a retained row that claims a present module's migration identity", async () => {
|
||||||
|
const peopleInitial = migration("people", 0);
|
||||||
|
const database = new FakeMigrationDatabase({
|
||||||
|
history: [historyRecord(peopleInitial, 1, { moduleId: "calendar" })],
|
||||||
|
});
|
||||||
|
|
||||||
|
await captureRunnerError(
|
||||||
|
planModuleMigrations({
|
||||||
|
aggregate: aggregate({ id: "people", migrations: [peopleInitial] }),
|
||||||
|
database,
|
||||||
|
}),
|
||||||
|
"MIGRATION_HISTORY_INVALID",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -0,0 +1,387 @@
|
|||||||
|
import { mkdtemp, readdir, rm, stat, writeFile } from "node:fs/promises";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import { afterEach, describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
type ModulePackageVerificationErrorCode,
|
||||||
|
ModulePackageVerificationError,
|
||||||
|
verifyModulePackage,
|
||||||
|
} from "../src/modules/index.js";
|
||||||
|
import {
|
||||||
|
type FixtureEntry,
|
||||||
|
modulePackageFixture,
|
||||||
|
type PackageFixtureOptions,
|
||||||
|
sha256,
|
||||||
|
} from "./support/module-package-fixture.js";
|
||||||
|
|
||||||
|
const platform = {
|
||||||
|
databaseRuntime: { "drizzle-orm": "0.45.2" },
|
||||||
|
moduleSdkVersion: "0.1.2",
|
||||||
|
platformVersion: "0.1.2",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const temporaryDirectories: string[] = [];
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await Promise.all(
|
||||||
|
temporaryDirectories
|
||||||
|
.splice(0)
|
||||||
|
.map((directory) => rm(directory, { force: true, recursive: true })),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
async function scratch(): Promise<string> {
|
||||||
|
const directory = await mkdtemp(path.join(tmpdir(), "ekklesia-verify-"));
|
||||||
|
temporaryDirectories.push(directory);
|
||||||
|
return directory;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function archiveFile(options: PackageFixtureOptions = {}): Promise<{
|
||||||
|
readonly archivePath: string;
|
||||||
|
readonly sha256: string;
|
||||||
|
}> {
|
||||||
|
const fixture = await modulePackageFixture(options);
|
||||||
|
const archivePath = path.join(await scratch(), "private-host-name-5e1d.tar.gz");
|
||||||
|
await writeFile(archivePath, fixture.archive);
|
||||||
|
return { archivePath, sha256: fixture.sha256 };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function rejection(
|
||||||
|
options: PackageFixtureOptions,
|
||||||
|
code: ModulePackageVerificationErrorCode,
|
||||||
|
extra: { readonly expectedSha256?: string } = {},
|
||||||
|
): Promise<ModulePackageVerificationError> {
|
||||||
|
const { archivePath } = await archiveFile(options);
|
||||||
|
try {
|
||||||
|
await verifyModulePackage({ archivePath, platform, ...extra });
|
||||||
|
} catch (error) {
|
||||||
|
expect(error).toBeInstanceOf(ModulePackageVerificationError);
|
||||||
|
const verificationError = error as ModulePackageVerificationError;
|
||||||
|
expect(verificationError.code).toBe(code);
|
||||||
|
// Sanitized: never the archive's location, a temporary directory, or text from inside it.
|
||||||
|
const rendered = JSON.stringify({ ...verificationError, message: verificationError.message });
|
||||||
|
expect(rendered).not.toContain("private-host-name-5e1d");
|
||||||
|
expect(rendered).not.toContain(tmpdir());
|
||||||
|
expect(rendered).not.toContain("attacker-chosen");
|
||||||
|
return verificationError;
|
||||||
|
}
|
||||||
|
throw new Error(`Expected ${code}.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function withEntry(entry: FixtureEntry): PackageFixtureOptions {
|
||||||
|
return { extraEntries: [entry] };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("Module Package v1 verification (ADR 0049)", () => {
|
||||||
|
it("verifies a valid any-target package, its digest, and its module", async () => {
|
||||||
|
const { archivePath, sha256: digest } = await archiveFile();
|
||||||
|
const result = await verifyModulePackage({ archivePath, expectedSha256: digest, platform });
|
||||||
|
expect(result).toEqual({
|
||||||
|
archiveSha256: digest,
|
||||||
|
fileCount: 8,
|
||||||
|
moduleId: "example",
|
||||||
|
target: "any",
|
||||||
|
version: "0.1.0",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("extracts only a verified package, with normalized modes, into a new directory", async () => {
|
||||||
|
const { archivePath } = await archiveFile();
|
||||||
|
const target = path.join(await scratch(), "example");
|
||||||
|
await verifyModulePackage({ archivePath, extractTo: target, platform });
|
||||||
|
const names = (await readdir(target, { recursive: true })).sort();
|
||||||
|
expect(names).toContain("ekklesia.package.json");
|
||||||
|
expect(names).toContain("dist/server/index.js");
|
||||||
|
expect((await stat(target)).mode & 0o7777).toBe(0o755);
|
||||||
|
expect((await stat(path.join(target, "dist", "server", "index.js"))).mode & 0o7777).toBe(0o644);
|
||||||
|
|
||||||
|
const { archivePath: broken } = await archiveFile({ runtimePackage: { name: "x" } });
|
||||||
|
const refused = path.join(await scratch(), "refused");
|
||||||
|
await expect(
|
||||||
|
verifyModulePackage({ archivePath: broken, extractTo: refused, platform }),
|
||||||
|
).rejects.toBeInstanceOf(ModulePackageVerificationError);
|
||||||
|
await expect(stat(refused)).rejects.toMatchObject({ code: "ENOENT" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses to extract over an existing directory", async () => {
|
||||||
|
const { archivePath } = await archiveFile();
|
||||||
|
const existing = await scratch();
|
||||||
|
await expect(
|
||||||
|
verifyModulePackage({ archivePath, extractTo: existing, platform }),
|
||||||
|
).rejects.toMatchObject({ code: "MODULE_PACKAGE_EXTRACTION_FAILED" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fails a wrong archive digest", async () => {
|
||||||
|
await rejection({}, "MODULE_PACKAGE_DIGEST_MISMATCH", { expectedSha256: sha256("other") });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fails a payload whose contents no longer match their digest", async () => {
|
||||||
|
await rejection(
|
||||||
|
{
|
||||||
|
tamper: (files) => {
|
||||||
|
files.set("dist/server/index.js", Buffer.from("export default {}; // attacker-chosen\n"));
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"MODULE_PACKAGE_FILE_MISMATCH",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fails a listed file missing from the archive", async () => {
|
||||||
|
await rejection(
|
||||||
|
{ tamper: (files) => void files.delete("THIRD_PARTY_NOTICES") },
|
||||||
|
"MODULE_PACKAGE_FILE_MISSING",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fails an archive file that is not listed", async () => {
|
||||||
|
await rejection(
|
||||||
|
withEntry({ path: "example/attacker-chosen.js", data: "extra" }),
|
||||||
|
"MODULE_PACKAGE_FILE_UNLISTED",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fails when the manifest and the package disagree about the version", async () => {
|
||||||
|
await rejection(
|
||||||
|
{ manifest: (manifest) => ({ ...manifest, version: "0.2.0" }) },
|
||||||
|
"MODULE_PACKAGE_IDENTITY_MISMATCH",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fails when the metadata names another module", async () => {
|
||||||
|
await rejection(
|
||||||
|
{ metadata: (metadata) => ({ ...metadata, module: { id: "other", version: "0.1.0" } }) },
|
||||||
|
"MODULE_PACKAGE_IDENTITY_MISMATCH",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fails a runtime package.json that is not the generated shape", async () => {
|
||||||
|
await rejection(
|
||||||
|
{
|
||||||
|
runtimePackage: {
|
||||||
|
name: "@test/module-example",
|
||||||
|
version: "0.1.0",
|
||||||
|
private: true,
|
||||||
|
type: "module",
|
||||||
|
ekklesia: { layer: "module" },
|
||||||
|
dependencies: { "@ekklesia/module-sdk": "workspace:*" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"MODULE_PACKAGE_RUNTIME_PACKAGE_INVALID",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fails an unsupported package format", async () => {
|
||||||
|
await rejection(
|
||||||
|
{ metadata: (metadata) => ({ ...metadata, packageFormat: 2 }) },
|
||||||
|
"MODULE_PACKAGE_FORMAT_UNSUPPORTED",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fails transitional metadata it does not understand", async () => {
|
||||||
|
await rejection(
|
||||||
|
{ metadata: (metadata) => ({ ...metadata, transitional: { orm: { prisma: "6.0.0" } } }) },
|
||||||
|
"MODULE_PACKAGE_TRANSITIONAL_UNSUPPORTED",
|
||||||
|
);
|
||||||
|
await rejection(
|
||||||
|
{
|
||||||
|
metadata: (metadata) => ({
|
||||||
|
...metadata,
|
||||||
|
transitional: { databaseRuntime: { "drizzle-orm": "0.45.2", prisma: "6.0.0" } },
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
"MODULE_PACKAGE_TRANSITIONAL_UNSUPPORTED",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("enforces today's database runtime agreement, and accepts it when it holds", async () => {
|
||||||
|
await rejection(
|
||||||
|
{
|
||||||
|
metadata: (metadata) => ({
|
||||||
|
...metadata,
|
||||||
|
transitional: { databaseRuntime: { "drizzle-orm": "0.44.0" } },
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
"MODULE_PACKAGE_DATABASE_RUNTIME_INCOMPATIBLE",
|
||||||
|
);
|
||||||
|
const { archivePath } = await archiveFile({
|
||||||
|
metadata: (metadata) => ({
|
||||||
|
...metadata,
|
||||||
|
transitional: { databaseRuntime: { "drizzle-orm": "0.45.2" } },
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
await expect(verifyModulePackage({ archivePath, platform })).resolves.toMatchObject({
|
||||||
|
moduleId: "example",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fails a target this platform cannot run", async () => {
|
||||||
|
await rejection(
|
||||||
|
{
|
||||||
|
metadata: (metadata) => ({
|
||||||
|
...metadata,
|
||||||
|
target: "linux-arm64-musl",
|
||||||
|
runtime: { node: ">=20", nodeApi: 9 },
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
"MODULE_PACKAGE_TARGET_INCOMPATIBLE",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fails a native file inside an any-target package", async () => {
|
||||||
|
const native = await modulePackageFixture({
|
||||||
|
tamper: (files) => {
|
||||||
|
files.set("dist/addon.node", Buffer.from([0x7f, 0x45, 0x4c, 0x46]));
|
||||||
|
},
|
||||||
|
metadata: (metadata) => {
|
||||||
|
const files = [
|
||||||
|
...(metadata.files as { path: string; size: number; sha256: string }[]),
|
||||||
|
{
|
||||||
|
path: "dist/addon.node",
|
||||||
|
size: 4,
|
||||||
|
sha256: sha256(Buffer.from([0x7f, 0x45, 0x4c, 0x46])),
|
||||||
|
},
|
||||||
|
].sort((left, right) => (left.path < right.path ? -1 : 1));
|
||||||
|
return { ...metadata, files };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const archivePath = path.join(await scratch(), "native.tar.gz");
|
||||||
|
await writeFile(archivePath, native.archive);
|
||||||
|
await expect(verifyModulePackage({ archivePath, platform })).rejects.toMatchObject({
|
||||||
|
code: "MODULE_PACKAGE_NATIVE_FILE",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fails a package that needs a newer Node.js", async () => {
|
||||||
|
await rejection(
|
||||||
|
{ metadata: (metadata) => ({ ...metadata, runtime: { node: ">=99" } }) },
|
||||||
|
"MODULE_PACKAGE_RUNTIME_INCOMPATIBLE",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fails an incorporated SDK outside the manifest's own range", async () => {
|
||||||
|
await rejection(
|
||||||
|
{
|
||||||
|
metadata: (metadata) => ({
|
||||||
|
...metadata,
|
||||||
|
incorporates: { "@ekklesia/module-sdk": "0.9.0" },
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
"MODULE_PACKAGE_SDK_INCOMPATIBLE",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fails a module whose declared entrypoint is missing", async () => {
|
||||||
|
await rejection(
|
||||||
|
{
|
||||||
|
manifest: (manifest) => ({
|
||||||
|
...manifest,
|
||||||
|
version: "0.1.0",
|
||||||
|
entrypoints: {
|
||||||
|
...(manifest.entrypoints as Record<string, unknown>),
|
||||||
|
schema: "./dist/schema/index.js",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
"MODULE_PACKAGE_MODULE_INVALID",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fails a backend that imports a dependency the package does not contain", async () => {
|
||||||
|
const error = await rejection(
|
||||||
|
{
|
||||||
|
serverSource:
|
||||||
|
'import pad from "ekklesia-dependency-not-in-this-package";\nexport default { id: "example", version: "0.1.0", register() { pad(); } };\n',
|
||||||
|
},
|
||||||
|
"MODULE_PACKAGE_ENTRYPOINT_LOAD_FAILED",
|
||||||
|
);
|
||||||
|
expect(error.message).not.toContain("ekklesia-dependency-not-in-this-package");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fails a backend whose default export names another module", async () => {
|
||||||
|
await rejection(
|
||||||
|
{ serverSource: 'export default { id: "other", version: "0.1.0" };\n' },
|
||||||
|
"MODULE_PACKAGE_ENTRYPOINT_LOAD_FAILED",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
["an absolute path", { path: "/etc/attacker-chosen", data: "x" }, "MODULE_PACKAGE_UNSAFE_PATH"],
|
||||||
|
[
|
||||||
|
"a .. segment",
|
||||||
|
{ path: "example/../attacker-chosen", data: "x" },
|
||||||
|
"MODULE_PACKAGE_UNSAFE_PATH",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"a symbolic link",
|
||||||
|
{ path: "example/link", type: "symlink", linkname: "/etc/passwd" },
|
||||||
|
"MODULE_PACKAGE_LINK",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"a hard link",
|
||||||
|
{ path: "example/hard", type: "hardlink", linkname: "example/package.json" },
|
||||||
|
"MODULE_PACKAGE_LINK",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"a device node",
|
||||||
|
{ path: "example/device", type: "character" },
|
||||||
|
"MODULE_PACKAGE_SPECIAL_ENTRY",
|
||||||
|
],
|
||||||
|
["a FIFO", { path: "example/fifo", type: "fifo" }, "MODULE_PACKAGE_SPECIAL_ENTRY"],
|
||||||
|
[
|
||||||
|
"a setuid file",
|
||||||
|
{ path: "example/setuid", data: "x", mode: 0o4755 },
|
||||||
|
"MODULE_PACKAGE_UNSAFE_MODE",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"a setgid directory",
|
||||||
|
{ path: "example/setgid", type: "directory", mode: 0o2755 },
|
||||||
|
"MODULE_PACKAGE_UNSAFE_MODE",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"a second top-level directory",
|
||||||
|
{ path: "attacker-chosen/file", data: "x" },
|
||||||
|
"MODULE_PACKAGE_LAYOUT_INVALID",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"a duplicate entry",
|
||||||
|
{ path: "example/package.json", data: "{}" },
|
||||||
|
"MODULE_PACKAGE_LAYOUT_INVALID",
|
||||||
|
],
|
||||||
|
] as const)("fails %s", async (_label, entry, code) => {
|
||||||
|
await rejection(withEntry(entry as FixtureEntry), code);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fails a file that is not a gzip-compressed tar archive", async () => {
|
||||||
|
const archivePath = path.join(await scratch(), "plain.tar.gz");
|
||||||
|
await writeFile(archivePath, "not an archive");
|
||||||
|
await expect(verifyModulePackage({ archivePath, platform })).rejects.toMatchObject({
|
||||||
|
code: "MODULE_PACKAGE_UNREADABLE",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fails an archive without package metadata", async () => {
|
||||||
|
const fixture = await modulePackageFixture();
|
||||||
|
const { gunzipSync, gzipSync } = await import("node:zlib");
|
||||||
|
// Rebuild without the metadata entry by filtering the fixture through a fresh archive.
|
||||||
|
const archivePath = path.join(await scratch(), "no-metadata.tar.gz");
|
||||||
|
const raw = gunzipSync(fixture.archive);
|
||||||
|
const blocks: Buffer[] = [];
|
||||||
|
for (let offset = 0; offset + 512 <= raw.length; ) {
|
||||||
|
const headerBlock = raw.subarray(offset, offset + 512);
|
||||||
|
if (headerBlock.every((byte) => byte === 0)) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
const name = headerBlock.subarray(0, 100).toString("latin1").replace(/\0.*$/su, "");
|
||||||
|
const size = Number.parseInt(headerBlock.subarray(124, 136).toString("latin1"), 8);
|
||||||
|
const length = 512 + Math.ceil(size / 512) * 512;
|
||||||
|
if (!name.endsWith("ekklesia.package.json")) {
|
||||||
|
blocks.push(raw.subarray(offset, offset + length));
|
||||||
|
}
|
||||||
|
offset += length;
|
||||||
|
}
|
||||||
|
await writeFile(archivePath, gzipSync(Buffer.concat([...blocks, Buffer.alloc(1024)])));
|
||||||
|
await expect(verifyModulePackage({ archivePath, platform })).rejects.toMatchObject({
|
||||||
|
code: "MODULE_PACKAGE_METADATA_MISSING",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
import { createHash } from "node:crypto";
|
||||||
|
import { mkdtemp, readdir, readFile, rm } from "node:fs/promises";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import { afterEach, describe, expect, it } from "vitest";
|
||||||
|
import { discoverRepositoryMigrations } from "../src/cli/migration-command-support.js";
|
||||||
|
import { resolveCommandModulesDirectory } from "../src/cli/modules-directory-environment.js";
|
||||||
|
import { discoverCoreMigrations } from "../src/migrations/index.js";
|
||||||
|
import { ModuleDiscoveryError } from "../src/modules/index.js";
|
||||||
|
import { createMigrationModule } from "./support/migration-module-fixture.js";
|
||||||
|
|
||||||
|
const repositoryRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..");
|
||||||
|
const temporaryDirectories: string[] = [];
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await Promise.all(
|
||||||
|
temporaryDirectories.splice(0).map((directory) => rm(directory, { recursive: true })),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
async function externalModulesDirectory(): Promise<string> {
|
||||||
|
const directory = await mkdtemp(path.join(tmpdir(), "ekklesia-installed-modules-"));
|
||||||
|
temporaryDirectories.push(directory);
|
||||||
|
return directory;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("operator commands select the modules directory through host configuration", () => {
|
||||||
|
it("defaults to the repository's own modules directory", () => {
|
||||||
|
expect(resolveCommandModulesDirectory({}, "/opt/ekklesia")).toEqual({
|
||||||
|
modulesDirectory: "/opt/ekklesia/modules",
|
||||||
|
modulesDirectoryRequired: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("honours EKKLESIA_MODULES_DIR", () => {
|
||||||
|
expect(
|
||||||
|
resolveCommandModulesDirectory(
|
||||||
|
{ EKKLESIA_MODULES_DIR: "/var/lib/ekklesia/modules" },
|
||||||
|
"/opt/ekklesia",
|
||||||
|
),
|
||||||
|
).toEqual({ modulesDirectory: "/var/lib/ekklesia/modules", modulesDirectoryRequired: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses a relative EKKLESIA_MODULES_DIR without echoing it", () => {
|
||||||
|
expect(() =>
|
||||||
|
resolveCommandModulesDirectory({ EKKLESIA_MODULES_DIR: "modules-9e1f" }, "/opt/ekklesia"),
|
||||||
|
).toThrow(ModuleDiscoveryError);
|
||||||
|
try {
|
||||||
|
resolveCommandModulesDirectory({ EKKLESIA_MODULES_DIR: "modules-9e1f" }, "/opt/ekklesia");
|
||||||
|
} catch (error) {
|
||||||
|
expect(JSON.stringify((error as ModuleDiscoveryError).issues)).not.toContain("9e1f");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("plans migrations for modules installed outside the repository, with Core's from the platform", async () => {
|
||||||
|
// A minimal installed module generated from nothing in the repository, so this test never
|
||||||
|
// depends on build output or ignored files: a clean checkout runs it before anything is built.
|
||||||
|
const modulesDirectory = await externalModulesDirectory();
|
||||||
|
const installed = await createMigrationModule(modulesDirectory, "example");
|
||||||
|
expect(path.relative(repositoryRoot, modulesDirectory).startsWith("..")).toBe(true);
|
||||||
|
|
||||||
|
const aggregate = await discoverRepositoryMigrations(repositoryRoot, {
|
||||||
|
EKKLESIA_MODULES_DIR: modulesDirectory,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(aggregate.modules.map((module) => module.moduleId)).toEqual(["core", "example"]);
|
||||||
|
|
||||||
|
// Core's migrations are exactly the platform's own, byte for byte, from `core/drizzle`.
|
||||||
|
const platformCore = await discoverCoreMigrations({
|
||||||
|
coreMigrationsDirectory: path.join(repositoryRoot, "core", "drizzle"),
|
||||||
|
});
|
||||||
|
const fingerprint = (migrations: readonly { identity: string; checksum: string }[]) =>
|
||||||
|
migrations.map((migration) => `${migration.identity} ${migration.checksum}`);
|
||||||
|
expect(platformCore.length).toBeGreaterThan(0);
|
||||||
|
expect(fingerprint(aggregate.modules[0]?.migrations ?? [])).toEqual(fingerprint(platformCore));
|
||||||
|
|
||||||
|
// The module's migration is the one installed outside the repository, byte for byte.
|
||||||
|
const installedSql = await readFile(
|
||||||
|
path.join(installed.migrationDirectory ?? "", "0000_initial.sql"),
|
||||||
|
);
|
||||||
|
expect(fingerprint(aggregate.modules[1]?.migrations ?? [])).toEqual([
|
||||||
|
`example:0000_initial ${createHash("sha256").update(installedSql).digest("hex")}`,
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fails closed when the configured directory does not exist", async () => {
|
||||||
|
const parent = await externalModulesDirectory();
|
||||||
|
await expect(
|
||||||
|
discoverRepositoryMigrations(repositoryRoot, {
|
||||||
|
EKKLESIA_MODULES_DIR: path.join(parent, "absent"),
|
||||||
|
}),
|
||||||
|
).rejects.toBeInstanceOf(ModuleDiscoveryError);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("no source chooses a modules directory except the resolver", () => {
|
||||||
|
async function sourceFiles(directory: string): Promise<readonly string[]> {
|
||||||
|
const entries = await readdir(directory, { recursive: true, withFileTypes: true });
|
||||||
|
return entries
|
||||||
|
.filter((entry) => entry.isFile() && entry.name.endsWith(".ts"))
|
||||||
|
.map((entry) => path.join(entry.parentPath, entry.name));
|
||||||
|
}
|
||||||
|
|
||||||
|
it("never joins `modules` onto a root anywhere else in Core or the API", async () => {
|
||||||
|
const files = [
|
||||||
|
...(await sourceFiles(path.join(repositoryRoot, "core", "src"))),
|
||||||
|
...(await sourceFiles(path.join(repositoryRoot, "apps", "api", "src"))),
|
||||||
|
];
|
||||||
|
const resolver = path.join(repositoryRoot, "core", "src", "modules", "modules-directory.ts");
|
||||||
|
for (const file of files) {
|
||||||
|
if (file === resolver) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const text = await readFile(file, "utf8");
|
||||||
|
expect(text, path.relative(repositoryRoot, file)).not.toMatch(/,\s*"modules"\s*\)/u);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import { afterEach, describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
discoverModules,
|
||||||
|
ModuleDiscoveryError,
|
||||||
|
type ModuleDiscoveryIssueCode,
|
||||||
|
resolveModulesDirectory,
|
||||||
|
} from "../src/modules/index.js";
|
||||||
|
|
||||||
|
const versions = { moduleSdkVersion: "0.1.0", platformVersion: "0.1.0" } as const;
|
||||||
|
const repositoryRoot = path.join(path.sep, "opt", "ekklesia");
|
||||||
|
const temporaryDirectories: string[] = [];
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await Promise.all(
|
||||||
|
temporaryDirectories.splice(0).map((directory) => rm(directory, { recursive: true })),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
async function temporaryDirectory(): Promise<string> {
|
||||||
|
const directory = await mkdtemp(path.join(tmpdir(), "ekklesia-modules-directory-"));
|
||||||
|
temporaryDirectories.push(directory);
|
||||||
|
return directory;
|
||||||
|
}
|
||||||
|
|
||||||
|
function captureDiscoveryError(operation: () => unknown): ModuleDiscoveryError {
|
||||||
|
try {
|
||||||
|
operation();
|
||||||
|
} catch (error) {
|
||||||
|
expect(error).toBeInstanceOf(ModuleDiscoveryError);
|
||||||
|
return error as ModuleDiscoveryError;
|
||||||
|
}
|
||||||
|
throw new Error("Expected a ModuleDiscoveryError.");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function captureAsyncDiscoveryError(
|
||||||
|
operation: Promise<unknown>,
|
||||||
|
): Promise<ModuleDiscoveryError> {
|
||||||
|
try {
|
||||||
|
await operation;
|
||||||
|
} catch (error) {
|
||||||
|
expect(error).toBeInstanceOf(ModuleDiscoveryError);
|
||||||
|
return error as ModuleDiscoveryError;
|
||||||
|
}
|
||||||
|
throw new Error("Expected a ModuleDiscoveryError.");
|
||||||
|
}
|
||||||
|
|
||||||
|
function codes(error: ModuleDiscoveryError): readonly ModuleDiscoveryIssueCode[] {
|
||||||
|
return error.issues.map((issue) => issue.code);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Everything an operator, a log, or a test report could ever see from this error. */
|
||||||
|
function rendered(error: ModuleDiscoveryError): string {
|
||||||
|
return JSON.stringify({ issues: error.issues, message: error.message, stack: error.stack });
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("resolveModulesDirectory", () => {
|
||||||
|
it("defaults to the repository's own modules directory when nothing is configured", () => {
|
||||||
|
expect(resolveModulesDirectory({ configured: undefined, repositoryRoot })).toEqual({
|
||||||
|
modulesDirectory: path.join(repositoryRoot, "modules"),
|
||||||
|
modulesDirectoryRequired: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts an explicit absolute path, normalized, and requires it to exist", () => {
|
||||||
|
expect(
|
||||||
|
resolveModulesDirectory({ configured: "/var/lib/ekklesia/modules/", repositoryRoot }),
|
||||||
|
).toEqual({ modulesDirectory: "/var/lib/ekklesia/modules", modulesDirectoryRequired: true });
|
||||||
|
expect(
|
||||||
|
resolveModulesDirectory({ configured: "/var/lib/./ekklesia//modules", repositoryRoot }),
|
||||||
|
).toEqual({ modulesDirectory: "/var/lib/ekklesia/modules", modulesDirectoryRequired: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never resolves a configured path against the repository or the working directory", () => {
|
||||||
|
const resolved = resolveModulesDirectory({ configured: "/srv/modules", repositoryRoot });
|
||||||
|
expect(resolved.modulesDirectory).toBe("/srv/modules");
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
["a relative name", "modules"],
|
||||||
|
["a dot-relative path", "./modules"],
|
||||||
|
["a parent-relative path", "../private/modules"],
|
||||||
|
["a home-relative path", "~/modules"],
|
||||||
|
["the empty string", ""],
|
||||||
|
["whitespace only", " "],
|
||||||
|
["an absolute path with surrounding whitespace", " /var/lib/ekklesia/modules"],
|
||||||
|
["a NUL byte", "/var/lib/ekklesia/\u0000modules"],
|
||||||
|
])("rejects %s with a sanitized MODULES_DIRECTORY_INVALID", (_label, configured) => {
|
||||||
|
const error = captureDiscoveryError(() =>
|
||||||
|
resolveModulesDirectory({ configured, repositoryRoot }),
|
||||||
|
);
|
||||||
|
expect(codes(error)).toEqual(["MODULES_DIRECTORY_INVALID"]);
|
||||||
|
expect(error.issues[0]?.message).toBe("EKKLESIA_MODULES_DIR must be an absolute path.");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never echoes the supplied value in the error", () => {
|
||||||
|
const configured = "relative/secret-token-a1b2c3/postgresql://admin:hunter2@db/modules";
|
||||||
|
const error = captureDiscoveryError(() =>
|
||||||
|
resolveModulesDirectory({ configured, repositoryRoot }),
|
||||||
|
);
|
||||||
|
const text = rendered(error);
|
||||||
|
expect(text).not.toContain("secret-token-a1b2c3");
|
||||||
|
expect(text).not.toContain("hunter2");
|
||||||
|
expect(text).not.toContain("relative/");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("module discovery against a configured modules directory", () => {
|
||||||
|
it("fails closed, without the path, when a configured directory does not exist", async () => {
|
||||||
|
const parent = await temporaryDirectory();
|
||||||
|
const missing = path.join(parent, "private-mount-name-7f3e");
|
||||||
|
const error = await captureAsyncDiscoveryError(
|
||||||
|
discoverModules({ ...versions, modulesDirectory: missing, modulesDirectoryRequired: true }),
|
||||||
|
);
|
||||||
|
expect(codes(error)).toEqual(["MODULES_DIRECTORY_UNREADABLE"]);
|
||||||
|
expect(rendered(error)).not.toContain("private-mount-name-7f3e");
|
||||||
|
expect(rendered(error)).not.toContain(parent);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fails closed when a configured path is a file rather than a directory", async () => {
|
||||||
|
const parent = await temporaryDirectory();
|
||||||
|
const file = path.join(parent, "modules");
|
||||||
|
await writeFile(file, "not a directory");
|
||||||
|
const error = await captureAsyncDiscoveryError(
|
||||||
|
discoverModules({ ...versions, modulesDirectory: file, modulesDirectoryRequired: true }),
|
||||||
|
);
|
||||||
|
expect(codes(error)).toEqual(["MODULES_DIRECTORY_UNREADABLE"]);
|
||||||
|
expect(rendered(error)).not.toContain(parent);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a relative configured path even when it bypasses the resolver", async () => {
|
||||||
|
const error = await captureAsyncDiscoveryError(
|
||||||
|
discoverModules({ ...versions, modulesDirectory: "modules", modulesDirectoryRequired: true }),
|
||||||
|
);
|
||||||
|
expect(codes(error)).toEqual(["MODULES_DIRECTORY_INVALID"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("treats an existing, empty configured directory as zero installed modules", async () => {
|
||||||
|
const directory = await temporaryDirectory();
|
||||||
|
await expect(
|
||||||
|
discoverModules({ ...versions, modulesDirectory: directory, modulesDirectoryRequired: true }),
|
||||||
|
).resolves.toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the development default lenient: a missing default directory is zero modules", async () => {
|
||||||
|
const parent = await temporaryDirectory();
|
||||||
|
await expect(
|
||||||
|
discoverModules({ ...versions, modulesDirectory: path.join(parent, "modules") }),
|
||||||
|
).resolves.toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("discovers through the resolver's own selection unchanged", async () => {
|
||||||
|
const directory = await temporaryDirectory();
|
||||||
|
const selection = resolveModulesDirectory({ configured: directory, repositoryRoot });
|
||||||
|
await expect(discoverModules({ ...versions, ...selection })).resolves.toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
import { createHash, randomBytes } from "node:crypto";
|
import { createHash, randomBytes } from "node:crypto";
|
||||||
|
import { cp, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||||
import net from "node:net";
|
import net from "node:net";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
import { Client, type QueryResult, type QueryResultRow } from "pg";
|
import { Client, type QueryResult, type QueryResultRow } from "pg";
|
||||||
@@ -8,6 +10,7 @@ import { reportMigrationCommandError } from "../src/cli/migration-command-errors
|
|||||||
import {
|
import {
|
||||||
applyModuleMigrations,
|
applyModuleMigrations,
|
||||||
createPostgresMigrationDatabase,
|
createPostgresMigrationDatabase,
|
||||||
|
discoverAllMigrations,
|
||||||
discoverCoreMigrations,
|
discoverCoreMigrations,
|
||||||
type MigrationApproval,
|
type MigrationApproval,
|
||||||
type MigrationApprovalProvider,
|
type MigrationApprovalProvider,
|
||||||
@@ -764,7 +767,7 @@ describe.sequential("live PostgreSQL module migration execution", () => {
|
|||||||
database: migrationDatabase,
|
database: migrationDatabase,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(result).toEqual({ applied: [], appliedCount: 0, noOp: true });
|
expect(result).toEqual({ applied: [], appliedCount: 0, noOp: true, retained: [] });
|
||||||
expect(await databaseState(database.client)).toEqual(before);
|
expect(await databaseState(database.client)).toEqual(before);
|
||||||
const afterData = await safeQuery<QueryResultRow>(
|
const afterData = await safeQuery<QueryResultRow>(
|
||||||
database.client,
|
database.client,
|
||||||
@@ -834,7 +837,48 @@ describe.sequential("live PostgreSQL module migration execution", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("rejects an unknown applied identity without repair or further execution", async () => {
|
it("retains an absent module's history and still applies later work (ADR 0048)", async () => {
|
||||||
|
await withIsolatedDatabase("absent_history", async (database) => {
|
||||||
|
const initial = peopleInitial();
|
||||||
|
const later = peopleLater();
|
||||||
|
const initialInventory = aggregate(moduleFixture("people", [initial]));
|
||||||
|
const completeInventory = aggregate(moduleFixture("people", [initial, later]));
|
||||||
|
const migrationDatabase = createPostgresMigrationDatabase(database.url);
|
||||||
|
await applyModuleMigrations({ aggregate: initialInventory, database: migrationDatabase });
|
||||||
|
// History left by a module that is no longer installed: kept, reported, never fatal.
|
||||||
|
await safeQuery(
|
||||||
|
database.client,
|
||||||
|
`
|
||||||
|
INSERT INTO public.ekklesia_core_migrations
|
||||||
|
(identity, module_id, migration_tag, migration_index, checksum)
|
||||||
|
VALUES ($1, $2, $3, $4, $5)
|
||||||
|
`,
|
||||||
|
["ghost:0000_unknown", "ghost", "0000_unknown", 0, "f".repeat(64)],
|
||||||
|
);
|
||||||
|
|
||||||
|
const plan = await planModuleMigrations({
|
||||||
|
aggregate: completeInventory,
|
||||||
|
database: migrationDatabase,
|
||||||
|
});
|
||||||
|
expect(plan.retained).toEqual([{ appliedCount: 1, moduleId: "ghost" }]);
|
||||||
|
expect(plan.pendingCount).toBe(1);
|
||||||
|
|
||||||
|
const result = await applyModuleMigrations({
|
||||||
|
aggregate: completeInventory,
|
||||||
|
database: migrationDatabase,
|
||||||
|
});
|
||||||
|
expect(result.applied.map((migration) => migration.identity)).toEqual([later.identity]);
|
||||||
|
expect(result.retained).toEqual([{ appliedCount: 1, moduleId: "ghost" }]);
|
||||||
|
expect(await columnExists(database.client, "people_records", "email")).toBe(true);
|
||||||
|
expect((await readHistory(database.client)).map((row) => row.identity)).toEqual([
|
||||||
|
initial.identity,
|
||||||
|
"ghost:0000_unknown",
|
||||||
|
later.identity,
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects an unknown applied identity of an installed module without repair or further execution", async () => {
|
||||||
await withIsolatedDatabase("unknown_history", async (database) => {
|
await withIsolatedDatabase("unknown_history", async (database) => {
|
||||||
const initial = peopleInitial();
|
const initial = peopleInitial();
|
||||||
const later = peopleLater();
|
const later = peopleLater();
|
||||||
@@ -849,7 +893,7 @@ describe.sequential("live PostgreSQL module migration execution", () => {
|
|||||||
(identity, module_id, migration_tag, migration_index, checksum)
|
(identity, module_id, migration_tag, migration_index, checksum)
|
||||||
VALUES ($1, $2, $3, $4, $5)
|
VALUES ($1, $2, $3, $4, $5)
|
||||||
`,
|
`,
|
||||||
["ghost:0000_unknown", "ghost", "0000_unknown", 0, "f".repeat(64)],
|
["people:0099_unknown", "people", "0099_unknown", 99, "f".repeat(64)],
|
||||||
);
|
);
|
||||||
const corrupted = await databaseState(database.client);
|
const corrupted = await databaseState(database.client);
|
||||||
|
|
||||||
@@ -866,7 +910,7 @@ describe.sequential("live PostgreSQL module migration execution", () => {
|
|||||||
expect(await columnExists(database.client, "people_records", "email")).toBe(false);
|
expect(await columnExists(database.client, "people_records", "email")).toBe(false);
|
||||||
expect((await readHistory(database.client)).map((row) => row.identity)).toEqual([
|
expect((await readHistory(database.client)).map((row) => row.identity)).toEqual([
|
||||||
initial.identity,
|
initial.identity,
|
||||||
"ghost:0000_unknown",
|
"people:0099_unknown",
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -1358,7 +1402,7 @@ describe.sequential("live PostgreSQL Core identity migration execution", () => {
|
|||||||
database: migrationDatabase,
|
database: migrationDatabase,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(result).toEqual({ applied: [], appliedCount: 0, noOp: true });
|
expect(result).toEqual({ applied: [], appliedCount: 0, noOp: true, retained: [] });
|
||||||
expect(await databaseState(database.client)).toEqual(before);
|
expect(await databaseState(database.client)).toEqual(before);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -1851,3 +1895,151 @@ describe.sequential("live PostgreSQL migration timeout precedence", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ADR 0048 end to end, with real discovery: Core's committed migrations, the module template
|
||||||
|
* installed in a modules directory outside the repository, and a real PostgreSQL. Core's newest
|
||||||
|
* migration is withheld from a copy of `core/drizzle` at first, so a genuinely later Core migration
|
||||||
|
* can be applied while the module is absent.
|
||||||
|
*/
|
||||||
|
describe.sequential("live PostgreSQL retained history for an uninstalled module", () => {
|
||||||
|
const repositoryRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..");
|
||||||
|
const templateModule = path.join(repositoryRoot, "templates", "module");
|
||||||
|
const versions = { moduleSdkVersion: "0.1.0", platformVersion: "0.1.0" } as const;
|
||||||
|
|
||||||
|
async function coreWithoutNewestMigration(directory: string): Promise<string> {
|
||||||
|
const target = path.join(directory, "core-drizzle");
|
||||||
|
await cp(coreMigrationsDirectory, target, { recursive: true });
|
||||||
|
const journalPath = path.join(target, "meta", "_journal.json");
|
||||||
|
const policyPath = path.join(target, "meta", "_ekklesia.json");
|
||||||
|
const journal = JSON.parse(await readFile(journalPath, "utf8")) as {
|
||||||
|
entries: { idx: number; tag: string }[];
|
||||||
|
};
|
||||||
|
const policy = JSON.parse(await readFile(policyPath, "utf8")) as { entries: unknown[] };
|
||||||
|
const newest = journal.entries.pop();
|
||||||
|
policy.entries.pop();
|
||||||
|
if (!newest) {
|
||||||
|
throw harnessError("Core has no migration to withhold.");
|
||||||
|
}
|
||||||
|
await writeFile(journalPath, `${JSON.stringify(journal, null, 2)}\n`);
|
||||||
|
await writeFile(policyPath, `${JSON.stringify(policy, null, 2)}\n`);
|
||||||
|
await rm(path.join(target, `${newest.tag}.sql`));
|
||||||
|
await rm(path.join(target, "meta", `${String(newest.idx).padStart(4, "0")}_snapshot.json`));
|
||||||
|
return target;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function installExample(modulesDirectory: string): Promise<void> {
|
||||||
|
for (const entry of ["package.json", "ekklesia.module.json", "drizzle", "dist"]) {
|
||||||
|
await cp(path.join(templateModule, entry), path.join(modulesDirectory, "example", entry), {
|
||||||
|
recursive: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function inventory(coreDirectory: string, modulesDirectory: string) {
|
||||||
|
return discoverAllMigrations({
|
||||||
|
coreMigrationsDirectory: coreDirectory,
|
||||||
|
moduleDiscovery: { ...versions, modulesDirectory, modulesDirectoryRequired: true },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function exampleValues(client: Client): Promise<readonly string[]> {
|
||||||
|
const result = await safeQuery<{ readonly value: string } & QueryResultRow>(
|
||||||
|
client,
|
||||||
|
"SELECT value FROM public.example_records ORDER BY value",
|
||||||
|
);
|
||||||
|
return result.rows.map((row) => row.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
it("keeps an uninstalled module's data and history, applies later Core work, and restores it", async () => {
|
||||||
|
const scratch = await mkdtemp(path.join(tmpdir(), "ekklesia-retained-history-"));
|
||||||
|
try {
|
||||||
|
const earlyCore = await coreWithoutNewestMigration(scratch);
|
||||||
|
const modulesDirectory = path.join(scratch, "modules");
|
||||||
|
await mkdir(modulesDirectory);
|
||||||
|
await installExample(modulesDirectory);
|
||||||
|
|
||||||
|
await withIsolatedDatabase("retained_module", async (database) => {
|
||||||
|
const migrationDatabase = createPostgresMigrationDatabase(database.url);
|
||||||
|
|
||||||
|
// 1. Core (minus its newest migration) and the installed module, applied together.
|
||||||
|
const first = await applyModuleMigrations({
|
||||||
|
aggregate: await inventory(earlyCore, modulesDirectory),
|
||||||
|
database: migrationDatabase,
|
||||||
|
});
|
||||||
|
expect(first.applied.map((migration) => migration.identity)).toContain(
|
||||||
|
"example:0000_initial",
|
||||||
|
);
|
||||||
|
expect(first.retained).toEqual([]);
|
||||||
|
|
||||||
|
// 2. Module-owned data.
|
||||||
|
await safeQuery(
|
||||||
|
database.client,
|
||||||
|
"INSERT INTO public.example_records (id, value) VALUES (gen_random_uuid(), 'kept')",
|
||||||
|
);
|
||||||
|
|
||||||
|
// 3. The module is uninstalled: its directory is gone from module storage.
|
||||||
|
await rm(path.join(modulesDirectory, "example"), { recursive: true });
|
||||||
|
|
||||||
|
// 4. A later Core migration plans and applies while the module is absent.
|
||||||
|
const absentInventory = await inventory(coreMigrationsDirectory, modulesDirectory);
|
||||||
|
const absentPlan = await planModuleMigrations({
|
||||||
|
aggregate: absentInventory,
|
||||||
|
database: migrationDatabase,
|
||||||
|
});
|
||||||
|
expect(absentPlan.retained).toEqual([{ appliedCount: 1, moduleId: "example" }]);
|
||||||
|
expect(
|
||||||
|
absentPlan.entries.filter((entry) => entry.status === "pending").map((e) => e.moduleId),
|
||||||
|
).toEqual(["core"]);
|
||||||
|
const later = await applyModuleMigrations({
|
||||||
|
aggregate: absentInventory,
|
||||||
|
database: migrationDatabase,
|
||||||
|
});
|
||||||
|
expect(later.applied).toHaveLength(1);
|
||||||
|
expect(later.applied[0]?.moduleId).toBe("core");
|
||||||
|
expect(later.retained).toEqual([{ appliedCount: 1, moduleId: "example" }]);
|
||||||
|
|
||||||
|
// 5. The module's table and rows are untouched, and so is its history.
|
||||||
|
expect(await exampleValues(database.client)).toEqual(["kept"]);
|
||||||
|
expect((await readHistory(database.client)).map((row) => row.identity)).toContain(
|
||||||
|
"example:0000_initial",
|
||||||
|
);
|
||||||
|
|
||||||
|
// 6. The module is reinstalled.
|
||||||
|
await installExample(modulesDirectory);
|
||||||
|
const restored = await inventory(coreMigrationsDirectory, modulesDirectory);
|
||||||
|
|
||||||
|
// 7. Its history is strict again, and valid; its data is where it left it.
|
||||||
|
const restoredPlan = await planModuleMigrations({
|
||||||
|
aggregate: restored,
|
||||||
|
database: migrationDatabase,
|
||||||
|
});
|
||||||
|
expect(restoredPlan.retained).toEqual([]);
|
||||||
|
expect(restoredPlan.pendingCount).toBe(0);
|
||||||
|
expect(
|
||||||
|
restoredPlan.entries.find((entry) => entry.identity === "example:0000_initial")?.status,
|
||||||
|
).toBe("applied");
|
||||||
|
const noOp = await applyModuleMigrations({
|
||||||
|
aggregate: restored,
|
||||||
|
database: migrationDatabase,
|
||||||
|
});
|
||||||
|
expect(noOp.noOp).toBe(true);
|
||||||
|
expect(await exampleValues(database.client)).toEqual(["kept"]);
|
||||||
|
|
||||||
|
// A returning package whose applied migration changed is refused, exactly as before.
|
||||||
|
const sqlPath = path.join(modulesDirectory, "example", "drizzle", "0000_initial.sql");
|
||||||
|
await writeFile(sqlPath, `${await readFile(sqlPath, "utf8")}-- edited\n`);
|
||||||
|
await captureRunnerError(
|
||||||
|
planModuleMigrations({
|
||||||
|
aggregate: await inventory(coreMigrationsDirectory, modulesDirectory),
|
||||||
|
database: migrationDatabase,
|
||||||
|
}),
|
||||||
|
"MIGRATION_CHECKSUM_MISMATCH",
|
||||||
|
);
|
||||||
|
expect(await exampleValues(database.client)).toEqual(["kept"]);
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
await rm(scratch, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -0,0 +1,171 @@
|
|||||||
|
import { mkdir, writeFile } from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A minimal, valid installed module written into a directory the caller owns: `package.json`,
|
||||||
|
* `ekklesia.module.json`, a compiled-ESM server stub at `dist/server/index.js`, and, unless
|
||||||
|
* disabled, a native Drizzle migration directory (journal, `_ekklesia.json` policy, snapshot chain,
|
||||||
|
* and SQL) that passes Core's migration discovery.
|
||||||
|
*
|
||||||
|
* Everything is generated here, from nothing in the repository: no build output, no ignored file,
|
||||||
|
* and no `node_modules`. Migration discovery imports no module code, so the stub is never run.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const originSnapshotId = "00000000-0000-0000-0000-000000000000";
|
||||||
|
|
||||||
|
export interface MigrationFixture {
|
||||||
|
readonly breakpoints?: boolean;
|
||||||
|
readonly destructive?:
|
||||||
|
| { readonly mode: "none" }
|
||||||
|
| { readonly mode: "approval-required"; readonly reason: string };
|
||||||
|
readonly snapshotId?: string;
|
||||||
|
readonly sql: string | Uint8Array;
|
||||||
|
readonly tag: string;
|
||||||
|
readonly timestamp?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MigrationModuleFixture {
|
||||||
|
readonly dependencies?: Record<string, unknown>;
|
||||||
|
readonly manifest?: Record<string, unknown>;
|
||||||
|
readonly migrations?: readonly MigrationFixture[] | false;
|
||||||
|
readonly schemaSource?: string;
|
||||||
|
readonly serverSource?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreatedMigrationModule {
|
||||||
|
readonly migrationDirectory?: string;
|
||||||
|
readonly moduleDirectory: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function snapshotId(index: number): string {
|
||||||
|
return `10000000-0000-4000-8000-${(index + 1).toString(16).padStart(12, "0")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function manifestFor(
|
||||||
|
id: string,
|
||||||
|
options: {
|
||||||
|
readonly dependencies?: Record<string, unknown>;
|
||||||
|
readonly migrations?: boolean;
|
||||||
|
readonly schema?: boolean;
|
||||||
|
} = {},
|
||||||
|
): Record<string, unknown> {
|
||||||
|
return {
|
||||||
|
manifestVersion: 7,
|
||||||
|
id,
|
||||||
|
name: `${id} module`,
|
||||||
|
version: "0.1.0",
|
||||||
|
compatibility: { platform: "^0.1.0", moduleSdk: "^0.1.0" },
|
||||||
|
...(options.dependencies ? { dependencies: options.dependencies } : {}),
|
||||||
|
capabilities: [],
|
||||||
|
permissions: [],
|
||||||
|
entrypoints: {
|
||||||
|
server: "./dist/server/index.js",
|
||||||
|
...(options.schema ? { schema: "./dist/schema/index.js" } : {}),
|
||||||
|
...(options.migrations === false ? {} : { migrations: "./drizzle" }),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function defaultMigrations(id: string): readonly MigrationFixture[] {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
sql: `CREATE TABLE "${id.replaceAll("-", "_")}_records" ("id" integer);\n`,
|
||||||
|
tag: "0000_initial",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createMigrationModule(
|
||||||
|
modulesDirectory: string,
|
||||||
|
id: string,
|
||||||
|
fixture: MigrationModuleFixture = {},
|
||||||
|
): Promise<CreatedMigrationModule> {
|
||||||
|
const migrations =
|
||||||
|
fixture.migrations === false ? undefined : (fixture.migrations ?? defaultMigrations(id));
|
||||||
|
const moduleDirectory = path.join(modulesDirectory, id);
|
||||||
|
const migrationDirectory = migrations ? path.join(moduleDirectory, "drizzle") : undefined;
|
||||||
|
await mkdir(path.join(moduleDirectory, "dist/server"), { recursive: true });
|
||||||
|
if (fixture.schemaSource !== undefined) {
|
||||||
|
await mkdir(path.join(moduleDirectory, "dist/schema"), { recursive: true });
|
||||||
|
}
|
||||||
|
if (migrationDirectory) {
|
||||||
|
await mkdir(path.join(migrationDirectory, "meta"), { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
await Promise.all([
|
||||||
|
writeFile(
|
||||||
|
path.join(moduleDirectory, "package.json"),
|
||||||
|
JSON.stringify({
|
||||||
|
name: `@test/module-${id}`,
|
||||||
|
version: "0.1.0",
|
||||||
|
type: "module",
|
||||||
|
ekklesia: { layer: "module" },
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
writeFile(
|
||||||
|
path.join(moduleDirectory, "ekklesia.module.json"),
|
||||||
|
JSON.stringify(
|
||||||
|
fixture.manifest ??
|
||||||
|
manifestFor(id, {
|
||||||
|
...(fixture.dependencies ? { dependencies: fixture.dependencies } : {}),
|
||||||
|
migrations: migrations !== undefined,
|
||||||
|
schema: fixture.schemaSource !== undefined,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
writeFile(
|
||||||
|
path.join(moduleDirectory, "dist/server/index.js"),
|
||||||
|
fixture.serverSource ?? `export default { id: ${JSON.stringify(id)}, version: "0.1.0" };`,
|
||||||
|
),
|
||||||
|
...(fixture.schemaSource === undefined
|
||||||
|
? []
|
||||||
|
: [writeFile(path.join(moduleDirectory, "dist/schema/index.js"), fixture.schemaSource)]),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!migrationDirectory || !migrations) {
|
||||||
|
return { moduleDirectory };
|
||||||
|
}
|
||||||
|
|
||||||
|
const journalEntries = migrations.map((migration, index) => ({
|
||||||
|
idx: index,
|
||||||
|
version: "7",
|
||||||
|
when: migration.timestamp ?? 1_700_000_000_000 + index,
|
||||||
|
tag: migration.tag,
|
||||||
|
breakpoints: migration.breakpoints ?? true,
|
||||||
|
}));
|
||||||
|
const policyEntries = migrations.map((migration) => ({
|
||||||
|
tag: migration.tag,
|
||||||
|
destructive: migration.destructive ?? { mode: "none" },
|
||||||
|
}));
|
||||||
|
await Promise.all([
|
||||||
|
writeFile(
|
||||||
|
path.join(migrationDirectory, "meta/_journal.json"),
|
||||||
|
JSON.stringify({ version: "7", dialect: "postgresql", entries: journalEntries }),
|
||||||
|
),
|
||||||
|
writeFile(
|
||||||
|
path.join(migrationDirectory, "meta/_ekklesia.json"),
|
||||||
|
JSON.stringify({ version: 1, moduleId: id, entries: policyEntries }),
|
||||||
|
),
|
||||||
|
...migrations.flatMap((migration, index) => {
|
||||||
|
const idForSnapshot = migration.snapshotId ?? snapshotId(index);
|
||||||
|
const previousId =
|
||||||
|
index === 0
|
||||||
|
? originSnapshotId
|
||||||
|
: (migrations[index - 1]?.snapshotId ?? snapshotId(index - 1));
|
||||||
|
return [
|
||||||
|
writeFile(path.join(migrationDirectory, `${migration.tag}.sql`), migration.sql),
|
||||||
|
writeFile(
|
||||||
|
path.join(migrationDirectory, `meta/${index.toString().padStart(4, "0")}_snapshot.json`),
|
||||||
|
JSON.stringify({
|
||||||
|
version: "7",
|
||||||
|
dialect: "postgresql",
|
||||||
|
id: idForSnapshot,
|
||||||
|
prevId: previousId,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return { migrationDirectory, moduleDirectory };
|
||||||
|
}
|
||||||
@@ -0,0 +1,205 @@
|
|||||||
|
import { createHash } from "node:crypto";
|
||||||
|
import { mkdtemp, readdir, readFile, rm } from "node:fs/promises";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import { gzipSync } from "node:zlib";
|
||||||
|
import { createMigrationModule } from "./migration-module-fixture.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test-only builders for Ekklesia Module Package v1 archives (ADR 0049). They write ustar archives
|
||||||
|
* by hand so a test can produce exactly what the canonical packager never would: links, device
|
||||||
|
* nodes, traversal, a second top-level directory, a corrupted payload.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type FixtureEntryType = "file" | "directory" | "symlink" | "hardlink" | "character" | "fifo";
|
||||||
|
|
||||||
|
export interface FixtureEntry {
|
||||||
|
readonly path: string;
|
||||||
|
readonly type?: FixtureEntryType;
|
||||||
|
readonly mode?: number;
|
||||||
|
readonly data?: Uint8Array | string;
|
||||||
|
readonly linkname?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const typeflags: Readonly<Record<FixtureEntryType, string>> = {
|
||||||
|
character: "3",
|
||||||
|
directory: "5",
|
||||||
|
fifo: "6",
|
||||||
|
file: "0",
|
||||||
|
hardlink: "1",
|
||||||
|
symlink: "2",
|
||||||
|
};
|
||||||
|
|
||||||
|
function octal(value: number, width: number): string {
|
||||||
|
return `${value.toString(8).padStart(width - 1, "0")}\u0000`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function header(entry: FixtureEntry, size: number): Buffer {
|
||||||
|
const block = Buffer.alloc(512);
|
||||||
|
const name =
|
||||||
|
entry.type === "directory" && !entry.path.endsWith("/") ? `${entry.path}/` : entry.path;
|
||||||
|
let prefix = "";
|
||||||
|
let base = name;
|
||||||
|
if (Buffer.byteLength(name) > 100) {
|
||||||
|
const split = name.lastIndexOf("/", name.length - 2);
|
||||||
|
prefix = name.slice(0, split);
|
||||||
|
base = name.slice(split + 1);
|
||||||
|
}
|
||||||
|
block.write(base, 0, 100, "latin1");
|
||||||
|
block.write(octal(entry.mode ?? (entry.type === "directory" ? 0o755 : 0o644), 8), 100, "latin1");
|
||||||
|
block.write(octal(0, 8), 108, "latin1");
|
||||||
|
block.write(octal(0, 8), 116, "latin1");
|
||||||
|
block.write(octal(size, 12), 124, "latin1");
|
||||||
|
block.write(octal(0, 12), 136, "latin1");
|
||||||
|
block.write(" ", 148, "latin1");
|
||||||
|
block.write(typeflags[entry.type ?? "file"], 156, "latin1");
|
||||||
|
block.write(entry.linkname ?? "", 157, 100, "latin1");
|
||||||
|
block.write("ustar\u000000", 257, "latin1");
|
||||||
|
block.write(prefix, 345, 155, "latin1");
|
||||||
|
let checksum = 0;
|
||||||
|
for (const byte of block) {
|
||||||
|
checksum += byte;
|
||||||
|
}
|
||||||
|
block.write(`${checksum.toString(8).padStart(6, "0")}\u0000 `, 148, "latin1");
|
||||||
|
return block;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A gzip-compressed ustar archive of exactly these entries, in this order. */
|
||||||
|
export function tarGz(entries: readonly FixtureEntry[]): Buffer {
|
||||||
|
const parts: Buffer[] = [];
|
||||||
|
for (const entry of entries) {
|
||||||
|
const data =
|
||||||
|
entry.type === undefined || entry.type === "file"
|
||||||
|
? Buffer.from(entry.data ?? "")
|
||||||
|
: Buffer.alloc(0);
|
||||||
|
parts.push(header(entry, data.length), data, Buffer.alloc((512 - (data.length % 512)) % 512));
|
||||||
|
}
|
||||||
|
parts.push(Buffer.alloc(1024));
|
||||||
|
return gzipSync(Buffer.concat(parts), { level: 9 });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sha256(data: Uint8Array | string): string {
|
||||||
|
return createHash("sha256").update(data).digest("hex");
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PackageFixtureOptions {
|
||||||
|
readonly id?: string;
|
||||||
|
readonly version?: string;
|
||||||
|
readonly serverSource?: string;
|
||||||
|
readonly manifest?: (manifest: Record<string, unknown>) => Record<string, unknown>;
|
||||||
|
readonly runtimePackage?: Record<string, unknown>;
|
||||||
|
readonly metadata?: (metadata: Record<string, unknown>) => Record<string, unknown>;
|
||||||
|
/** Changes the payload after its digests were computed. */
|
||||||
|
readonly tamper?: (files: Map<string, Buffer>) => void;
|
||||||
|
/** Entries appended after the package's own, under any path. */
|
||||||
|
readonly extraEntries?: readonly FixtureEntry[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PackageFixture {
|
||||||
|
readonly archive: Buffer;
|
||||||
|
readonly sha256: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readTree(root: string): Promise<Map<string, Buffer>> {
|
||||||
|
const files = new Map<string, Buffer>();
|
||||||
|
for (const entry of await readdir(root, { recursive: true, withFileTypes: true })) {
|
||||||
|
if (entry.isFile()) {
|
||||||
|
const absolute = path.join(entry.parentPath, entry.name);
|
||||||
|
files.set(path.relative(root, absolute).split(path.sep).join("/"), await readFile(absolute));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return files;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A valid any-target package of a minimal module: a server stub with no imports, one migration, a
|
||||||
|
* notices file, the generated runtime package.json, and complete metadata. Every option changes one
|
||||||
|
* thing, so a test can break exactly the rule it is about.
|
||||||
|
*/
|
||||||
|
export async function modulePackageFixture(
|
||||||
|
options: PackageFixtureOptions = {},
|
||||||
|
): Promise<PackageFixture> {
|
||||||
|
const id = options.id ?? "example";
|
||||||
|
const version = options.version ?? "0.1.0";
|
||||||
|
const scratch = await mkdtemp(path.join(tmpdir(), "ekklesia-package-fixture-"));
|
||||||
|
let files: Map<string, Buffer>;
|
||||||
|
try {
|
||||||
|
const created = await createMigrationModule(scratch, id, {
|
||||||
|
serverSource:
|
||||||
|
options.serverSource ??
|
||||||
|
`export default { id: ${JSON.stringify(id)}, version: ${JSON.stringify(version)}, register() {} };\n`,
|
||||||
|
});
|
||||||
|
files = await readTree(created.moduleDirectory);
|
||||||
|
} finally {
|
||||||
|
await rm(scratch, { force: true, recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
const manifest = JSON.parse(
|
||||||
|
files.get("ekklesia.module.json")?.toString("utf8") ?? "{}",
|
||||||
|
) as Record<string, unknown>;
|
||||||
|
files.set(
|
||||||
|
"ekklesia.module.json",
|
||||||
|
Buffer.from(
|
||||||
|
`${JSON.stringify(options.manifest ? options.manifest(manifest) : { ...manifest, version }, null, 2)}\n`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
files.set(
|
||||||
|
"package.json",
|
||||||
|
Buffer.from(
|
||||||
|
`${JSON.stringify(
|
||||||
|
options.runtimePackage ?? {
|
||||||
|
name: `@test/module-${id}`,
|
||||||
|
version,
|
||||||
|
private: true,
|
||||||
|
type: "module",
|
||||||
|
ekklesia: { layer: "module" },
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
)}\n`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
files.set("THIRD_PARTY_NOTICES", Buffer.from("No third-party software is included.\n"));
|
||||||
|
|
||||||
|
const listed = [...files.entries()]
|
||||||
|
.sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0))
|
||||||
|
.map(([filePath, data]) => ({ path: filePath, size: data.length, sha256: sha256(data) }));
|
||||||
|
const baseMetadata: Record<string, unknown> = {
|
||||||
|
packageFormat: 1,
|
||||||
|
module: { id, version },
|
||||||
|
target: "any",
|
||||||
|
runtime: { node: ">=20" },
|
||||||
|
provenance: {
|
||||||
|
revision: "86ae907f59458508e3277666cdd214f5bd7c1078",
|
||||||
|
packager: { name: "@ekklesia/module-pack", version: "0.1.2" },
|
||||||
|
},
|
||||||
|
incorporates: { "@ekklesia/module-sdk": "0.1.2" },
|
||||||
|
files: listed,
|
||||||
|
};
|
||||||
|
const metadata = options.metadata ? options.metadata(baseMetadata) : baseMetadata;
|
||||||
|
options.tamper?.(files);
|
||||||
|
|
||||||
|
const directories = new Set<string>([id]);
|
||||||
|
for (const filePath of files.keys()) {
|
||||||
|
const segments = filePath.split("/");
|
||||||
|
for (let index = 1; index < segments.length; index += 1) {
|
||||||
|
directories.add(`${id}/${segments.slice(0, index).join("/")}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const all = new Map<string, FixtureEntry>();
|
||||||
|
for (const directory of directories) {
|
||||||
|
all.set(directory, { path: directory, type: "directory" });
|
||||||
|
}
|
||||||
|
for (const [filePath, data] of files) {
|
||||||
|
all.set(`${id}/${filePath}`, { path: `${id}/${filePath}`, data });
|
||||||
|
}
|
||||||
|
all.set(`${id}/ekklesia.package.json`, {
|
||||||
|
path: `${id}/ekklesia.package.json`,
|
||||||
|
data: `${JSON.stringify(metadata, null, 2)}\n`,
|
||||||
|
});
|
||||||
|
const ordered = [...all.values()].sort((left, right) =>
|
||||||
|
left.path < right.path ? -1 : left.path > right.path ? 1 : 0,
|
||||||
|
);
|
||||||
|
const archive = tarGz([...ordered, ...(options.extraEntries ?? [])]);
|
||||||
|
return { archive, sha256: sha256(archive) };
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import { readFile } from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
parseSha256File,
|
||||||
|
parseVerifyModulePackageArguments,
|
||||||
|
readModulePackagePlatform,
|
||||||
|
} from "../src/cli/verify-module-package.js";
|
||||||
|
|
||||||
|
const repositoryRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..");
|
||||||
|
const digest = "ab".repeat(32);
|
||||||
|
|
||||||
|
describe("verify-module-package arguments", () => {
|
||||||
|
it("takes an archive and, optionally, a digest and an extraction directory", () => {
|
||||||
|
expect(parseVerifyModulePackageArguments(["example-0.1.0-any.tar.gz"])).toEqual({
|
||||||
|
archivePath: "example-0.1.0-any.tar.gz",
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
parseVerifyModulePackageArguments([
|
||||||
|
"x.tar.gz",
|
||||||
|
"--sha256",
|
||||||
|
digest.toUpperCase(),
|
||||||
|
"--extract-to",
|
||||||
|
"/staging/example",
|
||||||
|
]),
|
||||||
|
).toEqual({ archivePath: "x.tar.gz", expectedSha256: digest, extractTo: "/staging/example" });
|
||||||
|
expect(
|
||||||
|
parseVerifyModulePackageArguments(["x.tar.gz", "--sha256-file", "x.tar.gz.sha256"]),
|
||||||
|
).toEqual({ archivePath: "x.tar.gz", sha256File: "x.tar.gz.sha256" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
["no archive", []],
|
||||||
|
["two archives", ["a.tar.gz", "b.tar.gz"]],
|
||||||
|
["an unknown option", ["a.tar.gz", "--force"]],
|
||||||
|
["both digest options", ["a.tar.gz", "--sha256", digest, "--sha256-file", "a.sha256"]],
|
||||||
|
["a malformed digest", ["a.tar.gz", "--sha256", "abc"]],
|
||||||
|
["an option without its value", ["a.tar.gz", "--extract-to"]],
|
||||||
|
])("refuses %s", (_label, argv) => {
|
||||||
|
expect(() => parseVerifyModulePackageArguments(argv)).toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("verify-module-package digest files", () => {
|
||||||
|
it("reads the sha256sum format, ignoring the recorded file name", () => {
|
||||||
|
expect(parseSha256File(`${digest} example-0.1.0-any.tar.gz\n`)).toBe(digest);
|
||||||
|
expect(parseSha256File(`${digest.toUpperCase()} *renamed.tar.gz\n`)).toBe(digest);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
"",
|
||||||
|
"not-a-digest x.tar.gz\n",
|
||||||
|
`${digest}\n${digest} second\n`,
|
||||||
|
])("refuses %j", (contents) => {
|
||||||
|
expect(() => parseSha256File(contents)).toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("verify-module-package platform facts", () => {
|
||||||
|
it("reads this platform's versions and its transitional database runtime from the repository", async () => {
|
||||||
|
const root = JSON.parse(await readFile(path.join(repositoryRoot, "package.json"), "utf8"));
|
||||||
|
const core = JSON.parse(
|
||||||
|
await readFile(path.join(repositoryRoot, "core", "package.json"), "utf8"),
|
||||||
|
);
|
||||||
|
const sdk = JSON.parse(
|
||||||
|
await readFile(path.join(repositoryRoot, "packages", "module-sdk", "package.json"), "utf8"),
|
||||||
|
);
|
||||||
|
await expect(readModulePackagePlatform(repositoryRoot)).resolves.toEqual({
|
||||||
|
databaseRuntime: { "drizzle-orm": core.dependencies["drizzle-orm"] },
|
||||||
|
moduleSdkVersion: sdk.version,
|
||||||
|
platformVersion: root.version,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
# Ekklesia reference proxy configuration (ADR 0044). Copy to deploy/Caddyfile, which the proxy
|
||||||
|
# overlay mounts read-only. Hostnames, the ACME contact, and the tunnel connector's address come from
|
||||||
|
# the proxy service's environment (deploy/.env), so this file normally needs no edits.
|
||||||
|
{
|
||||||
|
# ACME account contact. Certificates use the HTTP-01 challenge on port 80 by default, which needs
|
||||||
|
# the Ekklesia and storage hostnames to resolve publicly to this host with 80 and 443 reachable.
|
||||||
|
email {$EKKLESIA_ACME_EMAIL}
|
||||||
|
|
||||||
|
# DNS-01, for an installation that keeps Ekklesia off the public Internet, serves only a LAN
|
||||||
|
# (split-horizon DNS), or is reachable only through Cloudflare Tunnel. Cloudflare terminates TLS at
|
||||||
|
# its edge, so HTTP-01 cannot reach this proxy through a tunnel.
|
||||||
|
#
|
||||||
|
# DNS-01 needs a DNS provider module that the stock Caddy image does not contain. Build an image
|
||||||
|
# with it (for example `xcaddy build --with github.com/caddy-dns/cloudflare`), pin that image by
|
||||||
|
# digest in your own overlay, give it CLOUDFLARE_API_TOKEN through a root-owned env file, then
|
||||||
|
# enable:
|
||||||
|
#
|
||||||
|
# acme_dns cloudflare {env.CLOUDFLARE_API_TOKEN}
|
||||||
|
|
||||||
|
servers {
|
||||||
|
# A client-address header is honored only from the tunnel connector's fixed address
|
||||||
|
# (compose.tunnel.yaml). From every other peer, the client address is the TCP peer itself and
|
||||||
|
# any such header is ignored. Without a tunnel, nothing has this address.
|
||||||
|
trusted_proxies static {$EKKLESIA_TUNNEL_ADDRESS}/32
|
||||||
|
client_ip_headers CF-Connecting-IP
|
||||||
|
trusted_proxies_strict
|
||||||
|
}
|
||||||
|
|
||||||
|
# Access logs are off unless enabled. If you enable them, keep request headers out: they carry
|
||||||
|
# cookies and forwarding chains (docs/development/security.md, launch checklist item 7).
|
||||||
|
}
|
||||||
|
|
||||||
|
# What every request to the API carries upstream: exactly one forwarding header, overwritten with
|
||||||
|
# the client address Caddy resolved, and none of the headers a caller could use to choose its own.
|
||||||
|
(ekklesia_forwarding) {
|
||||||
|
header_up X-Forwarded-For {client_ip}
|
||||||
|
header_up -CF-Connecting-IP
|
||||||
|
header_up -X-Real-IP
|
||||||
|
header_up -Forwarded
|
||||||
|
header_up -True-Client-IP
|
||||||
|
}
|
||||||
|
|
||||||
|
{$EKKLESIA_HOSTNAME} {
|
||||||
|
# Everything the API serves: the Dashboard at /, the API, display pages and their service
|
||||||
|
# worker, and the Dashboard and display host scripts. Any other path is refused here.
|
||||||
|
@ekklesia path / /api/* /display/* /assets/*
|
||||||
|
|
||||||
|
# Module realtime is Server-Sent Events: flush every event immediately.
|
||||||
|
@realtime path /api/v1/module-realtime/*
|
||||||
|
|
||||||
|
handle @realtime {
|
||||||
|
reverse_proxy api:3000 {
|
||||||
|
import ekklesia_forwarding
|
||||||
|
flush_interval -1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handle @ekklesia {
|
||||||
|
# Request bodies stream through unbuffered and unlimited here; the API enforces the platform's
|
||||||
|
# ceilings itself (8 KiB for authentication, up to 32 MiB for a streamed module route).
|
||||||
|
reverse_proxy api:3000 {
|
||||||
|
import ekklesia_forwarding
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handle {
|
||||||
|
respond 404
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Browsers fetch signed object URLs here (S3_DELIVERY_ENDPOINT, ADR 0031).
|
||||||
|
{$EKKLESIA_STORAGE_HOSTNAME} {
|
||||||
|
# Delivery-layer CORS belongs to the deployment, not to the object store: this block decides it,
|
||||||
|
# so it stays the same with any S3 backend behind it. Only the origins whose pages read objects
|
||||||
|
# with fetch() get CORS: the Ekklesia origin, which serves the Dashboard and the displays. An <img> needs none. Every object
|
||||||
|
# URL is still a signed, expiring capability; CORS only decides which pages may read the bytes.
|
||||||
|
@cors_origin header Origin https://{$EKKLESIA_HOSTNAME}
|
||||||
|
|
||||||
|
# A preflight is answered here and never reaches storage. Only GET and HEAD are ever allowed, and
|
||||||
|
# no request header beyond the CORS-safelisted ones.
|
||||||
|
@preflight method OPTIONS
|
||||||
|
handle @preflight {
|
||||||
|
header @cors_origin {
|
||||||
|
Access-Control-Allow-Origin {http.request.header.Origin}
|
||||||
|
Access-Control-Allow-Methods "GET, HEAD"
|
||||||
|
Access-Control-Max-Age 600
|
||||||
|
}
|
||||||
|
header Vary Origin
|
||||||
|
respond 204
|
||||||
|
}
|
||||||
|
|
||||||
|
handle {
|
||||||
|
# Set when the response is written, after the upstream's own CORS headers were removed below.
|
||||||
|
header @cors_origin >Access-Control-Allow-Origin {http.request.header.Origin}
|
||||||
|
header >Vary Origin
|
||||||
|
|
||||||
|
reverse_proxy https://object-storage:8443 {
|
||||||
|
# An S3 signature covers the Host header: pass the original through unchanged, or every
|
||||||
|
# signed URL fails verification.
|
||||||
|
header_up Host {host}
|
||||||
|
header_up -CF-Connecting-IP
|
||||||
|
header_up -X-Real-IP
|
||||||
|
header_up -Forwarded
|
||||||
|
header_up -True-Client-IP
|
||||||
|
# The object store's own CORS answer (SeaweedFS reflects any origin, with every method)
|
||||||
|
# is not the deployment's policy.
|
||||||
|
header_down -Access-Control-Allow-Origin
|
||||||
|
header_down -Access-Control-Allow-Methods
|
||||||
|
header_down -Access-Control-Allow-Headers
|
||||||
|
header_down -Access-Control-Expose-Headers
|
||||||
|
header_down -Access-Control-Allow-Credentials
|
||||||
|
header_down -Access-Control-Max-Age
|
||||||
|
header_down -Vary
|
||||||
|
transport http {
|
||||||
|
# object-storage presents a certificate from the installation-private CA.
|
||||||
|
tls_trust_pool file /etc/caddy/ekklesia-ca.crt
|
||||||
|
tls_server_name object-storage
|
||||||
|
# HTTP/1.1 only. Over HTTP/2, SeaweedFS answers a conditional request with a 304 that
|
||||||
|
# declares a Content-Length it never sends; Caddy then aborts the browser's stream,
|
||||||
|
# which a browser reports as ERR_HTTP2_PROTOCOL_ERROR on every revalidation. Its
|
||||||
|
# HTTP/1.1 304 is clean. Browsers still reach this proxy over HTTP/2 and HTTP/3.
|
||||||
|
versions 1.1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
# Ekklesia reference proxy configuration for the split topology (ADR 0045): the Dashboard and the API
|
||||||
|
# on separate hostnames of one site, for example dash.example.org and api.example.org. For the
|
||||||
|
# default same-origin topology use Caddyfile.example instead. Copy this file to deploy/Caddyfile.
|
||||||
|
#
|
||||||
|
# Hostnames, the ACME contact, and the tunnel connector's address come from the proxy service's
|
||||||
|
# environment (deploy/.env): EKKLESIA_DASHBOARD_HOSTNAME is the Dashboard's, EKKLESIA_HOSTNAME the
|
||||||
|
# API's (which also serves the display host), EKKLESIA_STORAGE_HOSTNAME the storage origin's. Only
|
||||||
|
# the two site blocks below differ from Caddyfile.example.
|
||||||
|
{
|
||||||
|
# ACME account contact. Certificates use the HTTP-01 challenge on port 80 by default, which needs
|
||||||
|
# all three hostnames to resolve publicly to this host with 80 and 443 reachable.
|
||||||
|
email {$EKKLESIA_ACME_EMAIL}
|
||||||
|
|
||||||
|
# DNS-01, for an installation that keeps Ekklesia off the public Internet, serves only a LAN
|
||||||
|
# (split-horizon DNS), or is reachable only through Cloudflare Tunnel. Cloudflare terminates TLS at
|
||||||
|
# its edge, so HTTP-01 cannot reach this proxy through a tunnel.
|
||||||
|
#
|
||||||
|
# DNS-01 needs a DNS provider module that the stock Caddy image does not contain. Build an image
|
||||||
|
# with it (for example `xcaddy build --with github.com/caddy-dns/cloudflare`), pin that image by
|
||||||
|
# digest in your own overlay, give it CLOUDFLARE_API_TOKEN through a root-owned env file, then
|
||||||
|
# enable:
|
||||||
|
#
|
||||||
|
# acme_dns cloudflare {env.CLOUDFLARE_API_TOKEN}
|
||||||
|
|
||||||
|
servers {
|
||||||
|
# A client-address header is honored only from the tunnel connector's fixed address
|
||||||
|
# (compose.tunnel.yaml). From every other peer, the client address is the TCP peer itself and
|
||||||
|
# any such header is ignored. Without a tunnel, nothing has this address.
|
||||||
|
trusted_proxies static {$EKKLESIA_TUNNEL_ADDRESS}/32
|
||||||
|
client_ip_headers CF-Connecting-IP
|
||||||
|
trusted_proxies_strict
|
||||||
|
}
|
||||||
|
|
||||||
|
# Access logs are off unless enabled. If you enable them, keep request headers out: they carry
|
||||||
|
# cookies and forwarding chains (docs/development/security.md, launch checklist item 7).
|
||||||
|
}
|
||||||
|
|
||||||
|
# What every request to the API carries upstream: exactly one forwarding header, overwritten with
|
||||||
|
# the client address Caddy resolved, and none of the headers a caller could use to choose its own.
|
||||||
|
(ekklesia_forwarding) {
|
||||||
|
header_up X-Forwarded-For {client_ip}
|
||||||
|
header_up -CF-Connecting-IP
|
||||||
|
header_up -X-Real-IP
|
||||||
|
header_up -Forwarded
|
||||||
|
header_up -True-Client-IP
|
||||||
|
}
|
||||||
|
|
||||||
|
# The Dashboard: its page and its one script, nothing else. Emailed verification and reset links land
|
||||||
|
# on `/` with a query. `/api/*` is refused here on purpose, so the session cookie has exactly one
|
||||||
|
# home, the API hostname, and the Dashboard can never fall back to a same-origin API.
|
||||||
|
{$EKKLESIA_DASHBOARD_HOSTNAME} {
|
||||||
|
@dashboard path / /assets/ekklesia-dashboard/*
|
||||||
|
|
||||||
|
handle @dashboard {
|
||||||
|
reverse_proxy api:3000 {
|
||||||
|
import ekklesia_forwarding
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handle {
|
||||||
|
respond 404
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# The API, and the display host on the API's own origin: a screen's pages, service worker, device
|
||||||
|
# cookies, and requests are all same-origin (ADR 0045). Nothing is served at `/`.
|
||||||
|
{$EKKLESIA_HOSTNAME} {
|
||||||
|
@api path /api/* /display/* /assets/ekklesia-display/*
|
||||||
|
|
||||||
|
# Module realtime is Server-Sent Events: flush every event immediately.
|
||||||
|
@realtime path /api/v1/module-realtime/*
|
||||||
|
|
||||||
|
handle @realtime {
|
||||||
|
reverse_proxy api:3000 {
|
||||||
|
import ekklesia_forwarding
|
||||||
|
flush_interval -1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handle @api {
|
||||||
|
# Request bodies stream through unbuffered and unlimited here; the API enforces the platform's
|
||||||
|
# ceilings itself (8 KiB for authentication, up to 32 MiB for a streamed module route).
|
||||||
|
reverse_proxy api:3000 {
|
||||||
|
import ekklesia_forwarding
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handle {
|
||||||
|
respond 404
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Browsers fetch signed object URLs here (S3_DELIVERY_ENDPOINT, ADR 0031).
|
||||||
|
{$EKKLESIA_STORAGE_HOSTNAME} {
|
||||||
|
# Delivery-layer CORS belongs to the deployment, not to the object store: this block decides it,
|
||||||
|
# so it stays the same with any S3 backend behind it. Only the origins whose pages read objects
|
||||||
|
# with fetch() get CORS: the API origin, where displays run, and the Dashboard origin,
|
||||||
|
# where a module's preview may cache images too. An <img> needs none. Every object
|
||||||
|
# URL is still a signed, expiring capability; CORS only decides which pages may read the bytes.
|
||||||
|
@cors_origin {
|
||||||
|
header Origin https://{$EKKLESIA_HOSTNAME}
|
||||||
|
header Origin https://{$EKKLESIA_DASHBOARD_HOSTNAME}
|
||||||
|
}
|
||||||
|
|
||||||
|
# A preflight is answered here and never reaches storage. Only GET and HEAD are ever allowed, and
|
||||||
|
# no request header beyond the CORS-safelisted ones.
|
||||||
|
@preflight method OPTIONS
|
||||||
|
handle @preflight {
|
||||||
|
header @cors_origin {
|
||||||
|
Access-Control-Allow-Origin {http.request.header.Origin}
|
||||||
|
Access-Control-Allow-Methods "GET, HEAD"
|
||||||
|
Access-Control-Max-Age 600
|
||||||
|
}
|
||||||
|
header Vary Origin
|
||||||
|
respond 204
|
||||||
|
}
|
||||||
|
|
||||||
|
handle {
|
||||||
|
# Set when the response is written, after the upstream's own CORS headers were removed below.
|
||||||
|
header @cors_origin >Access-Control-Allow-Origin {http.request.header.Origin}
|
||||||
|
header >Vary Origin
|
||||||
|
|
||||||
|
reverse_proxy https://object-storage:8443 {
|
||||||
|
# An S3 signature covers the Host header: pass the original through unchanged, or every
|
||||||
|
# signed URL fails verification.
|
||||||
|
header_up Host {host}
|
||||||
|
header_up -CF-Connecting-IP
|
||||||
|
header_up -X-Real-IP
|
||||||
|
header_up -Forwarded
|
||||||
|
header_up -True-Client-IP
|
||||||
|
# The object store's own CORS answer (SeaweedFS reflects any origin, with every method)
|
||||||
|
# is not the deployment's policy.
|
||||||
|
header_down -Access-Control-Allow-Origin
|
||||||
|
header_down -Access-Control-Allow-Methods
|
||||||
|
header_down -Access-Control-Allow-Headers
|
||||||
|
header_down -Access-Control-Expose-Headers
|
||||||
|
header_down -Access-Control-Allow-Credentials
|
||||||
|
header_down -Access-Control-Max-Age
|
||||||
|
header_down -Vary
|
||||||
|
transport http {
|
||||||
|
# object-storage presents a certificate from the installation-private CA.
|
||||||
|
tls_trust_pool file /etc/caddy/ekklesia-ca.crt
|
||||||
|
tls_server_name object-storage
|
||||||
|
# HTTP/1.1 only. Over HTTP/2, SeaweedFS answers a conditional request with a 304 that
|
||||||
|
# declares a Content-Length it never sends; Caddy then aborts the browser's stream,
|
||||||
|
# which a browser reports as ERR_HTTP2_PROTOCOL_ERROR on every revalidation. Its
|
||||||
|
# HTTP/1.1 304 is clean. Browsers still reach this proxy over HTTP/2 and HTTP/3.
|
||||||
|
versions 1.1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
# Ekklesia's one generic platform image (ADR 0044, ADR 0048): the `api` service and every operator-run
|
||||||
|
# CLI, from one build, for every installation. See deploy/README.md for how to build and run it.
|
||||||
|
#
|
||||||
|
# The build context is this repository. The image contains **zero feature modules**: installed
|
||||||
|
# modules live in installation-owned storage, mounted read-only at /var/lib/ekklesia/modules
|
||||||
|
# (EKKLESIA_MODULES_DIR) and discovered when a process starts. `.dockerignore` keeps a checkout's
|
||||||
|
# `modules/` out of the context, so a build is the same whether or not one is present.
|
||||||
|
#
|
||||||
|
# No `# syntax=` directive, deliberately: it would make BuildKit pull an unpinned frontend image.
|
||||||
|
# The built-in frontend supports everything below, including cache mounts.
|
||||||
|
|
||||||
|
# Node.js 24.18.0 (matching `.node-version`) on Debian 13 slim, pinned by its multi-architecture
|
||||||
|
# index digest. glibc rather than musl, because module dependencies such as sharp ship glibc
|
||||||
|
# prebuilt binaries. Every stage starts from this exact image.
|
||||||
|
FROM node:24.18.0-trixie-slim@sha256:ae91dcc111a68c9d2d81ff2a17bda61be126426176fde6fe7d08ab13b7f50573 AS base
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------------------------------
|
||||||
|
# toolchain: pnpm through Corepack, at exactly the root `packageManager` version.
|
||||||
|
# ---------------------------------------------------------------------------------------------------
|
||||||
|
FROM base AS toolchain
|
||||||
|
ENV COREPACK_ENABLE_DOWNLOAD_PROMPT=0 \
|
||||||
|
PNPM_HOME=/pnpm \
|
||||||
|
TURBO_TELEMETRY_DISABLED=1 \
|
||||||
|
DO_NOT_TRACK=1
|
||||||
|
WORKDIR /opt/ekklesia
|
||||||
|
COPY package.json ./
|
||||||
|
# Corepack reads `packageManager` (pnpm@<exact version>) from the root manifest, so the version is
|
||||||
|
# declared once, in the repository, and never restated here.
|
||||||
|
RUN corepack enable && corepack install
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------------------------------
|
||||||
|
# manifests: the lockfile, workspace configuration, and the package.json of every platform package
|
||||||
|
# the runtime needs, at their repository paths. Dependency installation keys on these alone, so a
|
||||||
|
# source-only change reuses the installed layers. The module template is a workspace package but not
|
||||||
|
# part of the platform, so neither its manifest nor its dependencies reach the runtime.
|
||||||
|
# ---------------------------------------------------------------------------------------------------
|
||||||
|
FROM base AS manifests
|
||||||
|
WORKDIR /context
|
||||||
|
COPY . .
|
||||||
|
RUN set -eu; \
|
||||||
|
mkdir /manifests; \
|
||||||
|
cp package.json pnpm-lock.yaml pnpm-workspace.yaml .npmrc /manifests/; \
|
||||||
|
for manifest in apps/*/package.json core/package.json packages/*/package.json \
|
||||||
|
surfaces/*/package.json; do \
|
||||||
|
if [ -f "$manifest" ]; then install -D -m 0644 "$manifest" "/manifests/$manifest"; fi; \
|
||||||
|
done
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------------------------------
|
||||||
|
# build: full install, validation, build, then the exact runtime file set.
|
||||||
|
# ---------------------------------------------------------------------------------------------------
|
||||||
|
FROM toolchain AS build
|
||||||
|
# Sources first, then one install: workspace packages publish `bin` commands from source files (the
|
||||||
|
# module UI builder's, which every module build invokes), and pnpm links a bin only when its target
|
||||||
|
# already exists. The store cache mount keeps a rebuild off the network all the same.
|
||||||
|
COPY . .
|
||||||
|
# A second line of defence behind `.dockerignore`: a context that still carries installed modules is
|
||||||
|
# refused rather than built into a platform image.
|
||||||
|
RUN if [ -e modules ]; then \
|
||||||
|
echo "The build context contains modules/. Installed modules never enter the platform image (ADR 0048)." >&2; \
|
||||||
|
exit 1; \
|
||||||
|
fi
|
||||||
|
RUN --mount=type=cache,id=ekklesia-pnpm-store,target=/pnpm/store \
|
||||||
|
pnpm install --frozen-lockfile --store-dir /pnpm/store
|
||||||
|
# The same gates `pnpm verify` runs for boundaries, modules, and migrations. With no modules in the
|
||||||
|
# context, module validation covers the empty set and migration validation covers Core's own. Each
|
||||||
|
# validate script builds first; the final build is a Turbo cache hit that keeps the intent explicit.
|
||||||
|
RUN pnpm boundaries && pnpm modules:validate && pnpm migrations:validate && pnpm build
|
||||||
|
# What the runtime resolves at startup, at the paths it resolves them from:
|
||||||
|
# - every platform package's `dist/` (every package exports only from `dist/`), which includes
|
||||||
|
# the Dashboard and display host assets under `surfaces/shell/dist/browser/`;
|
||||||
|
# - Core's migrations, `core/drizzle/` with its `meta/` journal.
|
||||||
|
# Never a module and never the module template. `node_modules` comes from the production-only
|
||||||
|
# install below, never from this stage.
|
||||||
|
RUN set -eu; \
|
||||||
|
mkdir -p /runtime/core; \
|
||||||
|
for package in apps/* core packages/* surfaces/*; do \
|
||||||
|
if [ -d "$package/dist" ]; then \
|
||||||
|
mkdir -p "/runtime/$package"; \
|
||||||
|
cp -a "$package/dist" "/runtime/$package/dist"; \
|
||||||
|
fi; \
|
||||||
|
done; \
|
||||||
|
cp -a core/drizzle /runtime/core/drizzle
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------------------------------
|
||||||
|
# prod-deps: the same lockfile, production dependencies only, for the platform packages alone. No
|
||||||
|
# compiler, bundler, or test runner reaches the runtime image, and neither does anything only the
|
||||||
|
# module template depends on.
|
||||||
|
# ---------------------------------------------------------------------------------------------------
|
||||||
|
FROM toolchain AS prod-deps
|
||||||
|
COPY --from=manifests /manifests/ ./
|
||||||
|
RUN --mount=type=cache,id=ekklesia-pnpm-store,target=/pnpm/store \
|
||||||
|
pnpm install --frozen-lockfile --prod --store-dir /pnpm/store
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------------------------------
|
||||||
|
# runtime
|
||||||
|
# ---------------------------------------------------------------------------------------------------
|
||||||
|
FROM base AS runtime
|
||||||
|
|
||||||
|
ARG VERSION=""
|
||||||
|
ARG REVISION=""
|
||||||
|
ARG SOURCE=""
|
||||||
|
LABEL org.opencontainers.image.title="Ekklesia" \
|
||||||
|
org.opencontainers.image.description="Ekklesia API, Dashboard, display host, and operator CLIs" \
|
||||||
|
org.opencontainers.image.version="${VERSION}" \
|
||||||
|
org.opencontainers.image.revision="${REVISION}" \
|
||||||
|
org.opencontainers.image.source="${SOURCE}" \
|
||||||
|
org.opencontainers.image.base.name="docker.io/library/node:24.18.0-trixie-slim" \
|
||||||
|
org.opencontainers.image.base.digest="sha256:ae91dcc111a68c9d2d81ff2a17bda61be126426176fde6fe7d08ab13b7f50573"
|
||||||
|
|
||||||
|
# Only the container's own listen address differs from the platform default: outside a container
|
||||||
|
# the API still binds 127.0.0.1. `production` is stated rather than relied on as the default.
|
||||||
|
ENV NODE_ENV=production \
|
||||||
|
EKKLESIA_API_HOST=0.0.0.0 \
|
||||||
|
EKKLESIA_API_PORT=3000 \
|
||||||
|
EKKLESIA_MODULES_DIR=/var/lib/ekklesia/modules
|
||||||
|
|
||||||
|
# The repository layout, preserved: the API finds the repository root three directories above
|
||||||
|
# `apps/api/dist/`, and every CLI uses its working directory as the root. Installed modules are not
|
||||||
|
# under it: they are read from EKKLESIA_MODULES_DIR.
|
||||||
|
WORKDIR /opt/ekklesia
|
||||||
|
COPY --from=prod-deps /opt/ekklesia/ ./
|
||||||
|
COPY --from=build /runtime/ ./
|
||||||
|
# The mount point for installation-owned module storage (ADR 0048), empty in the image. An image run
|
||||||
|
# with nothing mounted there has zero modules; the reference deployment mounts host storage
|
||||||
|
# read-only over it.
|
||||||
|
RUN install -d -o root -g root -m 0755 /var/lib/ekklesia/modules
|
||||||
|
|
||||||
|
# Files stay root-owned: the unprivileged runtime user can read the installation, never modify it.
|
||||||
|
USER node
|
||||||
|
EXPOSE 3000
|
||||||
|
|
||||||
|
# Readiness, not liveness: an instance whose database is unreachable or that is shutting down is
|
||||||
|
# unhealthy. Node's own fetch, so the image needs no curl.
|
||||||
|
HEALTHCHECK --interval=30s --timeout=5s --start-period=30s --retries=3 \
|
||||||
|
CMD ["node", "-e", "fetch('http://127.0.0.1:' + (process.env.EKKLESIA_API_PORT || '3000') + '/api/v1/health/ready', { signal: AbortSignal.timeout(4000) }).then((response) => process.exit(response.status === 200 ? 0 : 1), () => process.exit(1))"]
|
||||||
|
|
||||||
|
# `pnpm api:start` without its build step and without `--env-file`: configuration comes only from
|
||||||
|
# the container environment. The same image runs every CLI: `node core/dist/cli/<command>.js`.
|
||||||
|
CMD ["node", "apps/api/dist/server.js"]
|
||||||
@@ -0,0 +1,629 @@
|
|||||||
|
# Deployment
|
||||||
|
|
||||||
|
This directory holds Ekklesia's reference self-hosted deployment
|
||||||
|
([ADR 0044](../docs/architecture/decisions/0044-self-hosted-reference-deployment.md)): one Linux
|
||||||
|
host, one Docker Compose project, and the production container image it runs. Nothing here is
|
||||||
|
specific to one organization.
|
||||||
|
|
||||||
|
| File | What it is |
|
||||||
|
| --- | --- |
|
||||||
|
| [`compose.yaml`](compose.yaml) | The base: `api`, `postgres`, `object-storage`, and the operator-only `migrate`. Publishes no port. |
|
||||||
|
| [`compose.proxy.yaml`](compose.proxy.yaml) | Overlay: Caddy as the TLS proxy, the only service with published ports. |
|
||||||
|
| [`compose.tunnel.yaml`](compose.tunnel.yaml) | Overlay on top of the proxy: a Cloudflare Tunnel connector. |
|
||||||
|
| [`Caddyfile.example`](Caddyfile.example) | The proxy's configuration for the default same-origin topology; copy to `Caddyfile`. |
|
||||||
|
| [`Caddyfile.split.example`](Caddyfile.split.example) | The proxy's configuration when the Dashboard and the API have separate hostnames ([Split Dashboard and API origins](#split-dashboard-and-api-origins)); copy to `Caddyfile` instead. |
|
||||||
|
| [`postgres/pg_hba.conf`](postgres/pg_hba.conf) | PostgreSQL client authentication: TLS only, SCRAM passwords. |
|
||||||
|
| [`env/`](env/) | Commented examples of every env file. No values. |
|
||||||
|
| [`scripts/init-tls.sh`](scripts/init-tls.sh) | Creates the installation-private CA and the `postgres` and `object-storage` server certificates. |
|
||||||
|
| [`scripts/gen-secrets.sh`](scripts/gen-secrets.sh) | Prints freshly generated secrets to paste into the env files. Writes nothing. |
|
||||||
|
| [`Dockerfile`](Dockerfile) | The one production image, for the API and every operator command ([The production image](#the-production-image)). |
|
||||||
|
|
||||||
|
## The reference deployment
|
||||||
|
|
||||||
|
### Shape
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cd deploy
|
||||||
|
# Base and proxy: the reference.
|
||||||
|
docker compose -f compose.yaml -f compose.proxy.yaml up -d
|
||||||
|
# Base, proxy, and Cloudflare Tunnel.
|
||||||
|
docker compose -f compose.yaml -f compose.proxy.yaml -f compose.tunnel.yaml up -d
|
||||||
|
# Base only, behind a reverse proxy the installation already runs.
|
||||||
|
docker compose -f compose.yaml -f compose.myproxy.yaml up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
Pass the same `-f` files to every later `up`, `down`, `ps`, and `logs`. `migrate` commands need only
|
||||||
|
the base: `docker compose run --rm migrate …` from this directory uses `compose.yaml` by default.
|
||||||
|
|
||||||
|
| Network | Kind | Members |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `data` | internal | `postgres`, `api`, `migrate` |
|
||||||
|
| `storage` | internal | `object-storage`, `api`, `migrate` |
|
||||||
|
| `egress` | bridge | `api`, `migrate` (outbound only, for example an SMTP relay) |
|
||||||
|
| `edge` | bridge, fixed subnet (proxy overlay) | `proxy` (fixed address), `api`, `object-storage`, `tunnel` (fixed address) |
|
||||||
|
|
||||||
|
PostgreSQL is only ever on `data`, so no proxy and no tunnel can reach it. `internal` networks have
|
||||||
|
no route off the host. Object storage joins `edge` only when the proxy overlay attaches it.
|
||||||
|
|
||||||
|
TLS is everywhere, without any exception in Core:
|
||||||
|
|
||||||
|
- **Browsers to the proxy:** publicly trusted ACME certificates.
|
||||||
|
- **API to PostgreSQL:** `verify-full` against the installation-private CA, passed as PEM text in
|
||||||
|
`EKKLESIA_DATABASE_CA`. PostgreSQL accepts only TLS 1.3 network connections.
|
||||||
|
- **API (and proxy) to object storage:** HTTPS on port 8443 with a certificate from the same CA.
|
||||||
|
Production storage must be https (ADR 0030). Core's S3 client uses Node's default trust store, so
|
||||||
|
Compose adds the CA with `NODE_EXTRA_CA_CERTS`.
|
||||||
|
|
||||||
|
### Where configuration and secrets live
|
||||||
|
|
||||||
|
| File (on the host) | Holds | Read by |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `deploy/.env` (from [`env/compose.env.example`](env/compose.env.example)) | Non-secret settings: the image, hostnames, paths, the edge subnet | Compose itself |
|
||||||
|
| `/etc/ekklesia/ekklesia.env` | Everything the API needs, including `DATABASE_URL` and the S3 keys | `api`, `migrate` |
|
||||||
|
| `/etc/ekklesia/postgres.env` | `POSTGRES_USER`, `POSTGRES_PASSWORD`, `POSTGRES_DB` | `postgres` |
|
||||||
|
| `/etc/ekklesia/object-storage.env` | The S3 key pair | `object-storage` |
|
||||||
|
| `/etc/ekklesia/tunnel.env` | `TUNNEL_TOKEN` | `tunnel` |
|
||||||
|
| `/etc/ekklesia/tls/` | The CA and server certificates and keys | mounted file by file |
|
||||||
|
| `/var/lib/ekklesia/modules/` | Installed modules ([Installed modules](#installed-modules)); not a secret | `api`, `migrate`, read-only |
|
||||||
|
|
||||||
|
The secret files are owned by root with mode 600, one per service, so no container receives another
|
||||||
|
service's credentials. None is committed, and `deploy/.env` is ignored by Git. `EKKLESIA_ENV_DIR`
|
||||||
|
and `EKKLESIA_TLS_DIR` in `deploy/.env` move the directories.
|
||||||
|
|
||||||
|
### First boot
|
||||||
|
|
||||||
|
1. **Get the image.** Build it ([Building](#building)) or pull it, and set `EKKLESIA_IMAGE` in
|
||||||
|
`deploy/.env`.
|
||||||
|
2. **Create the TLS material**, as root:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
sudo sh deploy/scripts/init-tls.sh /etc/ekklesia/tls
|
||||||
|
```
|
||||||
|
|
||||||
|
It creates the CA once and a certificate for each of `postgres` and `object-storage`, gives each
|
||||||
|
key to its server's uid (PostgreSQL 70, SeaweedFS 1000) with mode 600, and prints the exact
|
||||||
|
`EKKLESIA_DATABASE_CA="…"` line. It never overwrites an existing key or certificate and never
|
||||||
|
prints a private key. `ca.key` is needed only to issue certificates: keep a copy offline.
|
||||||
|
3. **Generate secrets** with `sh deploy/scripts/gen-secrets.sh`, and paste each line into the file
|
||||||
|
named above it.
|
||||||
|
4. **Write the env files** from [`env/`](env/), then install each one owned by root with mode 600:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
sudo install -d -m 755 /etc/ekklesia
|
||||||
|
sudo install -o root -g root -m 600 ekklesia.env /etc/ekklesia/ekklesia.env
|
||||||
|
```
|
||||||
|
|
||||||
|
`EKKLESIA_IDENTITY_BASE_URL` and `EKKLESIA_IDENTITY_TRUSTED_ORIGINS` are `https://` and your
|
||||||
|
Ekklesia hostname. `S3_DELIVERY_ENDPOINT` is `https://` and your storage hostname.
|
||||||
|
5. **Create module storage**, empty, even if you install no module yet:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
sudo install -d -o root -g root -m 755 /var/lib/ekklesia/modules
|
||||||
|
```
|
||||||
|
|
||||||
|
`api` and `migrate` refuse to start without it: Compose never creates it for you, so a mistyped
|
||||||
|
path cannot quietly become an empty directory. `EKKLESIA_MODULES_HOST_DIR` in `deploy/.env`
|
||||||
|
moves it.
|
||||||
|
6. **Configure the proxy:** copy `Caddyfile.example` to `Caddyfile`, and set `EKKLESIA_HOSTNAME`,
|
||||||
|
`EKKLESIA_STORAGE_HOSTNAME`, and `EKKLESIA_ACME_EMAIL` in `deploy/.env`. Both hostnames must
|
||||||
|
resolve to this host with ports 80 and 443 reachable for HTTP-01, or see [DNS-01](#dns-01).
|
||||||
|
7. **Start the data services, then migrate explicitly:**
|
||||||
|
|
||||||
|
```sh
|
||||||
|
docker compose -f compose.yaml -f compose.proxy.yaml up -d --wait postgres object-storage
|
||||||
|
docker compose run --rm migrate # plan: read-only
|
||||||
|
docker compose run --rm migrate node core/dist/cli/apply-migrations.js
|
||||||
|
```
|
||||||
|
|
||||||
|
Nothing ever applies migrations automatically: the API never does, and `migrate` runs only when
|
||||||
|
you run it.
|
||||||
|
8. **Start everything:** `docker compose -f compose.yaml -f compose.proxy.yaml up -d --wait`.
|
||||||
|
9. **Check it:** `curl https://<hostname>/api/v1/health/ready` answers `{"status":"ready"}`, and
|
||||||
|
`docker compose run --rm migrate node core/dist/cli/doctor.js` reports `READY` (with warnings
|
||||||
|
for anything optional you left unconfigured, such as email).
|
||||||
|
|
||||||
|
### The initial owner
|
||||||
|
|
||||||
|
```sh
|
||||||
|
docker compose run --rm -it migrate node core/dist/cli/create-initial-owner.js --email owner@example.org
|
||||||
|
```
|
||||||
|
|
||||||
|
It prompts for the password with hidden input and never accepts it on the command line. The owner
|
||||||
|
is created already verified, so they can sign in before any email relay exists
|
||||||
|
([ADR 0035](../docs/architecture/decisions/0035-initial-platform-owner-bootstrap.md)). To supply the
|
||||||
|
password from a secret manager instead, pipe it on standard input and use `-T` instead of `-it`.
|
||||||
|
|
||||||
|
### Installed modules
|
||||||
|
|
||||||
|
The image contains no modules
|
||||||
|
([ADR 0048](../docs/architecture/decisions/0048-generic-image-and-installed-module-storage.md)).
|
||||||
|
Installed modules live on the host, in module storage: `/var/lib/ekklesia/modules`, or
|
||||||
|
`EKKLESIA_MODULES_HOST_DIR`. Compose mounts it **read-only** at `/var/lib/ekklesia/modules` in `api`
|
||||||
|
and `migrate`, which is where `EKKLESIA_MODULES_DIR` points. Ekklesia never writes there.
|
||||||
|
Installing, updating, or removing a module never needs a new image, only a restart.
|
||||||
|
|
||||||
|
**What belongs there.** One directory per module, each extracted from that module's **Ekklesia
|
||||||
|
Module Package**
|
||||||
|
([ADR 0049](../docs/architecture/decisions/0049-module-package-v1.md)): one archive,
|
||||||
|
`<id>-<version>-<target>.tar.gz`, and the SHA-256 published beside it. A package holds the module's
|
||||||
|
generated `package.json`, its manifest, `ekklesia.package.json`, bundled backend code, built browser
|
||||||
|
artifacts, migrations, and `THIRD_PARTY_NOTICES`
|
||||||
|
([Module development](../docs/module-development/README.md#an-installed-module-is-self-contained)).
|
||||||
|
Copying a module's `dist/` alone is not enough. Files are owned by root and readable by everyone
|
||||||
|
(directories 755, files 644), so the API's unprivileged user can read them and never change them.
|
||||||
|
The digest proves the archive is the one you were given; it does not prove who published it.
|
||||||
|
|
||||||
|
**Nothing else belongs there.** Every non-hidden directory must be a valid module, or the API does
|
||||||
|
not start. That includes `lost+found`, so if module storage is a file system of its own, mount it
|
||||||
|
one level up and point `EKKLESIA_MODULES_HOST_DIR` at a directory inside it. Hidden entries are
|
||||||
|
ignored, which is what makes staging possible.
|
||||||
|
|
||||||
|
**Changes take effect only at a restart.** A running API never notices module storage changing, and
|
||||||
|
never loads or unloads a module while it runs. Change module storage only while the API is stopped:
|
||||||
|
it reads browser artifacts from disk as it serves them, so files changed under a running API would
|
||||||
|
serve one version's browser code beside another's server code.
|
||||||
|
|
||||||
|
To **install or update** a module, with `<id>` its module id and the package and its `.sha256` in
|
||||||
|
the current directory, first **verify** it with the image's own verifier. It checks the archive's
|
||||||
|
digest, its safety (no links, special files, or escaping paths), every file's digest, the package's
|
||||||
|
identity, target, and Node.js range against this platform, runs the platform's module and migration
|
||||||
|
validation, and loads the backend once in a short-lived process with no environment. Only then does
|
||||||
|
it write the verified module directory, to a new directory outside module storage:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
mkdir -m 777 staging # writable by the image's unprivileged user; nothing else is
|
||||||
|
docker run --rm --network none --read-only --tmpfs /tmp \
|
||||||
|
-v "$PWD:/package:ro" -v "$PWD/staging:/staging" \
|
||||||
|
--entrypoint node "$EKKLESIA_IMAGE" core/dist/cli/verify-module-package.js \
|
||||||
|
/package/<id>-<version>-any.tar.gz --sha256-file /package/<id>-<version>-any.tar.gz.sha256 \
|
||||||
|
--extract-to /staging/<id>
|
||||||
|
```
|
||||||
|
|
||||||
|
Then install the verified directory:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
docker compose stop api
|
||||||
|
# Stage under a hidden name, which discovery ignores, then rename it into place in one step.
|
||||||
|
sudo cp -R staging/<id> /var/lib/ekklesia/modules/.incoming-<id>
|
||||||
|
sudo chown -R root:root /var/lib/ekklesia/modules/.incoming-<id>
|
||||||
|
sudo chmod -R a+rX,go-w /var/lib/ekklesia/modules/.incoming-<id>
|
||||||
|
sudo mv /var/lib/ekklesia/modules/<id> /var/lib/ekklesia/modules/.previous-<id> # update only
|
||||||
|
sudo mv /var/lib/ekklesia/modules/.incoming-<id> /var/lib/ekklesia/modules/<id>
|
||||||
|
docker compose run --rm migrate node core/dist/cli/validate-modules.js
|
||||||
|
docker compose run --rm migrate # plan: review it
|
||||||
|
docker compose run --rm migrate node core/dist/cli/apply-migrations.js # only when the plan is right
|
||||||
|
docker compose up -d --wait api
|
||||||
|
```
|
||||||
|
|
||||||
|
Back up before an update that brings migrations: migrations are forward-only. A newly installed
|
||||||
|
module is enabled unless an administrator disabled it before; set its configuration and grants as
|
||||||
|
its documentation says. Keep `.previous-<id>` until the update has proven itself, then delete it.
|
||||||
|
|
||||||
|
To **remove** a module, stop the API, delete (or move away) its directory, and start the API again.
|
||||||
|
Remove first any module that *requires* it, or the API refuses to start. Removing a module's code
|
||||||
|
deletes nothing else. Its tables and rows, its buckets and objects, its enablement, configuration,
|
||||||
|
Dashboard placement, and grants all stay. Its applied migration history stays too, and the
|
||||||
|
migration plan and Doctor report it as `retained` history for a module that is not installed. That
|
||||||
|
is information, not a problem, and it blocks no other migration. To stop a module without removing
|
||||||
|
it, disable it instead (`set-module-enabled.js --disable`, [Operator commands](#operator-commands)).
|
||||||
|
There is no purge: deleting a module's data is a separate operation that does not exist yet.
|
||||||
|
|
||||||
|
To **restore** a removed module, verify and install its package again as above, at the same version
|
||||||
|
or a newer one. It
|
||||||
|
finds its data, its settings, and its history where it left them. Its history is strict again: a
|
||||||
|
package that no longer contains a migration that was applied, or contains one with different
|
||||||
|
contents, is refused by the migration plan, so an older version cannot be installed over newer data.
|
||||||
|
|
||||||
|
Module storage is installation state: back it up with the database and the buckets.
|
||||||
|
|
||||||
|
### Upgrading
|
||||||
|
|
||||||
|
1. **Pull or build the new image**, and change `EKKLESIA_IMAGE` in `deploy/.env`. Keep the old tag.
|
||||||
|
Installed modules stay where they are, in module storage.
|
||||||
|
2. **Back up** ([Backups](#backups)).
|
||||||
|
3. **Check the installed modules against the new platform:**
|
||||||
|
`docker compose run --rm migrate node core/dist/cli/validate-modules.js`. A module whose
|
||||||
|
`compatibility.platform` range excludes the new version fails here, and would stop the API from
|
||||||
|
starting: update it first, or stay on the old image.
|
||||||
|
4. **Plan, review, then apply:** `docker compose run --rm migrate` with the new image, then
|
||||||
|
`docker compose run --rm migrate node core/dist/cli/apply-migrations.js`.
|
||||||
|
5. **Restart on the new image:** `docker compose -f compose.yaml -f compose.proxy.yaml up -d --wait`.
|
||||||
|
|
||||||
|
### Rolling back
|
||||||
|
|
||||||
|
Set `EKKLESIA_IMAGE` back to the previous tag and run `up -d --wait` again. That is enough only when
|
||||||
|
the upgrade applied no migrations (the plan in step 4 showed none pending). Migrations are
|
||||||
|
forward-only. If any were applied, restore the backup taken in step 2 before starting the previous
|
||||||
|
image, because it was not built for the newer schema. Module storage is not part of the image, so a
|
||||||
|
rollback does not change installed modules.
|
||||||
|
|
||||||
|
### Backups
|
||||||
|
|
||||||
|
Slice 6 adds the scheduled backup job and the restore procedure (ADR 0044, Backups). Until then,
|
||||||
|
take a `pg_dump`, a copy of every bucket, and a copy of module storage before each upgrade by hand.
|
||||||
|
|
||||||
|
### Split Dashboard and API origins
|
||||||
|
|
||||||
|
By default one hostname serves the Dashboard (`/`), the API (`/api/*`), and displays (`/display/*`).
|
||||||
|
An installation may instead give the Dashboard its own hostname, for example `dash.example.org`
|
||||||
|
beside `api.example.org` ([ADR 0045](../docs/architecture/decisions/0045-split-dashboard-and-api-origins.md)).
|
||||||
|
The same image serves both; only configuration differs.
|
||||||
|
|
||||||
|
| Hostname | Serves | Refuses (`404`) |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Dashboard (`EKKLESIA_DASHBOARD_HOSTNAME`) | `/` and `/assets/ekklesia-dashboard/*` | everything else, including `/api/*` |
|
||||||
|
| API (`EKKLESIA_HOSTNAME`) | `/api/*`, `/display/*`, `/assets/ekklesia-display/*` | everything else, including `/` |
|
||||||
|
| Storage (`EKKLESIA_STORAGE_HOSTNAME`) | signed object URLs, unchanged | |
|
||||||
|
|
||||||
|
Displays live on the API's hostname (`https://api.example.org/display/<module>`), because their
|
||||||
|
device cookies are bound to it and their offline cache keeps only same-origin responses. That
|
||||||
|
hostname is what every screen and paired device depends on: choose it once.
|
||||||
|
|
||||||
|
To run split:
|
||||||
|
|
||||||
|
1. Copy `Caddyfile.split.example` to `Caddyfile` instead of `Caddyfile.example`.
|
||||||
|
2. In `deploy/.env`, set `EKKLESIA_HOSTNAME` to the API's hostname and `EKKLESIA_DASHBOARD_HOSTNAME`
|
||||||
|
to the Dashboard's.
|
||||||
|
3. In `ekklesia.env`, set `EKKLESIA_IDENTITY_BASE_URL` to `https://` and the API's hostname,
|
||||||
|
`EKKLESIA_DASHBOARD_ORIGIN` to `https://` and the Dashboard's, and
|
||||||
|
`EKKLESIA_IDENTITY_TRUSTED_ORIGINS` to the Dashboard's origin. The API always trusts its own
|
||||||
|
origin, so it need not be listed.
|
||||||
|
|
||||||
|
**Both hostnames must belong to one registrable domain** (`dash.example.org` and `api.example.org`,
|
||||||
|
not `dash.example.org` and `api.example.net`). The session cookie stays host-only on the API's
|
||||||
|
hostname; browsers send it from the Dashboard only between origins of the same site. Doctor reports
|
||||||
|
the topology and warns when the two hostnames are not siblings.
|
||||||
|
|
||||||
|
The Dashboard learns the API's origin at run time: the API writes its identity base URL into the page
|
||||||
|
it serves (`<meta name="ekklesia-api-origin">`). Nothing about the topology is built into the image.
|
||||||
|
|
||||||
|
### Storage delivery: CORS is the deployment's
|
||||||
|
|
||||||
|
Browsers fetch objects directly from the storage hostname through signed, expiring URLs (ADR 0031).
|
||||||
|
**Which pages may read those bytes with `fetch()` is decided by the delivery layer, the proxy, and
|
||||||
|
never by the object store.** That policy therefore stays the same with SeaweedFS, R2, or any other
|
||||||
|
S3 backend behind it, and an installation that changes backends changes nothing about CORS.
|
||||||
|
|
||||||
|
The reference proxy's storage block:
|
||||||
|
|
||||||
|
- grants CORS only to the origins whose pages read objects with `fetch()`: the Ekklesia origin in the
|
||||||
|
same-origin topology; the API origin (displays) and the Dashboard origin (a module's preview) in
|
||||||
|
the split topology. An `<img>` needs no CORS and is unaffected;
|
||||||
|
- allows `GET` and `HEAD` only, and no request header beyond the CORS-safelisted ones, always with
|
||||||
|
`Vary: Origin`;
|
||||||
|
- answers preflight `OPTIONS` requests itself, so they never reach storage;
|
||||||
|
- removes the object store's own CORS headers. SeaweedFS's default reflects any origin and
|
||||||
|
advertises `PUT`, `POST`, and `DELETE`.
|
||||||
|
|
||||||
|
The proxy reaches storage over **HTTP/1.1 only**. Over HTTP/2, SeaweedFS answers a conditional
|
||||||
|
request with a `304` that declares a `Content-Length` it never sends. The proxy then resets the
|
||||||
|
browser's stream, and every reload of a cached image fails with `ERR_HTTP2_PROTOCOL_ERROR`.
|
||||||
|
Browsers still reach the proxy itself over HTTP/2 and HTTP/3.
|
||||||
|
|
||||||
|
### Behind an existing reverse proxy
|
||||||
|
|
||||||
|
Run the base alone and attach `api` and `object-storage` to your proxy's network with an overlay of
|
||||||
|
your own, for example `compose.myproxy.yaml`:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
services:
|
||||||
|
api:
|
||||||
|
networks:
|
||||||
|
proxy: {}
|
||||||
|
environment:
|
||||||
|
# Exactly one trusted peer: your proxy, at an address that cannot change.
|
||||||
|
EKKLESIA_CLIENT_ADDRESS_MODE: trusted-proxy
|
||||||
|
EKKLESIA_CLIENT_ADDRESS_HEADER: x-forwarded-for
|
||||||
|
EKKLESIA_CLIENT_ADDRESS_TRUSTED_PROXIES: 10.0.100.254/32
|
||||||
|
object-storage:
|
||||||
|
networks:
|
||||||
|
proxy: {}
|
||||||
|
|
||||||
|
networks:
|
||||||
|
proxy:
|
||||||
|
external: true
|
||||||
|
name: your-proxy-network
|
||||||
|
```
|
||||||
|
|
||||||
|
Then make your proxy do what `Caddyfile.example` does:
|
||||||
|
|
||||||
|
- forward `/`, `/api/*`, `/display/*`, and `/assets/*` on the Ekklesia hostname to `api:3000`;
|
||||||
|
- **overwrite** `X-Forwarded-For` with the one client address it resolved, and strip
|
||||||
|
`CF-Connecting-IP`, `X-Real-IP`, `Forwarded`, and `True-Client-IP`;
|
||||||
|
- stream responses on `/api/v1/module-realtime/*` without buffering (Server-Sent Events);
|
||||||
|
- set no request-body limit below 32 MiB; the API enforces its own ceilings;
|
||||||
|
- forward the storage hostname to `https://object-storage:8443`, verifying it against
|
||||||
|
`/etc/ekklesia/tls/ca.crt` with server name `object-storage`, and **pass the original `Host`
|
||||||
|
through unchanged**, or every signed URL fails (ADR 0031).
|
||||||
|
- reach `object-storage` over **HTTP/1.1** only, and own the storage hostname's CORS as described in
|
||||||
|
[Storage delivery](#storage-delivery-cors-is-the-deployments).
|
||||||
|
|
||||||
|
The proxy's address must be fixed and must never be handed to anything else. With Docker, give its
|
||||||
|
network an explicit subnet, limit `ip_range` to part of it, and fix the proxy's address outside that
|
||||||
|
range, as `compose.proxy.yaml` does.
|
||||||
|
|
||||||
|
### Fixed addresses on `edge`
|
||||||
|
|
||||||
|
`compose.proxy.yaml` gives `edge` the subnet `10.249.250.0/24` and lets Docker assign dynamic
|
||||||
|
addresses only from its lower half (`10.249.250.0/25`). The proxy (`.254`) and the tunnel connector
|
||||||
|
(`.253`) take fixed addresses from the top of the upper half, which Docker never assigns to anything
|
||||||
|
else. That is what makes "only the proxy" and "only the connector" expressible as `/32` trust
|
||||||
|
entries. If the subnet collides with one on your host, change `EKKLESIA_EDGE_SUBNET`,
|
||||||
|
`EKKLESIA_EDGE_DYNAMIC_RANGE`, `EKKLESIA_PROXY_ADDRESS`, and `EKKLESIA_TUNNEL_ADDRESS` together,
|
||||||
|
keeping the fixed addresses at the top and outside the dynamic range.
|
||||||
|
|
||||||
|
### Cloudflare Tunnel
|
||||||
|
|
||||||
|
`compose.tunnel.yaml` runs `cloudflared` with a token, so the tunnel is managed in the Cloudflare
|
||||||
|
dashboard. No Cloudflare API is called from here.
|
||||||
|
|
||||||
|
1. In Cloudflare Zero Trust, under **Networks → Tunnels**, create a Cloudflared tunnel. Copy its
|
||||||
|
token into `/etc/ekklesia/tunnel.env` as `TUNNEL_TOKEN`.
|
||||||
|
2. Add a **public hostname** (shown as a *published application route* in newer dashboards) for
|
||||||
|
the Ekklesia hostname. Service type **HTTPS**, URL `proxy:443`.
|
||||||
|
3. Under that hostname's **TLS** settings, set **Origin Server Name** to the same Ekklesia hostname,
|
||||||
|
and leave **No TLS Verify** off. The connector then verifies the proxy's certificate for that name.
|
||||||
|
4. Add a public hostname for the storage hostname, the same way, with its own origin server name.
|
||||||
|
In the [split topology](#split-dashboard-and-api-origins), add one for the Dashboard's hostname
|
||||||
|
too: three in all, each with its own origin server name.
|
||||||
|
5. Start with all three files. The proxy honors `CF-Connecting-IP` only from the connector's fixed
|
||||||
|
address. From any other peer it is stripped.
|
||||||
|
|
||||||
|
The proxy's certificate must still be publicly trusted, and an HTTP-01 challenge cannot reach the
|
||||||
|
proxy through a tunnel, because Cloudflare terminates TLS at its edge. A tunnel-only installation
|
||||||
|
therefore uses [DNS-01](#dns-01). An installation that also serves local displays should give the
|
||||||
|
same hostnames a LAN path to the proxy (split-horizon DNS), so displays do not depend on the
|
||||||
|
Internet link.
|
||||||
|
|
||||||
|
### DNS-01
|
||||||
|
|
||||||
|
For an installation off the public Internet, LAN-only, or tunnel-only. DNS-01 needs a DNS provider
|
||||||
|
module, which the stock Caddy image does not contain:
|
||||||
|
|
||||||
|
1. Build a Caddy image with the module (for example
|
||||||
|
`xcaddy build --with github.com/caddy-dns/cloudflare`) and pin it by digest.
|
||||||
|
2. Override the proxy's `image:` in an overlay of your own. Give it `CLOUDFLARE_API_TOKEN` (a token
|
||||||
|
scoped to DNS edits for the zone) through a root-owned env file.
|
||||||
|
3. Enable the commented `acme_dns` line in your `Caddyfile`.
|
||||||
|
|
||||||
|
### Launch checklist
|
||||||
|
|
||||||
|
The ten items in [Security § launch checklist](../docs/development/security.md) mapped to this
|
||||||
|
deployment:
|
||||||
|
|
||||||
|
| # | Item | Where it is met |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 1 | The proxy forwards the Dashboard and `/api` on the intended origin | `Caddyfile.example` routes `/`, `/api/*`, `/display/*`, `/assets/*` on one hostname to `api`; the API serves the Dashboard itself. In the split topology, `Caddyfile.split.example` gives each hostname exactly its own paths and the exact trusted-origin CORS of ADR 0045 covers the rest. |
|
||||||
|
| 2 | The origin port is unreachable from the Internet | The base publishes no port; only `proxy` publishes 80 and 443. |
|
||||||
|
| 3 | The origin port is unreachable from untrusted networks | `api` is reachable only on Docker networks; `EKKLESIA_PUBLISH_ADDRESS` limits the proxy to one interface. Firewall the host itself. |
|
||||||
|
| 4 | The proxy overwrites the trusted client-IP header | `header_up X-Forwarded-For {client_ip}` in `Caddyfile.example`. |
|
||||||
|
| 5 | Rotating forwarding headers cannot rotate the rate-limit bucket | The same, plus stripping `CF-Connecting-IP`, `X-Real-IP`, `Forwarded`, and `True-Client-IP`; the API overwrites its internal header. Proven by the smoke test. |
|
||||||
|
| 6 | The proxy peer is the only trusted proxy | `compose.proxy.yaml` sets `EKKLESIA_CLIENT_ADDRESS_TRUSTED_PROXIES` to the proxy's fixed `/32`. |
|
||||||
|
| 7 | Proxy logs record no cookies, credentials, bodies, or forwarding chains | Caddy access logs are off in `Caddyfile.example`. If you enable them, exclude request headers. |
|
||||||
|
| 8 | PostgreSQL only from the API, over verified TLS | `postgres` is only on internal `data`; `pg_hba.conf` rejects non-TLS; `verify-full` with `EKKLESIA_DATABASE_CA`. Proven by the smoke test. |
|
||||||
|
| 9 | The deployed login and logout flow keeps its contract | Unchanged by the proxy; the smoke test signs in and out through it. |
|
||||||
|
| 10 | A supported way to create the first production identity | `create-initial-owner.js` through `migrate` ([The initial owner](#the-initial-owner)). Exercised by the smoke test. |
|
||||||
|
|
||||||
|
### Testing this deployment
|
||||||
|
|
||||||
|
- `pnpm deploy:config` validates the base, base + proxy, and base + proxy + tunnel with the example
|
||||||
|
env files. CI runs it in `pnpm ci:verify`.
|
||||||
|
- `bash scripts/check-image.sh` builds the image and proves it is generic: no `/opt/ekklesia/modules`,
|
||||||
|
no module template, no `ekklesia.module.json` outside installed dependencies, an empty root-owned
|
||||||
|
`/var/lib/ekklesia/modules`, and `EKKLESIA_MODULES_DIR` set. It then builds again from a checkout
|
||||||
|
with a populated `modules/` directory and requires an identical runtime file system. CI runs it.
|
||||||
|
- `pnpm deploy:smoke` (`scripts/smoke-deploy.sh`) runs the whole deployment end to end, against the
|
||||||
|
generic image, empty module storage, and a disposable PostgreSQL and SeaweedFS that exist only for
|
||||||
|
that run. It follows the first boot above and checks the following through the proxy:
|
||||||
|
- readiness and the Dashboard;
|
||||||
|
- a signed object URL fetched through the storage hostname, and the same image loaded twice over
|
||||||
|
HTTP/2, the second time as a conditional request that must be a clean `304`;
|
||||||
|
- storage CORS: exactly the allowed origins, `GET` and `HEAD` only, preflights answered by the
|
||||||
|
proxy, and none of the object store's own CORS headers;
|
||||||
|
- the client-address chain, both from an ordinary peer and from the tunnel connector's address;
|
||||||
|
- initial-owner sign-in and sign-out;
|
||||||
|
- that the API's database connection is TLS 1.3, and that it is refused by a foreign CA and by a
|
||||||
|
wrong host name;
|
||||||
|
- Doctor.
|
||||||
|
|
||||||
|
`EKKLESIA_SMOKE_TOPOLOGY=split` runs the [split topology](#split-dashboard-and-api-origins)
|
||||||
|
instead, on the **same generic image**, and also proves [Installed modules](#installed-modules)
|
||||||
|
end to end:
|
||||||
|
- the image boots with no module;
|
||||||
|
- the repository's module template, packaged as an Ekklesia Module Package v1 by the canonical
|
||||||
|
packager, is verified by the image itself and installed into module storage, its migration is
|
||||||
|
planned and explicitly applied, the API restarts, and the module reads data it owns;
|
||||||
|
- every hostname's served and refused paths, exact CORS for the Dashboard's origin and none for
|
||||||
|
others;
|
||||||
|
- a real headless Chromium (pinned by digest, driven over the DevTools protocol) signs in on the
|
||||||
|
Dashboard's hostname, checks the session cookie lives only on the API's, mounts the module's UI
|
||||||
|
from the API's origin and sees its own credentialed requests succeed, mounts its display on the
|
||||||
|
API's hostname with its service worker, and signs out;
|
||||||
|
- the module is removed and the API restarted: Core still runs, the module's routes are gone,
|
||||||
|
migration planning and applying succeed and report its retained history, and its data is intact;
|
||||||
|
- the exact same package is verified and installed again and the API restarted: its history
|
||||||
|
validates strictly and it returns its previous data.
|
||||||
|
|
||||||
|
CI runs both topologies on every change.
|
||||||
|
- **Test-only:** the smoke test runs Caddy with its internal CA (`local_certs`), because a test run
|
||||||
|
has no public hostname. Never use it in an installation; `Caddyfile.example` does not contain it.
|
||||||
|
|
||||||
|
The smoke test uses no module, so it does not exercise realtime streams or large uploads. Through
|
||||||
|
this same proxy configuration, a scratch module confirmed both:
|
||||||
|
|
||||||
|
- a Server-Sent Event crossed the proxy in milliseconds;
|
||||||
|
- a 32 MiB streamed upload passed with and without `Content-Length`;
|
||||||
|
- one byte more was refused by the API's own `413 REQUEST_BODY_TOO_LARGE`.
|
||||||
|
|
||||||
|
## The production image
|
||||||
|
|
||||||
|
[`Dockerfile`](Dockerfile) builds **one image** for two jobs: the long-running `api` service, and
|
||||||
|
every operator-run command (migrations, Doctor, initial-owner creation, module enablement). There
|
||||||
|
is no second image and no second build.
|
||||||
|
|
||||||
|
### What it builds
|
||||||
|
|
||||||
|
The build context is **this repository**, and the image is the same for every installation
|
||||||
|
([ADR 0048](../docs/architecture/decisions/0048-generic-image-and-installed-module-storage.md)). It
|
||||||
|
contains **zero feature modules**: installed modules live in module storage outside it
|
||||||
|
([Installed modules](#installed-modules)). No input is specific to an installation: no module list,
|
||||||
|
no lockfile of its own, no branding, no configuration.
|
||||||
|
|
||||||
|
A `modules/` directory in the checkout never reaches the image. `.dockerignore` keeps it out of the
|
||||||
|
build context, and the build refuses a context that still contains one. The module template is a
|
||||||
|
workspace package, so it is built and validated with everything else, but neither its files nor its
|
||||||
|
dependencies reach the runtime image. `scripts/check-image.sh` proves all of this on every change
|
||||||
|
([Testing this deployment](#testing-this-deployment)).
|
||||||
|
|
||||||
|
| Stage | Does |
|
||||||
|
| --- | --- |
|
||||||
|
| `base` | `node:24.18.0-trixie-slim`, pinned by its multi-architecture index digest. Debian, so glibc, because module dependencies such as sharp ship glibc prebuilt binaries. Every stage starts here. |
|
||||||
|
| `toolchain` | Enables pnpm through Corepack, at exactly the root `packageManager` version. |
|
||||||
|
| `manifests` | Collects the lockfile, workspace configuration, `.npmrc`, and the `package.json` of every platform package (not the module template's) at its repository path. |
|
||||||
|
| `build` | Refuses a context containing `modules/`, then `pnpm install --frozen-lockfile`, `pnpm boundaries`, `pnpm modules:validate`, `pnpm migrations:validate`, and `pnpm build`. Never uses host `node_modules`: `.dockerignore` excludes them. |
|
||||||
|
| `prod-deps` | The same lockfile with `--prod`, for the platform packages only: production dependencies only. |
|
||||||
|
| `runtime` | Production dependencies plus built outputs, owned by root and run as the unprivileged `node` user (uid 1000), and an empty, root-owned `/var/lib/ekklesia/modules`. No pnpm, no compiler, no curl. |
|
||||||
|
|
||||||
|
The runtime keeps the repository layout at `/opt/ekklesia`, because that layout is how the platform
|
||||||
|
finds its files:
|
||||||
|
|
||||||
|
- the API takes the repository root as three directories above `apps/api/dist/`, and reads
|
||||||
|
`package.json` and `packages/module-sdk/package.json` beneath it;
|
||||||
|
- every CLI takes its **working directory** as the root and also reads Core's migrations in
|
||||||
|
`core/drizzle/`, so commands must run from `/opt/ekklesia`, the image's `WORKDIR`;
|
||||||
|
- the Dashboard and display host files are read from `surfaces/shell/dist/browser/`;
|
||||||
|
- installed modules are read from `EKKLESIA_MODULES_DIR`, `/var/lib/ekklesia/modules`, never from
|
||||||
|
under `/opt/ekklesia`. With nothing mounted there, the image runs with no modules.
|
||||||
|
|
||||||
|
### Building
|
||||||
|
|
||||||
|
```sh
|
||||||
|
docker build \
|
||||||
|
--file deploy/Dockerfile \
|
||||||
|
--build-arg VERSION=0.1.2 \
|
||||||
|
--build-arg REVISION="$(git rev-parse HEAD)" \
|
||||||
|
--build-arg SOURCE=https://git.example.org/your-org/ekklesia \
|
||||||
|
--tag ekklesia:0.1.2 \
|
||||||
|
.
|
||||||
|
```
|
||||||
|
|
||||||
|
The three build arguments become the OCI labels `org.opencontainers.image.version`, `.revision`,
|
||||||
|
and `.source`. They have no defaults: the repository cannot know an installation's Git host. The
|
||||||
|
image also records its base image name and digest. BuildKit is required (Docker's default builder);
|
||||||
|
set `DOCKER_BUILDKIT=1` if your Docker CLI has no buildx plugin.
|
||||||
|
|
||||||
|
No secret is a build argument, and none enters any layer. `.env` files are excluded from the build
|
||||||
|
context entirely, and no registry credentials are needed.
|
||||||
|
|
||||||
|
### The runtime an installed module targets
|
||||||
|
|
||||||
|
An installed module runs on the image's Node.js, inside the API process, from its own directory
|
||||||
|
([ADR 0048](../docs/architecture/decisions/0048-generic-image-and-installed-module-storage.md)):
|
||||||
|
|
||||||
|
- **Node.js 24.18.0 on Debian 13, so glibc**, for the architecture the image was built for. A native
|
||||||
|
dependency such as sharp must be built or downloaded for exactly that.
|
||||||
|
- **Its own dependencies only.** Node resolves a module's imports from the module's directory, and
|
||||||
|
the platform's `node_modules` is never on that path. A module whose server code imports something
|
||||||
|
its directory does not contain stops the API from starting, with a sanitized
|
||||||
|
`MODULE_RUNTIME_IMPORT_FAILED`.
|
||||||
|
- **The platform's exact Drizzle version, for now.** Core runs a module's queries through its own
|
||||||
|
Drizzle instance over tables the module defined with its own copy, so the two copies must be
|
||||||
|
interchangeable. That is a constraint of the current implementation, not a permanent contract.
|
||||||
|
|
||||||
|
The repository's boundary checks (`pnpm boundaries`) prove only the source modules in a workspace.
|
||||||
|
An installed module's conformance is its own CI's job, so check a module before installing it:
|
||||||
|
`validate-modules.js` ([Operator commands](#operator-commands)) validates what can be checked on an
|
||||||
|
installed directory, and the API's startup refuses anything it cannot load.
|
||||||
|
|
||||||
|
### Configuration
|
||||||
|
|
||||||
|
Configuration comes **only from the container environment**, never from a file inside the image.
|
||||||
|
The image sets exactly four variables itself:
|
||||||
|
|
||||||
|
| Variable | Image value | Why |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `NODE_ENV` | `production` | Stated rather than relied on. Anything other than `development` already validates as production. |
|
||||||
|
| `EKKLESIA_API_HOST` | `0.0.0.0` | Reachable from other containers. Outside a container the platform default stays `127.0.0.1`. |
|
||||||
|
| `EKKLESIA_API_PORT` | `3000` | The platform default, made explicit; `EXPOSE 3000`. |
|
||||||
|
| `EKKLESIA_MODULES_DIR` | `/var/lib/ekklesia/modules` | Where installed modules are read from (ADR 0048). Outside a container, unset means the checkout's own `modules/`. Compose states the same value beside the mount. |
|
||||||
|
|
||||||
|
Everything else is the ordinary host configuration in
|
||||||
|
[API § Required configuration](../docs/development/api.md#required-configuration), supplied by the
|
||||||
|
reference deployment's `ekklesia.env`. `EKKLESIA_DATABASE_CA` is the CA's PEM text itself, on one
|
||||||
|
double-quoted line with `\n` escapes. Compose expands those escapes; plain `docker run --env-file`
|
||||||
|
does not, which is why every command here goes through Compose.
|
||||||
|
|
||||||
|
Never publish the API's port to the host or the Internet. Only the proxy publishes ports.
|
||||||
|
|
||||||
|
### Process, signals, and the read-only posture
|
||||||
|
|
||||||
|
`compose.yaml` runs `api` and `migrate` with `init: true`, `read_only: true`, `tmpfs: /tmp`,
|
||||||
|
`cap_drop: [ALL]`, and `no-new-privileges`. That is the supported production posture:
|
||||||
|
|
||||||
|
- **`init: true`** runs a minimal init as PID 1 that forwards signals and reaps children, so
|
||||||
|
`docker stop` reaches the API's own SIGTERM handler promptly. The API then drains in-flight
|
||||||
|
requests, stops modules in reverse order, and closes its pool.
|
||||||
|
- **Read-only:** the image was verified under exactly these flags. The API reached `healthy`, and
|
||||||
|
the migration and Doctor commands ran. Nothing wrote to `/tmp`, which stays mounted as headroom
|
||||||
|
for module code that needs scratch space. The installation, `/home/node` included, is not
|
||||||
|
writable, and neither is module storage, which is mounted read-only. Anything that needs a
|
||||||
|
writable path other than `/tmp` is a defect to report, not to work around.
|
||||||
|
|
||||||
|
### Health
|
||||||
|
|
||||||
|
The image's `HEALTHCHECK` calls `GET /api/v1/health/ready` on `127.0.0.1` with Node's built-in
|
||||||
|
`fetch`, every 30 seconds after a 30-second start period, with three retries. Readiness means the
|
||||||
|
database answered within 2 seconds and the instance is not shutting down; see
|
||||||
|
[API § Health](../docs/development/api.md#health). Because it probes `127.0.0.1`, keep
|
||||||
|
`EKKLESIA_API_HOST` at `0.0.0.0` (or any address that includes loopback) inside the container. The
|
||||||
|
API trusts only the proxy's address for client-address headers, so the loopback probe is an
|
||||||
|
ordinary direct peer.
|
||||||
|
|
||||||
|
An external monitor can use the same endpoint through the proxy. Use `/api/v1/health/live` to ask
|
||||||
|
only whether the process is serving.
|
||||||
|
|
||||||
|
### Operator commands
|
||||||
|
|
||||||
|
The same image runs every CLI, directly with `node core/dist/cli/<command>.js`, through the
|
||||||
|
`migrate` service. The `pnpm` scripts of the same names rebuild first and read `.env`, so they are
|
||||||
|
for a development checkout, not for the image.
|
||||||
|
|
||||||
|
`migrate` has `profiles: ["ops"]`, so `up` never starts it. It never restarts, has no health check,
|
||||||
|
and its default command is the read-only plan. **Nothing applies migrations automatically.**
|
||||||
|
|
||||||
|
Run operator commands with the same Compose files as the stack. `COMPOSE_FILE` in `deploy/.env`
|
||||||
|
(set in [`env/compose.env.example`](env/compose.env.example)) does that for every plain
|
||||||
|
`docker compose` command below. It matters for Doctor in particular: Doctor checks the
|
||||||
|
client-address configuration of the process it runs in. The proxy overlay gives `migrate` the
|
||||||
|
API's trusted-proxy settings, so Doctor reports what the API runs. Without the overlay, Doctor
|
||||||
|
reports direct mode for an API that trusts its proxy. The running API's own value is shown by
|
||||||
|
`docker compose exec api printenv EKKLESIA_CLIENT_ADDRESS_MODE`.
|
||||||
|
|
||||||
|
| Task | Command (from `deploy/`) |
|
||||||
|
| --- | --- |
|
||||||
|
| Plan migrations (read-only) | `docker compose run --rm migrate` |
|
||||||
|
| Apply migrations | `docker compose run --rm migrate node core/dist/cli/apply-migrations.js` |
|
||||||
|
| Doctor (read-only report) | `docker compose run --rm migrate node core/dist/cli/doctor.js` |
|
||||||
|
| Create the initial owner (interactive) | `docker compose run --rm -it migrate node core/dist/cli/create-initial-owner.js --email [email protected]` |
|
||||||
|
| Validate the installed modules (read-only) | `docker compose run --rm migrate node core/dist/cli/validate-modules.js` |
|
||||||
|
| Verify a module package (read-only unless extracting) | `node core/dist/cli/verify-module-package.js <archive> --sha256-file <archive>.sha256 [--extract-to <new directory>]`, in a `docker run` of the image ([Installed modules](#installed-modules)) |
|
||||||
|
| Enable or disable a module | `docker compose run --rm migrate node core/dist/cli/set-module-enabled.js --enable --module <id>` (or `--disable`) |
|
||||||
|
|
||||||
|
Every command reads installed modules from the same `EKKLESIA_MODULES_DIR` the API does. The plan,
|
||||||
|
apply, Doctor, and initial-owner commands are exercised by the smoke test, and so are installing,
|
||||||
|
removing, and restoring a module; module validation and enablement are not.
|
||||||
|
|
||||||
|
### Size
|
||||||
|
|
||||||
|
The image is about 378 MB, and it contains no module: the Debian slim Node base (about 230 MB), production
|
||||||
|
dependencies (about 141 MB), and built outputs (about 6 MB). A rebuild that reuses a warm pnpm store
|
||||||
|
cache can come out about 21 MB larger. When esbuild's install script runs, it hard-links its
|
||||||
|
`bin/esbuild` to the native binary it ships beside it; when pnpm restores esbuild from the store's
|
||||||
|
cached post-install state instead, that file is a second full copy. Both are esbuild, one of the
|
||||||
|
unused tools below.
|
||||||
|
|
||||||
|
About 57 MB of the production dependencies are development tools that no runtime code imports:
|
||||||
|
`vitest`, `vite`, `drizzle-kit`, `tsx`, `esbuild`, and their native bindings. They arrive as
|
||||||
|
optional peer dependencies of Better Auth (and `esbuild` through the module UI builder), which pnpm
|
||||||
|
resolves because the workspace already has them. Excluding them means changing dependency
|
||||||
|
resolution and the lockfile, which is separate work.
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
# The reference TLS proxy overlay (ADR 0044's `proxy` service). Use it on top of the base:
|
||||||
|
#
|
||||||
|
# docker compose -f compose.yaml -f compose.proxy.yaml up -d
|
||||||
|
#
|
||||||
|
# Caddy terminates TLS with publicly trusted ACME certificates and publishes the deployment's only
|
||||||
|
# ports. It reaches `api` and `object-storage` on the `edge` network, which this overlay creates and
|
||||||
|
# attaches them to; `postgres` is never on it.
|
||||||
|
#
|
||||||
|
# Client addresses. Caddy is the only peer the API trusts to say who the client is: this overlay
|
||||||
|
# puts the API in trusted-proxy mode for exactly the proxy's fixed address, and the Caddyfile
|
||||||
|
# overwrites X-Forwarded-For with the one address Caddy resolved. Because the address must be
|
||||||
|
# fixed for "only the proxy" to be expressible, `edge` has an explicit subnet whose dynamic range is
|
||||||
|
# its lower half; the proxy (and the tunnel connector, if used) take addresses from the upper half,
|
||||||
|
# which Docker never hands to another container.
|
||||||
|
|
||||||
|
# Exactly one trusted peer: this proxy. Set from the same variable that fixes the proxy's address,
|
||||||
|
# so the two cannot drift apart. These override any value in ekklesia.env.
|
||||||
|
x-ekklesia-client-address: &ekklesia-client-address
|
||||||
|
EKKLESIA_CLIENT_ADDRESS_MODE: trusted-proxy
|
||||||
|
EKKLESIA_CLIENT_ADDRESS_HEADER: x-forwarded-for
|
||||||
|
EKKLESIA_CLIENT_ADDRESS_TRUSTED_PROXIES: ${EKKLESIA_PROXY_ADDRESS:-10.249.250.254}/32
|
||||||
|
|
||||||
|
services:
|
||||||
|
proxy:
|
||||||
|
image: caddy:2.11.4-alpine@sha256:6aeddd44c3078b0f9a35206472a11420648a79c184603ef95957d0a20044cb2b
|
||||||
|
restart: unless-stopped
|
||||||
|
read_only: true
|
||||||
|
tmpfs:
|
||||||
|
- /tmp
|
||||||
|
cap_drop:
|
||||||
|
- ALL
|
||||||
|
cap_add:
|
||||||
|
- NET_BIND_SERVICE
|
||||||
|
security_opt:
|
||||||
|
- no-new-privileges:true
|
||||||
|
ports:
|
||||||
|
# 80 answers ACME HTTP-01 challenges and redirects everything else to HTTPS. Set
|
||||||
|
# EKKLESIA_PUBLISH_ADDRESS to bind one host interface instead of all of them.
|
||||||
|
- "${EKKLESIA_PUBLISH_ADDRESS:-0.0.0.0}:${EKKLESIA_HTTP_PORT:-80}:80"
|
||||||
|
- "${EKKLESIA_PUBLISH_ADDRESS:-0.0.0.0}:${EKKLESIA_HTTPS_PORT:-443}:443"
|
||||||
|
- "${EKKLESIA_PUBLISH_ADDRESS:-0.0.0.0}:${EKKLESIA_HTTPS_PORT:-443}:443/udp"
|
||||||
|
environment:
|
||||||
|
EKKLESIA_HOSTNAME: ${EKKLESIA_HOSTNAME:?EKKLESIA_HOSTNAME is required, for example ekklesia.example.org}
|
||||||
|
EKKLESIA_STORAGE_HOSTNAME: ${EKKLESIA_STORAGE_HOSTNAME:?EKKLESIA_STORAGE_HOSTNAME is required, for example storage.ekklesia.example.org}
|
||||||
|
# The split topology only (ADR 0045, Caddyfile.split.example), where EKKLESIA_HOSTNAME is the
|
||||||
|
# API's hostname and this is the Dashboard's. Caddyfile.example ignores it; the split
|
||||||
|
# configuration refuses to start without it.
|
||||||
|
EKKLESIA_DASHBOARD_HOSTNAME: ${EKKLESIA_DASHBOARD_HOSTNAME:-}
|
||||||
|
EKKLESIA_ACME_EMAIL: ${EKKLESIA_ACME_EMAIL:?EKKLESIA_ACME_EMAIL is required for ACME account notices}
|
||||||
|
EKKLESIA_TUNNEL_ADDRESS: ${EKKLESIA_TUNNEL_ADDRESS:-10.249.250.253}
|
||||||
|
volumes:
|
||||||
|
- ${EKKLESIA_CADDYFILE:-./Caddyfile}:/etc/caddy/Caddyfile:ro
|
||||||
|
# The installation-private CA, so Caddy can verify object-storage's certificate.
|
||||||
|
- ${EKKLESIA_TLS_DIR:-/etc/ekklesia/tls}/ca.crt:/etc/caddy/ekklesia-ca.crt:ro
|
||||||
|
- proxy-data:/data
|
||||||
|
- proxy-config:/config
|
||||||
|
networks:
|
||||||
|
edge:
|
||||||
|
ipv4_address: ${EKKLESIA_PROXY_ADDRESS:-10.249.250.254}
|
||||||
|
depends_on:
|
||||||
|
api:
|
||||||
|
condition: service_healthy
|
||||||
|
object-storage:
|
||||||
|
condition: service_healthy
|
||||||
|
|
||||||
|
api:
|
||||||
|
networks:
|
||||||
|
edge: {}
|
||||||
|
environment: *ekklesia-client-address
|
||||||
|
|
||||||
|
# Operator commands (migrations, Doctor) run as `migrate`. It gets the API's client-address
|
||||||
|
# configuration too, so that Doctor checks what the API actually runs rather than an empty one.
|
||||||
|
migrate:
|
||||||
|
environment: *ekklesia-client-address
|
||||||
|
|
||||||
|
object-storage:
|
||||||
|
networks:
|
||||||
|
edge: {}
|
||||||
|
|
||||||
|
networks:
|
||||||
|
edge:
|
||||||
|
ipam:
|
||||||
|
config:
|
||||||
|
- subnet: ${EKKLESIA_EDGE_SUBNET:-10.249.250.0/24}
|
||||||
|
# Docker assigns dynamic addresses only from this range. Keep the fixed proxy and tunnel
|
||||||
|
# addresses outside it.
|
||||||
|
ip_range: ${EKKLESIA_EDGE_DYNAMIC_RANGE:-10.249.250.0/25}
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
proxy-data:
|
||||||
|
proxy-config:
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
# The Cloudflare Tunnel overlay (ADR 0044, optional ingress). Use it on top of base and proxy:
|
||||||
|
#
|
||||||
|
# docker compose -f compose.yaml -f compose.proxy.yaml -f compose.tunnel.yaml up -d
|
||||||
|
#
|
||||||
|
# `cloudflared` makes an outbound connection to Cloudflare and forwards visitors to the proxy over
|
||||||
|
# HTTPS. The tunnel is token-managed, so its public hostnames and origin settings live in the
|
||||||
|
# Cloudflare dashboard: each hostname's service is `https://proxy:443` with the matching origin
|
||||||
|
# server name and TLS verification left on (deploy/README.md, "Cloudflare Tunnel").
|
||||||
|
#
|
||||||
|
# The connector has a fixed address on `edge`. The Caddyfile trusts a client-address header
|
||||||
|
# (CF-Connecting-IP) only from that address, so no other peer can choose its own client address.
|
||||||
|
|
||||||
|
services:
|
||||||
|
tunnel:
|
||||||
|
image: cloudflare/cloudflared:2026.9.3@sha256:072c067d25ccbe61d46e18f0d0723255f2bb5304f7317caa95b27031520ff92c
|
||||||
|
restart: unless-stopped
|
||||||
|
read_only: true
|
||||||
|
cap_drop:
|
||||||
|
- ALL
|
||||||
|
security_opt:
|
||||||
|
- no-new-privileges:true
|
||||||
|
# The image's entrypoint is `cloudflared --no-autoupdate`; the token comes from TUNNEL_TOKEN.
|
||||||
|
command: ["tunnel", "run"]
|
||||||
|
env_file:
|
||||||
|
- ${EKKLESIA_ENV_DIR:-/etc/ekklesia}/tunnel.env
|
||||||
|
networks:
|
||||||
|
edge:
|
||||||
|
ipv4_address: ${EKKLESIA_TUNNEL_ADDRESS:-10.249.250.253}
|
||||||
|
depends_on:
|
||||||
|
- proxy
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
# Ekklesia's reference self-hosted deployment: the base project (ADR 0044).
|
||||||
|
#
|
||||||
|
# `api`, `postgres`, and `object-storage`, plus the operator-only `migrate` service. Nothing here
|
||||||
|
# publishes a port. Add `compose.proxy.yaml` for the reference TLS proxy, and `compose.tunnel.yaml`
|
||||||
|
# on top of that for a Cloudflare Tunnel connector; or attach `api` and `object-storage` to an
|
||||||
|
# existing proxy's network yourself (deploy/README.md).
|
||||||
|
#
|
||||||
|
# Non-secret settings come from `deploy/.env` (copy `env/compose.env.example`). Secrets live in
|
||||||
|
# root-owned, mode-600 files under EKKLESIA_ENV_DIR, one per service, so no container receives
|
||||||
|
# another service's credentials. TLS material comes from `scripts/init-tls.sh`.
|
||||||
|
|
||||||
|
name: ekklesia
|
||||||
|
|
||||||
|
x-ekklesia-hardening: &ekklesia-hardening
|
||||||
|
init: true
|
||||||
|
read_only: true
|
||||||
|
tmpfs:
|
||||||
|
- /tmp
|
||||||
|
cap_drop:
|
||||||
|
- ALL
|
||||||
|
security_opt:
|
||||||
|
- no-new-privileges:true
|
||||||
|
|
||||||
|
x-ekklesia-app: &ekklesia-app
|
||||||
|
<<: *ekklesia-hardening
|
||||||
|
image: ${EKKLESIA_IMAGE:?EKKLESIA_IMAGE is required, for example registry.example.org/ekklesia:0.1.0}
|
||||||
|
env_file:
|
||||||
|
- ${EKKLESIA_ENV_DIR:-/etc/ekklesia}/ekklesia.env
|
||||||
|
environment:
|
||||||
|
# Core's object-storage client trusts Node's default CA set. Adding the installation-private CA
|
||||||
|
# to it, from the file below, is what lets the API verify `object-storage`'s certificate
|
||||||
|
# without any storage TLS exception. The database CA travels separately, as PEM text in
|
||||||
|
# EKKLESIA_DATABASE_CA, and is the only CA the database connection trusts.
|
||||||
|
NODE_EXTRA_CA_CERTS: /run/ekklesia/tls/ca.crt
|
||||||
|
# Installed modules (ADR 0048). The image sets the same value; stating it beside the mount below
|
||||||
|
# keeps the variable and the mount target from ever drifting apart.
|
||||||
|
EKKLESIA_MODULES_DIR: /var/lib/ekklesia/modules
|
||||||
|
volumes:
|
||||||
|
- ${EKKLESIA_TLS_DIR:-/etc/ekklesia/tls}/ca.crt:/run/ekklesia/tls/ca.crt:ro
|
||||||
|
# Installation-owned module storage, outside the image (ADR 0048). Read-only: Ekklesia never
|
||||||
|
# writes installed code. The host directory must already exist; a missing one stops the
|
||||||
|
# container from starting instead of Compose quietly creating an empty, root-owned directory.
|
||||||
|
- type: bind
|
||||||
|
source: ${EKKLESIA_MODULES_HOST_DIR:-/var/lib/ekklesia/modules}
|
||||||
|
target: /var/lib/ekklesia/modules
|
||||||
|
read_only: true
|
||||||
|
bind:
|
||||||
|
create_host_path: false
|
||||||
|
networks:
|
||||||
|
data: {}
|
||||||
|
storage: {}
|
||||||
|
egress: {}
|
||||||
|
|
||||||
|
services:
|
||||||
|
api:
|
||||||
|
<<: *ekklesia-app
|
||||||
|
restart: unless-stopped
|
||||||
|
# The image's own HEALTHCHECK (GET /api/v1/health/ready) applies unchanged.
|
||||||
|
depends_on:
|
||||||
|
postgres:
|
||||||
|
condition: service_healthy
|
||||||
|
object-storage:
|
||||||
|
condition: service_healthy
|
||||||
|
|
||||||
|
# Operator commands only. `profiles` keeps `docker compose up` from ever starting it; it runs only
|
||||||
|
# through `docker compose run --rm migrate …`. The default command is the read-only plan.
|
||||||
|
migrate:
|
||||||
|
<<: *ekklesia-app
|
||||||
|
profiles:
|
||||||
|
- ops
|
||||||
|
restart: "no"
|
||||||
|
healthcheck:
|
||||||
|
disable: true
|
||||||
|
command: ["node", "core/dist/cli/plan-migrations.js"]
|
||||||
|
depends_on:
|
||||||
|
postgres:
|
||||||
|
condition: service_healthy
|
||||||
|
object-storage:
|
||||||
|
condition: service_healthy
|
||||||
|
|
||||||
|
postgres:
|
||||||
|
image: postgres:18.4-alpine3.24@sha256:9a8afca54e7861fd90fab5fdf4c42477a6b1cb7d293595148e674e0a3181de15
|
||||||
|
restart: unless-stopped
|
||||||
|
security_opt:
|
||||||
|
- no-new-privileges:true
|
||||||
|
env_file:
|
||||||
|
- ${EKKLESIA_ENV_DIR:-/etc/ekklesia}/postgres.env
|
||||||
|
environment:
|
||||||
|
POSTGRES_INITDB_ARGS: --data-checksums --auth-host=scram-sha-256 --auth-local=trust
|
||||||
|
TZ: UTC
|
||||||
|
# TLS with the installation-private CA's server certificate for the name `postgres`, TLS 1.3
|
||||||
|
# only, and a host-based authentication file that rejects every non-TLS network connection.
|
||||||
|
# The API connects with verify-full; there is no plaintext path to fall back to.
|
||||||
|
command:
|
||||||
|
- postgres
|
||||||
|
- -c
|
||||||
|
- ssl=on
|
||||||
|
- -c
|
||||||
|
- ssl_cert_file=/run/ekklesia/tls/postgres.crt
|
||||||
|
- -c
|
||||||
|
- ssl_key_file=/run/ekklesia/tls/postgres.key
|
||||||
|
- -c
|
||||||
|
- ssl_min_protocol_version=TLSv1.3
|
||||||
|
- -c
|
||||||
|
- hba_file=/etc/ekklesia/pg_hba.conf
|
||||||
|
volumes:
|
||||||
|
- postgres-data:/var/lib/postgresql
|
||||||
|
- ${EKKLESIA_TLS_DIR:-/etc/ekklesia/tls}/postgres.crt:/run/ekklesia/tls/postgres.crt:ro
|
||||||
|
- ${EKKLESIA_TLS_DIR:-/etc/ekklesia/tls}/postgres.key:/run/ekklesia/tls/postgres.key:ro
|
||||||
|
- ./postgres/pg_hba.conf:/etc/ekklesia/pg_hba.conf:ro
|
||||||
|
# Only `data`: never a proxy-facing network, and no route out of the host.
|
||||||
|
networks:
|
||||||
|
data: {}
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U \"$${POSTGRES_USER}\" -d \"$${POSTGRES_DB}\""]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 12
|
||||||
|
start_period: 20s
|
||||||
|
|
||||||
|
object-storage:
|
||||||
|
image: chrislusf/seaweedfs:4.28@sha256:d071979843142451d41566bc09f6adaa36902c112683d900a417247c82e9c3c1
|
||||||
|
restart: unless-stopped
|
||||||
|
security_opt:
|
||||||
|
- no-new-privileges:true
|
||||||
|
env_file:
|
||||||
|
- ${EKKLESIA_ENV_DIR:-/etc/ekklesia}/object-storage.env
|
||||||
|
environment:
|
||||||
|
TZ: UTC
|
||||||
|
# The S3 API over HTTPS on 8443, with the installation-private CA's certificate for the name
|
||||||
|
# `object-storage`: production storage must be https (ADR 0030), for the API and for the proxy.
|
||||||
|
command:
|
||||||
|
- mini
|
||||||
|
- -dir=/data
|
||||||
|
- -s3.port.https=8443
|
||||||
|
- -s3.cert.file=/run/ekklesia/tls/object-storage.crt
|
||||||
|
- -s3.key.file=/run/ekklesia/tls/object-storage.key
|
||||||
|
volumes:
|
||||||
|
- object-storage-data:/data
|
||||||
|
- ${EKKLESIA_TLS_DIR:-/etc/ekklesia/tls}/object-storage.crt:/run/ekklesia/tls/object-storage.crt:ro
|
||||||
|
- ${EKKLESIA_TLS_DIR:-/etc/ekklesia/tls}/object-storage.key:/run/ekklesia/tls/object-storage.key:ro
|
||||||
|
networks:
|
||||||
|
storage: {}
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "wget -q -O /dev/null http://127.0.0.1:9333/cluster/status"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 18
|
||||||
|
start_period: 15s
|
||||||
|
|
||||||
|
networks:
|
||||||
|
# The database network: postgres, api, and migrate. Internal, so nothing on it reaches beyond the
|
||||||
|
# host and no proxy is ever attached to it.
|
||||||
|
data:
|
||||||
|
internal: true
|
||||||
|
# The storage network: object-storage, api, and migrate. Internal for the same reason; browsers
|
||||||
|
# reach storage only through a proxy that joins it on `edge`.
|
||||||
|
storage:
|
||||||
|
internal: true
|
||||||
|
# Outbound access for api and migrate (an SMTP relay, for example). Nothing listens on it.
|
||||||
|
egress: {}
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
postgres-data:
|
||||||
|
object-storage-data:
|
||||||
Vendored
+46
@@ -0,0 +1,46 @@
|
|||||||
|
# Non-secret settings for the reference deployment's Compose project. Copy to deploy/.env, which
|
||||||
|
# Compose reads automatically from this directory. Nothing here is a secret; secrets live in the
|
||||||
|
# per-service files under EKKLESIA_ENV_DIR.
|
||||||
|
|
||||||
|
# Which Compose files make up this installation, so that every plain `docker compose …` command
|
||||||
|
# run from this directory, operator commands through `migrate` included, sees the same services and
|
||||||
|
# settings as the running stack. Add compose.tunnel.yaml if you use the tunnel connector. Without
|
||||||
|
# the proxy overlay here, Doctor checks a `migrate` that lacks the API's client-address settings.
|
||||||
|
COMPOSE_FILE=compose.yaml:compose.proxy.yaml
|
||||||
|
|
||||||
|
# The image to run, by registry, name, and tag (or digest). Required.
|
||||||
|
EKKLESIA_IMAGE=
|
||||||
|
|
||||||
|
# Where the root-owned, mode-600 env files and the TLS directory live on the host.
|
||||||
|
EKKLESIA_ENV_DIR=/etc/ekklesia
|
||||||
|
EKKLESIA_TLS_DIR=/etc/ekklesia/tls
|
||||||
|
|
||||||
|
# Installed modules (ADR 0048): the host directory mounted read-only into `api` and `migrate` at
|
||||||
|
# /var/lib/ekklesia/modules. It must already exist; Compose never creates it. Create it once, owned
|
||||||
|
# by root, before the first `up`:
|
||||||
|
# sudo install -d -o root -g root -m 755 /var/lib/ekklesia/modules
|
||||||
|
# EKKLESIA_MODULES_HOST_DIR=/var/lib/ekklesia/modules
|
||||||
|
|
||||||
|
# --- compose.proxy.yaml ---------------------------------------------------------------------------
|
||||||
|
# The Ekklesia hostname and the storage hostname (signed object URLs). In the default same-origin
|
||||||
|
# topology (Caddyfile.example) EKKLESIA_HOSTNAME serves the Dashboard, the API, and displays.
|
||||||
|
EKKLESIA_HOSTNAME=
|
||||||
|
EKKLESIA_STORAGE_HOSTNAME=
|
||||||
|
# The split topology only (ADR 0045, Caddyfile.split.example): the Dashboard's own hostname, while
|
||||||
|
# EKKLESIA_HOSTNAME is the API's and the displays'. Both must share one registrable domain, for
|
||||||
|
# example dash.example.org and api.example.org. Leave empty for the same-origin topology.
|
||||||
|
EKKLESIA_DASHBOARD_HOSTNAME=
|
||||||
|
# Contact address for the ACME account (certificate expiry notices).
|
||||||
|
EKKLESIA_ACME_EMAIL=
|
||||||
|
# EKKLESIA_CADDYFILE=./Caddyfile
|
||||||
|
# EKKLESIA_PUBLISH_ADDRESS=0.0.0.0
|
||||||
|
# EKKLESIA_HTTP_PORT=80
|
||||||
|
# EKKLESIA_HTTPS_PORT=443
|
||||||
|
|
||||||
|
# The edge network. Change the subnet only if it collides with one already on the host. Docker
|
||||||
|
# assigns dynamic addresses only from EKKLESIA_EDGE_DYNAMIC_RANGE (the lower half); the proxy and
|
||||||
|
# the tunnel connector take fixed addresses from the upper half, so nothing else can ever hold them.
|
||||||
|
# EKKLESIA_EDGE_SUBNET=10.249.250.0/24
|
||||||
|
# EKKLESIA_EDGE_DYNAMIC_RANGE=10.249.250.0/25
|
||||||
|
# EKKLESIA_PROXY_ADDRESS=10.249.250.254
|
||||||
|
# EKKLESIA_TUNNEL_ADDRESS=10.249.250.253
|
||||||
Vendored
+80
@@ -0,0 +1,80 @@
|
|||||||
|
# Ekklesia API and operator commands (`api` and `migrate`). Reference deployment, ADR 0044.
|
||||||
|
#
|
||||||
|
# Install as EKKLESIA_ENV_DIR/ekklesia.env (default /etc/ekklesia/ekklesia.env), owned by root with
|
||||||
|
# mode 600:
|
||||||
|
# install -o root -g root -m 600 env/ekklesia.env.example /etc/ekklesia/ekklesia.env
|
||||||
|
# Never commit the filled-in file. Generate secrets with scripts/gen-secrets.sh, and the database CA
|
||||||
|
# line with scripts/init-tls.sh.
|
||||||
|
#
|
||||||
|
# The image already sets NODE_ENV=production, EKKLESIA_API_HOST=0.0.0.0, EKKLESIA_API_PORT=3000, and
|
||||||
|
# EKKLESIA_MODULES_DIR=/var/lib/ekklesia/modules. Compose sets NODE_EXTRA_CA_CERTS and mounts module
|
||||||
|
# storage at that path. The proxy overlay sets the three EKKLESIA_CLIENT_ADDRESS_* variables. None of
|
||||||
|
# those belong here.
|
||||||
|
#
|
||||||
|
# Installed modules (ADR 0048) are read from EKKLESIA_MODULES_DIR, which must name the mounted
|
||||||
|
# directory. Move the host directory with EKKLESIA_MODULES_HOST_DIR in deploy/.env instead.
|
||||||
|
# EKKLESIA_MODULES_DIR=/var/lib/ekklesia/modules
|
||||||
|
#
|
||||||
|
# Values that are part of the reference topology are filled in; everything installation-specific is
|
||||||
|
# blank. Full reference: docs/development/api.md#required-configuration.
|
||||||
|
|
||||||
|
# --- Database (ADR 0018) ------------------------------------------------------------------------
|
||||||
|
# The host must be exactly `postgres`: the server certificate is issued for that name and
|
||||||
|
# verify-full checks it. User, password, and database must match postgres.env. Percent-encode any
|
||||||
|
# reserved character in the password. Never add sslmode or another TLS parameter to this URL.
|
||||||
|
DATABASE_URL=postgresql://<user>:<password>@postgres:5432/<database>
|
||||||
|
EKKLESIA_DATABASE_TLS_MODE=verify-full
|
||||||
|
# The installation-private CA as PEM text (not a path), on one double-quoted line with \n escapes.
|
||||||
|
# scripts/init-tls.sh prints this exact line. It is the only CA the database connection trusts.
|
||||||
|
EKKLESIA_DATABASE_CA=
|
||||||
|
|
||||||
|
# --- Identity (ADR 0011, ADR 0018, ADR 0034, ADR 0045) -----------------------------------------
|
||||||
|
# The public https origin of the API, with no path: the Ekklesia hostname in the same-origin
|
||||||
|
# topology, the API's hostname in the split one. Production rejects http and loopback origins.
|
||||||
|
EKKLESIA_IDENTITY_BASE_URL=
|
||||||
|
# Comma-separated exact origins allowed to make credentialed requests. The API's own origin is
|
||||||
|
# always trusted and need not be listed. Same-origin topology: the base URL. Split topology: the
|
||||||
|
# Dashboard's origin.
|
||||||
|
EKKLESIA_IDENTITY_TRUSTED_ORIGINS=
|
||||||
|
# At least 32 random characters. Rotating it signs every user out.
|
||||||
|
EKKLESIA_IDENTITY_SECRET=
|
||||||
|
# Same-origin topology: leave unset; the Dashboard is served from the base URL (ADR 0044).
|
||||||
|
# Split topology: the Dashboard's https origin, which must also be in the trusted origins. Emailed
|
||||||
|
# verification and reset links land here (ADR 0045).
|
||||||
|
# EKKLESIA_DASHBOARD_ORIGIN=
|
||||||
|
# Production refuses public sign-up. Leave unset.
|
||||||
|
# EKKLESIA_PUBLIC_SIGNUP_ENABLED=
|
||||||
|
|
||||||
|
# --- Client address (ADR 0017) ------------------------------------------------------------------
|
||||||
|
# Set by compose.proxy.yaml. Only a base-only deployment behind its own proxy sets these here, with
|
||||||
|
# that proxy's fixed address as the only trusted entry (deploy/README.md):
|
||||||
|
# EKKLESIA_CLIENT_ADDRESS_MODE=trusted-proxy
|
||||||
|
# EKKLESIA_CLIENT_ADDRESS_HEADER=x-forwarded-for
|
||||||
|
# EKKLESIA_CLIENT_ADDRESS_TRUSTED_PROXIES=<proxy address>/32
|
||||||
|
|
||||||
|
# --- Object storage (ADR 0030, ADR 0031) --------------------------------------------------------
|
||||||
|
# The API reaches SeaweedFS over HTTPS on the private network; production storage must be https.
|
||||||
|
S3_ENDPOINT=https://object-storage:8443
|
||||||
|
# The https origin browsers fetch signed object URLs from: the storage hostname on the proxy.
|
||||||
|
S3_DELIVERY_ENDPOINT=
|
||||||
|
# Must match object-storage.env.
|
||||||
|
S3_ACCESS_KEY=
|
||||||
|
S3_SECRET_KEY=
|
||||||
|
S3_REGION=us-east-1
|
||||||
|
S3_FORCE_PATH_STYLE=true
|
||||||
|
S3_BUCKET_PREFIX=ekklesia
|
||||||
|
|
||||||
|
# --- Outbound email (ADR 0037), optional --------------------------------------------------------
|
||||||
|
# Without a relay, email verification and password recovery cannot deliver. See
|
||||||
|
# docs/development/email.md.
|
||||||
|
# EKKLESIA_SMTP_HOST=
|
||||||
|
# EKKLESIA_SMTP_PORT=587
|
||||||
|
# EKKLESIA_SMTP_SECURITY=starttls
|
||||||
|
# EKKLESIA_SMTP_USERNAME=
|
||||||
|
# EKKLESIA_SMTP_PASSWORD=
|
||||||
|
# EKKLESIA_SMTP_FROM_ADDRESS=
|
||||||
|
# EKKLESIA_SMTP_FROM_NAME=Ekklesia
|
||||||
|
# EKKLESIA_SMTP_HELO_NAME=
|
||||||
|
|
||||||
|
# --- Module secrets (ADR 0029), as installed modules declare them --------------------------------
|
||||||
|
# EKKLESIA_MODULE_SECRET__<MODULE_ID>__<KEY>=
|
||||||
+4
@@ -0,0 +1,4 @@
|
|||||||
|
# SeaweedFS S3 credentials (`object-storage` only). Install as EKKLESIA_ENV_DIR/object-storage.env,
|
||||||
|
# owned by root with mode 600. Must match S3_ACCESS_KEY and S3_SECRET_KEY in ekklesia.env.
|
||||||
|
AWS_ACCESS_KEY_ID=
|
||||||
|
AWS_SECRET_ACCESS_KEY=
|
||||||
Vendored
+8
@@ -0,0 +1,8 @@
|
|||||||
|
# PostgreSQL superuser for the reference deployment (`postgres` only). Install as
|
||||||
|
# EKKLESIA_ENV_DIR/postgres.env, owned by root with mode 600. Read only on the database's first
|
||||||
|
# start; changing it later does not change the stored password.
|
||||||
|
#
|
||||||
|
# Must match the user, password, and database in ekklesia.env's DATABASE_URL.
|
||||||
|
POSTGRES_USER=ekklesia
|
||||||
|
POSTGRES_DB=ekklesia
|
||||||
|
POSTGRES_PASSWORD=
|
||||||
Vendored
+4
@@ -0,0 +1,4 @@
|
|||||||
|
# Cloudflare Tunnel connector token (`tunnel` only, compose.tunnel.yaml). Install as
|
||||||
|
# EKKLESIA_ENV_DIR/tunnel.env, owned by root with mode 600. Copy the token from the tunnel's page in
|
||||||
|
# the Cloudflare dashboard; it is the tunnel's credential.
|
||||||
|
TUNNEL_TOKEN=
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
# Host-based authentication for the reference deployment's PostgreSQL (ADR 0018, ADR 0044).
|
||||||
|
#
|
||||||
|
# Mounted read-only and selected with `-c hba_file=...` (deploy/compose.yaml), so it replaces the
|
||||||
|
# image's generated file entirely. Rules are matched top to bottom; the first match decides.
|
||||||
|
|
||||||
|
# TYPE DATABASE USER ADDRESS METHOD
|
||||||
|
|
||||||
|
# The container's own Unix socket: the image's first-start initialization and the health check.
|
||||||
|
# Nothing outside the container can reach it.
|
||||||
|
local all all trust
|
||||||
|
|
||||||
|
# Every network connection must be TLS (the server enforces TLS 1.3) and authenticate with SCRAM.
|
||||||
|
hostssl all all all scram-sha-256
|
||||||
|
|
||||||
|
# A network connection without TLS is refused outright, before authentication.
|
||||||
|
hostnossl all all all reject
|
||||||
Executable
+39
@@ -0,0 +1,39 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# Prints freshly generated secrets for the reference deployment's env files (ADR 0044). Writes
|
||||||
|
# nothing: paste each line into the file named above it, then install that file root-owned with
|
||||||
|
# mode 600.
|
||||||
|
#
|
||||||
|
# sh deploy/scripts/gen-secrets.sh
|
||||||
|
#
|
||||||
|
# Every value is URL-safe (hexadecimal), so the database password needs no percent-encoding in
|
||||||
|
# DATABASE_URL.
|
||||||
|
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
command -v openssl >/dev/null 2>&1 || {
|
||||||
|
echo "gen-secrets: openssl is required." >&2
|
||||||
|
exit 69
|
||||||
|
}
|
||||||
|
|
||||||
|
hex() {
|
||||||
|
openssl rand -hex "$1"
|
||||||
|
}
|
||||||
|
|
||||||
|
postgres_password=$(hex 24)
|
||||||
|
s3_access_key=$(hex 10)
|
||||||
|
s3_secret_key=$(hex 32)
|
||||||
|
|
||||||
|
cat <<EOF
|
||||||
|
# ekklesia.env
|
||||||
|
EKKLESIA_IDENTITY_SECRET=$(hex 32)
|
||||||
|
DATABASE_URL=postgresql://ekklesia:$postgres_password@postgres:5432/ekklesia
|
||||||
|
S3_ACCESS_KEY=$s3_access_key
|
||||||
|
S3_SECRET_KEY=$s3_secret_key
|
||||||
|
|
||||||
|
# postgres.env
|
||||||
|
POSTGRES_PASSWORD=$postgres_password
|
||||||
|
|
||||||
|
# object-storage.env
|
||||||
|
AWS_ACCESS_KEY_ID=$s3_access_key
|
||||||
|
AWS_SECRET_ACCESS_KEY=$s3_secret_key
|
||||||
|
EOF
|
||||||
Executable
+148
@@ -0,0 +1,148 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# Creates the installation-private CA (once) and the two server certificates the reference
|
||||||
|
# deployment needs (ADR 0044): `postgres` for the database and `object-storage` for SeaweedFS.
|
||||||
|
#
|
||||||
|
# sudo sh deploy/scripts/init-tls.sh /etc/ekklesia/tls
|
||||||
|
#
|
||||||
|
# Idempotent. An existing key or certificate is never overwritten: delete a file deliberately to
|
||||||
|
# reissue it. Ownership and modes are re-applied on every run, which needs root; a non-root run
|
||||||
|
# creates what is missing and then stops before that step, saying so. No private key is ever
|
||||||
|
# printed.
|
||||||
|
#
|
||||||
|
# Output (in the directory given):
|
||||||
|
# ca.key the CA's private key. Root only. Needed only to issue certificates; keep a
|
||||||
|
# copy offline and consider removing it from the server afterwards.
|
||||||
|
# ca.crt the CA certificate. Public. Mounted into api, migrate, and proxy.
|
||||||
|
# postgres.crt/.key for the name `postgres`; the key is owned by PostgreSQL's uid (70).
|
||||||
|
# object-storage.crt/.key for the name `object-storage`; the key is owned by SeaweedFS's uid (1000).
|
||||||
|
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
umask 077
|
||||||
|
|
||||||
|
usage() {
|
||||||
|
echo "Usage: sh init-tls.sh <output directory>" >&2
|
||||||
|
exit 64
|
||||||
|
}
|
||||||
|
|
||||||
|
[ "$#" -eq 1 ] || usage
|
||||||
|
out=$1
|
||||||
|
case "$out" in
|
||||||
|
-*) usage ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
# The uids the two images run their servers as: postgres:18.4-alpine3.24 (postgres) and
|
||||||
|
# chrislusf/seaweedfs:4.28 (seaweed). Each server refuses or fails to read a key it cannot own.
|
||||||
|
POSTGRES_UID=${EKKLESIA_POSTGRES_UID:-70}
|
||||||
|
SEAWEED_UID=${EKKLESIA_SEAWEED_UID:-1000}
|
||||||
|
|
||||||
|
CA_DAYS=3650
|
||||||
|
SERVER_DAYS=825
|
||||||
|
|
||||||
|
mkdir -p "$out"
|
||||||
|
chmod 700 "$out"
|
||||||
|
|
||||||
|
created=""
|
||||||
|
|
||||||
|
# Only generating needs openssl. A later root-only pass that applies ownership to files that already
|
||||||
|
# exist runs without it, in any image that has a POSIX shell.
|
||||||
|
require_openssl() {
|
||||||
|
command -v openssl >/dev/null 2>&1 || {
|
||||||
|
echo "init-tls: openssl is required to create keys and certificates." >&2
|
||||||
|
exit 69
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
note_created() {
|
||||||
|
created="$created $1"
|
||||||
|
}
|
||||||
|
|
||||||
|
# A file this script would create already exists: keep it, and say so.
|
||||||
|
exists() {
|
||||||
|
if [ -e "$out/$1" ]; then
|
||||||
|
echo "init-tls: keeping existing $out/$1"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
if ! exists ca.key; then
|
||||||
|
require_openssl
|
||||||
|
openssl ecparam -name prime256v1 -genkey -noout -out "$out/ca.key"
|
||||||
|
note_created ca.key
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! exists ca.crt; then
|
||||||
|
require_openssl
|
||||||
|
openssl req -x509 -new -sha256 -days "$CA_DAYS" -key "$out/ca.key" -out "$out/ca.crt" \
|
||||||
|
-subj "/CN=Ekklesia installation CA" \
|
||||||
|
-addext "basicConstraints=critical,CA:TRUE,pathlen:0" \
|
||||||
|
-addext "keyUsage=critical,keyCertSign,cRLSign" \
|
||||||
|
-addext "subjectKeyIdentifier=hash"
|
||||||
|
note_created ca.crt
|
||||||
|
fi
|
||||||
|
|
||||||
|
# issue <name>: a key and a certificate whose only subject alternative name is <name>, the Compose
|
||||||
|
# service name the client verifies against.
|
||||||
|
issue() {
|
||||||
|
name=$1
|
||||||
|
if ! exists "$name.key"; then
|
||||||
|
require_openssl
|
||||||
|
openssl ecparam -name prime256v1 -genkey -noout -out "$out/$name.key"
|
||||||
|
note_created "$name.key"
|
||||||
|
fi
|
||||||
|
if ! exists "$name.crt"; then
|
||||||
|
require_openssl
|
||||||
|
extensions=$(mktemp)
|
||||||
|
request=$(mktemp)
|
||||||
|
trap 'rm -f "$extensions" "$request"' EXIT
|
||||||
|
cat >"$extensions" <<EOF
|
||||||
|
basicConstraints=critical,CA:FALSE
|
||||||
|
keyUsage=critical,digitalSignature
|
||||||
|
extendedKeyUsage=serverAuth
|
||||||
|
subjectAltName=DNS:$name
|
||||||
|
subjectKeyIdentifier=hash
|
||||||
|
authorityKeyIdentifier=keyid
|
||||||
|
EOF
|
||||||
|
openssl req -new -sha256 -key "$out/$name.key" -subj "/CN=$name" -out "$request"
|
||||||
|
openssl x509 -req -sha256 -days "$SERVER_DAYS" -in "$request" \
|
||||||
|
-CA "$out/ca.crt" -CAkey "$out/ca.key" -CAcreateserial -CAserial "$out/ca.srl" \
|
||||||
|
-extfile "$extensions" -out "$out/$name.crt" 2>/dev/null
|
||||||
|
rm -f "$extensions" "$request"
|
||||||
|
trap - EXIT
|
||||||
|
note_created "$name.crt"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
issue postgres
|
||||||
|
issue object-storage
|
||||||
|
|
||||||
|
# Public material is world-readable; private keys are readable only by the process that needs them.
|
||||||
|
chmod 644 "$out/ca.crt" "$out/postgres.crt" "$out/object-storage.crt"
|
||||||
|
chmod 600 "$out/ca.key" "$out/postgres.key" "$out/object-storage.key"
|
||||||
|
[ -e "$out/ca.srl" ] && chmod 600 "$out/ca.srl"
|
||||||
|
|
||||||
|
if [ -n "$created" ]; then
|
||||||
|
echo "init-tls: created$created in $out"
|
||||||
|
else
|
||||||
|
echo "init-tls: nothing to create in $out"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$(id -u)" -ne 0 ]; then
|
||||||
|
echo "init-tls: ownership not applied: re-run as root to give postgres.key to uid $POSTGRES_UID and object-storage.key to uid $SEAWEED_UID." >&2
|
||||||
|
exit 77
|
||||||
|
fi
|
||||||
|
|
||||||
|
chown root:root "$out" "$out/ca.key" "$out/ca.crt" "$out/postgres.crt" "$out/object-storage.crt"
|
||||||
|
[ -e "$out/ca.srl" ] && chown root:root "$out/ca.srl"
|
||||||
|
chown "$POSTGRES_UID:$POSTGRES_UID" "$out/postgres.key"
|
||||||
|
chown "$SEAWEED_UID:$SEAWEED_UID" "$out/object-storage.key"
|
||||||
|
# The directory itself must be traversable by the two server uids for their bind mounts.
|
||||||
|
chmod 755 "$out"
|
||||||
|
|
||||||
|
echo "init-tls: postgres.key is owned by uid $POSTGRES_UID and object-storage.key by uid $SEAWEED_UID (mode 600)."
|
||||||
|
echo
|
||||||
|
echo "Set EKKLESIA_TLS_DIR=$out in deploy/.env, and put this line in ekklesia.env (the CA"
|
||||||
|
echo "certificate is public; this is not a secret):"
|
||||||
|
echo
|
||||||
|
printf 'EKKLESIA_DATABASE_CA="%s"\n' "$(awk 'BEGIN { ORS = "\\n" } { print }' "$out/ca.crt")"
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
# 0044: Ekklesia ships one self-hosted reference deployment, composed from pinned sources
|
# 0044: Ekklesia ships one self-hosted reference deployment, composed from pinned sources
|
||||||
|
|
||||||
- Status: Proposed
|
- Status: Accepted
|
||||||
- Date: 2026-10-01
|
- Date: 2026-10-01
|
||||||
- Supersedes: none
|
- Supersedes: none
|
||||||
- Related: [ADR 0003](0003-development-infrastructure.md),
|
- Related: [ADR 0003](0003-development-infrastructure.md),
|
||||||
@@ -48,7 +48,7 @@ One host, one Compose project, four long-running services on a private network:
|
|||||||
|
|
||||||
| Service | Role |
|
| Service | Role |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `proxy` | Caddy. Terminates TLS, serves the only published ports (443, 80 for redirect), routes `/api/*`, `/display/*`, `/assets/*` and the Dashboard to `api`, and a separate storage origin to `object-storage`. |
|
| `proxy` | Caddy. Terminates TLS, serves the only published ports (443; and 80, which answers ACME HTTP-01 challenges and redirects everything else to HTTPS), routes `/api/*`, `/display/*`, `/assets/*` and the Dashboard to `api`, and a separate storage origin to `object-storage`. |
|
||||||
| `api` | The `apps/api` image: Core, the enabled modules, and the built Dashboard and display host. Listens on the private network only. |
|
| `api` | The `apps/api` image: Core, the enabled modules, and the built Dashboard and display host. Listens on the private network only. |
|
||||||
| `postgres` | PostgreSQL 18 with TLS enabled, using a server certificate issued by an installation-private CA. |
|
| `postgres` | PostgreSQL 18 with TLS enabled, using a server certificate issued by an installation-private CA. |
|
||||||
| `object-storage` | SeaweedFS, the same image family as development, reachable by browsers only through the proxy. |
|
| `object-storage` | SeaweedFS, the same image family as development, reachable by browsers only through the proxy. |
|
||||||
@@ -65,21 +65,31 @@ server. The proxy never serves files from disk.
|
|||||||
### TLS everywhere, without weakening ADR 0018
|
### TLS everywhere, without weakening ADR 0018
|
||||||
|
|
||||||
- **Browser-facing:** the proxy obtains publicly trusted certificates through ACME. The reference
|
- **Browser-facing:** the proxy obtains publicly trusted certificates through ACME. The reference
|
||||||
configuration uses the HTTP-01 challenge; the DNS-01 challenge is documented for installations
|
configuration uses the HTTP-01 challenge on port 80; the DNS-01 challenge is documented for
|
||||||
that keep Ekklesia off the public Internet. A LAN-only installation can still hold a real
|
installations that keep Ekklesia off the public Internet. A LAN-only installation can still hold a
|
||||||
certificate for an internal hostname this way.
|
real certificate for an internal hostname this way. DNS-01 requires a Caddy build that includes a
|
||||||
|
DNS provider module, which the stock Caddy image does not; such an installation builds and pins
|
||||||
|
its own proxy image. A tunnel-only installation needs DNS-01 too: Cloudflare terminates TLS at its
|
||||||
|
edge, so an HTTP-01 challenge cannot reach the proxy through the tunnel.
|
||||||
- **Tunnel ingress (optional):** an installation may publish the proxy through an outbound tunnel
|
- **Tunnel ingress (optional):** an installation may publish the proxy through an outbound tunnel
|
||||||
(the reference documents `cloudflared`) instead of opening inbound ports. The tunnel connector
|
(the reference documents `cloudflared`) instead of opening inbound ports. The tunnel connector
|
||||||
is a separate Compose service that connects to the proxy over HTTPS and verifies the proxy's
|
is a separate Compose service that connects to the proxy over HTTPS and verifies the proxy's
|
||||||
certificate. The proxy accepts a tunnel-supplied client-address header **only** from the
|
certificate. With a token-managed tunnel, those origin settings (the service `https://proxy:443`,
|
||||||
connector's network identity. It overwrites the single forwarding header it sends to `api`, and
|
the origin server name, and TLS verification left on) live in the Cloudflare dashboard, not in the
|
||||||
`api` trusts only the proxy. An installation that serves local displays should give the same
|
repository; the deployment README documents them. The proxy accepts a tunnel-supplied
|
||||||
hostname a LAN path to the proxy (split-horizon DNS), so displays do not depend on the
|
client-address header **only** from the connector's network identity. It overwrites the single
|
||||||
Internet link.
|
forwarding header it sends to `api`, and `api` trusts only the proxy. An installation that serves
|
||||||
|
local displays should give the same hostname a LAN path to the proxy (split-horizon DNS), so
|
||||||
|
displays do not depend on the Internet link.
|
||||||
- **Database:** the deployment's setup script creates an installation-private CA once, issues a
|
- **Database:** the deployment's setup script creates an installation-private CA once, issues a
|
||||||
server certificate for the `postgres` service name, and gives the API only the CA certificate
|
server certificate for the `postgres` service name, and gives the API only the CA certificate
|
||||||
(`EKKLESIA_DATABASE_CA`). The API connects with `verify-full`. No plaintext or encryption-only
|
(`EKKLESIA_DATABASE_CA`). The API connects with `verify-full`. No plaintext or encryption-only
|
||||||
exception is added to Core.
|
exception is added to Core.
|
||||||
|
- **Object storage:** production storage must be `https` (ADR 0030), so the API reaches SeaweedFS
|
||||||
|
over HTTPS on the private network as well. The same setup script issues a server certificate for
|
||||||
|
the `object-storage` service name from the same CA. Core's storage client trusts Node's default CA
|
||||||
|
set, so the API adds the installation CA to it with `NODE_EXTRA_CA_CERTS`. No storage TLS
|
||||||
|
exception is added to Core either.
|
||||||
|
|
||||||
### Object storage
|
### Object storage
|
||||||
|
|
||||||
@@ -134,8 +144,9 @@ an installation is treated as in production.
|
|||||||
|
|
||||||
### Secrets
|
### Secrets
|
||||||
|
|
||||||
Secrets are supplied to Compose from a root-owned environment file on the host, never committed.
|
Secrets are supplied to Compose from root-owned environment files on the host, never committed:
|
||||||
This includes `EKKLESIA_IDENTITY_SECRET`, database and storage credentials, SMTP credentials, and
|
one per service, so no container receives another service's credentials. They include
|
||||||
|
`EKKLESIA_IDENTITY_SECRET`, database and storage credentials, SMTP credentials, and
|
||||||
`EKKLESIA_MODULE_SECRET__*` values. A secret manager is not required.
|
`EKKLESIA_MODULE_SECRET__*` values. A secret manager is not required.
|
||||||
|
|
||||||
## Consequences
|
## Consequences
|
||||||
|
|||||||
@@ -0,0 +1,233 @@
|
|||||||
|
# 0045: The Dashboard and the API may live on separate origins
|
||||||
|
|
||||||
|
- Status: Accepted
|
||||||
|
- Date: 2026-10-03
|
||||||
|
- Amends: [0034](0034-host-owned-browser-authentication.md)
|
||||||
|
- Extends: [0044](0044-self-hosted-reference-deployment.md)
|
||||||
|
- Related: [ADR 0011](0011-identity-http-transport-and-public-enrollment.md),
|
||||||
|
[ADR 0012](0012-identity-origin-csrf-protection-under-test.md),
|
||||||
|
[ADR 0018](0018-identity-session-resolution-and-database-transport.md),
|
||||||
|
[ADR 0025](0025-trusted-module-ui-asset-publication.md),
|
||||||
|
[ADR 0032](0032-native-module-realtime-transport.md),
|
||||||
|
[ADR 0033](0033-display-kiosk-host-contract.md),
|
||||||
|
[ADR 0036](0036-managed-device-identity-and-pairing.md),
|
||||||
|
[ADR 0038](0038-email-verification-and-password-recovery.md),
|
||||||
|
[ADR 0043](0043-display-offline-code-fallback.md)
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
ADR 0044's reference deployment serves everything a browser needs from one origin: the Dashboard at
|
||||||
|
`/`, the API at `/api/*`, and the display host at `/display/*`. ADR 0034 kept a deliberately split
|
||||||
|
browser/API origin possible through the exact trusted-origin allowlist and credentialed CORS of
|
||||||
|
ADR 0011, ADR 0012, and ADR 0018, but left that topology undesigned: "any eventual choice to place
|
||||||
|
the API and Dashboard on different sibling subdomains must be designed and tested rather than
|
||||||
|
assumed to share a session."
|
||||||
|
|
||||||
|
The first production installation needs exactly that: the Dashboard and the API on sibling
|
||||||
|
hostnames (for example `dash.example.org` and `api.example.org`), plus the existing storage
|
||||||
|
hostname. The choice has to be made before the first deployment, because a display's bookmark and a
|
||||||
|
paired device's cookies are bound to a hostname: moving them later re-pairs every screen.
|
||||||
|
|
||||||
|
Most of the platform was already written for this. The shell sends every platform and module request
|
||||||
|
with `credentials: "include"` and opens realtime with `withCredentials: true`; CORS on `/api/auth/*`
|
||||||
|
and `/api/v1/*` reflects only exact trusted origins with credentials; module artifacts are anonymous
|
||||||
|
by design so that cross-origin `import()` works (ADR 0025); emailed links already pass through the
|
||||||
|
API and land on `EKKLESIA_DASHBOARD_ORIGIN` (ADR 0038); and display page URLs are resolved against the
|
||||||
|
API's origin. Four things were not:
|
||||||
|
|
||||||
|
- the Dashboard learns its API origin at **build** time (`EKKLESIA_API_BASE_URL`), so one image
|
||||||
|
cannot serve both topologies;
|
||||||
|
- the API's own set of trusted browser origins is exactly `EKKLESIA_IDENTITY_TRUSTED_ORIGINS`, while
|
||||||
|
Better Auth implicitly adds the identity base URL to its own: a page served by the API itself would
|
||||||
|
fail the API's origin check unless an operator listed the API's own origin too;
|
||||||
|
- the reference proxy has no routing for two browser-facing hostnames;
|
||||||
|
- nothing proves a split deployment end to end, and a Node.js client cannot prove what matters most
|
||||||
|
here: what a real browser sends across the two origins.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
### Two topologies, one image
|
||||||
|
|
||||||
|
An installation runs in exactly one of two shapes, chosen by configuration alone:
|
||||||
|
|
||||||
|
| Topology | Dashboard | API and display host | Storage |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| **Same-origin** (default) | `https://ekklesia.example.org/` | `https://ekklesia.example.org/api/*`, `/display/*` | its own hostname |
|
||||||
|
| **Split** | `https://dash.example.org/` | `https://api.example.org/api/*`, `/display/*` | its own hostname |
|
||||||
|
|
||||||
|
Same-origin remains the default and keeps working unchanged for every installation that does not
|
||||||
|
opt in. The same image and the same built Dashboard serve both.
|
||||||
|
|
||||||
|
In both, the API process serves the Dashboard's bytes (ADR 0044 is unchanged in that). In the split
|
||||||
|
topology the proxy forwards the Dashboard hostname's two Dashboard paths to the same `api` service;
|
||||||
|
the API does not inspect `Host`, and which hostname serves which path is the proxy's job, as every
|
||||||
|
other perimeter rule already is.
|
||||||
|
|
||||||
|
### The Dashboard learns its API origin at run time
|
||||||
|
|
||||||
|
The API writes its own origin into the Dashboard page it serves, as one meta element:
|
||||||
|
|
||||||
|
```html
|
||||||
|
<meta name="ekklesia-api-origin" content="https://api.example.org" />
|
||||||
|
```
|
||||||
|
|
||||||
|
The value is the identity base URL's origin (`EKKLESIA_IDENTITY_BASE_URL`), which is by definition
|
||||||
|
where `/api` lives; no new setting is introduced. The built page carries the element with an empty
|
||||||
|
value; the API fills it once at startup, before computing the page's strong `ETag`, and refuses to
|
||||||
|
start if the built page does not contain exactly one such element. The shell reads it before
|
||||||
|
anything renders, accepts only an absolute `http(s)` origin with no path, and otherwise fails closed
|
||||||
|
with its existing sanitized bootstrap failure. An empty value (a static development server with no
|
||||||
|
API) means the page's own origin.
|
||||||
|
|
||||||
|
A meta element rather than an inline script keeps the page free of inline script, so a future strict
|
||||||
|
`script-src` needs no exception. The build-time `EKKLESIA_API_BASE_URL` is removed; the development
|
||||||
|
server injects the same element instead. The display host needs neither: it is always served from
|
||||||
|
the API's origin (below) and uses its own.
|
||||||
|
|
||||||
|
### The display host lives on the API's origin
|
||||||
|
|
||||||
|
In the split topology the display host stays where it is: `https://api.example.org/display/<module>`,
|
||||||
|
with its script under `/assets/ekklesia-display/` and its service worker at `/display/sw.js`. A
|
||||||
|
screen's bookmark names the API hostname.
|
||||||
|
|
||||||
|
- **Device cookies are already bound to the API's hostname.** They are host-only, `HttpOnly`, and
|
||||||
|
`SameSite=Strict` (ADR 0036), and they must accompany device requests to the API, so they live on
|
||||||
|
whichever hostname answers those requests. Serving the display page from that same origin makes it
|
||||||
|
the only hostname a screen depends on; serving it anywhere else adds a second one.
|
||||||
|
- **Offline code fallback is unchanged.** The display's service worker never caches a cross-origin
|
||||||
|
response (ADR 0043), which is what keeps signed storage URLs, bearer capabilities, out of the
|
||||||
|
cache. On the API's origin, every artifact it must cache is same-origin. On any other origin, every
|
||||||
|
artifact would be cross-origin, and the offline guarantee would need a weaker rule.
|
||||||
|
- **No device request crosses an origin.** Pairing, heartbeats, descriptors, module routes, and
|
||||||
|
realtime from an unattended screen stay same-origin, with no CORS dependence.
|
||||||
|
|
||||||
|
The Dashboard keeps linking to display pages exactly as now: their URLs are resolved against the API
|
||||||
|
origin.
|
||||||
|
|
||||||
|
### The API trusts its own origin
|
||||||
|
|
||||||
|
The API's set of trusted browser origins, used for CORS on `/api/auth/*` and `/api/v1/*` and by every
|
||||||
|
mutation route's origin check, becomes `EKKLESIA_IDENTITY_TRUSTED_ORIGINS` **plus the identity base
|
||||||
|
URL's origin**, matching what Better Auth already does for its own checks. In the split topology the
|
||||||
|
display page on the API's origin therefore passes the API's origin check without an operator having
|
||||||
|
to list it. Trusted origins remain exact: no wildcard, no pattern, and no parent domain.
|
||||||
|
|
||||||
|
### Cookies and identity: unchanged, with one stated requirement
|
||||||
|
|
||||||
|
The session cookie keeps every property ADR 0034 gave it: `HttpOnly`, `SameSite=Lax`, `Secure` under
|
||||||
|
HTTPS, `Path=/`, and **host-only on the API's hostname**. No `Domain` attribute is set, and the
|
||||||
|
Dashboard's hostname never holds a session credential. The Dashboard reaches the API with credentialed
|
||||||
|
CORS, and browsers send the cookie because the two hostnames are different origins of the **same
|
||||||
|
site**.
|
||||||
|
|
||||||
|
That is a requirement of the split topology: the Dashboard and API hostnames must share a registrable
|
||||||
|
domain (`dash.example.org` and `api.example.org` do; `dash.example.org` and `api.example.net` do not).
|
||||||
|
Across sites, browsers withhold `SameSite=Lax` cookies from cross-origin requests and may block or
|
||||||
|
partition third-party cookies altogether. The documentation states it, and Doctor reports the
|
||||||
|
topology and warns when the two hostnames are not siblings under one parent domain, because Doctor
|
||||||
|
cannot see the Public Suffix List and says so.
|
||||||
|
|
||||||
|
In the split topology `EKKLESIA_IDENTITY_BASE_URL` is the API's origin, `EKKLESIA_DASHBOARD_ORIGIN` is
|
||||||
|
the Dashboard's, and `EKKLESIA_IDENTITY_TRUSTED_ORIGINS` lists the Dashboard's. Email verification and
|
||||||
|
password reset links already point at the API and land on `EKKLESIA_DASHBOARD_ORIGIN` (ADR 0038);
|
||||||
|
nothing about them changes.
|
||||||
|
|
||||||
|
### CORS stays narrow, and that limit is part of the module contract
|
||||||
|
|
||||||
|
Credentialed CORS keeps its current shape: exact reflected origin, `Access-Control-Allow-Credentials:
|
||||||
|
true`, the existing method list, `Content-Type` as the only allowed request header beyond the
|
||||||
|
CORS-safelisted ones, and no exposed response headers beyond the safelisted ones. The API does not
|
||||||
|
reflect arbitrary requested headers.
|
||||||
|
|
||||||
|
A module UI's requests go through the host's `api.request` and inherit this policy. The module UI
|
||||||
|
contract therefore states it: portable module UI code uses only CORS-safelisted request headers plus
|
||||||
|
`Content-Type`, and reads only CORS-safelisted response headers. That is already true of every
|
||||||
|
existing module, and it keeps one module artifact working under either topology. Widening it for a
|
||||||
|
real module need is a later, explicit change.
|
||||||
|
|
||||||
|
### Routing per hostname
|
||||||
|
|
||||||
|
The reference proxy gains a second configuration for the split topology, beside the existing one:
|
||||||
|
|
||||||
|
| Hostname | Forwarded to `api` | Refused with `404` |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Dashboard | `/` (emailed links add only a query) and `/assets/ekklesia-dashboard/*` | everything else, including `/api/*` |
|
||||||
|
| API | `/api/*` (realtime flushed per event, as now), `/display/*`, `/assets/ekklesia-display/*` | everything else, including `/` and `/assets/ekklesia-dashboard/*` |
|
||||||
|
| Storage | unchanged (ADR 0031) | unchanged |
|
||||||
|
|
||||||
|
The Dashboard hostname refuses `/api/*` so that the session cookie has exactly one home: a Dashboard
|
||||||
|
cannot fall back to a same-origin API on its own hostname. The API hostname answers nothing at `/`.
|
||||||
|
Both hostnames keep every forwarding-header rule ADR 0044 established (one overwritten
|
||||||
|
`X-Forwarded-For`, the stripped client-chosen headers, and tunnel trust only from the connector's
|
||||||
|
address). The proxy overlay takes the Dashboard hostname from one more setting, which the split
|
||||||
|
configuration requires and the same-origin configuration ignores.
|
||||||
|
|
||||||
|
### What a future Content-Security-Policy must allow
|
||||||
|
|
||||||
|
This decision does not add a Content-Security-Policy; the Dashboard page keeps `frame-ancestors
|
||||||
|
'none'` and its other headers. When a full policy is designed, the split topology requires the
|
||||||
|
Dashboard's to allow the API origin in `script-src` (module artifacts and contributions are imported
|
||||||
|
from it) and `connect-src` (platform and module requests, realtime), and the storage origin in
|
||||||
|
`img-src`. The display page, being same-origin with the API, needs only its own origin and the
|
||||||
|
storage origin.
|
||||||
|
|
||||||
|
### Both topologies are proven end to end
|
||||||
|
|
||||||
|
The deployment smoke test runs in either topology, and CI runs both. The split run proves, through
|
||||||
|
the proxy, the routing table above on every hostname, the API origin in the Dashboard page, exact
|
||||||
|
CORS reflection with credentials for the Dashboard origin and none for an untrusted origin, and the
|
||||||
|
host-only session cookie.
|
||||||
|
|
||||||
|
Because those are browser behaviors, the split run also drives a **real browser**: a headless
|
||||||
|
Chromium from an image pinned by digest, controlled over the Chrome DevTools Protocol by the
|
||||||
|
smoke-test client with no added dependency. In it, a person signs in through the real form on the
|
||||||
|
Dashboard hostname, the session cookie is present only for the API hostname, the Dashboard imports a
|
||||||
|
module's UI from the API hostname and the module's own credentialed requests succeed under CORS, no
|
||||||
|
request is refused, the module's display page mounts on the API hostname with its service worker, and
|
||||||
|
sign-out ends the session. The split run installs the repository's module template as a test-only
|
||||||
|
module so these paths exist; installations' own smoke tests cover their real modules. Contribution
|
||||||
|
artifacts are imported exactly as UI artifacts are, and the API's tests cover their CORS headers and
|
||||||
|
those of realtime streams.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
- One image serves both topologies; an installation chooses with configuration and the matching proxy
|
||||||
|
configuration.
|
||||||
|
- Same-origin installations change nothing.
|
||||||
|
- A split installation depends on exactly two browser-facing hostnames plus storage. Screens and
|
||||||
|
paired devices depend only on the API hostname, which therefore must not change once screens are
|
||||||
|
paired.
|
||||||
|
- No credential is broadened: the session cookie stays host-only on the API hostname, device cookies
|
||||||
|
stay host-only and strict, and trusted origins stay exact.
|
||||||
|
- Module UI code is held to CORS-safe headers in both topologies, so a module that works in one
|
||||||
|
works in the other.
|
||||||
|
- CI gains a second deployment smoke run and a browser in it.
|
||||||
|
|
||||||
|
## Alternatives
|
||||||
|
|
||||||
|
- **A parent-domain session cookie (`Domain=example.org`).** Rejected, as ADR 0034 already rejected
|
||||||
|
it: it sends the credential to every sibling host and is unnecessary between same-site origins.
|
||||||
|
- **The display host on the Dashboard hostname, or on its own hostname.** Rejected: device cookies
|
||||||
|
are bound to the API's hostname regardless, so either adds a second hostname every screen depends
|
||||||
|
on, puts every device request behind CORS, and breaks ADR 0043's same-origin-only offline cache.
|
||||||
|
- **The API serving the display host and the device API on a second hostname.** Rejected: two API
|
||||||
|
origins for one identity base URL, with the same cookies split between them.
|
||||||
|
- **Inject the API origin with an inline script, or a configuration request.** Rejected: an inline
|
||||||
|
script needs a CSP exception, and a request is a round trip and a failure mode before the first
|
||||||
|
render; a meta element is static and inert.
|
||||||
|
- **Keep the API origin a build-time setting.** Rejected: the image would have to be built per
|
||||||
|
installation and per topology.
|
||||||
|
- **Require operators to list the API's own origin in the trusted origins.** Rejected: an omission
|
||||||
|
silently breaks every display mutation; Better Auth already trusts its own base URL.
|
||||||
|
- **Reflect any requested CORS header.** Rejected for now: no module needs it, and a module that
|
||||||
|
relied on it would be tied to its host's CORS policy.
|
||||||
|
- **Prove split mode with HTTP checks only.** Rejected: whether a browser sends the cookie, imports
|
||||||
|
a cross-origin module, and opens a credentialed stream is exactly what is at stake.
|
||||||
|
|
||||||
|
## Deferred
|
||||||
|
|
||||||
|
- A full Content-Security-Policy, with the requirements above.
|
||||||
|
- A broader CORS header policy, when a module needs one.
|
||||||
|
- More than one Dashboard origin per installation.
|
||||||
|
- Moving an installation between topologies, and the re-pairing that moving the API hostname
|
||||||
|
implies.
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
# 0046: Ekklesia's branding is never forced on an installation
|
||||||
|
|
||||||
|
- Status: Proposed
|
||||||
|
- Date: 2026-10-03
|
||||||
|
- Related: [ADR 0021](0021-modules-as-hostable-products.md),
|
||||||
|
[ADR 0034](0034-host-owned-browser-authentication.md),
|
||||||
|
[ADR 0037](0037-host-provided-outbound-email.md),
|
||||||
|
[ADR 0038](0038-email-verification-and-password-recovery.md),
|
||||||
|
[ADR 0039](0039-shared-design-tokens-and-elements.md),
|
||||||
|
[ADR 0043](0043-display-offline-code-fallback.md),
|
||||||
|
[ADR 0045](0045-split-dashboard-and-api-origins.md)
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
Ekklesia is software a church runs for its own people. The people who use an installation (members,
|
||||||
|
visitors, volunteers running a screen) know the church, not the software behind it, and the church
|
||||||
|
decides how it presents itself to them. Today the platform names itself in places those people see:
|
||||||
|
the sign-in screen asks them to "Sign in to Ekklesia", the Dashboard's heading and page title read
|
||||||
|
"Ekklesia", emails ask them to verify "your Ekklesia email address", and an offline screen in the
|
||||||
|
sanctuary says it is "waiting for Ekklesia".
|
||||||
|
|
||||||
|
None of those were decided; they accumulated. This records the rule they should have followed, so
|
||||||
|
every later screen, message, and module is held to it.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
### The principle
|
||||||
|
|
||||||
|
**Ekklesia's branding is never forced on an installation.** Anything a member, visitor, or volunteer
|
||||||
|
can see is configurable by the installation and removable entirely. That includes, and is not
|
||||||
|
limited to:
|
||||||
|
|
||||||
|
- page titles, headings, and the name of the application in a browser tab, an installed web app, or
|
||||||
|
a bookmark;
|
||||||
|
- sign-in, verification, recovery, and every other authentication screen;
|
||||||
|
- emails: the sender's display name, the subject, and the body;
|
||||||
|
- display screens, pairing screens, and offline and failure screens on a display;
|
||||||
|
- error pages and error messages a person reads;
|
||||||
|
- application names and icons, and any "powered by" or attribution text.
|
||||||
|
|
||||||
|
"Removable entirely" means an installation can show its own name, or no product name at all, and in
|
||||||
|
either case no screen or message a person sees names Ekklesia.
|
||||||
|
|
||||||
|
### What is exempt
|
||||||
|
|
||||||
|
Internal identifiers are not branding and stay as they are: package names, module ids, URL paths
|
||||||
|
(`/api/v1/…`, `/assets/ekklesia-dashboard/…`), HTTP header names, environment variable names, cookie
|
||||||
|
names, CSS custom property names, element ids and `data-` attributes, storage, cache, and database
|
||||||
|
names, and log field names. A person can find them only by opening developer tools or reading
|
||||||
|
configuration, and renaming them would break installations for no one's benefit.
|
||||||
|
|
||||||
|
Tooling an operator runs to administer the installation (the command-line tools, Doctor, server logs,
|
||||||
|
and startup messages) names the software being operated, as a package name does, and is exempt too.
|
||||||
|
So are messages written only for developers: thrown errors that reach a console and never a page, and
|
||||||
|
build-time diagnostics for module authors.
|
||||||
|
|
||||||
|
### How installations configure it
|
||||||
|
|
||||||
|
The platform takes the installation's name from configuration, once, and uses it everywhere a
|
||||||
|
person-facing string would otherwise have said "Ekklesia". When no name is configured, the wording is
|
||||||
|
neutral ("Sign in", "Verify your email address", "Waiting for the server") rather than falling back to
|
||||||
|
"Ekklesia". The default installation therefore names nothing, which is what makes the principle
|
||||||
|
checkable: a test can render every person-facing screen and message with no name configured and fail
|
||||||
|
on any occurrence of "Ekklesia".
|
||||||
|
|
||||||
|
Browser pages receive the name the way they receive the API origin (ADR 0045): the API renders it
|
||||||
|
into the page it serves at startup, so the image stays generic and nothing is decided at build time.
|
||||||
|
An email's sender display name is the existing `EKKLESIA_SMTP_FROM_NAME` (ADR 0037); its subject and
|
||||||
|
body use the same installation name.
|
||||||
|
|
||||||
|
Visual identity follows the same rule. The platform ships no logo, favicon, or icon today; any it
|
||||||
|
ships later must be replaceable by the installation and removable entirely, and must not appear by
|
||||||
|
default. Colours and type are the installation's theme (ADR 0039), not a mark.
|
||||||
|
|
||||||
|
### It applies to modules
|
||||||
|
|
||||||
|
A module's user-visible text follows the same principle. A module that names its host ("Open the
|
||||||
|
Ekklesia Dashboard") also ties itself to one host, which ADR 0021 already asks modules not to do.
|
||||||
|
Modules refer to the host generically ("the Dashboard") or not at all.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
- Every person-facing string that names the platform today becomes either the configured name or
|
||||||
|
neutral wording. An implementation slice makes that change and adds the test that keeps it true.
|
||||||
|
- New screens, emails, and modules are reviewed against this rule; "Ekklesia" in a person-facing
|
||||||
|
string is a defect.
|
||||||
|
- Internal identifiers, operator tooling, and developer diagnostics keep the name, so nothing
|
||||||
|
operational is renamed.
|
||||||
|
- An installation that wants to credit Ekklesia can do so in its own words; the platform never
|
||||||
|
inserts that credit.
|
||||||
|
|
||||||
|
## Alternatives
|
||||||
|
|
||||||
|
- **Keep "Ekklesia" as the default and let installations override it.** Rejected: an installation
|
||||||
|
that never thought to configure it would show the software's name to its people, which is forcing
|
||||||
|
it by default, and "no occurrence of Ekklesia" could not be tested in the default state.
|
||||||
|
- **Rename internal identifiers too.** Rejected: invisible to the people this protects, and costly
|
||||||
|
to every installation and module.
|
||||||
|
- **A configurable "powered by" line, on by default.** Rejected for the same reason as the first
|
||||||
|
alternative.
|
||||||
|
- **Per-string customization of every message.** Not needed for this principle: one installation
|
||||||
|
name and neutral wording remove the product's name everywhere. Richer copy customization, if
|
||||||
|
wanted, is separate work.
|
||||||
|
|
||||||
|
## Deferred
|
||||||
|
|
||||||
|
- The implementation: the setting, its delivery to pages and emails, the rewording, and the
|
||||||
|
regression test.
|
||||||
|
- Installation-supplied logos, icons, and favicons, and a web app manifest.
|
||||||
|
- Translation and per-string copy customization.
|
||||||
@@ -0,0 +1,262 @@
|
|||||||
|
# 0047: Every module proves itself inside Ekklesia, with one harness the platform publishes
|
||||||
|
|
||||||
|
- Status: Proposed
|
||||||
|
- Date: 2026-10-05
|
||||||
|
- Supersedes: none
|
||||||
|
- Related: [ADR 0016](0016-module-database-access.md),
|
||||||
|
[ADR 0021](0021-modules-as-hostable-products.md),
|
||||||
|
[ADR 0028](0028-module-operational-lifecycle.md),
|
||||||
|
[ADR 0030](0030-host-provided-module-object-storage.md),
|
||||||
|
[ADR 0033](0033-display-kiosk-host-contract.md),
|
||||||
|
[ADR 0036](0036-managed-device-identity-and-pairing.md),
|
||||||
|
[ADR 0041](0041-module-browser-contributions.md),
|
||||||
|
[ADR 0043](0043-display-offline-code-fallback.md),
|
||||||
|
[ADR 0044](0044-self-hosted-reference-deployment.md),
|
||||||
|
[ADR 0045](0045-split-dashboard-and-api-origins.md)
|
||||||
|
- Must be revised against: [ADR 0048](0048-generic-image-and-installed-module-storage.md) —
|
||||||
|
steps 1 and 3 below build the module into a deployment image, which ADR 0048 rules out. Before
|
||||||
|
this proposal is accepted, the harness must run the module from module storage in the generic
|
||||||
|
image.
|
||||||
|
- Should consume: [ADR 0049](0049-module-package-v1.md), when accepted — the harness installs the
|
||||||
|
module's canonical package, the artifact a church would install, rather than a workspace build.
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
Modules are independently versioned products (ADR 0021). They live in their own repositories, and
|
||||||
|
none of them can be built, run, or rendered without Ekklesia. The first production module, Signage,
|
||||||
|
reached a church with three defects that only show when it runs **inside** Ekklesia, in a real
|
||||||
|
browser:
|
||||||
|
|
||||||
|
- every heartbeat its display sent was refused with a 400, because the display and the server
|
||||||
|
disagreed about `null`;
|
||||||
|
- a display that failed one request at start-up never recovered without a reload;
|
||||||
|
- its media cache leaked one blob per slide per refresh, until the browser refused the display's own
|
||||||
|
requests.
|
||||||
|
|
||||||
|
Signage had unit tests, browser tests against a stubbed host, and no CI. Every test passed, because
|
||||||
|
each half was tested against its own idea of the other, and nothing ever ran the module in the
|
||||||
|
platform it ships into.
|
||||||
|
|
||||||
|
The platform's own deployment smoke (ADR 0044, ADR 0045) does run a module in a real browser, but
|
||||||
|
only the repository's template, and only far enough to mount it. The instance repository runs the
|
||||||
|
smoke against its real modules, but only at release time, after a module has already been tagged.
|
||||||
|
Each module repository that wants more than that has to assemble the platform, build an image,
|
||||||
|
start a deployment, and drive a browser itself. Signage is about to do exactly that by hand. The
|
||||||
|
second module, Classroom, would copy it, and the copies would drift from the platform they test.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
### The platform publishes a versioned module harness
|
||||||
|
|
||||||
|
Each platform release publishes one OCI image, `ekklesia-module-harness:<platform version>`. It is
|
||||||
|
built from the platform at that release tag by the platform's own release CI, and pushed to the
|
||||||
|
organization's registry. A version is never overwritten. Modules reference it **by digest**.
|
||||||
|
|
||||||
|
The harness contains the platform source at that version: its `deploy/` directory, its Dockerfile,
|
||||||
|
its lockfile, and its smoke scripts. It also contains the pinned Node.js and pnpm, the browser
|
||||||
|
driver, and the pinned headless Chromium image reference. Given a module checkout, it:
|
||||||
|
|
||||||
|
1. **assembles** the module into the platform at `modules/<id>`, exactly as an instance does (ADR
|
||||||
|
0044), and installs with `--frozen-lockfile`;
|
||||||
|
2. runs the platform's own gates against that set: `pnpm boundaries`, `modules:validate`,
|
||||||
|
`migrations:validate`, and the build;
|
||||||
|
3. **builds** the deployment image from the assembled workspace with the platform's Dockerfile;
|
||||||
|
4. starts the **reference deployment** in the split topology (ADR 0045), with a disposable
|
||||||
|
PostgreSQL and object store, Caddy's internal CA, and freshly generated secrets;
|
||||||
|
5. plans, then **explicitly applies**, migrations against that fresh database. This is the only
|
||||||
|
database the harness ever migrates;
|
||||||
|
6. creates the initial owner, grants the module's declared permissions to a test person, and
|
||||||
|
**mounts every UI surface the manifest declares**, in headless Chromium. That covers:
|
||||||
|
- the primary UI in the Dashboard;
|
||||||
|
- the display, paired as a device with no human session (ADR 0036);
|
||||||
|
- each browser contribution, whenever the module that hosts it is present (ADR 0041);
|
||||||
|
7. runs a short **display soak**, and the **no-dead-ends** injections below;
|
||||||
|
8. tears everything down: containers, networks, volumes, and the work directory.
|
||||||
|
|
||||||
|
It **fails** on any of the following:
|
||||||
|
|
||||||
|
- a `console.error` or uncaught exception on any page;
|
||||||
|
- an unhandled promise rejection;
|
||||||
|
- any 4xx or 5xx response that the harness did not itself expect. Expected ones are listed in the
|
||||||
|
harness, for example a display's descriptor before it is paired;
|
||||||
|
- a page that never mounts;
|
||||||
|
- a failed or refused request (CORS, blocked, network error);
|
||||||
|
- a soak leak.
|
||||||
|
|
||||||
|
Each failure line names the surface, the request path (never its query), and the step.
|
||||||
|
|
||||||
|
### The display soak and the no-dead-ends checks are generic
|
||||||
|
|
||||||
|
These checks come from what Signage got wrong, and they apply to any module with a display.
|
||||||
|
|
||||||
|
- **Soak**, two minutes by default. The display stays mounted with no remount and no offline
|
||||||
|
indicator. Its realtime streams stay healthy. Live object URLs stay bounded. JavaScript heap
|
||||||
|
growth, measured over DevTools, stays within a fixed ratio.
|
||||||
|
- **No dead ends.** One at a time, the harness refuses the first request to each module route the
|
||||||
|
display calls at start-up, and the first realtime connection. Within a bounded time and with no
|
||||||
|
reload, the display must make that request again and succeed. A display has nobody to press
|
||||||
|
Retry.
|
||||||
|
|
||||||
|
Module-specific behaviour, such as Signage's heartbeat being recorded, is not guessed by the
|
||||||
|
harness. A module declares it (below).
|
||||||
|
|
||||||
|
### A module is tested against both ends of its compatibility range
|
||||||
|
|
||||||
|
A module's CI runs the harness twice: against the **lowest** and the **newest** published platform
|
||||||
|
version within its manifest's `compatibility.platform` range. Testing every version in between adds
|
||||||
|
cost and finds little. Testing only the newest misses the floor the manifest promises to work on.
|
||||||
|
|
||||||
|
The two versions, and their harness digests, are pinned in the module's repository. The harness
|
||||||
|
refuses to run if:
|
||||||
|
|
||||||
|
- a pinned version lies outside the module's range;
|
||||||
|
- the lowest pinned version is not the range's floor.
|
||||||
|
|
||||||
|
Moving to a newer platform is a deliberate, reviewed pin change, as an instance's is (ADR 0044). It
|
||||||
|
is never a moving tag.
|
||||||
|
|
||||||
|
A frozen install of the module assembled into each of those two platforms needs a lockfile per
|
||||||
|
pinned version. The module keeps them (`ci/lock/<platform version>.yaml`), and the harness provides
|
||||||
|
the one command that regenerates them inside its own pinned Node.js. A stale lockfile fails the
|
||||||
|
frozen install, which is the signal to regenerate it in a reviewed change.
|
||||||
|
|
||||||
|
### Modules declare extra smoke steps, beside their manifest rather than in it
|
||||||
|
|
||||||
|
A module may add steps of its own in `ekklesia.harness.json`, next to `ekklesia.module.json`. Steps
|
||||||
|
are declarative first:
|
||||||
|
|
||||||
|
- open a surface;
|
||||||
|
- wait for a test id;
|
||||||
|
- call one of the module's routes as a named role, expecting a status;
|
||||||
|
- soak for N minutes;
|
||||||
|
- inject a failure into a named route;
|
||||||
|
- check a value in the module's own table, through its own route.
|
||||||
|
|
||||||
|
For what a declaration cannot express, a step may name a driver script in the module's repository.
|
||||||
|
The harness runs that script with the same DevTools client and the same failure rules.
|
||||||
|
|
||||||
|
**This is deliberately not a manifest field.** Manifest versions are strict, hard cutovers that every
|
||||||
|
host validates (ADR 0033). A test step is not a runtime contract, and a host must never read one.
|
||||||
|
Putting steps in the manifest would force a manifest version bump on every module for something
|
||||||
|
that has no meaning outside CI.
|
||||||
|
|
||||||
|
### Later: the harness checks what a module actually touches against what it declares
|
||||||
|
|
||||||
|
In a later phase, the harness records what the module does while it runs:
|
||||||
|
|
||||||
|
- the tables and schemas it reads and writes;
|
||||||
|
- the buckets it uses;
|
||||||
|
- its outbound network connections, with the egress network blocked and every attempt logged.
|
||||||
|
|
||||||
|
It compares them with what the manifest declares (ADR 0016, ADR 0030). A module that touches what it
|
||||||
|
did not declare fails. This is the module-trust check. The egress half needs a manifest declaration
|
||||||
|
of outbound destinations that does not exist yet, and is a separate decision. This ADR fixes only
|
||||||
|
the harness's shape, so that the recording phases can be added without changing how modules call
|
||||||
|
it.
|
||||||
|
|
||||||
|
### The template's workflow stays short, and calls the harness by digest
|
||||||
|
|
||||||
|
`templates/module` ships a workflow that:
|
||||||
|
|
||||||
|
- checks out the module with `persist-credentials: false` and pinned actions;
|
||||||
|
- runs the module's own unit tests, typecheck, and lint;
|
||||||
|
- sweeps stale CI stacks from the runner, then runs the harness, once per pinned platform version,
|
||||||
|
with one `docker run` of the harness image **by digest**, and finally, always, removes this job's
|
||||||
|
own stacks. The module directory is mounted read-only, and the runner's Docker is used exactly
|
||||||
|
as the platform's smoke uses it.
|
||||||
|
|
||||||
|
It references no secret, needs no registry credential (the harness image is public within the
|
||||||
|
organization), and uses no `pull_request_target`. The trusted-code-only rule for self-hosted runners
|
||||||
|
applies to module repositories exactly as it does to the platform (see `docs/development/security.md`).
|
||||||
|
|
||||||
|
**Why `docker run`, and not a reusable workflow or a composite action.** The server runs Gitea
|
||||||
|
1.27.2. (This proposal first assumed 1.24.2; the reasons below hold on the actual version.)
|
||||||
|
|
||||||
|
- Its runner can call a reusable workflow from another repository by cloning that repository, and
|
||||||
|
since 1.26 that includes private repositories (go-gitea/gitea#32562). That support is still thinly
|
||||||
|
documented, and has known problems in practice.
|
||||||
|
- A reusable workflow would make the harness's behaviour depend on how this server and runner
|
||||||
|
version expand it. An image runs the same everywhere it runs.
|
||||||
|
- Composite actions from another repository work through absolute `uses:` URLs. That would still
|
||||||
|
wrap the same `docker run`, adding a second pinned reference and nothing else.
|
||||||
|
|
||||||
|
An image digest is the one reference that is equally immutable, equally reviewable, and works
|
||||||
|
unchanged on any Gitea release and on GitHub Actions. A composite action can be added later as a
|
||||||
|
convenience over the same image, without changing what a module pins.
|
||||||
|
|
||||||
|
### Nothing the harness starts outlives the job that started it
|
||||||
|
|
||||||
|
The harness starts Docker stacks on a shared runner, and a runner can kill a job outright: a
|
||||||
|
cancelled or superseded job (`concurrency` cancels superseded runs on this server), or the runner's
|
||||||
|
own time limit. A script's own cleanup trap does not run then. The platform's CI leaked a whole
|
||||||
|
migration stack that way, and it ran until someone removed it by hand. So the harness follows the
|
||||||
|
platform's rule (`scripts/ci-sweep.sh`, `docs/development/security.md`):
|
||||||
|
|
||||||
|
- everything it starts is named and labeled as a CI stack of its run, attempt, and job key;
|
||||||
|
- it removes its own stacks in a trap;
|
||||||
|
- the template workflow runs the harness's **stale sweep** before the harness starts, and its
|
||||||
|
**job sweep** in a final `if: always()` step, so a killed run's stacks are removed by that run's
|
||||||
|
last step, or failing that by the next job on the runner.
|
||||||
|
|
||||||
|
The sweep ships inside the harness image, so a module never copies it.
|
||||||
|
|
||||||
|
### Publishing follows the instance's release pattern
|
||||||
|
|
||||||
|
The harness image is pushed only by a `publish` job that runs on `v*` tags, after the platform's CI
|
||||||
|
has built and tested that exact image (checked by image ID). That job:
|
||||||
|
|
||||||
|
- uses a write-only package token, the only secret in the platform's workflow;
|
||||||
|
- refuses to overwrite an existing version;
|
||||||
|
- prints the digest.
|
||||||
|
|
||||||
|
Pull requests and branch builds build the harness and run it against the template, but never push.
|
||||||
|
The registry is reached exactly as the instance's publish job reaches it.
|
||||||
|
|
||||||
|
### Adoption
|
||||||
|
|
||||||
|
- **Signage** uses an interim CI job until the first harness is published. That job assembles
|
||||||
|
Signage into a pinned platform commit and runs the platform's display-device smoke scenarios.
|
||||||
|
Signage switches to the harness as soon as one is published.
|
||||||
|
- **Classroom** follows.
|
||||||
|
- **The template** carries the workflow from the first harness release on, so every new module has
|
||||||
|
it from its first commit.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
- A module cannot reach a release without having run inside Ekklesia, in a real browser, at both
|
||||||
|
ends of the platform range it claims.
|
||||||
|
- The defects that reached the first production Signage deployment fail a module's CI instead:
|
||||||
|
refused requests, dead ends, leaks, and console errors.
|
||||||
|
- The platform owns one harness, versioned with itself. Modules do not copy deployment scripts that
|
||||||
|
then drift.
|
||||||
|
- Every module's CI needs Docker on its runner, and takes minutes rather than seconds. The harness
|
||||||
|
runs as separate jobs from the unit tests, so a quick failure still reports quickly.
|
||||||
|
- The platform's release process gains one published artifact, one credential, and one more failure
|
||||||
|
mode at release time.
|
||||||
|
- A module now carries a lockfile for each pinned platform version. The harness makes regenerating
|
||||||
|
them one command, and a stale one fails visibly.
|
||||||
|
|
||||||
|
## Alternatives
|
||||||
|
|
||||||
|
- **Each module keeps its own copy of the smoke scripts.** Rejected. That is today's situation in
|
||||||
|
embryo, and it drifts.
|
||||||
|
- **A reusable workflow in the platform repository.** Rejected for now, for the reasons above: thin
|
||||||
|
support in practice, and a harness whose behaviour depends on whichever runner and server version
|
||||||
|
happens to expand it.
|
||||||
|
- **Publish the harness as an npm package.** Rejected. The harness needs Docker, a browser, and the
|
||||||
|
platform's deployment files anyway, so a package would ship a fraction of it and assume the rest.
|
||||||
|
- **Test only the newest platform version.** Rejected. A module's range floor would go untested
|
||||||
|
until an installation that has not upgraded meets it.
|
||||||
|
- **Put smoke steps in the manifest.** Rejected. Manifest versions are hard cutovers with runtime
|
||||||
|
meaning, and test steps have neither.
|
||||||
|
- **Run the harness in the platform's CI against every known module.** Rejected. The platform would
|
||||||
|
need every module's source and would fail on modules it does not own. The direction is module
|
||||||
|
tests platform, not the reverse.
|
||||||
|
|
||||||
|
## Deferred
|
||||||
|
|
||||||
|
- The data-access and egress recording phases, and the manifest declaration egress needs.
|
||||||
|
- A composite action over the harness image.
|
||||||
|
- Running the harness against a module's declared dependencies at their own pinned versions, for
|
||||||
|
contributions hosted by another module.
|
||||||
|
- Visual regression of mounted surfaces.
|
||||||
@@ -0,0 +1,311 @@
|
|||||||
|
# 0048: Ekklesia ships one generic image, and installed modules live outside it
|
||||||
|
|
||||||
|
- Status: Accepted
|
||||||
|
- Date: 2026-10-07
|
||||||
|
- Supersedes (partial): [0044](0044-self-hosted-reference-deployment.md) — only its "Composition
|
||||||
|
from pinned sources" section, its topology table's statement that the `api` image contains "the
|
||||||
|
enabled modules", and its consequence that modules are built inside a platform workspace with a
|
||||||
|
per-instance lockfile. The one reference deployment, its topology, TLS everywhere, object storage,
|
||||||
|
explicit migrations, health endpoints, backups, and secrets remain accepted and unchanged.
|
||||||
|
- Supersedes (partial): [0009](0009-transactional-module-migrations.md) — only its rule that an
|
||||||
|
applied migration absent from the current inventory is rejected, which that ADR left to "a future
|
||||||
|
explicit platform policy". This ADR is that policy, for modules only. The fixed history contract,
|
||||||
|
the per-module exact-prefix rule, checksums, locking, the single transaction, approvals, and
|
||||||
|
forward-only execution remain accepted and unchanged.
|
||||||
|
- Amends: [0006](0006-versioned-capabilities-and-compiled-module-runtime.md) — installing a module
|
||||||
|
"by cloning it into `modules/`, installing workspace dependencies, building the workspace" becomes
|
||||||
|
the development workflow only. Inert manifests, atomic discovery, compiled ESM entrypoints, and
|
||||||
|
the trusted-plugin model remain accepted and unchanged.
|
||||||
|
- Extends: [0028](0028-module-operational-lifecycle.md),
|
||||||
|
[0030](0030-host-provided-module-object-storage.md)
|
||||||
|
- Related: [ADR 0007](0007-module-owned-drizzle-schemas.md),
|
||||||
|
[ADR 0008](0008-module-owned-migration-contracts.md),
|
||||||
|
[ADR 0016](0016-module-database-access.md),
|
||||||
|
[ADR 0021](0021-modules-as-hostable-products.md),
|
||||||
|
[ADR 0027](0027-dashboard-navigation-and-flat-modules.md),
|
||||||
|
[ADR 0029](0029-module-configuration-and-secrets.md),
|
||||||
|
[ADR 0047](0047-module-ci-harness.md)
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
ADR 0044 builds an installation's image from an **instance composition**: a separate repository
|
||||||
|
per installation pins the platform and each module, commits its own lockfile, assembles them into
|
||||||
|
one workspace, and builds the platform's Dockerfile over it. River of Glory Church runs exactly
|
||||||
|
that. In practice:
|
||||||
|
|
||||||
|
- Every module change, however small, is a new image build and a new image. The image an
|
||||||
|
installation runs is never the image the platform's CI built and tested.
|
||||||
|
- The Dockerfile copies whatever `modules/` contains into the image. What a platform build
|
||||||
|
produces therefore depends on what happens to be in the source checkout.
|
||||||
|
- A module resolves its runtime dependencies only through the platform's own workspace install. No
|
||||||
|
module can run anywhere except inside an Ekklesia workspace built together with it.
|
||||||
|
- Each installation needs its own assembly repository, lockfile, and build pipeline.
|
||||||
|
|
||||||
|
Removing a module has a further problem. Its stored platform state (enablement, configuration,
|
||||||
|
Dashboard placement, grants) and its data (tables, buckets) are already retained by design
|
||||||
|
(ADR 0027, ADR 0028, ADR 0029, ADR 0030). Its migration history is not: ADR 0009 rejects any
|
||||||
|
applied migration that is missing from the current inventory. Once a module with migrations is
|
||||||
|
removed, no migration can be planned or applied again, including Core's own on a platform upgrade,
|
||||||
|
until the module's code is put back.
|
||||||
|
|
||||||
|
Ekklesia will eventually install modules from a marketplace. That needs a platform whose image does
|
||||||
|
not change when its modules do. Designing the marketplace is not part of this decision.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
### One generic platform image, with no modules in it
|
||||||
|
|
||||||
|
Ekklesia produces **one** platform image for every installation. It contains Core, `apps/api`, the
|
||||||
|
Dashboard and display host, the operator commands, and the platform packages they need. It contains
|
||||||
|
**zero feature modules**: not the module template, and not a test module.
|
||||||
|
|
||||||
|
Building the image does not depend on which modules exist in the source checkout. A `modules/`
|
||||||
|
directory in the build context is never a build input: a platform build produces the same platform
|
||||||
|
whether or not one is present.
|
||||||
|
|
||||||
|
The build takes no installation-specific input: no module list, no per-installation lockfile, no
|
||||||
|
branding, no configuration. Per-installation application images and per-installation assembly
|
||||||
|
repositories are not part of the deployment model. An installation that already runs one may keep
|
||||||
|
it as a rollback path until it has moved; the platform does not support building new ones.
|
||||||
|
|
||||||
|
### Installed modules live in installation-owned storage
|
||||||
|
|
||||||
|
Installed modules live **outside the image**, in persistent storage the installation owns.
|
||||||
|
|
||||||
|
- **`EKKLESIA_MODULES_DIR`** selects the modules directory, as an absolute path. It is read once,
|
||||||
|
through the host configuration the API and Doctor already share. The API and every operator
|
||||||
|
command (migration plan and apply, module and migration validation, module enablement, Doctor)
|
||||||
|
honour the same value. There is one modules directory and one discovery path; discovery itself is
|
||||||
|
unchanged.
|
||||||
|
- **The shipping image sets it to `/var/lib/ekklesia/modules`.**
|
||||||
|
- **Unset**, as in a development checkout, it defaults to `<repository>/modules`. Workspace
|
||||||
|
development, module CI, and the platform's tests keep working as they do now.
|
||||||
|
- **A configured directory that is missing or unreadable fails closed** with a sanitized error. A
|
||||||
|
mistyped mount must never quietly start the platform with no modules.
|
||||||
|
- **Nothing in the platform locates its own files relative to the modules directory.** Core's
|
||||||
|
migrations and the platform's package metadata are found where the platform is installed, never
|
||||||
|
beside the modules.
|
||||||
|
|
||||||
|
In the reference deployment, the modules directory is persistent host storage mounted into the
|
||||||
|
`api` and `migrate` containers, and the mount is **read-only to Ekklesia**. The Ekklesia process
|
||||||
|
never writes to module storage. Whatever installs a module writes to that storage from outside the
|
||||||
|
process: today an operator, later perhaps an installer. Giving Ekklesia itself write access is a
|
||||||
|
separate, future decision. Module storage is installation state, so it belongs in the
|
||||||
|
installation's backups beside the database and the buckets.
|
||||||
|
|
||||||
|
Changes to module storage are discovered **only when a process starts**. The API sees them at a
|
||||||
|
controlled restart, and an operator command sees them when it runs. There is no file watching, hot
|
||||||
|
loading, or hot unloading. Changing modules is therefore a sequence, never an edit to a running
|
||||||
|
API: stop the API, change module storage, plan and explicitly apply migrations, start the API. A
|
||||||
|
running API may still read browser artifacts from disk, so changing files under it is unsupported.
|
||||||
|
|
||||||
|
Discovery's existing rules carry over unchanged. Core scans the immediate, non-hidden directories,
|
||||||
|
rejects a symbolic-link module directory, and rejects the whole inventory if any entry is invalid.
|
||||||
|
Two consequences matter for anything that writes module storage:
|
||||||
|
|
||||||
|
- the directory holds nothing but module directories;
|
||||||
|
- hidden entries are ignored, so a module can be staged under a hidden name and then renamed into
|
||||||
|
place in one step.
|
||||||
|
|
||||||
|
### An installed module directory is self-contained
|
||||||
|
|
||||||
|
An installed module directory is **self-contained for runtime use**: every file the platform reads
|
||||||
|
or loads from it, and everything those files import at run time, resolves inside that directory.
|
||||||
|
|
||||||
|
It contains:
|
||||||
|
|
||||||
|
- `package.json`, whose version matches the manifest's;
|
||||||
|
- `ekklesia.module.json`;
|
||||||
|
- every built artifact its manifest declares: the server entrypoint, the schema entrypoint when it
|
||||||
|
owns tables, the jobs entrypoint, and the primary, display, and contribution browser artifacts;
|
||||||
|
- its migrations directory, with the journal and Ekklesia metadata, when it has migration history;
|
||||||
|
- the **production runtime dependencies** of its server, schema, and jobs code, inside the module
|
||||||
|
directory. That includes `@ekklesia/module-sdk`, `@ekklesia/contracts`, Drizzle, and every
|
||||||
|
third-party package the module imports.
|
||||||
|
|
||||||
|
Copying `dist/` alone does not produce an installed module. Source, tests, and development
|
||||||
|
dependencies are not required.
|
||||||
|
|
||||||
|
**The platform lends a module nothing.** Node resolves a module's imports from the module's own
|
||||||
|
directory upwards, and the platform's own `node_modules` is never on that path. Ekklesia does not
|
||||||
|
add it there through links, `NODE_PATH`, loader hooks, aliases, or import maps. A module whose
|
||||||
|
imports do not resolve fails to load, and because startup is atomic, the API does not start. That
|
||||||
|
failure is sanitized like any other.
|
||||||
|
|
||||||
|
**Native dependencies** must be built for the platform image's runtime: its Node.js version and ABI,
|
||||||
|
its C library, and its CPU architecture. The platform documents that runtime for each image.
|
||||||
|
|
||||||
|
**Browser artifacts** are already self-contained bundles (ADR 0023, ADR 0040). Nothing changes for
|
||||||
|
them.
|
||||||
|
|
||||||
|
**Drizzle, for now.** A module defines its tables with its own copy of Drizzle, and Core runs that
|
||||||
|
module's queries through the platform's Drizzle instance (ADR 0016). Objects from two copies of the
|
||||||
|
library only interoperate when the copies are interchangeable. **The current implementation may
|
||||||
|
therefore require a module to carry exactly the Drizzle version the platform uses.** This is a
|
||||||
|
constraint of the current implementation, not a permanent part of the module contract. A stable
|
||||||
|
module database ABI, or a compatibility rule that permits a range, replaces it when one exists.
|
||||||
|
|
||||||
|
Core already validates a module's runtime and schema definitions structurally, against its own
|
||||||
|
contracts. It never relies on object identity with the module's copy of the SDK. The module's
|
||||||
|
`compatibility.moduleSdk` range continues to be checked against the platform's SDK version.
|
||||||
|
|
||||||
|
The archive or package format a module is delivered in, how a package is signed, and how it is
|
||||||
|
transported to an installation are deferred.
|
||||||
|
|
||||||
|
### Disable, uninstall, and purge are different operations
|
||||||
|
|
||||||
|
| Operation | Module code | Stored platform state | Module data |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| **Disable** (ADR 0028) | Stays installed; not loaded | Enablement record set to disabled | Kept |
|
||||||
|
| **Uninstall** | Removed from module storage | Kept and ignored: enablement, configuration, Dashboard placement, grants | Kept: tables and rows, buckets and objects, migration history |
|
||||||
|
| **Purge** | Not installed | Deleted | Deleted |
|
||||||
|
|
||||||
|
**Purge is deferred.** Until it exists, nothing in the platform deletes module data, and removing
|
||||||
|
module code never does. No table is dropped, no bucket or object is deleted (ADR 0030), and no
|
||||||
|
migration history or stored platform record is removed just because a module is absent. There are
|
||||||
|
no down migrations (ADR 0008, ADR 0009).
|
||||||
|
|
||||||
|
**A removed module may be reinstalled** with the same module id, and it recovers its retained state:
|
||||||
|
|
||||||
|
- its enablement decision, configuration, Dashboard placement, and grants apply again;
|
||||||
|
- its tables and objects are where it left them;
|
||||||
|
- its migration history is validated strictly again (next section).
|
||||||
|
|
||||||
|
Reinstalling a newer version plans that version's new migrations on top of the retained history.
|
||||||
|
|
||||||
|
**Dependencies are unchanged.** A present module whose *required* dependency is absent still fails
|
||||||
|
discovery, and the API does not start. Dependents are removed first. An optional dependency may be
|
||||||
|
absent, exactly as before.
|
||||||
|
|
||||||
|
### Migration history for an absent module is retained and reported
|
||||||
|
|
||||||
|
A module is **absent** when no module in the current discovered inventory has its module id.
|
||||||
|
|
||||||
|
**Applied history belonging to an absent module is retained and reported, not treated as fatal.**
|
||||||
|
|
||||||
|
- Its rows are still checked for shape and for global consistency: unique identities, and unique,
|
||||||
|
increasing applied order. A corrupt row still fails.
|
||||||
|
- They are not matched against any inventory. Migration plan, apply, and Doctor report them as
|
||||||
|
retained history for an absent module: the module id and how many migrations it has.
|
||||||
|
- Their presence never blocks planning or applying Core's migrations, or those of any present
|
||||||
|
module.
|
||||||
|
|
||||||
|
**History belonging to a present module stays strict, including a module that has just returned.**
|
||||||
|
Its applied history must be an exact prefix of its current journal, and every row must match its
|
||||||
|
migration's module, tag, index, and checksum. Therefore:
|
||||||
|
|
||||||
|
- a previously applied migration missing from the package fails;
|
||||||
|
- a checksum mismatch fails;
|
||||||
|
- a downgrade to a version with fewer migrations, or any incomplete or gapped history, fails.
|
||||||
|
|
||||||
|
A different package that claims a retained module id is held to that history in exactly the same
|
||||||
|
way.
|
||||||
|
|
||||||
|
**Core's history is never treated as absent.** Rows belonging to Core's migration source are always
|
||||||
|
validated strictly; an applied Core migration missing from Core's inventory is still fatal.
|
||||||
|
|
||||||
|
Applying migrations never touches an absent module's database objects, and nothing runs a down
|
||||||
|
migration or deletes data because a module is absent. Deleting retained history belongs to purge.
|
||||||
|
|
||||||
|
This is safe because nothing applied in the platform can depend on an absent module's objects:
|
||||||
|
|
||||||
|
- a module may not hold a foreign key into another module's tables (ADR 0007);
|
||||||
|
- a module's migrations may change only its own objects (ADR 0008);
|
||||||
|
- Core never depends on a module's tables.
|
||||||
|
|
||||||
|
### The trust boundary does not move
|
||||||
|
|
||||||
|
Installed modules remain **trusted backend code** (ADR 0006, ADR 0015, ADR 0021). They run inside
|
||||||
|
the API process with its database role. Module storage, the read-only mount, and self-contained
|
||||||
|
packages are integrity and operational properties. **They are not a security sandbox.** The
|
||||||
|
read-only mount stops the Ekklesia process from modifying installed code. It does not limit what
|
||||||
|
installed code can do.
|
||||||
|
|
||||||
|
What the platform can still check against an installed directory is unchanged:
|
||||||
|
|
||||||
|
- discovery: the manifest, the package, entrypoint containment, compatibility ranges, and the
|
||||||
|
dependency graph;
|
||||||
|
- module, schema, and migration validation, run against module storage.
|
||||||
|
|
||||||
|
The repository's boundary checks (`pnpm boundaries`) can only prove **source** modules present in a
|
||||||
|
workspace. That includes the rule that every runtime import is a declared production dependency. An
|
||||||
|
installed package normally ships no source, so the platform cannot run those checks on it.
|
||||||
|
Conformance of a published or installed module must therefore be enforced:
|
||||||
|
|
||||||
|
- by the module's own CI;
|
||||||
|
- by packaging validation;
|
||||||
|
- by the module harness (ADR 0047).
|
||||||
|
|
||||||
|
Until packaging validation exists, installing a module means trusting that module's CI and the
|
||||||
|
operator who installed it, exactly as installing trusted code always has.
|
||||||
|
|
||||||
|
### Development keeps the workspace
|
||||||
|
|
||||||
|
In a development checkout, `<repository>/modules` remains the default, and a module is still
|
||||||
|
developed as a workspace package there. Module CI and the module harness may still assemble a module
|
||||||
|
into a platform workspace to build and test it. A deployment image is never built from such a
|
||||||
|
workspace.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
- Every installation runs the same platform image, and that image is the one the platform's CI
|
||||||
|
tested. Installing, updating, or removing a module needs no image build. It needs only a
|
||||||
|
controlled restart and, when the module has migrations, an explicit migration plan and apply.
|
||||||
|
- Per-installation assembly repositories become unnecessary. River of Glory Church's instance
|
||||||
|
repository keeps running its pinned image as a rollback path until the new model is proven there.
|
||||||
|
Once the platform image stops accepting modules from its build context, that repository cannot
|
||||||
|
move to a newer platform release, which is intended.
|
||||||
|
- Module authors gain a packaging step: a self-contained directory with production dependencies and,
|
||||||
|
where needed, native binaries built for the image's runtime. Packages grow, and modules that share
|
||||||
|
a library each carry their own copy. That cost is accepted.
|
||||||
|
- Removing a module no longer blocks migrations. Its data stays, and reinstalling restores it.
|
||||||
|
- Operators own module storage: they create it, keep it in backups, and keep anything but module
|
||||||
|
directories out of it.
|
||||||
|
- ADR 0047, still proposed, assembles a module into the platform and builds a deployment image from
|
||||||
|
it. Before it is accepted, it must instead run the module from module storage in the generic
|
||||||
|
image.
|
||||||
|
- This ADR records the decision; it changes no code. Selecting `EKKLESIA_MODULES_DIR`, the image
|
||||||
|
build, the retained-history policy, the read-only mount, and the CI proof that the image is
|
||||||
|
independent of installed modules follow in separate changes. Until they land, the repository
|
||||||
|
behaves as `deploy/README.md` and the development guides describe.
|
||||||
|
|
||||||
|
## Alternatives
|
||||||
|
|
||||||
|
- **Keep per-installation images (ADR 0044).** Rejected: every module change is an image build, no
|
||||||
|
two installations run the same image, and a platform build depends on its checkout.
|
||||||
|
- **Lend modules the platform's dependencies** through links, `NODE_PATH`, loader hooks, or import
|
||||||
|
maps. Rejected:
|
||||||
|
- it couples every installed module to the platform's private dependency graph, so upgrading a
|
||||||
|
platform dependency could silently break an installed module;
|
||||||
|
- ESM ignores `NODE_PATH`;
|
||||||
|
- a resolution hook would be a second module-loading mechanism beside Node's own.
|
||||||
|
- **Require one bundled file per entrypoint as the package format.** Not adopted as a requirement:
|
||||||
|
native dependencies cannot be bundled, and the package format is deferred. Bundling is one
|
||||||
|
permitted way to produce a self-contained directory, not the definition of one.
|
||||||
|
- **Let the API write module storage, so it can install modules itself.** Deferred, not adopted:
|
||||||
|
code that can rewrite the code it executes is a larger trust decision, and it belongs with the
|
||||||
|
marketplace.
|
||||||
|
- **Hot loading and unloading.** Rejected: ES modules cannot be unloaded, and atomic discovery,
|
||||||
|
dependency-ordered startup, and immutable permission composition all assume a fresh process.
|
||||||
|
- **Delete data, or run down migrations, on uninstall.** Rejected: migrations are forward-only, and
|
||||||
|
a mistaken removal must never destroy data. Deleting data is purge, and purge is explicit.
|
||||||
|
- **Keep rejecting history for absent modules (ADR 0009 as written).** Rejected: removing any module
|
||||||
|
with migrations would block every later migration, including Core's.
|
||||||
|
- **Accept history for absent modules only behind an operator flag.** Rejected: applying migrations
|
||||||
|
with a module absent cannot harm that module's data, and the plan already reports what is
|
||||||
|
retained. A flag would turn every routine uninstall into a special case.
|
||||||
|
|
||||||
|
## Deferred
|
||||||
|
|
||||||
|
- The module archive or package transport format.
|
||||||
|
- Package signatures and trust roots.
|
||||||
|
- A marketplace or catalog.
|
||||||
|
- An installer UI.
|
||||||
|
- Installation by the running platform, and any write access for Ekklesia to module storage.
|
||||||
|
- Hot loading, hot unloading, and file watching.
|
||||||
|
- The purge workflow: deleting a module's tables, buckets and objects, migration history, and stored
|
||||||
|
platform records.
|
||||||
|
- A stable module database ABI, or a compatibility rule wider than the current exact Drizzle
|
||||||
|
requirement.
|
||||||
|
- A platform-provided packaging and package-validation command.
|
||||||
|
- Publishing the platform image and automated deployment from CI, still deferred from ADR 0044.
|
||||||
@@ -0,0 +1,426 @@
|
|||||||
|
# 0049: A module release is one immutable, self-contained package: Ekklesia Module Package v1
|
||||||
|
|
||||||
|
- Status: Accepted
|
||||||
|
- Date: 2026-10-07
|
||||||
|
- Supersedes: none
|
||||||
|
- Extends: [0048](0048-generic-image-and-installed-module-storage.md)
|
||||||
|
- Related: [ADR 0006](0006-versioned-capabilities-and-compiled-module-runtime.md),
|
||||||
|
[ADR 0007](0007-module-owned-drizzle-schemas.md),
|
||||||
|
[ADR 0008](0008-module-owned-migration-contracts.md),
|
||||||
|
[ADR 0009](0009-transactional-module-migrations.md),
|
||||||
|
[ADR 0016](0016-module-database-access.md),
|
||||||
|
[ADR 0021](0021-modules-as-hostable-products.md),
|
||||||
|
[ADR 0023](0023-module-ui-build-pipeline.md),
|
||||||
|
[ADR 0028](0028-module-operational-lifecycle.md),
|
||||||
|
[ADR 0040](0040-module-stylesheets-and-artifact-isolation.md),
|
||||||
|
[ADR 0041](0041-module-browser-contributions.md),
|
||||||
|
[ADR 0044](0044-self-hosted-reference-deployment.md),
|
||||||
|
[ADR 0047](0047-module-ci-harness.md)
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
ADR 0048 took modules out of the platform image. An installation keeps them in its own module
|
||||||
|
storage, mounted read-only, and discovers them at a controlled restart. Each installed module must
|
||||||
|
be a self-contained directory: the platform lends it no dependencies. ADR 0048 deferred the format
|
||||||
|
a module is delivered in.
|
||||||
|
|
||||||
|
That gap is now the obstacle. Nothing yet produces an installed module directory except a test-only
|
||||||
|
Dockerfile, so a module release cannot be built, distributed, and installed on its own. An
|
||||||
|
investigation against the module template and Signage 0.1.2 established:
|
||||||
|
|
||||||
|
- **`dist/` alone does not run.** Compiled server and schema code imports `@ekklesia/module-sdk`
|
||||||
|
(and through it `@ekklesia/contracts`, zod, and semver) and `drizzle-orm` by name, and Node never
|
||||||
|
resolves those from the platform's `node_modules`.
|
||||||
|
- **Bundling works.** Bundling the template's server and schema with esbuild gave about 720 KB of
|
||||||
|
backend code and no `node_modules` at all. Inside the generic image, run as its unprivileged
|
||||||
|
user from a read-only mount at `/var/lib/ekklesia/modules`, it passed module, schema, and
|
||||||
|
migration validation, and its server entrypoint imported. Core's own Drizzle instance built a
|
||||||
|
query over the bundled module's table.
|
||||||
|
- **Native dependencies cannot be bundled.** Signage needs `sharp`, a Node-API addon whose
|
||||||
|
platform packages carry an 18.6 MB `libvips` shared library for one operating system, CPU, and
|
||||||
|
C library (Linux, x64, glibc ≥ 2.28). With its backend bundled and only `sharp` and its own
|
||||||
|
dependencies kept beside it, Signage was 22 MB and contained no link. It validated and processed
|
||||||
|
an image inside the same image.
|
||||||
|
- **`pnpm deploy` is the wrong tool.** Non-legacy mode refuses unless the whole workspace switches
|
||||||
|
to injected workspace packages. Legacy mode leaves a link escaping the package. Every mode
|
||||||
|
ships packages nothing loads at run time (React and its DOM, type packages, database drivers
|
||||||
|
pulled in as peers) and leaves `workspace:` ranges in copied manifests. Running it inside a
|
||||||
|
workspace rewrites that workspace's install state to production-only, which then breaks the
|
||||||
|
workspace's own builds. It did so in the investigation's scratch workspaces, and earlier in a
|
||||||
|
developer's own checkout.
|
||||||
|
- **A deterministic archive is cheap.** A tar archive with sorted paths, fixed owner and
|
||||||
|
timestamps, and a gzip header without a name or time produced the same SHA-256 twice. Gzip packed
|
||||||
|
Signage to 8.5 MB.
|
||||||
|
|
||||||
|
Module releases also need a contract that does not depend on where they are hosted. Ekklesia's
|
||||||
|
source and CI live on a Gitea server today; a marketplace, another host, object storage, or offline
|
||||||
|
media may carry the same release tomorrow.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
### Purpose
|
||||||
|
|
||||||
|
A separately versioned Ekklesia module is distributed as an **Ekklesia Module Package**: one
|
||||||
|
immutable, self-contained archive. Installing, updating, or removing a module never rebuilds the
|
||||||
|
platform image (ADR 0048).
|
||||||
|
|
||||||
|
The package contract is independent of marketplace discovery, of the source-code host, of any
|
||||||
|
artifact registry, of the church installing it, and of the URL it was fetched from. Catalogues,
|
||||||
|
marketplaces, and signing are separate concerns, and none of them is decided here.
|
||||||
|
|
||||||
|
### The artifact is a deterministic `.tar.gz`
|
||||||
|
|
||||||
|
Module Package v1 is a gzip-compressed tar archive. Its identity is its contents, never its
|
||||||
|
transport: the same archive may travel through Gitea, GitHub, an OCI registry, object storage,
|
||||||
|
offline media, or a future Ekklesia marketplace without any change to what it installs. No registry,
|
||||||
|
host, or OCI concept is part of the package format.
|
||||||
|
|
||||||
|
The canonical packager produces the archive **deterministically**: the same packager version and
|
||||||
|
the same inputs produce byte-identical output. At minimum:
|
||||||
|
|
||||||
|
- **Entries.** Every directory and regular file appears in byte-wise sorted path order, directories
|
||||||
|
before their contents.
|
||||||
|
- **Ownership.** uid and gid are 0 and the user and group names are empty.
|
||||||
|
- **Timestamps.** Every entry's modification time is 0, and the archive holds no access, change, or
|
||||||
|
creation time.
|
||||||
|
- **Modes.** Directories are `0755` and files `0644` (see [Archive safety](#archive-safety)).
|
||||||
|
- **No host metadata.** No extended attributes, ACLs, or archive-level headers that carry host or
|
||||||
|
time information.
|
||||||
|
- **gzip.** The header has no file name and a modification time of 0, at a fixed compression level.
|
||||||
|
|
||||||
|
The SHA-256 of the complete archive is distributed beside it, as a separate `.sha256` file in the
|
||||||
|
`sha256sum` format. The **recommended** file name is `<module id>-<module version>-<target>.tar.gz`,
|
||||||
|
for example `signage-0.1.2-linux-x64-glibc.tar.gz`. A file name is a convenience, never trusted:
|
||||||
|
what the archive is comes from its contents and its digest.
|
||||||
|
|
||||||
|
### What a package contains
|
||||||
|
|
||||||
|
A v1 archive contains exactly one top-level directory, named with the module id. Its contents are
|
||||||
|
the installed module directory ADR 0048 requires:
|
||||||
|
|
||||||
|
```text
|
||||||
|
<module id>/
|
||||||
|
package.json generated runtime metadata
|
||||||
|
ekklesia.module.json the module's manifest, unchanged
|
||||||
|
ekklesia.package.json package metadata (below)
|
||||||
|
THIRD_PARTY_NOTICES licence and notice material for everything bundled or included
|
||||||
|
dist/ bundled backend entrypoints, their shared chunks, and the declared
|
||||||
|
UI, display, and contribution artifacts
|
||||||
|
drizzle/ migration SQL, journal, snapshots, and _ekklesia.json, when declared
|
||||||
|
node_modules/ only when the package needs a native runtime dependency (below)
|
||||||
|
```
|
||||||
|
|
||||||
|
Every entrypoint the manifest declares exists at its declared path.
|
||||||
|
|
||||||
|
It contains nothing else, and in particular:
|
||||||
|
|
||||||
|
- source files, unless something at run time reads them;
|
||||||
|
- tests;
|
||||||
|
- TypeScript declarations;
|
||||||
|
- source maps, until a later decision permits them;
|
||||||
|
- development dependencies;
|
||||||
|
- any `workspace:` reference;
|
||||||
|
- any link (see [Archive safety](#archive-safety)).
|
||||||
|
|
||||||
|
**The runtime `package.json` is generated by the packager, never copied** from the module's
|
||||||
|
development workspace. It holds only what module discovery reads:
|
||||||
|
- `name` and `version` (equal to the manifest's version);
|
||||||
|
- `"private": true`;
|
||||||
|
- `"type": "module"`;
|
||||||
|
- `"ekklesia": { "layer": "module" }`.
|
||||||
|
|
||||||
|
It declares no dependencies, scripts, exports, or imports, because nothing at run time resolves
|
||||||
|
through them.
|
||||||
|
|
||||||
|
The manifest is copied unchanged. It stays the module's authoritative runtime contract, and its
|
||||||
|
`compatibility.platform` and `compatibility.moduleSdk` ranges stay the only place those are stated.
|
||||||
|
|
||||||
|
### Backend code is bundled
|
||||||
|
|
||||||
|
The canonical packager bundles the module's backend entrypoints (server, schema, and jobs) with
|
||||||
|
every pure-JavaScript dependency they use. That includes `@ekklesia/module-sdk`,
|
||||||
|
`@ekklesia/contracts`, and ordinary libraries. The bundles are ES modules at the manifest's declared
|
||||||
|
paths, and they may share chunks under `dist/`. Browser artifacts are already self-contained bundles
|
||||||
|
(ADR 0023, ADR 0040, ADR 0041) and are packaged as built.
|
||||||
|
|
||||||
|
The platform lends an installed module nothing (ADR 0048). **`node_modules` in a package is the
|
||||||
|
exception, not the default.** A dependency stays outside the bundle only when it must exist as
|
||||||
|
separate files at run time: a native Node addon, or a package that is equally platform-specific.
|
||||||
|
Then the package carries it, and exactly its own runtime dependencies, flat under
|
||||||
|
`node_modules/<name>`.
|
||||||
|
|
||||||
|
**The canonical packager decides what stays external, and validates it.** It refuses to package a
|
||||||
|
module whose bundled code still needs a file it did not include, or whose externals are not native
|
||||||
|
or platform-specific. Choosing which dependencies to leave unbundled is not part of the module
|
||||||
|
author's interface.
|
||||||
|
|
||||||
|
### Targets
|
||||||
|
|
||||||
|
Every package has one **target**:
|
||||||
|
|
||||||
|
- **`any`**: the package contains no native or platform-specific file. It runs wherever its declared
|
||||||
|
Node.js range is met.
|
||||||
|
- **`<platform>-<arch>[-<libc>]`**: the package contains native code for exactly one environment.
|
||||||
|
- `<platform>` and `<arch>` use Node.js's own `process.platform` and `process.arch` names.
|
||||||
|
- On Linux, `<libc>` is required and is `glibc` or `musl`. On other platforms it is omitted.
|
||||||
|
|
||||||
|
Examples: `linux-x64-glibc`, `linux-arm64-glibc`, `linux-x64-musl`. Signage, because of `sharp`, is
|
||||||
|
`linux-x64-glibc` for the current production image.
|
||||||
|
|
||||||
|
A package has exactly one target. A module that ships for several environments publishes one
|
||||||
|
package per target. Packages carrying several architectures at once are not part of v1.
|
||||||
|
|
||||||
|
### `ekklesia.package.json`
|
||||||
|
|
||||||
|
For example (the values are illustrative):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"packageFormat": 1,
|
||||||
|
"module": { "id": "signage", "version": "0.1.2" },
|
||||||
|
"target": "linux-x64-glibc",
|
||||||
|
"runtime": {
|
||||||
|
"node": ">=24.11.0 <25",
|
||||||
|
"nodeApi": 9,
|
||||||
|
"libcVersion": ">=2.28"
|
||||||
|
},
|
||||||
|
"provenance": {
|
||||||
|
"revision": "86ae907f59458508e3277666cdd214f5bd7c1078",
|
||||||
|
"repository": "https://example.org/your-org/signage.git",
|
||||||
|
"packager": { "name": "@ekklesia/module-pack", "version": "0.2.0" }
|
||||||
|
},
|
||||||
|
"incorporates": {
|
||||||
|
"@ekklesia/module-sdk": "0.1.2",
|
||||||
|
"@ekklesia/contracts": "0.1.2"
|
||||||
|
},
|
||||||
|
"transitional": {
|
||||||
|
"databaseRuntime": { "drizzle-orm": "0.45.2" }
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
{ "path": "THIRD_PARTY_NOTICES", "size": 48211, "sha256": "…" },
|
||||||
|
{ "path": "dist/server/index.js", "size": 1346, "sha256": "…" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The fields are:
|
||||||
|
|
||||||
|
- **`packageFormat`**: the integer `1`. An installer refuses a format it does not implement. Format
|
||||||
|
versions are strict cutovers, like manifest versions.
|
||||||
|
- **`module.id`, `module.version`**: must equal the manifest's and the runtime `package.json`'s.
|
||||||
|
The top-level directory must be named with the id.
|
||||||
|
- **`target`**: as defined above.
|
||||||
|
- **`runtime`**: what the package needs from the Node.js runtime that will load it.
|
||||||
|
- `node`, a semantic-version range, is always present.
|
||||||
|
- A native package also states:
|
||||||
|
- `nodeApi`: the minimum Node-API version, for Node-API addons;
|
||||||
|
- or `nodeAbi`: the exact `process.versions.modules`, for an addon built against one Node
|
||||||
|
ABI;
|
||||||
|
- and on Linux, `libcVersion`: the minimum C library version.
|
||||||
|
- **`provenance`**: how the package was built.
|
||||||
|
- `revision`: the exact source revision, always present.
|
||||||
|
- `repository`: informational only. Nothing trusts it, and it ties the package to no host.
|
||||||
|
- `packager`: the packager's name and version.
|
||||||
|
- **`incorporates`**: the exact versions of the Ekklesia SDK and runtime libraries bundled into the
|
||||||
|
package. The packager refuses a package whose incorporated `@ekklesia/module-sdk` does not satisfy
|
||||||
|
the manifest's own `compatibility.moduleSdk` range, so the two can never disagree.
|
||||||
|
- **`transitional`**: compatibility facts that today's implementation needs and that are not part
|
||||||
|
of the long-term contract. See [Database runtime compatibility](#database-runtime-compatibility).
|
||||||
|
An installer must understand every key it finds there or refuse the package.
|
||||||
|
- **`files`**: one entry for every regular file in the module directory **except
|
||||||
|
`ekklesia.package.json` itself**, sorted by path. Each entry has its path relative to the module
|
||||||
|
directory, its size in bytes, and its SHA-256. The set is exact: a payload file missing from the
|
||||||
|
list, or a listed file missing from the payload, invalidates the package.
|
||||||
|
|
||||||
|
The archive's `.sha256` covers the whole archive, `ekklesia.package.json` included. The per-file
|
||||||
|
digests let an installer, or an operator later, check an extracted directory without the archive.
|
||||||
|
|
||||||
|
Platform and Module SDK compatibility stay in `ekklesia.module.json` and are not repeated here.
|
||||||
|
|
||||||
|
### Database runtime compatibility
|
||||||
|
|
||||||
|
Core runs a module's queries through the platform's Drizzle instance over tables the module defined
|
||||||
|
with its own copy of Drizzle (ADR 0016, ADR 0048). Those objects only interoperate when the two
|
||||||
|
copies are interchangeable. So while it is needed, a package bundles Drizzle with its backend code,
|
||||||
|
records the version in `transitional.databaseRuntime`, and an installer refuses a package whose
|
||||||
|
Drizzle version differs from the platform's.
|
||||||
|
|
||||||
|
**This is an implementation constraint of today's platform, not the Ekklesia Module Database ABI.**
|
||||||
|
Object identity and version agreement between two Drizzle copies are not a contract this ADR
|
||||||
|
establishes or that module authors may rely on. A separate Module Database ABI decision will replace
|
||||||
|
it, and that ABI will make the platform's ORM an implementation detail. When it lands, the
|
||||||
|
`transitional.databaseRuntime` requirement goes away under the format-versioning rules here.
|
||||||
|
|
||||||
|
### Licence obligations
|
||||||
|
|
||||||
|
Bundling copies third-party code into the module's own files, where the licence headers that came
|
||||||
|
with it may not survive. **Every package therefore carries `THIRD_PARTY_NOTICES`**: a generated
|
||||||
|
plain-text file at the module directory's root. It names each third-party package bundled into
|
||||||
|
or included in the package, with its version and licence, and holds the licence and notice text
|
||||||
|
that redistribution requires.
|
||||||
|
|
||||||
|
The packager's implementation must establish exactly how licence and notice material is collected
|
||||||
|
and preserved, including licence comments that bundling would otherwise strip. Until it does, no
|
||||||
|
package is treated as a distributable release.
|
||||||
|
|
||||||
|
### Archive safety
|
||||||
|
|
||||||
|
**v1 packages contain no links of any kind.** Neither symbolic nor hard links are permitted, so no
|
||||||
|
link can escape the package, and there is no case where one is allowed. Native dependencies were
|
||||||
|
packaged without any.
|
||||||
|
|
||||||
|
A conforming archive also contains no:
|
||||||
|
|
||||||
|
- absolute path;
|
||||||
|
- `..` path segment;
|
||||||
|
- path outside the single top-level module directory;
|
||||||
|
- device node, FIFO, or socket;
|
||||||
|
- setuid, setgid, or sticky bit.
|
||||||
|
|
||||||
|
It holds only directories and regular files.
|
||||||
|
|
||||||
|
**Extraction normalizes ownership and modes.** Everything is owned by the installation, not by the
|
||||||
|
uid or gid in the archive. Directories are `0755` and files `0644`, readable by the API's
|
||||||
|
unprivileged user and writable by nobody but the installer. No file in a v1 package is executable.
|
||||||
|
Loading JavaScript and native addons needs no execute bit.
|
||||||
|
|
||||||
|
An installer refuses an archive that breaks any of these rules, and never extracts it partially into
|
||||||
|
module storage.
|
||||||
|
|
||||||
|
### Installation model
|
||||||
|
|
||||||
|
This ADR records the intended installation lifecycle; it does not implement it.
|
||||||
|
|
||||||
|
```text
|
||||||
|
acquire the archive
|
||||||
|
→ verify the archive's SHA-256
|
||||||
|
→ extract into a hidden staging directory inside module storage
|
||||||
|
→ validate ekklesia.package.json, then every file's size and SHA-256
|
||||||
|
→ validate the target and runtime against this platform and host
|
||||||
|
→ validate the manifest and the complete candidate inventory: the installed modules, with this
|
||||||
|
candidate in place of any version of the same module
|
||||||
|
→ load the backend entrypoints once in an isolated, short-lived validation process
|
||||||
|
→ produce the migration plan
|
||||||
|
→ stop the API
|
||||||
|
→ swap the candidate into the active module directory, keeping the previous version
|
||||||
|
→ apply migrations, only with explicit operator approval
|
||||||
|
→ start the API
|
||||||
|
→ verify readiness
|
||||||
|
```
|
||||||
|
|
||||||
|
Details:
|
||||||
|
|
||||||
|
- **Hidden directories.** Staging and previous versions live in dot-prefixed directories inside
|
||||||
|
module storage. Module discovery ignores hidden entries (ADR 0048), so neither is ever loaded.
|
||||||
|
- **The swap.** It happens only while the API is stopped. It is as close to atomic as the
|
||||||
|
filesystem allows, and an interrupted swap is recoverable from the kept previous version.
|
||||||
|
- **Who writes.** Module storage stays read-only to Ekklesia. Whatever performs the installation
|
||||||
|
writes it from outside the API process (ADR 0048).
|
||||||
|
- **No hot loading.** Discovery still happens only when a process starts.
|
||||||
|
- **Migrations stay explicit.** Nothing in this lifecycle applies a migration on its own (ADR 0009,
|
||||||
|
ADR 0044).
|
||||||
|
|
||||||
|
### Rollback
|
||||||
|
|
||||||
|
- **Before any migration from the new version is applied,** restoring the previous module directory
|
||||||
|
and restarting is a supported rollback.
|
||||||
|
- **After forward migrations are applied,** restoring older code is **not** a supported rollback.
|
||||||
|
The older package lacks the newer migrations, so migration planning refuses it as a downgrade
|
||||||
|
(ADR 0048). Even running it would mean old code against a newer schema. A true rollback then
|
||||||
|
means restoring the matching database and storage backup, unless a future, module-specific
|
||||||
|
compatibility mechanism says otherwise.
|
||||||
|
|
||||||
|
There are no down migrations (ADR 0008, ADR 0009). An installer must make the point of no return
|
||||||
|
explicit: back up before applying, and offer code-only rollback only while no new migration has been
|
||||||
|
applied.
|
||||||
|
|
||||||
|
### Trust
|
||||||
|
|
||||||
|
The SHA-256 digest proves integrity: the archive is the one whose digest you were given. It says
|
||||||
|
nothing about who published it. Package signatures, publisher trust roots, marketplace verification,
|
||||||
|
and trust levels for third-party modules are deferred.
|
||||||
|
|
||||||
|
Installed modules remain trusted backend code under ADR 0048. A conforming package is not sandboxed,
|
||||||
|
and passing validation does not make a module safe; it makes it well-formed.
|
||||||
|
|
||||||
|
### Canonical tooling
|
||||||
|
|
||||||
|
Packages are produced by one Ekklesia-owned packager, proposed as the workspace package
|
||||||
|
`packages/module-pack` with the command `ekklesia-pack-module`, run in a module's CI. Its command
|
||||||
|
line is an implementation detail; this ADR defines what it must produce.
|
||||||
|
|
||||||
|
**`pnpm deploy` is not the packaging mechanism**, for the reasons in Context:
|
||||||
|
- it includes runtime dependencies nothing loads;
|
||||||
|
- it leaves `workspace:` metadata;
|
||||||
|
- it leaves links that escape the package, or needs a workspace-wide linking change to avoid them;
|
||||||
|
- it rewrites the install state of the workspace it runs in.
|
||||||
|
|
||||||
|
The repository's test-only packaging of the module template
|
||||||
|
(`scripts/lib/test-module-package.Dockerfile`) is replaced by the canonical packager when one
|
||||||
|
exists.
|
||||||
|
|
||||||
|
### Relationship to the module CI harness
|
||||||
|
|
||||||
|
ADR 0047, still proposed, is already marked for revision against ADR 0048. Its harness should
|
||||||
|
consume canonical module packages: a module's CI builds its package, and the harness installs that
|
||||||
|
exact archive into module storage of the generic image. The harness then tests the artifact a church
|
||||||
|
would install, not a workspace build of it. Until the packager exists, the harness and smoke tests
|
||||||
|
may keep their current test-only packaging.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
- A module release becomes one file plus its digest, built once by the module's CI and installable
|
||||||
|
on any compatible Ekklesia installation without touching the platform image.
|
||||||
|
- Module authors gain a packaging step and a target per native environment. A pure-JavaScript
|
||||||
|
module ships one `any` package; Signage ships `linux-x64-glibc` packages for the current fleet.
|
||||||
|
- Packages are small and auditable: bundled code plus, only where needed, a native dependency
|
||||||
|
tree. There are no links and no development dependencies, and every file is listed with its
|
||||||
|
digest.
|
||||||
|
- Each package carries its own copy of the SDK and contracts. Version agreement with the platform is
|
||||||
|
checked when the package is built, against the manifest's `moduleSdk` range, and Core keeps
|
||||||
|
validating module definitions structurally (ADR 0048).
|
||||||
|
- Rolling back after a schema-changing update is a backup restore. Operators must take a backup
|
||||||
|
before approving migrations.
|
||||||
|
- Licence handling becomes the packager's responsibility, and until it is proven no package
|
||||||
|
counts as a release.
|
||||||
|
- The Drizzle requirement is visible in package metadata as `transitional`, so its eventual removal
|
||||||
|
is a recorded format change and not a silent behaviour change.
|
||||||
|
|
||||||
|
## Alternatives
|
||||||
|
|
||||||
|
- **`pnpm deploy` as the packager.** Rejected for the reasons in Context.
|
||||||
|
- **Copying the workspace's dependency tree without bundling.** Rejected as the default. It ships
|
||||||
|
far more than runs (49 MB against 22 MB for Signage), keeps `workspace:` ranges, and makes every
|
||||||
|
pure-JavaScript dependency a file-layout concern. Bundling is already how browser artifacts are
|
||||||
|
built.
|
||||||
|
- **Platform-lent runtime dependencies.** Rejected by ADR 0048.
|
||||||
|
- **Installing the SDK from a registry when the module is installed.** Rejected: installation would
|
||||||
|
need network access and a resolver on every installation, and an installation's code would depend
|
||||||
|
on what a registry served that day. Publishing the SDK packages for module *builds* is a separate,
|
||||||
|
compatible future step.
|
||||||
|
- **Module-author-chosen externals.** Rejected: an arbitrary external is an undeclared runtime
|
||||||
|
dependency the platform cannot verify, and it reintroduces the `node_modules` layout as a
|
||||||
|
contract.
|
||||||
|
- **An OCI artifact as the package format.** Not adopted for v1: it needs registry tooling and login
|
||||||
|
to install, and adds nothing to integrity that the digest does not already give. Nothing here
|
||||||
|
prevents carrying the same `.tar.gz` inside an OCI artifact.
|
||||||
|
- **`.tar.zst`.** Not adopted for v1: about 17 % smaller for Signage at a high compression level,
|
||||||
|
but about three times slower to produce there, and less universally available than gzip. A later
|
||||||
|
format version may add it.
|
||||||
|
- **Packages carrying several architectures at once.** Not adopted for v1: each extra architecture
|
||||||
|
adds about 18.5 MB for Signage, for a fleet that runs one architecture. A later format version may
|
||||||
|
allow it.
|
||||||
|
- **Permitting internal symbolic links.** Rejected: links are where escape checks are subtle, and
|
||||||
|
the native dependencies that motivated the question were packaged without any.
|
||||||
|
|
||||||
|
## Deferred
|
||||||
|
|
||||||
|
- Package signatures and publisher trust roots.
|
||||||
|
- A marketplace or catalogue, and registry or hosting-provider integration.
|
||||||
|
- An installer, and any installer UI; an automatic update service.
|
||||||
|
- The purge workflow (ADR 0048).
|
||||||
|
- Packages carrying several architectures at once.
|
||||||
|
- The stable Module Database ABI that retires `transitional.databaseRuntime`.
|
||||||
|
- Source maps and other debugging material in or beside packages.
|
||||||
|
- Publishing the Ekklesia SDK packages for module builds outside a platform workspace.
|
||||||
|
- Moving Signage to the new package format.
|
||||||
@@ -52,4 +52,9 @@ ADRs capture durable choices that affect more than one package or phase.
|
|||||||
- [0041: One module may contribute a browser renderer to another module's capability](0041-module-browser-contributions.md) — Accepted
|
- [0041: One module may contribute a browser renderer to another module's capability](0041-module-browser-contributions.md) — Accepted
|
||||||
- [0042: Explicit, bounded streaming request bodies](0042-bounded-streaming-request-bodies.md) — Accepted
|
- [0042: Explicit, bounded streaming request bodies](0042-bounded-streaming-request-bodies.md) — Accepted
|
||||||
- [0043: The display caches its shell and declared code, and nothing else](0043-display-offline-code-fallback.md) — Accepted
|
- [0043: The display caches its shell and declared code, and nothing else](0043-display-offline-code-fallback.md) — Accepted
|
||||||
- [0044: Ekklesia ships one self-hosted reference deployment, composed from pinned sources](0044-self-hosted-reference-deployment.md) — Proposed
|
- [0044: Ekklesia ships one self-hosted reference deployment, composed from pinned sources](0044-self-hosted-reference-deployment.md) — Accepted
|
||||||
|
- [0045: The Dashboard and the API may live on separate origins](0045-split-dashboard-and-api-origins.md) — Accepted
|
||||||
|
- [0046: Ekklesia's branding is never forced on an installation](0046-installation-owned-branding.md) — Proposed
|
||||||
|
- [0047: Every module proves itself inside Ekklesia, with one harness the platform publishes](0047-module-ci-harness.md) — Proposed
|
||||||
|
- [0048: Ekklesia ships one generic image, and installed modules live outside it](0048-generic-image-and-installed-module-storage.md) — Accepted
|
||||||
|
- [0049: A module release is one immutable, self-contained package: Ekklesia Module Package v1](0049-module-package-v1.md) — Accepted
|
||||||
|
|||||||
@@ -652,3 +652,38 @@ Still deliberately absent after Phase 8: a theme editor, a brand builder, or any
|
|||||||
module store or installer; a standalone host; signed write URLs, resumable uploads, or any body above
|
module store or installer; a standalone host; signed write URLs, resumable uploads, or any body above
|
||||||
the absolute ceiling; module-owned offline *content*; and every real feature module, Signage
|
the absolute ceiling; module-owned offline *content*; and every real feature module, Signage
|
||||||
included. Nothing in this pass claims any of them exists.
|
included. Nothing in this pass claims any of them exists.
|
||||||
|
|
||||||
|
[ADR 0048](decisions/0048-generic-image-and-installed-module-storage.md) separates the platform
|
||||||
|
artifact from what an installation installs:
|
||||||
|
|
||||||
|
- **One generic image.** `deploy/Dockerfile` builds one image with zero feature modules, the same
|
||||||
|
for every installation; a checkout's `modules/` never reaches it.
|
||||||
|
- **Module storage.** Installed modules live in installation-owned storage that one Core resolver
|
||||||
|
selects from `EKKLESIA_MODULES_DIR`. The API, every operator command, and Doctor use the same
|
||||||
|
resolver, and Core's own migrations are always found under the platform, never beside the
|
||||||
|
modules. A configured directory that is relative, blank, missing, or unreadable fails closed
|
||||||
|
with a sanitized error.
|
||||||
|
- **Self-contained modules.** An installed module is a self-contained directory with its own
|
||||||
|
production dependencies; the platform lends it none. Discovery still happens only when a process
|
||||||
|
starts, so module changes take effect at a controlled restart and nothing is hot loaded.
|
||||||
|
- **Removing keeps data.** Removing a module's code keeps its data and stored state. The migration
|
||||||
|
runner retains and reports the applied history of a module that is absent, instead of refusing
|
||||||
|
to plan, while Core's history and every installed module's history stay strict, so a returning
|
||||||
|
module that lost or changed an applied migration is still refused.
|
||||||
|
- **Trust is unchanged.** Installed modules are still trusted code; module storage is mounted
|
||||||
|
read-only, which keeps Ekklesia from modifying installed code but does not sandbox it.
|
||||||
|
|
||||||
|
[ADR 0049](decisions/0049-module-package-v1.md) defines how a module is distributed: one Ekklesia
|
||||||
|
Module Package v1, a deterministic `.tar.gz` with its SHA-256.
|
||||||
|
|
||||||
|
- **The packager.** `@ekklesia/module-pack` bundles the module's backend with every pure-JavaScript
|
||||||
|
dependency, the SDK, contracts, and Drizzle included. It copies the built browser artifacts and
|
||||||
|
migrations, generates the runtime `package.json`, `ekklesia.package.json` with every file's digest,
|
||||||
|
and `THIRD_PARTY_NOTICES`, and writes the archive.
|
||||||
|
- **The verifier.** Core's `verifyModulePackage` reads the archive in memory and refuses links,
|
||||||
|
special files, and escaping paths. It checks every digest and identity, the target and runtime,
|
||||||
|
and the transitional Drizzle agreement, runs the platform's own module and migration validation,
|
||||||
|
and loads the backend once in a short-lived process with an empty environment. Only then does it
|
||||||
|
extract anything.
|
||||||
|
- **What exists today.** Only the `any` target. Drizzle agreement is recorded as transitional, not a
|
||||||
|
database ABI.
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ These rules map platform ownership to filesystem and package boundaries. The exe
|
|||||||
general "browser-safe shared packages are fine" relaxation: `api-client`, `auth-client`,
|
general "browser-safe shared packages are fine" relaxation: `api-client`, `auth-client`,
|
||||||
`surface-sdk`, and `module-ui-host` remain rejected inside a module. Both approved packages are
|
`surface-sdk`, and `module-ui-host` remain rejected inside a module. Both approved packages are
|
||||||
browser-safe by construction — `design-tokens` has no dependencies at all and `ui` depends only on
|
browser-safe by construction — `design-tokens` has no dependencies at all and `ui` depends only on
|
||||||
it — so a module UI file that imports them still cannot typecheck `process.env`, proven by a real
|
it and React — so a module UI file that imports them still cannot typecheck `process.env`, proven by a real
|
||||||
compile under the exact browser project a module uses. Both are also pinned at the bottom of the
|
compile under the exact browser project a module uses. Both are also pinned at the bottom of the
|
||||||
presentation stack by `design-packages-depend-downward-only`: neither may reach an implementation
|
presentation stack by `design-packages-depend-downward-only`: neither may reach an implementation
|
||||||
layer, an API/auth client, or the backend contracts barrel, because a design package that could
|
layer, an API/auth client, or the backend contracts barrel, because a design package that could
|
||||||
@@ -134,6 +134,14 @@ These rules map platform ownership to filesystem and package boundaries. The exe
|
|||||||
- Workspace package consumers use declared `exports`, not private subpaths.
|
- Workspace package consumers use declared `exports`, not private subpaths.
|
||||||
- Every internal dependency uses a `workspace:` range and every imported workspace package is
|
- Every internal dependency uses a `workspace:` range and every imported workspace package is
|
||||||
declared by the consumer.
|
declared by the consumer.
|
||||||
|
- Code a workspace package ships (its `src/` and `bin/`, excluding tests and declaration files)
|
||||||
|
declares every runtime import in `dependencies` or `optionalDependencies`. A peer declaration may
|
||||||
|
stay beside it, but never replaces it: a workspace package is linked into its consumers as a
|
||||||
|
directory, so its imports resolve from its own `node_modules`, where a consumer never satisfies a
|
||||||
|
peer, and a production install (`pnpm install --prod`, as the production image runs) omits
|
||||||
|
development dependencies. Type-only imports are exempt; a `.tsx`/`.jsx` file counts as importing
|
||||||
|
`react`, because the automatic JSX runtime imports `react/jsx-runtime` implicitly
|
||||||
|
(`runtime-imports-are-production-dependencies`).
|
||||||
- Workspace aliases are forbidden because they obscure package ownership in the dependency graph.
|
- Workspace aliases are forbidden because they obscure package ownership in the dependency graph.
|
||||||
- Workspace package names are unique and shared packages declare a recognized architectural role.
|
- Workspace package names are unique and shared packages declare a recognized architectural role.
|
||||||
- Circular source and workspace-package dependencies are rejected.
|
- Circular source and workspace-package dependencies are rejected.
|
||||||
|
|||||||
+142
-9
@@ -60,9 +60,10 @@ No real values are shown here — configure per environment and never commit the
|
|||||||
| `EKKLESIA_DATABASE_CA` | Optional. PEM text of a private certificate authority to trust in addition to the system store, for a database using an internal CA. |
|
| `EKKLESIA_DATABASE_CA` | Optional. PEM text of a private certificate authority to trust in addition to the system store, for a database using an internal CA. |
|
||||||
| `EKKLESIA_API_HOST` | Optional. Listen address. Defaults to loopback (`127.0.0.1`). An explicit non-loopback address is permitted for future deployment. |
|
| `EKKLESIA_API_HOST` | Optional. Listen address. Defaults to loopback (`127.0.0.1`). An explicit non-loopback address is permitted for future deployment. |
|
||||||
| `EKKLESIA_API_PORT` | Optional. Listen port, `0`–`65535` (`0` requests an OS-assigned port). Defaults to `3000`. |
|
| `EKKLESIA_API_PORT` | Optional. Listen port, `0`–`65535` (`0` requests an OS-assigned port). Defaults to `3000`. |
|
||||||
|
| `EKKLESIA_MODULES_DIR` | Optional. The installed-modules directory, as an absolute path ([ADR 0048](../architecture/decisions/0048-generic-image-and-installed-module-storage.md)). Unset means the checkout's own `modules/`, where a missing directory is simply zero modules. Set, the directory must exist: a relative, blank, or unreadable value, or a missing directory, stops startup before any database connection with a sanitized `MODULES_DIRECTORY_INVALID` or `MODULES_DIRECTORY_UNREADABLE` that never contains the value. The production image sets `/var/lib/ekklesia/modules`. Every operator command and Doctor read the same value. |
|
||||||
| `EKKLESIA_IDENTITY_SECRET` | At least 32 characters, high entropy (for example `openssl rand -base64 32`). Same requirement as Phase 4A. |
|
| `EKKLESIA_IDENTITY_SECRET` | At least 32 characters, high entropy (for example `openssl rand -base64 32`). Same requirement as Phase 4A. |
|
||||||
| `EKKLESIA_IDENTITY_BASE_URL` | Exactly one canonical `http`/`https` origin — scheme, host, and non-default port only, with no path, trailing slash, query, fragment, credentials, or explicit default port. `https` is required in production; loopback and unspecified hosts are rejected in production. |
|
| `EKKLESIA_IDENTITY_BASE_URL` | Exactly one canonical `http`/`https` origin — scheme, host, and non-default port only, with no path, trailing slash, query, fragment, credentials, or explicit default port. `https` is required in production; loopback and unspecified hosts are rejected in production. |
|
||||||
| `EKKLESIA_IDENTITY_TRUSTED_ORIGINS` | Comma-separated list of canonical origins, validated identically to the base URL (no wildcards). Used for both Better Auth's own CSRF/origin checks and the API's CORS policy — there is only one trusted-origin list. |
|
| `EKKLESIA_IDENTITY_TRUSTED_ORIGINS` | Comma-separated list of canonical origins, validated identically to the base URL (no wildcards). Used for both Better Auth's own CSRF/origin checks and the API's CORS policy — there is only one trusted-origin list. The base URL's own origin is always trusted as well and need not be listed ([ADR 0045](../architecture/decisions/0045-split-dashboard-and-api-origins.md)). |
|
||||||
| `EKKLESIA_PUBLIC_SIGNUP_ENABLED` | Optional, **development/test only**. Exactly `"true"` enables public sign-up when `NODE_ENV=development`; defaults to disabled, and a production configuration rejects `"true"` outright. Omitted, misspelled, or any other value never enables it. See [Public registration policy](#public-registration-policy). |
|
| `EKKLESIA_PUBLIC_SIGNUP_ENABLED` | Optional, **development/test only**. Exactly `"true"` enables public sign-up when `NODE_ENV=development`; defaults to disabled, and a production configuration rejects `"true"` outright. Omitted, misspelled, or any other value never enables it. See [Public registration policy](#public-registration-policy). |
|
||||||
| `EKKLESIA_CLIENT_ADDRESS_MODE` | Optional. `"direct"` (default when unset) or `"trusted-proxy"`. See [Client-address resolution](#client-address-resolution). |
|
| `EKKLESIA_CLIENT_ADDRESS_MODE` | Optional. `"direct"` (default when unset) or `"trusted-proxy"`. See [Client-address resolution](#client-address-resolution). |
|
||||||
| `EKKLESIA_CLIENT_ADDRESS_HEADER` | Required only when `EKKLESIA_CLIENT_ADDRESS_MODE=trusted-proxy`. The one forwarded-header name the reverse proxy is trusted to set (for example `x-forwarded-for`). |
|
| `EKKLESIA_CLIENT_ADDRESS_HEADER` | Required only when `EKKLESIA_CLIENT_ADDRESS_MODE=trusted-proxy`. The one forwarded-header name the reverse proxy is trusted to set (for example `x-forwarded-for`). |
|
||||||
@@ -77,6 +78,10 @@ No real values are shown here — configure per environment and never commit the
|
|||||||
| `/api/auth/{get-session,list-sessions,update-session,change-password}` | Deliberately unavailable | Token-bearing native session inspection/mutation endpoints are denied at the Ekklesia HTTP boundary with sanitized `404 {"code":"NOT_FOUND"}`. Use `/api/v1/principal`; no browser session-list/update or password-management contract exists. |
|
| `/api/auth/{get-session,list-sessions,update-session,change-password}` | Deliberately unavailable | Token-bearing native session inspection/mutation endpoints are denied at the Ekklesia HTTP boundary with sanitized `404 {"code":"NOT_FOUND"}`. Use `/api/v1/principal`; no browser session-list/update or password-management contract exists. |
|
||||||
| `POST\|GET /api/auth/*` | Better Auth (mounted directly) | Supported Better Auth routes retain their native shapes except for the successful email sign-in normalization above. The four denied paths above never reach Better Auth. See [Better Auth's email/password docs](https://www.better-auth.com/docs/authentication/email-password) for supported endpoints such as `/sign-up/email` and `/sign-out`. |
|
| `POST\|GET /api/auth/*` | Better Auth (mounted directly) | Supported Better Auth routes retain their native shapes except for the successful email sign-in normalization above. The four denied paths above never reach Better Auth. See [Better Auth's email/password docs](https://www.better-auth.com/docs/authentication/email-password) for supported endpoints such as `/sign-up/email` and `/sign-out`. |
|
||||||
| `GET /api/v1/principal` | Ekklesia | Returns the minimal `EkklesiaPrincipal` contract as JSON. `Cache-Control: no-store`. |
|
| `GET /api/v1/principal` | Ekklesia | Returns the minimal `EkklesiaPrincipal` contract as JSON. `Cache-Control: no-store`. |
|
||||||
|
| `GET /` | Ekklesia | The built Dashboard page (`surfaces/shell`'s `index.html`), with the API's origin written into it. Served from the API's origin, or from the Dashboard's own hostname through the proxy in the split topology. `no-cache` with a strong `ETag`; framing refused. See [Dashboard host](#dashboard-host). |
|
||||||
|
| `GET /assets/ekklesia-dashboard/app.js` | Ekklesia | The Dashboard's one script. `no-cache` with a strong `ETag`; its source map is never served. See [Dashboard host](#dashboard-host). |
|
||||||
|
| `GET /api/v1/health/live` | Ekklesia | Unauthenticated liveness: fixed `200 {"status":"live"}` whenever the process is serving HTTP. `Cache-Control: no-store`; GET only. See [Health](#health). |
|
||||||
|
| `GET /api/v1/health/ready` | Ekklesia | Unauthenticated readiness: fixed `200 {"status":"ready"}` only when a bounded database ping succeeds and module startup completed, otherwise `503 {"code":"NOT_READY"}`. `Cache-Control: no-store`; GET only. See [Health](#health). |
|
||||||
| `GET\|POST /api/v1/grants*` | Ekklesia | Explicit authorization grant management, gated by `core.authorization.grants.manage`. Listing is bounded keyset pagination; creation is unscoped-only and conflicts (`409`) on an equivalent effective grant; revocation clears every unrevoked equivalent. See [Authorization](authorization.md#explicit-grant-http-api). |
|
| `GET\|POST /api/v1/grants*` | Ekklesia | Explicit authorization grant management, gated by `core.authorization.grants.manage`. Listing is bounded keyset pagination; creation is unscoped-only and conflicts (`409`) on an equivalent effective grant; revocation clears every unrevoked equivalent. See [Authorization](authorization.md#explicit-grant-http-api). |
|
||||||
| `/api/v1/modules/<module-id>/*` | Installed modules | Every route an installed module registered during its `register` hook, gated by that route's own declared permission. See [Module HTTP routes](#module-http-routes). |
|
| `/api/v1/modules/<module-id>/*` | Installed modules | Every route an installed module registered during its `register` hook, gated by that route's own declared permission. See [Module HTTP routes](#module-http-routes). |
|
||||||
| `GET /api/v1/module-realtime/<module-id>/<channel>` | Installed modules | Snapshot-first Server-Sent Events for one registered module-local channel, gated by that channel's declared permission. See [Module realtime](#module-realtime). |
|
| `GET /api/v1/module-realtime/<module-id>/<channel>` | Installed modules | Snapshot-first Server-Sent Events for one registered module-local channel, gated by that channel's declared permission. See [Module realtime](#module-realtime). |
|
||||||
@@ -134,6 +139,77 @@ a new raw token when asked to revoke other sessions. The principal response neve
|
|||||||
contains email, name, image, session id or expiry, session token, account/provider details, or any
|
contains email, name, image, session id or expiry, session token, account/provider details, or any
|
||||||
role, permission, membership, or volunteer data.
|
role, permission, membership, or volunteer data.
|
||||||
|
|
||||||
|
## Dashboard host
|
||||||
|
|
||||||
|
`apps/api` serves the built Dashboard itself
|
||||||
|
([ADR 0044](../architecture/decisions/0044-self-hosted-reference-deployment.md)). In the default
|
||||||
|
same-origin topology it does so from the same origin as `/api`: `EKKLESIA_DASHBOARD_ORIGIN` stays
|
||||||
|
unset, the session cookie stays host-only, and no second static server or proxy file root is
|
||||||
|
needed. In the split topology
|
||||||
|
([ADR 0045](../architecture/decisions/0045-split-dashboard-and-api-origins.md)) the proxy forwards
|
||||||
|
the Dashboard's own hostname to the same two routes; the API does not inspect `Host`. It works
|
||||||
|
exactly like the display host:
|
||||||
|
|
||||||
|
- **A closed set of files, read once at startup.** `@ekklesia/shell/dashboard-assets` exports the
|
||||||
|
exact URLs of two built files, `dist/browser/index.html` and
|
||||||
|
`dist/browser/assets/ekklesia-dashboard/app.js`. `startApi()` reads both before importing any
|
||||||
|
module code or binding the listener, and keeps one frozen snapshot of their text with a SHA-256
|
||||||
|
strong `ETag` for each.
|
||||||
|
- **The API's origin is written in at startup.** The built page carries one empty
|
||||||
|
`<meta content="" name="ekklesia-api-origin" />`. Before computing the page's `ETag`, `startApi()`
|
||||||
|
fills it with the identity base URL's origin, which is where `/api` lives in either topology. The
|
||||||
|
shell reads it before rendering, uses the page's own origin when it is empty, and fails closed,
|
||||||
|
making no request, when it is anything but a canonical `http(s)` origin. Nothing about the topology
|
||||||
|
is built into the bundle.
|
||||||
|
- **A missing or incompatible build fails startup.** An absent, unreadable, or empty file, or a page
|
||||||
|
without exactly one empty API-origin element, stops `startApi()` with a fixed
|
||||||
|
`DashboardHostAssetError` (`DASHBOARD_HOST_ASSET_UNAVAILABLE`), after closing the resources
|
||||||
|
already opened. It never carries a path or the filesystem error. Build the shell first;
|
||||||
|
`pnpm api:start` already runs the full build.
|
||||||
|
- **Two exact routes, never a directory.** `GET /` answers the page and
|
||||||
|
`GET /assets/ekklesia-dashboard/app.js` answers the script. No request value selects a file, and
|
||||||
|
nothing touches the filesystem while answering. Every other path, including `/index.html`,
|
||||||
|
`/app.js`, the script's `.map`, and any traversal spelling, falls through to the ordinary
|
||||||
|
`404 {"code":"NOT_FOUND"}`. There is no catch-all HTML fallback: the Dashboard has no client
|
||||||
|
router, and emailed links land on `/` with a query (see
|
||||||
|
[The four anonymous screens](#the-four-anonymous-screens)). Methods other than `GET` (and Hono's
|
||||||
|
implicit `HEAD`) are also the ordinary `404`.
|
||||||
|
- **No source maps.** The build emits `app.js.map` beside the script, but it is not one of the two
|
||||||
|
files, so it is never served, matching module UI publication (ADR 0025). Browser devtools receive
|
||||||
|
a `404` for it.
|
||||||
|
|
||||||
|
Neither route resolves a principal or reads a cookie, and neither is under `/api/`, so the API's
|
||||||
|
CORS policy does not apply. The bytes grant no authority: the page renders sign-in until
|
||||||
|
`/api/v1/principal` says otherwise.
|
||||||
|
|
||||||
|
| Header | `GET /` | `GET /assets/ekklesia-dashboard/app.js` |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `Content-Type` | `text/html; charset=utf-8` | `text/javascript; charset=utf-8` |
|
||||||
|
| `Cache-Control` | `no-cache` | `no-cache` |
|
||||||
|
| `ETag` | strong SHA-256 of the page | strong SHA-256 of the script |
|
||||||
|
| `X-Content-Type-Options` | `nosniff` | `nosniff` |
|
||||||
|
| `X-Frame-Options` | `DENY` | — |
|
||||||
|
| `Content-Security-Policy` | `frame-ancestors 'none'` | — |
|
||||||
|
| `Referrer-Policy` | `no-referrer` | — |
|
||||||
|
|
||||||
|
**Caching.** The shell emits fixed, unhashed file names, so both files are revalidated on every
|
||||||
|
load, exactly like module UI artifacts and the display host's script. A matching `If-None-Match`
|
||||||
|
(strong, weak `W/`, a list, or `*`) answers an empty `304` carrying the same `ETag` and
|
||||||
|
`Cache-Control`. The page's `304` also carries its security headers, so a revalidated cache entry
|
||||||
|
keeps them. Neither file is ever `immutable`: a rebuilt Dashboard reaches a browser on its next load.
|
||||||
|
|
||||||
|
**Page security headers.** The page holds the platform sign-in form, so it refuses to be framed,
|
||||||
|
through `X-Frame-Options` and CSP `frame-ancestors`. That is the only directive the policy carries:
|
||||||
|
the full Content-Security-Policy is a separate decision. `no-referrer` keeps the page URL out of
|
||||||
|
every request the page makes, which matters while an emailed reset token is briefly in it. The shell
|
||||||
|
strips that token from the address bar before it renders anything.
|
||||||
|
|
||||||
|
**Development.** `pnpm --filter @ekklesia/shell dev` serves `dist/browser` at the same absolute
|
||||||
|
paths, because the build emits the script at `assets/ekklesia-dashboard/app.js`. `index.html` names
|
||||||
|
that path absolutely, so the page never depends on the URL that served it. For a split-origin
|
||||||
|
development API, set `EKKLESIA_API_BASE_URL` for the development server: it writes that origin into
|
||||||
|
its copy of the page exactly as the API does in production. A production build ignores it.
|
||||||
|
|
||||||
## Dashboard browser authentication
|
## Dashboard browser authentication
|
||||||
|
|
||||||
The Dashboard flow is owned by `surfaces/shell`; modules never drive these routes and the
|
The Dashboard flow is owned by `surfaces/shell`; modules never drive these routes and the
|
||||||
@@ -277,7 +353,10 @@ real server: 7 characters rejected, 8 accepted, 128 accepted, 129 rejected.
|
|||||||
## Origin, CORS, and cookies
|
## Origin, CORS, and cookies
|
||||||
|
|
||||||
The API reuses the same `EKKLESIA_IDENTITY_TRUSTED_ORIGINS` list for both CORS and Better Auth's
|
The API reuses the same `EKKLESIA_IDENTITY_TRUSTED_ORIGINS` list for both CORS and Better Auth's
|
||||||
own origin/CSRF checks — there is no second, competing configuration. CORS reflects a request's
|
own origin/CSRF checks — there is no second, competing configuration. Like Better Auth, the API also
|
||||||
|
trusts the identity base URL's own origin without it being listed, so a page the API itself serves
|
||||||
|
(the display host, or the Dashboard in the same-origin topology) passes the API's origin checks
|
||||||
|
([ADR 0045](../architecture/decisions/0045-split-dashboard-and-api-origins.md)). CORS reflects a request's
|
||||||
`Origin` header back only when it exactly matches a configured trusted origin (never a wildcard,
|
`Origin` header back only when it exactly matches a configured trusted origin (never a wildcard,
|
||||||
since `Access-Control-Allow-Origin: *` cannot be combined with `Access-Control-Allow-Credentials:
|
since `Access-Control-Allow-Origin: *` cannot be combined with `Access-Control-Allow-Credentials:
|
||||||
true`); a disallowed origin never receives the header at all, though the request itself may still
|
true`); a disallowed origin never receives the header at all, though the request itself may still
|
||||||
@@ -311,10 +390,18 @@ Cookies keep Better Auth's defaults: `HttpOnly` always, `SameSite=Lax` always, `
|
|||||||
configured base URL is `https://`. Cross-subdomain cookies are not enabled and no cookie `Domain`
|
configured base URL is `https://`. Cross-subdomain cookies are not enabled and no cookie `Domain`
|
||||||
attribute is set, so every cookie is host-only. See [Client-address resolution](#client-address-resolution)
|
attribute is set, so every cookie is host-only. See [Client-address resolution](#client-address-resolution)
|
||||||
for how (and whether) a reverse proxy's forwarded headers are trusted — a separate concern from
|
for how (and whether) a reverse proxy's forwarded headers are trusted — a separate concern from
|
||||||
CORS/CSRF above. The Dashboard does not broaden this policy: same-origin `/api` proxying is the
|
CORS/CSRF above. The Dashboard does not broaden this policy. In the split topology
|
||||||
simplest production topology, while a deliberately split browser/API origin must use the exact
|
([ADR 0045](../architecture/decisions/0045-split-dashboard-and-api-origins.md)) the cookie stays
|
||||||
trusted-origin allowlist and credentialed CORS. Sibling subdomains do not implicitly share the
|
host-only on the API's hostname, and the Dashboard on a sibling hostname reaches the API with
|
||||||
cookie. No production Dashboard hostname or proxy deployment exists in this repository.
|
credentialed CORS; browsers send the cookie because the two hostnames are origins of one site, which
|
||||||
|
is therefore a requirement of that topology. The reference deployment provides both topologies
|
||||||
|
([`deploy/README.md`](../../deploy/README.md#split-dashboard-and-api-origins)).
|
||||||
|
|
||||||
|
**CORS request and response headers are deliberately narrow, and part of the module UI contract.**
|
||||||
|
Beyond the CORS-safelisted request headers, only `Content-Type` is allowed, and no response header is
|
||||||
|
exposed beyond the safelisted ones. A module UI's `api.request` inherits this policy, so portable
|
||||||
|
module UI code sends only safelisted headers plus `Content-Type` and reads only safelisted response
|
||||||
|
headers. That keeps one module artifact working in either topology.
|
||||||
|
|
||||||
## Logging
|
## Logging
|
||||||
|
|
||||||
@@ -1420,6 +1507,51 @@ TLS protects the *connection*, not the exposure: it does not make an internet-re
|
|||||||
safe, and the network boundary remains the deployment's own responsibility. See
|
safe, and the network boundary remains the deployment's own responsibility. See
|
||||||
[ADR 0018](../architecture/decisions/0018-identity-session-resolution-and-database-transport.md).
|
[ADR 0018](../architecture/decisions/0018-identity-session-resolution-and-database-transport.md).
|
||||||
|
|
||||||
|
## Health
|
||||||
|
|
||||||
|
Two unauthenticated endpoints exist for Compose health checks and external monitoring
|
||||||
|
([ADR 0044](../architecture/decisions/0044-self-hosted-reference-deployment.md)). They are
|
||||||
|
deliberately uninformative: neither names a module, version, dependency, error, or connection
|
||||||
|
detail, and `pnpm doctor` remains the detailed operator report.
|
||||||
|
|
||||||
|
| Request | Answer |
|
||||||
|
| --- | --- |
|
||||||
|
| `GET /api/v1/health/live` | Always `200 {"status":"live"}`. Liveness consults nothing; if the process can answer, it is live. |
|
||||||
|
| `GET /api/v1/health/ready`, ready | `200 {"status":"ready"}` |
|
||||||
|
| `GET /api/v1/health/ready`, not ready | `503 {"code":"NOT_READY"}` |
|
||||||
|
| Any other method on either path, including `HEAD` | `405` with `Allow: GET` and no body |
|
||||||
|
|
||||||
|
Every answer sets `Cache-Control: no-store`. Neither route resolves a principal, reads a cookie, or
|
||||||
|
evaluates authorization. Both live under `/api/v1/health/`, a sibling of the `/api/v1/modules/`
|
||||||
|
prefix, so no module route can shadow them, including a module whose id is `health`. As for every
|
||||||
|
`/api/v1/*` route, an `OPTIONS` request is answered by the platform's CORS preflight and never
|
||||||
|
reaches the handlers.
|
||||||
|
|
||||||
|
Readiness is `200` only when **both** of the following hold:
|
||||||
|
|
||||||
|
- **Module startup completed, and shutdown has not begun.** `startApi()` binds the HTTP listener
|
||||||
|
only after the module runtime has started, so this holds whenever the server is answering. The
|
||||||
|
first `shutdown()` call clears it before anything drains, so a draining instance reports `503`
|
||||||
|
while liveness still reports `200`. It is checked again after the database ping, in case shutdown
|
||||||
|
began meanwhile.
|
||||||
|
- **The database answered a trivial query within 2 seconds** (`READINESS_DATABASE_TIMEOUT_MS`).
|
||||||
|
The ping runs on the one shared pool as a single message,
|
||||||
|
`BEGIN READ ONLY; SET LOCAL statement_timeout = 2000; SELECT 1; COMMIT`. The transaction-local
|
||||||
|
timeout means PostgreSQL cancels a stalled statement itself rather than leaving it running. On
|
||||||
|
any failure the pool discards that connection instead of reusing it. The pool's own 10-second
|
||||||
|
connection, query, and statement timeouts remain the outer bound, for example for a database
|
||||||
|
that cannot be reached at all.
|
||||||
|
|
||||||
|
**Pings never pile up.** At most one ping is ever unsettled. Probes that arrive while it runs share
|
||||||
|
its result and its single 2-second deadline. Once the deadline passes they answer `503` immediately,
|
||||||
|
without starting another query, until that ping has actually settled. A stalled database therefore
|
||||||
|
holds at most one pool connection on behalf of health checks, however often a monitor polls. A
|
||||||
|
successful result is never cached: every probe after a settled ping runs a new one.
|
||||||
|
|
||||||
|
A failed ping is never inspected. The response is the same fixed `503` whether the database
|
||||||
|
refused the connection, timed out, rejected the credentials, or was mid-restart, and nothing about
|
||||||
|
the failure is logged by the route.
|
||||||
|
|
||||||
## Graceful shutdown
|
## Graceful shutdown
|
||||||
|
|
||||||
`startApi()` (the composition function `apps/api/src/server.ts` exports, used by both the
|
`startApi()` (the composition function `apps/api/src/server.ts` exports, used by both the
|
||||||
@@ -1504,7 +1636,8 @@ Cookie: better-auth.session_token=<redacted, now revoked>
|
|||||||
|
|
||||||
See the repository root [README](../../README.md#current-status) and
|
See the repository root [README](../../README.md#current-status) and
|
||||||
[platform architecture](../architecture/platform-architecture.md#current-implementation-status)
|
[platform architecture](../architecture/platform-architecture.md#current-implementation-status)
|
||||||
for what has landed across every phase and what remains. Surface work is underway; production
|
for what has landed across every phase and what remains. Surface work is underway; the API now
|
||||||
Dashboard asset hosting, enrollment/recovery, display device identity, and public Website routing
|
serves the built Dashboard ([Dashboard host](#dashboard-host)) in either topology, while the full
|
||||||
remain separate work. This document tracks `apps/api`'s own HTTP, module-runtime, and
|
Content-Security-Policy, enrollment/recovery, display device identity, and public Website
|
||||||
|
routing remain separate work. This document tracks `apps/api`'s own HTTP, module-runtime, and
|
||||||
database-composition contracts as they evolve.
|
database-composition contracts as they evolve.
|
||||||
|
|||||||
@@ -173,8 +173,9 @@ integration layer depends on authorization).
|
|||||||
exact same installed-module inventory every time it starts, in a fixed order, before anything
|
exact same installed-module inventory every time it starts, in a fixed order, before anything
|
||||||
else:
|
else:
|
||||||
|
|
||||||
1. Resolve module-discovery configuration (the repository's `modules/` directory, and the
|
1. Resolve module-discovery configuration: the installed-modules directory, chosen by Core's one
|
||||||
platform/Module SDK versions from their own `package.json` files).
|
resolver from `EKKLESIA_MODULES_DIR` (the repository's own `modules/` when it is unset, ADR
|
||||||
|
0048), and the platform/Module SDK versions from the platform's own `package.json` files.
|
||||||
2. Discover installed modules exactly once (`discoverModules`).
|
2. Discover installed modules exactly once (`discoverModules`).
|
||||||
3. Compose the platform permission registry from that exact discovered list
|
3. Compose the platform permission registry from that exact discovered list
|
||||||
(`composePlatformPermissionRegistry`).
|
(`composePlatformPermissionRegistry`).
|
||||||
@@ -199,7 +200,8 @@ never falls back to `CORE_PERMISSION_REGISTRY` or any Core-only registry. The re
|
|||||||
resolved from this compiled/source file's own location (two directories up from
|
resolved from this compiled/source file's own location (two directories up from
|
||||||
`apps/api/dist/*.js` or `apps/api/src/*.ts`, which sit at identical depth), never `process.cwd()`
|
`apps/api/dist/*.js` or `apps/api/src/*.ts`, which sit at identical depth), never `process.cwd()`
|
||||||
— a package-scoped test run has `apps/api` as its working directory, which would otherwise point
|
— a package-scoped test run has `apps/api` as its working directory, which would otherwise point
|
||||||
discovery at the wrong `modules/` directory.
|
discovery at the wrong `modules/` directory. A configured `EKKLESIA_MODULES_DIR` that is not an
|
||||||
|
absolute path to an existing directory fails at step 1, before anything else exists.
|
||||||
|
|
||||||
The resulting registry is a **static, immutable snapshot** taken once at startup. There is no hot
|
The resulting registry is a **static, immutable snapshot** taken once at startup. There is no hot
|
||||||
installation, removal, or dynamic mutation of installed modules or their permissions while the
|
installation, removal, or dynamic mutation of installed modules or their permissions while the
|
||||||
|
|||||||
@@ -35,8 +35,10 @@ The lint, typecheck, and test commands run both repository-root checks and every
|
|||||||
package-level Turborepo task. New packages add their own `lint`, `typecheck`, and `test` scripts;
|
package-level Turborepo task. New packages add their own `lint`, `typecheck`, and `test` scripts;
|
||||||
the root verification command discovers them automatically.
|
the root verification command discovers them automatically.
|
||||||
|
|
||||||
`pnpm modules:validate` builds the workspace, then scans `modules/*` without a central registry. A
|
`pnpm modules:validate` builds the workspace, then scans the installed-modules directory without a
|
||||||
missing or empty modules directory is valid and reports zero installed modules. Installed modules
|
central registry: the checkout's own `modules/`, or `EKKLESIA_MODULES_DIR` when it is set (ADR
|
||||||
|
0048). A missing or empty `modules/` is valid and reports zero installed modules; a configured
|
||||||
|
directory must exist. Installed modules
|
||||||
must produce the compiled JavaScript entrypoints declared by their static manifests. Each module's
|
must produce the compiled JavaScript entrypoints declared by their static manifests. Each module's
|
||||||
declared browser contributions are listed with their target capability and range, and marked
|
declared browser contributions are listed with their target capability and range, and marked
|
||||||
according to whether a compatible provider is installed — a contribution nobody provides is otherwise
|
according to whether a compatible provider is installed — a contribution nobody provides is otherwise
|
||||||
@@ -113,9 +115,12 @@ identity secret, not a module secret value.
|
|||||||
|
|
||||||
Every check has a stable ID and one of four states: pass, warning, failure, or skipped. Independent
|
Every check has a stable ID and one of four states: pass, warning, failure, or skipped. Independent
|
||||||
checks continue after a failure; a failed prerequisite causes only its dependent checks to be
|
checks continue after a failure; a failed prerequisite causes only its dependent checks to be
|
||||||
skipped. Pending or approval-blocked migrations are warnings and are never applied. Invalid
|
skipped. Pending or approval-blocked migrations are warnings and are never applied. Applied
|
||||||
history, checksum disagreement, unknown applied migrations, or a failed configured connection are
|
history that belongs to a module which is not installed is reported as `retained` and is neither a
|
||||||
failures. If `DATABASE_URL` is missing, PostgreSQL is skipped and all static checks still run.
|
warning nor a failure (ADR 0048). Invalid history, checksum disagreement, an applied migration that
|
||||||
|
an installed module or Core no longer contains, or a failed configured connection are failures. An
|
||||||
|
unusable configured `EKKLESIA_MODULES_DIR` fails module discovery. If `DATABASE_URL` is missing,
|
||||||
|
PostgreSQL is skipped and all static checks still run.
|
||||||
|
|
||||||
`READY` means every check passed. `READY WITH WARNINGS` means no check failed but at least one check
|
`READY` means every check passed. `READY WITH WARNINGS` means no check failed but at least one check
|
||||||
warned or was skipped. Both results exit zero. `NOT READY` means at least one check failed and exits
|
warned or was skipped. Both results exit zero. `NOT READY` means at least one check failed and exits
|
||||||
|
|||||||
@@ -153,7 +153,8 @@ enforced by `requirePermission` regardless; the test suite proves it by having a
|
|||||||
"denied" call its API anyway and still receive `403`.
|
"denied" call its API anyway and still receive `403`.
|
||||||
|
|
||||||
**The host executes trusted module code in its own page and does not pretend otherwise.** The loader
|
**The host executes trusted module code in its own page and does not pretend otherwise.** The loader
|
||||||
performs a same-origin dynamic `import()` — no `eval`, no `new Function`, no `blob:` URL — and the
|
performs a dynamic `import()` from the API's origin (the page's own in the same-origin topology, the
|
||||||
|
API hostname in the split one, ADR 0045) — no `eval`, no `new Function`, no `blob:` URL — and the
|
||||||
shell owns the container, the loading state, and every error, so a failing module costs its own panel
|
shell owns the container, the loading state, and every error, so a failing module costs its own panel
|
||||||
rather than the shell. None of that is containment: once imported, a module UI is ordinary same-realm
|
rather than the shell. None of that is containment: once imported, a module UI is ordinary same-realm
|
||||||
JavaScript, exactly as described above.
|
JavaScript, exactly as described above.
|
||||||
@@ -463,10 +464,27 @@ Until that exists, the trusted-code model above is the security model.
|
|||||||
|
|
||||||
## Current deployment state
|
## Current deployment state
|
||||||
|
|
||||||
As of this branch, **Ekklesia is not deployed**. There is no production API instance, no reverse
|
As of this branch, **Ekklesia is not deployed**. There is no production API instance, no running
|
||||||
proxy, no public listener, and no deployment automation of any kind in this repository — no deploy
|
reverse proxy, no public listener, and no deployment automation in this repository — no deploy
|
||||||
script, no CI deployment step, no container publish, no orchestration manifest. No `.env` has ever
|
script, no CI deployment step, no container publish. The repository provides a **reference deployment**
|
||||||
been committed; only `.env.example`.
|
([`deploy/`](../../deploy/README.md)): a production image and the Compose project that runs it, which
|
||||||
|
CI builds and smoke-tests end to end on every change against disposable services. Nothing pushes,
|
||||||
|
publishes, or runs it against a real installation. No `.env` has ever been committed; only
|
||||||
|
`.env.example`, which the image build context excludes.
|
||||||
|
|
||||||
|
The image contains no modules
|
||||||
|
([ADR 0048](../architecture/decisions/0048-generic-image-and-installed-module-storage.md)).
|
||||||
|
Installed modules come from installation-owned module storage, which the reference deployment mounts
|
||||||
|
**read-only** into `api` and `migrate`, so the Ekklesia process cannot modify installed code. That
|
||||||
|
is an integrity property, not a sandbox: an installed module is still trusted code with the API's
|
||||||
|
database role, exactly as above. The repository's boundary checks prove only source modules in a
|
||||||
|
workspace, and an installed package usually ships none, so installing one means trusting that
|
||||||
|
module's own CI and whoever placed it in module storage. A Module Package v1 (ADR 0049) is verified
|
||||||
|
before it is installed: its digest, its archive safety (no links, special files, setuid bits, or
|
||||||
|
escaping paths), every file's digest, and its identity and compatibility. The digest proves
|
||||||
|
integrity, not who published the package; signing is not implemented. The verifier loads the backend
|
||||||
|
in a short-lived process with an empty environment, so module code never sees a database URL or a
|
||||||
|
secret while it is being checked.
|
||||||
|
|
||||||
Two production defaults are relevant but are **not** by themselves a deployment guarantee:
|
Two production defaults are relevant but are **not** by themselves a deployment guarantee:
|
||||||
|
|
||||||
@@ -486,9 +504,13 @@ This checklist gates a **public launch**. It does not gate development: Surfaces
|
|||||||
work all proceed against the local development infrastructure while every item below is still
|
work all proceed against the local development infrastructure while every item below is still
|
||||||
outstanding.
|
outstanding.
|
||||||
|
|
||||||
1. The reverse proxy serves the built Dashboard assets and `/api` on the intended origin, or an
|
1. The reverse proxy forwards the Dashboard and `/api` on the intended origin, or an explicitly
|
||||||
explicitly split origin is covered by the exact trusted-origin/CORS configuration. The
|
split origin is covered by the exact trusted-origin/CORS configuration. `apps/api` serves the
|
||||||
repository currently supplies neither deployment.
|
built Dashboard itself at `/` ([API § Dashboard host](api.md#dashboard-host)), and the
|
||||||
|
reference deployment's proxy routes it ([deploy/README.md](../../deploy/README.md#launch-checklist)
|
||||||
|
maps every item here to its configuration). A split installation (ADR 0045) puts its Dashboard
|
||||||
|
and API hostnames on one registrable domain and uses `Caddyfile.split.example`, which refuses
|
||||||
|
`/api/*` on the Dashboard's hostname so the session cookie has exactly one home.
|
||||||
2. The origin port is unreachable from the public Internet.
|
2. The origin port is unreachable from the public Internet.
|
||||||
3. The origin port is also unreachable from any untrusted network/VLAN — only the intended private
|
3. The origin port is also unreachable from any untrusted network/VLAN — only the intended private
|
||||||
proxy path connects. Security must not depend on an unpublished DNS name or origin IP.
|
proxy path connects. Security must not depend on an unpublished DNS name or origin IP.
|
||||||
@@ -685,8 +707,10 @@ public Dashboard login must be assessed with the actual edge/proxy controls befo
|
|||||||
|
|
||||||
## Continuous integration boundary
|
## Continuous integration boundary
|
||||||
|
|
||||||
CI runs on self-hosted Gitea Actions runners. The inventoried server is **Gitea 1.24.2**, and that
|
CI runs on self-hosted Gitea Actions runners. The server reports **Gitea 1.27.2** (`GET
|
||||||
version determines which parts of the workflow are real guarantees and which are not.
|
/api/v1/version`, checked 2026-10-06; this document previously said 1.24.2, which was stale). The
|
||||||
|
server's actual version, not the documentation of an earlier one, determines which parts of the
|
||||||
|
workflow are real guarantees and which are not.
|
||||||
|
|
||||||
### The current operating rule: trusted code only
|
### The current operating rule: trusted code only
|
||||||
|
|
||||||
@@ -719,24 +743,53 @@ so a later step in the same job cannot read or reuse it from there.
|
|||||||
It does **not** remove the short-lived `GITEA_TOKEN` from the job environment, and it does **not**
|
It does **not** remove the short-lived `GITEA_TOKEN` from the job environment, and it does **not**
|
||||||
reduce that token's server-side authority. A step that wants the token can still read it.
|
reduce that token's server-side authority. A step that wants the token can still read it.
|
||||||
|
|
||||||
### What Gitea 1.24 ignores — so the branch cannot claim it
|
### What this server honours, and what is not yet verified
|
||||||
|
|
||||||
Per the official comparison for this version
|
Each item separates what Gitea documents for 1.27
|
||||||
(<https://docs.gitea.com/1.24/usage/actions/comparison>), Gitea 1.24's unsupported workflow syntax
|
(<https://docs.gitea.com/1.27/usage/actions/comparison>) from what has been observed on this server:
|
||||||
includes `permissions` and `jobs.<job_id>.permissions`, `concurrency`, and
|
|
||||||
`jobs.<job_id>.timeout-minutes`. On this server those declarations are inert. Therefore:
|
|
||||||
|
|
||||||
- **The workflow's `permissions: contents: read` does not restrict the job token.** This branch
|
- **`concurrency` is honoured.** Gitea supports it since 1.26 (go-gitea/gitea#32751), and on this
|
||||||
cannot claim the `GITEA_TOKEN` is read-only, because nothing in workflow YAML makes it so on
|
server a newer push to a pull request cancels the run it supersedes. Observed: Ekklesia run 133 was
|
||||||
1.24. Job-token permission controls, repository/owner defaults, and server-side
|
cancelled when run 134 started for the same pull request, and run 135 when run 137 did. A
|
||||||
maximum-permission clamping arrive in the 1.26 feature line
|
superseded run's own cleanup is therefore never guaranteed to run, which is one reason for the
|
||||||
(<https://docs.gitea.com/1.26/usage/actions/token-permissions>).
|
stack sweep below.
|
||||||
- **`concurrency` and `timeout-minutes` are not bounded-execution guarantees here.** Cancellation,
|
- **`permissions` is supported by 1.27, with Gitea's own scopes, but is not verified here.** The
|
||||||
timeout, and teardown must be enforced by a trusted provisioner and reaper outside the workflow.
|
server-side token-permission settings are not visible through the API, and nothing has yet proven
|
||||||
|
what the job token can and cannot do on this server. Until that proof exists (requirement 3
|
||||||
|
below), this repository still does not claim the `GITEA_TOKEN` is read-only.
|
||||||
|
- **`timeout-minutes` is no longer listed as unsupported for 1.27, but is not verified here.**
|
||||||
|
Teardown does not rely on it: the stack sweep and a trusted reaper do.
|
||||||
|
- **Still ignored:** `jobs.<job_id>.environment`, and any `runs-on` other than a single label.
|
||||||
|
|
||||||
The declarations are kept deliberately: they are already correct for a supported Gitea release and
|
The declarations stay in the workflow either way: they state the intent, and are correct on GitHub
|
||||||
for GitHub Actions, and keeping them means the upgrade does not require re-deriving the intent. They
|
Actions as well.
|
||||||
are documented as ignored, not removed.
|
|
||||||
|
### Disposable stacks never outlive their job
|
||||||
|
|
||||||
|
Every CI script removes its own Compose stack in an `EXIT` trap, but a trap never runs when the runner
|
||||||
|
kills the job container: a cancelled job, or the runner's own time limit. One such stack ran on the
|
||||||
|
shared runner for two days. So stack cleanup has three layers:
|
||||||
|
|
||||||
|
- **The script's own trap**, for every ordinary failure.
|
||||||
|
- **A final `if: always()` step** in every job that starts stacks: `scripts/ci-sweep.sh --job` removes
|
||||||
|
everything that job started, at any age, even after a failed step.
|
||||||
|
- **A sweep before every such job starts**: `scripts/ci-sweep.sh --stale` removes anything a CI job
|
||||||
|
left more than three hours ago, which catches whatever a killed job could not clean up itself.
|
||||||
|
|
||||||
|
A stack is found by name and label, never by guesswork (`scripts/lib/ci-project.sh`):
|
||||||
|
|
||||||
|
- every CI Compose project is named `ekklesia-ci-<run>-<attempt>-<job key>-…`, and Compose labels
|
||||||
|
its containers, networks, and volumes with that name;
|
||||||
|
- anything a script starts outside Compose carries `org.riverofglorymn.ekklesia.ci.project`;
|
||||||
|
- images also carry the time CI built them, because an image's own creation time can be its base's.
|
||||||
|
|
||||||
|
The job key is per matrix leg, so one leg's cleanup cannot match another's running stack. Nothing
|
||||||
|
outside the `ekklesia-ci-` prefix is ever removed. A job container attached to a stack's network is
|
||||||
|
disconnected, never removed.
|
||||||
|
|
||||||
|
**This is not a substitute for a runner-side reaper.** A stack killed with its job is removed only
|
||||||
|
when another job that sweeps runs on the same runner. Bounded execution and teardown are still the
|
||||||
|
runner host's to enforce, as above.
|
||||||
|
|
||||||
### Runner topology is not verified
|
### Runner topology is not verified
|
||||||
|
|
||||||
@@ -758,16 +811,17 @@ they are not conditions on merging trusted, reviewed work.
|
|||||||
None of them can be satisfied by editing this repository; each requires access to the Gitea server
|
None of them can be satisfied by editing this repository; each requires access to the Gitea server
|
||||||
and runner hosts, and each must be proven there.
|
and runner hosts, and each must be proven there.
|
||||||
|
|
||||||
1. Upgrade Gitea to a reviewed supported release with job-token permission controls — at least the
|
1. ~~Upgrade Gitea to a reviewed supported release with job-token permission controls — at least
|
||||||
1.26 feature line. Select the actual release during infrastructure work, after backup and
|
the 1.26 feature line.~~ **Done:** the server runs 1.27.2. Items 2 and 3 now configure and prove
|
||||||
compatibility review; do not pin a version from this document.
|
what it makes possible.
|
||||||
2. Configure repository and owner defaults, and the server-side maximum permission, so workflow
|
2. Configure repository and owner defaults, and the server-side maximum permission, so workflow
|
||||||
code cannot request more than read-only repository access.
|
code cannot request more than read-only repository access.
|
||||||
3. Prove against the real server that the job's `GITEA_TOKEN` cannot push code, create or update
|
3. Prove against the real server that the job's `GITEA_TOKEN` cannot push code, create or update
|
||||||
releases, packages, issues, or pull requests, change Actions configuration, or reach another
|
releases, packages, issues, or pull requests, change Actions configuration, or reach another
|
||||||
private repository.
|
private repository.
|
||||||
4. Enforce job timeout, cancellation, concurrency, and teardown in the trusted
|
4. Enforce job timeout and teardown in the trusted provisioner/reaper. `concurrency` is honoured
|
||||||
provisioner/reaper rather than relying on the ignored workflow keys above.
|
by the server; `timeout-minutes` is not yet verified, and the in-workflow stack sweep only
|
||||||
|
complements a reaper.
|
||||||
5. Complete a disposable-guest runner isolation proof: one throwaway guest per job, torn down by a
|
5. Complete a disposable-guest runner isolation proof: one throwaway guest per job, torn down by a
|
||||||
trusted reaper outside workflow YAML, holding no deployment credential, registration token, or
|
trusted reaper outside workflow YAML, holding no deployment credential, registration token, or
|
||||||
reusable key.
|
reusable key.
|
||||||
|
|||||||
@@ -159,12 +159,28 @@ also receives a safely named disposable database. Direct host runs use a Docker-
|
|||||||
port bound only to `127.0.0.1`; a containerized Gitea job joins the isolated Compose network and
|
port bound only to `127.0.0.1`; a containerized Gitea job joins the isolated Compose network and
|
||||||
uses the PostgreSQL service name.
|
uses the PostgreSQL service name.
|
||||||
|
|
||||||
|
In CI, a stack whose job was killed before its own cleanup ran is still removed: by that job's final
|
||||||
|
`if: always()` step, or by the stale sweep before a later job starts (`scripts/ci-sweep.sh`, see
|
||||||
|
[Disposable stacks never outlive their job](security.md#disposable-stacks-never-outlive-their-job)).
|
||||||
|
`bash scripts/test-ci-sweep.sh` proves what the sweep removes and what it leaves alone, against real
|
||||||
|
Docker resources.
|
||||||
|
|
||||||
The live suite proves fresh read-only planning, initial and incremental application, genuine
|
The live suite proves fresh read-only planning, initial and incremental application, genuine
|
||||||
no-op behavior, exact applied checksums and history, edited or corrupt history rejection,
|
no-op behavior, exact applied checksums and history, edited or corrupt history rejection,
|
||||||
whole-batch DDL rollback, Drizzle breakpoint execution, real advisory-lock concurrency and
|
whole-batch DDL rollback, Drizzle breakpoint execution, real advisory-lock concurrency and
|
||||||
timeouts, lock release after failure, exact destructive approvals, and public-error sanitization.
|
timeouts, lock release after failure, exact destructive approvals, and public-error sanitization.
|
||||||
It does not make migrations automatic during Core startup.
|
It does not make migrations automatic during Core startup.
|
||||||
|
|
||||||
|
It also proves ADR 0048's retained history for an uninstalled module end to end, with real
|
||||||
|
discovery: Core's committed migrations (the newest one withheld at first) and the module template
|
||||||
|
installed in a modules directory outside the repository are applied, the module writes a row, and
|
||||||
|
its directory is removed. Planning then reports the module's history as retained, and the withheld
|
||||||
|
Core migration applies with the module absent. The module's table and row survive. Reinstalled, the
|
||||||
|
module's history validates strictly again with nothing pending, its row is intact, and a reinstalled
|
||||||
|
package whose applied migration changed is refused. A history row for an absent module is retained
|
||||||
|
and reported, never fatal, while an unknown row belonging to an installed module is still rejected
|
||||||
|
without repair.
|
||||||
|
|
||||||
A dedicated set of scenarios exercises the committed Core identity migration against real
|
A dedicated set of scenarios exercises the committed Core identity migration against real
|
||||||
PostgreSQL: read-only planning on a fresh database, applying it and confirming exactly the four
|
PostgreSQL: read-only planning on a fresh database, applying it and confirming exactly the four
|
||||||
expected `ekklesia_core_*` tables and their indexes exist, a genuine no-op on a second apply, Core
|
expected `ekklesia_core_*` tables and their indexes exist, a genuine no-op on a second apply, Core
|
||||||
@@ -548,6 +564,56 @@ the following pull-request status context to `main` branch protection:
|
|||||||
CI / migration-smoke (pull_request)
|
CI / migration-smoke (pull_request)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Module packages
|
||||||
|
|
||||||
|
Ekklesia Module Package v1 (ADR 0049) is proven at three levels, all in `pnpm verify`:
|
||||||
|
|
||||||
|
- `packages/contracts/tests/module-package.test.ts`: the `ekklesia.package.json` schema, target
|
||||||
|
names, and the generated runtime `package.json`.
|
||||||
|
- `core/tests/module-package-verification.test.ts`: the verifier against hand-built archives,
|
||||||
|
including what the packager never produces. A valid package verifies and extracts with normalized
|
||||||
|
modes. A wrong digest, a corrupted or missing or unlisted file, disagreeing identities, an unknown
|
||||||
|
format, unknown transitional metadata, an incompatible target, Node.js range, SDK, or database
|
||||||
|
runtime, a missing entrypoint, a backend that imports something the package lacks, absolute and
|
||||||
|
`..` paths, symbolic and hard links, device nodes and FIFOs, setuid and setgid entries, a second
|
||||||
|
top-level directory, and a duplicate entry each fail with their own sanitized code. The backend
|
||||||
|
load check runs in a real child process.
|
||||||
|
- `packages/module-pack/tests/`: the packager against generated fixture modules (payload contents,
|
||||||
|
generated `package.json`, metadata, bundled backend with only built-in imports, refusal of native
|
||||||
|
and platform-specific dependencies and of unknown or textless licences, notices, byte-identical
|
||||||
|
archives, normalized tar and gzip headers), and end to end against the built module template:
|
||||||
|
packed twice to identical bytes without changing the workspace's install state, verified by Core's
|
||||||
|
verifier, extracted elsewhere, and loaded with Node's own resolution hooks proving nothing outside
|
||||||
|
the package is resolved. Turbo builds the template and Core first, and runs the template's own
|
||||||
|
tests (which rebuild its UI in place) before these.
|
||||||
|
|
||||||
|
## The generic platform image and the reference deployment
|
||||||
|
|
||||||
|
These need Docker and build images locally; nothing is pushed or published.
|
||||||
|
|
||||||
|
- `bash scripts/check-image.sh` builds the production image and proves it is the one generic
|
||||||
|
platform artifact ADR 0048 requires. It checks that the image has:
|
||||||
|
- no `/opt/ekklesia/modules`;
|
||||||
|
- no module template;
|
||||||
|
- no `ekklesia.module.json` outside installed dependencies;
|
||||||
|
- an empty, root-owned `/var/lib/ekklesia/modules`;
|
||||||
|
- `EKKLESIA_MODULES_DIR` set, in the image configuration and in a running process.
|
||||||
|
|
||||||
|
It then builds again from the same checkout with a populated `modules/` directory, and requires
|
||||||
|
the same checks and an identical runtime file system. CI's `image-build` job runs it.
|
||||||
|
- `pnpm deploy:smoke` (`scripts/smoke-deploy.sh`) runs the reference deployment end to end on the
|
||||||
|
generic image with empty module storage. With `EKKLESIA_SMOKE_TOPOLOGY=split`, the same image also
|
||||||
|
runs the installed-module lifecycle:
|
||||||
|
- the module template is packaged by the canonical packager (Module Package v1, ADR 0049),
|
||||||
|
verified by the image's own verifier, and installed into module storage;
|
||||||
|
- its migration is planned and explicitly applied, and the API restarted;
|
||||||
|
- a real browser mounts it;
|
||||||
|
- it is removed and the API restarted, and Core still runs, migrations still plan and apply, and
|
||||||
|
its data survives;
|
||||||
|
- the exact same package is verified and installed again, and the module returns with its data.
|
||||||
|
|
||||||
|
See [deploy/README.md](../../deploy/README.md#testing-this-deployment).
|
||||||
|
|
||||||
## Safe troubleshooting
|
## Safe troubleshooting
|
||||||
|
|
||||||
Confirm the prerequisites without printing configuration:
|
Confirm the prerequisites without printing configuration:
|
||||||
|
|||||||
@@ -26,7 +26,10 @@ application's. A module consumes those; it never redefines or bypasses them.
|
|||||||
|
|
||||||
## Required package shape
|
## Required package shape
|
||||||
|
|
||||||
Each immediate child of `modules/` must be an ESM workspace package with a unique package name, a
|
A module is developed as an ESM workspace package in a checkout's `modules/` directory, and it is
|
||||||
|
installed, self-contained, into an installation's module storage
|
||||||
|
([An installed module is self-contained](#an-installed-module-is-self-contained)). In a checkout,
|
||||||
|
each immediate child of `modules/` must be an ESM workspace package with a unique package name, a
|
||||||
strict semantic version, and the module layer marker:
|
strict semantic version, and the module layer marker:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
@@ -66,6 +69,122 @@ module with no migration history omits `entrypoints.migrations` and needs no pla
|
|||||||
The package version must match the manifest and runtime-definition versions. Internal package
|
The package version must match the manifest and runtime-definition versions. Internal package
|
||||||
dependencies use `workspace:` ranges and supported public package entrypoints.
|
dependencies use `workspace:` ranges and supported public package entrypoints.
|
||||||
|
|
||||||
|
### An installed module is self-contained
|
||||||
|
|
||||||
|
Ekklesia's image contains no modules
|
||||||
|
([ADR 0048](../architecture/decisions/0048-generic-image-and-installed-module-storage.md)). An
|
||||||
|
installation keeps installed modules in its own module storage, the directory `EKKLESIA_MODULES_DIR`
|
||||||
|
names (`/var/lib/ekklesia/modules` in the production image, the checkout's `modules/` when it is
|
||||||
|
unset). Discovery reads it only when a process starts. Nothing is hot loaded, and an installed module
|
||||||
|
is read-only to the platform.
|
||||||
|
|
||||||
|
An installed module directory is **self-contained for runtime use**: everything the platform reads
|
||||||
|
or loads from it, and everything those files import at run time, resolves inside it. You never
|
||||||
|
assemble that directory by hand. You build your module, and the canonical packager turns it into an
|
||||||
|
**Ekklesia Module Package v1**
|
||||||
|
([ADR 0049](../architecture/decisions/0049-module-package-v1.md)): one deterministic archive and its
|
||||||
|
SHA-256, which an installation verifies and extracts into module storage.
|
||||||
|
|
||||||
|
**Copying `dist/` is not an installed module.** Your compiled server code imports
|
||||||
|
`@ekklesia/module-sdk` and `drizzle-orm` by name, and Node resolves those only from your module's
|
||||||
|
own directory upwards. The platform's `node_modules` is never on that path, and Ekklesia adds it
|
||||||
|
through no link, `NODE_PATH`, loader hook, or alias. The packager therefore bundles your backend.
|
||||||
|
|
||||||
|
#### Packaging your module
|
||||||
|
|
||||||
|
Build your module, then run the packager from the workspace your module is built in. It is a
|
||||||
|
development dependency like `@ekklesia/module-ui-build`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm exec ekklesia-pack-module --module . --target any --out ../packages \
|
||||||
|
--source-revision "$(git rev-parse HEAD)"
|
||||||
|
```
|
||||||
|
|
||||||
|
It writes `<id>-<version>-any.tar.gz` and its `.sha256` line, and nothing else anywhere: it never
|
||||||
|
installs, links, or deploys, so your workspace is left exactly as it was. Never run `pnpm deploy` in
|
||||||
|
a workspace instead: it rewrites that workspace's install state to production-only.
|
||||||
|
|
||||||
|
The archive holds one directory named with your module id:
|
||||||
|
|
||||||
|
```text
|
||||||
|
<id>/
|
||||||
|
├── package.json generated: name, version, private, "type": "module", the layer marker
|
||||||
|
├── ekklesia.module.json your manifest, byte for byte
|
||||||
|
├── ekklesia.package.json package metadata: target, runtime, provenance, file digests
|
||||||
|
├── THIRD_PARTY_NOTICES licence and notice text of every third-party package bundled
|
||||||
|
├── dist/server/index.js your backend entrypoints, bundled
|
||||||
|
├── dist/schema/index.js
|
||||||
|
├── dist/chunks/ code your backend entrypoints share
|
||||||
|
├── dist/ui/ … dist/display/ … dist/contributions/ your browser artifacts, exactly as built
|
||||||
|
└── drizzle/ your migrations
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Bundled backend.** Your server, schema, and jobs entrypoints are bundled with every
|
||||||
|
pure-JavaScript dependency they use, the Module SDK, the contracts, and Drizzle included. The only
|
||||||
|
imports left are Node.js built-ins. A dependency the bundle cannot resolve fails packaging; nothing
|
||||||
|
is left to be found at run time.
|
||||||
|
- **Never shipped:** `src/`, tests, TypeScript declarations, source maps, development dependencies,
|
||||||
|
your development `package.json`, any `workspace:` range, and links of any kind.
|
||||||
|
- **Browser artifacts must be current.** The packager rebuilds them in memory and refuses an artifact
|
||||||
|
on disk that differs, so run your build first.
|
||||||
|
- **`any` is the only target today.** A dependency that is native or platform-specific (a compiled
|
||||||
|
addon, a package that declares an operating system or CPU, or one that loads a per-platform binary
|
||||||
|
such as `sharp`) cannot be in an `any` package, and packaging fails. Native targets come later.
|
||||||
|
- **Licences.** `THIRD_PARTY_NOTICES` credits every third-party package your bundles contain, with
|
||||||
|
its declared licence and the licence and notice files it ships. A package that declares no licence
|
||||||
|
fails packaging, and so does one that ships no licence text, unless its licence's standard text is
|
||||||
|
complete without a copyright line (Apache-2.0, which the packager then supplies and says so). The
|
||||||
|
Ekklesia platform's own packages are listed in `ekklesia.package.json` under `incorporates`.
|
||||||
|
- **Deterministic.** The same packager version and the same built inputs produce the same archive,
|
||||||
|
byte for byte, wherever the checkout is.
|
||||||
|
|
||||||
|
**Drizzle, for now.** You define tables with your own copy of Drizzle, and Core runs your queries
|
||||||
|
through the platform's Drizzle instance (see [Database access](#database-access)). Objects from two
|
||||||
|
copies interoperate only when the copies are interchangeable, so the current implementation requires
|
||||||
|
**exactly the Drizzle version the platform uses**: the one `@ekklesia/module-sdk` depends on. The
|
||||||
|
package records it under `transitional`, and an installation refuses a package whose Drizzle differs.
|
||||||
|
This is a constraint of today's implementation, not a permanent contract.
|
||||||
|
|
||||||
|
#### Verifying a package
|
||||||
|
|
||||||
|
`node core/dist/cli/verify-module-package.js <archive> --sha256-file <archive>.sha256`, run from an
|
||||||
|
Ekklesia installation (`/opt/ekklesia` in the image), checks a package against that platform without
|
||||||
|
installing it. Pass `--extract-to <new directory>` to receive the verified module directory.
|
||||||
|
[deploy/README.md](../../deploy/README.md#installed-modules) shows the whole installation.
|
||||||
|
|
||||||
|
The repository's boundary checks (`pnpm boundaries`) prove only source modules in a workspace. An
|
||||||
|
installed package normally has no source, so the platform cannot check it the same way. Your
|
||||||
|
module's own CI is where its boundary rules, its production dependencies, and its behaviour inside
|
||||||
|
Ekklesia are proven ([ADR 0047](../architecture/decisions/0047-module-ci-harness.md)).
|
||||||
|
|
||||||
|
### License
|
||||||
|
|
||||||
|
Ekklesia is licensed under the GNU Affero General Public License, version 3 or (at your option) any
|
||||||
|
later version, `AGPL-3.0-or-later` ([LICENSE](../../LICENSE)), and official Ekklesia modules use the
|
||||||
|
same license. Declare it in your module's development `package.json`
|
||||||
|
(`"license": "AGPL-3.0-or-later"`), as the module template does.
|
||||||
|
|
||||||
|
A Module Package v1 does not yet record a module's own license: its runtime `package.json` and
|
||||||
|
`ekklesia.package.json` have fixed fields, and its contents are fixed, by ADR 0049. Carrying the
|
||||||
|
module's license, and the license text for the Ekklesia code bundled into its backend, needs a
|
||||||
|
change to that format.
|
||||||
|
|
||||||
|
### Removing a module keeps its data
|
||||||
|
|
||||||
|
Removing your module's directory from module storage is not deleting its data:
|
||||||
|
|
||||||
|
- your tables and rows, buckets and objects stay;
|
||||||
|
- the platform keeps its enablement decision, your configuration, its Dashboard placement, and
|
||||||
|
permission grants;
|
||||||
|
- your applied migration history stays, and the migration plan reports it as retained history for
|
||||||
|
a module that is not installed.
|
||||||
|
|
||||||
|
Reinstalled with the same module id, your module finds all of it again. Its history is validated
|
||||||
|
strictly again, so a reinstalled package must still contain every migration that was ever applied,
|
||||||
|
unchanged. Never delete or edit a released migration, and never ship an older version over newer
|
||||||
|
data. Disabling is different from removing: a disabled module stays installed and is simply not
|
||||||
|
loaded. Deleting a module's data (purge) does not exist yet.
|
||||||
|
|
||||||
## Canonical manifest
|
## Canonical manifest
|
||||||
|
|
||||||
`ekklesia.module.json` is inert and authoritative, so Core can validate every installed module
|
`ekklesia.module.json` is inert and authoritative, so Core can validate every installed module
|
||||||
@@ -1447,7 +1566,8 @@ pnpm migrations:plan
|
|||||||
pnpm verify
|
pnpm verify
|
||||||
```
|
```
|
||||||
|
|
||||||
`pnpm modules:validate` builds the workspace and then atomically validates every installed module.
|
`pnpm modules:validate` builds the workspace and then atomically validates every installed module,
|
||||||
|
from the checkout's `modules/` or from `EKKLESIA_MODULES_DIR` when it is set.
|
||||||
It rejects malformed manifests, duplicate IDs or package names, version mismatches, incompatible
|
It rejects malformed manifests, duplicate IDs or package names, version mismatches, incompatible
|
||||||
platform or SDK ranges, missing required modules or capabilities, incompatible installed
|
platform or SDK ranges, missing required modules or capabilities, incompatible installed
|
||||||
dependencies, cycles, and missing, escaping, or non-JavaScript server or schema entrypoints.
|
dependencies, cycles, and missing, escaping, or non-JavaScript server or schema entrypoints.
|
||||||
|
|||||||
+7
-2
@@ -1,6 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "ekklesia",
|
"name": "ekklesia",
|
||||||
"version": "0.1.0",
|
"version": "0.1.2",
|
||||||
|
"license": "AGPL-3.0-or-later",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "Open, modular church technology platform",
|
"description": "Open, modular church technology platform",
|
||||||
@@ -14,7 +15,9 @@
|
|||||||
"authorization:bootstrap-owner": "turbo run build && node --env-file-if-exists=.env core/dist/cli/bootstrap-platform-owner.js",
|
"authorization:bootstrap-owner": "turbo run build && node --env-file-if-exists=.env core/dist/cli/bootstrap-platform-owner.js",
|
||||||
"boundaries": "node scripts/check-boundaries.ts",
|
"boundaries": "node scripts/check-boundaries.ts",
|
||||||
"build": "turbo run build",
|
"build": "turbo run build",
|
||||||
"ci:verify": "pnpm verify && pnpm infrastructure:config",
|
"ci:verify": "pnpm verify && pnpm infrastructure:config && pnpm deploy:config",
|
||||||
|
"deploy:config": "bash scripts/check-deploy-config.sh",
|
||||||
|
"deploy:smoke": "bash scripts/smoke-deploy.sh",
|
||||||
"doctor": "node scripts/run-doctor.ts",
|
"doctor": "node scripts/run-doctor.ts",
|
||||||
"format": "biome format --write .",
|
"format": "biome format --write .",
|
||||||
"format:check": "biome format .",
|
"format:check": "biome format .",
|
||||||
@@ -32,7 +35,9 @@
|
|||||||
"migrations:validate": "turbo run build && node core/dist/cli/validate-migrations.js",
|
"migrations:validate": "turbo run build && node core/dist/cli/validate-migrations.js",
|
||||||
"modules:disable": "turbo run build && node --env-file-if-exists=.env core/dist/cli/set-module-enabled.js --disable",
|
"modules:disable": "turbo run build && node --env-file-if-exists=.env core/dist/cli/set-module-enabled.js --disable",
|
||||||
"modules:enable": "turbo run build && node --env-file-if-exists=.env core/dist/cli/set-module-enabled.js --enable",
|
"modules:enable": "turbo run build && node --env-file-if-exists=.env core/dist/cli/set-module-enabled.js --enable",
|
||||||
|
"modules:pack": "turbo run build && node packages/module-pack/bin/ekklesia-pack-module.mjs",
|
||||||
"modules:validate": "turbo run build && node core/dist/cli/validate-modules.js",
|
"modules:validate": "turbo run build && node core/dist/cli/validate-modules.js",
|
||||||
|
"modules:verify-package": "turbo run build && node core/dist/cli/verify-module-package.js",
|
||||||
"test": "turbo run test test:root",
|
"test": "turbo run test test:root",
|
||||||
"test:migrations:integration": "pnpm build && bash scripts/test-migrations-integration.sh",
|
"test:migrations:integration": "pnpm build && bash scripts/test-migrations-integration.sh",
|
||||||
"test:root": "vitest run --config vitest.config.ts",
|
"test:root": "vitest run --config vitest.config.ts",
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "@ekklesia/contracts",
|
"name": "@ekklesia/contracts",
|
||||||
"version": "0.1.0",
|
"version": "0.1.2",
|
||||||
|
"license": "AGPL-3.0-or-later",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"files": [
|
"files": [
|
||||||
"dist"
|
"dist"
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ export * from "./module-http.js";
|
|||||||
export * from "./module-lifecycle.js";
|
export * from "./module-lifecycle.js";
|
||||||
export * from "./module-manifest.js";
|
export * from "./module-manifest.js";
|
||||||
export * from "./module-migration.js";
|
export * from "./module-migration.js";
|
||||||
|
export * from "./module-package.js";
|
||||||
export * from "./module-realtime.js";
|
export * from "./module-realtime.js";
|
||||||
export * from "./module-runtime.js";
|
export * from "./module-runtime.js";
|
||||||
export * from "./module-schema.js";
|
export * from "./module-schema.js";
|
||||||
|
|||||||
@@ -0,0 +1,203 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
import { moduleIdSchema } from "./identifiers.js";
|
||||||
|
import { semanticVersionRangeSchema, semanticVersionSchema } from "./versions.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ekklesia Module Package v1 (ADR 0049): the format a separately versioned module is distributed
|
||||||
|
* in. One deterministic `.tar.gz` holding exactly one top-level directory named with the module id,
|
||||||
|
* whose contents are the installed module directory ADR 0048 requires. This file defines the
|
||||||
|
* package's metadata (`ekklesia.package.json`), its target names, and the runtime `package.json`
|
||||||
|
* the packager generates. The archive encoding itself belongs to the packager and the verifier.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const MODULE_PACKAGE_FORMAT = 1;
|
||||||
|
export const MODULE_PACKAGE_METADATA_FILENAME = "ekklesia.package.json";
|
||||||
|
export const MODULE_PACKAGE_NOTICES_FILENAME = "THIRD_PARTY_NOTICES";
|
||||||
|
/** The portable target: a package with no native or platform-specific file. */
|
||||||
|
export const MODULE_PACKAGE_ANY_TARGET = "any";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `any`, or `<platform>-<arch>[-<libc>]` in Node.js's own `process.platform` and `process.arch`
|
||||||
|
* vocabulary. On Linux the C library is required, because a native addon built against one does
|
||||||
|
* not load against the other; elsewhere it is meaningless and refused.
|
||||||
|
*/
|
||||||
|
export const modulePackageTargetSchema = z.string().superRefine((value, context) => {
|
||||||
|
if (value === MODULE_PACKAGE_ANY_TARGET) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const match = /^([a-z][a-z0-9]*)-([a-z][a-z0-9]*)(?:-([a-z]+))?$/u.exec(value);
|
||||||
|
const platform = match?.[1];
|
||||||
|
const libc = match?.[3];
|
||||||
|
const valid =
|
||||||
|
match !== null &&
|
||||||
|
platform !== "any" &&
|
||||||
|
(platform === "linux" ? libc === "glibc" || libc === "musl" : libc === undefined);
|
||||||
|
if (!valid) {
|
||||||
|
context.addIssue({
|
||||||
|
code: "custom",
|
||||||
|
message: "Must be any, or <platform>-<arch>, with -glibc or -musl on Linux.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/** A path inside the module directory: relative, POSIX, printable ASCII, and never escaping. ASCII
|
||||||
|
* keeps JavaScript's string order identical to the archive's byte-wise order. */
|
||||||
|
const payloadPathSchema = z.string().superRefine((value, context) => {
|
||||||
|
const segments = value.split("/");
|
||||||
|
const valid =
|
||||||
|
/^[\x20-\x7e]+$/u.test(value) &&
|
||||||
|
!value.includes("\\") &&
|
||||||
|
!value.startsWith("/") &&
|
||||||
|
segments.every((segment) => segment !== "" && segment !== "." && segment !== "..") &&
|
||||||
|
value !== MODULE_PACKAGE_METADATA_FILENAME;
|
||||||
|
if (!valid) {
|
||||||
|
context.addIssue({
|
||||||
|
code: "custom",
|
||||||
|
message: "Must be a portable path inside the module directory, other than the metadata file.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const modulePackageFileSchema = z.strictObject({
|
||||||
|
path: payloadPathSchema,
|
||||||
|
size: z.number().int().nonnegative(),
|
||||||
|
sha256: z.string().regex(/^[0-9a-f]{64}$/u, "Must be a lowercase hexadecimal SHA-256."),
|
||||||
|
});
|
||||||
|
|
||||||
|
const modulePackageRuntimeSchema = z.strictObject({
|
||||||
|
/** The Node.js versions the package was built for. Always present. */
|
||||||
|
node: semanticVersionRangeSchema,
|
||||||
|
/** Native packages only: the minimum Node-API version their addons need. */
|
||||||
|
nodeApi: z.number().int().positive().optional(),
|
||||||
|
/** Native packages only: the exact `process.versions.modules` an ABI-specific addon needs. */
|
||||||
|
nodeAbi: z
|
||||||
|
.string()
|
||||||
|
.regex(/^[0-9]+$/u)
|
||||||
|
.optional(),
|
||||||
|
/** Native Linux packages only: the minimum C library version. */
|
||||||
|
libcVersion: semanticVersionRangeSchema.optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const ekklesiaPackageNameSchema = z
|
||||||
|
.string()
|
||||||
|
.regex(/^@ekklesia\/[a-z0-9][a-z0-9-]*$/u, "Must name an @ekklesia package.");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compatibility facts today's implementation needs and that are not part of the long-term contract
|
||||||
|
* (ADR 0049). Strict at every level: a reader that finds a fact it does not understand must refuse
|
||||||
|
* the package rather than ignore it.
|
||||||
|
*
|
||||||
|
* `databaseRuntime` records the Drizzle version bundled with the module's backend, because Core runs
|
||||||
|
* a module's queries through its own Drizzle instance over tables the module defined with its own
|
||||||
|
* copy. That is an implementation constraint pending a Module Database ABI decision, **not** a
|
||||||
|
* database contract a module may rely on.
|
||||||
|
*/
|
||||||
|
const modulePackageTransitionalSchema = z.strictObject({
|
||||||
|
databaseRuntime: z
|
||||||
|
.strictObject({
|
||||||
|
"drizzle-orm": semanticVersionSchema.optional(),
|
||||||
|
})
|
||||||
|
.optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const modulePackageMetadataSchema = z
|
||||||
|
.strictObject({
|
||||||
|
packageFormat: z.literal(MODULE_PACKAGE_FORMAT),
|
||||||
|
module: z.strictObject({ id: moduleIdSchema, version: semanticVersionSchema }),
|
||||||
|
target: modulePackageTargetSchema,
|
||||||
|
runtime: modulePackageRuntimeSchema,
|
||||||
|
provenance: z.strictObject({
|
||||||
|
/** The exact source revision the package was built from: a full SHA-1 or SHA-256 commit id. */
|
||||||
|
revision: z.string().regex(/^(?:[0-9a-f]{40}|[0-9a-f]{64})$/u),
|
||||||
|
/** Informational only: nothing trusts it, and it ties the package to no host. */
|
||||||
|
repository: z.string().min(1).max(2048).optional(),
|
||||||
|
packager: z.strictObject({
|
||||||
|
name: z.string().min(1).max(214),
|
||||||
|
version: semanticVersionSchema,
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
/** The exact versions of the Ekklesia SDK and runtime libraries bundled into the package. */
|
||||||
|
incorporates: z.record(ekklesiaPackageNameSchema, semanticVersionSchema),
|
||||||
|
transitional: modulePackageTransitionalSchema.optional(),
|
||||||
|
/** Every regular file in the module directory except this metadata file, in byte-wise path
|
||||||
|
* order. The set is exact: nothing missing, nothing extra. */
|
||||||
|
files: z.array(modulePackageFileSchema),
|
||||||
|
})
|
||||||
|
.superRefine((metadata, context) => {
|
||||||
|
if (
|
||||||
|
metadata.target === MODULE_PACKAGE_ANY_TARGET &&
|
||||||
|
(metadata.runtime.nodeApi !== undefined ||
|
||||||
|
metadata.runtime.nodeAbi !== undefined ||
|
||||||
|
metadata.runtime.libcVersion !== undefined)
|
||||||
|
) {
|
||||||
|
context.addIssue({
|
||||||
|
code: "custom",
|
||||||
|
message: "An any-target package carries no native runtime requirement.",
|
||||||
|
path: ["runtime"],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
for (let index = 1; index < metadata.files.length; index += 1) {
|
||||||
|
const previous = metadata.files[index - 1]?.path ?? "";
|
||||||
|
const current = metadata.files[index]?.path ?? "";
|
||||||
|
if (!(previous < current)) {
|
||||||
|
context.addIssue({
|
||||||
|
code: "custom",
|
||||||
|
message: "Files must be listed once each, in byte-wise path order.",
|
||||||
|
path: ["files", index, "path"],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export type ModulePackageMetadata = z.output<typeof modulePackageMetadataSchema>;
|
||||||
|
export type ModulePackageFile = z.output<typeof modulePackageFileSchema>;
|
||||||
|
export type ModulePackageTarget = z.output<typeof modulePackageTargetSchema>;
|
||||||
|
|
||||||
|
export function safeParseModulePackageMetadata(input: unknown) {
|
||||||
|
return modulePackageMetadataSchema.safeParse(input);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function safeParseModulePackageTarget(input: unknown) {
|
||||||
|
return modulePackageTargetSchema.safeParse(input);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The runtime `package.json` of an installed module: exactly what module discovery reads, generated
|
||||||
|
* by the packager and never copied from a development workspace. No dependencies, scripts, exports,
|
||||||
|
* or imports, because nothing at run time resolves through them.
|
||||||
|
*/
|
||||||
|
export const modulePackageRuntimePackageSchema = z.strictObject({
|
||||||
|
name: z.string().min(1).max(214),
|
||||||
|
version: semanticVersionSchema,
|
||||||
|
private: z.literal(true),
|
||||||
|
type: z.literal("module"),
|
||||||
|
ekklesia: z.strictObject({ layer: z.literal("module") }),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type ModulePackageRuntimePackage = z.output<typeof modulePackageRuntimePackageSchema>;
|
||||||
|
|
||||||
|
export function modulePackageRuntimePackage(identity: {
|
||||||
|
readonly name: string;
|
||||||
|
readonly version: string;
|
||||||
|
}): ModulePackageRuntimePackage {
|
||||||
|
return {
|
||||||
|
name: identity.name,
|
||||||
|
version: identity.version,
|
||||||
|
private: true,
|
||||||
|
type: "module",
|
||||||
|
ekklesia: { layer: "module" },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function safeParseModulePackageRuntimePackage(input: unknown) {
|
||||||
|
return modulePackageRuntimePackageSchema.safeParse(input);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The recommended archive file name. A convenience only: a file name is never trusted. */
|
||||||
|
export function modulePackageArchiveFileName(
|
||||||
|
moduleId: string,
|
||||||
|
version: string,
|
||||||
|
target: string,
|
||||||
|
): string {
|
||||||
|
return `${moduleId}-${version}-${target}.tar.gz`;
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user