Compare commits
103
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cd0287537e | ||
|
|
8571e6854c | ||
|
|
89ef4b5ff6 | ||
|
|
34692ac05f | ||
|
|
d42b64a093 | ||
|
|
e3e2a901a7 | ||
|
|
5a7d44781b | ||
|
|
651efa8829 | ||
|
|
cf2f847c8c | ||
|
|
af31724cb4 | ||
|
|
f3b7e19651 | ||
|
|
ba60f70835 | ||
|
|
9afe9376c4 | ||
|
|
44430b5d59 | ||
|
|
bef516796e | ||
|
|
9bef1d9ef2 | ||
|
|
f2fc069e66 | ||
|
|
0552442c7c | ||
|
|
63fda0f8d6 | ||
|
|
94fa7a17a8 | ||
|
|
437720f228 | ||
|
|
d3148d45f7 | ||
|
|
303d455315 | ||
|
|
39f48dc7c9 | ||
|
|
9d515cd56f | ||
|
|
b8a826f8be | ||
|
|
0c9b8f7af9 | ||
|
|
5e9433d96b | ||
|
|
650932a170 | ||
|
|
2d861c6bf0 | ||
|
|
bae4ff3c6a | ||
|
|
9331a8f7df | ||
|
|
8f90871be0 | ||
|
|
85b1377fd3 | ||
|
|
4bcdb578e6 | ||
|
|
06bad2fe36 | ||
|
|
ae976ca229 | ||
|
|
f9585cc7ec | ||
|
|
6dae29412f | ||
|
|
40ef0ff802 | ||
|
|
a0e9948820 | ||
|
|
6e64b3c5f9 | ||
|
|
fa27603133 | ||
|
|
9e01679fcb | ||
|
|
227c92e35d | ||
|
|
505c9e8c0a | ||
|
|
4be046c8d0 | ||
|
|
c5a6cdaf6a | ||
|
|
c4fded6ecf | ||
|
|
9ad2d5b564 | ||
|
|
98d93cc12d | ||
|
|
e083c44e05 | ||
|
|
4919db6168 | ||
|
|
6e76293620 | ||
|
|
3b54a7f023 | ||
|
|
afaf3a853c | ||
|
|
1788d17f0a | ||
|
|
1caffc5f26 | ||
|
|
debb8df3ce | ||
|
|
98e59d4ba0 | ||
|
|
22404ea2d4 | ||
|
|
486356a01e | ||
|
|
1f30c86d4f | ||
|
|
d3c62e63bf | ||
|
|
57df2e15f2 | ||
|
|
4f1b1d68c7 | ||
|
|
9debe4e891 | ||
|
|
7ea2d7883f | ||
|
|
aaa7ebfd84 | ||
|
|
89d5d0f2da | ||
|
|
9426c83109 | ||
|
|
47e64c6f39 | ||
|
|
5803757153 | ||
|
|
44d385cd7f | ||
|
|
f53506b3f0 | ||
|
|
951453c735 | ||
|
|
99f145a80c | ||
|
|
104a5145c8 | ||
|
|
796ad4f16e | ||
|
|
b1e3244c1d | ||
|
|
85fae9e07e | ||
|
|
30f61094e6 | ||
|
|
1c7755d320 | ||
|
|
42acdeb9e8 | ||
|
|
70854d234c | ||
|
|
20ad1ae208 | ||
|
|
49b4d4165d | ||
|
|
a1cdcb3ad1 | ||
|
|
859675c3c7 | ||
|
|
4a3958c616 | ||
|
|
317a4ce410 | ||
|
|
6db50516b7 | ||
|
|
d43031997b | ||
|
|
11e2495484 | ||
|
|
759872834d | ||
|
|
9ddb5e2796 | ||
|
|
52a665502f | ||
|
|
9a328f647e | ||
|
|
22caa1dceb | ||
|
|
27d85e0162 | ||
|
|
35ef31df9f | ||
|
|
45d6506c10 | ||
|
|
1119cc5fdf |
@@ -6,3 +6,7 @@
|
||||
*.gif binary
|
||||
*.webp 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
|
||||
|
||||
@@ -128,6 +128,129 @@ jobs:
|
||||
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:
|
||||
needs: verify
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
name: Publish packages
|
||||
|
||||
# Publishes one public @ekklesia/* package train (ADR 0053, docs/development/releases.md) to the
|
||||
# npm registry: exactly the artifacts this run's package-release gate proves, and nothing else.
|
||||
#
|
||||
# Only ever started by hand, on `main`, by someone with write access to this repository: no pull
|
||||
# request or push can start it. It needs the version-set commit of a release (`version` must equal
|
||||
# the platform version there), and the repository secret EKKLESIA_PACKAGES_PUBLISH_TOKEN, a token of
|
||||
# the dedicated publisher account with only the `write:package` scope. Packages are publicly
|
||||
# readable (ADR 0054); publishing is what this protects.
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: The package train version to publish, equal to the platform version on main.
|
||||
required: true
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
# Never cancel a publication part way, and never run two at once. A rerun resumes safely.
|
||||
concurrency:
|
||||
group: publish-packages
|
||||
cancel-in-progress: false
|
||||
|
||||
env:
|
||||
# The only registry-specific setting: where the train is published.
|
||||
EKKLESIA_PACKAGES_REGISTRY: https://git.zayin.net/api/packages/Zayin/npm/
|
||||
|
||||
jobs:
|
||||
package-release:
|
||||
if: github.ref == 'refs/heads/main'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
outputs:
|
||||
version: ${{ steps.release.outputs.version }}
|
||||
revision: ${{ steps.release.outputs.revision }}
|
||||
manifest_sha256: ${{ steps.release.outputs.manifest_sha256 }}
|
||||
steps:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version-file: .node-version
|
||||
|
||||
- name: Activate pinned pnpm
|
||||
run: |
|
||||
corepack enable
|
||||
corepack prepare [email protected] --activate
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
# The same credential-free gate as CI: build, reproduce, and consume the train.
|
||||
- name: Build, reproduce, and consume the public package train
|
||||
run: pnpm test:package-release --keep package-train
|
||||
|
||||
- name: Describe the proven release train
|
||||
id: release
|
||||
run: |
|
||||
release="$(find package-train -mindepth 1 -maxdepth 1 -type d)"
|
||||
manifest="$release/ekklesia-package-train.json"
|
||||
{
|
||||
echo "version=$(node -p 'JSON.parse(require("fs").readFileSync(process.argv[1], "utf8")).version' "$manifest")"
|
||||
echo "revision=$(node -p 'JSON.parse(require("fs").readFileSync(process.argv[1], "utf8")).source.revision' "$manifest")"
|
||||
echo "manifest_sha256=$(sha256sum "$manifest" | cut -d' ' -f1)"
|
||||
} >>"$GITHUB_OUTPUT"
|
||||
|
||||
# Artifact actions version 3: version 4 refuses any server that is not github.com. The directory
|
||||
# name must not start with a dot: the artifact actions leave hidden files out.
|
||||
- name: Hand the release train on
|
||||
uses: actions/upload-artifact@c6a3b2bd78b3985e4b2f15397fec357f0fd808de # v3.2.2-node20
|
||||
with:
|
||||
name: package-train
|
||||
path: package-train/
|
||||
if-no-files-found: error
|
||||
retention-days: 90
|
||||
|
||||
publish:
|
||||
needs: package-release
|
||||
if: github.ref == 'refs/heads/main'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
# Only for the publication tool itself: nothing is built or installed in this job.
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version-file: .node-version
|
||||
|
||||
- name: Receive the release train the gate proved
|
||||
uses: actions/download-artifact@ad191675b41f6a5b46da9a048cb6893812da158b # v3.1.0-node20
|
||||
with:
|
||||
name: package-train
|
||||
path: package-train/
|
||||
|
||||
# The tool validates the whole train against its manifest, that manifest against the gate's,
|
||||
# its version against the request, and its revision against this commit, before it sends
|
||||
# anything; then publishes, verifies every package byte for byte, and moves `latest` last.
|
||||
- name: Publish the exact release train
|
||||
env:
|
||||
EKKLESIA_PACKAGES_PUBLISH_TOKEN: ${{ secrets.EKKLESIA_PACKAGES_PUBLISH_TOKEN }}
|
||||
TRAIN_VERSION: ${{ inputs.version }}
|
||||
TRAIN_REVISION: ${{ github.sha }}
|
||||
MANIFEST_SHA256: ${{ needs.package-release.outputs.manifest_sha256 }}
|
||||
run: |
|
||||
node scripts/publish-package-train.ts \
|
||||
--release "$(find package-train -mindepth 1 -maxdepth 1 -type d)" \
|
||||
--registry "$EKKLESIA_PACKAGES_REGISTRY" \
|
||||
--version "$TRAIN_VERSION" \
|
||||
--revision "$TRAIN_REVISION" \
|
||||
--manifest-sha256 "$MANIFEST_SHA256" \
|
||||
--record publication-record.json \
|
||||
--require-public-read
|
||||
|
||||
- name: Keep the publication record
|
||||
uses: actions/upload-artifact@c6a3b2bd78b3985e4b2f15397fec357f0fd808de # v3.2.2-node20
|
||||
with:
|
||||
name: package-train-publication-record
|
||||
path: publication-record.json
|
||||
if-no-files-found: error
|
||||
retention-days: 90
|
||||
+12
@@ -1,4 +1,8 @@
|
||||
node_modules/
|
||||
# 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/
|
||||
coverage/
|
||||
dist/
|
||||
@@ -16,3 +20,11 @@ generated/*
|
||||
|
||||
core/.identity-schema-reference.ts
|
||||
core/.identity-schema-check-*
|
||||
|
||||
# The packager tests copy fixture modules here (packages/module-pack/tests/support/fixture-module.ts).
|
||||
packages/module-pack/.fixtures/
|
||||
|
||||
# `pnpm packages:release` writes package release trains here (scripts/package-release.ts).
|
||||
.package-release/
|
||||
# CI keeps the proven release here to hand it on as a workflow artifact (scripts/test-package-release.sh --keep).
|
||||
/package-train/
|
||||
|
||||
@@ -6,27 +6,60 @@ contents, because restated details drift.
|
||||
|
||||
## What this repository is
|
||||
|
||||
Ekklesia is a modular, self-hosted church platform built as a strictly layered pnpm/TypeScript
|
||||
monorepo:
|
||||
Ekklesia is a modular, self-hosted church platform. **This repository is the platform** — Core, the
|
||||
API, the Dashboard and display shell, and the public `@ekklesia/*` module-development packages — built
|
||||
as a strictly layered pnpm/TypeScript monorepo:
|
||||
|
||||
- **Core** (`core/`) owns platform infrastructure: identity, database connectivity, migration
|
||||
execution, module discovery/lifecycle, and other feature-agnostic services. Core never imports a
|
||||
feature module, a surface, or an app.
|
||||
- **Modules** (`modules/*`, template at `templates/module`) are independently versioned product
|
||||
packages. They own their own data and migrations, declare capabilities/permissions through
|
||||
`@ekklesia/contracts` and `@ekklesia/module-sdk`, and must never import Core, another module, or a
|
||||
surface directly. Per ADR 0021 a module owns its UI; manifest v7 permits independently optional
|
||||
primary and full-viewport display artifacts, authored in React against the same role-neutral
|
||||
`@ekklesia/module-ui-sdk` ABI. Ekklesia mounts each through a separate surface role, while
|
||||
`pnpm boundaries` still rejects every non-React UI framework and non-`.tsx` presentation source.
|
||||
execution, module discovery/lifecycle, devices, and other feature-agnostic services. Core never
|
||||
imports a feature module, a surface, or an app.
|
||||
- **Modules** are independently versioned product packages. First-party modules live in **their own
|
||||
repositories** (Signage is `Zayin/Signage`), build against the published `@ekklesia/*` packages
|
||||
(ADR 0053), and reach an installation as a Module Package (ADR 0049) installed into module storage
|
||||
outside the image (ADR 0048). No module source lives in this repository; `templates/module` is the
|
||||
example, and `modules/` is only an optional, ignored local installed-modules directory. A module owns
|
||||
its data, migrations, and **its own domain UI** (ADR 0021), declares capabilities/permissions through
|
||||
`@ekklesia/contracts` and `@ekklesia/module-sdk`, and never imports Core, another module, or a
|
||||
surface. Manifest v8 permits independently optional primary and full-viewport display artifacts,
|
||||
authored in React against the role-neutral `@ekklesia/module-ui-sdk` ABI; database access is the
|
||||
Module Database ABI v1 (ADR 0050).
|
||||
- **Surfaces** (`surfaces/*`) own the containing application shell, navigation, and every
|
||||
presentation concern a module does not. They consume public API/client contracts only, never Core
|
||||
or module internals directly.
|
||||
presentation concern a module does not. The shell **hosts** module UI through the generic loader;
|
||||
it never rebuilds a module's domain screens. Surfaces consume public API/client contracts only.
|
||||
|
||||
These boundaries are enforced by `pnpm boundaries` (`scripts/check-boundaries.ts`), not just
|
||||
documented. Read `docs/architecture/repository-boundaries.md` and
|
||||
`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
|
||||
|
||||
`docs/architecture/decisions/` records durable architectural decisions. **Accepted ADRs are
|
||||
@@ -43,6 +76,7 @@ At minimum, read the current versions of:
|
||||
- `docs/architecture/decisions/README.md` and every ADR relevant to your change
|
||||
- `docs/architecture/platform-architecture.md` and `docs/architecture/repository-boundaries.md`
|
||||
- `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`
|
||||
- `CONTRIBUTING.md`
|
||||
|
||||
@@ -52,8 +86,16 @@ your own memory of a prior session** — treat stale assumptions as a bug to cat
|
||||
|
||||
## Working conventions
|
||||
|
||||
- Work on a clean branch off an up-to-date `main`; never commit or push directly to `main`; never
|
||||
change branch-protection settings.
|
||||
- The canonical repositories are on the church Gitea under the `Zayin` organization:
|
||||
`ssh://[email protected]:2222/Zayin/Ekklesia.git` (this one), `Zayin/Signage.git`, and
|
||||
`Zayin/Classroom_Desktop.git`. A local clone may be behind the server: `git fetch --prune --tags`
|
||||
and branch from `origin/main`, never from whatever is checked out.
|
||||
- One branch per PR, off an up-to-date `origin/main`. Never commit or push directly to `main`, never
|
||||
merge your own PR, and never change branch-protection settings.
|
||||
- Never create or push a release tag. Release tags belong to the human maintainer.
|
||||
- A change that needs both a platform contract change and a module change is two PRs: Core first, on
|
||||
its own branch, with the compatibility boundary stated explicitly; then the module. Do not copy
|
||||
platform logic into a module to avoid making the correct Core change.
|
||||
- Keep PRs focused on one bounded slice. Record major architectural changes in an ADR rather than
|
||||
burying them in an implementation.
|
||||
- Add or update tests when you change a contract or architectural rule. New behavior needs new
|
||||
@@ -66,6 +108,13 @@ 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
|
||||
explicitly a migration test, or as a side effect of an unrelated command). Migrations are always
|
||||
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
|
||||
|
||||
@@ -78,6 +127,13 @@ ever disagree:
|
||||
- `pnpm test:migrations:integration` — live, disposable-PostgreSQL evidence: migration discovery,
|
||||
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.
|
||||
- `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
|
||||
verify` or the integration suite.
|
||||
- `pnpm migrations:plan` / `pnpm migrations:apply` — read-only planning vs. mutating execution
|
||||
|
||||
@@ -0,0 +1,661 @@
|
||||
GNU AFFERO GENERAL PUBLIC LICENSE
|
||||
Version 3, 19 November 2007
|
||||
|
||||
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||
Everyone is permitted to copy and distribute verbatim copies
|
||||
of this license document, but changing it is not allowed.
|
||||
|
||||
Preamble
|
||||
|
||||
The GNU Affero General Public License is a free, copyleft license for
|
||||
software and other kinds of works, specifically designed to ensure
|
||||
cooperation with the community in the case of network server software.
|
||||
|
||||
The licenses for most software and other practical works are designed
|
||||
to take away your freedom to share and change the works. By contrast,
|
||||
our General Public Licenses are intended to guarantee your freedom to
|
||||
share and change all versions of a program--to make sure it remains free
|
||||
software for all its users.
|
||||
|
||||
When we speak of free software, we are referring to freedom, not
|
||||
price. Our General Public Licenses are designed to make sure that you
|
||||
have the freedom to distribute copies of free software (and charge for
|
||||
them if you wish), that you receive source code or can get it if you
|
||||
want it, that you can change the software or use pieces of it in new
|
||||
free programs, and that you know you can do these things.
|
||||
|
||||
Developers that use our General Public Licenses protect your rights
|
||||
with two steps: (1) assert copyright on the software, and (2) offer
|
||||
you this License which gives you legal permission to copy, distribute
|
||||
and/or modify the software.
|
||||
|
||||
A secondary benefit of defending all users' freedom is that
|
||||
improvements made in alternate versions of the program, if they
|
||||
receive widespread use, become available for other developers to
|
||||
incorporate. Many developers of free software are heartened and
|
||||
encouraged by the resulting cooperation. However, in the case of
|
||||
software used on network servers, this result may fail to come about.
|
||||
The GNU General Public License permits making a modified version and
|
||||
letting the public access it on a server without ever releasing its
|
||||
source code to the public.
|
||||
|
||||
The GNU Affero General Public License is designed specifically to
|
||||
ensure that, in such cases, the modified source code becomes available
|
||||
to the community. It requires the operator of a network server to
|
||||
provide the source code of the modified version running there to the
|
||||
users of that server. Therefore, public use of a modified version, on
|
||||
a publicly accessible server, gives the public access to the source
|
||||
code of the modified version.
|
||||
|
||||
An older license, called the Affero General Public License and
|
||||
published by Affero, was designed to accomplish similar goals. This is
|
||||
a different license, not a version of the Affero GPL, but Affero has
|
||||
released a new version of the Affero GPL which permits relicensing under
|
||||
this license.
|
||||
|
||||
The precise terms and conditions for copying, distribution and
|
||||
modification follow.
|
||||
|
||||
TERMS AND CONDITIONS
|
||||
|
||||
0. Definitions.
|
||||
|
||||
"This License" refers to version 3 of the GNU Affero General Public License.
|
||||
|
||||
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||
works, such as semiconductor masks.
|
||||
|
||||
"The Program" refers to any copyrightable work licensed under this
|
||||
License. Each licensee is addressed as "you". "Licensees" and
|
||||
"recipients" may be individuals or organizations.
|
||||
|
||||
To "modify" a work means to copy from or adapt all or part of the work
|
||||
in a fashion requiring copyright permission, other than the making of an
|
||||
exact copy. The resulting work is called a "modified version" of the
|
||||
earlier work or a work "based on" the earlier work.
|
||||
|
||||
A "covered work" means either the unmodified Program or a work based
|
||||
on the Program.
|
||||
|
||||
To "propagate" a work means to do anything with it that, without
|
||||
permission, would make you directly or secondarily liable for
|
||||
infringement under applicable copyright law, except executing it on a
|
||||
computer or modifying a private copy. Propagation includes copying,
|
||||
distribution (with or without modification), making available to the
|
||||
public, and in some countries other activities as well.
|
||||
|
||||
To "convey" a work means any kind of propagation that enables other
|
||||
parties to make or receive copies. Mere interaction with a user through
|
||||
a computer network, with no transfer of a copy, is not conveying.
|
||||
|
||||
An interactive user interface displays "Appropriate Legal Notices"
|
||||
to the extent that it includes a convenient and prominently visible
|
||||
feature that (1) displays an appropriate copyright notice, and (2)
|
||||
tells the user that there is no warranty for the work (except to the
|
||||
extent that warranties are provided), that licensees may convey the
|
||||
work under this License, and how to view a copy of this License. If
|
||||
the interface presents a list of user commands or options, such as a
|
||||
menu, a prominent item in the list meets this criterion.
|
||||
|
||||
1. Source Code.
|
||||
|
||||
The "source code" for a work means the preferred form of the work
|
||||
for making modifications to it. "Object code" means any non-source
|
||||
form of a work.
|
||||
|
||||
A "Standard Interface" means an interface that either is an official
|
||||
standard defined by a recognized standards body, or, in the case of
|
||||
interfaces specified for a particular programming language, one that
|
||||
is widely used among developers working in that language.
|
||||
|
||||
The "System Libraries" of an executable work include anything, other
|
||||
than the work as a whole, that (a) is included in the normal form of
|
||||
packaging a Major Component, but which is not part of that Major
|
||||
Component, and (b) serves only to enable use of the work with that
|
||||
Major Component, or to implement a Standard Interface for which an
|
||||
implementation is available to the public in source code form. A
|
||||
"Major Component", in this context, means a major essential component
|
||||
(kernel, window system, and so on) of the specific operating system
|
||||
(if any) on which the executable work runs, or a compiler used to
|
||||
produce the work, or an object code interpreter used to run it.
|
||||
|
||||
The "Corresponding Source" for a work in object code form means all
|
||||
the source code needed to generate, install, and (for an executable
|
||||
work) run the object code and to modify the work, including scripts to
|
||||
control those activities. However, it does not include the work's
|
||||
System Libraries, or general-purpose tools or generally available free
|
||||
programs which are used unmodified in performing those activities but
|
||||
which are not part of the work. For example, Corresponding Source
|
||||
includes interface definition files associated with source files for
|
||||
the work, and the source code for shared libraries and dynamically
|
||||
linked subprograms that the work is specifically designed to require,
|
||||
such as by intimate data communication or control flow between those
|
||||
subprograms and other parts of the work.
|
||||
|
||||
The Corresponding Source need not include anything that users
|
||||
can regenerate automatically from other parts of the Corresponding
|
||||
Source.
|
||||
|
||||
The Corresponding Source for a work in source code form is that
|
||||
same work.
|
||||
|
||||
2. Basic Permissions.
|
||||
|
||||
All rights granted under this License are granted for the term of
|
||||
copyright on the Program, and are irrevocable provided the stated
|
||||
conditions are met. This License explicitly affirms your unlimited
|
||||
permission to run the unmodified Program. The output from running a
|
||||
covered work is covered by this License only if the output, given its
|
||||
content, constitutes a covered work. This License acknowledges your
|
||||
rights of fair use or other equivalent, as provided by copyright law.
|
||||
|
||||
You may make, run and propagate covered works that you do not
|
||||
convey, without conditions so long as your license otherwise remains
|
||||
in force. You may convey covered works to others for the sole purpose
|
||||
of having them make modifications exclusively for you, or provide you
|
||||
with facilities for running those works, provided that you comply with
|
||||
the terms of this License in conveying all material for which you do
|
||||
not control copyright. Those thus making or running the covered works
|
||||
for you must do so exclusively on your behalf, under your direction
|
||||
and control, on terms that prohibit them from making any copies of
|
||||
your copyrighted material outside their relationship with you.
|
||||
|
||||
Conveying under any other circumstances is permitted solely under
|
||||
the conditions stated below. Sublicensing is not allowed; section 10
|
||||
makes it unnecessary.
|
||||
|
||||
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||
|
||||
No covered work shall be deemed part of an effective technological
|
||||
measure under any applicable law fulfilling obligations under article
|
||||
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
||||
similar laws prohibiting or restricting circumvention of such
|
||||
measures.
|
||||
|
||||
When you convey a covered work, you waive any legal power to forbid
|
||||
circumvention of technological measures to the extent such circumvention
|
||||
is effected by exercising rights under this License with respect to
|
||||
the covered work, and you disclaim any intention to limit operation or
|
||||
modification of the work as a means of enforcing, against the work's
|
||||
users, your or third parties' legal rights to forbid circumvention of
|
||||
technological measures.
|
||||
|
||||
4. Conveying Verbatim Copies.
|
||||
|
||||
You may convey verbatim copies of the Program's source code as you
|
||||
receive it, in any medium, provided that you conspicuously and
|
||||
appropriately publish on each copy an appropriate copyright notice;
|
||||
keep intact all notices stating that this License and any
|
||||
non-permissive terms added in accord with section 7 apply to the code;
|
||||
keep intact all notices of the absence of any warranty; and give all
|
||||
recipients a copy of this License along with the Program.
|
||||
|
||||
You may charge any price or no price for each copy that you convey,
|
||||
and you may offer support or warranty protection for a fee.
|
||||
|
||||
5. Conveying Modified Source Versions.
|
||||
|
||||
You may convey a work based on the Program, or the modifications to
|
||||
produce it from the Program, in the form of source code under the
|
||||
terms of section 4, provided that you also meet all of these conditions:
|
||||
|
||||
a) The work must carry prominent notices stating that you modified
|
||||
it, and giving a relevant date.
|
||||
|
||||
b) The work must carry prominent notices stating that it is
|
||||
released under this License and any conditions added under section
|
||||
7. This requirement modifies the requirement in section 4 to
|
||||
"keep intact all notices".
|
||||
|
||||
c) You must license the entire work, as a whole, under this
|
||||
License to anyone who comes into possession of a copy. This
|
||||
License will therefore apply, along with any applicable section 7
|
||||
additional terms, to the whole of the work, and all its parts,
|
||||
regardless of how they are packaged. This License gives no
|
||||
permission to license the work in any other way, but it does not
|
||||
invalidate such permission if you have separately received it.
|
||||
|
||||
d) If the work has interactive user interfaces, each must display
|
||||
Appropriate Legal Notices; however, if the Program has interactive
|
||||
interfaces that do not display Appropriate Legal Notices, your
|
||||
work need not make them do so.
|
||||
|
||||
A compilation of a covered work with other separate and independent
|
||||
works, which are not by their nature extensions of the covered work,
|
||||
and which are not combined with it such as to form a larger program,
|
||||
in or on a volume of a storage or distribution medium, is called an
|
||||
"aggregate" if the compilation and its resulting copyright are not
|
||||
used to limit the access or legal rights of the compilation's users
|
||||
beyond what the individual works permit. Inclusion of a covered work
|
||||
in an aggregate does not cause this License to apply to the other
|
||||
parts of the aggregate.
|
||||
|
||||
6. Conveying Non-Source Forms.
|
||||
|
||||
You may convey a covered work in object code form under the terms
|
||||
of sections 4 and 5, provided that you also convey the
|
||||
machine-readable Corresponding Source under the terms of this License,
|
||||
in one of these ways:
|
||||
|
||||
a) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by the
|
||||
Corresponding Source fixed on a durable physical medium
|
||||
customarily used for software interchange.
|
||||
|
||||
b) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by a
|
||||
written offer, valid for at least three years and valid for as
|
||||
long as you offer spare parts or customer support for that product
|
||||
model, to give anyone who possesses the object code either (1) a
|
||||
copy of the Corresponding Source for all the software in the
|
||||
product that is covered by this License, on a durable physical
|
||||
medium customarily used for software interchange, for a price no
|
||||
more than your reasonable cost of physically performing this
|
||||
conveying of source, or (2) access to copy the
|
||||
Corresponding Source from a network server at no charge.
|
||||
|
||||
c) Convey individual copies of the object code with a copy of the
|
||||
written offer to provide the Corresponding Source. This
|
||||
alternative is allowed only occasionally and noncommercially, and
|
||||
only if you received the object code with such an offer, in accord
|
||||
with subsection 6b.
|
||||
|
||||
d) Convey the object code by offering access from a designated
|
||||
place (gratis or for a charge), and offer equivalent access to the
|
||||
Corresponding Source in the same way through the same place at no
|
||||
further charge. You need not require recipients to copy the
|
||||
Corresponding Source along with the object code. If the place to
|
||||
copy the object code is a network server, the Corresponding Source
|
||||
may be on a different server (operated by you or a third party)
|
||||
that supports equivalent copying facilities, provided you maintain
|
||||
clear directions next to the object code saying where to find the
|
||||
Corresponding Source. Regardless of what server hosts the
|
||||
Corresponding Source, you remain obligated to ensure that it is
|
||||
available for as long as needed to satisfy these requirements.
|
||||
|
||||
e) Convey the object code using peer-to-peer transmission, provided
|
||||
you inform other peers where the object code and Corresponding
|
||||
Source of the work are being offered to the general public at no
|
||||
charge under subsection 6d.
|
||||
|
||||
A separable portion of the object code, whose source code is excluded
|
||||
from the Corresponding Source as a System Library, need not be
|
||||
included in conveying the object code work.
|
||||
|
||||
A "User Product" is either (1) a "consumer product", which means any
|
||||
tangible personal property which is normally used for personal, family,
|
||||
or household purposes, or (2) anything designed or sold for incorporation
|
||||
into a dwelling. In determining whether a product is a consumer product,
|
||||
doubtful cases shall be resolved in favor of coverage. For a particular
|
||||
product received by a particular user, "normally used" refers to a
|
||||
typical or common use of that class of product, regardless of the status
|
||||
of the particular user or of the way in which the particular user
|
||||
actually uses, or expects or is expected to use, the product. A product
|
||||
is a consumer product regardless of whether the product has substantial
|
||||
commercial, industrial or non-consumer uses, unless such uses represent
|
||||
the only significant mode of use of the product.
|
||||
|
||||
"Installation Information" for a User Product means any methods,
|
||||
procedures, authorization keys, or other information required to install
|
||||
and execute modified versions of a covered work in that User Product from
|
||||
a modified version of its Corresponding Source. The information must
|
||||
suffice to ensure that the continued functioning of the modified object
|
||||
code is in no case prevented or interfered with solely because
|
||||
modification has been made.
|
||||
|
||||
If you convey an object code work under this section in, or with, or
|
||||
specifically for use in, a User Product, and the conveying occurs as
|
||||
part of a transaction in which the right of possession and use of the
|
||||
User Product is transferred to the recipient in perpetuity or for a
|
||||
fixed term (regardless of how the transaction is characterized), the
|
||||
Corresponding Source conveyed under this section must be accompanied
|
||||
by the Installation Information. But this requirement does not apply
|
||||
if neither you nor any third party retains the ability to install
|
||||
modified object code on the User Product (for example, the work has
|
||||
been installed in ROM).
|
||||
|
||||
The requirement to provide Installation Information does not include a
|
||||
requirement to continue to provide support service, warranty, or updates
|
||||
for a work that has been modified or installed by the recipient, or for
|
||||
the User Product in which it has been modified or installed. Access to a
|
||||
network may be denied when the modification itself materially and
|
||||
adversely affects the operation of the network or violates the rules and
|
||||
protocols for communication across the network.
|
||||
|
||||
Corresponding Source conveyed, and Installation Information provided,
|
||||
in accord with this section must be in a format that is publicly
|
||||
documented (and with an implementation available to the public in
|
||||
source code form), and must require no special password or key for
|
||||
unpacking, reading or copying.
|
||||
|
||||
7. Additional Terms.
|
||||
|
||||
"Additional permissions" are terms that supplement the terms of this
|
||||
License by making exceptions from one or more of its conditions.
|
||||
Additional permissions that are applicable to the entire Program shall
|
||||
be treated as though they were included in this License, to the extent
|
||||
that they are valid under applicable law. If additional permissions
|
||||
apply only to part of the Program, that part may be used separately
|
||||
under those permissions, but the entire Program remains governed by
|
||||
this License without regard to the additional permissions.
|
||||
|
||||
When you convey a copy of a covered work, you may at your option
|
||||
remove any additional permissions from that copy, or from any part of
|
||||
it. (Additional permissions may be written to require their own
|
||||
removal in certain cases when you modify the work.) You may place
|
||||
additional permissions on material, added by you to a covered work,
|
||||
for which you have or can give appropriate copyright permission.
|
||||
|
||||
Notwithstanding any other provision of this License, for material you
|
||||
add to a covered work, you may (if authorized by the copyright holders of
|
||||
that material) supplement the terms of this License with terms:
|
||||
|
||||
a) Disclaiming warranty or limiting liability differently from the
|
||||
terms of sections 15 and 16 of this License; or
|
||||
|
||||
b) Requiring preservation of specified reasonable legal notices or
|
||||
author attributions in that material or in the Appropriate Legal
|
||||
Notices displayed by works containing it; or
|
||||
|
||||
c) Prohibiting misrepresentation of the origin of that material, or
|
||||
requiring that modified versions of such material be marked in
|
||||
reasonable ways as different from the original version; or
|
||||
|
||||
d) Limiting the use for publicity purposes of names of licensors or
|
||||
authors of the material; or
|
||||
|
||||
e) Declining to grant rights under trademark law for use of some
|
||||
trade names, trademarks, or service marks; or
|
||||
|
||||
f) Requiring indemnification of licensors and authors of that
|
||||
material by anyone who conveys the material (or modified versions of
|
||||
it) with contractual assumptions of liability to the recipient, for
|
||||
any liability that these contractual assumptions directly impose on
|
||||
those licensors and authors.
|
||||
|
||||
All other non-permissive additional terms are considered "further
|
||||
restrictions" within the meaning of section 10. If the Program as you
|
||||
received it, or any part of it, contains a notice stating that it is
|
||||
governed by this License along with a term that is a further
|
||||
restriction, you may remove that term. If a license document contains
|
||||
a further restriction but permits relicensing or conveying under this
|
||||
License, you may add to a covered work material governed by the terms
|
||||
of that license document, provided that the further restriction does
|
||||
not survive such relicensing or conveying.
|
||||
|
||||
If you add terms to a covered work in accord with this section, you
|
||||
must place, in the relevant source files, a statement of the
|
||||
additional terms that apply to those files, or a notice indicating
|
||||
where to find the applicable terms.
|
||||
|
||||
Additional terms, permissive or non-permissive, may be stated in the
|
||||
form of a separately written license, or stated as exceptions;
|
||||
the above requirements apply either way.
|
||||
|
||||
8. Termination.
|
||||
|
||||
You may not propagate or modify a covered work except as expressly
|
||||
provided under this License. Any attempt otherwise to propagate or
|
||||
modify it is void, and will automatically terminate your rights under
|
||||
this License (including any patent licenses granted under the third
|
||||
paragraph of section 11).
|
||||
|
||||
However, if you cease all violation of this License, then your
|
||||
license from a particular copyright holder is reinstated (a)
|
||||
provisionally, unless and until the copyright holder explicitly and
|
||||
finally terminates your license, and (b) permanently, if the copyright
|
||||
holder fails to notify you of the violation by some reasonable means
|
||||
prior to 60 days after the cessation.
|
||||
|
||||
Moreover, your license from a particular copyright holder is
|
||||
reinstated permanently if the copyright holder notifies you of the
|
||||
violation by some reasonable means, this is the first time you have
|
||||
received notice of violation of this License (for any work) from that
|
||||
copyright holder, and you cure the violation prior to 30 days after
|
||||
your receipt of the notice.
|
||||
|
||||
Termination of your rights under this section does not terminate the
|
||||
licenses of parties who have received copies or rights from you under
|
||||
this License. If your rights have been terminated and not permanently
|
||||
reinstated, you do not qualify to receive new licenses for the same
|
||||
material under section 10.
|
||||
|
||||
9. Acceptance Not Required for Having Copies.
|
||||
|
||||
You are not required to accept this License in order to receive or
|
||||
run a copy of the Program. Ancillary propagation of a covered work
|
||||
occurring solely as a consequence of using peer-to-peer transmission
|
||||
to receive a copy likewise does not require acceptance. However,
|
||||
nothing other than this License grants you permission to propagate or
|
||||
modify any covered work. These actions infringe copyright if you do
|
||||
not accept this License. Therefore, by modifying or propagating a
|
||||
covered work, you indicate your acceptance of this License to do so.
|
||||
|
||||
10. Automatic Licensing of Downstream Recipients.
|
||||
|
||||
Each time you convey a covered work, the recipient automatically
|
||||
receives a license from the original licensors, to run, modify and
|
||||
propagate that work, subject to this License. You are not responsible
|
||||
for enforcing compliance by third parties with this License.
|
||||
|
||||
An "entity transaction" is a transaction transferring control of an
|
||||
organization, or substantially all assets of one, or subdividing an
|
||||
organization, or merging organizations. If propagation of a covered
|
||||
work results from an entity transaction, each party to that
|
||||
transaction who receives a copy of the work also receives whatever
|
||||
licenses to the work the party's predecessor in interest had or could
|
||||
give under the previous paragraph, plus a right to possession of the
|
||||
Corresponding Source of the work from the predecessor in interest, if
|
||||
the predecessor has it or can get it with reasonable efforts.
|
||||
|
||||
You may not impose any further restrictions on the exercise of the
|
||||
rights granted or affirmed under this License. For example, you may
|
||||
not impose a license fee, royalty, or other charge for exercise of
|
||||
rights granted under this License, and you may not initiate litigation
|
||||
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
||||
any patent claim is infringed by making, using, selling, offering for
|
||||
sale, or importing the Program or any portion of it.
|
||||
|
||||
11. Patents.
|
||||
|
||||
A "contributor" is a copyright holder who authorizes use under this
|
||||
License of the Program or a work on which the Program is based. The
|
||||
work thus licensed is called the contributor's "contributor version".
|
||||
|
||||
A contributor's "essential patent claims" are all patent claims
|
||||
owned or controlled by the contributor, whether already acquired or
|
||||
hereafter acquired, that would be infringed by some manner, permitted
|
||||
by this License, of making, using, or selling its contributor version,
|
||||
but do not include claims that would be infringed only as a
|
||||
consequence of further modification of the contributor version. For
|
||||
purposes of this definition, "control" includes the right to grant
|
||||
patent sublicenses in a manner consistent with the requirements of
|
||||
this License.
|
||||
|
||||
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
||||
patent license under the contributor's essential patent claims, to
|
||||
make, use, sell, offer for sale, import and otherwise run, modify and
|
||||
propagate the contents of its contributor version.
|
||||
|
||||
In the following three paragraphs, a "patent license" is any express
|
||||
agreement or commitment, however denominated, not to enforce a patent
|
||||
(such as an express permission to practice a patent or covenant not to
|
||||
sue for patent infringement). To "grant" such a patent license to a
|
||||
party means to make such an agreement or commitment not to enforce a
|
||||
patent against the party.
|
||||
|
||||
If you convey a covered work, knowingly relying on a patent license,
|
||||
and the Corresponding Source of the work is not available for anyone
|
||||
to copy, free of charge and under the terms of this License, through a
|
||||
publicly available network server or other readily accessible means,
|
||||
then you must either (1) cause the Corresponding Source to be so
|
||||
available, or (2) arrange to deprive yourself of the benefit of the
|
||||
patent license for this particular work, or (3) arrange, in a manner
|
||||
consistent with the requirements of this License, to extend the patent
|
||||
license to downstream recipients. "Knowingly relying" means you have
|
||||
actual knowledge that, but for the patent license, your conveying the
|
||||
covered work in a country, or your recipient's use of the covered work
|
||||
in a country, would infringe one or more identifiable patents in that
|
||||
country that you have reason to believe are valid.
|
||||
|
||||
If, pursuant to or in connection with a single transaction or
|
||||
arrangement, you convey, or propagate by procuring conveyance of, a
|
||||
covered work, and grant a patent license to some of the parties
|
||||
receiving the covered work authorizing them to use, propagate, modify
|
||||
or convey a specific copy of the covered work, then the patent license
|
||||
you grant is automatically extended to all recipients of the covered
|
||||
work and works based on it.
|
||||
|
||||
A patent license is "discriminatory" if it does not include within
|
||||
the scope of its coverage, prohibits the exercise of, or is
|
||||
conditioned on the non-exercise of one or more of the rights that are
|
||||
specifically granted under this License. You may not convey a covered
|
||||
work if you are a party to an arrangement with a third party that is
|
||||
in the business of distributing software, under which you make payment
|
||||
to the third party based on the extent of your activity of conveying
|
||||
the work, and under which the third party grants, to any of the
|
||||
parties who would receive the covered work from you, a discriminatory
|
||||
patent license (a) in connection with copies of the covered work
|
||||
conveyed by you (or copies made from those copies), or (b) primarily
|
||||
for and in connection with specific products or compilations that
|
||||
contain the covered work, unless you entered into that arrangement,
|
||||
or that patent license was granted, prior to 28 March 2007.
|
||||
|
||||
Nothing in this License shall be construed as excluding or limiting
|
||||
any implied license or other defenses to infringement that may
|
||||
otherwise be available to you under applicable patent law.
|
||||
|
||||
12. No Surrender of Others' Freedom.
|
||||
|
||||
If conditions are imposed on you (whether by court order, agreement or
|
||||
otherwise) that contradict the conditions of this License, they do not
|
||||
excuse you from the conditions of this License. If you cannot convey a
|
||||
covered work so as to satisfy simultaneously your obligations under this
|
||||
License and any other pertinent obligations, then as a consequence you may
|
||||
not convey it at all. For example, if you agree to terms that obligate you
|
||||
to collect a royalty for further conveying from those to whom you convey
|
||||
the Program, the only way you could satisfy both those terms and this
|
||||
License would be to refrain entirely from conveying the Program.
|
||||
|
||||
13. Remote Network Interaction; Use with the GNU General Public License.
|
||||
|
||||
Notwithstanding any other provision of this License, if you modify the
|
||||
Program, your modified version must prominently offer all users
|
||||
interacting with it remotely through a computer network (if your version
|
||||
supports such interaction) an opportunity to receive the Corresponding
|
||||
Source of your version by providing access to the Corresponding Source
|
||||
from a network server at no charge, through some standard or customary
|
||||
means of facilitating copying of software. This Corresponding Source
|
||||
shall include the Corresponding Source for any work covered by version 3
|
||||
of the GNU General Public License that is incorporated pursuant to the
|
||||
following paragraph.
|
||||
|
||||
Notwithstanding any other provision of this License, you have
|
||||
permission to link or combine any covered work with a work licensed
|
||||
under version 3 of the GNU General Public License into a single
|
||||
combined work, and to convey the resulting work. The terms of this
|
||||
License will continue to apply to the part which is the covered work,
|
||||
but the work with which it is combined will remain governed by version
|
||||
3 of the GNU General Public License.
|
||||
|
||||
14. Revised Versions of this License.
|
||||
|
||||
The Free Software Foundation may publish revised and/or new versions of
|
||||
the GNU Affero General Public License from time to time. Such new versions
|
||||
will be similar in spirit to the present version, but may differ in detail to
|
||||
address new problems or concerns.
|
||||
|
||||
Each version is given a distinguishing version number. If the
|
||||
Program specifies that a certain numbered version of the GNU Affero General
|
||||
Public License "or any later version" applies to it, you have the
|
||||
option of following the terms and conditions either of that numbered
|
||||
version or of any later version published by the Free Software
|
||||
Foundation. If the Program does not specify a version number of the
|
||||
GNU Affero General Public License, you may choose any version ever published
|
||||
by the Free Software Foundation.
|
||||
|
||||
If the Program specifies that a proxy can decide which future
|
||||
versions of the GNU Affero General Public License can be used, that proxy's
|
||||
public statement of acceptance of a version permanently authorizes you
|
||||
to choose that version for the Program.
|
||||
|
||||
Later license versions may give you additional or different
|
||||
permissions. However, no additional obligations are imposed on any
|
||||
author or copyright holder as a result of your choosing to follow a
|
||||
later version.
|
||||
|
||||
15. Disclaimer of Warranty.
|
||||
|
||||
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
||||
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
||||
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
|
||||
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
||||
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
||||
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
||||
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
||||
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||
|
||||
16. Limitation of Liability.
|
||||
|
||||
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
||||
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
|
||||
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
|
||||
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
|
||||
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
|
||||
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
||||
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
||||
SUCH DAMAGES.
|
||||
|
||||
17. Interpretation of Sections 15 and 16.
|
||||
|
||||
If the disclaimer of warranty and limitation of liability provided
|
||||
above cannot be given local legal effect according to their terms,
|
||||
reviewing courts shall apply local law that most closely approximates
|
||||
an absolute waiver of all civil liability in connection with the
|
||||
Program, unless a warranty or assumption of liability accompanies a
|
||||
copy of the Program in return for a fee.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
How to Apply These Terms to Your New Programs
|
||||
|
||||
If you develop a new program, and you want it to be of the greatest
|
||||
possible use to the public, the best way to achieve this is to make it
|
||||
free software which everyone can redistribute and change under these terms.
|
||||
|
||||
To do so, attach the following notices to the program. It is safest
|
||||
to attach them to the start of each source file to most effectively
|
||||
state the exclusion of warranty; and each file should have at least
|
||||
the "copyright" line and a pointer to where the full notice is found.
|
||||
|
||||
<one line to give the program's name and a brief idea of what it does.>
|
||||
Copyright (C) <year> <name of author>
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
Also add information on how to contact you by electronic and paper mail.
|
||||
|
||||
If your software can interact with users remotely through a computer
|
||||
network, you should also make sure that it provides a way for users to
|
||||
get its source. For example, if your program is a web application, its
|
||||
interface could display a "Source" link that leads users to an archive
|
||||
of the code. There are many ways you could offer source, and different
|
||||
solutions will be better for different programs; see section 13 for the
|
||||
specific requirements.
|
||||
|
||||
You should also get your employer (if you work as a programmer) or school,
|
||||
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||
For more information on this, and how to apply and follow the GNU AGPL, see
|
||||
<https://www.gnu.org/licenses/>.
|
||||
@@ -44,8 +44,38 @@ selected by `EKKLESIA_MODULES_DIR` (`/var/lib/ekklesia/modules` in the image, th
|
||||
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. Purge, a package format,
|
||||
signing, and any installer or marketplace do not exist yet.
|
||||
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
|
||||
(`ekklesia_core_user`, `ekklesia_core_session`, `ekklesia_core_account`,
|
||||
@@ -502,8 +532,8 @@ reported `blocked · configuration_missing · bucket` — never imported, never
|
||||
from inside its own `start` hook — and its required dependents block behind it through ADR 0028's
|
||||
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
|
||||
manifest is version 7 after ADR 0033 added the optional display declaration and ADR 0041 added
|
||||
browser contributions.
|
||||
manifest is version 8 after ADR 0050 added the optional database ABI declaration. Version 8 is a
|
||||
hard cutover; a version 7 source module or package must be updated and rebuilt.
|
||||
|
||||
**Ekklesia does not store secrets, and does not pretend to.** There is no encryption at rest here and
|
||||
no key management, so a `secret` key's value is supplied by the deployment
|
||||
@@ -792,7 +822,8 @@ hosting are deployment work, not guarantees added by this pass. Display device i
|
||||
public Website remain separate future designs.
|
||||
|
||||
**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
|
||||
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
|
||||
@@ -837,7 +868,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
|
||||
([ADR 0041](docs/architecture/decisions/0041-module-browser-contributions.md)) — the browser half of
|
||||
the capability system Core has had since Phase 1. `manifestVersion: 7` declares a namespaced id, its
|
||||
the capability system Core has had since Phase 1. `manifestVersion: 8` declares a namespaced id, its
|
||||
own version, a target capability, a compatible range, and one artifact, and nothing else: there is no
|
||||
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
|
||||
@@ -875,6 +906,59 @@ the module's responsibility and do not exist yet.**
|
||||
Ekklesia Lite, a theme editor, a module store or installer, a standalone host, and every feature
|
||||
module — Signage included — still do not exist, and nothing in Phase 8 claims otherwise.
|
||||
|
||||
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
|
||||
|
||||
Prerequisites: Node.js 24 LTS, pnpm 11.12, and Docker with Compose.
|
||||
@@ -930,7 +1014,7 @@ Modules
|
||||
✓ Dependency graph valid
|
||||
Dependency order is empty.
|
||||
✓ Permission registry composed
|
||||
6 permissions registered
|
||||
7 permissions registered
|
||||
○ Module lifecycle skipped
|
||||
DATABASE_URL is not configured.
|
||||
○ Module configuration skipped
|
||||
@@ -940,7 +1024,7 @@ Data definitions
|
||||
✓ Schemas validated
|
||||
0 modules · 0 tables
|
||||
✓ Migrations validated
|
||||
7 migrations · checksums valid
|
||||
9 migrations · checksums valid
|
||||
core:0000_identity_core_tables · f8f2086ef06f
|
||||
core:0001_authorization_core_tables · 830e1fbc26d0
|
||||
core:0002_authorization_integrity · 634294731180
|
||||
@@ -948,6 +1032,8 @@ Data definitions
|
||||
core:0004_module_lifecycle_state · c8b5d8b3d48b
|
||||
core:0005_module_configuration · c8d57f24f84b
|
||||
core:0006_device_identity · 1db97b7e3ac5
|
||||
core:0007_verified_bootstrap_owners · 49013062f3e0
|
||||
core:0008_user_preferences · fa9d4b0bb9d3
|
||||
|
||||
PostgreSQL
|
||||
✓ Database TLS verified
|
||||
@@ -1068,5 +1154,27 @@ environment.
|
||||
- [Module development](docs/module-development/README.md)
|
||||
- [Architecture decisions](docs/architecture/decisions/README.md)
|
||||
|
||||
No open-source license has been selected yet. A license should be added only after the project
|
||||
owner makes that decision.
|
||||
## License
|
||||
|
||||
Ekklesia is licensed under the **GNU Affero General Public License, version 3 or any later version**
|
||||
(SPDX `AGPL-3.0-or-later`). The full text of version 3 is in [LICENSE](LICENSE). Its source is
|
||||
available to you under the terms of that license.
|
||||
|
||||
> Ekklesia is free software: you can redistribute it and/or modify it under the terms of the GNU
|
||||
> Affero General Public License as published by the Free Software Foundation, either version 3 of
|
||||
> the License, or (at your option) any later version.
|
||||
>
|
||||
> Ekklesia is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
|
||||
> the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero
|
||||
> General Public License for more details.
|
||||
>
|
||||
> You should have received a copy of the GNU Affero General Public License along with Ekklesia. If
|
||||
> not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
Section 13 of the license adds an obligation for network use: if you modify Ekklesia and run your
|
||||
modified version for users who interact with it remotely over a network, your version must offer
|
||||
those users the opportunity to receive its Corresponding Source.
|
||||
|
||||
Official Ekklesia modules, published by the Ekklesia project, are licensed under `AGPL-3.0-or-later`
|
||||
as well. Each module's own repository declares its license. The module template declares it in its
|
||||
`package.json`, so a module started from the template begins with the same license.
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"name": "@ekklesia/api",
|
||||
"version": "0.1.2",
|
||||
"version": "0.2.5",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"files": [
|
||||
@@ -27,6 +28,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "2.5.3",
|
||||
"@ekklesia/design-tokens": "workspace:*",
|
||||
"@ekklesia/module-ui-host": "workspace:*",
|
||||
"@ekklesia/module-ui-sdk": "workspace:*",
|
||||
"@ekklesia/typescript-config": "workspace:*",
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
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;
|
||||
}
|
||||
+59
-11
@@ -1,6 +1,5 @@
|
||||
import {
|
||||
isAuthenticatedPrincipal,
|
||||
type ModuleRuntimeServices,
|
||||
type ModuleSecretSource,
|
||||
type RegisteredModuleHttpRoute,
|
||||
} from "@ekklesia/contracts";
|
||||
@@ -9,10 +8,12 @@ import type {
|
||||
AuthorizationStore,
|
||||
PermissionRegistry,
|
||||
} from "@ekklesia/core/authorization";
|
||||
import type { AccountStore } from "@ekklesia/core/account";
|
||||
import type { DashboardStore } from "@ekklesia/core/dashboard";
|
||||
import type { DeviceStore } from "@ekklesia/core/devices";
|
||||
import { type EkklesiaIdentityRuntime, IdentityRuntimeError } from "@ekklesia/core/identity";
|
||||
import type {
|
||||
AnyModuleRuntimeServices,
|
||||
DiscoveredModule,
|
||||
ModuleConfigurationLookup,
|
||||
ModuleConfigurationSettings,
|
||||
@@ -20,6 +21,7 @@ import type {
|
||||
ModuleLifecycleSnapshot,
|
||||
ModuleLifecycleStore,
|
||||
ModuleRealtimeRegistry,
|
||||
ModuleRuntimeServicesLookup,
|
||||
ModuleStorageLookup,
|
||||
ModuleUiPublicationRegistry,
|
||||
} from "@ekklesia/core/modules";
|
||||
@@ -27,10 +29,12 @@ import type { Context, MiddlewareHandler } from "hono";
|
||||
import { Hono } from "hono";
|
||||
import { cors } from "hono/cors";
|
||||
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 { createDashboardHostRoutes } from "./dashboard-host-routes.js";
|
||||
import { createDashboardRoutes } from "./dashboard-routes.js";
|
||||
import { createDeviceRoutes } from "./device-routes.js";
|
||||
import { createDashboardSettingsRoutes } from "./dashboard-settings-routes.js";
|
||||
import { createDeviceRoutes, renewDeviceSessionCookie } from "./device-routes.js";
|
||||
import type { DisplayHostAssets } from "./display-host-assets.js";
|
||||
import { createGrantsRoutes } from "./grants-routes.js";
|
||||
import { type ApiHealthOptions, createHealthRoutes } from "./health-routes.js";
|
||||
@@ -62,11 +66,10 @@ export interface CreateApiAppOptions {
|
||||
/** 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. */
|
||||
readonly moduleRealtime: ModuleRealtimeRegistry;
|
||||
/** 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`) — reference-identical, never a fresh
|
||||
* object built here, so a module's HTTP handlers and lifecycle hooks share exactly one
|
||||
* `ModuleDatabase` for the lifetime of this API runtime. */
|
||||
readonly moduleServices: ModuleRuntimeServices;
|
||||
/** The same per-module service lookup the caller's started module runtime used for every
|
||||
* module's `start`/`stop` hooks (`apps/api/src/server.ts`) — each handler receives its module's
|
||||
* reference-identical service object, never a fresh facade or executor built per request. */
|
||||
readonly moduleServices: ModuleRuntimeServicesLookup | AnyModuleRuntimeServices;
|
||||
/** The platform's module UI publication policy — always the one Core builds from the exact
|
||||
* discovered inventory this runtime started (`createModuleUiPublicationRegistry`,
|
||||
* `apps/api/src/server.ts`). Required rather than defaulted, for the same reason `moduleRoutes`
|
||||
@@ -140,6 +143,10 @@ export interface CreateApiAppOptions {
|
||||
* presentation only — nothing reachable through this store influences discovery, dependency
|
||||
* ordering, module startup, or any authorization decision. */
|
||||
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
|
||||
* than optional, unlike `displayHostAssets`: every installation has a Dashboard, and a missing
|
||||
@@ -363,10 +370,18 @@ export function createApiApp(options: CreateApiAppOptions): Hono {
|
||||
// own entirely. See `./health-routes.js`.
|
||||
app.route("/", createHealthRoutes(options.health));
|
||||
|
||||
// The Dashboard page and its one script, at exact literal paths outside `/api/` (ADR 0044). They
|
||||
// grant no authority and resolve no principal; the page asks `/api/v1/principal` itself. See
|
||||
// `./dashboard-host-routes.js`.
|
||||
app.route("/", createDashboardHostRoutes(options.dashboardHostAssets));
|
||||
// The Dashboard page and its one script, at fixed paths outside `/api/` (ADR 0044, ADR 0057): the
|
||||
// page at `/`, the Dashboard's platform pages, and every discovered module with a Dashboard UI at
|
||||
// `/<module-id>`. They grant no authority and resolve no principal; the page asks
|
||||
// `/api/v1/principal` itself. See `./dashboard-host-routes.js`.
|
||||
app.route(
|
||||
"/",
|
||||
createDashboardHostRoutes(options.dashboardHostAssets, {
|
||||
modulePages: options.moduleLifecycle.snapshot.discoveredModules
|
||||
.filter((module) => module.entrypoints.ui !== undefined)
|
||||
.map((module) => module.manifest.id),
|
||||
}),
|
||||
);
|
||||
|
||||
app.route(
|
||||
"/",
|
||||
@@ -407,6 +422,12 @@ export function createApiApp(options: CreateApiAppOptions): Hono {
|
||||
registry: options.authorization.registry,
|
||||
runtime,
|
||||
...(options.displayHostAssets ? { assets: options.displayHostAssets } : {}),
|
||||
...(options.devices
|
||||
? {
|
||||
renewDeviceCredential: (c: Context) =>
|
||||
renewDeviceSessionCookie(c, options.devices?.secureCookies ?? true),
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
|
||||
if (options.devices) {
|
||||
@@ -441,6 +462,33 @@ 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/`
|
||||
// belongs entirely to installed modules, so the platform's own lifecycle resource lives beside it
|
||||
// rather than inside it. See `./module-lifecycle-routes.js`.
|
||||
|
||||
@@ -3,7 +3,9 @@ import { readFile } from "node:fs/promises";
|
||||
import { dashboardHostAssetUrls } from "@ekklesia/shell/dashboard-assets";
|
||||
|
||||
/**
|
||||
* The two immutable, platform-owned files that make up the built Ekklesia Dashboard (ADR 0044).
|
||||
* The three 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,
|
||||
* from the exact URLs the surface package exports. Request input is never involved in this read, the
|
||||
@@ -16,6 +18,8 @@ export interface DashboardHostAssets {
|
||||
readonly htmlEtag: string;
|
||||
readonly script: string;
|
||||
readonly scriptEtag: string;
|
||||
readonly appearanceScript: string;
|
||||
readonly appearanceScriptEtag: string;
|
||||
}
|
||||
|
||||
/** A startup failure with a fixed, non-secret shape. The originating filesystem exception, package
|
||||
@@ -42,11 +46,21 @@ function strongEtag(text: string): string {
|
||||
* can never be paired with an ETag from another. Empty output is a failed shell build rather than a
|
||||
* valid page that can never render anything.
|
||||
*/
|
||||
export function createDashboardHostAssets(html: string, script: string): DashboardHostAssets {
|
||||
if (html.trim().length === 0 || script.trim().length === 0) {
|
||||
export function createDashboardHostAssets(
|
||||
html: string,
|
||||
script: string,
|
||||
appearanceScript: string,
|
||||
): DashboardHostAssets {
|
||||
if (
|
||||
html.trim().length === 0 ||
|
||||
script.trim().length === 0 ||
|
||||
appearanceScript.trim().length === 0
|
||||
) {
|
||||
throw new DashboardHostAssetError();
|
||||
}
|
||||
return Object.freeze({
|
||||
appearanceScript,
|
||||
appearanceScriptEtag: strongEtag(appearanceScript),
|
||||
html,
|
||||
htmlEtag: strongEtag(html),
|
||||
script,
|
||||
@@ -92,7 +106,7 @@ export function renderDashboardPage(html: string, apiOrigin: string): string {
|
||||
return parts.join(`<meta content="${escaped}" name="ekklesia-api-origin" />`);
|
||||
}
|
||||
|
||||
/** Loads only the two surface-declared files, and renders the API origin into the page. A missing,
|
||||
/** 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> {
|
||||
@@ -101,7 +115,12 @@ export async function loadDashboardHostAssets(apiOrigin: string): Promise<Dashbo
|
||||
// rejected sibling read is left running after this function has already failed.
|
||||
const html = await readFile(new URL(dashboardHostAssetUrls.html), "utf8");
|
||||
const script = await readFile(new URL(dashboardHostAssetUrls.script), "utf8");
|
||||
return createDashboardHostAssets(renderDashboardPage(html, apiOrigin), script);
|
||||
const appearanceScript = await readFile(new URL(dashboardHostAssetUrls.appearance), "utf8");
|
||||
return createDashboardHostAssets(
|
||||
renderDashboardPage(html, apiOrigin),
|
||||
script,
|
||||
appearanceScript,
|
||||
);
|
||||
} catch {
|
||||
throw new DashboardHostAssetError();
|
||||
}
|
||||
|
||||
@@ -1,16 +1,24 @@
|
||||
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 { etagMatches } from "./conditional-request.js";
|
||||
import { createDashboardHostAssets, type DashboardHostAssets } from "./dashboard-host-assets.js";
|
||||
|
||||
/**
|
||||
* The Dashboard's two host-owned routes (ADR 0044). Neither can collide with anything else the API
|
||||
* serves: `/` is the only page the Dashboard has — it has no client router, and every emailed
|
||||
* verification or reset link lands on `/` with a query (ADR 0038) — and the script lives beside, never
|
||||
* inside, the display host's `/assets/ekklesia-display/` namespace. Module routes all live under
|
||||
* `/api/v1/modules/<module-id>`.
|
||||
* The Dashboard's host-owned routes (ADR 0044, ADR 0057). None can collide with anything else the API
|
||||
* serves: the page answers at `/`, at the Dashboard's own platform pages (which the shell exports, so
|
||||
* this server and the shell's router read one list), and at each installed module's `/<module-id>`.
|
||||
* Every one of those first segments is either a reserved platform name or a module id Core accepted,
|
||||
* and Core refuses a module id that is a reserved name, so no module page can shadow `/api`,
|
||||
* `/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_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
|
||||
@@ -50,8 +58,12 @@ export class DashboardHostConfigurationError extends Error {
|
||||
* a composition can never pair bytes with a validator computed from different bytes. */
|
||||
function snapshotAssets(input: DashboardHostAssets): DashboardHostAssets {
|
||||
try {
|
||||
const snapshot = createDashboardHostAssets(input.html, input.script);
|
||||
if (snapshot.htmlEtag !== input.htmlEtag || snapshot.scriptEtag !== input.scriptEtag) {
|
||||
const snapshot = createDashboardHostAssets(input.html, input.script, input.appearanceScript);
|
||||
if (
|
||||
snapshot.htmlEtag !== input.htmlEtag ||
|
||||
snapshot.scriptEtag !== input.scriptEtag ||
|
||||
snapshot.appearanceScriptEtag !== input.appearanceScriptEtag
|
||||
) {
|
||||
throw new DashboardHostConfigurationError();
|
||||
}
|
||||
return snapshot;
|
||||
@@ -85,29 +97,73 @@ 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
|
||||
* default same-origin shape.
|
||||
*
|
||||
* Two exact literal routes over one frozen snapshot: never a static directory, never a path or file
|
||||
* name taken from the request, and never the source map. Any other path — `/index.html`, `/app.js`,
|
||||
* the `.map`, a traversal spelling — has no route and falls through to the application's ordinary
|
||||
* sanitized `404`. Like the display host's code, these bytes grant no authority: the page renders
|
||||
* the sign-in form until `/api/v1/principal` says otherwise.
|
||||
* Fixed routes over one frozen snapshot: never a static directory, never a path or file name taken
|
||||
* from the request, and never the source map. The page answers at `/`, anywhere inside a Dashboard
|
||||
* platform page (`/settings/displays/add` as much as `/settings`), and anywhere inside an installed
|
||||
* module's page (`/signage/timelines` as much as `/signage`, so the module's own sub-pages need no
|
||||
* server change later) — always with the **same** bytes and the same validator, because the path
|
||||
* 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(assets: DashboardHostAssets): Hono {
|
||||
export function createDashboardHostRoutes(
|
||||
assets: DashboardHostAssets,
|
||||
options: DashboardHostRouteOptions,
|
||||
): Hono {
|
||||
const snapshot = snapshotAssets(assets);
|
||||
const modulePages = validatedModulePages(options.modulePages);
|
||||
const routes = new Hono();
|
||||
|
||||
routes.get(DASHBOARD_HOST_PAGE_PATH, (c) =>
|
||||
const page = (ifNoneMatch: string | undefined): Response =>
|
||||
fixedAsset(
|
||||
c.req.header("if-none-match"),
|
||||
ifNoneMatch,
|
||||
snapshot.html,
|
||||
snapshot.htmlEtag,
|
||||
"text/html; charset=utf-8",
|
||||
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) =>
|
||||
fixedAsset(
|
||||
@@ -119,5 +175,17 @@ export function createDashboardHostRoutes(assets: DashboardHostAssets): Hono {
|
||||
),
|
||||
);
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
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,4 +1,5 @@
|
||||
import type { ModuleDatabase } from "@ekklesia/contracts";
|
||||
import type { ModuleDatabaseExecutor, ModuleId } from "@ekklesia/contracts";
|
||||
import { type AccountDrizzleDatabase, createAccountDrizzleView } from "@ekklesia/core/account";
|
||||
import {
|
||||
type AuthorizationDrizzleDatabase,
|
||||
createAuthorizationDrizzleView,
|
||||
@@ -12,18 +13,23 @@ import { createDeviceDrizzleView, type DeviceDrizzleDatabase } from "@ekklesia/c
|
||||
import { createIdentityDrizzleView, type IdentityDrizzleDatabase } from "@ekklesia/core/identity";
|
||||
import {
|
||||
createModuleConfigurationView,
|
||||
createModuleDatabaseExecutor,
|
||||
createModuleDatabaseView,
|
||||
createModuleLifecycleView,
|
||||
type ModuleConfigurationDrizzleDatabase,
|
||||
type ModuleDatabase,
|
||||
type ModuleLifecycleDrizzleDatabase,
|
||||
} from "@ekklesia/core/modules";
|
||||
|
||||
export interface ApiDatabaseHandle {
|
||||
readonly account: AccountDrizzleDatabase;
|
||||
readonly authorization: AuthorizationDrizzleDatabase;
|
||||
readonly devices: DeviceDrizzleDatabase;
|
||||
readonly dashboard: DashboardDrizzleDatabase;
|
||||
readonly identity: IdentityDrizzleDatabase;
|
||||
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 moduleLifecycle: ModuleLifecycleDrizzleDatabase;
|
||||
/**
|
||||
@@ -70,12 +76,14 @@ export function createApiDatabase(
|
||||
const { close, pool } = createValidatedPostgresPool(databaseUrl, options);
|
||||
|
||||
return Object.freeze({
|
||||
account: createAccountDrizzleView(pool),
|
||||
authorization: createAuthorizationDrizzleView(pool),
|
||||
close,
|
||||
devices: createDeviceDrizzleView(pool),
|
||||
dashboard: createDashboardDrizzleView(pool),
|
||||
identity: createIdentityDrizzleView(pool),
|
||||
module: createModuleDatabaseView(pool),
|
||||
moduleExecutor: (moduleId: ModuleId) => createModuleDatabaseExecutor(pool, { moduleId }),
|
||||
moduleConfiguration: createModuleConfigurationView(pool),
|
||||
moduleLifecycle: createModuleLifecycleView(pool),
|
||||
ping: async (statementTimeoutMs: number) => {
|
||||
|
||||
@@ -92,7 +92,7 @@ function readString(body: Record<string, unknown>, key: string, max: number): st
|
||||
*/
|
||||
function writeDeviceCookie(
|
||||
c: Context,
|
||||
options: DeviceRoutesOptions,
|
||||
options: Pick<DeviceRoutesOptions, "secureCookies">,
|
||||
name: string,
|
||||
value: string,
|
||||
maxAgeSeconds: number,
|
||||
@@ -106,6 +106,27 @@ function writeDeviceCookie(
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-issues the device cookie a request just authenticated with, with a fresh lifetime (ADR 0056).
|
||||
*
|
||||
* The value is unchanged — no rotation — so a renewal lost in transit costs nothing: the screen still
|
||||
* holds a working credential. Only the browser's own expiry is pushed forward, matching the
|
||||
* server's sliding window. Called only after the request resolved to a live, unrevoked device.
|
||||
*/
|
||||
export function renewDeviceSessionCookie(c: Context, secureCookies: boolean): void {
|
||||
const credential = getCookie(c, DEVICE_SESSION_COOKIE);
|
||||
if (credential === undefined || credential.length === 0) {
|
||||
return;
|
||||
}
|
||||
writeDeviceCookie(
|
||||
c,
|
||||
{ secureCookies },
|
||||
DEVICE_SESSION_COOKIE,
|
||||
credential,
|
||||
Math.floor(DEVICE_CREDENTIAL_TTL_MS / 1000),
|
||||
);
|
||||
}
|
||||
|
||||
export function createDeviceRoutes(options: DeviceRoutesOptions): Hono {
|
||||
const routes = new HonoApp();
|
||||
const now = options.now ?? ((): Date => new Date());
|
||||
|
||||
@@ -83,6 +83,9 @@ export interface ModuleDisplayRoutesOptions extends AuthorizationMiddlewareOptio
|
||||
/** Omitted only when the registry contains no display entrypoints. Production reads the exact
|
||||
* surface-owned files before binding and supplies their frozen contents here. */
|
||||
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,
|
||||
@@ -199,6 +202,11 @@ function mountDisplay(
|
||||
if (!viewer) {
|
||||
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);
|
||||
} catch {
|
||||
return displayFailure(c);
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
import {
|
||||
EMPTY_MODULE_CONFIGURATION,
|
||||
EMPTY_MODULE_STORAGE,
|
||||
type ModuleDatabaseExecutor,
|
||||
type ModuleId,
|
||||
type ModuleRouteHandlerContext,
|
||||
type ModuleRuntimeServices,
|
||||
type RegisteredModuleHttpRoute,
|
||||
} from "@ekklesia/contracts";
|
||||
import type {
|
||||
AnyModuleRuntimeServices,
|
||||
ModuleConfigurationLookup,
|
||||
ModuleDatabase,
|
||||
ModuleRealtimeRegistry,
|
||||
ModuleRuntimeServicesLookup,
|
||||
ModuleStorageLookup,
|
||||
} from "@ekklesia/core/modules";
|
||||
import type { Context, Hono } from "hono";
|
||||
@@ -37,14 +40,14 @@ export interface ModuleHttpRoutesOptions extends AuthorizationMiddlewareOptions
|
||||
* received for this API runtime — never a fresh one built per request, and never touched for a
|
||||
* denied, trusted-origin-rejected, or `OPTIONS` request, since a handler is only ever invoked
|
||||
* after authorization succeeds. */
|
||||
readonly services: ModuleRuntimeServices;
|
||||
readonly services: ModuleRuntimeServicesLookup | AnyModuleRuntimeServices;
|
||||
/**
|
||||
* 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
|
||||
* rather than two independently resolved values. A module with no entry receives
|
||||
* `EMPTY_MODULE_CONFIGURATION`, so `context.config` is always present.
|
||||
*
|
||||
* Unlike `services`, which is one shared object, this is keyed by module id: a handler can only
|
||||
* Like `services`, this is keyed by module id: a handler can only
|
||||
* ever reach its own module's configuration, because that is the only entry looked up for it.
|
||||
*/
|
||||
readonly configurations: ModuleConfigurationLookup;
|
||||
@@ -232,6 +235,11 @@ export function mountModuleHttpRoutes(
|
||||
if (!options.registry.has(route.permission)) {
|
||||
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);
|
||||
// The route's own conscious body contract (`route.maxBodyBytes` — 0 means no body accepted at
|
||||
@@ -243,19 +251,24 @@ export function mountModuleHttpRoutes(
|
||||
const routeBodyLimit = platformBodyLimit(route.maxBodyBytes, route.bodyMode);
|
||||
const path = moduleRoutePath(route.moduleId, route.path);
|
||||
const finalHandler = async (c: Context): Promise<Response> => {
|
||||
const context: ModuleRouteHandlerContext = Object.freeze({
|
||||
const context = Object.freeze({
|
||||
config: options.configurations.get(route.moduleId) ?? EMPTY_MODULE_CONFIGURATION,
|
||||
moduleId: route.moduleId,
|
||||
storage: options.storages.get(route.moduleId) ?? EMPTY_MODULE_STORAGE,
|
||||
params: Object.freeze({ ...c.req.param() }),
|
||||
principal: getAuthorizedPrincipal(c),
|
||||
realtime: options.realtime.publisherFor(route.moduleId),
|
||||
services: options.services,
|
||||
});
|
||||
services,
|
||||
}) as ModuleRouteHandlerContext<ModuleDatabase | ModuleDatabaseExecutor>;
|
||||
|
||||
let response: Response;
|
||||
try {
|
||||
response = await route.handler(c.req.raw, context);
|
||||
response = await (
|
||||
route.handler as (
|
||||
request: Request,
|
||||
context: ModuleRouteHandlerContext<ModuleDatabase | ModuleDatabaseExecutor>,
|
||||
) => PromiseLike<Response> | Response
|
||||
)(c.req.raw, context);
|
||||
} catch {
|
||||
return sanitizedModuleHandlerFailure(c);
|
||||
}
|
||||
|
||||
+18
-13
@@ -1,15 +1,12 @@
|
||||
import type { Socket } from "node:net";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import {
|
||||
EMPTY_MODULE_SECRET_SOURCE,
|
||||
type ModuleRuntimeServices,
|
||||
type ModuleSecretSource,
|
||||
} from "@ekklesia/contracts";
|
||||
import { EMPTY_MODULE_SECRET_SOURCE, type ModuleSecretSource } from "@ekklesia/contracts";
|
||||
import {
|
||||
createAuthorizationEvaluator,
|
||||
createAuthorizationStore,
|
||||
} from "@ekklesia/core/authorization";
|
||||
import { createAccountStore } from "@ekklesia/core/account";
|
||||
import { createDashboardStore } from "@ekklesia/core/dashboard";
|
||||
import { createDeviceStore } from "@ekklesia/core/devices";
|
||||
import { type HostEnvironment, loadHostConfiguration } from "@ekklesia/core/host";
|
||||
@@ -21,12 +18,14 @@ import {
|
||||
} from "@ekklesia/core/identity";
|
||||
import { createSmtpMailProvider, type MailProvider } from "@ekklesia/core/mail";
|
||||
import {
|
||||
type AnyModuleRuntimeServices,
|
||||
createModuleConfigurationStore,
|
||||
createModuleLifecycleStore,
|
||||
createModuleUiPublicationRegistry,
|
||||
type DiscoveredModule,
|
||||
type ModuleConfigurationSettings,
|
||||
type ModuleEligibilityResolution,
|
||||
type ModuleRuntimeServicesLookup,
|
||||
resolveModuleEligibility,
|
||||
type StartedModuleRuntime,
|
||||
startDiscoveredModuleRuntime,
|
||||
@@ -393,16 +392,13 @@ function bindServer(
|
||||
* (`prepareStartupModuleInventory`, `./platform-permissions.js`).
|
||||
* 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`).
|
||||
* 3. Construct the frozen `ModuleRuntimeServices` (currently just `database`) from that same pool,
|
||||
* then the Core identity runtime, authorization store, and evaluator.
|
||||
* 3. Construct the Core identity runtime, authorization store, and evaluator from that pool.
|
||||
* 4. Read the stored module enablement configuration and resolve, from that exact same discovered
|
||||
* inventory, which modules are eligible to run (`resolveModuleEligibility`).
|
||||
* 5. Start the module runtime from the eligible subset (`startDiscoveredModuleRuntime`) — never
|
||||
* rediscovering modules a second time — passing it the same services object every module's
|
||||
* `start`/`stop` hook receives.
|
||||
* rediscovering modules a second time — passing the manifest-selected service map.
|
||||
* 6. Create the Hono application, mounting the started module runtime's published routes, publishing
|
||||
* UI for that same eligible set, and passing the exact same services object to every module HTTP
|
||||
* handler.
|
||||
* UI for that same eligible set, and passing the same per-module service map to HTTP handlers.
|
||||
* 7. Bind the Node HTTP server.
|
||||
*
|
||||
* Discovery and permission composition happen before any pool, Better Auth instance, module
|
||||
@@ -459,12 +455,10 @@ export async function startApi(
|
||||
const storageProvider = config.storage ? createS3StorageProvider(config.storage) : undefined;
|
||||
const mailProvider = config.mail ? createSmtpMailProvider(config.mail) : undefined;
|
||||
|
||||
let services: ModuleRuntimeServices;
|
||||
let runtime: ReturnType<typeof createIdentityRuntime>;
|
||||
let authorizationStore: ReturnType<typeof createAuthorizationStore>;
|
||||
let deviceStore: ReturnType<typeof createDeviceStore>;
|
||||
try {
|
||||
services = Object.freeze({ database: databaseHandle.module });
|
||||
runtime = createIdentityRuntime({
|
||||
config: config.identity,
|
||||
db: databaseHandle.identity,
|
||||
@@ -532,6 +526,15 @@ export async function startApi(
|
||||
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
|
||||
// declares. Built from the eligible set so a blocked module never receives storage at all.
|
||||
const moduleStorages = createModuleStorageLookup(
|
||||
@@ -637,6 +640,8 @@ export async function startApi(
|
||||
},
|
||||
// A typed view over the same single pool as identity, authorization, and the module facade.
|
||||
dashboard: createDashboardStore(databaseHandle.dashboard),
|
||||
// The same pool again: a person's own account summary and preferences (ADR 0062).
|
||||
account: createAccountStore(databaseHandle.account),
|
||||
dashboardHostAssets,
|
||||
// The app is only composed after `startDiscoveredModuleRuntime` resolved, so module startup
|
||||
// has completed whenever it can answer; it stops counting as complete once shutdown begins.
|
||||
|
||||
@@ -0,0 +1,340 @@
|
||||
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");
|
||||
});
|
||||
});
|
||||
@@ -208,6 +208,29 @@ 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 }> {
|
||||
const name = makeDatabaseName(label);
|
||||
await createDatabase(name);
|
||||
@@ -221,9 +244,9 @@ async function applyCoreMigrations(url: string): Promise<void> {
|
||||
const aggregate = await discoverAllMigrations({
|
||||
coreMigrationsDirectory,
|
||||
moduleDiscovery: {
|
||||
moduleSdkVersion: "0.1.0",
|
||||
moduleSdkVersion: "0.2.0",
|
||||
modulesDirectory: emptyModulesDirectory,
|
||||
platformVersion: "0.1.0",
|
||||
platformVersion: "0.2.0",
|
||||
},
|
||||
});
|
||||
// Phase 4A's and Phase 4C pass 1's committed migrations must not have drifted while building
|
||||
@@ -237,6 +260,7 @@ async function applyCoreMigrations(url: string): Promise<void> {
|
||||
"core:0005_module_configuration",
|
||||
"core:0006_device_identity",
|
||||
"core:0007_verified_bootstrap_owners",
|
||||
"core:0008_user_preferences",
|
||||
]);
|
||||
expect(aggregate.migrations[0]?.checksum).toBe(
|
||||
"f8f2086ef06f7a6943e8ffc4350bb010b37aed04c0a72030d2b153c986b1325f",
|
||||
@@ -339,10 +363,10 @@ async function createGatedRouteModule(
|
||||
writeFile(
|
||||
path.join(moduleDirectory, "ekklesia.module.json"),
|
||||
JSON.stringify({
|
||||
compatibility: { moduleSdk: "^0.1.0", platform: "^0.1.0" },
|
||||
compatibility: { moduleSdk: "^0.2.0", platform: "^0.2.0" },
|
||||
entrypoints: { server: "./dist/server/index.js" },
|
||||
id,
|
||||
manifestVersion: 7,
|
||||
manifestVersion: 8,
|
||||
name: `${id} module`,
|
||||
permissions: [{ baseline: "public", description: "Gated drain probe.", id: permissionId }],
|
||||
version: "0.1.0",
|
||||
@@ -386,11 +410,11 @@ async function createFixtureModule(
|
||||
): Promise<void> {
|
||||
const moduleDirectory = path.join(modulesDirectory, id);
|
||||
const manifest = {
|
||||
manifestVersion: 7,
|
||||
manifestVersion: 8,
|
||||
id,
|
||||
name: `${id} module`,
|
||||
version: "0.1.0",
|
||||
compatibility: { platform: "^0.1.0", moduleSdk: "^0.1.0" },
|
||||
compatibility: { platform: "^0.2.0", moduleSdk: "^0.2.0" },
|
||||
permissions: [],
|
||||
entrypoints: { server: "./dist/server/index.js" },
|
||||
...manifestOverrides,
|
||||
@@ -521,10 +545,10 @@ async function createUiModule(
|
||||
writeFile(
|
||||
path.join(moduleDirectory, "ekklesia.module.json"),
|
||||
JSON.stringify({
|
||||
compatibility: { moduleSdk: "^0.1.0", platform: "^0.1.0" },
|
||||
compatibility: { moduleSdk: "^0.2.0", platform: "^0.2.0" },
|
||||
entrypoints: { server: "./dist/server/index.js", ui: "./dist/ui/index.js" },
|
||||
id,
|
||||
manifestVersion: 7,
|
||||
manifestVersion: 8,
|
||||
name: `${id} module`,
|
||||
version,
|
||||
}),
|
||||
@@ -1349,7 +1373,7 @@ describe.sequential("live API — signup, sign-in, principal, sign-out", () => {
|
||||
await running.shutdown();
|
||||
|
||||
await expect(fetch(origin, { signal: AbortSignal.timeout(1_000) })).rejects.toBeTruthy();
|
||||
const remainingConnections = await activeConnectionCount(database.name);
|
||||
const remainingConnections = await connectionsLeftAfterShutdown(database.name);
|
||||
expect(remainingConnections).toBe(0);
|
||||
|
||||
await dropDatabase(database.name);
|
||||
@@ -1498,6 +1522,162 @@ 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", () => {
|
||||
it("refuses to start against an unmigrated database, and creates no tables or migration history", async () => {
|
||||
const database = await createDisposableDatabase("unmigrated");
|
||||
@@ -2230,7 +2410,7 @@ describe.sequential("live API — platform owner and delegated grant management"
|
||||
|
||||
it("shuts down cleanly, closing the one shared pool", async () => {
|
||||
await running.shutdown();
|
||||
const remainingConnections = await activeConnectionCount(database.name);
|
||||
const remainingConnections = await connectionsLeftAfterShutdown(database.name);
|
||||
expect(remainingConnections).toBe(0);
|
||||
await dropDatabase(database.name);
|
||||
});
|
||||
@@ -2592,9 +2772,9 @@ describe.sequential("live API — installed module permission end-to-end", () =>
|
||||
const report = await diagnoseEkklesia({
|
||||
coreMigrationsDirectory,
|
||||
moduleDiscovery: {
|
||||
moduleSdkVersion: "0.1.0",
|
||||
moduleSdkVersion: "0.2.0",
|
||||
modulesDirectory,
|
||||
platformVersion: "0.1.0",
|
||||
platformVersion: "0.2.0",
|
||||
},
|
||||
runtimeRange: ">=0.0.0",
|
||||
runtimeVersion: process.versions.node,
|
||||
@@ -2607,10 +2787,11 @@ describe.sequential("live API — installed module permission end-to-end", () =>
|
||||
expect(moduleCheck).toMatchObject({ status: "pass" });
|
||||
expect(moduleCheck?.details).toEqual(["calendar 0.1.0"]);
|
||||
expect(permissionsCheck).toMatchObject({
|
||||
// Core's own six (`core.authorization.grants.manage`, `core.modules.ui.read`,
|
||||
// Core's own seven (`core.authorization.grants.manage`, `core.modules.ui.read`,
|
||||
// `core.dashboard.navigation.manage`, `core.modules.lifecycle.manage`,
|
||||
// `core.modules.configuration.manage`, `core.devices.manage`) plus the fixture module's one.
|
||||
details: ["7 permissions registered"],
|
||||
// `core.modules.configuration.manage`, `core.devices.manage`, `core.account.self.manage`)
|
||||
// plus the fixture module's one.
|
||||
details: ["8 permissions registered"],
|
||||
status: "pass",
|
||||
});
|
||||
// Doctor and permission composition read only the manifest — the fixture's register/start
|
||||
@@ -2837,7 +3018,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 () => {
|
||||
await running.shutdown();
|
||||
const remainingConnections = await activeConnectionCount(database.name);
|
||||
const remainingConnections = await connectionsLeftAfterShutdown(database.name);
|
||||
expect(remainingConnections).toBe(0);
|
||||
// Exactly one stop, matching the exactly-one register/start already asserted above.
|
||||
expect(recorder.events.filter((event) => event === "calendar:stop")).toHaveLength(1);
|
||||
@@ -3906,9 +4087,9 @@ describe.sequential("live API — module lifecycle governs what actually runs",
|
||||
const aggregate = await discoverAllMigrations({
|
||||
coreMigrationsDirectory,
|
||||
moduleDiscovery: {
|
||||
moduleSdkVersion: "0.1.0",
|
||||
moduleSdkVersion: "0.2.0",
|
||||
modulesDirectory,
|
||||
platformVersion: "0.1.0",
|
||||
platformVersion: "0.2.0",
|
||||
},
|
||||
});
|
||||
expect(aggregate.discoveredModules.map((module) => module.manifest.id)).toEqual(["calendar"]);
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import {
|
||||
ANONYMOUS_PRINCIPAL,
|
||||
createAuthenticatedPrincipal,
|
||||
type ModuleDatabase,
|
||||
type ModuleRuntimeServices,
|
||||
moduleIdSchema,
|
||||
parseEkklesiaUserId,
|
||||
parsePermissionId,
|
||||
@@ -20,6 +18,8 @@ import { IdentityRuntimeError } from "@ekklesia/core/identity";
|
||||
import {
|
||||
createModuleUiPublicationRegistry,
|
||||
EMPTY_MODULE_REALTIME_REGISTRY,
|
||||
type LegacyModuleRuntimeServices,
|
||||
type ModuleDatabase,
|
||||
type ModuleUiPublicationRegistry,
|
||||
StagedModuleRealtimeRegistry,
|
||||
} from "@ekklesia/core/modules";
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
createApiApp as createApiAppWithModules,
|
||||
} from "../src/app.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 { testDashboardHostAssets } from "./support/dashboard-host-assets.js";
|
||||
import { readyHealth } from "./support/ready-health.js";
|
||||
@@ -48,9 +49,9 @@ function fakeRuntime(overrides: Partial<EkklesiaIdentityRuntime> = {}): Ekklesia
|
||||
};
|
||||
}
|
||||
|
||||
/** A small typed fake `ModuleRuntimeServices` — these tests exercise `createApiApp`'s own wiring,
|
||||
/** A small typed fake `LegacyModuleRuntimeServices` — these tests exercise `createApiApp`'s own wiring,
|
||||
* never real database behavior, so no PostgreSQL is required. */
|
||||
function fakeModuleServices(): ModuleRuntimeServices {
|
||||
function fakeModuleServices(): LegacyModuleRuntimeServices {
|
||||
const database = Object.freeze({
|
||||
delete: vi.fn(),
|
||||
execute: vi.fn(),
|
||||
@@ -111,6 +112,7 @@ function createAuthWiringApp(runtime: EkklesiaIdentityRuntime) {
|
||||
moduleLifecycle: emptyModuleLifecycle(),
|
||||
moduleConfiguration: emptyModuleConfiguration(),
|
||||
moduleStorage: emptyModuleStorage(),
|
||||
account: createInMemoryAccountStore(),
|
||||
dashboard: createInMemoryDashboardStore(),
|
||||
dashboardHostAssets: testDashboardHostAssets(),
|
||||
health: readyHealth(),
|
||||
@@ -130,6 +132,7 @@ describe("Ekklesia API Hono application", () => {
|
||||
moduleLifecycle: emptyModuleLifecycle(),
|
||||
moduleConfiguration: emptyModuleConfiguration(),
|
||||
moduleStorage: emptyModuleStorage(),
|
||||
account: createInMemoryAccountStore(),
|
||||
dashboard: createInMemoryDashboardStore(),
|
||||
dashboardHostAssets: testDashboardHostAssets(),
|
||||
health: readyHealth(),
|
||||
@@ -366,6 +369,7 @@ describe("Ekklesia API Hono application", () => {
|
||||
moduleLifecycle: emptyModuleLifecycle(),
|
||||
moduleConfiguration: emptyModuleConfiguration(),
|
||||
moduleStorage: emptyModuleStorage(),
|
||||
account: createInMemoryAccountStore(),
|
||||
dashboard: createInMemoryDashboardStore(),
|
||||
dashboardHostAssets: testDashboardHostAssets(),
|
||||
health: readyHealth(),
|
||||
@@ -391,6 +395,7 @@ describe("Ekklesia API Hono application", () => {
|
||||
moduleLifecycle: emptyModuleLifecycle(),
|
||||
moduleConfiguration: emptyModuleConfiguration(),
|
||||
moduleStorage: emptyModuleStorage(),
|
||||
account: createInMemoryAccountStore(),
|
||||
dashboard: createInMemoryDashboardStore(),
|
||||
dashboardHostAssets: testDashboardHostAssets(),
|
||||
health: readyHealth(),
|
||||
@@ -418,6 +423,7 @@ describe("Ekklesia API Hono application", () => {
|
||||
moduleLifecycle: emptyModuleLifecycle(),
|
||||
moduleConfiguration: emptyModuleConfiguration(),
|
||||
moduleStorage: emptyModuleStorage(),
|
||||
account: createInMemoryAccountStore(),
|
||||
dashboard: createInMemoryDashboardStore(),
|
||||
dashboardHostAssets: testDashboardHostAssets(),
|
||||
health: readyHealth(),
|
||||
@@ -447,6 +453,7 @@ describe("Ekklesia API Hono application", () => {
|
||||
moduleLifecycle: emptyModuleLifecycle(),
|
||||
moduleConfiguration: emptyModuleConfiguration(),
|
||||
moduleStorage: emptyModuleStorage(),
|
||||
account: createInMemoryAccountStore(),
|
||||
dashboard: createInMemoryDashboardStore(),
|
||||
dashboardHostAssets: testDashboardHostAssets(),
|
||||
health: readyHealth(),
|
||||
@@ -476,6 +483,7 @@ describe("Ekklesia API Hono application", () => {
|
||||
moduleLifecycle: emptyModuleLifecycle(),
|
||||
moduleConfiguration: emptyModuleConfiguration(),
|
||||
moduleStorage: emptyModuleStorage(),
|
||||
account: createInMemoryAccountStore(),
|
||||
dashboard: createInMemoryDashboardStore(),
|
||||
dashboardHostAssets: testDashboardHostAssets(),
|
||||
health: readyHealth(),
|
||||
@@ -502,6 +510,7 @@ describe("Ekklesia API Hono application", () => {
|
||||
moduleLifecycle: emptyModuleLifecycle(),
|
||||
moduleConfiguration: emptyModuleConfiguration(),
|
||||
moduleStorage: emptyModuleStorage(),
|
||||
account: createInMemoryAccountStore(),
|
||||
dashboard: createInMemoryDashboardStore(),
|
||||
dashboardHostAssets: testDashboardHostAssets(),
|
||||
health: readyHealth(),
|
||||
@@ -530,6 +539,7 @@ describe("Ekklesia API Hono application", () => {
|
||||
moduleLifecycle: emptyModuleLifecycle(),
|
||||
moduleConfiguration: emptyModuleConfiguration(),
|
||||
moduleStorage: emptyModuleStorage(),
|
||||
account: createInMemoryAccountStore(),
|
||||
dashboard: createInMemoryDashboardStore(),
|
||||
dashboardHostAssets: testDashboardHostAssets(),
|
||||
health: readyHealth(),
|
||||
@@ -555,6 +565,7 @@ describe("Ekklesia API Hono application", () => {
|
||||
moduleLifecycle: emptyModuleLifecycle(),
|
||||
moduleConfiguration: emptyModuleConfiguration(),
|
||||
moduleStorage: emptyModuleStorage(),
|
||||
account: createInMemoryAccountStore(),
|
||||
dashboard: createInMemoryDashboardStore(),
|
||||
dashboardHostAssets: testDashboardHostAssets(),
|
||||
health: readyHealth(),
|
||||
@@ -582,6 +593,7 @@ describe("Ekklesia API Hono application", () => {
|
||||
moduleLifecycle: emptyModuleLifecycle(),
|
||||
moduleConfiguration: emptyModuleConfiguration(),
|
||||
moduleStorage: emptyModuleStorage(),
|
||||
account: createInMemoryAccountStore(),
|
||||
dashboard: createInMemoryDashboardStore(),
|
||||
dashboardHostAssets: testDashboardHostAssets(),
|
||||
health: readyHealth(),
|
||||
@@ -616,6 +628,7 @@ describe("Ekklesia API Hono application", () => {
|
||||
moduleLifecycle: emptyModuleLifecycle(),
|
||||
moduleConfiguration: emptyModuleConfiguration(),
|
||||
moduleStorage: emptyModuleStorage(),
|
||||
account: createInMemoryAccountStore(),
|
||||
dashboard: createInMemoryDashboardStore(),
|
||||
dashboardHostAssets: testDashboardHostAssets(),
|
||||
health: readyHealth(),
|
||||
@@ -651,6 +664,7 @@ describe("Ekklesia API Hono application", () => {
|
||||
moduleLifecycle: emptyModuleLifecycle(),
|
||||
moduleConfiguration: emptyModuleConfiguration(),
|
||||
moduleStorage: emptyModuleStorage(),
|
||||
account: createInMemoryAccountStore(),
|
||||
dashboard: createInMemoryDashboardStore(),
|
||||
dashboardHostAssets: testDashboardHostAssets(),
|
||||
health: readyHealth(),
|
||||
@@ -691,6 +705,7 @@ describe("Ekklesia API Hono application", () => {
|
||||
moduleLifecycle: emptyModuleLifecycle(),
|
||||
moduleConfiguration: emptyModuleConfiguration(),
|
||||
moduleStorage: emptyModuleStorage(),
|
||||
account: createInMemoryAccountStore(),
|
||||
dashboard: createInMemoryDashboardStore(),
|
||||
dashboardHostAssets: testDashboardHostAssets(),
|
||||
health: readyHealth(),
|
||||
@@ -713,6 +728,7 @@ describe("Ekklesia API Hono application", () => {
|
||||
moduleLifecycle: emptyModuleLifecycle(),
|
||||
moduleConfiguration: emptyModuleConfiguration(),
|
||||
moduleStorage: emptyModuleStorage(),
|
||||
account: createInMemoryAccountStore(),
|
||||
dashboard: createInMemoryDashboardStore(),
|
||||
dashboardHostAssets: testDashboardHostAssets(),
|
||||
health: readyHealth(),
|
||||
@@ -737,6 +753,7 @@ describe("Ekklesia API Hono application", () => {
|
||||
moduleLifecycle: emptyModuleLifecycle(),
|
||||
moduleConfiguration: emptyModuleConfiguration(),
|
||||
moduleStorage: emptyModuleStorage(),
|
||||
account: createInMemoryAccountStore(),
|
||||
dashboard: createInMemoryDashboardStore(),
|
||||
dashboardHostAssets: testDashboardHostAssets(),
|
||||
health: readyHealth(),
|
||||
@@ -760,6 +777,7 @@ describe("Ekklesia API Hono application", () => {
|
||||
moduleLifecycle: emptyModuleLifecycle(),
|
||||
moduleConfiguration: emptyModuleConfiguration(),
|
||||
moduleStorage: emptyModuleStorage(),
|
||||
account: createInMemoryAccountStore(),
|
||||
dashboard: createInMemoryDashboardStore(),
|
||||
dashboardHostAssets: testDashboardHostAssets(),
|
||||
health: readyHealth(),
|
||||
@@ -781,6 +799,7 @@ describe("Ekklesia API Hono application", () => {
|
||||
moduleLifecycle: emptyModuleLifecycle(),
|
||||
moduleConfiguration: emptyModuleConfiguration(),
|
||||
moduleStorage: emptyModuleStorage(),
|
||||
account: createInMemoryAccountStore(),
|
||||
dashboard: createInMemoryDashboardStore(),
|
||||
dashboardHostAssets: testDashboardHostAssets(),
|
||||
health: readyHealth(),
|
||||
@@ -809,6 +828,7 @@ describe("Ekklesia API Hono application", () => {
|
||||
moduleLifecycle: emptyModuleLifecycle(),
|
||||
moduleConfiguration: emptyModuleConfiguration(),
|
||||
moduleStorage: emptyModuleStorage(),
|
||||
account: createInMemoryAccountStore(),
|
||||
dashboard: createInMemoryDashboardStore(),
|
||||
dashboardHostAssets: testDashboardHostAssets(),
|
||||
health: readyHealth(),
|
||||
@@ -840,6 +860,7 @@ describe("Ekklesia API Hono application", () => {
|
||||
moduleLifecycle: emptyModuleLifecycle(),
|
||||
moduleConfiguration: emptyModuleConfiguration(),
|
||||
moduleStorage: emptyModuleStorage(),
|
||||
account: createInMemoryAccountStore(),
|
||||
dashboard: createInMemoryDashboardStore(),
|
||||
dashboardHostAssets: testDashboardHostAssets(),
|
||||
health: readyHealth(),
|
||||
@@ -874,6 +895,7 @@ describe("Ekklesia API Hono application", () => {
|
||||
moduleLifecycle: emptyModuleLifecycle(),
|
||||
moduleConfiguration: emptyModuleConfiguration(),
|
||||
moduleStorage: emptyModuleStorage(),
|
||||
account: createInMemoryAccountStore(),
|
||||
dashboard: createInMemoryDashboardStore(),
|
||||
dashboardHostAssets: testDashboardHostAssets(),
|
||||
health: readyHealth(),
|
||||
@@ -899,6 +921,7 @@ describe("Ekklesia API Hono application", () => {
|
||||
moduleLifecycle: emptyModuleLifecycle(),
|
||||
moduleConfiguration: emptyModuleConfiguration(),
|
||||
moduleStorage: emptyModuleStorage(),
|
||||
account: createInMemoryAccountStore(),
|
||||
dashboard: createInMemoryDashboardStore(),
|
||||
dashboardHostAssets: testDashboardHostAssets(),
|
||||
health: readyHealth(),
|
||||
@@ -938,6 +961,7 @@ describe("split Dashboard and API origins (ADR 0045)", () => {
|
||||
moduleConfiguration: emptyModuleConfiguration(),
|
||||
moduleStorage: emptyModuleStorage(),
|
||||
moduleRealtime,
|
||||
account: createInMemoryAccountStore(),
|
||||
dashboard: createInMemoryDashboardStore(),
|
||||
dashboardHostAssets: testDashboardHostAssets(),
|
||||
health: readyHealth(),
|
||||
|
||||
@@ -8,6 +8,7 @@ vi.mock("@ekklesia/shell/dashboard-assets", () => ({
|
||||
dashboardHostAssetUrls: Object.freeze({
|
||||
html: "file:///surface/browser/index.html",
|
||||
script: "file:///surface/browser/assets/ekklesia-dashboard/app.js",
|
||||
appearance: "file:///surface/browser/assets/ekklesia-dashboard/appearance.js",
|
||||
}),
|
||||
}));
|
||||
|
||||
@@ -28,19 +29,26 @@ function sha256Etag(text: string): string {
|
||||
describe("Dashboard host asset loading", () => {
|
||||
beforeEach(() => readFileMock.mockReset());
|
||||
|
||||
it("reads only the two declared file URLs in order and freezes one consistent snapshot", async () => {
|
||||
it("reads only the three declared file URLs in order and freezes one consistent snapshot", async () => {
|
||||
const html = `<!doctype html>${DASHBOARD_API_ORIGIN_PLACEHOLDER}<div id="ekklesia-host"></div>`;
|
||||
const script = "export const dashboardHost = true;\n";
|
||||
readFileMock.mockResolvedValueOnce(html).mockResolvedValueOnce(script);
|
||||
const appearanceScript = "(() => {})();\n";
|
||||
readFileMock
|
||||
.mockResolvedValueOnce(html)
|
||||
.mockResolvedValueOnce(script)
|
||||
.mockResolvedValueOnce(appearanceScript);
|
||||
|
||||
const assets = await loadDashboardHostAssets(API_ORIGIN);
|
||||
|
||||
expect(readFileMock.mock.calls).toEqual([
|
||||
[new URL("file:///surface/browser/index.html"), "utf8"],
|
||||
[new URL("file:///surface/browser/assets/ekklesia-dashboard/app.js"), "utf8"],
|
||||
[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({
|
||||
appearanceScript,
|
||||
appearanceScriptEtag: sha256Etag(appearanceScript),
|
||||
html: rendered,
|
||||
htmlEtag: sha256Etag(rendered),
|
||||
script,
|
||||
@@ -83,12 +91,27 @@ describe("Dashboard host asset loading", () => {
|
||||
expect(readFileMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("rejects an empty page or script as an unavailable shell build", () => {
|
||||
for (const [html, script] of [
|
||||
["", "export {};"],
|
||||
["<html></html>", " \n"],
|
||||
it("fails when the appearance script is missing even though the page and script were read", async () => {
|
||||
readFileMock
|
||||
.mockResolvedValueOnce(`<!doctype html>${DASHBOARD_API_ORIGIN_PLACEHOLDER}`)
|
||||
.mockResolvedValueOnce("export {};\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) {
|
||||
expect(() => createDashboardHostAssets(html, script)).toThrow(DashboardHostAssetError);
|
||||
expect(() => createDashboardHostAssets(html, script, appearance)).toThrow(
|
||||
DashboardHostAssetError,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -99,7 +122,10 @@ describe("Dashboard host asset loading", () => {
|
||||
'<!doctype html><meta content="https://elsewhere.test" name="ekklesia-api-origin" />',
|
||||
]) {
|
||||
readFileMock.mockReset();
|
||||
readFileMock.mockResolvedValueOnce(html).mockResolvedValueOnce("export {};\n");
|
||||
readFileMock
|
||||
.mockResolvedValueOnce(html)
|
||||
.mockResolvedValueOnce("export {};\n")
|
||||
.mockResolvedValueOnce("(() => {})();\n");
|
||||
await expect(loadDashboardHostAssets(API_ORIGIN)).rejects.toBeInstanceOf(
|
||||
DashboardHostAssetError,
|
||||
);
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import {
|
||||
ANONYMOUS_PRINCIPAL,
|
||||
type ModuleDatabase,
|
||||
type ModuleRouteHandler,
|
||||
type ModuleRuntimeServices,
|
||||
parseModuleManifest,
|
||||
parsePermissionId,
|
||||
type RegisteredModuleHttpRoute,
|
||||
} from "@ekklesia/contracts";
|
||||
@@ -15,7 +14,15 @@ import {
|
||||
import type { EkklesiaIdentityRuntime } from "@ekklesia/core/identity";
|
||||
import {
|
||||
createModuleUiPublicationRegistry,
|
||||
type DiscoveredModule,
|
||||
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";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
@@ -31,13 +38,27 @@ import {
|
||||
} from "../src/dashboard-host-assets.js";
|
||||
import {
|
||||
DASHBOARD_HOST_PAGE_PATH,
|
||||
DASHBOARD_HOST_APPEARANCE_SCRIPT_PATH,
|
||||
DASHBOARD_HOST_SCRIPT_PATH,
|
||||
DashboardHostConfigurationError,
|
||||
createDashboardHostRoutes,
|
||||
} from "../src/dashboard-host-routes.js";
|
||||
import { DISPLAY_HOST_SCRIPT_PATH } from "../src/module-display-routes.js";
|
||||
import { DASHBOARD_PLATFORM_PAGES, SETTINGS_SECTIONS } from "@ekklesia/shell/dashboard-routes";
|
||||
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 { emptyModuleConfiguration } from "./support/in-memory-module-configuration.js";
|
||||
import { emptyModuleLifecycle } from "./support/in-memory-module-lifecycle.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";
|
||||
|
||||
@@ -54,6 +75,8 @@ function sha256Etag(text: string): string {
|
||||
|
||||
const PAGE_ETAG = sha256Etag(PAGE_HTML);
|
||||
const SCRIPT_ETAG = sha256Etag(SCRIPT);
|
||||
const APPEARANCE_SCRIPT = "(() => { /* pre-paint appearance */ })();\n";
|
||||
const APPEARANCE_SCRIPT_ETAG = sha256Etag(APPEARANCE_SCRIPT);
|
||||
|
||||
function fakeRuntime(): EkklesiaIdentityRuntime {
|
||||
return {
|
||||
@@ -74,13 +97,44 @@ function fakeStore(): AuthorizationStore {
|
||||
};
|
||||
}
|
||||
|
||||
function fakeModuleServices(): ModuleRuntimeServices {
|
||||
function fakeModuleServices(): LegacyModuleRuntimeServices {
|
||||
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(
|
||||
options: {
|
||||
readonly assets?: DashboardHostAssets;
|
||||
readonly configuration?: ModuleLifecycleConfiguration;
|
||||
readonly discovered?: readonly DiscoveredModule[];
|
||||
readonly moduleRoutes?: readonly RegisteredModuleHttpRoute[];
|
||||
} = {},
|
||||
) {
|
||||
@@ -100,11 +154,16 @@ function buildApp(
|
||||
moduleRealtime: EMPTY_MODULE_REALTIME_REGISTRY,
|
||||
moduleServices: fakeModuleServices(),
|
||||
moduleUi: createModuleUiPublicationRegistry([]),
|
||||
moduleLifecycle: emptyModuleLifecycle(),
|
||||
moduleLifecycle: {
|
||||
snapshot: moduleLifecycleSnapshotFor(options.discovered ?? [], options.configuration),
|
||||
store: createInMemoryModuleLifecycleStore(),
|
||||
},
|
||||
moduleConfiguration: emptyModuleConfiguration(),
|
||||
moduleStorage: emptyModuleStorage(),
|
||||
account: createInMemoryAccountStore(),
|
||||
dashboard: createInMemoryDashboardStore(),
|
||||
dashboardHostAssets: options.assets ?? createDashboardHostAssets(PAGE_HTML, SCRIPT),
|
||||
dashboardHostAssets:
|
||||
options.assets ?? createDashboardHostAssets(PAGE_HTML, SCRIPT, APPEARANCE_SCRIPT),
|
||||
health: readyHealth(),
|
||||
runtime,
|
||||
trustedOrigins: [trustedOrigin],
|
||||
@@ -193,6 +252,182 @@ 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)", () => {
|
||||
it("serves the exact startup script bytes as JavaScript with revalidation and a strong ETag", async () => {
|
||||
const { app } = buildApp();
|
||||
@@ -231,8 +466,45 @@ 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", () => {
|
||||
it("serves nothing but its two exact paths, answering every other spelling with the sanitized 404", async () => {
|
||||
it("serves nothing but its exact paths, answering every other spelling with the sanitized 404", async () => {
|
||||
const { app } = buildApp();
|
||||
|
||||
for (const path of [
|
||||
@@ -246,6 +518,10 @@ describe("Dashboard host boundaries", () => {
|
||||
"/assets/ekklesia-dashboard/index.html",
|
||||
"/assets/",
|
||||
"/assets/ekklesia-dashboard/APP.JS",
|
||||
"/assets/ekklesia-dashboard/appearance.js.map",
|
||||
"/assets/ekklesia-dashboard/appearance.js/",
|
||||
"/assets/ekklesia-dashboard/APPEARANCE.JS",
|
||||
"/appearance.js",
|
||||
"/dashboard",
|
||||
"/login",
|
||||
]) {
|
||||
@@ -334,12 +610,21 @@ describe("Dashboard host boundaries", () => {
|
||||
});
|
||||
|
||||
it("refuses at composition a snapshot whose validator does not match its bytes, or empty bytes", () => {
|
||||
const consistent = createDashboardHostAssets(PAGE_HTML, SCRIPT);
|
||||
const consistent = createDashboardHostAssets(PAGE_HTML, SCRIPT, APPEARANCE_SCRIPT);
|
||||
|
||||
for (const assets of [
|
||||
{ ...consistent, html: `${PAGE_HTML}<!-- tampered -->` },
|
||||
{ ...consistent, scriptEtag: PAGE_ETAG },
|
||||
{ html: "", htmlEtag: sha256Etag(""), script: SCRIPT, scriptEtag: SCRIPT_ETAG },
|
||||
{ ...consistent, appearanceScriptEtag: 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);
|
||||
}
|
||||
|
||||
@@ -5,8 +5,6 @@ import {
|
||||
ANONYMOUS_PRINCIPAL,
|
||||
createAuthenticatedPrincipal,
|
||||
type EkklesiaPrincipal,
|
||||
type ModuleDatabase,
|
||||
type ModuleRuntimeServices,
|
||||
parseEkklesiaUserId,
|
||||
} from "@ekklesia/contracts";
|
||||
import {
|
||||
@@ -20,12 +18,15 @@ import {
|
||||
createModuleUiPublicationRegistry,
|
||||
discoverModules,
|
||||
EMPTY_MODULE_REALTIME_REGISTRY,
|
||||
type LegacyModuleRuntimeServices,
|
||||
type ModuleDatabase,
|
||||
} from "@ekklesia/core/modules";
|
||||
import type { Hono } from "hono";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { createApiApp } from "../src/app.js";
|
||||
import { DASHBOARD_NAVIGATION_PATH } from "../src/dashboard-routes.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 { testDashboardHostAssets } from "./support/dashboard-host-assets.js";
|
||||
import { readyHealth } from "./support/ready-health.js";
|
||||
@@ -77,7 +78,7 @@ async function createModule(modulesDirectory: string, fixture: ModuleFixture): P
|
||||
...(fixture.withUi === false ? {} : { ui: "./dist/ui/index.js" }),
|
||||
},
|
||||
id: fixture.id,
|
||||
manifestVersion: 7,
|
||||
manifestVersion: 8,
|
||||
name: fixture.name,
|
||||
version: "1.0.0",
|
||||
}),
|
||||
@@ -140,6 +141,7 @@ async function install(options: InstallOptions = {}): Promise<{ app: Hono }> {
|
||||
|
||||
const app = createApiApp({
|
||||
authorization: { evaluator, registry: composePlatformPermissionRegistry(modules), store },
|
||||
account: createInMemoryAccountStore(),
|
||||
dashboard: createInMemoryDashboardStore(options.configuration ?? {}),
|
||||
dashboardHostAssets: testDashboardHostAssets(),
|
||||
health: readyHealth(),
|
||||
@@ -147,7 +149,7 @@ async function install(options: InstallOptions = {}): Promise<{ app: Hono }> {
|
||||
moduleRealtime: EMPTY_MODULE_REALTIME_REGISTRY,
|
||||
moduleServices: Object.freeze({
|
||||
database: Object.freeze({}) as unknown as ModuleDatabase,
|
||||
}) as ModuleRuntimeServices,
|
||||
}) as LegacyModuleRuntimeServices,
|
||||
moduleUi: createModuleUiPublicationRegistry(modules),
|
||||
moduleLifecycle: {
|
||||
snapshot: moduleLifecycleSnapshotFor(modules),
|
||||
|
||||
@@ -0,0 +1,250 @@
|
||||
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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -118,13 +118,13 @@ async function createDisplayModuleDirectory(): Promise<string> {
|
||||
writeFile(
|
||||
path.join(moduleDirectory, "ekklesia.module.json"),
|
||||
JSON.stringify({
|
||||
compatibility: { moduleSdk: "^0.1.0", platform: "^0.1.0" },
|
||||
compatibility: { moduleSdk: "^0.2.0", platform: "^0.2.0" },
|
||||
entrypoints: {
|
||||
display: { artifact: "./dist/ui/display.js", permission: displayPermission },
|
||||
server: "./dist/server/index.js",
|
||||
},
|
||||
id: "announcements",
|
||||
manifestVersion: 7,
|
||||
manifestVersion: 8,
|
||||
name: "Announcements fixture",
|
||||
permissions: [
|
||||
{
|
||||
@@ -782,6 +782,127 @@ describe.sequential("display device pairing, end to end", () => {
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* ADR 0056: a credential lasts while the screen is in use, and dies 180 days after it was last
|
||||
* seen. Ages are set directly in the database, exactly as months of real use would leave them.
|
||||
*/
|
||||
describe("credential lifetime", () => {
|
||||
const ttlSeconds = 180 * 24 * 60 * 60;
|
||||
const descriptor = "/api/v1/module-displays/announcements";
|
||||
|
||||
function renewal(response: Response): string | undefined {
|
||||
return setCookieFor(response, "ekklesia_device_session");
|
||||
}
|
||||
|
||||
it("keeps a screen that is in use paired long past 180 days, renewing its cookie", async () => {
|
||||
const running = await platform("renewal");
|
||||
const device = new DeviceBrowser(running.origin);
|
||||
try {
|
||||
await pairDevice(running, device);
|
||||
const credential = device.header().replace(/^.*ekklesia_device_session=/u, "");
|
||||
// Paired 200 days ago, and seen yesterday: a screen that has simply been working.
|
||||
await query(
|
||||
running.url,
|
||||
`update ekklesia_core_device set created_at = now() - interval '200 days', last_seen_at = now() - interval '1 day'`,
|
||||
);
|
||||
|
||||
const response = await device.request(descriptor);
|
||||
expect(response.status).toBe(200);
|
||||
// The same credential, re-issued with a full lifetime and the same protections.
|
||||
const header = renewal(response);
|
||||
expect(header).toBeDefined();
|
||||
expect(header).toContain(`ekklesia_device_session=${credential};`);
|
||||
expect(header).toMatch(new RegExp(`Max-Age=${ttlSeconds}(;|$)`, "u"));
|
||||
expect(header).toMatch(/HttpOnly/u);
|
||||
expect(header).toMatch(/SameSite=Strict/u);
|
||||
expect(header).toMatch(/Path=\//u);
|
||||
expect(header).not.toMatch(/Domain=/iu);
|
||||
|
||||
// Using it moved the server's window forward too.
|
||||
const [row] = await query<{ recent: boolean }>(
|
||||
running.url,
|
||||
`select last_seen_at > now() - interval '1 minute' as recent from ekklesia_core_device`,
|
||||
);
|
||||
expect(row?.recent).toBe(true);
|
||||
} finally {
|
||||
await running.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps a credential paired under the fixed 180-day rule working once it is renewed", async () => {
|
||||
const running = await platform("legacy");
|
||||
const device = new DeviceBrowser(running.origin);
|
||||
try {
|
||||
await pairDevice(running, device);
|
||||
// A production screen paired 179 days ago that checked in this morning: under the old
|
||||
// rule it had one day left. It must carry straight on.
|
||||
await query(
|
||||
running.url,
|
||||
`update ekklesia_core_device set created_at = now() - interval '179 days', last_seen_at = now() - interval '2 hours'`,
|
||||
);
|
||||
expect((await device.request(descriptor)).status).toBe(200);
|
||||
await query(
|
||||
running.url,
|
||||
`update ekklesia_core_device set created_at = now() - interval '190 days'`,
|
||||
);
|
||||
expect((await device.request(descriptor)).status).toBe(200);
|
||||
} finally {
|
||||
await running.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("lets an abandoned screen stop being an identity 180 days after it was last seen", async () => {
|
||||
const running = await platform("abandoned");
|
||||
const device = new DeviceBrowser(running.origin);
|
||||
try {
|
||||
await pairDevice(running, device);
|
||||
await query(
|
||||
running.url,
|
||||
`update ekklesia_core_device set created_at = now() - interval '400 days', last_seen_at = now() - interval '181 days'`,
|
||||
);
|
||||
const response = await device.request(descriptor);
|
||||
// Anonymous, exactly as revoked or wrong credentials are: the screen shows a pairing code.
|
||||
expect(response.status).toBe(403);
|
||||
expect(renewal(response)).toBeUndefined();
|
||||
} finally {
|
||||
await running.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("never renews a revoked screen", async () => {
|
||||
const running = await platform("revoked-renewal");
|
||||
const device = new DeviceBrowser(running.origin);
|
||||
try {
|
||||
await pairDevice(running, device);
|
||||
const listed = (await (
|
||||
await adminRequest(running.origin, running.ownerCookie, "/api/v1/devices")
|
||||
).json()) as { devices: Array<{ id: string }> };
|
||||
await adminRequest(
|
||||
running.origin,
|
||||
running.ownerCookie,
|
||||
`/api/v1/devices/${listed.devices[0]?.id}/revoke`,
|
||||
{},
|
||||
);
|
||||
const response = await device.request(descriptor);
|
||||
expect(response.status).toBe(403);
|
||||
expect(renewal(response)).toBeUndefined();
|
||||
} finally {
|
||||
await running.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("does not hand a device cookie to a person viewing the display", async () => {
|
||||
const running = await platform("human-renewal");
|
||||
try {
|
||||
const response = await adminRequest(running.origin, running.ownerCookie, descriptor);
|
||||
expect(response.status).toBe(200);
|
||||
expect(renewal(response)).toBeUndefined();
|
||||
} finally {
|
||||
await running.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps a human administrator's own session unaffected by a device cookie", async () => {
|
||||
const running = await platform("mixed");
|
||||
const device = new DeviceBrowser(running.origin);
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import {
|
||||
ANONYMOUS_PRINCIPAL,
|
||||
type ModuleDatabase,
|
||||
type ModuleRouteHandler,
|
||||
type ModuleRuntimeServices,
|
||||
parsePermissionId,
|
||||
type RegisteredModuleHttpRoute,
|
||||
} from "@ekklesia/contracts";
|
||||
@@ -15,6 +13,8 @@ import type { EkklesiaIdentityRuntime } from "@ekklesia/core/identity";
|
||||
import {
|
||||
createModuleUiPublicationRegistry,
|
||||
EMPTY_MODULE_REALTIME_REGISTRY,
|
||||
type LegacyModuleRuntimeServices,
|
||||
type ModuleDatabase,
|
||||
} from "@ekklesia/core/modules";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { createApiApp } from "../src/app.js";
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
HEALTH_READY_PATH,
|
||||
READINESS_DATABASE_TIMEOUT_MS,
|
||||
} from "../src/health-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";
|
||||
@@ -56,7 +57,7 @@ function fakeStore(): AuthorizationStore {
|
||||
};
|
||||
}
|
||||
|
||||
function fakeModuleServices(): ModuleRuntimeServices {
|
||||
function fakeModuleServices(): LegacyModuleRuntimeServices {
|
||||
return Object.freeze({ database: Object.freeze({}) as unknown as ModuleDatabase });
|
||||
}
|
||||
|
||||
@@ -91,6 +92,7 @@ function buildApp(
|
||||
moduleLifecycle: emptyModuleLifecycle(),
|
||||
moduleConfiguration: emptyModuleConfiguration(),
|
||||
moduleStorage: emptyModuleStorage(),
|
||||
account: createInMemoryAccountStore(),
|
||||
dashboard: createInMemoryDashboardStore(),
|
||||
dashboardHostAssets: testDashboardHostAssets(),
|
||||
health,
|
||||
|
||||
@@ -20,7 +20,7 @@ vi.mock("@hono/node-server", () => ({ serve: serveMock }));
|
||||
vi.mock("../src/dashboard-host-assets.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../src/dashboard-host-assets.js")>();
|
||||
loadDashboardHostAssetsMock.mockImplementation(async () =>
|
||||
actual.createDashboardHostAssets("<!doctype html>", "export {};\n"),
|
||||
actual.createDashboardHostAssets("<!doctype html>", "export {};\n", "(() => {})();\n"),
|
||||
);
|
||||
return { ...actual, loadDashboardHostAssets: loadDashboardHostAssetsMock };
|
||||
});
|
||||
@@ -104,11 +104,11 @@ async function createFixtureModule(
|
||||
): Promise<void> {
|
||||
const moduleDirectory = path.join(modulesDirectory, id);
|
||||
const manifest = {
|
||||
manifestVersion: 7,
|
||||
manifestVersion: 8,
|
||||
id,
|
||||
name: `${id} module`,
|
||||
version: "0.1.0",
|
||||
compatibility: { platform: "^0.1.0", moduleSdk: "^0.1.0" },
|
||||
compatibility: { platform: "^0.2.0", moduleSdk: "^0.2.0" },
|
||||
permissions: [],
|
||||
entrypoints: { server: "./dist/server/index.js" },
|
||||
...manifestOverrides,
|
||||
@@ -267,10 +267,10 @@ function fakeDiscoveredModule(
|
||||
): DiscoveredModule {
|
||||
const manifest = parseModuleManifest({
|
||||
capabilities: [],
|
||||
compatibility: { moduleSdk: "^0.1.0", platform: "^0.1.0" },
|
||||
compatibility: { moduleSdk: "^0.2.0", platform: "^0.2.0" },
|
||||
entrypoints: { server: "./dist/does-not-exist/index.js" },
|
||||
id,
|
||||
manifestVersion: 7,
|
||||
manifestVersion: 8,
|
||||
name: `${id} module`,
|
||||
permissions,
|
||||
version: "0.1.0",
|
||||
@@ -1435,9 +1435,9 @@ describe("Ekklesia API module runtime startup and shutdown", () => {
|
||||
const recorder = createEventRecorder();
|
||||
await createWorkingFixtureModule(modulesDirectory, "calendar", recorder, { withRoute: true });
|
||||
const discovered = await (await import("@ekklesia/core/modules")).discoverModules({
|
||||
moduleSdkVersion: "0.1.0",
|
||||
moduleSdkVersion: "0.2.0",
|
||||
modulesDirectory,
|
||||
platformVersion: "0.1.0",
|
||||
platformVersion: "0.2.0",
|
||||
});
|
||||
mockPoolAndAuth();
|
||||
mockSuccessfulServe();
|
||||
|
||||
@@ -6,8 +6,6 @@ import {
|
||||
createAuthenticatedPrincipal,
|
||||
type EkklesiaPrincipal,
|
||||
type ModuleConfigurationDeclarationInput,
|
||||
type ModuleDatabase,
|
||||
type ModuleRuntimeServices,
|
||||
parseEkklesiaUserId,
|
||||
} from "@ekklesia/contracts";
|
||||
import {
|
||||
@@ -22,10 +20,12 @@ import {
|
||||
createModuleUiPublicationRegistry,
|
||||
type DiscoveredModule,
|
||||
discoverModules,
|
||||
type LegacyModuleRuntimeServices,
|
||||
type ModuleConfigurationRecord,
|
||||
type ModuleConfigurationStore,
|
||||
ModuleConfigurationStoreError,
|
||||
EMPTY_MODULE_REALTIME_REGISTRY,
|
||||
type ModuleDatabase,
|
||||
resolveModuleEligibility,
|
||||
} from "@ekklesia/core/modules";
|
||||
import type { Hono } from "hono";
|
||||
@@ -34,6 +34,7 @@ import { createApiApp } from "../src/app.js";
|
||||
import { CORE_MUTATION_MAX_BODY_BYTES } from "../src/body-limits.js";
|
||||
import { MODULE_CONFIGURATION_PATH } from "../src/module-configuration-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 { testDashboardHostAssets } from "./support/dashboard-host-assets.js";
|
||||
import { readyHealth } from "./support/ready-health.js";
|
||||
@@ -108,7 +109,7 @@ async function createModule(modulesDirectory: string, fixture: ModuleFixture): P
|
||||
configuration: fixture.configuration ?? [],
|
||||
entrypoints: { server: "./dist/server/index.js" },
|
||||
id: fixture.id,
|
||||
manifestVersion: 7,
|
||||
manifestVersion: 8,
|
||||
name: `${fixture.id} module`,
|
||||
version: "1.0.0",
|
||||
}),
|
||||
@@ -190,6 +191,7 @@ async function install(options: InstallOptions = {}): Promise<Installation> {
|
||||
registry: composePlatformPermissionRegistry(modules),
|
||||
store: authorizationStore,
|
||||
},
|
||||
account: createInMemoryAccountStore(),
|
||||
dashboard: createInMemoryDashboardStore(),
|
||||
dashboardHostAssets: testDashboardHostAssets(),
|
||||
health: readyHealth(),
|
||||
@@ -213,7 +215,7 @@ async function install(options: InstallOptions = {}): Promise<Installation> {
|
||||
moduleRealtime: EMPTY_MODULE_REALTIME_REGISTRY,
|
||||
moduleServices: Object.freeze({
|
||||
database: Object.freeze({}) as unknown as ModuleDatabase,
|
||||
}) as ModuleRuntimeServices,
|
||||
}) as LegacyModuleRuntimeServices,
|
||||
moduleUi: createModuleUiPublicationRegistry(eligibility.eligibleModules),
|
||||
runtime,
|
||||
trustedOrigins: [trustedOrigin],
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
createPostgresMigrationDatabase,
|
||||
discoverAllMigrations,
|
||||
} from "@ekklesia/core/migrations";
|
||||
import { moduleDatabaseExecutorModuleId } from "@ekklesia/core/modules";
|
||||
import { Client, type QueryResultRow } from "pg";
|
||||
import { afterAll, describe, expect, it } from "vitest";
|
||||
import { createApiRuntimeConfig } from "../src/config.js";
|
||||
@@ -194,6 +195,29 @@ 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 }> {
|
||||
const name = makeDatabaseName(label);
|
||||
await createDatabase(name);
|
||||
@@ -211,9 +235,9 @@ async function applyCoreMigrations(url: string): Promise<void> {
|
||||
const aggregate = await discoverAllMigrations({
|
||||
coreMigrationsDirectory,
|
||||
moduleDiscovery: {
|
||||
moduleSdkVersion: "0.1.0",
|
||||
moduleSdkVersion: "0.2.0",
|
||||
modulesDirectory: emptyModulesDirectory,
|
||||
platformVersion: "0.1.0",
|
||||
platformVersion: "0.2.0",
|
||||
},
|
||||
});
|
||||
expect(aggregate.migrations.map((entry) => entry.identity)).toEqual([
|
||||
@@ -225,6 +249,7 @@ async function applyCoreMigrations(url: string): Promise<void> {
|
||||
"core:0005_module_configuration",
|
||||
"core:0006_device_identity",
|
||||
"core:0007_verified_bootstrap_owners",
|
||||
"core:0008_user_preferences",
|
||||
]);
|
||||
await applyModuleMigrations({ aggregate, database: createPostgresMigrationDatabase(url) });
|
||||
} finally {
|
||||
@@ -240,9 +265,9 @@ async function applyMigrationsWithModule(url: string): Promise<void> {
|
||||
const aggregate = await discoverAllMigrations({
|
||||
coreMigrationsDirectory,
|
||||
moduleDiscovery: {
|
||||
moduleSdkVersion: "0.1.0",
|
||||
moduleSdkVersion: "0.2.0",
|
||||
modulesDirectory: fixtureModulesDirectory,
|
||||
platformVersion: "0.1.0",
|
||||
platformVersion: "0.2.0",
|
||||
},
|
||||
});
|
||||
// Core migrations aggregate first, exactly once each, and always before any module migration.
|
||||
@@ -255,6 +280,7 @@ async function applyMigrationsWithModule(url: string): Promise<void> {
|
||||
"core:0005_module_configuration",
|
||||
"core:0006_device_identity",
|
||||
"core:0007_verified_bootstrap_owners",
|
||||
"core:0008_user_preferences",
|
||||
"db-facade:0000_initial",
|
||||
]);
|
||||
expect(aggregate.migrations[0]?.checksum).toBe(
|
||||
@@ -441,6 +467,11 @@ describe.sequential("live API — module database explicit migration ownership",
|
||||
|
||||
const handle = createApiDatabase(database.url);
|
||||
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();
|
||||
expect(await tableExists(database.url)).toBe(false);
|
||||
|
||||
@@ -754,7 +785,7 @@ describe.sequential("live API — module database access through ModuleRuntimeSe
|
||||
|
||||
await running.shutdown();
|
||||
|
||||
const remainingConnections = await activeConnectionCount(database.name);
|
||||
const remainingConnections = await connectionsLeftAfterShutdown(database.name);
|
||||
expect(remainingConnections).toBe(0);
|
||||
|
||||
const eventsAfterShutdown = readRecorder();
|
||||
|
||||
@@ -5,8 +5,6 @@ import {
|
||||
ANONYMOUS_PRINCIPAL,
|
||||
createAuthenticatedPrincipal,
|
||||
type EkklesiaPrincipal,
|
||||
type ModuleDatabase,
|
||||
type ModuleRuntimeServices,
|
||||
parseEkklesiaUserId,
|
||||
} from "@ekklesia/contracts";
|
||||
import {
|
||||
@@ -21,6 +19,8 @@ import {
|
||||
type DiscoveredModule,
|
||||
discoverModules,
|
||||
EMPTY_MODULE_REALTIME_REGISTRY,
|
||||
type LegacyModuleRuntimeServices,
|
||||
type ModuleDatabase,
|
||||
} from "@ekklesia/core/modules";
|
||||
import type { Hono } from "hono";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
@@ -37,6 +37,7 @@ import { moduleApiBaseUrl } from "../src/module-http-routes.js";
|
||||
import { moduleRealtimeBaseUrl } from "../src/module-realtime-routes.js";
|
||||
import { MODULE_UI_INVENTORY_PATH } from "../src/module-ui-inventory.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 { testDashboardHostAssets } from "./support/dashboard-host-assets.js";
|
||||
import { readyHealth } from "./support/ready-health.js";
|
||||
@@ -105,7 +106,7 @@ async function createModule(modulesDirectory: string, fixture: ModuleFixture = {
|
||||
: {}),
|
||||
},
|
||||
id,
|
||||
manifestVersion: 7,
|
||||
manifestVersion: 8,
|
||||
name: `${id} module`,
|
||||
permissions: fixture.display
|
||||
? [
|
||||
@@ -155,7 +156,7 @@ interface Installation {
|
||||
readonly modules: readonly DiscoveredModule[];
|
||||
}
|
||||
|
||||
function fakeServices(): ModuleRuntimeServices {
|
||||
function fakeServices(): LegacyModuleRuntimeServices {
|
||||
return Object.freeze({ database: Object.freeze({}) as unknown as ModuleDatabase });
|
||||
}
|
||||
|
||||
@@ -199,6 +200,7 @@ async function install(options: InstallOptions): Promise<Installation> {
|
||||
registry: composePlatformPermissionRegistry(modules),
|
||||
store: fakeAuthorizationStore(),
|
||||
},
|
||||
account: createInMemoryAccountStore(),
|
||||
dashboard: createInMemoryDashboardStore(),
|
||||
dashboardHostAssets: testDashboardHostAssets(),
|
||||
health: readyHealth(),
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
import {
|
||||
ANONYMOUS_PRINCIPAL,
|
||||
createAuthenticatedPrincipal,
|
||||
type ModuleDatabase,
|
||||
type ModuleRouteHandler,
|
||||
type ModuleRouteHandlerContext,
|
||||
type ModuleRuntimeServices,
|
||||
moduleRoutesOverlap,
|
||||
parsePermissionId,
|
||||
type PermissionId,
|
||||
parsePermissionId,
|
||||
type RegisteredModuleHttpRoute,
|
||||
} from "@ekklesia/contracts";
|
||||
import {
|
||||
@@ -20,20 +18,24 @@ import type { EkklesiaIdentityRuntime } from "@ekklesia/core/identity";
|
||||
import {
|
||||
createModuleUiPublicationRegistry,
|
||||
EMPTY_MODULE_REALTIME_REGISTRY,
|
||||
type LegacyModuleRuntimeServices,
|
||||
type ModuleDatabase,
|
||||
type ModuleRuntimeServicesLookup,
|
||||
} from "@ekklesia/core/modules";
|
||||
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 {
|
||||
canonicalizeVaryHeader,
|
||||
mergedVaryHeader,
|
||||
ModuleRouteConfigurationError,
|
||||
mergedVaryHeader,
|
||||
} from "../src/module-http-routes.js";
|
||||
import { emptyModuleLifecycle } from "./support/in-memory-module-lifecycle.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 { emptyModuleStorage } from "./support/in-memory-module-storage.js";
|
||||
import { readyHealth } from "./support/ready-health.js";
|
||||
|
||||
const trustedOrigin = "https://dashboard.example.test";
|
||||
const untrustedOrigin = "https://evil.example.test";
|
||||
@@ -73,11 +75,11 @@ function moduleRoute(
|
||||
return Object.freeze({ bodyMode: "buffered", maxBodyBytes: 1024, ...fields });
|
||||
}
|
||||
|
||||
/** A small typed fake `ModuleRuntimeServices` — these tests exercise route mounting,
|
||||
/** A small typed fake `LegacyModuleRuntimeServices` — these tests exercise route mounting,
|
||||
* authorization, and response sanitization, never real database behavior, so no PostgreSQL is
|
||||
* 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). */
|
||||
function fakeModuleServices(): ModuleRuntimeServices {
|
||||
function fakeModuleServices(): LegacyModuleRuntimeServices {
|
||||
const database = Object.freeze({
|
||||
delete: vi.fn(),
|
||||
execute: vi.fn(),
|
||||
@@ -91,7 +93,7 @@ function fakeModuleServices(): ModuleRuntimeServices {
|
||||
return Object.freeze({ database });
|
||||
}
|
||||
|
||||
function expectDatabaseFakeUntouched(services: ModuleRuntimeServices): void {
|
||||
function expectDatabaseFakeUntouched(services: LegacyModuleRuntimeServices): void {
|
||||
expect(vi.mocked(services.database.select)).not.toHaveBeenCalled();
|
||||
expect(vi.mocked(services.database.selectDistinct)).not.toHaveBeenCalled();
|
||||
expect(vi.mocked(services.database.selectDistinctOn)).not.toHaveBeenCalled();
|
||||
@@ -107,7 +109,7 @@ function buildModuleApp(options: {
|
||||
readonly registry: PermissionRegistry;
|
||||
readonly routes: readonly RegisteredModuleHttpRoute[];
|
||||
readonly runtime?: EkklesiaIdentityRuntime;
|
||||
readonly services?: ModuleRuntimeServices;
|
||||
readonly services?: LegacyModuleRuntimeServices | ModuleRuntimeServicesLookup;
|
||||
readonly trustedOrigins?: readonly string[];
|
||||
}) {
|
||||
const evaluator = options.evaluator ?? {
|
||||
@@ -124,6 +126,7 @@ function buildModuleApp(options: {
|
||||
moduleLifecycle: emptyModuleLifecycle(),
|
||||
moduleConfiguration: emptyModuleConfiguration(),
|
||||
moduleStorage: emptyModuleStorage(),
|
||||
account: createInMemoryAccountStore(),
|
||||
dashboard: createInMemoryDashboardStore(),
|
||||
dashboardHostAssets: testDashboardHostAssets(),
|
||||
health: readyHealth(),
|
||||
@@ -145,6 +148,36 @@ function permissionRegistry(
|
||||
}
|
||||
|
||||
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 () => {
|
||||
const handler = vi.fn<ModuleRouteHandler>(async () => new Response("root", { status: 200 }));
|
||||
const { app } = buildModuleApp({
|
||||
@@ -508,7 +541,7 @@ describe("module route authorization", () => {
|
||||
expect(response.status).toBe(403);
|
||||
expect(await response.json()).toEqual({ code: "AUTHORIZATION_DENIED" });
|
||||
expect(handler).not.toHaveBeenCalled();
|
||||
expectDatabaseFakeUntouched(services);
|
||||
expectDatabaseFakeUntouched(services as LegacyModuleRuntimeServices);
|
||||
});
|
||||
|
||||
it("does not invoke the handler when principal resolution fails", async () => {
|
||||
@@ -637,7 +670,7 @@ describe("module route trusted-origin enforcement", () => {
|
||||
}
|
||||
expect(handler).not.toHaveBeenCalled();
|
||||
expect(evaluator.authorize).not.toHaveBeenCalled();
|
||||
expectDatabaseFakeUntouched(services);
|
||||
expectDatabaseFakeUntouched(services as LegacyModuleRuntimeServices);
|
||||
});
|
||||
|
||||
it("allows a trusted-origin state-changing request through to the handler", async () => {
|
||||
@@ -766,7 +799,7 @@ describe("module route CORS", () => {
|
||||
|
||||
expect(handler).not.toHaveBeenCalled();
|
||||
expect(evaluator.authorize).not.toHaveBeenCalled();
|
||||
expectDatabaseFakeUntouched(services);
|
||||
expectDatabaseFakeUntouched(services as LegacyModuleRuntimeServices);
|
||||
});
|
||||
|
||||
it("never reflects an untrusted origin for a module route", async () => {
|
||||
|
||||
@@ -5,8 +5,6 @@ import {
|
||||
ANONYMOUS_PRINCIPAL,
|
||||
createAuthenticatedPrincipal,
|
||||
type EkklesiaPrincipal,
|
||||
type ModuleDatabase,
|
||||
type ModuleRuntimeServices,
|
||||
parseEkklesiaUserId,
|
||||
} from "@ekklesia/contracts";
|
||||
import {
|
||||
@@ -20,6 +18,8 @@ import {
|
||||
createModuleUiPublicationRegistry,
|
||||
type DiscoveredModule,
|
||||
discoverModules,
|
||||
type LegacyModuleRuntimeServices,
|
||||
type ModuleDatabase,
|
||||
type ModuleLifecycleStore,
|
||||
ModuleLifecycleStoreError,
|
||||
EMPTY_MODULE_REALTIME_REGISTRY,
|
||||
@@ -32,6 +32,7 @@ import { DASHBOARD_NAVIGATION_PATH } from "../src/dashboard-routes.js";
|
||||
import { CORE_MUTATION_MAX_BODY_BYTES } from "../src/body-limits.js";
|
||||
import { MODULE_LIFECYCLE_PATH } from "../src/module-lifecycle-routes.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 { testDashboardHostAssets } from "./support/dashboard-host-assets.js";
|
||||
import { readyHealth } from "./support/ready-health.js";
|
||||
@@ -79,7 +80,7 @@ async function createModule(modulesDirectory: string, fixture: ModuleFixture): P
|
||||
...(fixture.dependencies ? { dependencies: fixture.dependencies } : {}),
|
||||
entrypoints: { server: "./dist/server/index.js", ui: "./dist/ui/index.js" },
|
||||
id: fixture.id,
|
||||
manifestVersion: 7,
|
||||
manifestVersion: 8,
|
||||
name: `${fixture.id} module`,
|
||||
version: "1.0.0",
|
||||
}),
|
||||
@@ -172,6 +173,7 @@ async function install(options: InstallOptions = {}): Promise<Installation> {
|
||||
registry: composePlatformPermissionRegistry(modules),
|
||||
store: authorizationStore,
|
||||
},
|
||||
account: createInMemoryAccountStore(),
|
||||
dashboard: createInMemoryDashboardStore(),
|
||||
dashboardHostAssets: testDashboardHostAssets(),
|
||||
health: readyHealth(),
|
||||
@@ -189,7 +191,7 @@ async function install(options: InstallOptions = {}): Promise<Installation> {
|
||||
moduleRealtime: EMPTY_MODULE_REALTIME_REGISTRY,
|
||||
moduleServices: Object.freeze({
|
||||
database: Object.freeze({}) as unknown as ModuleDatabase,
|
||||
}) as ModuleRuntimeServices,
|
||||
}) as LegacyModuleRuntimeServices,
|
||||
moduleUi: createModuleUiPublicationRegistry(eligibility.eligibleModules),
|
||||
runtime,
|
||||
trustedOrigins: [trustedOrigin],
|
||||
|
||||
@@ -4,9 +4,7 @@ import path from "node:path";
|
||||
import {
|
||||
ANONYMOUS_PRINCIPAL,
|
||||
createAuthenticatedPrincipal,
|
||||
type ModuleDatabase,
|
||||
type ModuleRealtimePublisher,
|
||||
type ModuleRuntimeServices,
|
||||
parseEkklesiaUserId,
|
||||
} from "@ekklesia/contracts";
|
||||
import {
|
||||
@@ -18,6 +16,8 @@ import type { EkklesiaIdentityRuntime } from "@ekklesia/core/identity";
|
||||
import {
|
||||
composePlatformPermissionRegistry,
|
||||
discoverModules,
|
||||
type LegacyModuleRuntimeServices,
|
||||
type ModuleDatabase,
|
||||
type ModuleRealtimeRegistry,
|
||||
type ModuleRealtimeSubscription,
|
||||
startDiscoveredModuleRuntime,
|
||||
@@ -188,7 +188,7 @@ async function createFixtureModule(control: FixtureControl): Promise<{
|
||||
compatibility: { moduleSdk: "^0.1.0", platform: "^0.1.0" },
|
||||
entrypoints: { server: "./dist/server/index.js" },
|
||||
id: "announcements",
|
||||
manifestVersion: 7,
|
||||
manifestVersion: 8,
|
||||
name: "Announcements fixture",
|
||||
permissions: [
|
||||
{
|
||||
@@ -226,7 +226,7 @@ async function createFixtureModule(control: FixtureControl): Promise<{
|
||||
return { directory, symbol };
|
||||
}
|
||||
|
||||
function services(): ModuleRuntimeServices {
|
||||
function services(): LegacyModuleRuntimeServices {
|
||||
return Object.freeze({ database: Object.freeze({}) as ModuleDatabase });
|
||||
}
|
||||
|
||||
|
||||
@@ -5,8 +5,6 @@ import {
|
||||
ANONYMOUS_PRINCIPAL,
|
||||
createAuthenticatedPrincipal,
|
||||
type EkklesiaPrincipal,
|
||||
type ModuleDatabase,
|
||||
type ModuleRuntimeServices,
|
||||
parseEkklesiaUserId,
|
||||
} from "@ekklesia/contracts";
|
||||
import {
|
||||
@@ -18,6 +16,8 @@ import {
|
||||
composePlatformPermissionRegistry,
|
||||
createModuleUiPublicationRegistry,
|
||||
discoverModules,
|
||||
type LegacyModuleRuntimeServices,
|
||||
type ModuleDatabase,
|
||||
startDiscoveredModuleRuntime,
|
||||
} from "@ekklesia/core/modules";
|
||||
import {
|
||||
@@ -33,8 +33,10 @@ import {
|
||||
createModuleUiContext,
|
||||
type EkklesiaHostConfig,
|
||||
moduleUiSourceUrl,
|
||||
saveHostAccountPreferences,
|
||||
} from "@ekklesia/shell";
|
||||
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 { testDashboardHostAssets } from "./support/dashboard-host-assets.js";
|
||||
import { readyHealth } from "./support/ready-health.js";
|
||||
@@ -129,7 +131,7 @@ async function createFixtureModule(
|
||||
: {}),
|
||||
},
|
||||
id,
|
||||
manifestVersion: 7,
|
||||
manifestVersion: 8,
|
||||
name,
|
||||
permissions: [
|
||||
{
|
||||
@@ -216,6 +218,8 @@ interface HarnessOptions {
|
||||
* single module proves very little. */
|
||||
readonly extraModules?: ReadonlyArray<{ readonly id: string; readonly name: string }>;
|
||||
readonly platformOwner?: boolean;
|
||||
/** People with an account, for the account area's routes (ADR 0062). */
|
||||
readonly people?: Parameters<typeof createInMemoryAccountStore>[0];
|
||||
}
|
||||
|
||||
interface Harness {
|
||||
@@ -241,7 +245,7 @@ async function startHarness(options: HarnessOptions = {}): Promise<Harness> {
|
||||
modulesDirectory,
|
||||
platformVersion: "0.1.0",
|
||||
});
|
||||
const services: ModuleRuntimeServices = Object.freeze({
|
||||
const services: LegacyModuleRuntimeServices = Object.freeze({
|
||||
database: Object.freeze({}) as unknown as ModuleDatabase,
|
||||
});
|
||||
// The module's real runtime: its server entrypoint is imported, registered, and started, and the
|
||||
@@ -289,6 +293,7 @@ async function startHarness(options: HarnessOptions = {}): Promise<Harness> {
|
||||
},
|
||||
moduleConfiguration: moduleConfigurationOptionFor(modules),
|
||||
moduleStorage: moduleStorageOptionFor(modules),
|
||||
account: createInMemoryAccountStore(options.people ?? []),
|
||||
dashboard: createInMemoryDashboardStore(),
|
||||
dashboardHostAssets: testDashboardHostAssets(),
|
||||
health: readyHealth(),
|
||||
@@ -302,7 +307,10 @@ async function startHarness(options: HarnessOptions = {}): Promise<Harness> {
|
||||
if (url.pathname.startsWith("/api/v1/modules/")) {
|
||||
moduleApiCalls.push(`${init?.method ?? "GET"} ${url.pathname}${url.search}`);
|
||||
}
|
||||
return app.request(url.href, init);
|
||||
// A browser names the Dashboard's origin on every cross-origin request; the shell never sets it.
|
||||
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;
|
||||
|
||||
return {
|
||||
@@ -738,6 +746,39 @@ 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 () => {
|
||||
const harness = await startHarness();
|
||||
try {
|
||||
|
||||
@@ -5,8 +5,6 @@ import {
|
||||
ANONYMOUS_PRINCIPAL,
|
||||
createAuthenticatedPrincipal,
|
||||
type EkklesiaPrincipal,
|
||||
type ModuleDatabase,
|
||||
type ModuleRuntimeServices,
|
||||
parseEkklesiaUserId,
|
||||
} from "@ekklesia/contracts";
|
||||
import {
|
||||
@@ -21,9 +19,12 @@ import {
|
||||
type DiscoveredModule,
|
||||
discoverModules,
|
||||
EMPTY_MODULE_REALTIME_REGISTRY,
|
||||
type LegacyModuleRuntimeServices,
|
||||
type ModuleDatabase,
|
||||
} from "@ekklesia/core/modules";
|
||||
import type { Hono } from "hono";
|
||||
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 { testDashboardHostAssets } from "./support/dashboard-host-assets.js";
|
||||
import { readyHealth } from "./support/ready-health.js";
|
||||
@@ -99,7 +100,7 @@ async function createModule(modulesDirectory: string, fixture: ModuleFixture): P
|
||||
version: "1.0.0",
|
||||
})),
|
||||
id: fixture.id,
|
||||
manifestVersion: 7,
|
||||
manifestVersion: 8,
|
||||
name: `${fixture.id} module`,
|
||||
permissions: (fixture.permissions ?? []).map((permission) => ({
|
||||
...permission,
|
||||
@@ -192,7 +193,7 @@ async function install(options: InstallOptions): Promise<Installation> {
|
||||
revokeGrant: vi.fn(),
|
||||
revokePlatformOwner: vi.fn(),
|
||||
} as unknown as AuthorizationStore;
|
||||
const services: ModuleRuntimeServices = Object.freeze({
|
||||
const services: LegacyModuleRuntimeServices = Object.freeze({
|
||||
database: Object.freeze({}) as unknown as ModuleDatabase,
|
||||
});
|
||||
const permissionRegistry = composePlatformPermissionRegistry(modules);
|
||||
@@ -209,6 +210,7 @@ async function install(options: InstallOptions): Promise<Installation> {
|
||||
},
|
||||
moduleConfiguration: moduleConfigurationOptionFor(modules),
|
||||
moduleStorage: moduleStorageOptionFor(modules),
|
||||
account: createInMemoryAccountStore(),
|
||||
dashboard: createInMemoryDashboardStore(),
|
||||
dashboardHostAssets: testDashboardHostAssets(),
|
||||
health: readyHealth(),
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import {
|
||||
ANONYMOUS_PRINCIPAL,
|
||||
type ModuleDatabase,
|
||||
type ModuleRuntimeServices,
|
||||
} from "@ekklesia/contracts";
|
||||
import { ANONYMOUS_PRINCIPAL } from "@ekklesia/contracts";
|
||||
import type { AuthorizationEvaluator, AuthorizationStore } from "@ekklesia/core/authorization";
|
||||
import type { EkklesiaIdentityRuntime } from "@ekklesia/core/identity";
|
||||
import {
|
||||
@@ -14,12 +10,15 @@ import {
|
||||
type DiscoveredModule,
|
||||
discoverModules,
|
||||
EMPTY_MODULE_REALTIME_REGISTRY,
|
||||
type LegacyModuleRuntimeServices,
|
||||
type ModuleDatabase,
|
||||
} from "@ekklesia/core/modules";
|
||||
import { loadModuleUi } from "@ekklesia/module-ui-host";
|
||||
import { MODULE_UI_ABI_VERSION, NO_MODULE_UI_CONTRIBUTIONS } from "@ekklesia/module-ui-sdk";
|
||||
import { Window } from "happy-dom";
|
||||
import type { Hono } from "hono";
|
||||
import { 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 { testDashboardHostAssets } from "./support/dashboard-host-assets.js";
|
||||
import { readyHealth } from "./support/ready-health.js";
|
||||
@@ -139,7 +138,7 @@ async function createModule(modulesDirectory: string, fixture: ModuleFixture): P
|
||||
version: "1.0.0",
|
||||
})),
|
||||
id: fixture.id,
|
||||
manifestVersion: 7,
|
||||
manifestVersion: 8,
|
||||
name: `${fixture.id} module`,
|
||||
permissions:
|
||||
fixture.display === undefined
|
||||
@@ -215,7 +214,7 @@ async function install(
|
||||
: modules.filter((module) => publicationModuleIds.includes(module.manifest.id));
|
||||
|
||||
const database = Object.freeze({}) as unknown as ModuleDatabase;
|
||||
const services: ModuleRuntimeServices = Object.freeze({ database });
|
||||
const services: LegacyModuleRuntimeServices = Object.freeze({ database });
|
||||
const runtime: EkklesiaIdentityRuntime = {
|
||||
handleAuthRequest: vi.fn().mockResolvedValue(new Response(null, { status: 200 })),
|
||||
// Anonymous, and every authorization decision denied: whatever this route does, it does it
|
||||
@@ -256,6 +255,7 @@ async function install(
|
||||
},
|
||||
moduleConfiguration: moduleConfigurationOptionFor(modules),
|
||||
moduleStorage: moduleStorageOptionFor(modules),
|
||||
account: createInMemoryAccountStore(),
|
||||
dashboard: createInMemoryDashboardStore(),
|
||||
dashboardHostAssets: testDashboardHostAssets(),
|
||||
health: readyHealth(),
|
||||
|
||||
@@ -9,5 +9,6 @@ export function testDashboardHostAssets(): DashboardHostAssets {
|
||||
return createDashboardHostAssets(
|
||||
'<!doctype html><div id="ekklesia-host"></div><script src="/assets/ekklesia-dashboard/app.js" type="module"></script>',
|
||||
"export const dashboardHost = true;\n",
|
||||
"(() => {})();\n",
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
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 };
|
||||
},
|
||||
};
|
||||
}
|
||||
+4
-1
@@ -19,7 +19,10 @@
|
||||
"linter": {
|
||||
"enabled": true,
|
||||
"rules": {
|
||||
"preset": "recommended"
|
||||
"preset": "recommended",
|
||||
"suspicious": {
|
||||
"noAlert": "error"
|
||||
}
|
||||
}
|
||||
},
|
||||
"javascript": {
|
||||
|
||||
@@ -9,6 +9,7 @@ export default defineConfig({
|
||||
"./src/modules/lifecycle-schema.ts",
|
||||
"./src/modules/configuration-schema.ts",
|
||||
"./src/devices/schema.ts",
|
||||
"./src/account/schema.ts",
|
||||
],
|
||||
out: "./drizzle",
|
||||
migrations: {
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
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
@@ -49,6 +49,12 @@
|
||||
"destructive": {
|
||||
"mode": "none"
|
||||
}
|
||||
},
|
||||
{
|
||||
"tag": "0008_user_preferences",
|
||||
"destructive": {
|
||||
"mode": "none"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -57,6 +57,13 @@
|
||||
"when": 1787543773058,
|
||||
"tag": "0007_verified_bootstrap_owners",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 8,
|
||||
"version": "7",
|
||||
"when": 1792036365485,
|
||||
"tag": "0008_user_preferences",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
+6
-1
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"name": "@ekklesia/core",
|
||||
"version": "0.1.2",
|
||||
"version": "0.2.5",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"files": [
|
||||
@@ -11,6 +12,10 @@
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js"
|
||||
},
|
||||
"./account": {
|
||||
"types": "./dist/account/index.d.ts",
|
||||
"import": "./dist/account/index.js"
|
||||
},
|
||||
"./modules": {
|
||||
"types": "./dist/modules/index.d.ts",
|
||||
"import": "./dist/modules/index.js"
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
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 });
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export * from "./database.js";
|
||||
export * from "./preferences.js";
|
||||
export * from "./schema.js";
|
||||
export * from "./store.js";
|
||||
@@ -0,0 +1,108 @@
|
||||
/**
|
||||
* 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,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
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;
|
||||
@@ -0,0 +1,156 @@
|
||||
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");
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -169,6 +169,20 @@ export const CORE_MODULES_CONFIGURATION_MANAGE: PermissionId = parsePermissionId
|
||||
*/
|
||||
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
|
||||
* integration layer, `core/src/modules/permissions.ts`) can build one combined registry from
|
||||
@@ -207,6 +221,11 @@ export const CORE_PERMISSION_DECLARATIONS: readonly PermissionDeclaration[] = Ob
|
||||
description: "Approve display device pairing, list paired devices, and revoke a device.",
|
||||
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,
|
||||
}),
|
||||
]);
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import {
|
||||
type ModulePackagePlatform,
|
||||
ModulePackageVerificationError,
|
||||
verifyModulePackage,
|
||||
} from "../modules/index.js";
|
||||
|
||||
/**
|
||||
* `verify-module-package` — verifies an Ekklesia Module Package v1 archive against this platform
|
||||
* (ADR 0049), and never installs it.
|
||||
*
|
||||
* node core/dist/cli/verify-module-package.js <archive>
|
||||
* [--sha256 <hex> | --sha256-file <file>] the archive's expected SHA-256
|
||||
* [--extract-to <new directory>] write the verified module directory there
|
||||
*
|
||||
* Run from the platform's root (`/opt/ekklesia` in the image), like every operator command: the
|
||||
* platform's own versions are read from there. `--extract-to` writes only after every check passed,
|
||||
* into a directory that must not exist yet; it is the staging step of an installation, not the
|
||||
* installation itself.
|
||||
*/
|
||||
|
||||
class VerifyArgumentsError extends Error {}
|
||||
|
||||
const USAGE =
|
||||
"Usage: verify-module-package <archive> [--sha256 <hex> | --sha256-file <file>] [--extract-to <new directory>]";
|
||||
export const MODULE_VERIFIER_SCRATCH_ENV = "EKKLESIA_MODULE_VERIFIER_SCRATCH_DIR";
|
||||
|
||||
export interface VerifyModulePackageArguments {
|
||||
readonly archivePath: string;
|
||||
readonly expectedSha256?: string;
|
||||
readonly sha256File?: string;
|
||||
readonly extractTo?: string;
|
||||
}
|
||||
|
||||
function normalizedDigest(value: string): string {
|
||||
const digest = value.trim().toLowerCase();
|
||||
if (!/^[0-9a-f]{64}$/u.test(digest)) {
|
||||
throw new VerifyArgumentsError("The SHA-256 must be 64 hexadecimal characters.");
|
||||
}
|
||||
return digest;
|
||||
}
|
||||
|
||||
export function parseVerifyModulePackageArguments(
|
||||
argv: readonly string[],
|
||||
): VerifyModulePackageArguments {
|
||||
const positional: string[] = [];
|
||||
const options = new Map<string, string>();
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
const argument = argv[index] ?? "";
|
||||
if (argument.startsWith("--")) {
|
||||
if (!["--sha256", "--sha256-file", "--extract-to"].includes(argument)) {
|
||||
throw new VerifyArgumentsError(USAGE);
|
||||
}
|
||||
const value = argv[index + 1];
|
||||
if (value === undefined || value.startsWith("--") || options.has(argument)) {
|
||||
throw new VerifyArgumentsError(USAGE);
|
||||
}
|
||||
options.set(argument, value);
|
||||
index += 1;
|
||||
} else {
|
||||
positional.push(argument);
|
||||
}
|
||||
}
|
||||
const archivePath = positional[0];
|
||||
if (positional.length !== 1 || archivePath === undefined || archivePath === "") {
|
||||
throw new VerifyArgumentsError(USAGE);
|
||||
}
|
||||
if (options.has("--sha256") && options.has("--sha256-file")) {
|
||||
throw new VerifyArgumentsError(USAGE);
|
||||
}
|
||||
const sha256 = options.get("--sha256");
|
||||
const sha256File = options.get("--sha256-file");
|
||||
const extractTo = options.get("--extract-to");
|
||||
return {
|
||||
archivePath,
|
||||
...(sha256 === undefined ? {} : { expectedSha256: normalizedDigest(sha256) }),
|
||||
...(sha256File === undefined ? {} : { sha256File }),
|
||||
...(extractTo === undefined ? {} : { extractTo }),
|
||||
};
|
||||
}
|
||||
|
||||
/** The `sha256sum` format: one line, the digest, whitespace, and a file name that is never trusted. */
|
||||
export function parseSha256File(contents: string): string {
|
||||
const lines = contents.split("\n").filter((line) => line.trim() !== "");
|
||||
const match = lines.length === 1 ? /^([0-9a-fA-F]{64})\s+\*?\S/u.exec(lines[0] ?? "") : null;
|
||||
if (!match?.[1]) {
|
||||
throw new VerifyArgumentsError("The SHA-256 file must hold one sha256sum line.");
|
||||
}
|
||||
return match[1].toLowerCase();
|
||||
}
|
||||
|
||||
/** The CLI's operator-facing bridge to Core's otherwise explicit verifier option. */
|
||||
export function moduleVerifierScratchDirectory(environment: NodeJS.ProcessEnv): string | undefined {
|
||||
const configured = environment[MODULE_VERIFIER_SCRATCH_ENV];
|
||||
if (configured === undefined) return undefined;
|
||||
if (configured === "" || !path.isAbsolute(configured)) {
|
||||
throw new VerifyArgumentsError(`${MODULE_VERIFIER_SCRATCH_ENV} must be an absolute path.`);
|
||||
}
|
||||
return configured;
|
||||
}
|
||||
|
||||
async function readJson(file: string): Promise<Record<string, unknown>> {
|
||||
return JSON.parse(await readFile(file, "utf8")) as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function requiredString(value: unknown): string {
|
||||
if (typeof value !== "string" || value === "") {
|
||||
throw new VerifyArgumentsError("The platform's own package metadata could not be read.");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
/** What this platform is, read from where it is installed. The transitional database runtime is
|
||||
* the Drizzle version Core itself depends on (ADR 0049). */
|
||||
export async function readModulePackagePlatform(
|
||||
repositoryRoot: string,
|
||||
): Promise<ModulePackagePlatform> {
|
||||
const [root, sdk, core] = await Promise.all([
|
||||
readJson(path.join(repositoryRoot, "package.json")),
|
||||
readJson(path.join(repositoryRoot, "packages", "module-sdk", "package.json")),
|
||||
readJson(path.join(repositoryRoot, "core", "package.json")),
|
||||
]);
|
||||
const dependencies = core.dependencies as Record<string, unknown> | undefined;
|
||||
return {
|
||||
databaseRuntime: { "drizzle-orm": requiredString(dependencies?.["drizzle-orm"]) },
|
||||
moduleSdkVersion: requiredString(sdk.version),
|
||||
platformVersion: requiredString(root.version),
|
||||
};
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const parsed = parseVerifyModulePackageArguments(process.argv.slice(2));
|
||||
const executionScratchDirectory = moduleVerifierScratchDirectory(process.env);
|
||||
let expectedSha256 = parsed.expectedSha256;
|
||||
if (parsed.sha256File !== undefined) {
|
||||
let contents: string;
|
||||
try {
|
||||
contents = await readFile(parsed.sha256File, "utf8");
|
||||
} catch {
|
||||
throw new VerifyArgumentsError("The SHA-256 file could not be read.");
|
||||
}
|
||||
expectedSha256 = parseSha256File(contents);
|
||||
}
|
||||
const platform = await readModulePackagePlatform(process.cwd()).catch(() => {
|
||||
throw new VerifyArgumentsError("The platform's own package metadata could not be read.");
|
||||
});
|
||||
const result = await verifyModulePackage({
|
||||
archivePath: parsed.archivePath,
|
||||
platform,
|
||||
...(executionScratchDirectory === undefined ? {} : { executionScratchDirectory }),
|
||||
...(expectedSha256 === undefined ? {} : { expectedSha256 }),
|
||||
...(parsed.extractTo === undefined ? {} : { extractTo: parsed.extractTo }),
|
||||
});
|
||||
console.log(
|
||||
`Module package verified: ${result.moduleId}@${result.version} (target ${result.target}), ${result.fileCount} files, sha256 ${result.archiveSha256}.`,
|
||||
);
|
||||
if (expectedSha256 === undefined) {
|
||||
console.log("The archive's SHA-256 was not checked: no expected digest was given.");
|
||||
}
|
||||
if (parsed.extractTo !== undefined) {
|
||||
console.log("The verified module directory was extracted. Nothing was installed.");
|
||||
}
|
||||
}
|
||||
|
||||
function isDirectExecution(): boolean {
|
||||
const entrypoint = process.argv[1];
|
||||
return (
|
||||
entrypoint !== undefined && import.meta.url === pathToFileURL(path.resolve(entrypoint)).href
|
||||
);
|
||||
}
|
||||
|
||||
if (isDirectExecution()) {
|
||||
main().catch((error: unknown) => {
|
||||
if (error instanceof ModulePackageVerificationError) {
|
||||
console.error(`[${error.code}] ${error.message}`);
|
||||
if (error.issueCodes.length > 0) {
|
||||
console.error(`Module validation issues: ${error.issueCodes.join(", ")}`);
|
||||
}
|
||||
} else if (error instanceof VerifyArgumentsError) {
|
||||
console.error(error.message);
|
||||
} else {
|
||||
console.error("Module package verification failed unexpectedly.");
|
||||
}
|
||||
process.exitCode = 1;
|
||||
});
|
||||
}
|
||||
@@ -44,17 +44,26 @@ export class DeviceStoreError extends Error {
|
||||
export const DEVICE_PAIRING_REQUEST_TTL_MS = 10 * 60_000;
|
||||
|
||||
/**
|
||||
* How long a device credential is accepted before the device must pair again.
|
||||
* How long a device credential may go **unused** before the device must pair again (ADR 0056).
|
||||
*
|
||||
* A display runs unattended for months; forcing a signage player to be re-paired every fortnight
|
||||
* because human sessions expire that fast would mean someone climbing to a ceiling-mounted screen
|
||||
* on a schedule. What makes a long horizon safe is that it is not the security boundary: the server
|
||||
* decides on every single request whether the device is still revoked, so an administrator's revoke
|
||||
* 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.
|
||||
* The window slides: every request from the device moves `lastSeenAt` forward, and the host
|
||||
* re-issues the cookie with a fresh lifetime, so a screen that is in use stays paired until an
|
||||
* administrator revokes it. Revocation, not expiry, is the security boundary — the server checks it
|
||||
* on every single request. The window exists only so an abandoned, forgotten screen eventually stops
|
||||
* being an identity at all: 180 days after it was last seen, its credential is dead.
|
||||
*/
|
||||
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
|
||||
* realtime and assets would otherwise turn an operational nicety into a continuous write load. */
|
||||
export const DEVICE_LAST_SEEN_THROTTLE_MS = 15 * 60_000;
|
||||
@@ -475,7 +484,8 @@ export function createDeviceStore(db: DeviceDrizzleDatabase): DeviceStore {
|
||||
}
|
||||
// The credential's own horizon, measured from when the device was paired. Revocation is
|
||||
// checked above and is what makes a long horizon acceptable.
|
||||
if (row.createdAt.getTime() + DEVICE_CREDENTIAL_TTL_MS <= now.getTime()) {
|
||||
// Expiry is measured from last use, not from pairing, so a screen in use never ages out.
|
||||
if (deviceCredentialExpiresAt(row).getTime() <= now.getTime()) {
|
||||
return undefined;
|
||||
}
|
||||
return Object.freeze({
|
||||
|
||||
@@ -0,0 +1,349 @@
|
||||
import {
|
||||
type ModuleDatabaseField,
|
||||
ModuleDatabaseError,
|
||||
type ModuleDatabaseValue,
|
||||
} from "@ekklesia/contracts";
|
||||
|
||||
/**
|
||||
* The value contract of Module Database ABI v1 (ADR 0050): how parameters are encoded before they
|
||||
* reach PostgreSQL, and how result text is decoded after. Both are Core's, and neither consults
|
||||
* `pg`'s process-global type registry or any ORM, which module code could change or differ from.
|
||||
*
|
||||
* Every value travels in PostgreSQL's text format. The session settings the decoding depends on
|
||||
* (`DateStyle`, `IntervalStyle`, `TimeZone`, `bytea_output`, `extra_float_digits`) are pinned by the
|
||||
* executor for every ABI operation (`./database-abi.ts`).
|
||||
*/
|
||||
|
||||
/** PostgreSQL's limit on bound parameters in one statement (the protocol's 16-bit count). */
|
||||
export const MODULE_DATABASE_MAX_PARAMETERS = 65_535;
|
||||
/** PostgreSQL's limit on array dimensions (`MAXDIM`). */
|
||||
const maximumArrayDimensions = 6;
|
||||
|
||||
function parameterInvalid(): ModuleDatabaseError {
|
||||
return new ModuleDatabaseError("MODULE_DATABASE_PARAMETER_INVALID");
|
||||
}
|
||||
|
||||
function isScalar(value: unknown): boolean {
|
||||
return (
|
||||
value === null ||
|
||||
typeof value === "boolean" ||
|
||||
typeof value === "number" ||
|
||||
typeof value === "bigint" ||
|
||||
typeof value === "string" ||
|
||||
value instanceof Uint8Array
|
||||
);
|
||||
}
|
||||
|
||||
function scalarText(value: boolean | number | bigint | string): string {
|
||||
// `String` spells NaN, Infinity, and -Infinity exactly as PostgreSQL's float and numeric input
|
||||
// accepts them.
|
||||
return typeof value === "string" ? value : String(value);
|
||||
}
|
||||
|
||||
function byteaText(value: Uint8Array): string {
|
||||
return `\\x${Buffer.from(value.buffer, value.byteOffset, value.byteLength).toString("hex")}`;
|
||||
}
|
||||
|
||||
function quoteArrayElement(text: string): string {
|
||||
return `"${text.replaceAll("\\", "\\\\").replaceAll('"', '\\"')}"`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates one array level and returns its PostgreSQL array literal. Every element of a level is
|
||||
* either a sub-array or a scalar, and sibling sub-arrays have equal lengths, because PostgreSQL
|
||||
* arrays are rectangular. Every non-null element is quoted, which PostgreSQL accepts for any element
|
||||
* type.
|
||||
*/
|
||||
function arrayLiteral(values: readonly unknown[], depth: number): string {
|
||||
if (depth > maximumArrayDimensions) {
|
||||
throw parameterInvalid();
|
||||
}
|
||||
const elements: string[] = [];
|
||||
let nestedLength: number | undefined;
|
||||
let nested: boolean | undefined;
|
||||
for (let index = 0; index < values.length; index += 1) {
|
||||
if (!Object.hasOwn(values, index)) {
|
||||
throw parameterInvalid();
|
||||
}
|
||||
const element = values[index];
|
||||
const isNested = Array.isArray(element);
|
||||
if (nested === undefined) {
|
||||
nested = isNested;
|
||||
} else if (nested !== isNested) {
|
||||
throw parameterInvalid();
|
||||
}
|
||||
if (isNested) {
|
||||
if (nestedLength === undefined) {
|
||||
nestedLength = element.length;
|
||||
} else if (nestedLength !== element.length) {
|
||||
throw parameterInvalid();
|
||||
}
|
||||
elements.push(arrayLiteral(element, depth + 1));
|
||||
} else if (element === null) {
|
||||
elements.push("NULL");
|
||||
} else if (element instanceof Uint8Array) {
|
||||
elements.push(quoteArrayElement(byteaText(element)));
|
||||
} else if (isScalar(element)) {
|
||||
elements.push(quoteArrayElement(scalarText(element as boolean | number | bigint | string)));
|
||||
} else {
|
||||
throw parameterInvalid();
|
||||
}
|
||||
}
|
||||
return `{${elements.join(",")}}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates `values` against the ABI's parameter set and encodes each one into the only forms the
|
||||
* driver then passes through unchanged: `null`, a string, or a `Buffer` copy of a `Uint8Array`.
|
||||
* Anything else, including `undefined`, `Date`, plain objects, other class instances, functions,
|
||||
* symbols, sparse or ragged arrays, and arrays deeper than PostgreSQL allows, is refused before
|
||||
* anything reaches PostgreSQL. The error never names the value.
|
||||
*/
|
||||
export function encodeModuleDatabaseParameters(values: unknown): (string | Buffer | null)[] {
|
||||
if (values === undefined) {
|
||||
return [];
|
||||
}
|
||||
if (!Array.isArray(values) || values.length > MODULE_DATABASE_MAX_PARAMETERS) {
|
||||
throw parameterInvalid();
|
||||
}
|
||||
const encoded: (string | Buffer | null)[] = [];
|
||||
for (let index = 0; index < values.length; index += 1) {
|
||||
if (!Object.hasOwn(values, index)) {
|
||||
throw parameterInvalid();
|
||||
}
|
||||
const value: unknown = values[index];
|
||||
if (value === null) {
|
||||
encoded.push(null);
|
||||
} else if (value instanceof Uint8Array) {
|
||||
// A copy: the driver serializes when the statement is sent, which may be after the caller
|
||||
// has changed its own bytes.
|
||||
encoded.push(Buffer.from(value));
|
||||
} else if (Array.isArray(value)) {
|
||||
encoded.push(arrayLiteral(value, 1));
|
||||
} else if (isScalar(value)) {
|
||||
encoded.push(scalarText(value as boolean | number | bigint | string));
|
||||
} else {
|
||||
throw parameterInvalid();
|
||||
}
|
||||
}
|
||||
return encoded;
|
||||
}
|
||||
|
||||
// --- Results -----------------------------------------------------------------------------------
|
||||
|
||||
type ScalarDecoder = (text: string) => ModuleDatabaseValue;
|
||||
|
||||
const asText: ScalarDecoder = (text) => text;
|
||||
const asBoolean: ScalarDecoder = (text) => text === "t";
|
||||
const asInteger: ScalarDecoder = (text) => Number.parseInt(text, 10);
|
||||
// `Number` reads PostgreSQL's NaN, Infinity, -Infinity, and exponent forms. With
|
||||
// `extra_float_digits` pinned to 1 the text is the shortest form that round-trips exactly.
|
||||
const asFloat: ScalarDecoder = (text) => Number(text);
|
||||
|
||||
function hexDigit(code: number): number {
|
||||
if (code >= 48 && code <= 57) {
|
||||
return code - 48;
|
||||
}
|
||||
if (code >= 97 && code <= 102) {
|
||||
return code - 87;
|
||||
}
|
||||
if (code >= 65 && code <= 70) {
|
||||
return code - 55;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
/**
|
||||
* `bytea` text to bytes. The executor pins `bytea_output` to `hex`; the older `escape` form is read
|
||||
* too, so a transaction that changed the setting still decodes faithfully rather than failing.
|
||||
* Always a plain `Uint8Array`, never a `Buffer`.
|
||||
*/
|
||||
const asBytes: ScalarDecoder = (text) => {
|
||||
if (text.startsWith("\\x")) {
|
||||
const length = (text.length - 2) / 2;
|
||||
const bytes = new Uint8Array(length);
|
||||
for (let index = 0; index < length; index += 1) {
|
||||
const high = hexDigit(text.charCodeAt(2 + index * 2));
|
||||
const low = hexDigit(text.charCodeAt(3 + index * 2));
|
||||
bytes[index] = high * 16 + low;
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
const bytes: number[] = [];
|
||||
for (let index = 0; index < text.length; index += 1) {
|
||||
const code = text.charCodeAt(index);
|
||||
if (code === 92 && text.charCodeAt(index + 1) === 92) {
|
||||
bytes.push(92);
|
||||
index += 1;
|
||||
} else if (code === 92) {
|
||||
bytes.push(Number.parseInt(text.slice(index + 1, index + 4), 8));
|
||||
index += 3;
|
||||
} else {
|
||||
bytes.push(code);
|
||||
}
|
||||
}
|
||||
return Uint8Array.from(bytes);
|
||||
};
|
||||
|
||||
/** Built-in scalar type OIDs with a decoding other than "PostgreSQL's text output, unchanged". Every
|
||||
* type not listed here, `int8`, `numeric`, date and time types, `json`, and `jsonb` included,
|
||||
* arrives as its exact text. */
|
||||
const scalarDecoders: ReadonlyMap<number, ScalarDecoder> = new Map([
|
||||
[16, asBoolean], // bool
|
||||
[17, asBytes], // bytea
|
||||
[21, asInteger], // int2
|
||||
[23, asInteger], // int4
|
||||
[26, asInteger], // oid
|
||||
[700, asFloat], // float4
|
||||
[701, asFloat], // float8
|
||||
]);
|
||||
|
||||
/** Built-in array type OIDs, each with its element type OID. An array of a type not listed here
|
||||
* (an enum, a domain, an extension type) arrives as its text, like any other type. */
|
||||
const arrayElementTypes: ReadonlyMap<number, number> = new Map([
|
||||
[1000, 16], // bool[]
|
||||
[1001, 17], // bytea[]
|
||||
[1002, 18], // "char"[]
|
||||
[1003, 19], // name[]
|
||||
[1005, 21], // int2[]
|
||||
[1007, 23], // int4[]
|
||||
[1009, 25], // text[]
|
||||
[1014, 1042], // bpchar[]
|
||||
[1015, 1043], // varchar[]
|
||||
[1016, 20], // int8[]
|
||||
[1021, 700], // float4[]
|
||||
[1022, 701], // float8[]
|
||||
[1028, 26], // oid[]
|
||||
[1115, 1114], // timestamp[]
|
||||
[1182, 1082], // date[]
|
||||
[1183, 1083], // time[]
|
||||
[1185, 1184], // timestamptz[]
|
||||
[1187, 1186], // interval[]
|
||||
[1231, 1700], // numeric[]
|
||||
[1270, 1266], // timetz[]
|
||||
[2951, 2950], // uuid[]
|
||||
[199, 114], // json[]
|
||||
[3807, 3802], // jsonb[]
|
||||
]);
|
||||
|
||||
class ArrayTextError extends Error {}
|
||||
|
||||
/**
|
||||
* Parses PostgreSQL's array text output into nested arrays, each element decoded by `decode`.
|
||||
* Unquoted `NULL` is null; a quoted `"NULL"` is the string. A non-default lower bound
|
||||
* (`[0:1]={…}`) is not represented: a JavaScript array is a zero-based list. Every element type this
|
||||
* decodes uses `,` as its delimiter.
|
||||
*/
|
||||
export function parsePostgresArrayText(text: string, decode: ScalarDecoder): ModuleDatabaseValue[] {
|
||||
let position = 0;
|
||||
if (text.startsWith("[")) {
|
||||
const equals = text.indexOf("=");
|
||||
if (equals === -1) {
|
||||
throw new ArrayTextError();
|
||||
}
|
||||
position = equals + 1;
|
||||
}
|
||||
|
||||
function parseArray(): ModuleDatabaseValue[] {
|
||||
if (text[position] !== "{") {
|
||||
throw new ArrayTextError();
|
||||
}
|
||||
position += 1;
|
||||
const items: ModuleDatabaseValue[] = [];
|
||||
if (text[position] === "}") {
|
||||
position += 1;
|
||||
return items;
|
||||
}
|
||||
for (;;) {
|
||||
const character = text[position];
|
||||
if (character === "{") {
|
||||
items.push(parseArray());
|
||||
} else if (character === '"') {
|
||||
position += 1;
|
||||
let element = "";
|
||||
for (;;) {
|
||||
const next = text[position];
|
||||
if (next === undefined) {
|
||||
throw new ArrayTextError();
|
||||
}
|
||||
if (next === "\\") {
|
||||
element += text[position + 1] ?? "";
|
||||
position += 2;
|
||||
} else if (next === '"') {
|
||||
position += 1;
|
||||
break;
|
||||
} else {
|
||||
element += next;
|
||||
position += 1;
|
||||
}
|
||||
}
|
||||
items.push(decode(element));
|
||||
} else {
|
||||
const start = position;
|
||||
while (position < text.length && text[position] !== "," && text[position] !== "}") {
|
||||
position += 1;
|
||||
}
|
||||
const element = text.slice(start, position);
|
||||
items.push(element.toUpperCase() === "NULL" ? null : decode(element));
|
||||
}
|
||||
if (text[position] === ",") {
|
||||
position += 1;
|
||||
} else if (text[position] === "}") {
|
||||
position += 1;
|
||||
return items;
|
||||
} else {
|
||||
throw new ArrayTextError();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const parsed = parseArray();
|
||||
if (position !== text.length) {
|
||||
throw new ArrayTextError();
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function scalarDecoder(typeOid: number): ScalarDecoder {
|
||||
return scalarDecoders.get(typeOid) ?? asText;
|
||||
}
|
||||
|
||||
/** Decodes one non-null column value from its PostgreSQL text by the column's type OID. */
|
||||
export function decodeModuleDatabaseValue(typeOid: number, text: string): ModuleDatabaseValue {
|
||||
const elementType = arrayElementTypes.get(typeOid);
|
||||
if (elementType === undefined) {
|
||||
return scalarDecoder(typeOid)(text);
|
||||
}
|
||||
try {
|
||||
return parsePostgresArrayText(text, scalarDecoder(elementType));
|
||||
} catch {
|
||||
// PostgreSQL produced this text, so it cannot be malformed; should it ever be, the column still
|
||||
// arrives faithfully, as text, rather than as a guess.
|
||||
return text;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The `types` object handed to `pg` for every ABI statement. Its parser returns the raw text for
|
||||
* every type, so the driver's global registry, which module code could change, is never consulted
|
||||
* and the ABI decodes from text itself. It is an empty array as well because the driver also reads
|
||||
* the same option as the Parse message's parameter-type list: empty, so PostgreSQL infers every
|
||||
* parameter type from the statement.
|
||||
*/
|
||||
export const rawTextTypes: { getTypeParser: () => (text: string) => string } & never[] =
|
||||
Object.freeze(
|
||||
Object.assign([] as never[], {
|
||||
getTypeParser: () => (text: string) => text,
|
||||
}),
|
||||
);
|
||||
|
||||
/** Decodes one positional row of raw driver text. */
|
||||
export function decodeModuleDatabaseRow(
|
||||
fields: readonly ModuleDatabaseField[],
|
||||
raw: readonly unknown[],
|
||||
): ModuleDatabaseValue[] {
|
||||
return fields.map((field, index) => {
|
||||
const value = raw[index];
|
||||
return typeof value === "string" ? decodeModuleDatabaseValue(field.typeOid, value) : null;
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,596 @@
|
||||
import {
|
||||
MODULE_DATABASE_ABI_VERSION,
|
||||
type ModuleDatabaseArrayRow,
|
||||
ModuleDatabaseError,
|
||||
type ModuleDatabaseErrorCode,
|
||||
type ModuleDatabaseExecutor,
|
||||
type ModuleDatabaseField,
|
||||
type ModuleDatabaseObjectRow,
|
||||
type ModuleDatabaseQueryable,
|
||||
type ModuleDatabaseQueryResult,
|
||||
type ModuleDatabaseTransaction,
|
||||
type ModuleId,
|
||||
moduleIdSchema,
|
||||
} from "@ekklesia/contracts";
|
||||
import { DatabaseError, type Pool, type PoolClient, type QueryArrayConfig } from "pg";
|
||||
import {
|
||||
decodeModuleDatabaseRow,
|
||||
encodeModuleDatabaseParameters,
|
||||
rawTextTypes,
|
||||
} from "./database-abi-values.js";
|
||||
|
||||
/**
|
||||
* Core's Module Database ABI v1 executor (ADR 0050).
|
||||
*
|
||||
* A module hands Core SQL text, ordered parameter values, and a row mode; Core hands back decoded
|
||||
* rows, column metadata, and stable errors. No ORM, driver, pool, client, or connection object ever
|
||||
* crosses in either direction. The executor is built over the one pool the API already owns
|
||||
* (ADR 0016) and never closes it.
|
||||
*
|
||||
* This is a correctness and compatibility boundary, **not a security sandbox**: a trusted module can
|
||||
* still send any SQL its database role permits. Nothing here inspects table names.
|
||||
*
|
||||
* ### Connections and session state
|
||||
*
|
||||
* Every ABI operation, a top-level statement or a whole transaction, borrows a connection from the
|
||||
* pool, pins the session settings the ABI's decoding depends on, does its work, and resets the
|
||||
* session with `DISCARD ALL` before returning the connection. So no setting, temporary table,
|
||||
* listener, advisory lock, prepared statement, or role change a module makes survives the operation
|
||||
* that made it, and nothing the ABI pinned reaches the pool's other users. A connection that cannot
|
||||
* be reset cleanly, or that is not idle when the operation ends, is destroyed rather than returned.
|
||||
* `DISCARD ALL` also deallocates named prepared statements; Core uses none on the shared pool.
|
||||
*
|
||||
* ### One statement per `query`
|
||||
*
|
||||
* Every ABI statement is sent with PostgreSQL's extended query protocol, forced with the driver's
|
||||
* `queryMode: "extended"` even when there are no parameters. PostgreSQL itself refuses a Parse
|
||||
* message holding more than one statement (SQLSTATE 42601), using its own SQL grammar, so comments,
|
||||
* quoted literals, and semicolons inside them are never mistaken for a second statement. Core does
|
||||
* not parse SQL. A statement text that holds no statement at all is refused after PostgreSQL reports
|
||||
* it empty.
|
||||
*
|
||||
* ### Transaction control belongs to the callbacks
|
||||
*
|
||||
* Transaction-control SQL (`BEGIN`, `START TRANSACTION`, `COMMIT`, `ROLLBACK`, `SAVEPOINT`,
|
||||
* `RELEASE SAVEPOINT`, `PREPARE TRANSACTION`, and their aliases) is forbidden through `query`: a
|
||||
* conformance rule for trusted module code, not a security guarantee. Core cannot refuse such a
|
||||
* statement beforehand without parsing SQL, which it does not do, and it cannot undo one PostgreSQL
|
||||
* has already executed: a `COMMIT` sent through a transaction's `query` has committed by the time
|
||||
* Core sees it. What Core does is detect the violation afterwards, from what PostgreSQL itself
|
||||
* reports (the statement's command tag, and the protocol's transaction status), and fail safely:
|
||||
* the call rejects, an enclosing transaction is never committed by Core and rejects too, and the
|
||||
* connection is destroyed rather than returned to the pool. The commit and rollback guarantees of
|
||||
* the transaction API apply to conforming use.
|
||||
*/
|
||||
|
||||
/** The settings the ABI's decoding and determinism depend on, pinned for every ABI operation. */
|
||||
const pinSessionStatement =
|
||||
"SET TimeZone TO 'UTC'; SET DateStyle TO 'ISO, MDY'; SET IntervalStyle TO 'postgres'; " +
|
||||
"SET bytea_output TO 'hex'; SET extra_float_digits TO 1";
|
||||
const resetSessionStatement = "DISCARD ALL";
|
||||
|
||||
const transactionControlCommands: ReadonlySet<string> = new Set([
|
||||
"BEGIN",
|
||||
"COMMIT",
|
||||
"PREPARE",
|
||||
"RELEASE",
|
||||
"ROLLBACK",
|
||||
"SAVEPOINT",
|
||||
"START",
|
||||
]);
|
||||
|
||||
const isolationStatements: ReadonlyMap<unknown, string> = new Map([
|
||||
["read committed", "READ COMMITTED"],
|
||||
["repeatable read", "REPEATABLE READ"],
|
||||
["serializable", "SERIALIZABLE"],
|
||||
]);
|
||||
|
||||
/** `pg` 8.22 reports the protocol's ReadyForQuery transaction status through this method, which
|
||||
* its type declarations omit. `acquire` refuses a client without it rather than guessing. */
|
||||
type AbiClient = PoolClient & { getTransactionStatus(): string | null };
|
||||
|
||||
interface PreparedQuery {
|
||||
readonly config: QueryArrayConfig;
|
||||
readonly rowMode: "array" | "object";
|
||||
}
|
||||
|
||||
interface RawResult {
|
||||
readonly command: string | null;
|
||||
readonly fields: readonly { readonly dataTypeID: number; readonly name: string }[];
|
||||
readonly rowCount: number | null;
|
||||
readonly rows: readonly unknown[][];
|
||||
}
|
||||
|
||||
function databaseError(code: ModuleDatabaseErrorCode, cause?: unknown): ModuleDatabaseError {
|
||||
// Only PostgreSQL's own SQLSTATE survives. The driver's message, detail, hint, position, and
|
||||
// object names, and every non-PostgreSQL error, are dropped here.
|
||||
if (cause instanceof DatabaseError && typeof cause.code === "string") {
|
||||
return new ModuleDatabaseError(code, { sqlState: cause.code });
|
||||
}
|
||||
return new ModuleDatabaseError(code);
|
||||
}
|
||||
|
||||
/** A PostgreSQL error leaves the connection usable; anything else (a dropped connection, a
|
||||
* client-side timeout) may leave it mid-protocol, so it must be destroyed. */
|
||||
function connectionSurvives(error: unknown): boolean {
|
||||
return error instanceof DatabaseError;
|
||||
}
|
||||
|
||||
function prepareQuery(request: unknown): PreparedQuery {
|
||||
if (typeof request !== "object" || request === null || Array.isArray(request)) {
|
||||
throw new ModuleDatabaseError("MODULE_DATABASE_REQUEST_INVALID");
|
||||
}
|
||||
for (const key of Object.keys(request)) {
|
||||
if (key !== "text" && key !== "values" && key !== "rowMode") {
|
||||
throw new ModuleDatabaseError("MODULE_DATABASE_REQUEST_INVALID");
|
||||
}
|
||||
}
|
||||
const { rowMode, text, values } = request as Record<string, unknown>;
|
||||
if (typeof text !== "string" || text.length === 0) {
|
||||
throw new ModuleDatabaseError("MODULE_DATABASE_REQUEST_INVALID");
|
||||
}
|
||||
if (rowMode !== undefined && rowMode !== "object" && rowMode !== "array") {
|
||||
throw new ModuleDatabaseError("MODULE_DATABASE_REQUEST_INVALID");
|
||||
}
|
||||
const config = {
|
||||
queryMode: "extended",
|
||||
rowMode: "array",
|
||||
text,
|
||||
types: rawTextTypes,
|
||||
values: encodeModuleDatabaseParameters(values),
|
||||
};
|
||||
return { config: config as unknown as QueryArrayConfig, rowMode: rowMode ?? "object" };
|
||||
}
|
||||
|
||||
function beginStatement(options: unknown): string {
|
||||
if (options === undefined) {
|
||||
return "BEGIN ISOLATION LEVEL READ COMMITTED READ WRITE";
|
||||
}
|
||||
if (typeof options !== "object" || options === null || Array.isArray(options)) {
|
||||
throw new ModuleDatabaseError("MODULE_DATABASE_TRANSACTION_OPTION_INVALID");
|
||||
}
|
||||
for (const key of Object.keys(options)) {
|
||||
if (key !== "isolation" && key !== "readOnly") {
|
||||
throw new ModuleDatabaseError("MODULE_DATABASE_TRANSACTION_OPTION_INVALID");
|
||||
}
|
||||
}
|
||||
const { isolation, readOnly } = options as Record<string, unknown>;
|
||||
const level = isolation === undefined ? "READ COMMITTED" : isolationStatements.get(isolation);
|
||||
if (level === undefined || (readOnly !== undefined && typeof readOnly !== "boolean")) {
|
||||
throw new ModuleDatabaseError("MODULE_DATABASE_TRANSACTION_OPTION_INVALID");
|
||||
}
|
||||
return `BEGIN ISOLATION LEVEL ${level} ${readOnly === true ? "READ ONLY" : "READ WRITE"}`;
|
||||
}
|
||||
|
||||
function buildResult(
|
||||
raw: RawResult,
|
||||
rowMode: "array" | "object",
|
||||
): ModuleDatabaseQueryResult<ModuleDatabaseArrayRow | ModuleDatabaseObjectRow> {
|
||||
if (typeof raw.command !== "string" || raw.command === "") {
|
||||
// PostgreSQL answered with EmptyQueryResponse: the text held no statement.
|
||||
throw new ModuleDatabaseError("MODULE_DATABASE_REQUEST_INVALID");
|
||||
}
|
||||
const fields: readonly ModuleDatabaseField[] = Object.freeze(
|
||||
raw.fields.map((field) => Object.freeze({ name: field.name, typeOid: field.dataTypeID })),
|
||||
);
|
||||
const decoded = raw.rows.map((row) => decodeModuleDatabaseRow(fields, row));
|
||||
let rows: readonly (ModuleDatabaseArrayRow | ModuleDatabaseObjectRow)[];
|
||||
if (rowMode === "array") {
|
||||
rows = decoded;
|
||||
} else {
|
||||
if (new Set(fields.map((field) => field.name)).size !== fields.length) {
|
||||
// Object rows cannot hold two columns with one name; refusing beats dropping one.
|
||||
throw new ModuleDatabaseError("MODULE_DATABASE_REQUEST_INVALID");
|
||||
}
|
||||
// `Object.fromEntries` defines properties, so a column named `__proto__` stays a column.
|
||||
rows = decoded.map((row) =>
|
||||
Object.fromEntries(fields.map((field, index) => [field.name, row[index] ?? null])),
|
||||
);
|
||||
}
|
||||
return Object.freeze({
|
||||
command: raw.command,
|
||||
fields,
|
||||
rowCount: raw.rowCount ?? null,
|
||||
rows: Object.freeze(rows),
|
||||
});
|
||||
}
|
||||
|
||||
/** A borrowed client's `error` events are already reported through the statement they interrupt.
|
||||
* The pool only listens to idle clients, so without this a connection the server terminates while
|
||||
* borrowed would surface as an unhandled `error` event. */
|
||||
function ignoreClientError(): void {}
|
||||
|
||||
function destroy(client: AbiClient): void {
|
||||
client.removeListener("error", ignoreClientError);
|
||||
client.release(true);
|
||||
}
|
||||
|
||||
async function acquire(pool: Pool, failure: ModuleDatabaseErrorCode): Promise<AbiClient> {
|
||||
let client: AbiClient;
|
||||
try {
|
||||
client = (await pool.connect()) as AbiClient;
|
||||
} catch (error) {
|
||||
throw databaseError(failure, error);
|
||||
}
|
||||
client.on("error", ignoreClientError);
|
||||
if (typeof client.getTransactionStatus !== "function") {
|
||||
destroy(client);
|
||||
throw new ModuleDatabaseError(failure);
|
||||
}
|
||||
try {
|
||||
await client.query(pinSessionStatement);
|
||||
} catch (error) {
|
||||
destroy(client);
|
||||
throw databaseError(failure, error);
|
||||
}
|
||||
return client;
|
||||
}
|
||||
|
||||
/** Returns a connection to the pool only when it is idle and resets cleanly; destroys it
|
||||
* otherwise. Never throws. */
|
||||
async function relinquish(client: AbiClient, healthy: boolean): Promise<void> {
|
||||
if (!healthy || client.getTransactionStatus() !== "I") {
|
||||
destroy(client);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await client.query(resetSessionStatement);
|
||||
} catch {
|
||||
destroy(client);
|
||||
return;
|
||||
}
|
||||
client.removeListener("error", ignoreClientError);
|
||||
client.release();
|
||||
}
|
||||
|
||||
/** One open scope: a transaction or a savepoint inside it. */
|
||||
interface Scope {
|
||||
activeChild: Scope | undefined;
|
||||
readonly children: Set<Scope>;
|
||||
closed: boolean;
|
||||
/** Operations started through this scope's executor that have not settled yet. */
|
||||
pending: number;
|
||||
}
|
||||
|
||||
interface TransactionState {
|
||||
readonly client: AbiClient;
|
||||
healthy: boolean;
|
||||
/** Set when the transaction can no longer be committed: transaction control sent through
|
||||
* `query`, or a savepoint that could not be undone. */
|
||||
failure: ModuleDatabaseError | undefined;
|
||||
finished: boolean;
|
||||
/** Settled-never-rejecting shadows of every statement sent on the connection. */
|
||||
readonly inflight: Set<Promise<void>>;
|
||||
savepoints: number;
|
||||
}
|
||||
|
||||
function newScope(parent?: Scope): Scope {
|
||||
const scope: Scope = { activeChild: undefined, children: new Set(), closed: false, pending: 0 };
|
||||
parent?.children.add(scope);
|
||||
return scope;
|
||||
}
|
||||
|
||||
function closeScope(scope: Scope): void {
|
||||
scope.closed = true;
|
||||
for (const child of scope.children) {
|
||||
closeScope(child);
|
||||
}
|
||||
}
|
||||
|
||||
function assertActive(scope: Scope): void {
|
||||
if (scope.closed) {
|
||||
throw new ModuleDatabaseError("MODULE_DATABASE_SCOPE_CLOSED");
|
||||
}
|
||||
if (scope.activeChild !== undefined) {
|
||||
throw new ModuleDatabaseError("MODULE_DATABASE_SCOPE_INACTIVE");
|
||||
}
|
||||
}
|
||||
|
||||
/** Once a transaction can no longer be committed, its scopes send nothing more. After a
|
||||
* transaction-control violation in particular, a later statement would otherwise run outside any
|
||||
* transaction, as its own autocommitted statement. */
|
||||
function assertUsable(state: TransactionState): void {
|
||||
if (state.failure !== undefined) {
|
||||
throw new ModuleDatabaseError(state.failure.code);
|
||||
}
|
||||
}
|
||||
|
||||
function track<T>(state: TransactionState, promise: Promise<T>): Promise<T> {
|
||||
const shadow = promise.then(
|
||||
() => undefined,
|
||||
() => undefined,
|
||||
);
|
||||
state.inflight.add(shadow);
|
||||
void shadow.then(() => state.inflight.delete(shadow));
|
||||
return promise;
|
||||
}
|
||||
|
||||
/** Waits until every statement already sent has settled, including any sent while waiting. */
|
||||
async function drain(state: TransactionState): Promise<void> {
|
||||
while (state.inflight.size > 0) {
|
||||
await Promise.all([...state.inflight]);
|
||||
}
|
||||
}
|
||||
|
||||
/** Runs one of Core's own transaction-control statements. Refuses, without sending anything, once
|
||||
* the transaction has finished. */
|
||||
async function control(state: TransactionState, statement: string): Promise<void> {
|
||||
if (state.finished) {
|
||||
throw new ModuleDatabaseError("MODULE_DATABASE_SCOPE_CLOSED");
|
||||
}
|
||||
try {
|
||||
await track(state, state.client.query(statement));
|
||||
} catch (error) {
|
||||
if (!connectionSurvives(error)) {
|
||||
state.healthy = false;
|
||||
}
|
||||
throw databaseError("MODULE_DATABASE_TRANSACTION_FAILED", error);
|
||||
}
|
||||
}
|
||||
|
||||
async function rollback(state: TransactionState): Promise<void> {
|
||||
try {
|
||||
await state.client.query("ROLLBACK");
|
||||
} catch {
|
||||
state.healthy = false;
|
||||
}
|
||||
}
|
||||
|
||||
type Outcome<T> = { readonly value: T } | { readonly error: unknown };
|
||||
|
||||
async function settle<T>(run: () => Promise<T> | T): Promise<Outcome<T>> {
|
||||
try {
|
||||
return { value: await run() };
|
||||
} catch (error) {
|
||||
return { error };
|
||||
}
|
||||
}
|
||||
|
||||
const moduleByExecutor = new WeakMap<object, ModuleId>();
|
||||
|
||||
/** Which module an ABI executor, or a transaction or savepoint executor derived from it, belongs
|
||||
* to. For Core's own logging, diagnostics, and attribution; never exposed to modules. */
|
||||
export function moduleDatabaseExecutorModuleId(executor: object): ModuleId | undefined {
|
||||
return moduleByExecutor.get(executor);
|
||||
}
|
||||
|
||||
export interface ModuleDatabaseExecutorOptions {
|
||||
/** The module this executor serves. Carried for attribution only: the ABI enforces no table
|
||||
* ownership at run time (ADR 0050). */
|
||||
readonly moduleId: ModuleId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates one module's ABI v1 executor over the API's existing pool. Synchronous and free of I/O:
|
||||
* nothing touches the database until the module calls it. The returned object, and every
|
||||
* transaction and savepoint executor it hands out, is frozen and holds exactly the ABI's members.
|
||||
*/
|
||||
export function createModuleDatabaseExecutor(
|
||||
pool: Pool,
|
||||
options: ModuleDatabaseExecutorOptions,
|
||||
): ModuleDatabaseExecutor {
|
||||
const parsedModuleId = moduleIdSchema.safeParse(options.moduleId);
|
||||
if (!parsedModuleId.success) {
|
||||
throw new TypeError("A module database executor needs a valid module ID.");
|
||||
}
|
||||
const moduleId = parsedModuleId.data;
|
||||
|
||||
async function query(
|
||||
request: unknown,
|
||||
): Promise<ModuleDatabaseQueryResult<ModuleDatabaseArrayRow | ModuleDatabaseObjectRow>> {
|
||||
const prepared = prepareQuery(request);
|
||||
const client = await acquire(pool, "MODULE_DATABASE_QUERY_FAILED");
|
||||
let healthy = true;
|
||||
try {
|
||||
let raw: RawResult;
|
||||
try {
|
||||
raw = (await client.query(prepared.config)) as unknown as RawResult;
|
||||
} catch (error) {
|
||||
healthy = connectionSurvives(error);
|
||||
throw databaseError("MODULE_DATABASE_QUERY_FAILED", error);
|
||||
}
|
||||
if (
|
||||
(raw.command !== null && transactionControlCommands.has(raw.command)) ||
|
||||
client.getTransactionStatus() !== "I"
|
||||
) {
|
||||
// A transaction-control violation. PostgreSQL has already run it; the connection is
|
||||
// destroyed below rather than returned in a state Core did not choose.
|
||||
healthy = false;
|
||||
throw new ModuleDatabaseError("MODULE_DATABASE_REQUEST_INVALID");
|
||||
}
|
||||
return buildResult(raw, prepared.rowMode);
|
||||
} finally {
|
||||
await relinquish(client, healthy);
|
||||
}
|
||||
}
|
||||
|
||||
function scopeExecutor(state: TransactionState, scope: Scope): ModuleDatabaseTransaction {
|
||||
async function scopeQuery(
|
||||
request: unknown,
|
||||
): Promise<ModuleDatabaseQueryResult<ModuleDatabaseArrayRow | ModuleDatabaseObjectRow>> {
|
||||
assertActive(scope);
|
||||
assertUsable(state);
|
||||
const prepared = prepareQuery(request);
|
||||
scope.pending += 1;
|
||||
try {
|
||||
let raw: RawResult;
|
||||
try {
|
||||
raw = (await track(state, state.client.query(prepared.config))) as unknown as RawResult;
|
||||
} catch (error) {
|
||||
if (!connectionSurvives(error)) {
|
||||
state.healthy = false;
|
||||
}
|
||||
throw databaseError("MODULE_DATABASE_QUERY_FAILED", error);
|
||||
}
|
||||
if (
|
||||
(raw.command !== null && transactionControlCommands.has(raw.command)) ||
|
||||
state.client.getTransactionStatus() === "I"
|
||||
) {
|
||||
// A transaction-control violation. PostgreSQL has already run it, and Core cannot undo
|
||||
// that; it can only never commit this transaction itself, and destroy the connection.
|
||||
state.failure ??= new ModuleDatabaseError("MODULE_DATABASE_REQUEST_INVALID");
|
||||
state.healthy = false;
|
||||
throw new ModuleDatabaseError("MODULE_DATABASE_REQUEST_INVALID");
|
||||
}
|
||||
return buildResult(raw, prepared.rowMode);
|
||||
} finally {
|
||||
scope.pending -= 1;
|
||||
}
|
||||
}
|
||||
|
||||
async function savepoint<T>(
|
||||
callback: (savepoint: ModuleDatabaseTransaction) => Promise<T> | T,
|
||||
): Promise<T> {
|
||||
assertActive(scope);
|
||||
assertUsable(state);
|
||||
if (typeof callback !== "function") {
|
||||
throw new ModuleDatabaseError("MODULE_DATABASE_REQUEST_INVALID");
|
||||
}
|
||||
scope.pending += 1;
|
||||
const child = newScope(scope);
|
||||
scope.activeChild = child;
|
||||
state.savepoints += 1;
|
||||
// Core's own identifier: a fixed prefix and a counter, never anything a module supplied.
|
||||
const name = `ekklesia_savepoint_${state.savepoints}`;
|
||||
try {
|
||||
await control(state, `SAVEPOINT ${name}`);
|
||||
if (child.closed) {
|
||||
throw new ModuleDatabaseError("MODULE_DATABASE_SCOPE_CLOSED");
|
||||
}
|
||||
const outcome = await settle(() => callback(scopeExecutor(state, child)));
|
||||
const outstanding = child.pending > 0;
|
||||
closeScope(child);
|
||||
await drain(state);
|
||||
if (scope.closed) {
|
||||
// The enclosing transaction finished first; it rolls back, and nothing more is sent.
|
||||
throw new ModuleDatabaseError("MODULE_DATABASE_SCOPE_CLOSED");
|
||||
}
|
||||
if ("error" in outcome) {
|
||||
// A rejected callback's own error stays the error, even if sibling work it started was
|
||||
// still settling: that work has been drained, and is undone here with everything else.
|
||||
try {
|
||||
await control(state, `ROLLBACK TO SAVEPOINT ${name}`);
|
||||
await control(state, `RELEASE SAVEPOINT ${name}`);
|
||||
} catch (error) {
|
||||
state.failure ??= new ModuleDatabaseError("MODULE_DATABASE_TRANSACTION_FAILED");
|
||||
throw error;
|
||||
}
|
||||
throw outcome.error;
|
||||
}
|
||||
if (outstanding) {
|
||||
// A callback that resolved with work outstanding forgot an await. Roll back to the
|
||||
// savepoint and never release it.
|
||||
try {
|
||||
await control(state, `ROLLBACK TO SAVEPOINT ${name}`);
|
||||
} catch (error) {
|
||||
state.failure ??= new ModuleDatabaseError("MODULE_DATABASE_TRANSACTION_FAILED");
|
||||
throw error;
|
||||
}
|
||||
throw new ModuleDatabaseError("MODULE_DATABASE_SCOPE_WORK_OUTSTANDING");
|
||||
}
|
||||
try {
|
||||
await control(state, `RELEASE SAVEPOINT ${name}`);
|
||||
} catch (releaseError) {
|
||||
// Typically a failed statement left the transaction aborted. Undo the savepoint so the
|
||||
// transaction stays usable, and report that this savepoint did not complete.
|
||||
try {
|
||||
await control(state, `ROLLBACK TO SAVEPOINT ${name}`);
|
||||
await control(state, `RELEASE SAVEPOINT ${name}`);
|
||||
} catch {
|
||||
state.failure ??= new ModuleDatabaseError("MODULE_DATABASE_TRANSACTION_FAILED");
|
||||
}
|
||||
throw releaseError;
|
||||
}
|
||||
return (outcome as { readonly value: T }).value;
|
||||
} finally {
|
||||
closeScope(child);
|
||||
if (scope.activeChild === child) {
|
||||
scope.activeChild = undefined;
|
||||
}
|
||||
scope.pending -= 1;
|
||||
}
|
||||
}
|
||||
|
||||
const executor = Object.freeze({
|
||||
abiVersion: MODULE_DATABASE_ABI_VERSION,
|
||||
query: scopeQuery as ModuleDatabaseQueryable["query"],
|
||||
savepoint,
|
||||
});
|
||||
moduleByExecutor.set(executor, moduleId);
|
||||
return executor;
|
||||
}
|
||||
|
||||
async function transaction<T>(
|
||||
callback: (transaction: ModuleDatabaseTransaction) => Promise<T> | T,
|
||||
options?: unknown,
|
||||
): Promise<T> {
|
||||
if (typeof callback !== "function") {
|
||||
throw new ModuleDatabaseError("MODULE_DATABASE_REQUEST_INVALID");
|
||||
}
|
||||
const begin = beginStatement(options);
|
||||
const client = await acquire(pool, "MODULE_DATABASE_TRANSACTION_FAILED");
|
||||
const state: TransactionState = {
|
||||
client,
|
||||
failure: undefined,
|
||||
finished: false,
|
||||
healthy: true,
|
||||
inflight: new Set(),
|
||||
savepoints: 0,
|
||||
};
|
||||
try {
|
||||
try {
|
||||
await client.query(begin);
|
||||
} catch (error) {
|
||||
state.healthy = connectionSurvives(error);
|
||||
throw databaseError("MODULE_DATABASE_TRANSACTION_FAILED", error);
|
||||
}
|
||||
const scope = newScope();
|
||||
const outcome = await settle(() => callback(scopeExecutor(state, scope)));
|
||||
const outstanding = scope.pending > 0;
|
||||
closeScope(scope);
|
||||
await drain(state);
|
||||
state.finished = true;
|
||||
|
||||
if ("error" in outcome) {
|
||||
// The callback's own error stays primary, even when sibling work it started (say, the
|
||||
// other half of a `Promise.all`) was still settling. That work has been drained, and rolls
|
||||
// back with the rest; outstanding work never leads to a commit.
|
||||
await rollback(state);
|
||||
throw outcome.error;
|
||||
}
|
||||
if (outstanding) {
|
||||
// The callback resolved with work it never awaited: a forgotten `await`.
|
||||
await rollback(state);
|
||||
throw new ModuleDatabaseError("MODULE_DATABASE_SCOPE_WORK_OUTSTANDING");
|
||||
}
|
||||
if (state.failure !== undefined) {
|
||||
await rollback(state);
|
||||
throw state.failure;
|
||||
}
|
||||
let committed: { readonly command: string | null };
|
||||
try {
|
||||
committed = await client.query("COMMIT");
|
||||
} catch (error) {
|
||||
state.healthy = connectionSurvives(error);
|
||||
throw databaseError("MODULE_DATABASE_TRANSACTION_FAILED", error);
|
||||
}
|
||||
if (committed.command !== "COMMIT") {
|
||||
// PostgreSQL answers COMMIT with ROLLBACK when a failed statement had aborted the
|
||||
// transaction. Nothing was committed, and PostgreSQL supplied no SQLSTATE to report.
|
||||
throw new ModuleDatabaseError("MODULE_DATABASE_TRANSACTION_FAILED");
|
||||
}
|
||||
return outcome.value;
|
||||
} finally {
|
||||
state.finished = true;
|
||||
await relinquish(client, state.healthy);
|
||||
}
|
||||
}
|
||||
|
||||
const executor = Object.freeze({
|
||||
abiVersion: MODULE_DATABASE_ABI_VERSION,
|
||||
query: query as ModuleDatabaseQueryable["query"],
|
||||
transaction: transaction as ModuleDatabaseExecutor["transaction"],
|
||||
});
|
||||
moduleByExecutor.set(executor, moduleId);
|
||||
return executor;
|
||||
}
|
||||
@@ -1,7 +1,54 @@
|
||||
import type { ModuleDatabase } from "@ekklesia/contracts";
|
||||
import { drizzle } from "drizzle-orm/node-postgres";
|
||||
import type { ModuleDatabaseAbiRuntimeServices, ModuleRuntimeServices } from "@ekklesia/contracts";
|
||||
import { drizzle, type NodePgDatabase } from "drizzle-orm/node-postgres";
|
||||
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
|
||||
* `createIdentityDrizzleView` (`core/src/identity/database.ts`) and
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
safeParseModuleManifest,
|
||||
semanticVersionSchema,
|
||||
} from "@ekklesia/contracts";
|
||||
import { isReservedModuleId } from "./reserved-ids.js";
|
||||
|
||||
export const MODULE_MANIFEST_FILENAME = "ekklesia.module.json";
|
||||
|
||||
@@ -39,6 +40,7 @@ export type ModuleDiscoveryIssueCode =
|
||||
| "MODULE_ENTRYPOINT_INVALID"
|
||||
| "MODULE_ENTRYPOINT_MISSING"
|
||||
| "MODULE_ID_DUPLICATE"
|
||||
| "MODULE_ID_RESERVED"
|
||||
| "MODULE_MANIFEST_INVALID"
|
||||
| "MODULE_MANIFEST_MISSING"
|
||||
| "MODULE_PACKAGE_INVALID"
|
||||
@@ -614,6 +616,18 @@ 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);
|
||||
if (!artifacts) {
|
||||
return undefined;
|
||||
|
||||
@@ -2,14 +2,18 @@ export * from "./configuration.js";
|
||||
export * from "./configuration-schema.js";
|
||||
export * from "./configuration-store.js";
|
||||
export * from "./database.js";
|
||||
export * from "./database-abi.js";
|
||||
export * from "./discovery.js";
|
||||
export * from "./lifecycle.js";
|
||||
export * from "./lifecycle-schema.js";
|
||||
export * from "./lifecycle-store.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 "./realtime.js";
|
||||
export * from "./reserved-ids.js";
|
||||
export * from "./runtime.js";
|
||||
export * from "./schema-discovery.js";
|
||||
export * from "./ui-publication.js";
|
||||
|
||||
+10
-31
@@ -1,19 +1,15 @@
|
||||
/**
|
||||
* The legacy module schema definition (ADR 0007): a v8 module that declares no `database` default-
|
||||
* exports `{ moduleId, tables }` with its Drizzle table objects themselves. Core alone validates it,
|
||||
* for the legacy facade's deprecation window (ADR 0050). It lives here, beside Core's own Drizzle
|
||||
* dependency, so that no package a module installs needs Drizzle at run time (ADR 0053).
|
||||
*
|
||||
* Erasable TypeScript with no relative imports: the verifier's probe imports this source file
|
||||
* directly in tests (`module-package-probe.ts`).
|
||||
*/
|
||||
import { is } from "drizzle-orm";
|
||||
import { type AnyPgTable, PgTable } from "drizzle-orm/pg-core";
|
||||
import { z } from "zod";
|
||||
import { type ModuleId, moduleIdSchema } from "./identifiers.js";
|
||||
|
||||
export const POSTGRES_IDENTIFIER_MAX_LENGTH = 63;
|
||||
|
||||
export const moduleTableLocalNameSchema = z
|
||||
.string()
|
||||
.min(1)
|
||||
.regex(/^[a-z][a-z0-9]*(?:_[a-z0-9]+)*$/u, "Must be a lowercase snake-case table name.");
|
||||
|
||||
export const physicalModuleTableNameSchema = moduleTableLocalNameSchema.max(
|
||||
POSTGRES_IDENTIFIER_MAX_LENGTH,
|
||||
`Must not exceed ${POSTGRES_IDENTIFIER_MAX_LENGTH} characters.`,
|
||||
);
|
||||
import { type ModuleId, moduleIdSchema } from "@ekklesia/contracts";
|
||||
|
||||
export interface ModuleSchemaDefinition<
|
||||
Tables extends Readonly<Record<string, AnyPgTable>> = Readonly<Record<string, AnyPgTable>>,
|
||||
@@ -35,23 +31,6 @@ function hasOnlyStringKeys(value: object, allowedKeys?: ReadonlySet<string>): bo
|
||||
);
|
||||
}
|
||||
|
||||
export function moduleTableNamespace(moduleId: ModuleId): string {
|
||||
return moduleIdSchema.parse(moduleId).replaceAll("-", "_");
|
||||
}
|
||||
|
||||
export function moduleTablePrefix(moduleId: ModuleId): string {
|
||||
return `${moduleTableNamespace(moduleId)}_`;
|
||||
}
|
||||
|
||||
export function moduleTableName(moduleId: ModuleId, localName: string): string {
|
||||
const parsedLocalName = moduleTableLocalNameSchema.parse(localName);
|
||||
const tableName = `${moduleTablePrefix(moduleId)}${parsedLocalName}`;
|
||||
if (!physicalModuleTableNameSchema.safeParse(tableName).success) {
|
||||
throw new RangeError("The namespaced PostgreSQL table name is invalid or too long.");
|
||||
}
|
||||
return tableName;
|
||||
}
|
||||
|
||||
export function isModuleSchemaDefinition(value: unknown): value is ModuleSchemaDefinition {
|
||||
try {
|
||||
if (
|
||||
@@ -0,0 +1,203 @@
|
||||
import { gunzipSync } from "node:zlib";
|
||||
|
||||
/**
|
||||
* Reads an Ekklesia Module Package v1 archive (ADR 0049) entirely in memory and enforces the
|
||||
* archive-safety rules before anything is written anywhere.
|
||||
*
|
||||
* Deliberately a strict reader rather than a general tar implementation: v1 packages hold only
|
||||
* POSIX ustar directories and regular files, so every other entry type is refused instead of
|
||||
* interpreted. Nothing here touches the filesystem; extraction happens only after the caller has
|
||||
* verified the whole package.
|
||||
*/
|
||||
|
||||
export type ModulePackageArchiveErrorCode =
|
||||
| "MODULE_PACKAGE_LAYOUT_INVALID"
|
||||
| "MODULE_PACKAGE_LINK"
|
||||
| "MODULE_PACKAGE_SPECIAL_ENTRY"
|
||||
| "MODULE_PACKAGE_TOO_LARGE"
|
||||
| "MODULE_PACKAGE_UNREADABLE"
|
||||
| "MODULE_PACKAGE_UNSAFE_MODE"
|
||||
| "MODULE_PACKAGE_UNSAFE_PATH";
|
||||
|
||||
export class ModulePackageArchiveError extends Error {
|
||||
readonly code: ModulePackageArchiveErrorCode;
|
||||
|
||||
constructor(code: ModulePackageArchiveErrorCode) {
|
||||
super(code);
|
||||
this.name = "ModulePackageArchiveError";
|
||||
this.code = code;
|
||||
}
|
||||
}
|
||||
|
||||
/** Generous for a module, small enough that a hostile archive cannot exhaust the host. */
|
||||
export const MODULE_PACKAGE_MAX_COMPRESSED_BYTES = 256 * 1024 * 1024;
|
||||
export const MODULE_PACKAGE_MAX_UNCOMPRESSED_BYTES = 1024 * 1024 * 1024;
|
||||
export const MODULE_PACKAGE_MAX_ENTRIES = 100_000;
|
||||
|
||||
export interface ModulePackageArchiveEntry {
|
||||
/** Relative to the top-level module directory; empty for that directory itself. */
|
||||
readonly path: string;
|
||||
readonly type: "directory" | "file";
|
||||
readonly data: Buffer;
|
||||
}
|
||||
|
||||
export interface ModulePackageArchive {
|
||||
/** The single top-level directory's name. Validated as a module id by the caller. */
|
||||
readonly topLevel: string;
|
||||
/** Every entry except the top-level directory itself, in archive order. */
|
||||
readonly entries: readonly ModulePackageArchiveEntry[];
|
||||
}
|
||||
|
||||
const BLOCK = 512;
|
||||
|
||||
function field(block: Buffer, offset: number, length: number): string {
|
||||
const raw = block.subarray(offset, offset + length);
|
||||
const end = raw.indexOf(0);
|
||||
return raw.subarray(0, end === -1 ? length : end).toString("latin1");
|
||||
}
|
||||
|
||||
function octalField(block: Buffer, offset: number, length: number): number {
|
||||
const text = field(block, offset, length).trim();
|
||||
if (!/^[0-7]+$/u.test(text)) {
|
||||
throw new ModulePackageArchiveError("MODULE_PACKAGE_UNREADABLE");
|
||||
}
|
||||
const value = Number.parseInt(text, 8);
|
||||
if (!Number.isSafeInteger(value)) {
|
||||
throw new ModulePackageArchiveError("MODULE_PACKAGE_UNREADABLE");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function headerChecksumValid(block: Buffer): boolean {
|
||||
const declared = octalField(block, 148, 8);
|
||||
let sum = 0;
|
||||
for (let index = 0; index < BLOCK; index += 1) {
|
||||
sum += index >= 148 && index < 156 ? 0x20 : (block[index] ?? 0);
|
||||
}
|
||||
return sum === declared;
|
||||
}
|
||||
|
||||
/** Printable ASCII, no backslash, relative, and no empty, `.`, or `..` segment. */
|
||||
function safeSegments(value: string): readonly string[] {
|
||||
if (
|
||||
value.length === 0 ||
|
||||
!/^[\x20-\x7e]+$/u.test(value) ||
|
||||
value.includes("\\") ||
|
||||
value.startsWith("/")
|
||||
) {
|
||||
throw new ModulePackageArchiveError("MODULE_PACKAGE_UNSAFE_PATH");
|
||||
}
|
||||
const segments = value.split("/");
|
||||
if (segments.some((segment) => segment === "" || segment === "." || segment === "..")) {
|
||||
throw new ModulePackageArchiveError("MODULE_PACKAGE_UNSAFE_PATH");
|
||||
}
|
||||
return segments;
|
||||
}
|
||||
|
||||
export function readModulePackageArchive(compressed: Buffer): ModulePackageArchive {
|
||||
if (compressed.length > MODULE_PACKAGE_MAX_COMPRESSED_BYTES) {
|
||||
throw new ModulePackageArchiveError("MODULE_PACKAGE_TOO_LARGE");
|
||||
}
|
||||
let raw: Buffer;
|
||||
try {
|
||||
raw = gunzipSync(compressed, { maxOutputLength: MODULE_PACKAGE_MAX_UNCOMPRESSED_BYTES });
|
||||
} catch (error) {
|
||||
throw new ModulePackageArchiveError(
|
||||
error instanceof RangeError ? "MODULE_PACKAGE_TOO_LARGE" : "MODULE_PACKAGE_UNREADABLE",
|
||||
);
|
||||
}
|
||||
|
||||
const entries: ModulePackageArchiveEntry[] = [];
|
||||
const kinds = new Map<string, "directory" | "file">();
|
||||
/** Every path that is an ancestor of some entry, so a file can never claim one. */
|
||||
const ancestors = new Set<string>();
|
||||
let topLevel: string | undefined;
|
||||
let offset = 0;
|
||||
let ended = false;
|
||||
|
||||
while (offset + BLOCK <= raw.length) {
|
||||
const block = raw.subarray(offset, offset + BLOCK);
|
||||
if (block.every((byte) => byte === 0)) {
|
||||
ended = true;
|
||||
break;
|
||||
}
|
||||
if (field(block, 257, 6) !== "ustar" || field(block, 263, 2) !== "00") {
|
||||
throw new ModulePackageArchiveError("MODULE_PACKAGE_UNREADABLE");
|
||||
}
|
||||
if (!headerChecksumValid(block)) {
|
||||
throw new ModulePackageArchiveError("MODULE_PACKAGE_UNREADABLE");
|
||||
}
|
||||
if (entries.length + 1 > MODULE_PACKAGE_MAX_ENTRIES) {
|
||||
throw new ModulePackageArchiveError("MODULE_PACKAGE_TOO_LARGE");
|
||||
}
|
||||
|
||||
const typeflag = field(block, 156, 1);
|
||||
if (typeflag === "1" || typeflag === "2") {
|
||||
throw new ModulePackageArchiveError("MODULE_PACKAGE_LINK");
|
||||
}
|
||||
const type =
|
||||
typeflag === "5" ? "directory" : typeflag === "0" || typeflag === "" ? "file" : undefined;
|
||||
if (type === undefined) {
|
||||
// Devices, FIFOs, contiguous files, and every extended or long-name header: v1 packages hold
|
||||
// directories and regular files, so nothing else is interpreted.
|
||||
throw new ModulePackageArchiveError("MODULE_PACKAGE_SPECIAL_ENTRY");
|
||||
}
|
||||
|
||||
const mode = octalField(block, 100, 8);
|
||||
if ((mode & 0o7000) !== 0) {
|
||||
throw new ModulePackageArchiveError("MODULE_PACKAGE_UNSAFE_MODE");
|
||||
}
|
||||
|
||||
const name = field(block, 0, 100);
|
||||
const prefix = field(block, 345, 155);
|
||||
let fullPath = prefix === "" ? name : `${prefix}/${name}`;
|
||||
if (type === "directory" && fullPath.endsWith("/")) {
|
||||
fullPath = fullPath.slice(0, -1);
|
||||
}
|
||||
const segments = safeSegments(fullPath);
|
||||
const top = segments[0] ?? "";
|
||||
if (topLevel === undefined) {
|
||||
topLevel = top;
|
||||
} else if (top !== topLevel) {
|
||||
throw new ModulePackageArchiveError("MODULE_PACKAGE_LAYOUT_INVALID");
|
||||
}
|
||||
|
||||
const size = type === "file" ? octalField(block, 124, 12) : 0;
|
||||
const dataStart = offset + BLOCK;
|
||||
const dataEnd = dataStart + size;
|
||||
if (dataEnd > raw.length) {
|
||||
throw new ModulePackageArchiveError("MODULE_PACKAGE_UNREADABLE");
|
||||
}
|
||||
const relative = segments.slice(1).join("/");
|
||||
|
||||
if (relative === "") {
|
||||
if (type !== "directory") {
|
||||
throw new ModulePackageArchiveError("MODULE_PACKAGE_LAYOUT_INVALID");
|
||||
}
|
||||
} else {
|
||||
if (kinds.has(relative)) {
|
||||
throw new ModulePackageArchiveError("MODULE_PACKAGE_LAYOUT_INVALID");
|
||||
}
|
||||
// No entry may sit below a file, and no file may claim a path something already sits below.
|
||||
// Linear in the total number of path segments, whatever order the entries arrive in.
|
||||
if (type === "file" && ancestors.has(relative)) {
|
||||
throw new ModulePackageArchiveError("MODULE_PACKAGE_LAYOUT_INVALID");
|
||||
}
|
||||
for (let index = 1; index < segments.length - 1; index += 1) {
|
||||
const ancestor = segments.slice(1, index + 1).join("/");
|
||||
if (kinds.get(ancestor) === "file") {
|
||||
throw new ModulePackageArchiveError("MODULE_PACKAGE_LAYOUT_INVALID");
|
||||
}
|
||||
ancestors.add(ancestor);
|
||||
}
|
||||
kinds.set(relative, type);
|
||||
entries.push({ data: Buffer.from(raw.subarray(dataStart, dataEnd)), path: relative, type });
|
||||
}
|
||||
offset = dataStart + Math.ceil(size / BLOCK) * BLOCK;
|
||||
}
|
||||
|
||||
if (!ended || topLevel === undefined) {
|
||||
throw new ModulePackageArchiveError("MODULE_PACKAGE_UNREADABLE");
|
||||
}
|
||||
return Object.freeze({ entries: Object.freeze(entries), topLevel });
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* The facts about this host that decide whether a native Module Package v1 can run on it
|
||||
* (ADR 0049): operating system, CPU, C library family and version, and Node-API version.
|
||||
*
|
||||
* Everything comes from Node.js itself (`process.platform`, `process.arch`, `process.versions`, and
|
||||
* the diagnostic report's `glibcVersionRuntime`), never from running a system command. A fact Node
|
||||
* cannot report is left undefined, and every check that needs it fails closed.
|
||||
*/
|
||||
export interface ModulePackageHost {
|
||||
readonly platform: string;
|
||||
readonly arch: string;
|
||||
/** The C library on Linux: glibc with its runtime version, or undefined when it cannot be proven
|
||||
* to be glibc (musl reports none). */
|
||||
readonly libc?: { readonly family: "glibc"; readonly version: string };
|
||||
/** `process.versions.napi`: the highest Node-API version this Node.js provides. */
|
||||
readonly nodeApi?: number;
|
||||
/** `process.versions.modules`: the ABI an ABI-specific addon must have been built for. */
|
||||
readonly nodeAbi?: string;
|
||||
}
|
||||
|
||||
function glibcVersion(): string | undefined {
|
||||
try {
|
||||
const header = (process.report?.getReport() as { header?: { glibcVersionRuntime?: unknown } })
|
||||
?.header;
|
||||
const version = header?.glibcVersionRuntime;
|
||||
return typeof version === "string" && /^\d+\.\d+(?:\.\d+)?$/u.test(version)
|
||||
? version
|
||||
: undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export function currentModulePackageHost(): ModulePackageHost {
|
||||
const libc = process.platform === "linux" ? glibcVersion() : undefined;
|
||||
const nodeApi = Number(process.versions.napi);
|
||||
return Object.freeze({
|
||||
arch: process.arch,
|
||||
...(libc === undefined
|
||||
? {}
|
||||
: { libc: Object.freeze({ family: "glibc" as const, version: libc }) }),
|
||||
...(Number.isInteger(nodeApi) && nodeApi > 0 ? { nodeApi } : {}),
|
||||
...(typeof process.versions.modules === "string" ? { nodeAbi: process.versions.modules } : {}),
|
||||
platform: process.platform,
|
||||
});
|
||||
}
|
||||
|
||||
/** The native targets this platform can verify, with what each requires of a host. */
|
||||
export const MODULE_PACKAGE_NATIVE_TARGETS: ReadonlyMap<
|
||||
string,
|
||||
{
|
||||
readonly platform: string;
|
||||
readonly arch: string;
|
||||
readonly libc: "glibc";
|
||||
readonly elfMachine: number;
|
||||
}
|
||||
> = new Map([
|
||||
["linux-x64-glibc", { arch: "x64", elfMachine: 62, libc: "glibc", platform: "linux" }],
|
||||
]);
|
||||
@@ -0,0 +1,101 @@
|
||||
// The isolated, short-lived validation process of Module Package v1 verification (ADR 0049).
|
||||
//
|
||||
// The verifier forks this file with an empty environment and one JSON argument naming a candidate
|
||||
// module's backend entrypoints. It imports each one, so a package whose code cannot load, or whose
|
||||
// bundle still reaches for a dependency it does not contain, is refused before it is ever installed.
|
||||
// Whatever the module's top-level code does happens here, in a process with no database URL, no
|
||||
// secret, and no platform state, and that is discarded as soon as it reports.
|
||||
//
|
||||
// The result travels over the IPC channel, never stdout, so nothing a module prints can forge it.
|
||||
// It carries fixed role and reason names only: never an error message, a path, or module output.
|
||||
//
|
||||
// Written as erasable TypeScript, so Node can run the source file directly in tests as well as the
|
||||
// compiled file in the image. Its one relative import names the source file's own extension, which
|
||||
// the build rewrites to `.js` (`rewriteRelativeImportExtensions`); that file follows the same rules.
|
||||
import { pathToFileURL } from "node:url";
|
||||
import {
|
||||
parseModuleRuntimeDefinition,
|
||||
parseModuleSchemaMetadataDefinition,
|
||||
} from "@ekklesia/contracts";
|
||||
import { parseModuleSchemaDefinition } from "./legacy-schema-definition.ts";
|
||||
|
||||
interface ProbeRequest {
|
||||
readonly databaseAbi?: 1;
|
||||
readonly moduleId: string;
|
||||
readonly version: string;
|
||||
readonly entrypoints: Readonly<Record<string, string>>;
|
||||
}
|
||||
|
||||
interface ProbeFailure {
|
||||
readonly role: string;
|
||||
readonly reason: "contract" | "export" | "identity" | "load";
|
||||
}
|
||||
|
||||
function defaultExport(namespace: unknown): unknown {
|
||||
return typeof namespace === "object" && namespace !== null && "default" in namespace
|
||||
? (namespace as { default: unknown }).default
|
||||
: undefined;
|
||||
}
|
||||
|
||||
async function probe(request: ProbeRequest): Promise<readonly ProbeFailure[]> {
|
||||
const failures: ProbeFailure[] = [];
|
||||
for (const role of Object.keys(request.entrypoints).sort()) {
|
||||
const entrypoint = request.entrypoints[role];
|
||||
if (entrypoint === undefined) {
|
||||
continue;
|
||||
}
|
||||
let namespace: unknown;
|
||||
try {
|
||||
namespace = await import(pathToFileURL(entrypoint).href);
|
||||
} catch {
|
||||
failures.push({ reason: "load", role });
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
if (role === "server") {
|
||||
const definition = parseModuleRuntimeDefinition(defaultExport(namespace));
|
||||
if (definition.id !== request.moduleId || definition.version !== request.version) {
|
||||
failures.push({ reason: "identity", role });
|
||||
}
|
||||
if ((definition.database?.abi === 1) !== (request.databaseAbi === 1)) {
|
||||
failures.push({ reason: "contract", role });
|
||||
}
|
||||
} else if (role === "schema") {
|
||||
const definition =
|
||||
request.databaseAbi === 1
|
||||
? parseModuleSchemaMetadataDefinition(defaultExport(namespace))
|
||||
: parseModuleSchemaDefinition(defaultExport(namespace));
|
||||
if (definition.moduleId !== request.moduleId) {
|
||||
failures.push({ reason: "identity", role });
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
failures.push({ reason: "export", role });
|
||||
}
|
||||
}
|
||||
return failures;
|
||||
}
|
||||
|
||||
function report(message: unknown): void {
|
||||
if (typeof process.send !== "function") {
|
||||
process.exitCode = 2;
|
||||
return;
|
||||
}
|
||||
process.send(message, () => {
|
||||
// A module may leave timers or handles behind; the probe's work is done either way.
|
||||
process.exit(0);
|
||||
});
|
||||
}
|
||||
|
||||
let request: ProbeRequest | undefined;
|
||||
try {
|
||||
request = JSON.parse(process.argv[2] ?? "") as ProbeRequest;
|
||||
} catch {
|
||||
report({ failures: [{ reason: "load", role: "request" }] });
|
||||
}
|
||||
if (request !== undefined) {
|
||||
probe(request).then(
|
||||
(failures) => report({ failures }),
|
||||
() => report({ failures: [{ reason: "load", role: "probe" }] }),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,650 @@
|
||||
import { fork } from "node:child_process";
|
||||
import { createHash } from "node:crypto";
|
||||
import { chmod, mkdir, mkdtemp, readFile, realpath, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import {
|
||||
isVersionCompatible,
|
||||
type ModuleManifest,
|
||||
safeParseModuleManifest,
|
||||
} from "@ekklesia/contracts";
|
||||
import {
|
||||
MODULE_PACKAGE_ANY_TARGET,
|
||||
MODULE_PACKAGE_FORMAT,
|
||||
MODULE_PACKAGE_METADATA_FILENAME,
|
||||
type ModulePackageMetadata,
|
||||
safeParseModulePackageMetadata,
|
||||
safeParseModulePackageRuntimePackage,
|
||||
} from "@ekklesia/contracts/module-package";
|
||||
import { discoverModules, MODULE_MANIFEST_FILENAME, ModuleDiscoveryError } from "./discovery.js";
|
||||
import {
|
||||
currentModulePackageHost,
|
||||
MODULE_PACKAGE_NATIVE_TARGETS,
|
||||
type ModulePackageHost,
|
||||
} from "./module-package-host.js";
|
||||
import { discoverModuleMigrations, ModuleMigrationDiscoveryError } from "./migration-discovery.js";
|
||||
import {
|
||||
type ModulePackageArchive,
|
||||
ModulePackageArchiveError,
|
||||
type ModulePackageArchiveErrorCode,
|
||||
readModulePackageArchive,
|
||||
} from "./module-package-archive.js";
|
||||
import { discoverModuleSchemas, ModuleSchemaDiscoveryError } from "./schema-discovery.js";
|
||||
|
||||
/**
|
||||
* Verifies an Ekklesia Module Package v1 archive (ADR 0049) against the platform it runs on, without
|
||||
* ever installing it.
|
||||
*
|
||||
* Order matters: everything that can be decided from bytes is decided before anything is written,
|
||||
* and nothing is executed until the package has proven itself well-formed. The archive is read and
|
||||
* checked for safety in memory; its metadata, every payload digest, and every identity statement
|
||||
* are checked next; only then is it written to a fresh temporary directory, validated by the same
|
||||
* discovery the platform uses, and its backend loaded once in a separate, short-lived process with
|
||||
* an empty environment. A caller that asks for extraction gets the verified tree written to a new
|
||||
* directory only after all of that succeeded.
|
||||
*
|
||||
* Errors carry a stable code and a fixed message. Paths, archive entry names, file contents, module
|
||||
* output, and exception messages never appear in them: an archive is untrusted input, and none of
|
||||
* it is ever echoed.
|
||||
*/
|
||||
|
||||
export type ModulePackageVerificationErrorCode =
|
||||
| ModulePackageArchiveErrorCode
|
||||
| "MODULE_PACKAGE_DATABASE_RUNTIME_INCOMPATIBLE"
|
||||
| "MODULE_PACKAGE_DATABASE_RUNTIME_UNEXPECTED"
|
||||
| "MODULE_PACKAGE_DIGEST_MISMATCH"
|
||||
| "MODULE_PACKAGE_ENTRYPOINT_LOAD_FAILED"
|
||||
| "MODULE_PACKAGE_EXTRACTION_FAILED"
|
||||
| "MODULE_PACKAGE_FILE_MISMATCH"
|
||||
| "MODULE_PACKAGE_FILE_MISSING"
|
||||
| "MODULE_PACKAGE_FILE_UNLISTED"
|
||||
| "MODULE_PACKAGE_FORMAT_UNSUPPORTED"
|
||||
| "MODULE_PACKAGE_IDENTITY_MISMATCH"
|
||||
| "MODULE_PACKAGE_LIBC_INCOMPATIBLE"
|
||||
| "MODULE_PACKAGE_MANIFEST_INVALID"
|
||||
| "MODULE_PACKAGE_METADATA_INVALID"
|
||||
| "MODULE_PACKAGE_METADATA_MISSING"
|
||||
| "MODULE_PACKAGE_MODULE_INVALID"
|
||||
| "MODULE_PACKAGE_NATIVE_FILE"
|
||||
| "MODULE_PACKAGE_NATIVE_FILE_INCOMPATIBLE"
|
||||
| "MODULE_PACKAGE_NODE_API_INCOMPATIBLE"
|
||||
| "MODULE_PACKAGE_RUNTIME_INCOMPATIBLE"
|
||||
| "MODULE_PACKAGE_RUNTIME_PACKAGE_INVALID"
|
||||
| "MODULE_PACKAGE_SDK_INCOMPATIBLE"
|
||||
| "MODULE_PACKAGE_TARGET_INCOMPATIBLE"
|
||||
| "MODULE_PACKAGE_TRANSITIONAL_UNSUPPORTED"
|
||||
| "MODULE_PACKAGE_VERIFIER_SCRATCH_UNAVAILABLE";
|
||||
|
||||
const messages: Readonly<Record<ModulePackageVerificationErrorCode, string>> = Object.freeze({
|
||||
MODULE_PACKAGE_DATABASE_RUNTIME_INCOMPATIBLE:
|
||||
"The package's bundled database runtime does not match this platform's (a transitional constraint).",
|
||||
MODULE_PACKAGE_DATABASE_RUNTIME_UNEXPECTED:
|
||||
"A Module Database ABI package must not carry a transitional database runtime.",
|
||||
MODULE_PACKAGE_DIGEST_MISMATCH: "The archive does not match the SHA-256 it was given.",
|
||||
MODULE_PACKAGE_ENTRYPOINT_LOAD_FAILED:
|
||||
"A backend entrypoint could not be loaded from the package alone.",
|
||||
MODULE_PACKAGE_EXTRACTION_FAILED:
|
||||
"The verified package could not be extracted to a new directory.",
|
||||
MODULE_PACKAGE_FILE_MISMATCH: "A payload file does not match its listed size or SHA-256.",
|
||||
MODULE_PACKAGE_FILE_MISSING: "A file listed in the package metadata is missing from the archive.",
|
||||
MODULE_PACKAGE_FILE_UNLISTED: "The archive contains a file the package metadata does not list.",
|
||||
MODULE_PACKAGE_FORMAT_UNSUPPORTED: "The package format is not one this platform implements.",
|
||||
MODULE_PACKAGE_IDENTITY_MISMATCH:
|
||||
"The package metadata, runtime package.json, and manifest disagree about the module's identity.",
|
||||
MODULE_PACKAGE_LAYOUT_INVALID:
|
||||
"The archive must contain exactly one top-level module directory, with each path once.",
|
||||
MODULE_PACKAGE_LIBC_INCOMPATIBLE:
|
||||
"The package needs a newer C library than this host provides, or one it cannot establish.",
|
||||
MODULE_PACKAGE_LINK: "The archive contains a link; module packages may contain none.",
|
||||
MODULE_PACKAGE_MANIFEST_INVALID: "The package's module manifest is missing or invalid.",
|
||||
MODULE_PACKAGE_METADATA_INVALID: "The package metadata is invalid.",
|
||||
MODULE_PACKAGE_METADATA_MISSING: "The archive contains no package metadata.",
|
||||
MODULE_PACKAGE_MODULE_INVALID: "The packaged module failed module validation.",
|
||||
MODULE_PACKAGE_NATIVE_FILE:
|
||||
"An any-target package contains a native file or a node_modules directory.",
|
||||
MODULE_PACKAGE_NATIVE_FILE_INCOMPATIBLE:
|
||||
"A native file in the package is not built for the package's target.",
|
||||
MODULE_PACKAGE_NODE_API_INCOMPATIBLE:
|
||||
"The package's addons need a Node-API version or Node.js ABI this host does not provide.",
|
||||
MODULE_PACKAGE_RUNTIME_INCOMPATIBLE:
|
||||
"The package needs a Node.js version this host does not run.",
|
||||
MODULE_PACKAGE_RUNTIME_PACKAGE_INVALID:
|
||||
"The package's runtime package.json is not the generated runtime shape.",
|
||||
MODULE_PACKAGE_SDK_INCOMPATIBLE:
|
||||
"The Module SDK bundled into the package is outside the manifest's own moduleSdk range.",
|
||||
MODULE_PACKAGE_SPECIAL_ENTRY:
|
||||
"The archive contains an entry that is neither a directory nor a regular file.",
|
||||
MODULE_PACKAGE_TARGET_INCOMPATIBLE: "The package's target is not one this platform can run.",
|
||||
MODULE_PACKAGE_TOO_LARGE: "The archive exceeds the module package size limits.",
|
||||
MODULE_PACKAGE_TRANSITIONAL_UNSUPPORTED:
|
||||
"The package metadata contains transitional facts this platform does not understand.",
|
||||
MODULE_PACKAGE_VERIFIER_SCRATCH_UNAVAILABLE:
|
||||
"The verifier execution scratch directory is missing, not writable, or mounted noexec.",
|
||||
MODULE_PACKAGE_UNREADABLE: "The file is not a readable gzip-compressed ustar archive.",
|
||||
MODULE_PACKAGE_UNSAFE_MODE: "The archive contains a setuid, setgid, or sticky entry.",
|
||||
MODULE_PACKAGE_UNSAFE_PATH: "The archive contains an absolute, escaping, or non-portable path.",
|
||||
});
|
||||
|
||||
export class ModulePackageVerificationError extends Error {
|
||||
readonly code: ModulePackageVerificationErrorCode;
|
||||
/** Stable discovery issue codes, when module validation failed; never their messages. */
|
||||
readonly issueCodes: readonly string[];
|
||||
|
||||
constructor(code: ModulePackageVerificationErrorCode, issueCodes: readonly string[] = []) {
|
||||
super(messages[code]);
|
||||
this.name = "ModulePackageVerificationError";
|
||||
this.code = code;
|
||||
this.issueCodes = Object.freeze([...new Set(issueCodes)].sort());
|
||||
this.stack = `${this.name}: ${this.message}`;
|
||||
Object.freeze(this);
|
||||
}
|
||||
}
|
||||
|
||||
/** What this platform is, for the checks that compare a package against it. */
|
||||
export interface ModulePackagePlatform {
|
||||
readonly platformVersion: string;
|
||||
readonly moduleSdkVersion: string;
|
||||
/** The transitional database runtime facts this platform requires a package to agree with. */
|
||||
readonly databaseRuntime: Readonly<Record<string, string>>;
|
||||
/** Defaults to the running process's Node.js version. */
|
||||
readonly nodeVersion?: string;
|
||||
/** Defaults to this process's own host (`currentModulePackageHost`). */
|
||||
readonly host?: ModulePackageHost;
|
||||
}
|
||||
|
||||
export interface VerifyModulePackageOptions {
|
||||
readonly archivePath: string;
|
||||
/** When given, the archive's SHA-256 must equal it (lowercase hexadecimal). */
|
||||
readonly expectedSha256?: string;
|
||||
readonly platform: ModulePackagePlatform;
|
||||
/** A directory that must not exist yet; the verified module directory is written there. */
|
||||
readonly extractTo?: string;
|
||||
/**
|
||||
* Existing writable directory where backend candidates may execute. Defaults to `os.tmpdir()`.
|
||||
* A hardened caller whose system temporary directory is `noexec` must provide a dedicated
|
||||
* executable scratch mount; each verification creates and removes its own child directory.
|
||||
*/
|
||||
readonly executionScratchDirectory?: string;
|
||||
readonly loadTimeoutMs?: number;
|
||||
}
|
||||
|
||||
export interface ModulePackageVerificationResult {
|
||||
readonly archiveSha256: string;
|
||||
/** Payload files, the metadata file not included. */
|
||||
readonly fileCount: number;
|
||||
readonly moduleId: string;
|
||||
readonly target: string;
|
||||
readonly version: string;
|
||||
}
|
||||
|
||||
function fail(code: ModulePackageVerificationErrorCode, issueCodes?: readonly string[]): never {
|
||||
throw new ModulePackageVerificationError(code, issueCodes);
|
||||
}
|
||||
|
||||
function sha256(data: Uint8Array): string {
|
||||
return createHash("sha256").update(data).digest("hex");
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function parseJson(data: Buffer | undefined): unknown {
|
||||
if (data === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
try {
|
||||
return JSON.parse(data.toString("utf8")) as unknown;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/** The transitional facts this verifier understands, and nothing else (ADR 0049). */
|
||||
const UNDERSTOOD_TRANSITIONAL: Readonly<Record<string, readonly string[]>> = Object.freeze({
|
||||
databaseRuntime: Object.freeze(["drizzle-orm"]),
|
||||
});
|
||||
|
||||
function checkTransitional(raw: Record<string, unknown>): void {
|
||||
const transitional = raw.transitional;
|
||||
if (transitional === undefined) {
|
||||
return;
|
||||
}
|
||||
if (!isRecord(transitional)) {
|
||||
fail("MODULE_PACKAGE_TRANSITIONAL_UNSUPPORTED");
|
||||
}
|
||||
for (const [key, value] of Object.entries(transitional)) {
|
||||
const understood = UNDERSTOOD_TRANSITIONAL[key];
|
||||
if (!understood || !isRecord(value)) {
|
||||
fail("MODULE_PACKAGE_TRANSITIONAL_UNSUPPORTED");
|
||||
}
|
||||
if (Object.keys(value).some((name) => !understood.includes(name))) {
|
||||
fail("MODULE_PACKAGE_TRANSITIONAL_UNSUPPORTED");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function readMetadata(files: ReadonlyMap<string, Buffer>): ModulePackageMetadata {
|
||||
const raw = parseJson(files.get(MODULE_PACKAGE_METADATA_FILENAME));
|
||||
if (!files.has(MODULE_PACKAGE_METADATA_FILENAME)) {
|
||||
fail("MODULE_PACKAGE_METADATA_MISSING");
|
||||
}
|
||||
if (!isRecord(raw)) {
|
||||
fail("MODULE_PACKAGE_METADATA_INVALID");
|
||||
}
|
||||
if (raw.packageFormat !== MODULE_PACKAGE_FORMAT) {
|
||||
fail("MODULE_PACKAGE_FORMAT_UNSUPPORTED");
|
||||
}
|
||||
checkTransitional(raw);
|
||||
const parsed = safeParseModulePackageMetadata(raw);
|
||||
if (!parsed.success) {
|
||||
fail("MODULE_PACKAGE_METADATA_INVALID");
|
||||
}
|
||||
return parsed.data;
|
||||
}
|
||||
|
||||
function checkPayload(metadata: ModulePackageMetadata, files: ReadonlyMap<string, Buffer>): void {
|
||||
const listed = new Set(metadata.files.map((file) => file.path));
|
||||
for (const filePath of files.keys()) {
|
||||
if (filePath !== MODULE_PACKAGE_METADATA_FILENAME && !listed.has(filePath)) {
|
||||
fail("MODULE_PACKAGE_FILE_UNLISTED");
|
||||
}
|
||||
}
|
||||
for (const file of metadata.files) {
|
||||
const data = files.get(file.path);
|
||||
if (data === undefined) {
|
||||
fail("MODULE_PACKAGE_FILE_MISSING");
|
||||
}
|
||||
if (data.length !== file.size || sha256(data) !== file.sha256) {
|
||||
fail("MODULE_PACKAGE_FILE_MISMATCH");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function checkIdentity(
|
||||
archive: ModulePackageArchive,
|
||||
metadata: ModulePackageMetadata,
|
||||
files: ReadonlyMap<string, Buffer>,
|
||||
): ModuleManifest {
|
||||
const runtimePackage = safeParseModulePackageRuntimePackage(parseJson(files.get("package.json")));
|
||||
if (!runtimePackage.success) {
|
||||
fail("MODULE_PACKAGE_RUNTIME_PACKAGE_INVALID");
|
||||
}
|
||||
const manifest = safeParseModuleManifest(parseJson(files.get(MODULE_MANIFEST_FILENAME)));
|
||||
if (!manifest.success) {
|
||||
fail("MODULE_PACKAGE_MANIFEST_INVALID");
|
||||
}
|
||||
if (
|
||||
archive.topLevel !== metadata.module.id ||
|
||||
manifest.data.id !== metadata.module.id ||
|
||||
manifest.data.version !== metadata.module.version ||
|
||||
runtimePackage.data.version !== metadata.module.version
|
||||
) {
|
||||
fail("MODULE_PACKAGE_IDENTITY_MISMATCH");
|
||||
}
|
||||
return manifest.data;
|
||||
}
|
||||
|
||||
function checkCompatibility(
|
||||
metadata: ModulePackageMetadata,
|
||||
manifest: ModuleManifest,
|
||||
files: ReadonlyMap<string, Buffer>,
|
||||
platform: ModulePackagePlatform,
|
||||
): void {
|
||||
if (metadata.target === MODULE_PACKAGE_ANY_TARGET) {
|
||||
// An any-target package bundles everything: no addon, no ELF object, no node_modules.
|
||||
for (const [filePath, data] of files) {
|
||||
if (
|
||||
filePath.toLowerCase().endsWith(".node") ||
|
||||
filePath.startsWith("node_modules/") ||
|
||||
isElf(data)
|
||||
) {
|
||||
fail("MODULE_PACKAGE_NATIVE_FILE");
|
||||
}
|
||||
}
|
||||
} else {
|
||||
checkNativeTarget(metadata, files, platform.host ?? currentModulePackageHost());
|
||||
}
|
||||
if (!isVersionCompatible(platform.nodeVersion ?? process.versions.node, metadata.runtime.node)) {
|
||||
fail("MODULE_PACKAGE_RUNTIME_INCOMPATIBLE");
|
||||
}
|
||||
const sdk = metadata.incorporates["@ekklesia/module-sdk"];
|
||||
if (sdk !== undefined && !isVersionCompatible(sdk, manifest.compatibility.moduleSdk)) {
|
||||
fail("MODULE_PACKAGE_SDK_INCOMPATIBLE");
|
||||
}
|
||||
if (manifest.database?.abi === 1) {
|
||||
if (metadata.transitional?.databaseRuntime !== undefined) {
|
||||
fail("MODULE_PACKAGE_DATABASE_RUNTIME_UNEXPECTED");
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Transitional legacy-v8 path (ADR 0049): Core queries the module's Drizzle tables with its own
|
||||
// Drizzle instance, so a recorded module runtime must match. ABI-v1 packages return above: their
|
||||
// bundled ORM is a private implementation detail and is never compared with Core's.
|
||||
for (const [runtime, version] of Object.entries(metadata.transitional?.databaseRuntime ?? {})) {
|
||||
if (platform.databaseRuntime[runtime] !== version) {
|
||||
fail("MODULE_PACKAGE_DATABASE_RUNTIME_INCOMPATIBLE");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function isElf(data: Uint8Array): boolean {
|
||||
return (
|
||||
data.length >= 4 && data[0] === 0x7f && data[1] === 0x45 && data[2] === 0x4c && data[3] === 0x46
|
||||
);
|
||||
}
|
||||
|
||||
/** `2.41` → `2.41.0`, for comparison against a semantic-version range. */
|
||||
function asSemver(version: string): string {
|
||||
const parts = version.split(".");
|
||||
return [...parts, "0", "0"].slice(0, 3).join(".");
|
||||
}
|
||||
|
||||
/**
|
||||
* A native target (ADR 0049): accepted only for a target this platform implements, on a host that
|
||||
* provably matches it. Platform, CPU, C library family and minimum version, and the Node-API version
|
||||
* or ABI the addons need are all checked; a fact the host cannot establish fails closed. Every ELF
|
||||
* object in the package must be built for the target's machine, and every `.node` addon must be one.
|
||||
*/
|
||||
function checkNativeTarget(
|
||||
metadata: ModulePackageMetadata,
|
||||
files: ReadonlyMap<string, Buffer>,
|
||||
host: ModulePackageHost,
|
||||
): void {
|
||||
const target = MODULE_PACKAGE_NATIVE_TARGETS.get(metadata.target);
|
||||
if (
|
||||
target === undefined ||
|
||||
host.platform !== target.platform ||
|
||||
host.arch !== target.arch ||
|
||||
host.libc?.family !== target.libc
|
||||
) {
|
||||
fail("MODULE_PACKAGE_TARGET_INCOMPATIBLE");
|
||||
}
|
||||
const required = metadata.runtime.libcVersion;
|
||||
if (
|
||||
required === undefined ||
|
||||
host.libc === undefined ||
|
||||
!isVersionCompatible(asSemver(host.libc.version), required)
|
||||
) {
|
||||
fail("MODULE_PACKAGE_LIBC_INCOMPATIBLE");
|
||||
}
|
||||
const { nodeAbi, nodeApi } = metadata.runtime;
|
||||
if (
|
||||
(nodeApi === undefined && nodeAbi === undefined) ||
|
||||
(nodeApi !== undefined && (host.nodeApi === undefined || host.nodeApi < nodeApi)) ||
|
||||
(nodeAbi !== undefined && host.nodeAbi !== nodeAbi)
|
||||
) {
|
||||
fail("MODULE_PACKAGE_NODE_API_INCOMPATIBLE");
|
||||
}
|
||||
for (const [filePath, data] of files) {
|
||||
const addon = filePath.toLowerCase().endsWith(".node");
|
||||
if (!isElf(data)) {
|
||||
if (addon) {
|
||||
fail("MODULE_PACKAGE_NATIVE_FILE_INCOMPATIBLE");
|
||||
}
|
||||
continue;
|
||||
}
|
||||
// ELF64, little-endian, for the target's machine (e_machine at offset 18).
|
||||
if (
|
||||
data.length < 20 ||
|
||||
data[4] !== 2 ||
|
||||
data[5] !== 1 ||
|
||||
data.readUInt16LE(18) !== target.elfMachine
|
||||
) {
|
||||
fail("MODULE_PACKAGE_NATIVE_FILE_INCOMPATIBLE");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Writes the archive's directories (`0755`) and files (`0644`) under `root`, which must not exist.
|
||||
* Safe by construction: every path was validated as relative and non-escaping, and only directories
|
||||
* and regular files exist. Ownership is the writing process's, never the archive's. */
|
||||
async function writeTree(root: string, archive: ModulePackageArchive): Promise<void> {
|
||||
await mkdir(root, { mode: 0o755 });
|
||||
await chmod(root, 0o755);
|
||||
for (const entry of archive.entries) {
|
||||
const target = path.join(root, ...entry.path.split("/"));
|
||||
if (entry.type === "directory") {
|
||||
await mkdir(target, { mode: 0o755, recursive: true });
|
||||
await chmod(target, 0o755);
|
||||
} else {
|
||||
await mkdir(path.dirname(target), { mode: 0o755, recursive: true });
|
||||
await writeFile(target, entry.data, { flag: "wx", mode: 0o644 });
|
||||
await chmod(target, 0o644);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function probeEntrypointPath(): string {
|
||||
// The probe sits beside this file: compiled `.js` in an installation, source `.ts` under a test
|
||||
// runner, which Node runs directly. Either way it is a separate process.
|
||||
const self = fileURLToPath(import.meta.url);
|
||||
return path.join(path.dirname(self), `module-package-probe${path.extname(self)}`);
|
||||
}
|
||||
|
||||
function decodedMountPath(value: string): string {
|
||||
return value.replace(/\\([0-7]{3})/gu, (_match, octal: string) =>
|
||||
String.fromCharCode(Number.parseInt(octal, 8)),
|
||||
);
|
||||
}
|
||||
|
||||
/** Linux exposes the effective options of every mount, including a container's tmpfs mounts. A
|
||||
* verifier cannot infer `noexec` from file permissions: native addons are mapped executable rather
|
||||
* than invoked as files. On systems without mountinfo, the load remains the final authority. */
|
||||
async function isNoExecMount(directory: string): Promise<boolean> {
|
||||
if (process.platform !== "linux") return false;
|
||||
let mountinfo: string;
|
||||
let canonicalDirectory: string;
|
||||
try {
|
||||
[mountinfo, canonicalDirectory] = await Promise.all([
|
||||
readFile("/proc/self/mountinfo", "utf8"),
|
||||
realpath(directory),
|
||||
]);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
const mounts = mountinfo
|
||||
.split("\n")
|
||||
.map((line) => line.split(" "))
|
||||
.filter((fields) => fields.length > 6 && fields.includes("-"))
|
||||
.map((fields) => ({
|
||||
mountPoint: decodedMountPath(fields[4] ?? ""),
|
||||
options: (fields[5] ?? "").split(","),
|
||||
}))
|
||||
.filter(
|
||||
({ mountPoint }) =>
|
||||
canonicalDirectory === mountPoint ||
|
||||
canonicalDirectory.startsWith(mountPoint === "/" ? "/" : `${mountPoint}/`),
|
||||
)
|
||||
.sort((left, right) => right.mountPoint.length - left.mountPoint.length);
|
||||
return mounts[0]?.options.includes("noexec") ?? false;
|
||||
}
|
||||
|
||||
async function createExecutionScratch(baseDirectory: string): Promise<string> {
|
||||
if (!path.isAbsolute(baseDirectory)) {
|
||||
fail("MODULE_PACKAGE_VERIFIER_SCRATCH_UNAVAILABLE");
|
||||
}
|
||||
try {
|
||||
if (await isNoExecMount(baseDirectory)) {
|
||||
fail("MODULE_PACKAGE_VERIFIER_SCRATCH_UNAVAILABLE");
|
||||
}
|
||||
return await mkdtemp(path.join(baseDirectory, "ekklesia-package-verify-"));
|
||||
} catch (error) {
|
||||
if (error instanceof ModulePackageVerificationError) throw error;
|
||||
fail("MODULE_PACKAGE_VERIFIER_SCRATCH_UNAVAILABLE");
|
||||
}
|
||||
}
|
||||
|
||||
/** Loads every backend entrypoint in a short-lived child with an empty environment, and reports
|
||||
* only whether each loaded and exported a definition naming this module. */
|
||||
async function loadEntrypoints(
|
||||
manifest: Pick<ModuleManifest, "database" | "id" | "version">,
|
||||
entrypoints: Readonly<Record<string, string>>,
|
||||
timeoutMs: number,
|
||||
executionDirectory: string,
|
||||
): Promise<void> {
|
||||
const outcome = await new Promise<"ok" | "failed">((resolve) => {
|
||||
let settled = false;
|
||||
let child: ReturnType<typeof fork> | undefined;
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
const finish = (value: "ok" | "failed"): void => {
|
||||
if (!settled) {
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
child?.kill("SIGKILL");
|
||||
resolve(value);
|
||||
}
|
||||
};
|
||||
try {
|
||||
child = fork(
|
||||
probeEntrypointPath(),
|
||||
[
|
||||
JSON.stringify({
|
||||
...(manifest.database?.abi === 1 ? { databaseAbi: 1 } : {}),
|
||||
entrypoints,
|
||||
moduleId: manifest.id,
|
||||
version: manifest.version,
|
||||
}),
|
||||
],
|
||||
{
|
||||
cwd: executionDirectory,
|
||||
env: {},
|
||||
execArgv: [],
|
||||
serialization: "json",
|
||||
stdio: ["ignore", "ignore", "ignore", "ipc"],
|
||||
},
|
||||
);
|
||||
} catch {
|
||||
finish("failed");
|
||||
return;
|
||||
}
|
||||
timer = setTimeout(() => finish("failed"), timeoutMs);
|
||||
child.on("message", (message: unknown) => {
|
||||
const failures = isRecord(message) ? message.failures : undefined;
|
||||
finish(Array.isArray(failures) && failures.length === 0 ? "ok" : "failed");
|
||||
});
|
||||
child.on("error", () => finish("failed"));
|
||||
child.on("exit", () => finish("failed"));
|
||||
});
|
||||
if (outcome !== "ok") {
|
||||
fail("MODULE_PACKAGE_ENTRYPOINT_LOAD_FAILED");
|
||||
}
|
||||
}
|
||||
|
||||
function discoveryIssueCodes(error: unknown): readonly string[] {
|
||||
if (
|
||||
error instanceof ModuleDiscoveryError ||
|
||||
error instanceof ModuleMigrationDiscoveryError ||
|
||||
error instanceof ModuleSchemaDiscoveryError
|
||||
) {
|
||||
return error.issues.map((issue) => issue.code);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
export async function verifyModulePackage(
|
||||
options: VerifyModulePackageOptions,
|
||||
): Promise<ModulePackageVerificationResult> {
|
||||
let compressed: Buffer;
|
||||
try {
|
||||
compressed = await readFile(options.archivePath);
|
||||
} catch {
|
||||
fail("MODULE_PACKAGE_UNREADABLE");
|
||||
}
|
||||
const archiveSha256 = sha256(compressed);
|
||||
if (options.expectedSha256 !== undefined && options.expectedSha256 !== archiveSha256) {
|
||||
fail("MODULE_PACKAGE_DIGEST_MISMATCH");
|
||||
}
|
||||
|
||||
let archive: ModulePackageArchive;
|
||||
try {
|
||||
archive = readModulePackageArchive(compressed);
|
||||
} catch (error) {
|
||||
if (error instanceof ModulePackageArchiveError) {
|
||||
fail(error.code);
|
||||
}
|
||||
fail("MODULE_PACKAGE_UNREADABLE");
|
||||
}
|
||||
const files = new Map(
|
||||
archive.entries
|
||||
.filter((entry) => entry.type === "file")
|
||||
.map((entry) => [entry.path, entry.data] as const),
|
||||
);
|
||||
|
||||
const metadata = readMetadata(files);
|
||||
checkPayload(metadata, files);
|
||||
const manifest = checkIdentity(archive, metadata, files);
|
||||
checkCompatibility(metadata, manifest, files, options.platform);
|
||||
|
||||
const scratch = await createExecutionScratch(options.executionScratchDirectory ?? tmpdir());
|
||||
try {
|
||||
// The candidate alone, in a scratch inventory of its own: never module storage.
|
||||
const modulesDirectory = path.join(scratch, "candidates");
|
||||
await mkdir(modulesDirectory, { mode: 0o755 });
|
||||
await writeTree(path.join(modulesDirectory, metadata.module.id), archive);
|
||||
|
||||
const discoveryOptions = {
|
||||
moduleSdkVersion: options.platform.moduleSdkVersion,
|
||||
modulesDirectory,
|
||||
modulesDirectoryRequired: true,
|
||||
platformVersion: options.platform.platformVersion,
|
||||
} as const;
|
||||
let entrypoints: Record<string, string>;
|
||||
try {
|
||||
// The same static validation the platform runs, against this package alone. A module that
|
||||
// requires another module is validated with its dependencies when it is installed.
|
||||
const [discovered] = await discoverModules(discoveryOptions);
|
||||
if (discovered === undefined) {
|
||||
fail("MODULE_PACKAGE_MODULE_INVALID");
|
||||
}
|
||||
await discoverModuleMigrations(discoveryOptions);
|
||||
entrypoints = {
|
||||
server: discovered.entrypoints.server,
|
||||
...(discovered.entrypoints.schema ? { schema: discovered.entrypoints.schema } : {}),
|
||||
...(discovered.entrypoints.jobs ? { jobs: discovered.entrypoints.jobs } : {}),
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof ModulePackageVerificationError) {
|
||||
throw error;
|
||||
}
|
||||
fail("MODULE_PACKAGE_MODULE_INVALID", discoveryIssueCodes(error));
|
||||
}
|
||||
|
||||
// Executed only now, and first in isolation: a schema is imported in this process below only
|
||||
// after the probe proved it loads on its own.
|
||||
await loadEntrypoints(manifest, entrypoints, options.loadTimeoutMs ?? 30_000, scratch);
|
||||
try {
|
||||
await discoverModuleSchemas(discoveryOptions);
|
||||
} catch (error) {
|
||||
fail("MODULE_PACKAGE_MODULE_INVALID", discoveryIssueCodes(error));
|
||||
}
|
||||
|
||||
if (options.extractTo !== undefined) {
|
||||
const extractTo = options.extractTo;
|
||||
let created = false;
|
||||
try {
|
||||
await mkdir(extractTo, { mode: 0o755 });
|
||||
created = true;
|
||||
await rm(extractTo, { recursive: true });
|
||||
await writeTree(extractTo, archive);
|
||||
} catch {
|
||||
// Never leave a partial module directory behind; never remove one this call did not create.
|
||||
if (created) {
|
||||
await rm(extractTo, { force: true, recursive: true }).catch(() => undefined);
|
||||
}
|
||||
fail("MODULE_PACKAGE_EXTRACTION_FAILED");
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
await rm(scratch, { force: true, recursive: true });
|
||||
}
|
||||
|
||||
return Object.freeze({
|
||||
archiveSha256,
|
||||
fileCount: metadata.files.length,
|
||||
moduleId: metadata.module.id,
|
||||
target: metadata.target,
|
||||
version: metadata.module.version,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
+68
-26
@@ -7,15 +7,16 @@ import {
|
||||
EMPTY_MODULE_CONFIGURATION,
|
||||
EMPTY_MODULE_STORAGE,
|
||||
type ModuleConfiguration,
|
||||
type ModuleDatabaseExecutor,
|
||||
type ModuleHttpRouteRegistrar,
|
||||
type ModuleId,
|
||||
ModuleRealtimeError,
|
||||
type ModuleRealtimePublisher,
|
||||
type ModuleRegistrationContext,
|
||||
type ModuleRouteDefinitionInput,
|
||||
type ModuleRouteHandler,
|
||||
type ModuleRuntimeContext,
|
||||
type ModuleRuntimeDefinition,
|
||||
type ModuleRuntimeServices,
|
||||
type ModuleStorage,
|
||||
moduleRouteShapeKey,
|
||||
moduleRoutesOverlap,
|
||||
@@ -24,6 +25,7 @@ import {
|
||||
type RegisteredModuleHttpRoute,
|
||||
safeParseModuleRouteDefinition,
|
||||
} from "@ekklesia/contracts";
|
||||
import type { AnyModuleRuntimeServices, ModuleDatabase } from "./database.js";
|
||||
import {
|
||||
type DiscoveredModule,
|
||||
discoverModules,
|
||||
@@ -68,6 +70,7 @@ export type ModuleRuntimeIssueCode =
|
||||
| "MODULE_ROUTE_PERMISSION_INVALID"
|
||||
| "MODULE_ROUTE_PERMISSION_UNDECLARED"
|
||||
| "MODULE_RUNTIME_EXPORT_INVALID"
|
||||
| "MODULE_RUNTIME_DATABASE_MISMATCH"
|
||||
| "MODULE_RUNTIME_ID_MISMATCH"
|
||||
| "MODULE_RUNTIME_IMPORT_FAILED"
|
||||
| "MODULE_RUNTIME_VERSION_MISMATCH"
|
||||
@@ -105,9 +108,8 @@ export interface StartedModuleRuntime {
|
||||
/**
|
||||
* Per-module frozen configuration snapshots, keyed by module id (ADR 0029).
|
||||
*
|
||||
* A lookup rather than a field on `ModuleRuntimeServices`, because `services` is deliberately **one
|
||||
* 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
|
||||
* A lookup rather than a field on `ModuleRuntimeServices`, because configuration is resolved and
|
||||
* frozen per module independently of its live service object. A module with no entry receives
|
||||
* `EMPTY_MODULE_CONFIGURATION` rather than `undefined`, so `context.config` is always present and
|
||||
* module code never branches on whether the platform supplied one.
|
||||
*/
|
||||
@@ -124,12 +126,33 @@ function configurationFor(
|
||||
* Per-module scoped object storage, keyed by module id (ADR 0030).
|
||||
*
|
||||
* A lookup for the same reason configuration is one, and emphatically **not** a field on
|
||||
* `ModuleRuntimeServices`: `services` is one shared object identity every module receives, while a
|
||||
* storage facade is scoped to exactly the buckets one module declared. Sharing it would hand every
|
||||
* `ModuleRuntimeServices`: a storage facade is scoped to exactly the buckets one module declared.
|
||||
* Sharing it would hand every
|
||||
* module every other module's storage.
|
||||
*/
|
||||
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 {
|
||||
return storages.get(moduleId) ?? EMPTY_MODULE_STORAGE;
|
||||
}
|
||||
@@ -164,10 +187,9 @@ export interface ModuleRuntimeCompositionOptions extends ModuleRuntimeTimeoutOpt
|
||||
export interface StartModuleRuntimeOptions
|
||||
extends ModuleDiscoveryOptions,
|
||||
ModuleRuntimeCompositionOptions {
|
||||
/** The same frozen services object every started module's `start`/`stop` hooks and (once
|
||||
* `apps/api` mounts routes) HTTP handlers receive for this runtime — a required production
|
||||
* dependency, never an optional test-only global. See `ModuleRuntimeServices`. */
|
||||
readonly services: ModuleRuntimeServices;
|
||||
/** The manifest-selected service object for each module. One module's `start`/`stop` hooks and
|
||||
* handlers receive its same entry; ABI-v1 entries carry distinct attributed executors. */
|
||||
readonly services: ModuleRuntimeServicesSource;
|
||||
}
|
||||
|
||||
interface LoadedModule {
|
||||
@@ -175,6 +197,13 @@ interface LoadedModule {
|
||||
readonly discovered: DiscoveredModule;
|
||||
}
|
||||
|
||||
type AnyRegistrationHook = (
|
||||
context: ModuleRegistrationContext<ModuleDatabase | ModuleDatabaseExecutor>,
|
||||
) => unknown;
|
||||
type AnyRuntimeHook = (
|
||||
context: ModuleRuntimeContext<ModuleDatabase | ModuleDatabaseExecutor>,
|
||||
) => unknown;
|
||||
|
||||
interface CapabilityRegistrationStage {
|
||||
readonly capabilities: CapabilityRegistrar;
|
||||
commit(): readonly ModuleRuntimeIssue[];
|
||||
@@ -316,11 +345,11 @@ class StagedCapabilityRegistry {
|
||||
createRuntimeContext(
|
||||
module: DiscoveredModule,
|
||||
phase: "rollback" | "start" | "stop",
|
||||
services: ModuleRuntimeServices,
|
||||
services: AnyModuleRuntimeServices,
|
||||
config: ModuleConfiguration,
|
||||
storage: ModuleStorage,
|
||||
realtime: ModuleRealtimePublisher,
|
||||
): ModuleRuntimeContext {
|
||||
): ModuleRuntimeContext<ModuleDatabase | ModuleDatabaseExecutor> {
|
||||
const capabilities: CapabilityResolver = Object.freeze({
|
||||
resolve: (capabilityId: CapabilityId): RegisteredCapability | undefined => {
|
||||
this.#assertAccess(module, capabilityId, phase);
|
||||
@@ -469,7 +498,7 @@ class StagedModuleHttpRoutes {
|
||||
local.push(
|
||||
Object.freeze({
|
||||
bodyMode: parsed.data.bodyMode,
|
||||
handler: parsed.data.handler,
|
||||
handler: parsed.data.handler as ModuleRouteHandler,
|
||||
maxBodyBytes: parsed.data.maxBodyBytes,
|
||||
method: parsed.data.method,
|
||||
moduleId: module.manifest.id,
|
||||
@@ -555,6 +584,19 @@ async function loadRuntimeDefinitions(
|
||||
),
|
||||
);
|
||||
}
|
||||
const runtimeDeclaresAbi = definition.database?.abi === 1;
|
||||
const manifestDeclaresAbi = discovered.manifest.database?.abi === 1;
|
||||
if (runtimeDeclaresAbi !== manifestDeclaresAbi) {
|
||||
matchesManifest = false;
|
||||
issues.push(
|
||||
runtimeIssue(
|
||||
"MODULE_RUNTIME_DATABASE_MISMATCH",
|
||||
"load",
|
||||
discovered.manifest.id,
|
||||
`Module runtime database declaration does not match manifest ${discovered.manifest.id}.`,
|
||||
),
|
||||
);
|
||||
}
|
||||
if (matchesManifest) {
|
||||
loaded.push({ definition, discovered });
|
||||
}
|
||||
@@ -580,15 +622,15 @@ async function registerModules(
|
||||
module.discovered.manifest.id,
|
||||
new Set(module.discovered.manifest.permissions.map((declaration) => declaration.id)),
|
||||
);
|
||||
const context: ModuleRegistrationContext = Object.freeze({
|
||||
const context = Object.freeze({
|
||||
capabilities: capabilityStage.capabilities,
|
||||
config: configurationFor(configurations, module.discovered.manifest.id),
|
||||
http: routeStage.http,
|
||||
moduleId: module.discovered.manifest.id,
|
||||
realtime: realtimeStage.realtime,
|
||||
});
|
||||
}) as ModuleRegistrationContext<ModuleDatabase | ModuleDatabaseExecutor>;
|
||||
try {
|
||||
await module.definition.register?.(context);
|
||||
await (module.definition.register as AnyRegistrationHook | undefined)?.(context);
|
||||
} catch (error) {
|
||||
if (error instanceof ModuleRuntimeSignal) {
|
||||
throw new ModuleRuntimeError([error.issue]);
|
||||
@@ -685,7 +727,7 @@ async function stopModules(
|
||||
modules: readonly LoadedModule[],
|
||||
registry: StagedCapabilityRegistry,
|
||||
phase: "rollback" | "stop",
|
||||
services: ModuleRuntimeServices,
|
||||
services: ModuleRuntimeServicesSource,
|
||||
configurations: ModuleConfigurationLookup,
|
||||
storages: ModuleStorageLookup,
|
||||
realtimeRegistry: StagedModuleRealtimeRegistry,
|
||||
@@ -698,7 +740,7 @@ async function stopModules(
|
||||
// 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.
|
||||
realtimeRegistry.deactivateModule(moduleId);
|
||||
const stop = module.definition.stop;
|
||||
const stop = module.definition.stop as AnyRuntimeHook | undefined;
|
||||
if (!stop) {
|
||||
// No synthetic callback and no timer for a module that declares no stop hook.
|
||||
continue;
|
||||
@@ -710,13 +752,13 @@ async function stopModules(
|
||||
registry.createRuntimeContext(
|
||||
module.discovered,
|
||||
phase,
|
||||
services,
|
||||
servicesFor(services, module.discovered),
|
||||
configurationFor(configurations, moduleId),
|
||||
storageFor(storages, moduleId),
|
||||
realtimeRegistry.publisherFor(moduleId),
|
||||
),
|
||||
) as ModuleRuntimeContext<ModuleDatabase | ModuleDatabaseExecutor>,
|
||||
),
|
||||
),
|
||||
).then(() => undefined),
|
||||
timeoutMs,
|
||||
);
|
||||
if (outcome === "timed-out") {
|
||||
@@ -750,7 +792,7 @@ async function stopModules(
|
||||
async function startModules(
|
||||
modules: readonly LoadedModule[],
|
||||
registry: StagedCapabilityRegistry,
|
||||
services: ModuleRuntimeServices,
|
||||
services: ModuleRuntimeServicesSource,
|
||||
configurations: ModuleConfigurationLookup,
|
||||
storages: ModuleStorageLookup,
|
||||
realtimeRegistry: StagedModuleRealtimeRegistry,
|
||||
@@ -766,15 +808,15 @@ async function startModules(
|
||||
for (const module of modules) {
|
||||
rollbackCandidates.push(module);
|
||||
try {
|
||||
await module.definition.start?.(
|
||||
await (module.definition.start as AnyRuntimeHook | undefined)?.(
|
||||
registry.createRuntimeContext(
|
||||
module.discovered,
|
||||
"start",
|
||||
services,
|
||||
servicesFor(services, module.discovered),
|
||||
configurationFor(configurations, module.discovered.manifest.id),
|
||||
storageFor(storages, module.discovered.manifest.id),
|
||||
realtimeRegistry.publisherFor(module.discovered.manifest.id),
|
||||
),
|
||||
) as ModuleRuntimeContext<ModuleDatabase | ModuleDatabaseExecutor>,
|
||||
);
|
||||
const missingSnapshots = realtimeRegistry.missingSnapshots(module.discovered.manifest.id);
|
||||
if (missingSnapshots.length > 0) {
|
||||
@@ -831,7 +873,7 @@ async function startModules(
|
||||
*/
|
||||
export async function startDiscoveredModuleRuntime(
|
||||
discoveredModules: readonly DiscoveredModule[],
|
||||
services: ModuleRuntimeServices,
|
||||
services: ModuleRuntimeServicesSource,
|
||||
timeouts: ModuleRuntimeCompositionOptions = {},
|
||||
): Promise<StartedModuleRuntime> {
|
||||
const modules = await loadRuntimeDefinitions(discoveredModules);
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
import { pathToFileURL } from "node:url";
|
||||
import {
|
||||
type ModuleId,
|
||||
type ModuleSchemaDefinition,
|
||||
type ModuleSchemaMetadataDefinition,
|
||||
moduleTableLocalNameSchema,
|
||||
moduleTablePrefix,
|
||||
parseModuleSchemaDefinition,
|
||||
parseModuleSchemaMetadataDefinition,
|
||||
physicalModuleTableNameSchema,
|
||||
} from "@ekklesia/contracts";
|
||||
import {
|
||||
type ModuleSchemaDefinition,
|
||||
parseModuleSchemaDefinition,
|
||||
} from "./legacy-schema-definition.js";
|
||||
import { is } from "drizzle-orm";
|
||||
import { type AnyPgTable, getTableConfig, PgColumn, PgTable } from "drizzle-orm/pg-core";
|
||||
import {
|
||||
@@ -40,7 +44,9 @@ export interface DiscoveredModuleTable {
|
||||
readonly exportName: string;
|
||||
readonly moduleId: ModuleId;
|
||||
readonly name: string;
|
||||
readonly table: AnyPgTable;
|
||||
/** Present only for a temporary legacy-facade module. ABI-v1 schema discovery publishes no ORM
|
||||
* object and contributes nothing to the legacy aggregate below. */
|
||||
readonly table?: AnyPgTable;
|
||||
}
|
||||
|
||||
export interface DiscoveredModuleSchema {
|
||||
@@ -55,21 +61,40 @@ export interface ModuleSchemaAggregate {
|
||||
readonly tables: readonly DiscoveredModuleTable[];
|
||||
}
|
||||
|
||||
interface LoadedModuleSchema {
|
||||
interface LoadedLegacyModuleSchema {
|
||||
readonly definition: ModuleSchemaDefinition;
|
||||
readonly discovered: DiscoveredModule;
|
||||
readonly mode: "legacy";
|
||||
}
|
||||
|
||||
interface LoadedAbiModuleSchema {
|
||||
readonly definition: ModuleSchemaMetadataDefinition;
|
||||
readonly discovered: DiscoveredModule;
|
||||
readonly mode: "abi-v1";
|
||||
}
|
||||
|
||||
type LoadedModuleSchema = LoadedLegacyModuleSchema | LoadedAbiModuleSchema;
|
||||
|
||||
interface InspectedModuleSchema {
|
||||
readonly discovered: DiscoveredModule;
|
||||
readonly tables: ModuleTableCandidate[];
|
||||
}
|
||||
|
||||
interface ModuleTableCandidate extends DiscoveredModuleTable {
|
||||
interface LegacyModuleTableCandidate extends DiscoveredModuleTable {
|
||||
readonly mode: "legacy";
|
||||
readonly foreignKeys: ReturnType<typeof getTableConfig>["foreignKeys"];
|
||||
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");
|
||||
|
||||
export class ModuleSchemaDiscoveryError extends Error {
|
||||
@@ -156,9 +181,13 @@ async function loadSchemaDefinitions(discoveredModules: readonly DiscoveredModul
|
||||
continue;
|
||||
}
|
||||
|
||||
let definition: ModuleSchemaDefinition;
|
||||
let definition: ModuleSchemaDefinition | ModuleSchemaMetadataDefinition;
|
||||
const mode = discovered.manifest.database?.abi === 1 ? "abi-v1" : "legacy";
|
||||
try {
|
||||
definition = parseModuleSchemaDefinition(defaultExport(moduleNamespace));
|
||||
definition =
|
||||
mode === "abi-v1"
|
||||
? parseModuleSchemaMetadataDefinition(defaultExport(moduleNamespace))
|
||||
: parseModuleSchemaDefinition(defaultExport(moduleNamespace));
|
||||
} catch {
|
||||
issues.push(
|
||||
issue(
|
||||
@@ -182,7 +211,11 @@ async function loadSchemaDefinitions(discoveredModules: readonly DiscoveredModul
|
||||
continue;
|
||||
}
|
||||
|
||||
loaded.push({ definition, discovered });
|
||||
loaded.push(
|
||||
mode === "abi-v1"
|
||||
? { definition: definition as ModuleSchemaMetadataDefinition, discovered, mode }
|
||||
: { definition: definition as ModuleSchemaDefinition, discovered, mode },
|
||||
);
|
||||
}
|
||||
|
||||
return { issues, loaded };
|
||||
@@ -197,6 +230,58 @@ function inspectTables(
|
||||
|
||||
for (const loaded of loadedSchemas) {
|
||||
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 candidates: ModuleTableCandidate[] = [];
|
||||
const entries = Object.entries(loaded.definition.tables).sort(([left], [right]) =>
|
||||
@@ -293,6 +378,7 @@ function inspectTables(
|
||||
const candidate: ModuleTableCandidate = {
|
||||
exportName,
|
||||
foreignKeys: config.foreignKeys,
|
||||
mode: "legacy",
|
||||
moduleId,
|
||||
name: config.name,
|
||||
path,
|
||||
@@ -321,8 +407,8 @@ function inspectTables(
|
||||
}
|
||||
|
||||
function validForeignKeyTarget(
|
||||
foreignKey: ModuleTableCandidate["foreignKeys"][number],
|
||||
candidate: ModuleTableCandidate,
|
||||
foreignKey: LegacyModuleTableCandidate["foreignKeys"][number],
|
||||
candidate: LegacyModuleTableCandidate,
|
||||
): AnyPgTable | undefined {
|
||||
try {
|
||||
const reference = foreignKey.reference();
|
||||
@@ -356,7 +442,9 @@ function inspectForeignKeys(
|
||||
const ownerByTable = new Map<AnyPgTable, ModuleId>();
|
||||
for (const inspected of inspectedModules) {
|
||||
for (const candidate of inspected.tables) {
|
||||
ownerByTable.set(candidate.table, candidate.moduleId);
|
||||
if (candidate.mode === "legacy") {
|
||||
ownerByTable.set(candidate.table, candidate.moduleId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -364,6 +452,32 @@ function inspectForeignKeys(
|
||||
const moduleId = inspected.discovered.manifest.id;
|
||||
const ownedTables = new Set(inspected.tables.map((candidate) => candidate.table));
|
||||
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) => {
|
||||
const path = `${candidate.path}.foreignKeys.${foreignKeyIndex}`;
|
||||
const foreignTable = validForeignKeyTarget(foreignKey, candidate);
|
||||
@@ -446,7 +560,9 @@ function publishAggregate(
|
||||
moduleId: inspected.discovered.manifest.id,
|
||||
tables: Object.freeze(
|
||||
inspected.tables
|
||||
.map(({ foreignKeys: _foreignKeys, path: _path, ...table }) => Object.freeze(table))
|
||||
.map(({ foreignKeys: _foreignKeys, mode: _mode, path: _path, ...table }) =>
|
||||
Object.freeze(table),
|
||||
)
|
||||
.sort((left, right) => compareText(left.exportName, right.exportName)),
|
||||
),
|
||||
}),
|
||||
@@ -458,7 +574,11 @@ function publishAggregate(
|
||||
.sort((left, right) => compareText(left.name, right.name)),
|
||||
);
|
||||
const drizzleSchema = Object.freeze(
|
||||
Object.fromEntries(tables.map((table) => [table.name, table.table] as const)),
|
||||
Object.fromEntries(
|
||||
tables.flatMap((table) =>
|
||||
table.table === undefined ? [] : [[table.name, table.table] as const],
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
return Object.freeze({ discoveredModules, drizzleSchema, modules, tables });
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
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");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,378 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -3,6 +3,7 @@ import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
CORE_AUTHORIZATION_GRANTS_MANAGE,
|
||||
CORE_DASHBOARD_NAVIGATION_MANAGE,
|
||||
CORE_ACCOUNT_SELF_MANAGE,
|
||||
CORE_DEVICES_MANAGE,
|
||||
CORE_MODULES_CONFIGURATION_MANAGE,
|
||||
CORE_MODULES_LIFECYCLE_MANAGE,
|
||||
@@ -133,7 +134,13 @@ describe("CORE_PERMISSION_REGISTRY", () => {
|
||||
expect(CORE_MODULES_CONFIGURATION_MANAGE).not.toBe(CORE_MODULES_LIFECYCLE_MANAGE);
|
||||
});
|
||||
|
||||
it("contains exactly these six permissions and no others", () => {
|
||||
it("registers core.account.self.manage as authenticated: managing your own account needs no grant", () => {
|
||||
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([
|
||||
CORE_AUTHORIZATION_GRANTS_MANAGE,
|
||||
CORE_MODULES_UI_READ,
|
||||
@@ -141,6 +148,7 @@ describe("CORE_PERMISSION_REGISTRY", () => {
|
||||
CORE_MODULES_LIFECYCLE_MANAGE,
|
||||
CORE_MODULES_CONFIGURATION_MANAGE,
|
||||
CORE_DEVICES_MANAGE,
|
||||
CORE_ACCOUNT_SELF_MANAGE,
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -178,6 +186,11 @@ describe("CORE_PERMISSION_DECLARATIONS", () => {
|
||||
description: "Approve display device pairing, list paired devices, and revoke a device.",
|
||||
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);
|
||||
for (const declaration of CORE_PERMISSION_DECLARATIONS) {
|
||||
|
||||
@@ -178,6 +178,7 @@ async function applyCoreMigrations(url: string): Promise<void> {
|
||||
"core:0005_module_configuration",
|
||||
"core:0006_device_identity",
|
||||
"core:0007_verified_bootstrap_owners",
|
||||
"core:0008_user_preferences",
|
||||
]);
|
||||
await applyModuleMigrations({ aggregate, database: createPostgresMigrationDatabase(url) });
|
||||
} finally {
|
||||
@@ -243,6 +244,8 @@ describe.sequential("live Core authorization store", () => {
|
||||
"ekklesia_core_platform_owner_bootstrap",
|
||||
"ekklesia_core_session",
|
||||
"ekklesia_core_user",
|
||||
// A person's own preferences, one typed column each (ADR 0062).
|
||||
"ekklesia_core_user_preference",
|
||||
"ekklesia_core_verification",
|
||||
]);
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { IMPORT_SAFETY_TEST_TIMEOUT_MS } from "./support/import-safety.js";
|
||||
|
||||
const poolConstructor = vi.hoisted(() => vi.fn());
|
||||
|
||||
@@ -7,16 +8,20 @@ vi.mock("pg", () => ({ Pool: poolConstructor }));
|
||||
describe("bootstrap-platform-owner CLI import safety", () => {
|
||||
const originalEnv = { ...process.env };
|
||||
|
||||
it("does not read DATABASE_URL, connect to PostgreSQL, or run bootstrap merely by importing the module", async () => {
|
||||
delete process.env.DATABASE_URL;
|
||||
it(
|
||||
"does not read DATABASE_URL, connect to PostgreSQL, or run bootstrap merely by importing the module",
|
||||
async () => {
|
||||
delete process.env.DATABASE_URL;
|
||||
|
||||
// Under Vitest, process.argv[1] is never this file's own path, so isDirectExecution() is
|
||||
// false the same way it naturally is for apps/api/src/server.ts under its own test suite —
|
||||
// no argv manipulation is needed to prove the guard holds.
|
||||
await expect(import("../src/cli/bootstrap-platform-owner.js")).resolves.toBeDefined();
|
||||
// Under Vitest, process.argv[1] is never this file's own path, so isDirectExecution() is
|
||||
// false the same way it naturally is for apps/api/src/server.ts under its own test suite —
|
||||
// no argv manipulation is needed to prove the guard holds.
|
||||
await expect(import("../src/cli/bootstrap-platform-owner.js")).resolves.toBeDefined();
|
||||
|
||||
expect(poolConstructor).not.toHaveBeenCalled();
|
||||
expect(poolConstructor).not.toHaveBeenCalled();
|
||||
|
||||
process.env = originalEnv;
|
||||
});
|
||||
process.env = originalEnv;
|
||||
},
|
||||
IMPORT_SAFETY_TEST_TIMEOUT_MS,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { IMPORT_SAFETY_TEST_TIMEOUT_MS } from "./support/import-safety.js";
|
||||
|
||||
const poolConstructor = vi.hoisted(() => vi.fn());
|
||||
|
||||
@@ -7,18 +8,22 @@ vi.mock("pg", () => ({ Pool: poolConstructor }));
|
||||
describe("create-initial-owner CLI import safety", () => {
|
||||
const originalEnv = { ...process.env };
|
||||
|
||||
it("does not read DATABASE_URL, connect to PostgreSQL, or create anything merely by importing the module", async () => {
|
||||
delete process.env.DATABASE_URL;
|
||||
it(
|
||||
"does not read DATABASE_URL, connect to PostgreSQL, or create anything merely by importing the module",
|
||||
async () => {
|
||||
delete process.env.DATABASE_URL;
|
||||
|
||||
// Under Vitest, process.argv[1] is never this file's own path, so isDirectExecution() is false
|
||||
// exactly as it is for `bootstrap-platform-owner.ts` — no argv manipulation is needed to prove
|
||||
// the guard holds. Importing the command must never prompt, connect, or write.
|
||||
await expect(import("../src/cli/create-initial-owner.js")).resolves.toBeDefined();
|
||||
// Under Vitest, process.argv[1] is never this file's own path, so isDirectExecution() is false
|
||||
// exactly as it is for `bootstrap-platform-owner.ts` — no argv manipulation is needed to prove
|
||||
// the guard holds. Importing the command must never prompt, connect, or write.
|
||||
await expect(import("../src/cli/create-initial-owner.js")).resolves.toBeDefined();
|
||||
|
||||
expect(poolConstructor).not.toHaveBeenCalled();
|
||||
expect(poolConstructor).not.toHaveBeenCalled();
|
||||
|
||||
process.env = originalEnv;
|
||||
});
|
||||
process.env = originalEnv;
|
||||
},
|
||||
IMPORT_SAFETY_TEST_TIMEOUT_MS,
|
||||
);
|
||||
|
||||
it("reads no environment variable for the password", async () => {
|
||||
// The password is prompted for or read from standard input; it is deliberately never taken from
|
||||
|
||||
@@ -91,7 +91,7 @@ async function createModule(
|
||||
writeFile(
|
||||
path.join(moduleDirectory, "ekklesia.module.json"),
|
||||
JSON.stringify({
|
||||
manifestVersion: 7,
|
||||
manifestVersion: 8,
|
||||
id,
|
||||
name: `${id} module`,
|
||||
version: "0.1.0",
|
||||
|
||||
@@ -59,7 +59,7 @@ async function createModule(modulesDirectory: string, fixture: ModuleFixture): P
|
||||
: {}),
|
||||
entrypoints: { server: "./dist/server/index.js", ui: "./dist/ui/index.js" },
|
||||
id: fixture.id,
|
||||
manifestVersion: 7,
|
||||
manifestVersion: 8,
|
||||
name: fixture.name,
|
||||
version: "1.0.0",
|
||||
}),
|
||||
|
||||
@@ -177,6 +177,7 @@ async function applyCoreMigrations(url: string): Promise<void> {
|
||||
"core:0005_module_configuration",
|
||||
"core:0006_device_identity",
|
||||
"core:0007_verified_bootstrap_owners",
|
||||
"core:0008_user_preferences",
|
||||
]);
|
||||
await applyModuleMigrations({ aggregate, database: createPostgresMigrationDatabase(url) });
|
||||
} finally {
|
||||
|
||||
@@ -83,7 +83,7 @@ function discoveredModule(id: string, overrides: Record<string, unknown> = {}):
|
||||
},
|
||||
entrypoints: { server: "./dist/server.js" },
|
||||
id,
|
||||
manifestVersion: 7,
|
||||
manifestVersion: 8,
|
||||
name: `${id} module`,
|
||||
permissions: [],
|
||||
storage: { buckets: [] },
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { IMPORT_SAFETY_TEST_TIMEOUT_MS } from "./support/import-safety.js";
|
||||
|
||||
const discoverModules = vi.fn();
|
||||
const discoverModuleSchemas = vi.fn();
|
||||
@@ -22,24 +23,28 @@ vi.mock("../src/migrations/index.js", async (importOriginal) => ({
|
||||
}));
|
||||
|
||||
describe("Doctor import safety", () => {
|
||||
it("does not discover, connect, plan, or print when imported", async () => {
|
||||
const log = vi.spyOn(console, "log").mockImplementation(() => undefined);
|
||||
const error = vi.spyOn(console, "error").mockImplementation(() => undefined);
|
||||
it(
|
||||
"does not discover, connect, plan, or print when imported",
|
||||
async () => {
|
||||
const log = vi.spyOn(console, "log").mockImplementation(() => undefined);
|
||||
const error = vi.spyOn(console, "error").mockImplementation(() => undefined);
|
||||
|
||||
await import("../src/diagnostics/index.js");
|
||||
await import("../src/cli/doctor.js");
|
||||
await import("../src/index.js");
|
||||
await import("../src/diagnostics/index.js");
|
||||
await import("../src/cli/doctor.js");
|
||||
await import("../src/index.js");
|
||||
|
||||
expect(discoverModules).not.toHaveBeenCalled();
|
||||
expect(discoverModuleSchemas).not.toHaveBeenCalled();
|
||||
expect(discoverModuleMigrations).not.toHaveBeenCalled();
|
||||
expect(startModuleRuntime).not.toHaveBeenCalled();
|
||||
expect(createPostgresMigrationDatabase).not.toHaveBeenCalled();
|
||||
expect(planModuleMigrations).not.toHaveBeenCalled();
|
||||
expect(log).not.toHaveBeenCalled();
|
||||
expect(error).not.toHaveBeenCalled();
|
||||
expect(discoverModules).not.toHaveBeenCalled();
|
||||
expect(discoverModuleSchemas).not.toHaveBeenCalled();
|
||||
expect(discoverModuleMigrations).not.toHaveBeenCalled();
|
||||
expect(startModuleRuntime).not.toHaveBeenCalled();
|
||||
expect(createPostgresMigrationDatabase).not.toHaveBeenCalled();
|
||||
expect(planModuleMigrations).not.toHaveBeenCalled();
|
||||
expect(log).not.toHaveBeenCalled();
|
||||
expect(error).not.toHaveBeenCalled();
|
||||
|
||||
log.mockRestore();
|
||||
error.mockRestore();
|
||||
});
|
||||
log.mockRestore();
|
||||
error.mockRestore();
|
||||
},
|
||||
IMPORT_SAFETY_TEST_TIMEOUT_MS,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -92,7 +92,7 @@ Modules
|
||||
✓ Dependency graph valid
|
||||
Dependency order is empty.
|
||||
✓ Permission registry composed
|
||||
6 permissions registered
|
||||
7 permissions registered
|
||||
○ Module lifecycle skipped
|
||||
DATABASE_URL is not configured.
|
||||
○ Module configuration skipped
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { type ModuleManifest, parsePermissionId } from "@ekklesia/contracts";
|
||||
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 { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { createPermissionRegistry, PermissionRegistryError } from "../src/authorization/index.js";
|
||||
import {
|
||||
@@ -32,7 +32,11 @@ const checksumA = "a".repeat(64);
|
||||
const checksumB = "b".repeat(64);
|
||||
const checksumC = "c".repeat(64);
|
||||
|
||||
function discoveredModule(id: string, version = "0.1.0"): DiscoveredModule {
|
||||
function discoveredModule(
|
||||
id: string,
|
||||
version = "0.1.0",
|
||||
database?: { readonly abi: 1 },
|
||||
): DiscoveredModule {
|
||||
return {
|
||||
contributions: [],
|
||||
directory: `/unpublished/${id}`,
|
||||
@@ -41,13 +45,14 @@ function discoveredModule(id: string, version = "0.1.0"): DiscoveredModule {
|
||||
capabilities: [],
|
||||
compatibility: { moduleSdk: "^0.1.0", platform: "^0.1.0" },
|
||||
configuration: [],
|
||||
...(database ? { database } : {}),
|
||||
dependencies: {
|
||||
capabilities: { optional: {}, required: {} },
|
||||
modules: { optional: {}, required: {} },
|
||||
},
|
||||
entrypoints: { server: "./dist/server.js" },
|
||||
id,
|
||||
manifestVersion: 7,
|
||||
manifestVersion: 8,
|
||||
name: `${id} module`,
|
||||
permissions: [],
|
||||
storage: { buckets: [] },
|
||||
@@ -294,7 +299,7 @@ describe("Ekklesia Doctor outbound email reporting", () => {
|
||||
describe("Ekklesia Doctor diagnostics", () => {
|
||||
it("publishes a healthy deterministic static report without database configuration", async () => {
|
||||
const people = discoveredModule("people");
|
||||
const calendar = discoveredModule("calendar", "0.2.0");
|
||||
const calendar = discoveredModule("calendar", "0.2.0", { abi: 1 });
|
||||
const inventory = migrationAggregate(
|
||||
[migration("people", "0000_initial", checksumA)],
|
||||
[people, calendar],
|
||||
@@ -327,6 +332,7 @@ describe("Ekklesia Doctor diagnostics", () => {
|
||||
]);
|
||||
expect(check(report, "modules.discovery").details).toEqual(["people 0.1.0", "calendar 0.2.0"]);
|
||||
expect(check(report, "modules.dependencies").details).toEqual(["Order: people → calendar"]);
|
||||
expect(check(report, "schemas.discovery")).toMatchObject({ status: "pass" });
|
||||
expect(check(report, "modules.permissions")).toMatchObject({
|
||||
details: ["0 permissions registered"],
|
||||
status: "pass",
|
||||
|
||||
+3
-3
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"manifestVersion": 7,
|
||||
"manifestVersion": 8,
|
||||
"id": "db-facade",
|
||||
"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.",
|
||||
"version": "0.1.0",
|
||||
"compatibility": {
|
||||
"platform": "^0.1.0",
|
||||
"moduleSdk": "^0.1.0"
|
||||
"platform": "^0.2.0",
|
||||
"moduleSdk": "^0.2.0"
|
||||
},
|
||||
"permissions": [
|
||||
{
|
||||
|
||||
@@ -169,13 +169,57 @@ describe("the browser shell reaches none of it", () => {
|
||||
});
|
||||
|
||||
it("never puts a reset token in browser storage", async () => {
|
||||
for (const file of await readTypeScriptFiles(path.join("surfaces", "shell", "src"))) {
|
||||
// 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. 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);
|
||||
// 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("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);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { alias, getTableConfig, integer, pgTable } from "drizzle-orm/pg-core";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { parseModuleSchemaDefinition } from "../src/modules/legacy-schema-definition.js";
|
||||
|
||||
describe("the legacy module schema definition", () => {
|
||||
it("normalizes and freezes a typed schema definition without freezing its tables", () => {
|
||||
const records = pgTable("example_records", { id: integer().primaryKey() });
|
||||
const definition = parseModuleSchemaDefinition({
|
||||
moduleId: "example",
|
||||
tables: { records },
|
||||
});
|
||||
|
||||
expect(definition.tables.records).toBe(records);
|
||||
expect(getTableConfig(records).name).toBe("example_records");
|
||||
expect(Object.isFrozen(definition)).toBe(true);
|
||||
expect(Object.isFrozen(definition.tables)).toBe(true);
|
||||
expect(Object.isFrozen(records)).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects malformed, empty, unknown, and non-PostgreSQL table definitions", () => {
|
||||
const records = pgTable("example_records", { id: integer() });
|
||||
const disguisedRecords = alias(records, "example_disguised_records");
|
||||
expect(() => parseModuleSchemaDefinition({ moduleId: "example", tables: {} })).toThrow();
|
||||
expect(() =>
|
||||
parseModuleSchemaDefinition({ moduleId: "example", tables: { records: {} } }),
|
||||
).toThrow();
|
||||
expect(() =>
|
||||
parseModuleSchemaDefinition({ moduleId: "example", tables: { records }, extra: true }),
|
||||
).toThrow();
|
||||
expect(() =>
|
||||
parseModuleSchemaDefinition({ moduleId: "example", tables: { disguisedRecords } }),
|
||||
).toThrow();
|
||||
});
|
||||
});
|
||||
@@ -17,7 +17,7 @@ function fakeModule(
|
||||
} = {},
|
||||
): DiscoveredModule {
|
||||
const manifest = parseModuleManifest({
|
||||
manifestVersion: 7,
|
||||
manifestVersion: 8,
|
||||
id,
|
||||
name: `${id} module`,
|
||||
version: "1.0.0",
|
||||
|
||||
@@ -179,6 +179,7 @@ async function applyCoreMigrations(url: string): Promise<void> {
|
||||
"core:0005_module_configuration",
|
||||
"core:0006_device_identity",
|
||||
"core:0007_verified_bootstrap_owners",
|
||||
"core:0008_user_preferences",
|
||||
]);
|
||||
await applyModuleMigrations({ aggregate, database: createPostgresMigrationDatabase(url) });
|
||||
} finally {
|
||||
@@ -194,7 +195,7 @@ afterAll(async () => {
|
||||
|
||||
function manifestWith(configuration: readonly Record<string, unknown>[], id = "announcements") {
|
||||
return parseModuleManifest({
|
||||
manifestVersion: 7,
|
||||
manifestVersion: 8,
|
||||
id,
|
||||
name: `${id} module`,
|
||||
version: "1.0.0",
|
||||
|
||||
@@ -21,7 +21,7 @@ function manifestWith(
|
||||
id = "announcements",
|
||||
) {
|
||||
return parseModuleManifest({
|
||||
manifestVersion: 7,
|
||||
manifestVersion: 8,
|
||||
id,
|
||||
name: `${id} module`,
|
||||
version: "1.0.0",
|
||||
@@ -92,7 +92,7 @@ describe("module configuration declarations", () => {
|
||||
|
||||
it("rejects two declarations of the same key, so an ordinary key and a secret can never collide", () => {
|
||||
const result = safeParseModuleManifest({
|
||||
manifestVersion: 7,
|
||||
manifestVersion: 8,
|
||||
id: "announcements",
|
||||
name: "announcements module",
|
||||
version: "1.0.0",
|
||||
@@ -114,7 +114,7 @@ describe("module configuration declarations", () => {
|
||||
|
||||
it("rejects a default on a secret, because a checked-in default credential is a leaked one", () => {
|
||||
const result = safeParseModuleManifest({
|
||||
manifestVersion: 7,
|
||||
manifestVersion: 8,
|
||||
id: "announcements",
|
||||
name: "announcements module",
|
||||
version: "1.0.0",
|
||||
@@ -130,7 +130,7 @@ describe("module configuration declarations", () => {
|
||||
|
||||
it("rejects a key that is both required and defaulted, which states an intent nothing can honour", () => {
|
||||
const result = safeParseModuleManifest({
|
||||
manifestVersion: 7,
|
||||
manifestVersion: 8,
|
||||
id: "announcements",
|
||||
name: "announcements module",
|
||||
version: "1.0.0",
|
||||
@@ -169,7 +169,7 @@ describe("module configuration declarations", () => {
|
||||
],
|
||||
])("rejects %s", (_label, declaration) => {
|
||||
const result = safeParseModuleManifest({
|
||||
manifestVersion: 7,
|
||||
manifestVersion: 8,
|
||||
id: "announcements",
|
||||
name: "announcements module",
|
||||
version: "1.0.0",
|
||||
@@ -189,7 +189,7 @@ describe("module configuration declarations", () => {
|
||||
"valueOf",
|
||||
])("rejects the reserved object property name %s as a configuration key", (key) => {
|
||||
const result = safeParseModuleManifest({
|
||||
manifestVersion: 7,
|
||||
manifestVersion: 8,
|
||||
id: "announcements",
|
||||
name: "announcements module",
|
||||
version: "1.0.0",
|
||||
@@ -210,7 +210,7 @@ describe("module configuration declarations", () => {
|
||||
"",
|
||||
])("rejects the malformed configuration key %s", (key) => {
|
||||
const result = safeParseModuleManifest({
|
||||
manifestVersion: 7,
|
||||
manifestVersion: 8,
|
||||
id: "announcements",
|
||||
name: "announcements module",
|
||||
version: "1.0.0",
|
||||
@@ -591,7 +591,7 @@ describe("configuration over a real discovered inventory", () => {
|
||||
writeFile(
|
||||
path.join(moduleDirectory, "ekklesia.module.json"),
|
||||
JSON.stringify({
|
||||
manifestVersion: 7,
|
||||
manifestVersion: 8,
|
||||
id,
|
||||
name: `${id} module`,
|
||||
version: "1.0.0",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,867 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
import {
|
||||
isModuleDatabaseError,
|
||||
type ModuleDatabaseErrorCode,
|
||||
type ModuleDatabaseTransaction,
|
||||
} from "@ekklesia/contracts";
|
||||
import { DatabaseError, type Pool } from "pg";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
createModuleDatabaseExecutor,
|
||||
moduleDatabaseExecutorModuleId,
|
||||
} from "../src/modules/database-abi.js";
|
||||
import {
|
||||
decodeModuleDatabaseValue,
|
||||
encodeModuleDatabaseParameters,
|
||||
MODULE_DATABASE_MAX_PARAMETERS,
|
||||
parsePostgresArrayText,
|
||||
} from "../src/modules/database-abi-values.js";
|
||||
|
||||
/**
|
||||
* Module Database ABI v1 (ADR 0050) without a database: parameter validation and encoding, result
|
||||
* decoding from PostgreSQL text, option validation, error sanitization, and scope bookkeeping.
|
||||
*
|
||||
* Everything that depends on PostgreSQL's own behaviour (type OIDs, the protocol, SQLSTATEs,
|
||||
* transactions, savepoints, session settings) is proven against a real database in
|
||||
* `module-database-abi.integration.ts`. The scripted client here only replays command tags and
|
||||
* transaction status, so the executor's own bookkeeping can be observed statement by statement.
|
||||
*/
|
||||
|
||||
async function rejection(promise: Promise<unknown>): Promise<unknown> {
|
||||
try {
|
||||
await promise;
|
||||
} catch (error) {
|
||||
return error;
|
||||
}
|
||||
throw new Error("Expected the operation to reject.");
|
||||
}
|
||||
|
||||
async function expectAbiError(
|
||||
promise: Promise<unknown>,
|
||||
code: ModuleDatabaseErrorCode,
|
||||
sqlState?: string,
|
||||
): Promise<void> {
|
||||
const error = await rejection(promise);
|
||||
expect(isModuleDatabaseError(error)).toBe(true);
|
||||
expect(error).toMatchObject({ code });
|
||||
expect((error as { sqlState?: string }).sqlState).toBe(sqlState);
|
||||
}
|
||||
|
||||
function syncThrows(run: () => unknown): unknown {
|
||||
try {
|
||||
run();
|
||||
} catch (error) {
|
||||
return error;
|
||||
}
|
||||
throw new Error("Expected the call to throw.");
|
||||
}
|
||||
|
||||
// --- Parameters --------------------------------------------------------------------------------
|
||||
|
||||
describe("encodeModuleDatabaseParameters", () => {
|
||||
it("encodes every scalar category as text, or bytes, and nothing else", () => {
|
||||
const bytes = new Uint8Array([0, 1, 255]);
|
||||
const encoded = encodeModuleDatabaseParameters([
|
||||
null,
|
||||
true,
|
||||
false,
|
||||
1.5,
|
||||
Number.NaN,
|
||||
Number.POSITIVE_INFINITY,
|
||||
Number.NEGATIVE_INFINITY,
|
||||
9_007_199_254_740_993n,
|
||||
'it\'s \\ "quoted"',
|
||||
bytes,
|
||||
]);
|
||||
expect(encoded.slice(0, 9)).toEqual([
|
||||
null,
|
||||
"true",
|
||||
"false",
|
||||
"1.5",
|
||||
"NaN",
|
||||
"Infinity",
|
||||
"-Infinity",
|
||||
"9007199254740993",
|
||||
'it\'s \\ "quoted"',
|
||||
]);
|
||||
expect(Buffer.isBuffer(encoded[9])).toBe(true);
|
||||
expect([...(encoded[9] as Buffer)]).toEqual([0, 1, 255]);
|
||||
});
|
||||
|
||||
it("copies bytes, so a later change by the caller cannot reach the statement", () => {
|
||||
const bytes = new Uint8Array([1, 2, 3]);
|
||||
const view = bytes.subarray(1);
|
||||
const [encoded] = encodeModuleDatabaseParameters([view]);
|
||||
bytes[1] = 9;
|
||||
expect([...(encoded as Buffer)]).toEqual([2, 3]);
|
||||
});
|
||||
|
||||
it("encodes arrays as quoted PostgreSQL array literals, nested for more dimensions", () => {
|
||||
expect(
|
||||
encodeModuleDatabaseParameters([
|
||||
[1, null, 3],
|
||||
['a"b', "c\\d", "", "NULL", "{,}"],
|
||||
[
|
||||
[1, 2],
|
||||
[3, 4],
|
||||
],
|
||||
[new Uint8Array([0, 255])],
|
||||
[true, 2n],
|
||||
[],
|
||||
]),
|
||||
).toEqual([
|
||||
'{"1",NULL,"3"}',
|
||||
'{"a\\"b","c\\\\d","","NULL","{,}"}',
|
||||
'{{"1","2"},{"3","4"}}',
|
||||
'{"\\\\x00ff"}',
|
||||
'{"true","2"}',
|
||||
"{}",
|
||||
]);
|
||||
});
|
||||
|
||||
it("treats omitted values as no parameters", () => {
|
||||
expect(encodeModuleDatabaseParameters(undefined)).toEqual([]);
|
||||
});
|
||||
|
||||
class Point {
|
||||
readonly x = 1;
|
||||
}
|
||||
|
||||
const refused: readonly [string, unknown][] = [
|
||||
["a Date", new Date(0)],
|
||||
["a plain object", { a: 1 }],
|
||||
["a null-prototype object", Object.create(null)],
|
||||
["undefined", undefined],
|
||||
["a function", () => 1],
|
||||
["a symbol", Symbol("s")],
|
||||
["a class instance", new Point()],
|
||||
["a Map", new Map()],
|
||||
["another typed array", new Int16Array([1])],
|
||||
["an ArrayBuffer", new ArrayBuffer(1)],
|
||||
["a boxed string", new String("x")],
|
||||
["an array holding a Date", [new Date(0)]],
|
||||
["an array holding undefined", [1, undefined]],
|
||||
["an array holding an object", [{ a: 1 }]],
|
||||
// biome-ignore lint/suspicious/noSparseArray: the hole is the case under test.
|
||||
["a sparse array", [1, , 3]],
|
||||
["scalars mixed with sub-arrays", [1, [2]]],
|
||||
["a null where a sub-array belongs", [[1], null]],
|
||||
["ragged sub-arrays", [[1], [1, 2]]],
|
||||
["more dimensions than PostgreSQL allows", [[[[[[[1]]]]]]]],
|
||||
];
|
||||
|
||||
for (const [label, value] of refused) {
|
||||
it(`refuses ${label}, without naming the value`, () => {
|
||||
const error = syncThrows(() => encodeModuleDatabaseParameters(["fine", value]));
|
||||
expect(isModuleDatabaseError(error)).toBe(true);
|
||||
expect(error).toMatchObject({ code: "MODULE_DATABASE_PARAMETER_INVALID" });
|
||||
expect((error as Error).message).not.toMatch(/fine|1970|Point|Symbol/u);
|
||||
});
|
||||
}
|
||||
|
||||
it("accepts exactly PostgreSQL's six array dimensions", () => {
|
||||
expect(encodeModuleDatabaseParameters([[[[[[[1]]]]]]])).toEqual(['{{{{{{"1"}}}}}}']);
|
||||
});
|
||||
|
||||
it("refuses a values list that is not an array, or longer than the protocol allows", () => {
|
||||
for (const values of [
|
||||
"x",
|
||||
{ 0: "x", length: 1 },
|
||||
new Array(MODULE_DATABASE_MAX_PARAMETERS + 1).fill(1),
|
||||
]) {
|
||||
const error = syncThrows(() => encodeModuleDatabaseParameters(values));
|
||||
expect(error).toMatchObject({ code: "MODULE_DATABASE_PARAMETER_INVALID" });
|
||||
}
|
||||
expect(
|
||||
encodeModuleDatabaseParameters(new Array(MODULE_DATABASE_MAX_PARAMETERS).fill(1)),
|
||||
).toHaveLength(MODULE_DATABASE_MAX_PARAMETERS);
|
||||
});
|
||||
|
||||
it("refuses a self-referencing array instead of recursing forever", () => {
|
||||
const cyclic: unknown[] = [];
|
||||
cyclic.push(cyclic);
|
||||
expect(syncThrows(() => encodeModuleDatabaseParameters([cyclic]))).toMatchObject({
|
||||
code: "MODULE_DATABASE_PARAMETER_INVALID",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// --- Results -----------------------------------------------------------------------------------
|
||||
|
||||
describe("decodeModuleDatabaseValue", () => {
|
||||
it("decodes the scalar types the table names, and keeps everything else as exact text", () => {
|
||||
expect(decodeModuleDatabaseValue(16, "t")).toBe(true);
|
||||
expect(decodeModuleDatabaseValue(16, "f")).toBe(false);
|
||||
expect(decodeModuleDatabaseValue(21, "-32768")).toBe(-32_768);
|
||||
expect(decodeModuleDatabaseValue(23, "2147483647")).toBe(2_147_483_647);
|
||||
expect(decodeModuleDatabaseValue(26, "4294967295")).toBe(4_294_967_295);
|
||||
expect(decodeModuleDatabaseValue(700, "1.5")).toBe(1.5);
|
||||
expect(decodeModuleDatabaseValue(701, "NaN")).toBeNaN();
|
||||
expect(decodeModuleDatabaseValue(701, "Infinity")).toBe(Number.POSITIVE_INFINITY);
|
||||
expect(decodeModuleDatabaseValue(701, "-Infinity")).toBe(Number.NEGATIVE_INFINITY);
|
||||
expect(decodeModuleDatabaseValue(701, "1e+300")).toBe(1e300);
|
||||
// Precision-sensitive and zone-sensitive types stay exactly as PostgreSQL wrote them.
|
||||
expect(decodeModuleDatabaseValue(20, "9223372036854775807")).toBe("9223372036854775807");
|
||||
expect(decodeModuleDatabaseValue(1700, "0.1000000000000000000001")).toBe(
|
||||
"0.1000000000000000000001",
|
||||
);
|
||||
expect(decodeModuleDatabaseValue(3802, '{"a": 12345678901234567890}')).toBe(
|
||||
'{"a": 12345678901234567890}',
|
||||
);
|
||||
expect(decodeModuleDatabaseValue(1184, "2026-10-07 12:00:00+00")).toBe(
|
||||
"2026-10-07 12:00:00+00",
|
||||
);
|
||||
// An unknown OID (an enum, a domain, an extension type) is text.
|
||||
expect(decodeModuleDatabaseValue(987_654, "anything")).toBe("anything");
|
||||
});
|
||||
|
||||
it("decodes bytea to a plain Uint8Array, from hex or the older escape form", () => {
|
||||
const hex = decodeModuleDatabaseValue(17, "\\x00ff10");
|
||||
expect(Object.getPrototypeOf(hex)).toBe(Uint8Array.prototype);
|
||||
expect([...(hex as Uint8Array)]).toEqual([0, 255, 16]);
|
||||
expect([...(decodeModuleDatabaseValue(17, "\\x") as Uint8Array)]).toEqual([]);
|
||||
const escaped = decodeModuleDatabaseValue(17, "a\\000\\\\\\377");
|
||||
expect([...(escaped as Uint8Array)]).toEqual([97, 0, 92, 255]);
|
||||
});
|
||||
|
||||
it("decodes built-in arrays element by element", () => {
|
||||
expect(decodeModuleDatabaseValue(1016, "{1,NULL,9223372036854775807}")).toEqual([
|
||||
"1",
|
||||
null,
|
||||
"9223372036854775807",
|
||||
]);
|
||||
expect(decodeModuleDatabaseValue(1231, "{1.10,2}")).toEqual(["1.10", "2"]);
|
||||
expect(decodeModuleDatabaseValue(1000, "{t,f}")).toEqual([true, false]);
|
||||
expect(decodeModuleDatabaseValue(1007, "{{1,2},{3,4}}")).toEqual([
|
||||
[1, 2],
|
||||
[3, 4],
|
||||
]);
|
||||
const bytes = decodeModuleDatabaseValue(1001, '{"\\\\x00ff"}') as Uint8Array[];
|
||||
expect([...(bytes[0] as Uint8Array)]).toEqual([0, 255]);
|
||||
expect(decodeModuleDatabaseValue(1009, "{}")).toEqual([]);
|
||||
// An array of a type the table does not know arrives as text, like the type itself.
|
||||
expect(decodeModuleDatabaseValue(987_655, "{a,b}")).toBe("{a,b}");
|
||||
});
|
||||
});
|
||||
|
||||
describe("parsePostgresArrayText", () => {
|
||||
const text = (value: string) => value;
|
||||
|
||||
it('honours quoting, escapes, and the difference between NULL and "NULL"', () => {
|
||||
expect(parsePostgresArrayText('{"a\\"b","c,d",NULL,"NULL","","{}",null}', text)).toEqual([
|
||||
'a"b',
|
||||
"c,d",
|
||||
null,
|
||||
"NULL",
|
||||
"",
|
||||
"{}",
|
||||
null,
|
||||
]);
|
||||
expect(parsePostgresArrayText('{"back\\\\slash"}', text)).toEqual(["back\\slash"]);
|
||||
});
|
||||
|
||||
it("drops a non-default lower bound: a JavaScript array is a zero-based list", () => {
|
||||
expect(parsePostgresArrayText("[0:1]={5,6}", text)).toEqual(["5", "6"]);
|
||||
expect(parsePostgresArrayText("[1:1][2:3]={{7,8}}", text)).toEqual([["7", "8"]]);
|
||||
});
|
||||
|
||||
it("refuses text that is not an array literal", () => {
|
||||
for (const malformed of ["", "{", "{1", '{"a', "{1}x", "[1:2]{1,2}", "1"]) {
|
||||
expect(() => parsePostgresArrayText(malformed, text)).toThrow();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// --- The executor, over a scripted client -------------------------------------------------------
|
||||
|
||||
interface ScriptedStatement {
|
||||
readonly text: string;
|
||||
readonly values?: readonly unknown[];
|
||||
}
|
||||
|
||||
interface Script {
|
||||
readonly pool: Pool;
|
||||
readonly statements: ScriptedStatement[];
|
||||
readonly released: (boolean | undefined)[];
|
||||
/** Resolve a statement only when `release` is called, to model in-flight work. */
|
||||
holdNext(): () => void;
|
||||
failNext(error: unknown): void;
|
||||
/** Fail the next statement whose text is exactly `text`. */
|
||||
failWhen(text: string, error: unknown): void;
|
||||
connectCount(): number;
|
||||
}
|
||||
|
||||
function commandOf(text: string): string {
|
||||
const word = /^\s*([A-Za-z]+)/u.exec(text)?.[1]?.toUpperCase() ?? "";
|
||||
return word === "END" ? "COMMIT" : word;
|
||||
}
|
||||
|
||||
/**
|
||||
* A pool whose one client replays PostgreSQL's command tags and transaction status for simple SQL.
|
||||
* It is not PostgreSQL: it exists only so the executor's bookkeeping can be observed.
|
||||
*/
|
||||
function scriptedPool(): Script {
|
||||
const statements: ScriptedStatement[] = [];
|
||||
const released: (boolean | undefined)[] = [];
|
||||
let status = "I";
|
||||
let connects = 0;
|
||||
let hold: Promise<void> | undefined;
|
||||
let failure: unknown;
|
||||
const failures = new Map<string, unknown>();
|
||||
|
||||
const client = {
|
||||
getTransactionStatus: () => status,
|
||||
on: () => client,
|
||||
removeListener: () => client,
|
||||
async query(config: string | { text: string; values?: unknown[] }) {
|
||||
const text = typeof config === "string" ? config : config.text;
|
||||
statements.push(
|
||||
typeof config === "string" ? { text } : { text, values: config.values ?? [] },
|
||||
);
|
||||
const held = hold;
|
||||
hold = undefined;
|
||||
if (held !== undefined) {
|
||||
await held;
|
||||
}
|
||||
if (failure !== undefined || failures.has(text)) {
|
||||
const error = failure ?? failures.get(text);
|
||||
failure = undefined;
|
||||
failures.delete(text);
|
||||
if (status === "T") {
|
||||
status = "E";
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
const command = commandOf(text);
|
||||
if (command === "BEGIN" || command === "START") {
|
||||
status = "T";
|
||||
} else if (command === "COMMIT" || (command === "ROLLBACK" && !/\bTO\b/iu.test(text))) {
|
||||
const aborted = status === "E";
|
||||
status = "I";
|
||||
return { command: aborted ? "ROLLBACK" : command, fields: [], rowCount: null, rows: [] };
|
||||
}
|
||||
return {
|
||||
command,
|
||||
fields: [{ dataTypeID: 23, name: "n" }],
|
||||
rowCount: 1,
|
||||
rows: [["1"]],
|
||||
};
|
||||
},
|
||||
release: (destroy?: boolean) => {
|
||||
released.push(destroy);
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
connectCount: () => connects,
|
||||
failNext(error) {
|
||||
failure = error;
|
||||
},
|
||||
failWhen(text, error) {
|
||||
failures.set(text, error);
|
||||
},
|
||||
holdNext() {
|
||||
let release!: () => void;
|
||||
hold = new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
return release;
|
||||
},
|
||||
pool: {
|
||||
connect: vi.fn(async () => {
|
||||
connects += 1;
|
||||
return client;
|
||||
}),
|
||||
} as unknown as Pool,
|
||||
released,
|
||||
statements,
|
||||
};
|
||||
}
|
||||
|
||||
function pgError(code: string): DatabaseError {
|
||||
const error = new DatabaseError(
|
||||
'duplicate key value violates unique constraint "example_secret_key"',
|
||||
100,
|
||||
"error",
|
||||
);
|
||||
error.code = code;
|
||||
error.detail = "Key (secret)=(hunter2) already exists.";
|
||||
error.hint = "a hint";
|
||||
error.table = "example_records";
|
||||
error.constraint = "example_secret_key";
|
||||
return error;
|
||||
}
|
||||
|
||||
const pin =
|
||||
"SET TimeZone TO 'UTC'; SET DateStyle TO 'ISO, MDY'; SET IntervalStyle TO 'postgres'; SET bytea_output TO 'hex'; SET extra_float_digits TO 1";
|
||||
|
||||
function texts(script: Script): string[] {
|
||||
return script.statements.map((statement) => statement.text);
|
||||
}
|
||||
|
||||
describe("createModuleDatabaseExecutor", () => {
|
||||
it("builds a frozen executor holding exactly the ABI's members, without touching the pool", () => {
|
||||
const script = scriptedPool();
|
||||
const executor = createModuleDatabaseExecutor(script.pool, { moduleId: "example" });
|
||||
|
||||
expect(Object.keys(executor).sort()).toEqual(["abiVersion", "query", "transaction"]);
|
||||
expect(executor.abiVersion).toBe(1);
|
||||
expect(Object.isFrozen(executor)).toBe(true);
|
||||
expect(Object.getPrototypeOf(executor)).toBe(Object.prototype);
|
||||
for (const forbidden of ["pool", "client", "$client", "_", "session", "dialect", "select"]) {
|
||||
expect(forbidden in executor).toBe(false);
|
||||
}
|
||||
expect(script.connectCount()).toBe(0);
|
||||
});
|
||||
|
||||
it("carries its module for Core's attribution, and refuses an invalid module ID", () => {
|
||||
const executor = createModuleDatabaseExecutor(scriptedPool().pool, { moduleId: "care-team" });
|
||||
expect(moduleDatabaseExecutorModuleId(executor)).toBe("care-team");
|
||||
expect(moduleDatabaseExecutorModuleId({})).toBeUndefined();
|
||||
expect(() =>
|
||||
createModuleDatabaseExecutor(scriptedPool().pool, { moduleId: "Not A Module" }),
|
||||
).toThrow(TypeError);
|
||||
});
|
||||
|
||||
it("pins the session, runs one extended-protocol statement, and resets before releasing", async () => {
|
||||
const script = scriptedPool();
|
||||
const executor = createModuleDatabaseExecutor(script.pool, { moduleId: "example" });
|
||||
|
||||
const result = await executor.query({ text: "SELECT 1 AS n", values: [5n] });
|
||||
|
||||
expect(result).toEqual({
|
||||
command: "SELECT",
|
||||
fields: [{ name: "n", typeOid: 23 }],
|
||||
rowCount: 1,
|
||||
rows: [{ n: 1 }],
|
||||
});
|
||||
expect(texts(script)).toEqual([pin, "SELECT 1 AS n", "DISCARD ALL"]);
|
||||
expect(script.statements[1]).toEqual({ text: "SELECT 1 AS n", values: ["5"] });
|
||||
expect(script.released).toEqual([undefined]);
|
||||
expect(Object.isFrozen(result)).toBe(true);
|
||||
expect(Object.isFrozen(result.rows)).toBe(true);
|
||||
expect(Object.isFrozen(result.fields)).toBe(true);
|
||||
});
|
||||
|
||||
it("refuses a malformed request before borrowing a connection", async () => {
|
||||
const script = scriptedPool();
|
||||
const executor = createModuleDatabaseExecutor(script.pool, { moduleId: "example" });
|
||||
const query = executor.query as (request: unknown) => Promise<unknown>;
|
||||
|
||||
for (const request of [
|
||||
undefined,
|
||||
null,
|
||||
"SELECT 1",
|
||||
[],
|
||||
{ text: "" },
|
||||
{ text: 1 },
|
||||
{ text: "SELECT 1", rowMode: "rows" },
|
||||
{ text: "SELECT 1", name: "prepared" },
|
||||
{ text: "SELECT 1", types: [] },
|
||||
]) {
|
||||
await expectAbiError(query(request), "MODULE_DATABASE_REQUEST_INVALID");
|
||||
}
|
||||
await expectAbiError(
|
||||
query({ text: "SELECT $1", values: [new Date()] }),
|
||||
"MODULE_DATABASE_PARAMETER_INVALID",
|
||||
);
|
||||
expect(script.connectCount()).toBe(0);
|
||||
});
|
||||
|
||||
it("reports only PostgreSQL's SQLSTATE from a database error, never its text", async () => {
|
||||
const script = scriptedPool();
|
||||
const executor = createModuleDatabaseExecutor(script.pool, { moduleId: "example" });
|
||||
script.failWhen("SELECT 1", pgError("23505"));
|
||||
const error = await rejection(executor.query({ text: "SELECT 1" }));
|
||||
|
||||
expect(error).toMatchObject({ code: "MODULE_DATABASE_QUERY_FAILED", sqlState: "23505" });
|
||||
const serialized = `${JSON.stringify(error)} ${String(error)} ${(error as Error).stack}`;
|
||||
for (const secret of [
|
||||
"hunter2",
|
||||
"example_secret_key",
|
||||
"example_records",
|
||||
"duplicate",
|
||||
"hint",
|
||||
]) {
|
||||
expect(serialized).not.toContain(secret);
|
||||
}
|
||||
expect((error as Error).cause).toBeUndefined();
|
||||
// A PostgreSQL error leaves the connection usable, so it is reset and returned.
|
||||
expect(script.released).toEqual([undefined]);
|
||||
});
|
||||
|
||||
it("drops a non-PostgreSQL failure's code and destroys the connection it happened on", async () => {
|
||||
const script = scriptedPool();
|
||||
const executor = createModuleDatabaseExecutor(script.pool, { moduleId: "example" });
|
||||
script.failWhen(
|
||||
"SELECT 1",
|
||||
Object.assign(new Error("Connection terminated: postgres://u:p@h/db"), { code: "EPIPE" }),
|
||||
);
|
||||
|
||||
const error = await rejection(executor.query({ text: "SELECT 1" }));
|
||||
expect(error).toMatchObject({ code: "MODULE_DATABASE_QUERY_FAILED" });
|
||||
expect((error as { sqlState?: string }).sqlState).toBeUndefined();
|
||||
expect(JSON.stringify(error)).not.toContain("postgres://");
|
||||
expect(script.released).toEqual([true]);
|
||||
});
|
||||
|
||||
it("refuses a top-level transaction-control statement and destroys a connection it left open", async () => {
|
||||
const script = scriptedPool();
|
||||
const executor = createModuleDatabaseExecutor(script.pool, { moduleId: "example" });
|
||||
|
||||
await expectAbiError(executor.query({ text: "BEGIN" }), "MODULE_DATABASE_REQUEST_INVALID");
|
||||
expect(script.released).toEqual([true]);
|
||||
expect(texts(script)).not.toContain("DISCARD ALL");
|
||||
});
|
||||
});
|
||||
|
||||
describe("transaction options", () => {
|
||||
const invalid: readonly unknown[] = [
|
||||
null,
|
||||
"serializable",
|
||||
[],
|
||||
{ isolation: "read uncommitted" },
|
||||
{ isolation: "SERIALIZABLE" },
|
||||
{ isolation: "constructor" },
|
||||
{ readOnly: "yes" },
|
||||
{ readOnly: 1 },
|
||||
{ deferrable: true },
|
||||
{ isolation: "serializable", retries: 3 },
|
||||
];
|
||||
|
||||
for (const options of invalid) {
|
||||
it(`refuses ${JSON.stringify(options)} before borrowing a connection`, async () => {
|
||||
const script = scriptedPool();
|
||||
const executor = createModuleDatabaseExecutor(script.pool, { moduleId: "example" });
|
||||
const callback = vi.fn();
|
||||
await expectAbiError(
|
||||
executor.transaction(callback, options as never),
|
||||
"MODULE_DATABASE_TRANSACTION_OPTION_INVALID",
|
||||
);
|
||||
expect(callback).not.toHaveBeenCalled();
|
||||
expect(script.connectCount()).toBe(0);
|
||||
});
|
||||
}
|
||||
|
||||
it("refuses a callback that is not a function", async () => {
|
||||
const script = scriptedPool();
|
||||
const executor = createModuleDatabaseExecutor(script.pool, { moduleId: "example" });
|
||||
await expectAbiError(
|
||||
executor.transaction("SELECT 1" as never),
|
||||
"MODULE_DATABASE_REQUEST_INVALID",
|
||||
);
|
||||
expect(script.connectCount()).toBe(0);
|
||||
});
|
||||
|
||||
it("begins every transaction with an explicit isolation level and access mode", async () => {
|
||||
const cases: readonly [unknown, string][] = [
|
||||
[undefined, "BEGIN ISOLATION LEVEL READ COMMITTED READ WRITE"],
|
||||
[{}, "BEGIN ISOLATION LEVEL READ COMMITTED READ WRITE"],
|
||||
[{ readOnly: true }, "BEGIN ISOLATION LEVEL READ COMMITTED READ ONLY"],
|
||||
[{ isolation: "repeatable read" }, "BEGIN ISOLATION LEVEL REPEATABLE READ READ WRITE"],
|
||||
[
|
||||
{ isolation: "serializable", readOnly: false },
|
||||
"BEGIN ISOLATION LEVEL SERIALIZABLE READ WRITE",
|
||||
],
|
||||
];
|
||||
for (const [options, begin] of cases) {
|
||||
const script = scriptedPool();
|
||||
const executor = createModuleDatabaseExecutor(script.pool, { moduleId: "example" });
|
||||
await executor.transaction(async () => undefined, options as never);
|
||||
expect(texts(script)).toEqual([pin, begin, "COMMIT", "DISCARD ALL"]);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("transaction scopes", () => {
|
||||
it("commits a resolved callback and returns its value", async () => {
|
||||
const script = scriptedPool();
|
||||
const executor = createModuleDatabaseExecutor(script.pool, { moduleId: "example" });
|
||||
|
||||
const value = await executor.transaction(async (tx) => {
|
||||
expect(Object.keys(tx).sort()).toEqual(["abiVersion", "query", "savepoint"]);
|
||||
expect(Object.isFrozen(tx)).toBe(true);
|
||||
expect(moduleDatabaseExecutorModuleId(tx)).toBe("example");
|
||||
await tx.query({ text: "INSERT INTO example_records VALUES (1)" });
|
||||
return "done";
|
||||
});
|
||||
|
||||
expect(value).toBe("done");
|
||||
expect(texts(script).slice(1)).toEqual([
|
||||
"BEGIN ISOLATION LEVEL READ COMMITTED READ WRITE",
|
||||
"INSERT INTO example_records VALUES (1)",
|
||||
"COMMIT",
|
||||
"DISCARD ALL",
|
||||
]);
|
||||
});
|
||||
|
||||
it("rolls back a rejected callback and rejects with the callback's own error, untouched", async () => {
|
||||
const script = scriptedPool();
|
||||
const executor = createModuleDatabaseExecutor(script.pool, { moduleId: "example" });
|
||||
const own = new Error("module failure");
|
||||
|
||||
const error = await rejection(
|
||||
executor.transaction(async (tx) => {
|
||||
await tx.query({ text: "INSERT INTO example_records VALUES (1)" });
|
||||
throw own;
|
||||
}),
|
||||
);
|
||||
|
||||
expect(error).toBe(own);
|
||||
expect(texts(script)).toContain("ROLLBACK");
|
||||
expect(texts(script)).not.toContain("COMMIT");
|
||||
});
|
||||
|
||||
it("revokes the executor when the callback settles; later use sends nothing", async () => {
|
||||
const script = scriptedPool();
|
||||
const executor = createModuleDatabaseExecutor(script.pool, { moduleId: "example" });
|
||||
let escaped: ModuleDatabaseTransaction | undefined;
|
||||
await executor.transaction((tx) => {
|
||||
escaped = tx;
|
||||
});
|
||||
const sent = script.statements.length;
|
||||
|
||||
await expectAbiError(
|
||||
(escaped as ModuleDatabaseTransaction).query({ text: "SELECT 1" }),
|
||||
"MODULE_DATABASE_SCOPE_CLOSED",
|
||||
);
|
||||
await expectAbiError(
|
||||
(escaped as ModuleDatabaseTransaction).savepoint(async () => undefined),
|
||||
"MODULE_DATABASE_SCOPE_CLOSED",
|
||||
);
|
||||
expect(script.statements).toHaveLength(sent);
|
||||
});
|
||||
|
||||
it("never commits a callback that returned with a query still outstanding, even once it succeeds", async () => {
|
||||
const script = scriptedPool();
|
||||
const executor = createModuleDatabaseExecutor(script.pool, { moduleId: "example" });
|
||||
let forgotten: Promise<unknown> | undefined;
|
||||
let releaseHeld: (() => void) | undefined;
|
||||
|
||||
const outcome = executor.transaction((tx) => {
|
||||
releaseHeld = script.holdNext();
|
||||
forgotten = tx.query({ text: "INSERT INTO example_records VALUES (1)" });
|
||||
});
|
||||
await vi.waitFor(() => expect(releaseHeld).toBeDefined());
|
||||
// Core waits for the started statement to settle before rolling back the connection.
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
expect(texts(script)).not.toContain("ROLLBACK");
|
||||
releaseHeld?.();
|
||||
|
||||
await expectAbiError(outcome, "MODULE_DATABASE_SCOPE_WORK_OUTSTANDING");
|
||||
await expect(forgotten).resolves.toBeDefined();
|
||||
expect(texts(script).slice(-2)).toEqual(["ROLLBACK", "DISCARD ALL"]);
|
||||
expect(texts(script)).not.toContain("COMMIT");
|
||||
});
|
||||
|
||||
it("keeps a rejected callback's own error when sibling work is still settling, and still never commits", async () => {
|
||||
const script = scriptedPool();
|
||||
const executor = createModuleDatabaseExecutor(script.pool, { moduleId: "example" });
|
||||
const own = new Error("module failure");
|
||||
let releaseHeld: (() => void) | undefined;
|
||||
|
||||
const outcome = executor.transaction(async (tx) => {
|
||||
releaseHeld = script.holdNext();
|
||||
void tx.query({ text: "INSERT INTO example_records VALUES (1)" });
|
||||
throw own;
|
||||
});
|
||||
await vi.waitFor(() => expect(releaseHeld).toBeDefined());
|
||||
// The sibling statement is drained before Core rolls back.
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
expect(texts(script)).not.toContain("ROLLBACK");
|
||||
releaseHeld?.();
|
||||
|
||||
expect(await rejection(outcome)).toBe(own);
|
||||
expect(texts(script).slice(-2)).toEqual(["ROLLBACK", "DISCARD ALL"]);
|
||||
expect(texts(script)).not.toContain("COMMIT");
|
||||
});
|
||||
|
||||
it("refuses a transaction-control statement inside a transaction and never commits it", async () => {
|
||||
const script = scriptedPool();
|
||||
const executor = createModuleDatabaseExecutor(script.pool, { moduleId: "example" });
|
||||
|
||||
const error = await rejection(
|
||||
executor.transaction(async (tx) => {
|
||||
await expectAbiError(
|
||||
tx.query({ text: "SAVEPOINT mine" }),
|
||||
"MODULE_DATABASE_REQUEST_INVALID",
|
||||
);
|
||||
return "carried on";
|
||||
}),
|
||||
);
|
||||
|
||||
expect(error).toMatchObject({ code: "MODULE_DATABASE_REQUEST_INVALID" });
|
||||
expect(texts(script)).not.toContain("COMMIT");
|
||||
expect(texts(script)).toContain("ROLLBACK");
|
||||
// The connection is destroyed, never reset and reused.
|
||||
expect(texts(script)).not.toContain("DISCARD ALL");
|
||||
expect(script.released).toEqual([true]);
|
||||
});
|
||||
|
||||
it("reports a COMMIT that PostgreSQL answered with ROLLBACK as a failed transaction", async () => {
|
||||
const script = scriptedPool();
|
||||
const executor = createModuleDatabaseExecutor(script.pool, { moduleId: "example" });
|
||||
|
||||
const outcome = executor.transaction(async (tx) => {
|
||||
script.failNext(pgError("22012"));
|
||||
await expectAbiError(
|
||||
tx.query({ text: "SELECT 1/0" }),
|
||||
"MODULE_DATABASE_QUERY_FAILED",
|
||||
"22012",
|
||||
);
|
||||
});
|
||||
|
||||
await expectAbiError(outcome, "MODULE_DATABASE_TRANSACTION_FAILED");
|
||||
});
|
||||
});
|
||||
|
||||
describe("savepoint scopes", () => {
|
||||
it("names savepoints itself, releases a resolved one, and rolls back a rejected one", async () => {
|
||||
const script = scriptedPool();
|
||||
const executor = createModuleDatabaseExecutor(script.pool, { moduleId: "example" });
|
||||
const own = new Error("savepoint failure");
|
||||
|
||||
await executor.transaction(async (tx) => {
|
||||
await tx.savepoint(async (sp) => {
|
||||
await sp.query({ text: "INSERT INTO example_records VALUES (1)" });
|
||||
});
|
||||
expect(await rejection(tx.savepoint(async () => Promise.reject(own)))).toBe(own);
|
||||
});
|
||||
|
||||
expect(texts(script).slice(2, -2)).toEqual([
|
||||
"SAVEPOINT ekklesia_savepoint_1",
|
||||
"INSERT INTO example_records VALUES (1)",
|
||||
"RELEASE SAVEPOINT ekklesia_savepoint_1",
|
||||
"SAVEPOINT ekklesia_savepoint_2",
|
||||
"ROLLBACK TO SAVEPOINT ekklesia_savepoint_2",
|
||||
"RELEASE SAVEPOINT ekklesia_savepoint_2",
|
||||
]);
|
||||
});
|
||||
|
||||
it("makes the parent inactive while a savepoint is open, so scopes cannot overlap", async () => {
|
||||
const script = scriptedPool();
|
||||
const executor = createModuleDatabaseExecutor(script.pool, { moduleId: "example" });
|
||||
|
||||
await executor.transaction(async (tx) => {
|
||||
await tx.savepoint(async (sp) => {
|
||||
await expectAbiError(tx.query({ text: "SELECT 1" }), "MODULE_DATABASE_SCOPE_INACTIVE");
|
||||
await expectAbiError(
|
||||
tx.savepoint(async () => undefined),
|
||||
"MODULE_DATABASE_SCOPE_INACTIVE",
|
||||
);
|
||||
await sp.savepoint(async (inner) => {
|
||||
await expectAbiError(sp.query({ text: "SELECT 1" }), "MODULE_DATABASE_SCOPE_INACTIVE");
|
||||
await inner.query({ text: "SELECT 2" });
|
||||
});
|
||||
await sp.query({ text: "SELECT 3" });
|
||||
});
|
||||
await tx.query({ text: "SELECT 4" });
|
||||
});
|
||||
|
||||
expect(texts(script)).toContain("SAVEPOINT ekklesia_savepoint_2");
|
||||
expect(texts(script)).toContain("COMMIT");
|
||||
});
|
||||
|
||||
it("revokes an escaped savepoint executor", async () => {
|
||||
const script = scriptedPool();
|
||||
const executor = createModuleDatabaseExecutor(script.pool, { moduleId: "example" });
|
||||
|
||||
await executor.transaction(async (tx) => {
|
||||
let escaped: ModuleDatabaseTransaction | undefined;
|
||||
await tx.savepoint((sp) => {
|
||||
escaped = sp;
|
||||
});
|
||||
await expectAbiError(
|
||||
(escaped as ModuleDatabaseTransaction).query({ text: "SELECT 1" }),
|
||||
"MODULE_DATABASE_SCOPE_CLOSED",
|
||||
);
|
||||
await tx.query({ text: "SELECT 2" });
|
||||
});
|
||||
expect(texts(script)).toContain("COMMIT");
|
||||
});
|
||||
|
||||
it("rolls back, and never releases, a savepoint whose callback left work outstanding", async () => {
|
||||
const script = scriptedPool();
|
||||
const executor = createModuleDatabaseExecutor(script.pool, { moduleId: "example" });
|
||||
|
||||
await executor.transaction(async (tx) => {
|
||||
await expectAbiError(
|
||||
tx.savepoint((sp) => {
|
||||
const release = script.holdNext();
|
||||
void sp.query({ text: "INSERT INTO example_records VALUES (1)" });
|
||||
setTimeout(release, 10);
|
||||
}),
|
||||
"MODULE_DATABASE_SCOPE_WORK_OUTSTANDING",
|
||||
);
|
||||
});
|
||||
|
||||
expect(texts(script).slice(2, -2)).toEqual([
|
||||
"SAVEPOINT ekklesia_savepoint_1",
|
||||
"INSERT INTO example_records VALUES (1)",
|
||||
"ROLLBACK TO SAVEPOINT ekklesia_savepoint_1",
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps a rejected savepoint callback's own error, rolling back and releasing after its sibling work settles", async () => {
|
||||
const script = scriptedPool();
|
||||
const executor = createModuleDatabaseExecutor(script.pool, { moduleId: "example" });
|
||||
const own = new Error("savepoint failure");
|
||||
|
||||
await executor.transaction(async (tx) => {
|
||||
const error = await rejection(
|
||||
tx.savepoint(async (sp) => {
|
||||
const release = script.holdNext();
|
||||
void sp.query({ text: "INSERT INTO example_records VALUES (1)" });
|
||||
setTimeout(release, 10);
|
||||
throw own;
|
||||
}),
|
||||
);
|
||||
expect(error).toBe(own);
|
||||
});
|
||||
|
||||
expect(texts(script).slice(2, -2)).toEqual([
|
||||
"SAVEPOINT ekklesia_savepoint_1",
|
||||
"INSERT INTO example_records VALUES (1)",
|
||||
"ROLLBACK TO SAVEPOINT ekklesia_savepoint_1",
|
||||
"RELEASE SAVEPOINT ekklesia_savepoint_1",
|
||||
]);
|
||||
expect(texts(script)).toContain("COMMIT");
|
||||
});
|
||||
|
||||
it("counts an unawaited savepoint as the transaction's outstanding work", async () => {
|
||||
const script = scriptedPool();
|
||||
const executor = createModuleDatabaseExecutor(script.pool, { moduleId: "example" });
|
||||
let forgotten: Promise<unknown> | undefined;
|
||||
|
||||
await expectAbiError(
|
||||
executor.transaction((tx) => {
|
||||
forgotten = tx.savepoint(async (sp) => {
|
||||
await sp.query({ text: "INSERT INTO example_records VALUES (1)" });
|
||||
});
|
||||
}),
|
||||
"MODULE_DATABASE_SCOPE_WORK_OUTSTANDING",
|
||||
);
|
||||
await expectAbiError(forgotten as Promise<unknown>, "MODULE_DATABASE_SCOPE_CLOSED");
|
||||
expect(texts(script)).not.toContain("COMMIT");
|
||||
expect(texts(script).filter((text) => text.startsWith("RELEASE"))).toEqual([]);
|
||||
expect(texts(script).at(-1)).toBe("DISCARD ALL");
|
||||
});
|
||||
});
|
||||
|
||||
describe("the ABI's own sources", () => {
|
||||
it("import no ORM, query builder, or ORM type", async () => {
|
||||
for (const file of [
|
||||
"../src/modules/database-abi.ts",
|
||||
"../src/modules/database-abi-values.ts",
|
||||
]) {
|
||||
const source = await readFile(new URL(file, import.meta.url), "utf8");
|
||||
const specifiers = [...source.matchAll(/\bfrom\s+"([^"]+)"/gu)].map((match) => match[1]);
|
||||
expect(
|
||||
specifiers.every((specifier) =>
|
||||
["@ekklesia/contracts", "pg", "./database-abi-values.js"].includes(specifier ?? ""),
|
||||
),
|
||||
).toBe(true);
|
||||
expect(source).not.toMatch(/drizzle|kysely|NodePg|PgTable|PgColumn/iu);
|
||||
}
|
||||
});
|
||||
});
|
||||
+9
-8
@@ -1,8 +1,8 @@
|
||||
import { integer, pgTable, text } from "drizzle-orm/pg-core";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { ModuleDatabase, ModuleRuntimeServices } from "../src/index.js";
|
||||
import type { LegacyModuleRuntimeServices, ModuleDatabase } from "../src/modules/database.js";
|
||||
|
||||
const testTable = pgTable("contracts_module_database_test", {
|
||||
const testTable = pgTable("core_module_database_type_test", {
|
||||
id: integer("id").primaryKey(),
|
||||
name: text("name").notNull(),
|
||||
});
|
||||
@@ -68,16 +68,17 @@ function typeOnlyExclusions(database: ModuleDatabase): void {
|
||||
}
|
||||
void typeOnlyExclusions;
|
||||
|
||||
/** Never invoked — proves `ModuleRuntimeServices` has exactly one field, `database`, typed as
|
||||
* `ModuleDatabase`, with no storage/jobs/configuration/secrets/authorization field this phase. */
|
||||
function typeOnlyServicesShape(services: ModuleRuntimeServices): void {
|
||||
/** Never invoked — proves a legacy module's `ModuleRuntimeServices` has exactly one field,
|
||||
* `database`, typed as `ModuleDatabase`, with no storage/jobs/configuration/secrets/authorization
|
||||
* field this phase. */
|
||||
function typeOnlyServicesShape(services: LegacyModuleRuntimeServices): void {
|
||||
const database: ModuleDatabase = services.database;
|
||||
void database;
|
||||
// @ts-expect-error `storage` does not exist on `ModuleRuntimeServices` yet.
|
||||
// @ts-expect-error `storage` does not exist on the services yet.
|
||||
void services.storage;
|
||||
// @ts-expect-error `jobs` does not exist on `ModuleRuntimeServices` yet.
|
||||
// @ts-expect-error `jobs` does not exist on the services yet.
|
||||
void services.jobs;
|
||||
// @ts-expect-error `authorization` does not exist on `ModuleRuntimeServices` yet.
|
||||
// @ts-expect-error `authorization` does not exist on the services yet.
|
||||
void services.authorization;
|
||||
}
|
||||
void typeOnlyServicesShape;
|
||||
@@ -4,8 +4,12 @@ import path from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
discoverModules,
|
||||
isReservedModuleId,
|
||||
ModuleDiscoveryError,
|
||||
type ModuleDiscoveryIssueCode,
|
||||
RESERVED_MODULE_IDS,
|
||||
RESERVED_SETTINGS_NAMES,
|
||||
RESERVED_TOP_LEVEL_NAMES,
|
||||
} from "../src/modules/index.js";
|
||||
|
||||
interface ModuleFixtureOptions {
|
||||
@@ -34,7 +38,7 @@ async function createModulesDirectory(): Promise<string> {
|
||||
|
||||
function manifestFor(id: string): Record<string, unknown> {
|
||||
return {
|
||||
manifestVersion: 7,
|
||||
manifestVersion: 8,
|
||||
id,
|
||||
name: `${id} module`,
|
||||
version: "0.1.0",
|
||||
@@ -271,6 +275,89 @@ describe("module discovery", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects a module whose id is one of the platform's reserved top-level names (ADR 0057)", async () => {
|
||||
// Every module with a Dashboard UI is addressed at `/<module-id>`, so an id equal to a
|
||||
// platform-owned top-level namespace would collide with the API, the display host, a host
|
||||
// script, or a Dashboard page.
|
||||
expect(RESERVED_TOP_LEVEL_NAMES).toEqual(["account", "api", "assets", "display", "settings"]);
|
||||
// A module's settings live at `/settings/<module-id>` (ADR 0061), beside the platform's own
|
||||
// Settings sections, so their names are reserved too.
|
||||
expect(RESERVED_SETTINGS_NAMES).toEqual([
|
||||
"branding",
|
||||
"discover",
|
||||
"displays",
|
||||
"general",
|
||||
"system",
|
||||
]);
|
||||
expect(RESERVED_MODULE_IDS).toEqual([
|
||||
"account",
|
||||
"api",
|
||||
"assets",
|
||||
"branding",
|
||||
"discover",
|
||||
"display",
|
||||
"displays",
|
||||
"general",
|
||||
"settings",
|
||||
"system",
|
||||
]);
|
||||
for (const list of [RESERVED_MODULE_IDS, RESERVED_TOP_LEVEL_NAMES, RESERVED_SETTINGS_NAMES]) {
|
||||
expect(Object.isFrozen(list)).toBe(true);
|
||||
}
|
||||
|
||||
for (const id of RESERVED_MODULE_IDS) {
|
||||
const modulesDirectory = await createModulesDirectory();
|
||||
await createModule(modulesDirectory, id);
|
||||
|
||||
const error = await discoveryError(modulesDirectory);
|
||||
|
||||
expect(error.issues, id).toEqual([
|
||||
expect.objectContaining({
|
||||
code: "MODULE_ID_RESERVED",
|
||||
moduleDirectory: id,
|
||||
moduleId: id,
|
||||
path: "id",
|
||||
}),
|
||||
]);
|
||||
}
|
||||
});
|
||||
|
||||
it("accepts ids that merely resemble a reserved name", async () => {
|
||||
const modulesDirectory = await createModulesDirectory();
|
||||
// `modules` is an ordinary id again: module management is Settings → Discover.
|
||||
for (const id of [
|
||||
"settings-sync",
|
||||
"my-account",
|
||||
"display-board",
|
||||
"api-docs",
|
||||
"general-info",
|
||||
"modules",
|
||||
"discovery",
|
||||
"systems",
|
||||
]) {
|
||||
await createModule(modulesDirectory, id);
|
||||
}
|
||||
|
||||
const modules = await discoverModules({
|
||||
moduleSdkVersion: "0.1.0",
|
||||
modulesDirectory,
|
||||
platformVersion: "0.1.0",
|
||||
});
|
||||
|
||||
expect(modules.map((module) => module.manifest.id).sort()).toEqual([
|
||||
"api-docs",
|
||||
"discovery",
|
||||
"display-board",
|
||||
"general-info",
|
||||
"modules",
|
||||
"my-account",
|
||||
"settings-sync",
|
||||
"systems",
|
||||
]);
|
||||
expect(isReservedModuleId("settings")).toBe(true);
|
||||
expect(isReservedModuleId("settings-sync")).toBe(false);
|
||||
});
|
||||
|
||||
it("enforces platform and Module SDK compatibility", async () => {
|
||||
const modulesDirectory = await createModulesDirectory();
|
||||
await createModule(modulesDirectory, "future", {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { IMPORT_SAFETY_TEST_TIMEOUT_MS } from "./support/import-safety.js";
|
||||
|
||||
const readdir = vi.fn();
|
||||
const readFile = vi.fn();
|
||||
@@ -14,13 +15,17 @@ vi.mock("node:fs/promises", async (importOriginal) => ({
|
||||
}));
|
||||
|
||||
describe("module HTTP contract and runtime import safety", () => {
|
||||
it("performs no filesystem I/O merely by importing the module runtime or the module-http contract", async () => {
|
||||
await import("../src/modules/index.js");
|
||||
await import("@ekklesia/contracts");
|
||||
it(
|
||||
"performs no filesystem I/O merely by importing the module runtime or the module-http contract",
|
||||
async () => {
|
||||
await import("../src/modules/index.js");
|
||||
await import("@ekklesia/contracts");
|
||||
|
||||
expect(readdir).not.toHaveBeenCalled();
|
||||
expect(readFile).not.toHaveBeenCalled();
|
||||
expect(realpath).not.toHaveBeenCalled();
|
||||
expect(stat).not.toHaveBeenCalled();
|
||||
});
|
||||
expect(readdir).not.toHaveBeenCalled();
|
||||
expect(readFile).not.toHaveBeenCalled();
|
||||
expect(realpath).not.toHaveBeenCalled();
|
||||
expect(stat).not.toHaveBeenCalled();
|
||||
},
|
||||
IMPORT_SAFETY_TEST_TIMEOUT_MS,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -182,6 +182,7 @@ async function applyCoreMigrations(url: string): Promise<void> {
|
||||
"core:0005_module_configuration",
|
||||
"core:0006_device_identity",
|
||||
"core:0007_verified_bootstrap_owners",
|
||||
"core:0008_user_preferences",
|
||||
]);
|
||||
await applyModuleMigrations({ aggregate, database: createPostgresMigrationDatabase(url) });
|
||||
} finally {
|
||||
@@ -200,7 +201,7 @@ afterAll(async () => {
|
||||
* by eligibility resolution. */
|
||||
function fakeModule(id: string, dependencies?: Record<string, unknown>): DiscoveredModule {
|
||||
const manifest = parseModuleManifest({
|
||||
manifestVersion: 7,
|
||||
manifestVersion: 8,
|
||||
id,
|
||||
name: `${id} module`,
|
||||
version: "1.0.0",
|
||||
|
||||
@@ -22,7 +22,7 @@ function fakeModule(
|
||||
} = {},
|
||||
): DiscoveredModule {
|
||||
const manifest = parseModuleManifest({
|
||||
manifestVersion: 7,
|
||||
manifestVersion: 8,
|
||||
id,
|
||||
name: `${id} module`,
|
||||
version: "0.1.0",
|
||||
@@ -344,7 +344,7 @@ describe("resolveModuleEligibility — over a real discovered inventory", () =>
|
||||
writeFile(
|
||||
path.join(moduleDirectory, "ekklesia.module.json"),
|
||||
JSON.stringify({
|
||||
manifestVersion: 7,
|
||||
manifestVersion: 8,
|
||||
id,
|
||||
name: `${id} module`,
|
||||
version: "0.1.0",
|
||||
|
||||
@@ -35,9 +35,9 @@ async function createModulesDirectory(): Promise<string> {
|
||||
|
||||
function discoveryOptions(modulesDirectory: string) {
|
||||
return {
|
||||
moduleSdkVersion: "0.1.0",
|
||||
moduleSdkVersion: "0.2.0",
|
||||
modulesDirectory,
|
||||
platformVersion: "0.1.0",
|
||||
platformVersion: "0.2.0",
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,748 @@
|
||||
import { mkdir, mkdtemp, readdir, rm, stat, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
currentModulePackageHost,
|
||||
type ModulePackageHost,
|
||||
ModulePackageVerificationError,
|
||||
type ModulePackageVerificationErrorCode,
|
||||
verifyModulePackage,
|
||||
} from "../src/modules/index.js";
|
||||
import {
|
||||
type FixtureEntry,
|
||||
modulePackageFixture,
|
||||
type PackageFixtureOptions,
|
||||
sha256,
|
||||
} from "./support/module-package-fixture.js";
|
||||
|
||||
const platform = {
|
||||
databaseRuntime: { "drizzle-orm": "0.45.2" },
|
||||
moduleSdkVersion: "0.2.0",
|
||||
platformVersion: "0.2.0",
|
||||
} as const;
|
||||
|
||||
const temporaryDirectories: string[] = [];
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(
|
||||
temporaryDirectories
|
||||
.splice(0)
|
||||
.map((directory) => rm(directory, { force: true, recursive: true })),
|
||||
);
|
||||
});
|
||||
|
||||
async function scratch(): Promise<string> {
|
||||
const directory = await mkdtemp(path.join(tmpdir(), "ekklesia-verify-"));
|
||||
temporaryDirectories.push(directory);
|
||||
return directory;
|
||||
}
|
||||
|
||||
async function archiveFile(options: PackageFixtureOptions = {}): Promise<{
|
||||
readonly archivePath: string;
|
||||
readonly sha256: string;
|
||||
}> {
|
||||
const fixture = await modulePackageFixture(options);
|
||||
const archivePath = path.join(await scratch(), "private-host-name-5e1d.tar.gz");
|
||||
await writeFile(archivePath, fixture.archive);
|
||||
return { archivePath, sha256: fixture.sha256 };
|
||||
}
|
||||
|
||||
async function executionScratch(): Promise<string> {
|
||||
const directory = path.join(await scratch(), "execution");
|
||||
await mkdir(directory);
|
||||
return directory;
|
||||
}
|
||||
|
||||
async function rejection(
|
||||
options: PackageFixtureOptions,
|
||||
code: ModulePackageVerificationErrorCode,
|
||||
extra: { readonly expectedSha256?: string } = {},
|
||||
): Promise<ModulePackageVerificationError> {
|
||||
const { archivePath } = await archiveFile(options);
|
||||
try {
|
||||
await verifyModulePackage({ archivePath, platform, ...extra });
|
||||
} catch (error) {
|
||||
expect(error).toBeInstanceOf(ModulePackageVerificationError);
|
||||
const verificationError = error as ModulePackageVerificationError;
|
||||
expect(verificationError.code).toBe(code);
|
||||
// Sanitized: never the archive's location, a temporary directory, or text from inside it.
|
||||
const rendered = JSON.stringify({ ...verificationError, message: verificationError.message });
|
||||
expect(rendered).not.toContain("private-host-name-5e1d");
|
||||
expect(rendered).not.toContain(tmpdir());
|
||||
expect(rendered).not.toContain("attacker-chosen");
|
||||
return verificationError;
|
||||
}
|
||||
throw new Error(`Expected ${code}.`);
|
||||
}
|
||||
|
||||
function withEntry(entry: FixtureEntry): PackageFixtureOptions {
|
||||
return { extraEntries: [entry] };
|
||||
}
|
||||
|
||||
function abiPackage(overrides: PackageFixtureOptions = {}): PackageFixtureOptions {
|
||||
return {
|
||||
manifest: (manifest) => ({ ...manifest, database: { abi: 1 } }),
|
||||
schemaSource: `export default {
|
||||
moduleId: "example",
|
||||
tables: [{ name: "example_records", foreignKeys: [] }]
|
||||
};\n`,
|
||||
serverSource:
|
||||
'export default { database: { abi: 1 }, id: "example", version: "0.1.0", register() {} };\n',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("Module Package v1 verification (ADR 0049)", () => {
|
||||
it("verifies a valid any-target package, its digest, and its module", async () => {
|
||||
const { archivePath, sha256: digest } = await archiveFile();
|
||||
const result = await verifyModulePackage({ archivePath, expectedSha256: digest, platform });
|
||||
expect(result).toEqual({
|
||||
archiveSha256: digest,
|
||||
fileCount: 9,
|
||||
moduleId: "example",
|
||||
target: "any",
|
||||
version: "0.1.0",
|
||||
});
|
||||
});
|
||||
|
||||
it("uses an explicit execution scratch for pure JavaScript and cleans concurrent runs", async () => {
|
||||
const { archivePath } = await archiveFile();
|
||||
const executionScratchDirectory = await executionScratch();
|
||||
const runs = Array.from({ length: 4 }, () =>
|
||||
verifyModulePackage({ archivePath, executionScratchDirectory, platform }),
|
||||
);
|
||||
await expect(Promise.all(runs)).resolves.toHaveLength(4);
|
||||
expect(await readdir(executionScratchDirectory)).toEqual([]);
|
||||
});
|
||||
|
||||
it("cleans its execution scratch after a backend load failure", async () => {
|
||||
const { archivePath } = await archiveFile({
|
||||
serverSource:
|
||||
'import "dependency-not-in-this-package";\nexport default { id: "example", version: "0.1.0" };\n',
|
||||
});
|
||||
const executionScratchDirectory = await executionScratch();
|
||||
await expect(
|
||||
verifyModulePackage({ archivePath, executionScratchDirectory, platform }),
|
||||
).rejects.toMatchObject({ code: "MODULE_PACKAGE_ENTRYPOINT_LOAD_FAILED" });
|
||||
expect(await readdir(executionScratchDirectory)).toEqual([]);
|
||||
});
|
||||
|
||||
it("reports a missing execution scratch as a verifier environment error", async () => {
|
||||
const { archivePath } = await archiveFile();
|
||||
const executionScratchDirectory = path.join(await scratch(), "missing");
|
||||
await expect(
|
||||
verifyModulePackage({ archivePath, executionScratchDirectory, platform }),
|
||||
).rejects.toMatchObject({
|
||||
code: "MODULE_PACKAGE_VERIFIER_SCRATCH_UNAVAILABLE",
|
||||
message:
|
||||
"The verifier execution scratch directory is missing, not writable, or mounted noexec.",
|
||||
});
|
||||
await expect(stat(executionScratchDirectory)).rejects.toMatchObject({ code: "ENOENT" });
|
||||
});
|
||||
|
||||
it("rejects an unsafe archive before consulting the execution scratch", async () => {
|
||||
const { archivePath } = await archiveFile(
|
||||
withEntry({ path: "example/../attacker-chosen", data: "x" }),
|
||||
);
|
||||
const executionScratchDirectory = path.join(await scratch(), "missing");
|
||||
await expect(
|
||||
verifyModulePackage({ archivePath, executionScratchDirectory, platform }),
|
||||
).rejects.toMatchObject({ code: "MODULE_PACKAGE_UNSAFE_PATH" });
|
||||
await expect(stat(executionScratchDirectory)).rejects.toMatchObject({ code: "ENOENT" });
|
||||
});
|
||||
|
||||
it("extracts only a verified package, with normalized modes, into a new directory", async () => {
|
||||
const { archivePath } = await archiveFile();
|
||||
const target = path.join(await scratch(), "example");
|
||||
await verifyModulePackage({ archivePath, extractTo: target, platform });
|
||||
const names = (await readdir(target, { recursive: true })).sort();
|
||||
expect(names).toContain("ekklesia.package.json");
|
||||
expect(names).toContain("LICENSE");
|
||||
expect(names).toContain("dist/server/index.js");
|
||||
expect((await stat(target)).mode & 0o7777).toBe(0o755);
|
||||
expect((await stat(path.join(target, "dist", "server", "index.js"))).mode & 0o7777).toBe(0o644);
|
||||
|
||||
const { archivePath: broken } = await archiveFile({ runtimePackage: { name: "x" } });
|
||||
const refused = path.join(await scratch(), "refused");
|
||||
await expect(
|
||||
verifyModulePackage({ archivePath: broken, extractTo: refused, platform }),
|
||||
).rejects.toBeInstanceOf(ModulePackageVerificationError);
|
||||
await expect(stat(refused)).rejects.toMatchObject({ code: "ENOENT" });
|
||||
});
|
||||
|
||||
it("refuses to extract over an existing directory", async () => {
|
||||
const { archivePath } = await archiveFile();
|
||||
const existing = await scratch();
|
||||
await expect(
|
||||
verifyModulePackage({ archivePath, extractTo: existing, platform }),
|
||||
).rejects.toMatchObject({ code: "MODULE_PACKAGE_EXTRACTION_FAILED" });
|
||||
});
|
||||
|
||||
it("fails a wrong archive digest", async () => {
|
||||
await rejection({}, "MODULE_PACKAGE_DIGEST_MISMATCH", { expectedSha256: sha256("other") });
|
||||
});
|
||||
|
||||
it("fails a payload whose contents no longer match their digest", async () => {
|
||||
await rejection(
|
||||
{
|
||||
tamper: (files) => {
|
||||
files.set("dist/server/index.js", Buffer.from("export default {}; // attacker-chosen\n"));
|
||||
},
|
||||
},
|
||||
"MODULE_PACKAGE_FILE_MISMATCH",
|
||||
);
|
||||
});
|
||||
|
||||
it("fails a listed file missing from the archive", async () => {
|
||||
await rejection(
|
||||
{ tamper: (files) => void files.delete("THIRD_PARTY_NOTICES") },
|
||||
"MODULE_PACKAGE_FILE_MISSING",
|
||||
);
|
||||
});
|
||||
|
||||
it("fails an archive file that is not listed", async () => {
|
||||
await rejection(
|
||||
withEntry({ path: "example/attacker-chosen.js", data: "extra" }),
|
||||
"MODULE_PACKAGE_FILE_UNLISTED",
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["a missing license declaration", undefined],
|
||||
["an empty license declaration", ""],
|
||||
["UNLICENSED", "UNLICENSED"],
|
||||
["a malformed SPDX expression", "MIT OR"],
|
||||
])("fails package metadata with %s", async (_label, license) => {
|
||||
await rejection(
|
||||
{
|
||||
metadata: (metadata) => {
|
||||
const changed: Record<string, unknown> = { ...metadata, license };
|
||||
if (license === undefined) {
|
||||
delete changed.license;
|
||||
}
|
||||
return changed;
|
||||
},
|
||||
},
|
||||
"MODULE_PACKAGE_METADATA_INVALID",
|
||||
);
|
||||
});
|
||||
|
||||
it("fails closed on pre-ADR-0051 metadata", async () => {
|
||||
await rejection(
|
||||
{
|
||||
metadata: (metadata) => {
|
||||
const old: Record<string, unknown> = { ...metadata };
|
||||
delete old.license;
|
||||
return old;
|
||||
},
|
||||
},
|
||||
"MODULE_PACKAGE_METADATA_INVALID",
|
||||
);
|
||||
});
|
||||
|
||||
it("requires LICENSE at the canonical path in both payload and inventory", async () => {
|
||||
await rejection(
|
||||
{ tamper: (files) => void files.delete("LICENSE") },
|
||||
"MODULE_PACKAGE_FILE_MISSING",
|
||||
);
|
||||
await rejection(
|
||||
{
|
||||
metadata: (metadata) => ({
|
||||
...metadata,
|
||||
files: (metadata.files as { path: string }[]).filter((file) => file.path !== "LICENSE"),
|
||||
}),
|
||||
},
|
||||
"MODULE_PACKAGE_METADATA_INVALID",
|
||||
);
|
||||
await rejection(
|
||||
withEntry({ path: "example/licenses/LICENSE", data: "attacker-chosen" }),
|
||||
"MODULE_PACKAGE_FILE_UNLISTED",
|
||||
);
|
||||
await rejection(
|
||||
withEntry({ path: "example/COPYING", data: "attacker-chosen" }),
|
||||
"MODULE_PACKAGE_FILE_UNLISTED",
|
||||
);
|
||||
});
|
||||
|
||||
it("checks the listed LICENSE bytes, size, and digest", async () => {
|
||||
await rejection(
|
||||
{ tamper: (files) => files.set("LICENSE", Buffer.from("attacker-chosen")) },
|
||||
"MODULE_PACKAGE_FILE_MISMATCH",
|
||||
);
|
||||
await rejection(
|
||||
{
|
||||
metadata: (metadata) => ({
|
||||
...metadata,
|
||||
files: (metadata.files as { path: string; size: number; sha256: string }[]).map((file) =>
|
||||
file.path === "LICENSE" ? { ...file, size: file.size + 1 } : file,
|
||||
),
|
||||
}),
|
||||
},
|
||||
"MODULE_PACKAGE_FILE_MISMATCH",
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects a duplicate LICENSE archive entry", async () => {
|
||||
await rejection(
|
||||
withEntry({ path: "example/LICENSE", data: "attacker-chosen" }),
|
||||
"MODULE_PACKAGE_LAYOUT_INVALID",
|
||||
);
|
||||
});
|
||||
|
||||
it("fails when the manifest and the package disagree about the version", async () => {
|
||||
await rejection(
|
||||
{ manifest: (manifest) => ({ ...manifest, version: "0.2.0" }) },
|
||||
"MODULE_PACKAGE_IDENTITY_MISMATCH",
|
||||
);
|
||||
});
|
||||
|
||||
it("fails when the metadata names another module", async () => {
|
||||
await rejection(
|
||||
{ metadata: (metadata) => ({ ...metadata, module: { id: "other", version: "0.1.0" } }) },
|
||||
"MODULE_PACKAGE_IDENTITY_MISMATCH",
|
||||
);
|
||||
});
|
||||
|
||||
it("fails a runtime package.json that is not the generated shape", async () => {
|
||||
await rejection(
|
||||
{
|
||||
runtimePackage: {
|
||||
name: "@test/module-example",
|
||||
version: "0.1.0",
|
||||
private: true,
|
||||
type: "module",
|
||||
ekklesia: { layer: "module" },
|
||||
dependencies: { "@ekklesia/module-sdk": "workspace:*" },
|
||||
},
|
||||
},
|
||||
"MODULE_PACKAGE_RUNTIME_PACKAGE_INVALID",
|
||||
);
|
||||
});
|
||||
|
||||
it("fails an unsupported package format", async () => {
|
||||
await rejection(
|
||||
{ metadata: (metadata) => ({ ...metadata, packageFormat: 2 }) },
|
||||
"MODULE_PACKAGE_FORMAT_UNSUPPORTED",
|
||||
);
|
||||
});
|
||||
|
||||
it("fails transitional metadata it does not understand", async () => {
|
||||
await rejection(
|
||||
{ metadata: (metadata) => ({ ...metadata, transitional: { orm: { prisma: "6.0.0" } } }) },
|
||||
"MODULE_PACKAGE_TRANSITIONAL_UNSUPPORTED",
|
||||
);
|
||||
await rejection(
|
||||
{
|
||||
metadata: (metadata) => ({
|
||||
...metadata,
|
||||
transitional: { databaseRuntime: { "drizzle-orm": "0.45.2", prisma: "6.0.0" } },
|
||||
}),
|
||||
},
|
||||
"MODULE_PACKAGE_TRANSITIONAL_UNSUPPORTED",
|
||||
);
|
||||
});
|
||||
|
||||
it("enforces today's database runtime agreement, and accepts it when it holds", async () => {
|
||||
await rejection(
|
||||
{
|
||||
metadata: (metadata) => ({
|
||||
...metadata,
|
||||
transitional: { databaseRuntime: { "drizzle-orm": "0.44.0" } },
|
||||
}),
|
||||
},
|
||||
"MODULE_PACKAGE_DATABASE_RUNTIME_INCOMPATIBLE",
|
||||
);
|
||||
const { archivePath } = await archiveFile({
|
||||
metadata: (metadata) => ({
|
||||
...metadata,
|
||||
transitional: { databaseRuntime: { "drizzle-orm": "0.45.2" } },
|
||||
}),
|
||||
});
|
||||
await expect(verifyModulePackage({ archivePath, platform })).resolves.toMatchObject({
|
||||
moduleId: "example",
|
||||
});
|
||||
});
|
||||
|
||||
it("verifies ABI-v1 plain schema metadata without comparing Drizzle with Core", async () => {
|
||||
const { archivePath } = await archiveFile(abiPackage());
|
||||
|
||||
await expect(
|
||||
verifyModulePackage({
|
||||
archivePath,
|
||||
platform: { ...platform, databaseRuntime: { "drizzle-orm": "99.0.0" } },
|
||||
}),
|
||||
).resolves.toMatchObject({ moduleId: "example" });
|
||||
});
|
||||
|
||||
it("rejects transitional database runtime metadata from an ABI-v1 package", async () => {
|
||||
await rejection(
|
||||
abiPackage({
|
||||
metadata: (metadata) => ({
|
||||
...metadata,
|
||||
transitional: { databaseRuntime: { "drizzle-orm": "0.45.2" } },
|
||||
}),
|
||||
}),
|
||||
"MODULE_PACKAGE_DATABASE_RUNTIME_UNEXPECTED",
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects an unsupported database ABI and malformed ABI-v1 schema metadata", async () => {
|
||||
await rejection(
|
||||
abiPackage({ manifest: (manifest) => ({ ...manifest, database: { abi: 2 } }) }),
|
||||
"MODULE_PACKAGE_MANIFEST_INVALID",
|
||||
);
|
||||
const schemaError = await rejection(
|
||||
abiPackage({
|
||||
schemaSource:
|
||||
'export default { moduleId: "example", tables: { records: "attacker-chosen" } };\n',
|
||||
}),
|
||||
"MODULE_PACKAGE_ENTRYPOINT_LOAD_FAILED",
|
||||
);
|
||||
expect(schemaError.message).not.toContain("attacker-chosen");
|
||||
});
|
||||
|
||||
it("fails a target this platform cannot run", async () => {
|
||||
await rejection(
|
||||
{
|
||||
metadata: (metadata) => ({
|
||||
...metadata,
|
||||
target: "linux-arm64-musl",
|
||||
runtime: { libcVersion: ">=1.2.0", node: ">=20", nodeApi: 9 },
|
||||
}),
|
||||
},
|
||||
"MODULE_PACKAGE_TARGET_INCOMPATIBLE",
|
||||
);
|
||||
});
|
||||
|
||||
it("fails a native file inside an any-target package", async () => {
|
||||
const native = await modulePackageFixture({
|
||||
tamper: (files) => {
|
||||
files.set("dist/addon.node", Buffer.from([0x7f, 0x45, 0x4c, 0x46]));
|
||||
},
|
||||
metadata: (metadata) => {
|
||||
const files = [
|
||||
...(metadata.files as { path: string; size: number; sha256: string }[]),
|
||||
{
|
||||
path: "dist/addon.node",
|
||||
size: 4,
|
||||
sha256: sha256(Buffer.from([0x7f, 0x45, 0x4c, 0x46])),
|
||||
},
|
||||
].sort((left, right) => (left.path < right.path ? -1 : 1));
|
||||
return { ...metadata, files };
|
||||
},
|
||||
});
|
||||
const archivePath = path.join(await scratch(), "native.tar.gz");
|
||||
await writeFile(archivePath, native.archive);
|
||||
await expect(verifyModulePackage({ archivePath, platform })).rejects.toMatchObject({
|
||||
code: "MODULE_PACKAGE_NATIVE_FILE",
|
||||
});
|
||||
});
|
||||
|
||||
it("fails a package that needs a newer Node.js", async () => {
|
||||
await rejection(
|
||||
{ metadata: (metadata) => ({ ...metadata, runtime: { node: ">=99" } }) },
|
||||
"MODULE_PACKAGE_RUNTIME_INCOMPATIBLE",
|
||||
);
|
||||
});
|
||||
|
||||
it("fails an incorporated SDK outside the manifest's own range", async () => {
|
||||
await rejection(
|
||||
{
|
||||
metadata: (metadata) => ({
|
||||
...metadata,
|
||||
incorporates: { "@ekklesia/module-sdk": "0.9.0" },
|
||||
}),
|
||||
},
|
||||
"MODULE_PACKAGE_SDK_INCOMPATIBLE",
|
||||
);
|
||||
});
|
||||
|
||||
it("fails a module whose declared entrypoint is missing", async () => {
|
||||
await rejection(
|
||||
{
|
||||
manifest: (manifest) => ({
|
||||
...manifest,
|
||||
version: "0.1.0",
|
||||
entrypoints: {
|
||||
...(manifest.entrypoints as Record<string, unknown>),
|
||||
schema: "./dist/schema/index.js",
|
||||
},
|
||||
}),
|
||||
},
|
||||
"MODULE_PACKAGE_MODULE_INVALID",
|
||||
);
|
||||
});
|
||||
|
||||
it("fails a backend that imports a dependency the package does not contain", async () => {
|
||||
const error = await rejection(
|
||||
{
|
||||
serverSource:
|
||||
'import pad from "ekklesia-dependency-not-in-this-package";\nexport default { id: "example", version: "0.1.0", register() { pad(); } };\n',
|
||||
},
|
||||
"MODULE_PACKAGE_ENTRYPOINT_LOAD_FAILED",
|
||||
);
|
||||
expect(error.message).not.toContain("ekklesia-dependency-not-in-this-package");
|
||||
});
|
||||
|
||||
it("fails a backend whose default export names another module", async () => {
|
||||
await rejection(
|
||||
{ serverSource: 'export default { id: "other", version: "0.1.0" };\n' },
|
||||
"MODULE_PACKAGE_ENTRYPOINT_LOAD_FAILED",
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["an absolute path", { path: "/etc/attacker-chosen", data: "x" }, "MODULE_PACKAGE_UNSAFE_PATH"],
|
||||
[
|
||||
"a .. segment",
|
||||
{ path: "example/../attacker-chosen", data: "x" },
|
||||
"MODULE_PACKAGE_UNSAFE_PATH",
|
||||
],
|
||||
[
|
||||
"a symbolic link",
|
||||
{ path: "example/link", type: "symlink", linkname: "/etc/passwd" },
|
||||
"MODULE_PACKAGE_LINK",
|
||||
],
|
||||
[
|
||||
"a hard link",
|
||||
{ path: "example/hard", type: "hardlink", linkname: "example/package.json" },
|
||||
"MODULE_PACKAGE_LINK",
|
||||
],
|
||||
[
|
||||
"a device node",
|
||||
{ path: "example/device", type: "character" },
|
||||
"MODULE_PACKAGE_SPECIAL_ENTRY",
|
||||
],
|
||||
["a FIFO", { path: "example/fifo", type: "fifo" }, "MODULE_PACKAGE_SPECIAL_ENTRY"],
|
||||
[
|
||||
"a setuid file",
|
||||
{ path: "example/setuid", data: "x", mode: 0o4755 },
|
||||
"MODULE_PACKAGE_UNSAFE_MODE",
|
||||
],
|
||||
[
|
||||
"a setgid directory",
|
||||
{ path: "example/setgid", type: "directory", mode: 0o2755 },
|
||||
"MODULE_PACKAGE_UNSAFE_MODE",
|
||||
],
|
||||
[
|
||||
"a second top-level directory",
|
||||
{ path: "attacker-chosen/file", data: "x" },
|
||||
"MODULE_PACKAGE_LAYOUT_INVALID",
|
||||
],
|
||||
[
|
||||
"a duplicate entry",
|
||||
{ path: "example/package.json", data: "{}" },
|
||||
"MODULE_PACKAGE_LAYOUT_INVALID",
|
||||
],
|
||||
] as const)("fails %s", async (_label, entry, code) => {
|
||||
await rejection(withEntry(entry as FixtureEntry), code);
|
||||
});
|
||||
|
||||
it("fails a file that is not a gzip-compressed tar archive", async () => {
|
||||
const archivePath = path.join(await scratch(), "plain.tar.gz");
|
||||
await writeFile(archivePath, "not an archive");
|
||||
await expect(verifyModulePackage({ archivePath, platform })).rejects.toMatchObject({
|
||||
code: "MODULE_PACKAGE_UNREADABLE",
|
||||
});
|
||||
});
|
||||
|
||||
describe("native targets (ADR 0049)", () => {
|
||||
/** The first bytes of a 64-bit little-endian ELF object for `machine` (62 is x86-64). The
|
||||
* verifier reads only the identification and machine fields. */
|
||||
function elfHeader(machine = 62): Buffer {
|
||||
const header = Buffer.alloc(64);
|
||||
Buffer.from([0x7f, 0x45, 0x4c, 0x46, 2, 1, 1]).copy(header);
|
||||
header.writeUInt16LE(3, 16);
|
||||
header.writeUInt16LE(machine, 18);
|
||||
return header;
|
||||
}
|
||||
|
||||
const linuxHost = {
|
||||
arch: "x64",
|
||||
libc: { family: "glibc", version: "2.41" },
|
||||
nodeApi: 10,
|
||||
nodeAbi: "137",
|
||||
platform: "linux",
|
||||
} as const;
|
||||
|
||||
function nativePackage(
|
||||
overrides: {
|
||||
readonly files?: Record<string, Uint8Array | string>;
|
||||
readonly target?: string;
|
||||
readonly runtime?: Record<string, unknown>;
|
||||
} = {},
|
||||
): PackageFixtureOptions {
|
||||
return {
|
||||
files: overrides.files ?? {
|
||||
"node_modules/native-thing/index.js": "module.exports = {};\n",
|
||||
"node_modules/native-thing/lib/native-thing.node": elfHeader(),
|
||||
"node_modules/native-thing/package.json": '{"name":"native-thing","version":"1.0.0"}\n',
|
||||
},
|
||||
metadata: (metadata) => ({
|
||||
...metadata,
|
||||
runtime: overrides.runtime ?? {
|
||||
libcVersion: ">=2.28.0",
|
||||
node: ">=20",
|
||||
nodeApi: 9,
|
||||
},
|
||||
target: overrides.target ?? "linux-x64-glibc",
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
async function verifyOn(
|
||||
options: PackageFixtureOptions,
|
||||
host: ModulePackageHost,
|
||||
): Promise<unknown> {
|
||||
const { archivePath } = await archiveFile(options);
|
||||
return verifyModulePackage({ archivePath, platform: { ...platform, host } });
|
||||
}
|
||||
|
||||
async function refusedOn(
|
||||
options: PackageFixtureOptions,
|
||||
host: ModulePackageHost,
|
||||
code: ModulePackageVerificationErrorCode,
|
||||
): Promise<void> {
|
||||
await expect(verifyOn(options, host)).rejects.toMatchObject({ code });
|
||||
}
|
||||
|
||||
it("accepts linux-x64-glibc on a matching host, native node_modules and addon included", async () => {
|
||||
await expect(verifyOn(nativePackage(), linuxHost)).resolves.toMatchObject({
|
||||
moduleId: "example",
|
||||
target: "linux-x64-glibc",
|
||||
});
|
||||
});
|
||||
|
||||
it("refuses another operating system, CPU, or C library family", async () => {
|
||||
await refusedOn(
|
||||
nativePackage(),
|
||||
{ ...linuxHost, platform: "darwin" },
|
||||
"MODULE_PACKAGE_TARGET_INCOMPATIBLE",
|
||||
);
|
||||
await refusedOn(
|
||||
nativePackage(),
|
||||
{ ...linuxHost, arch: "arm64" },
|
||||
"MODULE_PACKAGE_TARGET_INCOMPATIBLE",
|
||||
);
|
||||
// musl reports no glibc version, so the host cannot be proven glibc.
|
||||
const { libc: _libc, ...musl } = linuxHost;
|
||||
await refusedOn(nativePackage(), musl, "MODULE_PACKAGE_TARGET_INCOMPATIBLE");
|
||||
});
|
||||
|
||||
it("refuses a target this platform does not implement", async () => {
|
||||
await refusedOn(
|
||||
nativePackage({ target: "linux-arm64-glibc" }),
|
||||
{ ...linuxHost, arch: "arm64" },
|
||||
"MODULE_PACKAGE_TARGET_INCOMPATIBLE",
|
||||
);
|
||||
});
|
||||
|
||||
it("refuses a C library older than the package needs", async () => {
|
||||
await refusedOn(
|
||||
nativePackage(),
|
||||
{ ...linuxHost, libc: { family: "glibc", version: "2.17" } },
|
||||
"MODULE_PACKAGE_LIBC_INCOMPATIBLE",
|
||||
);
|
||||
await expect(
|
||||
verifyOn(nativePackage(), { ...linuxHost, libc: { family: "glibc", version: "2.28" } }),
|
||||
).resolves.toMatchObject({ target: "linux-x64-glibc" });
|
||||
});
|
||||
|
||||
it("refuses a Node-API version or Node.js ABI the host does not provide", async () => {
|
||||
await refusedOn(
|
||||
nativePackage(),
|
||||
{ ...linuxHost, nodeApi: 8 },
|
||||
"MODULE_PACKAGE_NODE_API_INCOMPATIBLE",
|
||||
);
|
||||
const { nodeApi: _nodeApi, ...unknownApi } = linuxHost;
|
||||
await refusedOn(nativePackage(), unknownApi, "MODULE_PACKAGE_NODE_API_INCOMPATIBLE");
|
||||
await refusedOn(
|
||||
nativePackage({ runtime: { libcVersion: ">=2.28.0", node: ">=20", nodeAbi: "115" } }),
|
||||
linuxHost,
|
||||
"MODULE_PACKAGE_NODE_API_INCOMPATIBLE",
|
||||
);
|
||||
});
|
||||
|
||||
it("refuses a native file built for another machine, or an addon that is not one", async () => {
|
||||
await refusedOn(
|
||||
nativePackage({
|
||||
files: { "node_modules/native-thing/lib/native-thing.node": elfHeader(183) },
|
||||
}),
|
||||
linuxHost,
|
||||
"MODULE_PACKAGE_NATIVE_FILE_INCOMPATIBLE",
|
||||
);
|
||||
await refusedOn(
|
||||
nativePackage({
|
||||
files: { "node_modules/native-thing/lib/libthing.so": elfHeader(183) },
|
||||
}),
|
||||
linuxHost,
|
||||
"MODULE_PACKAGE_NATIVE_FILE_INCOMPATIBLE",
|
||||
);
|
||||
await refusedOn(
|
||||
nativePackage({
|
||||
files: { "node_modules/native-thing/lib/native-thing.node": "not an ELF object" },
|
||||
}),
|
||||
linuxHost,
|
||||
"MODULE_PACKAGE_NATIVE_FILE_INCOMPATIBLE",
|
||||
);
|
||||
});
|
||||
|
||||
it("requires a native package to state its Node.js interface and, on Linux, its C library", async () => {
|
||||
await rejection(
|
||||
nativePackage({ runtime: { libcVersion: ">=2.28.0", node: ">=20" } }),
|
||||
"MODULE_PACKAGE_METADATA_INVALID",
|
||||
);
|
||||
await rejection(
|
||||
nativePackage({ runtime: { node: ">=20", nodeApi: 9 } }),
|
||||
"MODULE_PACKAGE_METADATA_INVALID",
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps any-target packages free of node_modules, addons, and ELF objects", async () => {
|
||||
await rejection(
|
||||
{ files: { "node_modules/thing/index.js": "module.exports = {};\n" } },
|
||||
"MODULE_PACKAGE_NATIVE_FILE",
|
||||
);
|
||||
await rejection({ files: { "dist/thing.node": elfHeader() } }, "MODULE_PACKAGE_NATIVE_FILE");
|
||||
await rejection({ files: { "dist/libthing.so": elfHeader() } }, "MODULE_PACKAGE_NATIVE_FILE");
|
||||
});
|
||||
|
||||
it("establishes this host's facts from Node.js alone", () => {
|
||||
const host = currentModulePackageHost();
|
||||
expect(host.platform).toBe(process.platform);
|
||||
expect(host.arch).toBe(process.arch);
|
||||
expect(host.nodeApi).toBe(Number(process.versions.napi));
|
||||
if (process.platform === "linux") {
|
||||
const report = process.report.getReport() as { header?: { glibcVersionRuntime?: string } };
|
||||
expect(host.libc?.version).toBe(report.header?.glibcVersionRuntime);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it("fails an archive without package metadata", async () => {
|
||||
const fixture = await modulePackageFixture();
|
||||
const { gunzipSync, gzipSync } = await import("node:zlib");
|
||||
// Rebuild without the metadata entry by filtering the fixture through a fresh archive.
|
||||
const archivePath = path.join(await scratch(), "no-metadata.tar.gz");
|
||||
const raw = gunzipSync(fixture.archive);
|
||||
const blocks: Buffer[] = [];
|
||||
for (let offset = 0; offset + 512 <= raw.length; ) {
|
||||
const headerBlock = raw.subarray(offset, offset + 512);
|
||||
if (headerBlock.every((byte) => byte === 0)) {
|
||||
break;
|
||||
}
|
||||
const name = headerBlock.subarray(0, 100).toString("latin1").replace(/\0.*$/su, "");
|
||||
const size = Number.parseInt(headerBlock.subarray(124, 136).toString("latin1"), 8);
|
||||
const length = 512 + Math.ceil(size / 512) * 512;
|
||||
if (!name.endsWith("ekklesia.package.json")) {
|
||||
blocks.push(raw.subarray(offset, offset + length));
|
||||
}
|
||||
offset += length;
|
||||
}
|
||||
await writeFile(archivePath, gzipSync(Buffer.concat([...blocks, Buffer.alloc(1024)])));
|
||||
await expect(verifyModulePackage({ archivePath, platform })).rejects.toMatchObject({
|
||||
code: "MODULE_PACKAGE_METADATA_MISSING",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -3,6 +3,7 @@ import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
CORE_AUTHORIZATION_GRANTS_MANAGE,
|
||||
CORE_DASHBOARD_NAVIGATION_MANAGE,
|
||||
CORE_ACCOUNT_SELF_MANAGE,
|
||||
CORE_DEVICES_MANAGE,
|
||||
CORE_MODULES_CONFIGURATION_MANAGE,
|
||||
CORE_MODULES_LIFECYCLE_MANAGE,
|
||||
@@ -28,7 +29,7 @@ interface PermissionInput {
|
||||
* is read by `composePlatformPermissionRegistry`; the rest exist only to satisfy the type. */
|
||||
function fakeModule(id: string, permissions: readonly PermissionInput[] = []): DiscoveredModule {
|
||||
const manifest = parseModuleManifest({
|
||||
manifestVersion: 7,
|
||||
manifestVersion: 8,
|
||||
id,
|
||||
name: `${id} module`,
|
||||
version: "0.1.0",
|
||||
@@ -75,8 +76,9 @@ describe("composePlatformPermissionRegistry", () => {
|
||||
expect(registry.has(CORE_DASHBOARD_NAVIGATION_MANAGE)).toBe(true);
|
||||
expect(registry.has(CORE_MODULES_LIFECYCLE_MANAGE)).toBe(true);
|
||||
expect(registry.has(CORE_MODULES_CONFIGURATION_MANAGE)).toBe(true);
|
||||
// Core's own five declarations plus one from each module.
|
||||
expect(registry.declarations).toHaveLength(8);
|
||||
expect(registry.has(CORE_ACCOUNT_SELF_MANAGE)).toBe(true);
|
||||
// Core's own seven declarations plus one from each module.
|
||||
expect(registry.declarations).toHaveLength(9);
|
||||
});
|
||||
|
||||
it("preserves deterministic order: Core first, then modules in the supplied order, then declarations in manifest order", () => {
|
||||
@@ -97,6 +99,7 @@ describe("composePlatformPermissionRegistry", () => {
|
||||
CORE_MODULES_LIFECYCLE_MANAGE,
|
||||
CORE_MODULES_CONFIGURATION_MANAGE,
|
||||
CORE_DEVICES_MANAGE,
|
||||
CORE_ACCOUNT_SELF_MANAGE,
|
||||
"calendar.events.read",
|
||||
"calendar.events.write",
|
||||
"people.directory.read",
|
||||
|
||||
@@ -1,11 +1,8 @@
|
||||
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import type {
|
||||
ModuleDatabase,
|
||||
ModuleRealtimePublisher,
|
||||
ModuleRuntimeServices,
|
||||
} from "@ekklesia/contracts";
|
||||
import type { ModuleRealtimePublisher } from "@ekklesia/contracts";
|
||||
import type { LegacyModuleRuntimeServices, ModuleDatabase } from "../src/modules/database.js";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { ModuleRuntimeError, startModuleRuntime } from "../src/modules/runtime.js";
|
||||
|
||||
@@ -22,7 +19,7 @@ afterEach(async () => {
|
||||
);
|
||||
});
|
||||
|
||||
function fakeServices(): ModuleRuntimeServices {
|
||||
function fakeServices(): LegacyModuleRuntimeServices {
|
||||
return Object.freeze({ database: Object.freeze({}) as ModuleDatabase });
|
||||
}
|
||||
|
||||
@@ -31,7 +28,7 @@ function manifest(
|
||||
permissions: readonly Record<string, string>[] = [],
|
||||
): Record<string, unknown> {
|
||||
return {
|
||||
manifestVersion: 7,
|
||||
manifestVersion: 8,
|
||||
id,
|
||||
name: `${id} module`,
|
||||
version: "0.1.0",
|
||||
|
||||
@@ -1,21 +1,23 @@
|
||||
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import type { ModuleConfiguration, ModuleDatabaseExecutor } from "@ekklesia/contracts";
|
||||
import type {
|
||||
ModuleConfiguration,
|
||||
AnyModuleRuntimeServices,
|
||||
LegacyModuleRuntimeServices,
|
||||
ModuleDatabase,
|
||||
ModuleRuntimeServices,
|
||||
} from "@ekklesia/contracts";
|
||||
} from "../src/modules/database.js";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { discoverModules } from "../src/modules/discovery.js";
|
||||
import { resolveModuleEligibility } from "../src/modules/lifecycle.js";
|
||||
import {
|
||||
ModuleDiscoveryError,
|
||||
ModuleRuntimeError,
|
||||
type ModuleRuntimeIssueCode,
|
||||
type ModuleRuntimeServicesLookup,
|
||||
startDiscoveredModuleRuntime,
|
||||
startModuleRuntime,
|
||||
} from "../src/modules/index.js";
|
||||
import { resolveModuleEligibility } from "../src/modules/lifecycle.js";
|
||||
|
||||
vi.mock("../src/modules/discovery.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../src/modules/discovery.js")>();
|
||||
@@ -52,12 +54,13 @@ function manifestFor(
|
||||
id: string,
|
||||
options: {
|
||||
capabilities?: Array<{ id: string; version: string }>;
|
||||
database?: { abi: 1 };
|
||||
dependencies?: Record<string, unknown>;
|
||||
permissions?: Array<{ id: string; baseline: string; description: string }>;
|
||||
} = {},
|
||||
): Record<string, unknown> {
|
||||
return {
|
||||
manifestVersion: 7,
|
||||
manifestVersion: 8,
|
||||
id,
|
||||
name: `${id} module`,
|
||||
version: "0.1.0",
|
||||
@@ -67,6 +70,7 @@ function manifestFor(
|
||||
},
|
||||
capabilities: options.capabilities ?? [],
|
||||
permissions: options.permissions ?? [],
|
||||
...(options.database ? { database: options.database } : {}),
|
||||
...(options.dependencies ? { dependencies: options.dependencies } : {}),
|
||||
entrypoints: {
|
||||
server: "./dist/server/index.js",
|
||||
@@ -145,13 +149,29 @@ function fakeModuleDatabase(): ModuleDatabase {
|
||||
}) as unknown as ModuleDatabase;
|
||||
}
|
||||
|
||||
function fakeModuleRuntimeServices(): ModuleRuntimeServices {
|
||||
return Object.freeze({ database: fakeModuleDatabase() });
|
||||
function fakeModuleRuntimeServices(): ModuleRuntimeServicesLookup {
|
||||
const services = new Map<string, LegacyModuleRuntimeServices>();
|
||||
const database = fakeModuleDatabase();
|
||||
return new Proxy(services, {
|
||||
get(target, property, receiver) {
|
||||
if (property === "get") {
|
||||
return (moduleId: string) => {
|
||||
let value = target.get(moduleId);
|
||||
if (!value) {
|
||||
value = Object.freeze({ database });
|
||||
target.set(moduleId, value);
|
||||
}
|
||||
return value;
|
||||
};
|
||||
}
|
||||
return Reflect.get(target, property, receiver);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function runtimeOptions(
|
||||
modulesDirectory: string,
|
||||
services: ModuleRuntimeServices = fakeModuleRuntimeServices(),
|
||||
services: ModuleRuntimeServicesLookup = fakeModuleRuntimeServices(),
|
||||
) {
|
||||
return {
|
||||
moduleSdkVersion: "0.1.0",
|
||||
@@ -1067,6 +1087,54 @@ describe("already-discovered module runtime", () => {
|
||||
});
|
||||
|
||||
describe("module runtime services", () => {
|
||||
it("delivers only the manifest-selected database mode and distinct ABI executors", async () => {
|
||||
const modulesDirectory = await createModulesDirectory();
|
||||
const recorder = createReferenceRecorder();
|
||||
const legacyDatabase = fakeModuleDatabase();
|
||||
const alphaExecutor = Object.freeze({
|
||||
abiVersion: 1,
|
||||
query: vi.fn(),
|
||||
transaction: vi.fn(),
|
||||
}) as unknown as ModuleDatabaseExecutor;
|
||||
const betaExecutor = Object.freeze({
|
||||
abiVersion: 1,
|
||||
query: vi.fn(),
|
||||
transaction: vi.fn(),
|
||||
}) as unknown as ModuleDatabaseExecutor;
|
||||
const services: ModuleRuntimeServicesLookup = new Map<string, AnyModuleRuntimeServices>([
|
||||
["legacy", Object.freeze({ database: legacyDatabase })],
|
||||
["alpha", Object.freeze({ database: alphaExecutor })],
|
||||
["beta", Object.freeze({ database: betaExecutor })],
|
||||
]);
|
||||
await createRuntimeModule(modulesDirectory, "legacy", {
|
||||
serverSource: `export default { id: "legacy", version: "0.1.0", start({ services }) { ${recorder.expression}.push(services.database); } };`,
|
||||
});
|
||||
for (const id of ["alpha", "beta"]) {
|
||||
await createRuntimeModule(modulesDirectory, id, {
|
||||
manifest: manifestFor(id, { database: { abi: 1 } }),
|
||||
serverSource: `export default { database: { abi: 1 }, id: ${JSON.stringify(id)}, version: "0.1.0", start({ services }) { ${recorder.expression}.push(services.database); } };`,
|
||||
});
|
||||
}
|
||||
|
||||
const runtime = await startModuleRuntime(runtimeOptions(modulesDirectory, services));
|
||||
|
||||
expect(recorder.values).toEqual([alphaExecutor, betaExecutor, legacyDatabase]);
|
||||
expect("select" in alphaExecutor).toBe(false);
|
||||
expect(alphaExecutor).not.toBe(betaExecutor);
|
||||
await runtime.stop();
|
||||
});
|
||||
|
||||
it("rejects a runtime database declaration that disagrees with its manifest", async () => {
|
||||
const modulesDirectory = await createModulesDirectory();
|
||||
await createRuntimeModule(modulesDirectory, "mismatch", {
|
||||
manifest: manifestFor("mismatch", { database: { abi: 1 } }),
|
||||
serverSource: `export default { id: "mismatch", version: "0.1.0" };`,
|
||||
});
|
||||
|
||||
const error = await captureRuntimeError(modulesDirectory);
|
||||
expect(runtimeIssueCodes(error)).toContain("MODULE_RUNTIME_DATABASE_MISMATCH");
|
||||
});
|
||||
|
||||
it("never gives register a services field, and gives start and stop the exact same services object", async () => {
|
||||
const modulesDirectory = await createModulesDirectory();
|
||||
const services = fakeModuleRuntimeServices();
|
||||
@@ -1086,11 +1154,11 @@ describe("module runtime services", () => {
|
||||
|
||||
expect(registerServices.values).toEqual([undefined]);
|
||||
expect(lifecycleServices.values).toHaveLength(2);
|
||||
expect(lifecycleServices.values[0]).toBe(services);
|
||||
expect(lifecycleServices.values[1]).toBe(services);
|
||||
expect(lifecycleServices.values[0]).toBe(services.get("wired"));
|
||||
expect(lifecycleServices.values[1]).toBe(services.get("wired"));
|
||||
});
|
||||
|
||||
it("shares the exact same services object identity across every module's start hook", async () => {
|
||||
it("selects a stable services object per module rather than sharing one across modules", async () => {
|
||||
const modulesDirectory = await createModulesDirectory();
|
||||
const services = fakeModuleRuntimeServices();
|
||||
const recorder = createReferenceRecorder();
|
||||
@@ -1112,7 +1180,8 @@ describe("module runtime services", () => {
|
||||
|
||||
const runtime = await startModuleRuntime(runtimeOptions(modulesDirectory, services));
|
||||
|
||||
expect(recorder.values).toEqual([services, services]);
|
||||
expect(recorder.values).toEqual([services.get("alpha"), services.get("beta")]);
|
||||
expect(recorder.values[0]).not.toBe(recorder.values[1]);
|
||||
await runtime.stop();
|
||||
});
|
||||
|
||||
@@ -1141,23 +1210,26 @@ describe("module runtime services", () => {
|
||||
startModuleRuntime(runtimeOptions(modulesDirectory, services)),
|
||||
).rejects.toBeInstanceOf(ModuleRuntimeError);
|
||||
|
||||
expect(recorder.values).toEqual([services, services]);
|
||||
expect(recorder.values).toEqual([services.get("started"), services.get("started")]);
|
||||
});
|
||||
|
||||
it("starts and stops a zero-module runtime without ever touching the provided services", async () => {
|
||||
const modulesDirectory = await createModulesDirectory();
|
||||
const services = fakeModuleRuntimeServices();
|
||||
const database = fakeModuleDatabase();
|
||||
const services: ModuleRuntimeServicesLookup = new Map([
|
||||
["unused", Object.freeze({ database })],
|
||||
]);
|
||||
|
||||
const runtime = await startModuleRuntime(runtimeOptions(modulesDirectory, services));
|
||||
await runtime.stop();
|
||||
|
||||
expect(runtime.modules).toEqual([]);
|
||||
expect(vi.mocked(services.database.select)).not.toHaveBeenCalled();
|
||||
expect(vi.mocked(services.database.insert)).not.toHaveBeenCalled();
|
||||
expect(vi.mocked(services.database.update)).not.toHaveBeenCalled();
|
||||
expect(vi.mocked(services.database.delete)).not.toHaveBeenCalled();
|
||||
expect(vi.mocked(services.database.execute)).not.toHaveBeenCalled();
|
||||
expect(vi.mocked(services.database.transaction)).not.toHaveBeenCalled();
|
||||
expect(vi.mocked(database.select)).not.toHaveBeenCalled();
|
||||
expect(vi.mocked(database.insert)).not.toHaveBeenCalled();
|
||||
expect(vi.mocked(database.update)).not.toHaveBeenCalled();
|
||||
expect(vi.mocked(database.delete)).not.toHaveBeenCalled();
|
||||
expect(vi.mocked(database.execute)).not.toHaveBeenCalled();
|
||||
expect(vi.mocked(database.transaction)).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1463,7 +1535,7 @@ describe("module configuration delivery (ADR 0029)", () => {
|
||||
expect(recorder.values).toEqual([["object", []]]);
|
||||
});
|
||||
|
||||
it("keeps the services object one shared identity even though configuration is per-module", async () => {
|
||||
it("keeps each module's services object stable while configuration remains per-module", async () => {
|
||||
const modulesDirectory = await createModulesDirectory();
|
||||
const services = fakeModuleRuntimeServices();
|
||||
const recorder = createReferenceRecorder();
|
||||
@@ -1492,11 +1564,11 @@ describe("module configuration delivery (ADR 0029)", () => {
|
||||
await runtime.stop();
|
||||
|
||||
const [alphaServices, alphaConfig, bravoServices, bravoConfig] = recorder.values;
|
||||
// This is the reason configuration is a sibling of `services` rather than a field inside it:
|
||||
// `services` is deliberately one shared object for the whole runtime, while configuration must
|
||||
// differ per module. Both properties hold simultaneously.
|
||||
expect(alphaServices).toBe(services);
|
||||
expect(bravoServices).toBe(services);
|
||||
// Configuration remains a sibling of `services`: both are selected per module, but they are
|
||||
// independently composed snapshots with separate contracts and lifetimes.
|
||||
expect(alphaServices).toBe(services.get("alpha"));
|
||||
expect(bravoServices).toBe(services.get("bravo"));
|
||||
expect(alphaServices).not.toBe(bravoServices);
|
||||
expect(alphaConfig).not.toBe(bravoConfig);
|
||||
expect((alphaConfig as ModuleConfiguration).getString("label")).toBe("alpha");
|
||||
expect((bravoConfig as ModuleConfiguration).getString("label")).toBe("bravo");
|
||||
|
||||
@@ -38,10 +38,14 @@ async function createModulesDirectory(): Promise<string> {
|
||||
|
||||
function manifestFor(
|
||||
id: string,
|
||||
options: { dependencies?: Record<string, unknown>; schema?: string | false } = {},
|
||||
options: {
|
||||
database?: { abi: 1 };
|
||||
dependencies?: Record<string, unknown>;
|
||||
schema?: string | false;
|
||||
} = {},
|
||||
): Record<string, unknown> {
|
||||
return {
|
||||
manifestVersion: 7,
|
||||
manifestVersion: 8,
|
||||
id,
|
||||
name: `${id} module`,
|
||||
version: "0.1.0",
|
||||
@@ -49,6 +53,7 @@ function manifestFor(
|
||||
...(options.dependencies ? { dependencies: options.dependencies } : {}),
|
||||
capabilities: [],
|
||||
permissions: [],
|
||||
...(options.database ? { database: options.database } : {}),
|
||||
entrypoints: {
|
||||
server: "./dist/server/index.js",
|
||||
...(options.schema === false ? {} : { schema: options.schema ?? "./dist/schema/index.js" }),
|
||||
@@ -126,6 +131,62 @@ function issueCodes(error: ModuleSchemaDiscoveryError): ModuleSchemaDiscoveryIss
|
||||
}
|
||||
|
||||
describe("module schema discovery", () => {
|
||||
it("loads ABI-v1 schema entrypoints as plain metadata without importing an ORM", async () => {
|
||||
const modulesDirectory = await createModulesDirectory();
|
||||
await createSchemaModule(modulesDirectory, "calendar", {
|
||||
manifest: manifestFor("calendar", { database: { abi: 1 } }),
|
||||
schemaSource: `
|
||||
export default {
|
||||
moduleId: "calendar",
|
||||
tables: [
|
||||
{ name: "calendar_events", foreignKeys: [] },
|
||||
{ name: "calendar_attendees", foreignKeys: [{ table: "calendar_events" }] }
|
||||
]
|
||||
};
|
||||
`,
|
||||
});
|
||||
|
||||
const aggregate = await discoverModuleSchemas(discoveryOptions(modulesDirectory));
|
||||
|
||||
expect(aggregate.tables.map((table) => table.name)).toEqual([
|
||||
"calendar_attendees",
|
||||
"calendar_events",
|
||||
]);
|
||||
expect(aggregate.tables.every((table) => table.table === undefined)).toBe(true);
|
||||
expect(aggregate.drizzleSchema).toEqual({});
|
||||
});
|
||||
|
||||
it("aggregates ABI-v1 prefix, duplicate, cross-module FK, missing target, and malformed errors", async () => {
|
||||
const modulesDirectory = await createModulesDirectory();
|
||||
await createSchemaModule(modulesDirectory, "alpha", {
|
||||
manifest: manifestFor("alpha", { database: { abi: 1 } }),
|
||||
schemaSource: `export default {
|
||||
moduleId: "alpha",
|
||||
tables: [
|
||||
{ name: "wrong_records", foreignKeys: [] },
|
||||
{ name: "alpha_records", foreignKeys: [{ table: "beta_records" }] },
|
||||
{ name: "alpha_records", foreignKeys: [{ table: "alpha_missing" }] }
|
||||
]
|
||||
};`,
|
||||
});
|
||||
await createSchemaModule(modulesDirectory, "malformed", {
|
||||
manifest: manifestFor("malformed", { database: { abi: 1 } }),
|
||||
schemaSource: `export default { moduleId: "malformed", tables: [{ name: "malformed_rows", unknown: true }] };`,
|
||||
});
|
||||
|
||||
const error = await captureSchemaError(modulesDirectory);
|
||||
|
||||
expect(issueCodes(error)).toEqual(
|
||||
expect.arrayContaining([
|
||||
"MODULE_SCHEMA_CROSS_MODULE_FOREIGN_KEY",
|
||||
"MODULE_SCHEMA_EXPORT_INVALID",
|
||||
"MODULE_SCHEMA_FOREIGN_KEY_TARGET_MISSING",
|
||||
"MODULE_SCHEMA_TABLE_DUPLICATE",
|
||||
"MODULE_SCHEMA_TABLE_NAMESPACE_INVALID",
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("returns a frozen empty aggregate without importing module servers", async () => {
|
||||
const modulesDirectory = await createModulesDirectory();
|
||||
const recorder = createGlobalEventRecorder();
|
||||
|
||||
@@ -18,7 +18,7 @@ function fakeModule(
|
||||
} = {},
|
||||
): DiscoveredModule {
|
||||
const manifest = parseModuleManifest({
|
||||
manifestVersion: 7,
|
||||
manifestVersion: 8,
|
||||
id,
|
||||
name: `${id} module`,
|
||||
version: "1.0.0",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user