Author SHA1 Message Date
ekklesiaandClaude Opus 5.5 5b78ee0ffb docs: propose ADR 0046, installation-owned branding
CI / verify (pull_request) Successful in 49s
CI / infrastructure-smoke (pull_request) Successful in 21s
CI / migration-smoke (pull_request) Failing after 25s
CI / image-build (pull_request) Successful in 17s
CI / deploy-smoke (pull_request) Successful in 49s
Records a core principle: Ekklesia's branding is never forced on an
installation. Anything a member, visitor, or volunteer can see (titles,
sign-in and auth screens, emails, display and offline screens, error
messages, app names and icons, attribution) is configurable and removable
entirely. With no name configured, wording is neutral, so the default
names nothing and the rule is testable. Internal identifiers, operator
tooling, and developer diagnostics are exempt. Modules follow the same
rule.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-03 11:21:53 -05:00
ekklesiaandClaude Opus 5.5 2323467aef docs: state exactly what ADR 0045's browser smoke proves
CI / verify (pull_request) Successful in 51s
CI / infrastructure-smoke (pull_request) Successful in 22s
CI / image-build (pull_request) Canceled after 0s
CI / deploy-smoke (pull_request) Canceled after 0s
CI / migration-smoke (pull_request) Canceled after 1m28s
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-03 11:21:46 -05:00
ekklesiaandClaude Opus 5.5 fe390bb09d docs: propose ADR 0045, split Dashboard and API origins
Records how an installation runs the Dashboard and the API on sibling
hostnames (dash./api.) beside the default same-origin topology, from one
image: the API origin reaches the Dashboard at run time in a meta element,
the display host stays on the API's origin (device cookies and the offline
code cache depend on it), the API trusts its own origin as Better Auth
already does, cookies stay host-only, CORS stays narrow and becomes part of
the module UI contract, the reference proxy gains per-hostname routing, and
the deploy smoke test proves both topologies, the split one in a real
browser.

Amends ADR 0034 and extends ADR 0044.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-03 11:03:28 -05:00
678 changed files with 1570 additions and 98541 deletions
-5
View File
@@ -2,11 +2,6 @@
# `pnpm install --frozen-lockfile` and builds its own outputs, so nothing installed or built on the # `pnpm install --frozen-lockfile` and builds its own outputs, so nothing installed or built on the
# host may enter the context, and no secret or environment file may enter any layer. # host may enter the context, and no secret or environment file may enter any layer.
# Installed modules never enter the platform image (ADR 0048): an installation keeps them in its own
# module storage, mounted at run time. Excluding the checkout's `modules/` is what makes a build
# identical whether or not modules happen to be present in the source tree.
/modules
# Host-installed dependencies and build outputs: the image produces its own. # Host-installed dependencies and build outputs: the image produces its own.
**/node_modules **/node_modules
**/dist **/dist
-6
View File
@@ -10,12 +10,6 @@
# 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
-4
View File
@@ -6,7 +6,3 @@
*.gif binary *.gif binary
*.webp binary *.webp binary
*.woff2 binary *.woff2 binary
# Reviewed license supplements are named by the SHA-256 of their exact bytes (ADR 0052), so Git must
# never rewrite their line endings.
packages/module-pack/license-supplements/** -text
+27 -202
View File
@@ -1,13 +1,18 @@
name: CI name: CI
# The server runs Gitea 1.27.2 (docs/development/security.md, "What this server honours"): # IMPORTANT — the currently inventoried server is Gitea 1.24.2, which *ignores* several keys used
# 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:
# #
# * `concurrency:` is honoured: a newer push to a pull request cancels the run it supersedes, so a # * `permissions:` does NOT restrict the in-job `GITEA_TOKEN`. Job-token permission controls,
# cancelled run's own cleanup is never guaranteed (see the CI stack sweep in each stack job). # repository/owner defaults, and server-side maximum-permission clamping arrive in the 1.26
# * `permissions:` is supported by 1.27 with Gitea's own scopes, but what the job token can do on # feature line (https://docs.gitea.com/1.26/usage/actions/token-permissions). Until the server
# this server is not yet proven, so nothing here relies on it being read-only. # is upgraded and configured, the token's authority is whatever the server grants — not what
# * `timeout-minutes:` is no longer listed as unsupported, but is not verified here either; # this file requests.
# teardown relies on the stack sweep and the runner's own reaper. # * `concurrency:` and `timeout-minutes:` are NOT bounded-execution guarantees here. Cancellation,
# 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.
@@ -58,9 +63,6 @@ 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
@@ -70,29 +72,13 @@ 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
@@ -102,11 +88,6 @@ 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:
@@ -123,141 +104,11 @@ 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
package-release:
needs: verify
runs-on: ubuntu-latest
timeout-minutes: 15
# What the gate proved, for the job that consumes its artifact to check it received exactly that.
outputs:
version: ${{ steps.release.outputs.version }}
revision: ${{ steps.release.outputs.revision }}
manifest_sha256: ${{ steps.release.outputs.manifest_sha256 }}
steps:
- name: Check out repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
# `actions/checkout` defaults to leaving the job token in `.git/config`, where any later
# step — including one a pull request added to this file — could read or reuse it. Nothing
# in this workflow pushes, so the credential is never needed after checkout.
persist-credentials: false
- name: Set up Node.js
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version-file: .node-version
- name: Activate pinned pnpm
run: |
corepack enable
corepack prepare [email protected] --activate
- name: Install dependencies
run: pnpm install --frozen-lockfile
# ADR 0053's package release gate: build the public package train from this commit, build it
# again from a second checkout and require byte-identical artifacts, then install exactly those
# tarballs (offline, no registry) into a module outside this repository and prove it builds,
# packages, and verifies. Nothing is published and no credential exists here. A future publish
# job must depend on this job and publish only the artifacts it proved.
- name: Build, reproduce, and consume the public package train
run: pnpm test:package-release --keep package-train
- name: Describe the proven release train
id: release
run: |
release="$(find package-train -mindepth 1 -maxdepth 1 -type d)"
manifest="$release/ekklesia-package-train.json"
{
echo "version=$(node -p 'JSON.parse(require("fs").readFileSync(process.argv[1], "utf8")).version' "$manifest")"
echo "revision=$(node -p 'JSON.parse(require("fs").readFileSync(process.argv[1], "utf8")).source.revision' "$manifest")"
echo "manifest_sha256=$(sha256sum "$manifest" | cut -d' ' -f1)"
} >>"$GITHUB_OUTPUT"
cat "$manifest"
# The exact release artifacts, for the jobs that consume them, from a directory whose name does
# not start with a dot: the artifact actions leave hidden files out. Version 3 of the artifact
# actions: version 4 refuses any server that is not github.com, which this Gitea is not, while
# Gitea implements the protocol version 3 uses. A consumer checks the files, not the wrapper.
- name: Hand the release train on
uses: actions/upload-artifact@c6a3b2bd78b3985e4b2f15397fec357f0fd808de # v3.2.2-node20
with:
name: package-train
path: package-train/
if-no-files-found: error
retention-days: 7
# The publication tool, given exactly the artifact the package-release job proved, against a
# disposable Gitea 1.27.2 (scripts/test-package-publication.sh). Never the real registry, and no
# credential: the disposable registry's accounts are created and discarded inside the job.
package-publication:
needs: package-release
runs-on: ubuntu-latest
timeout-minutes: 15
env:
# Names this job's stacks, so its final step removes exactly these (scripts/lib/ci-project.sh).
EKKLESIA_CI_JOB_KEY: package-publication
steps:
- name: Check out repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
# A job the runner killed (cancelled, or past the runner's own time limit) never ran its
# cleanup. Anything a CI job left on this shared daemon more than three hours ago goes first.
- name: Sweep stale CI stacks from the runner
run: bash scripts/ci-sweep.sh --stale
- name: Set up Node.js
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version-file: .node-version
- name: Activate pinned pnpm
run: |
corepack enable
corepack prepare [email protected] --activate
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Receive the release train the gate proved
uses: actions/download-artifact@ad191675b41f6a5b46da9a048cb6893812da158b # v3.1.0-node20
with:
name: package-train
path: package-train/
- name: Check it is exactly that train
env:
EXPECTED_MANIFEST_SHA256: ${{ needs.package-release.outputs.manifest_sha256 }}
run: |
release="$(find package-train -mindepth 1 -maxdepth 1 -type d)"
actual="$(sha256sum "$release/ekklesia-package-train.json" | cut -d' ' -f1)"
if [[ -z "$EXPECTED_MANIFEST_SHA256" || "$actual" != "$EXPECTED_MANIFEST_SHA256" ]]; then
echo "The received release manifest is not the one the package-release job proved." >&2
exit 1
fi
(cd "$release" && sha256sum --check --strict SHA256SUMS)
- name: Publish it to a disposable Gitea, and prove the round trip and recovery
run: bash scripts/test-package-publication.sh --release "$(find package-train -mindepth 1 -maxdepth 1 -type d)"
# Whatever happened above, this job's own stacks do not outlive it.
- name: Remove this job's CI stacks
if: always()
run: bash scripts/ci-sweep.sh --job
image-build: image-build:
needs: verify needs: verify
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 20 timeout-minutes: 20
env: env:
# Names this job's stacks, so its final step removes exactly these (scripts/lib/ci-project.sh).
EKKLESIA_CI_JOB_KEY: image-build
# Cache mounts in deploy/Dockerfile need BuildKit; this selects it even where the Docker CLI # Cache mounts in deploy/Dockerfile need BuildKit; this selects it even where the Docker CLI
# has no buildx plugin. # has no buildx plugin.
DOCKER_BUILDKIT: "1" DOCKER_BUILDKIT: "1"
@@ -270,43 +121,27 @@ 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 # The platform-only image (ADR 0044): a checkout has no `modules/`, so this proves the image
# cleanup. Anything a CI job left on this shared daemon more than three hours ago goes first. # builds, validates, and assembles with zero modules. Build only — no registry login, no
- name: Sweep stale CI stacks from the runner # credentials, no push, no tag anywhere but this runner's local image store.
run: bash scripts/ci-sweep.sh --stale - name: Build the production image without modules
run: >-
docker build
--file deploy/Dockerfile
--build-arg REVISION="$GITHUB_SHA"
--tag ekklesia-ci:"$GITHUB_SHA"
.
# The one generic platform image (ADR 0044, ADR 0048), proved rather than assumed: it contains no - name: Remove the image from the runner
# module, no module template, and no module manifest; its module storage mount point is empty;
# EKKLESIA_MODULES_DIR names it; and a checkout with a populated modules/ directory builds an
# identical runtime file system. Build only — no registry login, no credentials, no push, no tag
# anywhere but this runner's local image store. Tagged per run and attempt, and labeled like
# every CI stack, so this job's final step removes the images even if a build is killed.
- name: Build the generic platform image and prove it holds no modules
run: bash scripts/check-image.sh
# Whatever happened above, this job's own stacks do not outlive it.
- name: Remove this job's CI stacks
if: always() if: always()
run: bash scripts/ci-sweep.sh --job run: docker image rm --force ekklesia-ci:"$GITHUB_SHA" || true
deploy-smoke: deploy-smoke:
needs: verify needs: verify
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 30 timeout-minutes: 20
# 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: env:
# Names this job's stacks, so its final step removes exactly these (scripts/lib/ci-project.sh).
EKKLESIA_CI_JOB_KEY: deploy-smoke-${{ matrix.topology }}
DOCKER_BUILDKIT: "1" DOCKER_BUILDKIT: "1"
EKKLESIA_SMOKE_TOPOLOGY: ${{ matrix.topology }}
steps: steps:
- name: Check out repository - name: Check out repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
@@ -316,20 +151,10 @@ 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 # The reference deployment end to end (ADR 0044): a platform-only image, base + proxy with
# cleanup. Anything a CI job left on this shared daemon more than three hours ago goes first.
- name: Sweep stale CI stacks from the runner
run: bash scripts/ci-sweep.sh --stale
# The reference deployment end to end (ADR 0044): the generic platform image, base + proxy with
# Caddy's internal CA (test-only), an explicit migration apply against this job's own fresh # Caddy's internal CA (test-only), an explicit migration apply against this job's own fresh
# PostgreSQL, then the API, Dashboard, storage delivery, client-address chain, and verify-full # PostgreSQL, then the API, Dashboard, storage delivery, client-address chain, and verify-full
# database TLS through the proxy. Every secret is generated for this run and discarded with # database TLS through the proxy. Every secret is generated for this run and discarded with
# it; nothing is pushed, published, or logged in to. # it; nothing is pushed, published, or logged in to.
- name: Smoke-test the reference deployment (${{ matrix.topology }}) - name: Smoke-test the reference deployment
run: bash scripts/smoke-deploy.sh run: bash scripts/smoke-deploy.sh
# Whatever happened above, this job's own stacks do not outlive it.
- name: Remove this job's CI stacks
if: always()
run: bash scripts/ci-sweep.sh --job
-133
View File
@@ -1,133 +0,0 @@
name: Publish packages
# Publishes one public @ekklesia/* package train (ADR 0053, docs/development/releases.md) to the
# npm registry: exactly the artifacts this run's package-release gate proves, and nothing else.
#
# Only ever started by hand, on `main`, by someone with write access to this repository: no pull
# request or push can start it. It needs the version-set commit of a release (`version` must equal
# the platform version there), and the repository secret EKKLESIA_PACKAGES_PUBLISH_TOKEN, a token of
# the dedicated publisher account with only the `write:package` scope. Packages are publicly
# readable (ADR 0054); publishing is what this protects.
on:
workflow_dispatch:
inputs:
version:
description: The package train version to publish, equal to the platform version on main.
required: true
type: string
permissions:
contents: read
# Never cancel a publication part way, and never run two at once. A rerun resumes safely.
concurrency:
group: publish-packages
cancel-in-progress: false
env:
# The only registry-specific setting: where the train is published.
EKKLESIA_PACKAGES_REGISTRY: https://git.zayin.net/api/packages/Zayin/npm/
jobs:
package-release:
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
timeout-minutes: 20
outputs:
version: ${{ steps.release.outputs.version }}
revision: ${{ steps.release.outputs.revision }}
manifest_sha256: ${{ steps.release.outputs.manifest_sha256 }}
steps:
- name: Check out repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Set up Node.js
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version-file: .node-version
- name: Activate pinned pnpm
run: |
corepack enable
corepack prepare [email protected] --activate
- name: Install dependencies
run: pnpm install --frozen-lockfile
# The same credential-free gate as CI: build, reproduce, and consume the train.
- name: Build, reproduce, and consume the public package train
run: pnpm test:package-release --keep package-train
- name: Describe the proven release train
id: release
run: |
release="$(find package-train -mindepth 1 -maxdepth 1 -type d)"
manifest="$release/ekklesia-package-train.json"
{
echo "version=$(node -p 'JSON.parse(require("fs").readFileSync(process.argv[1], "utf8")).version' "$manifest")"
echo "revision=$(node -p 'JSON.parse(require("fs").readFileSync(process.argv[1], "utf8")).source.revision' "$manifest")"
echo "manifest_sha256=$(sha256sum "$manifest" | cut -d' ' -f1)"
} >>"$GITHUB_OUTPUT"
# Artifact actions version 3: version 4 refuses any server that is not github.com. The directory
# name must not start with a dot: the artifact actions leave hidden files out.
- name: Hand the release train on
uses: actions/upload-artifact@c6a3b2bd78b3985e4b2f15397fec357f0fd808de # v3.2.2-node20
with:
name: package-train
path: package-train/
if-no-files-found: error
retention-days: 90
publish:
needs: package-release
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
# Only for the publication tool itself: nothing is built or installed in this job.
- name: Check out repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Set up Node.js
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version-file: .node-version
- name: Receive the release train the gate proved
uses: actions/download-artifact@ad191675b41f6a5b46da9a048cb6893812da158b # v3.1.0-node20
with:
name: package-train
path: package-train/
# The tool validates the whole train against its manifest, that manifest against the gate's,
# its version against the request, and its revision against this commit, before it sends
# anything; then publishes, verifies every package byte for byte, and moves `latest` last.
- name: Publish the exact release train
env:
EKKLESIA_PACKAGES_PUBLISH_TOKEN: ${{ secrets.EKKLESIA_PACKAGES_PUBLISH_TOKEN }}
TRAIN_VERSION: ${{ inputs.version }}
TRAIN_REVISION: ${{ github.sha }}
MANIFEST_SHA256: ${{ needs.package-release.outputs.manifest_sha256 }}
run: |
node scripts/publish-package-train.ts \
--release "$(find package-train -mindepth 1 -maxdepth 1 -type d)" \
--registry "$EKKLESIA_PACKAGES_REGISTRY" \
--version "$TRAIN_VERSION" \
--revision "$TRAIN_REVISION" \
--manifest-sha256 "$MANIFEST_SHA256" \
--record publication-record.json \
--require-public-read
- name: Keep the publication record
uses: actions/upload-artifact@c6a3b2bd78b3985e4b2f15397fec357f0fd808de # v3.2.2-node20
with:
name: package-train-publication-record
path: publication-record.json
if-no-files-found: error
retention-days: 90
-12
View File
@@ -1,8 +1,4 @@
node_modules/ node_modules/
# pnpm's content-addressed package store. pnpm keeps it beside node_modules on the same file system,
# so when the checkout is its own mount (a CI runner's workspace volume, a bind-mounted container),
# `pnpm install` creates it here, at the checkout's root. A package cache, never source.
.pnpm-store/
.turbo/ .turbo/
coverage/ coverage/
dist/ dist/
@@ -20,11 +16,3 @@ generated/*
core/.identity-schema-reference.ts core/.identity-schema-reference.ts
core/.identity-schema-check-* core/.identity-schema-check-*
# The packager tests copy fixture modules here (packages/module-pack/tests/support/fixture-module.ts).
packages/module-pack/.fixtures/
# `pnpm packages:release` writes package release trains here (scripts/package-release.ts).
.package-release/
# CI keeps the proven release here to hand it on as a workflow artifact (scripts/test-package-release.sh --keep).
/package-train/
+15 -71
View File
@@ -6,60 +6,27 @@ contents, because restated details drift.
## What this repository is ## What this repository is
Ekklesia is a modular, self-hosted church platform. **This repository is the platform** — Core, the Ekklesia is a modular, self-hosted church platform built as a strictly layered pnpm/TypeScript
API, the Dashboard and display shell, and the public `@ekklesia/*` module-development packages — built monorepo:
as a strictly layered pnpm/TypeScript monorepo:
- **Core** (`core/`) owns platform infrastructure: identity, database connectivity, migration - **Core** (`core/`) owns platform infrastructure: identity, database connectivity, migration
execution, module discovery/lifecycle, devices, and other feature-agnostic services. Core never execution, module discovery/lifecycle, and other feature-agnostic services. Core never imports a
imports a feature module, a surface, or an app. feature module, a surface, or an app.
- **Modules** are independently versioned product packages. First-party modules live in **their own - **Modules** (`modules/*`, template at `templates/module`) are independently versioned product
repositories** (Signage is `Zayin/Signage`), build against the published `@ekklesia/*` packages packages. They own their own data and migrations, declare capabilities/permissions through
(ADR 0053), and reach an installation as a Module Package (ADR 0049) installed into module storage `@ekklesia/contracts` and `@ekklesia/module-sdk`, and must never import Core, another module, or a
outside the image (ADR 0048). No module source lives in this repository; `templates/module` is the surface directly. Per ADR 0021 a module owns its UI; manifest v7 permits independently optional
example, and `modules/` is only an optional, ignored local installed-modules directory. A module owns primary and full-viewport display artifacts, authored in React against the same role-neutral
its data, migrations, and **its own domain UI** (ADR 0021), declares capabilities/permissions through `@ekklesia/module-ui-sdk` ABI. Ekklesia mounts each through a separate surface role, while
`@ekklesia/contracts` and `@ekklesia/module-sdk`, and never imports Core, another module, or a `pnpm boundaries` still rejects every non-React UI framework and non-`.tsx` presentation source.
surface. Manifest v8 permits independently optional primary and full-viewport display artifacts,
authored in React against the role-neutral `@ekklesia/module-ui-sdk` ABI; database access is the
Module Database ABI v1 (ADR 0050).
- **Surfaces** (`surfaces/*`) own the containing application shell, navigation, and every - **Surfaces** (`surfaces/*`) own the containing application shell, navigation, and every
presentation concern a module does not. The shell **hosts** module UI through the generic loader; presentation concern a module does not. They consume public API/client contracts only, never Core
it never rebuilds a module's domain screens. Surfaces consume public API/client contracts only. or module internals directly.
These boundaries are enforced by `pnpm boundaries` (`scripts/check-boundaries.ts`), not just These boundaries are enforced by `pnpm boundaries` (`scripts/check-boundaries.ts`), not just
documented. Read `docs/architecture/repository-boundaries.md` and documented. Read `docs/architecture/repository-boundaries.md` and
`docs/architecture/platform-architecture.md` before proposing any change that crosses a layer. `docs/architecture/platform-architecture.md` before proposing any change that crosses a layer.
## Product direction lives beside this repository
The current roadmap and product/interface design contract are `ROADMAP.md` and `DESIGN.md` in the
parent development directory (`../ROADMAP.md` and `../DESIGN.md` from a checkout in the standard
`~/development/ekklesia/` layout). They are deliberately **not** committed here; do not copy them into
this repository to make them available. They are authoritative for product direction: read the
relevant stages and sections before designing a change. If this repository's documentation disagrees
with them, flag the conflict, correct the repository-local documentation where appropriate, and never
silently follow the stale text. Architectural decisions still change only through ADRs (below).
Rules from those documents that every change here must respect:
- **Ownership.** Modules own their domain data, behaviour, and UI. Shared resources — displays and
devices, accounts and identity, roles and authorization, branding, mail, push, storage providers,
and module installation/lifecycle — belong to the platform, and modules consume them.
- **Branding.** Normal user-facing UI never hard-codes "Ekklesia"; product identity comes from
installation branding (ADR 0046). Package names, diagnostics, logs, and developer tooling may.
Existing hard-coded shell strings are known debt for Stage 1, not a pattern to copy.
- **Visual direction.** Simple, minimal, and calm; responsive for phone, tablet, and desktop; light,
dark, and system modes. Normal UI colours come only from the shared design tokens
(`@ekklesia/design-tokens`, ADR 0039) — never a hard-coded colour.
- **No browser-native dialogs.** `window.alert`, `window.confirm`, and `window.prompt` are prohibited
in product UI. Destructive actions use in-product confirmation proportional to their impact.
- **Displays are transitional.** Displays are platform-owned. Today's device pairing (the Dashboard
sidebar's *Display devices* panel) and Signage's own display roster are a transitional
implementation that Stage 1 migrates to **Settings → Displays**. Fix defects in them, but do not
build the Stage 1 Settings, global Displays, theme editor, module installer, or responsive shell
redesign early, and keep existing paired displays working across any change.
## ADRs are authoritative ## ADRs are authoritative
`docs/architecture/decisions/` records durable architectural decisions. **Accepted ADRs are `docs/architecture/decisions/` records durable architectural decisions. **Accepted ADRs are
@@ -76,7 +43,6 @@ At minimum, read the current versions of:
- `docs/architecture/decisions/README.md` and every ADR relevant to your change - `docs/architecture/decisions/README.md` and every ADR relevant to your change
- `docs/architecture/platform-architecture.md` and `docs/architecture/repository-boundaries.md` - `docs/architecture/platform-architecture.md` and `docs/architecture/repository-boundaries.md`
- `README.md` (current platform status — what is and is not implemented) - `README.md` (current platform status — what is and is not implemented)
- `../ROADMAP.md` and `../DESIGN.md` (current product direction; see above)
- `docs/development/repository-workflow.md` and `docs/development/testing.md` - `docs/development/repository-workflow.md` and `docs/development/testing.md`
- `CONTRIBUTING.md` - `CONTRIBUTING.md`
@@ -86,16 +52,8 @@ your own memory of a prior session** — treat stale assumptions as a bug to cat
## Working conventions ## Working conventions
- The canonical repositories are on the church Gitea under the `Zayin` organization: - Work on a clean branch off an up-to-date `main`; never commit or push directly to `main`; never
`ssh://[email protected]:2222/Zayin/Ekklesia.git` (this one), `Zayin/Signage.git`, and change branch-protection settings.
`Zayin/Classroom_Desktop.git`. A local clone may be behind the server: `git fetch --prune --tags`
and branch from `origin/main`, never from whatever is checked out.
- One branch per PR, off an up-to-date `origin/main`. Never commit or push directly to `main`, never
merge your own PR, and never change branch-protection settings.
- Never create or push a release tag. Release tags belong to the human maintainer.
- A change that needs both a platform contract change and a module change is two PRs: Core first, on
its own branch, with the compatibility boundary stated explicitly; then the module. Do not copy
platform logic into a module to avoid making the correct Core change.
- Keep PRs focused on one bounded slice. Record major architectural changes in an ADR rather than - Keep PRs focused on one bounded slice. Record major architectural changes in an ADR rather than
burying them in an implementation. burying them in an implementation.
- Add or update tests when you change a contract or architectural rule. New behavior needs new - Add or update tests when you change a contract or architectural rule. New behavior needs new
@@ -108,13 +66,6 @@ your own memory of a prior session** — treat stale assumptions as a bug to cat
- Never run a database migration automatically (on import, on startup, in a test that isn't - Never run a database migration automatically (on import, on startup, in a test that isn't
explicitly a migration test, or as a side effect of an unrelated command). Migrations are always explicitly a migration test, or as a side effect of an unrelated command). Migrations are always
an explicit, operator-invoked action. an explicit, operator-invoked action.
- Report what you find. A defect, flaky test, documentation/ADR conflict, or CI problem outside the
task at hand gets an issue in the repository it belongs to, as soon as you find it. Search for an
existing issue first; something fixed in the same PR goes in that PR's description instead. Be
descriptive: what you saw, the evidence (run and job links, commit, file and line), how to
reproduce it, the impact, and options, and never a secret. Label it `found-by-claude` (an agent
that cannot apply labels ends the body with a line saying so), leave it unassigned, and list what
you filed when you report back.
## Canonical verification commands ## Canonical verification commands
@@ -127,13 +78,6 @@ ever disagree:
- `pnpm test:migrations:integration` — live, disposable-PostgreSQL evidence: migration discovery, - `pnpm test:migrations:integration` — live, disposable-PostgreSQL evidence: migration discovery,
planning, and execution, plus the `apps/api` HTTP transport (signup, sign-in, principal planning, and execution, plus the `apps/api` HTTP transport (signup, sign-in, principal
resolution, sign-out). Requires Docker. Run this for any change touching either area. resolution, sign-out). Requires Docker. Run this for any change touching either area.
- `pnpm test:package-release` — builds the public `@ekklesia/*` package train, proves it
reproducible, and proves a standalone module consumes exactly those tarballs. Publishes nothing.
Run it for any change to the public packages or their release tooling.
- `pnpm test:package-publication` — publishes a release train to a disposable Gitea (Docker) and
proves the publication tool. Never the real registry. The real publication is the manual
`Publish packages` workflow only; never run `scripts/publish-package-train.ts` against the real
registry without the user's explicit intent.
- `pnpm doctor` — read-only local readiness report. Never treat it as a substitute for `pnpm - `pnpm doctor` — read-only local readiness report. Never treat it as a substitute for `pnpm
verify` or the integration suite. verify` or the integration suite.
- `pnpm migrations:plan` / `pnpm migrations:apply` — read-only planning vs. mutating execution - `pnpm migrations:plan` / `pnpm migrations:apply` — read-only planning vs. mutating execution
-661
View File
@@ -1,661 +0,0 @@
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/>.
+11 -132
View File
@@ -35,48 +35,6 @@ concurrency and release, exact destructive approvals, and sanitization against t
adapter. A Core-owned `pnpm doctor` dashboard combines the existing static discovery and read-only adapter. A Core-owned `pnpm doctor` dashboard combines the existing static discovery and read-only
migration-planning services into one concise readiness report. migration-planning services into one concise readiness report.
**Ekklesia ships one generic image, and installed modules live outside it
([ADR 0048](docs/architecture/decisions/0048-generic-image-and-installed-module-storage.md)).** The
production image built from [`deploy/Dockerfile`](deploy/Dockerfile) contains zero feature modules
and is the same for every installation. Installed modules live in installation-owned module storage,
selected by `EKKLESIA_MODULES_DIR` (`/var/lib/ekklesia/modules` in the image, the checkout's
`modules/` when unset) and mounted read-only by the reference deployment. Each installed module is a
self-contained directory carrying its own production dependencies. Installing, updating, or removing
a module needs only a controlled restart and, when it has migrations, an explicit plan and apply.
Nothing is hot loaded. Removing a module's code keeps its data and its stored state; its applied
migration history is retained and reported, and blocks no other migration. A module is distributed
as one **Ekklesia Module Package v1**
([ADR 0049](docs/architecture/decisions/0049-module-package-v1.md)): a deterministic `.tar.gz` and
its SHA-256, produced by the canonical packager (`@ekklesia/module-pack`) with the module's backend
bundled, and checked by Core's verifier before it is installed. Two targets exist:
- the portable `any` target, for pure-JavaScript modules;
- `linux-x64-glibc`, for modules with a native dependency such as `sharp`. Its native runtime
packages are carried as real files under the package's `node_modules`, and the verifier checks the
host before anything is loaded.
Every package records the module's validated SPDX expression and carries its explicitly
supplied `LICENSE` bytes; dependency material remains separate in `THIRD_PARTY_NOTICES`. ABI-v1
packages may bundle a module-owned Drizzle version without coupling it to Core;
legacy-v8 packages retain their transitional exact-version check. Where a redistributed dependency
omits licence material, the packager may add reviewed licence supplements
([ADR 0052](docs/architecture/decisions/0052-reviewed-license-supplements.md)). A native package that
redistributes LGPL or MPL code is distributed publicly only with its corresponding-source artifact,
which `ekklesia-corresponding-source` builds and gates
([ADR 0055](docs/architecture/decisions/0055-corresponding-source-artifacts.md)). Purge, other
operating-system or CPU targets, signing, and any installer or marketplace do not exist yet.
A module can be built in its own repository from Ekklesia's public module-development packages
([ADR 0053](docs/architecture/decisions/0053-standalone-module-development-packages.md)):
a curated `@ekklesia/module-sdk`, `@ekklesia/module-database`, the UI packages, and the build and
packaging tools. They are released as one coordinated train of tarballs with a release manifest,
and CI proves on every run that two builds of a commit are byte-identical and that a module outside
the repository installs, builds, packages, and verifies from those artifacts alone. A manual
workflow, the only way to publish, publishes exactly those artifacts to the npm registry and
verifies every package byte for byte. Anyone may read and install the packages before launch
([ADR 0054](docs/architecture/decisions/0054-public-package-reads-before-launch.md)). The first train
published will be the next platform release
([Releases and the public package train](docs/development/releases.md)).
Phase 4A added Core-owned identity foundations: a Core-owned Better Auth database schema Phase 4A added Core-owned identity foundations: a Core-owned Better Auth database schema
(`ekklesia_core_user`, `ekklesia_core_session`, `ekklesia_core_account`, (`ekklesia_core_user`, `ekklesia_core_session`, `ekklesia_core_account`,
`ekklesia_core_verification`), a first-class Core-owned migration source that coexists with `ekklesia_core_verification`), a first-class Core-owned migration source that coexists with
@@ -260,10 +218,9 @@ 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 —
configuring and proving job-token authority server-side (the server now runs Gitea 1.27.2, which upgrading Gitea past 1.24.2 (which *ignores* the workflow's `permissions`, `concurrency`, and
honours `concurrency` and documents `permissions`, but neither token limits nor `timeout-minutes` are `timeout-minutes` declarations), clamping job-token authority server-side, enforcing timeout and
verified on it yet), enforcing timeout and teardown in a trusted provisioner/reaper, and proving teardown in a trusted provisioner/reaper, and proving disposable runner isolation. Those are
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
@@ -532,8 +489,8 @@ reported `blocked · configuration_missing · bucket` — never imported, never
from inside its own `start` hook — and its required dependents block behind it through ADR 0028's from inside its own `start` hook — and its required dependents block behind it through ADR 0028's
existing mechanism. Doctor and `pnpm modules:validate` report the same thing while still importing no existing mechanism. Doctor and `pnpm modules:validate` report the same thing while still importing no
module code. That change introduced `manifestVersion: 5` as a hard cutover; the current strict module code. That change introduced `manifestVersion: 5` as a hard cutover; the current strict
manifest is version 8 after ADR 0050 added the optional database ABI declaration. Version 8 is a manifest is version 7 after ADR 0033 added the optional display declaration and ADR 0041 added
hard cutover; a version 7 source module or package must be updated and rebuilt. browser contributions.
**Ekklesia does not store secrets, and does not pretend to.** There is no encryption at rest here and **Ekklesia does not store secrets, and does not pretend to.** There is no encryption at rest here and
no key management, so a `secret` key's value is supplied by the deployment no key management, so a `secret` key's value is supplied by the deployment
@@ -822,8 +779,7 @@ hosting are deployment work, not guarantees added by this pass. Display device i
public Website remain separate future designs. public Website remain separate future designs.
**Still not the finished Dashboard.** There is no navigation editor or drag and drop, no nested **Still not the finished Dashboard.** There is no navigation editor or drag and drop, no nested
folders, no icons or colors, no Dashboard Home or widgets, no design system, no client-side routing folders, no icons or colors, no Dashboard Home or widgets, no design system, no client-side routing,
(since added; see the Stage 1 paragraph below),
and no module-management, settings, or storage UI — module enablement and configuration are an API and and no module-management, settings, or storage UI — module enablement and configuration are an API and
a CLI, not a screen, and object storage is deployment configuration. A production CSP and a CLI, not a screen, and object storage is deployment configuration. A production CSP and
deployment for the built shells, general sidecar asset publication, React runtime sharing, the deployment for the built shells, general sidecar asset publication, React runtime sharing, the
@@ -868,7 +824,7 @@ is versioned with no dual acceptance; migrating is one `pnpm build`.
Phase 8 pass 3 lets one module contribute a browser renderer to a capability another module provides Phase 8 pass 3 lets one module contribute a browser renderer to a capability another module provides
([ADR 0041](docs/architecture/decisions/0041-module-browser-contributions.md)) — the browser half of ([ADR 0041](docs/architecture/decisions/0041-module-browser-contributions.md)) — the browser half of
the capability system Core has had since Phase 1. `manifestVersion: 8` declares a namespaced id, its the capability system Core has had since Phase 1. `manifestVersion: 7` declares a namespaced id, its
own version, a target capability, a compatible range, and one artifact, and nothing else: there is no own version, a target capability, a compatible range, and one artifact, and nothing else: there is no
payload schema, slot, ordering, or rendering hint anywhere, so Core cannot learn what any of it payload schema, slot, ordering, or rendering hint anywhere, so Core cannot learn what any of it
means. Publication requires that another eligible module provides the capability inside the declared means. Publication requires that another eligible module provides the capability inside the declared
@@ -906,59 +862,6 @@ the module's responsibility and do not exist yet.**
Ekklesia Lite, a theme editor, a module store or installer, a standalone host, and every feature Ekklesia Lite, a theme editor, a module store or installer, a standalone host, and every feature
module — Signage included — still do not exist, and nothing in Phase 8 claims otherwise. module — Signage included — still do not exist, and nothing in Phase 8 claims otherwise.
Stage 1 begins with **real Dashboard URLs**
([ADR 0057](docs/architecture/decisions/0057-dashboard-urls-are-host-owned-routes.md)). The
Dashboard opens on an **Overview** at `/`, and each module is a top-level destination with its own
address, `/<module-id>` (`/signage`), beside `/settings` and `/account`, so a refresh, a bookmark,
and a shared link land on the page that was open and Back and Forward walk the Dashboard's own pages.
The API serves the same Dashboard page at `/`, throughout `/settings` and `/account`, and throughout
each installed module's `/<module-id>`; any other top-level path is its sanitized `404`. Core reserves
the platform's own top-level names (`account`, `api`, `assets`, `display`, `settings`), and
discovery refuses a module that uses one. An installed module that is disabled shows a host-owned
*This page isn't available* rather than loading anything. A module's own
sub-pages, Settings and Account pages, and returning to the workflow a Settings link came from are
later Stage 1 work.
The Dashboard's **shell** follows DESIGN.md §4
([ADR 0058](docs/architecture/decisions/0058-dashboard-shell-navigation-and-lucide-icons.md)): a
collapsible left sidebar — icons and labels expanded, icons with accessible tooltips collapsed, the
choice remembered in this browser — with the Overview first, the server's groups and modules below it,
and an account menu anchored at the bottom (Sign out, for now). There is no top bar. On a phone the
same navigation opens in a modal drawer with focus trapped inside it, Escape and the backdrop closing
it, and focus returned to the menu button. Lucide is Ekklesia's icon vocabulary; until modules can
declare their own icon (manifest v9), every module shows one generic icon. Church branding in the
sidebar's identity slot is still to come.
`@ekklesia/ui` now carries the interactions every later screen shares
([ADR 0059](docs/architecture/decisions/0059-shared-interaction-primitives.md)): a product-owned
`ConfirmationDialog` (simple, destructive, or typed-phrase, chosen by the caller), a per-tree
top-center toast system, `ErrorState` with collapsed technical detail and remedies, skeleton loading,
and `PageHeader` with a Back action that returns only to a validated Dashboard page. Every dialog
traps and restores focus, and `pnpm lint` refuses `window.alert`, `confirm`, and `prompt`
repository-wide.
The Dashboard follows the operating system's **Light or Dark** appearance and **reduced-motion**
setting, live and without a reload, and so does every module's UI, through the same design tokens
([ADR 0060](docs/architecture/decisions/0060-light-dark-system-theme-and-motion.md)). The dark theme is
designed rather than inverted, every text and focus pairing is contrast-tested to WCAG AA in both
schemes, and corners scale from one `radius.scale` token. Displays stay light.
**Settings** has its own place in the sidebar
([ADR 0061](docs/architecture/decisions/0061-settings-hierarchy-and-module-settings-routes.md)): a
landing page at `/settings`; the platform's General, Branding, Displays, Discover, and System; and each
module's settings at `/settings/<module-id>`. Discover is where modules are found, installed, managed,
and updated (`/settings/discover/browse`, `/installed`, `/updates`); configuring a module is its own
Settings page. A person sees only what the server says they may administer, and no Settings link at all
when that is nothing. The pages are placeholders until each section is built.
**Your account** is at the bottom of the sidebar, showing your name, email, and initials
([ADR 0062](docs/architecture/decisions/0062-account-area-and-synced-appearance-preferences.md)).
`/account` shows who you are signed in as. On `/account/appearance` you choose **System, Light, or
Dark** and **reduced motion** (System, Reduce, or Allow). A choice takes effect at once, is saved to
your account so it follows you to every device, and is applied before the first paint on your next
visit. Managing your own appearance needs no administrative permission. Profile editing and an
avatar image are not part of this.
## Quick start ## Quick start
Prerequisites: Node.js 24 LTS, pnpm 11.12, and Docker with Compose. Prerequisites: Node.js 24 LTS, pnpm 11.12, and Docker with Compose.
@@ -1014,7 +917,7 @@ Modules
✓ Dependency graph valid ✓ Dependency graph valid
Dependency order is empty. Dependency order is empty.
✓ Permission registry composed ✓ Permission registry composed
7 permissions registered 6 permissions registered
○ Module lifecycle skipped ○ Module lifecycle skipped
DATABASE_URL is not configured. DATABASE_URL is not configured.
○ Module configuration skipped ○ Module configuration skipped
@@ -1024,7 +927,7 @@ Data definitions
✓ Schemas validated ✓ Schemas validated
0 modules · 0 tables 0 modules · 0 tables
✓ Migrations validated ✓ Migrations validated
9 migrations · checksums valid 7 migrations · checksums valid
core:0000_identity_core_tables · f8f2086ef06f core:0000_identity_core_tables · f8f2086ef06f
core:0001_authorization_core_tables · 830e1fbc26d0 core:0001_authorization_core_tables · 830e1fbc26d0
core:0002_authorization_integrity · 634294731180 core:0002_authorization_integrity · 634294731180
@@ -1032,8 +935,6 @@ Data definitions
core:0004_module_lifecycle_state · c8b5d8b3d48b core:0004_module_lifecycle_state · c8b5d8b3d48b
core:0005_module_configuration · c8d57f24f84b core:0005_module_configuration · c8d57f24f84b
core:0006_device_identity · 1db97b7e3ac5 core:0006_device_identity · 1db97b7e3ac5
core:0007_verified_bootstrap_owners · 49013062f3e0
core:0008_user_preferences · fa9d4b0bb9d3
PostgreSQL PostgreSQL
✓ Database TLS verified ✓ Database TLS verified
@@ -1154,27 +1055,5 @@ 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)
## License No open-source license has been selected yet. A license should be added only after the project
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 -3
View File
@@ -1,7 +1,6 @@
{ {
"name": "@ekklesia/api", "name": "@ekklesia/api",
"version": "0.2.5", "version": "0.1.1",
"license": "AGPL-3.0-or-later",
"private": true, "private": true,
"type": "module", "type": "module",
"files": [ "files": [
@@ -28,7 +27,6 @@
}, },
"devDependencies": { "devDependencies": {
"@biomejs/biome": "2.5.3", "@biomejs/biome": "2.5.3",
"@ekklesia/design-tokens": "workspace:*",
"@ekklesia/module-ui-host": "workspace:*", "@ekklesia/module-ui-host": "workspace:*",
"@ekklesia/module-ui-sdk": "workspace:*", "@ekklesia/module-ui-sdk": "workspace:*",
"@ekklesia/typescript-config": "workspace:*", "@ekklesia/typescript-config": "workspace:*",
-157
View File
@@ -1,157 +0,0 @@
import { isAuthenticatedPrincipal } from "@ekklesia/contracts";
import {
type AccountPreferences,
type AccountStore,
AccountStoreError,
parseAccountPreferencesUpdate,
} from "@ekklesia/core/account";
import { CORE_ACCOUNT_SELF_MANAGE } from "@ekklesia/core/authorization";
import { type Context, Hono } from "hono";
import {
type AuthorizationMiddlewareOptions,
getAuthorizedPrincipal,
requirePermission,
requireTrustedOrigin,
} from "./authorization-middleware.js";
import { CORE_MUTATION_MAX_BODY_BYTES, platformBodyLimit } from "./body-limits.js";
export const ACCOUNT_PATH = "/api/v1/account";
export const ACCOUNT_PREFERENCES_PATH = `${ACCOUNT_PATH}/preferences`;
/** `GET /api/v1/account`: the signed-in person's own account, as the account area shows it. */
export interface AccountResponse {
readonly name: string;
readonly email: string;
readonly preferences: AccountPreferences;
}
export interface CreateAccountRoutesOptions extends AuthorizationMiddlewareOptions {
readonly store: AccountStore;
readonly trustedOrigins: ReadonlySet<string>;
}
function sanitizedFailure(c: Context, status: 400 | 403 | 404 | 503, code: string): Response {
c.header("Cache-Control", "no-store");
return c.json({ code }, status);
}
/** Maps the store's own sanitized codes onto HTTP without inventing a second vocabulary. */
function storeFailure(
c: Context,
error: unknown,
fallback: "ACCOUNT_READ_FAILED" | "ACCOUNT_WRITE_FAILED",
): Response {
if (error instanceof AccountStoreError) {
if (error.code === "ACCOUNT_NOT_FOUND") {
return sanitizedFailure(c, 404, error.code);
}
if (error.code === "ACCOUNT_PREFERENCES_INVALID") {
return sanitizedFailure(c, 400, "INVALID_REQUEST");
}
return sanitizedFailure(c, 503, error.code);
}
return sanitizedFailure(c, 503, fallback);
}
async function readJsonBody(c: Context): Promise<unknown> {
try {
return (await c.req.json()) as unknown;
} catch {
return undefined;
}
}
/**
* A signed-in person's own account (ADR 0062): who they are signed in as, and their own
* preferences.
*
* ## Self-only by construction
*
* Neither route accepts a user id — not in the path, not in the query, not in the body. The account
* is always the one the **session** names, so there is no request that could read or change anyone
* else's, whatever permission the caller holds. A body that names any key besides a known
* preference is refused whole.
*
* ## Authorization
*
* Both routes require `core.account.self.manage`, whose baseline is `authenticated`: every
* signed-in person may manage their own account, and needs no church-management permission to do
* so. An anonymous caller and a paired device are refused (`403`). Changing a preference is a
* mutation like any other Core mutation: an exact trusted `Origin` and a bounded body are checked
* before authorization work begins.
*
* ## What the browser learns
*
* Name, email, and preferences — exactly what the account area shows, and nothing else. Not the
* account's image URL, verification state, timestamps, or any credential. Responses are per-person
* and never cached.
*/
export function createAccountRoutes(options: CreateAccountRoutesOptions): Hono {
const routes = new Hono();
const requireSelf = requirePermission(options, CORE_ACCOUNT_SELF_MANAGE);
const requireOrigin = requireTrustedOrigin(options.trustedOrigins);
const mutationBodyLimit = platformBodyLimit(CORE_MUTATION_MAX_BODY_BYTES);
routes.get(ACCOUNT_PATH, requireSelf, async (c) => {
c.header("Cache-Control", "no-store");
c.header("Vary", "Origin, Cookie");
const principal = getAuthorizedPrincipal(c);
// Unreachable through the `authenticated` baseline, which only a person satisfies; checked
// again so the account read can never be keyed by anything but a person's own session.
if (!isAuthenticatedPrincipal(principal)) {
return sanitizedFailure(c, 403, "AUTHORIZATION_DENIED");
}
try {
const account = await options.store.readAccount(principal.userId);
if (!account) {
return sanitizedFailure(c, 404, "ACCOUNT_NOT_FOUND");
}
return c.json(
{
email: account.email,
name: account.name,
preferences: {
appearance: account.preferences.appearance,
motion: account.preferences.motion,
},
} satisfies AccountResponse,
200,
);
} catch (error) {
return storeFailure(c, error, "ACCOUNT_READ_FAILED");
}
});
routes.patch(
ACCOUNT_PREFERENCES_PATH,
requireOrigin,
mutationBodyLimit,
requireSelf,
async (c) => {
c.header("Cache-Control", "no-store");
c.header("Vary", "Origin, Cookie");
const principal = getAuthorizedPrincipal(c);
if (!isAuthenticatedPrincipal(principal)) {
return sanitizedFailure(c, 403, "AUTHORIZATION_DENIED");
}
const update = parseAccountPreferencesUpdate(await readJsonBody(c));
if (!update) {
return sanitizedFailure(c, 400, "INVALID_REQUEST");
}
try {
const preferences = await options.store.updatePreferences(principal.userId, update);
return c.json(
{
appearance: preferences.appearance,
motion: preferences.motion,
} satisfies AccountPreferences,
200,
);
} catch (error) {
return storeFailure(c, error, "ACCOUNT_WRITE_FAILED");
}
},
);
return routes;
}
+11 -59
View File
@@ -1,5 +1,6 @@
import { import {
isAuthenticatedPrincipal, isAuthenticatedPrincipal,
type ModuleRuntimeServices,
type ModuleSecretSource, type ModuleSecretSource,
type RegisteredModuleHttpRoute, type RegisteredModuleHttpRoute,
} from "@ekklesia/contracts"; } from "@ekklesia/contracts";
@@ -8,12 +9,10 @@ import type {
AuthorizationStore, AuthorizationStore,
PermissionRegistry, PermissionRegistry,
} from "@ekklesia/core/authorization"; } from "@ekklesia/core/authorization";
import type { AccountStore } from "@ekklesia/core/account";
import type { DashboardStore } from "@ekklesia/core/dashboard"; import type { DashboardStore } from "@ekklesia/core/dashboard";
import type { DeviceStore } from "@ekklesia/core/devices"; import type { DeviceStore } from "@ekklesia/core/devices";
import { type EkklesiaIdentityRuntime, IdentityRuntimeError } from "@ekklesia/core/identity"; import { type EkklesiaIdentityRuntime, IdentityRuntimeError } from "@ekklesia/core/identity";
import type { import type {
AnyModuleRuntimeServices,
DiscoveredModule, DiscoveredModule,
ModuleConfigurationLookup, ModuleConfigurationLookup,
ModuleConfigurationSettings, ModuleConfigurationSettings,
@@ -21,7 +20,6 @@ import type {
ModuleLifecycleSnapshot, ModuleLifecycleSnapshot,
ModuleLifecycleStore, ModuleLifecycleStore,
ModuleRealtimeRegistry, ModuleRealtimeRegistry,
ModuleRuntimeServicesLookup,
ModuleStorageLookup, ModuleStorageLookup,
ModuleUiPublicationRegistry, ModuleUiPublicationRegistry,
} from "@ekklesia/core/modules"; } from "@ekklesia/core/modules";
@@ -29,12 +27,10 @@ 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 { createAccountRoutes } from "./account-routes.js";
import type { DashboardHostAssets } from "./dashboard-host-assets.js"; import type { DashboardHostAssets } from "./dashboard-host-assets.js";
import { createDashboardHostRoutes } from "./dashboard-host-routes.js"; import { createDashboardHostRoutes } from "./dashboard-host-routes.js";
import { createDashboardRoutes } from "./dashboard-routes.js"; import { createDashboardRoutes } from "./dashboard-routes.js";
import { createDashboardSettingsRoutes } from "./dashboard-settings-routes.js"; import { createDeviceRoutes } from "./device-routes.js";
import { createDeviceRoutes, renewDeviceSessionCookie } from "./device-routes.js";
import type { DisplayHostAssets } from "./display-host-assets.js"; import type { DisplayHostAssets } from "./display-host-assets.js";
import { createGrantsRoutes } from "./grants-routes.js"; import { createGrantsRoutes } from "./grants-routes.js";
import { type ApiHealthOptions, createHealthRoutes } from "./health-routes.js"; import { type ApiHealthOptions, createHealthRoutes } from "./health-routes.js";
@@ -66,10 +62,11 @@ export interface CreateApiAppOptions {
/** The started runtime's immutable module-owned realtime registry. Required explicitly even when /** The started runtime's immutable module-owned realtime registry. Required explicitly even when
* empty, for the same reason as module routes: application composition never guesses what ran. */ * empty, for the same reason as module routes: application composition never guesses what ran. */
readonly moduleRealtime: ModuleRealtimeRegistry; readonly moduleRealtime: ModuleRealtimeRegistry;
/** The same per-module service lookup the caller's started module runtime used for every /** The same frozen services object the caller's started module runtime already passed to every
* module's `start`/`stop` hooks (`apps/api/src/server.ts`) — each handler receives its module's * module's `start`/`stop` hooks (`apps/api/src/server.ts`) — reference-identical, never a fresh
* reference-identical service object, never a fresh facade or executor built per request. */ * object built here, so a module's HTTP handlers and lifecycle hooks share exactly one
readonly moduleServices: ModuleRuntimeServicesLookup | AnyModuleRuntimeServices; * `ModuleDatabase` for the lifetime of this API runtime. */
readonly moduleServices: ModuleRuntimeServices;
/** The platform's module UI publication policy — always the one Core builds from the exact /** The platform's module UI publication policy — always the one Core builds from the exact
* discovered inventory this runtime started (`createModuleUiPublicationRegistry`, * discovered inventory this runtime started (`createModuleUiPublicationRegistry`,
* `apps/api/src/server.ts`). Required rather than defaulted, for the same reason `moduleRoutes` * `apps/api/src/server.ts`). Required rather than defaulted, for the same reason `moduleRoutes`
@@ -143,10 +140,6 @@ 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;
/** Core's persistence for a signed-in person's own account summary and preferences (ADR 0062).
* Required like `dashboard`: every installation has accounts. Reachable only through routes that
* take the account from the session, never from the request. */
readonly account: AccountStore;
/** /**
* The built Dashboard's two files, read once by production startup (ADR 0044). Required rather * 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 * than optional, unlike `displayHostAssets`: every installation has a Dashboard, and a missing
@@ -370,18 +363,10 @@ export function createApiApp(options: CreateApiAppOptions): Hono {
// own entirely. See `./health-routes.js`. // own entirely. See `./health-routes.js`.
app.route("/", createHealthRoutes(options.health)); app.route("/", createHealthRoutes(options.health));
// The Dashboard page and its one script, at fixed paths outside `/api/` (ADR 0044, ADR 0057): the // The Dashboard page and its one script, at exact literal paths outside `/api/` (ADR 0044). They
// page at `/`, the Dashboard's platform pages, and every discovered module with a Dashboard UI at // grant no authority and resolve no principal; the page asks `/api/v1/principal` itself. See
// `/<module-id>`. They grant no authority and resolve no principal; the page asks // `./dashboard-host-routes.js`.
// `/api/v1/principal` itself. See `./dashboard-host-routes.js`. app.route("/", createDashboardHostRoutes(options.dashboardHostAssets));
app.route(
"/",
createDashboardHostRoutes(options.dashboardHostAssets, {
modulePages: options.moduleLifecycle.snapshot.discoveredModules
.filter((module) => module.entrypoints.ui !== undefined)
.map((module) => module.manifest.id),
}),
);
app.route( app.route(
"/", "/",
@@ -422,12 +407,6 @@ export function createApiApp(options: CreateApiAppOptions): Hono {
registry: options.authorization.registry, registry: options.authorization.registry,
runtime, runtime,
...(options.displayHostAssets ? { assets: options.displayHostAssets } : {}), ...(options.displayHostAssets ? { assets: options.displayHostAssets } : {}),
...(options.devices
? {
renewDeviceCredential: (c: Context) =>
renewDeviceSessionCookie(c, options.devices?.secureCookies ?? true),
}
: {}),
}); });
if (options.devices) { if (options.devices) {
@@ -462,33 +441,6 @@ export function createApiApp(options: CreateApiAppOptions): Hono {
}), }),
); );
// Which Settings destinations the viewer may see (ADR 0061): server-evaluated presentation hints
// beside the navigation, never authority. See `./dashboard-settings-routes.js`.
app.route(
"/",
createDashboardSettingsRoutes({
evaluator: options.authorization.evaluator,
platformOwners: options.authorization.store,
registry: options.authorization.registry,
runtime,
snapshot: options.moduleLifecycle.snapshot,
}),
);
// The signed-in person's own account and preferences (ADR 0062). Self-only by construction: the
// account is always the session's, and no request value can name another. See
// `./account-routes.js`.
app.route(
"/",
createAccountRoutes({
evaluator: options.authorization.evaluator,
registry: options.authorization.registry,
runtime,
store: options.account,
trustedOrigins: allowedOrigins,
}),
);
// Mounted before module routes and, critically, under its own sibling prefix: `/api/v1/modules/` // Mounted before module routes and, critically, under its own sibling prefix: `/api/v1/modules/`
// belongs entirely to installed modules, so the platform's own lifecycle resource lives beside it // belongs entirely to installed modules, so the platform's own lifecycle resource lives beside it
// rather than inside it. See `./module-lifecycle-routes.js`. // rather than inside it. See `./module-lifecycle-routes.js`.
-16
View File
@@ -200,19 +200,3 @@ 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])]);
}
+7 -65
View File
@@ -3,9 +3,7 @@ import { readFile } from "node:fs/promises";
import { dashboardHostAssetUrls } from "@ekklesia/shell/dashboard-assets"; import { dashboardHostAssetUrls } from "@ekklesia/shell/dashboard-assets";
/** /**
* The three immutable, platform-owned files that make up the built Ekklesia Dashboard (ADR 0044): * The two immutable, platform-owned files that make up the built Ekklesia Dashboard (ADR 0044).
* the page, its application script, and the small pre-paint appearance script the page loads in
* `<head>` (ADR 0062).
* *
* Exactly the model `./display-host-assets.js` uses: read once, before the HTTP listener is bound, * 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 * from the exact URLs the surface package exports. Request input is never involved in this read, the
@@ -18,8 +16,6 @@ export interface DashboardHostAssets {
readonly htmlEtag: string; readonly htmlEtag: string;
readonly script: string; readonly script: string;
readonly scriptEtag: string; readonly scriptEtag: string;
readonly appearanceScript: string;
readonly appearanceScriptEtag: string;
} }
/** A startup failure with a fixed, non-secret shape. The originating filesystem exception, package /** A startup failure with a fixed, non-secret shape. The originating filesystem exception, package
@@ -46,21 +42,11 @@ function strongEtag(text: string): string {
* can never be paired with an ETag from another. Empty output is a failed shell build rather than a * 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. * valid page that can never render anything.
*/ */
export function createDashboardHostAssets( export function createDashboardHostAssets(html: string, script: string): DashboardHostAssets {
html: string, if (html.trim().length === 0 || script.trim().length === 0) {
script: string,
appearanceScript: string,
): DashboardHostAssets {
if (
html.trim().length === 0 ||
script.trim().length === 0 ||
appearanceScript.trim().length === 0
) {
throw new DashboardHostAssetError(); throw new DashboardHostAssetError();
} }
return Object.freeze({ return Object.freeze({
appearanceScript,
appearanceScriptEtag: strongEtag(appearanceScript),
html, html,
htmlEtag: strongEtag(html), htmlEtag: strongEtag(html),
script, script,
@@ -68,59 +54,15 @@ export function createDashboardHostAssets(
}); });
} }
/** /** Loads only the two surface-declared files. A missing or unreadable build fails startup rather
* The exact element the built Dashboard page carries for its API origin (ADR 0045), empty. The shell * than leaving an API whose own origin answers `/` with a 404. */
* reads the filled-in value at run time; empty means the page's own origin. export async function loadDashboardHostAssets(): Promise<DashboardHostAssets> {
*/
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("&", "&amp;")
.replaceAll('"', "&quot;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;");
return parts.join(`<meta content="${escaped}" name="ekklesia-api-origin" />`);
}
/** Loads only the three 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 { try {
// Sequential deliberately, as for the display host: diagnostics stay deterministic and no // Sequential deliberately, as for the display host: diagnostics stay deterministic and no
// rejected sibling read is left running after this function has already failed. // rejected sibling read is left running after this function has already failed.
const html = await readFile(new URL(dashboardHostAssetUrls.html), "utf8"); const html = await readFile(new URL(dashboardHostAssetUrls.html), "utf8");
const script = await readFile(new URL(dashboardHostAssetUrls.script), "utf8"); const script = await readFile(new URL(dashboardHostAssetUrls.script), "utf8");
const appearanceScript = await readFile(new URL(dashboardHostAssetUrls.appearance), "utf8"); return createDashboardHostAssets(html, script);
return createDashboardHostAssets(
renderDashboardPage(html, apiOrigin),
script,
appearanceScript,
);
} catch { } catch {
throw new DashboardHostAssetError(); throw new DashboardHostAssetError();
} }
+16 -84
View File
@@ -1,24 +1,16 @@
import { moduleIdSchema } from "@ekklesia/contracts";
import { isReservedModuleId } from "@ekklesia/core/modules";
import { DASHBOARD_PLATFORM_PAGES } from "@ekklesia/shell/dashboard-routes";
import { Hono } from "hono"; import { Hono } from "hono";
import { etagMatches } from "./conditional-request.js"; import { etagMatches } from "./conditional-request.js";
import { createDashboardHostAssets, type DashboardHostAssets } from "./dashboard-host-assets.js"; import { createDashboardHostAssets, type DashboardHostAssets } from "./dashboard-host-assets.js";
/** /**
* The Dashboard's host-owned routes (ADR 0044, ADR 0057). None can collide with anything else the API * The Dashboard's two host-owned routes (ADR 0044). Neither can collide with anything else the API
* serves: the page answers at `/`, at the Dashboard's own platform pages (which the shell exports, so * serves: `/` is the only page the Dashboard has — it has no client router, and every emailed
* this server and the shell's router read one list), and at each installed module's `/<module-id>`. * verification or reset link lands on `/` with a query (ADR 0038) — and the script lives beside, never
* Every one of those first segments is either a reserved platform name or a module id Core accepted, * inside, the display host's `/assets/ekklesia-display/` namespace. Module routes all live under
* and Core refuses a module id that is a reserved name, so no module page can shadow `/api`, * `/api/v1/modules/<module-id>`.
* `/assets`, `/display`, or a platform page. The script lives beside, never inside, the display
* host's `/assets/ekklesia-display/` namespace, and every emailed verification or reset link still
* lands on `/` with a query (ADR 0038).
*/ */
export const DASHBOARD_HOST_PAGE_PATH = "/"; export const DASHBOARD_HOST_PAGE_PATH = "/";
export const DASHBOARD_HOST_SCRIPT_PATH = "/assets/ekklesia-dashboard/app.js"; export const DASHBOARD_HOST_SCRIPT_PATH = "/assets/ekklesia-dashboard/app.js";
/** The pre-paint appearance script (ADR 0062), beside the application script. */
export const DASHBOARD_HOST_APPEARANCE_SCRIPT_PATH = "/assets/ekklesia-dashboard/appearance.js";
/** /**
* Revalidate-always with a strong `ETag`, exactly the module UI and display-host script policy. The * Revalidate-always with a strong `ETag`, exactly the module UI and display-host script policy. The
@@ -58,12 +50,8 @@ export class DashboardHostConfigurationError extends Error {
* a composition can never pair bytes with a validator computed from different bytes. */ * a composition can never pair bytes with a validator computed from different bytes. */
function snapshotAssets(input: DashboardHostAssets): DashboardHostAssets { function snapshotAssets(input: DashboardHostAssets): DashboardHostAssets {
try { try {
const snapshot = createDashboardHostAssets(input.html, input.script, input.appearanceScript); const snapshot = createDashboardHostAssets(input.html, input.script);
if ( if (snapshot.htmlEtag !== input.htmlEtag || snapshot.scriptEtag !== input.scriptEtag) {
snapshot.htmlEtag !== input.htmlEtag ||
snapshot.scriptEtag !== input.scriptEtag ||
snapshot.appearanceScriptEtag !== input.appearanceScriptEtag
) {
throw new DashboardHostConfigurationError(); throw new DashboardHostConfigurationError();
} }
return snapshot; return snapshot;
@@ -97,74 +85,30 @@ function fixedAsset(
}); });
} }
export interface DashboardHostRouteOptions {
/**
* The id of every module this process discovered that declares a Dashboard UI — enabled or not,
* since a disabled module's bookmark should open the Dashboard and say the module is unavailable
* rather than fail as an unknown address. Fixed at startup, like the rest of the module set.
*/
readonly modulePages: readonly string[];
}
/** Each module page must be a real module id that names no platform namespace, exactly once.
* Discovery already guarantees this; the routes refuse to compose rather than trust it. */
function validatedModulePages(modulePages: readonly string[]): readonly string[] {
const seen = new Set<string>();
for (const moduleId of modulePages) {
if (
!moduleIdSchema.safeParse(moduleId).success ||
isReservedModuleId(moduleId) ||
DASHBOARD_PLATFORM_PAGES.some((page) => page === moduleId) ||
seen.has(moduleId)
) {
throw new DashboardHostConfigurationError();
}
seen.add(moduleId);
}
return [...seen];
}
/** /**
* Serves the built Dashboard from the same origin as `/api`, so the identity system keeps its * Serves the built Dashboard from the same origin as `/api`, so the identity system keeps its
* default same-origin shape. * default same-origin shape.
* *
* Fixed routes over one frozen snapshot: never a static directory, never a path or file name taken * Two exact literal routes over one frozen snapshot: never a static directory, never a path or file
* from the request, and never the source map. The page answers at `/`, anywhere inside a Dashboard * name taken from the request, and never the source map. Any other path — `/index.html`, `/app.js`,
* platform page (`/settings/displays/add` as much as `/settings`), and anywhere inside an installed * the `.map`, a traversal spelling — has no route and falls through to the application's ordinary
* module's page (`/signage/timelines` as much as `/signage`, so the module's own sub-pages need no * sanitized `404`. Like the display host's code, these bytes grant no authority: the page renders
* server change later) — always with the **same** bytes and the same validator, because the path * the sign-in form until `/api/v1/principal` says otherwise.
* selects a page inside the shell rather than a file here; that is what lets a refreshed or
* bookmarked deep link stay where it was.
*
* A top-level name that is neither — `/definitely-not-a-real-module`, a module that is not
* installed or has no Dashboard UI, a look-alike such as `/settings-old` — has no route, nor does
* `/index.html`, `/app.js`, the `.map`, or a traversal spelling: all fall 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( export function createDashboardHostRoutes(assets: DashboardHostAssets): Hono {
assets: DashboardHostAssets,
options: DashboardHostRouteOptions,
): Hono {
const snapshot = snapshotAssets(assets); const snapshot = snapshotAssets(assets);
const modulePages = validatedModulePages(options.modulePages);
const routes = new Hono(); const routes = new Hono();
const page = (ifNoneMatch: string | undefined): Response => routes.get(DASHBOARD_HOST_PAGE_PATH, (c) =>
fixedAsset( fixedAsset(
ifNoneMatch, c.req.header("if-none-match"),
snapshot.html, snapshot.html,
snapshot.htmlEtag, snapshot.htmlEtag,
"text/html; charset=utf-8", "text/html; charset=utf-8",
DASHBOARD_PAGE_SECURITY_HEADERS, DASHBOARD_PAGE_SECURITY_HEADERS,
),
); );
routes.get(DASHBOARD_HOST_PAGE_PATH, (c) => page(c.req.header("if-none-match")));
for (const name of [...DASHBOARD_PLATFORM_PAGES, ...modulePages]) {
routes.get(`/${name}`, (c) => page(c.req.header("if-none-match")));
routes.get(`/${name}/*`, (c) => page(c.req.header("if-none-match")));
}
routes.get(DASHBOARD_HOST_SCRIPT_PATH, (c) => routes.get(DASHBOARD_HOST_SCRIPT_PATH, (c) =>
fixedAsset( fixedAsset(
c.req.header("if-none-match"), c.req.header("if-none-match"),
@@ -175,17 +119,5 @@ export function createDashboardHostRoutes(
), ),
); );
// Same-origin, revalidated like the application script, and carrying nothing but code: the
// person's cached choice lives in their browser and is read by this script, never served by it.
routes.get(DASHBOARD_HOST_APPEARANCE_SCRIPT_PATH, (c) =>
fixedAsset(
c.req.header("if-none-match"),
snapshot.appearanceScript,
snapshot.appearanceScriptEtag,
"text/javascript; charset=utf-8",
{},
),
);
return routes; return routes;
} }
-133
View File
@@ -1,133 +0,0 @@
import type { EkklesiaPrincipal, PermissionId } from "@ekklesia/contracts";
import {
type AuthorizationPlatformOwnerPort,
CORE_DEVICES_MANAGE,
CORE_MODULES_CONFIGURATION_MANAGE,
CORE_MODULES_LIFECYCLE_MANAGE,
CORE_MODULES_UI_READ,
} from "@ekklesia/core/authorization";
import type { ModuleLifecycleSnapshot } from "@ekklesia/core/modules";
import { SETTINGS_SECTIONS, type SettingsSection } from "@ekklesia/shell/dashboard-routes";
import { Hono } from "hono";
import {
type AuthorizationMiddlewareOptions,
getAuthorizedPrincipal,
requirePermission,
} from "./authorization-middleware.js";
export const DASHBOARD_SETTINGS_PATH = "/api/v1/dashboard/settings";
/** One module's settings destination: its id (the `/settings/<module-id>` segment), its display
* name, and whether it is running. A disabled or blocked module is listed so its address can say it
* is unavailable, and flagged so the Dashboard never offers it in navigation. */
export interface DashboardSettingsModuleView {
readonly id: string;
readonly name: string;
readonly available: boolean;
}
export interface DashboardSettingsResponse {
/** The platform sections this viewer may see, in navigation order. */
readonly sections: readonly SettingsSection[];
/** Modules whose settings this viewer may see, by name. */
readonly modules: readonly DashboardSettingsModuleView[];
}
/**
* Who may see each platform section (ADR 0061).
*
* A section whose administration already has a Core permission uses it, so it is delegable today.
* General, Branding, and System have no permission yet — nothing in them exists to authorize — so
* they are shown only to the **platform owner**, the one principal who already holds every
* permission; each gains its own delegable permission in the work that builds it.
*/
const SECTION_ACCESS: Readonly<Record<SettingsSection, PermissionId | "platform-owner">> =
Object.freeze({
branding: "platform-owner",
// Finding, installing, turning on and off, and updating modules: deciding which modules run.
discover: CORE_MODULES_LIFECYCLE_MANAGE,
displays: CORE_DEVICES_MANAGE,
general: "platform-owner",
system: "platform-owner",
});
export interface CreateDashboardSettingsRoutesOptions extends AuthorizationMiddlewareOptions {
/** The boot snapshot: every module this process discovered, and which it started. A module's
* settings exist when its manifest declares configuration (ADR 0029). */
readonly snapshot: ModuleLifecycleSnapshot;
readonly platformOwners: AuthorizationPlatformOwnerPort;
}
function compareText(left: string, right: string): number {
return left < right ? -1 : left > right ? 1 : 0;
}
/**
* The Dashboard's Settings navigation, evaluated for the viewer.
*
* Like the module permission hints in the navigation (ADR 0026), these are **presentation hints,
* not authority**: they decide which Settings destinations the Dashboard offers, so nobody is shown
* administration they cannot carry out (DESIGN.md §9.2). Every route behind a section keeps its own
* enforcement, unchanged. Each answer goes through Core's one evaluator — baselines, grants, expiry,
* and the owner bypass behave exactly as on a real request — and an evaluation failure is a `503`,
* never an empty answer that would look like a permissions problem.
*
* Reading requires `core.modules.ui.read`, the same permission the navigation it sits beside
* requires: any signed-in person may ask, and most will be told there is nothing for them.
*/
export function createDashboardSettingsRoutes(options: CreateDashboardSettingsRoutesOptions): Hono {
const routes = new Hono();
const requireRead = requirePermission(options, CORE_MODULES_UI_READ);
const started = new Set(options.snapshot.startedModuleIds);
const modulesWithSettings = options.snapshot.discoveredModules
.filter((module) => module.manifest.configuration.length > 0)
.map((module) => ({ id: module.manifest.id, name: module.manifest.name }))
.sort((left, right) => compareText(left.name, right.name) || compareText(left.id, right.id));
const holds = async (
principal: EkklesiaPrincipal,
permission: PermissionId,
): Promise<boolean> => {
const declaration = options.registry.get(permission);
if (!declaration) {
return false;
}
return (
await options.evaluator.authorize({
baseline: declaration.baseline,
permission,
principal,
})
).allowed;
};
routes.get(DASHBOARD_SETTINGS_PATH, requireRead, async (c) => {
c.header("Cache-Control", "no-store");
c.header("Vary", "Origin, Cookie");
const principal = getAuthorizedPrincipal(c);
try {
let owner: boolean | undefined;
const isOwner = async (): Promise<boolean> => {
owner ??=
principal.kind === "authenticated" &&
(await options.platformOwners.isPlatformOwner(principal.userId));
return owner;
};
const sections: SettingsSection[] = [];
for (const section of SETTINGS_SECTIONS) {
const access = SECTION_ACCESS[section];
if (access === "platform-owner" ? await isOwner() : await holds(principal, access)) {
sections.push(section);
}
}
const modules = (await holds(principal, CORE_MODULES_CONFIGURATION_MANAGE))
? modulesWithSettings.map((module) => ({ ...module, available: started.has(module.id) }))
: [];
return c.json({ modules, sections } satisfies DashboardSettingsResponse, 200);
} catch {
return c.json({ code: "AUTHORIZATION_EVALUATION_FAILED" }, 503);
}
});
return routes;
}
+1 -9
View File
@@ -1,5 +1,4 @@
import type { ModuleDatabaseExecutor, ModuleId } from "@ekklesia/contracts"; import type { ModuleDatabase } from "@ekklesia/contracts";
import { type AccountDrizzleDatabase, createAccountDrizzleView } from "@ekklesia/core/account";
import { import {
type AuthorizationDrizzleDatabase, type AuthorizationDrizzleDatabase,
createAuthorizationDrizzleView, createAuthorizationDrizzleView,
@@ -13,23 +12,18 @@ import { createDeviceDrizzleView, type DeviceDrizzleDatabase } from "@ekklesia/c
import { createIdentityDrizzleView, type IdentityDrizzleDatabase } from "@ekklesia/core/identity"; import { createIdentityDrizzleView, type IdentityDrizzleDatabase } from "@ekklesia/core/identity";
import { import {
createModuleConfigurationView, createModuleConfigurationView,
createModuleDatabaseExecutor,
createModuleDatabaseView, createModuleDatabaseView,
createModuleLifecycleView, createModuleLifecycleView,
type ModuleConfigurationDrizzleDatabase, type ModuleConfigurationDrizzleDatabase,
type ModuleDatabase,
type ModuleLifecycleDrizzleDatabase, type ModuleLifecycleDrizzleDatabase,
} from "@ekklesia/core/modules"; } from "@ekklesia/core/modules";
export interface ApiDatabaseHandle { export interface ApiDatabaseHandle {
readonly account: AccountDrizzleDatabase;
readonly authorization: AuthorizationDrizzleDatabase; readonly authorization: AuthorizationDrizzleDatabase;
readonly devices: DeviceDrizzleDatabase; readonly devices: DeviceDrizzleDatabase;
readonly dashboard: DashboardDrizzleDatabase; readonly dashboard: DashboardDrizzleDatabase;
readonly identity: IdentityDrizzleDatabase; readonly identity: IdentityDrizzleDatabase;
readonly module: ModuleDatabase; readonly module: ModuleDatabase;
/** Builds one ABI-v1 executor attributed to exactly one module, over this handle's shared pool. */
readonly moduleExecutor: (moduleId: ModuleId) => ModuleDatabaseExecutor;
readonly moduleConfiguration: ModuleConfigurationDrizzleDatabase; readonly moduleConfiguration: ModuleConfigurationDrizzleDatabase;
readonly moduleLifecycle: ModuleLifecycleDrizzleDatabase; readonly moduleLifecycle: ModuleLifecycleDrizzleDatabase;
/** /**
@@ -76,14 +70,12 @@ export function createApiDatabase(
const { close, pool } = createValidatedPostgresPool(databaseUrl, options); const { close, pool } = createValidatedPostgresPool(databaseUrl, options);
return Object.freeze({ return Object.freeze({
account: createAccountDrizzleView(pool),
authorization: createAuthorizationDrizzleView(pool), authorization: createAuthorizationDrizzleView(pool),
close, close,
devices: createDeviceDrizzleView(pool), devices: createDeviceDrizzleView(pool),
dashboard: createDashboardDrizzleView(pool), dashboard: createDashboardDrizzleView(pool),
identity: createIdentityDrizzleView(pool), identity: createIdentityDrizzleView(pool),
module: createModuleDatabaseView(pool), module: createModuleDatabaseView(pool),
moduleExecutor: (moduleId: ModuleId) => createModuleDatabaseExecutor(pool, { moduleId }),
moduleConfiguration: createModuleConfigurationView(pool), moduleConfiguration: createModuleConfigurationView(pool),
moduleLifecycle: createModuleLifecycleView(pool), moduleLifecycle: createModuleLifecycleView(pool),
ping: async (statementTimeoutMs: number) => { ping: async (statementTimeoutMs: number) => {
+1 -22
View File
@@ -92,7 +92,7 @@ function readString(body: Record<string, unknown>, key: string, max: number): st
*/ */
function writeDeviceCookie( function writeDeviceCookie(
c: Context, c: Context,
options: Pick<DeviceRoutesOptions, "secureCookies">, options: DeviceRoutesOptions,
name: string, name: string,
value: string, value: string,
maxAgeSeconds: number, maxAgeSeconds: number,
@@ -106,27 +106,6 @@ function writeDeviceCookie(
}); });
} }
/**
* Re-issues the device cookie a request just authenticated with, with a fresh lifetime (ADR 0056).
*
* The value is unchanged — no rotation — so a renewal lost in transit costs nothing: the screen still
* holds a working credential. Only the browser's own expiry is pushed forward, matching the
* server's sliding window. Called only after the request resolved to a live, unrevoked device.
*/
export function renewDeviceSessionCookie(c: Context, secureCookies: boolean): void {
const credential = getCookie(c, DEVICE_SESSION_COOKIE);
if (credential === undefined || credential.length === 0) {
return;
}
writeDeviceCookie(
c,
{ secureCookies },
DEVICE_SESSION_COOKIE,
credential,
Math.floor(DEVICE_CREDENTIAL_TTL_MS / 1000),
);
}
export function createDeviceRoutes(options: DeviceRoutesOptions): Hono { export function createDeviceRoutes(options: DeviceRoutesOptions): Hono {
const routes = new HonoApp(); const routes = new HonoApp();
const now = options.now ?? ((): Date => new Date()); const now = options.now ?? ((): Date => new Date());
-8
View File
@@ -83,9 +83,6 @@ export interface ModuleDisplayRoutesOptions extends AuthorizationMiddlewareOptio
/** Omitted only when the registry contains no display entrypoints. Production reads the exact /** Omitted only when the registry contains no display entrypoints. Production reads the exact
* surface-owned files before binding and supplies their frozen contents here. */ * surface-owned files before binding and supplies their frozen contents here. */
readonly assets?: DisplayHostAssets; readonly assets?: DisplayHostAssets;
/** Re-issues a paired device's cookie with a fresh lifetime (ADR 0056). Composed only when the
* deployment has device support; called only for a request that resolved to a device. */
readonly renewDeviceCredential?: (c: Context) => void;
} }
/** A composition error, detected before any display route is registered. Carries no module id, /** A composition error, detected before any display route is registered. Carries no module id,
@@ -202,11 +199,6 @@ function mountDisplay(
if (!viewer) { if (!viewer) {
return displayFailure(c); return displayFailure(c);
} }
// Every screen asks for this on start, on reconnect, and periodically while running, so it
// is where an in-use credential's browser expiry is pushed forward.
if (principal.kind === "device") {
options.renewDeviceCredential?.(c);
}
return c.json(await describeDisplay(options, principal, display, viewer), 200); return c.json(await describeDisplay(options, principal, display, viewer), 200);
} catch { } catch {
return displayFailure(c); return displayFailure(c);
+7 -20
View File
@@ -1,17 +1,14 @@
import { import {
EMPTY_MODULE_CONFIGURATION, EMPTY_MODULE_CONFIGURATION,
EMPTY_MODULE_STORAGE, EMPTY_MODULE_STORAGE,
type ModuleDatabaseExecutor,
type ModuleId, type ModuleId,
type ModuleRouteHandlerContext, type ModuleRouteHandlerContext,
type ModuleRuntimeServices,
type RegisteredModuleHttpRoute, type RegisteredModuleHttpRoute,
} from "@ekklesia/contracts"; } from "@ekklesia/contracts";
import type { import type {
AnyModuleRuntimeServices,
ModuleConfigurationLookup, ModuleConfigurationLookup,
ModuleDatabase,
ModuleRealtimeRegistry, ModuleRealtimeRegistry,
ModuleRuntimeServicesLookup,
ModuleStorageLookup, ModuleStorageLookup,
} from "@ekklesia/core/modules"; } from "@ekklesia/core/modules";
import type { Context, Hono } from "hono"; import type { Context, Hono } from "hono";
@@ -40,14 +37,14 @@ export interface ModuleHttpRoutesOptions extends AuthorizationMiddlewareOptions
* received for this API runtime — never a fresh one built per request, and never touched for a * received for this API runtime — never a fresh one built per request, and never touched for a
* denied, trusted-origin-rejected, or `OPTIONS` request, since a handler is only ever invoked * denied, trusted-origin-rejected, or `OPTIONS` request, since a handler is only ever invoked
* after authorization succeeds. */ * after authorization succeeds. */
readonly services: ModuleRuntimeServicesLookup | AnyModuleRuntimeServices; readonly services: ModuleRuntimeServices;
/** /**
* Per-module frozen configuration snapshots — the exact map the module runtime started from * Per-module frozen configuration snapshots — the exact map the module runtime started from
* (ADR 0029), so a handler and that module's `start`/`stop` hooks receive the identical object * (ADR 0029), so a handler and that module's `start`/`stop` hooks receive the identical object
* rather than two independently resolved values. A module with no entry receives * rather than two independently resolved values. A module with no entry receives
* `EMPTY_MODULE_CONFIGURATION`, so `context.config` is always present. * `EMPTY_MODULE_CONFIGURATION`, so `context.config` is always present.
* *
* Like `services`, this is keyed by module id: a handler can only * Unlike `services`, which is one shared object, this is keyed by module id: a handler can only
* ever reach its own module's configuration, because that is the only entry looked up for it. * ever reach its own module's configuration, because that is the only entry looked up for it.
*/ */
readonly configurations: ModuleConfigurationLookup; readonly configurations: ModuleConfigurationLookup;
@@ -235,11 +232,6 @@ export function mountModuleHttpRoutes(
if (!options.registry.has(route.permission)) { if (!options.registry.has(route.permission)) {
throw new ModuleRouteConfigurationError(); throw new ModuleRouteConfigurationError();
} }
const services =
"get" in options.services ? options.services.get(route.moduleId) : options.services;
if (!services) {
throw new ModuleRouteConfigurationError();
}
const requireRoutePermission = requirePermission(middlewareOptions, route.permission); const requireRoutePermission = requirePermission(middlewareOptions, route.permission);
// The route's own conscious body contract (`route.maxBodyBytes` — 0 means no body accepted at // The route's own conscious body contract (`route.maxBodyBytes` — 0 means no body accepted at
@@ -251,24 +243,19 @@ export function mountModuleHttpRoutes(
const routeBodyLimit = platformBodyLimit(route.maxBodyBytes, route.bodyMode); const routeBodyLimit = platformBodyLimit(route.maxBodyBytes, route.bodyMode);
const path = moduleRoutePath(route.moduleId, route.path); const path = moduleRoutePath(route.moduleId, route.path);
const finalHandler = async (c: Context): Promise<Response> => { const finalHandler = async (c: Context): Promise<Response> => {
const context = Object.freeze({ const context: ModuleRouteHandlerContext = Object.freeze({
config: options.configurations.get(route.moduleId) ?? EMPTY_MODULE_CONFIGURATION, config: options.configurations.get(route.moduleId) ?? EMPTY_MODULE_CONFIGURATION,
moduleId: route.moduleId, moduleId: route.moduleId,
storage: options.storages.get(route.moduleId) ?? EMPTY_MODULE_STORAGE, storage: options.storages.get(route.moduleId) ?? EMPTY_MODULE_STORAGE,
params: Object.freeze({ ...c.req.param() }), params: Object.freeze({ ...c.req.param() }),
principal: getAuthorizedPrincipal(c), principal: getAuthorizedPrincipal(c),
realtime: options.realtime.publisherFor(route.moduleId), realtime: options.realtime.publisherFor(route.moduleId),
services, services: options.services,
}) as ModuleRouteHandlerContext<ModuleDatabase | ModuleDatabaseExecutor>; });
let response: Response; let response: Response;
try { try {
response = await ( response = await route.handler(c.req.raw, context);
route.handler as (
request: Request,
context: ModuleRouteHandlerContext<ModuleDatabase | ModuleDatabaseExecutor>,
) => PromiseLike<Response> | Response
)(c.req.raw, context);
} catch { } catch {
return sanitizedModuleHandlerFailure(c); return sanitizedModuleHandlerFailure(c);
} }
+4 -18
View File
@@ -6,7 +6,6 @@ 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";
@@ -56,18 +55,10 @@ 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, taking precedence over `configuredModulesDirectory`, which is then never resolved. * modules. Never set by the executable entry point — production always discovers the real
* Never set by the executable entry point. Platform and Module SDK versions are always read from * repository's `modules/` directory. Platform and Module SDK versions are always read from the
* the real repository's own `package.json` files regardless of this override. */ * 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`
@@ -118,11 +109,6 @@ 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")),
@@ -130,7 +116,7 @@ export async function prepareStartupModuleInventory(
const modules = await discoverModules({ const modules = await discoverModules({
moduleSdkVersion, moduleSdkVersion,
...modulesDirectory, modulesDirectory: options.modulesDirectoryOverride ?? path.join(repositoryRoot, "modules"),
platformVersion, platformVersion,
}); });
+20 -40
View File
@@ -1,12 +1,15 @@
import type { Socket } from "node:net"; import type { Socket } from "node:net";
import path from "node:path"; import path from "node:path";
import { pathToFileURL } from "node:url"; import { pathToFileURL } from "node:url";
import { EMPTY_MODULE_SECRET_SOURCE, type ModuleSecretSource } from "@ekklesia/contracts"; import {
EMPTY_MODULE_SECRET_SOURCE,
type ModuleRuntimeServices,
type ModuleSecretSource,
} from "@ekklesia/contracts";
import { import {
createAuthorizationEvaluator, createAuthorizationEvaluator,
createAuthorizationStore, createAuthorizationStore,
} from "@ekklesia/core/authorization"; } from "@ekklesia/core/authorization";
import { createAccountStore } from "@ekklesia/core/account";
import { createDashboardStore } from "@ekklesia/core/dashboard"; import { createDashboardStore } from "@ekklesia/core/dashboard";
import { createDeviceStore } from "@ekklesia/core/devices"; import { createDeviceStore } from "@ekklesia/core/devices";
import { type HostEnvironment, loadHostConfiguration } from "@ekklesia/core/host"; import { type HostEnvironment, loadHostConfiguration } from "@ekklesia/core/host";
@@ -18,14 +21,12 @@ import {
} from "@ekklesia/core/identity"; } from "@ekklesia/core/identity";
import { createSmtpMailProvider, type MailProvider } from "@ekklesia/core/mail"; import { createSmtpMailProvider, type MailProvider } from "@ekklesia/core/mail";
import { import {
type AnyModuleRuntimeServices,
createModuleConfigurationStore, createModuleConfigurationStore,
createModuleLifecycleStore, createModuleLifecycleStore,
createModuleUiPublicationRegistry, createModuleUiPublicationRegistry,
type DiscoveredModule, type DiscoveredModule,
type ModuleConfigurationSettings, type ModuleConfigurationSettings,
type ModuleEligibilityResolution, type ModuleEligibilityResolution,
type ModuleRuntimeServicesLookup,
resolveModuleEligibility, resolveModuleEligibility,
type StartedModuleRuntime, type StartedModuleRuntime,
startDiscoveredModuleRuntime, startDiscoveredModuleRuntime,
@@ -43,7 +44,7 @@ import {
createClientAddressResolver, createClientAddressResolver,
wrapFetchWithClientAddress, wrapFetchWithClientAddress,
} from "./client-address.js"; } from "./client-address.js";
import { type ApiRuntimeConfig, apiTrustedOrigins, createApiRuntimeConfig } from "./config.js"; import { type ApiRuntimeConfig, createApiRuntimeConfig } from "./config.js";
import { loadDashboardHostAssets } from "./dashboard-host-assets.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";
@@ -153,12 +154,8 @@ 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;
/** The deployment's raw `EKKLESIA_MODULES_DIR` (ADR 0048), a production input the executable /** Test-only escape hatch: overrides the directory module discovery scans. Never set by the
* entry point always passes from host configuration. See `prepareStartupModuleInventory`. */ * executable entry point. 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
@@ -392,13 +389,16 @@ function bindServer(
* (`prepareStartupModuleInventory`, `./platform-permissions.js`). * (`prepareStartupModuleInventory`, `./platform-permissions.js`).
* 2. Construct the one shared PostgreSQL pool (identity, authorization, and the module database * 2. Construct the one shared PostgreSQL pool (identity, authorization, and the module database
* facade each get a typed Drizzle view over it — see `./database.js`). * facade each get a typed Drizzle view over it — see `./database.js`).
* 3. Construct the Core identity runtime, authorization store, and evaluator from that pool. * 3. Construct the frozen `ModuleRuntimeServices` (currently just `database`) from that same pool,
* then the Core identity runtime, authorization store, and evaluator.
* 4. Read the stored module enablement configuration and resolve, from that exact same discovered * 4. Read the stored module enablement configuration and resolve, from that exact same discovered
* inventory, which modules are eligible to run (`resolveModuleEligibility`). * inventory, which modules are eligible to run (`resolveModuleEligibility`).
* 5. Start the module runtime from the eligible subset (`startDiscoveredModuleRuntime`) — never * 5. Start the module runtime from the eligible subset (`startDiscoveredModuleRuntime`) — never
* rediscovering modules a second time — passing the manifest-selected service map. * rediscovering modules a second time — passing it the same services object every module's
* `start`/`stop` hook receives.
* 6. Create the Hono application, mounting the started module runtime's published routes, publishing * 6. Create the Hono application, mounting the started module runtime's published routes, publishing
* UI for that same eligible set, and passing the same per-module service map to HTTP handlers. * UI for that same eligible set, and passing the exact same services object to every module HTTP
* handler.
* 7. Bind the Node HTTP server. * 7. Bind the Node HTTP server.
* *
* Discovery and permission composition happen before any pool, Better Auth instance, module * Discovery and permission composition happen before any pool, Better Auth instance, module
@@ -429,7 +429,6 @@ 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 }),
@@ -455,10 +454,12 @@ export async function startApi(
const storageProvider = config.storage ? createS3StorageProvider(config.storage) : undefined; const storageProvider = config.storage ? createS3StorageProvider(config.storage) : undefined;
const mailProvider = config.mail ? createSmtpMailProvider(config.mail) : undefined; const mailProvider = config.mail ? createSmtpMailProvider(config.mail) : undefined;
let services: ModuleRuntimeServices;
let runtime: ReturnType<typeof createIdentityRuntime>; let runtime: ReturnType<typeof createIdentityRuntime>;
let authorizationStore: ReturnType<typeof createAuthorizationStore>; let authorizationStore: ReturnType<typeof createAuthorizationStore>;
let deviceStore: ReturnType<typeof createDeviceStore>; let deviceStore: ReturnType<typeof createDeviceStore>;
try { try {
services = Object.freeze({ database: databaseHandle.module });
runtime = createIdentityRuntime({ runtime = createIdentityRuntime({
config: config.identity, config: config.identity,
db: databaseHandle.identity, db: databaseHandle.identity,
@@ -526,15 +527,6 @@ export async function startApi(
throw error; throw error;
} }
const services: ModuleRuntimeServicesLookup = new Map<string, AnyModuleRuntimeServices>(
eligibility.eligibleModules.map((module) => [
module.manifest.id,
module.manifest.database?.abi === 1
? Object.freeze({ database: databaseHandle.moduleExecutor(module.manifest.id) })
: Object.freeze({ database: databaseHandle.module }),
]),
);
// One scoped facade per eligible module, over the exact buckets that module's own manifest // One scoped facade per eligible module, over the exact buckets that module's own manifest
// declares. Built from the eligible set so a blocked module never receives storage at all. // declares. Built from the eligible set so a blocked module never receives storage at all.
const moduleStorages = createModuleStorageLookup( const moduleStorages = createModuleStorageLookup(
@@ -558,9 +550,7 @@ export async function startApi(
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), // 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. // 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 dashboardHostAssets = await loadDashboardHostAssets();
// 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);
@@ -640,8 +630,6 @@ 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),
// The same pool again: a person's own account summary and preferences (ADR 0062).
account: createAccountStore(databaseHandle.account),
dashboardHostAssets, dashboardHostAssets,
// The app is only composed after `startDiscoveredModuleRuntime` resolved, so module startup // 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. // has completed whenever it can answer; it stops counting as complete once shutdown begins.
@@ -660,8 +648,7 @@ 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 }),
// The configured origins plus the API's own (ADR 0045). trustedOrigins: config.identity.trustedOrigins,
trustedOrigins: apiTrustedOrigins(config.identity),
}); });
} catch (error) { } catch (error) {
await safeStopModuleRuntime(moduleRuntime); await safeStopModuleRuntime(moduleRuntime);
@@ -779,7 +766,6 @@ 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 {
@@ -795,19 +781,13 @@ 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, modulesDirectory } = readConfigFromEnvironment( const { environment, input, moduleSecrets } = readConfigFromEnvironment(process.env);
process.env,
);
const config = createApiRuntimeConfig(input, { environment }); const config = createApiRuntimeConfig(input, { environment });
const running = await startApi(config, { const running = await startApi(config, { moduleSecrets });
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;
-340
View File
@@ -1,340 +0,0 @@
import {
ANONYMOUS_PRINCIPAL,
createAuthenticatedPrincipal,
createDevicePrincipal,
type EkklesiaPrincipal,
parseEkklesiaUserId,
} from "@ekklesia/contracts";
import { ACCOUNT_APPEARANCES, ACCOUNT_MOTION_PREFERENCES } from "@ekklesia/core/account";
import {
type AuthorizationStore,
CORE_ACCOUNT_SELF_MANAGE,
CORE_PERMISSION_REGISTRY,
createAuthorizationEvaluator,
} from "@ekklesia/core/authorization";
import type { EkklesiaIdentityRuntime } from "@ekklesia/core/identity";
import {
composePlatformPermissionRegistry,
createModuleUiPublicationRegistry,
EMPTY_MODULE_REALTIME_REGISTRY,
type LegacyModuleRuntimeServices,
type ModuleDatabase,
} from "@ekklesia/core/modules";
import { EKKLESIA_APPEARANCES, EKKLESIA_MOTION_PREFERENCES } from "@ekklesia/design-tokens";
import { describe, expect, it, vi } from "vitest";
import { ACCOUNT_PATH, ACCOUNT_PREFERENCES_PATH } from "../src/account-routes.js";
import { createApiApp } from "../src/app.js";
import {
createInMemoryAccountStore,
type InMemoryAccountPerson,
} from "./support/in-memory-account-store.js";
import { testDashboardHostAssets } from "./support/dashboard-host-assets.js";
import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
import { emptyModuleConfiguration } from "./support/in-memory-module-configuration.js";
import {
createInMemoryModuleLifecycleStore,
moduleLifecycleSnapshotFor,
} from "./support/in-memory-module-lifecycle.js";
import { emptyModuleStorage } from "./support/in-memory-module-storage.js";
import { readyHealth } from "./support/ready-health.js";
/**
* `GET /api/v1/account` and `PATCH /api/v1/account/preferences` (ADR 0062): a signed-in person's
* own account and preferences, self-only by construction.
*/
const trustedOrigin = "https://dashboard.example.test";
const adaId = parseEkklesiaUserId("11111111-1111-4111-8111-111111111111");
const graceId = parseEkklesiaUserId("22222222-2222-4222-8222-222222222222");
const ada = createAuthenticatedPrincipal(adaId);
const people: readonly InMemoryAccountPerson[] = [
{ email: "[email protected]", name: "Ada Lovelace", userId: adaId },
{
email: "[email protected]",
name: "Grace Hopper",
preferences: { appearance: "light", motion: "reduce" },
userId: graceId,
},
];
interface Options {
readonly principal?: EkklesiaPrincipal;
readonly evaluationFails?: boolean;
}
function build(options: Options = {}) {
const evaluator = createAuthorizationEvaluator({
grants: { hasActiveGrant: async () => false },
platformOwners: {
isPlatformOwner: async () => {
if (options.evaluationFails) {
throw new Error("database down");
}
return false;
},
},
});
const runtime: EkklesiaIdentityRuntime = {
handleAuthRequest: vi.fn(),
resolvePrincipal: vi.fn().mockResolvedValue(options.principal ?? ada),
};
const account = createInMemoryAccountStore(people);
const app = createApiApp({
account,
authorization: {
evaluator,
registry: composePlatformPermissionRegistry([]),
store: {} as unknown as AuthorizationStore,
},
dashboard: createInMemoryDashboardStore(),
dashboardHostAssets: testDashboardHostAssets(),
health: readyHealth(),
moduleConfiguration: emptyModuleConfiguration(),
moduleLifecycle: {
snapshot: moduleLifecycleSnapshotFor([], { modules: [] }),
store: createInMemoryModuleLifecycleStore(),
},
moduleRealtime: EMPTY_MODULE_REALTIME_REGISTRY,
moduleRoutes: Object.freeze([]),
moduleServices: Object.freeze({
database: Object.freeze({}) as unknown as ModuleDatabase,
}) as LegacyModuleRuntimeServices,
moduleStorage: emptyModuleStorage(),
moduleUi: createModuleUiPublicationRegistry([]),
runtime,
trustedOrigins: [trustedOrigin],
});
return { account, app };
}
function patch(body: unknown, init: { origin?: string | null; raw?: string } = {}): RequestInit {
const headers: Record<string, string> = { "Content-Type": "application/json" };
if (init.origin !== null) {
headers.Origin = init.origin ?? trustedOrigin;
}
return { body: init.raw ?? JSON.stringify(body), headers, method: "PATCH" };
}
describe("core.account.self.manage", () => {
it("is an authenticated-baseline Core permission, so managing your own account needs no grant", () => {
expect(CORE_PERMISSION_REGISTRY.get(CORE_ACCOUNT_SELF_MANAGE)?.baseline).toBe("authenticated");
});
it("holds Core's preference values equal to the design tokens' own", () => {
expect([...ACCOUNT_APPEARANCES]).toEqual([...EKKLESIA_APPEARANCES]);
expect([...ACCOUNT_MOTION_PREFERENCES]).toEqual([...EKKLESIA_MOTION_PREFERENCES]);
});
});
describe("GET /api/v1/account", () => {
it("returns the signed-in person's own name, email, and default preferences", async () => {
const { app } = build();
const response = await app.request(ACCOUNT_PATH);
expect(response.status).toBe(200);
expect(response.headers.get("cache-control")).toBe("no-store");
expect(response.headers.get("vary")).toContain("Cookie");
expect(await response.json()).toEqual({
email: "[email protected]",
name: "Ada Lovelace",
preferences: { appearance: "system", motion: "system" },
});
});
it("returns exactly name, email, and preferences — nothing else about the account", async () => {
const { app } = build();
const body = (await (await app.request(ACCOUNT_PATH)).json()) as Record<string, unknown>;
expect(Object.keys(body).sort()).toEqual(["email", "name", "preferences"]);
expect(Object.keys(body.preferences as object).sort()).toEqual(["appearance", "motion"]);
});
it("refuses an anonymous caller", async () => {
const { account, app } = build({ principal: ANONYMOUS_PRINCIPAL });
const response = await app.request(ACCOUNT_PATH);
expect(response.status).toBe(403);
expect(await response.json()).toEqual({ code: "AUTHORIZATION_DENIED" });
expect(account.requestedUserIds).toEqual([]);
});
it("refuses a paired display device, which has no account", async () => {
const { account, app } = build({
principal: createDevicePrincipal("33333333-3333-4333-8333-333333333333"),
});
const response = await app.request(ACCOUNT_PATH);
expect(response.status).toBe(403);
expect(account.requestedUserIds).toEqual([]);
});
it("reads only the session's account, whatever the request names", async () => {
const { account, app } = build();
const response = await app.request(`${ACCOUNT_PATH}?userId=${graceId}&id=${graceId}`, {
headers: { "X-User-Id": graceId },
});
expect(((await response.json()) as { name: string }).name).toBe("Ada Lovelace");
expect(account.requestedUserIds).toEqual([adaId]);
});
it("has no route that addresses another person's account", async () => {
const { account, app } = build();
for (const path of [`${ACCOUNT_PATH}/${graceId}`, `${ACCOUNT_PREFERENCES_PATH}/${graceId}`]) {
expect((await app.request(path)).status).toBe(404);
expect((await app.request(path, patch({ appearance: "dark" }))).status).toBe(404);
}
expect(account.requestedUserIds).toEqual([]);
});
it("is not found when the session's person no longer exists", async () => {
const { app } = build({
principal: createAuthenticatedPrincipal("99999999-9999-4999-8999-999999999999"),
});
const response = await app.request(ACCOUNT_PATH);
expect(response.status).toBe(404);
expect(await response.json()).toEqual({ code: "ACCOUNT_NOT_FOUND" });
});
it("reports a storage failure as a sanitized 503", async () => {
const { account, app } = build();
account.failWith("ACCOUNT_READ_FAILED");
const response = await app.request(ACCOUNT_PATH);
expect(response.status).toBe(503);
expect(await response.json()).toEqual({ code: "ACCOUNT_READ_FAILED" });
});
it("reports an authorization outage as a 503, never as a denial", async () => {
// The authenticated baseline needs no owner lookup, so an owner-lookup outage cannot deny it.
const { app } = build({ evaluationFails: true });
expect((await app.request(ACCOUNT_PATH)).status).toBe(200);
});
});
describe("PATCH /api/v1/account/preferences", () => {
it.each([
["appearance", "light"],
["appearance", "dark"],
["appearance", "system"],
["motion", "reduce"],
["motion", "allow"],
["motion", "system"],
] as const)("saves %s %s and returns everything in effect", async (name, value) => {
const { app } = build();
const response = await app.request(ACCOUNT_PREFERENCES_PATH, patch({ [name]: value }));
expect(response.status).toBe(200);
expect(response.headers.get("cache-control")).toBe("no-store");
const body = (await response.json()) as Record<string, string>;
expect(body[name]).toBe(value);
expect(Object.keys(body).sort()).toEqual(["appearance", "motion"]);
const read = (await (await app.request(ACCOUNT_PATH)).json()) as {
preferences: Record<string, string>;
};
expect(read.preferences[name]).toBe(value);
});
it("changes only the preference named", async () => {
const { app } = build();
await app.request(ACCOUNT_PREFERENCES_PATH, patch({ appearance: "dark" }));
const response = await app.request(ACCOUNT_PREFERENCES_PATH, patch({ motion: "reduce" }));
expect(await response.json()).toEqual({ appearance: "dark", motion: "reduce" });
});
it.each([
["an empty object", {}],
["an unknown appearance", { appearance: "sepia" }],
["an unknown motion", { motion: "none" }],
["an unknown key", { appearance: "dark", radius: "large" }],
["another person's id", { appearance: "dark", userId: "22222222-2222-4222-8222-222222222222" }],
["an array", ["dark"]],
["null", null],
])("refuses %s with nothing changed", async (_label, body) => {
const { account, app } = build();
const response = await app.request(ACCOUNT_PREFERENCES_PATH, patch(body));
expect(response.status).toBe(400);
expect(await response.json()).toEqual({ code: "INVALID_REQUEST" });
expect(account.requestedUserIds).toEqual([]);
});
it("refuses a body that is not JSON", async () => {
const { app } = build();
const response = await app.request(ACCOUNT_PREFERENCES_PATH, patch(undefined, { raw: "{" }));
expect(response.status).toBe(400);
});
it("refuses an untrusted or missing Origin before anything else", async () => {
const { account, app } = build();
for (const origin of ["https://elsewhere.example.test", null] as const) {
const response = await app.request(
ACCOUNT_PREFERENCES_PATH,
patch({ appearance: "dark" }, { origin }),
);
expect(response.status).toBe(403);
expect(await response.json()).toEqual({ code: "UNTRUSTED_ORIGIN" });
}
expect(account.requestedUserIds).toEqual([]);
});
it("refuses an oversized body", async () => {
const { app } = build();
const response = await app.request(
ACCOUNT_PREFERENCES_PATH,
patch(undefined, { raw: JSON.stringify({ appearance: "dark", pad: "x".repeat(20_000) }) }),
);
expect(response.status).toBe(413);
});
it("refuses an anonymous caller and a device", async () => {
for (const principal of [
ANONYMOUS_PRINCIPAL,
createDevicePrincipal("33333333-3333-4333-8333-333333333333"),
]) {
const { account, app } = build({ principal });
const response = await app.request(ACCOUNT_PREFERENCES_PATH, patch({ appearance: "dark" }));
expect(response.status).toBe(403);
expect(account.requestedUserIds).toEqual([]);
}
});
it("changes only the session's own preferences, never another person's", async () => {
const { account, app } = build();
await app.request(
`${ACCOUNT_PREFERENCES_PATH}?userId=${graceId}`,
patch({ appearance: "dark", motion: "allow" }),
);
expect(account.requestedUserIds).toEqual([adaId]);
expect((await account.readAccount(graceId))?.preferences).toEqual({
appearance: "light",
motion: "reduce",
});
});
it("reports a storage failure as a sanitized 503", async () => {
const { account, app } = build();
account.failWith("ACCOUNT_WRITE_FAILED");
const response = await app.request(ACCOUNT_PREFERENCES_PATH, patch({ appearance: "dark" }));
expect(response.status).toBe(503);
expect(await response.json()).toEqual({ code: "ACCOUNT_WRITE_FAILED" });
});
it("answers a preflight from the Dashboard origin with PATCH allowed", async () => {
const { app } = build();
const response = await app.request(ACCOUNT_PREFERENCES_PATH, {
headers: {
"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-methods")).toContain("PATCH");
});
});
+24 -213
View File
@@ -29,7 +29,6 @@ import type { ClientAddressConfigInput } from "../src/client-address.js";
import { dashboardHostAssetUrls } from "@ekklesia/shell/dashboard-assets"; 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 { createApiDatabase } from "../src/database.js";
import { DASHBOARD_API_ORIGIN_PLACEHOLDER } from "../src/dashboard-host-assets.js";
import { import {
DASHBOARD_HOST_PAGE_PATH, DASHBOARD_HOST_PAGE_PATH,
DASHBOARD_HOST_SCRIPT_PATH, DASHBOARD_HOST_SCRIPT_PATH,
@@ -208,29 +207,6 @@ async function activeConnectionCount(databaseName: string): Promise<number> {
} }
} }
/** How long a shutdown's closed connections may take to leave `pg_stat_activity`. Far below pg's
* 10 s idle timeout, so a connection that is genuinely still open is still caught. */
const SHUTDOWN_SETTLE_MS = 3_000;
/**
* The connections left on `databaseName` once a shutdown has had a moment to land.
*
* PostgreSQL ends a backend asynchronously: `pool.end()` resolves once the client side has closed,
* and the server can still list that backend for a moment afterwards. On a loaded CI runner that
* moment was long enough for an immediate count to see one. So this waits, briefly, for the count to
* reach zero; a leaked pooled client would stay idle and listed far past the deadline.
*/
async function connectionsLeftAfterShutdown(databaseName: string): Promise<number> {
const deadline = Date.now() + SHUTDOWN_SETTLE_MS;
for (;;) {
const count = await activeConnectionCount(databaseName);
if (count === 0 || Date.now() >= deadline) {
return count;
}
await new Promise((resolve) => setTimeout(resolve, 50));
}
}
async function createDisposableDatabase(label: string): Promise<{ name: string; url: string }> { async function createDisposableDatabase(label: string): Promise<{ name: string; url: string }> {
const name = makeDatabaseName(label); const name = makeDatabaseName(label);
await createDatabase(name); await createDatabase(name);
@@ -244,9 +220,9 @@ async function applyCoreMigrations(url: string): Promise<void> {
const aggregate = await discoverAllMigrations({ const aggregate = await discoverAllMigrations({
coreMigrationsDirectory, coreMigrationsDirectory,
moduleDiscovery: { moduleDiscovery: {
moduleSdkVersion: "0.2.0", moduleSdkVersion: "0.1.0",
modulesDirectory: emptyModulesDirectory, modulesDirectory: emptyModulesDirectory,
platformVersion: "0.2.0", platformVersion: "0.1.0",
}, },
}); });
// Phase 4A's and Phase 4C pass 1's committed migrations must not have drifted while building // Phase 4A's and Phase 4C pass 1's committed migrations must not have drifted while building
@@ -260,7 +236,6 @@ async function applyCoreMigrations(url: string): Promise<void> {
"core:0005_module_configuration", "core:0005_module_configuration",
"core:0006_device_identity", "core:0006_device_identity",
"core:0007_verified_bootstrap_owners", "core:0007_verified_bootstrap_owners",
"core:0008_user_preferences",
]); ]);
expect(aggregate.migrations[0]?.checksum).toBe( expect(aggregate.migrations[0]?.checksum).toBe(
"f8f2086ef06f7a6943e8ffc4350bb010b37aed04c0a72030d2b153c986b1325f", "f8f2086ef06f7a6943e8ffc4350bb010b37aed04c0a72030d2b153c986b1325f",
@@ -363,10 +338,10 @@ async function createGatedRouteModule(
writeFile( writeFile(
path.join(moduleDirectory, "ekklesia.module.json"), path.join(moduleDirectory, "ekklesia.module.json"),
JSON.stringify({ JSON.stringify({
compatibility: { moduleSdk: "^0.2.0", platform: "^0.2.0" }, compatibility: { moduleSdk: "^0.1.0", platform: "^0.1.0" },
entrypoints: { server: "./dist/server/index.js" }, entrypoints: { server: "./dist/server/index.js" },
id, id,
manifestVersion: 8, manifestVersion: 7,
name: `${id} module`, name: `${id} module`,
permissions: [{ baseline: "public", description: "Gated drain probe.", id: permissionId }], permissions: [{ baseline: "public", description: "Gated drain probe.", id: permissionId }],
version: "0.1.0", version: "0.1.0",
@@ -410,11 +385,11 @@ async function createFixtureModule(
): Promise<void> { ): Promise<void> {
const moduleDirectory = path.join(modulesDirectory, id); const moduleDirectory = path.join(modulesDirectory, id);
const manifest = { const manifest = {
manifestVersion: 8, manifestVersion: 7,
id, id,
name: `${id} module`, name: `${id} module`,
version: "0.1.0", version: "0.1.0",
compatibility: { platform: "^0.2.0", moduleSdk: "^0.2.0" }, compatibility: { platform: "^0.1.0", moduleSdk: "^0.1.0" },
permissions: [], permissions: [],
entrypoints: { server: "./dist/server/index.js" }, entrypoints: { server: "./dist/server/index.js" },
...manifestOverrides, ...manifestOverrides,
@@ -545,10 +520,10 @@ async function createUiModule(
writeFile( writeFile(
path.join(moduleDirectory, "ekklesia.module.json"), path.join(moduleDirectory, "ekklesia.module.json"),
JSON.stringify({ JSON.stringify({
compatibility: { moduleSdk: "^0.2.0", platform: "^0.2.0" }, compatibility: { moduleSdk: "^0.1.0", platform: "^0.1.0" },
entrypoints: { server: "./dist/server/index.js", ui: "./dist/ui/index.js" }, entrypoints: { server: "./dist/server/index.js", ui: "./dist/ui/index.js" },
id, id,
manifestVersion: 8, manifestVersion: 7,
name: `${id} module`, name: `${id} module`,
version, version,
}), }),
@@ -1373,7 +1348,7 @@ describe.sequential("live API — signup, sign-in, principal, sign-out", () => {
await running.shutdown(); await running.shutdown();
await expect(fetch(origin, { signal: AbortSignal.timeout(1_000) })).rejects.toBeTruthy(); await expect(fetch(origin, { signal: AbortSignal.timeout(1_000) })).rejects.toBeTruthy();
const remainingConnections = await connectionsLeftAfterShutdown(database.name); const remainingConnections = await activeConnectionCount(database.name);
expect(remainingConnections).toBe(0); expect(remainingConnections).toBe(0);
await dropDatabase(database.name); await dropDatabase(database.name);
@@ -1522,162 +1497,6 @@ describe.sequential("live API — exported browser auth shell against PostgreSQL
}); });
}); });
describe.sequential("live API — a person's own account and preferences (ADR 0062)", () => {
let database: { name: string; url: string };
let running: RunningApi;
let origin: string;
let ada: { cookie: string; userId: string };
let grace: { cookie: string; userId: string };
const password = "correct horse battery staple";
async function account(cookie?: string): Promise<{ status: number; body: unknown }> {
const response = await fetch(`${origin}/api/v1/account`, {
headers: { origin: dashboardOrigin, ...(cookie ? { cookie } : {}) },
});
return { body: await response.json(), status: response.status };
}
async function changePreferences(
cookie: string | undefined,
body: unknown,
options: { readonly origin?: string; readonly query?: string } = {},
): Promise<{ status: number; body: unknown }> {
const response = await fetch(`${origin}/api/v1/account/preferences${options.query ?? ""}`, {
body: JSON.stringify(body),
headers: {
"content-type": "application/json",
origin: options.origin ?? dashboardOrigin,
...(cookie ? { cookie } : {}),
},
method: "PATCH",
});
return { body: await response.json(), status: response.status };
}
async function person(email: string): Promise<{ cookie: string; userId: string }> {
const created = await signUp(origin, email, password);
expect(created.response.status).toBe(200);
const session = await signIn(origin, email, password);
if (!session.cookiePair) {
throw harnessError("The account session cookie was not issued.");
}
return { cookie: session.cookiePair, userId: created.body.user.id };
}
it("starts a freshly migrated API with two ordinary accounts", async () => {
database = await createDisposableDatabase("account_prefs");
await applyCoreMigrations(database.url);
running = await startMigratedApi(database.url);
origin = apiOrigin(running);
ada = await person("[email protected]");
grace = await person("[email protected]");
});
it("shows each person their own name, email, and the defaults, with no grant", async () => {
expect(await account(ada.cookie)).toEqual({
body: {
email: "[email protected]",
name: "Integration Test User",
preferences: { appearance: "system", motion: "system" },
},
status: 200,
});
expect(((await account(grace.cookie)).body as { email: string }).email).toBe(
"[email protected]",
);
});
it("refuses an anonymous caller both routes", async () => {
expect((await account()).status).toBe(403);
expect((await changePreferences(undefined, { appearance: "dark" })).status).toBe(403);
});
it("saves each person's change to their own account only", async () => {
expect(await changePreferences(ada.cookie, { appearance: "dark" })).toEqual({
body: { appearance: "dark", motion: "system" },
status: 200,
});
expect(await changePreferences(ada.cookie, { motion: "reduce" })).toEqual({
body: { appearance: "dark", motion: "reduce" },
status: 200,
});
expect((await account(grace.cookie)).body).toMatchObject({
preferences: { appearance: "system", motion: "system" },
});
});
it("cannot be pointed at another person's account by the query or the body", async () => {
const viaQuery = await changePreferences(
ada.cookie,
{ appearance: "light" },
{ query: `?userId=${grace.userId}` },
);
expect(viaQuery).toEqual({ body: { appearance: "light", motion: "reduce" }, status: 200 });
const viaBody = await changePreferences(ada.cookie, {
appearance: "dark",
userId: grace.userId,
});
expect(viaBody).toEqual({ body: { code: "INVALID_REQUEST" }, status: 400 });
expect((await account(grace.cookie)).body).toMatchObject({
preferences: { appearance: "system", motion: "system" },
});
expect((await account(ada.cookie)).body).toMatchObject({
preferences: { appearance: "light", motion: "reduce" },
});
});
it("refuses an untrusted Origin and an invalid value, changing nothing", async () => {
expect(
await changePreferences(ada.cookie, { appearance: "dark" }, { origin: untrustedOrigin }),
).toEqual({ body: { code: "UNTRUSTED_ORIGIN" }, status: 403 });
expect(await changePreferences(ada.cookie, { appearance: "sepia" })).toEqual({
body: { code: "INVALID_REQUEST" },
status: 400,
});
expect((await account(ada.cookie)).body).toMatchObject({
preferences: { appearance: "light", motion: "reduce" },
});
});
it("follows the person to a new session, as another device would see it", async () => {
const elsewhere = await signIn(origin, "[email protected]", password);
expect((await account(elsewhere.cookiePair as string)).body).toMatchObject({
preferences: { appearance: "light", motion: "reduce" },
});
});
it("stores exactly one typed row, for the one person who chose something", async () => {
const admin = await openAdministrativeClient(database.url);
try {
const rows = await safeQuery<{ user_id: string; appearance: string; motion: string }>(
admin,
"select user_id, appearance, motion from ekklesia_core_user_preference",
);
expect(rows).toEqual([{ appearance: "light", motion: "reduce", user_id: ada.userId }]);
} finally {
await safeEnd(admin);
}
});
it("refuses a signed-out session", async () => {
const signedOut = await fetch(`${origin}/api/auth/sign-out`, {
body: "{}",
headers: { "content-type": "application/json", cookie: ada.cookie, origin: dashboardOrigin },
method: "POST",
});
expect(signedOut.status).toBe(200);
expect((await account(ada.cookie)).status).toBe(403);
expect((await changePreferences(ada.cookie, { appearance: "dark" })).status).toBe(403);
});
it("shuts down cleanly", async () => {
await running.shutdown();
await dropDatabase(database.name);
});
});
describe.sequential("live API — startup and migration guarantees", () => { describe.sequential("live API — startup and migration guarantees", () => {
it("refuses to start against an unmigrated database, and creates no tables or migration history", async () => { it("refuses to start against an unmigrated database, and creates no tables or migration history", async () => {
const database = await createDisposableDatabase("unmigrated"); const database = await createDisposableDatabase("unmigrated");
@@ -2410,7 +2229,7 @@ describe.sequential("live API — platform owner and delegated grant management"
it("shuts down cleanly, closing the one shared pool", async () => { it("shuts down cleanly, closing the one shared pool", async () => {
await running.shutdown(); await running.shutdown();
const remainingConnections = await connectionsLeftAfterShutdown(database.name); const remainingConnections = await activeConnectionCount(database.name);
expect(remainingConnections).toBe(0); expect(remainingConnections).toBe(0);
await dropDatabase(database.name); await dropDatabase(database.name);
}); });
@@ -2770,28 +2589,28 @@ describe.sequential("live API — installed module permission end-to-end", () =>
}); });
const report = await diagnoseEkklesia({ const report = await diagnoseEkklesia({
coreMigrationsDirectory,
moduleDiscovery: { moduleDiscovery: {
moduleSdkVersion: "0.2.0", moduleSdkVersion: "0.1.0",
modulesDirectory, modulesDirectory,
platformVersion: "0.2.0", platformVersion: "0.1.0",
}, },
runtimeRange: ">=0.0.0", runtimeRange: ">=0.0.0",
runtimeVersion: process.versions.node, runtimeVersion: process.versions.node,
}); });
// Only the module-discovery and permission-composition checks are asserted here; nothing else // Only the module-discovery and permission-composition checks are asserted here — this
// in the report bears on what this scenario proves. // fixture's temporary directory has no `core/drizzle` sibling for migrations discovery to
// 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" });
expect(moduleCheck?.details).toEqual(["calendar 0.1.0"]); expect(moduleCheck?.details).toEqual(["calendar 0.1.0"]);
expect(permissionsCheck).toMatchObject({ expect(permissionsCheck).toMatchObject({
// Core's own seven (`core.authorization.grants.manage`, `core.modules.ui.read`, // Core's own six (`core.authorization.grants.manage`, `core.modules.ui.read`,
// `core.dashboard.navigation.manage`, `core.modules.lifecycle.manage`, // `core.dashboard.navigation.manage`, `core.modules.lifecycle.manage`,
// `core.modules.configuration.manage`, `core.devices.manage`, `core.account.self.manage`) // `core.modules.configuration.manage`, `core.devices.manage`) plus the fixture module's one.
// plus the fixture module's one. details: ["7 permissions registered"],
details: ["8 permissions registered"],
status: "pass", status: "pass",
}); });
// Doctor and permission composition read only the manifest — the fixture's register/start // Doctor and permission composition read only the manifest — the fixture's register/start
@@ -3018,7 +2837,7 @@ describe.sequential("live API — installed module permission end-to-end", () =>
it("shuts down cleanly, then restarting with the module absent rejects new grants for its former permission, returns NOT_FOUND for its former route, while the historical grant remains listable", async () => { it("shuts down cleanly, then restarting with the module absent rejects new grants for its former permission, returns NOT_FOUND for its former route, while the historical grant remains listable", async () => {
await running.shutdown(); await running.shutdown();
const remainingConnections = await connectionsLeftAfterShutdown(database.name); const remainingConnections = await activeConnectionCount(database.name);
expect(remainingConnections).toBe(0); expect(remainingConnections).toBe(0);
// Exactly one stop, matching the exactly-one register/start already asserted above. // Exactly one stop, matching the exactly-one register/start already asserted above.
expect(recorder.events.filter((event) => event === "calendar:stop")).toHaveLength(1); expect(recorder.events.filter((event) => event === "calendar:stop")).toHaveLength(1);
@@ -3170,7 +2989,7 @@ describe.sequential("live API — published module UI artifact", () => {
}); });
describe.sequential("live API — the built Dashboard on the API's own origin", () => { 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 () => { it("serves the built shell's exact index.html at / and its exact script, and nothing beside them", async () => {
const database = await createDisposableDatabase("dashboard_host"); const database = await createDisposableDatabase("dashboard_host");
await applyCoreMigrations(database.url); await applyCoreMigrations(database.url);
const running = await startMigratedApi(database.url); const running = await startMigratedApi(database.url);
@@ -3187,15 +3006,7 @@ describe.sequential("live API — the built Dashboard on the API's own origin",
expect(page.headers.get("x-frame-options")).toBe("DENY"); expect(page.headers.get("x-frame-options")).toBe("DENY");
expect(page.headers.get("content-security-policy")).toBe("frame-ancestors 'none'"); expect(page.headers.get("content-security-policy")).toBe("frame-ancestors 'none'");
expect(page.headers.get("referrer-policy")).toBe("no-referrer"); 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 expect(html).toBe(builtHtml);
// (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('id="ekklesia-host"');
expect(html).toContain(`<script src="${DASHBOARD_HOST_SCRIPT_PATH}" type="module"></script>`); expect(html).toContain(`<script src="${DASHBOARD_HOST_SCRIPT_PATH}" type="module"></script>`);
@@ -4087,9 +3898,9 @@ describe.sequential("live API — module lifecycle governs what actually runs",
const aggregate = await discoverAllMigrations({ const aggregate = await discoverAllMigrations({
coreMigrationsDirectory, coreMigrationsDirectory,
moduleDiscovery: { moduleDiscovery: {
moduleSdkVersion: "0.2.0", moduleSdkVersion: "0.1.0",
modulesDirectory, modulesDirectory,
platformVersion: "0.2.0", platformVersion: "0.1.0",
}, },
}); });
expect(aggregate.discoveredModules.map((module) => module.manifest.id)).toEqual(["calendar"]); expect(aggregate.discoveredModules.map((module) => module.manifest.id)).toEqual(["calendar"]);
+4 -132
View File
@@ -1,16 +1,13 @@
import { import {
ANONYMOUS_PRINCIPAL, ANONYMOUS_PRINCIPAL,
createAuthenticatedPrincipal, createAuthenticatedPrincipal,
moduleIdSchema, type ModuleDatabase,
parseEkklesiaUserId, type ModuleRuntimeServices,
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";
@@ -18,10 +15,7 @@ import { IdentityRuntimeError } from "@ekklesia/core/identity";
import { import {
createModuleUiPublicationRegistry, createModuleUiPublicationRegistry,
EMPTY_MODULE_REALTIME_REGISTRY, EMPTY_MODULE_REALTIME_REGISTRY,
type LegacyModuleRuntimeServices,
type ModuleDatabase,
type ModuleUiPublicationRegistry, type ModuleUiPublicationRegistry,
StagedModuleRealtimeRegistry,
} from "@ekklesia/core/modules"; } from "@ekklesia/core/modules";
import { describe, expect, it, vi } from "vitest"; import { describe, expect, it, vi } from "vitest";
import { import {
@@ -30,7 +24,6 @@ import {
createApiApp as createApiAppWithModules, createApiApp as createApiAppWithModules,
} 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 { createInMemoryAccountStore } from "./support/in-memory-account-store.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 { testDashboardHostAssets } from "./support/dashboard-host-assets.js";
import { readyHealth } from "./support/ready-health.js"; import { readyHealth } from "./support/ready-health.js";
@@ -49,9 +42,9 @@ function fakeRuntime(overrides: Partial<EkklesiaIdentityRuntime> = {}): Ekklesia
}; };
} }
/** A small typed fake `LegacyModuleRuntimeServices` — these tests exercise `createApiApp`'s own wiring, /** A small typed fake `ModuleRuntimeServices` — these tests exercise `createApiApp`'s own wiring,
* never real database behavior, so no PostgreSQL is required. */ * never real database behavior, so no PostgreSQL is required. */
function fakeModuleServices(): LegacyModuleRuntimeServices { function fakeModuleServices(): ModuleRuntimeServices {
const database = Object.freeze({ const database = Object.freeze({
delete: vi.fn(), delete: vi.fn(),
execute: vi.fn(), execute: vi.fn(),
@@ -112,7 +105,6 @@ function createAuthWiringApp(runtime: EkklesiaIdentityRuntime) {
moduleLifecycle: emptyModuleLifecycle(), moduleLifecycle: emptyModuleLifecycle(),
moduleConfiguration: emptyModuleConfiguration(), moduleConfiguration: emptyModuleConfiguration(),
moduleStorage: emptyModuleStorage(), moduleStorage: emptyModuleStorage(),
account: createInMemoryAccountStore(),
dashboard: createInMemoryDashboardStore(), dashboard: createInMemoryDashboardStore(),
dashboardHostAssets: testDashboardHostAssets(), dashboardHostAssets: testDashboardHostAssets(),
health: readyHealth(), health: readyHealth(),
@@ -132,7 +124,6 @@ describe("Ekklesia API Hono application", () => {
moduleLifecycle: emptyModuleLifecycle(), moduleLifecycle: emptyModuleLifecycle(),
moduleConfiguration: emptyModuleConfiguration(), moduleConfiguration: emptyModuleConfiguration(),
moduleStorage: emptyModuleStorage(), moduleStorage: emptyModuleStorage(),
account: createInMemoryAccountStore(),
dashboard: createInMemoryDashboardStore(), dashboard: createInMemoryDashboardStore(),
dashboardHostAssets: testDashboardHostAssets(), dashboardHostAssets: testDashboardHostAssets(),
health: readyHealth(), health: readyHealth(),
@@ -369,7 +360,6 @@ describe("Ekklesia API Hono application", () => {
moduleLifecycle: emptyModuleLifecycle(), moduleLifecycle: emptyModuleLifecycle(),
moduleConfiguration: emptyModuleConfiguration(), moduleConfiguration: emptyModuleConfiguration(),
moduleStorage: emptyModuleStorage(), moduleStorage: emptyModuleStorage(),
account: createInMemoryAccountStore(),
dashboard: createInMemoryDashboardStore(), dashboard: createInMemoryDashboardStore(),
dashboardHostAssets: testDashboardHostAssets(), dashboardHostAssets: testDashboardHostAssets(),
health: readyHealth(), health: readyHealth(),
@@ -395,7 +385,6 @@ describe("Ekklesia API Hono application", () => {
moduleLifecycle: emptyModuleLifecycle(), moduleLifecycle: emptyModuleLifecycle(),
moduleConfiguration: emptyModuleConfiguration(), moduleConfiguration: emptyModuleConfiguration(),
moduleStorage: emptyModuleStorage(), moduleStorage: emptyModuleStorage(),
account: createInMemoryAccountStore(),
dashboard: createInMemoryDashboardStore(), dashboard: createInMemoryDashboardStore(),
dashboardHostAssets: testDashboardHostAssets(), dashboardHostAssets: testDashboardHostAssets(),
health: readyHealth(), health: readyHealth(),
@@ -423,7 +412,6 @@ describe("Ekklesia API Hono application", () => {
moduleLifecycle: emptyModuleLifecycle(), moduleLifecycle: emptyModuleLifecycle(),
moduleConfiguration: emptyModuleConfiguration(), moduleConfiguration: emptyModuleConfiguration(),
moduleStorage: emptyModuleStorage(), moduleStorage: emptyModuleStorage(),
account: createInMemoryAccountStore(),
dashboard: createInMemoryDashboardStore(), dashboard: createInMemoryDashboardStore(),
dashboardHostAssets: testDashboardHostAssets(), dashboardHostAssets: testDashboardHostAssets(),
health: readyHealth(), health: readyHealth(),
@@ -453,7 +441,6 @@ describe("Ekklesia API Hono application", () => {
moduleLifecycle: emptyModuleLifecycle(), moduleLifecycle: emptyModuleLifecycle(),
moduleConfiguration: emptyModuleConfiguration(), moduleConfiguration: emptyModuleConfiguration(),
moduleStorage: emptyModuleStorage(), moduleStorage: emptyModuleStorage(),
account: createInMemoryAccountStore(),
dashboard: createInMemoryDashboardStore(), dashboard: createInMemoryDashboardStore(),
dashboardHostAssets: testDashboardHostAssets(), dashboardHostAssets: testDashboardHostAssets(),
health: readyHealth(), health: readyHealth(),
@@ -483,7 +470,6 @@ describe("Ekklesia API Hono application", () => {
moduleLifecycle: emptyModuleLifecycle(), moduleLifecycle: emptyModuleLifecycle(),
moduleConfiguration: emptyModuleConfiguration(), moduleConfiguration: emptyModuleConfiguration(),
moduleStorage: emptyModuleStorage(), moduleStorage: emptyModuleStorage(),
account: createInMemoryAccountStore(),
dashboard: createInMemoryDashboardStore(), dashboard: createInMemoryDashboardStore(),
dashboardHostAssets: testDashboardHostAssets(), dashboardHostAssets: testDashboardHostAssets(),
health: readyHealth(), health: readyHealth(),
@@ -510,7 +496,6 @@ describe("Ekklesia API Hono application", () => {
moduleLifecycle: emptyModuleLifecycle(), moduleLifecycle: emptyModuleLifecycle(),
moduleConfiguration: emptyModuleConfiguration(), moduleConfiguration: emptyModuleConfiguration(),
moduleStorage: emptyModuleStorage(), moduleStorage: emptyModuleStorage(),
account: createInMemoryAccountStore(),
dashboard: createInMemoryDashboardStore(), dashboard: createInMemoryDashboardStore(),
dashboardHostAssets: testDashboardHostAssets(), dashboardHostAssets: testDashboardHostAssets(),
health: readyHealth(), health: readyHealth(),
@@ -539,7 +524,6 @@ describe("Ekklesia API Hono application", () => {
moduleLifecycle: emptyModuleLifecycle(), moduleLifecycle: emptyModuleLifecycle(),
moduleConfiguration: emptyModuleConfiguration(), moduleConfiguration: emptyModuleConfiguration(),
moduleStorage: emptyModuleStorage(), moduleStorage: emptyModuleStorage(),
account: createInMemoryAccountStore(),
dashboard: createInMemoryDashboardStore(), dashboard: createInMemoryDashboardStore(),
dashboardHostAssets: testDashboardHostAssets(), dashboardHostAssets: testDashboardHostAssets(),
health: readyHealth(), health: readyHealth(),
@@ -565,7 +549,6 @@ describe("Ekklesia API Hono application", () => {
moduleLifecycle: emptyModuleLifecycle(), moduleLifecycle: emptyModuleLifecycle(),
moduleConfiguration: emptyModuleConfiguration(), moduleConfiguration: emptyModuleConfiguration(),
moduleStorage: emptyModuleStorage(), moduleStorage: emptyModuleStorage(),
account: createInMemoryAccountStore(),
dashboard: createInMemoryDashboardStore(), dashboard: createInMemoryDashboardStore(),
dashboardHostAssets: testDashboardHostAssets(), dashboardHostAssets: testDashboardHostAssets(),
health: readyHealth(), health: readyHealth(),
@@ -593,7 +576,6 @@ describe("Ekklesia API Hono application", () => {
moduleLifecycle: emptyModuleLifecycle(), moduleLifecycle: emptyModuleLifecycle(),
moduleConfiguration: emptyModuleConfiguration(), moduleConfiguration: emptyModuleConfiguration(),
moduleStorage: emptyModuleStorage(), moduleStorage: emptyModuleStorage(),
account: createInMemoryAccountStore(),
dashboard: createInMemoryDashboardStore(), dashboard: createInMemoryDashboardStore(),
dashboardHostAssets: testDashboardHostAssets(), dashboardHostAssets: testDashboardHostAssets(),
health: readyHealth(), health: readyHealth(),
@@ -628,7 +610,6 @@ describe("Ekklesia API Hono application", () => {
moduleLifecycle: emptyModuleLifecycle(), moduleLifecycle: emptyModuleLifecycle(),
moduleConfiguration: emptyModuleConfiguration(), moduleConfiguration: emptyModuleConfiguration(),
moduleStorage: emptyModuleStorage(), moduleStorage: emptyModuleStorage(),
account: createInMemoryAccountStore(),
dashboard: createInMemoryDashboardStore(), dashboard: createInMemoryDashboardStore(),
dashboardHostAssets: testDashboardHostAssets(), dashboardHostAssets: testDashboardHostAssets(),
health: readyHealth(), health: readyHealth(),
@@ -664,7 +645,6 @@ describe("Ekklesia API Hono application", () => {
moduleLifecycle: emptyModuleLifecycle(), moduleLifecycle: emptyModuleLifecycle(),
moduleConfiguration: emptyModuleConfiguration(), moduleConfiguration: emptyModuleConfiguration(),
moduleStorage: emptyModuleStorage(), moduleStorage: emptyModuleStorage(),
account: createInMemoryAccountStore(),
dashboard: createInMemoryDashboardStore(), dashboard: createInMemoryDashboardStore(),
dashboardHostAssets: testDashboardHostAssets(), dashboardHostAssets: testDashboardHostAssets(),
health: readyHealth(), health: readyHealth(),
@@ -705,7 +685,6 @@ describe("Ekklesia API Hono application", () => {
moduleLifecycle: emptyModuleLifecycle(), moduleLifecycle: emptyModuleLifecycle(),
moduleConfiguration: emptyModuleConfiguration(), moduleConfiguration: emptyModuleConfiguration(),
moduleStorage: emptyModuleStorage(), moduleStorage: emptyModuleStorage(),
account: createInMemoryAccountStore(),
dashboard: createInMemoryDashboardStore(), dashboard: createInMemoryDashboardStore(),
dashboardHostAssets: testDashboardHostAssets(), dashboardHostAssets: testDashboardHostAssets(),
health: readyHealth(), health: readyHealth(),
@@ -728,7 +707,6 @@ describe("Ekklesia API Hono application", () => {
moduleLifecycle: emptyModuleLifecycle(), moduleLifecycle: emptyModuleLifecycle(),
moduleConfiguration: emptyModuleConfiguration(), moduleConfiguration: emptyModuleConfiguration(),
moduleStorage: emptyModuleStorage(), moduleStorage: emptyModuleStorage(),
account: createInMemoryAccountStore(),
dashboard: createInMemoryDashboardStore(), dashboard: createInMemoryDashboardStore(),
dashboardHostAssets: testDashboardHostAssets(), dashboardHostAssets: testDashboardHostAssets(),
health: readyHealth(), health: readyHealth(),
@@ -753,7 +731,6 @@ describe("Ekklesia API Hono application", () => {
moduleLifecycle: emptyModuleLifecycle(), moduleLifecycle: emptyModuleLifecycle(),
moduleConfiguration: emptyModuleConfiguration(), moduleConfiguration: emptyModuleConfiguration(),
moduleStorage: emptyModuleStorage(), moduleStorage: emptyModuleStorage(),
account: createInMemoryAccountStore(),
dashboard: createInMemoryDashboardStore(), dashboard: createInMemoryDashboardStore(),
dashboardHostAssets: testDashboardHostAssets(), dashboardHostAssets: testDashboardHostAssets(),
health: readyHealth(), health: readyHealth(),
@@ -777,7 +754,6 @@ describe("Ekklesia API Hono application", () => {
moduleLifecycle: emptyModuleLifecycle(), moduleLifecycle: emptyModuleLifecycle(),
moduleConfiguration: emptyModuleConfiguration(), moduleConfiguration: emptyModuleConfiguration(),
moduleStorage: emptyModuleStorage(), moduleStorage: emptyModuleStorage(),
account: createInMemoryAccountStore(),
dashboard: createInMemoryDashboardStore(), dashboard: createInMemoryDashboardStore(),
dashboardHostAssets: testDashboardHostAssets(), dashboardHostAssets: testDashboardHostAssets(),
health: readyHealth(), health: readyHealth(),
@@ -799,7 +775,6 @@ describe("Ekklesia API Hono application", () => {
moduleLifecycle: emptyModuleLifecycle(), moduleLifecycle: emptyModuleLifecycle(),
moduleConfiguration: emptyModuleConfiguration(), moduleConfiguration: emptyModuleConfiguration(),
moduleStorage: emptyModuleStorage(), moduleStorage: emptyModuleStorage(),
account: createInMemoryAccountStore(),
dashboard: createInMemoryDashboardStore(), dashboard: createInMemoryDashboardStore(),
dashboardHostAssets: testDashboardHostAssets(), dashboardHostAssets: testDashboardHostAssets(),
health: readyHealth(), health: readyHealth(),
@@ -828,7 +803,6 @@ describe("Ekklesia API Hono application", () => {
moduleLifecycle: emptyModuleLifecycle(), moduleLifecycle: emptyModuleLifecycle(),
moduleConfiguration: emptyModuleConfiguration(), moduleConfiguration: emptyModuleConfiguration(),
moduleStorage: emptyModuleStorage(), moduleStorage: emptyModuleStorage(),
account: createInMemoryAccountStore(),
dashboard: createInMemoryDashboardStore(), dashboard: createInMemoryDashboardStore(),
dashboardHostAssets: testDashboardHostAssets(), dashboardHostAssets: testDashboardHostAssets(),
health: readyHealth(), health: readyHealth(),
@@ -860,7 +834,6 @@ describe("Ekklesia API Hono application", () => {
moduleLifecycle: emptyModuleLifecycle(), moduleLifecycle: emptyModuleLifecycle(),
moduleConfiguration: emptyModuleConfiguration(), moduleConfiguration: emptyModuleConfiguration(),
moduleStorage: emptyModuleStorage(), moduleStorage: emptyModuleStorage(),
account: createInMemoryAccountStore(),
dashboard: createInMemoryDashboardStore(), dashboard: createInMemoryDashboardStore(),
dashboardHostAssets: testDashboardHostAssets(), dashboardHostAssets: testDashboardHostAssets(),
health: readyHealth(), health: readyHealth(),
@@ -895,7 +868,6 @@ describe("Ekklesia API Hono application", () => {
moduleLifecycle: emptyModuleLifecycle(), moduleLifecycle: emptyModuleLifecycle(),
moduleConfiguration: emptyModuleConfiguration(), moduleConfiguration: emptyModuleConfiguration(),
moduleStorage: emptyModuleStorage(), moduleStorage: emptyModuleStorage(),
account: createInMemoryAccountStore(),
dashboard: createInMemoryDashboardStore(), dashboard: createInMemoryDashboardStore(),
dashboardHostAssets: testDashboardHostAssets(), dashboardHostAssets: testDashboardHostAssets(),
health: readyHealth(), health: readyHealth(),
@@ -921,7 +893,6 @@ describe("Ekklesia API Hono application", () => {
moduleLifecycle: emptyModuleLifecycle(), moduleLifecycle: emptyModuleLifecycle(),
moduleConfiguration: emptyModuleConfiguration(), moduleConfiguration: emptyModuleConfiguration(),
moduleStorage: emptyModuleStorage(), moduleStorage: emptyModuleStorage(),
account: createInMemoryAccountStore(),
dashboard: createInMemoryDashboardStore(), dashboard: createInMemoryDashboardStore(),
dashboardHostAssets: testDashboardHostAssets(), dashboardHostAssets: testDashboardHostAssets(),
health: readyHealth(), health: readyHealth(),
@@ -939,102 +910,3 @@ 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,
account: createInMemoryAccountStore(),
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 -22
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { ApiConfigError, apiTrustedOrigins, createApiRuntimeConfig } from "../src/config.js"; import { ApiConfigError, createApiRuntimeConfig } from "../src/config.js";
const validSecret = "correct-horse-battery-staple-0123456789"; const validSecret = "correct-horse-battery-staple-0123456789";
@@ -337,24 +337,3 @@ 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"]);
});
});
+14 -84
View File
@@ -8,20 +8,15 @@ vi.mock("@ekklesia/shell/dashboard-assets", () => ({
dashboardHostAssetUrls: Object.freeze({ dashboardHostAssetUrls: Object.freeze({
html: "file:///surface/browser/index.html", html: "file:///surface/browser/index.html",
script: "file:///surface/browser/assets/ekklesia-dashboard/app.js", script: "file:///surface/browser/assets/ekklesia-dashboard/app.js",
appearance: "file:///surface/browser/assets/ekklesia-dashboard/appearance.js",
}), }),
})); }));
import { import {
createDashboardHostAssets, createDashboardHostAssets,
DASHBOARD_API_ORIGIN_PLACEHOLDER,
DashboardHostAssetError, DashboardHostAssetError,
loadDashboardHostAssets, loadDashboardHostAssets,
renderDashboardPage,
} from "../src/dashboard-host-assets.js"; } from "../src/dashboard-host-assets.js";
const API_ORIGIN = "https://api.example.test";
function sha256Etag(text: string): string { function sha256Etag(text: string): string {
return `"${createHash("sha256").update(text).digest("base64url")}"`; return `"${createHash("sha256").update(text).digest("base64url")}"`;
} }
@@ -29,28 +24,20 @@ function sha256Etag(text: string): string {
describe("Dashboard host asset loading", () => { describe("Dashboard host asset loading", () => {
beforeEach(() => readFileMock.mockReset()); beforeEach(() => readFileMock.mockReset());
it("reads only the three declared file URLs in order and freezes one consistent snapshot", async () => { 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 html = '<!doctype html><div id="ekklesia-host"></div>';
const script = "export const dashboardHost = true;\n"; const script = "export const dashboardHost = true;\n";
const appearanceScript = "(() => {})();\n"; readFileMock.mockResolvedValueOnce(html).mockResolvedValueOnce(script);
readFileMock
.mockResolvedValueOnce(html)
.mockResolvedValueOnce(script)
.mockResolvedValueOnce(appearanceScript);
const assets = await loadDashboardHostAssets(API_ORIGIN); const assets = await loadDashboardHostAssets();
expect(readFileMock.mock.calls).toEqual([ expect(readFileMock.mock.calls).toEqual([
[new URL("file:///surface/browser/index.html"), "utf8"], [new URL("file:///surface/browser/index.html"), "utf8"],
[new URL("file:///surface/browser/assets/ekklesia-dashboard/app.js"), "utf8"], [new URL("file:///surface/browser/assets/ekklesia-dashboard/app.js"), "utf8"],
[new URL("file:///surface/browser/assets/ekklesia-dashboard/appearance.js"), "utf8"],
]); ]);
const rendered = `<!doctype html><meta content="${API_ORIGIN}" name="ekklesia-api-origin" /><div id="ekklesia-host"></div>`;
expect(assets).toEqual({ expect(assets).toEqual({
appearanceScript, html,
appearanceScriptEtag: sha256Etag(appearanceScript), htmlEtag: sha256Etag(html),
html: rendered,
htmlEtag: sha256Etag(rendered),
script, script,
scriptEtag: sha256Etag(script), scriptEtag: sha256Etag(script),
}); });
@@ -62,7 +49,7 @@ describe("Dashboard host asset loading", () => {
new Error("ENOENT /home/operator/private/surface-build secret-token"), new Error("ENOENT /home/operator/private/surface-build secret-token"),
); );
const failure = await loadDashboardHostAssets(API_ORIGIN).catch((error: unknown) => error); const failure = await loadDashboardHostAssets().catch((error: unknown) => error);
expect(readFileMock).toHaveBeenCalledTimes(1); expect(readFileMock).toHaveBeenCalledTimes(1);
expect(failure).toBeInstanceOf(DashboardHostAssetError); expect(failure).toBeInstanceOf(DashboardHostAssetError);
@@ -82,76 +69,19 @@ describe("Dashboard host asset loading", () => {
it("fails when the script is missing even though the page was read", async () => { it("fails when the script is missing even though the page was read", async () => {
readFileMock readFileMock
.mockResolvedValueOnce(`<!doctype html>${DASHBOARD_API_ORIGIN_PLACEHOLDER}`) .mockResolvedValueOnce("<!doctype html>")
.mockRejectedValueOnce(new Error("ENOENT app.js")); .mockRejectedValueOnce(new Error("ENOENT app.js"));
await expect(loadDashboardHostAssets(API_ORIGIN)).rejects.toBeInstanceOf( await expect(loadDashboardHostAssets()).rejects.toBeInstanceOf(DashboardHostAssetError);
DashboardHostAssetError,
);
expect(readFileMock).toHaveBeenCalledTimes(2); expect(readFileMock).toHaveBeenCalledTimes(2);
}); });
it("fails when the appearance script is missing even though the page and script were read", async () => { it("rejects an empty page or script as an unavailable shell build", () => {
readFileMock for (const [html, script] of [
.mockResolvedValueOnce(`<!doctype html>${DASHBOARD_API_ORIGIN_PLACEHOLDER}`) ["", "export {};"],
.mockResolvedValueOnce("export {};\n") ["<html></html>", " \n"],
.mockRejectedValueOnce(new Error("ENOENT appearance.js"));
await expect(loadDashboardHostAssets(API_ORIGIN)).rejects.toBeInstanceOf(
DashboardHostAssetError,
);
expect(readFileMock).toHaveBeenCalledTimes(3);
});
it("rejects an empty page, script, or appearance script as an unavailable shell build", () => {
for (const [html, script, appearance] of [
["", "export {};", "(() => {})();"],
["<html></html>", " \n", "(() => {})();"],
["<html></html>", "export {};", " "],
] as const) { ] as const) {
expect(() => createDashboardHostAssets(html, script, appearance)).toThrow( expect(() => createDashboardHostAssets(html, script)).toThrow(DashboardHostAssetError);
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")
.mockResolvedValueOnce("(() => {})();\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);
});
});
+10 -295
View File
@@ -1,8 +1,9 @@
import { createHash } from "node:crypto"; import { createHash } from "node:crypto";
import { import {
ANONYMOUS_PRINCIPAL, ANONYMOUS_PRINCIPAL,
type ModuleDatabase,
type ModuleRouteHandler, type ModuleRouteHandler,
parseModuleManifest, type ModuleRuntimeServices,
parsePermissionId, parsePermissionId,
type RegisteredModuleHttpRoute, type RegisteredModuleHttpRoute,
} from "@ekklesia/contracts"; } from "@ekklesia/contracts";
@@ -14,15 +15,7 @@ import {
import type { EkklesiaIdentityRuntime } from "@ekklesia/core/identity"; import type { EkklesiaIdentityRuntime } from "@ekklesia/core/identity";
import { import {
createModuleUiPublicationRegistry, createModuleUiPublicationRegistry,
type DiscoveredModule,
EMPTY_MODULE_REALTIME_REGISTRY, EMPTY_MODULE_REALTIME_REGISTRY,
isReservedModuleId,
type LegacyModuleRuntimeServices,
type ModuleDatabase,
type ModuleLifecycleConfiguration,
RESERVED_MODULE_IDS,
RESERVED_SETTINGS_NAMES,
RESERVED_TOP_LEVEL_NAMES,
} from "@ekklesia/core/modules"; } from "@ekklesia/core/modules";
import { describe, expect, it, vi } from "vitest"; import { describe, expect, it, vi } from "vitest";
@@ -38,27 +31,13 @@ import {
} from "../src/dashboard-host-assets.js"; } from "../src/dashboard-host-assets.js";
import { import {
DASHBOARD_HOST_PAGE_PATH, DASHBOARD_HOST_PAGE_PATH,
DASHBOARD_HOST_APPEARANCE_SCRIPT_PATH,
DASHBOARD_HOST_SCRIPT_PATH, DASHBOARD_HOST_SCRIPT_PATH,
DashboardHostConfigurationError, DashboardHostConfigurationError,
createDashboardHostRoutes,
} from "../src/dashboard-host-routes.js"; } from "../src/dashboard-host-routes.js";
import { DASHBOARD_PLATFORM_PAGES, SETTINGS_SECTIONS } from "@ekklesia/shell/dashboard-routes"; import { DISPLAY_HOST_SCRIPT_PATH } from "../src/module-display-routes.js";
import {
MODULE_DISPLAY_DESCRIPTOR_PREFIX,
MODULE_DISPLAY_PAGE_PREFIX,
} from "../src/module-display-path.js";
import {
DISPLAY_HOST_SCRIPT_PATH,
DISPLAY_SERVICE_WORKER_PATH,
} from "../src/module-display-routes.js";
import { createInMemoryAccountStore } from "./support/in-memory-account-store.js";
import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js"; import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
import { emptyModuleConfiguration } from "./support/in-memory-module-configuration.js"; import { emptyModuleConfiguration } from "./support/in-memory-module-configuration.js";
import { import { emptyModuleLifecycle } from "./support/in-memory-module-lifecycle.js";
createInMemoryModuleLifecycleStore,
moduleLifecycleSnapshotFor,
} from "./support/in-memory-module-lifecycle.js";
import { emptyModuleStorage } from "./support/in-memory-module-storage.js"; import { emptyModuleStorage } from "./support/in-memory-module-storage.js";
import { readyHealth } from "./support/ready-health.js"; import { readyHealth } from "./support/ready-health.js";
@@ -75,8 +54,6 @@ function sha256Etag(text: string): string {
const PAGE_ETAG = sha256Etag(PAGE_HTML); const PAGE_ETAG = sha256Etag(PAGE_HTML);
const SCRIPT_ETAG = sha256Etag(SCRIPT); const SCRIPT_ETAG = sha256Etag(SCRIPT);
const APPEARANCE_SCRIPT = "(() => { /* pre-paint appearance */ })();\n";
const APPEARANCE_SCRIPT_ETAG = sha256Etag(APPEARANCE_SCRIPT);
function fakeRuntime(): EkklesiaIdentityRuntime { function fakeRuntime(): EkklesiaIdentityRuntime {
return { return {
@@ -97,44 +74,13 @@ function fakeStore(): AuthorizationStore {
}; };
} }
function fakeModuleServices(): LegacyModuleRuntimeServices { function fakeModuleServices(): ModuleRuntimeServices {
return Object.freeze({ database: Object.freeze({}) as unknown as ModuleDatabase }); return Object.freeze({ database: Object.freeze({}) as unknown as ModuleDatabase });
} }
/** A discovered module as the boot snapshot records it, from a real validated manifest. Only its id
* and whether it declares a Dashboard UI matter to these routes; nothing is ever loaded. */
function discoveredModule(id: string, options: { readonly ui: boolean }): DiscoveredModule {
const manifest = parseModuleManifest({
capabilities: [],
compatibility: { moduleSdk: "^0.2.0", platform: "^0.2.0" },
entrypoints: {
server: "./dist/server/index.js",
...(options.ui ? { ui: "./dist/ui/index.js" } : {}),
},
id,
manifestVersion: 8,
name: `${id} module`,
permissions: [],
version: "0.1.0",
});
return {
contributions: [],
directory: `/modules/${id}`,
entrypoints: {
server: `/modules/${id}/dist/server/index.js`,
...(options.ui ? { ui: `/modules/${id}/dist/ui/index.js` } : {}),
},
manifest,
manifestPath: `/modules/${id}/ekklesia.module.json`,
packageName: `@test/${id}`,
};
}
function buildApp( function buildApp(
options: { options: {
readonly assets?: DashboardHostAssets; readonly assets?: DashboardHostAssets;
readonly configuration?: ModuleLifecycleConfiguration;
readonly discovered?: readonly DiscoveredModule[];
readonly moduleRoutes?: readonly RegisteredModuleHttpRoute[]; readonly moduleRoutes?: readonly RegisteredModuleHttpRoute[];
} = {}, } = {},
) { ) {
@@ -154,16 +100,11 @@ function buildApp(
moduleRealtime: EMPTY_MODULE_REALTIME_REGISTRY, moduleRealtime: EMPTY_MODULE_REALTIME_REGISTRY,
moduleServices: fakeModuleServices(), moduleServices: fakeModuleServices(),
moduleUi: createModuleUiPublicationRegistry([]), moduleUi: createModuleUiPublicationRegistry([]),
moduleLifecycle: { moduleLifecycle: emptyModuleLifecycle(),
snapshot: moduleLifecycleSnapshotFor(options.discovered ?? [], options.configuration),
store: createInMemoryModuleLifecycleStore(),
},
moduleConfiguration: emptyModuleConfiguration(), moduleConfiguration: emptyModuleConfiguration(),
moduleStorage: emptyModuleStorage(), moduleStorage: emptyModuleStorage(),
account: createInMemoryAccountStore(),
dashboard: createInMemoryDashboardStore(), dashboard: createInMemoryDashboardStore(),
dashboardHostAssets: dashboardHostAssets: options.assets ?? createDashboardHostAssets(PAGE_HTML, SCRIPT),
options.assets ?? createDashboardHostAssets(PAGE_HTML, SCRIPT, APPEARANCE_SCRIPT),
health: readyHealth(), health: readyHealth(),
runtime, runtime,
trustedOrigins: [trustedOrigin], trustedOrigins: [trustedOrigin],
@@ -252,182 +193,6 @@ describe("GET / (the Dashboard page)", () => {
}); });
}); });
describe("Dashboard deep links (ADR 0057)", () => {
const installed = () => [
discoveredModule("signage", { ui: true }),
discoveredModule("calendar", { ui: true }),
discoveredModule("retired", { ui: true }),
// A display-only module has no Dashboard page (ADR 0033).
discoveredModule("lobby-screen", { ui: false }),
];
// Installed but disabled: its address still opens the Dashboard, which says it is unavailable.
const configuration = { modules: [{ enabled: false, moduleId: "retired" }] };
const deepLinks = [
"/settings",
"/settings/",
"/settings/displays/add",
"/account",
"/account/appearance",
"/signage",
"/signage/",
// Reserved for the module's own pages, which the shell will interpret (ADR 0057).
"/signage/timelines",
"/signage/timelines/a1b2",
"/calendar",
"/retired",
];
it("serves the shell's own platform pages, from the shell's own list", () => {
expect(DASHBOARD_PLATFORM_PAGES).toEqual(["settings", "account"]);
});
it("serves the identical page, validator, and security headers at every Dashboard page, so a refresh stays put", async () => {
const { app } = buildApp({ configuration, discovered: installed() });
for (const path of deepLinks) {
const response = await app.request(path);
expect(response.status, path).toBe(200);
expect(await response.text(), path).toBe(PAGE_HTML);
expect(response.headers.get("content-type"), path).toBe("text/html; charset=utf-8");
expect(response.headers.get("cache-control"), path).toBe("no-cache");
expect(response.headers.get("etag"), path).toBe(PAGE_ETAG);
expectPageSecurityHeaders(response);
}
expect(readFileMock).not.toHaveBeenCalled();
});
it("revalidates a deep link with the page's one validator", async () => {
const { app } = buildApp({ discovered: installed() });
for (const path of ["/settings/displays", "/signage"]) {
const response = await app.request(path, { headers: { "If-None-Match": PAGE_ETAG } });
expect(response.status, path).toBe(304);
expect(await response.text(), path).toBe("");
expectPageSecurityHeaders(response);
}
});
it("never turns an arbitrary top-level path into a Dashboard page", async () => {
const { app } = buildApp({ configuration, discovered: installed() });
for (const path of [
"/definitely-not-a-real-module",
"/definitely-not-a-real-module/timelines",
// Installed, but with no Dashboard UI.
"/lobby-screen",
// Look-alikes of real pages.
"/signages",
"/signage-old",
"/Signage",
"/settings-old",
"/accounts",
// The former implementation prefix is not a namespace at all.
"/modules",
"/modules/signage",
]) {
await expectSanitizedNotFound(await app.request(path), path);
}
});
it("serves a module's page only while that module is installed", async () => {
const { app } = buildApp({ discovered: [] });
await expectSanitizedNotFound(await app.request("/signage"), "/signage");
expect((await app.request("/settings")).status).toBe(200);
});
it("serves the page and never the script, whatever a deep link's last segment looks like", async () => {
const { app } = buildApp({ discovered: installed() });
for (const path of [
"/signage/app.js",
"/settings/assets/ekklesia-dashboard/app.js",
"/account/index.html",
]) {
expect(await (await app.request(path)).text(), path).toBe(PAGE_HTML);
}
});
it("answers no other method at a Dashboard page", async () => {
const { app } = buildApp({ discovered: installed() });
for (const path of ["/settings/displays", "/signage"]) {
for (const method of ["POST", "PUT", "PATCH", "DELETE"]) {
await expectSanitizedNotFound(await app.request(path, { method }), `${method} ${path}`);
}
}
});
it("refuses at composition a module page that would shadow a platform namespace", () => {
for (const modulePages of [["api"], ["settings"], ["display"], ["Not-An-Id"], ["a", "a"]]) {
expect(
() =>
createDashboardHostRoutes(
createDashboardHostAssets(PAGE_HTML, SCRIPT, APPEARANCE_SCRIPT),
{ modulePages },
),
JSON.stringify(modulePages),
).toThrow(DashboardHostConfigurationError);
}
});
});
describe("the platform's reserved top-level names (ADR 0057)", () => {
it("cover every top-level namespace the API registers, so a module page can never shadow one", () => {
const { app } = buildApp({ discovered: [discoveredModule("signage", { ui: true })] });
const topLevel = new Set(
app.routes
.map((route) => route.path.split("/")[1] ?? "")
.filter((segment) => segment !== "" && segment !== "*"),
);
topLevel.delete("signage");
// This composition publishes no display, so the display host's routes are absent here; they
// are checked through their own path constants below.
expect([...topLevel].sort()).toEqual(["account", "api", "assets", "settings"]);
for (const segment of topLevel) {
expect(isReservedModuleId(segment), segment).toBe(true);
}
});
it("cover the display host's pages, service worker, and script, and the Dashboard's script", () => {
for (const path of [
MODULE_DISPLAY_PAGE_PREFIX,
DISPLAY_SERVICE_WORKER_PATH,
DISPLAY_HOST_SCRIPT_PATH,
DASHBOARD_HOST_SCRIPT_PATH,
MODULE_DISPLAY_DESCRIPTOR_PREFIX,
]) {
expect(isReservedModuleId(path.split("/")[1] ?? ""), path).toBe(true);
}
expect([...RESERVED_TOP_LEVEL_NAMES].sort()).toEqual([
"account",
"api",
"assets",
"display",
"settings",
]);
});
it("include every Dashboard platform page the shell interprets", () => {
for (const page of DASHBOARD_PLATFORM_PAGES) {
expect(RESERVED_TOP_LEVEL_NAMES, page).toContain(page);
}
});
it("include exactly the Settings sections the shell interprets, so /settings/<module-id> never collides (ADR 0061)", () => {
expect([...SETTINGS_SECTIONS].sort()).toEqual([...RESERVED_SETTINGS_NAMES]);
for (const section of SETTINGS_SECTIONS) {
expect(isReservedModuleId(section), section).toBe(true);
}
expect(RESERVED_MODULE_IDS).toEqual(
[...RESERVED_TOP_LEVEL_NAMES, ...RESERVED_SETTINGS_NAMES].sort(),
);
});
});
describe("GET /assets/ekklesia-dashboard/app.js (the Dashboard script)", () => { 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 () => { it("serves the exact startup script bytes as JavaScript with revalidation and a strong ETag", async () => {
const { app } = buildApp(); const { app } = buildApp();
@@ -466,45 +231,8 @@ describe("GET /assets/ekklesia-dashboard/app.js (the Dashboard script)", () => {
}); });
}); });
describe("GET /assets/ekklesia-dashboard/appearance.js (the pre-paint appearance script)", () => {
it("serves the exact pre-paint script as JavaScript with revalidation and its own strong ETag", async () => {
const { app } = buildApp();
const response = await app.request(DASHBOARD_HOST_APPEARANCE_SCRIPT_PATH);
expect(response.status).toBe(200);
expect(await response.text()).toBe(APPEARANCE_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(APPEARANCE_SCRIPT_ETAG);
expect(response.headers.get("x-content-type-options")).toBe("nosniff");
// Code only: nothing about any viewer, and nothing that varies by request.
expect(response.headers.get("set-cookie")).toBeNull();
expect(response.headers.get("vary")).toBeNull();
});
it("answers a matching If-None-Match with a 304, and does not accept the app script's validator", async () => {
const { app } = buildApp();
const notModified = await app.request(DASHBOARD_HOST_APPEARANCE_SCRIPT_PATH, {
headers: { "If-None-Match": APPEARANCE_SCRIPT_ETAG },
});
expect(notModified.status).toBe(304);
expect(notModified.headers.get("etag")).toBe(APPEARANCE_SCRIPT_ETAG);
const other = await app.request(DASHBOARD_HOST_APPEARANCE_SCRIPT_PATH, {
headers: { "If-None-Match": SCRIPT_ETAG },
});
expect(other.status).toBe(200);
});
it("lives beside the app script, in the Dashboard's own asset namespace", () => {
expect(DASHBOARD_HOST_APPEARANCE_SCRIPT_PATH).toBe("/assets/ekklesia-dashboard/appearance.js");
});
});
describe("Dashboard host boundaries", () => { describe("Dashboard host boundaries", () => {
it("serves nothing but its exact paths, answering every other spelling with the sanitized 404", async () => { it("serves nothing but its two exact paths, answering every other spelling with the sanitized 404", async () => {
const { app } = buildApp(); const { app } = buildApp();
for (const path of [ for (const path of [
@@ -518,10 +246,6 @@ describe("Dashboard host boundaries", () => {
"/assets/ekklesia-dashboard/index.html", "/assets/ekklesia-dashboard/index.html",
"/assets/", "/assets/",
"/assets/ekklesia-dashboard/APP.JS", "/assets/ekklesia-dashboard/APP.JS",
"/assets/ekklesia-dashboard/appearance.js.map",
"/assets/ekklesia-dashboard/appearance.js/",
"/assets/ekklesia-dashboard/APPEARANCE.JS",
"/appearance.js",
"/dashboard", "/dashboard",
"/login", "/login",
]) { ]) {
@@ -610,21 +334,12 @@ describe("Dashboard host boundaries", () => {
}); });
it("refuses at composition a snapshot whose validator does not match its bytes, or empty bytes", () => { it("refuses at composition a snapshot whose validator does not match its bytes, or empty bytes", () => {
const consistent = createDashboardHostAssets(PAGE_HTML, SCRIPT, APPEARANCE_SCRIPT); const consistent = createDashboardHostAssets(PAGE_HTML, SCRIPT);
for (const assets of [ for (const assets of [
{ ...consistent, html: `${PAGE_HTML}<!-- tampered -->` }, { ...consistent, html: `${PAGE_HTML}<!-- tampered -->` },
{ ...consistent, scriptEtag: PAGE_ETAG }, { ...consistent, scriptEtag: PAGE_ETAG },
{ ...consistent, appearanceScriptEtag: SCRIPT_ETAG }, { html: "", htmlEtag: sha256Etag(""), script: SCRIPT, scriptEtag: SCRIPT_ETAG },
{ ...consistent, appearanceScript: `${APPEARANCE_SCRIPT}// tampered` },
{
appearanceScript: APPEARANCE_SCRIPT,
appearanceScriptEtag: APPEARANCE_SCRIPT_ETAG,
html: "",
htmlEtag: sha256Etag(""),
script: SCRIPT,
scriptEtag: SCRIPT_ETAG,
},
]) { ]) {
expect(() => buildApp({ assets })).toThrow(DashboardHostConfigurationError); expect(() => buildApp({ assets })).toThrow(DashboardHostConfigurationError);
} }
+4 -6
View File
@@ -5,6 +5,8 @@ import {
ANONYMOUS_PRINCIPAL, ANONYMOUS_PRINCIPAL,
createAuthenticatedPrincipal, createAuthenticatedPrincipal,
type EkklesiaPrincipal, type EkklesiaPrincipal,
type ModuleDatabase,
type ModuleRuntimeServices,
parseEkklesiaUserId, parseEkklesiaUserId,
} from "@ekklesia/contracts"; } from "@ekklesia/contracts";
import { import {
@@ -18,15 +20,12 @@ import {
createModuleUiPublicationRegistry, createModuleUiPublicationRegistry,
discoverModules, discoverModules,
EMPTY_MODULE_REALTIME_REGISTRY, EMPTY_MODULE_REALTIME_REGISTRY,
type LegacyModuleRuntimeServices,
type ModuleDatabase,
} from "@ekklesia/core/modules"; } from "@ekklesia/core/modules";
import type { Hono } from "hono"; import type { Hono } from "hono";
import { afterEach, describe, expect, it, vi } from "vitest"; import { afterEach, describe, expect, it, vi } from "vitest";
import { createApiApp } from "../src/app.js"; 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 { createInMemoryAccountStore } from "./support/in-memory-account-store.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 { testDashboardHostAssets } from "./support/dashboard-host-assets.js";
import { readyHealth } from "./support/ready-health.js"; import { readyHealth } from "./support/ready-health.js";
@@ -78,7 +77,7 @@ async function createModule(modulesDirectory: string, fixture: ModuleFixture): P
...(fixture.withUi === false ? {} : { ui: "./dist/ui/index.js" }), ...(fixture.withUi === false ? {} : { ui: "./dist/ui/index.js" }),
}, },
id: fixture.id, id: fixture.id,
manifestVersion: 8, manifestVersion: 7,
name: fixture.name, name: fixture.name,
version: "1.0.0", version: "1.0.0",
}), }),
@@ -141,7 +140,6 @@ 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 },
account: createInMemoryAccountStore(),
dashboard: createInMemoryDashboardStore(options.configuration ?? {}), dashboard: createInMemoryDashboardStore(options.configuration ?? {}),
dashboardHostAssets: testDashboardHostAssets(), dashboardHostAssets: testDashboardHostAssets(),
health: readyHealth(), health: readyHealth(),
@@ -149,7 +147,7 @@ async function install(options: InstallOptions = {}): Promise<{ app: Hono }> {
moduleRealtime: EMPTY_MODULE_REALTIME_REGISTRY, moduleRealtime: EMPTY_MODULE_REALTIME_REGISTRY,
moduleServices: Object.freeze({ moduleServices: Object.freeze({
database: Object.freeze({}) as unknown as ModuleDatabase, database: Object.freeze({}) as unknown as ModuleDatabase,
}) as LegacyModuleRuntimeServices, }) as ModuleRuntimeServices,
moduleUi: createModuleUiPublicationRegistry(modules), moduleUi: createModuleUiPublicationRegistry(modules),
moduleLifecycle: { moduleLifecycle: {
snapshot: moduleLifecycleSnapshotFor(modules), snapshot: moduleLifecycleSnapshotFor(modules),
@@ -1,250 +0,0 @@
import {
ANONYMOUS_PRINCIPAL,
createAuthenticatedPrincipal,
type EkklesiaPrincipal,
parseEkklesiaUserId,
parseModuleManifest,
} from "@ekklesia/contracts";
import {
type AuthorizationStore,
CORE_DEVICES_MANAGE,
CORE_MODULES_CONFIGURATION_MANAGE,
CORE_MODULES_LIFECYCLE_MANAGE,
createAuthorizationEvaluator,
} from "@ekklesia/core/authorization";
import type { EkklesiaIdentityRuntime } from "@ekklesia/core/identity";
import {
composePlatformPermissionRegistry,
createModuleUiPublicationRegistry,
type DiscoveredModule,
EMPTY_MODULE_REALTIME_REGISTRY,
type LegacyModuleRuntimeServices,
type ModuleDatabase,
type ModuleLifecycleConfiguration,
} from "@ekklesia/core/modules";
import { describe, expect, it, vi } from "vitest";
import { createApiApp } from "../src/app.js";
import {
DASHBOARD_SETTINGS_PATH,
type DashboardSettingsResponse,
} from "../src/dashboard-settings-routes.js";
import { createInMemoryAccountStore } from "./support/in-memory-account-store.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 {
createInMemoryModuleLifecycleStore,
moduleLifecycleSnapshotFor,
} from "./support/in-memory-module-lifecycle.js";
import { emptyModuleStorage } from "./support/in-memory-module-storage.js";
import { readyHealth } from "./support/ready-health.js";
/**
* `GET /api/v1/dashboard/settings` (ADR 0061): which Settings destinations the viewer may see,
* evaluated by the server through Core's one authorization evaluator. Presentation hints, never
* authority: every Settings data route keeps its own enforcement.
*/
const viewerId = parseEkklesiaUserId("44444444-4444-4444-8444-444444444444");
const viewer = createAuthenticatedPrincipal(viewerId);
function discovered(
id: string,
options: { readonly name?: string; readonly settings: boolean },
): DiscoveredModule {
const manifest = parseModuleManifest({
capabilities: [],
compatibility: { moduleSdk: "^0.2.0", platform: "^0.2.0" },
configuration: options.settings
? [
{
default: "Hello",
description: "A greeting.",
key: "greeting",
maxLength: 40,
type: "string",
},
]
: [],
entrypoints: { server: "./dist/server/index.js", ui: "./dist/ui/index.js" },
id,
manifestVersion: 8,
name: options.name ?? `${id} module`,
permissions: [],
version: "0.1.0",
});
return {
contributions: [],
directory: `/modules/${id}`,
entrypoints: {
server: `/modules/${id}/dist/server/index.js`,
ui: `/modules/${id}/dist/ui/index.js`,
},
manifest,
manifestPath: `/modules/${id}/ekklesia.module.json`,
packageName: `@test/${id}`,
};
}
interface Options {
readonly principal?: EkklesiaPrincipal;
readonly owner?: boolean;
readonly grants?: readonly string[];
readonly modules?: readonly DiscoveredModule[];
readonly configuration?: ModuleLifecycleConfiguration;
readonly ownerLookupFails?: boolean;
}
function build(options: Options = {}) {
const modules = options.modules ?? [
discovered("signage", { name: "Signage", settings: true }),
discovered("calendar", { name: "Calendar", settings: true }),
discovered("retired", { name: "Archive", settings: true }),
discovered("plain", { name: "Plain", settings: false }),
];
const isPlatformOwner = vi.fn(async () => {
if (options.ownerLookupFails) {
throw new Error("database down");
}
return options.owner === true;
});
const grants = new Set(options.grants ?? []);
const evaluator = createAuthorizationEvaluator({
grants: { hasActiveGrant: async ({ permission }) => grants.has(String(permission)) },
platformOwners: { isPlatformOwner },
});
const runtime: EkklesiaIdentityRuntime = {
handleAuthRequest: vi.fn(),
resolvePrincipal: vi.fn().mockResolvedValue(options.principal ?? viewer),
};
const store = {
createGrant: vi.fn(),
grantPlatformOwner: vi.fn(),
hasActiveGrant: vi.fn(),
isPlatformOwner,
listGrantsForUser: vi.fn(),
revokeGrant: vi.fn(),
revokePlatformOwner: vi.fn(),
} as unknown as AuthorizationStore;
const app = createApiApp({
authorization: { evaluator, registry: composePlatformPermissionRegistry(modules), store },
account: createInMemoryAccountStore(),
dashboard: createInMemoryDashboardStore(),
dashboardHostAssets: testDashboardHostAssets(),
health: readyHealth(),
moduleConfiguration: emptyModuleConfiguration(),
moduleLifecycle: {
snapshot: moduleLifecycleSnapshotFor(
modules,
options.configuration ?? { modules: [{ enabled: false, moduleId: "retired" }] },
),
store: createInMemoryModuleLifecycleStore(),
},
moduleRealtime: EMPTY_MODULE_REALTIME_REGISTRY,
moduleRoutes: Object.freeze([]),
moduleServices: Object.freeze({
database: Object.freeze({}) as unknown as ModuleDatabase,
}) as LegacyModuleRuntimeServices,
moduleStorage: emptyModuleStorage(),
moduleUi: createModuleUiPublicationRegistry([]),
runtime,
trustedOrigins: ["https://dashboard.example.test"],
});
return { app, isPlatformOwner };
}
async function settings(options: Options = {}) {
const { app } = build(options);
const response = await app.request(DASHBOARD_SETTINGS_PATH);
const body =
response.status === 200 ? ((await response.json()) as DashboardSettingsResponse) : undefined;
return { body, response };
}
describe("GET /api/v1/dashboard/settings", () => {
it("shows a signed-in person with no administrative permission nothing at all", async () => {
const { body, response } = await settings();
expect(response.status).toBe(200);
expect(response.headers.get("cache-control")).toBe("no-store");
expect(body).toEqual({ modules: [], sections: [] });
});
it("refuses an anonymous viewer, like the navigation it sits beside", async () => {
const { response } = await settings({ principal: ANONYMOUS_PRINCIPAL });
expect(response.status).toBe(403);
});
it("shows the platform owner every section, in order, and every module with settings", async () => {
const { body } = await settings({ owner: true });
expect(body).toEqual({
modules: [
{ available: false, id: "retired", name: "Archive" },
{ available: true, id: "calendar", name: "Calendar" },
{ available: true, id: "signage", name: "Signage" },
],
sections: ["general", "branding", "displays", "discover", "system"],
});
});
it("shows Displays exactly to whoever may manage devices", async () => {
const { body } = await settings({ grants: [String(CORE_DEVICES_MANAGE)] });
expect(body).toEqual({ modules: [], sections: ["displays"] });
});
it("shows Discover — module lifecycle — exactly to whoever may manage which modules run", async () => {
const { body } = await settings({ grants: [String(CORE_MODULES_LIFECYCLE_MANAGE)] });
expect(body).toEqual({ modules: [], sections: ["discover"] });
});
it("lists modules' settings exactly to whoever may configure modules, flagging disabled ones", async () => {
const { body } = await settings({ grants: [String(CORE_MODULES_CONFIGURATION_MANAGE)] });
// A module that declares no configuration has no settings page; a disabled module is listed so
// its address can say it is unavailable, but marked so the Dashboard does not navigate to it.
expect(body).toEqual({
modules: [
{ available: false, id: "retired", name: "Archive" },
{ available: true, id: "calendar", name: "Calendar" },
{ available: true, id: "signage", name: "Signage" },
],
sections: [],
});
});
it("keeps General, Branding, and System to the platform owner until each has a permission of its own", async () => {
const { body } = await settings({
grants: [
String(CORE_DEVICES_MANAGE),
String(CORE_MODULES_LIFECYCLE_MANAGE),
String(CORE_MODULES_CONFIGURATION_MANAGE),
],
});
expect(body?.sections).toEqual(["displays", "discover"]);
});
it("is a 503, never an empty answer, when authorization cannot be evaluated", async () => {
const { response } = await settings({ ownerLookupFails: true });
expect(response.status).toBe(503);
expect(await response.json()).toEqual({ code: "AUTHORIZATION_EVALUATION_FAILED" });
});
it("is a hint, not authority: the data behind a section stays refused without its permission", async () => {
const { app } = build({ grants: [] });
for (const path of [
"/api/v1/module-lifecycle",
"/api/v1/module-configuration/signage",
"/api/v1/grants",
]) {
const response = await app.request(path);
expect(response.status, path).toBe(403);
}
});
});
+2 -123
View File
@@ -118,13 +118,13 @@ async function createDisplayModuleDirectory(): Promise<string> {
writeFile( writeFile(
path.join(moduleDirectory, "ekklesia.module.json"), path.join(moduleDirectory, "ekklesia.module.json"),
JSON.stringify({ JSON.stringify({
compatibility: { moduleSdk: "^0.2.0", platform: "^0.2.0" }, compatibility: { moduleSdk: "^0.1.0", platform: "^0.1.0" },
entrypoints: { entrypoints: {
display: { artifact: "./dist/ui/display.js", permission: displayPermission }, display: { artifact: "./dist/ui/display.js", permission: displayPermission },
server: "./dist/server/index.js", server: "./dist/server/index.js",
}, },
id: "announcements", id: "announcements",
manifestVersion: 8, manifestVersion: 7,
name: "Announcements fixture", name: "Announcements fixture",
permissions: [ permissions: [
{ {
@@ -782,127 +782,6 @@ describe.sequential("display device pairing, end to end", () => {
} }
}); });
/**
* ADR 0056: a credential lasts while the screen is in use, and dies 180 days after it was last
* seen. Ages are set directly in the database, exactly as months of real use would leave them.
*/
describe("credential lifetime", () => {
const ttlSeconds = 180 * 24 * 60 * 60;
const descriptor = "/api/v1/module-displays/announcements";
function renewal(response: Response): string | undefined {
return setCookieFor(response, "ekklesia_device_session");
}
it("keeps a screen that is in use paired long past 180 days, renewing its cookie", async () => {
const running = await platform("renewal");
const device = new DeviceBrowser(running.origin);
try {
await pairDevice(running, device);
const credential = device.header().replace(/^.*ekklesia_device_session=/u, "");
// Paired 200 days ago, and seen yesterday: a screen that has simply been working.
await query(
running.url,
`update ekklesia_core_device set created_at = now() - interval '200 days', last_seen_at = now() - interval '1 day'`,
);
const response = await device.request(descriptor);
expect(response.status).toBe(200);
// The same credential, re-issued with a full lifetime and the same protections.
const header = renewal(response);
expect(header).toBeDefined();
expect(header).toContain(`ekklesia_device_session=${credential};`);
expect(header).toMatch(new RegExp(`Max-Age=${ttlSeconds}(;|$)`, "u"));
expect(header).toMatch(/HttpOnly/u);
expect(header).toMatch(/SameSite=Strict/u);
expect(header).toMatch(/Path=\//u);
expect(header).not.toMatch(/Domain=/iu);
// Using it moved the server's window forward too.
const [row] = await query<{ recent: boolean }>(
running.url,
`select last_seen_at > now() - interval '1 minute' as recent from ekklesia_core_device`,
);
expect(row?.recent).toBe(true);
} finally {
await running.close();
}
});
it("keeps a credential paired under the fixed 180-day rule working once it is renewed", async () => {
const running = await platform("legacy");
const device = new DeviceBrowser(running.origin);
try {
await pairDevice(running, device);
// A production screen paired 179 days ago that checked in this morning: under the old
// rule it had one day left. It must carry straight on.
await query(
running.url,
`update ekklesia_core_device set created_at = now() - interval '179 days', last_seen_at = now() - interval '2 hours'`,
);
expect((await device.request(descriptor)).status).toBe(200);
await query(
running.url,
`update ekklesia_core_device set created_at = now() - interval '190 days'`,
);
expect((await device.request(descriptor)).status).toBe(200);
} finally {
await running.close();
}
});
it("lets an abandoned screen stop being an identity 180 days after it was last seen", async () => {
const running = await platform("abandoned");
const device = new DeviceBrowser(running.origin);
try {
await pairDevice(running, device);
await query(
running.url,
`update ekklesia_core_device set created_at = now() - interval '400 days', last_seen_at = now() - interval '181 days'`,
);
const response = await device.request(descriptor);
// Anonymous, exactly as revoked or wrong credentials are: the screen shows a pairing code.
expect(response.status).toBe(403);
expect(renewal(response)).toBeUndefined();
} finally {
await running.close();
}
});
it("never renews a revoked screen", async () => {
const running = await platform("revoked-renewal");
const device = new DeviceBrowser(running.origin);
try {
await pairDevice(running, device);
const listed = (await (
await adminRequest(running.origin, running.ownerCookie, "/api/v1/devices")
).json()) as { devices: Array<{ id: string }> };
await adminRequest(
running.origin,
running.ownerCookie,
`/api/v1/devices/${listed.devices[0]?.id}/revoke`,
{},
);
const response = await device.request(descriptor);
expect(response.status).toBe(403);
expect(renewal(response)).toBeUndefined();
} finally {
await running.close();
}
});
it("does not hand a device cookie to a person viewing the display", async () => {
const running = await platform("human-renewal");
try {
const response = await adminRequest(running.origin, running.ownerCookie, descriptor);
expect(response.status).toBe(200);
expect(renewal(response)).toBeUndefined();
} finally {
await running.close();
}
});
});
it("keeps a human administrator's own session unaffected by a device cookie", async () => { it("keeps a human administrator's own session unaffected by a device cookie", async () => {
const running = await platform("mixed"); const running = await platform("mixed");
const device = new DeviceBrowser(running.origin); const device = new DeviceBrowser(running.origin);
+3 -5
View File
@@ -1,6 +1,8 @@
import { import {
ANONYMOUS_PRINCIPAL, ANONYMOUS_PRINCIPAL,
type ModuleDatabase,
type ModuleRouteHandler, type ModuleRouteHandler,
type ModuleRuntimeServices,
parsePermissionId, parsePermissionId,
type RegisteredModuleHttpRoute, type RegisteredModuleHttpRoute,
} from "@ekklesia/contracts"; } from "@ekklesia/contracts";
@@ -13,8 +15,6 @@ import type { EkklesiaIdentityRuntime } from "@ekklesia/core/identity";
import { import {
createModuleUiPublicationRegistry, createModuleUiPublicationRegistry,
EMPTY_MODULE_REALTIME_REGISTRY, EMPTY_MODULE_REALTIME_REGISTRY,
type LegacyModuleRuntimeServices,
type ModuleDatabase,
} from "@ekklesia/core/modules"; } from "@ekklesia/core/modules";
import { afterEach, describe, expect, it, vi } from "vitest"; import { afterEach, describe, expect, it, vi } from "vitest";
import { createApiApp } from "../src/app.js"; import { createApiApp } from "../src/app.js";
@@ -24,7 +24,6 @@ import {
HEALTH_READY_PATH, HEALTH_READY_PATH,
READINESS_DATABASE_TIMEOUT_MS, READINESS_DATABASE_TIMEOUT_MS,
} from "../src/health-routes.js"; } from "../src/health-routes.js";
import { createInMemoryAccountStore } from "./support/in-memory-account-store.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 { testDashboardHostAssets } from "./support/dashboard-host-assets.js";
import { emptyModuleConfiguration } from "./support/in-memory-module-configuration.js"; import { emptyModuleConfiguration } from "./support/in-memory-module-configuration.js";
@@ -57,7 +56,7 @@ function fakeStore(): AuthorizationStore {
}; };
} }
function fakeModuleServices(): LegacyModuleRuntimeServices { function fakeModuleServices(): ModuleRuntimeServices {
return Object.freeze({ database: Object.freeze({}) as unknown as ModuleDatabase }); return Object.freeze({ database: Object.freeze({}) as unknown as ModuleDatabase });
} }
@@ -92,7 +91,6 @@ function buildApp(
moduleLifecycle: emptyModuleLifecycle(), moduleLifecycle: emptyModuleLifecycle(),
moduleConfiguration: emptyModuleConfiguration(), moduleConfiguration: emptyModuleConfiguration(),
moduleStorage: emptyModuleStorage(), moduleStorage: emptyModuleStorage(),
account: createInMemoryAccountStore(),
dashboard: createInMemoryDashboardStore(), dashboard: createInMemoryDashboardStore(),
dashboardHostAssets: testDashboardHostAssets(), dashboardHostAssets: testDashboardHostAssets(),
health, health,
@@ -151,13 +151,6 @@ 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" }),
+7 -74
View File
@@ -20,7 +20,7 @@ vi.mock("@hono/node-server", () => ({ serve: serveMock }));
vi.mock("../src/dashboard-host-assets.js", async (importOriginal) => { vi.mock("../src/dashboard-host-assets.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../src/dashboard-host-assets.js")>(); const actual = await importOriginal<typeof import("../src/dashboard-host-assets.js")>();
loadDashboardHostAssetsMock.mockImplementation(async () => loadDashboardHostAssetsMock.mockImplementation(async () =>
actual.createDashboardHostAssets("<!doctype html>", "export {};\n", "(() => {})();\n"), actual.createDashboardHostAssets("<!doctype html>", "export {};\n"),
); );
return { ...actual, loadDashboardHostAssets: loadDashboardHostAssetsMock }; return { ...actual, loadDashboardHostAssets: loadDashboardHostAssetsMock };
}); });
@@ -104,11 +104,11 @@ async function createFixtureModule(
): Promise<void> { ): Promise<void> {
const moduleDirectory = path.join(modulesDirectory, id); const moduleDirectory = path.join(modulesDirectory, id);
const manifest = { const manifest = {
manifestVersion: 8, manifestVersion: 7,
id, id,
name: `${id} module`, name: `${id} module`,
version: "0.1.0", version: "0.1.0",
compatibility: { platform: "^0.2.0", moduleSdk: "^0.2.0" }, compatibility: { platform: "^0.1.0", moduleSdk: "^0.1.0" },
permissions: [], permissions: [],
entrypoints: { server: "./dist/server/index.js" }, entrypoints: { server: "./dist/server/index.js" },
...manifestOverrides, ...manifestOverrides,
@@ -267,10 +267,10 @@ function fakeDiscoveredModule(
): DiscoveredModule { ): DiscoveredModule {
const manifest = parseModuleManifest({ const manifest = parseModuleManifest({
capabilities: [], capabilities: [],
compatibility: { moduleSdk: "^0.2.0", platform: "^0.2.0" }, compatibility: { moduleSdk: "^0.1.0", platform: "^0.1.0" },
entrypoints: { server: "./dist/does-not-exist/index.js" }, entrypoints: { server: "./dist/does-not-exist/index.js" },
id, id,
manifestVersion: 8, manifestVersion: 7,
name: `${id} module`, name: `${id} module`,
permissions, permissions,
version: "0.1.0", version: "0.1.0",
@@ -1004,73 +1004,6 @@ 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");
@@ -1435,9 +1368,9 @@ describe("Ekklesia API module runtime startup and shutdown", () => {
const recorder = createEventRecorder(); const recorder = createEventRecorder();
await createWorkingFixtureModule(modulesDirectory, "calendar", recorder, { withRoute: true }); await createWorkingFixtureModule(modulesDirectory, "calendar", recorder, { withRoute: true });
const discovered = await (await import("@ekklesia/core/modules")).discoverModules({ const discovered = await (await import("@ekklesia/core/modules")).discoverModules({
moduleSdkVersion: "0.2.0", moduleSdkVersion: "0.1.0",
modulesDirectory, modulesDirectory,
platformVersion: "0.2.0", platformVersion: "0.1.0",
}); });
mockPoolAndAuth(); mockPoolAndAuth();
mockSuccessfulServe(); mockSuccessfulServe();
@@ -6,6 +6,8 @@ import {
createAuthenticatedPrincipal, createAuthenticatedPrincipal,
type EkklesiaPrincipal, type EkklesiaPrincipal,
type ModuleConfigurationDeclarationInput, type ModuleConfigurationDeclarationInput,
type ModuleDatabase,
type ModuleRuntimeServices,
parseEkklesiaUserId, parseEkklesiaUserId,
} from "@ekklesia/contracts"; } from "@ekklesia/contracts";
import { import {
@@ -20,12 +22,10 @@ import {
createModuleUiPublicationRegistry, createModuleUiPublicationRegistry,
type DiscoveredModule, type DiscoveredModule,
discoverModules, discoverModules,
type LegacyModuleRuntimeServices,
type ModuleConfigurationRecord, type ModuleConfigurationRecord,
type ModuleConfigurationStore, type ModuleConfigurationStore,
ModuleConfigurationStoreError, ModuleConfigurationStoreError,
EMPTY_MODULE_REALTIME_REGISTRY, EMPTY_MODULE_REALTIME_REGISTRY,
type ModuleDatabase,
resolveModuleEligibility, resolveModuleEligibility,
} from "@ekklesia/core/modules"; } from "@ekklesia/core/modules";
import type { Hono } from "hono"; import type { Hono } from "hono";
@@ -34,7 +34,6 @@ import { createApiApp } from "../src/app.js";
import { CORE_MUTATION_MAX_BODY_BYTES } from "../src/body-limits.js"; 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 { createInMemoryAccountStore } from "./support/in-memory-account-store.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 { testDashboardHostAssets } from "./support/dashboard-host-assets.js";
import { readyHealth } from "./support/ready-health.js"; import { readyHealth } from "./support/ready-health.js";
@@ -109,7 +108,7 @@ async function createModule(modulesDirectory: string, fixture: ModuleFixture): P
configuration: fixture.configuration ?? [], configuration: fixture.configuration ?? [],
entrypoints: { server: "./dist/server/index.js" }, entrypoints: { server: "./dist/server/index.js" },
id: fixture.id, id: fixture.id,
manifestVersion: 8, manifestVersion: 7,
name: `${fixture.id} module`, name: `${fixture.id} module`,
version: "1.0.0", version: "1.0.0",
}), }),
@@ -191,7 +190,6 @@ async function install(options: InstallOptions = {}): Promise<Installation> {
registry: composePlatformPermissionRegistry(modules), registry: composePlatformPermissionRegistry(modules),
store: authorizationStore, store: authorizationStore,
}, },
account: createInMemoryAccountStore(),
dashboard: createInMemoryDashboardStore(), dashboard: createInMemoryDashboardStore(),
dashboardHostAssets: testDashboardHostAssets(), dashboardHostAssets: testDashboardHostAssets(),
health: readyHealth(), health: readyHealth(),
@@ -215,7 +213,7 @@ async function install(options: InstallOptions = {}): Promise<Installation> {
moduleRealtime: EMPTY_MODULE_REALTIME_REGISTRY, moduleRealtime: EMPTY_MODULE_REALTIME_REGISTRY,
moduleServices: Object.freeze({ moduleServices: Object.freeze({
database: Object.freeze({}) as unknown as ModuleDatabase, database: Object.freeze({}) as unknown as ModuleDatabase,
}) as LegacyModuleRuntimeServices, }) as ModuleRuntimeServices,
moduleUi: createModuleUiPublicationRegistry(eligibility.eligibleModules), moduleUi: createModuleUiPublicationRegistry(eligibility.eligibleModules),
runtime, runtime,
trustedOrigins: [trustedOrigin], trustedOrigins: [trustedOrigin],
+5 -36
View File
@@ -8,7 +8,6 @@ import {
createPostgresMigrationDatabase, createPostgresMigrationDatabase,
discoverAllMigrations, discoverAllMigrations,
} from "@ekklesia/core/migrations"; } from "@ekklesia/core/migrations";
import { moduleDatabaseExecutorModuleId } from "@ekklesia/core/modules";
import { Client, type QueryResultRow } from "pg"; import { Client, type QueryResultRow } from "pg";
import { afterAll, describe, expect, it } from "vitest"; import { afterAll, describe, expect, it } from "vitest";
import { createApiRuntimeConfig } from "../src/config.js"; import { createApiRuntimeConfig } from "../src/config.js";
@@ -195,29 +194,6 @@ async function activeConnectionCount(databaseName: string): Promise<number> {
} }
} }
/** How long a shutdown's closed connections may take to leave `pg_stat_activity`. Far below pg's
* 10 s idle timeout, so a connection that is genuinely still open is still caught. */
const SHUTDOWN_SETTLE_MS = 3_000;
/**
* The connections left on `databaseName` once a shutdown has had a moment to land.
*
* PostgreSQL ends a backend asynchronously: `pool.end()` resolves once the client side has closed,
* and the server can still list that backend for a moment afterwards. On a loaded CI runner that
* moment was long enough for an immediate count to see one. So this waits, briefly, for the count to
* reach zero; a leaked pooled client would stay idle and listed far past the deadline.
*/
async function connectionsLeftAfterShutdown(databaseName: string): Promise<number> {
const deadline = Date.now() + SHUTDOWN_SETTLE_MS;
for (;;) {
const count = await activeConnectionCount(databaseName);
if (count === 0 || Date.now() >= deadline) {
return count;
}
await new Promise((resolve) => setTimeout(resolve, 50));
}
}
async function createDisposableDatabase(label: string): Promise<{ name: string; url: string }> { async function createDisposableDatabase(label: string): Promise<{ name: string; url: string }> {
const name = makeDatabaseName(label); const name = makeDatabaseName(label);
await createDatabase(name); await createDatabase(name);
@@ -235,9 +211,9 @@ async function applyCoreMigrations(url: string): Promise<void> {
const aggregate = await discoverAllMigrations({ const aggregate = await discoverAllMigrations({
coreMigrationsDirectory, coreMigrationsDirectory,
moduleDiscovery: { moduleDiscovery: {
moduleSdkVersion: "0.2.0", moduleSdkVersion: "0.1.0",
modulesDirectory: emptyModulesDirectory, modulesDirectory: emptyModulesDirectory,
platformVersion: "0.2.0", platformVersion: "0.1.0",
}, },
}); });
expect(aggregate.migrations.map((entry) => entry.identity)).toEqual([ expect(aggregate.migrations.map((entry) => entry.identity)).toEqual([
@@ -249,7 +225,6 @@ async function applyCoreMigrations(url: string): Promise<void> {
"core:0005_module_configuration", "core:0005_module_configuration",
"core:0006_device_identity", "core:0006_device_identity",
"core:0007_verified_bootstrap_owners", "core:0007_verified_bootstrap_owners",
"core:0008_user_preferences",
]); ]);
await applyModuleMigrations({ aggregate, database: createPostgresMigrationDatabase(url) }); await applyModuleMigrations({ aggregate, database: createPostgresMigrationDatabase(url) });
} finally { } finally {
@@ -265,9 +240,9 @@ async function applyMigrationsWithModule(url: string): Promise<void> {
const aggregate = await discoverAllMigrations({ const aggregate = await discoverAllMigrations({
coreMigrationsDirectory, coreMigrationsDirectory,
moduleDiscovery: { moduleDiscovery: {
moduleSdkVersion: "0.2.0", moduleSdkVersion: "0.1.0",
modulesDirectory: fixtureModulesDirectory, modulesDirectory: fixtureModulesDirectory,
platformVersion: "0.2.0", platformVersion: "0.1.0",
}, },
}); });
// Core migrations aggregate first, exactly once each, and always before any module migration. // Core migrations aggregate first, exactly once each, and always before any module migration.
@@ -280,7 +255,6 @@ async function applyMigrationsWithModule(url: string): Promise<void> {
"core:0005_module_configuration", "core:0005_module_configuration",
"core:0006_device_identity", "core:0006_device_identity",
"core:0007_verified_bootstrap_owners", "core:0007_verified_bootstrap_owners",
"core:0008_user_preferences",
"db-facade:0000_initial", "db-facade:0000_initial",
]); ]);
expect(aggregate.migrations[0]?.checksum).toBe( expect(aggregate.migrations[0]?.checksum).toBe(
@@ -467,11 +441,6 @@ describe.sequential("live API — module database explicit migration ownership",
const handle = createApiDatabase(database.url); const handle = createApiDatabase(database.url);
expect(handle.module).toBeDefined(); expect(handle.module).toBeDefined();
const alpha = handle.moduleExecutor("alpha");
const beta = handle.moduleExecutor("beta");
expect(alpha).not.toBe(beta);
expect(moduleDatabaseExecutorModuleId(alpha)).toBe("alpha");
expect(moduleDatabaseExecutorModuleId(beta)).toBe("beta");
await handle.close(); await handle.close();
expect(await tableExists(database.url)).toBe(false); expect(await tableExists(database.url)).toBe(false);
@@ -785,7 +754,7 @@ describe.sequential("live API — module database access through ModuleRuntimeSe
await running.shutdown(); await running.shutdown();
const remainingConnections = await connectionsLeftAfterShutdown(database.name); const remainingConnections = await activeConnectionCount(database.name);
expect(remainingConnections).toBe(0); expect(remainingConnections).toBe(0);
const eventsAfterShutdown = readRecorder(); const eventsAfterShutdown = readRecorder();
+4 -6
View File
@@ -5,6 +5,8 @@ import {
ANONYMOUS_PRINCIPAL, ANONYMOUS_PRINCIPAL,
createAuthenticatedPrincipal, createAuthenticatedPrincipal,
type EkklesiaPrincipal, type EkklesiaPrincipal,
type ModuleDatabase,
type ModuleRuntimeServices,
parseEkklesiaUserId, parseEkklesiaUserId,
} from "@ekklesia/contracts"; } from "@ekklesia/contracts";
import { import {
@@ -19,8 +21,6 @@ import {
type DiscoveredModule, type DiscoveredModule,
discoverModules, discoverModules,
EMPTY_MODULE_REALTIME_REGISTRY, EMPTY_MODULE_REALTIME_REGISTRY,
type LegacyModuleRuntimeServices,
type ModuleDatabase,
} from "@ekklesia/core/modules"; } from "@ekklesia/core/modules";
import type { Hono } from "hono"; import type { Hono } from "hono";
import { afterEach, describe, expect, it, vi } from "vitest"; import { afterEach, describe, expect, it, vi } from "vitest";
@@ -37,7 +37,6 @@ import { moduleApiBaseUrl } from "../src/module-http-routes.js";
import { moduleRealtimeBaseUrl } from "../src/module-realtime-routes.js"; 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 { createInMemoryAccountStore } from "./support/in-memory-account-store.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 { testDashboardHostAssets } from "./support/dashboard-host-assets.js";
import { readyHealth } from "./support/ready-health.js"; import { readyHealth } from "./support/ready-health.js";
@@ -106,7 +105,7 @@ async function createModule(modulesDirectory: string, fixture: ModuleFixture = {
: {}), : {}),
}, },
id, id,
manifestVersion: 8, manifestVersion: 7,
name: `${id} module`, name: `${id} module`,
permissions: fixture.display permissions: fixture.display
? [ ? [
@@ -156,7 +155,7 @@ interface Installation {
readonly modules: readonly DiscoveredModule[]; readonly modules: readonly DiscoveredModule[];
} }
function fakeServices(): LegacyModuleRuntimeServices { function fakeServices(): ModuleRuntimeServices {
return Object.freeze({ database: Object.freeze({}) as unknown as ModuleDatabase }); return Object.freeze({ database: Object.freeze({}) as unknown as ModuleDatabase });
} }
@@ -200,7 +199,6 @@ async function install(options: InstallOptions): Promise<Installation> {
registry: composePlatformPermissionRegistry(modules), registry: composePlatformPermissionRegistry(modules),
store: fakeAuthorizationStore(), store: fakeAuthorizationStore(),
}, },
account: createInMemoryAccountStore(),
dashboard: createInMemoryDashboardStore(), dashboard: createInMemoryDashboardStore(),
dashboardHostAssets: testDashboardHostAssets(), dashboardHostAssets: testDashboardHostAssets(),
health: readyHealth(), health: readyHealth(),
+15 -48
View File
@@ -1,11 +1,13 @@
import { import {
ANONYMOUS_PRINCIPAL, ANONYMOUS_PRINCIPAL,
createAuthenticatedPrincipal, createAuthenticatedPrincipal,
type ModuleDatabase,
type ModuleRouteHandler, type ModuleRouteHandler,
type ModuleRouteHandlerContext, type ModuleRouteHandlerContext,
type ModuleRuntimeServices,
moduleRoutesOverlap, moduleRoutesOverlap,
type PermissionId,
parsePermissionId, parsePermissionId,
type PermissionId,
type RegisteredModuleHttpRoute, type RegisteredModuleHttpRoute,
} from "@ekklesia/contracts"; } from "@ekklesia/contracts";
import { import {
@@ -18,24 +20,20 @@ import type { EkklesiaIdentityRuntime } from "@ekklesia/core/identity";
import { import {
createModuleUiPublicationRegistry, createModuleUiPublicationRegistry,
EMPTY_MODULE_REALTIME_REGISTRY, EMPTY_MODULE_REALTIME_REGISTRY,
type LegacyModuleRuntimeServices,
type ModuleDatabase,
type ModuleRuntimeServicesLookup,
} from "@ekklesia/core/modules"; } from "@ekklesia/core/modules";
import { describe, expect, it, vi } from "vitest"; import { describe, expect, it, vi } from "vitest";
import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
import { testDashboardHostAssets } from "./support/dashboard-host-assets.js";
import { readyHealth } from "./support/ready-health.js";
import { createApiApp } from "../src/app.js"; import { createApiApp } from "../src/app.js";
import { import {
canonicalizeVaryHeader, canonicalizeVaryHeader,
ModuleRouteConfigurationError,
mergedVaryHeader, mergedVaryHeader,
ModuleRouteConfigurationError,
} from "../src/module-http-routes.js"; } from "../src/module-http-routes.js";
import { testDashboardHostAssets } from "./support/dashboard-host-assets.js";
import { createInMemoryAccountStore } from "./support/in-memory-account-store.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 { emptyModuleLifecycle } from "./support/in-memory-module-lifecycle.js";
import { emptyModuleConfiguration } from "./support/in-memory-module-configuration.js";
import { emptyModuleStorage } from "./support/in-memory-module-storage.js"; import { emptyModuleStorage } from "./support/in-memory-module-storage.js";
import { readyHealth } from "./support/ready-health.js";
const trustedOrigin = "https://dashboard.example.test"; const trustedOrigin = "https://dashboard.example.test";
const untrustedOrigin = "https://evil.example.test"; const untrustedOrigin = "https://evil.example.test";
@@ -75,11 +73,11 @@ function moduleRoute(
return Object.freeze({ bodyMode: "buffered", maxBodyBytes: 1024, ...fields }); return Object.freeze({ bodyMode: "buffered", maxBodyBytes: 1024, ...fields });
} }
/** A small typed fake `LegacyModuleRuntimeServices` — these tests exercise route mounting, /** A small typed fake `ModuleRuntimeServices` — these tests exercise route mounting,
* authorization, and response sanitization, never real database behavior, so no PostgreSQL is * authorization, and response sanitization, never real database behavior, so no PostgreSQL is
* required. Each `database` method stays a distinct spy so a test can assert none of them were * required. Each `database` method stays a distinct spy so a test can assert none of them were
* ever called (denied, trusted-origin-rejected, and `OPTIONS` requests never reach a handler). */ * ever called (denied, trusted-origin-rejected, and `OPTIONS` requests never reach a handler). */
function fakeModuleServices(): LegacyModuleRuntimeServices { function fakeModuleServices(): ModuleRuntimeServices {
const database = Object.freeze({ const database = Object.freeze({
delete: vi.fn(), delete: vi.fn(),
execute: vi.fn(), execute: vi.fn(),
@@ -93,7 +91,7 @@ function fakeModuleServices(): LegacyModuleRuntimeServices {
return Object.freeze({ database }); return Object.freeze({ database });
} }
function expectDatabaseFakeUntouched(services: LegacyModuleRuntimeServices): void { function expectDatabaseFakeUntouched(services: ModuleRuntimeServices): void {
expect(vi.mocked(services.database.select)).not.toHaveBeenCalled(); expect(vi.mocked(services.database.select)).not.toHaveBeenCalled();
expect(vi.mocked(services.database.selectDistinct)).not.toHaveBeenCalled(); expect(vi.mocked(services.database.selectDistinct)).not.toHaveBeenCalled();
expect(vi.mocked(services.database.selectDistinctOn)).not.toHaveBeenCalled(); expect(vi.mocked(services.database.selectDistinctOn)).not.toHaveBeenCalled();
@@ -109,7 +107,7 @@ function buildModuleApp(options: {
readonly registry: PermissionRegistry; readonly registry: PermissionRegistry;
readonly routes: readonly RegisteredModuleHttpRoute[]; readonly routes: readonly RegisteredModuleHttpRoute[];
readonly runtime?: EkklesiaIdentityRuntime; readonly runtime?: EkklesiaIdentityRuntime;
readonly services?: LegacyModuleRuntimeServices | ModuleRuntimeServicesLookup; readonly services?: ModuleRuntimeServices;
readonly trustedOrigins?: readonly string[]; readonly trustedOrigins?: readonly string[];
}) { }) {
const evaluator = options.evaluator ?? { const evaluator = options.evaluator ?? {
@@ -126,7 +124,6 @@ function buildModuleApp(options: {
moduleLifecycle: emptyModuleLifecycle(), moduleLifecycle: emptyModuleLifecycle(),
moduleConfiguration: emptyModuleConfiguration(), moduleConfiguration: emptyModuleConfiguration(),
moduleStorage: emptyModuleStorage(), moduleStorage: emptyModuleStorage(),
account: createInMemoryAccountStore(),
dashboard: createInMemoryDashboardStore(), dashboard: createInMemoryDashboardStore(),
dashboardHostAssets: testDashboardHostAssets(), dashboardHostAssets: testDashboardHostAssets(),
health: readyHealth(), health: readyHealth(),
@@ -148,36 +145,6 @@ function permissionRegistry(
} }
describe("module HTTP route mounting", () => { describe("module HTTP route mounting", () => {
it("selects the owning module's stable service entry for its handler", async () => {
const calendar = fakeModuleServices();
const people = fakeModuleServices();
const services: ModuleRuntimeServicesLookup = new Map([
["calendar", calendar],
["people", people],
]);
let received: unknown;
const { app } = buildModuleApp({
registry: permissionRegistry([{ id: readPermission, baseline: "public" }]),
routes: [
moduleRoute({
handler: (_request, context) => {
received = context.services;
return Response.json({ ok: true });
},
method: "GET",
moduleId: "calendar",
path: "/services",
permission: readPermission,
}),
],
services,
});
expect((await app.request("/api/v1/modules/calendar/services")).status).toBe(200);
expect(received).toBe(calendar);
expect(received).not.toBe(people);
});
it('mounts a "/" route at exactly the module root, never with an added trailing slash', async () => { it('mounts a "/" route at exactly the module root, never with an added trailing slash', async () => {
const handler = vi.fn<ModuleRouteHandler>(async () => new Response("root", { status: 200 })); const handler = vi.fn<ModuleRouteHandler>(async () => new Response("root", { status: 200 }));
const { app } = buildModuleApp({ const { app } = buildModuleApp({
@@ -541,7 +508,7 @@ describe("module route authorization", () => {
expect(response.status).toBe(403); expect(response.status).toBe(403);
expect(await response.json()).toEqual({ code: "AUTHORIZATION_DENIED" }); expect(await response.json()).toEqual({ code: "AUTHORIZATION_DENIED" });
expect(handler).not.toHaveBeenCalled(); expect(handler).not.toHaveBeenCalled();
expectDatabaseFakeUntouched(services as LegacyModuleRuntimeServices); expectDatabaseFakeUntouched(services);
}); });
it("does not invoke the handler when principal resolution fails", async () => { it("does not invoke the handler when principal resolution fails", async () => {
@@ -670,7 +637,7 @@ describe("module route trusted-origin enforcement", () => {
} }
expect(handler).not.toHaveBeenCalled(); expect(handler).not.toHaveBeenCalled();
expect(evaluator.authorize).not.toHaveBeenCalled(); expect(evaluator.authorize).not.toHaveBeenCalled();
expectDatabaseFakeUntouched(services as LegacyModuleRuntimeServices); expectDatabaseFakeUntouched(services);
}); });
it("allows a trusted-origin state-changing request through to the handler", async () => { it("allows a trusted-origin state-changing request through to the handler", async () => {
@@ -799,7 +766,7 @@ describe("module route CORS", () => {
expect(handler).not.toHaveBeenCalled(); expect(handler).not.toHaveBeenCalled();
expect(evaluator.authorize).not.toHaveBeenCalled(); expect(evaluator.authorize).not.toHaveBeenCalled();
expectDatabaseFakeUntouched(services as LegacyModuleRuntimeServices); expectDatabaseFakeUntouched(services);
}); });
it("never reflects an untrusted origin for a module route", async () => { it("never reflects an untrusted origin for a module route", async () => {
@@ -5,6 +5,8 @@ import {
ANONYMOUS_PRINCIPAL, ANONYMOUS_PRINCIPAL,
createAuthenticatedPrincipal, createAuthenticatedPrincipal,
type EkklesiaPrincipal, type EkklesiaPrincipal,
type ModuleDatabase,
type ModuleRuntimeServices,
parseEkklesiaUserId, parseEkklesiaUserId,
} from "@ekklesia/contracts"; } from "@ekklesia/contracts";
import { import {
@@ -18,8 +20,6 @@ import {
createModuleUiPublicationRegistry, createModuleUiPublicationRegistry,
type DiscoveredModule, type DiscoveredModule,
discoverModules, discoverModules,
type LegacyModuleRuntimeServices,
type ModuleDatabase,
type ModuleLifecycleStore, type ModuleLifecycleStore,
ModuleLifecycleStoreError, ModuleLifecycleStoreError,
EMPTY_MODULE_REALTIME_REGISTRY, EMPTY_MODULE_REALTIME_REGISTRY,
@@ -32,7 +32,6 @@ import { DASHBOARD_NAVIGATION_PATH } from "../src/dashboard-routes.js";
import { CORE_MUTATION_MAX_BODY_BYTES } from "../src/body-limits.js"; 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 { createInMemoryAccountStore } from "./support/in-memory-account-store.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 { testDashboardHostAssets } from "./support/dashboard-host-assets.js";
import { readyHealth } from "./support/ready-health.js"; import { readyHealth } from "./support/ready-health.js";
@@ -80,7 +79,7 @@ async function createModule(modulesDirectory: string, fixture: ModuleFixture): P
...(fixture.dependencies ? { dependencies: fixture.dependencies } : {}), ...(fixture.dependencies ? { dependencies: fixture.dependencies } : {}),
entrypoints: { server: "./dist/server/index.js", ui: "./dist/ui/index.js" }, entrypoints: { server: "./dist/server/index.js", ui: "./dist/ui/index.js" },
id: fixture.id, id: fixture.id,
manifestVersion: 8, manifestVersion: 7,
name: `${fixture.id} module`, name: `${fixture.id} module`,
version: "1.0.0", version: "1.0.0",
}), }),
@@ -173,7 +172,6 @@ async function install(options: InstallOptions = {}): Promise<Installation> {
registry: composePlatformPermissionRegistry(modules), registry: composePlatformPermissionRegistry(modules),
store: authorizationStore, store: authorizationStore,
}, },
account: createInMemoryAccountStore(),
dashboard: createInMemoryDashboardStore(), dashboard: createInMemoryDashboardStore(),
dashboardHostAssets: testDashboardHostAssets(), dashboardHostAssets: testDashboardHostAssets(),
health: readyHealth(), health: readyHealth(),
@@ -191,7 +189,7 @@ async function install(options: InstallOptions = {}): Promise<Installation> {
moduleRealtime: EMPTY_MODULE_REALTIME_REGISTRY, moduleRealtime: EMPTY_MODULE_REALTIME_REGISTRY,
moduleServices: Object.freeze({ moduleServices: Object.freeze({
database: Object.freeze({}) as unknown as ModuleDatabase, database: Object.freeze({}) as unknown as ModuleDatabase,
}) as LegacyModuleRuntimeServices, }) as ModuleRuntimeServices,
moduleUi: createModuleUiPublicationRegistry(eligibility.eligibleModules), moduleUi: createModuleUiPublicationRegistry(eligibility.eligibleModules),
runtime, runtime,
trustedOrigins: [trustedOrigin], trustedOrigins: [trustedOrigin],
+4 -4
View File
@@ -4,7 +4,9 @@ import path from "node:path";
import { import {
ANONYMOUS_PRINCIPAL, ANONYMOUS_PRINCIPAL,
createAuthenticatedPrincipal, createAuthenticatedPrincipal,
type ModuleDatabase,
type ModuleRealtimePublisher, type ModuleRealtimePublisher,
type ModuleRuntimeServices,
parseEkklesiaUserId, parseEkklesiaUserId,
} from "@ekklesia/contracts"; } from "@ekklesia/contracts";
import { import {
@@ -16,8 +18,6 @@ import type { EkklesiaIdentityRuntime } from "@ekklesia/core/identity";
import { import {
composePlatformPermissionRegistry, composePlatformPermissionRegistry,
discoverModules, discoverModules,
type LegacyModuleRuntimeServices,
type ModuleDatabase,
type ModuleRealtimeRegistry, type ModuleRealtimeRegistry,
type ModuleRealtimeSubscription, type ModuleRealtimeSubscription,
startDiscoveredModuleRuntime, startDiscoveredModuleRuntime,
@@ -188,7 +188,7 @@ async function createFixtureModule(control: FixtureControl): Promise<{
compatibility: { moduleSdk: "^0.1.0", platform: "^0.1.0" }, compatibility: { moduleSdk: "^0.1.0", platform: "^0.1.0" },
entrypoints: { server: "./dist/server/index.js" }, entrypoints: { server: "./dist/server/index.js" },
id: "announcements", id: "announcements",
manifestVersion: 8, manifestVersion: 7,
name: "Announcements fixture", name: "Announcements fixture",
permissions: [ permissions: [
{ {
@@ -226,7 +226,7 @@ async function createFixtureModule(control: FixtureControl): Promise<{
return { directory, symbol }; return { directory, symbol };
} }
function services(): LegacyModuleRuntimeServices { function services(): ModuleRuntimeServices {
return Object.freeze({ database: Object.freeze({}) as ModuleDatabase }); return Object.freeze({ database: Object.freeze({}) as ModuleDatabase });
} }
+5 -46
View File
@@ -5,6 +5,8 @@ import {
ANONYMOUS_PRINCIPAL, ANONYMOUS_PRINCIPAL,
createAuthenticatedPrincipal, createAuthenticatedPrincipal,
type EkklesiaPrincipal, type EkklesiaPrincipal,
type ModuleDatabase,
type ModuleRuntimeServices,
parseEkklesiaUserId, parseEkklesiaUserId,
} from "@ekklesia/contracts"; } from "@ekklesia/contracts";
import { import {
@@ -16,8 +18,6 @@ import {
composePlatformPermissionRegistry, composePlatformPermissionRegistry,
createModuleUiPublicationRegistry, createModuleUiPublicationRegistry,
discoverModules, discoverModules,
type LegacyModuleRuntimeServices,
type ModuleDatabase,
startDiscoveredModuleRuntime, startDiscoveredModuleRuntime,
} from "@ekklesia/core/modules"; } from "@ekklesia/core/modules";
import { import {
@@ -33,10 +33,8 @@ import {
createModuleUiContext, createModuleUiContext,
type EkklesiaHostConfig, type EkklesiaHostConfig,
moduleUiSourceUrl, moduleUiSourceUrl,
saveHostAccountPreferences,
} from "@ekklesia/shell"; } from "@ekklesia/shell";
import { afterEach, describe, expect, it, vi } from "vitest"; import { afterEach, describe, expect, it, vi } from "vitest";
import { createInMemoryAccountStore } from "./support/in-memory-account-store.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 { testDashboardHostAssets } from "./support/dashboard-host-assets.js";
import { readyHealth } from "./support/ready-health.js"; import { readyHealth } from "./support/ready-health.js";
@@ -131,7 +129,7 @@ async function createFixtureModule(
: {}), : {}),
}, },
id, id,
manifestVersion: 8, manifestVersion: 7,
name, name,
permissions: [ permissions: [
{ {
@@ -218,8 +216,6 @@ interface HarnessOptions {
* single module proves very little. */ * single module proves very little. */
readonly extraModules?: ReadonlyArray<{ readonly id: string; readonly name: string }>; readonly extraModules?: ReadonlyArray<{ readonly id: string; readonly name: string }>;
readonly platformOwner?: boolean; readonly platformOwner?: boolean;
/** People with an account, for the account area's routes (ADR 0062). */
readonly people?: Parameters<typeof createInMemoryAccountStore>[0];
} }
interface Harness { interface Harness {
@@ -245,7 +241,7 @@ async function startHarness(options: HarnessOptions = {}): Promise<Harness> {
modulesDirectory, modulesDirectory,
platformVersion: "0.1.0", platformVersion: "0.1.0",
}); });
const services: LegacyModuleRuntimeServices = Object.freeze({ const services: ModuleRuntimeServices = Object.freeze({
database: Object.freeze({}) as unknown as ModuleDatabase, database: Object.freeze({}) as unknown as ModuleDatabase,
}); });
// The module's real runtime: its server entrypoint is imported, registered, and started, and the // The module's real runtime: its server entrypoint is imported, registered, and started, and the
@@ -293,7 +289,6 @@ async function startHarness(options: HarnessOptions = {}): Promise<Harness> {
}, },
moduleConfiguration: moduleConfigurationOptionFor(modules), moduleConfiguration: moduleConfigurationOptionFor(modules),
moduleStorage: moduleStorageOptionFor(modules), moduleStorage: moduleStorageOptionFor(modules),
account: createInMemoryAccountStore(options.people ?? []),
dashboard: createInMemoryDashboardStore(), dashboard: createInMemoryDashboardStore(),
dashboardHostAssets: testDashboardHostAssets(), dashboardHostAssets: testDashboardHostAssets(),
health: readyHealth(), health: readyHealth(),
@@ -307,10 +302,7 @@ async function startHarness(options: HarnessOptions = {}): Promise<Harness> {
if (url.pathname.startsWith("/api/v1/modules/")) { if (url.pathname.startsWith("/api/v1/modules/")) {
moduleApiCalls.push(`${init?.method ?? "GET"} ${url.pathname}${url.search}`); moduleApiCalls.push(`${init?.method ?? "GET"} ${url.pathname}${url.search}`);
} }
// A browser names the Dashboard's origin on every cross-origin request; the shell never sets it. return app.request(url.href, init);
const headers = new Headers(init?.headers);
headers.set("Origin", "https://dashboard.example.test");
return app.request(url.href, { ...init, headers });
}) as unknown as typeof fetch; }) as unknown as typeof fetch;
return { return {
@@ -746,39 +738,6 @@ describe("end to end: a discovered module's UI mounts in the Ekklesia host", ()
} }
}); });
it("loads the signed-in person's own account, and a saved preference is what the next load sees (ADR 0062)", async () => {
const harness = await startHarness({
people: [{ email: "[email protected]", name: "Viewer Person", userId: viewerId }],
principal: createAuthenticatedPrincipal(viewerId),
});
try {
const first = await bootstrapHost(harness.config);
expect(first.account).toEqual({
email: "[email protected]",
name: "Viewer Person",
preferences: { appearance: "system", motion: "system" },
});
expect(await saveHostAccountPreferences(harness.config, { appearance: "dark" })).toEqual({
appearance: "dark",
motion: "system",
});
expect(await saveHostAccountPreferences(harness.config, { motion: "reduce" })).toEqual({
appearance: "dark",
motion: "reduce",
});
// Another device signing in reads the account, not this one's browser.
const next = await bootstrapHost(harness.config);
expect(next.account !== "unavailable" && next.account.preferences).toEqual({
appearance: "dark",
motion: "reduce",
});
} finally {
await harness.stop();
}
});
it("gives an anonymous visitor no inventory and therefore nothing to mount", async () => { it("gives an anonymous visitor no inventory and therefore nothing to mount", async () => {
const harness = await startHarness(); const harness = await startHarness();
try { try {
+4 -6
View File
@@ -5,6 +5,8 @@ import {
ANONYMOUS_PRINCIPAL, ANONYMOUS_PRINCIPAL,
createAuthenticatedPrincipal, createAuthenticatedPrincipal,
type EkklesiaPrincipal, type EkklesiaPrincipal,
type ModuleDatabase,
type ModuleRuntimeServices,
parseEkklesiaUserId, parseEkklesiaUserId,
} from "@ekklesia/contracts"; } from "@ekklesia/contracts";
import { import {
@@ -19,12 +21,9 @@ import {
type DiscoveredModule, type DiscoveredModule,
discoverModules, discoverModules,
EMPTY_MODULE_REALTIME_REGISTRY, EMPTY_MODULE_REALTIME_REGISTRY,
type LegacyModuleRuntimeServices,
type ModuleDatabase,
} from "@ekklesia/core/modules"; } from "@ekklesia/core/modules";
import type { Hono } from "hono"; import type { Hono } from "hono";
import { afterEach, describe, expect, it, vi } from "vitest"; import { afterEach, describe, expect, it, vi } from "vitest";
import { createInMemoryAccountStore } from "./support/in-memory-account-store.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 { testDashboardHostAssets } from "./support/dashboard-host-assets.js";
import { readyHealth } from "./support/ready-health.js"; import { readyHealth } from "./support/ready-health.js";
@@ -100,7 +99,7 @@ async function createModule(modulesDirectory: string, fixture: ModuleFixture): P
version: "1.0.0", version: "1.0.0",
})), })),
id: fixture.id, id: fixture.id,
manifestVersion: 8, manifestVersion: 7,
name: `${fixture.id} module`, name: `${fixture.id} module`,
permissions: (fixture.permissions ?? []).map((permission) => ({ permissions: (fixture.permissions ?? []).map((permission) => ({
...permission, ...permission,
@@ -193,7 +192,7 @@ async function install(options: InstallOptions): Promise<Installation> {
revokeGrant: vi.fn(), revokeGrant: vi.fn(),
revokePlatformOwner: vi.fn(), revokePlatformOwner: vi.fn(),
} as unknown as AuthorizationStore; } as unknown as AuthorizationStore;
const services: LegacyModuleRuntimeServices = Object.freeze({ const services: ModuleRuntimeServices = Object.freeze({
database: Object.freeze({}) as unknown as ModuleDatabase, database: Object.freeze({}) as unknown as ModuleDatabase,
}); });
const permissionRegistry = composePlatformPermissionRegistry(modules); const permissionRegistry = composePlatformPermissionRegistry(modules);
@@ -210,7 +209,6 @@ async function install(options: InstallOptions): Promise<Installation> {
}, },
moduleConfiguration: moduleConfigurationOptionFor(modules), moduleConfiguration: moduleConfigurationOptionFor(modules),
moduleStorage: moduleStorageOptionFor(modules), moduleStorage: moduleStorageOptionFor(modules),
account: createInMemoryAccountStore(),
dashboard: createInMemoryDashboardStore(), dashboard: createInMemoryDashboardStore(),
dashboardHostAssets: testDashboardHostAssets(), dashboardHostAssets: testDashboardHostAssets(),
health: readyHealth(), health: readyHealth(),
+7 -7
View File
@@ -1,7 +1,11 @@
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import path from "node:path"; import path from "node:path";
import { ANONYMOUS_PRINCIPAL } from "@ekklesia/contracts"; import {
ANONYMOUS_PRINCIPAL,
type ModuleDatabase,
type ModuleRuntimeServices,
} from "@ekklesia/contracts";
import type { AuthorizationEvaluator, AuthorizationStore } from "@ekklesia/core/authorization"; import type { AuthorizationEvaluator, AuthorizationStore } from "@ekklesia/core/authorization";
import type { EkklesiaIdentityRuntime } from "@ekklesia/core/identity"; import type { EkklesiaIdentityRuntime } from "@ekklesia/core/identity";
import { import {
@@ -10,15 +14,12 @@ import {
type DiscoveredModule, type DiscoveredModule,
discoverModules, discoverModules,
EMPTY_MODULE_REALTIME_REGISTRY, EMPTY_MODULE_REALTIME_REGISTRY,
type LegacyModuleRuntimeServices,
type ModuleDatabase,
} from "@ekklesia/core/modules"; } from "@ekklesia/core/modules";
import { loadModuleUi } from "@ekklesia/module-ui-host"; import { loadModuleUi } from "@ekklesia/module-ui-host";
import { MODULE_UI_ABI_VERSION, NO_MODULE_UI_CONTRIBUTIONS } from "@ekklesia/module-ui-sdk"; import { MODULE_UI_ABI_VERSION, NO_MODULE_UI_CONTRIBUTIONS } from "@ekklesia/module-ui-sdk";
import { Window } from "happy-dom"; 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 { createInMemoryAccountStore } from "./support/in-memory-account-store.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 { testDashboardHostAssets } from "./support/dashboard-host-assets.js";
import { readyHealth } from "./support/ready-health.js"; import { readyHealth } from "./support/ready-health.js";
@@ -138,7 +139,7 @@ async function createModule(modulesDirectory: string, fixture: ModuleFixture): P
version: "1.0.0", version: "1.0.0",
})), })),
id: fixture.id, id: fixture.id,
manifestVersion: 8, manifestVersion: 7,
name: `${fixture.id} module`, name: `${fixture.id} module`,
permissions: permissions:
fixture.display === undefined fixture.display === undefined
@@ -214,7 +215,7 @@ async function install(
: modules.filter((module) => publicationModuleIds.includes(module.manifest.id)); : modules.filter((module) => publicationModuleIds.includes(module.manifest.id));
const database = Object.freeze({}) as unknown as ModuleDatabase; const database = Object.freeze({}) as unknown as ModuleDatabase;
const services: LegacyModuleRuntimeServices = Object.freeze({ database }); const services: ModuleRuntimeServices = Object.freeze({ database });
const runtime: EkklesiaIdentityRuntime = { const runtime: EkklesiaIdentityRuntime = {
handleAuthRequest: vi.fn().mockResolvedValue(new Response(null, { status: 200 })), handleAuthRequest: vi.fn().mockResolvedValue(new Response(null, { status: 200 })),
// Anonymous, and every authorization decision denied: whatever this route does, it does it // Anonymous, and every authorization decision denied: whatever this route does, it does it
@@ -255,7 +256,6 @@ async function install(
}, },
moduleConfiguration: moduleConfigurationOptionFor(modules), moduleConfiguration: moduleConfigurationOptionFor(modules),
moduleStorage: moduleStorageOptionFor(modules), moduleStorage: moduleStorageOptionFor(modules),
account: createInMemoryAccountStore(),
dashboard: createInMemoryDashboardStore(), dashboard: createInMemoryDashboardStore(),
dashboardHostAssets: testDashboardHostAssets(), dashboardHostAssets: testDashboardHostAssets(),
health: readyHealth(), health: readyHealth(),
@@ -9,6 +9,5 @@ export function testDashboardHostAssets(): DashboardHostAssets {
return createDashboardHostAssets( return createDashboardHostAssets(
'<!doctype html><div id="ekklesia-host"></div><script src="/assets/ekklesia-dashboard/app.js" type="module"></script>', '<!doctype html><div id="ekklesia-host"></div><script src="/assets/ekklesia-dashboard/app.js" type="module"></script>',
"export const dashboardHost = true;\n", "export const dashboardHost = true;\n",
"(() => {})();\n",
); );
} }
@@ -1,85 +0,0 @@
import type { EkklesiaUserId } from "@ekklesia/contracts";
import {
type AccountPreferences,
type AccountPreferencesUpdate,
type AccountStore,
AccountStoreError,
type AccountSummary,
DEFAULT_ACCOUNT_PREFERENCES,
parseAccountPreferencesUpdate,
} from "@ekklesia/core/account";
export interface InMemoryAccountPerson {
readonly userId: string;
readonly name: string;
readonly email: string;
readonly preferences?: Partial<AccountPreferences>;
}
export interface InMemoryAccountStore extends AccountStore {
/** Every user id a read or write was asked for, in order, so a route test can prove which account
* the route touched — the session's, and never one named by the request. */
readonly requestedUserIds: readonly string[];
/** Makes the next operations fail the way an unreachable database would. */
failWith(code: "ACCOUNT_READ_FAILED" | "ACCOUNT_WRITE_FAILED" | undefined): void;
}
/**
* An in-memory `AccountStore` for tests that exercise **routing and authorization**, not
* persistence. It reproduces the store's contract — strict update parsing, partial updates, an
* unknown person is not found — and nothing a database owns; persistence, the `CHECK`
* constraints, isolation across connections, and the cascade are proven against real PostgreSQL in
* `core/tests/account-store.integration.ts`.
*/
export function createInMemoryAccountStore(
people: readonly InMemoryAccountPerson[] = [],
): InMemoryAccountStore {
const records = new Map<string, { name: string; email: string; preferences: AccountPreferences }>(
people.map((person) => [
person.userId,
{
email: person.email,
name: person.name,
preferences: { ...DEFAULT_ACCOUNT_PREFERENCES, ...person.preferences },
},
]),
);
const requestedUserIds: string[] = [];
let failure: "ACCOUNT_READ_FAILED" | "ACCOUNT_WRITE_FAILED" | undefined;
return {
failWith(code) {
failure = code;
},
async readAccount(userId: EkklesiaUserId): Promise<AccountSummary | undefined> {
requestedUserIds.push(userId);
if (failure) {
throw new AccountStoreError("ACCOUNT_READ_FAILED");
}
const record = records.get(userId);
return record
? { email: record.email, name: record.name, preferences: { ...record.preferences } }
: undefined;
},
requestedUserIds,
async updatePreferences(
userId: EkklesiaUserId,
update: AccountPreferencesUpdate,
): Promise<AccountPreferences> {
requestedUserIds.push(userId);
const parsed = parseAccountPreferencesUpdate(update);
if (!parsed) {
throw new AccountStoreError("ACCOUNT_PREFERENCES_INVALID");
}
if (failure) {
throw new AccountStoreError("ACCOUNT_WRITE_FAILED");
}
const record = records.get(userId);
if (!record) {
throw new AccountStoreError("ACCOUNT_NOT_FOUND");
}
record.preferences = { ...record.preferences, ...parsed };
return { ...record.preferences };
},
};
}
+1 -4
View File
@@ -19,10 +19,7 @@
"linter": { "linter": {
"enabled": true, "enabled": true,
"rules": { "rules": {
"preset": "recommended", "preset": "recommended"
"suspicious": {
"noAlert": "error"
}
} }
}, },
"javascript": { "javascript": {
-1
View File
@@ -9,7 +9,6 @@ export default defineConfig({
"./src/modules/lifecycle-schema.ts", "./src/modules/lifecycle-schema.ts",
"./src/modules/configuration-schema.ts", "./src/modules/configuration-schema.ts",
"./src/devices/schema.ts", "./src/devices/schema.ts",
"./src/account/schema.ts",
], ],
out: "./drizzle", out: "./drizzle",
migrations: { migrations: {
-11
View File
@@ -1,11 +0,0 @@
CREATE TABLE "ekklesia_core_user_preference" (
"user_id" text PRIMARY KEY NOT NULL,
"appearance" text,
"motion" text,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "ekklesia_core_user_preference_appearance_check" CHECK ("ekklesia_core_user_preference"."appearance" is null or "ekklesia_core_user_preference"."appearance" in ('system', 'light', 'dark')),
CONSTRAINT "ekklesia_core_user_preference_motion_check" CHECK ("ekklesia_core_user_preference"."motion" is null or "ekklesia_core_user_preference"."motion" in ('system', 'reduce', 'allow'))
);
--> statement-breakpoint
ALTER TABLE "ekklesia_core_user_preference" ADD CONSTRAINT "ekklesia_core_user_preference_user_id_ekklesia_core_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."ekklesia_core_user"("id") ON DELETE cascade ON UPDATE no action;
File diff suppressed because it is too large Load Diff
-6
View File
@@ -49,12 +49,6 @@
"destructive": { "destructive": {
"mode": "none" "mode": "none"
} }
},
{
"tag": "0008_user_preferences",
"destructive": {
"mode": "none"
}
} }
] ]
} }
-7
View File
@@ -57,13 +57,6 @@
"when": 1787543773058, "when": 1787543773058,
"tag": "0007_verified_bootstrap_owners", "tag": "0007_verified_bootstrap_owners",
"breakpoints": true "breakpoints": true
},
{
"idx": 8,
"version": "7",
"when": 1792036365485,
"tag": "0008_user_preferences",
"breakpoints": true
} }
] ]
} }
+1 -6
View File
@@ -1,7 +1,6 @@
{ {
"name": "@ekklesia/core", "name": "@ekklesia/core",
"version": "0.2.5", "version": "0.1.1",
"license": "AGPL-3.0-or-later",
"private": true, "private": true,
"type": "module", "type": "module",
"files": [ "files": [
@@ -12,10 +11,6 @@
"types": "./dist/index.d.ts", "types": "./dist/index.d.ts",
"import": "./dist/index.js" "import": "./dist/index.js"
}, },
"./account": {
"types": "./dist/account/index.d.ts",
"import": "./dist/account/index.js"
},
"./modules": { "./modules": {
"types": "./dist/modules/index.d.ts", "types": "./dist/modules/index.d.ts",
"import": "./dist/modules/index.js" "import": "./dist/modules/index.js"
-13
View File
@@ -1,13 +0,0 @@
import { drizzle, type NodePgDatabase } from "drizzle-orm/node-postgres";
import type { Pool } from "pg";
import { accountSchema } from "./schema.js";
export type AccountDrizzleDatabase = NodePgDatabase<typeof accountSchema>;
/**
* A typed Drizzle view over an externally owned pool, exactly like `createDeviceDrizzleView`: it
* never constructs, registers an error listener on, or closes a pool.
*/
export function createAccountDrizzleView(pool: Pool): AccountDrizzleDatabase {
return drizzle(pool, { schema: accountSchema });
}
-4
View File
@@ -1,4 +0,0 @@
export * from "./database.js";
export * from "./preferences.js";
export * from "./schema.js";
export * from "./store.js";
-108
View File
@@ -1,108 +0,0 @@
/**
* A person's own preferences (DESIGN.md §9.2, ADR 0062): how the Dashboard looks and behaves *for
* them*, on every device they sign in on.
*
* The model is deliberately a closed, typed record rather than a key/value bag or a JSON document.
* Every preference has a fixed name, a closed set of values, and a default; a value outside the set
* is refused at this boundary and again by the database's `CHECK` constraint. A new preference is a
* new field here and a new nullable column in a migration — never a second table, and never a
* free-form value a client could fill with anything.
*
* Core cannot import the design-token package that renders these, so the value sets are declared
* here and an `apps/api` test holds them equal to `@ekklesia/design-tokens`' own
* (`EKKLESIA_APPEARANCES`, `EKKLESIA_MOTION_PREFERENCES`): one list cannot change without the other.
*/
/** Light, dark, or following the operating system. `system` is the default. */
export const ACCOUNT_APPEARANCES = Object.freeze(["system", "light", "dark"] as const);
export type AccountAppearance = (typeof ACCOUNT_APPEARANCES)[number];
/** Following `prefers-reduced-motion`, always reducing, or always allowing the platform's subtle
* motion. `system` is the default. */
export const ACCOUNT_MOTION_PREFERENCES = Object.freeze(["system", "reduce", "allow"] as const);
export type AccountMotionPreference = (typeof ACCOUNT_MOTION_PREFERENCES)[number];
export interface AccountPreferences {
readonly appearance: AccountAppearance;
readonly motion: AccountMotionPreference;
}
/** What a person who has never chosen anything gets: follow the system for both. */
export const DEFAULT_ACCOUNT_PREFERENCES: AccountPreferences = Object.freeze({
appearance: "system",
motion: "system",
});
/** A change to some of a person's preferences. Fields left out keep their stored value, so
* changing motion on a phone never overwrites an appearance chosen a moment earlier on a laptop. */
export type AccountPreferencesUpdate = Partial<AccountPreferences>;
const PREFERENCE_VALUES: Readonly<Record<keyof AccountPreferences, readonly string[]>> =
Object.freeze({
appearance: ACCOUNT_APPEARANCES,
motion: ACCOUNT_MOTION_PREFERENCES,
});
/** The preference names, in a stable order. */
export const ACCOUNT_PREFERENCE_NAMES = Object.freeze(
Object.keys(PREFERENCE_VALUES).sort() as (keyof AccountPreferences)[],
);
export function isAccountAppearance(value: unknown): value is AccountAppearance {
return typeof value === "string" && ACCOUNT_APPEARANCES.includes(value as AccountAppearance);
}
export function isAccountMotionPreference(value: unknown): value is AccountMotionPreference {
return (
typeof value === "string" &&
ACCOUNT_MOTION_PREFERENCES.includes(value as AccountMotionPreference)
);
}
/**
* Reads an untrusted update strictly: a plain object naming at least one known preference, each
* with one of its allowed values, and nothing else. Returns `undefined` for anything else — an
* unknown key, a wrong value, an empty object, an array, `null` — so a malformed request is refused
* whole rather than partly applied, and no unexpected key can ever reach storage.
*/
export function parseAccountPreferencesUpdate(
value: unknown,
): AccountPreferencesUpdate | undefined {
if (typeof value !== "object" || value === null || Array.isArray(value)) {
return undefined;
}
if (Object.getPrototypeOf(value) !== Object.prototype) {
return undefined;
}
const entries = Object.entries(value);
if (entries.length === 0) {
return undefined;
}
const update: { appearance?: AccountAppearance; motion?: AccountMotionPreference } = {};
for (const [key, entry] of entries) {
if (key === "appearance" && isAccountAppearance(entry)) {
update.appearance = entry;
} else if (key === "motion" && isAccountMotionPreference(entry)) {
update.motion = entry;
} else {
return undefined;
}
}
return Object.freeze(update);
}
/** Resolves stored columns, where `null` means "never chosen", to the preferences in effect. A
* stored value the database constraint should have made impossible is treated as never chosen. */
export function resolveAccountPreferences(stored: {
readonly appearance: string | null;
readonly motion: string | null;
}): AccountPreferences {
return Object.freeze({
appearance: isAccountAppearance(stored.appearance)
? stored.appearance
: DEFAULT_ACCOUNT_PREFERENCES.appearance,
motion: isAccountMotionPreference(stored.motion)
? stored.motion
: DEFAULT_ACCOUNT_PREFERENCES.motion,
});
}
-56
View File
@@ -1,56 +0,0 @@
import { sql } from "drizzle-orm";
import { check, pgTable, text, timestamp } from "drizzle-orm/pg-core";
import { ekklesiaCoreUser } from "../identity/schema.js";
/**
* Core-owned storage for a person's own preferences (ADR 0062).
*
* **One row per person, one typed column per preference.** There is deliberately no key/value
* table and no JSON column: each preference has a fixed name and a closed set of values, enforced
* here by a `CHECK` constraint beneath the application-layer parser
* (`parseAccountPreferencesUpdate`), so nothing but a known value can be stored. A new preference is
* a new nullable column in a new migration.
*
* **`null` means "never chosen"**, and is resolved to the platform default when read
* (`resolveAccountPreferences`) rather than written as a column default. That keeps "this person
* chose System" distinguishable from "this person never chose", and lets a later preference column
* arrive without backfilling a value nobody chose — the same reasoning
* `ekklesia_core_module_state` follows for its absent rows (ADR 0028). A person with no row at all
* has chosen nothing.
*
* The row belongs to its person: `user_id` is the primary key and cascades on delete, so deleting
* an account removes its preferences with it, and no row can exist for a person who does not.
*
* Personal, not church-wide. Nothing here is branding, a church theme, or an administrator's
* decision for anyone else; those are Settings' (ADR 0061).
*/
export const ekklesiaCoreUserPreference = pgTable(
"ekklesia_core_user_preference",
{
userId: text("user_id")
.primaryKey()
.references(() => ekklesiaCoreUser.id, { onDelete: "cascade" }),
appearance: text("appearance"),
motion: text("motion"),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
},
(table) => [
check(
"ekklesia_core_user_preference_appearance_check",
sql`${table.appearance} is null or ${table.appearance} in ('system', 'light', 'dark')`,
),
check(
"ekklesia_core_user_preference_motion_check",
sql`${table.motion} is null or ${table.motion} in ('system', 'reduce', 'allow')`,
),
],
);
/** Explicit map passed to Drizzle, mirroring `dashboardSchema` and `moduleLifecycleSchema`. The
* user table is included because the account summary reads a person's own name and email from it;
* nothing in this domain writes to it. */
export const accountSchema = {
user: ekklesiaCoreUser,
userPreference: ekklesiaCoreUserPreference,
} as const;
-156
View File
@@ -1,156 +0,0 @@
import type { EkklesiaUserId } from "@ekklesia/contracts";
import { eq, sql } from "drizzle-orm";
import { ekklesiaCoreUser } from "../identity/schema.js";
import type { AccountDrizzleDatabase } from "./database.js";
import {
type AccountPreferences,
type AccountPreferencesUpdate,
parseAccountPreferencesUpdate,
resolveAccountPreferences,
} from "./preferences.js";
import { ekklesiaCoreUserPreference } from "./schema.js";
export type AccountStoreErrorCode =
| "ACCOUNT_NOT_FOUND"
| "ACCOUNT_PREFERENCES_INVALID"
| "ACCOUNT_READ_FAILED"
| "ACCOUNT_WRITE_FAILED";
const accountStoreMessages: Readonly<Record<AccountStoreErrorCode, string>> = Object.freeze({
ACCOUNT_NOT_FOUND: "The account was not found.",
ACCOUNT_PREFERENCES_INVALID: "The preferences are not valid.",
ACCOUNT_READ_FAILED: "The account could not be read.",
ACCOUNT_WRITE_FAILED: "The preferences could not be saved.",
});
/** Never carries the underlying database error, SQL text, host, or stack — matching
* `DashboardStoreError` exactly. */
export class AccountStoreError extends Error {
readonly code: AccountStoreErrorCode;
constructor(code: AccountStoreErrorCode) {
super(accountStoreMessages[code]);
this.name = "AccountStoreError";
this.code = code;
this.stack = `${this.name}: ${this.message}`;
Object.freeze(this);
}
}
/**
* What a signed-in person is shown about their own account: the name and email they signed up
* with, and their preferences. Deliberately nothing else — not the image URL, not verification
* state, not timestamps — so the one place the browser learns about an account carries exactly
* what the account area displays (ADR 0062).
*/
export interface AccountSummary {
readonly name: string;
readonly email: string;
readonly preferences: AccountPreferences;
}
/**
* A person's own account, and only their own: every method takes the one user id whose account it
* reads or writes, and the HTTP surface supplies it from the authenticated session, never from the
* request. There is no method that lists accounts or reads someone else's by any other key.
*/
export interface AccountStore {
/** The person's summary, or `undefined` when no such person exists. A person who never chose a
* preference gets the defaults. */
readAccount(userId: EkklesiaUserId): Promise<AccountSummary | undefined>;
/** Changes the named preferences and keeps the rest, returning everything now in effect. */
updatePreferences(
userId: EkklesiaUserId,
update: AccountPreferencesUpdate,
): Promise<AccountPreferences>;
}
function rethrowAsStoreError(error: unknown, code: AccountStoreErrorCode): never {
if (error instanceof AccountStoreError) {
throw error;
}
throw new AccountStoreError(code);
}
/**
* Core's persistence for a person's own account summary and preferences.
*
* Validation lives here as well as at the HTTP boundary, so the invariants hold for any caller: an
* update is re-read through the same strict parser, and a preference row can only be written for a
* person who exists.
*/
export function createAccountStore(db: AccountDrizzleDatabase): AccountStore {
return Object.freeze({
async readAccount(userId: EkklesiaUserId): Promise<AccountSummary | undefined> {
try {
const rows = await db
.select({
appearance: ekklesiaCoreUserPreference.appearance,
email: ekklesiaCoreUser.email,
motion: ekklesiaCoreUserPreference.motion,
name: ekklesiaCoreUser.name,
})
.from(ekklesiaCoreUser)
.leftJoin(
ekklesiaCoreUserPreference,
eq(ekklesiaCoreUserPreference.userId, ekklesiaCoreUser.id),
)
.where(eq(ekklesiaCoreUser.id, userId))
.limit(1);
const row = rows[0];
if (!row) {
return undefined;
}
return Object.freeze({
email: row.email,
name: row.name,
preferences: resolveAccountPreferences(row),
});
} catch (error) {
rethrowAsStoreError(error, "ACCOUNT_READ_FAILED");
}
},
async updatePreferences(
userId: EkklesiaUserId,
update: AccountPreferencesUpdate,
): Promise<AccountPreferences> {
const parsed = parseAccountPreferencesUpdate(update);
if (parsed === undefined) {
throw new AccountStoreError("ACCOUNT_PREFERENCES_INVALID");
}
try {
return await db.transaction(async (tx) => {
// Checked inside the transaction rather than left to the foreign key, so a person who
// does not exist is a clear "not found" instead of a constraint failure.
const people = await tx
.select({ id: ekklesiaCoreUser.id })
.from(ekklesiaCoreUser)
.where(eq(ekklesiaCoreUser.id, userId))
.limit(1);
if (people.length === 0) {
throw new AccountStoreError("ACCOUNT_NOT_FOUND");
}
const rows = await tx
.insert(ekklesiaCoreUserPreference)
.values({ userId, ...parsed })
.onConflictDoUpdate({
set: { ...parsed, updatedAt: sql`now()` },
target: ekklesiaCoreUserPreference.userId,
})
.returning({
appearance: ekklesiaCoreUserPreference.appearance,
motion: ekklesiaCoreUserPreference.motion,
});
const row = rows[0];
if (!row) {
throw new AccountStoreError("ACCOUNT_WRITE_FAILED");
}
return resolveAccountPreferences(row);
});
} catch (error) {
rethrowAsStoreError(error, "ACCOUNT_WRITE_FAILED");
}
},
});
}
-19
View File
@@ -169,20 +169,6 @@ export const CORE_MODULES_CONFIGURATION_MANAGE: PermissionId = parsePermissionId
*/ */
export const CORE_DEVICES_MANAGE: PermissionId = parsePermissionId("core.devices.manage"); export const CORE_DEVICES_MANAGE: PermissionId = parsePermissionId("core.devices.manage");
/**
* Authenticated: every signed-in person may read their own name and email and read and change their
* own preferences (ADR 0062). Choosing dark mode is not church administration, so it needs no
* grant, and a Dashboard where it did would be unusable for its ordinary users. It is a declared
* permission rather than a hand-written "is signed in" check, as `core.modules.ui.read` is
* (ADR 0026), so the account area is authorized by the same evaluator as everything else.
*
* **It is self-only by construction.** The routes it guards take the account from the
* authenticated session and accept no user id at all, so holding it — whether through the baseline
* or a grant — confers no ability to read or change anyone else's account. A device never satisfies
* the `authenticated` baseline: a screen in the foyer has no account to read.
*/
export const CORE_ACCOUNT_SELF_MANAGE: PermissionId = parsePermissionId("core.account.self.manage");
/** /**
* Core's own declarations, exported in deeply immutable form so a trusted composer (the module * Core's own declarations, exported in deeply immutable form so a trusted composer (the module
* integration layer, `core/src/modules/permissions.ts`) can build one combined registry from * integration layer, `core/src/modules/permissions.ts`) can build one combined registry from
@@ -221,11 +207,6 @@ export const CORE_PERMISSION_DECLARATIONS: readonly PermissionDeclaration[] = Ob
description: "Approve display device pairing, list paired devices, and revoke a device.", description: "Approve display device pairing, list paired devices, and revoke a device.",
id: CORE_DEVICES_MANAGE, id: CORE_DEVICES_MANAGE,
}), }),
Object.freeze({
baseline: "authenticated" as const,
description: "Read your own account and read and change your own preferences.",
id: CORE_ACCOUNT_SELF_MANAGE,
}),
]); ]);
/** /**
+3 -23
View File
@@ -8,7 +8,6 @@ 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;
@@ -26,10 +25,8 @@ 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;
} }
@@ -52,22 +49,6 @@ 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.
* *
@@ -88,7 +69,6 @@ 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,
@@ -96,7 +76,7 @@ export function createDoctorOptions(
mailConfiguration: host.mail, mailConfiguration: host.mail,
moduleDiscovery: { moduleDiscovery: {
moduleSdkVersion: metadata.moduleSdkVersion, moduleSdkVersion: metadata.moduleSdkVersion,
...doctorModulesDirectory(host.modulesDirectory, metadata.repositoryRoot), modulesDirectory: metadata.modulesDirectory,
platformVersion: metadata.platformVersion, platformVersion: metadata.platformVersion,
}, },
moduleSecrets: host.moduleSecrets, moduleSecrets: host.moduleSecrets,
@@ -117,8 +97,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 -10
View File
@@ -1,9 +1,7 @@
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;
@@ -17,16 +15,9 @@ 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")),
@@ -36,7 +27,7 @@ export async function discoverRepositoryMigrations(
coreMigrationsDirectory: path.join(repositoryRoot, "core", "drizzle"), coreMigrationsDirectory: path.join(repositoryRoot, "core", "drizzle"),
moduleDiscovery: { moduleDiscovery: {
moduleSdkVersion, moduleSdkVersion,
...modulesDirectory, modulesDirectory: path.join(repositoryRoot, "modules"),
platformVersion, platformVersion,
}, },
}); });
+3 -21
View File
@@ -1,9 +1,5 @@
import type { ModuleMigrationAggregate } from "../modules/index.js"; import type { ModuleMigrationAggregate } from "../modules/index.js";
import type { import type { MigrationApplyResult, MigrationPlanResult } from "../migrations/index.js";
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`;
@@ -32,19 +28,10 @@ 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${retainedSummary}):`, `Migration plan (${plan.appliedCount} applied, ${readyCount} pending, ${plan.approvalRequiredCount} approval-blocked):`,
]; ];
for (const entry of plan.entries) { for (const entry of plan.entries) {
@@ -53,7 +40,6 @@ 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.",
); );
@@ -63,10 +49,7 @@ 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([ return Object.freeze(["Migration apply completed: database is already current (no changes)."]);
"Migration apply completed: database is already current (no changes).",
...retainedLines(result.retained),
]);
} }
const lines = [ const lines = [
@@ -75,6 +58,5 @@ 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);
} }
@@ -1,17 +0,0 @@
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,
});
}
+1 -2
View File
@@ -12,7 +12,6 @@ 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.
@@ -100,7 +99,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,
...resolveCommandModulesDirectory(process.env, repositoryRoot), modulesDirectory: path.join(repositoryRoot, "modules"),
platformVersion, platformVersion,
}); });
if (!discovered.some((module) => module.manifest.id === moduleId)) { if (!discovered.some((module) => module.manifest.id === moduleId)) {
+1 -2
View File
@@ -9,7 +9,6 @@ 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;
@@ -31,7 +30,7 @@ async function main(): Promise<void> {
]); ]);
const schemaAggregate = await discoverModuleSchemas({ const schemaAggregate = await discoverModuleSchemas({
moduleSdkVersion, moduleSdkVersion,
...resolveCommandModulesDirectory(process.env, repositoryRoot), modulesDirectory: path.join(repositoryRoot, "modules"),
platformVersion, platformVersion,
}); });
const modules = schemaAggregate.discoveredModules; const modules = schemaAggregate.discoveredModules;
-188
View File
@@ -1,188 +0,0 @@
import { readFile } from "node:fs/promises";
import path from "node:path";
import { pathToFileURL } from "node:url";
import {
type ModulePackagePlatform,
ModulePackageVerificationError,
verifyModulePackage,
} from "../modules/index.js";
/**
* `verify-module-package` — verifies an Ekklesia Module Package v1 archive against this platform
* (ADR 0049), and never installs it.
*
* node core/dist/cli/verify-module-package.js <archive>
* [--sha256 <hex> | --sha256-file <file>] the archive's expected SHA-256
* [--extract-to <new directory>] write the verified module directory there
*
* Run from the platform's root (`/opt/ekklesia` in the image), like every operator command: the
* platform's own versions are read from there. `--extract-to` writes only after every check passed,
* into a directory that must not exist yet; it is the staging step of an installation, not the
* installation itself.
*/
class VerifyArgumentsError extends Error {}
const USAGE =
"Usage: verify-module-package <archive> [--sha256 <hex> | --sha256-file <file>] [--extract-to <new directory>]";
export const MODULE_VERIFIER_SCRATCH_ENV = "EKKLESIA_MODULE_VERIFIER_SCRATCH_DIR";
export interface VerifyModulePackageArguments {
readonly archivePath: string;
readonly expectedSha256?: string;
readonly sha256File?: string;
readonly extractTo?: string;
}
function normalizedDigest(value: string): string {
const digest = value.trim().toLowerCase();
if (!/^[0-9a-f]{64}$/u.test(digest)) {
throw new VerifyArgumentsError("The SHA-256 must be 64 hexadecimal characters.");
}
return digest;
}
export function parseVerifyModulePackageArguments(
argv: readonly string[],
): VerifyModulePackageArguments {
const positional: string[] = [];
const options = new Map<string, string>();
for (let index = 0; index < argv.length; index += 1) {
const argument = argv[index] ?? "";
if (argument.startsWith("--")) {
if (!["--sha256", "--sha256-file", "--extract-to"].includes(argument)) {
throw new VerifyArgumentsError(USAGE);
}
const value = argv[index + 1];
if (value === undefined || value.startsWith("--") || options.has(argument)) {
throw new VerifyArgumentsError(USAGE);
}
options.set(argument, value);
index += 1;
} else {
positional.push(argument);
}
}
const archivePath = positional[0];
if (positional.length !== 1 || archivePath === undefined || archivePath === "") {
throw new VerifyArgumentsError(USAGE);
}
if (options.has("--sha256") && options.has("--sha256-file")) {
throw new VerifyArgumentsError(USAGE);
}
const sha256 = options.get("--sha256");
const sha256File = options.get("--sha256-file");
const extractTo = options.get("--extract-to");
return {
archivePath,
...(sha256 === undefined ? {} : { expectedSha256: normalizedDigest(sha256) }),
...(sha256File === undefined ? {} : { sha256File }),
...(extractTo === undefined ? {} : { extractTo }),
};
}
/** The `sha256sum` format: one line, the digest, whitespace, and a file name that is never trusted. */
export function parseSha256File(contents: string): string {
const lines = contents.split("\n").filter((line) => line.trim() !== "");
const match = lines.length === 1 ? /^([0-9a-fA-F]{64})\s+\*?\S/u.exec(lines[0] ?? "") : null;
if (!match?.[1]) {
throw new VerifyArgumentsError("The SHA-256 file must hold one sha256sum line.");
}
return match[1].toLowerCase();
}
/** The CLI's operator-facing bridge to Core's otherwise explicit verifier option. */
export function moduleVerifierScratchDirectory(environment: NodeJS.ProcessEnv): string | undefined {
const configured = environment[MODULE_VERIFIER_SCRATCH_ENV];
if (configured === undefined) return undefined;
if (configured === "" || !path.isAbsolute(configured)) {
throw new VerifyArgumentsError(`${MODULE_VERIFIER_SCRATCH_ENV} must be an absolute path.`);
}
return configured;
}
async function readJson(file: string): Promise<Record<string, unknown>> {
return JSON.parse(await readFile(file, "utf8")) as Record<string, unknown>;
}
function requiredString(value: unknown): string {
if (typeof value !== "string" || value === "") {
throw new VerifyArgumentsError("The platform's own package metadata could not be read.");
}
return value;
}
/** What this platform is, read from where it is installed. The transitional database runtime is
* the Drizzle version Core itself depends on (ADR 0049). */
export async function readModulePackagePlatform(
repositoryRoot: string,
): Promise<ModulePackagePlatform> {
const [root, sdk, core] = await Promise.all([
readJson(path.join(repositoryRoot, "package.json")),
readJson(path.join(repositoryRoot, "packages", "module-sdk", "package.json")),
readJson(path.join(repositoryRoot, "core", "package.json")),
]);
const dependencies = core.dependencies as Record<string, unknown> | undefined;
return {
databaseRuntime: { "drizzle-orm": requiredString(dependencies?.["drizzle-orm"]) },
moduleSdkVersion: requiredString(sdk.version),
platformVersion: requiredString(root.version),
};
}
async function main(): Promise<void> {
const parsed = parseVerifyModulePackageArguments(process.argv.slice(2));
const executionScratchDirectory = moduleVerifierScratchDirectory(process.env);
let expectedSha256 = parsed.expectedSha256;
if (parsed.sha256File !== undefined) {
let contents: string;
try {
contents = await readFile(parsed.sha256File, "utf8");
} catch {
throw new VerifyArgumentsError("The SHA-256 file could not be read.");
}
expectedSha256 = parseSha256File(contents);
}
const platform = await readModulePackagePlatform(process.cwd()).catch(() => {
throw new VerifyArgumentsError("The platform's own package metadata could not be read.");
});
const result = await verifyModulePackage({
archivePath: parsed.archivePath,
platform,
...(executionScratchDirectory === undefined ? {} : { executionScratchDirectory }),
...(expectedSha256 === undefined ? {} : { expectedSha256 }),
...(parsed.extractTo === undefined ? {} : { extractTo: parsed.extractTo }),
});
console.log(
`Module package verified: ${result.moduleId}@${result.version} (target ${result.target}), ${result.fileCount} files, sha256 ${result.archiveSha256}.`,
);
if (expectedSha256 === undefined) {
console.log("The archive's SHA-256 was not checked: no expected digest was given.");
}
if (parsed.extractTo !== undefined) {
console.log("The verified module directory was extracted. Nothing was installed.");
}
}
function isDirectExecution(): boolean {
const entrypoint = process.argv[1];
return (
entrypoint !== undefined && import.meta.url === pathToFileURL(path.resolve(entrypoint)).href
);
}
if (isDirectExecution()) {
main().catch((error: unknown) => {
if (error instanceof ModulePackageVerificationError) {
console.error(`[${error.code}] ${error.message}`);
if (error.issueCodes.length > 0) {
console.error(`Module validation issues: ${error.issueCodes.join(", ")}`);
}
} else if (error instanceof VerifyArgumentsError) {
console.error(error.message);
} else {
console.error("Module package verification failed unexpectedly.");
}
process.exitCode = 1;
});
}
+8 -18
View File
@@ -44,26 +44,17 @@ export class DeviceStoreError extends Error {
export const DEVICE_PAIRING_REQUEST_TTL_MS = 10 * 60_000; export const DEVICE_PAIRING_REQUEST_TTL_MS = 10 * 60_000;
/** /**
* How long a device credential may go **unused** before the device must pair again (ADR 0056). * How long a device credential is accepted before the device must pair again.
* *
* The window slides: every request from the device moves `lastSeenAt` forward, and the host * A display runs unattended for months; forcing a signage player to be re-paired every fortnight
* re-issues the cookie with a fresh lifetime, so a screen that is in use stays paired until an * because human sessions expire that fast would mean someone climbing to a ceiling-mounted screen
* administrator revokes it. Revocation, not expiry, is the security boundary — the server checks it * on a schedule. What makes a long horizon safe is that it is not the security boundary: the server
* on every single request. The window exists only so an abandoned, forgotten screen eventually stops * decides on every single request whether the device is still revoked, so an administrator's revoke
* being an identity at all: 180 days after it was last seen, its credential is dead. * takes effect immediately regardless of how long the credential would otherwise have lived. The
* expiry exists so an abandoned, forgotten screen eventually stops being an identity at all.
*/ */
export const DEVICE_CREDENTIAL_TTL_MS = 180 * 24 * 60 * 60_000; export const DEVICE_CREDENTIAL_TTL_MS = 180 * 24 * 60 * 60_000;
/** When a device credential expires: the idle window, measured from the last time the device was
* seen, or from pairing if it never has been. */
export function deviceCredentialExpiresAt(input: {
readonly createdAt: Date;
readonly lastSeenAt?: Date | null | undefined;
}): Date {
const anchor = Math.max(input.createdAt.getTime(), input.lastSeenAt?.getTime() ?? 0);
return new Date(anchor + DEVICE_CREDENTIAL_TTL_MS);
}
/** How stale `lastSeenAt` may become before the resolver spends a write on it. A display polling /** How stale `lastSeenAt` may become before the resolver spends a write on it. A display polling
* realtime and assets would otherwise turn an operational nicety into a continuous write load. */ * realtime and assets would otherwise turn an operational nicety into a continuous write load. */
export const DEVICE_LAST_SEEN_THROTTLE_MS = 15 * 60_000; export const DEVICE_LAST_SEEN_THROTTLE_MS = 15 * 60_000;
@@ -484,8 +475,7 @@ export function createDeviceStore(db: DeviceDrizzleDatabase): DeviceStore {
} }
// The credential's own horizon, measured from when the device was paired. Revocation is // The credential's own horizon, measured from when the device was paired. Revocation is
// checked above and is what makes a long horizon acceptable. // checked above and is what makes a long horizon acceptable.
// Expiry is measured from last use, not from pairing, so a screen in use never ages out. if (row.createdAt.getTime() + DEVICE_CREDENTIAL_TTL_MS <= now.getTime()) {
if (deviceCredentialExpiresAt(row).getTime() <= now.getTime()) {
return undefined; return undefined;
} }
return Object.freeze({ return Object.freeze({
+22 -104
View File
@@ -1,3 +1,4 @@
import path from "node:path";
import { import {
capabilityIdSchema, capabilityIdSchema,
EMPTY_MODULE_SECRET_SOURCE, EMPTY_MODULE_SECRET_SOURCE,
@@ -42,7 +43,6 @@ 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,7 +64,6 @@ import {
isConfigurationBlockedDetail, isConfigurationBlockedDetail,
isStorageBlockedDetail, isStorageBlockedDetail,
type ModuleConfigurationSettings, type ModuleConfigurationSettings,
ModuleDiscoveryError,
type ModuleDiscoveryOptions, type ModuleDiscoveryOptions,
type ModuleEligibility, type ModuleEligibility,
type ModuleLifecycleConfiguration, type ModuleLifecycleConfiguration,
@@ -127,9 +126,6 @@ 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;
@@ -174,7 +170,7 @@ export interface DoctorDependencies {
options: PostgresMigrationDatabaseOptions, options: PostgresMigrationDatabaseOptions,
) => MigrationDatabase; ) => MigrationDatabase;
readonly discoverMigrations: ( readonly discoverMigrations: (
options: DiscoverAllMigrationsOptions, options: ModuleDiscoveryOptions,
) => Promise<ModuleMigrationAggregate>; ) => Promise<ModuleMigrationAggregate>;
readonly discoverModules: ( readonly discoverModules: (
options: ModuleDiscoveryOptions, options: ModuleDiscoveryOptions,
@@ -211,6 +207,15 @@ 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(
@@ -268,7 +273,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: discoverAllMigrations, discoverMigrations: discoverAllMigrationsForRuntime,
discoverModules, discoverModules,
discoverSchemas: discoverModuleSchemas, discoverSchemas: discoverModuleSchemas,
planMigrations: planModuleMigrations, planMigrations: planModuleMigrations,
@@ -628,23 +633,11 @@ 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 = [moduleDiscoveryFailureDetail(error)]; const details =
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);
} }
@@ -740,14 +733,6 @@ 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) {
@@ -772,35 +757,6 @@ 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
@@ -813,7 +769,6 @@ 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;
@@ -828,43 +783,17 @@ 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 return freezeCheck("identity.configuration", "pass", "Identity configuration valid", [
schemaDetail,
config.publicSignupEnabled
? "Public signup is explicitly ENABLED." ? "Public signup is explicitly ENABLED."
: "Public signup is disabled (safe default)."; : "Public signup is disabled (safe default).",
if (config.dashboardOrigin === config.baseUrl) {
return freezeCheck("identity.configuration", "pass", "Identity configuration valid", [
schemaDetail,
signupDetail,
"Topology: same-origin (the Dashboard is served from the API's origin).",
]);
}
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 =
@@ -920,25 +849,17 @@ 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 =
@@ -949,7 +870,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.`, CLIENT_ADDRESS_SCOPE], [`[${code}] Configured client-address values failed validation.`],
); );
} }
} }
@@ -1147,10 +1068,7 @@ export async function diagnoseEkklesia(
} }
try { try {
migrations = await dependencies.discoverMigrations({ migrations = await dependencies.discoverMigrations(options.moduleDiscovery);
coreMigrationsDirectory: options.coreMigrationsDirectory,
moduleDiscovery: options.moduleDiscovery,
});
const inventory = migrations.migrations.map((migration) => const inventory = migrations.migrations.map((migration) =>
safeMigrationReference(migration.identity, migration.checksum), safeMigrationReference(migration.identity, migration.checksum),
); );
-5
View File
@@ -59,10 +59,6 @@ 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;
} }
@@ -148,7 +144,6 @@ 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).
+8 -70
View File
@@ -2,7 +2,6 @@ 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;
@@ -87,24 +86,11 @@ 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 {
@@ -119,8 +105,6 @@ 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 {
@@ -220,38 +204,10 @@ 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[],
): ValidatedAppliedHistory { ): ReadonlySet<string> {
if (!Array.isArray(history)) { if (!Array.isArray(history)) {
throw runnerError("MIGRATION_HISTORY_INVALID"); throw runnerError("MIGRATION_HISTORY_INVALID");
} }
@@ -259,9 +215,6 @@ 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>();
@@ -287,20 +240,8 @@ 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 ||
@@ -311,12 +252,11 @@ 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 moduleHistory = history.filter((record) => record.moduleId === module.moduleId); const applied = history.filter((record) => record.moduleId === module.moduleId);
for (const [position, record] of moduleHistory.entries()) { for (const [position, record] of applied.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);
@@ -324,13 +264,13 @@ function validateAppliedHistoryUnchecked(
} }
} }
return Object.freeze({ applied, retained: freezeRetained(retained) }); return seenIdentities;
} }
function validateAppliedHistory( function validateAppliedHistory(
aggregate: ModuleMigrationAggregate, aggregate: ModuleMigrationAggregate,
history: readonly AppliedMigrationRecord[], history: readonly AppliedMigrationRecord[],
): ValidatedAppliedHistory { ): ReadonlySet<string> {
try { try {
return validateAppliedHistoryUnchecked(aggregate, history); return validateAppliedHistoryUnchecked(aggregate, history);
} catch (error) { } catch (error) {
@@ -345,7 +285,7 @@ function planFromHistory(
aggregate: ModuleMigrationAggregate, aggregate: ModuleMigrationAggregate,
history: readonly AppliedMigrationRecord[], history: readonly AppliedMigrationRecord[],
): MigrationPlanResult { ): MigrationPlanResult {
const { applied, retained } = validateAppliedHistory(aggregate, history); const applied = 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);
@@ -374,7 +314,6 @@ function planFromHistory(
approvalRequiredCount, approvalRequiredCount,
entries, entries,
pendingCount: entries.length - appliedCount, pendingCount: entries.length - appliedCount,
retained,
}); });
} }
@@ -533,14 +472,14 @@ export async function applyModuleMigrations(
} catch { } catch {
throw runnerError("MIGRATION_HISTORY_READ_FAILED"); throw runnerError("MIGRATION_HISTORY_READ_FAILED");
} }
const { applied, retained } = validateAppliedHistory(options.aggregate, history); const applied = 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, retained }); return Object.freeze({ applied: Object.freeze([]), appliedCount: 0, noOp: true });
} }
let executingIdentity: string | undefined; let executingIdentity: string | undefined;
@@ -569,7 +508,6 @@ 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;
-349
View File
@@ -1,349 +0,0 @@
import {
type ModuleDatabaseField,
ModuleDatabaseError,
type ModuleDatabaseValue,
} from "@ekklesia/contracts";
/**
* The value contract of Module Database ABI v1 (ADR 0050): how parameters are encoded before they
* reach PostgreSQL, and how result text is decoded after. Both are Core's, and neither consults
* `pg`'s process-global type registry or any ORM, which module code could change or differ from.
*
* Every value travels in PostgreSQL's text format. The session settings the decoding depends on
* (`DateStyle`, `IntervalStyle`, `TimeZone`, `bytea_output`, `extra_float_digits`) are pinned by the
* executor for every ABI operation (`./database-abi.ts`).
*/
/** PostgreSQL's limit on bound parameters in one statement (the protocol's 16-bit count). */
export const MODULE_DATABASE_MAX_PARAMETERS = 65_535;
/** PostgreSQL's limit on array dimensions (`MAXDIM`). */
const maximumArrayDimensions = 6;
function parameterInvalid(): ModuleDatabaseError {
return new ModuleDatabaseError("MODULE_DATABASE_PARAMETER_INVALID");
}
function isScalar(value: unknown): boolean {
return (
value === null ||
typeof value === "boolean" ||
typeof value === "number" ||
typeof value === "bigint" ||
typeof value === "string" ||
value instanceof Uint8Array
);
}
function scalarText(value: boolean | number | bigint | string): string {
// `String` spells NaN, Infinity, and -Infinity exactly as PostgreSQL's float and numeric input
// accepts them.
return typeof value === "string" ? value : String(value);
}
function byteaText(value: Uint8Array): string {
return `\\x${Buffer.from(value.buffer, value.byteOffset, value.byteLength).toString("hex")}`;
}
function quoteArrayElement(text: string): string {
return `"${text.replaceAll("\\", "\\\\").replaceAll('"', '\\"')}"`;
}
/**
* Validates one array level and returns its PostgreSQL array literal. Every element of a level is
* either a sub-array or a scalar, and sibling sub-arrays have equal lengths, because PostgreSQL
* arrays are rectangular. Every non-null element is quoted, which PostgreSQL accepts for any element
* type.
*/
function arrayLiteral(values: readonly unknown[], depth: number): string {
if (depth > maximumArrayDimensions) {
throw parameterInvalid();
}
const elements: string[] = [];
let nestedLength: number | undefined;
let nested: boolean | undefined;
for (let index = 0; index < values.length; index += 1) {
if (!Object.hasOwn(values, index)) {
throw parameterInvalid();
}
const element = values[index];
const isNested = Array.isArray(element);
if (nested === undefined) {
nested = isNested;
} else if (nested !== isNested) {
throw parameterInvalid();
}
if (isNested) {
if (nestedLength === undefined) {
nestedLength = element.length;
} else if (nestedLength !== element.length) {
throw parameterInvalid();
}
elements.push(arrayLiteral(element, depth + 1));
} else if (element === null) {
elements.push("NULL");
} else if (element instanceof Uint8Array) {
elements.push(quoteArrayElement(byteaText(element)));
} else if (isScalar(element)) {
elements.push(quoteArrayElement(scalarText(element as boolean | number | bigint | string)));
} else {
throw parameterInvalid();
}
}
return `{${elements.join(",")}}`;
}
/**
* Validates `values` against the ABI's parameter set and encodes each one into the only forms the
* driver then passes through unchanged: `null`, a string, or a `Buffer` copy of a `Uint8Array`.
* Anything else, including `undefined`, `Date`, plain objects, other class instances, functions,
* symbols, sparse or ragged arrays, and arrays deeper than PostgreSQL allows, is refused before
* anything reaches PostgreSQL. The error never names the value.
*/
export function encodeModuleDatabaseParameters(values: unknown): (string | Buffer | null)[] {
if (values === undefined) {
return [];
}
if (!Array.isArray(values) || values.length > MODULE_DATABASE_MAX_PARAMETERS) {
throw parameterInvalid();
}
const encoded: (string | Buffer | null)[] = [];
for (let index = 0; index < values.length; index += 1) {
if (!Object.hasOwn(values, index)) {
throw parameterInvalid();
}
const value: unknown = values[index];
if (value === null) {
encoded.push(null);
} else if (value instanceof Uint8Array) {
// A copy: the driver serializes when the statement is sent, which may be after the caller
// has changed its own bytes.
encoded.push(Buffer.from(value));
} else if (Array.isArray(value)) {
encoded.push(arrayLiteral(value, 1));
} else if (isScalar(value)) {
encoded.push(scalarText(value as boolean | number | bigint | string));
} else {
throw parameterInvalid();
}
}
return encoded;
}
// --- Results -----------------------------------------------------------------------------------
type ScalarDecoder = (text: string) => ModuleDatabaseValue;
const asText: ScalarDecoder = (text) => text;
const asBoolean: ScalarDecoder = (text) => text === "t";
const asInteger: ScalarDecoder = (text) => Number.parseInt(text, 10);
// `Number` reads PostgreSQL's NaN, Infinity, -Infinity, and exponent forms. With
// `extra_float_digits` pinned to 1 the text is the shortest form that round-trips exactly.
const asFloat: ScalarDecoder = (text) => Number(text);
function hexDigit(code: number): number {
if (code >= 48 && code <= 57) {
return code - 48;
}
if (code >= 97 && code <= 102) {
return code - 87;
}
if (code >= 65 && code <= 70) {
return code - 55;
}
return -1;
}
/**
* `bytea` text to bytes. The executor pins `bytea_output` to `hex`; the older `escape` form is read
* too, so a transaction that changed the setting still decodes faithfully rather than failing.
* Always a plain `Uint8Array`, never a `Buffer`.
*/
const asBytes: ScalarDecoder = (text) => {
if (text.startsWith("\\x")) {
const length = (text.length - 2) / 2;
const bytes = new Uint8Array(length);
for (let index = 0; index < length; index += 1) {
const high = hexDigit(text.charCodeAt(2 + index * 2));
const low = hexDigit(text.charCodeAt(3 + index * 2));
bytes[index] = high * 16 + low;
}
return bytes;
}
const bytes: number[] = [];
for (let index = 0; index < text.length; index += 1) {
const code = text.charCodeAt(index);
if (code === 92 && text.charCodeAt(index + 1) === 92) {
bytes.push(92);
index += 1;
} else if (code === 92) {
bytes.push(Number.parseInt(text.slice(index + 1, index + 4), 8));
index += 3;
} else {
bytes.push(code);
}
}
return Uint8Array.from(bytes);
};
/** Built-in scalar type OIDs with a decoding other than "PostgreSQL's text output, unchanged". Every
* type not listed here, `int8`, `numeric`, date and time types, `json`, and `jsonb` included,
* arrives as its exact text. */
const scalarDecoders: ReadonlyMap<number, ScalarDecoder> = new Map([
[16, asBoolean], // bool
[17, asBytes], // bytea
[21, asInteger], // int2
[23, asInteger], // int4
[26, asInteger], // oid
[700, asFloat], // float4
[701, asFloat], // float8
]);
/** Built-in array type OIDs, each with its element type OID. An array of a type not listed here
* (an enum, a domain, an extension type) arrives as its text, like any other type. */
const arrayElementTypes: ReadonlyMap<number, number> = new Map([
[1000, 16], // bool[]
[1001, 17], // bytea[]
[1002, 18], // "char"[]
[1003, 19], // name[]
[1005, 21], // int2[]
[1007, 23], // int4[]
[1009, 25], // text[]
[1014, 1042], // bpchar[]
[1015, 1043], // varchar[]
[1016, 20], // int8[]
[1021, 700], // float4[]
[1022, 701], // float8[]
[1028, 26], // oid[]
[1115, 1114], // timestamp[]
[1182, 1082], // date[]
[1183, 1083], // time[]
[1185, 1184], // timestamptz[]
[1187, 1186], // interval[]
[1231, 1700], // numeric[]
[1270, 1266], // timetz[]
[2951, 2950], // uuid[]
[199, 114], // json[]
[3807, 3802], // jsonb[]
]);
class ArrayTextError extends Error {}
/**
* Parses PostgreSQL's array text output into nested arrays, each element decoded by `decode`.
* Unquoted `NULL` is null; a quoted `"NULL"` is the string. A non-default lower bound
* (`[0:1]={…}`) is not represented: a JavaScript array is a zero-based list. Every element type this
* decodes uses `,` as its delimiter.
*/
export function parsePostgresArrayText(text: string, decode: ScalarDecoder): ModuleDatabaseValue[] {
let position = 0;
if (text.startsWith("[")) {
const equals = text.indexOf("=");
if (equals === -1) {
throw new ArrayTextError();
}
position = equals + 1;
}
function parseArray(): ModuleDatabaseValue[] {
if (text[position] !== "{") {
throw new ArrayTextError();
}
position += 1;
const items: ModuleDatabaseValue[] = [];
if (text[position] === "}") {
position += 1;
return items;
}
for (;;) {
const character = text[position];
if (character === "{") {
items.push(parseArray());
} else if (character === '"') {
position += 1;
let element = "";
for (;;) {
const next = text[position];
if (next === undefined) {
throw new ArrayTextError();
}
if (next === "\\") {
element += text[position + 1] ?? "";
position += 2;
} else if (next === '"') {
position += 1;
break;
} else {
element += next;
position += 1;
}
}
items.push(decode(element));
} else {
const start = position;
while (position < text.length && text[position] !== "," && text[position] !== "}") {
position += 1;
}
const element = text.slice(start, position);
items.push(element.toUpperCase() === "NULL" ? null : decode(element));
}
if (text[position] === ",") {
position += 1;
} else if (text[position] === "}") {
position += 1;
return items;
} else {
throw new ArrayTextError();
}
}
}
const parsed = parseArray();
if (position !== text.length) {
throw new ArrayTextError();
}
return parsed;
}
function scalarDecoder(typeOid: number): ScalarDecoder {
return scalarDecoders.get(typeOid) ?? asText;
}
/** Decodes one non-null column value from its PostgreSQL text by the column's type OID. */
export function decodeModuleDatabaseValue(typeOid: number, text: string): ModuleDatabaseValue {
const elementType = arrayElementTypes.get(typeOid);
if (elementType === undefined) {
return scalarDecoder(typeOid)(text);
}
try {
return parsePostgresArrayText(text, scalarDecoder(elementType));
} catch {
// PostgreSQL produced this text, so it cannot be malformed; should it ever be, the column still
// arrives faithfully, as text, rather than as a guess.
return text;
}
}
/**
* The `types` object handed to `pg` for every ABI statement. Its parser returns the raw text for
* every type, so the driver's global registry, which module code could change, is never consulted
* and the ABI decodes from text itself. It is an empty array as well because the driver also reads
* the same option as the Parse message's parameter-type list: empty, so PostgreSQL infers every
* parameter type from the statement.
*/
export const rawTextTypes: { getTypeParser: () => (text: string) => string } & never[] =
Object.freeze(
Object.assign([] as never[], {
getTypeParser: () => (text: string) => text,
}),
);
/** Decodes one positional row of raw driver text. */
export function decodeModuleDatabaseRow(
fields: readonly ModuleDatabaseField[],
raw: readonly unknown[],
): ModuleDatabaseValue[] {
return fields.map((field, index) => {
const value = raw[index];
return typeof value === "string" ? decodeModuleDatabaseValue(field.typeOid, value) : null;
});
}
-596
View File
@@ -1,596 +0,0 @@
import {
MODULE_DATABASE_ABI_VERSION,
type ModuleDatabaseArrayRow,
ModuleDatabaseError,
type ModuleDatabaseErrorCode,
type ModuleDatabaseExecutor,
type ModuleDatabaseField,
type ModuleDatabaseObjectRow,
type ModuleDatabaseQueryable,
type ModuleDatabaseQueryResult,
type ModuleDatabaseTransaction,
type ModuleId,
moduleIdSchema,
} from "@ekklesia/contracts";
import { DatabaseError, type Pool, type PoolClient, type QueryArrayConfig } from "pg";
import {
decodeModuleDatabaseRow,
encodeModuleDatabaseParameters,
rawTextTypes,
} from "./database-abi-values.js";
/**
* Core's Module Database ABI v1 executor (ADR 0050).
*
* A module hands Core SQL text, ordered parameter values, and a row mode; Core hands back decoded
* rows, column metadata, and stable errors. No ORM, driver, pool, client, or connection object ever
* crosses in either direction. The executor is built over the one pool the API already owns
* (ADR 0016) and never closes it.
*
* This is a correctness and compatibility boundary, **not a security sandbox**: a trusted module can
* still send any SQL its database role permits. Nothing here inspects table names.
*
* ### Connections and session state
*
* Every ABI operation, a top-level statement or a whole transaction, borrows a connection from the
* pool, pins the session settings the ABI's decoding depends on, does its work, and resets the
* session with `DISCARD ALL` before returning the connection. So no setting, temporary table,
* listener, advisory lock, prepared statement, or role change a module makes survives the operation
* that made it, and nothing the ABI pinned reaches the pool's other users. A connection that cannot
* be reset cleanly, or that is not idle when the operation ends, is destroyed rather than returned.
* `DISCARD ALL` also deallocates named prepared statements; Core uses none on the shared pool.
*
* ### One statement per `query`
*
* Every ABI statement is sent with PostgreSQL's extended query protocol, forced with the driver's
* `queryMode: "extended"` even when there are no parameters. PostgreSQL itself refuses a Parse
* message holding more than one statement (SQLSTATE 42601), using its own SQL grammar, so comments,
* quoted literals, and semicolons inside them are never mistaken for a second statement. Core does
* not parse SQL. A statement text that holds no statement at all is refused after PostgreSQL reports
* it empty.
*
* ### Transaction control belongs to the callbacks
*
* Transaction-control SQL (`BEGIN`, `START TRANSACTION`, `COMMIT`, `ROLLBACK`, `SAVEPOINT`,
* `RELEASE SAVEPOINT`, `PREPARE TRANSACTION`, and their aliases) is forbidden through `query`: a
* conformance rule for trusted module code, not a security guarantee. Core cannot refuse such a
* statement beforehand without parsing SQL, which it does not do, and it cannot undo one PostgreSQL
* has already executed: a `COMMIT` sent through a transaction's `query` has committed by the time
* Core sees it. What Core does is detect the violation afterwards, from what PostgreSQL itself
* reports (the statement's command tag, and the protocol's transaction status), and fail safely:
* the call rejects, an enclosing transaction is never committed by Core and rejects too, and the
* connection is destroyed rather than returned to the pool. The commit and rollback guarantees of
* the transaction API apply to conforming use.
*/
/** The settings the ABI's decoding and determinism depend on, pinned for every ABI operation. */
const pinSessionStatement =
"SET TimeZone TO 'UTC'; SET DateStyle TO 'ISO, MDY'; SET IntervalStyle TO 'postgres'; " +
"SET bytea_output TO 'hex'; SET extra_float_digits TO 1";
const resetSessionStatement = "DISCARD ALL";
const transactionControlCommands: ReadonlySet<string> = new Set([
"BEGIN",
"COMMIT",
"PREPARE",
"RELEASE",
"ROLLBACK",
"SAVEPOINT",
"START",
]);
const isolationStatements: ReadonlyMap<unknown, string> = new Map([
["read committed", "READ COMMITTED"],
["repeatable read", "REPEATABLE READ"],
["serializable", "SERIALIZABLE"],
]);
/** `pg` 8.22 reports the protocol's ReadyForQuery transaction status through this method, which
* its type declarations omit. `acquire` refuses a client without it rather than guessing. */
type AbiClient = PoolClient & { getTransactionStatus(): string | null };
interface PreparedQuery {
readonly config: QueryArrayConfig;
readonly rowMode: "array" | "object";
}
interface RawResult {
readonly command: string | null;
readonly fields: readonly { readonly dataTypeID: number; readonly name: string }[];
readonly rowCount: number | null;
readonly rows: readonly unknown[][];
}
function databaseError(code: ModuleDatabaseErrorCode, cause?: unknown): ModuleDatabaseError {
// Only PostgreSQL's own SQLSTATE survives. The driver's message, detail, hint, position, and
// object names, and every non-PostgreSQL error, are dropped here.
if (cause instanceof DatabaseError && typeof cause.code === "string") {
return new ModuleDatabaseError(code, { sqlState: cause.code });
}
return new ModuleDatabaseError(code);
}
/** A PostgreSQL error leaves the connection usable; anything else (a dropped connection, a
* client-side timeout) may leave it mid-protocol, so it must be destroyed. */
function connectionSurvives(error: unknown): boolean {
return error instanceof DatabaseError;
}
function prepareQuery(request: unknown): PreparedQuery {
if (typeof request !== "object" || request === null || Array.isArray(request)) {
throw new ModuleDatabaseError("MODULE_DATABASE_REQUEST_INVALID");
}
for (const key of Object.keys(request)) {
if (key !== "text" && key !== "values" && key !== "rowMode") {
throw new ModuleDatabaseError("MODULE_DATABASE_REQUEST_INVALID");
}
}
const { rowMode, text, values } = request as Record<string, unknown>;
if (typeof text !== "string" || text.length === 0) {
throw new ModuleDatabaseError("MODULE_DATABASE_REQUEST_INVALID");
}
if (rowMode !== undefined && rowMode !== "object" && rowMode !== "array") {
throw new ModuleDatabaseError("MODULE_DATABASE_REQUEST_INVALID");
}
const config = {
queryMode: "extended",
rowMode: "array",
text,
types: rawTextTypes,
values: encodeModuleDatabaseParameters(values),
};
return { config: config as unknown as QueryArrayConfig, rowMode: rowMode ?? "object" };
}
function beginStatement(options: unknown): string {
if (options === undefined) {
return "BEGIN ISOLATION LEVEL READ COMMITTED READ WRITE";
}
if (typeof options !== "object" || options === null || Array.isArray(options)) {
throw new ModuleDatabaseError("MODULE_DATABASE_TRANSACTION_OPTION_INVALID");
}
for (const key of Object.keys(options)) {
if (key !== "isolation" && key !== "readOnly") {
throw new ModuleDatabaseError("MODULE_DATABASE_TRANSACTION_OPTION_INVALID");
}
}
const { isolation, readOnly } = options as Record<string, unknown>;
const level = isolation === undefined ? "READ COMMITTED" : isolationStatements.get(isolation);
if (level === undefined || (readOnly !== undefined && typeof readOnly !== "boolean")) {
throw new ModuleDatabaseError("MODULE_DATABASE_TRANSACTION_OPTION_INVALID");
}
return `BEGIN ISOLATION LEVEL ${level} ${readOnly === true ? "READ ONLY" : "READ WRITE"}`;
}
function buildResult(
raw: RawResult,
rowMode: "array" | "object",
): ModuleDatabaseQueryResult<ModuleDatabaseArrayRow | ModuleDatabaseObjectRow> {
if (typeof raw.command !== "string" || raw.command === "") {
// PostgreSQL answered with EmptyQueryResponse: the text held no statement.
throw new ModuleDatabaseError("MODULE_DATABASE_REQUEST_INVALID");
}
const fields: readonly ModuleDatabaseField[] = Object.freeze(
raw.fields.map((field) => Object.freeze({ name: field.name, typeOid: field.dataTypeID })),
);
const decoded = raw.rows.map((row) => decodeModuleDatabaseRow(fields, row));
let rows: readonly (ModuleDatabaseArrayRow | ModuleDatabaseObjectRow)[];
if (rowMode === "array") {
rows = decoded;
} else {
if (new Set(fields.map((field) => field.name)).size !== fields.length) {
// Object rows cannot hold two columns with one name; refusing beats dropping one.
throw new ModuleDatabaseError("MODULE_DATABASE_REQUEST_INVALID");
}
// `Object.fromEntries` defines properties, so a column named `__proto__` stays a column.
rows = decoded.map((row) =>
Object.fromEntries(fields.map((field, index) => [field.name, row[index] ?? null])),
);
}
return Object.freeze({
command: raw.command,
fields,
rowCount: raw.rowCount ?? null,
rows: Object.freeze(rows),
});
}
/** A borrowed client's `error` events are already reported through the statement they interrupt.
* The pool only listens to idle clients, so without this a connection the server terminates while
* borrowed would surface as an unhandled `error` event. */
function ignoreClientError(): void {}
function destroy(client: AbiClient): void {
client.removeListener("error", ignoreClientError);
client.release(true);
}
async function acquire(pool: Pool, failure: ModuleDatabaseErrorCode): Promise<AbiClient> {
let client: AbiClient;
try {
client = (await pool.connect()) as AbiClient;
} catch (error) {
throw databaseError(failure, error);
}
client.on("error", ignoreClientError);
if (typeof client.getTransactionStatus !== "function") {
destroy(client);
throw new ModuleDatabaseError(failure);
}
try {
await client.query(pinSessionStatement);
} catch (error) {
destroy(client);
throw databaseError(failure, error);
}
return client;
}
/** Returns a connection to the pool only when it is idle and resets cleanly; destroys it
* otherwise. Never throws. */
async function relinquish(client: AbiClient, healthy: boolean): Promise<void> {
if (!healthy || client.getTransactionStatus() !== "I") {
destroy(client);
return;
}
try {
await client.query(resetSessionStatement);
} catch {
destroy(client);
return;
}
client.removeListener("error", ignoreClientError);
client.release();
}
/** One open scope: a transaction or a savepoint inside it. */
interface Scope {
activeChild: Scope | undefined;
readonly children: Set<Scope>;
closed: boolean;
/** Operations started through this scope's executor that have not settled yet. */
pending: number;
}
interface TransactionState {
readonly client: AbiClient;
healthy: boolean;
/** Set when the transaction can no longer be committed: transaction control sent through
* `query`, or a savepoint that could not be undone. */
failure: ModuleDatabaseError | undefined;
finished: boolean;
/** Settled-never-rejecting shadows of every statement sent on the connection. */
readonly inflight: Set<Promise<void>>;
savepoints: number;
}
function newScope(parent?: Scope): Scope {
const scope: Scope = { activeChild: undefined, children: new Set(), closed: false, pending: 0 };
parent?.children.add(scope);
return scope;
}
function closeScope(scope: Scope): void {
scope.closed = true;
for (const child of scope.children) {
closeScope(child);
}
}
function assertActive(scope: Scope): void {
if (scope.closed) {
throw new ModuleDatabaseError("MODULE_DATABASE_SCOPE_CLOSED");
}
if (scope.activeChild !== undefined) {
throw new ModuleDatabaseError("MODULE_DATABASE_SCOPE_INACTIVE");
}
}
/** Once a transaction can no longer be committed, its scopes send nothing more. After a
* transaction-control violation in particular, a later statement would otherwise run outside any
* transaction, as its own autocommitted statement. */
function assertUsable(state: TransactionState): void {
if (state.failure !== undefined) {
throw new ModuleDatabaseError(state.failure.code);
}
}
function track<T>(state: TransactionState, promise: Promise<T>): Promise<T> {
const shadow = promise.then(
() => undefined,
() => undefined,
);
state.inflight.add(shadow);
void shadow.then(() => state.inflight.delete(shadow));
return promise;
}
/** Waits until every statement already sent has settled, including any sent while waiting. */
async function drain(state: TransactionState): Promise<void> {
while (state.inflight.size > 0) {
await Promise.all([...state.inflight]);
}
}
/** Runs one of Core's own transaction-control statements. Refuses, without sending anything, once
* the transaction has finished. */
async function control(state: TransactionState, statement: string): Promise<void> {
if (state.finished) {
throw new ModuleDatabaseError("MODULE_DATABASE_SCOPE_CLOSED");
}
try {
await track(state, state.client.query(statement));
} catch (error) {
if (!connectionSurvives(error)) {
state.healthy = false;
}
throw databaseError("MODULE_DATABASE_TRANSACTION_FAILED", error);
}
}
async function rollback(state: TransactionState): Promise<void> {
try {
await state.client.query("ROLLBACK");
} catch {
state.healthy = false;
}
}
type Outcome<T> = { readonly value: T } | { readonly error: unknown };
async function settle<T>(run: () => Promise<T> | T): Promise<Outcome<T>> {
try {
return { value: await run() };
} catch (error) {
return { error };
}
}
const moduleByExecutor = new WeakMap<object, ModuleId>();
/** Which module an ABI executor, or a transaction or savepoint executor derived from it, belongs
* to. For Core's own logging, diagnostics, and attribution; never exposed to modules. */
export function moduleDatabaseExecutorModuleId(executor: object): ModuleId | undefined {
return moduleByExecutor.get(executor);
}
export interface ModuleDatabaseExecutorOptions {
/** The module this executor serves. Carried for attribution only: the ABI enforces no table
* ownership at run time (ADR 0050). */
readonly moduleId: ModuleId;
}
/**
* Creates one module's ABI v1 executor over the API's existing pool. Synchronous and free of I/O:
* nothing touches the database until the module calls it. The returned object, and every
* transaction and savepoint executor it hands out, is frozen and holds exactly the ABI's members.
*/
export function createModuleDatabaseExecutor(
pool: Pool,
options: ModuleDatabaseExecutorOptions,
): ModuleDatabaseExecutor {
const parsedModuleId = moduleIdSchema.safeParse(options.moduleId);
if (!parsedModuleId.success) {
throw new TypeError("A module database executor needs a valid module ID.");
}
const moduleId = parsedModuleId.data;
async function query(
request: unknown,
): Promise<ModuleDatabaseQueryResult<ModuleDatabaseArrayRow | ModuleDatabaseObjectRow>> {
const prepared = prepareQuery(request);
const client = await acquire(pool, "MODULE_DATABASE_QUERY_FAILED");
let healthy = true;
try {
let raw: RawResult;
try {
raw = (await client.query(prepared.config)) as unknown as RawResult;
} catch (error) {
healthy = connectionSurvives(error);
throw databaseError("MODULE_DATABASE_QUERY_FAILED", error);
}
if (
(raw.command !== null && transactionControlCommands.has(raw.command)) ||
client.getTransactionStatus() !== "I"
) {
// A transaction-control violation. PostgreSQL has already run it; the connection is
// destroyed below rather than returned in a state Core did not choose.
healthy = false;
throw new ModuleDatabaseError("MODULE_DATABASE_REQUEST_INVALID");
}
return buildResult(raw, prepared.rowMode);
} finally {
await relinquish(client, healthy);
}
}
function scopeExecutor(state: TransactionState, scope: Scope): ModuleDatabaseTransaction {
async function scopeQuery(
request: unknown,
): Promise<ModuleDatabaseQueryResult<ModuleDatabaseArrayRow | ModuleDatabaseObjectRow>> {
assertActive(scope);
assertUsable(state);
const prepared = prepareQuery(request);
scope.pending += 1;
try {
let raw: RawResult;
try {
raw = (await track(state, state.client.query(prepared.config))) as unknown as RawResult;
} catch (error) {
if (!connectionSurvives(error)) {
state.healthy = false;
}
throw databaseError("MODULE_DATABASE_QUERY_FAILED", error);
}
if (
(raw.command !== null && transactionControlCommands.has(raw.command)) ||
state.client.getTransactionStatus() === "I"
) {
// A transaction-control violation. PostgreSQL has already run it, and Core cannot undo
// that; it can only never commit this transaction itself, and destroy the connection.
state.failure ??= new ModuleDatabaseError("MODULE_DATABASE_REQUEST_INVALID");
state.healthy = false;
throw new ModuleDatabaseError("MODULE_DATABASE_REQUEST_INVALID");
}
return buildResult(raw, prepared.rowMode);
} finally {
scope.pending -= 1;
}
}
async function savepoint<T>(
callback: (savepoint: ModuleDatabaseTransaction) => Promise<T> | T,
): Promise<T> {
assertActive(scope);
assertUsable(state);
if (typeof callback !== "function") {
throw new ModuleDatabaseError("MODULE_DATABASE_REQUEST_INVALID");
}
scope.pending += 1;
const child = newScope(scope);
scope.activeChild = child;
state.savepoints += 1;
// Core's own identifier: a fixed prefix and a counter, never anything a module supplied.
const name = `ekklesia_savepoint_${state.savepoints}`;
try {
await control(state, `SAVEPOINT ${name}`);
if (child.closed) {
throw new ModuleDatabaseError("MODULE_DATABASE_SCOPE_CLOSED");
}
const outcome = await settle(() => callback(scopeExecutor(state, child)));
const outstanding = child.pending > 0;
closeScope(child);
await drain(state);
if (scope.closed) {
// The enclosing transaction finished first; it rolls back, and nothing more is sent.
throw new ModuleDatabaseError("MODULE_DATABASE_SCOPE_CLOSED");
}
if ("error" in outcome) {
// A rejected callback's own error stays the error, even if sibling work it started was
// still settling: that work has been drained, and is undone here with everything else.
try {
await control(state, `ROLLBACK TO SAVEPOINT ${name}`);
await control(state, `RELEASE SAVEPOINT ${name}`);
} catch (error) {
state.failure ??= new ModuleDatabaseError("MODULE_DATABASE_TRANSACTION_FAILED");
throw error;
}
throw outcome.error;
}
if (outstanding) {
// A callback that resolved with work outstanding forgot an await. Roll back to the
// savepoint and never release it.
try {
await control(state, `ROLLBACK TO SAVEPOINT ${name}`);
} catch (error) {
state.failure ??= new ModuleDatabaseError("MODULE_DATABASE_TRANSACTION_FAILED");
throw error;
}
throw new ModuleDatabaseError("MODULE_DATABASE_SCOPE_WORK_OUTSTANDING");
}
try {
await control(state, `RELEASE SAVEPOINT ${name}`);
} catch (releaseError) {
// Typically a failed statement left the transaction aborted. Undo the savepoint so the
// transaction stays usable, and report that this savepoint did not complete.
try {
await control(state, `ROLLBACK TO SAVEPOINT ${name}`);
await control(state, `RELEASE SAVEPOINT ${name}`);
} catch {
state.failure ??= new ModuleDatabaseError("MODULE_DATABASE_TRANSACTION_FAILED");
}
throw releaseError;
}
return (outcome as { readonly value: T }).value;
} finally {
closeScope(child);
if (scope.activeChild === child) {
scope.activeChild = undefined;
}
scope.pending -= 1;
}
}
const executor = Object.freeze({
abiVersion: MODULE_DATABASE_ABI_VERSION,
query: scopeQuery as ModuleDatabaseQueryable["query"],
savepoint,
});
moduleByExecutor.set(executor, moduleId);
return executor;
}
async function transaction<T>(
callback: (transaction: ModuleDatabaseTransaction) => Promise<T> | T,
options?: unknown,
): Promise<T> {
if (typeof callback !== "function") {
throw new ModuleDatabaseError("MODULE_DATABASE_REQUEST_INVALID");
}
const begin = beginStatement(options);
const client = await acquire(pool, "MODULE_DATABASE_TRANSACTION_FAILED");
const state: TransactionState = {
client,
failure: undefined,
finished: false,
healthy: true,
inflight: new Set(),
savepoints: 0,
};
try {
try {
await client.query(begin);
} catch (error) {
state.healthy = connectionSurvives(error);
throw databaseError("MODULE_DATABASE_TRANSACTION_FAILED", error);
}
const scope = newScope();
const outcome = await settle(() => callback(scopeExecutor(state, scope)));
const outstanding = scope.pending > 0;
closeScope(scope);
await drain(state);
state.finished = true;
if ("error" in outcome) {
// The callback's own error stays primary, even when sibling work it started (say, the
// other half of a `Promise.all`) was still settling. That work has been drained, and rolls
// back with the rest; outstanding work never leads to a commit.
await rollback(state);
throw outcome.error;
}
if (outstanding) {
// The callback resolved with work it never awaited: a forgotten `await`.
await rollback(state);
throw new ModuleDatabaseError("MODULE_DATABASE_SCOPE_WORK_OUTSTANDING");
}
if (state.failure !== undefined) {
await rollback(state);
throw state.failure;
}
let committed: { readonly command: string | null };
try {
committed = await client.query("COMMIT");
} catch (error) {
state.healthy = connectionSurvives(error);
throw databaseError("MODULE_DATABASE_TRANSACTION_FAILED", error);
}
if (committed.command !== "COMMIT") {
// PostgreSQL answers COMMIT with ROLLBACK when a failed statement had aborted the
// transaction. Nothing was committed, and PostgreSQL supplied no SQLSTATE to report.
throw new ModuleDatabaseError("MODULE_DATABASE_TRANSACTION_FAILED");
}
return outcome.value;
} finally {
state.finished = true;
await relinquish(client, state.healthy);
}
}
const executor = Object.freeze({
abiVersion: MODULE_DATABASE_ABI_VERSION,
query: query as ModuleDatabaseQueryable["query"],
transaction: transaction as ModuleDatabaseExecutor["transaction"],
});
moduleByExecutor.set(executor, moduleId);
return executor;
}
+2 -49
View File
@@ -1,54 +1,7 @@
import type { ModuleDatabaseAbiRuntimeServices, ModuleRuntimeServices } from "@ekklesia/contracts"; import type { ModuleDatabase } from "@ekklesia/contracts";
import { drizzle, type NodePgDatabase } from "drizzle-orm/node-postgres"; import { drizzle } from "drizzle-orm/node-postgres";
import type { Pool } from "pg"; import type { Pool } from "pg";
/**
* A trusted backend module's typed Drizzle query surface — the same query-building methods a
* platform-owned Drizzle instance exposes, restricted to exactly the operations a module needs to
* read and write its own tables. Derived with `Pick` from the real `drizzle-orm/node-postgres`
* database type rather than hand-declared, so every method keeps Drizzle's own table-aware
* argument and result inference exactly as-is — a module calling `database.select().from(table)`
* or `database.insert(table).values(...)` gets the identical typed builder a direct Drizzle caller
* would, with no `any`/`unknown` erasure anywhere in the chain.
*
* Deliberately excludes everything `NodePgDatabase` or the `drizzle(...)` factory otherwise
* exposes: `$client` (the raw `pg` client/pool), `_` (internal session/dialect/schema state),
* `query` (a relational-query builder tied to one schema, which would have to span every
* installed module's tables to exist here at all), `$cache`, `$count`, `$with`, `with`, and
* `refreshMaterializedView`. There is no connection string, pool, session, or dialect object
* reachable through this type. Carries no schema generic — a module brings its own Drizzle table
* objects to each call, so there is no global relational schema spanning every installed module for
* this type to leak.
*
* Core's own, private type (ADR 0053): it moved here from `@ekklesia/contracts` so no published
* package references Drizzle. New modules use the Module Database ABI v1 instead (ADR 0050), and
* the Module SDK never exposes this facade's type.
*
* This is an integration boundary, not a security sandbox: modules are trusted, compiled backend
* plugins that already run arbitrary code in this process, exactly like every other lifecycle hook
* and HTTP handler they define. `ModuleDatabase` exists to keep a module's code Drizzle-typed and
* decoupled from how the platform physically connects to PostgreSQL — not to defend against a
* malicious module, which this facade cannot do and does not attempt to do.
*/
export type ModuleDatabase = Pick<
NodePgDatabase,
| "select"
| "selectDistinct"
| "selectDistinctOn"
| "insert"
| "update"
| "delete"
| "execute"
| "transaction"
>;
/** The services a legacy module's hooks and handlers receive: the legacy facade. */
export type LegacyModuleRuntimeServices = ModuleRuntimeServices<ModuleDatabase>;
/** The services Core hands any started module: the legacy facade or the ABI v1 executor. */
export type AnyModuleRuntimeServices =
| LegacyModuleRuntimeServices
| ModuleDatabaseAbiRuntimeServices;
/** /**
* A typed `ModuleDatabase` view over an externally owned pool — the module-facing counterpart to * A typed `ModuleDatabase` view over an externally owned pool — the module-facing counterpart to
* `createIdentityDrizzleView` (`core/src/identity/database.ts`) and * `createIdentityDrizzleView` (`core/src/identity/database.ts`) and
+1 -49
View File
@@ -9,7 +9,6 @@ import {
safeParseModuleManifest, safeParseModuleManifest,
semanticVersionSchema, semanticVersionSchema,
} from "@ekklesia/contracts"; } from "@ekklesia/contracts";
import { isReservedModuleId } from "./reserved-ids.js";
export const MODULE_MANIFEST_FILENAME = "ekklesia.module.json"; export const MODULE_MANIFEST_FILENAME = "ekklesia.module.json";
@@ -40,7 +39,6 @@ export type ModuleDiscoveryIssueCode =
| "MODULE_ENTRYPOINT_INVALID" | "MODULE_ENTRYPOINT_INVALID"
| "MODULE_ENTRYPOINT_MISSING" | "MODULE_ENTRYPOINT_MISSING"
| "MODULE_ID_DUPLICATE" | "MODULE_ID_DUPLICATE"
| "MODULE_ID_RESERVED"
| "MODULE_MANIFEST_INVALID" | "MODULE_MANIFEST_INVALID"
| "MODULE_MANIFEST_MISSING" | "MODULE_MANIFEST_MISSING"
| "MODULE_PACKAGE_INVALID" | "MODULE_PACKAGE_INVALID"
@@ -49,7 +47,6 @@ 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 {
@@ -120,35 +117,9 @@ 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;
@@ -616,18 +587,6 @@ async function scanModuleDirectory(
}); });
} }
if (isReservedModuleId(manifest.id)) {
// A module's id is a stable URL identifier: its page is `/<module-id>` (ADR 0057) and its
// settings `/settings/<module-id>` (ADR 0061), beside the platform's own pages in both.
issues.push({
code: "MODULE_ID_RESERVED",
message: `Module ID ${manifest.id} is reserved for the platform's own pages and routes.`,
moduleDirectory: directoryName,
moduleId: manifest.id,
path: "id",
});
}
const artifacts = await resolveEntrypoints(moduleDirectory, directoryName, manifest, issues); const artifacts = await resolveEntrypoints(moduleDirectory, directoryName, manifest, issues);
if (!artifacts) { if (!artifacts) {
return undefined; return undefined;
@@ -736,13 +695,6 @@ 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);
} }
@@ -751,7 +703,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" && !options.modulesDirectoryRequired) { if (errorCode(error) === "ENOENT") {
return Object.freeze([]); return Object.freeze([]);
} }
throw new ModuleDiscoveryError([ throw new ModuleDiscoveryError([
-5
View File
@@ -2,18 +2,13 @@ export * from "./configuration.js";
export * from "./configuration-schema.js"; export * from "./configuration-schema.js";
export * from "./configuration-store.js"; export * from "./configuration-store.js";
export * from "./database.js"; export * from "./database.js";
export * from "./database-abi.js";
export * from "./discovery.js"; export * from "./discovery.js";
export * from "./lifecycle.js"; export * from "./lifecycle.js";
export * from "./lifecycle-schema.js"; export * from "./lifecycle-schema.js";
export * from "./lifecycle-store.js"; export * from "./lifecycle-store.js";
export * from "./migration-discovery.js"; export * from "./migration-discovery.js";
export * from "./modules-directory.js";
export * from "./module-package-host.js";
export * from "./module-package-verification.js";
export * from "./permissions.js"; export * from "./permissions.js";
export * from "./realtime.js"; export * from "./realtime.js";
export * from "./reserved-ids.js";
export * from "./runtime.js"; export * from "./runtime.js";
export * from "./schema-discovery.js"; export * from "./schema-discovery.js";
export * from "./ui-publication.js"; export * from "./ui-publication.js";
-203
View File
@@ -1,203 +0,0 @@
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 });
}
-59
View File
@@ -1,59 +0,0 @@
/**
* The facts about this host that decide whether a native Module Package v1 can run on it
* (ADR 0049): operating system, CPU, C library family and version, and Node-API version.
*
* Everything comes from Node.js itself (`process.platform`, `process.arch`, `process.versions`, and
* the diagnostic report's `glibcVersionRuntime`), never from running a system command. A fact Node
* cannot report is left undefined, and every check that needs it fails closed.
*/
export interface ModulePackageHost {
readonly platform: string;
readonly arch: string;
/** The C library on Linux: glibc with its runtime version, or undefined when it cannot be proven
* to be glibc (musl reports none). */
readonly libc?: { readonly family: "glibc"; readonly version: string };
/** `process.versions.napi`: the highest Node-API version this Node.js provides. */
readonly nodeApi?: number;
/** `process.versions.modules`: the ABI an ABI-specific addon must have been built for. */
readonly nodeAbi?: string;
}
function glibcVersion(): string | undefined {
try {
const header = (process.report?.getReport() as { header?: { glibcVersionRuntime?: unknown } })
?.header;
const version = header?.glibcVersionRuntime;
return typeof version === "string" && /^\d+\.\d+(?:\.\d+)?$/u.test(version)
? version
: undefined;
} catch {
return undefined;
}
}
export function currentModulePackageHost(): ModulePackageHost {
const libc = process.platform === "linux" ? glibcVersion() : undefined;
const nodeApi = Number(process.versions.napi);
return Object.freeze({
arch: process.arch,
...(libc === undefined
? {}
: { libc: Object.freeze({ family: "glibc" as const, version: libc }) }),
...(Number.isInteger(nodeApi) && nodeApi > 0 ? { nodeApi } : {}),
...(typeof process.versions.modules === "string" ? { nodeAbi: process.versions.modules } : {}),
platform: process.platform,
});
}
/** The native targets this platform can verify, with what each requires of a host. */
export const MODULE_PACKAGE_NATIVE_TARGETS: ReadonlyMap<
string,
{
readonly platform: string;
readonly arch: string;
readonly libc: "glibc";
readonly elfMachine: number;
}
> = new Map([
["linux-x64-glibc", { arch: "x64", elfMachine: 62, libc: "glibc", platform: "linux" }],
]);
-101
View File
@@ -1,101 +0,0 @@
// The isolated, short-lived validation process of Module Package v1 verification (ADR 0049).
//
// The verifier forks this file with an empty environment and one JSON argument naming a candidate
// module's backend entrypoints. It imports each one, so a package whose code cannot load, or whose
// bundle still reaches for a dependency it does not contain, is refused before it is ever installed.
// Whatever the module's top-level code does happens here, in a process with no database URL, no
// secret, and no platform state, and that is discarded as soon as it reports.
//
// The result travels over the IPC channel, never stdout, so nothing a module prints can forge it.
// It carries fixed role and reason names only: never an error message, a path, or module output.
//
// Written as erasable TypeScript, so Node can run the source file directly in tests as well as the
// compiled file in the image. Its one relative import names the source file's own extension, which
// the build rewrites to `.js` (`rewriteRelativeImportExtensions`); that file follows the same rules.
import { pathToFileURL } from "node:url";
import {
parseModuleRuntimeDefinition,
parseModuleSchemaMetadataDefinition,
} from "@ekklesia/contracts";
import { parseModuleSchemaDefinition } from "./legacy-schema-definition.ts";
interface ProbeRequest {
readonly databaseAbi?: 1;
readonly moduleId: string;
readonly version: string;
readonly entrypoints: Readonly<Record<string, string>>;
}
interface ProbeFailure {
readonly role: string;
readonly reason: "contract" | "export" | "identity" | "load";
}
function defaultExport(namespace: unknown): unknown {
return typeof namespace === "object" && namespace !== null && "default" in namespace
? (namespace as { default: unknown }).default
: undefined;
}
async function probe(request: ProbeRequest): Promise<readonly ProbeFailure[]> {
const failures: ProbeFailure[] = [];
for (const role of Object.keys(request.entrypoints).sort()) {
const entrypoint = request.entrypoints[role];
if (entrypoint === undefined) {
continue;
}
let namespace: unknown;
try {
namespace = await import(pathToFileURL(entrypoint).href);
} catch {
failures.push({ reason: "load", role });
continue;
}
try {
if (role === "server") {
const definition = parseModuleRuntimeDefinition(defaultExport(namespace));
if (definition.id !== request.moduleId || definition.version !== request.version) {
failures.push({ reason: "identity", role });
}
if ((definition.database?.abi === 1) !== (request.databaseAbi === 1)) {
failures.push({ reason: "contract", role });
}
} else if (role === "schema") {
const definition =
request.databaseAbi === 1
? parseModuleSchemaMetadataDefinition(defaultExport(namespace))
: parseModuleSchemaDefinition(defaultExport(namespace));
if (definition.moduleId !== request.moduleId) {
failures.push({ reason: "identity", role });
}
}
} catch {
failures.push({ reason: "export", role });
}
}
return failures;
}
function report(message: unknown): void {
if (typeof process.send !== "function") {
process.exitCode = 2;
return;
}
process.send(message, () => {
// A module may leave timers or handles behind; the probe's work is done either way.
process.exit(0);
});
}
let request: ProbeRequest | undefined;
try {
request = JSON.parse(process.argv[2] ?? "") as ProbeRequest;
} catch {
report({ failures: [{ reason: "load", role: "request" }] });
}
if (request !== undefined) {
probe(request).then(
(failures) => report({ failures }),
() => report({ failures: [{ reason: "load", role: "probe" }] }),
);
}
@@ -1,650 +0,0 @@
import { fork } from "node:child_process";
import { createHash } from "node:crypto";
import { chmod, mkdir, mkdtemp, readFile, realpath, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
import {
isVersionCompatible,
type ModuleManifest,
safeParseModuleManifest,
} from "@ekklesia/contracts";
import {
MODULE_PACKAGE_ANY_TARGET,
MODULE_PACKAGE_FORMAT,
MODULE_PACKAGE_METADATA_FILENAME,
type ModulePackageMetadata,
safeParseModulePackageMetadata,
safeParseModulePackageRuntimePackage,
} from "@ekklesia/contracts/module-package";
import { discoverModules, MODULE_MANIFEST_FILENAME, ModuleDiscoveryError } from "./discovery.js";
import {
currentModulePackageHost,
MODULE_PACKAGE_NATIVE_TARGETS,
type ModulePackageHost,
} from "./module-package-host.js";
import { discoverModuleMigrations, ModuleMigrationDiscoveryError } from "./migration-discovery.js";
import {
type ModulePackageArchive,
ModulePackageArchiveError,
type ModulePackageArchiveErrorCode,
readModulePackageArchive,
} from "./module-package-archive.js";
import { discoverModuleSchemas, ModuleSchemaDiscoveryError } from "./schema-discovery.js";
/**
* Verifies an Ekklesia Module Package v1 archive (ADR 0049) against the platform it runs on, without
* ever installing it.
*
* Order matters: everything that can be decided from bytes is decided before anything is written,
* and nothing is executed until the package has proven itself well-formed. The archive is read and
* checked for safety in memory; its metadata, every payload digest, and every identity statement
* are checked next; only then is it written to a fresh temporary directory, validated by the same
* discovery the platform uses, and its backend loaded once in a separate, short-lived process with
* an empty environment. A caller that asks for extraction gets the verified tree written to a new
* directory only after all of that succeeded.
*
* Errors carry a stable code and a fixed message. Paths, archive entry names, file contents, module
* output, and exception messages never appear in them: an archive is untrusted input, and none of
* it is ever echoed.
*/
export type ModulePackageVerificationErrorCode =
| ModulePackageArchiveErrorCode
| "MODULE_PACKAGE_DATABASE_RUNTIME_INCOMPATIBLE"
| "MODULE_PACKAGE_DATABASE_RUNTIME_UNEXPECTED"
| "MODULE_PACKAGE_DIGEST_MISMATCH"
| "MODULE_PACKAGE_ENTRYPOINT_LOAD_FAILED"
| "MODULE_PACKAGE_EXTRACTION_FAILED"
| "MODULE_PACKAGE_FILE_MISMATCH"
| "MODULE_PACKAGE_FILE_MISSING"
| "MODULE_PACKAGE_FILE_UNLISTED"
| "MODULE_PACKAGE_FORMAT_UNSUPPORTED"
| "MODULE_PACKAGE_IDENTITY_MISMATCH"
| "MODULE_PACKAGE_LIBC_INCOMPATIBLE"
| "MODULE_PACKAGE_MANIFEST_INVALID"
| "MODULE_PACKAGE_METADATA_INVALID"
| "MODULE_PACKAGE_METADATA_MISSING"
| "MODULE_PACKAGE_MODULE_INVALID"
| "MODULE_PACKAGE_NATIVE_FILE"
| "MODULE_PACKAGE_NATIVE_FILE_INCOMPATIBLE"
| "MODULE_PACKAGE_NODE_API_INCOMPATIBLE"
| "MODULE_PACKAGE_RUNTIME_INCOMPATIBLE"
| "MODULE_PACKAGE_RUNTIME_PACKAGE_INVALID"
| "MODULE_PACKAGE_SDK_INCOMPATIBLE"
| "MODULE_PACKAGE_TARGET_INCOMPATIBLE"
| "MODULE_PACKAGE_TRANSITIONAL_UNSUPPORTED"
| "MODULE_PACKAGE_VERIFIER_SCRATCH_UNAVAILABLE";
const messages: Readonly<Record<ModulePackageVerificationErrorCode, string>> = Object.freeze({
MODULE_PACKAGE_DATABASE_RUNTIME_INCOMPATIBLE:
"The package's bundled database runtime does not match this platform's (a transitional constraint).",
MODULE_PACKAGE_DATABASE_RUNTIME_UNEXPECTED:
"A Module Database ABI package must not carry a transitional database runtime.",
MODULE_PACKAGE_DIGEST_MISMATCH: "The archive does not match the SHA-256 it was given.",
MODULE_PACKAGE_ENTRYPOINT_LOAD_FAILED:
"A backend entrypoint could not be loaded from the package alone.",
MODULE_PACKAGE_EXTRACTION_FAILED:
"The verified package could not be extracted to a new directory.",
MODULE_PACKAGE_FILE_MISMATCH: "A payload file does not match its listed size or SHA-256.",
MODULE_PACKAGE_FILE_MISSING: "A file listed in the package metadata is missing from the archive.",
MODULE_PACKAGE_FILE_UNLISTED: "The archive contains a file the package metadata does not list.",
MODULE_PACKAGE_FORMAT_UNSUPPORTED: "The package format is not one this platform implements.",
MODULE_PACKAGE_IDENTITY_MISMATCH:
"The package metadata, runtime package.json, and manifest disagree about the module's identity.",
MODULE_PACKAGE_LAYOUT_INVALID:
"The archive must contain exactly one top-level module directory, with each path once.",
MODULE_PACKAGE_LIBC_INCOMPATIBLE:
"The package needs a newer C library than this host provides, or one it cannot establish.",
MODULE_PACKAGE_LINK: "The archive contains a link; module packages may contain none.",
MODULE_PACKAGE_MANIFEST_INVALID: "The package's module manifest is missing or invalid.",
MODULE_PACKAGE_METADATA_INVALID: "The package metadata is invalid.",
MODULE_PACKAGE_METADATA_MISSING: "The archive contains no package metadata.",
MODULE_PACKAGE_MODULE_INVALID: "The packaged module failed module validation.",
MODULE_PACKAGE_NATIVE_FILE:
"An any-target package contains a native file or a node_modules directory.",
MODULE_PACKAGE_NATIVE_FILE_INCOMPATIBLE:
"A native file in the package is not built for the package's target.",
MODULE_PACKAGE_NODE_API_INCOMPATIBLE:
"The package's addons need a Node-API version or Node.js ABI this host does not provide.",
MODULE_PACKAGE_RUNTIME_INCOMPATIBLE:
"The package needs a Node.js version this host does not run.",
MODULE_PACKAGE_RUNTIME_PACKAGE_INVALID:
"The package's runtime package.json is not the generated runtime shape.",
MODULE_PACKAGE_SDK_INCOMPATIBLE:
"The Module SDK bundled into the package is outside the manifest's own moduleSdk range.",
MODULE_PACKAGE_SPECIAL_ENTRY:
"The archive contains an entry that is neither a directory nor a regular file.",
MODULE_PACKAGE_TARGET_INCOMPATIBLE: "The package's target is not one this platform can run.",
MODULE_PACKAGE_TOO_LARGE: "The archive exceeds the module package size limits.",
MODULE_PACKAGE_TRANSITIONAL_UNSUPPORTED:
"The package metadata contains transitional facts this platform does not understand.",
MODULE_PACKAGE_VERIFIER_SCRATCH_UNAVAILABLE:
"The verifier execution scratch directory is missing, not writable, or mounted noexec.",
MODULE_PACKAGE_UNREADABLE: "The file is not a readable gzip-compressed ustar archive.",
MODULE_PACKAGE_UNSAFE_MODE: "The archive contains a setuid, setgid, or sticky entry.",
MODULE_PACKAGE_UNSAFE_PATH: "The archive contains an absolute, escaping, or non-portable path.",
});
export class ModulePackageVerificationError extends Error {
readonly code: ModulePackageVerificationErrorCode;
/** Stable discovery issue codes, when module validation failed; never their messages. */
readonly issueCodes: readonly string[];
constructor(code: ModulePackageVerificationErrorCode, issueCodes: readonly string[] = []) {
super(messages[code]);
this.name = "ModulePackageVerificationError";
this.code = code;
this.issueCodes = Object.freeze([...new Set(issueCodes)].sort());
this.stack = `${this.name}: ${this.message}`;
Object.freeze(this);
}
}
/** What this platform is, for the checks that compare a package against it. */
export interface ModulePackagePlatform {
readonly platformVersion: string;
readonly moduleSdkVersion: string;
/** The transitional database runtime facts this platform requires a package to agree with. */
readonly databaseRuntime: Readonly<Record<string, string>>;
/** Defaults to the running process's Node.js version. */
readonly nodeVersion?: string;
/** Defaults to this process's own host (`currentModulePackageHost`). */
readonly host?: ModulePackageHost;
}
export interface VerifyModulePackageOptions {
readonly archivePath: string;
/** When given, the archive's SHA-256 must equal it (lowercase hexadecimal). */
readonly expectedSha256?: string;
readonly platform: ModulePackagePlatform;
/** A directory that must not exist yet; the verified module directory is written there. */
readonly extractTo?: string;
/**
* Existing writable directory where backend candidates may execute. Defaults to `os.tmpdir()`.
* A hardened caller whose system temporary directory is `noexec` must provide a dedicated
* executable scratch mount; each verification creates and removes its own child directory.
*/
readonly executionScratchDirectory?: string;
readonly loadTimeoutMs?: number;
}
export interface ModulePackageVerificationResult {
readonly archiveSha256: string;
/** Payload files, the metadata file not included. */
readonly fileCount: number;
readonly moduleId: string;
readonly target: string;
readonly version: string;
}
function fail(code: ModulePackageVerificationErrorCode, issueCodes?: readonly string[]): never {
throw new ModulePackageVerificationError(code, issueCodes);
}
function sha256(data: Uint8Array): string {
return createHash("sha256").update(data).digest("hex");
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function parseJson(data: Buffer | undefined): unknown {
if (data === undefined) {
return undefined;
}
try {
return JSON.parse(data.toString("utf8")) as unknown;
} catch {
return undefined;
}
}
/** The transitional facts this verifier understands, and nothing else (ADR 0049). */
const UNDERSTOOD_TRANSITIONAL: Readonly<Record<string, readonly string[]>> = Object.freeze({
databaseRuntime: Object.freeze(["drizzle-orm"]),
});
function checkTransitional(raw: Record<string, unknown>): void {
const transitional = raw.transitional;
if (transitional === undefined) {
return;
}
if (!isRecord(transitional)) {
fail("MODULE_PACKAGE_TRANSITIONAL_UNSUPPORTED");
}
for (const [key, value] of Object.entries(transitional)) {
const understood = UNDERSTOOD_TRANSITIONAL[key];
if (!understood || !isRecord(value)) {
fail("MODULE_PACKAGE_TRANSITIONAL_UNSUPPORTED");
}
if (Object.keys(value).some((name) => !understood.includes(name))) {
fail("MODULE_PACKAGE_TRANSITIONAL_UNSUPPORTED");
}
}
}
function readMetadata(files: ReadonlyMap<string, Buffer>): ModulePackageMetadata {
const raw = parseJson(files.get(MODULE_PACKAGE_METADATA_FILENAME));
if (!files.has(MODULE_PACKAGE_METADATA_FILENAME)) {
fail("MODULE_PACKAGE_METADATA_MISSING");
}
if (!isRecord(raw)) {
fail("MODULE_PACKAGE_METADATA_INVALID");
}
if (raw.packageFormat !== MODULE_PACKAGE_FORMAT) {
fail("MODULE_PACKAGE_FORMAT_UNSUPPORTED");
}
checkTransitional(raw);
const parsed = safeParseModulePackageMetadata(raw);
if (!parsed.success) {
fail("MODULE_PACKAGE_METADATA_INVALID");
}
return parsed.data;
}
function checkPayload(metadata: ModulePackageMetadata, files: ReadonlyMap<string, Buffer>): void {
const listed = new Set(metadata.files.map((file) => file.path));
for (const filePath of files.keys()) {
if (filePath !== MODULE_PACKAGE_METADATA_FILENAME && !listed.has(filePath)) {
fail("MODULE_PACKAGE_FILE_UNLISTED");
}
}
for (const file of metadata.files) {
const data = files.get(file.path);
if (data === undefined) {
fail("MODULE_PACKAGE_FILE_MISSING");
}
if (data.length !== file.size || sha256(data) !== file.sha256) {
fail("MODULE_PACKAGE_FILE_MISMATCH");
}
}
}
function checkIdentity(
archive: ModulePackageArchive,
metadata: ModulePackageMetadata,
files: ReadonlyMap<string, Buffer>,
): ModuleManifest {
const runtimePackage = safeParseModulePackageRuntimePackage(parseJson(files.get("package.json")));
if (!runtimePackage.success) {
fail("MODULE_PACKAGE_RUNTIME_PACKAGE_INVALID");
}
const manifest = safeParseModuleManifest(parseJson(files.get(MODULE_MANIFEST_FILENAME)));
if (!manifest.success) {
fail("MODULE_PACKAGE_MANIFEST_INVALID");
}
if (
archive.topLevel !== metadata.module.id ||
manifest.data.id !== metadata.module.id ||
manifest.data.version !== metadata.module.version ||
runtimePackage.data.version !== metadata.module.version
) {
fail("MODULE_PACKAGE_IDENTITY_MISMATCH");
}
return manifest.data;
}
function checkCompatibility(
metadata: ModulePackageMetadata,
manifest: ModuleManifest,
files: ReadonlyMap<string, Buffer>,
platform: ModulePackagePlatform,
): void {
if (metadata.target === MODULE_PACKAGE_ANY_TARGET) {
// An any-target package bundles everything: no addon, no ELF object, no node_modules.
for (const [filePath, data] of files) {
if (
filePath.toLowerCase().endsWith(".node") ||
filePath.startsWith("node_modules/") ||
isElf(data)
) {
fail("MODULE_PACKAGE_NATIVE_FILE");
}
}
} else {
checkNativeTarget(metadata, files, platform.host ?? currentModulePackageHost());
}
if (!isVersionCompatible(platform.nodeVersion ?? process.versions.node, metadata.runtime.node)) {
fail("MODULE_PACKAGE_RUNTIME_INCOMPATIBLE");
}
const sdk = metadata.incorporates["@ekklesia/module-sdk"];
if (sdk !== undefined && !isVersionCompatible(sdk, manifest.compatibility.moduleSdk)) {
fail("MODULE_PACKAGE_SDK_INCOMPATIBLE");
}
if (manifest.database?.abi === 1) {
if (metadata.transitional?.databaseRuntime !== undefined) {
fail("MODULE_PACKAGE_DATABASE_RUNTIME_UNEXPECTED");
}
return;
}
// Transitional legacy-v8 path (ADR 0049): Core queries the module's Drizzle tables with its own
// Drizzle instance, so a recorded module runtime must match. ABI-v1 packages return above: their
// bundled ORM is a private implementation detail and is never compared with Core's.
for (const [runtime, version] of Object.entries(metadata.transitional?.databaseRuntime ?? {})) {
if (platform.databaseRuntime[runtime] !== version) {
fail("MODULE_PACKAGE_DATABASE_RUNTIME_INCOMPATIBLE");
}
}
}
function isElf(data: Uint8Array): boolean {
return (
data.length >= 4 && data[0] === 0x7f && data[1] === 0x45 && data[2] === 0x4c && data[3] === 0x46
);
}
/** `2.41` → `2.41.0`, for comparison against a semantic-version range. */
function asSemver(version: string): string {
const parts = version.split(".");
return [...parts, "0", "0"].slice(0, 3).join(".");
}
/**
* A native target (ADR 0049): accepted only for a target this platform implements, on a host that
* provably matches it. Platform, CPU, C library family and minimum version, and the Node-API version
* or ABI the addons need are all checked; a fact the host cannot establish fails closed. Every ELF
* object in the package must be built for the target's machine, and every `.node` addon must be one.
*/
function checkNativeTarget(
metadata: ModulePackageMetadata,
files: ReadonlyMap<string, Buffer>,
host: ModulePackageHost,
): void {
const target = MODULE_PACKAGE_NATIVE_TARGETS.get(metadata.target);
if (
target === undefined ||
host.platform !== target.platform ||
host.arch !== target.arch ||
host.libc?.family !== target.libc
) {
fail("MODULE_PACKAGE_TARGET_INCOMPATIBLE");
}
const required = metadata.runtime.libcVersion;
if (
required === undefined ||
host.libc === undefined ||
!isVersionCompatible(asSemver(host.libc.version), required)
) {
fail("MODULE_PACKAGE_LIBC_INCOMPATIBLE");
}
const { nodeAbi, nodeApi } = metadata.runtime;
if (
(nodeApi === undefined && nodeAbi === undefined) ||
(nodeApi !== undefined && (host.nodeApi === undefined || host.nodeApi < nodeApi)) ||
(nodeAbi !== undefined && host.nodeAbi !== nodeAbi)
) {
fail("MODULE_PACKAGE_NODE_API_INCOMPATIBLE");
}
for (const [filePath, data] of files) {
const addon = filePath.toLowerCase().endsWith(".node");
if (!isElf(data)) {
if (addon) {
fail("MODULE_PACKAGE_NATIVE_FILE_INCOMPATIBLE");
}
continue;
}
// ELF64, little-endian, for the target's machine (e_machine at offset 18).
if (
data.length < 20 ||
data[4] !== 2 ||
data[5] !== 1 ||
data.readUInt16LE(18) !== target.elfMachine
) {
fail("MODULE_PACKAGE_NATIVE_FILE_INCOMPATIBLE");
}
}
}
/** Writes the archive's directories (`0755`) and files (`0644`) under `root`, which must not exist.
* Safe by construction: every path was validated as relative and non-escaping, and only directories
* and regular files exist. Ownership is the writing process's, never the archive's. */
async function writeTree(root: string, archive: ModulePackageArchive): Promise<void> {
await mkdir(root, { mode: 0o755 });
await chmod(root, 0o755);
for (const entry of archive.entries) {
const target = path.join(root, ...entry.path.split("/"));
if (entry.type === "directory") {
await mkdir(target, { mode: 0o755, recursive: true });
await chmod(target, 0o755);
} else {
await mkdir(path.dirname(target), { mode: 0o755, recursive: true });
await writeFile(target, entry.data, { flag: "wx", mode: 0o644 });
await chmod(target, 0o644);
}
}
}
function probeEntrypointPath(): string {
// The probe sits beside this file: compiled `.js` in an installation, source `.ts` under a test
// runner, which Node runs directly. Either way it is a separate process.
const self = fileURLToPath(import.meta.url);
return path.join(path.dirname(self), `module-package-probe${path.extname(self)}`);
}
function decodedMountPath(value: string): string {
return value.replace(/\\([0-7]{3})/gu, (_match, octal: string) =>
String.fromCharCode(Number.parseInt(octal, 8)),
);
}
/** Linux exposes the effective options of every mount, including a container's tmpfs mounts. A
* verifier cannot infer `noexec` from file permissions: native addons are mapped executable rather
* than invoked as files. On systems without mountinfo, the load remains the final authority. */
async function isNoExecMount(directory: string): Promise<boolean> {
if (process.platform !== "linux") return false;
let mountinfo: string;
let canonicalDirectory: string;
try {
[mountinfo, canonicalDirectory] = await Promise.all([
readFile("/proc/self/mountinfo", "utf8"),
realpath(directory),
]);
} catch {
return false;
}
const mounts = mountinfo
.split("\n")
.map((line) => line.split(" "))
.filter((fields) => fields.length > 6 && fields.includes("-"))
.map((fields) => ({
mountPoint: decodedMountPath(fields[4] ?? ""),
options: (fields[5] ?? "").split(","),
}))
.filter(
({ mountPoint }) =>
canonicalDirectory === mountPoint ||
canonicalDirectory.startsWith(mountPoint === "/" ? "/" : `${mountPoint}/`),
)
.sort((left, right) => right.mountPoint.length - left.mountPoint.length);
return mounts[0]?.options.includes("noexec") ?? false;
}
async function createExecutionScratch(baseDirectory: string): Promise<string> {
if (!path.isAbsolute(baseDirectory)) {
fail("MODULE_PACKAGE_VERIFIER_SCRATCH_UNAVAILABLE");
}
try {
if (await isNoExecMount(baseDirectory)) {
fail("MODULE_PACKAGE_VERIFIER_SCRATCH_UNAVAILABLE");
}
return await mkdtemp(path.join(baseDirectory, "ekklesia-package-verify-"));
} catch (error) {
if (error instanceof ModulePackageVerificationError) throw error;
fail("MODULE_PACKAGE_VERIFIER_SCRATCH_UNAVAILABLE");
}
}
/** Loads every backend entrypoint in a short-lived child with an empty environment, and reports
* only whether each loaded and exported a definition naming this module. */
async function loadEntrypoints(
manifest: Pick<ModuleManifest, "database" | "id" | "version">,
entrypoints: Readonly<Record<string, string>>,
timeoutMs: number,
executionDirectory: string,
): Promise<void> {
const outcome = await new Promise<"ok" | "failed">((resolve) => {
let settled = false;
let child: ReturnType<typeof fork> | undefined;
let timer: ReturnType<typeof setTimeout> | undefined;
const finish = (value: "ok" | "failed"): void => {
if (!settled) {
settled = true;
clearTimeout(timer);
child?.kill("SIGKILL");
resolve(value);
}
};
try {
child = fork(
probeEntrypointPath(),
[
JSON.stringify({
...(manifest.database?.abi === 1 ? { databaseAbi: 1 } : {}),
entrypoints,
moduleId: manifest.id,
version: manifest.version,
}),
],
{
cwd: executionDirectory,
env: {},
execArgv: [],
serialization: "json",
stdio: ["ignore", "ignore", "ignore", "ipc"],
},
);
} catch {
finish("failed");
return;
}
timer = setTimeout(() => finish("failed"), timeoutMs);
child.on("message", (message: unknown) => {
const failures = isRecord(message) ? message.failures : undefined;
finish(Array.isArray(failures) && failures.length === 0 ? "ok" : "failed");
});
child.on("error", () => finish("failed"));
child.on("exit", () => finish("failed"));
});
if (outcome !== "ok") {
fail("MODULE_PACKAGE_ENTRYPOINT_LOAD_FAILED");
}
}
function discoveryIssueCodes(error: unknown): readonly string[] {
if (
error instanceof ModuleDiscoveryError ||
error instanceof ModuleMigrationDiscoveryError ||
error instanceof ModuleSchemaDiscoveryError
) {
return error.issues.map((issue) => issue.code);
}
return [];
}
export async function verifyModulePackage(
options: VerifyModulePackageOptions,
): Promise<ModulePackageVerificationResult> {
let compressed: Buffer;
try {
compressed = await readFile(options.archivePath);
} catch {
fail("MODULE_PACKAGE_UNREADABLE");
}
const archiveSha256 = sha256(compressed);
if (options.expectedSha256 !== undefined && options.expectedSha256 !== archiveSha256) {
fail("MODULE_PACKAGE_DIGEST_MISMATCH");
}
let archive: ModulePackageArchive;
try {
archive = readModulePackageArchive(compressed);
} catch (error) {
if (error instanceof ModulePackageArchiveError) {
fail(error.code);
}
fail("MODULE_PACKAGE_UNREADABLE");
}
const files = new Map(
archive.entries
.filter((entry) => entry.type === "file")
.map((entry) => [entry.path, entry.data] as const),
);
const metadata = readMetadata(files);
checkPayload(metadata, files);
const manifest = checkIdentity(archive, metadata, files);
checkCompatibility(metadata, manifest, files, options.platform);
const scratch = await createExecutionScratch(options.executionScratchDirectory ?? tmpdir());
try {
// The candidate alone, in a scratch inventory of its own: never module storage.
const modulesDirectory = path.join(scratch, "candidates");
await mkdir(modulesDirectory, { mode: 0o755 });
await writeTree(path.join(modulesDirectory, metadata.module.id), archive);
const discoveryOptions = {
moduleSdkVersion: options.platform.moduleSdkVersion,
modulesDirectory,
modulesDirectoryRequired: true,
platformVersion: options.platform.platformVersion,
} as const;
let entrypoints: Record<string, string>;
try {
// The same static validation the platform runs, against this package alone. A module that
// requires another module is validated with its dependencies when it is installed.
const [discovered] = await discoverModules(discoveryOptions);
if (discovered === undefined) {
fail("MODULE_PACKAGE_MODULE_INVALID");
}
await discoverModuleMigrations(discoveryOptions);
entrypoints = {
server: discovered.entrypoints.server,
...(discovered.entrypoints.schema ? { schema: discovered.entrypoints.schema } : {}),
...(discovered.entrypoints.jobs ? { jobs: discovered.entrypoints.jobs } : {}),
};
} catch (error) {
if (error instanceof ModulePackageVerificationError) {
throw error;
}
fail("MODULE_PACKAGE_MODULE_INVALID", discoveryIssueCodes(error));
}
// Executed only now, and first in isolation: a schema is imported in this process below only
// after the probe proved it loads on its own.
await loadEntrypoints(manifest, entrypoints, options.loadTimeoutMs ?? 30_000, scratch);
try {
await discoverModuleSchemas(discoveryOptions);
} catch (error) {
fail("MODULE_PACKAGE_MODULE_INVALID", discoveryIssueCodes(error));
}
if (options.extractTo !== undefined) {
const extractTo = options.extractTo;
let created = false;
try {
await mkdir(extractTo, { mode: 0o755 });
created = true;
await rm(extractTo, { recursive: true });
await writeTree(extractTo, archive);
} catch {
// Never leave a partial module directory behind; never remove one this call did not create.
if (created) {
await rm(extractTo, { force: true, recursive: true }).catch(() => undefined);
}
fail("MODULE_PACKAGE_EXTRACTION_FAILED");
}
}
} finally {
await rm(scratch, { force: true, recursive: true });
}
return Object.freeze({
archiveSha256,
fileCount: metadata.files.length,
moduleId: metadata.module.id,
target: metadata.target,
version: metadata.module.version,
});
}
-66
View File
@@ -1,66 +0,0 @@
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,
});
}
-56
View File
@@ -1,56 +0,0 @@
/**
* Names the platform owns, which therefore can never be a module id (ADR 0057, ADR 0061).
*
* A module id is a **stable URL identifier**. Every module with a Dashboard UI is addressed at
* `/<module-id>`, and every module with settings at `/settings/<module-id>`, beside the platform's
* own pages in the same two namespaces. A module called `settings` would collide with the
* Dashboard's Settings, and one called `discover` with Settings → Discover. This is the complete set
* the platform serves today, not a guess at what it might want later.
*/
/**
* Top-level namespaces (`/<name>`):
*
* - `api` — the API: identity, principal, health, Core resources, module routes, module UI artifacts,
* display descriptors, and realtime (`/api/…`);
* - `assets` — the Dashboard's and display host's scripts (`/assets/…`);
* - `display` — the display host's pages and service worker (`/display/…`);
* - `settings` and `account` — the Dashboard's own Settings and account areas.
*/
export const RESERVED_TOP_LEVEL_NAMES = Object.freeze([
"account",
"api",
"assets",
"display",
"settings",
] as const);
/**
* The platform's own Settings sections (`/settings/<name>`): General, Branding, Displays, Discover
* (finding, installing, and managing modules), and System. Every other `/settings/<name>` is a
* module's settings. Discover's own pages live beneath it (`/settings/discover/…`), so they cannot
* collide with a module and need no reservation of their own.
*/
export const RESERVED_SETTINGS_NAMES = Object.freeze([
"branding",
"discover",
"displays",
"general",
"system",
] as const);
/**
* Every reserved name, sorted. `apps/api` holds this list to the routes it actually registers and to
* the pages and Settings sections the shell interprets, so a new platform route or Settings section
* cannot be added without reserving it here. Reserving a name is a breaking change for any
* installation with a module of that id, so the set grows only deliberately.
*/
export const RESERVED_MODULE_IDS = Object.freeze(
[...RESERVED_TOP_LEVEL_NAMES, ...RESERVED_SETTINGS_NAMES].sort(),
);
const reserved: ReadonlySet<string> = new Set(RESERVED_MODULE_IDS);
export function isReservedModuleId(moduleId: string): boolean {
return reserved.has(moduleId);
}
+26 -68
View File
@@ -7,16 +7,15 @@ import {
EMPTY_MODULE_CONFIGURATION, EMPTY_MODULE_CONFIGURATION,
EMPTY_MODULE_STORAGE, EMPTY_MODULE_STORAGE,
type ModuleConfiguration, type ModuleConfiguration,
type ModuleDatabaseExecutor,
type ModuleHttpRouteRegistrar, type ModuleHttpRouteRegistrar,
type ModuleId, type ModuleId,
ModuleRealtimeError, ModuleRealtimeError,
type ModuleRealtimePublisher, type ModuleRealtimePublisher,
type ModuleRegistrationContext, type ModuleRegistrationContext,
type ModuleRouteDefinitionInput, type ModuleRouteDefinitionInput,
type ModuleRouteHandler,
type ModuleRuntimeContext, type ModuleRuntimeContext,
type ModuleRuntimeDefinition, type ModuleRuntimeDefinition,
type ModuleRuntimeServices,
type ModuleStorage, type ModuleStorage,
moduleRouteShapeKey, moduleRouteShapeKey,
moduleRoutesOverlap, moduleRoutesOverlap,
@@ -25,7 +24,6 @@ import {
type RegisteredModuleHttpRoute, type RegisteredModuleHttpRoute,
safeParseModuleRouteDefinition, safeParseModuleRouteDefinition,
} from "@ekklesia/contracts"; } from "@ekklesia/contracts";
import type { AnyModuleRuntimeServices, ModuleDatabase } from "./database.js";
import { import {
type DiscoveredModule, type DiscoveredModule,
discoverModules, discoverModules,
@@ -70,7 +68,6 @@ export type ModuleRuntimeIssueCode =
| "MODULE_ROUTE_PERMISSION_INVALID" | "MODULE_ROUTE_PERMISSION_INVALID"
| "MODULE_ROUTE_PERMISSION_UNDECLARED" | "MODULE_ROUTE_PERMISSION_UNDECLARED"
| "MODULE_RUNTIME_EXPORT_INVALID" | "MODULE_RUNTIME_EXPORT_INVALID"
| "MODULE_RUNTIME_DATABASE_MISMATCH"
| "MODULE_RUNTIME_ID_MISMATCH" | "MODULE_RUNTIME_ID_MISMATCH"
| "MODULE_RUNTIME_IMPORT_FAILED" | "MODULE_RUNTIME_IMPORT_FAILED"
| "MODULE_RUNTIME_VERSION_MISMATCH" | "MODULE_RUNTIME_VERSION_MISMATCH"
@@ -108,8 +105,9 @@ export interface StartedModuleRuntime {
/** /**
* Per-module frozen configuration snapshots, keyed by module id (ADR 0029). * Per-module frozen configuration snapshots, keyed by module id (ADR 0029).
* *
* A lookup rather than a field on `ModuleRuntimeServices`, because configuration is resolved and * A lookup rather than a field on `ModuleRuntimeServices`, because `services` is deliberately **one
* frozen per module independently of its live service object. A module with no entry receives * shared object identity every module receives** — a property this runtime has a test for — while
* configuration is per-module by definition. A module with no entry receives
* `EMPTY_MODULE_CONFIGURATION` rather than `undefined`, so `context.config` is always present and * `EMPTY_MODULE_CONFIGURATION` rather than `undefined`, so `context.config` is always present and
* module code never branches on whether the platform supplied one. * module code never branches on whether the platform supplied one.
*/ */
@@ -126,33 +124,12 @@ function configurationFor(
* Per-module scoped object storage, keyed by module id (ADR 0030). * Per-module scoped object storage, keyed by module id (ADR 0030).
* *
* A lookup for the same reason configuration is one, and emphatically **not** a field on * A lookup for the same reason configuration is one, and emphatically **not** a field on
* `ModuleRuntimeServices`: a storage facade is scoped to exactly the buckets one module declared. * `ModuleRuntimeServices`: `services` is one shared object identity every module receives, while a
* Sharing it would hand every * storage facade is scoped to exactly the buckets one module declared. Sharing it would hand every
* module every other module's storage. * module every other module's storage.
*/ */
export type ModuleStorageLookup = ReadonlyMap<string, ModuleStorage>; export type ModuleStorageLookup = ReadonlyMap<string, ModuleStorage>;
/** One frozen service object per module id. Legacy modules may point at objects wrapping the same
* facade; ABI-v1 modules each point at their own attributed executor. */
export type ModuleRuntimeServicesLookup = ReadonlyMap<string, AnyModuleRuntimeServices>;
export type ModuleRuntimeServicesSource = ModuleRuntimeServicesLookup | AnyModuleRuntimeServices;
function servicesFor(
services: ModuleRuntimeServicesSource,
module: DiscoveredModule,
): AnyModuleRuntimeServices {
const selected = "get" in services ? services.get(module.manifest.id) : services;
if (!selected) {
throw new TypeError(`Module ${module.manifest.id} has no runtime services.`);
}
const expectsAbi = module.manifest.database?.abi === 1;
const isAbi = "abiVersion" in selected.database && selected.database.abiVersion === 1;
if (expectsAbi !== isAbi) {
throw new TypeError(`Module ${module.manifest.id} has the wrong database service.`);
}
return selected;
}
function storageFor(storages: ModuleStorageLookup, moduleId: string): ModuleStorage { function storageFor(storages: ModuleStorageLookup, moduleId: string): ModuleStorage {
return storages.get(moduleId) ?? EMPTY_MODULE_STORAGE; return storages.get(moduleId) ?? EMPTY_MODULE_STORAGE;
} }
@@ -187,9 +164,10 @@ export interface ModuleRuntimeCompositionOptions extends ModuleRuntimeTimeoutOpt
export interface StartModuleRuntimeOptions export interface StartModuleRuntimeOptions
extends ModuleDiscoveryOptions, extends ModuleDiscoveryOptions,
ModuleRuntimeCompositionOptions { ModuleRuntimeCompositionOptions {
/** The manifest-selected service object for each module. One module's `start`/`stop` hooks and /** The same frozen services object every started module's `start`/`stop` hooks and (once
* handlers receive its same entry; ABI-v1 entries carry distinct attributed executors. */ * `apps/api` mounts routes) HTTP handlers receive for this runtime — a required production
readonly services: ModuleRuntimeServicesSource; * dependency, never an optional test-only global. See `ModuleRuntimeServices`. */
readonly services: ModuleRuntimeServices;
} }
interface LoadedModule { interface LoadedModule {
@@ -197,13 +175,6 @@ interface LoadedModule {
readonly discovered: DiscoveredModule; readonly discovered: DiscoveredModule;
} }
type AnyRegistrationHook = (
context: ModuleRegistrationContext<ModuleDatabase | ModuleDatabaseExecutor>,
) => unknown;
type AnyRuntimeHook = (
context: ModuleRuntimeContext<ModuleDatabase | ModuleDatabaseExecutor>,
) => unknown;
interface CapabilityRegistrationStage { interface CapabilityRegistrationStage {
readonly capabilities: CapabilityRegistrar; readonly capabilities: CapabilityRegistrar;
commit(): readonly ModuleRuntimeIssue[]; commit(): readonly ModuleRuntimeIssue[];
@@ -345,11 +316,11 @@ class StagedCapabilityRegistry {
createRuntimeContext( createRuntimeContext(
module: DiscoveredModule, module: DiscoveredModule,
phase: "rollback" | "start" | "stop", phase: "rollback" | "start" | "stop",
services: AnyModuleRuntimeServices, services: ModuleRuntimeServices,
config: ModuleConfiguration, config: ModuleConfiguration,
storage: ModuleStorage, storage: ModuleStorage,
realtime: ModuleRealtimePublisher, realtime: ModuleRealtimePublisher,
): ModuleRuntimeContext<ModuleDatabase | ModuleDatabaseExecutor> { ): ModuleRuntimeContext {
const capabilities: CapabilityResolver = Object.freeze({ const capabilities: CapabilityResolver = Object.freeze({
resolve: (capabilityId: CapabilityId): RegisteredCapability | undefined => { resolve: (capabilityId: CapabilityId): RegisteredCapability | undefined => {
this.#assertAccess(module, capabilityId, phase); this.#assertAccess(module, capabilityId, phase);
@@ -498,7 +469,7 @@ class StagedModuleHttpRoutes {
local.push( local.push(
Object.freeze({ Object.freeze({
bodyMode: parsed.data.bodyMode, bodyMode: parsed.data.bodyMode,
handler: parsed.data.handler as ModuleRouteHandler, handler: parsed.data.handler,
maxBodyBytes: parsed.data.maxBodyBytes, maxBodyBytes: parsed.data.maxBodyBytes,
method: parsed.data.method, method: parsed.data.method,
moduleId: module.manifest.id, moduleId: module.manifest.id,
@@ -584,19 +555,6 @@ async function loadRuntimeDefinitions(
), ),
); );
} }
const runtimeDeclaresAbi = definition.database?.abi === 1;
const manifestDeclaresAbi = discovered.manifest.database?.abi === 1;
if (runtimeDeclaresAbi !== manifestDeclaresAbi) {
matchesManifest = false;
issues.push(
runtimeIssue(
"MODULE_RUNTIME_DATABASE_MISMATCH",
"load",
discovered.manifest.id,
`Module runtime database declaration does not match manifest ${discovered.manifest.id}.`,
),
);
}
if (matchesManifest) { if (matchesManifest) {
loaded.push({ definition, discovered }); loaded.push({ definition, discovered });
} }
@@ -622,15 +580,15 @@ async function registerModules(
module.discovered.manifest.id, module.discovered.manifest.id,
new Set(module.discovered.manifest.permissions.map((declaration) => declaration.id)), new Set(module.discovered.manifest.permissions.map((declaration) => declaration.id)),
); );
const context = Object.freeze({ const context: ModuleRegistrationContext = Object.freeze({
capabilities: capabilityStage.capabilities, capabilities: capabilityStage.capabilities,
config: configurationFor(configurations, module.discovered.manifest.id), config: configurationFor(configurations, module.discovered.manifest.id),
http: routeStage.http, http: routeStage.http,
moduleId: module.discovered.manifest.id, moduleId: module.discovered.manifest.id,
realtime: realtimeStage.realtime, realtime: realtimeStage.realtime,
}) as ModuleRegistrationContext<ModuleDatabase | ModuleDatabaseExecutor>; });
try { try {
await (module.definition.register as AnyRegistrationHook | undefined)?.(context); await module.definition.register?.(context);
} catch (error) { } catch (error) {
if (error instanceof ModuleRuntimeSignal) { if (error instanceof ModuleRuntimeSignal) {
throw new ModuleRuntimeError([error.issue]); throw new ModuleRuntimeError([error.issue]);
@@ -727,7 +685,7 @@ async function stopModules(
modules: readonly LoadedModule[], modules: readonly LoadedModule[],
registry: StagedCapabilityRegistry, registry: StagedCapabilityRegistry,
phase: "rollback" | "stop", phase: "rollback" | "stop",
services: ModuleRuntimeServicesSource, services: ModuleRuntimeServices,
configurations: ModuleConfigurationLookup, configurations: ModuleConfigurationLookup,
storages: ModuleStorageLookup, storages: ModuleStorageLookup,
realtimeRegistry: StagedModuleRealtimeRegistry, realtimeRegistry: StagedModuleRealtimeRegistry,
@@ -740,7 +698,7 @@ async function stopModules(
// A dependency may keep doing ordinary work while its dependents stop. Deactivate only the // A dependency may keep doing ordinary work while its dependents stop. Deactivate only the
// module whose stop hook is about to run, not every publisher at the beginning of the sequence. // module whose stop hook is about to run, not every publisher at the beginning of the sequence.
realtimeRegistry.deactivateModule(moduleId); realtimeRegistry.deactivateModule(moduleId);
const stop = module.definition.stop as AnyRuntimeHook | undefined; const stop = module.definition.stop;
if (!stop) { if (!stop) {
// No synthetic callback and no timer for a module that declares no stop hook. // No synthetic callback and no timer for a module that declares no stop hook.
continue; continue;
@@ -752,13 +710,13 @@ async function stopModules(
registry.createRuntimeContext( registry.createRuntimeContext(
module.discovered, module.discovered,
phase, phase,
servicesFor(services, module.discovered), services,
configurationFor(configurations, moduleId), configurationFor(configurations, moduleId),
storageFor(storages, moduleId), storageFor(storages, moduleId),
realtimeRegistry.publisherFor(moduleId), realtimeRegistry.publisherFor(moduleId),
) as ModuleRuntimeContext<ModuleDatabase | ModuleDatabaseExecutor>,
), ),
).then(() => undefined), ),
),
timeoutMs, timeoutMs,
); );
if (outcome === "timed-out") { if (outcome === "timed-out") {
@@ -792,7 +750,7 @@ async function stopModules(
async function startModules( async function startModules(
modules: readonly LoadedModule[], modules: readonly LoadedModule[],
registry: StagedCapabilityRegistry, registry: StagedCapabilityRegistry,
services: ModuleRuntimeServicesSource, services: ModuleRuntimeServices,
configurations: ModuleConfigurationLookup, configurations: ModuleConfigurationLookup,
storages: ModuleStorageLookup, storages: ModuleStorageLookup,
realtimeRegistry: StagedModuleRealtimeRegistry, realtimeRegistry: StagedModuleRealtimeRegistry,
@@ -808,15 +766,15 @@ async function startModules(
for (const module of modules) { for (const module of modules) {
rollbackCandidates.push(module); rollbackCandidates.push(module);
try { try {
await (module.definition.start as AnyRuntimeHook | undefined)?.( await module.definition.start?.(
registry.createRuntimeContext( registry.createRuntimeContext(
module.discovered, module.discovered,
"start", "start",
servicesFor(services, module.discovered), services,
configurationFor(configurations, module.discovered.manifest.id), configurationFor(configurations, module.discovered.manifest.id),
storageFor(storages, module.discovered.manifest.id), storageFor(storages, module.discovered.manifest.id),
realtimeRegistry.publisherFor(module.discovered.manifest.id), realtimeRegistry.publisherFor(module.discovered.manifest.id),
) as ModuleRuntimeContext<ModuleDatabase | ModuleDatabaseExecutor>, ),
); );
const missingSnapshots = realtimeRegistry.missingSnapshots(module.discovered.manifest.id); const missingSnapshots = realtimeRegistry.missingSnapshots(module.discovered.manifest.id);
if (missingSnapshots.length > 0) { if (missingSnapshots.length > 0) {
@@ -873,7 +831,7 @@ async function startModules(
*/ */
export async function startDiscoveredModuleRuntime( export async function startDiscoveredModuleRuntime(
discoveredModules: readonly DiscoveredModule[], discoveredModules: readonly DiscoveredModule[],
services: ModuleRuntimeServicesSource, services: ModuleRuntimeServices,
timeouts: ModuleRuntimeCompositionOptions = {}, timeouts: ModuleRuntimeCompositionOptions = {},
): Promise<StartedModuleRuntime> { ): Promise<StartedModuleRuntime> {
const modules = await loadRuntimeDefinitions(discoveredModules); const modules = await loadRuntimeDefinitions(discoveredModules);
+12 -132
View File
@@ -1,16 +1,12 @@
import { pathToFileURL } from "node:url"; import { pathToFileURL } from "node:url";
import { import {
type ModuleId, type ModuleId,
type ModuleSchemaMetadataDefinition, type ModuleSchemaDefinition,
moduleTableLocalNameSchema, moduleTableLocalNameSchema,
moduleTablePrefix, moduleTablePrefix,
parseModuleSchemaMetadataDefinition, parseModuleSchemaDefinition,
physicalModuleTableNameSchema, physicalModuleTableNameSchema,
} from "@ekklesia/contracts"; } from "@ekklesia/contracts";
import {
type ModuleSchemaDefinition,
parseModuleSchemaDefinition,
} from "./legacy-schema-definition.js";
import { is } from "drizzle-orm"; import { is } from "drizzle-orm";
import { type AnyPgTable, getTableConfig, PgColumn, PgTable } from "drizzle-orm/pg-core"; import { type AnyPgTable, getTableConfig, PgColumn, PgTable } from "drizzle-orm/pg-core";
import { import {
@@ -44,9 +40,7 @@ export interface DiscoveredModuleTable {
readonly exportName: string; readonly exportName: string;
readonly moduleId: ModuleId; readonly moduleId: ModuleId;
readonly name: string; readonly name: string;
/** Present only for a temporary legacy-facade module. ABI-v1 schema discovery publishes no ORM readonly table: AnyPgTable;
* object and contributes nothing to the legacy aggregate below. */
readonly table?: AnyPgTable;
} }
export interface DiscoveredModuleSchema { export interface DiscoveredModuleSchema {
@@ -61,40 +55,21 @@ export interface ModuleSchemaAggregate {
readonly tables: readonly DiscoveredModuleTable[]; readonly tables: readonly DiscoveredModuleTable[];
} }
interface LoadedLegacyModuleSchema { interface LoadedModuleSchema {
readonly definition: ModuleSchemaDefinition; readonly definition: ModuleSchemaDefinition;
readonly discovered: DiscoveredModule; readonly discovered: DiscoveredModule;
readonly mode: "legacy";
} }
interface LoadedAbiModuleSchema {
readonly definition: ModuleSchemaMetadataDefinition;
readonly discovered: DiscoveredModule;
readonly mode: "abi-v1";
}
type LoadedModuleSchema = LoadedLegacyModuleSchema | LoadedAbiModuleSchema;
interface InspectedModuleSchema { interface InspectedModuleSchema {
readonly discovered: DiscoveredModule; readonly discovered: DiscoveredModule;
readonly tables: ModuleTableCandidate[]; readonly tables: ModuleTableCandidate[];
} }
interface LegacyModuleTableCandidate extends DiscoveredModuleTable { interface ModuleTableCandidate extends DiscoveredModuleTable {
readonly mode: "legacy";
readonly foreignKeys: ReturnType<typeof getTableConfig>["foreignKeys"]; readonly foreignKeys: ReturnType<typeof getTableConfig>["foreignKeys"];
readonly path: string; readonly path: string;
readonly table: AnyPgTable;
} }
interface AbiModuleTableCandidate extends DiscoveredModuleTable {
readonly mode: "abi-v1";
readonly foreignKeys: ModuleSchemaMetadataDefinition["tables"][number]["foreignKeys"];
readonly path: string;
}
type ModuleTableCandidate = LegacyModuleTableCandidate | AbiModuleTableCandidate;
const drizzleTableIsAlias = Symbol.for("drizzle:IsAlias"); const drizzleTableIsAlias = Symbol.for("drizzle:IsAlias");
export class ModuleSchemaDiscoveryError extends Error { export class ModuleSchemaDiscoveryError extends Error {
@@ -181,13 +156,9 @@ async function loadSchemaDefinitions(discoveredModules: readonly DiscoveredModul
continue; continue;
} }
let definition: ModuleSchemaDefinition | ModuleSchemaMetadataDefinition; let definition: ModuleSchemaDefinition;
const mode = discovered.manifest.database?.abi === 1 ? "abi-v1" : "legacy";
try { try {
definition = definition = parseModuleSchemaDefinition(defaultExport(moduleNamespace));
mode === "abi-v1"
? parseModuleSchemaMetadataDefinition(defaultExport(moduleNamespace))
: parseModuleSchemaDefinition(defaultExport(moduleNamespace));
} catch { } catch {
issues.push( issues.push(
issue( issue(
@@ -211,11 +182,7 @@ async function loadSchemaDefinitions(discoveredModules: readonly DiscoveredModul
continue; continue;
} }
loaded.push( loaded.push({ definition, discovered });
mode === "abi-v1"
? { definition: definition as ModuleSchemaMetadataDefinition, discovered, mode }
: { definition: definition as ModuleSchemaDefinition, discovered, mode },
);
} }
return { issues, loaded }; return { issues, loaded };
@@ -230,58 +197,6 @@ function inspectTables(
for (const loaded of loadedSchemas) { for (const loaded of loadedSchemas) {
const moduleId = loaded.discovered.manifest.id; const moduleId = loaded.discovered.manifest.id;
if (loaded.mode === "abi-v1") {
const candidates: AbiModuleTableCandidate[] = [];
loaded.definition.tables.forEach((table, index) => {
const path = `tables.${index}`;
const candidate: AbiModuleTableCandidate = {
exportName: table.name,
foreignKeys: table.foreignKeys,
mode: "abi-v1",
moduleId,
name: table.name,
path,
};
const tablePrefix = moduleTablePrefix(moduleId);
if (!table.name.startsWith(tablePrefix)) {
issues.push(
issue(
"MODULE_SCHEMA_TABLE_NAMESPACE_INVALID",
moduleId,
`Table ${table.name} is not namespaced for module ${moduleId}.`,
{ path, tableName: table.name },
),
);
} else if (
!moduleTableLocalNameSchema.safeParse(table.name.slice(tablePrefix.length)).success
) {
issues.push(
issue(
"MODULE_SCHEMA_TABLE_NAME_INVALID",
moduleId,
`Module ${moduleId} has an invalid local table name.`,
{ path },
),
);
}
const existing = firstTableByName.get(table.name);
if (existing) {
issues.push(
issue(
"MODULE_SCHEMA_TABLE_DUPLICATE",
moduleId,
`Table ${table.name} is declared more than once.`,
{ path, tableName: table.name },
),
);
} else {
firstTableByName.set(table.name, candidate);
}
candidates.push(candidate);
});
inspectedModules.push({ discovered: loaded.discovered, tables: candidates });
continue;
}
const seenTables = new Set<AnyPgTable>(); const seenTables = new Set<AnyPgTable>();
const candidates: ModuleTableCandidate[] = []; const candidates: ModuleTableCandidate[] = [];
const entries = Object.entries(loaded.definition.tables).sort(([left], [right]) => const entries = Object.entries(loaded.definition.tables).sort(([left], [right]) =>
@@ -378,7 +293,6 @@ function inspectTables(
const candidate: ModuleTableCandidate = { const candidate: ModuleTableCandidate = {
exportName, exportName,
foreignKeys: config.foreignKeys, foreignKeys: config.foreignKeys,
mode: "legacy",
moduleId, moduleId,
name: config.name, name: config.name,
path, path,
@@ -407,8 +321,8 @@ function inspectTables(
} }
function validForeignKeyTarget( function validForeignKeyTarget(
foreignKey: LegacyModuleTableCandidate["foreignKeys"][number], foreignKey: ModuleTableCandidate["foreignKeys"][number],
candidate: LegacyModuleTableCandidate, candidate: ModuleTableCandidate,
): AnyPgTable | undefined { ): AnyPgTable | undefined {
try { try {
const reference = foreignKey.reference(); const reference = foreignKey.reference();
@@ -442,42 +356,14 @@ function inspectForeignKeys(
const ownerByTable = new Map<AnyPgTable, ModuleId>(); const ownerByTable = new Map<AnyPgTable, ModuleId>();
for (const inspected of inspectedModules) { for (const inspected of inspectedModules) {
for (const candidate of inspected.tables) { for (const candidate of inspected.tables) {
if (candidate.mode === "legacy") {
ownerByTable.set(candidate.table, candidate.moduleId); ownerByTable.set(candidate.table, candidate.moduleId);
} }
} }
}
for (const inspected of inspectedModules) { for (const inspected of inspectedModules) {
const moduleId = inspected.discovered.manifest.id; const moduleId = inspected.discovered.manifest.id;
const ownedTables = new Set(inspected.tables.map((candidate) => candidate.table)); const ownedTables = new Set(inspected.tables.map((candidate) => candidate.table));
for (const candidate of inspected.tables) { for (const candidate of inspected.tables) {
if (candidate.mode === "abi-v1") {
const ownedNames = new Set(inspected.tables.map((table) => table.name));
candidate.foreignKeys.forEach((foreignKey, foreignKeyIndex) => {
const path = `${candidate.path}.foreignKeys.${foreignKeyIndex}`;
if (!foreignKey.table.startsWith(moduleTablePrefix(moduleId))) {
issues.push(
issue(
"MODULE_SCHEMA_CROSS_MODULE_FOREIGN_KEY",
moduleId,
`Table ${candidate.name} has a foreign key outside module ${moduleId}.`,
{ path, tableName: candidate.name },
),
);
} else if (!ownedNames.has(foreignKey.table)) {
issues.push(
issue(
"MODULE_SCHEMA_FOREIGN_KEY_TARGET_MISSING",
moduleId,
`Table ${candidate.name} references a table not exported by module ${moduleId}.`,
{ path, tableName: candidate.name },
),
);
}
});
continue;
}
candidate.foreignKeys.forEach((foreignKey, foreignKeyIndex) => { candidate.foreignKeys.forEach((foreignKey, foreignKeyIndex) => {
const path = `${candidate.path}.foreignKeys.${foreignKeyIndex}`; const path = `${candidate.path}.foreignKeys.${foreignKeyIndex}`;
const foreignTable = validForeignKeyTarget(foreignKey, candidate); const foreignTable = validForeignKeyTarget(foreignKey, candidate);
@@ -560,9 +446,7 @@ function publishAggregate(
moduleId: inspected.discovered.manifest.id, moduleId: inspected.discovered.manifest.id,
tables: Object.freeze( tables: Object.freeze(
inspected.tables inspected.tables
.map(({ foreignKeys: _foreignKeys, mode: _mode, path: _path, ...table }) => .map(({ foreignKeys: _foreignKeys, path: _path, ...table }) => Object.freeze(table))
Object.freeze(table),
)
.sort((left, right) => compareText(left.exportName, right.exportName)), .sort((left, right) => compareText(left.exportName, right.exportName)),
), ),
}), }),
@@ -574,11 +458,7 @@ function publishAggregate(
.sort((left, right) => compareText(left.name, right.name)), .sort((left, right) => compareText(left.name, right.name)),
); );
const drizzleSchema = Object.freeze( const drizzleSchema = Object.freeze(
Object.fromEntries( Object.fromEntries(tables.map((table) => [table.name, table.table] as const)),
tables.flatMap((table) =>
table.table === undefined ? [] : [[table.name, table.table] as const],
),
),
); );
return Object.freeze({ discoveredModules, drizzleSchema, modules, tables }); return Object.freeze({ discoveredModules, drizzleSchema, modules, tables });
-178
View File
@@ -1,178 +0,0 @@
import { readFile } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { getTableConfig } from "drizzle-orm/pg-core";
import { describe, expect, it } from "vitest";
import {
ACCOUNT_APPEARANCES,
ACCOUNT_MOTION_PREFERENCES,
ACCOUNT_PREFERENCE_NAMES,
AccountStoreError,
createAccountStore,
DEFAULT_ACCOUNT_PREFERENCES,
ekklesiaCoreUserPreference,
parseAccountPreferencesUpdate,
resolveAccountPreferences,
} from "../src/account/index.js";
const migrationsDirectory = path.join(
path.dirname(fileURLToPath(import.meta.url)),
"..",
"drizzle",
);
describe("account preferences model", () => {
it("defaults both preferences to following the system", () => {
expect(DEFAULT_ACCOUNT_PREFERENCES).toEqual({ appearance: "system", motion: "system" });
expect(Object.isFrozen(DEFAULT_ACCOUNT_PREFERENCES)).toBe(true);
});
it("declares a closed, ordered set of names and values", () => {
expect(ACCOUNT_PREFERENCE_NAMES).toEqual(["appearance", "motion"]);
expect(ACCOUNT_APPEARANCES).toEqual(["system", "light", "dark"]);
expect(ACCOUNT_MOTION_PREFERENCES).toEqual(["system", "reduce", "allow"]);
});
it.each(
ACCOUNT_APPEARANCES.map((appearance) => [appearance] as const),
)("accepts appearance %s on its own", (appearance) => {
expect(parseAccountPreferencesUpdate({ appearance })).toEqual({ appearance });
});
it.each(
ACCOUNT_MOTION_PREFERENCES.map((motion) => [motion] as const),
)("accepts motion %s on its own", (motion) => {
expect(parseAccountPreferencesUpdate({ motion })).toEqual({ motion });
});
it("accepts both preferences together", () => {
expect(parseAccountPreferencesUpdate({ appearance: "dark", motion: "reduce" })).toEqual({
appearance: "dark",
motion: "reduce",
});
});
it.each([
["an empty object", {}],
["null", null],
["an array", [["appearance", "dark"]]],
["a string", "dark"],
["an unknown appearance", { appearance: "sepia" }],
["an unknown motion", { motion: "none" }],
["a differently cased value", { appearance: "Dark" }],
["a non-string value", { appearance: 1 }],
["a null value", { motion: null }],
["an unknown key beside a valid one", { appearance: "dark", radius: "large" }],
["an unknown key alone", { theme: "dark" }],
["a user id", { appearance: "dark", userId: "someone-else" }],
["a prototype key", JSON.parse('{"__proto__": {"appearance": "dark"}}')],
[
"a class instance",
new (class Preferences {
appearance = "dark";
})(),
],
])("refuses %s whole", (_label, value) => {
expect(parseAccountPreferencesUpdate(value)).toBeUndefined();
});
it("resolves never-chosen columns to the defaults, and keeps a chosen value", () => {
expect(resolveAccountPreferences({ appearance: null, motion: null })).toEqual(
DEFAULT_ACCOUNT_PREFERENCES,
);
expect(resolveAccountPreferences({ appearance: "dark", motion: null })).toEqual({
appearance: "dark",
motion: "system",
});
// A value the constraint should have made impossible is never passed through.
expect(resolveAccountPreferences({ appearance: "neon", motion: "allow" })).toEqual({
appearance: "system",
motion: "allow",
});
});
});
describe("account preference storage", () => {
it("is one row per person, keyed and cascaded by user id, with one typed column per preference", () => {
const config = getTableConfig(ekklesiaCoreUserPreference);
expect(config.name).toBe("ekklesia_core_user_preference");
expect(config.columns.map((column) => column.name).sort()).toEqual([
"appearance",
"created_at",
"motion",
"updated_at",
"user_id",
]);
const userId = config.columns.find((column) => column.name === "user_id");
expect(userId?.primary).toBe(true);
// Preference columns are nullable: `null` is "never chosen", resolved to the default on read.
for (const name of ACCOUNT_PREFERENCE_NAMES) {
expect(config.columns.find((column) => column.name === name)?.notNull).toBe(false);
}
expect(config.foreignKeys).toHaveLength(1);
expect(config.foreignKeys[0]?.onDelete).toBe("cascade");
});
it("constrains each column in the migration to exactly the model's values", async () => {
const sql = await readFile(path.join(migrationsDirectory, "0008_user_preferences.sql"), "utf8");
const allowed = (column: string): string[] => {
const match = new RegExp(`"${column}" in \\(([^)]*)\\)`, "u").exec(sql);
return (match?.[1] ?? "").split(",").map((value) => value.trim().replaceAll("'", ""));
};
expect(allowed("appearance")).toEqual([...ACCOUNT_APPEARANCES]);
expect(allowed("motion")).toEqual([...ACCOUNT_MOTION_PREFERENCES]);
// No JSON, no key/value pair, nothing a client could fill with anything.
expect(sql).not.toMatch(/jsonb?|hstore/iu);
});
it("is a migration that destroys nothing", async () => {
const metadata = JSON.parse(
await readFile(path.join(migrationsDirectory, "meta", "_ekklesia.json"), "utf8"),
) as { entries: { tag: string; destructive: { mode: string } }[] };
expect(metadata.entries.find((entry) => entry.tag === "0008_user_preferences")).toEqual({
destructive: { mode: "none" },
tag: "0008_user_preferences",
});
});
});
describe("account store validation", () => {
it("refuses an invalid update before touching the database", async () => {
const untouchable = new Proxy(
{},
{
get() {
throw new Error("the database must not be reached");
},
},
);
const store = createAccountStore(untouchable as never);
await expect(
store.updatePreferences("user-1" as never, { appearance: "sepia" } as never),
).rejects.toMatchObject({ code: "ACCOUNT_PREFERENCES_INVALID", name: "AccountStoreError" });
await expect(store.updatePreferences("user-1" as never, {})).rejects.toBeInstanceOf(
AccountStoreError,
);
});
it("reports a database failure without the underlying error", async () => {
const failing = {
select() {
throw new Error("connect ECONNREFUSED postgres://secret@db:5432");
},
transaction() {
throw new Error("connect ECONNREFUSED postgres://secret@db:5432");
},
};
const store = createAccountStore(failing as never);
const read = await store.readAccount("user-1" as never).catch((error: unknown) => error);
expect(read).toBeInstanceOf(AccountStoreError);
expect(read).toMatchObject({ code: "ACCOUNT_READ_FAILED" });
expect(String((read as Error).stack)).not.toContain("secret");
const write = await store
.updatePreferences("user-1" as never, { appearance: "dark" })
.catch((error: unknown) => error);
expect(write).toMatchObject({ code: "ACCOUNT_WRITE_FAILED" });
expect(String((write as Error).message)).not.toContain("ECONNREFUSED");
});
});
-378
View File
@@ -1,378 +0,0 @@
import { randomBytes } from "node:crypto";
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
import type { EkklesiaUserId } from "@ekklesia/contracts";
import { Client, type QueryResultRow } from "pg";
import { afterAll, describe, expect, it } from "vitest";
import {
type AccountStore,
AccountStoreError,
createAccountDrizzleView,
createAccountStore,
} from "../src/account/index.js";
import { createValidatedPostgresPool } from "../src/database/index.js";
import {
applyModuleMigrations,
createPostgresMigrationDatabase,
discoverAllMigrations,
} from "../src/migrations/index.js";
/**
* A person's own account summary and preferences against real PostgreSQL (ADR 0062).
*
* The unit suite proves the model and parser; this proves what only a database can: that the
* migration creates the table with its constraints, that a choice survives a completely new
* connection (which is what "follows you to another device" rests on), that one person's change
* never touches another's row, that the database itself refuses a value outside the model, and
* that deleting a person deletes their preferences.
*/
const integrationDatabaseUrlEnvironment = "EKKLESIA_MIGRATION_TEST_DATABASE_URL";
const coreMigrationsDirectory = path.join(
path.dirname(fileURLToPath(import.meta.url)),
"..",
"drizzle",
);
const trackedDatabases = new Set<string>();
let databaseCounter = 0;
class IntegrationHarnessError extends Error {
constructor(message: string) {
super(message);
this.name = "IntegrationHarnessError";
this.stack = `${this.name}: ${this.message}`;
}
}
function harnessError(message: string): IntegrationHarnessError {
return new IntegrationHarnessError(message);
}
function baseDatabaseUrl(): string {
const value = process.env[integrationDatabaseUrlEnvironment];
if (typeof value !== "string" || value.trim() === "") {
throw harnessError("The account integration database is not configured.");
}
try {
const parsed = new URL(value);
if (parsed.protocol !== "postgres:" && parsed.protocol !== "postgresql:") {
throw new TypeError("Unsupported protocol.");
}
} catch {
throw harnessError("The account integration database configuration is invalid.");
}
return value;
}
function quoteDatabaseName(databaseName: string): string {
if (!/^[a-z][a-z0-9_]{0,62}$/u.test(databaseName)) {
throw harnessError("The generated integration database name is invalid.");
}
return `"${databaseName}"`;
}
function makeDatabaseName(label: string): string {
const safeLabel = label
.toLowerCase()
.replaceAll(/[^a-z0-9]+/gu, "_")
.replaceAll(/^_+|_+$/gu, "")
.slice(0, 14);
databaseCounter += 1;
return `ekk_acct_${process.pid}_${databaseCounter}_${safeLabel || "scenario"}_${randomBytes(4).toString("hex")}`;
}
function databaseUrl(databaseName: string): string {
const parsed = new URL(baseDatabaseUrl());
parsed.pathname = `/${databaseName}`;
parsed.searchParams.delete("application_name");
return parsed.toString();
}
async function openAdministrativeClient(connectionString: string): Promise<Client> {
const client = new Client({ connectionString });
client.on("error", () => undefined);
try {
await client.connect();
return client;
} catch {
try {
await client.end();
} catch {
// The public harness failure stays static when connection cleanup also fails.
}
throw harnessError("The account integration database connection failed.");
}
}
async function safeQuery<Row extends QueryResultRow>(
client: Client,
text: string,
values: unknown[] = [],
): Promise<readonly Row[]> {
try {
const result = await client.query<Row>(text, values);
return result.rows;
} catch {
throw harnessError("An integration database administration operation failed.");
}
}
async function safeEnd(client: Client): Promise<void> {
try {
await client.end();
} catch {
throw harnessError("An integration database connection could not be closed.");
}
}
async function dropDatabase(databaseName: string): Promise<void> {
const admin = await openAdministrativeClient(baseDatabaseUrl());
try {
await safeQuery(
admin,
"SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = $1 AND pid <> pg_backend_pid()",
[databaseName],
);
await safeQuery(
admin,
`DROP DATABASE IF EXISTS ${quoteDatabaseName(databaseName)} WITH (FORCE)`,
);
trackedDatabases.delete(databaseName);
} finally {
await safeEnd(admin);
}
}
async function createDisposableDatabase(label: string): Promise<{ name: string; url: string }> {
const name = makeDatabaseName(label);
const admin = await openAdministrativeClient(baseDatabaseUrl());
try {
await safeQuery(admin, `CREATE DATABASE ${quoteDatabaseName(name)} TEMPLATE template0`);
} finally {
await safeEnd(admin);
}
trackedDatabases.add(name);
return { name, url: databaseUrl(name) };
}
async function applyCoreMigrations(url: string): Promise<void> {
const emptyModulesDirectory = await mkdtemp(path.join(tmpdir(), "ekklesia-acct-no-modules-"));
try {
const aggregate = await discoverAllMigrations({
coreMigrationsDirectory,
moduleDiscovery: {
moduleSdkVersion: "0.1.0",
modulesDirectory: emptyModulesDirectory,
platformVersion: "0.1.0",
},
});
expect(aggregate.migrations.map((entry) => entry.identity)).toEqual([
"core:0000_identity_core_tables",
"core:0001_authorization_core_tables",
"core:0002_authorization_integrity",
"core:0003_dashboard_navigation_tables",
"core:0004_module_lifecycle_state",
"core:0005_module_configuration",
"core:0006_device_identity",
"core:0007_verified_bootstrap_owners",
"core:0008_user_preferences",
]);
await applyModuleMigrations({ aggregate, database: createPostgresMigrationDatabase(url) });
} finally {
await rm(emptyModulesDirectory, { force: true, recursive: true });
}
}
afterAll(async () => {
for (const databaseName of [...trackedDatabases]) {
await dropDatabase(databaseName).catch(() => undefined);
}
});
const ada = "user-ada" as EkklesiaUserId;
const grace = "user-grace" as EkklesiaUserId;
const nobody = "user-nobody" as EkklesiaUserId;
async function insertPerson(
client: Client,
id: string,
name: string,
email: string,
): Promise<void> {
await safeQuery(
client,
"insert into ekklesia_core_user (id, name, email, email_verified) values ($1, $2, $3, true)",
[id, name, email],
);
}
describe.sequential("live Core account store", () => {
let database: { name: string; url: string };
let admin: Client;
let store: AccountStore;
let close: () => Promise<void>;
it("creates the preference table with its constraints", async () => {
database = await createDisposableDatabase("fresh");
await applyCoreMigrations(database.url);
admin = await openAdministrativeClient(database.url);
const constraints = await safeQuery<{ constraint_name: string; constraint_type: string }>(
admin,
`select constraint_name, constraint_type from information_schema.table_constraints
where table_name = 'ekklesia_core_user_preference' and constraint_type <> 'CHECK'
or constraint_name like 'ekklesia_core_user_preference_%_check'
order by constraint_name`,
);
const deleteRules = await safeQuery<{ delete_rule: string }>(
admin,
`select rc.delete_rule from information_schema.referential_constraints rc
join information_schema.table_constraints tc on tc.constraint_name = rc.constraint_name
where tc.table_name = 'ekklesia_core_user_preference'`,
);
expect(constraints.map((constraint) => constraint.constraint_name)).toEqual([
"ekklesia_core_user_preference_appearance_check",
"ekklesia_core_user_preference_motion_check",
"ekklesia_core_user_preference_pkey",
"ekklesia_core_user_preference_user_id_ekklesia_core_user_id_fk",
]);
expect(deleteRules.map((rule) => rule.delete_rule)).toEqual(["CASCADE"]);
await insertPerson(admin, ada, "Ada Lovelace", "[email protected]");
await insertPerson(admin, grace, "Grace Hopper", "[email protected]");
const handle = createValidatedPostgresPool(database.url);
close = handle.close;
store = createAccountStore(createAccountDrizzleView(handle.pool));
});
it("reads a person who never chose anything with their name, email, and the defaults", async () => {
expect(await store.readAccount(ada)).toEqual({
email: "[email protected]",
name: "Ada Lovelace",
preferences: { appearance: "system", motion: "system" },
});
const rows = await safeQuery(admin, "select * from ekklesia_core_user_preference");
// Reading never materializes a row nobody chose.
expect(rows).toHaveLength(0);
});
it("answers undefined for a person who does not exist", async () => {
expect(await store.readAccount(nobody)).toBeUndefined();
});
it.each([
["appearance", "light"],
["appearance", "dark"],
["appearance", "system"],
["motion", "reduce"],
["motion", "allow"],
["motion", "system"],
] as const)("saves %s %s and reads it back", async (name, value) => {
const saved = await store.updatePreferences(ada, { [name]: value });
expect(saved[name]).toBe(value);
expect((await store.readAccount(ada))?.preferences[name]).toBe(value);
});
it("changes only the preference named, keeping the other", async () => {
await store.updatePreferences(ada, { appearance: "dark", motion: "reduce" });
expect(await store.updatePreferences(ada, { motion: "allow" })).toEqual({
appearance: "dark",
motion: "allow",
});
expect(await store.updatePreferences(ada, { appearance: "light" })).toEqual({
appearance: "light",
motion: "allow",
});
});
it("stores a first partial choice with the other preference still never chosen", async () => {
expect(await store.updatePreferences(grace, { motion: "reduce" })).toEqual({
appearance: "system",
motion: "reduce",
});
const rows = await safeQuery<{ appearance: string | null; motion: string | null }>(
admin,
"select appearance, motion from ekklesia_core_user_preference where user_id = $1",
[grace],
);
expect(rows).toEqual([{ appearance: null, motion: "reduce" }]);
});
it("keeps each person's preferences to themselves", async () => {
await store.updatePreferences(ada, { appearance: "dark", motion: "allow" });
await store.updatePreferences(grace, { appearance: "light", motion: "reduce" });
expect((await store.readAccount(ada))?.preferences).toEqual({
appearance: "dark",
motion: "allow",
});
expect(await store.readAccount(grace)).toEqual({
email: "[email protected]",
name: "Grace Hopper",
preferences: { appearance: "light", motion: "reduce" },
});
});
it("survives a completely new connection, as another device signing in would see", async () => {
const reopened = createValidatedPostgresPool(database.url);
try {
const other = createAccountStore(createAccountDrizzleView(reopened.pool));
expect((await other.readAccount(ada))?.preferences).toEqual({
appearance: "dark",
motion: "allow",
});
} finally {
await reopened.close();
}
});
it("refuses to store preferences for a person who does not exist", async () => {
const failure = await store
.updatePreferences(nobody, { appearance: "dark" })
.catch((error: unknown) => error);
expect(failure).toBeInstanceOf(AccountStoreError);
expect(failure).toMatchObject({ code: "ACCOUNT_NOT_FOUND" });
const rows = await safeQuery(
admin,
"select 1 from ekklesia_core_user_preference where user_id = $1",
[nobody],
);
expect(rows).toHaveLength(0);
});
it("refuses an invalid update with nothing written", async () => {
await expect(
store.updatePreferences(ada, { appearance: "sepia" } as never),
).rejects.toMatchObject({ code: "ACCOUNT_PREFERENCES_INVALID" });
expect((await store.readAccount(ada))?.preferences.appearance).toBe("dark");
});
it("has the database itself refuse a value outside the model", async () => {
await expect(
admin.query(
"update ekklesia_core_user_preference set appearance = 'sepia' where user_id = $1",
[ada],
),
).rejects.toMatchObject({ code: "23514" });
await expect(
admin.query("update ekklesia_core_user_preference set motion = 'none' where user_id = $1", [
ada,
]),
).rejects.toMatchObject({ code: "23514" });
});
it("deletes a person's preferences with the person", async () => {
await safeQuery(admin, "delete from ekklesia_core_user where id = $1", [grace]);
const rows = await safeQuery(
admin,
"select 1 from ekklesia_core_user_preference where user_id = $1",
[grace],
);
expect(rows).toHaveLength(0);
expect(await store.readAccount(grace)).toBeUndefined();
await safeEnd(admin);
await close();
});
});
+1 -14
View File
@@ -3,7 +3,6 @@ import { describe, expect, it } from "vitest";
import { import {
CORE_AUTHORIZATION_GRANTS_MANAGE, CORE_AUTHORIZATION_GRANTS_MANAGE,
CORE_DASHBOARD_NAVIGATION_MANAGE, CORE_DASHBOARD_NAVIGATION_MANAGE,
CORE_ACCOUNT_SELF_MANAGE,
CORE_DEVICES_MANAGE, CORE_DEVICES_MANAGE,
CORE_MODULES_CONFIGURATION_MANAGE, CORE_MODULES_CONFIGURATION_MANAGE,
CORE_MODULES_LIFECYCLE_MANAGE, CORE_MODULES_LIFECYCLE_MANAGE,
@@ -134,13 +133,7 @@ describe("CORE_PERMISSION_REGISTRY", () => {
expect(CORE_MODULES_CONFIGURATION_MANAGE).not.toBe(CORE_MODULES_LIFECYCLE_MANAGE); expect(CORE_MODULES_CONFIGURATION_MANAGE).not.toBe(CORE_MODULES_LIFECYCLE_MANAGE);
}); });
it("registers core.account.self.manage as authenticated: managing your own account needs no grant", () => { it("contains exactly these six permissions and no others", () => {
const declaration = CORE_PERMISSION_REGISTRY.get(CORE_ACCOUNT_SELF_MANAGE);
expect(declaration?.baseline).toBe("authenticated");
expect(declaration?.id).toBe("core.account.self.manage");
});
it("contains exactly these seven permissions and no others", () => {
expect(CORE_PERMISSION_REGISTRY.declarations.map((declaration) => declaration.id)).toEqual([ expect(CORE_PERMISSION_REGISTRY.declarations.map((declaration) => declaration.id)).toEqual([
CORE_AUTHORIZATION_GRANTS_MANAGE, CORE_AUTHORIZATION_GRANTS_MANAGE,
CORE_MODULES_UI_READ, CORE_MODULES_UI_READ,
@@ -148,7 +141,6 @@ describe("CORE_PERMISSION_REGISTRY", () => {
CORE_MODULES_LIFECYCLE_MANAGE, CORE_MODULES_LIFECYCLE_MANAGE,
CORE_MODULES_CONFIGURATION_MANAGE, CORE_MODULES_CONFIGURATION_MANAGE,
CORE_DEVICES_MANAGE, CORE_DEVICES_MANAGE,
CORE_ACCOUNT_SELF_MANAGE,
]); ]);
}); });
}); });
@@ -186,11 +178,6 @@ describe("CORE_PERMISSION_DECLARATIONS", () => {
description: "Approve display device pairing, list paired devices, and revoke a device.", description: "Approve display device pairing, list paired devices, and revoke a device.",
id: CORE_DEVICES_MANAGE, id: CORE_DEVICES_MANAGE,
}, },
{
baseline: "authenticated",
description: "Read your own account and read and change your own preferences.",
id: CORE_ACCOUNT_SELF_MANAGE,
},
]); ]);
expect(Object.isFrozen(CORE_PERMISSION_DECLARATIONS)).toBe(true); expect(Object.isFrozen(CORE_PERMISSION_DECLARATIONS)).toBe(true);
for (const declaration of CORE_PERMISSION_DECLARATIONS) { for (const declaration of CORE_PERMISSION_DECLARATIONS) {
@@ -178,7 +178,6 @@ async function applyCoreMigrations(url: string): Promise<void> {
"core:0005_module_configuration", "core:0005_module_configuration",
"core:0006_device_identity", "core:0006_device_identity",
"core:0007_verified_bootstrap_owners", "core:0007_verified_bootstrap_owners",
"core:0008_user_preferences",
]); ]);
await applyModuleMigrations({ aggregate, database: createPostgresMigrationDatabase(url) }); await applyModuleMigrations({ aggregate, database: createPostgresMigrationDatabase(url) });
} finally { } finally {
@@ -244,8 +243,6 @@ describe.sequential("live Core authorization store", () => {
"ekklesia_core_platform_owner_bootstrap", "ekklesia_core_platform_owner_bootstrap",
"ekklesia_core_session", "ekklesia_core_session",
"ekklesia_core_user", "ekklesia_core_user",
// A person's own preferences, one typed column each (ADR 0062).
"ekklesia_core_user_preference",
"ekklesia_core_verification", "ekklesia_core_verification",
]); ]);
@@ -1,5 +1,4 @@
import { describe, expect, it, vi } from "vitest"; import { describe, expect, it, vi } from "vitest";
import { IMPORT_SAFETY_TEST_TIMEOUT_MS } from "./support/import-safety.js";
const poolConstructor = vi.hoisted(() => vi.fn()); const poolConstructor = vi.hoisted(() => vi.fn());
@@ -8,9 +7,7 @@ vi.mock("pg", () => ({ Pool: poolConstructor }));
describe("bootstrap-platform-owner CLI import safety", () => { describe("bootstrap-platform-owner CLI import safety", () => {
const originalEnv = { ...process.env }; const originalEnv = { ...process.env };
it( it("does not read DATABASE_URL, connect to PostgreSQL, or run bootstrap merely by importing the module", async () => {
"does not read DATABASE_URL, connect to PostgreSQL, or run bootstrap merely by importing the module",
async () => {
delete process.env.DATABASE_URL; delete process.env.DATABASE_URL;
// Under Vitest, process.argv[1] is never this file's own path, so isDirectExecution() is // Under Vitest, process.argv[1] is never this file's own path, so isDirectExecution() is
@@ -21,7 +18,5 @@ describe("bootstrap-platform-owner CLI import safety", () => {
expect(poolConstructor).not.toHaveBeenCalled(); expect(poolConstructor).not.toHaveBeenCalled();
process.env = originalEnv; process.env = originalEnv;
}, });
IMPORT_SAFETY_TEST_TIMEOUT_MS,
);
}); });
+2 -7
View File
@@ -1,5 +1,4 @@
import { describe, expect, it, vi } from "vitest"; import { describe, expect, it, vi } from "vitest";
import { IMPORT_SAFETY_TEST_TIMEOUT_MS } from "./support/import-safety.js";
const poolConstructor = vi.hoisted(() => vi.fn()); const poolConstructor = vi.hoisted(() => vi.fn());
@@ -8,9 +7,7 @@ vi.mock("pg", () => ({ Pool: poolConstructor }));
describe("create-initial-owner CLI import safety", () => { describe("create-initial-owner CLI import safety", () => {
const originalEnv = { ...process.env }; const originalEnv = { ...process.env };
it( it("does not read DATABASE_URL, connect to PostgreSQL, or create anything merely by importing the module", async () => {
"does not read DATABASE_URL, connect to PostgreSQL, or create anything merely by importing the module",
async () => {
delete process.env.DATABASE_URL; delete process.env.DATABASE_URL;
// Under Vitest, process.argv[1] is never this file's own path, so isDirectExecution() is false // Under Vitest, process.argv[1] is never this file's own path, so isDirectExecution() is false
@@ -21,9 +18,7 @@ describe("create-initial-owner CLI import safety", () => {
expect(poolConstructor).not.toHaveBeenCalled(); expect(poolConstructor).not.toHaveBeenCalled();
process.env = originalEnv; process.env = originalEnv;
}, });
IMPORT_SAFETY_TEST_TIMEOUT_MS,
);
it("reads no environment variable for the password", async () => { it("reads no environment variable for the password", async () => {
// The password is prompted for or read from standard input; it is deliberately never taken from // The password is prompted for or read from standard input; it is deliberately never taken from
+1 -1
View File
@@ -91,7 +91,7 @@ async function createModule(
writeFile( writeFile(
path.join(moduleDirectory, "ekklesia.module.json"), path.join(moduleDirectory, "ekklesia.module.json"),
JSON.stringify({ JSON.stringify({
manifestVersion: 8, manifestVersion: 7,
id, id,
name: `${id} module`, name: `${id} module`,
version: "0.1.0", version: "0.1.0",
+1 -1
View File
@@ -59,7 +59,7 @@ async function createModule(modulesDirectory: string, fixture: ModuleFixture): P
: {}), : {}),
entrypoints: { server: "./dist/server/index.js", ui: "./dist/ui/index.js" }, entrypoints: { server: "./dist/server/index.js", ui: "./dist/ui/index.js" },
id: fixture.id, id: fixture.id,
manifestVersion: 8, manifestVersion: 7,
name: fixture.name, name: fixture.name,
version: "1.0.0", version: "1.0.0",
}), }),
@@ -177,7 +177,6 @@ async function applyCoreMigrations(url: string): Promise<void> {
"core:0005_module_configuration", "core:0005_module_configuration",
"core:0006_device_identity", "core:0006_device_identity",
"core:0007_verified_bootstrap_owners", "core:0007_verified_bootstrap_owners",
"core:0008_user_preferences",
]); ]);
await applyModuleMigrations({ aggregate, database: createPostgresMigrationDatabase(url) }); await applyModuleMigrations({ aggregate, database: createPostgresMigrationDatabase(url) });
} finally { } finally {
+6 -37
View File
@@ -8,11 +8,10 @@ import {
diagnoseEkklesia, diagnoseEkklesia,
formatDoctorReport, formatDoctorReport,
} from "../src/diagnostics/index.js"; } from "../src/diagnostics/index.js";
import { import type {
type DiscoveredModule, DiscoveredModule,
discoverModules, ModuleMigrationAggregate,
type ModuleMigrationAggregate, ModuleSchemaAggregate,
type ModuleSchemaAggregate,
} from "../src/modules/index.js"; } from "../src/modules/index.js";
/** /**
@@ -35,8 +34,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",
}); });
@@ -83,7 +82,7 @@ function discoveredModule(id: string, overrides: Record<string, unknown> = {}):
}, },
entrypoints: { server: "./dist/server.js" }, entrypoints: { server: "./dist/server.js" },
id, id,
manifestVersion: 8, manifestVersion: 7,
name: `${id} module`, name: `${id} module`,
permissions: [], permissions: [],
storage: { buckets: [] }, storage: { buckets: [] },
@@ -455,33 +454,3 @@ 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");
});
});
+2 -7
View File
@@ -1,5 +1,4 @@
import { describe, expect, it, vi } from "vitest"; import { describe, expect, it, vi } from "vitest";
import { IMPORT_SAFETY_TEST_TIMEOUT_MS } from "./support/import-safety.js";
const discoverModules = vi.fn(); const discoverModules = vi.fn();
const discoverModuleSchemas = vi.fn(); const discoverModuleSchemas = vi.fn();
@@ -23,9 +22,7 @@ vi.mock("../src/migrations/index.js", async (importOriginal) => ({
})); }));
describe("Doctor import safety", () => { describe("Doctor import safety", () => {
it( it("does not discover, connect, plan, or print when imported", async () => {
"does not discover, connect, plan, or print when imported",
async () => {
const log = vi.spyOn(console, "log").mockImplementation(() => undefined); const log = vi.spyOn(console, "log").mockImplementation(() => undefined);
const error = vi.spyOn(console, "error").mockImplementation(() => undefined); const error = vi.spyOn(console, "error").mockImplementation(() => undefined);
@@ -44,7 +41,5 @@ describe("Doctor import safety", () => {
log.mockRestore(); log.mockRestore();
error.mockRestore(); error.mockRestore();
}, });
IMPORT_SAFETY_TEST_TIMEOUT_MS,
);
}); });
+1 -6
View File
@@ -68,7 +68,6 @@ 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",
@@ -92,7 +91,7 @@ Modules
✓ Dependency graph valid ✓ Dependency graph valid
Dependency order is empty. Dependency order is empty.
✓ Permission registry composed ✓ Permission registry composed
7 permissions registered 6 permissions registered
○ Module lifecycle skipped ○ Module lifecycle skipped
DATABASE_URL is not configured. DATABASE_URL is not configured.
○ Module configuration skipped ○ Module configuration skipped
@@ -118,7 +117,6 @@ 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
@@ -136,7 +134,6 @@ 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",
@@ -161,7 +158,6 @@ 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",
@@ -210,7 +206,6 @@ 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",
+5 -227
View File
@@ -1,9 +1,5 @@
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { type ModuleManifest, parsePermissionId } from "@ekklesia/contracts"; import { type ModuleManifest, parsePermissionId } from "@ekklesia/contracts";
import { afterEach, describe, expect, it, vi } from "vitest"; import { 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,
@@ -32,11 +28,7 @@ const checksumA = "a".repeat(64);
const checksumB = "b".repeat(64); const checksumB = "b".repeat(64);
const checksumC = "c".repeat(64); const checksumC = "c".repeat(64);
function discoveredModule( function discoveredModule(id: string, version = "0.1.0"): DiscoveredModule {
id: string,
version = "0.1.0",
database?: { readonly abi: 1 },
): DiscoveredModule {
return { return {
contributions: [], contributions: [],
directory: `/unpublished/${id}`, directory: `/unpublished/${id}`,
@@ -45,14 +37,13 @@ function discoveredModule(
capabilities: [], capabilities: [],
compatibility: { moduleSdk: "^0.1.0", platform: "^0.1.0" }, compatibility: { moduleSdk: "^0.1.0", platform: "^0.1.0" },
configuration: [], configuration: [],
...(database ? { database } : {}),
dependencies: { dependencies: {
capabilities: { optional: {}, required: {} }, capabilities: { optional: {}, required: {} },
modules: { optional: {}, required: {} }, modules: { optional: {}, required: {} },
}, },
entrypoints: { server: "./dist/server.js" }, entrypoints: { server: "./dist/server.js" },
id, id,
manifestVersion: 8, manifestVersion: 7,
name: `${id} module`, name: `${id} module`,
permissions: [], permissions: [],
storage: { buckets: [] }, storage: { buckets: [] },
@@ -123,10 +114,7 @@ function emptyDatabase(): MigrationDatabase {
}); });
} }
function plan( function plan(entries: MigrationPlanResult["entries"] = []): MigrationPlanResult {
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",
@@ -136,7 +124,6 @@ function plan(
approvalRequiredCount, approvalRequiredCount,
entries: Object.freeze([...entries]), entries: Object.freeze([...entries]),
pendingCount: entries.length - appliedCount, pendingCount: entries.length - appliedCount,
retained: Object.freeze([...retained]),
}); });
} }
@@ -164,7 +151,6 @@ 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",
@@ -299,7 +285,7 @@ describe("Ekklesia Doctor outbound email reporting", () => {
describe("Ekklesia Doctor diagnostics", () => { describe("Ekklesia Doctor diagnostics", () => {
it("publishes a healthy deterministic static report without database configuration", async () => { it("publishes a healthy deterministic static report without database configuration", async () => {
const people = discoveredModule("people"); const people = discoveredModule("people");
const calendar = discoveredModule("calendar", "0.2.0", { abi: 1 }); const calendar = discoveredModule("calendar", "0.2.0");
const inventory = migrationAggregate( const inventory = migrationAggregate(
[migration("people", "0000_initial", checksumA)], [migration("people", "0000_initial", checksumA)],
[people, calendar], [people, calendar],
@@ -332,7 +318,6 @@ describe("Ekklesia Doctor diagnostics", () => {
]); ]);
expect(check(report, "modules.discovery").details).toEqual(["people 0.1.0", "calendar 0.2.0"]); expect(check(report, "modules.discovery").details).toEqual(["people 0.1.0", "calendar 0.2.0"]);
expect(check(report, "modules.dependencies").details).toEqual(["Order: people → calendar"]); expect(check(report, "modules.dependencies").details).toEqual(["Order: people → calendar"]);
expect(check(report, "schemas.discovery")).toMatchObject({ status: "pass" });
expect(check(report, "modules.permissions")).toMatchObject({ expect(check(report, "modules.permissions")).toMatchObject({
details: ["0 permissions registered"], details: ["0 permissions registered"],
status: "pass", status: "pass",
@@ -720,88 +705,6 @@ 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(
@@ -973,21 +876,6 @@ 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(
{ {
@@ -1672,113 +1560,3 @@ 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,
});
});
});
@@ -1,12 +1,12 @@
{ {
"manifestVersion": 8, "manifestVersion": 7,
"id": "db-facade", "id": "db-facade",
"name": "Database Facade Integration Test Module", "name": "Database Facade Integration Test Module",
"description": "Test-only installed module fixture proving ModuleDatabase against real PostgreSQL. Never discovered by production startup, Doctor, or normal migration aggregation — only apps/api/tests/module-database.integration.ts points discovery at it explicitly.", "description": "Test-only installed module fixture proving ModuleDatabase against real PostgreSQL. Never discovered by production startup, Doctor, or normal migration aggregation — only apps/api/tests/module-database.integration.ts points discovery at it explicitly.",
"version": "0.1.0", "version": "0.1.0",
"compatibility": { "compatibility": {
"platform": "^0.2.0", "platform": "^0.1.0",
"moduleSdk": "^0.2.0" "moduleSdk": "^0.1.0"
}, },
"permissions": [ "permissions": [
{ {
@@ -68,7 +68,6 @@ const INTERPRETED_VARIABLES = Object.freeze([
"EKKLESIA_IDENTITY_BASE_URL", "EKKLESIA_IDENTITY_BASE_URL",
"EKKLESIA_IDENTITY_SECRET", "EKKLESIA_IDENTITY_SECRET",
"EKKLESIA_IDENTITY_TRUSTED_ORIGINS", "EKKLESIA_IDENTITY_TRUSTED_ORIGINS",
"EKKLESIA_MODULES_DIR",
"EKKLESIA_PUBLIC_SIGNUP_ENABLED", "EKKLESIA_PUBLIC_SIGNUP_ENABLED",
"EKKLESIA_SMTP_FROM_ADDRESS", "EKKLESIA_SMTP_FROM_ADDRESS",
"EKKLESIA_SMTP_FROM_NAME", "EKKLESIA_SMTP_FROM_NAME",
-13
View File
@@ -48,19 +48,6 @@ 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);
@@ -169,57 +169,13 @@ describe("the browser shell reaches none of it", () => {
}); });
it("never puts a reset token in browser storage", async () => { it("never puts a reset token in browser storage", async () => {
// A reset token is a bearer credential for one account; the shell holds it in React state for for (const file of await readTypeScriptFiles(path.join("surfaces", "shell", "src"))) {
// the life of one form and nowhere else. The shell's uses of browser storage are pinned
// exactly: the desktop sidebar's expanded/collapsed mode (ADR 0058) and the pre-paint copy of
// the person's appearance (ADR 0062). `localStorage` is named only by the entry point, once, to
// hand to those two, and by the pre-paint script, once, to read its one key. Each write is its
// own fixed key and a validated value.
const files = await readTypeScriptFiles(path.join("surfaces", "shell", "src"));
const writes: string[] = [];
const removals: string[] = [];
for (const file of files) {
const code = withoutComments(file.text); const code = withoutComments(file.text);
// A reset token is a bearer credential for one account; the shell holds it in React state for
// the life of one form and nowhere else.
expect(code, file.path).not.toContain("localStorage");
expect(code, file.path).not.toContain("sessionStorage"); expect(code, file.path).not.toContain("sessionStorage");
expect(code, file.path).not.toContain("document.cookie"); expect(code, file.path).not.toContain("document.cookie");
writes.push(
...[...code.matchAll(/\.setItem\(([^)]*)\)/gu)].map(
(match) => `${path.relative(repositoryRoot, file.path)}: ${match[1]}`,
),
);
removals.push(
...[...code.matchAll(/\.removeItem\(([^)]*)\)/gu)].map(
(match) => `${path.relative(repositoryRoot, file.path)}: ${match[1]}`,
),
);
if (file.path.endsWith(path.join("browser", "main.tsx"))) {
expect(code.match(/localStorage/gu), file.path).toHaveLength(1);
expect(code).toMatch(
/const browserStorage = \(\(\) => \{\s*try \{\s*return window\.localStorage;/u,
);
expect(code).toContain("createLocalSidebarPreference(browserStorage)");
expect(code).toContain("createLocalAppearanceCache(browserStorage)");
} else if (file.path.endsWith(path.join("browser", "appearance-boot.ts"))) {
expect(code.match(/localStorage/gu), file.path).toHaveLength(1);
expect(code).toContain("window.localStorage.getItem(APPEARANCE_CACHE_KEY)");
} else {
expect(code, file.path).not.toContain("localStorage");
}
}
expect(writes).toEqual([
`${path.join("surfaces", "shell", "src", "browser", "appearance-cache.ts")}: APPEARANCE_CACHE_KEY, serialized`,
`${path.join("surfaces", "shell", "src", "browser", "sidebar-preference.ts")}: SIDEBAR_PREFERENCE_KEY, mode`,
]);
expect(removals).toEqual([
`${path.join("surfaces", "shell", "src", "browser", "appearance-cache.ts")}: APPEARANCE_CACHE_KEY`,
]);
// The files that ever hold the token touch no storage API at all.
for (const file of files.filter((candidate) =>
["auth-protocol.ts", "ResetPasswordForm.tsx", "HostApp.tsx"].some((name) =>
candidate.path.endsWith(name),
),
)) {
expect(withoutComments(file.text), file.path).not.toMatch(/\b(?:setItem|getItem|Storage)\b/u);
} }
}); });
}); });
@@ -1,34 +0,0 @@
import { alias, getTableConfig, integer, pgTable } from "drizzle-orm/pg-core";
import { describe, expect, it } from "vitest";
import { parseModuleSchemaDefinition } from "../src/modules/legacy-schema-definition.js";
describe("the legacy module schema definition", () => {
it("normalizes and freezes a typed schema definition without freezing its tables", () => {
const records = pgTable("example_records", { id: integer().primaryKey() });
const definition = parseModuleSchemaDefinition({
moduleId: "example",
tables: { records },
});
expect(definition.tables.records).toBe(records);
expect(getTableConfig(records).name).toBe("example_records");
expect(Object.isFrozen(definition)).toBe(true);
expect(Object.isFrozen(definition.tables)).toBe(true);
expect(Object.isFrozen(records)).toBe(false);
});
it("rejects malformed, empty, unknown, and non-PostgreSQL table definitions", () => {
const records = pgTable("example_records", { id: integer() });
const disguisedRecords = alias(records, "example_disguised_records");
expect(() => parseModuleSchemaDefinition({ moduleId: "example", tables: {} })).toThrow();
expect(() =>
parseModuleSchemaDefinition({ moduleId: "example", tables: { records: {} } }),
).toThrow();
expect(() =>
parseModuleSchemaDefinition({ moduleId: "example", tables: { records }, extra: true }),
).toThrow();
expect(() =>
parseModuleSchemaDefinition({ moduleId: "example", tables: { disguisedRecords } }),
).toThrow();
});
});

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