Compare commits
74
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 |
@@ -6,3 +6,7 @@
|
|||||||
*.gif binary
|
*.gif binary
|
||||||
*.webp binary
|
*.webp binary
|
||||||
*.woff2 binary
|
*.woff2 binary
|
||||||
|
|
||||||
|
# Reviewed license supplements are named by the SHA-256 of their exact bytes (ADR 0052), so Git must
|
||||||
|
# never rewrite their line endings.
|
||||||
|
packages/module-pack/license-supplements/** -text
|
||||||
|
|||||||
@@ -128,6 +128,129 @@ jobs:
|
|||||||
if: always()
|
if: always()
|
||||||
run: bash scripts/ci-sweep.sh --job
|
run: bash scripts/ci-sweep.sh --job
|
||||||
|
|
||||||
|
package-release:
|
||||||
|
needs: verify
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 15
|
||||||
|
# What the gate proved, for the job that consumes its artifact to check it received exactly that.
|
||||||
|
outputs:
|
||||||
|
version: ${{ steps.release.outputs.version }}
|
||||||
|
revision: ${{ steps.release.outputs.revision }}
|
||||||
|
manifest_sha256: ${{ steps.release.outputs.manifest_sha256 }}
|
||||||
|
steps:
|
||||||
|
- name: Check out repository
|
||||||
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
|
with:
|
||||||
|
# `actions/checkout` defaults to leaving the job token in `.git/config`, where any later
|
||||||
|
# step — including one a pull request added to this file — could read or reuse it. Nothing
|
||||||
|
# in this workflow pushes, so the credential is never needed after checkout.
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
|
- name: Set up Node.js
|
||||||
|
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||||
|
with:
|
||||||
|
node-version-file: .node-version
|
||||||
|
|
||||||
|
- name: Activate pinned pnpm
|
||||||
|
run: |
|
||||||
|
corepack enable
|
||||||
|
corepack prepare [email protected] --activate
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
# ADR 0053's package release gate: build the public package train from this commit, build it
|
||||||
|
# again from a second checkout and require byte-identical artifacts, then install exactly those
|
||||||
|
# tarballs (offline, no registry) into a module outside this repository and prove it builds,
|
||||||
|
# packages, and verifies. Nothing is published and no credential exists here. A future publish
|
||||||
|
# job must depend on this job and publish only the artifacts it proved.
|
||||||
|
- name: Build, reproduce, and consume the public package train
|
||||||
|
run: pnpm test:package-release --keep package-train
|
||||||
|
|
||||||
|
- name: Describe the proven release train
|
||||||
|
id: release
|
||||||
|
run: |
|
||||||
|
release="$(find package-train -mindepth 1 -maxdepth 1 -type d)"
|
||||||
|
manifest="$release/ekklesia-package-train.json"
|
||||||
|
{
|
||||||
|
echo "version=$(node -p 'JSON.parse(require("fs").readFileSync(process.argv[1], "utf8")).version' "$manifest")"
|
||||||
|
echo "revision=$(node -p 'JSON.parse(require("fs").readFileSync(process.argv[1], "utf8")).source.revision' "$manifest")"
|
||||||
|
echo "manifest_sha256=$(sha256sum "$manifest" | cut -d' ' -f1)"
|
||||||
|
} >>"$GITHUB_OUTPUT"
|
||||||
|
cat "$manifest"
|
||||||
|
|
||||||
|
# The exact release artifacts, for the jobs that consume them, from a directory whose name does
|
||||||
|
# not start with a dot: the artifact actions leave hidden files out. Version 3 of the artifact
|
||||||
|
# actions: version 4 refuses any server that is not github.com, which this Gitea is not, while
|
||||||
|
# Gitea implements the protocol version 3 uses. A consumer checks the files, not the wrapper.
|
||||||
|
- name: Hand the release train on
|
||||||
|
uses: actions/upload-artifact@c6a3b2bd78b3985e4b2f15397fec357f0fd808de # v3.2.2-node20
|
||||||
|
with:
|
||||||
|
name: package-train
|
||||||
|
path: package-train/
|
||||||
|
if-no-files-found: error
|
||||||
|
retention-days: 7
|
||||||
|
|
||||||
|
# The publication tool, given exactly the artifact the package-release job proved, against a
|
||||||
|
# disposable Gitea 1.27.2 (scripts/test-package-publication.sh). Never the real registry, and no
|
||||||
|
# credential: the disposable registry's accounts are created and discarded inside the job.
|
||||||
|
package-publication:
|
||||||
|
needs: package-release
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 15
|
||||||
|
env:
|
||||||
|
# Names this job's stacks, so its final step removes exactly these (scripts/lib/ci-project.sh).
|
||||||
|
EKKLESIA_CI_JOB_KEY: package-publication
|
||||||
|
steps:
|
||||||
|
- name: Check out repository
|
||||||
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
|
# A job the runner killed (cancelled, or past the runner's own time limit) never ran its
|
||||||
|
# cleanup. Anything a CI job left on this shared daemon more than three hours ago goes first.
|
||||||
|
- name: Sweep stale CI stacks from the runner
|
||||||
|
run: bash scripts/ci-sweep.sh --stale
|
||||||
|
|
||||||
|
- name: Set up Node.js
|
||||||
|
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||||
|
with:
|
||||||
|
node-version-file: .node-version
|
||||||
|
|
||||||
|
- name: Activate pinned pnpm
|
||||||
|
run: |
|
||||||
|
corepack enable
|
||||||
|
corepack prepare [email protected] --activate
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
- name: Receive the release train the gate proved
|
||||||
|
uses: actions/download-artifact@ad191675b41f6a5b46da9a048cb6893812da158b # v3.1.0-node20
|
||||||
|
with:
|
||||||
|
name: package-train
|
||||||
|
path: package-train/
|
||||||
|
|
||||||
|
- name: Check it is exactly that train
|
||||||
|
env:
|
||||||
|
EXPECTED_MANIFEST_SHA256: ${{ needs.package-release.outputs.manifest_sha256 }}
|
||||||
|
run: |
|
||||||
|
release="$(find package-train -mindepth 1 -maxdepth 1 -type d)"
|
||||||
|
actual="$(sha256sum "$release/ekklesia-package-train.json" | cut -d' ' -f1)"
|
||||||
|
if [[ -z "$EXPECTED_MANIFEST_SHA256" || "$actual" != "$EXPECTED_MANIFEST_SHA256" ]]; then
|
||||||
|
echo "The received release manifest is not the one the package-release job proved." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
(cd "$release" && sha256sum --check --strict SHA256SUMS)
|
||||||
|
|
||||||
|
- name: Publish it to a disposable Gitea, and prove the round trip and recovery
|
||||||
|
run: bash scripts/test-package-publication.sh --release "$(find package-train -mindepth 1 -maxdepth 1 -type d)"
|
||||||
|
|
||||||
|
# Whatever happened above, this job's own stacks do not outlive it.
|
||||||
|
- name: Remove this job's CI stacks
|
||||||
|
if: always()
|
||||||
|
run: bash scripts/ci-sweep.sh --job
|
||||||
|
|
||||||
image-build:
|
image-build:
|
||||||
needs: verify
|
needs: verify
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|||||||
@@ -0,0 +1,133 @@
|
|||||||
|
name: Publish packages
|
||||||
|
|
||||||
|
# Publishes one public @ekklesia/* package train (ADR 0053, docs/development/releases.md) to the
|
||||||
|
# npm registry: exactly the artifacts this run's package-release gate proves, and nothing else.
|
||||||
|
#
|
||||||
|
# Only ever started by hand, on `main`, by someone with write access to this repository: no pull
|
||||||
|
# request or push can start it. It needs the version-set commit of a release (`version` must equal
|
||||||
|
# the platform version there), and the repository secret EKKLESIA_PACKAGES_PUBLISH_TOKEN, a token of
|
||||||
|
# the dedicated publisher account with only the `write:package` scope. Packages are publicly
|
||||||
|
# readable (ADR 0054); publishing is what this protects.
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
version:
|
||||||
|
description: The package train version to publish, equal to the platform version on main.
|
||||||
|
required: true
|
||||||
|
type: string
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
# Never cancel a publication part way, and never run two at once. A rerun resumes safely.
|
||||||
|
concurrency:
|
||||||
|
group: publish-packages
|
||||||
|
cancel-in-progress: false
|
||||||
|
|
||||||
|
env:
|
||||||
|
# The only registry-specific setting: where the train is published.
|
||||||
|
EKKLESIA_PACKAGES_REGISTRY: https://git.zayin.net/api/packages/Zayin/npm/
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
package-release:
|
||||||
|
if: github.ref == 'refs/heads/main'
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 20
|
||||||
|
outputs:
|
||||||
|
version: ${{ steps.release.outputs.version }}
|
||||||
|
revision: ${{ steps.release.outputs.revision }}
|
||||||
|
manifest_sha256: ${{ steps.release.outputs.manifest_sha256 }}
|
||||||
|
steps:
|
||||||
|
- name: Check out repository
|
||||||
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
|
- name: Set up Node.js
|
||||||
|
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||||
|
with:
|
||||||
|
node-version-file: .node-version
|
||||||
|
|
||||||
|
- name: Activate pinned pnpm
|
||||||
|
run: |
|
||||||
|
corepack enable
|
||||||
|
corepack prepare [email protected] --activate
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
# The same credential-free gate as CI: build, reproduce, and consume the train.
|
||||||
|
- name: Build, reproduce, and consume the public package train
|
||||||
|
run: pnpm test:package-release --keep package-train
|
||||||
|
|
||||||
|
- name: Describe the proven release train
|
||||||
|
id: release
|
||||||
|
run: |
|
||||||
|
release="$(find package-train -mindepth 1 -maxdepth 1 -type d)"
|
||||||
|
manifest="$release/ekklesia-package-train.json"
|
||||||
|
{
|
||||||
|
echo "version=$(node -p 'JSON.parse(require("fs").readFileSync(process.argv[1], "utf8")).version' "$manifest")"
|
||||||
|
echo "revision=$(node -p 'JSON.parse(require("fs").readFileSync(process.argv[1], "utf8")).source.revision' "$manifest")"
|
||||||
|
echo "manifest_sha256=$(sha256sum "$manifest" | cut -d' ' -f1)"
|
||||||
|
} >>"$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
# Artifact actions version 3: version 4 refuses any server that is not github.com. The directory
|
||||||
|
# name must not start with a dot: the artifact actions leave hidden files out.
|
||||||
|
- name: Hand the release train on
|
||||||
|
uses: actions/upload-artifact@c6a3b2bd78b3985e4b2f15397fec357f0fd808de # v3.2.2-node20
|
||||||
|
with:
|
||||||
|
name: package-train
|
||||||
|
path: package-train/
|
||||||
|
if-no-files-found: error
|
||||||
|
retention-days: 90
|
||||||
|
|
||||||
|
publish:
|
||||||
|
needs: package-release
|
||||||
|
if: github.ref == 'refs/heads/main'
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 20
|
||||||
|
steps:
|
||||||
|
# Only for the publication tool itself: nothing is built or installed in this job.
|
||||||
|
- name: Check out repository
|
||||||
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
|
- name: Set up Node.js
|
||||||
|
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||||
|
with:
|
||||||
|
node-version-file: .node-version
|
||||||
|
|
||||||
|
- name: Receive the release train the gate proved
|
||||||
|
uses: actions/download-artifact@ad191675b41f6a5b46da9a048cb6893812da158b # v3.1.0-node20
|
||||||
|
with:
|
||||||
|
name: package-train
|
||||||
|
path: package-train/
|
||||||
|
|
||||||
|
# The tool validates the whole train against its manifest, that manifest against the gate's,
|
||||||
|
# its version against the request, and its revision against this commit, before it sends
|
||||||
|
# anything; then publishes, verifies every package byte for byte, and moves `latest` last.
|
||||||
|
- name: Publish the exact release train
|
||||||
|
env:
|
||||||
|
EKKLESIA_PACKAGES_PUBLISH_TOKEN: ${{ secrets.EKKLESIA_PACKAGES_PUBLISH_TOKEN }}
|
||||||
|
TRAIN_VERSION: ${{ inputs.version }}
|
||||||
|
TRAIN_REVISION: ${{ github.sha }}
|
||||||
|
MANIFEST_SHA256: ${{ needs.package-release.outputs.manifest_sha256 }}
|
||||||
|
run: |
|
||||||
|
node scripts/publish-package-train.ts \
|
||||||
|
--release "$(find package-train -mindepth 1 -maxdepth 1 -type d)" \
|
||||||
|
--registry "$EKKLESIA_PACKAGES_REGISTRY" \
|
||||||
|
--version "$TRAIN_VERSION" \
|
||||||
|
--revision "$TRAIN_REVISION" \
|
||||||
|
--manifest-sha256 "$MANIFEST_SHA256" \
|
||||||
|
--record publication-record.json \
|
||||||
|
--require-public-read
|
||||||
|
|
||||||
|
- name: Keep the publication record
|
||||||
|
uses: actions/upload-artifact@c6a3b2bd78b3985e4b2f15397fec357f0fd808de # v3.2.2-node20
|
||||||
|
with:
|
||||||
|
name: package-train-publication-record
|
||||||
|
path: publication-record.json
|
||||||
|
if-no-files-found: error
|
||||||
|
retention-days: 90
|
||||||
+12
@@ -1,4 +1,8 @@
|
|||||||
node_modules/
|
node_modules/
|
||||||
|
# pnpm's content-addressed package store. pnpm keeps it beside node_modules on the same file system,
|
||||||
|
# so when the checkout is its own mount (a CI runner's workspace volume, a bind-mounted container),
|
||||||
|
# `pnpm install` creates it here, at the checkout's root. A package cache, never source.
|
||||||
|
.pnpm-store/
|
||||||
.turbo/
|
.turbo/
|
||||||
coverage/
|
coverage/
|
||||||
dist/
|
dist/
|
||||||
@@ -16,3 +20,11 @@ generated/*
|
|||||||
|
|
||||||
core/.identity-schema-reference.ts
|
core/.identity-schema-reference.ts
|
||||||
core/.identity-schema-check-*
|
core/.identity-schema-check-*
|
||||||
|
|
||||||
|
# The packager tests copy fixture modules here (packages/module-pack/tests/support/fixture-module.ts).
|
||||||
|
packages/module-pack/.fixtures/
|
||||||
|
|
||||||
|
# `pnpm packages:release` writes package release trains here (scripts/package-release.ts).
|
||||||
|
.package-release/
|
||||||
|
# CI keeps the proven release here to hand it on as a workflow artifact (scripts/test-package-release.sh --keep).
|
||||||
|
/package-train/
|
||||||
|
|||||||
@@ -6,27 +6,60 @@ contents, because restated details drift.
|
|||||||
|
|
||||||
## What this repository is
|
## What this repository is
|
||||||
|
|
||||||
Ekklesia is a modular, self-hosted church platform built as a strictly layered pnpm/TypeScript
|
Ekklesia is a modular, self-hosted church platform. **This repository is the platform** — Core, the
|
||||||
monorepo:
|
API, the Dashboard and display shell, and the public `@ekklesia/*` module-development packages — built
|
||||||
|
as a strictly layered pnpm/TypeScript monorepo:
|
||||||
|
|
||||||
- **Core** (`core/`) owns platform infrastructure: identity, database connectivity, migration
|
- **Core** (`core/`) owns platform infrastructure: identity, database connectivity, migration
|
||||||
execution, module discovery/lifecycle, and other feature-agnostic services. Core never imports a
|
execution, module discovery/lifecycle, devices, and other feature-agnostic services. Core never
|
||||||
feature module, a surface, or an app.
|
imports a feature module, a surface, or an app.
|
||||||
- **Modules** (`modules/*`, template at `templates/module`) are independently versioned product
|
- **Modules** are independently versioned product packages. First-party modules live in **their own
|
||||||
packages. They own their own data and migrations, declare capabilities/permissions through
|
repositories** (Signage is `Zayin/Signage`), build against the published `@ekklesia/*` packages
|
||||||
`@ekklesia/contracts` and `@ekklesia/module-sdk`, and must never import Core, another module, or a
|
(ADR 0053), and reach an installation as a Module Package (ADR 0049) installed into module storage
|
||||||
surface directly. Per ADR 0021 a module owns its UI; manifest v7 permits independently optional
|
outside the image (ADR 0048). No module source lives in this repository; `templates/module` is the
|
||||||
primary and full-viewport display artifacts, authored in React against the same role-neutral
|
example, and `modules/` is only an optional, ignored local installed-modules directory. A module owns
|
||||||
`@ekklesia/module-ui-sdk` ABI. Ekklesia mounts each through a separate surface role, while
|
its data, migrations, and **its own domain UI** (ADR 0021), declares capabilities/permissions through
|
||||||
`pnpm boundaries` still rejects every non-React UI framework and non-`.tsx` presentation source.
|
`@ekklesia/contracts` and `@ekklesia/module-sdk`, and never imports Core, another module, or a
|
||||||
|
surface. Manifest v8 permits independently optional primary and full-viewport display artifacts,
|
||||||
|
authored in React against the role-neutral `@ekklesia/module-ui-sdk` ABI; database access is the
|
||||||
|
Module Database ABI v1 (ADR 0050).
|
||||||
- **Surfaces** (`surfaces/*`) own the containing application shell, navigation, and every
|
- **Surfaces** (`surfaces/*`) own the containing application shell, navigation, and every
|
||||||
presentation concern a module does not. They consume public API/client contracts only, never Core
|
presentation concern a module does not. The shell **hosts** module UI through the generic loader;
|
||||||
or module internals directly.
|
it never rebuilds a module's domain screens. Surfaces consume public API/client contracts only.
|
||||||
|
|
||||||
These boundaries are enforced by `pnpm boundaries` (`scripts/check-boundaries.ts`), not just
|
These boundaries are enforced by `pnpm boundaries` (`scripts/check-boundaries.ts`), not just
|
||||||
documented. Read `docs/architecture/repository-boundaries.md` and
|
documented. Read `docs/architecture/repository-boundaries.md` and
|
||||||
`docs/architecture/platform-architecture.md` before proposing any change that crosses a layer.
|
`docs/architecture/platform-architecture.md` before proposing any change that crosses a layer.
|
||||||
|
|
||||||
|
## Product direction lives beside this repository
|
||||||
|
|
||||||
|
The current roadmap and product/interface design contract are `ROADMAP.md` and `DESIGN.md` in the
|
||||||
|
parent development directory (`../ROADMAP.md` and `../DESIGN.md` from a checkout in the standard
|
||||||
|
`~/development/ekklesia/` layout). They are deliberately **not** committed here; do not copy them into
|
||||||
|
this repository to make them available. They are authoritative for product direction: read the
|
||||||
|
relevant stages and sections before designing a change. If this repository's documentation disagrees
|
||||||
|
with them, flag the conflict, correct the repository-local documentation where appropriate, and never
|
||||||
|
silently follow the stale text. Architectural decisions still change only through ADRs (below).
|
||||||
|
|
||||||
|
Rules from those documents that every change here must respect:
|
||||||
|
|
||||||
|
- **Ownership.** Modules own their domain data, behaviour, and UI. Shared resources — displays and
|
||||||
|
devices, accounts and identity, roles and authorization, branding, mail, push, storage providers,
|
||||||
|
and module installation/lifecycle — belong to the platform, and modules consume them.
|
||||||
|
- **Branding.** Normal user-facing UI never hard-codes "Ekklesia"; product identity comes from
|
||||||
|
installation branding (ADR 0046). Package names, diagnostics, logs, and developer tooling may.
|
||||||
|
Existing hard-coded shell strings are known debt for Stage 1, not a pattern to copy.
|
||||||
|
- **Visual direction.** Simple, minimal, and calm; responsive for phone, tablet, and desktop; light,
|
||||||
|
dark, and system modes. Normal UI colours come only from the shared design tokens
|
||||||
|
(`@ekklesia/design-tokens`, ADR 0039) — never a hard-coded colour.
|
||||||
|
- **No browser-native dialogs.** `window.alert`, `window.confirm`, and `window.prompt` are prohibited
|
||||||
|
in product UI. Destructive actions use in-product confirmation proportional to their impact.
|
||||||
|
- **Displays are transitional.** Displays are platform-owned. Today's device pairing (the Dashboard
|
||||||
|
sidebar's *Display devices* panel) and Signage's own display roster are a transitional
|
||||||
|
implementation that Stage 1 migrates to **Settings → Displays**. Fix defects in them, but do not
|
||||||
|
build the Stage 1 Settings, global Displays, theme editor, module installer, or responsive shell
|
||||||
|
redesign early, and keep existing paired displays working across any change.
|
||||||
|
|
||||||
## ADRs are authoritative
|
## ADRs are authoritative
|
||||||
|
|
||||||
`docs/architecture/decisions/` records durable architectural decisions. **Accepted ADRs are
|
`docs/architecture/decisions/` records durable architectural decisions. **Accepted ADRs are
|
||||||
@@ -43,6 +76,7 @@ At minimum, read the current versions of:
|
|||||||
- `docs/architecture/decisions/README.md` and every ADR relevant to your change
|
- `docs/architecture/decisions/README.md` and every ADR relevant to your change
|
||||||
- `docs/architecture/platform-architecture.md` and `docs/architecture/repository-boundaries.md`
|
- `docs/architecture/platform-architecture.md` and `docs/architecture/repository-boundaries.md`
|
||||||
- `README.md` (current platform status — what is and is not implemented)
|
- `README.md` (current platform status — what is and is not implemented)
|
||||||
|
- `../ROADMAP.md` and `../DESIGN.md` (current product direction; see above)
|
||||||
- `docs/development/repository-workflow.md` and `docs/development/testing.md`
|
- `docs/development/repository-workflow.md` and `docs/development/testing.md`
|
||||||
- `CONTRIBUTING.md`
|
- `CONTRIBUTING.md`
|
||||||
|
|
||||||
@@ -52,8 +86,16 @@ your own memory of a prior session** — treat stale assumptions as a bug to cat
|
|||||||
|
|
||||||
## Working conventions
|
## Working conventions
|
||||||
|
|
||||||
- Work on a clean branch off an up-to-date `main`; never commit or push directly to `main`; never
|
- The canonical repositories are on the church Gitea under the `Zayin` organization:
|
||||||
change branch-protection settings.
|
`ssh://[email protected]:2222/Zayin/Ekklesia.git` (this one), `Zayin/Signage.git`, and
|
||||||
|
`Zayin/Classroom_Desktop.git`. A local clone may be behind the server: `git fetch --prune --tags`
|
||||||
|
and branch from `origin/main`, never from whatever is checked out.
|
||||||
|
- One branch per PR, off an up-to-date `origin/main`. Never commit or push directly to `main`, never
|
||||||
|
merge your own PR, and never change branch-protection settings.
|
||||||
|
- Never create or push a release tag. Release tags belong to the human maintainer.
|
||||||
|
- A change that needs both a platform contract change and a module change is two PRs: Core first, on
|
||||||
|
its own branch, with the compatibility boundary stated explicitly; then the module. Do not copy
|
||||||
|
platform logic into a module to avoid making the correct Core change.
|
||||||
- Keep PRs focused on one bounded slice. Record major architectural changes in an ADR rather than
|
- Keep PRs focused on one bounded slice. Record major architectural changes in an ADR rather than
|
||||||
burying them in an implementation.
|
burying them in an implementation.
|
||||||
- Add or update tests when you change a contract or architectural rule. New behavior needs new
|
- Add or update tests when you change a contract or architectural rule. New behavior needs new
|
||||||
@@ -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
|
- Never run a database migration automatically (on import, on startup, in a test that isn't
|
||||||
explicitly a migration test, or as a side effect of an unrelated command). Migrations are always
|
explicitly a migration test, or as a side effect of an unrelated command). Migrations are always
|
||||||
an explicit, operator-invoked action.
|
an explicit, operator-invoked action.
|
||||||
|
- Report what you find. A defect, flaky test, documentation/ADR conflict, or CI problem outside the
|
||||||
|
task at hand gets an issue in the repository it belongs to, as soon as you find it. Search for an
|
||||||
|
existing issue first; something fixed in the same PR goes in that PR's description instead. Be
|
||||||
|
descriptive: what you saw, the evidence (run and job links, commit, file and line), how to
|
||||||
|
reproduce it, the impact, and options, and never a secret. Label it `found-by-claude` (an agent
|
||||||
|
that cannot apply labels ends the body with a line saying so), leave it unassigned, and list what
|
||||||
|
you filed when you report back.
|
||||||
|
|
||||||
## Canonical verification commands
|
## Canonical verification commands
|
||||||
|
|
||||||
@@ -78,6 +127,13 @@ ever disagree:
|
|||||||
- `pnpm test:migrations:integration` — live, disposable-PostgreSQL evidence: migration discovery,
|
- `pnpm test:migrations:integration` — live, disposable-PostgreSQL evidence: migration discovery,
|
||||||
planning, and execution, plus the `apps/api` HTTP transport (signup, sign-in, principal
|
planning, and execution, plus the `apps/api` HTTP transport (signup, sign-in, principal
|
||||||
resolution, sign-out). Requires Docker. Run this for any change touching either area.
|
resolution, sign-out). Requires Docker. Run this for any change touching either area.
|
||||||
|
- `pnpm test:package-release` — builds the public `@ekklesia/*` package train, proves it
|
||||||
|
reproducible, and proves a standalone module consumes exactly those tarballs. Publishes nothing.
|
||||||
|
Run it for any change to the public packages or their release tooling.
|
||||||
|
- `pnpm test:package-publication` — publishes a release train to a disposable Gitea (Docker) and
|
||||||
|
proves the publication tool. Never the real registry. The real publication is the manual
|
||||||
|
`Publish packages` workflow only; never run `scripts/publish-package-train.ts` against the real
|
||||||
|
registry without the user's explicit intent.
|
||||||
- `pnpm doctor` — read-only local readiness report. Never treat it as a substitute for `pnpm
|
- `pnpm doctor` — read-only local readiness report. Never treat it as a substitute for `pnpm
|
||||||
verify` or the integration suite.
|
verify` or the integration suite.
|
||||||
- `pnpm migrations:plan` / `pnpm migrations:apply` — read-only planning vs. mutating execution
|
- `pnpm migrations:plan` / `pnpm migrations:apply` — read-only planning vs. mutating execution
|
||||||
|
|||||||
@@ -48,12 +48,34 @@ migration history is retained and reported, and blocks no other migration. A mod
|
|||||||
as one **Ekklesia Module Package v1**
|
as one **Ekklesia Module Package v1**
|
||||||
([ADR 0049](docs/architecture/decisions/0049-module-package-v1.md)): a deterministic `.tar.gz` and
|
([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
|
its SHA-256, produced by the canonical packager (`@ekklesia/module-pack`) with the module's backend
|
||||||
bundled, and checked by Core's verifier before it is installed. Only the portable `any` target exists
|
bundled, and checked by Core's verifier before it is installed. Two targets exist:
|
||||||
so far. Every package records the module's validated SPDX expression and carries its explicitly
|
- 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
|
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;
|
packages may bundle a module-owned Drizzle version without coupling it to Core;
|
||||||
legacy-v8 packages retain their transitional exact-version check. Purge, native-target packages,
|
legacy-v8 packages retain their transitional exact-version check. Where a redistributed dependency
|
||||||
signing, and any installer or marketplace do not exist yet.
|
omits licence material, the packager may add reviewed licence supplements
|
||||||
|
([ADR 0052](docs/architecture/decisions/0052-reviewed-license-supplements.md)). A native package that
|
||||||
|
redistributes LGPL or MPL code is distributed publicly only with its corresponding-source artifact,
|
||||||
|
which `ekklesia-corresponding-source` builds and gates
|
||||||
|
([ADR 0055](docs/architecture/decisions/0055-corresponding-source-artifacts.md)). Purge, other
|
||||||
|
operating-system or CPU targets, signing, and any installer or marketplace do not exist yet.
|
||||||
|
|
||||||
|
A module can be built in its own repository from Ekklesia's public module-development packages
|
||||||
|
([ADR 0053](docs/architecture/decisions/0053-standalone-module-development-packages.md)):
|
||||||
|
a curated `@ekklesia/module-sdk`, `@ekklesia/module-database`, the UI packages, and the build and
|
||||||
|
packaging tools. They are released as one coordinated train of tarballs with a release manifest,
|
||||||
|
and CI proves on every run that two builds of a commit are byte-identical and that a module outside
|
||||||
|
the repository installs, builds, packages, and verifies from those artifacts alone. A manual
|
||||||
|
workflow, the only way to publish, publishes exactly those artifacts to the npm registry and
|
||||||
|
verifies every package byte for byte. Anyone may read and install the packages before launch
|
||||||
|
([ADR 0054](docs/architecture/decisions/0054-public-package-reads-before-launch.md)). The first train
|
||||||
|
published will be the next platform release
|
||||||
|
([Releases and the public package train](docs/development/releases.md)).
|
||||||
|
|
||||||
Phase 4A added Core-owned identity foundations: a Core-owned Better Auth database schema
|
Phase 4A added Core-owned identity foundations: a Core-owned Better Auth database schema
|
||||||
(`ekklesia_core_user`, `ekklesia_core_session`, `ekklesia_core_account`,
|
(`ekklesia_core_user`, `ekklesia_core_session`, `ekklesia_core_account`,
|
||||||
@@ -800,7 +822,8 @@ hosting are deployment work, not guarantees added by this pass. Display device i
|
|||||||
public Website remain separate future designs.
|
public Website remain separate future designs.
|
||||||
|
|
||||||
**Still not the finished Dashboard.** There is no navigation editor or drag and drop, no nested
|
**Still not the finished Dashboard.** There is no navigation editor or drag and drop, no nested
|
||||||
folders, no icons or colors, no Dashboard Home or widgets, no design system, no client-side routing,
|
folders, no icons or colors, no Dashboard Home or widgets, no design system, no client-side routing
|
||||||
|
(since added; see the Stage 1 paragraph below),
|
||||||
and no module-management, settings, or storage UI — module enablement and configuration are an API and
|
and no module-management, settings, or storage UI — module enablement and configuration are an API and
|
||||||
a CLI, not a screen, and object storage is deployment configuration. A production CSP and
|
a CLI, not a screen, and object storage is deployment configuration. A production CSP and
|
||||||
deployment for the built shells, general sidecar asset publication, React runtime sharing, the
|
deployment for the built shells, general sidecar asset publication, React runtime sharing, the
|
||||||
@@ -883,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
|
Ekklesia Lite, a theme editor, a module store or installer, a standalone host, and every feature
|
||||||
module — Signage included — still do not exist, and nothing in Phase 8 claims otherwise.
|
module — Signage included — still do not exist, and nothing in Phase 8 claims otherwise.
|
||||||
|
|
||||||
|
Stage 1 begins with **real Dashboard URLs**
|
||||||
|
([ADR 0057](docs/architecture/decisions/0057-dashboard-urls-are-host-owned-routes.md)). The
|
||||||
|
Dashboard opens on an **Overview** at `/`, and each module is a top-level destination with its own
|
||||||
|
address, `/<module-id>` (`/signage`), beside `/settings` and `/account`, so a refresh, a bookmark,
|
||||||
|
and a shared link land on the page that was open and Back and Forward walk the Dashboard's own pages.
|
||||||
|
The API serves the same Dashboard page at `/`, throughout `/settings` and `/account`, and throughout
|
||||||
|
each installed module's `/<module-id>`; any other top-level path is its sanitized `404`. Core reserves
|
||||||
|
the platform's own top-level names (`account`, `api`, `assets`, `display`, `settings`), and
|
||||||
|
discovery refuses a module that uses one. An installed module that is disabled shows a host-owned
|
||||||
|
*This page isn't available* rather than loading anything. A module's own
|
||||||
|
sub-pages, Settings and Account pages, and returning to the workflow a Settings link came from are
|
||||||
|
later Stage 1 work.
|
||||||
|
|
||||||
|
The Dashboard's **shell** follows DESIGN.md §4
|
||||||
|
([ADR 0058](docs/architecture/decisions/0058-dashboard-shell-navigation-and-lucide-icons.md)): a
|
||||||
|
collapsible left sidebar — icons and labels expanded, icons with accessible tooltips collapsed, the
|
||||||
|
choice remembered in this browser — with the Overview first, the server's groups and modules below it,
|
||||||
|
and an account menu anchored at the bottom (Sign out, for now). There is no top bar. On a phone the
|
||||||
|
same navigation opens in a modal drawer with focus trapped inside it, Escape and the backdrop closing
|
||||||
|
it, and focus returned to the menu button. Lucide is Ekklesia's icon vocabulary; until modules can
|
||||||
|
declare their own icon (manifest v9), every module shows one generic icon. Church branding in the
|
||||||
|
sidebar's identity slot is still to come.
|
||||||
|
|
||||||
|
`@ekklesia/ui` now carries the interactions every later screen shares
|
||||||
|
([ADR 0059](docs/architecture/decisions/0059-shared-interaction-primitives.md)): a product-owned
|
||||||
|
`ConfirmationDialog` (simple, destructive, or typed-phrase, chosen by the caller), a per-tree
|
||||||
|
top-center toast system, `ErrorState` with collapsed technical detail and remedies, skeleton loading,
|
||||||
|
and `PageHeader` with a Back action that returns only to a validated Dashboard page. Every dialog
|
||||||
|
traps and restores focus, and `pnpm lint` refuses `window.alert`, `confirm`, and `prompt`
|
||||||
|
repository-wide.
|
||||||
|
|
||||||
|
The Dashboard follows the operating system's **Light or Dark** appearance and **reduced-motion**
|
||||||
|
setting, live and without a reload, and so does every module's UI, through the same design tokens
|
||||||
|
([ADR 0060](docs/architecture/decisions/0060-light-dark-system-theme-and-motion.md)). The dark theme is
|
||||||
|
designed rather than inverted, every text and focus pairing is contrast-tested to WCAG AA in both
|
||||||
|
schemes, and corners scale from one `radius.scale` token. Displays stay light.
|
||||||
|
|
||||||
|
**Settings** has its own place in the sidebar
|
||||||
|
([ADR 0061](docs/architecture/decisions/0061-settings-hierarchy-and-module-settings-routes.md)): a
|
||||||
|
landing page at `/settings`; the platform's General, Branding, Displays, Discover, and System; and each
|
||||||
|
module's settings at `/settings/<module-id>`. Discover is where modules are found, installed, managed,
|
||||||
|
and updated (`/settings/discover/browse`, `/installed`, `/updates`); configuring a module is its own
|
||||||
|
Settings page. A person sees only what the server says they may administer, and no Settings link at all
|
||||||
|
when that is nothing. The pages are placeholders until each section is built.
|
||||||
|
|
||||||
|
**Your account** is at the bottom of the sidebar, showing your name, email, and initials
|
||||||
|
([ADR 0062](docs/architecture/decisions/0062-account-area-and-synced-appearance-preferences.md)).
|
||||||
|
`/account` shows who you are signed in as. On `/account/appearance` you choose **System, Light, or
|
||||||
|
Dark** and **reduced motion** (System, Reduce, or Allow). A choice takes effect at once, is saved to
|
||||||
|
your account so it follows you to every device, and is applied before the first paint on your next
|
||||||
|
visit. Managing your own appearance needs no administrative permission. Profile editing and an
|
||||||
|
avatar image are not part of this.
|
||||||
|
|
||||||
## Quick start
|
## Quick start
|
||||||
|
|
||||||
Prerequisites: Node.js 24 LTS, pnpm 11.12, and Docker with Compose.
|
Prerequisites: Node.js 24 LTS, pnpm 11.12, and Docker with Compose.
|
||||||
@@ -938,7 +1014,7 @@ Modules
|
|||||||
✓ Dependency graph valid
|
✓ Dependency graph valid
|
||||||
Dependency order is empty.
|
Dependency order is empty.
|
||||||
✓ Permission registry composed
|
✓ Permission registry composed
|
||||||
6 permissions registered
|
7 permissions registered
|
||||||
○ Module lifecycle skipped
|
○ Module lifecycle skipped
|
||||||
DATABASE_URL is not configured.
|
DATABASE_URL is not configured.
|
||||||
○ Module configuration skipped
|
○ Module configuration skipped
|
||||||
@@ -948,7 +1024,7 @@ Data definitions
|
|||||||
✓ Schemas validated
|
✓ Schemas validated
|
||||||
0 modules · 0 tables
|
0 modules · 0 tables
|
||||||
✓ Migrations validated
|
✓ Migrations validated
|
||||||
7 migrations · checksums valid
|
9 migrations · checksums valid
|
||||||
core:0000_identity_core_tables · f8f2086ef06f
|
core:0000_identity_core_tables · f8f2086ef06f
|
||||||
core:0001_authorization_core_tables · 830e1fbc26d0
|
core:0001_authorization_core_tables · 830e1fbc26d0
|
||||||
core:0002_authorization_integrity · 634294731180
|
core:0002_authorization_integrity · 634294731180
|
||||||
@@ -956,6 +1032,8 @@ Data definitions
|
|||||||
core:0004_module_lifecycle_state · c8b5d8b3d48b
|
core:0004_module_lifecycle_state · c8b5d8b3d48b
|
||||||
core:0005_module_configuration · c8d57f24f84b
|
core:0005_module_configuration · c8d57f24f84b
|
||||||
core:0006_device_identity · 1db97b7e3ac5
|
core:0006_device_identity · 1db97b7e3ac5
|
||||||
|
core:0007_verified_bootstrap_owners · 49013062f3e0
|
||||||
|
core:0008_user_preferences · fa9d4b0bb9d3
|
||||||
|
|
||||||
PostgreSQL
|
PostgreSQL
|
||||||
✓ Database TLS verified
|
✓ Database TLS verified
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@ekklesia/api",
|
"name": "@ekklesia/api",
|
||||||
"version": "0.1.2",
|
"version": "0.2.5",
|
||||||
"license": "AGPL-3.0-or-later",
|
"license": "AGPL-3.0-or-later",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
@@ -28,6 +28,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@biomejs/biome": "2.5.3",
|
"@biomejs/biome": "2.5.3",
|
||||||
|
"@ekklesia/design-tokens": "workspace:*",
|
||||||
"@ekklesia/module-ui-host": "workspace:*",
|
"@ekklesia/module-ui-host": "workspace:*",
|
||||||
"@ekklesia/module-ui-sdk": "workspace:*",
|
"@ekklesia/module-ui-sdk": "workspace:*",
|
||||||
"@ekklesia/typescript-config": "workspace:*",
|
"@ekklesia/typescript-config": "workspace:*",
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
+55
-7
@@ -1,6 +1,5 @@
|
|||||||
import {
|
import {
|
||||||
isAuthenticatedPrincipal,
|
isAuthenticatedPrincipal,
|
||||||
type ModuleRuntimeServices,
|
|
||||||
type ModuleSecretSource,
|
type ModuleSecretSource,
|
||||||
type RegisteredModuleHttpRoute,
|
type RegisteredModuleHttpRoute,
|
||||||
} from "@ekklesia/contracts";
|
} from "@ekklesia/contracts";
|
||||||
@@ -9,10 +8,12 @@ import type {
|
|||||||
AuthorizationStore,
|
AuthorizationStore,
|
||||||
PermissionRegistry,
|
PermissionRegistry,
|
||||||
} from "@ekklesia/core/authorization";
|
} from "@ekklesia/core/authorization";
|
||||||
|
import type { AccountStore } from "@ekklesia/core/account";
|
||||||
import type { DashboardStore } from "@ekklesia/core/dashboard";
|
import type { DashboardStore } from "@ekklesia/core/dashboard";
|
||||||
import type { DeviceStore } from "@ekklesia/core/devices";
|
import type { DeviceStore } from "@ekklesia/core/devices";
|
||||||
import { type EkklesiaIdentityRuntime, IdentityRuntimeError } from "@ekklesia/core/identity";
|
import { type EkklesiaIdentityRuntime, IdentityRuntimeError } from "@ekklesia/core/identity";
|
||||||
import type {
|
import type {
|
||||||
|
AnyModuleRuntimeServices,
|
||||||
DiscoveredModule,
|
DiscoveredModule,
|
||||||
ModuleConfigurationLookup,
|
ModuleConfigurationLookup,
|
||||||
ModuleConfigurationSettings,
|
ModuleConfigurationSettings,
|
||||||
@@ -28,10 +29,12 @@ import type { Context, MiddlewareHandler } from "hono";
|
|||||||
import { Hono } from "hono";
|
import { Hono } from "hono";
|
||||||
import { cors } from "hono/cors";
|
import { cors } from "hono/cors";
|
||||||
import { AUTH_REQUEST_MAX_BODY_BYTES, platformBodyLimit } from "./body-limits.js";
|
import { AUTH_REQUEST_MAX_BODY_BYTES, platformBodyLimit } from "./body-limits.js";
|
||||||
|
import { createAccountRoutes } from "./account-routes.js";
|
||||||
import type { DashboardHostAssets } from "./dashboard-host-assets.js";
|
import type { DashboardHostAssets } from "./dashboard-host-assets.js";
|
||||||
import { createDashboardHostRoutes } from "./dashboard-host-routes.js";
|
import { createDashboardHostRoutes } from "./dashboard-host-routes.js";
|
||||||
import { createDashboardRoutes } from "./dashboard-routes.js";
|
import { createDashboardRoutes } from "./dashboard-routes.js";
|
||||||
import { 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 type { DisplayHostAssets } from "./display-host-assets.js";
|
||||||
import { createGrantsRoutes } from "./grants-routes.js";
|
import { createGrantsRoutes } from "./grants-routes.js";
|
||||||
import { type ApiHealthOptions, createHealthRoutes } from "./health-routes.js";
|
import { type ApiHealthOptions, createHealthRoutes } from "./health-routes.js";
|
||||||
@@ -66,7 +69,7 @@ export interface CreateApiAppOptions {
|
|||||||
/** The same per-module service lookup the caller's started module runtime used for every
|
/** The same per-module service lookup the caller's started module runtime used for every
|
||||||
* module's `start`/`stop` hooks (`apps/api/src/server.ts`) — each handler receives its module's
|
* 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. */
|
* reference-identical service object, never a fresh facade or executor built per request. */
|
||||||
readonly moduleServices: ModuleRuntimeServicesLookup | ModuleRuntimeServices;
|
readonly moduleServices: ModuleRuntimeServicesLookup | AnyModuleRuntimeServices;
|
||||||
/** The platform's module UI publication policy — always the one Core builds from the exact
|
/** The platform's module UI publication policy — always the one Core builds from the exact
|
||||||
* discovered inventory this runtime started (`createModuleUiPublicationRegistry`,
|
* discovered inventory this runtime started (`createModuleUiPublicationRegistry`,
|
||||||
* `apps/api/src/server.ts`). Required rather than defaulted, for the same reason `moduleRoutes`
|
* `apps/api/src/server.ts`). Required rather than defaulted, for the same reason `moduleRoutes`
|
||||||
@@ -140,6 +143,10 @@ export interface CreateApiAppOptions {
|
|||||||
* presentation only — nothing reachable through this store influences discovery, dependency
|
* presentation only — nothing reachable through this store influences discovery, dependency
|
||||||
* ordering, module startup, or any authorization decision. */
|
* ordering, module startup, or any authorization decision. */
|
||||||
readonly dashboard: DashboardStore;
|
readonly dashboard: DashboardStore;
|
||||||
|
/** Core's persistence for a signed-in person's own account summary and preferences (ADR 0062).
|
||||||
|
* Required like `dashboard`: every installation has accounts. Reachable only through routes that
|
||||||
|
* take the account from the session, never from the request. */
|
||||||
|
readonly account: AccountStore;
|
||||||
/**
|
/**
|
||||||
* The built Dashboard's two files, read once by production startup (ADR 0044). Required rather
|
* The built Dashboard's two files, read once by production startup (ADR 0044). Required rather
|
||||||
* than optional, unlike `displayHostAssets`: every installation has a Dashboard, and a missing
|
* than optional, unlike `displayHostAssets`: every installation has a Dashboard, and a missing
|
||||||
@@ -363,10 +370,18 @@ export function createApiApp(options: CreateApiAppOptions): Hono {
|
|||||||
// own entirely. See `./health-routes.js`.
|
// own entirely. See `./health-routes.js`.
|
||||||
app.route("/", createHealthRoutes(options.health));
|
app.route("/", createHealthRoutes(options.health));
|
||||||
|
|
||||||
// The Dashboard page and its one script, at exact literal paths outside `/api/` (ADR 0044). They
|
// The Dashboard page and its one script, at fixed paths outside `/api/` (ADR 0044, ADR 0057): the
|
||||||
// grant no authority and resolve no principal; the page asks `/api/v1/principal` itself. See
|
// page at `/`, the Dashboard's platform pages, and every discovered module with a Dashboard UI at
|
||||||
// `./dashboard-host-routes.js`.
|
// `/<module-id>`. They grant no authority and resolve no principal; the page asks
|
||||||
app.route("/", createDashboardHostRoutes(options.dashboardHostAssets));
|
// `/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(
|
app.route(
|
||||||
"/",
|
"/",
|
||||||
@@ -407,6 +422,12 @@ export function createApiApp(options: CreateApiAppOptions): Hono {
|
|||||||
registry: options.authorization.registry,
|
registry: options.authorization.registry,
|
||||||
runtime,
|
runtime,
|
||||||
...(options.displayHostAssets ? { assets: options.displayHostAssets } : {}),
|
...(options.displayHostAssets ? { assets: options.displayHostAssets } : {}),
|
||||||
|
...(options.devices
|
||||||
|
? {
|
||||||
|
renewDeviceCredential: (c: Context) =>
|
||||||
|
renewDeviceSessionCookie(c, options.devices?.secureCookies ?? true),
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (options.devices) {
|
if (options.devices) {
|
||||||
@@ -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/`
|
// Mounted before module routes and, critically, under its own sibling prefix: `/api/v1/modules/`
|
||||||
// belongs entirely to installed modules, so the platform's own lifecycle resource lives beside it
|
// belongs entirely to installed modules, so the platform's own lifecycle resource lives beside it
|
||||||
// rather than inside it. See `./module-lifecycle-routes.js`.
|
// rather than inside it. See `./module-lifecycle-routes.js`.
|
||||||
|
|||||||
@@ -3,7 +3,9 @@ import { readFile } from "node:fs/promises";
|
|||||||
import { dashboardHostAssetUrls } from "@ekklesia/shell/dashboard-assets";
|
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,
|
* Exactly the model `./display-host-assets.js` uses: read once, before the HTTP listener is bound,
|
||||||
* from the exact URLs the surface package exports. Request input is never involved in this read, the
|
* from the exact URLs the surface package exports. Request input is never involved in this read, the
|
||||||
@@ -16,6 +18,8 @@ export interface DashboardHostAssets {
|
|||||||
readonly htmlEtag: string;
|
readonly htmlEtag: string;
|
||||||
readonly script: string;
|
readonly script: string;
|
||||||
readonly scriptEtag: string;
|
readonly scriptEtag: string;
|
||||||
|
readonly appearanceScript: string;
|
||||||
|
readonly appearanceScriptEtag: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A startup failure with a fixed, non-secret shape. The originating filesystem exception, package
|
/** A startup failure with a fixed, non-secret shape. The originating filesystem exception, package
|
||||||
@@ -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
|
* can never be paired with an ETag from another. Empty output is a failed shell build rather than a
|
||||||
* valid page that can never render anything.
|
* valid page that can never render anything.
|
||||||
*/
|
*/
|
||||||
export function createDashboardHostAssets(html: string, script: string): DashboardHostAssets {
|
export function createDashboardHostAssets(
|
||||||
if (html.trim().length === 0 || script.trim().length === 0) {
|
html: string,
|
||||||
|
script: string,
|
||||||
|
appearanceScript: string,
|
||||||
|
): DashboardHostAssets {
|
||||||
|
if (
|
||||||
|
html.trim().length === 0 ||
|
||||||
|
script.trim().length === 0 ||
|
||||||
|
appearanceScript.trim().length === 0
|
||||||
|
) {
|
||||||
throw new DashboardHostAssetError();
|
throw new DashboardHostAssetError();
|
||||||
}
|
}
|
||||||
return Object.freeze({
|
return Object.freeze({
|
||||||
|
appearanceScript,
|
||||||
|
appearanceScriptEtag: strongEtag(appearanceScript),
|
||||||
html,
|
html,
|
||||||
htmlEtag: strongEtag(html),
|
htmlEtag: strongEtag(html),
|
||||||
script,
|
script,
|
||||||
@@ -92,7 +106,7 @@ export function renderDashboardPage(html: string, apiOrigin: string): string {
|
|||||||
return parts.join(`<meta content="${escaped}" name="ekklesia-api-origin" />`);
|
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
|
* 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. */
|
* `/` with a 404 or a page that cannot find its API. */
|
||||||
export async function loadDashboardHostAssets(apiOrigin: string): Promise<DashboardHostAssets> {
|
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.
|
// rejected sibling read is left running after this function has already failed.
|
||||||
const html = await readFile(new URL(dashboardHostAssetUrls.html), "utf8");
|
const html = await readFile(new URL(dashboardHostAssetUrls.html), "utf8");
|
||||||
const script = await readFile(new URL(dashboardHostAssetUrls.script), "utf8");
|
const script = await readFile(new URL(dashboardHostAssetUrls.script), "utf8");
|
||||||
return createDashboardHostAssets(renderDashboardPage(html, apiOrigin), script);
|
const appearanceScript = await readFile(new URL(dashboardHostAssetUrls.appearance), "utf8");
|
||||||
|
return createDashboardHostAssets(
|
||||||
|
renderDashboardPage(html, apiOrigin),
|
||||||
|
script,
|
||||||
|
appearanceScript,
|
||||||
|
);
|
||||||
} catch {
|
} catch {
|
||||||
throw new DashboardHostAssetError();
|
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 { Hono } from "hono";
|
||||||
import { etagMatches } from "./conditional-request.js";
|
import { etagMatches } from "./conditional-request.js";
|
||||||
import { createDashboardHostAssets, type DashboardHostAssets } from "./dashboard-host-assets.js";
|
import { createDashboardHostAssets, type DashboardHostAssets } from "./dashboard-host-assets.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The Dashboard's two host-owned routes (ADR 0044). Neither can collide with anything else the API
|
* The Dashboard's host-owned routes (ADR 0044, ADR 0057). None can collide with anything else the API
|
||||||
* serves: `/` is the only page the Dashboard has — it has no client router, and every emailed
|
* serves: the page answers at `/`, at the Dashboard's own platform pages (which the shell exports, so
|
||||||
* verification or reset link lands on `/` with a query (ADR 0038) — and the script lives beside, never
|
* this server and the shell's router read one list), and at each installed module's `/<module-id>`.
|
||||||
* inside, the display host's `/assets/ekklesia-display/` namespace. Module routes all live under
|
* Every one of those first segments is either a reserved platform name or a module id Core accepted,
|
||||||
* `/api/v1/modules/<module-id>`.
|
* 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_PAGE_PATH = "/";
|
||||||
export const DASHBOARD_HOST_SCRIPT_PATH = "/assets/ekklesia-dashboard/app.js";
|
export const DASHBOARD_HOST_SCRIPT_PATH = "/assets/ekklesia-dashboard/app.js";
|
||||||
|
/** The pre-paint appearance script (ADR 0062), beside the application script. */
|
||||||
|
export const DASHBOARD_HOST_APPEARANCE_SCRIPT_PATH = "/assets/ekklesia-dashboard/appearance.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Revalidate-always with a strong `ETag`, exactly the module UI and display-host script policy. The
|
* Revalidate-always with a strong `ETag`, exactly the module UI and display-host script policy. The
|
||||||
@@ -50,8 +58,12 @@ export class DashboardHostConfigurationError extends Error {
|
|||||||
* a composition can never pair bytes with a validator computed from different bytes. */
|
* a composition can never pair bytes with a validator computed from different bytes. */
|
||||||
function snapshotAssets(input: DashboardHostAssets): DashboardHostAssets {
|
function snapshotAssets(input: DashboardHostAssets): DashboardHostAssets {
|
||||||
try {
|
try {
|
||||||
const snapshot = createDashboardHostAssets(input.html, input.script);
|
const snapshot = createDashboardHostAssets(input.html, input.script, input.appearanceScript);
|
||||||
if (snapshot.htmlEtag !== input.htmlEtag || snapshot.scriptEtag !== input.scriptEtag) {
|
if (
|
||||||
|
snapshot.htmlEtag !== input.htmlEtag ||
|
||||||
|
snapshot.scriptEtag !== input.scriptEtag ||
|
||||||
|
snapshot.appearanceScriptEtag !== input.appearanceScriptEtag
|
||||||
|
) {
|
||||||
throw new DashboardHostConfigurationError();
|
throw new DashboardHostConfigurationError();
|
||||||
}
|
}
|
||||||
return snapshot;
|
return snapshot;
|
||||||
@@ -85,30 +97,74 @@ function fixedAsset(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface DashboardHostRouteOptions {
|
||||||
|
/**
|
||||||
|
* The id of every module this process discovered that declares a Dashboard UI — enabled or not,
|
||||||
|
* since a disabled module's bookmark should open the Dashboard and say the module is unavailable
|
||||||
|
* rather than fail as an unknown address. Fixed at startup, like the rest of the module set.
|
||||||
|
*/
|
||||||
|
readonly modulePages: readonly string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Each module page must be a real module id that names no platform namespace, exactly once.
|
||||||
|
* Discovery already guarantees this; the routes refuse to compose rather than trust it. */
|
||||||
|
function validatedModulePages(modulePages: readonly string[]): readonly string[] {
|
||||||
|
const seen = new Set<string>();
|
||||||
|
for (const moduleId of modulePages) {
|
||||||
|
if (
|
||||||
|
!moduleIdSchema.safeParse(moduleId).success ||
|
||||||
|
isReservedModuleId(moduleId) ||
|
||||||
|
DASHBOARD_PLATFORM_PAGES.some((page) => page === moduleId) ||
|
||||||
|
seen.has(moduleId)
|
||||||
|
) {
|
||||||
|
throw new DashboardHostConfigurationError();
|
||||||
|
}
|
||||||
|
seen.add(moduleId);
|
||||||
|
}
|
||||||
|
return [...seen];
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Serves the built Dashboard from the same origin as `/api`, so the identity system keeps its
|
* Serves the built Dashboard from the same origin as `/api`, so the identity system keeps its
|
||||||
* default same-origin shape.
|
* default same-origin shape.
|
||||||
*
|
*
|
||||||
* Two exact literal routes over one frozen snapshot: never a static directory, never a path or file
|
* Fixed routes over one frozen snapshot: never a static directory, never a path or file name taken
|
||||||
* name taken from the request, and never the source map. Any other path — `/index.html`, `/app.js`,
|
* from the request, and never the source map. The page answers at `/`, anywhere inside a Dashboard
|
||||||
* the `.map`, a traversal spelling — has no route and falls through to the application's ordinary
|
* platform page (`/settings/displays/add` as much as `/settings`), and anywhere inside an installed
|
||||||
* sanitized `404`. Like the display host's code, these bytes grant no authority: the page renders
|
* module's page (`/signage/timelines` as much as `/signage`, so the module's own sub-pages need no
|
||||||
* the sign-in form until `/api/v1/principal` says otherwise.
|
* 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 snapshot = snapshotAssets(assets);
|
||||||
|
const modulePages = validatedModulePages(options.modulePages);
|
||||||
const routes = new Hono();
|
const routes = new Hono();
|
||||||
|
|
||||||
routes.get(DASHBOARD_HOST_PAGE_PATH, (c) =>
|
const page = (ifNoneMatch: string | undefined): Response =>
|
||||||
fixedAsset(
|
fixedAsset(
|
||||||
c.req.header("if-none-match"),
|
ifNoneMatch,
|
||||||
snapshot.html,
|
snapshot.html,
|
||||||
snapshot.htmlEtag,
|
snapshot.htmlEtag,
|
||||||
"text/html; charset=utf-8",
|
"text/html; charset=utf-8",
|
||||||
DASHBOARD_PAGE_SECURITY_HEADERS,
|
DASHBOARD_PAGE_SECURITY_HEADERS,
|
||||||
),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
|
routes.get(DASHBOARD_HOST_PAGE_PATH, (c) => page(c.req.header("if-none-match")));
|
||||||
|
for (const name of [...DASHBOARD_PLATFORM_PAGES, ...modulePages]) {
|
||||||
|
routes.get(`/${name}`, (c) => page(c.req.header("if-none-match")));
|
||||||
|
routes.get(`/${name}/*`, (c) => page(c.req.header("if-none-match")));
|
||||||
|
}
|
||||||
|
|
||||||
routes.get(DASHBOARD_HOST_SCRIPT_PATH, (c) =>
|
routes.get(DASHBOARD_HOST_SCRIPT_PATH, (c) =>
|
||||||
fixedAsset(
|
fixedAsset(
|
||||||
c.req.header("if-none-match"),
|
c.req.header("if-none-match"),
|
||||||
@@ -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;
|
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, ModuleDatabaseExecutor, ModuleId } from "@ekklesia/contracts";
|
import type { ModuleDatabaseExecutor, ModuleId } from "@ekklesia/contracts";
|
||||||
|
import { type AccountDrizzleDatabase, createAccountDrizzleView } from "@ekklesia/core/account";
|
||||||
import {
|
import {
|
||||||
type AuthorizationDrizzleDatabase,
|
type AuthorizationDrizzleDatabase,
|
||||||
createAuthorizationDrizzleView,
|
createAuthorizationDrizzleView,
|
||||||
@@ -16,10 +17,12 @@ import {
|
|||||||
createModuleDatabaseView,
|
createModuleDatabaseView,
|
||||||
createModuleLifecycleView,
|
createModuleLifecycleView,
|
||||||
type ModuleConfigurationDrizzleDatabase,
|
type ModuleConfigurationDrizzleDatabase,
|
||||||
|
type ModuleDatabase,
|
||||||
type ModuleLifecycleDrizzleDatabase,
|
type ModuleLifecycleDrizzleDatabase,
|
||||||
} from "@ekklesia/core/modules";
|
} from "@ekklesia/core/modules";
|
||||||
|
|
||||||
export interface ApiDatabaseHandle {
|
export interface ApiDatabaseHandle {
|
||||||
|
readonly account: AccountDrizzleDatabase;
|
||||||
readonly authorization: AuthorizationDrizzleDatabase;
|
readonly authorization: AuthorizationDrizzleDatabase;
|
||||||
readonly devices: DeviceDrizzleDatabase;
|
readonly devices: DeviceDrizzleDatabase;
|
||||||
readonly dashboard: DashboardDrizzleDatabase;
|
readonly dashboard: DashboardDrizzleDatabase;
|
||||||
@@ -73,6 +76,7 @@ export function createApiDatabase(
|
|||||||
const { close, pool } = createValidatedPostgresPool(databaseUrl, options);
|
const { close, pool } = createValidatedPostgresPool(databaseUrl, options);
|
||||||
|
|
||||||
return Object.freeze({
|
return Object.freeze({
|
||||||
|
account: createAccountDrizzleView(pool),
|
||||||
authorization: createAuthorizationDrizzleView(pool),
|
authorization: createAuthorizationDrizzleView(pool),
|
||||||
close,
|
close,
|
||||||
devices: createDeviceDrizzleView(pool),
|
devices: createDeviceDrizzleView(pool),
|
||||||
|
|||||||
@@ -92,7 +92,7 @@ function readString(body: Record<string, unknown>, key: string, max: number): st
|
|||||||
*/
|
*/
|
||||||
function writeDeviceCookie(
|
function writeDeviceCookie(
|
||||||
c: Context,
|
c: Context,
|
||||||
options: DeviceRoutesOptions,
|
options: Pick<DeviceRoutesOptions, "secureCookies">,
|
||||||
name: string,
|
name: string,
|
||||||
value: string,
|
value: string,
|
||||||
maxAgeSeconds: number,
|
maxAgeSeconds: number,
|
||||||
@@ -106,6 +106,27 @@ function writeDeviceCookie(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Re-issues the device cookie a request just authenticated with, with a fresh lifetime (ADR 0056).
|
||||||
|
*
|
||||||
|
* The value is unchanged — no rotation — so a renewal lost in transit costs nothing: the screen still
|
||||||
|
* holds a working credential. Only the browser's own expiry is pushed forward, matching the
|
||||||
|
* server's sliding window. Called only after the request resolved to a live, unrevoked device.
|
||||||
|
*/
|
||||||
|
export function renewDeviceSessionCookie(c: Context, secureCookies: boolean): void {
|
||||||
|
const credential = getCookie(c, DEVICE_SESSION_COOKIE);
|
||||||
|
if (credential === undefined || credential.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
writeDeviceCookie(
|
||||||
|
c,
|
||||||
|
{ secureCookies },
|
||||||
|
DEVICE_SESSION_COOKIE,
|
||||||
|
credential,
|
||||||
|
Math.floor(DEVICE_CREDENTIAL_TTL_MS / 1000),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function createDeviceRoutes(options: DeviceRoutesOptions): Hono {
|
export function createDeviceRoutes(options: DeviceRoutesOptions): Hono {
|
||||||
const routes = new HonoApp();
|
const routes = new HonoApp();
|
||||||
const now = options.now ?? ((): Date => new Date());
|
const now = options.now ?? ((): Date => new Date());
|
||||||
|
|||||||
@@ -83,6 +83,9 @@ export interface ModuleDisplayRoutesOptions extends AuthorizationMiddlewareOptio
|
|||||||
/** Omitted only when the registry contains no display entrypoints. Production reads the exact
|
/** Omitted only when the registry contains no display entrypoints. Production reads the exact
|
||||||
* surface-owned files before binding and supplies their frozen contents here. */
|
* surface-owned files before binding and supplies their frozen contents here. */
|
||||||
readonly assets?: DisplayHostAssets;
|
readonly assets?: DisplayHostAssets;
|
||||||
|
/** Re-issues a paired device's cookie with a fresh lifetime (ADR 0056). Composed only when the
|
||||||
|
* deployment has device support; called only for a request that resolved to a device. */
|
||||||
|
readonly renewDeviceCredential?: (c: Context) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A composition error, detected before any display route is registered. Carries no module id,
|
/** A composition error, detected before any display route is registered. Carries no module id,
|
||||||
@@ -199,6 +202,11 @@ function mountDisplay(
|
|||||||
if (!viewer) {
|
if (!viewer) {
|
||||||
return displayFailure(c);
|
return displayFailure(c);
|
||||||
}
|
}
|
||||||
|
// Every screen asks for this on start, on reconnect, and periodically while running, so it
|
||||||
|
// is where an in-use credential's browser expiry is pushed forward.
|
||||||
|
if (principal.kind === "device") {
|
||||||
|
options.renewDeviceCredential?.(c);
|
||||||
|
}
|
||||||
return c.json(await describeDisplay(options, principal, display, viewer), 200);
|
return c.json(await describeDisplay(options, principal, display, viewer), 200);
|
||||||
} catch {
|
} catch {
|
||||||
return displayFailure(c);
|
return displayFailure(c);
|
||||||
|
|||||||
@@ -1,15 +1,15 @@
|
|||||||
import {
|
import {
|
||||||
EMPTY_MODULE_CONFIGURATION,
|
EMPTY_MODULE_CONFIGURATION,
|
||||||
EMPTY_MODULE_STORAGE,
|
EMPTY_MODULE_STORAGE,
|
||||||
type ModuleDatabase,
|
|
||||||
type ModuleDatabaseExecutor,
|
type ModuleDatabaseExecutor,
|
||||||
type ModuleId,
|
type ModuleId,
|
||||||
type ModuleRouteHandlerContext,
|
type ModuleRouteHandlerContext,
|
||||||
type ModuleRuntimeServices,
|
|
||||||
type RegisteredModuleHttpRoute,
|
type RegisteredModuleHttpRoute,
|
||||||
} from "@ekklesia/contracts";
|
} from "@ekklesia/contracts";
|
||||||
import type {
|
import type {
|
||||||
|
AnyModuleRuntimeServices,
|
||||||
ModuleConfigurationLookup,
|
ModuleConfigurationLookup,
|
||||||
|
ModuleDatabase,
|
||||||
ModuleRealtimeRegistry,
|
ModuleRealtimeRegistry,
|
||||||
ModuleRuntimeServicesLookup,
|
ModuleRuntimeServicesLookup,
|
||||||
ModuleStorageLookup,
|
ModuleStorageLookup,
|
||||||
@@ -40,7 +40,7 @@ export interface ModuleHttpRoutesOptions extends AuthorizationMiddlewareOptions
|
|||||||
* received for this API runtime — never a fresh one built per request, and never touched for a
|
* received for this API runtime — never a fresh one built per request, and never touched for a
|
||||||
* denied, trusted-origin-rejected, or `OPTIONS` request, since a handler is only ever invoked
|
* denied, trusted-origin-rejected, or `OPTIONS` request, since a handler is only ever invoked
|
||||||
* after authorization succeeds. */
|
* after authorization succeeds. */
|
||||||
readonly services: ModuleRuntimeServicesLookup | ModuleRuntimeServices;
|
readonly services: ModuleRuntimeServicesLookup | AnyModuleRuntimeServices;
|
||||||
/**
|
/**
|
||||||
* Per-module frozen configuration snapshots — the exact map the module runtime started from
|
* Per-module frozen configuration snapshots — the exact map the module runtime started from
|
||||||
* (ADR 0029), so a handler and that module's `start`/`stop` hooks receive the identical object
|
* (ADR 0029), so a handler and that module's `start`/`stop` hooks receive the identical object
|
||||||
|
|||||||
@@ -1,15 +1,12 @@
|
|||||||
import type { Socket } from "node:net";
|
import type { Socket } from "node:net";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { pathToFileURL } from "node:url";
|
import { pathToFileURL } from "node:url";
|
||||||
import {
|
import { EMPTY_MODULE_SECRET_SOURCE, type ModuleSecretSource } from "@ekklesia/contracts";
|
||||||
type AnyModuleRuntimeServices,
|
|
||||||
EMPTY_MODULE_SECRET_SOURCE,
|
|
||||||
type ModuleSecretSource,
|
|
||||||
} from "@ekklesia/contracts";
|
|
||||||
import {
|
import {
|
||||||
createAuthorizationEvaluator,
|
createAuthorizationEvaluator,
|
||||||
createAuthorizationStore,
|
createAuthorizationStore,
|
||||||
} from "@ekklesia/core/authorization";
|
} from "@ekklesia/core/authorization";
|
||||||
|
import { createAccountStore } from "@ekklesia/core/account";
|
||||||
import { createDashboardStore } from "@ekklesia/core/dashboard";
|
import { createDashboardStore } from "@ekklesia/core/dashboard";
|
||||||
import { createDeviceStore } from "@ekklesia/core/devices";
|
import { createDeviceStore } from "@ekklesia/core/devices";
|
||||||
import { type HostEnvironment, loadHostConfiguration } from "@ekklesia/core/host";
|
import { type HostEnvironment, loadHostConfiguration } from "@ekklesia/core/host";
|
||||||
@@ -21,6 +18,7 @@ import {
|
|||||||
} from "@ekklesia/core/identity";
|
} from "@ekklesia/core/identity";
|
||||||
import { createSmtpMailProvider, type MailProvider } from "@ekklesia/core/mail";
|
import { createSmtpMailProvider, type MailProvider } from "@ekklesia/core/mail";
|
||||||
import {
|
import {
|
||||||
|
type AnyModuleRuntimeServices,
|
||||||
createModuleConfigurationStore,
|
createModuleConfigurationStore,
|
||||||
createModuleLifecycleStore,
|
createModuleLifecycleStore,
|
||||||
createModuleUiPublicationRegistry,
|
createModuleUiPublicationRegistry,
|
||||||
@@ -642,6 +640,8 @@ export async function startApi(
|
|||||||
},
|
},
|
||||||
// A typed view over the same single pool as identity, authorization, and the module facade.
|
// A typed view over the same single pool as identity, authorization, and the module facade.
|
||||||
dashboard: createDashboardStore(databaseHandle.dashboard),
|
dashboard: createDashboardStore(databaseHandle.dashboard),
|
||||||
|
// The same pool again: a person's own account summary and preferences (ADR 0062).
|
||||||
|
account: createAccountStore(databaseHandle.account),
|
||||||
dashboardHostAssets,
|
dashboardHostAssets,
|
||||||
// The app is only composed after `startDiscoveredModuleRuntime` resolved, so module startup
|
// The app is only composed after `startDiscoveredModuleRuntime` resolved, so module startup
|
||||||
// has completed whenever it can answer; it stops counting as complete once shutdown begins.
|
// has completed whenever it can answer; it stops counting as complete once shutdown begins.
|
||||||
|
|||||||
@@ -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 }> {
|
async function createDisposableDatabase(label: string): Promise<{ name: string; url: string }> {
|
||||||
const name = makeDatabaseName(label);
|
const name = makeDatabaseName(label);
|
||||||
await createDatabase(name);
|
await createDatabase(name);
|
||||||
@@ -221,9 +244,9 @@ async function applyCoreMigrations(url: string): Promise<void> {
|
|||||||
const aggregate = await discoverAllMigrations({
|
const aggregate = await discoverAllMigrations({
|
||||||
coreMigrationsDirectory,
|
coreMigrationsDirectory,
|
||||||
moduleDiscovery: {
|
moduleDiscovery: {
|
||||||
moduleSdkVersion: "0.1.0",
|
moduleSdkVersion: "0.2.0",
|
||||||
modulesDirectory: emptyModulesDirectory,
|
modulesDirectory: emptyModulesDirectory,
|
||||||
platformVersion: "0.1.0",
|
platformVersion: "0.2.0",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
// Phase 4A's and Phase 4C pass 1's committed migrations must not have drifted while building
|
// Phase 4A's and Phase 4C pass 1's committed migrations must not have drifted while building
|
||||||
@@ -237,6 +260,7 @@ async function applyCoreMigrations(url: string): Promise<void> {
|
|||||||
"core:0005_module_configuration",
|
"core:0005_module_configuration",
|
||||||
"core:0006_device_identity",
|
"core:0006_device_identity",
|
||||||
"core:0007_verified_bootstrap_owners",
|
"core:0007_verified_bootstrap_owners",
|
||||||
|
"core:0008_user_preferences",
|
||||||
]);
|
]);
|
||||||
expect(aggregate.migrations[0]?.checksum).toBe(
|
expect(aggregate.migrations[0]?.checksum).toBe(
|
||||||
"f8f2086ef06f7a6943e8ffc4350bb010b37aed04c0a72030d2b153c986b1325f",
|
"f8f2086ef06f7a6943e8ffc4350bb010b37aed04c0a72030d2b153c986b1325f",
|
||||||
@@ -339,7 +363,7 @@ async function createGatedRouteModule(
|
|||||||
writeFile(
|
writeFile(
|
||||||
path.join(moduleDirectory, "ekklesia.module.json"),
|
path.join(moduleDirectory, "ekklesia.module.json"),
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
compatibility: { moduleSdk: "^0.1.0", platform: "^0.1.0" },
|
compatibility: { moduleSdk: "^0.2.0", platform: "^0.2.0" },
|
||||||
entrypoints: { server: "./dist/server/index.js" },
|
entrypoints: { server: "./dist/server/index.js" },
|
||||||
id,
|
id,
|
||||||
manifestVersion: 8,
|
manifestVersion: 8,
|
||||||
@@ -390,7 +414,7 @@ async function createFixtureModule(
|
|||||||
id,
|
id,
|
||||||
name: `${id} module`,
|
name: `${id} module`,
|
||||||
version: "0.1.0",
|
version: "0.1.0",
|
||||||
compatibility: { platform: "^0.1.0", moduleSdk: "^0.1.0" },
|
compatibility: { platform: "^0.2.0", moduleSdk: "^0.2.0" },
|
||||||
permissions: [],
|
permissions: [],
|
||||||
entrypoints: { server: "./dist/server/index.js" },
|
entrypoints: { server: "./dist/server/index.js" },
|
||||||
...manifestOverrides,
|
...manifestOverrides,
|
||||||
@@ -521,7 +545,7 @@ async function createUiModule(
|
|||||||
writeFile(
|
writeFile(
|
||||||
path.join(moduleDirectory, "ekklesia.module.json"),
|
path.join(moduleDirectory, "ekklesia.module.json"),
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
compatibility: { moduleSdk: "^0.1.0", platform: "^0.1.0" },
|
compatibility: { moduleSdk: "^0.2.0", platform: "^0.2.0" },
|
||||||
entrypoints: { server: "./dist/server/index.js", ui: "./dist/ui/index.js" },
|
entrypoints: { server: "./dist/server/index.js", ui: "./dist/ui/index.js" },
|
||||||
id,
|
id,
|
||||||
manifestVersion: 8,
|
manifestVersion: 8,
|
||||||
@@ -1349,7 +1373,7 @@ describe.sequential("live API — signup, sign-in, principal, sign-out", () => {
|
|||||||
await running.shutdown();
|
await running.shutdown();
|
||||||
|
|
||||||
await expect(fetch(origin, { signal: AbortSignal.timeout(1_000) })).rejects.toBeTruthy();
|
await expect(fetch(origin, { signal: AbortSignal.timeout(1_000) })).rejects.toBeTruthy();
|
||||||
const remainingConnections = await activeConnectionCount(database.name);
|
const remainingConnections = await connectionsLeftAfterShutdown(database.name);
|
||||||
expect(remainingConnections).toBe(0);
|
expect(remainingConnections).toBe(0);
|
||||||
|
|
||||||
await dropDatabase(database.name);
|
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", () => {
|
describe.sequential("live API — startup and migration guarantees", () => {
|
||||||
it("refuses to start against an unmigrated database, and creates no tables or migration history", async () => {
|
it("refuses to start against an unmigrated database, and creates no tables or migration history", async () => {
|
||||||
const database = await createDisposableDatabase("unmigrated");
|
const database = await createDisposableDatabase("unmigrated");
|
||||||
@@ -2230,7 +2410,7 @@ describe.sequential("live API — platform owner and delegated grant management"
|
|||||||
|
|
||||||
it("shuts down cleanly, closing the one shared pool", async () => {
|
it("shuts down cleanly, closing the one shared pool", async () => {
|
||||||
await running.shutdown();
|
await running.shutdown();
|
||||||
const remainingConnections = await activeConnectionCount(database.name);
|
const remainingConnections = await connectionsLeftAfterShutdown(database.name);
|
||||||
expect(remainingConnections).toBe(0);
|
expect(remainingConnections).toBe(0);
|
||||||
await dropDatabase(database.name);
|
await dropDatabase(database.name);
|
||||||
});
|
});
|
||||||
@@ -2592,9 +2772,9 @@ describe.sequential("live API — installed module permission end-to-end", () =>
|
|||||||
const report = await diagnoseEkklesia({
|
const report = await diagnoseEkklesia({
|
||||||
coreMigrationsDirectory,
|
coreMigrationsDirectory,
|
||||||
moduleDiscovery: {
|
moduleDiscovery: {
|
||||||
moduleSdkVersion: "0.1.0",
|
moduleSdkVersion: "0.2.0",
|
||||||
modulesDirectory,
|
modulesDirectory,
|
||||||
platformVersion: "0.1.0",
|
platformVersion: "0.2.0",
|
||||||
},
|
},
|
||||||
runtimeRange: ">=0.0.0",
|
runtimeRange: ">=0.0.0",
|
||||||
runtimeVersion: process.versions.node,
|
runtimeVersion: process.versions.node,
|
||||||
@@ -2607,10 +2787,11 @@ describe.sequential("live API — installed module permission end-to-end", () =>
|
|||||||
expect(moduleCheck).toMatchObject({ status: "pass" });
|
expect(moduleCheck).toMatchObject({ status: "pass" });
|
||||||
expect(moduleCheck?.details).toEqual(["calendar 0.1.0"]);
|
expect(moduleCheck?.details).toEqual(["calendar 0.1.0"]);
|
||||||
expect(permissionsCheck).toMatchObject({
|
expect(permissionsCheck).toMatchObject({
|
||||||
// Core's own 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.dashboard.navigation.manage`, `core.modules.lifecycle.manage`,
|
||||||
// `core.modules.configuration.manage`, `core.devices.manage`) plus the fixture module's one.
|
// `core.modules.configuration.manage`, `core.devices.manage`, `core.account.self.manage`)
|
||||||
details: ["7 permissions registered"],
|
// plus the fixture module's one.
|
||||||
|
details: ["8 permissions registered"],
|
||||||
status: "pass",
|
status: "pass",
|
||||||
});
|
});
|
||||||
// Doctor and permission composition read only the manifest — the fixture's register/start
|
// Doctor and permission composition read only the manifest — the fixture's register/start
|
||||||
@@ -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 () => {
|
it("shuts down cleanly, then restarting with the module absent rejects new grants for its former permission, returns NOT_FOUND for its former route, while the historical grant remains listable", async () => {
|
||||||
await running.shutdown();
|
await running.shutdown();
|
||||||
const remainingConnections = await activeConnectionCount(database.name);
|
const remainingConnections = await connectionsLeftAfterShutdown(database.name);
|
||||||
expect(remainingConnections).toBe(0);
|
expect(remainingConnections).toBe(0);
|
||||||
// Exactly one stop, matching the exactly-one register/start already asserted above.
|
// Exactly one stop, matching the exactly-one register/start already asserted above.
|
||||||
expect(recorder.events.filter((event) => event === "calendar:stop")).toHaveLength(1);
|
expect(recorder.events.filter((event) => event === "calendar:stop")).toHaveLength(1);
|
||||||
@@ -3906,9 +4087,9 @@ describe.sequential("live API — module lifecycle governs what actually runs",
|
|||||||
const aggregate = await discoverAllMigrations({
|
const aggregate = await discoverAllMigrations({
|
||||||
coreMigrationsDirectory,
|
coreMigrationsDirectory,
|
||||||
moduleDiscovery: {
|
moduleDiscovery: {
|
||||||
moduleSdkVersion: "0.1.0",
|
moduleSdkVersion: "0.2.0",
|
||||||
modulesDirectory,
|
modulesDirectory,
|
||||||
platformVersion: "0.1.0",
|
platformVersion: "0.2.0",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
expect(aggregate.discoveredModules.map((module) => module.manifest.id)).toEqual(["calendar"]);
|
expect(aggregate.discoveredModules.map((module) => module.manifest.id)).toEqual(["calendar"]);
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
import {
|
import {
|
||||||
ANONYMOUS_PRINCIPAL,
|
ANONYMOUS_PRINCIPAL,
|
||||||
createAuthenticatedPrincipal,
|
createAuthenticatedPrincipal,
|
||||||
type ModuleDatabase,
|
|
||||||
type ModuleRuntimeServices,
|
|
||||||
moduleIdSchema,
|
moduleIdSchema,
|
||||||
parseEkklesiaUserId,
|
parseEkklesiaUserId,
|
||||||
parsePermissionId,
|
parsePermissionId,
|
||||||
@@ -20,6 +18,8 @@ import { IdentityRuntimeError } from "@ekklesia/core/identity";
|
|||||||
import {
|
import {
|
||||||
createModuleUiPublicationRegistry,
|
createModuleUiPublicationRegistry,
|
||||||
EMPTY_MODULE_REALTIME_REGISTRY,
|
EMPTY_MODULE_REALTIME_REGISTRY,
|
||||||
|
type LegacyModuleRuntimeServices,
|
||||||
|
type ModuleDatabase,
|
||||||
type ModuleUiPublicationRegistry,
|
type ModuleUiPublicationRegistry,
|
||||||
StagedModuleRealtimeRegistry,
|
StagedModuleRealtimeRegistry,
|
||||||
} from "@ekklesia/core/modules";
|
} from "@ekklesia/core/modules";
|
||||||
@@ -30,6 +30,7 @@ import {
|
|||||||
createApiApp as createApiAppWithModules,
|
createApiApp as createApiAppWithModules,
|
||||||
} from "../src/app.js";
|
} from "../src/app.js";
|
||||||
import { AUTH_REQUEST_MAX_BODY_BYTES } from "../src/body-limits.js";
|
import { AUTH_REQUEST_MAX_BODY_BYTES } from "../src/body-limits.js";
|
||||||
|
import { createInMemoryAccountStore } from "./support/in-memory-account-store.js";
|
||||||
import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
|
import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
|
||||||
import { testDashboardHostAssets } from "./support/dashboard-host-assets.js";
|
import { testDashboardHostAssets } from "./support/dashboard-host-assets.js";
|
||||||
import { readyHealth } from "./support/ready-health.js";
|
import { readyHealth } from "./support/ready-health.js";
|
||||||
@@ -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. */
|
* never real database behavior, so no PostgreSQL is required. */
|
||||||
function fakeModuleServices(): ModuleRuntimeServices {
|
function fakeModuleServices(): LegacyModuleRuntimeServices {
|
||||||
const database = Object.freeze({
|
const database = Object.freeze({
|
||||||
delete: vi.fn(),
|
delete: vi.fn(),
|
||||||
execute: vi.fn(),
|
execute: vi.fn(),
|
||||||
@@ -111,6 +112,7 @@ function createAuthWiringApp(runtime: EkklesiaIdentityRuntime) {
|
|||||||
moduleLifecycle: emptyModuleLifecycle(),
|
moduleLifecycle: emptyModuleLifecycle(),
|
||||||
moduleConfiguration: emptyModuleConfiguration(),
|
moduleConfiguration: emptyModuleConfiguration(),
|
||||||
moduleStorage: emptyModuleStorage(),
|
moduleStorage: emptyModuleStorage(),
|
||||||
|
account: createInMemoryAccountStore(),
|
||||||
dashboard: createInMemoryDashboardStore(),
|
dashboard: createInMemoryDashboardStore(),
|
||||||
dashboardHostAssets: testDashboardHostAssets(),
|
dashboardHostAssets: testDashboardHostAssets(),
|
||||||
health: readyHealth(),
|
health: readyHealth(),
|
||||||
@@ -130,6 +132,7 @@ describe("Ekklesia API Hono application", () => {
|
|||||||
moduleLifecycle: emptyModuleLifecycle(),
|
moduleLifecycle: emptyModuleLifecycle(),
|
||||||
moduleConfiguration: emptyModuleConfiguration(),
|
moduleConfiguration: emptyModuleConfiguration(),
|
||||||
moduleStorage: emptyModuleStorage(),
|
moduleStorage: emptyModuleStorage(),
|
||||||
|
account: createInMemoryAccountStore(),
|
||||||
dashboard: createInMemoryDashboardStore(),
|
dashboard: createInMemoryDashboardStore(),
|
||||||
dashboardHostAssets: testDashboardHostAssets(),
|
dashboardHostAssets: testDashboardHostAssets(),
|
||||||
health: readyHealth(),
|
health: readyHealth(),
|
||||||
@@ -366,6 +369,7 @@ describe("Ekklesia API Hono application", () => {
|
|||||||
moduleLifecycle: emptyModuleLifecycle(),
|
moduleLifecycle: emptyModuleLifecycle(),
|
||||||
moduleConfiguration: emptyModuleConfiguration(),
|
moduleConfiguration: emptyModuleConfiguration(),
|
||||||
moduleStorage: emptyModuleStorage(),
|
moduleStorage: emptyModuleStorage(),
|
||||||
|
account: createInMemoryAccountStore(),
|
||||||
dashboard: createInMemoryDashboardStore(),
|
dashboard: createInMemoryDashboardStore(),
|
||||||
dashboardHostAssets: testDashboardHostAssets(),
|
dashboardHostAssets: testDashboardHostAssets(),
|
||||||
health: readyHealth(),
|
health: readyHealth(),
|
||||||
@@ -391,6 +395,7 @@ describe("Ekklesia API Hono application", () => {
|
|||||||
moduleLifecycle: emptyModuleLifecycle(),
|
moduleLifecycle: emptyModuleLifecycle(),
|
||||||
moduleConfiguration: emptyModuleConfiguration(),
|
moduleConfiguration: emptyModuleConfiguration(),
|
||||||
moduleStorage: emptyModuleStorage(),
|
moduleStorage: emptyModuleStorage(),
|
||||||
|
account: createInMemoryAccountStore(),
|
||||||
dashboard: createInMemoryDashboardStore(),
|
dashboard: createInMemoryDashboardStore(),
|
||||||
dashboardHostAssets: testDashboardHostAssets(),
|
dashboardHostAssets: testDashboardHostAssets(),
|
||||||
health: readyHealth(),
|
health: readyHealth(),
|
||||||
@@ -418,6 +423,7 @@ describe("Ekklesia API Hono application", () => {
|
|||||||
moduleLifecycle: emptyModuleLifecycle(),
|
moduleLifecycle: emptyModuleLifecycle(),
|
||||||
moduleConfiguration: emptyModuleConfiguration(),
|
moduleConfiguration: emptyModuleConfiguration(),
|
||||||
moduleStorage: emptyModuleStorage(),
|
moduleStorage: emptyModuleStorage(),
|
||||||
|
account: createInMemoryAccountStore(),
|
||||||
dashboard: createInMemoryDashboardStore(),
|
dashboard: createInMemoryDashboardStore(),
|
||||||
dashboardHostAssets: testDashboardHostAssets(),
|
dashboardHostAssets: testDashboardHostAssets(),
|
||||||
health: readyHealth(),
|
health: readyHealth(),
|
||||||
@@ -447,6 +453,7 @@ describe("Ekklesia API Hono application", () => {
|
|||||||
moduleLifecycle: emptyModuleLifecycle(),
|
moduleLifecycle: emptyModuleLifecycle(),
|
||||||
moduleConfiguration: emptyModuleConfiguration(),
|
moduleConfiguration: emptyModuleConfiguration(),
|
||||||
moduleStorage: emptyModuleStorage(),
|
moduleStorage: emptyModuleStorage(),
|
||||||
|
account: createInMemoryAccountStore(),
|
||||||
dashboard: createInMemoryDashboardStore(),
|
dashboard: createInMemoryDashboardStore(),
|
||||||
dashboardHostAssets: testDashboardHostAssets(),
|
dashboardHostAssets: testDashboardHostAssets(),
|
||||||
health: readyHealth(),
|
health: readyHealth(),
|
||||||
@@ -476,6 +483,7 @@ describe("Ekklesia API Hono application", () => {
|
|||||||
moduleLifecycle: emptyModuleLifecycle(),
|
moduleLifecycle: emptyModuleLifecycle(),
|
||||||
moduleConfiguration: emptyModuleConfiguration(),
|
moduleConfiguration: emptyModuleConfiguration(),
|
||||||
moduleStorage: emptyModuleStorage(),
|
moduleStorage: emptyModuleStorage(),
|
||||||
|
account: createInMemoryAccountStore(),
|
||||||
dashboard: createInMemoryDashboardStore(),
|
dashboard: createInMemoryDashboardStore(),
|
||||||
dashboardHostAssets: testDashboardHostAssets(),
|
dashboardHostAssets: testDashboardHostAssets(),
|
||||||
health: readyHealth(),
|
health: readyHealth(),
|
||||||
@@ -502,6 +510,7 @@ describe("Ekklesia API Hono application", () => {
|
|||||||
moduleLifecycle: emptyModuleLifecycle(),
|
moduleLifecycle: emptyModuleLifecycle(),
|
||||||
moduleConfiguration: emptyModuleConfiguration(),
|
moduleConfiguration: emptyModuleConfiguration(),
|
||||||
moduleStorage: emptyModuleStorage(),
|
moduleStorage: emptyModuleStorage(),
|
||||||
|
account: createInMemoryAccountStore(),
|
||||||
dashboard: createInMemoryDashboardStore(),
|
dashboard: createInMemoryDashboardStore(),
|
||||||
dashboardHostAssets: testDashboardHostAssets(),
|
dashboardHostAssets: testDashboardHostAssets(),
|
||||||
health: readyHealth(),
|
health: readyHealth(),
|
||||||
@@ -530,6 +539,7 @@ describe("Ekklesia API Hono application", () => {
|
|||||||
moduleLifecycle: emptyModuleLifecycle(),
|
moduleLifecycle: emptyModuleLifecycle(),
|
||||||
moduleConfiguration: emptyModuleConfiguration(),
|
moduleConfiguration: emptyModuleConfiguration(),
|
||||||
moduleStorage: emptyModuleStorage(),
|
moduleStorage: emptyModuleStorage(),
|
||||||
|
account: createInMemoryAccountStore(),
|
||||||
dashboard: createInMemoryDashboardStore(),
|
dashboard: createInMemoryDashboardStore(),
|
||||||
dashboardHostAssets: testDashboardHostAssets(),
|
dashboardHostAssets: testDashboardHostAssets(),
|
||||||
health: readyHealth(),
|
health: readyHealth(),
|
||||||
@@ -555,6 +565,7 @@ describe("Ekklesia API Hono application", () => {
|
|||||||
moduleLifecycle: emptyModuleLifecycle(),
|
moduleLifecycle: emptyModuleLifecycle(),
|
||||||
moduleConfiguration: emptyModuleConfiguration(),
|
moduleConfiguration: emptyModuleConfiguration(),
|
||||||
moduleStorage: emptyModuleStorage(),
|
moduleStorage: emptyModuleStorage(),
|
||||||
|
account: createInMemoryAccountStore(),
|
||||||
dashboard: createInMemoryDashboardStore(),
|
dashboard: createInMemoryDashboardStore(),
|
||||||
dashboardHostAssets: testDashboardHostAssets(),
|
dashboardHostAssets: testDashboardHostAssets(),
|
||||||
health: readyHealth(),
|
health: readyHealth(),
|
||||||
@@ -582,6 +593,7 @@ describe("Ekklesia API Hono application", () => {
|
|||||||
moduleLifecycle: emptyModuleLifecycle(),
|
moduleLifecycle: emptyModuleLifecycle(),
|
||||||
moduleConfiguration: emptyModuleConfiguration(),
|
moduleConfiguration: emptyModuleConfiguration(),
|
||||||
moduleStorage: emptyModuleStorage(),
|
moduleStorage: emptyModuleStorage(),
|
||||||
|
account: createInMemoryAccountStore(),
|
||||||
dashboard: createInMemoryDashboardStore(),
|
dashboard: createInMemoryDashboardStore(),
|
||||||
dashboardHostAssets: testDashboardHostAssets(),
|
dashboardHostAssets: testDashboardHostAssets(),
|
||||||
health: readyHealth(),
|
health: readyHealth(),
|
||||||
@@ -616,6 +628,7 @@ describe("Ekklesia API Hono application", () => {
|
|||||||
moduleLifecycle: emptyModuleLifecycle(),
|
moduleLifecycle: emptyModuleLifecycle(),
|
||||||
moduleConfiguration: emptyModuleConfiguration(),
|
moduleConfiguration: emptyModuleConfiguration(),
|
||||||
moduleStorage: emptyModuleStorage(),
|
moduleStorage: emptyModuleStorage(),
|
||||||
|
account: createInMemoryAccountStore(),
|
||||||
dashboard: createInMemoryDashboardStore(),
|
dashboard: createInMemoryDashboardStore(),
|
||||||
dashboardHostAssets: testDashboardHostAssets(),
|
dashboardHostAssets: testDashboardHostAssets(),
|
||||||
health: readyHealth(),
|
health: readyHealth(),
|
||||||
@@ -651,6 +664,7 @@ describe("Ekklesia API Hono application", () => {
|
|||||||
moduleLifecycle: emptyModuleLifecycle(),
|
moduleLifecycle: emptyModuleLifecycle(),
|
||||||
moduleConfiguration: emptyModuleConfiguration(),
|
moduleConfiguration: emptyModuleConfiguration(),
|
||||||
moduleStorage: emptyModuleStorage(),
|
moduleStorage: emptyModuleStorage(),
|
||||||
|
account: createInMemoryAccountStore(),
|
||||||
dashboard: createInMemoryDashboardStore(),
|
dashboard: createInMemoryDashboardStore(),
|
||||||
dashboardHostAssets: testDashboardHostAssets(),
|
dashboardHostAssets: testDashboardHostAssets(),
|
||||||
health: readyHealth(),
|
health: readyHealth(),
|
||||||
@@ -691,6 +705,7 @@ describe("Ekklesia API Hono application", () => {
|
|||||||
moduleLifecycle: emptyModuleLifecycle(),
|
moduleLifecycle: emptyModuleLifecycle(),
|
||||||
moduleConfiguration: emptyModuleConfiguration(),
|
moduleConfiguration: emptyModuleConfiguration(),
|
||||||
moduleStorage: emptyModuleStorage(),
|
moduleStorage: emptyModuleStorage(),
|
||||||
|
account: createInMemoryAccountStore(),
|
||||||
dashboard: createInMemoryDashboardStore(),
|
dashboard: createInMemoryDashboardStore(),
|
||||||
dashboardHostAssets: testDashboardHostAssets(),
|
dashboardHostAssets: testDashboardHostAssets(),
|
||||||
health: readyHealth(),
|
health: readyHealth(),
|
||||||
@@ -713,6 +728,7 @@ describe("Ekklesia API Hono application", () => {
|
|||||||
moduleLifecycle: emptyModuleLifecycle(),
|
moduleLifecycle: emptyModuleLifecycle(),
|
||||||
moduleConfiguration: emptyModuleConfiguration(),
|
moduleConfiguration: emptyModuleConfiguration(),
|
||||||
moduleStorage: emptyModuleStorage(),
|
moduleStorage: emptyModuleStorage(),
|
||||||
|
account: createInMemoryAccountStore(),
|
||||||
dashboard: createInMemoryDashboardStore(),
|
dashboard: createInMemoryDashboardStore(),
|
||||||
dashboardHostAssets: testDashboardHostAssets(),
|
dashboardHostAssets: testDashboardHostAssets(),
|
||||||
health: readyHealth(),
|
health: readyHealth(),
|
||||||
@@ -737,6 +753,7 @@ describe("Ekklesia API Hono application", () => {
|
|||||||
moduleLifecycle: emptyModuleLifecycle(),
|
moduleLifecycle: emptyModuleLifecycle(),
|
||||||
moduleConfiguration: emptyModuleConfiguration(),
|
moduleConfiguration: emptyModuleConfiguration(),
|
||||||
moduleStorage: emptyModuleStorage(),
|
moduleStorage: emptyModuleStorage(),
|
||||||
|
account: createInMemoryAccountStore(),
|
||||||
dashboard: createInMemoryDashboardStore(),
|
dashboard: createInMemoryDashboardStore(),
|
||||||
dashboardHostAssets: testDashboardHostAssets(),
|
dashboardHostAssets: testDashboardHostAssets(),
|
||||||
health: readyHealth(),
|
health: readyHealth(),
|
||||||
@@ -760,6 +777,7 @@ describe("Ekklesia API Hono application", () => {
|
|||||||
moduleLifecycle: emptyModuleLifecycle(),
|
moduleLifecycle: emptyModuleLifecycle(),
|
||||||
moduleConfiguration: emptyModuleConfiguration(),
|
moduleConfiguration: emptyModuleConfiguration(),
|
||||||
moduleStorage: emptyModuleStorage(),
|
moduleStorage: emptyModuleStorage(),
|
||||||
|
account: createInMemoryAccountStore(),
|
||||||
dashboard: createInMemoryDashboardStore(),
|
dashboard: createInMemoryDashboardStore(),
|
||||||
dashboardHostAssets: testDashboardHostAssets(),
|
dashboardHostAssets: testDashboardHostAssets(),
|
||||||
health: readyHealth(),
|
health: readyHealth(),
|
||||||
@@ -781,6 +799,7 @@ describe("Ekklesia API Hono application", () => {
|
|||||||
moduleLifecycle: emptyModuleLifecycle(),
|
moduleLifecycle: emptyModuleLifecycle(),
|
||||||
moduleConfiguration: emptyModuleConfiguration(),
|
moduleConfiguration: emptyModuleConfiguration(),
|
||||||
moduleStorage: emptyModuleStorage(),
|
moduleStorage: emptyModuleStorage(),
|
||||||
|
account: createInMemoryAccountStore(),
|
||||||
dashboard: createInMemoryDashboardStore(),
|
dashboard: createInMemoryDashboardStore(),
|
||||||
dashboardHostAssets: testDashboardHostAssets(),
|
dashboardHostAssets: testDashboardHostAssets(),
|
||||||
health: readyHealth(),
|
health: readyHealth(),
|
||||||
@@ -809,6 +828,7 @@ describe("Ekklesia API Hono application", () => {
|
|||||||
moduleLifecycle: emptyModuleLifecycle(),
|
moduleLifecycle: emptyModuleLifecycle(),
|
||||||
moduleConfiguration: emptyModuleConfiguration(),
|
moduleConfiguration: emptyModuleConfiguration(),
|
||||||
moduleStorage: emptyModuleStorage(),
|
moduleStorage: emptyModuleStorage(),
|
||||||
|
account: createInMemoryAccountStore(),
|
||||||
dashboard: createInMemoryDashboardStore(),
|
dashboard: createInMemoryDashboardStore(),
|
||||||
dashboardHostAssets: testDashboardHostAssets(),
|
dashboardHostAssets: testDashboardHostAssets(),
|
||||||
health: readyHealth(),
|
health: readyHealth(),
|
||||||
@@ -840,6 +860,7 @@ describe("Ekklesia API Hono application", () => {
|
|||||||
moduleLifecycle: emptyModuleLifecycle(),
|
moduleLifecycle: emptyModuleLifecycle(),
|
||||||
moduleConfiguration: emptyModuleConfiguration(),
|
moduleConfiguration: emptyModuleConfiguration(),
|
||||||
moduleStorage: emptyModuleStorage(),
|
moduleStorage: emptyModuleStorage(),
|
||||||
|
account: createInMemoryAccountStore(),
|
||||||
dashboard: createInMemoryDashboardStore(),
|
dashboard: createInMemoryDashboardStore(),
|
||||||
dashboardHostAssets: testDashboardHostAssets(),
|
dashboardHostAssets: testDashboardHostAssets(),
|
||||||
health: readyHealth(),
|
health: readyHealth(),
|
||||||
@@ -874,6 +895,7 @@ describe("Ekklesia API Hono application", () => {
|
|||||||
moduleLifecycle: emptyModuleLifecycle(),
|
moduleLifecycle: emptyModuleLifecycle(),
|
||||||
moduleConfiguration: emptyModuleConfiguration(),
|
moduleConfiguration: emptyModuleConfiguration(),
|
||||||
moduleStorage: emptyModuleStorage(),
|
moduleStorage: emptyModuleStorage(),
|
||||||
|
account: createInMemoryAccountStore(),
|
||||||
dashboard: createInMemoryDashboardStore(),
|
dashboard: createInMemoryDashboardStore(),
|
||||||
dashboardHostAssets: testDashboardHostAssets(),
|
dashboardHostAssets: testDashboardHostAssets(),
|
||||||
health: readyHealth(),
|
health: readyHealth(),
|
||||||
@@ -899,6 +921,7 @@ describe("Ekklesia API Hono application", () => {
|
|||||||
moduleLifecycle: emptyModuleLifecycle(),
|
moduleLifecycle: emptyModuleLifecycle(),
|
||||||
moduleConfiguration: emptyModuleConfiguration(),
|
moduleConfiguration: emptyModuleConfiguration(),
|
||||||
moduleStorage: emptyModuleStorage(),
|
moduleStorage: emptyModuleStorage(),
|
||||||
|
account: createInMemoryAccountStore(),
|
||||||
dashboard: createInMemoryDashboardStore(),
|
dashboard: createInMemoryDashboardStore(),
|
||||||
dashboardHostAssets: testDashboardHostAssets(),
|
dashboardHostAssets: testDashboardHostAssets(),
|
||||||
health: readyHealth(),
|
health: readyHealth(),
|
||||||
@@ -938,6 +961,7 @@ describe("split Dashboard and API origins (ADR 0045)", () => {
|
|||||||
moduleConfiguration: emptyModuleConfiguration(),
|
moduleConfiguration: emptyModuleConfiguration(),
|
||||||
moduleStorage: emptyModuleStorage(),
|
moduleStorage: emptyModuleStorage(),
|
||||||
moduleRealtime,
|
moduleRealtime,
|
||||||
|
account: createInMemoryAccountStore(),
|
||||||
dashboard: createInMemoryDashboardStore(),
|
dashboard: createInMemoryDashboardStore(),
|
||||||
dashboardHostAssets: testDashboardHostAssets(),
|
dashboardHostAssets: testDashboardHostAssets(),
|
||||||
health: readyHealth(),
|
health: readyHealth(),
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ vi.mock("@ekklesia/shell/dashboard-assets", () => ({
|
|||||||
dashboardHostAssetUrls: Object.freeze({
|
dashboardHostAssetUrls: Object.freeze({
|
||||||
html: "file:///surface/browser/index.html",
|
html: "file:///surface/browser/index.html",
|
||||||
script: "file:///surface/browser/assets/ekklesia-dashboard/app.js",
|
script: "file:///surface/browser/assets/ekklesia-dashboard/app.js",
|
||||||
|
appearance: "file:///surface/browser/assets/ekklesia-dashboard/appearance.js",
|
||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -28,19 +29,26 @@ function sha256Etag(text: string): string {
|
|||||||
describe("Dashboard host asset loading", () => {
|
describe("Dashboard host asset loading", () => {
|
||||||
beforeEach(() => readFileMock.mockReset());
|
beforeEach(() => readFileMock.mockReset());
|
||||||
|
|
||||||
it("reads only the 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 html = `<!doctype html>${DASHBOARD_API_ORIGIN_PLACEHOLDER}<div id="ekklesia-host"></div>`;
|
||||||
const script = "export const dashboardHost = true;\n";
|
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);
|
const assets = await loadDashboardHostAssets(API_ORIGIN);
|
||||||
|
|
||||||
expect(readFileMock.mock.calls).toEqual([
|
expect(readFileMock.mock.calls).toEqual([
|
||||||
[new URL("file:///surface/browser/index.html"), "utf8"],
|
[new URL("file:///surface/browser/index.html"), "utf8"],
|
||||||
[new URL("file:///surface/browser/assets/ekklesia-dashboard/app.js"), "utf8"],
|
[new URL("file:///surface/browser/assets/ekklesia-dashboard/app.js"), "utf8"],
|
||||||
|
[new URL("file:///surface/browser/assets/ekklesia-dashboard/appearance.js"), "utf8"],
|
||||||
]);
|
]);
|
||||||
const rendered = `<!doctype html><meta content="${API_ORIGIN}" name="ekklesia-api-origin" /><div id="ekklesia-host"></div>`;
|
const rendered = `<!doctype html><meta content="${API_ORIGIN}" name="ekklesia-api-origin" /><div id="ekklesia-host"></div>`;
|
||||||
expect(assets).toEqual({
|
expect(assets).toEqual({
|
||||||
|
appearanceScript,
|
||||||
|
appearanceScriptEtag: sha256Etag(appearanceScript),
|
||||||
html: rendered,
|
html: rendered,
|
||||||
htmlEtag: sha256Etag(rendered),
|
htmlEtag: sha256Etag(rendered),
|
||||||
script,
|
script,
|
||||||
@@ -83,12 +91,27 @@ describe("Dashboard host asset loading", () => {
|
|||||||
expect(readFileMock).toHaveBeenCalledTimes(2);
|
expect(readFileMock).toHaveBeenCalledTimes(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("rejects an empty page or script as an unavailable shell build", () => {
|
it("fails when the appearance script is missing even though the page and script were read", async () => {
|
||||||
for (const [html, script] of [
|
readFileMock
|
||||||
["", "export {};"],
|
.mockResolvedValueOnce(`<!doctype html>${DASHBOARD_API_ORIGIN_PLACEHOLDER}`)
|
||||||
["<html></html>", " \n"],
|
.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) {
|
] 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" />',
|
'<!doctype html><meta content="https://elsewhere.test" name="ekklesia-api-origin" />',
|
||||||
]) {
|
]) {
|
||||||
readFileMock.mockReset();
|
readFileMock.mockReset();
|
||||||
readFileMock.mockResolvedValueOnce(html).mockResolvedValueOnce("export {};\n");
|
readFileMock
|
||||||
|
.mockResolvedValueOnce(html)
|
||||||
|
.mockResolvedValueOnce("export {};\n")
|
||||||
|
.mockResolvedValueOnce("(() => {})();\n");
|
||||||
await expect(loadDashboardHostAssets(API_ORIGIN)).rejects.toBeInstanceOf(
|
await expect(loadDashboardHostAssets(API_ORIGIN)).rejects.toBeInstanceOf(
|
||||||
DashboardHostAssetError,
|
DashboardHostAssetError,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
import { createHash } from "node:crypto";
|
import { createHash } from "node:crypto";
|
||||||
import {
|
import {
|
||||||
ANONYMOUS_PRINCIPAL,
|
ANONYMOUS_PRINCIPAL,
|
||||||
type ModuleDatabase,
|
|
||||||
type ModuleRouteHandler,
|
type ModuleRouteHandler,
|
||||||
type ModuleRuntimeServices,
|
parseModuleManifest,
|
||||||
parsePermissionId,
|
parsePermissionId,
|
||||||
type RegisteredModuleHttpRoute,
|
type RegisteredModuleHttpRoute,
|
||||||
} from "@ekklesia/contracts";
|
} from "@ekklesia/contracts";
|
||||||
@@ -15,7 +14,15 @@ import {
|
|||||||
import type { EkklesiaIdentityRuntime } from "@ekklesia/core/identity";
|
import type { EkklesiaIdentityRuntime } from "@ekklesia/core/identity";
|
||||||
import {
|
import {
|
||||||
createModuleUiPublicationRegistry,
|
createModuleUiPublicationRegistry,
|
||||||
|
type DiscoveredModule,
|
||||||
EMPTY_MODULE_REALTIME_REGISTRY,
|
EMPTY_MODULE_REALTIME_REGISTRY,
|
||||||
|
isReservedModuleId,
|
||||||
|
type LegacyModuleRuntimeServices,
|
||||||
|
type ModuleDatabase,
|
||||||
|
type ModuleLifecycleConfiguration,
|
||||||
|
RESERVED_MODULE_IDS,
|
||||||
|
RESERVED_SETTINGS_NAMES,
|
||||||
|
RESERVED_TOP_LEVEL_NAMES,
|
||||||
} from "@ekklesia/core/modules";
|
} from "@ekklesia/core/modules";
|
||||||
import { describe, expect, it, vi } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
@@ -31,13 +38,27 @@ import {
|
|||||||
} from "../src/dashboard-host-assets.js";
|
} from "../src/dashboard-host-assets.js";
|
||||||
import {
|
import {
|
||||||
DASHBOARD_HOST_PAGE_PATH,
|
DASHBOARD_HOST_PAGE_PATH,
|
||||||
|
DASHBOARD_HOST_APPEARANCE_SCRIPT_PATH,
|
||||||
DASHBOARD_HOST_SCRIPT_PATH,
|
DASHBOARD_HOST_SCRIPT_PATH,
|
||||||
DashboardHostConfigurationError,
|
DashboardHostConfigurationError,
|
||||||
|
createDashboardHostRoutes,
|
||||||
} from "../src/dashboard-host-routes.js";
|
} from "../src/dashboard-host-routes.js";
|
||||||
import { 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 { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
|
||||||
import { emptyModuleConfiguration } from "./support/in-memory-module-configuration.js";
|
import { emptyModuleConfiguration } from "./support/in-memory-module-configuration.js";
|
||||||
import { emptyModuleLifecycle } from "./support/in-memory-module-lifecycle.js";
|
import {
|
||||||
|
createInMemoryModuleLifecycleStore,
|
||||||
|
moduleLifecycleSnapshotFor,
|
||||||
|
} from "./support/in-memory-module-lifecycle.js";
|
||||||
import { emptyModuleStorage } from "./support/in-memory-module-storage.js";
|
import { emptyModuleStorage } from "./support/in-memory-module-storage.js";
|
||||||
import { readyHealth } from "./support/ready-health.js";
|
import { readyHealth } from "./support/ready-health.js";
|
||||||
|
|
||||||
@@ -54,6 +75,8 @@ function sha256Etag(text: string): string {
|
|||||||
|
|
||||||
const PAGE_ETAG = sha256Etag(PAGE_HTML);
|
const PAGE_ETAG = sha256Etag(PAGE_HTML);
|
||||||
const SCRIPT_ETAG = sha256Etag(SCRIPT);
|
const SCRIPT_ETAG = sha256Etag(SCRIPT);
|
||||||
|
const APPEARANCE_SCRIPT = "(() => { /* pre-paint appearance */ })();\n";
|
||||||
|
const APPEARANCE_SCRIPT_ETAG = sha256Etag(APPEARANCE_SCRIPT);
|
||||||
|
|
||||||
function fakeRuntime(): EkklesiaIdentityRuntime {
|
function fakeRuntime(): EkklesiaIdentityRuntime {
|
||||||
return {
|
return {
|
||||||
@@ -74,13 +97,44 @@ function fakeStore(): AuthorizationStore {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function fakeModuleServices(): ModuleRuntimeServices {
|
function fakeModuleServices(): LegacyModuleRuntimeServices {
|
||||||
return Object.freeze({ database: Object.freeze({}) as unknown as ModuleDatabase });
|
return Object.freeze({ database: Object.freeze({}) as unknown as ModuleDatabase });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A discovered module as the boot snapshot records it, from a real validated manifest. Only its id
|
||||||
|
* and whether it declares a Dashboard UI matter to these routes; nothing is ever loaded. */
|
||||||
|
function discoveredModule(id: string, options: { readonly ui: boolean }): DiscoveredModule {
|
||||||
|
const manifest = parseModuleManifest({
|
||||||
|
capabilities: [],
|
||||||
|
compatibility: { moduleSdk: "^0.2.0", platform: "^0.2.0" },
|
||||||
|
entrypoints: {
|
||||||
|
server: "./dist/server/index.js",
|
||||||
|
...(options.ui ? { ui: "./dist/ui/index.js" } : {}),
|
||||||
|
},
|
||||||
|
id,
|
||||||
|
manifestVersion: 8,
|
||||||
|
name: `${id} module`,
|
||||||
|
permissions: [],
|
||||||
|
version: "0.1.0",
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
contributions: [],
|
||||||
|
directory: `/modules/${id}`,
|
||||||
|
entrypoints: {
|
||||||
|
server: `/modules/${id}/dist/server/index.js`,
|
||||||
|
...(options.ui ? { ui: `/modules/${id}/dist/ui/index.js` } : {}),
|
||||||
|
},
|
||||||
|
manifest,
|
||||||
|
manifestPath: `/modules/${id}/ekklesia.module.json`,
|
||||||
|
packageName: `@test/${id}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function buildApp(
|
function buildApp(
|
||||||
options: {
|
options: {
|
||||||
readonly assets?: DashboardHostAssets;
|
readonly assets?: DashboardHostAssets;
|
||||||
|
readonly configuration?: ModuleLifecycleConfiguration;
|
||||||
|
readonly discovered?: readonly DiscoveredModule[];
|
||||||
readonly moduleRoutes?: readonly RegisteredModuleHttpRoute[];
|
readonly moduleRoutes?: readonly RegisteredModuleHttpRoute[];
|
||||||
} = {},
|
} = {},
|
||||||
) {
|
) {
|
||||||
@@ -100,11 +154,16 @@ function buildApp(
|
|||||||
moduleRealtime: EMPTY_MODULE_REALTIME_REGISTRY,
|
moduleRealtime: EMPTY_MODULE_REALTIME_REGISTRY,
|
||||||
moduleServices: fakeModuleServices(),
|
moduleServices: fakeModuleServices(),
|
||||||
moduleUi: createModuleUiPublicationRegistry([]),
|
moduleUi: createModuleUiPublicationRegistry([]),
|
||||||
moduleLifecycle: emptyModuleLifecycle(),
|
moduleLifecycle: {
|
||||||
|
snapshot: moduleLifecycleSnapshotFor(options.discovered ?? [], options.configuration),
|
||||||
|
store: createInMemoryModuleLifecycleStore(),
|
||||||
|
},
|
||||||
moduleConfiguration: emptyModuleConfiguration(),
|
moduleConfiguration: emptyModuleConfiguration(),
|
||||||
moduleStorage: emptyModuleStorage(),
|
moduleStorage: emptyModuleStorage(),
|
||||||
|
account: createInMemoryAccountStore(),
|
||||||
dashboard: createInMemoryDashboardStore(),
|
dashboard: createInMemoryDashboardStore(),
|
||||||
dashboardHostAssets: options.assets ?? createDashboardHostAssets(PAGE_HTML, SCRIPT),
|
dashboardHostAssets:
|
||||||
|
options.assets ?? createDashboardHostAssets(PAGE_HTML, SCRIPT, APPEARANCE_SCRIPT),
|
||||||
health: readyHealth(),
|
health: readyHealth(),
|
||||||
runtime,
|
runtime,
|
||||||
trustedOrigins: [trustedOrigin],
|
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)", () => {
|
describe("GET /assets/ekklesia-dashboard/app.js (the Dashboard script)", () => {
|
||||||
it("serves the exact startup script bytes as JavaScript with revalidation and a strong ETag", async () => {
|
it("serves the exact startup script bytes as JavaScript with revalidation and a strong ETag", async () => {
|
||||||
const { app } = buildApp();
|
const { app } = buildApp();
|
||||||
@@ -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", () => {
|
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();
|
const { app } = buildApp();
|
||||||
|
|
||||||
for (const path of [
|
for (const path of [
|
||||||
@@ -246,6 +518,10 @@ describe("Dashboard host boundaries", () => {
|
|||||||
"/assets/ekklesia-dashboard/index.html",
|
"/assets/ekklesia-dashboard/index.html",
|
||||||
"/assets/",
|
"/assets/",
|
||||||
"/assets/ekklesia-dashboard/APP.JS",
|
"/assets/ekklesia-dashboard/APP.JS",
|
||||||
|
"/assets/ekklesia-dashboard/appearance.js.map",
|
||||||
|
"/assets/ekklesia-dashboard/appearance.js/",
|
||||||
|
"/assets/ekklesia-dashboard/APPEARANCE.JS",
|
||||||
|
"/appearance.js",
|
||||||
"/dashboard",
|
"/dashboard",
|
||||||
"/login",
|
"/login",
|
||||||
]) {
|
]) {
|
||||||
@@ -334,12 +610,21 @@ describe("Dashboard host boundaries", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("refuses at composition a snapshot whose validator does not match its bytes, or empty bytes", () => {
|
it("refuses at composition a snapshot whose validator does not match its bytes, or empty bytes", () => {
|
||||||
const consistent = createDashboardHostAssets(PAGE_HTML, SCRIPT);
|
const consistent = createDashboardHostAssets(PAGE_HTML, SCRIPT, APPEARANCE_SCRIPT);
|
||||||
|
|
||||||
for (const assets of [
|
for (const assets of [
|
||||||
{ ...consistent, html: `${PAGE_HTML}<!-- tampered -->` },
|
{ ...consistent, html: `${PAGE_HTML}<!-- tampered -->` },
|
||||||
{ ...consistent, scriptEtag: PAGE_ETAG },
|
{ ...consistent, scriptEtag: PAGE_ETAG },
|
||||||
{ 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);
|
expect(() => buildApp({ assets })).toThrow(DashboardHostConfigurationError);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,8 +5,6 @@ import {
|
|||||||
ANONYMOUS_PRINCIPAL,
|
ANONYMOUS_PRINCIPAL,
|
||||||
createAuthenticatedPrincipal,
|
createAuthenticatedPrincipal,
|
||||||
type EkklesiaPrincipal,
|
type EkklesiaPrincipal,
|
||||||
type ModuleDatabase,
|
|
||||||
type ModuleRuntimeServices,
|
|
||||||
parseEkklesiaUserId,
|
parseEkklesiaUserId,
|
||||||
} from "@ekklesia/contracts";
|
} from "@ekklesia/contracts";
|
||||||
import {
|
import {
|
||||||
@@ -20,12 +18,15 @@ import {
|
|||||||
createModuleUiPublicationRegistry,
|
createModuleUiPublicationRegistry,
|
||||||
discoverModules,
|
discoverModules,
|
||||||
EMPTY_MODULE_REALTIME_REGISTRY,
|
EMPTY_MODULE_REALTIME_REGISTRY,
|
||||||
|
type LegacyModuleRuntimeServices,
|
||||||
|
type ModuleDatabase,
|
||||||
} from "@ekklesia/core/modules";
|
} from "@ekklesia/core/modules";
|
||||||
import type { Hono } from "hono";
|
import type { Hono } from "hono";
|
||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
import { createApiApp } from "../src/app.js";
|
import { createApiApp } from "../src/app.js";
|
||||||
import { DASHBOARD_NAVIGATION_PATH } from "../src/dashboard-routes.js";
|
import { DASHBOARD_NAVIGATION_PATH } from "../src/dashboard-routes.js";
|
||||||
import { MODULE_UI_INVENTORY_PATH } from "../src/module-ui-inventory.js";
|
import { MODULE_UI_INVENTORY_PATH } from "../src/module-ui-inventory.js";
|
||||||
|
import { createInMemoryAccountStore } from "./support/in-memory-account-store.js";
|
||||||
import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
|
import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
|
||||||
import { testDashboardHostAssets } from "./support/dashboard-host-assets.js";
|
import { testDashboardHostAssets } from "./support/dashboard-host-assets.js";
|
||||||
import { readyHealth } from "./support/ready-health.js";
|
import { readyHealth } from "./support/ready-health.js";
|
||||||
@@ -140,6 +141,7 @@ async function install(options: InstallOptions = {}): Promise<{ app: Hono }> {
|
|||||||
|
|
||||||
const app = createApiApp({
|
const app = createApiApp({
|
||||||
authorization: { evaluator, registry: composePlatformPermissionRegistry(modules), store },
|
authorization: { evaluator, registry: composePlatformPermissionRegistry(modules), store },
|
||||||
|
account: createInMemoryAccountStore(),
|
||||||
dashboard: createInMemoryDashboardStore(options.configuration ?? {}),
|
dashboard: createInMemoryDashboardStore(options.configuration ?? {}),
|
||||||
dashboardHostAssets: testDashboardHostAssets(),
|
dashboardHostAssets: testDashboardHostAssets(),
|
||||||
health: readyHealth(),
|
health: readyHealth(),
|
||||||
@@ -147,7 +149,7 @@ async function install(options: InstallOptions = {}): Promise<{ app: Hono }> {
|
|||||||
moduleRealtime: EMPTY_MODULE_REALTIME_REGISTRY,
|
moduleRealtime: EMPTY_MODULE_REALTIME_REGISTRY,
|
||||||
moduleServices: Object.freeze({
|
moduleServices: Object.freeze({
|
||||||
database: Object.freeze({}) as unknown as ModuleDatabase,
|
database: Object.freeze({}) as unknown as ModuleDatabase,
|
||||||
}) as ModuleRuntimeServices,
|
}) as LegacyModuleRuntimeServices,
|
||||||
moduleUi: createModuleUiPublicationRegistry(modules),
|
moduleUi: createModuleUiPublicationRegistry(modules),
|
||||||
moduleLifecycle: {
|
moduleLifecycle: {
|
||||||
snapshot: moduleLifecycleSnapshotFor(modules),
|
snapshot: moduleLifecycleSnapshotFor(modules),
|
||||||
|
|||||||
@@ -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,7 +118,7 @@ async function createDisplayModuleDirectory(): Promise<string> {
|
|||||||
writeFile(
|
writeFile(
|
||||||
path.join(moduleDirectory, "ekklesia.module.json"),
|
path.join(moduleDirectory, "ekklesia.module.json"),
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
compatibility: { moduleSdk: "^0.1.0", platform: "^0.1.0" },
|
compatibility: { moduleSdk: "^0.2.0", platform: "^0.2.0" },
|
||||||
entrypoints: {
|
entrypoints: {
|
||||||
display: { artifact: "./dist/ui/display.js", permission: displayPermission },
|
display: { artifact: "./dist/ui/display.js", permission: displayPermission },
|
||||||
server: "./dist/server/index.js",
|
server: "./dist/server/index.js",
|
||||||
@@ -782,6 +782,127 @@ describe.sequential("display device pairing, end to end", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ADR 0056: a credential lasts while the screen is in use, and dies 180 days after it was last
|
||||||
|
* seen. Ages are set directly in the database, exactly as months of real use would leave them.
|
||||||
|
*/
|
||||||
|
describe("credential lifetime", () => {
|
||||||
|
const ttlSeconds = 180 * 24 * 60 * 60;
|
||||||
|
const descriptor = "/api/v1/module-displays/announcements";
|
||||||
|
|
||||||
|
function renewal(response: Response): string | undefined {
|
||||||
|
return setCookieFor(response, "ekklesia_device_session");
|
||||||
|
}
|
||||||
|
|
||||||
|
it("keeps a screen that is in use paired long past 180 days, renewing its cookie", async () => {
|
||||||
|
const running = await platform("renewal");
|
||||||
|
const device = new DeviceBrowser(running.origin);
|
||||||
|
try {
|
||||||
|
await pairDevice(running, device);
|
||||||
|
const credential = device.header().replace(/^.*ekklesia_device_session=/u, "");
|
||||||
|
// Paired 200 days ago, and seen yesterday: a screen that has simply been working.
|
||||||
|
await query(
|
||||||
|
running.url,
|
||||||
|
`update ekklesia_core_device set created_at = now() - interval '200 days', last_seen_at = now() - interval '1 day'`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const response = await device.request(descriptor);
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
// The same credential, re-issued with a full lifetime and the same protections.
|
||||||
|
const header = renewal(response);
|
||||||
|
expect(header).toBeDefined();
|
||||||
|
expect(header).toContain(`ekklesia_device_session=${credential};`);
|
||||||
|
expect(header).toMatch(new RegExp(`Max-Age=${ttlSeconds}(;|$)`, "u"));
|
||||||
|
expect(header).toMatch(/HttpOnly/u);
|
||||||
|
expect(header).toMatch(/SameSite=Strict/u);
|
||||||
|
expect(header).toMatch(/Path=\//u);
|
||||||
|
expect(header).not.toMatch(/Domain=/iu);
|
||||||
|
|
||||||
|
// Using it moved the server's window forward too.
|
||||||
|
const [row] = await query<{ recent: boolean }>(
|
||||||
|
running.url,
|
||||||
|
`select last_seen_at > now() - interval '1 minute' as recent from ekklesia_core_device`,
|
||||||
|
);
|
||||||
|
expect(row?.recent).toBe(true);
|
||||||
|
} finally {
|
||||||
|
await running.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps a credential paired under the fixed 180-day rule working once it is renewed", async () => {
|
||||||
|
const running = await platform("legacy");
|
||||||
|
const device = new DeviceBrowser(running.origin);
|
||||||
|
try {
|
||||||
|
await pairDevice(running, device);
|
||||||
|
// A production screen paired 179 days ago that checked in this morning: under the old
|
||||||
|
// rule it had one day left. It must carry straight on.
|
||||||
|
await query(
|
||||||
|
running.url,
|
||||||
|
`update ekklesia_core_device set created_at = now() - interval '179 days', last_seen_at = now() - interval '2 hours'`,
|
||||||
|
);
|
||||||
|
expect((await device.request(descriptor)).status).toBe(200);
|
||||||
|
await query(
|
||||||
|
running.url,
|
||||||
|
`update ekklesia_core_device set created_at = now() - interval '190 days'`,
|
||||||
|
);
|
||||||
|
expect((await device.request(descriptor)).status).toBe(200);
|
||||||
|
} finally {
|
||||||
|
await running.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lets an abandoned screen stop being an identity 180 days after it was last seen", async () => {
|
||||||
|
const running = await platform("abandoned");
|
||||||
|
const device = new DeviceBrowser(running.origin);
|
||||||
|
try {
|
||||||
|
await pairDevice(running, device);
|
||||||
|
await query(
|
||||||
|
running.url,
|
||||||
|
`update ekklesia_core_device set created_at = now() - interval '400 days', last_seen_at = now() - interval '181 days'`,
|
||||||
|
);
|
||||||
|
const response = await device.request(descriptor);
|
||||||
|
// Anonymous, exactly as revoked or wrong credentials are: the screen shows a pairing code.
|
||||||
|
expect(response.status).toBe(403);
|
||||||
|
expect(renewal(response)).toBeUndefined();
|
||||||
|
} finally {
|
||||||
|
await running.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never renews a revoked screen", async () => {
|
||||||
|
const running = await platform("revoked-renewal");
|
||||||
|
const device = new DeviceBrowser(running.origin);
|
||||||
|
try {
|
||||||
|
await pairDevice(running, device);
|
||||||
|
const listed = (await (
|
||||||
|
await adminRequest(running.origin, running.ownerCookie, "/api/v1/devices")
|
||||||
|
).json()) as { devices: Array<{ id: string }> };
|
||||||
|
await adminRequest(
|
||||||
|
running.origin,
|
||||||
|
running.ownerCookie,
|
||||||
|
`/api/v1/devices/${listed.devices[0]?.id}/revoke`,
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
const response = await device.request(descriptor);
|
||||||
|
expect(response.status).toBe(403);
|
||||||
|
expect(renewal(response)).toBeUndefined();
|
||||||
|
} finally {
|
||||||
|
await running.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not hand a device cookie to a person viewing the display", async () => {
|
||||||
|
const running = await platform("human-renewal");
|
||||||
|
try {
|
||||||
|
const response = await adminRequest(running.origin, running.ownerCookie, descriptor);
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(renewal(response)).toBeUndefined();
|
||||||
|
} finally {
|
||||||
|
await running.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("keeps a human administrator's own session unaffected by a device cookie", async () => {
|
it("keeps a human administrator's own session unaffected by a device cookie", async () => {
|
||||||
const running = await platform("mixed");
|
const running = await platform("mixed");
|
||||||
const device = new DeviceBrowser(running.origin);
|
const device = new DeviceBrowser(running.origin);
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
import {
|
import {
|
||||||
ANONYMOUS_PRINCIPAL,
|
ANONYMOUS_PRINCIPAL,
|
||||||
type ModuleDatabase,
|
|
||||||
type ModuleRouteHandler,
|
type ModuleRouteHandler,
|
||||||
type ModuleRuntimeServices,
|
|
||||||
parsePermissionId,
|
parsePermissionId,
|
||||||
type RegisteredModuleHttpRoute,
|
type RegisteredModuleHttpRoute,
|
||||||
} from "@ekklesia/contracts";
|
} from "@ekklesia/contracts";
|
||||||
@@ -15,6 +13,8 @@ import type { EkklesiaIdentityRuntime } from "@ekklesia/core/identity";
|
|||||||
import {
|
import {
|
||||||
createModuleUiPublicationRegistry,
|
createModuleUiPublicationRegistry,
|
||||||
EMPTY_MODULE_REALTIME_REGISTRY,
|
EMPTY_MODULE_REALTIME_REGISTRY,
|
||||||
|
type LegacyModuleRuntimeServices,
|
||||||
|
type ModuleDatabase,
|
||||||
} from "@ekklesia/core/modules";
|
} from "@ekklesia/core/modules";
|
||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
import { createApiApp } from "../src/app.js";
|
import { createApiApp } from "../src/app.js";
|
||||||
@@ -24,6 +24,7 @@ import {
|
|||||||
HEALTH_READY_PATH,
|
HEALTH_READY_PATH,
|
||||||
READINESS_DATABASE_TIMEOUT_MS,
|
READINESS_DATABASE_TIMEOUT_MS,
|
||||||
} from "../src/health-routes.js";
|
} from "../src/health-routes.js";
|
||||||
|
import { createInMemoryAccountStore } from "./support/in-memory-account-store.js";
|
||||||
import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
|
import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
|
||||||
import { testDashboardHostAssets } from "./support/dashboard-host-assets.js";
|
import { testDashboardHostAssets } from "./support/dashboard-host-assets.js";
|
||||||
import { emptyModuleConfiguration } from "./support/in-memory-module-configuration.js";
|
import { emptyModuleConfiguration } from "./support/in-memory-module-configuration.js";
|
||||||
@@ -56,7 +57,7 @@ function fakeStore(): AuthorizationStore {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function fakeModuleServices(): ModuleRuntimeServices {
|
function fakeModuleServices(): LegacyModuleRuntimeServices {
|
||||||
return Object.freeze({ database: Object.freeze({}) as unknown as ModuleDatabase });
|
return Object.freeze({ database: Object.freeze({}) as unknown as ModuleDatabase });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -91,6 +92,7 @@ function buildApp(
|
|||||||
moduleLifecycle: emptyModuleLifecycle(),
|
moduleLifecycle: emptyModuleLifecycle(),
|
||||||
moduleConfiguration: emptyModuleConfiguration(),
|
moduleConfiguration: emptyModuleConfiguration(),
|
||||||
moduleStorage: emptyModuleStorage(),
|
moduleStorage: emptyModuleStorage(),
|
||||||
|
account: createInMemoryAccountStore(),
|
||||||
dashboard: createInMemoryDashboardStore(),
|
dashboard: createInMemoryDashboardStore(),
|
||||||
dashboardHostAssets: testDashboardHostAssets(),
|
dashboardHostAssets: testDashboardHostAssets(),
|
||||||
health,
|
health,
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ vi.mock("@hono/node-server", () => ({ serve: serveMock }));
|
|||||||
vi.mock("../src/dashboard-host-assets.js", async (importOriginal) => {
|
vi.mock("../src/dashboard-host-assets.js", async (importOriginal) => {
|
||||||
const actual = await importOriginal<typeof import("../src/dashboard-host-assets.js")>();
|
const actual = await importOriginal<typeof import("../src/dashboard-host-assets.js")>();
|
||||||
loadDashboardHostAssetsMock.mockImplementation(async () =>
|
loadDashboardHostAssetsMock.mockImplementation(async () =>
|
||||||
actual.createDashboardHostAssets("<!doctype html>", "export {};\n"),
|
actual.createDashboardHostAssets("<!doctype html>", "export {};\n", "(() => {})();\n"),
|
||||||
);
|
);
|
||||||
return { ...actual, loadDashboardHostAssets: loadDashboardHostAssetsMock };
|
return { ...actual, loadDashboardHostAssets: loadDashboardHostAssetsMock };
|
||||||
});
|
});
|
||||||
@@ -108,7 +108,7 @@ async function createFixtureModule(
|
|||||||
id,
|
id,
|
||||||
name: `${id} module`,
|
name: `${id} module`,
|
||||||
version: "0.1.0",
|
version: "0.1.0",
|
||||||
compatibility: { platform: "^0.1.0", moduleSdk: "^0.1.0" },
|
compatibility: { platform: "^0.2.0", moduleSdk: "^0.2.0" },
|
||||||
permissions: [],
|
permissions: [],
|
||||||
entrypoints: { server: "./dist/server/index.js" },
|
entrypoints: { server: "./dist/server/index.js" },
|
||||||
...manifestOverrides,
|
...manifestOverrides,
|
||||||
@@ -267,7 +267,7 @@ function fakeDiscoveredModule(
|
|||||||
): DiscoveredModule {
|
): DiscoveredModule {
|
||||||
const manifest = parseModuleManifest({
|
const manifest = parseModuleManifest({
|
||||||
capabilities: [],
|
capabilities: [],
|
||||||
compatibility: { moduleSdk: "^0.1.0", platform: "^0.1.0" },
|
compatibility: { moduleSdk: "^0.2.0", platform: "^0.2.0" },
|
||||||
entrypoints: { server: "./dist/does-not-exist/index.js" },
|
entrypoints: { server: "./dist/does-not-exist/index.js" },
|
||||||
id,
|
id,
|
||||||
manifestVersion: 8,
|
manifestVersion: 8,
|
||||||
@@ -1435,9 +1435,9 @@ describe("Ekklesia API module runtime startup and shutdown", () => {
|
|||||||
const recorder = createEventRecorder();
|
const recorder = createEventRecorder();
|
||||||
await createWorkingFixtureModule(modulesDirectory, "calendar", recorder, { withRoute: true });
|
await createWorkingFixtureModule(modulesDirectory, "calendar", recorder, { withRoute: true });
|
||||||
const discovered = await (await import("@ekklesia/core/modules")).discoverModules({
|
const discovered = await (await import("@ekklesia/core/modules")).discoverModules({
|
||||||
moduleSdkVersion: "0.1.0",
|
moduleSdkVersion: "0.2.0",
|
||||||
modulesDirectory,
|
modulesDirectory,
|
||||||
platformVersion: "0.1.0",
|
platformVersion: "0.2.0",
|
||||||
});
|
});
|
||||||
mockPoolAndAuth();
|
mockPoolAndAuth();
|
||||||
mockSuccessfulServe();
|
mockSuccessfulServe();
|
||||||
|
|||||||
@@ -6,8 +6,6 @@ import {
|
|||||||
createAuthenticatedPrincipal,
|
createAuthenticatedPrincipal,
|
||||||
type EkklesiaPrincipal,
|
type EkklesiaPrincipal,
|
||||||
type ModuleConfigurationDeclarationInput,
|
type ModuleConfigurationDeclarationInput,
|
||||||
type ModuleDatabase,
|
|
||||||
type ModuleRuntimeServices,
|
|
||||||
parseEkklesiaUserId,
|
parseEkklesiaUserId,
|
||||||
} from "@ekklesia/contracts";
|
} from "@ekklesia/contracts";
|
||||||
import {
|
import {
|
||||||
@@ -22,10 +20,12 @@ import {
|
|||||||
createModuleUiPublicationRegistry,
|
createModuleUiPublicationRegistry,
|
||||||
type DiscoveredModule,
|
type DiscoveredModule,
|
||||||
discoverModules,
|
discoverModules,
|
||||||
|
type LegacyModuleRuntimeServices,
|
||||||
type ModuleConfigurationRecord,
|
type ModuleConfigurationRecord,
|
||||||
type ModuleConfigurationStore,
|
type ModuleConfigurationStore,
|
||||||
ModuleConfigurationStoreError,
|
ModuleConfigurationStoreError,
|
||||||
EMPTY_MODULE_REALTIME_REGISTRY,
|
EMPTY_MODULE_REALTIME_REGISTRY,
|
||||||
|
type ModuleDatabase,
|
||||||
resolveModuleEligibility,
|
resolveModuleEligibility,
|
||||||
} from "@ekklesia/core/modules";
|
} from "@ekklesia/core/modules";
|
||||||
import type { Hono } from "hono";
|
import type { Hono } from "hono";
|
||||||
@@ -34,6 +34,7 @@ import { createApiApp } from "../src/app.js";
|
|||||||
import { CORE_MUTATION_MAX_BODY_BYTES } from "../src/body-limits.js";
|
import { CORE_MUTATION_MAX_BODY_BYTES } from "../src/body-limits.js";
|
||||||
import { MODULE_CONFIGURATION_PATH } from "../src/module-configuration-routes.js";
|
import { MODULE_CONFIGURATION_PATH } from "../src/module-configuration-routes.js";
|
||||||
import { MODULE_LIFECYCLE_PATH } from "../src/module-lifecycle-routes.js";
|
import { MODULE_LIFECYCLE_PATH } from "../src/module-lifecycle-routes.js";
|
||||||
|
import { createInMemoryAccountStore } from "./support/in-memory-account-store.js";
|
||||||
import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
|
import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
|
||||||
import { testDashboardHostAssets } from "./support/dashboard-host-assets.js";
|
import { testDashboardHostAssets } from "./support/dashboard-host-assets.js";
|
||||||
import { readyHealth } from "./support/ready-health.js";
|
import { readyHealth } from "./support/ready-health.js";
|
||||||
@@ -190,6 +191,7 @@ async function install(options: InstallOptions = {}): Promise<Installation> {
|
|||||||
registry: composePlatformPermissionRegistry(modules),
|
registry: composePlatformPermissionRegistry(modules),
|
||||||
store: authorizationStore,
|
store: authorizationStore,
|
||||||
},
|
},
|
||||||
|
account: createInMemoryAccountStore(),
|
||||||
dashboard: createInMemoryDashboardStore(),
|
dashboard: createInMemoryDashboardStore(),
|
||||||
dashboardHostAssets: testDashboardHostAssets(),
|
dashboardHostAssets: testDashboardHostAssets(),
|
||||||
health: readyHealth(),
|
health: readyHealth(),
|
||||||
@@ -213,7 +215,7 @@ async function install(options: InstallOptions = {}): Promise<Installation> {
|
|||||||
moduleRealtime: EMPTY_MODULE_REALTIME_REGISTRY,
|
moduleRealtime: EMPTY_MODULE_REALTIME_REGISTRY,
|
||||||
moduleServices: Object.freeze({
|
moduleServices: Object.freeze({
|
||||||
database: Object.freeze({}) as unknown as ModuleDatabase,
|
database: Object.freeze({}) as unknown as ModuleDatabase,
|
||||||
}) as ModuleRuntimeServices,
|
}) as LegacyModuleRuntimeServices,
|
||||||
moduleUi: createModuleUiPublicationRegistry(eligibility.eligibleModules),
|
moduleUi: createModuleUiPublicationRegistry(eligibility.eligibleModules),
|
||||||
runtime,
|
runtime,
|
||||||
trustedOrigins: [trustedOrigin],
|
trustedOrigins: [trustedOrigin],
|
||||||
|
|||||||
@@ -195,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 }> {
|
async function createDisposableDatabase(label: string): Promise<{ name: string; url: string }> {
|
||||||
const name = makeDatabaseName(label);
|
const name = makeDatabaseName(label);
|
||||||
await createDatabase(name);
|
await createDatabase(name);
|
||||||
@@ -212,9 +235,9 @@ async function applyCoreMigrations(url: string): Promise<void> {
|
|||||||
const aggregate = await discoverAllMigrations({
|
const aggregate = await discoverAllMigrations({
|
||||||
coreMigrationsDirectory,
|
coreMigrationsDirectory,
|
||||||
moduleDiscovery: {
|
moduleDiscovery: {
|
||||||
moduleSdkVersion: "0.1.0",
|
moduleSdkVersion: "0.2.0",
|
||||||
modulesDirectory: emptyModulesDirectory,
|
modulesDirectory: emptyModulesDirectory,
|
||||||
platformVersion: "0.1.0",
|
platformVersion: "0.2.0",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
expect(aggregate.migrations.map((entry) => entry.identity)).toEqual([
|
expect(aggregate.migrations.map((entry) => entry.identity)).toEqual([
|
||||||
@@ -226,6 +249,7 @@ async function applyCoreMigrations(url: string): Promise<void> {
|
|||||||
"core:0005_module_configuration",
|
"core:0005_module_configuration",
|
||||||
"core:0006_device_identity",
|
"core:0006_device_identity",
|
||||||
"core:0007_verified_bootstrap_owners",
|
"core:0007_verified_bootstrap_owners",
|
||||||
|
"core:0008_user_preferences",
|
||||||
]);
|
]);
|
||||||
await applyModuleMigrations({ aggregate, database: createPostgresMigrationDatabase(url) });
|
await applyModuleMigrations({ aggregate, database: createPostgresMigrationDatabase(url) });
|
||||||
} finally {
|
} finally {
|
||||||
@@ -241,9 +265,9 @@ async function applyMigrationsWithModule(url: string): Promise<void> {
|
|||||||
const aggregate = await discoverAllMigrations({
|
const aggregate = await discoverAllMigrations({
|
||||||
coreMigrationsDirectory,
|
coreMigrationsDirectory,
|
||||||
moduleDiscovery: {
|
moduleDiscovery: {
|
||||||
moduleSdkVersion: "0.1.0",
|
moduleSdkVersion: "0.2.0",
|
||||||
modulesDirectory: fixtureModulesDirectory,
|
modulesDirectory: fixtureModulesDirectory,
|
||||||
platformVersion: "0.1.0",
|
platformVersion: "0.2.0",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
// Core migrations aggregate first, exactly once each, and always before any module migration.
|
// Core migrations aggregate first, exactly once each, and always before any module migration.
|
||||||
@@ -256,6 +280,7 @@ async function applyMigrationsWithModule(url: string): Promise<void> {
|
|||||||
"core:0005_module_configuration",
|
"core:0005_module_configuration",
|
||||||
"core:0006_device_identity",
|
"core:0006_device_identity",
|
||||||
"core:0007_verified_bootstrap_owners",
|
"core:0007_verified_bootstrap_owners",
|
||||||
|
"core:0008_user_preferences",
|
||||||
"db-facade:0000_initial",
|
"db-facade:0000_initial",
|
||||||
]);
|
]);
|
||||||
expect(aggregate.migrations[0]?.checksum).toBe(
|
expect(aggregate.migrations[0]?.checksum).toBe(
|
||||||
@@ -760,7 +785,7 @@ describe.sequential("live API — module database access through ModuleRuntimeSe
|
|||||||
|
|
||||||
await running.shutdown();
|
await running.shutdown();
|
||||||
|
|
||||||
const remainingConnections = await activeConnectionCount(database.name);
|
const remainingConnections = await connectionsLeftAfterShutdown(database.name);
|
||||||
expect(remainingConnections).toBe(0);
|
expect(remainingConnections).toBe(0);
|
||||||
|
|
||||||
const eventsAfterShutdown = readRecorder();
|
const eventsAfterShutdown = readRecorder();
|
||||||
|
|||||||
@@ -5,8 +5,6 @@ import {
|
|||||||
ANONYMOUS_PRINCIPAL,
|
ANONYMOUS_PRINCIPAL,
|
||||||
createAuthenticatedPrincipal,
|
createAuthenticatedPrincipal,
|
||||||
type EkklesiaPrincipal,
|
type EkklesiaPrincipal,
|
||||||
type ModuleDatabase,
|
|
||||||
type ModuleRuntimeServices,
|
|
||||||
parseEkklesiaUserId,
|
parseEkklesiaUserId,
|
||||||
} from "@ekklesia/contracts";
|
} from "@ekklesia/contracts";
|
||||||
import {
|
import {
|
||||||
@@ -21,6 +19,8 @@ import {
|
|||||||
type DiscoveredModule,
|
type DiscoveredModule,
|
||||||
discoverModules,
|
discoverModules,
|
||||||
EMPTY_MODULE_REALTIME_REGISTRY,
|
EMPTY_MODULE_REALTIME_REGISTRY,
|
||||||
|
type LegacyModuleRuntimeServices,
|
||||||
|
type ModuleDatabase,
|
||||||
} from "@ekklesia/core/modules";
|
} from "@ekklesia/core/modules";
|
||||||
import type { Hono } from "hono";
|
import type { Hono } from "hono";
|
||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
@@ -37,6 +37,7 @@ import { moduleApiBaseUrl } from "../src/module-http-routes.js";
|
|||||||
import { moduleRealtimeBaseUrl } from "../src/module-realtime-routes.js";
|
import { moduleRealtimeBaseUrl } from "../src/module-realtime-routes.js";
|
||||||
import { MODULE_UI_INVENTORY_PATH } from "../src/module-ui-inventory.js";
|
import { MODULE_UI_INVENTORY_PATH } from "../src/module-ui-inventory.js";
|
||||||
import { moduleDisplayUiArtifactUrl } from "../src/module-ui-routes.js";
|
import { moduleDisplayUiArtifactUrl } from "../src/module-ui-routes.js";
|
||||||
|
import { createInMemoryAccountStore } from "./support/in-memory-account-store.js";
|
||||||
import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
|
import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
|
||||||
import { testDashboardHostAssets } from "./support/dashboard-host-assets.js";
|
import { testDashboardHostAssets } from "./support/dashboard-host-assets.js";
|
||||||
import { readyHealth } from "./support/ready-health.js";
|
import { readyHealth } from "./support/ready-health.js";
|
||||||
@@ -155,7 +156,7 @@ interface Installation {
|
|||||||
readonly modules: readonly DiscoveredModule[];
|
readonly modules: readonly DiscoveredModule[];
|
||||||
}
|
}
|
||||||
|
|
||||||
function fakeServices(): ModuleRuntimeServices {
|
function fakeServices(): LegacyModuleRuntimeServices {
|
||||||
return Object.freeze({ database: Object.freeze({}) as unknown as ModuleDatabase });
|
return Object.freeze({ database: Object.freeze({}) as unknown as ModuleDatabase });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -199,6 +200,7 @@ async function install(options: InstallOptions): Promise<Installation> {
|
|||||||
registry: composePlatformPermissionRegistry(modules),
|
registry: composePlatformPermissionRegistry(modules),
|
||||||
store: fakeAuthorizationStore(),
|
store: fakeAuthorizationStore(),
|
||||||
},
|
},
|
||||||
|
account: createInMemoryAccountStore(),
|
||||||
dashboard: createInMemoryDashboardStore(),
|
dashboard: createInMemoryDashboardStore(),
|
||||||
dashboardHostAssets: testDashboardHostAssets(),
|
dashboardHostAssets: testDashboardHostAssets(),
|
||||||
health: readyHealth(),
|
health: readyHealth(),
|
||||||
|
|||||||
@@ -1,10 +1,8 @@
|
|||||||
import {
|
import {
|
||||||
ANONYMOUS_PRINCIPAL,
|
ANONYMOUS_PRINCIPAL,
|
||||||
createAuthenticatedPrincipal,
|
createAuthenticatedPrincipal,
|
||||||
type ModuleDatabase,
|
|
||||||
type ModuleRouteHandler,
|
type ModuleRouteHandler,
|
||||||
type ModuleRouteHandlerContext,
|
type ModuleRouteHandlerContext,
|
||||||
type ModuleRuntimeServices,
|
|
||||||
moduleRoutesOverlap,
|
moduleRoutesOverlap,
|
||||||
type PermissionId,
|
type PermissionId,
|
||||||
parsePermissionId,
|
parsePermissionId,
|
||||||
@@ -20,6 +18,8 @@ import type { EkklesiaIdentityRuntime } from "@ekklesia/core/identity";
|
|||||||
import {
|
import {
|
||||||
createModuleUiPublicationRegistry,
|
createModuleUiPublicationRegistry,
|
||||||
EMPTY_MODULE_REALTIME_REGISTRY,
|
EMPTY_MODULE_REALTIME_REGISTRY,
|
||||||
|
type LegacyModuleRuntimeServices,
|
||||||
|
type ModuleDatabase,
|
||||||
type ModuleRuntimeServicesLookup,
|
type ModuleRuntimeServicesLookup,
|
||||||
} from "@ekklesia/core/modules";
|
} from "@ekklesia/core/modules";
|
||||||
import { describe, expect, it, vi } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
@@ -30,6 +30,7 @@ import {
|
|||||||
mergedVaryHeader,
|
mergedVaryHeader,
|
||||||
} from "../src/module-http-routes.js";
|
} from "../src/module-http-routes.js";
|
||||||
import { testDashboardHostAssets } from "./support/dashboard-host-assets.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 { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
|
||||||
import { emptyModuleConfiguration } from "./support/in-memory-module-configuration.js";
|
import { emptyModuleConfiguration } from "./support/in-memory-module-configuration.js";
|
||||||
import { emptyModuleLifecycle } from "./support/in-memory-module-lifecycle.js";
|
import { emptyModuleLifecycle } from "./support/in-memory-module-lifecycle.js";
|
||||||
@@ -74,11 +75,11 @@ function moduleRoute(
|
|||||||
return Object.freeze({ bodyMode: "buffered", maxBodyBytes: 1024, ...fields });
|
return Object.freeze({ bodyMode: "buffered", maxBodyBytes: 1024, ...fields });
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A small typed fake `ModuleRuntimeServices` — these tests exercise route mounting,
|
/** A small typed fake `LegacyModuleRuntimeServices` — these tests exercise route mounting,
|
||||||
* authorization, and response sanitization, never real database behavior, so no PostgreSQL is
|
* authorization, and response sanitization, never real database behavior, so no PostgreSQL is
|
||||||
* required. Each `database` method stays a distinct spy so a test can assert none of them were
|
* required. Each `database` method stays a distinct spy so a test can assert none of them were
|
||||||
* ever called (denied, trusted-origin-rejected, and `OPTIONS` requests never reach a handler). */
|
* ever called (denied, trusted-origin-rejected, and `OPTIONS` requests never reach a handler). */
|
||||||
function fakeModuleServices(): ModuleRuntimeServices {
|
function fakeModuleServices(): LegacyModuleRuntimeServices {
|
||||||
const database = Object.freeze({
|
const database = Object.freeze({
|
||||||
delete: vi.fn(),
|
delete: vi.fn(),
|
||||||
execute: vi.fn(),
|
execute: vi.fn(),
|
||||||
@@ -92,7 +93,7 @@ function fakeModuleServices(): ModuleRuntimeServices {
|
|||||||
return Object.freeze({ database });
|
return Object.freeze({ database });
|
||||||
}
|
}
|
||||||
|
|
||||||
function expectDatabaseFakeUntouched(services: ModuleRuntimeServices): void {
|
function expectDatabaseFakeUntouched(services: LegacyModuleRuntimeServices): void {
|
||||||
expect(vi.mocked(services.database.select)).not.toHaveBeenCalled();
|
expect(vi.mocked(services.database.select)).not.toHaveBeenCalled();
|
||||||
expect(vi.mocked(services.database.selectDistinct)).not.toHaveBeenCalled();
|
expect(vi.mocked(services.database.selectDistinct)).not.toHaveBeenCalled();
|
||||||
expect(vi.mocked(services.database.selectDistinctOn)).not.toHaveBeenCalled();
|
expect(vi.mocked(services.database.selectDistinctOn)).not.toHaveBeenCalled();
|
||||||
@@ -108,7 +109,7 @@ function buildModuleApp(options: {
|
|||||||
readonly registry: PermissionRegistry;
|
readonly registry: PermissionRegistry;
|
||||||
readonly routes: readonly RegisteredModuleHttpRoute[];
|
readonly routes: readonly RegisteredModuleHttpRoute[];
|
||||||
readonly runtime?: EkklesiaIdentityRuntime;
|
readonly runtime?: EkklesiaIdentityRuntime;
|
||||||
readonly services?: ModuleRuntimeServices | ModuleRuntimeServicesLookup;
|
readonly services?: LegacyModuleRuntimeServices | ModuleRuntimeServicesLookup;
|
||||||
readonly trustedOrigins?: readonly string[];
|
readonly trustedOrigins?: readonly string[];
|
||||||
}) {
|
}) {
|
||||||
const evaluator = options.evaluator ?? {
|
const evaluator = options.evaluator ?? {
|
||||||
@@ -125,6 +126,7 @@ function buildModuleApp(options: {
|
|||||||
moduleLifecycle: emptyModuleLifecycle(),
|
moduleLifecycle: emptyModuleLifecycle(),
|
||||||
moduleConfiguration: emptyModuleConfiguration(),
|
moduleConfiguration: emptyModuleConfiguration(),
|
||||||
moduleStorage: emptyModuleStorage(),
|
moduleStorage: emptyModuleStorage(),
|
||||||
|
account: createInMemoryAccountStore(),
|
||||||
dashboard: createInMemoryDashboardStore(),
|
dashboard: createInMemoryDashboardStore(),
|
||||||
dashboardHostAssets: testDashboardHostAssets(),
|
dashboardHostAssets: testDashboardHostAssets(),
|
||||||
health: readyHealth(),
|
health: readyHealth(),
|
||||||
@@ -539,7 +541,7 @@ describe("module route authorization", () => {
|
|||||||
expect(response.status).toBe(403);
|
expect(response.status).toBe(403);
|
||||||
expect(await response.json()).toEqual({ code: "AUTHORIZATION_DENIED" });
|
expect(await response.json()).toEqual({ code: "AUTHORIZATION_DENIED" });
|
||||||
expect(handler).not.toHaveBeenCalled();
|
expect(handler).not.toHaveBeenCalled();
|
||||||
expectDatabaseFakeUntouched(services as ModuleRuntimeServices);
|
expectDatabaseFakeUntouched(services as LegacyModuleRuntimeServices);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does not invoke the handler when principal resolution fails", async () => {
|
it("does not invoke the handler when principal resolution fails", async () => {
|
||||||
@@ -668,7 +670,7 @@ describe("module route trusted-origin enforcement", () => {
|
|||||||
}
|
}
|
||||||
expect(handler).not.toHaveBeenCalled();
|
expect(handler).not.toHaveBeenCalled();
|
||||||
expect(evaluator.authorize).not.toHaveBeenCalled();
|
expect(evaluator.authorize).not.toHaveBeenCalled();
|
||||||
expectDatabaseFakeUntouched(services as ModuleRuntimeServices);
|
expectDatabaseFakeUntouched(services as LegacyModuleRuntimeServices);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("allows a trusted-origin state-changing request through to the handler", async () => {
|
it("allows a trusted-origin state-changing request through to the handler", async () => {
|
||||||
@@ -797,7 +799,7 @@ describe("module route CORS", () => {
|
|||||||
|
|
||||||
expect(handler).not.toHaveBeenCalled();
|
expect(handler).not.toHaveBeenCalled();
|
||||||
expect(evaluator.authorize).not.toHaveBeenCalled();
|
expect(evaluator.authorize).not.toHaveBeenCalled();
|
||||||
expectDatabaseFakeUntouched(services as ModuleRuntimeServices);
|
expectDatabaseFakeUntouched(services as LegacyModuleRuntimeServices);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("never reflects an untrusted origin for a module route", async () => {
|
it("never reflects an untrusted origin for a module route", async () => {
|
||||||
|
|||||||
@@ -5,8 +5,6 @@ import {
|
|||||||
ANONYMOUS_PRINCIPAL,
|
ANONYMOUS_PRINCIPAL,
|
||||||
createAuthenticatedPrincipal,
|
createAuthenticatedPrincipal,
|
||||||
type EkklesiaPrincipal,
|
type EkklesiaPrincipal,
|
||||||
type ModuleDatabase,
|
|
||||||
type ModuleRuntimeServices,
|
|
||||||
parseEkklesiaUserId,
|
parseEkklesiaUserId,
|
||||||
} from "@ekklesia/contracts";
|
} from "@ekklesia/contracts";
|
||||||
import {
|
import {
|
||||||
@@ -20,6 +18,8 @@ import {
|
|||||||
createModuleUiPublicationRegistry,
|
createModuleUiPublicationRegistry,
|
||||||
type DiscoveredModule,
|
type DiscoveredModule,
|
||||||
discoverModules,
|
discoverModules,
|
||||||
|
type LegacyModuleRuntimeServices,
|
||||||
|
type ModuleDatabase,
|
||||||
type ModuleLifecycleStore,
|
type ModuleLifecycleStore,
|
||||||
ModuleLifecycleStoreError,
|
ModuleLifecycleStoreError,
|
||||||
EMPTY_MODULE_REALTIME_REGISTRY,
|
EMPTY_MODULE_REALTIME_REGISTRY,
|
||||||
@@ -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 { CORE_MUTATION_MAX_BODY_BYTES } from "../src/body-limits.js";
|
||||||
import { MODULE_LIFECYCLE_PATH } from "../src/module-lifecycle-routes.js";
|
import { MODULE_LIFECYCLE_PATH } from "../src/module-lifecycle-routes.js";
|
||||||
import { MODULE_UI_INVENTORY_PATH } from "../src/module-ui-inventory.js";
|
import { MODULE_UI_INVENTORY_PATH } from "../src/module-ui-inventory.js";
|
||||||
|
import { createInMemoryAccountStore } from "./support/in-memory-account-store.js";
|
||||||
import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
|
import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
|
||||||
import { testDashboardHostAssets } from "./support/dashboard-host-assets.js";
|
import { testDashboardHostAssets } from "./support/dashboard-host-assets.js";
|
||||||
import { readyHealth } from "./support/ready-health.js";
|
import { readyHealth } from "./support/ready-health.js";
|
||||||
@@ -172,6 +173,7 @@ async function install(options: InstallOptions = {}): Promise<Installation> {
|
|||||||
registry: composePlatformPermissionRegistry(modules),
|
registry: composePlatformPermissionRegistry(modules),
|
||||||
store: authorizationStore,
|
store: authorizationStore,
|
||||||
},
|
},
|
||||||
|
account: createInMemoryAccountStore(),
|
||||||
dashboard: createInMemoryDashboardStore(),
|
dashboard: createInMemoryDashboardStore(),
|
||||||
dashboardHostAssets: testDashboardHostAssets(),
|
dashboardHostAssets: testDashboardHostAssets(),
|
||||||
health: readyHealth(),
|
health: readyHealth(),
|
||||||
@@ -189,7 +191,7 @@ async function install(options: InstallOptions = {}): Promise<Installation> {
|
|||||||
moduleRealtime: EMPTY_MODULE_REALTIME_REGISTRY,
|
moduleRealtime: EMPTY_MODULE_REALTIME_REGISTRY,
|
||||||
moduleServices: Object.freeze({
|
moduleServices: Object.freeze({
|
||||||
database: Object.freeze({}) as unknown as ModuleDatabase,
|
database: Object.freeze({}) as unknown as ModuleDatabase,
|
||||||
}) as ModuleRuntimeServices,
|
}) as LegacyModuleRuntimeServices,
|
||||||
moduleUi: createModuleUiPublicationRegistry(eligibility.eligibleModules),
|
moduleUi: createModuleUiPublicationRegistry(eligibility.eligibleModules),
|
||||||
runtime,
|
runtime,
|
||||||
trustedOrigins: [trustedOrigin],
|
trustedOrigins: [trustedOrigin],
|
||||||
|
|||||||
@@ -4,9 +4,7 @@ import path from "node:path";
|
|||||||
import {
|
import {
|
||||||
ANONYMOUS_PRINCIPAL,
|
ANONYMOUS_PRINCIPAL,
|
||||||
createAuthenticatedPrincipal,
|
createAuthenticatedPrincipal,
|
||||||
type ModuleDatabase,
|
|
||||||
type ModuleRealtimePublisher,
|
type ModuleRealtimePublisher,
|
||||||
type ModuleRuntimeServices,
|
|
||||||
parseEkklesiaUserId,
|
parseEkklesiaUserId,
|
||||||
} from "@ekklesia/contracts";
|
} from "@ekklesia/contracts";
|
||||||
import {
|
import {
|
||||||
@@ -18,6 +16,8 @@ import type { EkklesiaIdentityRuntime } from "@ekklesia/core/identity";
|
|||||||
import {
|
import {
|
||||||
composePlatformPermissionRegistry,
|
composePlatformPermissionRegistry,
|
||||||
discoverModules,
|
discoverModules,
|
||||||
|
type LegacyModuleRuntimeServices,
|
||||||
|
type ModuleDatabase,
|
||||||
type ModuleRealtimeRegistry,
|
type ModuleRealtimeRegistry,
|
||||||
type ModuleRealtimeSubscription,
|
type ModuleRealtimeSubscription,
|
||||||
startDiscoveredModuleRuntime,
|
startDiscoveredModuleRuntime,
|
||||||
@@ -226,7 +226,7 @@ async function createFixtureModule(control: FixtureControl): Promise<{
|
|||||||
return { directory, symbol };
|
return { directory, symbol };
|
||||||
}
|
}
|
||||||
|
|
||||||
function services(): ModuleRuntimeServices {
|
function services(): LegacyModuleRuntimeServices {
|
||||||
return Object.freeze({ database: Object.freeze({}) as ModuleDatabase });
|
return Object.freeze({ database: Object.freeze({}) as ModuleDatabase });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,8 +5,6 @@ import {
|
|||||||
ANONYMOUS_PRINCIPAL,
|
ANONYMOUS_PRINCIPAL,
|
||||||
createAuthenticatedPrincipal,
|
createAuthenticatedPrincipal,
|
||||||
type EkklesiaPrincipal,
|
type EkklesiaPrincipal,
|
||||||
type ModuleDatabase,
|
|
||||||
type ModuleRuntimeServices,
|
|
||||||
parseEkklesiaUserId,
|
parseEkklesiaUserId,
|
||||||
} from "@ekklesia/contracts";
|
} from "@ekklesia/contracts";
|
||||||
import {
|
import {
|
||||||
@@ -18,6 +16,8 @@ import {
|
|||||||
composePlatformPermissionRegistry,
|
composePlatformPermissionRegistry,
|
||||||
createModuleUiPublicationRegistry,
|
createModuleUiPublicationRegistry,
|
||||||
discoverModules,
|
discoverModules,
|
||||||
|
type LegacyModuleRuntimeServices,
|
||||||
|
type ModuleDatabase,
|
||||||
startDiscoveredModuleRuntime,
|
startDiscoveredModuleRuntime,
|
||||||
} from "@ekklesia/core/modules";
|
} from "@ekklesia/core/modules";
|
||||||
import {
|
import {
|
||||||
@@ -33,8 +33,10 @@ import {
|
|||||||
createModuleUiContext,
|
createModuleUiContext,
|
||||||
type EkklesiaHostConfig,
|
type EkklesiaHostConfig,
|
||||||
moduleUiSourceUrl,
|
moduleUiSourceUrl,
|
||||||
|
saveHostAccountPreferences,
|
||||||
} from "@ekklesia/shell";
|
} from "@ekklesia/shell";
|
||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { createInMemoryAccountStore } from "./support/in-memory-account-store.js";
|
||||||
import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
|
import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
|
||||||
import { testDashboardHostAssets } from "./support/dashboard-host-assets.js";
|
import { testDashboardHostAssets } from "./support/dashboard-host-assets.js";
|
||||||
import { readyHealth } from "./support/ready-health.js";
|
import { readyHealth } from "./support/ready-health.js";
|
||||||
@@ -216,6 +218,8 @@ interface HarnessOptions {
|
|||||||
* single module proves very little. */
|
* single module proves very little. */
|
||||||
readonly extraModules?: ReadonlyArray<{ readonly id: string; readonly name: string }>;
|
readonly extraModules?: ReadonlyArray<{ readonly id: string; readonly name: string }>;
|
||||||
readonly platformOwner?: boolean;
|
readonly platformOwner?: boolean;
|
||||||
|
/** People with an account, for the account area's routes (ADR 0062). */
|
||||||
|
readonly people?: Parameters<typeof createInMemoryAccountStore>[0];
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Harness {
|
interface Harness {
|
||||||
@@ -241,7 +245,7 @@ async function startHarness(options: HarnessOptions = {}): Promise<Harness> {
|
|||||||
modulesDirectory,
|
modulesDirectory,
|
||||||
platformVersion: "0.1.0",
|
platformVersion: "0.1.0",
|
||||||
});
|
});
|
||||||
const services: ModuleRuntimeServices = Object.freeze({
|
const services: LegacyModuleRuntimeServices = Object.freeze({
|
||||||
database: Object.freeze({}) as unknown as ModuleDatabase,
|
database: Object.freeze({}) as unknown as ModuleDatabase,
|
||||||
});
|
});
|
||||||
// The module's real runtime: its server entrypoint is imported, registered, and started, and the
|
// The module's real runtime: its server entrypoint is imported, registered, and started, and the
|
||||||
@@ -289,6 +293,7 @@ async function startHarness(options: HarnessOptions = {}): Promise<Harness> {
|
|||||||
},
|
},
|
||||||
moduleConfiguration: moduleConfigurationOptionFor(modules),
|
moduleConfiguration: moduleConfigurationOptionFor(modules),
|
||||||
moduleStorage: moduleStorageOptionFor(modules),
|
moduleStorage: moduleStorageOptionFor(modules),
|
||||||
|
account: createInMemoryAccountStore(options.people ?? []),
|
||||||
dashboard: createInMemoryDashboardStore(),
|
dashboard: createInMemoryDashboardStore(),
|
||||||
dashboardHostAssets: testDashboardHostAssets(),
|
dashboardHostAssets: testDashboardHostAssets(),
|
||||||
health: readyHealth(),
|
health: readyHealth(),
|
||||||
@@ -302,7 +307,10 @@ async function startHarness(options: HarnessOptions = {}): Promise<Harness> {
|
|||||||
if (url.pathname.startsWith("/api/v1/modules/")) {
|
if (url.pathname.startsWith("/api/v1/modules/")) {
|
||||||
moduleApiCalls.push(`${init?.method ?? "GET"} ${url.pathname}${url.search}`);
|
moduleApiCalls.push(`${init?.method ?? "GET"} ${url.pathname}${url.search}`);
|
||||||
}
|
}
|
||||||
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;
|
}) as unknown as typeof fetch;
|
||||||
|
|
||||||
return {
|
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 () => {
|
it("gives an anonymous visitor no inventory and therefore nothing to mount", async () => {
|
||||||
const harness = await startHarness();
|
const harness = await startHarness();
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -5,8 +5,6 @@ import {
|
|||||||
ANONYMOUS_PRINCIPAL,
|
ANONYMOUS_PRINCIPAL,
|
||||||
createAuthenticatedPrincipal,
|
createAuthenticatedPrincipal,
|
||||||
type EkklesiaPrincipal,
|
type EkklesiaPrincipal,
|
||||||
type ModuleDatabase,
|
|
||||||
type ModuleRuntimeServices,
|
|
||||||
parseEkklesiaUserId,
|
parseEkklesiaUserId,
|
||||||
} from "@ekklesia/contracts";
|
} from "@ekklesia/contracts";
|
||||||
import {
|
import {
|
||||||
@@ -21,9 +19,12 @@ import {
|
|||||||
type DiscoveredModule,
|
type DiscoveredModule,
|
||||||
discoverModules,
|
discoverModules,
|
||||||
EMPTY_MODULE_REALTIME_REGISTRY,
|
EMPTY_MODULE_REALTIME_REGISTRY,
|
||||||
|
type LegacyModuleRuntimeServices,
|
||||||
|
type ModuleDatabase,
|
||||||
} from "@ekklesia/core/modules";
|
} from "@ekklesia/core/modules";
|
||||||
import type { Hono } from "hono";
|
import type { Hono } from "hono";
|
||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { createInMemoryAccountStore } from "./support/in-memory-account-store.js";
|
||||||
import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
|
import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
|
||||||
import { testDashboardHostAssets } from "./support/dashboard-host-assets.js";
|
import { testDashboardHostAssets } from "./support/dashboard-host-assets.js";
|
||||||
import { readyHealth } from "./support/ready-health.js";
|
import { readyHealth } from "./support/ready-health.js";
|
||||||
@@ -192,7 +193,7 @@ async function install(options: InstallOptions): Promise<Installation> {
|
|||||||
revokeGrant: vi.fn(),
|
revokeGrant: vi.fn(),
|
||||||
revokePlatformOwner: vi.fn(),
|
revokePlatformOwner: vi.fn(),
|
||||||
} as unknown as AuthorizationStore;
|
} as unknown as AuthorizationStore;
|
||||||
const services: ModuleRuntimeServices = Object.freeze({
|
const services: LegacyModuleRuntimeServices = Object.freeze({
|
||||||
database: Object.freeze({}) as unknown as ModuleDatabase,
|
database: Object.freeze({}) as unknown as ModuleDatabase,
|
||||||
});
|
});
|
||||||
const permissionRegistry = composePlatformPermissionRegistry(modules);
|
const permissionRegistry = composePlatformPermissionRegistry(modules);
|
||||||
@@ -209,6 +210,7 @@ async function install(options: InstallOptions): Promise<Installation> {
|
|||||||
},
|
},
|
||||||
moduleConfiguration: moduleConfigurationOptionFor(modules),
|
moduleConfiguration: moduleConfigurationOptionFor(modules),
|
||||||
moduleStorage: moduleStorageOptionFor(modules),
|
moduleStorage: moduleStorageOptionFor(modules),
|
||||||
|
account: createInMemoryAccountStore(),
|
||||||
dashboard: createInMemoryDashboardStore(),
|
dashboard: createInMemoryDashboardStore(),
|
||||||
dashboardHostAssets: testDashboardHostAssets(),
|
dashboardHostAssets: testDashboardHostAssets(),
|
||||||
health: readyHealth(),
|
health: readyHealth(),
|
||||||
|
|||||||
@@ -1,11 +1,7 @@
|
|||||||
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import {
|
import { ANONYMOUS_PRINCIPAL } from "@ekklesia/contracts";
|
||||||
ANONYMOUS_PRINCIPAL,
|
|
||||||
type ModuleDatabase,
|
|
||||||
type ModuleRuntimeServices,
|
|
||||||
} from "@ekklesia/contracts";
|
|
||||||
import type { AuthorizationEvaluator, AuthorizationStore } from "@ekklesia/core/authorization";
|
import type { AuthorizationEvaluator, AuthorizationStore } from "@ekklesia/core/authorization";
|
||||||
import type { EkklesiaIdentityRuntime } from "@ekklesia/core/identity";
|
import type { EkklesiaIdentityRuntime } from "@ekklesia/core/identity";
|
||||||
import {
|
import {
|
||||||
@@ -14,12 +10,15 @@ import {
|
|||||||
type DiscoveredModule,
|
type DiscoveredModule,
|
||||||
discoverModules,
|
discoverModules,
|
||||||
EMPTY_MODULE_REALTIME_REGISTRY,
|
EMPTY_MODULE_REALTIME_REGISTRY,
|
||||||
|
type LegacyModuleRuntimeServices,
|
||||||
|
type ModuleDatabase,
|
||||||
} from "@ekklesia/core/modules";
|
} from "@ekklesia/core/modules";
|
||||||
import { loadModuleUi } from "@ekklesia/module-ui-host";
|
import { loadModuleUi } from "@ekklesia/module-ui-host";
|
||||||
import { MODULE_UI_ABI_VERSION, NO_MODULE_UI_CONTRIBUTIONS } from "@ekklesia/module-ui-sdk";
|
import { MODULE_UI_ABI_VERSION, NO_MODULE_UI_CONTRIBUTIONS } from "@ekklesia/module-ui-sdk";
|
||||||
import { Window } from "happy-dom";
|
import { Window } from "happy-dom";
|
||||||
import type { Hono } from "hono";
|
import type { Hono } from "hono";
|
||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { createInMemoryAccountStore } from "./support/in-memory-account-store.js";
|
||||||
import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
|
import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
|
||||||
import { testDashboardHostAssets } from "./support/dashboard-host-assets.js";
|
import { testDashboardHostAssets } from "./support/dashboard-host-assets.js";
|
||||||
import { readyHealth } from "./support/ready-health.js";
|
import { readyHealth } from "./support/ready-health.js";
|
||||||
@@ -215,7 +214,7 @@ async function install(
|
|||||||
: modules.filter((module) => publicationModuleIds.includes(module.manifest.id));
|
: modules.filter((module) => publicationModuleIds.includes(module.manifest.id));
|
||||||
|
|
||||||
const database = Object.freeze({}) as unknown as ModuleDatabase;
|
const database = Object.freeze({}) as unknown as ModuleDatabase;
|
||||||
const services: ModuleRuntimeServices = Object.freeze({ database });
|
const services: LegacyModuleRuntimeServices = Object.freeze({ database });
|
||||||
const runtime: EkklesiaIdentityRuntime = {
|
const runtime: EkklesiaIdentityRuntime = {
|
||||||
handleAuthRequest: vi.fn().mockResolvedValue(new Response(null, { status: 200 })),
|
handleAuthRequest: vi.fn().mockResolvedValue(new Response(null, { status: 200 })),
|
||||||
// Anonymous, and every authorization decision denied: whatever this route does, it does it
|
// Anonymous, and every authorization decision denied: whatever this route does, it does it
|
||||||
@@ -256,6 +255,7 @@ async function install(
|
|||||||
},
|
},
|
||||||
moduleConfiguration: moduleConfigurationOptionFor(modules),
|
moduleConfiguration: moduleConfigurationOptionFor(modules),
|
||||||
moduleStorage: moduleStorageOptionFor(modules),
|
moduleStorage: moduleStorageOptionFor(modules),
|
||||||
|
account: createInMemoryAccountStore(),
|
||||||
dashboard: createInMemoryDashboardStore(),
|
dashboard: createInMemoryDashboardStore(),
|
||||||
dashboardHostAssets: testDashboardHostAssets(),
|
dashboardHostAssets: testDashboardHostAssets(),
|
||||||
health: readyHealth(),
|
health: readyHealth(),
|
||||||
|
|||||||
@@ -9,5 +9,6 @@ export function testDashboardHostAssets(): DashboardHostAssets {
|
|||||||
return createDashboardHostAssets(
|
return createDashboardHostAssets(
|
||||||
'<!doctype html><div id="ekklesia-host"></div><script src="/assets/ekklesia-dashboard/app.js" type="module"></script>',
|
'<!doctype html><div id="ekklesia-host"></div><script src="/assets/ekklesia-dashboard/app.js" type="module"></script>',
|
||||||
"export const dashboardHost = true;\n",
|
"export const dashboardHost = true;\n",
|
||||||
|
"(() => {})();\n",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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": {
|
"linter": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"rules": {
|
"rules": {
|
||||||
"preset": "recommended"
|
"preset": "recommended",
|
||||||
|
"suspicious": {
|
||||||
|
"noAlert": "error"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"javascript": {
|
"javascript": {
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ export default defineConfig({
|
|||||||
"./src/modules/lifecycle-schema.ts",
|
"./src/modules/lifecycle-schema.ts",
|
||||||
"./src/modules/configuration-schema.ts",
|
"./src/modules/configuration-schema.ts",
|
||||||
"./src/devices/schema.ts",
|
"./src/devices/schema.ts",
|
||||||
|
"./src/account/schema.ts",
|
||||||
],
|
],
|
||||||
out: "./drizzle",
|
out: "./drizzle",
|
||||||
migrations: {
|
migrations: {
|
||||||
|
|||||||
@@ -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": {
|
"destructive": {
|
||||||
"mode": "none"
|
"mode": "none"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tag": "0008_user_preferences",
|
||||||
|
"destructive": {
|
||||||
|
"mode": "none"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -57,6 +57,13 @@
|
|||||||
"when": 1787543773058,
|
"when": 1787543773058,
|
||||||
"tag": "0007_verified_bootstrap_owners",
|
"tag": "0007_verified_bootstrap_owners",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 8,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1792036365485,
|
||||||
|
"tag": "0008_user_preferences",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
+5
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@ekklesia/core",
|
"name": "@ekklesia/core",
|
||||||
"version": "0.1.2",
|
"version": "0.2.5",
|
||||||
"license": "AGPL-3.0-or-later",
|
"license": "AGPL-3.0-or-later",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
@@ -12,6 +12,10 @@
|
|||||||
"types": "./dist/index.d.ts",
|
"types": "./dist/index.d.ts",
|
||||||
"import": "./dist/index.js"
|
"import": "./dist/index.js"
|
||||||
},
|
},
|
||||||
|
"./account": {
|
||||||
|
"types": "./dist/account/index.d.ts",
|
||||||
|
"import": "./dist/account/index.js"
|
||||||
|
},
|
||||||
"./modules": {
|
"./modules": {
|
||||||
"types": "./dist/modules/index.d.ts",
|
"types": "./dist/modules/index.d.ts",
|
||||||
"import": "./dist/modules/index.js"
|
"import": "./dist/modules/index.js"
|
||||||
|
|||||||
@@ -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");
|
export const CORE_DEVICES_MANAGE: PermissionId = parsePermissionId("core.devices.manage");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Authenticated: every signed-in person may read their own name and email and read and change their
|
||||||
|
* own preferences (ADR 0062). Choosing dark mode is not church administration, so it needs no
|
||||||
|
* grant, and a Dashboard where it did would be unusable for its ordinary users. It is a declared
|
||||||
|
* permission rather than a hand-written "is signed in" check, as `core.modules.ui.read` is
|
||||||
|
* (ADR 0026), so the account area is authorized by the same evaluator as everything else.
|
||||||
|
*
|
||||||
|
* **It is self-only by construction.** The routes it guards take the account from the
|
||||||
|
* authenticated session and accept no user id at all, so holding it — whether through the baseline
|
||||||
|
* or a grant — confers no ability to read or change anyone else's account. A device never satisfies
|
||||||
|
* the `authenticated` baseline: a screen in the foyer has no account to read.
|
||||||
|
*/
|
||||||
|
export const CORE_ACCOUNT_SELF_MANAGE: PermissionId = parsePermissionId("core.account.self.manage");
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Core's own declarations, exported in deeply immutable form so a trusted composer (the module
|
* Core's own declarations, exported in deeply immutable form so a trusted composer (the module
|
||||||
* integration layer, `core/src/modules/permissions.ts`) can build one combined registry from
|
* integration layer, `core/src/modules/permissions.ts`) can build one combined registry from
|
||||||
@@ -207,6 +221,11 @@ export const CORE_PERMISSION_DECLARATIONS: readonly PermissionDeclaration[] = Ob
|
|||||||
description: "Approve display device pairing, list paired devices, and revoke a device.",
|
description: "Approve display device pairing, list paired devices, and revoke a device.",
|
||||||
id: CORE_DEVICES_MANAGE,
|
id: CORE_DEVICES_MANAGE,
|
||||||
}),
|
}),
|
||||||
|
Object.freeze({
|
||||||
|
baseline: "authenticated" as const,
|
||||||
|
description: "Read your own account and read and change your own preferences.",
|
||||||
|
id: CORE_ACCOUNT_SELF_MANAGE,
|
||||||
|
}),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ class VerifyArgumentsError extends Error {}
|
|||||||
|
|
||||||
const USAGE =
|
const USAGE =
|
||||||
"Usage: verify-module-package <archive> [--sha256 <hex> | --sha256-file <file>] [--extract-to <new directory>]";
|
"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 {
|
export interface VerifyModulePackageArguments {
|
||||||
readonly archivePath: string;
|
readonly archivePath: string;
|
||||||
@@ -90,6 +91,16 @@ export function parseSha256File(contents: string): string {
|
|||||||
return match[1].toLowerCase();
|
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>> {
|
async function readJson(file: string): Promise<Record<string, unknown>> {
|
||||||
return JSON.parse(await readFile(file, "utf8")) as Record<string, unknown>;
|
return JSON.parse(await readFile(file, "utf8")) as Record<string, unknown>;
|
||||||
}
|
}
|
||||||
@@ -121,6 +132,7 @@ export async function readModulePackagePlatform(
|
|||||||
|
|
||||||
async function main(): Promise<void> {
|
async function main(): Promise<void> {
|
||||||
const parsed = parseVerifyModulePackageArguments(process.argv.slice(2));
|
const parsed = parseVerifyModulePackageArguments(process.argv.slice(2));
|
||||||
|
const executionScratchDirectory = moduleVerifierScratchDirectory(process.env);
|
||||||
let expectedSha256 = parsed.expectedSha256;
|
let expectedSha256 = parsed.expectedSha256;
|
||||||
if (parsed.sha256File !== undefined) {
|
if (parsed.sha256File !== undefined) {
|
||||||
let contents: string;
|
let contents: string;
|
||||||
@@ -137,6 +149,7 @@ async function main(): Promise<void> {
|
|||||||
const result = await verifyModulePackage({
|
const result = await verifyModulePackage({
|
||||||
archivePath: parsed.archivePath,
|
archivePath: parsed.archivePath,
|
||||||
platform,
|
platform,
|
||||||
|
...(executionScratchDirectory === undefined ? {} : { executionScratchDirectory }),
|
||||||
...(expectedSha256 === undefined ? {} : { expectedSha256 }),
|
...(expectedSha256 === undefined ? {} : { expectedSha256 }),
|
||||||
...(parsed.extractTo === undefined ? {} : { extractTo: parsed.extractTo }),
|
...(parsed.extractTo === undefined ? {} : { extractTo: parsed.extractTo }),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -44,17 +44,26 @@ export class DeviceStoreError extends Error {
|
|||||||
export const DEVICE_PAIRING_REQUEST_TTL_MS = 10 * 60_000;
|
export const DEVICE_PAIRING_REQUEST_TTL_MS = 10 * 60_000;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* How long a device credential is accepted before the device must pair again.
|
* How long a device credential may go **unused** before the device must pair again (ADR 0056).
|
||||||
*
|
*
|
||||||
* A display runs unattended for months; forcing a signage player to be re-paired every fortnight
|
* The window slides: every request from the device moves `lastSeenAt` forward, and the host
|
||||||
* because human sessions expire that fast would mean someone climbing to a ceiling-mounted screen
|
* re-issues the cookie with a fresh lifetime, so a screen that is in use stays paired until an
|
||||||
* on a schedule. What makes a long horizon safe is that it is not the security boundary: the server
|
* administrator revokes it. Revocation, not expiry, is the security boundary — the server checks it
|
||||||
* decides on every single request whether the device is still revoked, so an administrator's revoke
|
* on every single request. The window exists only so an abandoned, forgotten screen eventually stops
|
||||||
* takes effect immediately regardless of how long the credential would otherwise have lived. The
|
* being an identity at all: 180 days after it was last seen, its credential is dead.
|
||||||
* expiry exists so an abandoned, forgotten screen eventually stops being an identity at all.
|
|
||||||
*/
|
*/
|
||||||
export const DEVICE_CREDENTIAL_TTL_MS = 180 * 24 * 60 * 60_000;
|
export const DEVICE_CREDENTIAL_TTL_MS = 180 * 24 * 60 * 60_000;
|
||||||
|
|
||||||
|
/** When a device credential expires: the idle window, measured from the last time the device was
|
||||||
|
* seen, or from pairing if it never has been. */
|
||||||
|
export function deviceCredentialExpiresAt(input: {
|
||||||
|
readonly createdAt: Date;
|
||||||
|
readonly lastSeenAt?: Date | null | undefined;
|
||||||
|
}): Date {
|
||||||
|
const anchor = Math.max(input.createdAt.getTime(), input.lastSeenAt?.getTime() ?? 0);
|
||||||
|
return new Date(anchor + DEVICE_CREDENTIAL_TTL_MS);
|
||||||
|
}
|
||||||
|
|
||||||
/** How stale `lastSeenAt` may become before the resolver spends a write on it. A display polling
|
/** How stale `lastSeenAt` may become before the resolver spends a write on it. A display polling
|
||||||
* realtime and assets would otherwise turn an operational nicety into a continuous write load. */
|
* realtime and assets would otherwise turn an operational nicety into a continuous write load. */
|
||||||
export const DEVICE_LAST_SEEN_THROTTLE_MS = 15 * 60_000;
|
export const DEVICE_LAST_SEEN_THROTTLE_MS = 15 * 60_000;
|
||||||
@@ -475,7 +484,8 @@ export function createDeviceStore(db: DeviceDrizzleDatabase): DeviceStore {
|
|||||||
}
|
}
|
||||||
// The credential's own horizon, measured from when the device was paired. Revocation is
|
// The credential's own horizon, measured from when the device was paired. Revocation is
|
||||||
// checked above and is what makes a long horizon acceptable.
|
// checked above and is what makes a long horizon acceptable.
|
||||||
if (row.createdAt.getTime() + DEVICE_CREDENTIAL_TTL_MS <= now.getTime()) {
|
// Expiry is measured from last use, not from pairing, so a screen in use never ages out.
|
||||||
|
if (deviceCredentialExpiresAt(row).getTime() <= now.getTime()) {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
return Object.freeze({
|
return Object.freeze({
|
||||||
|
|||||||
@@ -1,7 +1,54 @@
|
|||||||
import type { ModuleDatabase } from "@ekklesia/contracts";
|
import type { ModuleDatabaseAbiRuntimeServices, ModuleRuntimeServices } from "@ekklesia/contracts";
|
||||||
import { drizzle } from "drizzle-orm/node-postgres";
|
import { drizzle, type NodePgDatabase } from "drizzle-orm/node-postgres";
|
||||||
import type { Pool } from "pg";
|
import type { Pool } from "pg";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A trusted backend module's typed Drizzle query surface — the same query-building methods a
|
||||||
|
* platform-owned Drizzle instance exposes, restricted to exactly the operations a module needs to
|
||||||
|
* read and write its own tables. Derived with `Pick` from the real `drizzle-orm/node-postgres`
|
||||||
|
* database type rather than hand-declared, so every method keeps Drizzle's own table-aware
|
||||||
|
* argument and result inference exactly as-is — a module calling `database.select().from(table)`
|
||||||
|
* or `database.insert(table).values(...)` gets the identical typed builder a direct Drizzle caller
|
||||||
|
* would, with no `any`/`unknown` erasure anywhere in the chain.
|
||||||
|
*
|
||||||
|
* Deliberately excludes everything `NodePgDatabase` or the `drizzle(...)` factory otherwise
|
||||||
|
* exposes: `$client` (the raw `pg` client/pool), `_` (internal session/dialect/schema state),
|
||||||
|
* `query` (a relational-query builder tied to one schema, which would have to span every
|
||||||
|
* installed module's tables to exist here at all), `$cache`, `$count`, `$with`, `with`, and
|
||||||
|
* `refreshMaterializedView`. There is no connection string, pool, session, or dialect object
|
||||||
|
* reachable through this type. Carries no schema generic — a module brings its own Drizzle table
|
||||||
|
* objects to each call, so there is no global relational schema spanning every installed module for
|
||||||
|
* this type to leak.
|
||||||
|
*
|
||||||
|
* Core's own, private type (ADR 0053): it moved here from `@ekklesia/contracts` so no published
|
||||||
|
* package references Drizzle. New modules use the Module Database ABI v1 instead (ADR 0050), and
|
||||||
|
* the Module SDK never exposes this facade's type.
|
||||||
|
*
|
||||||
|
* This is an integration boundary, not a security sandbox: modules are trusted, compiled backend
|
||||||
|
* plugins that already run arbitrary code in this process, exactly like every other lifecycle hook
|
||||||
|
* and HTTP handler they define. `ModuleDatabase` exists to keep a module's code Drizzle-typed and
|
||||||
|
* decoupled from how the platform physically connects to PostgreSQL — not to defend against a
|
||||||
|
* malicious module, which this facade cannot do and does not attempt to do.
|
||||||
|
*/
|
||||||
|
export type ModuleDatabase = Pick<
|
||||||
|
NodePgDatabase,
|
||||||
|
| "select"
|
||||||
|
| "selectDistinct"
|
||||||
|
| "selectDistinctOn"
|
||||||
|
| "insert"
|
||||||
|
| "update"
|
||||||
|
| "delete"
|
||||||
|
| "execute"
|
||||||
|
| "transaction"
|
||||||
|
>;
|
||||||
|
|
||||||
|
/** The services a legacy module's hooks and handlers receive: the legacy facade. */
|
||||||
|
export type LegacyModuleRuntimeServices = ModuleRuntimeServices<ModuleDatabase>;
|
||||||
|
/** The services Core hands any started module: the legacy facade or the ABI v1 executor. */
|
||||||
|
export type AnyModuleRuntimeServices =
|
||||||
|
| LegacyModuleRuntimeServices
|
||||||
|
| ModuleDatabaseAbiRuntimeServices;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A typed `ModuleDatabase` view over an externally owned pool — the module-facing counterpart to
|
* A typed `ModuleDatabase` view over an externally owned pool — the module-facing counterpart to
|
||||||
* `createIdentityDrizzleView` (`core/src/identity/database.ts`) and
|
* `createIdentityDrizzleView` (`core/src/identity/database.ts`) and
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
safeParseModuleManifest,
|
safeParseModuleManifest,
|
||||||
semanticVersionSchema,
|
semanticVersionSchema,
|
||||||
} from "@ekklesia/contracts";
|
} from "@ekklesia/contracts";
|
||||||
|
import { isReservedModuleId } from "./reserved-ids.js";
|
||||||
|
|
||||||
export const MODULE_MANIFEST_FILENAME = "ekklesia.module.json";
|
export const MODULE_MANIFEST_FILENAME = "ekklesia.module.json";
|
||||||
|
|
||||||
@@ -39,6 +40,7 @@ export type ModuleDiscoveryIssueCode =
|
|||||||
| "MODULE_ENTRYPOINT_INVALID"
|
| "MODULE_ENTRYPOINT_INVALID"
|
||||||
| "MODULE_ENTRYPOINT_MISSING"
|
| "MODULE_ENTRYPOINT_MISSING"
|
||||||
| "MODULE_ID_DUPLICATE"
|
| "MODULE_ID_DUPLICATE"
|
||||||
|
| "MODULE_ID_RESERVED"
|
||||||
| "MODULE_MANIFEST_INVALID"
|
| "MODULE_MANIFEST_INVALID"
|
||||||
| "MODULE_MANIFEST_MISSING"
|
| "MODULE_MANIFEST_MISSING"
|
||||||
| "MODULE_PACKAGE_INVALID"
|
| "MODULE_PACKAGE_INVALID"
|
||||||
@@ -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);
|
const artifacts = await resolveEntrypoints(moduleDirectory, directoryName, manifest, issues);
|
||||||
if (!artifacts) {
|
if (!artifacts) {
|
||||||
return undefined;
|
return undefined;
|
||||||
|
|||||||
@@ -9,9 +9,11 @@ export * from "./lifecycle-schema.js";
|
|||||||
export * from "./lifecycle-store.js";
|
export * from "./lifecycle-store.js";
|
||||||
export * from "./migration-discovery.js";
|
export * from "./migration-discovery.js";
|
||||||
export * from "./modules-directory.js";
|
export * from "./modules-directory.js";
|
||||||
|
export * from "./module-package-host.js";
|
||||||
export * from "./module-package-verification.js";
|
export * from "./module-package-verification.js";
|
||||||
export * from "./permissions.js";
|
export * from "./permissions.js";
|
||||||
export * from "./realtime.js";
|
export * from "./realtime.js";
|
||||||
|
export * from "./reserved-ids.js";
|
||||||
export * from "./runtime.js";
|
export * from "./runtime.js";
|
||||||
export * from "./schema-discovery.js";
|
export * from "./schema-discovery.js";
|
||||||
export * from "./ui-publication.js";
|
export * from "./ui-publication.js";
|
||||||
|
|||||||
+10
-10
@@ -1,15 +1,15 @@
|
|||||||
|
/**
|
||||||
|
* The legacy module schema definition (ADR 0007): a v8 module that declares no `database` default-
|
||||||
|
* exports `{ moduleId, tables }` with its Drizzle table objects themselves. Core alone validates it,
|
||||||
|
* for the legacy facade's deprecation window (ADR 0050). It lives here, beside Core's own Drizzle
|
||||||
|
* dependency, so that no package a module installs needs Drizzle at run time (ADR 0053).
|
||||||
|
*
|
||||||
|
* Erasable TypeScript with no relative imports: the verifier's probe imports this source file
|
||||||
|
* directly in tests (`module-package-probe.ts`).
|
||||||
|
*/
|
||||||
import { is } from "drizzle-orm";
|
import { is } from "drizzle-orm";
|
||||||
import { type AnyPgTable, PgTable } from "drizzle-orm/pg-core";
|
import { type AnyPgTable, PgTable } from "drizzle-orm/pg-core";
|
||||||
import { type ModuleId, moduleIdSchema } from "./identifiers.js";
|
import { type ModuleId, moduleIdSchema } from "@ekklesia/contracts";
|
||||||
|
|
||||||
export {
|
|
||||||
moduleTableLocalNameSchema,
|
|
||||||
moduleTableName,
|
|
||||||
moduleTableNamespace,
|
|
||||||
moduleTablePrefix,
|
|
||||||
POSTGRES_IDENTIFIER_MAX_LENGTH,
|
|
||||||
physicalModuleTableNameSchema,
|
|
||||||
} from "./module-schema-names.js";
|
|
||||||
|
|
||||||
export interface ModuleSchemaDefinition<
|
export interface ModuleSchemaDefinition<
|
||||||
Tables extends Readonly<Record<string, AnyPgTable>> = Readonly<Record<string, AnyPgTable>>,
|
Tables extends Readonly<Record<string, AnyPgTable>> = Readonly<Record<string, AnyPgTable>>,
|
||||||
@@ -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" }],
|
||||||
|
]);
|
||||||
@@ -9,14 +9,15 @@
|
|||||||
// The result travels over the IPC channel, never stdout, so nothing a module prints can forge it.
|
// 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.
|
// It carries fixed role and reason names only: never an error message, a path, or module output.
|
||||||
//
|
//
|
||||||
// Written as erasable TypeScript with no relative imports, so Node can run the source file directly
|
// Written as erasable TypeScript, so Node can run the source file directly in tests as well as the
|
||||||
// in tests as well as the compiled file in the image.
|
// 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 { pathToFileURL } from "node:url";
|
||||||
import {
|
import {
|
||||||
parseModuleRuntimeDefinition,
|
parseModuleRuntimeDefinition,
|
||||||
parseModuleSchemaDefinition,
|
|
||||||
parseModuleSchemaMetadataDefinition,
|
parseModuleSchemaMetadataDefinition,
|
||||||
} from "@ekklesia/contracts";
|
} from "@ekklesia/contracts";
|
||||||
|
import { parseModuleSchemaDefinition } from "./legacy-schema-definition.ts";
|
||||||
|
|
||||||
interface ProbeRequest {
|
interface ProbeRequest {
|
||||||
readonly databaseAbi?: 1;
|
readonly databaseAbi?: 1;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { fork } from "node:child_process";
|
import { fork } from "node:child_process";
|
||||||
import { createHash } from "node:crypto";
|
import { createHash } from "node:crypto";
|
||||||
import { chmod, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
import { chmod, mkdir, mkdtemp, readFile, realpath, rm, writeFile } from "node:fs/promises";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
@@ -18,6 +18,11 @@ import {
|
|||||||
safeParseModulePackageRuntimePackage,
|
safeParseModulePackageRuntimePackage,
|
||||||
} from "@ekklesia/contracts/module-package";
|
} from "@ekklesia/contracts/module-package";
|
||||||
import { discoverModules, MODULE_MANIFEST_FILENAME, ModuleDiscoveryError } from "./discovery.js";
|
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 { discoverModuleMigrations, ModuleMigrationDiscoveryError } from "./migration-discovery.js";
|
||||||
import {
|
import {
|
||||||
type ModulePackageArchive,
|
type ModulePackageArchive,
|
||||||
@@ -56,16 +61,20 @@ export type ModulePackageVerificationErrorCode =
|
|||||||
| "MODULE_PACKAGE_FILE_UNLISTED"
|
| "MODULE_PACKAGE_FILE_UNLISTED"
|
||||||
| "MODULE_PACKAGE_FORMAT_UNSUPPORTED"
|
| "MODULE_PACKAGE_FORMAT_UNSUPPORTED"
|
||||||
| "MODULE_PACKAGE_IDENTITY_MISMATCH"
|
| "MODULE_PACKAGE_IDENTITY_MISMATCH"
|
||||||
|
| "MODULE_PACKAGE_LIBC_INCOMPATIBLE"
|
||||||
| "MODULE_PACKAGE_MANIFEST_INVALID"
|
| "MODULE_PACKAGE_MANIFEST_INVALID"
|
||||||
| "MODULE_PACKAGE_METADATA_INVALID"
|
| "MODULE_PACKAGE_METADATA_INVALID"
|
||||||
| "MODULE_PACKAGE_METADATA_MISSING"
|
| "MODULE_PACKAGE_METADATA_MISSING"
|
||||||
| "MODULE_PACKAGE_MODULE_INVALID"
|
| "MODULE_PACKAGE_MODULE_INVALID"
|
||||||
| "MODULE_PACKAGE_NATIVE_FILE"
|
| "MODULE_PACKAGE_NATIVE_FILE"
|
||||||
|
| "MODULE_PACKAGE_NATIVE_FILE_INCOMPATIBLE"
|
||||||
|
| "MODULE_PACKAGE_NODE_API_INCOMPATIBLE"
|
||||||
| "MODULE_PACKAGE_RUNTIME_INCOMPATIBLE"
|
| "MODULE_PACKAGE_RUNTIME_INCOMPATIBLE"
|
||||||
| "MODULE_PACKAGE_RUNTIME_PACKAGE_INVALID"
|
| "MODULE_PACKAGE_RUNTIME_PACKAGE_INVALID"
|
||||||
| "MODULE_PACKAGE_SDK_INCOMPATIBLE"
|
| "MODULE_PACKAGE_SDK_INCOMPATIBLE"
|
||||||
| "MODULE_PACKAGE_TARGET_INCOMPATIBLE"
|
| "MODULE_PACKAGE_TARGET_INCOMPATIBLE"
|
||||||
| "MODULE_PACKAGE_TRANSITIONAL_UNSUPPORTED";
|
| "MODULE_PACKAGE_TRANSITIONAL_UNSUPPORTED"
|
||||||
|
| "MODULE_PACKAGE_VERIFIER_SCRATCH_UNAVAILABLE";
|
||||||
|
|
||||||
const messages: Readonly<Record<ModulePackageVerificationErrorCode, string>> = Object.freeze({
|
const messages: Readonly<Record<ModulePackageVerificationErrorCode, string>> = Object.freeze({
|
||||||
MODULE_PACKAGE_DATABASE_RUNTIME_INCOMPATIBLE:
|
MODULE_PACKAGE_DATABASE_RUNTIME_INCOMPATIBLE:
|
||||||
@@ -85,12 +94,19 @@ const messages: Readonly<Record<ModulePackageVerificationErrorCode, string>> = O
|
|||||||
"The package metadata, runtime package.json, and manifest disagree about the module's identity.",
|
"The package metadata, runtime package.json, and manifest disagree about the module's identity.",
|
||||||
MODULE_PACKAGE_LAYOUT_INVALID:
|
MODULE_PACKAGE_LAYOUT_INVALID:
|
||||||
"The archive must contain exactly one top-level module directory, with each path once.",
|
"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_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_MANIFEST_INVALID: "The package's module manifest is missing or invalid.",
|
||||||
MODULE_PACKAGE_METADATA_INVALID: "The package metadata is invalid.",
|
MODULE_PACKAGE_METADATA_INVALID: "The package metadata is invalid.",
|
||||||
MODULE_PACKAGE_METADATA_MISSING: "The archive contains no package metadata.",
|
MODULE_PACKAGE_METADATA_MISSING: "The archive contains no package metadata.",
|
||||||
MODULE_PACKAGE_MODULE_INVALID: "The packaged module failed module validation.",
|
MODULE_PACKAGE_MODULE_INVALID: "The packaged module failed module validation.",
|
||||||
MODULE_PACKAGE_NATIVE_FILE: "An any-target package contains a native file.",
|
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:
|
MODULE_PACKAGE_RUNTIME_INCOMPATIBLE:
|
||||||
"The package needs a Node.js version this host does not run.",
|
"The package needs a Node.js version this host does not run.",
|
||||||
MODULE_PACKAGE_RUNTIME_PACKAGE_INVALID:
|
MODULE_PACKAGE_RUNTIME_PACKAGE_INVALID:
|
||||||
@@ -103,6 +119,8 @@ const messages: Readonly<Record<ModulePackageVerificationErrorCode, string>> = O
|
|||||||
MODULE_PACKAGE_TOO_LARGE: "The archive exceeds the module package size limits.",
|
MODULE_PACKAGE_TOO_LARGE: "The archive exceeds the module package size limits.",
|
||||||
MODULE_PACKAGE_TRANSITIONAL_UNSUPPORTED:
|
MODULE_PACKAGE_TRANSITIONAL_UNSUPPORTED:
|
||||||
"The package metadata contains transitional facts this platform does not understand.",
|
"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_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_MODE: "The archive contains a setuid, setgid, or sticky entry.",
|
||||||
MODULE_PACKAGE_UNSAFE_PATH: "The archive contains an absolute, escaping, or non-portable path.",
|
MODULE_PACKAGE_UNSAFE_PATH: "The archive contains an absolute, escaping, or non-portable path.",
|
||||||
@@ -131,6 +149,8 @@ export interface ModulePackagePlatform {
|
|||||||
readonly databaseRuntime: Readonly<Record<string, string>>;
|
readonly databaseRuntime: Readonly<Record<string, string>>;
|
||||||
/** Defaults to the running process's Node.js version. */
|
/** Defaults to the running process's Node.js version. */
|
||||||
readonly nodeVersion?: string;
|
readonly nodeVersion?: string;
|
||||||
|
/** Defaults to this process's own host (`currentModulePackageHost`). */
|
||||||
|
readonly host?: ModulePackageHost;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface VerifyModulePackageOptions {
|
export interface VerifyModulePackageOptions {
|
||||||
@@ -140,6 +160,12 @@ export interface VerifyModulePackageOptions {
|
|||||||
readonly platform: ModulePackagePlatform;
|
readonly platform: ModulePackagePlatform;
|
||||||
/** A directory that must not exist yet; the verified module directory is written there. */
|
/** A directory that must not exist yet; the verified module directory is written there. */
|
||||||
readonly extractTo?: string;
|
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;
|
readonly loadTimeoutMs?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -266,13 +292,20 @@ function checkCompatibility(
|
|||||||
files: ReadonlyMap<string, Buffer>,
|
files: ReadonlyMap<string, Buffer>,
|
||||||
platform: ModulePackagePlatform,
|
platform: ModulePackagePlatform,
|
||||||
): void {
|
): void {
|
||||||
// This verifier implements the portable target only. Native targets are a later slice.
|
if (metadata.target === MODULE_PACKAGE_ANY_TARGET) {
|
||||||
if (metadata.target !== MODULE_PACKAGE_ANY_TARGET) {
|
// An any-target package bundles everything: no addon, no ELF object, no node_modules.
|
||||||
fail("MODULE_PACKAGE_TARGET_INCOMPATIBLE");
|
for (const [filePath, data] of files) {
|
||||||
}
|
if (
|
||||||
if ([...files.keys()].some((filePath) => filePath.toLowerCase().endsWith(".node"))) {
|
filePath.toLowerCase().endsWith(".node") ||
|
||||||
|
filePath.startsWith("node_modules/") ||
|
||||||
|
isElf(data)
|
||||||
|
) {
|
||||||
fail("MODULE_PACKAGE_NATIVE_FILE");
|
fail("MODULE_PACKAGE_NATIVE_FILE");
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
checkNativeTarget(metadata, files, platform.host ?? currentModulePackageHost());
|
||||||
|
}
|
||||||
if (!isVersionCompatible(platform.nodeVersion ?? process.versions.node, metadata.runtime.node)) {
|
if (!isVersionCompatible(platform.nodeVersion ?? process.versions.node, metadata.runtime.node)) {
|
||||||
fail("MODULE_PACKAGE_RUNTIME_INCOMPATIBLE");
|
fail("MODULE_PACKAGE_RUNTIME_INCOMPATIBLE");
|
||||||
}
|
}
|
||||||
@@ -296,6 +329,74 @@ function checkCompatibility(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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.
|
/** 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
|
* 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. */
|
* and regular files exist. Ownership is the writing process's, never the archive's. */
|
||||||
@@ -322,12 +423,66 @@ function probeEntrypointPath(): string {
|
|||||||
return path.join(path.dirname(self), `module-package-probe${path.extname(self)}`);
|
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
|
/** 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. */
|
* only whether each loaded and exported a definition naming this module. */
|
||||||
async function loadEntrypoints(
|
async function loadEntrypoints(
|
||||||
manifest: Pick<ModuleManifest, "database" | "id" | "version">,
|
manifest: Pick<ModuleManifest, "database" | "id" | "version">,
|
||||||
entrypoints: Readonly<Record<string, string>>,
|
entrypoints: Readonly<Record<string, string>>,
|
||||||
timeoutMs: number,
|
timeoutMs: number,
|
||||||
|
executionDirectory: string,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const outcome = await new Promise<"ok" | "failed">((resolve) => {
|
const outcome = await new Promise<"ok" | "failed">((resolve) => {
|
||||||
let settled = false;
|
let settled = false;
|
||||||
@@ -353,7 +508,7 @@ async function loadEntrypoints(
|
|||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
{
|
{
|
||||||
cwd: tmpdir(),
|
cwd: executionDirectory,
|
||||||
env: {},
|
env: {},
|
||||||
execArgv: [],
|
execArgv: [],
|
||||||
serialization: "json",
|
serialization: "json",
|
||||||
@@ -422,7 +577,7 @@ export async function verifyModulePackage(
|
|||||||
const manifest = checkIdentity(archive, metadata, files);
|
const manifest = checkIdentity(archive, metadata, files);
|
||||||
checkCompatibility(metadata, manifest, files, options.platform);
|
checkCompatibility(metadata, manifest, files, options.platform);
|
||||||
|
|
||||||
const scratch = await mkdtemp(path.join(tmpdir(), "ekklesia-package-verify-"));
|
const scratch = await createExecutionScratch(options.executionScratchDirectory ?? tmpdir());
|
||||||
try {
|
try {
|
||||||
// The candidate alone, in a scratch inventory of its own: never module storage.
|
// The candidate alone, in a scratch inventory of its own: never module storage.
|
||||||
const modulesDirectory = path.join(scratch, "candidates");
|
const modulesDirectory = path.join(scratch, "candidates");
|
||||||
@@ -458,7 +613,7 @@ export async function verifyModulePackage(
|
|||||||
|
|
||||||
// Executed only now, and first in isolation: a schema is imported in this process below only
|
// 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.
|
// after the probe proved it loads on its own.
|
||||||
await loadEntrypoints(manifest, entrypoints, options.loadTimeoutMs ?? 30_000);
|
await loadEntrypoints(manifest, entrypoints, options.loadTimeoutMs ?? 30_000, scratch);
|
||||||
try {
|
try {
|
||||||
await discoverModuleSchemas(discoveryOptions);
|
await discoverModuleSchemas(discoveryOptions);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
import { pathToFileURL } from "node:url";
|
import { pathToFileURL } from "node:url";
|
||||||
import {
|
import {
|
||||||
type AnyModuleRuntimeServices,
|
|
||||||
type CapabilityId,
|
type CapabilityId,
|
||||||
type CapabilityRegistrar,
|
type CapabilityRegistrar,
|
||||||
type CapabilityResolver,
|
type CapabilityResolver,
|
||||||
@@ -8,7 +7,6 @@ import {
|
|||||||
EMPTY_MODULE_CONFIGURATION,
|
EMPTY_MODULE_CONFIGURATION,
|
||||||
EMPTY_MODULE_STORAGE,
|
EMPTY_MODULE_STORAGE,
|
||||||
type ModuleConfiguration,
|
type ModuleConfiguration,
|
||||||
type ModuleDatabase,
|
|
||||||
type ModuleDatabaseExecutor,
|
type ModuleDatabaseExecutor,
|
||||||
type ModuleHttpRouteRegistrar,
|
type ModuleHttpRouteRegistrar,
|
||||||
type ModuleId,
|
type ModuleId,
|
||||||
@@ -19,7 +17,6 @@ import {
|
|||||||
type ModuleRouteHandler,
|
type ModuleRouteHandler,
|
||||||
type ModuleRuntimeContext,
|
type ModuleRuntimeContext,
|
||||||
type ModuleRuntimeDefinition,
|
type ModuleRuntimeDefinition,
|
||||||
type ModuleRuntimeServices,
|
|
||||||
type ModuleStorage,
|
type ModuleStorage,
|
||||||
moduleRouteShapeKey,
|
moduleRouteShapeKey,
|
||||||
moduleRoutesOverlap,
|
moduleRoutesOverlap,
|
||||||
@@ -28,6 +25,7 @@ import {
|
|||||||
type RegisteredModuleHttpRoute,
|
type RegisteredModuleHttpRoute,
|
||||||
safeParseModuleRouteDefinition,
|
safeParseModuleRouteDefinition,
|
||||||
} from "@ekklesia/contracts";
|
} from "@ekklesia/contracts";
|
||||||
|
import type { AnyModuleRuntimeServices, ModuleDatabase } from "./database.js";
|
||||||
import {
|
import {
|
||||||
type DiscoveredModule,
|
type DiscoveredModule,
|
||||||
discoverModules,
|
discoverModules,
|
||||||
@@ -137,7 +135,7 @@ export type ModuleStorageLookup = ReadonlyMap<string, ModuleStorage>;
|
|||||||
/** One frozen service object per module id. Legacy modules may point at objects wrapping the same
|
/** 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. */
|
* facade; ABI-v1 modules each point at their own attributed executor. */
|
||||||
export type ModuleRuntimeServicesLookup = ReadonlyMap<string, AnyModuleRuntimeServices>;
|
export type ModuleRuntimeServicesLookup = ReadonlyMap<string, AnyModuleRuntimeServices>;
|
||||||
export type ModuleRuntimeServicesSource = ModuleRuntimeServicesLookup | ModuleRuntimeServices;
|
export type ModuleRuntimeServicesSource = ModuleRuntimeServicesLookup | AnyModuleRuntimeServices;
|
||||||
|
|
||||||
function servicesFor(
|
function servicesFor(
|
||||||
services: ModuleRuntimeServicesSource,
|
services: ModuleRuntimeServicesSource,
|
||||||
@@ -347,11 +345,11 @@ class StagedCapabilityRegistry {
|
|||||||
createRuntimeContext(
|
createRuntimeContext(
|
||||||
module: DiscoveredModule,
|
module: DiscoveredModule,
|
||||||
phase: "rollback" | "start" | "stop",
|
phase: "rollback" | "start" | "stop",
|
||||||
services: ModuleRuntimeServices,
|
services: AnyModuleRuntimeServices,
|
||||||
config: ModuleConfiguration,
|
config: ModuleConfiguration,
|
||||||
storage: ModuleStorage,
|
storage: ModuleStorage,
|
||||||
realtime: ModuleRealtimePublisher,
|
realtime: ModuleRealtimePublisher,
|
||||||
): ModuleRuntimeContext {
|
): ModuleRuntimeContext<ModuleDatabase | ModuleDatabaseExecutor> {
|
||||||
const capabilities: CapabilityResolver = Object.freeze({
|
const capabilities: CapabilityResolver = Object.freeze({
|
||||||
resolve: (capabilityId: CapabilityId): RegisteredCapability | undefined => {
|
resolve: (capabilityId: CapabilityId): RegisteredCapability | undefined => {
|
||||||
this.#assertAccess(module, capabilityId, phase);
|
this.#assertAccess(module, capabilityId, phase);
|
||||||
@@ -754,7 +752,7 @@ async function stopModules(
|
|||||||
registry.createRuntimeContext(
|
registry.createRuntimeContext(
|
||||||
module.discovered,
|
module.discovered,
|
||||||
phase,
|
phase,
|
||||||
servicesFor(services, module.discovered) as ModuleRuntimeServices,
|
servicesFor(services, module.discovered),
|
||||||
configurationFor(configurations, moduleId),
|
configurationFor(configurations, moduleId),
|
||||||
storageFor(storages, moduleId),
|
storageFor(storages, moduleId),
|
||||||
realtimeRegistry.publisherFor(moduleId),
|
realtimeRegistry.publisherFor(moduleId),
|
||||||
@@ -814,7 +812,7 @@ async function startModules(
|
|||||||
registry.createRuntimeContext(
|
registry.createRuntimeContext(
|
||||||
module.discovered,
|
module.discovered,
|
||||||
"start",
|
"start",
|
||||||
servicesFor(services, module.discovered) as ModuleRuntimeServices,
|
servicesFor(services, module.discovered),
|
||||||
configurationFor(configurations, module.discovered.manifest.id),
|
configurationFor(configurations, module.discovered.manifest.id),
|
||||||
storageFor(storages, module.discovered.manifest.id),
|
storageFor(storages, module.discovered.manifest.id),
|
||||||
realtimeRegistry.publisherFor(module.discovered.manifest.id),
|
realtimeRegistry.publisherFor(module.discovered.manifest.id),
|
||||||
|
|||||||
@@ -1,14 +1,16 @@
|
|||||||
import { pathToFileURL } from "node:url";
|
import { pathToFileURL } from "node:url";
|
||||||
import {
|
import {
|
||||||
type ModuleId,
|
type ModuleId,
|
||||||
type ModuleSchemaDefinition,
|
|
||||||
type ModuleSchemaMetadataDefinition,
|
type ModuleSchemaMetadataDefinition,
|
||||||
moduleTableLocalNameSchema,
|
moduleTableLocalNameSchema,
|
||||||
moduleTablePrefix,
|
moduleTablePrefix,
|
||||||
parseModuleSchemaDefinition,
|
|
||||||
parseModuleSchemaMetadataDefinition,
|
parseModuleSchemaMetadataDefinition,
|
||||||
physicalModuleTableNameSchema,
|
physicalModuleTableNameSchema,
|
||||||
} from "@ekklesia/contracts";
|
} from "@ekklesia/contracts";
|
||||||
|
import {
|
||||||
|
type ModuleSchemaDefinition,
|
||||||
|
parseModuleSchemaDefinition,
|
||||||
|
} from "./legacy-schema-definition.js";
|
||||||
import { is } from "drizzle-orm";
|
import { is } from "drizzle-orm";
|
||||||
import { type AnyPgTable, getTableConfig, PgColumn, PgTable } from "drizzle-orm/pg-core";
|
import { type AnyPgTable, getTableConfig, PgColumn, PgTable } from "drizzle-orm/pg-core";
|
||||||
import {
|
import {
|
||||||
|
|||||||
@@ -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 {
|
import {
|
||||||
CORE_AUTHORIZATION_GRANTS_MANAGE,
|
CORE_AUTHORIZATION_GRANTS_MANAGE,
|
||||||
CORE_DASHBOARD_NAVIGATION_MANAGE,
|
CORE_DASHBOARD_NAVIGATION_MANAGE,
|
||||||
|
CORE_ACCOUNT_SELF_MANAGE,
|
||||||
CORE_DEVICES_MANAGE,
|
CORE_DEVICES_MANAGE,
|
||||||
CORE_MODULES_CONFIGURATION_MANAGE,
|
CORE_MODULES_CONFIGURATION_MANAGE,
|
||||||
CORE_MODULES_LIFECYCLE_MANAGE,
|
CORE_MODULES_LIFECYCLE_MANAGE,
|
||||||
@@ -133,7 +134,13 @@ describe("CORE_PERMISSION_REGISTRY", () => {
|
|||||||
expect(CORE_MODULES_CONFIGURATION_MANAGE).not.toBe(CORE_MODULES_LIFECYCLE_MANAGE);
|
expect(CORE_MODULES_CONFIGURATION_MANAGE).not.toBe(CORE_MODULES_LIFECYCLE_MANAGE);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("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([
|
expect(CORE_PERMISSION_REGISTRY.declarations.map((declaration) => declaration.id)).toEqual([
|
||||||
CORE_AUTHORIZATION_GRANTS_MANAGE,
|
CORE_AUTHORIZATION_GRANTS_MANAGE,
|
||||||
CORE_MODULES_UI_READ,
|
CORE_MODULES_UI_READ,
|
||||||
@@ -141,6 +148,7 @@ describe("CORE_PERMISSION_REGISTRY", () => {
|
|||||||
CORE_MODULES_LIFECYCLE_MANAGE,
|
CORE_MODULES_LIFECYCLE_MANAGE,
|
||||||
CORE_MODULES_CONFIGURATION_MANAGE,
|
CORE_MODULES_CONFIGURATION_MANAGE,
|
||||||
CORE_DEVICES_MANAGE,
|
CORE_DEVICES_MANAGE,
|
||||||
|
CORE_ACCOUNT_SELF_MANAGE,
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -178,6 +186,11 @@ describe("CORE_PERMISSION_DECLARATIONS", () => {
|
|||||||
description: "Approve display device pairing, list paired devices, and revoke a device.",
|
description: "Approve display device pairing, list paired devices, and revoke a device.",
|
||||||
id: CORE_DEVICES_MANAGE,
|
id: CORE_DEVICES_MANAGE,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
baseline: "authenticated",
|
||||||
|
description: "Read your own account and read and change your own preferences.",
|
||||||
|
id: CORE_ACCOUNT_SELF_MANAGE,
|
||||||
|
},
|
||||||
]);
|
]);
|
||||||
expect(Object.isFrozen(CORE_PERMISSION_DECLARATIONS)).toBe(true);
|
expect(Object.isFrozen(CORE_PERMISSION_DECLARATIONS)).toBe(true);
|
||||||
for (const declaration of CORE_PERMISSION_DECLARATIONS) {
|
for (const declaration of CORE_PERMISSION_DECLARATIONS) {
|
||||||
|
|||||||
@@ -178,6 +178,7 @@ async function applyCoreMigrations(url: string): Promise<void> {
|
|||||||
"core:0005_module_configuration",
|
"core:0005_module_configuration",
|
||||||
"core:0006_device_identity",
|
"core:0006_device_identity",
|
||||||
"core:0007_verified_bootstrap_owners",
|
"core:0007_verified_bootstrap_owners",
|
||||||
|
"core:0008_user_preferences",
|
||||||
]);
|
]);
|
||||||
await applyModuleMigrations({ aggregate, database: createPostgresMigrationDatabase(url) });
|
await applyModuleMigrations({ aggregate, database: createPostgresMigrationDatabase(url) });
|
||||||
} finally {
|
} finally {
|
||||||
@@ -243,6 +244,8 @@ describe.sequential("live Core authorization store", () => {
|
|||||||
"ekklesia_core_platform_owner_bootstrap",
|
"ekklesia_core_platform_owner_bootstrap",
|
||||||
"ekklesia_core_session",
|
"ekklesia_core_session",
|
||||||
"ekklesia_core_user",
|
"ekklesia_core_user",
|
||||||
|
// A person's own preferences, one typed column each (ADR 0062).
|
||||||
|
"ekklesia_core_user_preference",
|
||||||
"ekklesia_core_verification",
|
"ekklesia_core_verification",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|||||||
@@ -177,6 +177,7 @@ async function applyCoreMigrations(url: string): Promise<void> {
|
|||||||
"core:0005_module_configuration",
|
"core:0005_module_configuration",
|
||||||
"core:0006_device_identity",
|
"core:0006_device_identity",
|
||||||
"core:0007_verified_bootstrap_owners",
|
"core:0007_verified_bootstrap_owners",
|
||||||
|
"core:0008_user_preferences",
|
||||||
]);
|
]);
|
||||||
await applyModuleMigrations({ aggregate, database: createPostgresMigrationDatabase(url) });
|
await applyModuleMigrations({ aggregate, database: createPostgresMigrationDatabase(url) });
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -92,7 +92,7 @@ Modules
|
|||||||
✓ Dependency graph valid
|
✓ Dependency graph valid
|
||||||
Dependency order is empty.
|
Dependency order is empty.
|
||||||
✓ Permission registry composed
|
✓ Permission registry composed
|
||||||
6 permissions registered
|
7 permissions registered
|
||||||
○ Module lifecycle skipped
|
○ Module lifecycle skipped
|
||||||
DATABASE_URL is not configured.
|
DATABASE_URL is not configured.
|
||||||
○ Module configuration skipped
|
○ Module configuration skipped
|
||||||
|
|||||||
+2
-2
@@ -5,8 +5,8 @@
|
|||||||
"description": "Test-only installed module fixture proving ModuleDatabase against real PostgreSQL. Never discovered by production startup, Doctor, or normal migration aggregation — only apps/api/tests/module-database.integration.ts points discovery at it explicitly.",
|
"description": "Test-only installed module fixture proving ModuleDatabase against real PostgreSQL. Never discovered by production startup, Doctor, or normal migration aggregation — only apps/api/tests/module-database.integration.ts points discovery at it explicitly.",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"compatibility": {
|
"compatibility": {
|
||||||
"platform": "^0.1.0",
|
"platform": "^0.2.0",
|
||||||
"moduleSdk": "^0.1.0"
|
"moduleSdk": "^0.2.0"
|
||||||
},
|
},
|
||||||
"permissions": [
|
"permissions": [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -169,13 +169,57 @@ describe("the browser shell reaches none of it", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("never puts a reset token in browser storage", async () => {
|
it("never puts a reset token in browser storage", async () => {
|
||||||
for (const file of await readTypeScriptFiles(path.join("surfaces", "shell", "src"))) {
|
|
||||||
const code = withoutComments(file.text);
|
|
||||||
// A reset token is a bearer credential for one account; the shell holds it in React state for
|
// 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 life of one form and nowhere else. The shell's uses of browser storage are pinned
|
||||||
expect(code, file.path).not.toContain("localStorage");
|
// 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);
|
||||||
expect(code, file.path).not.toContain("sessionStorage");
|
expect(code, file.path).not.toContain("sessionStorage");
|
||||||
expect(code, file.path).not.toContain("document.cookie");
|
expect(code, file.path).not.toContain("document.cookie");
|
||||||
|
writes.push(
|
||||||
|
...[...code.matchAll(/\.setItem\(([^)]*)\)/gu)].map(
|
||||||
|
(match) => `${path.relative(repositoryRoot, file.path)}: ${match[1]}`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
removals.push(
|
||||||
|
...[...code.matchAll(/\.removeItem\(([^)]*)\)/gu)].map(
|
||||||
|
(match) => `${path.relative(repositoryRoot, file.path)}: ${match[1]}`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (file.path.endsWith(path.join("browser", "main.tsx"))) {
|
||||||
|
expect(code.match(/localStorage/gu), file.path).toHaveLength(1);
|
||||||
|
expect(code).toMatch(
|
||||||
|
/const browserStorage = \(\(\) => \{\s*try \{\s*return window\.localStorage;/u,
|
||||||
|
);
|
||||||
|
expect(code).toContain("createLocalSidebarPreference(browserStorage)");
|
||||||
|
expect(code).toContain("createLocalAppearanceCache(browserStorage)");
|
||||||
|
} else if (file.path.endsWith(path.join("browser", "appearance-boot.ts"))) {
|
||||||
|
expect(code.match(/localStorage/gu), file.path).toHaveLength(1);
|
||||||
|
expect(code).toContain("window.localStorage.getItem(APPEARANCE_CACHE_KEY)");
|
||||||
|
} else {
|
||||||
|
expect(code, file.path).not.toContain("localStorage");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
expect(writes).toEqual([
|
||||||
|
`${path.join("surfaces", "shell", "src", "browser", "appearance-cache.ts")}: APPEARANCE_CACHE_KEY, serialized`,
|
||||||
|
`${path.join("surfaces", "shell", "src", "browser", "sidebar-preference.ts")}: SIDEBAR_PREFERENCE_KEY, mode`,
|
||||||
|
]);
|
||||||
|
expect(removals).toEqual([
|
||||||
|
`${path.join("surfaces", "shell", "src", "browser", "appearance-cache.ts")}: APPEARANCE_CACHE_KEY`,
|
||||||
|
]);
|
||||||
|
// The files that ever hold the token touch no storage API at all.
|
||||||
|
for (const file of files.filter((candidate) =>
|
||||||
|
["auth-protocol.ts", "ResetPasswordForm.tsx", "HostApp.tsx"].some((name) =>
|
||||||
|
candidate.path.endsWith(name),
|
||||||
|
),
|
||||||
|
)) {
|
||||||
|
expect(withoutComments(file.text), file.path).not.toMatch(/\b(?:setItem|getItem|Storage)\b/u);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -179,6 +179,7 @@ async function applyCoreMigrations(url: string): Promise<void> {
|
|||||||
"core:0005_module_configuration",
|
"core:0005_module_configuration",
|
||||||
"core:0006_device_identity",
|
"core:0006_device_identity",
|
||||||
"core:0007_verified_bootstrap_owners",
|
"core:0007_verified_bootstrap_owners",
|
||||||
|
"core:0008_user_preferences",
|
||||||
]);
|
]);
|
||||||
await applyModuleMigrations({ aggregate, database: createPostgresMigrationDatabase(url) });
|
await applyModuleMigrations({ aggregate, database: createPostgresMigrationDatabase(url) });
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
+9
-8
@@ -1,8 +1,8 @@
|
|||||||
import { integer, pgTable, text } from "drizzle-orm/pg-core";
|
import { integer, pgTable, text } from "drizzle-orm/pg-core";
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import type { ModuleDatabase, ModuleRuntimeServices } from "../src/index.js";
|
import type { LegacyModuleRuntimeServices, ModuleDatabase } from "../src/modules/database.js";
|
||||||
|
|
||||||
const testTable = pgTable("contracts_module_database_test", {
|
const testTable = pgTable("core_module_database_type_test", {
|
||||||
id: integer("id").primaryKey(),
|
id: integer("id").primaryKey(),
|
||||||
name: text("name").notNull(),
|
name: text("name").notNull(),
|
||||||
});
|
});
|
||||||
@@ -68,16 +68,17 @@ function typeOnlyExclusions(database: ModuleDatabase): void {
|
|||||||
}
|
}
|
||||||
void typeOnlyExclusions;
|
void typeOnlyExclusions;
|
||||||
|
|
||||||
/** Never invoked — proves `ModuleRuntimeServices` has exactly one field, `database`, typed as
|
/** Never invoked — proves a legacy module's `ModuleRuntimeServices` has exactly one field,
|
||||||
* `ModuleDatabase`, with no storage/jobs/configuration/secrets/authorization field this phase. */
|
* `database`, typed as `ModuleDatabase`, with no storage/jobs/configuration/secrets/authorization
|
||||||
function typeOnlyServicesShape(services: ModuleRuntimeServices): void {
|
* field this phase. */
|
||||||
|
function typeOnlyServicesShape(services: LegacyModuleRuntimeServices): void {
|
||||||
const database: ModuleDatabase = services.database;
|
const database: ModuleDatabase = services.database;
|
||||||
void database;
|
void database;
|
||||||
// @ts-expect-error `storage` does not exist on `ModuleRuntimeServices` yet.
|
// @ts-expect-error `storage` does not exist on the services yet.
|
||||||
void services.storage;
|
void services.storage;
|
||||||
// @ts-expect-error `jobs` does not exist on `ModuleRuntimeServices` yet.
|
// @ts-expect-error `jobs` does not exist on the services yet.
|
||||||
void services.jobs;
|
void services.jobs;
|
||||||
// @ts-expect-error `authorization` does not exist on `ModuleRuntimeServices` yet.
|
// @ts-expect-error `authorization` does not exist on the services yet.
|
||||||
void services.authorization;
|
void services.authorization;
|
||||||
}
|
}
|
||||||
void typeOnlyServicesShape;
|
void typeOnlyServicesShape;
|
||||||
@@ -4,8 +4,12 @@ import path from "node:path";
|
|||||||
import { afterEach, describe, expect, it } from "vitest";
|
import { afterEach, describe, expect, it } from "vitest";
|
||||||
import {
|
import {
|
||||||
discoverModules,
|
discoverModules,
|
||||||
|
isReservedModuleId,
|
||||||
ModuleDiscoveryError,
|
ModuleDiscoveryError,
|
||||||
type ModuleDiscoveryIssueCode,
|
type ModuleDiscoveryIssueCode,
|
||||||
|
RESERVED_MODULE_IDS,
|
||||||
|
RESERVED_SETTINGS_NAMES,
|
||||||
|
RESERVED_TOP_LEVEL_NAMES,
|
||||||
} from "../src/modules/index.js";
|
} from "../src/modules/index.js";
|
||||||
|
|
||||||
interface ModuleFixtureOptions {
|
interface ModuleFixtureOptions {
|
||||||
@@ -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 () => {
|
it("enforces platform and Module SDK compatibility", async () => {
|
||||||
const modulesDirectory = await createModulesDirectory();
|
const modulesDirectory = await createModulesDirectory();
|
||||||
await createModule(modulesDirectory, "future", {
|
await createModule(modulesDirectory, "future", {
|
||||||
|
|||||||
@@ -182,6 +182,7 @@ async function applyCoreMigrations(url: string): Promise<void> {
|
|||||||
"core:0005_module_configuration",
|
"core:0005_module_configuration",
|
||||||
"core:0006_device_identity",
|
"core:0006_device_identity",
|
||||||
"core:0007_verified_bootstrap_owners",
|
"core:0007_verified_bootstrap_owners",
|
||||||
|
"core:0008_user_preferences",
|
||||||
]);
|
]);
|
||||||
await applyModuleMigrations({ aggregate, database: createPostgresMigrationDatabase(url) });
|
await applyModuleMigrations({ aggregate, database: createPostgresMigrationDatabase(url) });
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -35,9 +35,9 @@ async function createModulesDirectory(): Promise<string> {
|
|||||||
|
|
||||||
function discoveryOptions(modulesDirectory: string) {
|
function discoveryOptions(modulesDirectory: string) {
|
||||||
return {
|
return {
|
||||||
moduleSdkVersion: "0.1.0",
|
moduleSdkVersion: "0.2.0",
|
||||||
modulesDirectory,
|
modulesDirectory,
|
||||||
platformVersion: "0.1.0",
|
platformVersion: "0.2.0",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import { mkdtemp, readdir, rm, stat, writeFile } from "node:fs/promises";
|
import { mkdir, mkdtemp, readdir, rm, stat, writeFile } from "node:fs/promises";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { afterEach, describe, expect, it } from "vitest";
|
import { afterEach, describe, expect, it } from "vitest";
|
||||||
import {
|
import {
|
||||||
|
currentModulePackageHost,
|
||||||
|
type ModulePackageHost,
|
||||||
ModulePackageVerificationError,
|
ModulePackageVerificationError,
|
||||||
type ModulePackageVerificationErrorCode,
|
type ModulePackageVerificationErrorCode,
|
||||||
verifyModulePackage,
|
verifyModulePackage,
|
||||||
@@ -16,8 +18,8 @@ import {
|
|||||||
|
|
||||||
const platform = {
|
const platform = {
|
||||||
databaseRuntime: { "drizzle-orm": "0.45.2" },
|
databaseRuntime: { "drizzle-orm": "0.45.2" },
|
||||||
moduleSdkVersion: "0.1.2",
|
moduleSdkVersion: "0.2.0",
|
||||||
platformVersion: "0.1.2",
|
platformVersion: "0.2.0",
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
const temporaryDirectories: string[] = [];
|
const temporaryDirectories: string[] = [];
|
||||||
@@ -46,6 +48,12 @@ async function archiveFile(options: PackageFixtureOptions = {}): Promise<{
|
|||||||
return { archivePath, sha256: fixture.sha256 };
|
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(
|
async function rejection(
|
||||||
options: PackageFixtureOptions,
|
options: PackageFixtureOptions,
|
||||||
code: ModulePackageVerificationErrorCode,
|
code: ModulePackageVerificationErrorCode,
|
||||||
@@ -98,6 +106,52 @@ describe("Module Package v1 verification (ADR 0049)", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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 () => {
|
it("extracts only a verified package, with normalized modes, into a new directory", async () => {
|
||||||
const { archivePath } = await archiveFile();
|
const { archivePath } = await archiveFile();
|
||||||
const target = path.join(await scratch(), "example");
|
const target = path.join(await scratch(), "example");
|
||||||
@@ -354,7 +408,7 @@ describe("Module Package v1 verification (ADR 0049)", () => {
|
|||||||
metadata: (metadata) => ({
|
metadata: (metadata) => ({
|
||||||
...metadata,
|
...metadata,
|
||||||
target: "linux-arm64-musl",
|
target: "linux-arm64-musl",
|
||||||
runtime: { node: ">=20", nodeApi: 9 },
|
runtime: { libcVersion: ">=1.2.0", node: ">=20", nodeApi: 9 },
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
"MODULE_PACKAGE_TARGET_INCOMPATIBLE",
|
"MODULE_PACKAGE_TARGET_INCOMPATIBLE",
|
||||||
@@ -493,6 +547,179 @@ describe("Module Package v1 verification (ADR 0049)", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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 () => {
|
it("fails an archive without package metadata", async () => {
|
||||||
const fixture = await modulePackageFixture();
|
const fixture = await modulePackageFixture();
|
||||||
const { gunzipSync, gzipSync } = await import("node:zlib");
|
const { gunzipSync, gzipSync } = await import("node:zlib");
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { describe, expect, it } from "vitest";
|
|||||||
import {
|
import {
|
||||||
CORE_AUTHORIZATION_GRANTS_MANAGE,
|
CORE_AUTHORIZATION_GRANTS_MANAGE,
|
||||||
CORE_DASHBOARD_NAVIGATION_MANAGE,
|
CORE_DASHBOARD_NAVIGATION_MANAGE,
|
||||||
|
CORE_ACCOUNT_SELF_MANAGE,
|
||||||
CORE_DEVICES_MANAGE,
|
CORE_DEVICES_MANAGE,
|
||||||
CORE_MODULES_CONFIGURATION_MANAGE,
|
CORE_MODULES_CONFIGURATION_MANAGE,
|
||||||
CORE_MODULES_LIFECYCLE_MANAGE,
|
CORE_MODULES_LIFECYCLE_MANAGE,
|
||||||
@@ -75,8 +76,9 @@ describe("composePlatformPermissionRegistry", () => {
|
|||||||
expect(registry.has(CORE_DASHBOARD_NAVIGATION_MANAGE)).toBe(true);
|
expect(registry.has(CORE_DASHBOARD_NAVIGATION_MANAGE)).toBe(true);
|
||||||
expect(registry.has(CORE_MODULES_LIFECYCLE_MANAGE)).toBe(true);
|
expect(registry.has(CORE_MODULES_LIFECYCLE_MANAGE)).toBe(true);
|
||||||
expect(registry.has(CORE_MODULES_CONFIGURATION_MANAGE)).toBe(true);
|
expect(registry.has(CORE_MODULES_CONFIGURATION_MANAGE)).toBe(true);
|
||||||
// Core's own five declarations plus one from each module.
|
expect(registry.has(CORE_ACCOUNT_SELF_MANAGE)).toBe(true);
|
||||||
expect(registry.declarations).toHaveLength(8);
|
// 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", () => {
|
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_LIFECYCLE_MANAGE,
|
||||||
CORE_MODULES_CONFIGURATION_MANAGE,
|
CORE_MODULES_CONFIGURATION_MANAGE,
|
||||||
CORE_DEVICES_MANAGE,
|
CORE_DEVICES_MANAGE,
|
||||||
|
CORE_ACCOUNT_SELF_MANAGE,
|
||||||
"calendar.events.read",
|
"calendar.events.read",
|
||||||
"calendar.events.write",
|
"calendar.events.write",
|
||||||
"people.directory.read",
|
"people.directory.read",
|
||||||
|
|||||||
@@ -1,11 +1,8 @@
|
|||||||
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import type {
|
import type { ModuleRealtimePublisher } from "@ekklesia/contracts";
|
||||||
ModuleDatabase,
|
import type { LegacyModuleRuntimeServices, ModuleDatabase } from "../src/modules/database.js";
|
||||||
ModuleRealtimePublisher,
|
|
||||||
ModuleRuntimeServices,
|
|
||||||
} from "@ekklesia/contracts";
|
|
||||||
import { afterEach, describe, expect, it } from "vitest";
|
import { afterEach, describe, expect, it } from "vitest";
|
||||||
import { ModuleRuntimeError, startModuleRuntime } from "../src/modules/runtime.js";
|
import { ModuleRuntimeError, startModuleRuntime } from "../src/modules/runtime.js";
|
||||||
|
|
||||||
@@ -22,7 +19,7 @@ afterEach(async () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
function fakeServices(): ModuleRuntimeServices {
|
function fakeServices(): LegacyModuleRuntimeServices {
|
||||||
return Object.freeze({ database: Object.freeze({}) as ModuleDatabase });
|
return Object.freeze({ database: Object.freeze({}) as ModuleDatabase });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,13 +1,12 @@
|
|||||||
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
|
import type { ModuleConfiguration, ModuleDatabaseExecutor } from "@ekklesia/contracts";
|
||||||
import type {
|
import type {
|
||||||
AnyModuleRuntimeServices,
|
AnyModuleRuntimeServices,
|
||||||
ModuleConfiguration,
|
LegacyModuleRuntimeServices,
|
||||||
ModuleDatabase,
|
ModuleDatabase,
|
||||||
ModuleDatabaseExecutor,
|
} from "../src/modules/database.js";
|
||||||
ModuleRuntimeServices,
|
|
||||||
} from "@ekklesia/contracts";
|
|
||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
import { discoverModules } from "../src/modules/discovery.js";
|
import { discoverModules } from "../src/modules/discovery.js";
|
||||||
import {
|
import {
|
||||||
@@ -151,7 +150,7 @@ function fakeModuleDatabase(): ModuleDatabase {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function fakeModuleRuntimeServices(): ModuleRuntimeServicesLookup {
|
function fakeModuleRuntimeServices(): ModuleRuntimeServicesLookup {
|
||||||
const services = new Map<string, ModuleRuntimeServices>();
|
const services = new Map<string, LegacyModuleRuntimeServices>();
|
||||||
const database = fakeModuleDatabase();
|
const database = fakeModuleDatabase();
|
||||||
return new Proxy(services, {
|
return new Proxy(services, {
|
||||||
get(target, property, receiver) {
|
get(target, property, receiver) {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import {
|
import {
|
||||||
EMPTY_MODULE_STORAGE,
|
EMPTY_MODULE_STORAGE,
|
||||||
|
isModuleStorageError,
|
||||||
isValidModuleStorageKey,
|
isValidModuleStorageKey,
|
||||||
MODULE_STORAGE_KEY_MAX_BYTES,
|
MODULE_STORAGE_KEY_MAX_BYTES,
|
||||||
MODULE_STORAGE_READ_URL_DEFAULT_SECONDS,
|
MODULE_STORAGE_READ_URL_DEFAULT_SECONDS,
|
||||||
@@ -322,6 +323,17 @@ describe("module storage scoping", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("recognizes a storage error from an installed module's separate package copy", () => {
|
||||||
|
const external = Object.assign(new Error("The storage service could not be reached."), {
|
||||||
|
code: "STORAGE_PROVIDER_UNAVAILABLE",
|
||||||
|
name: "ModuleStorageError",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(external).not.toBeInstanceOf(ModuleStorageError);
|
||||||
|
expect(isModuleStorageError(external)).toBe(true);
|
||||||
|
expect(isModuleStorageError(Object.assign(new Error("no"), { code: "UNKNOWN" }))).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
it("gives a module no way to reach another module's storage by knowing its name", async () => {
|
it("gives a module no way to reach another module's storage by knowing its name", async () => {
|
||||||
const shared = fakeProvider();
|
const shared = fakeProvider();
|
||||||
const announcements = createModuleStorage({
|
const announcements = createModuleStorage({
|
||||||
|
|||||||
@@ -1905,7 +1905,7 @@ describe.sequential("live PostgreSQL migration timeout precedence", () => {
|
|||||||
describe.sequential("live PostgreSQL retained history for an uninstalled module", () => {
|
describe.sequential("live PostgreSQL retained history for an uninstalled module", () => {
|
||||||
const repositoryRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..");
|
const repositoryRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..");
|
||||||
const templateModule = path.join(repositoryRoot, "templates", "module");
|
const templateModule = path.join(repositoryRoot, "templates", "module");
|
||||||
const versions = { moduleSdkVersion: "0.1.0", platformVersion: "0.1.0" } as const;
|
const versions = { moduleSdkVersion: "0.2.0", platformVersion: "0.2.0" } as const;
|
||||||
|
|
||||||
async function coreWithoutNewestMigration(directory: string): Promise<string> {
|
async function coreWithoutNewestMigration(directory: string): Promise<string> {
|
||||||
const target = path.join(directory, "core-drizzle");
|
const target = path.join(directory, "core-drizzle");
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ export function manifestFor(
|
|||||||
id,
|
id,
|
||||||
name: `${id} module`,
|
name: `${id} module`,
|
||||||
version: "0.1.0",
|
version: "0.1.0",
|
||||||
compatibility: { platform: "^0.1.0", moduleSdk: "^0.1.0" },
|
compatibility: { platform: "^0.2.0", moduleSdk: "^0.2.0" },
|
||||||
...(options.dependencies ? { dependencies: options.dependencies } : {}),
|
...(options.dependencies ? { dependencies: options.dependencies } : {}),
|
||||||
capabilities: [],
|
capabilities: [],
|
||||||
permissions: [],
|
permissions: [],
|
||||||
|
|||||||
@@ -94,6 +94,8 @@ export interface PackageFixtureOptions {
|
|||||||
readonly tamper?: (files: Map<string, Buffer>) => void;
|
readonly tamper?: (files: Map<string, Buffer>) => void;
|
||||||
/** Entries appended after the package's own, under any path. */
|
/** Entries appended after the package's own, under any path. */
|
||||||
readonly extraEntries?: readonly FixtureEntry[];
|
readonly extraEntries?: readonly FixtureEntry[];
|
||||||
|
/** Extra payload files, listed in the metadata like every other file. */
|
||||||
|
readonly files?: Readonly<Record<string, Uint8Array | string>>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PackageFixture {
|
export interface PackageFixture {
|
||||||
@@ -163,6 +165,9 @@ export async function modulePackageFixture(
|
|||||||
);
|
);
|
||||||
files.set("THIRD_PARTY_NOTICES", Buffer.from("No third-party software is included.\n"));
|
files.set("THIRD_PARTY_NOTICES", Buffer.from("No third-party software is included.\n"));
|
||||||
files.set("LICENSE", Buffer.from("Example module license text.\n"));
|
files.set("LICENSE", Buffer.from("Example module license text.\n"));
|
||||||
|
for (const [filePath, data] of Object.entries(options.files ?? {})) {
|
||||||
|
files.set(filePath, Buffer.from(data));
|
||||||
|
}
|
||||||
|
|
||||||
const listed = [...files.entries()]
|
const listed = [...files.entries()]
|
||||||
.sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0))
|
.sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0))
|
||||||
@@ -175,9 +180,9 @@ export async function modulePackageFixture(
|
|||||||
runtime: { node: ">=20" },
|
runtime: { node: ">=20" },
|
||||||
provenance: {
|
provenance: {
|
||||||
revision: "86ae907f59458508e3277666cdd214f5bd7c1078",
|
revision: "86ae907f59458508e3277666cdd214f5bd7c1078",
|
||||||
packager: { name: "@ekklesia/module-pack", version: "0.1.2" },
|
packager: { name: "@ekklesia/module-pack", version: "0.2.0" },
|
||||||
},
|
},
|
||||||
incorporates: { "@ekklesia/module-sdk": "0.1.2" },
|
incorporates: { "@ekklesia/module-sdk": "0.2.0" },
|
||||||
files: listed,
|
files: listed,
|
||||||
};
|
};
|
||||||
const metadata = options.metadata ? options.metadata(baseMetadata) : baseMetadata;
|
const metadata = options.metadata ? options.metadata(baseMetadata) : baseMetadata;
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import path from "node:path";
|
|||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import {
|
import {
|
||||||
|
MODULE_VERIFIER_SCRATCH_ENV,
|
||||||
|
moduleVerifierScratchDirectory,
|
||||||
parseSha256File,
|
parseSha256File,
|
||||||
parseVerifyModulePackageArguments,
|
parseVerifyModulePackageArguments,
|
||||||
readModulePackagePlatform,
|
readModulePackagePlatform,
|
||||||
@@ -57,6 +59,21 @@ describe("verify-module-package digest files", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("verify-module-package execution scratch", () => {
|
||||||
|
it("accepts an absolute operator-configured directory and otherwise leaves the library default", () => {
|
||||||
|
expect(moduleVerifierScratchDirectory({})).toBeUndefined();
|
||||||
|
expect(
|
||||||
|
moduleVerifierScratchDirectory({ [MODULE_VERIFIER_SCRATCH_ENV]: "/run/ekklesia-verifier" }),
|
||||||
|
).toBe("/run/ekklesia-verifier");
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(["", "relative/path"])("refuses the invalid configured path %j", (configured) => {
|
||||||
|
expect(() =>
|
||||||
|
moduleVerifierScratchDirectory({ [MODULE_VERIFIER_SCRATCH_ENV]: configured }),
|
||||||
|
).toThrow(`${MODULE_VERIFIER_SCRATCH_ENV} must be an absolute path.`);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("verify-module-package platform facts", () => {
|
describe("verify-module-package platform facts", () => {
|
||||||
it("reads this platform's versions and its transitional database runtime from the repository", async () => {
|
it("reads this platform's versions and its transitional database runtime from the repository", async () => {
|
||||||
const root = JSON.parse(await readFile(path.join(repositoryRoot, "package.json"), "utf8"));
|
const root = JSON.parse(await readFile(path.join(repositoryRoot, "package.json"), "utf8"));
|
||||||
|
|||||||
+2
-1
@@ -2,7 +2,8 @@
|
|||||||
"extends": "../packages/typescript-config/node.json",
|
"extends": "../packages/typescript-config/node.json",
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"noEmit": true,
|
"noEmit": true,
|
||||||
"types": ["node", "vitest/globals"]
|
"types": ["node", "vitest/globals"],
|
||||||
|
"rewriteRelativeImportExtensions": true
|
||||||
},
|
},
|
||||||
"include": [
|
"include": [
|
||||||
"drizzle.config.ts",
|
"drizzle.config.ts",
|
||||||
|
|||||||
@@ -42,9 +42,16 @@
|
|||||||
|
|
||||||
{$EKKLESIA_HOSTNAME} {
|
{$EKKLESIA_HOSTNAME} {
|
||||||
# Everything the API serves: the Dashboard at /, the API, display pages and their service
|
# Everything the API serves: the Dashboard at /, the API, display pages and their service
|
||||||
# worker, and the Dashboard and display host scripts. Any other path is refused here.
|
# worker, and the Dashboard and display host scripts.
|
||||||
@ekklesia path / /api/* /display/* /assets/*
|
@ekklesia path / /api/* /display/* /assets/*
|
||||||
|
|
||||||
|
# The Dashboard's other pages (ADR 0057): its own Settings and account pages, and each installed
|
||||||
|
# module's page at /<module-id> (/signage, and later /signage/timelines), so a refreshed deep link
|
||||||
|
# stays put. Module ids are installation-specific, so this forwards every top-level name shaped
|
||||||
|
# like one and the API decides: the Dashboard page where one exists, its sanitized 404 elsewhere.
|
||||||
|
# Any other path is refused here.
|
||||||
|
@dashboardPage path_regexp dashboardPage ^/[a-z][a-z0-9]*(?:-[a-z0-9]+)*(?:/.*)?$
|
||||||
|
|
||||||
# Module realtime is Server-Sent Events: flush every event immediately.
|
# Module realtime is Server-Sent Events: flush every event immediately.
|
||||||
@realtime path /api/v1/module-realtime/*
|
@realtime path /api/v1/module-realtime/*
|
||||||
|
|
||||||
@@ -63,6 +70,12 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
handle @dashboardPage {
|
||||||
|
reverse_proxy api:3000 {
|
||||||
|
import ekklesia_forwarding
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
handle {
|
handle {
|
||||||
respond 404
|
respond 404
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -51,12 +51,28 @@
|
|||||||
{$EKKLESIA_DASHBOARD_HOSTNAME} {
|
{$EKKLESIA_DASHBOARD_HOSTNAME} {
|
||||||
@dashboard path / /assets/ekklesia-dashboard/*
|
@dashboard path / /assets/ekklesia-dashboard/*
|
||||||
|
|
||||||
|
# The Dashboard's other pages (ADR 0057): its own Settings and account pages, and each installed
|
||||||
|
# module's page at /<module-id> (/signage, and later /signage/timelines), so a refreshed deep link
|
||||||
|
# stays put. Module ids are installation-specific, so this forwards every top-level name shaped
|
||||||
|
# like one and the API decides: the Dashboard page where one exists, its sanitized 404 elsewhere.
|
||||||
|
# The API's own namespaces stay refused on this hostname.
|
||||||
|
@dashboardPage {
|
||||||
|
path_regexp dashboardPage ^/[a-z][a-z0-9]*(?:-[a-z0-9]+)*(?:/.*)?$
|
||||||
|
not path /api /api/* /display /display/* /assets /assets/*
|
||||||
|
}
|
||||||
|
|
||||||
handle @dashboard {
|
handle @dashboard {
|
||||||
reverse_proxy api:3000 {
|
reverse_proxy api:3000 {
|
||||||
import ekklesia_forwarding
|
import ekklesia_forwarding
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
handle @dashboardPage {
|
||||||
|
reverse_proxy api:3000 {
|
||||||
|
import ekklesia_forwarding
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
handle {
|
handle {
|
||||||
respond 404
|
respond 404
|
||||||
}
|
}
|
||||||
|
|||||||
+30
-6
@@ -17,6 +17,7 @@ specific to one organization.
|
|||||||
| [`scripts/init-tls.sh`](scripts/init-tls.sh) | Creates the installation-private CA and the `postgres` and `object-storage` server certificates. |
|
| [`scripts/init-tls.sh`](scripts/init-tls.sh) | Creates the installation-private CA and the `postgres` and `object-storage` server certificates. |
|
||||||
| [`scripts/gen-secrets.sh`](scripts/gen-secrets.sh) | Prints freshly generated secrets to paste into the env files. Writes nothing. |
|
| [`scripts/gen-secrets.sh`](scripts/gen-secrets.sh) | Prints freshly generated secrets to paste into the env files. Writes nothing. |
|
||||||
| [`Dockerfile`](Dockerfile) | The one production image, for the API and every operator command ([The production image](#the-production-image)). |
|
| [`Dockerfile`](Dockerfile) | The one production image, for the API and every operator command ([The production image](#the-production-image)). |
|
||||||
|
| [`display-kiosk.md`](display-kiosk.md) | Setting up an unattended display screen: launch, pairing, and recovery. |
|
||||||
|
|
||||||
## The reference deployment
|
## The reference deployment
|
||||||
|
|
||||||
@@ -170,9 +171,20 @@ identity, target, and Node.js range against this platform, runs the platform's m
|
|||||||
validation, and loads the backend once in a short-lived process with no environment. Only then does
|
validation, and loads the backend once in a short-lived process with no environment. Only then does
|
||||||
it write the verified module directory, to a new directory outside module storage:
|
it write the verified module directory, to a new directory outside module storage:
|
||||||
|
|
||||||
|
The backend probe executes the package's server-side code; the verifier container is its isolation
|
||||||
|
boundary. Keep the general-purpose `/tmp` mount `noexec`, and give only this one-shot verifier a
|
||||||
|
separate executable scratch tmpfs. The mount below is ephemeral, bounded at 1100 MiB (just above the
|
||||||
|
package format's 1 GiB expanded limit), `nodev`, `nosuid`, and writable only by the image's
|
||||||
|
unprivileged `node` user. `EKKLESIA_MODULE_VERIFIER_SCRATCH_DIR` tells the CLI where that mount is;
|
||||||
|
the verifier creates a unique child for each run and removes it on success or failure.
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
mkdir -m 777 staging # writable by the image's unprivileged user; nothing else is
|
mkdir -m 777 staging # writable by the image's unprivileged user; nothing else is
|
||||||
docker run --rm --network none --read-only --tmpfs /tmp \
|
docker run --rm --network none --read-only \
|
||||||
|
--tmpfs /tmp:rw,nosuid,nodev,noexec,size=64m \
|
||||||
|
--tmpfs /run/ekklesia-verifier:rw,nosuid,nodev,exec,size=1100m,mode=0700,uid=1000,gid=1000 \
|
||||||
|
--cap-drop ALL --security-opt no-new-privileges \
|
||||||
|
-e EKKLESIA_MODULE_VERIFIER_SCRATCH_DIR=/run/ekklesia-verifier \
|
||||||
-v "$PWD:/package:ro" -v "$PWD/staging:/staging" \
|
-v "$PWD:/package:ro" -v "$PWD/staging:/staging" \
|
||||||
--entrypoint node "$EKKLESIA_IMAGE" core/dist/cli/verify-module-package.js \
|
--entrypoint node "$EKKLESIA_IMAGE" core/dist/cli/verify-module-package.js \
|
||||||
/package/<id>-<version>-any.tar.gz --sha256-file /package/<id>-<version>-any.tar.gz.sha256 \
|
/package/<id>-<version>-any.tar.gz --sha256-file /package/<id>-<version>-any.tar.gz.sha256 \
|
||||||
@@ -229,6 +241,14 @@ Module storage is installation state: back it up with the database and the bucke
|
|||||||
`docker compose run --rm migrate node core/dist/cli/apply-migrations.js`.
|
`docker compose run --rm migrate node core/dist/cli/apply-migrations.js`.
|
||||||
5. **Restart on the new image:** `docker compose -f compose.yaml -f compose.proxy.yaml up -d --wait`.
|
5. **Restart on the new image:** `docker compose -f compose.yaml -f compose.proxy.yaml up -d --wait`.
|
||||||
|
|
||||||
|
**Upgrading to a release with Dashboard page addresses (ADR 0057):** compare your `Caddyfile` with
|
||||||
|
the new `Caddyfile.example` (or `Caddyfile.split.example`). Each gains a `@dashboardPage` matcher
|
||||||
|
and `handle` block that forward every top-level path shaped like a module id — `/settings`,
|
||||||
|
`/account`, and each installed module's page such as `/signage` — to the API, which answers with
|
||||||
|
the Dashboard page where one exists and its sanitized `404` elsewhere. Without it the Dashboard still
|
||||||
|
opens at `/`, but refreshing or bookmarking any other Dashboard page is the proxy's `404`. Reload the
|
||||||
|
proxy after editing it. Installing or removing a module needs no proxy change.
|
||||||
|
|
||||||
### Rolling back
|
### Rolling back
|
||||||
|
|
||||||
Set `EKKLESIA_IMAGE` back to the previous tag and run `up -d --wait` again. That is enough only when
|
Set `EKKLESIA_IMAGE` back to the previous tag and run `up -d --wait` again. That is enough only when
|
||||||
@@ -251,7 +271,7 @@ The same image serves both; only configuration differs.
|
|||||||
|
|
||||||
| Hostname | Serves | Refuses (`404`) |
|
| Hostname | Serves | Refuses (`404`) |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| Dashboard (`EKKLESIA_DASHBOARD_HOSTNAME`) | `/` and `/assets/ekklesia-dashboard/*` | everything else, including `/api/*` |
|
| Dashboard (`EKKLESIA_DASHBOARD_HOSTNAME`) | `/`, `/assets/ekklesia-dashboard/*`, and every module-shaped top-level path (the Dashboard's pages, such as `/settings` and `/signage`; the API answers the rest with its `404`) | everything else, including `/api/*`, `/display/*`, and `/assets/*` |
|
||||||
| API (`EKKLESIA_HOSTNAME`) | `/api/*`, `/display/*`, `/assets/ekklesia-display/*` | everything else, including `/` |
|
| API (`EKKLESIA_HOSTNAME`) | `/api/*`, `/display/*`, `/assets/ekklesia-display/*` | everything else, including `/` |
|
||||||
| Storage (`EKKLESIA_STORAGE_HOSTNAME`) | signed object URLs, unchanged | |
|
| Storage (`EKKLESIA_STORAGE_HOSTNAME`) | signed object URLs, unchanged | |
|
||||||
|
|
||||||
@@ -327,7 +347,11 @@ networks:
|
|||||||
|
|
||||||
Then make your proxy do what `Caddyfile.example` does:
|
Then make your proxy do what `Caddyfile.example` does:
|
||||||
|
|
||||||
- forward `/`, `/api/*`, `/display/*`, and `/assets/*` on the Ekklesia hostname to `api:3000`;
|
- forward `/`, `/api/*`, `/display/*`, and `/assets/*` on the Ekklesia hostname to `api:3000`,
|
||||||
|
and every path whose first segment is shaped like a module id, `^/[a-z][a-z0-9]*(?:-[a-z0-9]+)*(?:/.*)?$` (ADR 0057): that is
|
||||||
|
`/settings`, `/account`, and each installed module's page such as `/signage`, and the API answers
|
||||||
|
anything that is not a real page with its sanitized `404`. Without it, refreshing any Dashboard page
|
||||||
|
other than `/` is your proxy's `404`. Do not list module ids: they change as modules are installed;
|
||||||
- **overwrite** `X-Forwarded-For` with the one client address it resolved, and strip
|
- **overwrite** `X-Forwarded-For` with the one client address it resolved, and strip
|
||||||
`CF-Connecting-IP`, `X-Real-IP`, `Forwarded`, and `True-Client-IP`;
|
`CF-Connecting-IP`, `X-Real-IP`, `Forwarded`, and `True-Client-IP`;
|
||||||
- stream responses on `/api/v1/module-realtime/*` without buffering (Server-Sent Events);
|
- stream responses on `/api/v1/module-realtime/*` without buffering (Server-Sent Events);
|
||||||
@@ -393,7 +417,7 @@ deployment:
|
|||||||
|
|
||||||
| # | Item | Where it is met |
|
| # | Item | Where it is met |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| 1 | The proxy forwards the Dashboard and `/api` on the intended origin | `Caddyfile.example` routes `/`, `/api/*`, `/display/*`, `/assets/*` on one hostname to `api`; the API serves the Dashboard itself. In the split topology, `Caddyfile.split.example` gives each hostname exactly its own paths and the exact trusted-origin CORS of ADR 0045 covers the rest. |
|
| 1 | The proxy forwards the Dashboard and `/api` on the intended origin | `Caddyfile.example` routes `/`, `/api/*`, `/display/*`, `/assets/*`, and every module-shaped top-level path (the Dashboard's pages) on one hostname to `api`; the API serves the Dashboard itself. In the split topology, `Caddyfile.split.example` gives each hostname exactly its own paths and the exact trusted-origin CORS of ADR 0045 covers the rest. |
|
||||||
| 2 | The origin port is unreachable from the Internet | The base publishes no port; only `proxy` publishes 80 and 443. |
|
| 2 | The origin port is unreachable from the Internet | The base publishes no port; only `proxy` publishes 80 and 443. |
|
||||||
| 3 | The origin port is unreachable from untrusted networks | `api` is reachable only on Docker networks; `EKKLESIA_PUBLISH_ADDRESS` limits the proxy to one interface. Firewall the host itself. |
|
| 3 | The origin port is unreachable from untrusted networks | `api` is reachable only on Docker networks; `EKKLESIA_PUBLISH_ADDRESS` limits the proxy to one interface. Firewall the host itself. |
|
||||||
| 4 | The proxy overwrites the trusted client-IP header | `header_up X-Forwarded-For {client_ip}` in `Caddyfile.example`. |
|
| 4 | The proxy overwrites the trusted client-IP header | `header_up X-Forwarded-For {client_ip}` in `Caddyfile.example`. |
|
||||||
@@ -500,10 +524,10 @@ finds its files:
|
|||||||
```sh
|
```sh
|
||||||
docker build \
|
docker build \
|
||||||
--file deploy/Dockerfile \
|
--file deploy/Dockerfile \
|
||||||
--build-arg VERSION=0.1.2 \
|
--build-arg VERSION=0.2.5 \
|
||||||
--build-arg REVISION="$(git rev-parse HEAD)" \
|
--build-arg REVISION="$(git rev-parse HEAD)" \
|
||||||
--build-arg SOURCE=https://git.example.org/your-org/ekklesia \
|
--build-arg SOURCE=https://git.example.org/your-org/ekklesia \
|
||||||
--tag ekklesia:0.1.2 \
|
--tag ekklesia:0.2.5 \
|
||||||
.
|
.
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -132,6 +132,10 @@ services:
|
|||||||
command:
|
command:
|
||||||
- mini
|
- mini
|
||||||
- -dir=/data
|
- -dir=/data
|
||||||
|
# A bucket is a SeaweedFS collection and claims a block of volumes. The ~1 GB automatic
|
||||||
|
# volume size leaves modest hosts with room for only a few collections; after that, writes
|
||||||
|
# stall instead of failing. This changes allocation granularity, not object or total capacity.
|
||||||
|
- -master.volumeSizeLimitMB=32
|
||||||
- -s3.port.https=8443
|
- -s3.port.https=8443
|
||||||
- -s3.cert.file=/run/ekklesia/tls/object-storage.crt
|
- -s3.cert.file=/run/ekklesia/tls/object-storage.crt
|
||||||
- -s3.key.file=/run/ekklesia/tls/object-storage.key
|
- -s3.key.file=/run/ekklesia/tls/object-storage.key
|
||||||
|
|||||||
@@ -0,0 +1,130 @@
|
|||||||
|
# Setting up a display kiosk
|
||||||
|
|
||||||
|
A display is a screen — a television with a small computer, or a mini PC on a wall — that opens one
|
||||||
|
module's display page full screen and keeps it open unattended. This guide sets one up so it starts
|
||||||
|
on boot, stays paired, recovers from network and server outages on its own, and never opens a second
|
||||||
|
copy of itself.
|
||||||
|
|
||||||
|
Pairing and display identity belong to the platform (ADR 0036). Today an administrator approves a
|
||||||
|
screen in the Dashboard sidebar's **Display devices** panel; a later release moves this to
|
||||||
|
**Settings → Displays**. Nothing below changes when that happens.
|
||||||
|
|
||||||
|
## What the screen needs
|
||||||
|
|
||||||
|
- Chromium or Google Chrome, current stable. The display page is tested against Chromium.
|
||||||
|
- A **dedicated, persistent browser profile directory**. The device credential is an HttpOnly cookie
|
||||||
|
in that profile; it is what keeps the screen paired across reboots. Never use a guest, incognito, or
|
||||||
|
"clear data on exit" profile, and never share the profile with a person's browsing.
|
||||||
|
- Network access to the installation's **API hostname** (the display page, its realtime stream, and
|
||||||
|
its module requests) and to the storage hostname that serves images.
|
||||||
|
- A clock that is roughly right. Playback follows the server's clock, but TLS does not tolerate a
|
||||||
|
wildly wrong one.
|
||||||
|
|
||||||
|
## The address
|
||||||
|
|
||||||
|
Every display-capable module has one display page on the API hostname:
|
||||||
|
|
||||||
|
```text
|
||||||
|
https://<api-hostname>/display/<module-id>
|
||||||
|
```
|
||||||
|
|
||||||
|
For Signage that is `https://<api-hostname>/display/signage`. The Signage **Displays** tab shows the
|
||||||
|
exact address under **Copy setup address**. Open it on the screen itself; opening it on an
|
||||||
|
administrator's own computer only previews the display (and that browser appears in the roster as a
|
||||||
|
*Signed-in browser*, which can be removed once it is offline).
|
||||||
|
|
||||||
|
## Launching the browser
|
||||||
|
|
||||||
|
Run exactly **one** launcher, and launch the page as an app window with a fixed profile:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
chromium \
|
||||||
|
--user-data-dir=/var/lib/display-kiosk/profile \
|
||||||
|
--app=https://<api-hostname>/display/signage \
|
||||||
|
--kiosk \
|
||||||
|
--no-first-run \
|
||||||
|
--noerrdialogs \
|
||||||
|
--disable-session-crashed-bubble \
|
||||||
|
--hide-crash-restore-bubble \
|
||||||
|
--disable-features=Translate \
|
||||||
|
--autoplay-policy=no-user-gesture-required \
|
||||||
|
--check-for-update-interval=31536000
|
||||||
|
```
|
||||||
|
|
||||||
|
- `--app` opens the page with no tab strip and no address bar. `--kiosk` makes it full screen.
|
||||||
|
- `--user-data-dir` is the persistent profile that holds the pairing. Keep it on persistent storage
|
||||||
|
and back it up with nothing: if it is lost, the screen simply shows a new pairing code.
|
||||||
|
- Do **not** add `--restore-last-session`, and do not also start the browser from a desktop autostart
|
||||||
|
entry. Chromium allows one process per profile; a second launch hands its URL to the running
|
||||||
|
process, which opens a **second window or tab** of the display. That is the usual cause of
|
||||||
|
"duplicate" displays. One launcher, one profile, one window.
|
||||||
|
|
||||||
|
### Start on boot and restart on exit (systemd)
|
||||||
|
|
||||||
|
Run the browser as an unprivileged user in that user's graphical session, from a single user unit:
|
||||||
|
|
||||||
|
```ini
|
||||||
|
# ~/.config/systemd/user/display-kiosk.service
|
||||||
|
[Unit]
|
||||||
|
Description=Display kiosk
|
||||||
|
After=graphical-session.target network-online.target
|
||||||
|
Wants=network-online.target
|
||||||
|
PartOf=graphical-session.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
ExecStart=/usr/bin/chromium --user-data-dir=%h/.local/share/display-kiosk --app=https://<api-hostname>/display/signage --kiosk --no-first-run --noerrdialogs --disable-session-crashed-bubble --hide-crash-restore-bubble --disable-features=Translate --autoplay-policy=no-user-gesture-required --check-for-update-interval=31536000
|
||||||
|
Restart=always
|
||||||
|
RestartSec=5
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=graphical-session.target
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
systemctl --user daemon-reload
|
||||||
|
systemctl --user enable --now display-kiosk.service
|
||||||
|
loginctl enable-linger <kiosk-user> # with the desktop set to log this user in automatically
|
||||||
|
```
|
||||||
|
|
||||||
|
`systemctl --user stop display-kiosk` closes the browser cleanly, which is what keeps its profile —
|
||||||
|
and the pairing cookie — intact. Avoid cutting power to a running kiosk when you can: a clean shutdown
|
||||||
|
is always safer for a browser profile.
|
||||||
|
|
||||||
|
## Pairing
|
||||||
|
|
||||||
|
1. Start the kiosk. With no credential it shows a **six-digit pairing code**.
|
||||||
|
2. In the Dashboard, enter the code under **Display devices**, give the screen a name, and approve.
|
||||||
|
3. Within a few seconds the screen collects its credential and starts showing content.
|
||||||
|
4. In Signage → **Displays**, name the display and choose its group and audience.
|
||||||
|
|
||||||
|
A paired screen stays paired across browser restarts and reboots, and for as long as it is in use:
|
||||||
|
its credential is renewed each time it checks in, and only expires after **180 days without being
|
||||||
|
used** (ADR 0056). A screen that is switched off and stored for longer than that, or that is revoked,
|
||||||
|
returns to its pairing code and is paired again the same way.
|
||||||
|
|
||||||
|
## What happens when things go wrong
|
||||||
|
|
||||||
|
| Event | What the screen does |
|
||||||
|
| --- | --- |
|
||||||
|
| Network drops for seconds | Nothing visible. The display keeps running; the realtime stream reconnects. |
|
||||||
|
| Network or API down for longer | Keeps playing what it has cached, with a small corner note; recovers by itself when the server is back. No remount, no reload. |
|
||||||
|
| API restarted or upgraded | Reconnects by itself. A new module version is picked up within five minutes and swapped in. |
|
||||||
|
| Browser crashes or is restarted | systemd restarts it; the same profile is still paired. |
|
||||||
|
| Machine reboots | Starts on boot and resumes. If the server is not up yet, the screen retries on its own (every few seconds, backing off to once a minute) until it is. |
|
||||||
|
| Display revoked in the Dashboard | Stops showing content within seconds and returns to its pairing code. |
|
||||||
|
| Display moved to another group or audience | Switches content within about one heartbeat (≈12 s), no reload. |
|
||||||
|
|
||||||
|
**Offline cold start is not supported yet.** A screen that is already showing content survives an
|
||||||
|
outage. A screen that *boots* while the server is completely unreachable can load its cached page but
|
||||||
|
cannot mount the module until the server answers, because the authorization it needs is never cached
|
||||||
|
(ADR 0043, Signage ADR 0006). It shows a short message and retries on its own until the server is
|
||||||
|
reachable — nobody needs to touch it.
|
||||||
|
|
||||||
|
## Checklist for a new kiosk
|
||||||
|
|
||||||
|
- [ ] One systemd unit, no desktop autostart entry, no second launcher.
|
||||||
|
- [ ] `--user-data-dir` on persistent storage, not shared, not cleared on exit.
|
||||||
|
- [ ] Automatic login of the kiosk user, and screen blanking/power saving disabled.
|
||||||
|
- [ ] Paired and named; assigned in Signage.
|
||||||
|
- [ ] Unplug the network for a minute: content keeps playing, then the note clears on reconnect.
|
||||||
|
- [ ] Reboot: the screen comes back paired without anyone touching it.
|
||||||
@@ -267,6 +267,8 @@ to follow — and the API never builds frontend source at run time.
|
|||||||
and its only allowed build script, and a second toolchain buys HMR that a proof does not need.
|
and its only allowed build script, and a second toolchain buys HMR that a proof does not need.
|
||||||
- **A client router.** Rejected: a selected-module state is sufficient, and host routing versus
|
- **A client router.** Rejected: a selected-module state is sufficient, and host routing versus
|
||||||
module routing is a design this pass should not settle by accident.
|
module routing is a design this pass should not settle by accident.
|
||||||
|
> **Cross-reference — see [ADR 0057](0057-dashboard-urls-are-host-owned-routes.md).** The Dashboard
|
||||||
|
> now has host-owned URL routing; module routing remains a separate, deferred decision.
|
||||||
- **`react-router-dom`.** Rejected outright — modules are denied it precisely because routing belongs
|
- **`react-router-dom`.** Rejected outright — modules are denied it precisely because routing belongs
|
||||||
to the host, and adopting it here for a two-item selector would prejudge that design.
|
to the host, and adopting it here for a two-item selector would prejudge that design.
|
||||||
- **Clearing the container between selections instead of keying the frame.** Rejected: it leaves one
|
- **Clearing the container between selections instead of keying the frame.** Rejected: it leaves one
|
||||||
|
|||||||
@@ -255,6 +255,10 @@ that is unremarkable, and it is measured work rather than hidden.
|
|||||||
|
|
||||||
## Deferred
|
## Deferred
|
||||||
|
|
||||||
|
> **Cross-reference — see [ADR 0057](0057-dashboard-urls-are-host-owned-routes.md).** Client-side
|
||||||
|
> routing and deep links to a selected module, deferred below, have landed; the rest of this list is
|
||||||
|
> unchanged.
|
||||||
|
|
||||||
Not implemented or promised here: the navigation **editor** — drag and drop, a management UI, and any
|
Not implemented or promised here: the navigation **editor** — drag and drop, a management UI, and any
|
||||||
administrator-facing affordance beyond the API; nested folders and subgroups; icons, colors, badges,
|
administrator-facing affordance beyond the API; nested folders and subgroups; icons, colors, badges,
|
||||||
and module descriptions in navigation; `suggestedGroup` and any configuration-initialization or
|
and module descriptions in navigation; `suggestedGroup` and any configuration-initialization or
|
||||||
|
|||||||
@@ -165,6 +165,10 @@ redirect target.
|
|||||||
A future client router may define an internal return-location contract, but it must be a separately
|
A future client router may define an internal return-location contract, but it must be a separately
|
||||||
validated, same-origin route decision. This pass does not create one speculatively.
|
validated, same-origin route decision. This pass does not create one speculatively.
|
||||||
|
|
||||||
|
> **Cross-reference — see [ADR 0057](0057-dashboard-urls-are-host-owned-routes.md).** The Dashboard
|
||||||
|
> now has a host-owned router. It adds no return-location contract: sign-in still happens in place at
|
||||||
|
> the current URL, which is now a real page address.
|
||||||
|
|
||||||
### Cookie and origin policy does not broaden for the Dashboard
|
### Cookie and origin policy does not broaden for the Dashboard
|
||||||
|
|
||||||
The session cookie keeps the identity platform's existing properties: HttpOnly, SameSite=Lax,
|
The session cookie keeps the identity platform's existing properties: HttpOnly, SameSite=Lax,
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
- Status: Accepted
|
- Status: Accepted
|
||||||
- Date: 2026-08-24
|
- Date: 2026-08-24
|
||||||
|
- Session lifetime superseded by: [0056](0056-device-credentials-last-while-in-use.md)
|
||||||
- Extends: [0013](0013-core-authorization-and-owner-bootstrap.md),
|
- Extends: [0013](0013-core-authorization-and-owner-bootstrap.md),
|
||||||
[0032](0032-native-module-realtime-transport.md),
|
[0032](0032-native-module-realtime-transport.md),
|
||||||
[0033](0033-display-kiosk-host-contract.md),
|
[0033](0033-display-kiosk-host-contract.md),
|
||||||
|
|||||||
@@ -151,6 +151,10 @@ reaching into the artifact or the artifact importing the host.
|
|||||||
|
|
||||||
## Deferred
|
## Deferred
|
||||||
|
|
||||||
|
> **Cross-reference — see [ADR 0060](0060-light-dark-system-theme-and-motion.md).** A dark theme,
|
||||||
|
> System appearance, reduced motion, and a radius scale have landed; ADR 0059 added the focus-trap
|
||||||
|
> utility. The rest of this list is unchanged.
|
||||||
|
|
||||||
A theme editor, a brand builder, per-installation persisted overrides, a dark theme or any second
|
A theme editor, a brand builder, per-installation persisted overrides, a dark theme or any second
|
||||||
theme, density modes, an icon set, charting, layout primitives beyond what the elements need, a
|
theme, density modes, an icon set, charting, layout primitives beyond what the elements need, a
|
||||||
documentation site, and a focus-trap utility.
|
documentation site, and a focus-trap utility.
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# 0052: Reviewed license supplements cover redistributed dependencies whose packages omit required material
|
# 0052: Reviewed license supplements cover redistributed dependencies whose packages omit required material
|
||||||
|
|
||||||
- Status: Proposed
|
- Status: Accepted
|
||||||
- Date: 2026-10-07
|
- Date: 2026-10-07
|
||||||
- Extends: [0049](0049-module-package-v1.md), in one respect only. ADR 0049 left exactly how licence
|
- Extends: [0049](0049-module-package-v1.md), in one respect only. ADR 0049 left exactly how licence
|
||||||
and notice material is collected to the packager's implementation. That implementation, and the
|
and notice material is collected to the packager's implementation. That implementation, and the
|
||||||
@@ -9,7 +9,9 @@
|
|||||||
further source: a reviewed supplement. Everything else in ADR 0049 is unchanged, as is
|
further source: a reviewed supplement. Everything else in ADR 0049 is unchanged, as is
|
||||||
`packageFormat`.
|
`packageFormat`.
|
||||||
- Related: [ADR 0048](0048-generic-image-and-installed-module-storage.md),
|
- Related: [ADR 0048](0048-generic-image-and-installed-module-storage.md),
|
||||||
[ADR 0051](0051-module-package-license-material.md)
|
[ADR 0051](0051-module-package-license-material.md),
|
||||||
|
[ADR 0055](0055-corresponding-source-artifacts.md) (decides the source-obligation policy deferred
|
||||||
|
below)
|
||||||
|
|
||||||
## Context
|
## Context
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,255 @@
|
|||||||
|
# 0053: A module builds outside the monorepo, from Ekklesia packages published over the npm registry protocol
|
||||||
|
|
||||||
|
- Status: Accepted
|
||||||
|
- Date: 2026-10-07
|
||||||
|
- Superseded in part by: [0054](0054-public-package-reads-before-launch.md) — only where this ADR
|
||||||
|
describes the package phase before launch as private, or requires authenticated package reads.
|
||||||
|
- Extends: [0049](0049-module-package-v1.md). This is the step ADR 0049 deferred: "publishing the
|
||||||
|
Ekklesia SDK packages for module builds outside a platform workspace". A registry is used only to
|
||||||
|
build a module. Installing one stays registry-free: an installed package is self-contained, as ADR
|
||||||
|
0049 decided.
|
||||||
|
- Related: [ADR 0002](0002-pnpm-workspace-and-typescript.md),
|
||||||
|
[ADR 0006](0006-versioned-capabilities-and-compiled-module-runtime.md),
|
||||||
|
[ADR 0021](0021-modules-as-hostable-products.md),
|
||||||
|
[ADR 0023](0023-module-ui-build-pipeline.md),
|
||||||
|
[ADR 0039](0039-shared-design-tokens-and-elements.md),
|
||||||
|
[ADR 0047](0047-module-ci-harness.md),
|
||||||
|
[ADR 0048](0048-generic-image-and-installed-module-storage.md),
|
||||||
|
[ADR 0050](0050-module-database-abi-v1.md),
|
||||||
|
[ADR 0051](0051-module-package-license-material.md),
|
||||||
|
[ADR 0052](0052-reviewed-license-supplements.md)
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
Modules are independently versioned products in their own repositories (ADR 0021). Yet no module can
|
||||||
|
be built outside Ekklesia's monorepo today. Signage's continuous integration clones the platform at
|
||||||
|
a pinned commit, places Signage under `modules/signage`, and installs and builds inside that
|
||||||
|
workspace, because its Ekklesia dependencies exist only as `workspace:*` packages.
|
||||||
|
|
||||||
|
An investigation of the current packages found the following.
|
||||||
|
|
||||||
|
- **One coordinated version.** Every `@ekklesia/*` package, and the platform, is version 0.1.2. The
|
||||||
|
release process already bumps them together. The platform reports its Module SDK version, which
|
||||||
|
manifests' `compatibility.moduleSdk` ranges are checked against (ADR 0006), by reading
|
||||||
|
`@ekklesia/module-sdk`'s own package version.
|
||||||
|
- **The tarballs already stand alone.** `pnpm pack` rewrites `workspace:*` ranges to exact versions,
|
||||||
|
and each tarball carries only its built `dist`, declarations, and `LICENSE`. But every package's
|
||||||
|
`prepack` runs a command at the monorepo root, and `@ekklesia/typescript-config` is marked
|
||||||
|
`private`.
|
||||||
|
- **The SDK exposes everything.** `@ekklesia/module-sdk` re-exports the whole `@ekklesia/contracts`
|
||||||
|
barrel. That includes contracts no module needs, such as authorization and identity, and the
|
||||||
|
legacy Drizzle `ModuleDatabase` type. `@ekklesia/contracts` depends on `drizzle-orm` for that type,
|
||||||
|
so every module install pulls Drizzle in.
|
||||||
|
- Neither the module template nor Signage imports `@ekklesia/contracts` directly.
|
||||||
|
- **Frameworks are declared twice.** `@ekklesia/ui` lists `react`, and `@ekklesia/module-sdk` lists
|
||||||
|
`drizzle-orm`, as both a dependency and a peer dependency. That invites a second copy of a runtime
|
||||||
|
that must be single within one module bundle.
|
||||||
|
- **The canonical packager assumes the monorepo**, in two ways:
|
||||||
|
- it finds the module's Node.js range only through an enclosing `pnpm-workspace.yaml`;
|
||||||
|
- it decides that an Ekklesia package is the platform's own, not third-party, because its real
|
||||||
|
path does not contain `node_modules`.
|
||||||
|
|
||||||
|
An `@ekklesia/*` package installed from a registry would therefore be treated as third-party.
|
||||||
|
Packaging would omit it from `incorporates` and skip the Module SDK compatibility check.
|
||||||
|
- **The registry and its hosts.**
|
||||||
|
- The current private host is a Gitea 1.27.2 server. Its npm registry is enabled for the
|
||||||
|
organization, and that endpoint and the repositories are currently readable anonymously.
|
||||||
|
- pnpm leaves a tarball URL out of its lockfile only when the URL has the canonical
|
||||||
|
`<registry>/<name>/-/<name>-<version>.tgz` form. Gitea advertises another form, so lockfiles
|
||||||
|
produced against Gitea name the Gitea host.
|
||||||
|
- The integrity hashes in those lockfiles are independent of the host.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
### The npm registry protocol, not a provider
|
||||||
|
|
||||||
|
Ekklesia's module-development packages are distributed over the **standard npm registry protocol**,
|
||||||
|
and modules install them with ordinary pnpm or npm commands.
|
||||||
|
|
||||||
|
- **No provider is part of the architecture.** Not Gitea, GitHub, npmjs, or any other. A module
|
||||||
|
repository depends on the protocol and on a scope-to-registry mapping in normal npm/pnpm
|
||||||
|
configuration.
|
||||||
|
- **Gitea may host the private phase.** It is a host for this phase, not a permanent decision.
|
||||||
|
- **No host-neutral lockfile or Ekklesia proxy is required.** Moving to another registry may need a
|
||||||
|
one-time change of a module's registry configuration and a regeneration of its lockfile. The
|
||||||
|
integrity hashes keep verifying the package content across such a move.
|
||||||
|
|
||||||
|
A module repository's registry configuration is, for example:
|
||||||
|
|
||||||
|
```ini
|
||||||
|
# .npmrc, committed: where the @ekklesia scope lives, and nothing secret.
|
||||||
|
@ekklesia:registry=https://<registry endpoint>/
|
||||||
|
```
|
||||||
|
|
||||||
|
```ini
|
||||||
|
# User or CI configuration, never committed: the credential, from the environment.
|
||||||
|
//<registry endpoint>/:_authToken=${EKKLESIA_PACKAGES_TOKEN}
|
||||||
|
```
|
||||||
|
|
||||||
|
### The public package set
|
||||||
|
|
||||||
|
Only packages a module author needs are published. Nothing is published merely because it exists in
|
||||||
|
the workspace.
|
||||||
|
|
||||||
|
| Category | Packages |
|
||||||
|
| --- | --- |
|
||||||
|
| Author API | `@ekklesia/module-sdk`, `@ekklesia/module-database`, `@ekklesia/module-ui-sdk`, `@ekklesia/design-tokens` |
|
||||||
|
| Tooling | `@ekklesia/module-ui-build`, `@ekklesia/module-pack`, `@ekklesia/typescript-config` |
|
||||||
|
| Pre-1.0 component API | `@ekklesia/ui`: publishable, explicitly unstable before 1.0 |
|
||||||
|
| Implementation dependency only | `@ekklesia/contracts`: may be published because the packages above depend on it, but it is **not** a supported direct module-author API |
|
||||||
|
| Never published | `@ekklesia/core`, the applications, `@ekklesia/module-ui-host`, `@ekklesia/module-template`, and every other platform internal |
|
||||||
|
|
||||||
|
### A curated SDK surface
|
||||||
|
|
||||||
|
Before the first external publication, the author-facing SDK is narrowed.
|
||||||
|
|
||||||
|
- **A curated API.** `@ekklesia/module-sdk` exposes what a module author needs, and no longer
|
||||||
|
re-exports the complete `@ekklesia/contracts` barrel.
|
||||||
|
- **No forced Drizzle.** The stable SDK root does not make Drizzle a dependency of modules that do
|
||||||
|
not use it.
|
||||||
|
- **No legacy database contract.** The legacy Drizzle database contract (ADR 0016's facade type) is
|
||||||
|
not part of the standalone author-facing surface. Core still serves the legacy facade for its
|
||||||
|
deprecation window (ADR 0050). This decision only keeps its type out of what new standalone
|
||||||
|
modules build against.
|
||||||
|
- **Drizzle support lives in one place.** Drizzle-specific helpers belong to the
|
||||||
|
`@ekklesia/module-database` Drizzle adapter (ADR 0050).
|
||||||
|
|
||||||
|
### Dependency rules
|
||||||
|
|
||||||
|
These rules apply to the published packages:
|
||||||
|
|
||||||
|
- **React and ReactDOM** are peer dependencies of the public UI packages, plus development
|
||||||
|
dependencies for those packages' own builds and tests. This does not change ADR 0023: a module UI
|
||||||
|
artifact still bundles React. The peer only ensures there is one React in that bundle, the
|
||||||
|
module's own.
|
||||||
|
- **Drizzle** is an optional peer dependency only where the Drizzle adapter needs it.
|
||||||
|
- **Real implementation dependencies,** at run time or build time, remain normal dependencies.
|
||||||
|
|
||||||
|
These rules apply to a module repository:
|
||||||
|
|
||||||
|
- **Runtime and module-code packages** go in `dependencies`: the Author API packages, `@ekklesia/ui`,
|
||||||
|
React, and the module's ORM if it uses one.
|
||||||
|
- **Build tooling and configuration** go in `devDependencies`: `@ekklesia/module-ui-build`,
|
||||||
|
`@ekklesia/module-pack`, `@ekklesia/typescript-config`, and type packages.
|
||||||
|
|
||||||
|
### Versioning
|
||||||
|
|
||||||
|
- **One coordinated version during 0.x.** Every public Ekklesia module-development package uses one
|
||||||
|
release version, equal to the platform release version. The version the platform reports for
|
||||||
|
`moduleSdk` compatibility is therefore the version of the packages a module was built with.
|
||||||
|
- **No mixed trains.** Within a published train, Ekklesia-owned dependencies name the coordinated,
|
||||||
|
tested version, never a version from another train.
|
||||||
|
- **Ranges in source, exact artifacts in lockfiles.** A module may declare compatible ranges in its
|
||||||
|
source `package.json`; its lockfile pins exact artifacts with their integrity hashes.
|
||||||
|
- **`@ekklesia/module-pack` is pinned exactly.** A module repository pins it in its source
|
||||||
|
`package.json`, not only in its lockfile, because its version affects the package it produces.
|
||||||
|
- **Independent package versions are deferred.**
|
||||||
|
|
||||||
|
### Standalone packaging
|
||||||
|
|
||||||
|
- **No monorepo checkout.** A standalone module builds and packages from its own repository and its
|
||||||
|
installed dependencies.
|
||||||
|
- **Its own Node.js range.** The module declares its own Node.js compatibility, preferably in
|
||||||
|
`engines.node`, and the canonical packager reads it from there or from an explicit option. It
|
||||||
|
never requires an enclosing `pnpm-workspace.yaml`.
|
||||||
|
- **Ekklesia packages recognized by identity.** The canonical packager recognizes Ekklesia-owned
|
||||||
|
installed packages by explicit package identity and metadata, never by workspace path. So
|
||||||
|
`incorporates` and the Module SDK compatibility check (ADR 0049) work the same whether a package
|
||||||
|
came from the monorepo or from a registry. Ekklesia packages are never reported as third-party
|
||||||
|
software (ADR 0051).
|
||||||
|
- **No monorepo-root commands at install time.** Publishing workflows for the public packages run
|
||||||
|
their build in the platform's release pipeline. Nothing in a published package runs a
|
||||||
|
monorepo-root command when a consumer installs or packs it.
|
||||||
|
|
||||||
|
### Supply chain
|
||||||
|
|
||||||
|
- **Immutable versions.** A version is never republished with other content, and never withdrawn to
|
||||||
|
be reused.
|
||||||
|
- **Lockfiles.** pnpm lockfile integrity is part of the reproducibility model: modules install with a
|
||||||
|
frozen lockfile.
|
||||||
|
- **Release CI proves the tarballs before publication.** It must:
|
||||||
|
1. build and pack the public package set;
|
||||||
|
2. inspect every tarball: only the intended contents, a `LICENSE`, and no `workspace:` range;
|
||||||
|
3. install those tarballs into a clean standalone module fixture, outside any monorepo;
|
||||||
|
4. prove there that the fixture builds, packages with the canonical packager, and verifies.
|
||||||
|
|
||||||
|
Only then are the versions published.
|
||||||
|
- **Signing and provenance are deferred.**
|
||||||
|
|
||||||
|
### Registry access and the private phase
|
||||||
|
|
||||||
|
- **No secrets in module repositories.** Registry configuration for the scope is normal npm/pnpm
|
||||||
|
configuration.
|
||||||
|
- **Credentials from the environment.** Read and publish credentials come from environment or user
|
||||||
|
configuration: a read credential in module CI where reads need one, and a publish credential only
|
||||||
|
in Ekklesia's release pipeline.
|
||||||
|
- **No permanent provider.**
|
||||||
|
- **Verify access before the first publication.** Check the chosen registry's actual private and
|
||||||
|
read-access behaviour first. Ekklesia's packages must not be published unintentionally to a
|
||||||
|
registry that serves them anonymously while Ekklesia is not public.
|
||||||
|
|
||||||
|
### The release workflow
|
||||||
|
|
||||||
|
During 0.x, public package releases form a **coordinated SDK train** associated with platform
|
||||||
|
releases:
|
||||||
|
|
||||||
|
1. build;
|
||||||
|
2. pack;
|
||||||
|
3. standalone tarball smoke (see [Supply chain](#supply-chain));
|
||||||
|
4. publish immutable versions;
|
||||||
|
5. downstream module CI installs and tests those versions.
|
||||||
|
|
||||||
|
### Signage
|
||||||
|
|
||||||
|
The intended result is a normal standalone Signage repository:
|
||||||
|
- versioned `@ekklesia/*` dependencies;
|
||||||
|
- its own lockfile;
|
||||||
|
- continuous integration that installs, builds, packages, and verifies on its own.
|
||||||
|
|
||||||
|
That CI must not clone Ekklesia or place Signage under `modules/`. The migration itself is a separate
|
||||||
|
change.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
- A module repository is an ordinary pnpm project. Clone it, install, build, and package it with the
|
||||||
|
canonical packager, with no Ekklesia checkout.
|
||||||
|
- Module authors see a smaller, deliberate API, and the platform can change its internal contracts
|
||||||
|
without breaking them.
|
||||||
|
- One coordinated version keeps compatibility simple: SDK version X is what platform version X
|
||||||
|
reports and accepts. The cost is republishing unchanged packages with each platform release.
|
||||||
|
- A registry move costs each module a configuration change and a lockfile regeneration, but no
|
||||||
|
source change. Package content stays verifiable by integrity hash throughout.
|
||||||
|
- Before the first publication, the SDK surface must be curated and the packager's two monorepo
|
||||||
|
assumptions removed. Both are breaking for code that relied on them.
|
||||||
|
- ADR 0047, still proposed, assembles a module into the platform workspace to test it. Before it is
|
||||||
|
accepted it must instead consume published packages and canonical module packages, consistent with
|
||||||
|
this decision and with ADR 0048.
|
||||||
|
|
||||||
|
## Alternatives
|
||||||
|
|
||||||
|
- **Git URL, tag, or commit dependencies.** Rejected: they tie modules to one Git host, need a build
|
||||||
|
on every install because `dist` is not committed, and cannot express version ranges.
|
||||||
|
- **Release-asset tarball URLs.** Rejected: each dependency becomes a host-specific URL, and ranges
|
||||||
|
cannot be resolved.
|
||||||
|
- **OCI artifacts for packages.** Rejected: npm and pnpm cannot install from them. (A harness image
|
||||||
|
is a different matter; see ADR 0047.)
|
||||||
|
- **Vendoring Ekklesia packages into each module repository.** Rejected: the copies drift, updates
|
||||||
|
are manual, and there would be no single canonical SDK.
|
||||||
|
- **Requiring host-neutral lockfiles, or an Ekklesia registry proxy that rewrites tarball URLs.** Not
|
||||||
|
required: a one-time lockfile regeneration on a registry move is an acceptable cost, and integrity
|
||||||
|
hashes already keep content verifiable. Ekklesia may still choose a stable endpoint later.
|
||||||
|
- **Publishing `@ekklesia/contracts` as a direct author API.** Rejected: it holds platform-wide
|
||||||
|
contracts. Modules get a curated surface through the SDK packages.
|
||||||
|
- **Externalizing React from module UI bundles to share one runtime.** Rejected again: ADR 0023's
|
||||||
|
reasoning stands, and peers only prevent duplicates within one bundle.
|
||||||
|
- **Independent versions per package now.** Deferred: during 0.x a coordinated train is simpler for
|
||||||
|
module authors and matches how the platform reports its SDK version.
|
||||||
|
|
||||||
|
## Deferred
|
||||||
|
|
||||||
|
- Provisioning a registry, and its credentials.
|
||||||
|
- Public publication on npmjs or any other public registry.
|
||||||
|
- Package signing and provenance.
|
||||||
|
- Independent package versioning.
|
||||||
|
- Dashboard and UI redesign.
|
||||||
|
- The Signage migration itself.
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
# 0054: Ekklesia's module-development packages are publicly readable before launch
|
||||||
|
|
||||||
|
- Status: Accepted
|
||||||
|
- Date: 2026-10-08
|
||||||
|
- Supersedes (partial): [0053](0053-standalone-module-development-packages.md) — only where it
|
||||||
|
describes the package phase before launch as private: "Gitea may host the private phase", the
|
||||||
|
read credential for module CI in "Registry access and the private phase", and its requirement
|
||||||
|
that packages not be served anonymously while Ekklesia is not public. Every other part of that ADR
|
||||||
|
remains accepted and unchanged: the npm registry protocol, the public package set and its
|
||||||
|
boundaries, the curated SDK, the dependency rules, the coordinated package train, exact release
|
||||||
|
artifacts, standalone module builds, provider portability, supply chain and immutability, and the
|
||||||
|
credential handling for publishing.
|
||||||
|
- Related: [ADR 0049](0049-module-package-v1.md)
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
ADR 0053 decided how Ekklesia's module-development packages (`@ekklesia/*`) are built, released, and
|
||||||
|
distributed over the npm registry protocol. It assumed that the phase before Ekklesia's public
|
||||||
|
launch would be private: module CI would carry a read credential where reads needed one, and the
|
||||||
|
first publication had to make sure the registry would not serve the packages anonymously.
|
||||||
|
|
||||||
|
Checking the chosen host before the first publication showed what keeping the packages private would
|
||||||
|
take:
|
||||||
|
|
||||||
|
- Gitea 1.27.2 decides package read access by the package **owner's** visibility, never a
|
||||||
|
repository's. The `River_of_Glory_Church` owner is a public organization, so its packages are
|
||||||
|
readable anonymously.
|
||||||
|
- That organization's repositories, Ekklesia's source among them, are already publicly readable.
|
||||||
|
- The instance allows self-registration, so "limited" visibility, readable by any signed-in user,
|
||||||
|
is readable by anyone who creates an account.
|
||||||
|
- Private packages would therefore need a separate private package owner, plus reader accounts and
|
||||||
|
read tokens for every module repository, every module's CI, and every developer.
|
||||||
|
|
||||||
|
The project owner has decided that Ekklesia is not secret. Its source is public; the project is
|
||||||
|
simply not being launched or promoted yet. Hiding the packages built from that public source would
|
||||||
|
protect nothing, and would still cost every module author an account and a token.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
1. **Ekklesia's module-development packages may be anonymously readable before the project's public
|
||||||
|
launch.** This covers the public package set of ADR 0053: search, metadata, and download.
|
||||||
|
2. **Readability is not a launch, and promises nothing more.** Public readability does not mean:
|
||||||
|
- that Ekklesia has been publicly launched;
|
||||||
|
- that the APIs are stable beyond the project's pre-1.0 SemVer policy;
|
||||||
|
- that third-party production use is supported;
|
||||||
|
- that a marketplace or public module ecosystem exists.
|
||||||
|
3. **Reading needs no authentication in this phase.** Installing `@ekklesia/*` needs no account,
|
||||||
|
reader account, read token, or authenticated install workflow. A module repository configures only
|
||||||
|
the scope's registry.
|
||||||
|
4. **Publication stays authenticated and tightly controlled.** Only Ekklesia's release process
|
||||||
|
publishes, with one dedicated publisher credential that holds nothing beyond package write
|
||||||
|
access, used only by that process, as ADR 0053's credential handling for publishing already
|
||||||
|
requires.
|
||||||
|
5. **Published versions stay immutable by Ekklesia policy.** A published version is never deleted or
|
||||||
|
republished with other bytes, whatever the registry technically allows.
|
||||||
|
6. **The current Gitea owner is an acceptable host.** The `River_of_Glory_Church` package namespace
|
||||||
|
on the current Gitea may host this phase, as long as publishing permission stays restricted to
|
||||||
|
the publisher. No private package organization is needed.
|
||||||
|
7. **A public launch is a separate decision.** Launching or promoting Ekklesia publicly, and anything
|
||||||
|
that changes for its packages then, is a product and release decision this ADR does not make.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
- **Module authors and module CI need no Ekklesia credential.** The committed `.npmrc` scope mapping
|
||||||
|
is the whole registry configuration. ADR 0053's example of a read credential in user or CI
|
||||||
|
configuration is no longer needed in this phase.
|
||||||
|
- **ADR 0053's pre-publication access check changes meaning.** It now confirms that anonymous reads
|
||||||
|
work as intended and that publishing is refused without the publisher's permission, rather than
|
||||||
|
that reads are private. The disposable-registry suite proves both, and the publication tool can
|
||||||
|
require an anonymous download to match each published artifact.
|
||||||
|
- **Restricting publishing is now the whole access boundary**, so the publisher credential's scope,
|
||||||
|
where it is held, and who can reach it matter all the more.
|
||||||
|
- **Making the packages private again would be a new decision.** Package names, versions, and
|
||||||
|
contents already published stay public, because a published version is never deleted.
|
||||||
|
|
||||||
|
## Alternatives considered
|
||||||
|
|
||||||
|
- **A private package organization on the same Gitea.** Rejected: it hides nothing that is not
|
||||||
|
already public in the source, and it requires reader accounts and tokens for every module
|
||||||
|
repository, its CI, and every developer.
|
||||||
|
- **"Limited" package visibility.** Rejected: with self-registration open, anyone can read anyway,
|
||||||
|
while the configuration suggests otherwise.
|
||||||
|
- **Requiring sign-in for the whole instance.** Rejected: it would also hide the public
|
||||||
|
repositories.
|
||||||
|
- **Another registry for a private phase.** Unnecessary once reads are public.
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user