Compare commits
61
Commits
b14613decd
..
v0.1.2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fc504eee0a | ||
|
|
5abc8be167 | ||
|
|
12dca3e8a4 | ||
|
|
0ec1755279 | ||
|
|
874deabea3 | ||
|
|
62951975da | ||
|
|
703d92436b | ||
|
|
43911a088d | ||
|
|
3a2f5d68f6 | ||
|
|
3827841205 | ||
|
|
2152eb036a | ||
|
|
e1ac1da416 | ||
|
|
dcbd7613ee | ||
|
|
dbb4648a35 | ||
|
|
f1d6501239 | ||
|
|
54ac6ab83f | ||
|
|
fa493365bd | ||
|
|
f50e8f4201 | ||
|
|
ff8a3e8097 | ||
|
|
b2efb4b029 | ||
|
|
f63f80f2f0 | ||
|
|
cbf5962d04 | ||
|
|
38cf52757d | ||
|
|
5162b4cc77 | ||
|
|
8ea4d4d2e4 | ||
|
|
55accd0e65 | ||
|
|
a56a853319 | ||
|
|
b642d8fe30 | ||
|
|
278e66049f | ||
|
|
c737ebb472 | ||
|
|
7cbe4e1b2c | ||
|
|
481e80118b | ||
|
|
167b4c4f14 | ||
|
|
b593abe7a5 | ||
|
|
e12ef10a65 | ||
|
|
4468335e8f | ||
|
|
756e9b821c | ||
|
|
ef1383d080 | ||
|
|
fc422b6d8d | ||
|
|
d5d4928b9f | ||
|
|
a12cbe9600 | ||
|
|
220b7b152a | ||
|
|
aaec9789c4 | ||
|
|
d593c10f1d | ||
|
|
06e55cebd5 | ||
|
|
f3cd5a4152 | ||
|
|
3a26ef49ad | ||
|
|
3ca6b2cb32 | ||
|
|
75324af045 | ||
|
|
f445a83ea5 | ||
|
|
a130eb78ea | ||
|
|
8127cc87b4 | ||
|
|
2969ed4992 | ||
|
|
14ac46bd3d | ||
|
|
bb7a9eb351 | ||
|
|
20d5128bad | ||
|
|
ca3f37d5f9 | ||
|
|
e36485bf99 | ||
|
|
c444ff570b | ||
|
|
07835b7b56 | ||
|
|
e03d38fcab |
@@ -0,0 +1,25 @@
|
||||
# Build context for deploy/Dockerfile (ADR 0044). The image installs its own dependencies with
|
||||
# `pnpm install --frozen-lockfile` and builds its own outputs, so nothing installed or built on the
|
||||
# host may enter the context, and no secret or environment file may enter any layer.
|
||||
|
||||
# Host-installed dependencies and build outputs: the image produces its own.
|
||||
**/node_modules
|
||||
**/dist
|
||||
**/.turbo
|
||||
**/coverage
|
||||
**/*.tsbuildinfo
|
||||
|
||||
# Environment and secret material. Configuration reaches the container only through its runtime
|
||||
# environment; `.env.example` is excluded too, since nothing in the image reads it.
|
||||
**/.env
|
||||
**/.env.*
|
||||
|
||||
# Repository and tooling metadata the image never reads.
|
||||
.git
|
||||
.github
|
||||
.gitea
|
||||
.forgejo
|
||||
.vscode
|
||||
.idea
|
||||
**/*.log
|
||||
**/.DS_Store
|
||||
+127
-12
@@ -1,18 +1,13 @@
|
||||
name: CI
|
||||
|
||||
# IMPORTANT — the currently inventoried server is Gitea 1.24.2, which *ignores* several keys used
|
||||
# below. Per https://docs.gitea.com/1.24/usage/actions/comparison, its unsupported workflow syntax
|
||||
# includes `permissions` / `jobs.<job_id>.permissions`, `concurrency`, and
|
||||
# `jobs.<job_id>.timeout-minutes`. They are declared here deliberately, so the workflow is already
|
||||
# correct on a supported release and on GitHub Actions, but on this server they are inert:
|
||||
# The server runs Gitea 1.27.2 (docs/development/security.md, "What this server honours"):
|
||||
#
|
||||
# * `permissions:` does NOT restrict the in-job `GITEA_TOKEN`. Job-token permission controls,
|
||||
# repository/owner defaults, and server-side maximum-permission clamping arrive in the 1.26
|
||||
# feature line (https://docs.gitea.com/1.26/usage/actions/token-permissions). Until the server
|
||||
# is upgraded and configured, the token's authority is whatever the server grants — not what
|
||||
# this file requests.
|
||||
# * `concurrency:` and `timeout-minutes:` are NOT bounded-execution guarantees here. Cancellation,
|
||||
# timeout, and teardown must be enforced by the trusted runner provisioner/reaper.
|
||||
# * `concurrency:` is honoured: a newer push to a pull request cancels the run it supersedes, so a
|
||||
# cancelled run's own cleanup is never guaranteed (see the CI stack sweep in each stack job).
|
||||
# * `permissions:` is supported by 1.27 with Gitea's own scopes, but what the job token can do on
|
||||
# this server is not yet proven, so nothing here relies on it being read-only.
|
||||
# * `timeout-minutes:` is no longer listed as unsupported, but is not verified here either;
|
||||
# teardown relies on the stack sweep and the runner's own reaper.
|
||||
#
|
||||
# `persist-credentials: false` below is unaffected by any of this and is enforced by the action
|
||||
# itself. See docs/development/security.md.
|
||||
@@ -63,6 +58,9 @@ jobs:
|
||||
needs: verify
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
env:
|
||||
# Names this job's stacks, so its final step removes exactly these (scripts/lib/ci-project.sh).
|
||||
EKKLESIA_CI_JOB_KEY: infrastructure-smoke
|
||||
steps:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
@@ -72,13 +70,29 @@ jobs:
|
||||
# in this workflow pushes, so the credential is never needed after checkout.
|
||||
persist-credentials: false
|
||||
|
||||
# A job the runner killed (cancelled, or past the runner's own time limit) never ran its
|
||||
# cleanup. Anything a CI job left on this shared daemon more than three hours ago goes first.
|
||||
- name: Sweep stale CI stacks from the runner
|
||||
run: bash scripts/ci-sweep.sh --stale
|
||||
|
||||
- name: Prove the sweep removes exactly what it should, against real Docker resources
|
||||
run: bash scripts/test-ci-sweep.sh
|
||||
|
||||
- name: Exercise PostgreSQL and S3 infrastructure
|
||||
run: bash scripts/smoke-infrastructure.sh
|
||||
|
||||
# Whatever happened above, this job's own stacks do not outlive it.
|
||||
- name: Remove this job's CI stacks
|
||||
if: always()
|
||||
run: bash scripts/ci-sweep.sh --job
|
||||
|
||||
migration-smoke:
|
||||
needs: verify
|
||||
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: migration-smoke
|
||||
steps:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
@@ -88,6 +102,11 @@ jobs:
|
||||
# in this workflow pushes, so the credential is never needed after checkout.
|
||||
persist-credentials: false
|
||||
|
||||
# A job the runner killed (cancelled, or past the runner's own time limit) never ran its
|
||||
# cleanup. Anything a CI job left on this shared daemon more than three hours ago goes first.
|
||||
- name: Sweep stale CI stacks from the runner
|
||||
run: bash scripts/ci-sweep.sh --stale
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
@@ -103,3 +122,99 @@ jobs:
|
||||
|
||||
- name: Exercise transactional migrations against PostgreSQL
|
||||
run: pnpm test:migrations:integration
|
||||
|
||||
# Whatever happened above, this job's own stacks do not outlive it.
|
||||
- name: Remove this job's CI stacks
|
||||
if: always()
|
||||
run: bash scripts/ci-sweep.sh --job
|
||||
|
||||
image-build:
|
||||
needs: verify
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
env:
|
||||
# Names this job's stacks, so its final step removes exactly these (scripts/lib/ci-project.sh).
|
||||
EKKLESIA_CI_JOB_KEY: image-build
|
||||
# Cache mounts in deploy/Dockerfile need BuildKit; this selects it even where the Docker CLI
|
||||
# has no buildx plugin.
|
||||
DOCKER_BUILDKIT: "1"
|
||||
steps:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
# `actions/checkout` defaults to leaving the job token in `.git/config`, where any later
|
||||
# step — including one a pull request added to this file — could read or reuse it. Nothing
|
||||
# in this workflow pushes, so the credential is never needed after checkout.
|
||||
persist-credentials: false
|
||||
|
||||
# A job the runner killed (cancelled, or past the runner's own time limit) never ran its
|
||||
# cleanup. Anything a CI job left on this shared daemon more than three hours ago goes first.
|
||||
- name: Sweep stale CI stacks from the runner
|
||||
run: bash scripts/ci-sweep.sh --stale
|
||||
|
||||
# The platform-only image (ADR 0044): a checkout has no `modules/`, so this proves the image
|
||||
# builds, validates, and assembles with zero modules. Build only — no registry login, no
|
||||
# credentials, no push, no tag anywhere but this runner's local image store.
|
||||
# Tagged per run and attempt, not only per commit: a pull request and a push of the same commit
|
||||
# can build at once on one runner's Docker daemon, and neither may remove the other's image.
|
||||
# Labeled like every CI stack, so this job's final step removes it even if the build is killed.
|
||||
- name: Build the production image without modules
|
||||
shell: bash
|
||||
run: |
|
||||
source scripts/lib/ci-project.sh
|
||||
docker build \
|
||||
--file deploy/Dockerfile \
|
||||
--build-arg REVISION="$GITHUB_SHA" \
|
||||
--label "${EKKLESIA_CI_LABEL}=$(ci_project_name image)" \
|
||||
--label "${EKKLESIA_CI_CREATED_LABEL}=$(date +%s)" \
|
||||
--tag ekklesia-ci:"$GITHUB_SHA-${GITHUB_RUN_ID:-0}-${GITHUB_RUN_ATTEMPT:-1}" \
|
||||
.
|
||||
|
||||
# Whatever happened above, this job's own stacks do not outlive it.
|
||||
- name: Remove this job's CI stacks
|
||||
if: always()
|
||||
run: bash scripts/ci-sweep.sh --job
|
||||
|
||||
deploy-smoke:
|
||||
needs: verify
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
# Both topologies (ADR 0045): same-origin, the default every installation gets, and split, which
|
||||
# adds a test-only module and drives a real headless browser across the two origins.
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
topology:
|
||||
- same-origin
|
||||
- split
|
||||
env:
|
||||
# Names this job's stacks, so its final step removes exactly these (scripts/lib/ci-project.sh).
|
||||
EKKLESIA_CI_JOB_KEY: deploy-smoke-${{ matrix.topology }}
|
||||
DOCKER_BUILDKIT: "1"
|
||||
EKKLESIA_SMOKE_TOPOLOGY: ${{ matrix.topology }}
|
||||
steps:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
# `actions/checkout` defaults to leaving the job token in `.git/config`, where any later
|
||||
# step — including one a pull request added to this file — could read or reuse it. Nothing
|
||||
# in this workflow pushes, so the credential is never needed after checkout.
|
||||
persist-credentials: false
|
||||
|
||||
# A job the runner killed (cancelled, or past the runner's own time limit) never ran its
|
||||
# cleanup. Anything a CI job left on this shared daemon more than three hours ago goes first.
|
||||
- name: Sweep stale CI stacks from the runner
|
||||
run: bash scripts/ci-sweep.sh --stale
|
||||
|
||||
# The reference deployment end to end (ADR 0044): a platform-only image, base + proxy with
|
||||
# Caddy's internal CA (test-only), an explicit migration apply against this job's own fresh
|
||||
# PostgreSQL, then the API, Dashboard, storage delivery, client-address chain, and verify-full
|
||||
# database TLS through the proxy. Every secret is generated for this run and discarded with
|
||||
# it; nothing is pushed, published, or logged in to.
|
||||
- name: Smoke-test the reference deployment (${{ matrix.topology }})
|
||||
run: bash scripts/smoke-deploy.sh
|
||||
|
||||
# Whatever happened above, this job's own stacks do not outlive it.
|
||||
- name: Remove this job's CI stacks
|
||||
if: always()
|
||||
run: bash scripts/ci-sweep.sh --job
|
||||
|
||||
@@ -15,7 +15,7 @@ monorepo:
|
||||
- **Modules** (`modules/*`, template at `templates/module`) are independently versioned product
|
||||
packages. They own their own data and migrations, declare capabilities/permissions through
|
||||
`@ekklesia/contracts` and `@ekklesia/module-sdk`, and must never import Core, another module, or a
|
||||
surface directly. Per ADR 0021 a module owns its UI; manifest v6 permits independently optional
|
||||
surface directly. Per ADR 0021 a module owns its UI; manifest v7 permits independently optional
|
||||
primary and full-viewport display artifacts, authored in React against the same role-neutral
|
||||
`@ekklesia/module-ui-sdk` ABI. Ekklesia mounts each through a separate surface role, while
|
||||
`pnpm boundaries` still rejects every non-React UI framework and non-`.tsx` presentation source.
|
||||
|
||||
+22
-7
@@ -35,13 +35,21 @@ pnpm infrastructure:config
|
||||
private source paths.
|
||||
- Use `workspace:` ranges for internal package dependencies.
|
||||
- Modules own their own UI and author it in React and TypeScript (ADR 0022). A module package may
|
||||
contain `.tsx` and depend on `react`, `react-dom`, and `@ekklesia/module-ui-sdk`. Every other UI
|
||||
framework, `.jsx`, stylesheets, and other presentation formats remain rejected by
|
||||
`pnpm boundaries`. Primary and display sources live at the exact `src/ui/index.tsx` and
|
||||
contain `.tsx` and depend on `react`, `react-dom`, and `@ekklesia/module-ui-sdk`, and may author
|
||||
plain `.css` inside a browser-artifact source root (ADR 0040). Every other UI framework, `.jsx`,
|
||||
other stylesheet dialects, and other presentation formats remain rejected by `pnpm boundaries`. Primary and display sources live at the exact `src/ui/index.tsx` and
|
||||
`src/display/index.tsx` conventions; one `pnpm build` invocation atomically compiles every role
|
||||
the v6 manifest declares via `@ekklesia/module-ui-build` (ADR 0023/0033). Module authors write no
|
||||
bundler configuration, and styling is React inline style objects only for now. Both roles use the
|
||||
same UI SDK context with no presentation-mode flag.
|
||||
the v7 manifest declares via `@ekklesia/module-ui-build` (ADR 0023/0033), including every declared
|
||||
browser contribution at `src/contributions/<local-name>/index.tsx` (ADR 0041). Module authors write no
|
||||
bundler configuration. A module's stylesheets are compiled into its declared artifact as text and
|
||||
installed by the host into that artifact's own Shadow DOM root, so module styles cannot leak into
|
||||
the Dashboard or another module (ADR 0040). Both roles use the same UI SDK context with no
|
||||
presentation-mode flag, and the same versioned mount ABI.
|
||||
- A module may also consume exactly two shared design packages — `@ekklesia/design-tokens` and
|
||||
`@ekklesia/ui` (ADR 0039) — so two modules' screens look like one product. That is a named
|
||||
allowlist: every other shared role (`api-client`, `auth-client`, `surface-sdk`, `module-ui-host`)
|
||||
stays rejected inside a module. Reference registered semantic tokens; never define an
|
||||
`--ekklesia-*` custom property of your own.
|
||||
- Module UI reaches its host only through `@ekklesia/module-ui-sdk` — never
|
||||
`@ekklesia/module-ui-host`, which is the host-side loader — and asks the host for its own
|
||||
API by relative path — never by constructing `/api/v1/modules/<id>/...`, which is Ekklesia's
|
||||
@@ -51,10 +59,17 @@ pnpm infrastructure:config
|
||||
primary module UIs through `GET /api/v1/dashboard/navigation`, resolves a dedicated display
|
||||
through `GET /api/v1/module-displays/<module-id>`, mounts both with
|
||||
`@ekklesia/module-ui-host`, and statically imports no module (ADR 0026/0033).
|
||||
- A module may contribute a browser renderer to a capability another module provides
|
||||
(`manifest.contributions`, ADR 0041). The consumer discovers and mounts it through
|
||||
`ModuleUiHostContext.contributions`, never by building a publication URL, and owns the input
|
||||
contract entirely: the platform carries opaque JSON and defines no payload schema, slot, or
|
||||
ordering. A contributed renderer gets its own id and that input, and nothing else.
|
||||
- Dedicated displays are module-owned renderers mounted by a separate full-viewport surface at
|
||||
`/display/<module-id>`, never Dashboard UI with hidden chrome. Its page and descriptor require the
|
||||
module-owned, non-public permission declared by `entrypoints.display`; artifact bytes remain
|
||||
anonymous trusted code. Public/device identity, display inventory, and variants are deferred.
|
||||
anonymous trusted code. Public/device identity, display inventory, and variants are deferred. A
|
||||
display-scoped service worker caches the shell and declared artifacts only, network-first, and
|
||||
never a descriptor, an API response, or anything else authorization-bearing (ADR 0043).
|
||||
- Dashboard groups are presentation configuration, never module structure. Do not add a parent
|
||||
module, child module, owning module, or group-derived dependency: real module relationships are
|
||||
the dependency contract's job alone, and grouping must never influence discovery, dependency
|
||||
|
||||
@@ -218,9 +218,10 @@ undeployed state, and the launch checklist in a single place. **CI is accepted a
|
||||
trusted-code-only boundary**: the self-hosted Gitea runner executes only code controlled and
|
||||
reviewed by trusted repository collaborators, and unknown or untrusted pull requests must not
|
||||
execute on it automatically. What remains is operational and cannot be proven from this repository —
|
||||
upgrading Gitea past 1.24.2 (which *ignores* the workflow's `permissions`, `concurrency`, and
|
||||
`timeout-minutes` declarations), clamping job-token authority server-side, enforcing timeout and
|
||||
teardown in a trusted provisioner/reaper, and proving disposable runner isolation. Those are
|
||||
configuring and proving job-token authority server-side (the server now runs Gitea 1.27.2, which
|
||||
honours `concurrency` and documents `permissions`, but neither token limits nor `timeout-minutes` are
|
||||
verified on it yet), enforcing timeout and teardown in a trusted provisioner/reaper, and proving
|
||||
disposable runner isolation. Those are
|
||||
prerequisites for **executing untrusted contributions**, alongside the deployment checklist that
|
||||
gates a **public launch**; moving future public contribution workflows to a service with isolated
|
||||
hosted runners is an acceptable alternative. **Neither set of requirements blocks Phase 6** — Surface
|
||||
@@ -289,7 +290,8 @@ artifacts cannot drift.
|
||||
externalizing it would make every host supply a compatible React — defeating standalone hosting. The
|
||||
accepted cost is duplicate React runtimes and large unminified artifacts (~570 KB for the template);
|
||||
sharing one runtime later is a build optimization that does not change the contract. Styling is React
|
||||
inline style objects only: a stylesheet import **fails the build** rather than emitting a sidecar
|
||||
inline style objects only at that checkpoint (Phase 8 pass 2 replaced that with compiled
|
||||
stylesheets): a stylesheet import then **failed the build** rather than emitting a sidecar
|
||||
`.css` that no manifest declares and no host loads. Building is an authoring step — Core never builds,
|
||||
and installing a module or starting the API never triggers one.
|
||||
|
||||
@@ -488,7 +490,8 @@ reported `blocked · configuration_missing · bucket` — never imported, never
|
||||
from inside its own `start` hook — and its required dependents block behind it through ADR 0028's
|
||||
existing mechanism. Doctor and `pnpm modules:validate` report the same thing while still importing no
|
||||
module code. That change introduced `manifestVersion: 5` as a hard cutover; the current strict
|
||||
manifest is version 6 after ADR 0033 added the optional display declaration.
|
||||
manifest is version 7 after ADR 0033 added the optional display declaration and ADR 0041 added
|
||||
browser contributions.
|
||||
|
||||
**Ekklesia does not store secrets, and does not pretend to.** There is no encryption at rest here and
|
||||
no key management, so a `secret` key's value is supplied by the deployment
|
||||
@@ -792,6 +795,74 @@ unproven launch-blockers rather than existing guarantees. Read
|
||||
[Security model and deployment trust boundaries](docs/development/security.md) before installing a
|
||||
module or exposing the API.
|
||||
|
||||
**Phase 8 adds the five generic platform prerequisites the first production feature module needs.**
|
||||
None of them is a feature module, and none of them names one.
|
||||
|
||||
Phase 8 pass 1 gives every Ekklesia screen one design vocabulary
|
||||
([ADR 0039](docs/architecture/decisions/0039-shared-design-tokens-and-elements.md)).
|
||||
`@ekklesia/design-tokens` is a validated, enumerable registry of **semantic** tokens whose category
|
||||
and `--ekklesia-*` identifier are derived from the token's name, and whose values must be bounded
|
||||
literals — so a registry that constructs can be emitted as CSS without escaping, and a future theme
|
||||
editor has something labelled, described, and categorised to enumerate. `@ekklesia/ui` is a small set
|
||||
of foundational elements (button, inputs, field, panel, dialog, tabs, table and list primitives,
|
||||
badge, toast, and empty/error/loading states), every value a token reference, with real
|
||||
`:focus-visible` treatment and status that never signals by colour alone. `pnpm boundaries` opens
|
||||
exactly two shared roles to modules by name and pins both design packages beneath every other role.
|
||||
The Dashboard and display install the defaults at their document root; custom properties inherit into
|
||||
module artifacts, so a module writes `var(--ekklesia-…)` with nothing to import at run time. **No
|
||||
theme editor, brand builder, second theme, or component catalogue exists.**
|
||||
|
||||
Phase 8 pass 2 gives module UI real stylesheets and gives every browser artifact its own root
|
||||
([ADR 0040](docs/architecture/decisions/0040-module-stylesheets-and-artifact-isolation.md)). A module
|
||||
imports plain `.css` and the canonical builder compiles it **into** the one declared artifact as
|
||||
text — no sidecar can exist, which is the failure ADR 0023 refused stylesheets to avoid. The build
|
||||
rejects a module stylesheet that *declares* an `--ekklesia-*` variable or leaves an unresolved
|
||||
`@import`. The generic loader then mounts every artifact — primary, display, and contributed — inside
|
||||
its own open Shadow DOM root with its stylesheet installed there, so one module's rules cannot reach
|
||||
the Dashboard or another module and theirs cannot reach in. That root is a correctness boundary for
|
||||
styles and **is not a sandbox**: module code remains trusted, same-realm code. The browser mount ABI
|
||||
is versioned with no dual acceptance; migrating is one `pnpm build`.
|
||||
|
||||
Phase 8 pass 3 lets one module contribute a browser renderer to a capability another module provides
|
||||
([ADR 0041](docs/architecture/decisions/0041-module-browser-contributions.md)) — the browser half of
|
||||
the capability system Core has had since Phase 1. `manifestVersion: 7` declares a namespaced id, its
|
||||
own version, a target capability, a compatible range, and one artifact, and nothing else: there is no
|
||||
payload schema, slot, ordering, or rendering hint anywhere, so Core cannot learn what any of it
|
||||
means. Publication requires that another eligible module provides the capability inside the declared
|
||||
range; a contribution that fails is absent rather than broken. A consumer discovers through
|
||||
`ModuleUiHostContext.contributions` and mounts by id, never by building a publication URL, and can
|
||||
only see contributions aimed at a capability it itself provides. A contributed renderer receives its
|
||||
own id and the consumer's opaque JSON input — no API client, realtime, principal, configuration,
|
||||
database, storage, or credential — and loading one grants its module nothing.
|
||||
|
||||
Phase 8 pass 4 closes the object-storage write path
|
||||
([ADR 0042](docs/architecture/decisions/0042-bounded-streaming-request-bodies.md)). A module route
|
||||
declares `bodyMode`: `buffered` (the default, and what every route already meant) keeps the 1 MiB
|
||||
ceiling and materializes the body before the handler runs; `streamed` raises it to 32 MiB and counts
|
||||
bytes as the handler consumes them, holding nothing. 32 MiB is also the platform's absolute ceiling
|
||||
in every mode — sized for one 25 MiB image plus bounded overhead, one file per request, with no
|
||||
unbounded option anywhere. The platform's own limiter replaces the buffering dependency: a declared
|
||||
oversize is refused before authorization, the buffered path now counts what arrived rather than
|
||||
trusting a header, and a streamed overflow errors the stream, cancels the source, and replaces the
|
||||
response with the same sanitized `413` even if the handler swallowed it. Signed write URLs,
|
||||
resumable uploads, quotas, and anything above the ceiling remain deliberately absent.
|
||||
|
||||
Phase 8 pass 5 gives the display host-owned offline resilience for **the shell and the declared
|
||||
code** ([ADR 0043](docs/architecture/decisions/0043-display-offline-code-fallback.md)). A service
|
||||
worker scoped to `/display/` caches the page, the host script, the module's display artifact, and
|
||||
every contributed renderer that display mounted, network-first so a screen can never be trapped on
|
||||
obsolete code. It caches **nothing else, ever**: not the descriptor, `/api/v1/principal`, any API
|
||||
response, the realtime stream, a pairing exchange, or any cross-origin request. The consequence is
|
||||
stated rather than papered over — a display cannot render module content while offline, because
|
||||
mounting needs an authorization-bearing descriptor that is never cached, so a revoked device stays
|
||||
revoked and cached code can never manufacture authorization. What it buys is that a kiosk rebooting
|
||||
during an outage boots into Ekklesia's own offline state and recovers by itself, and that
|
||||
reconnecting mounts with nothing left to download. **Module-owned offline content and media remain
|
||||
the module's responsibility and do not exist yet.**
|
||||
|
||||
Ekklesia Lite, a theme editor, a module store or installer, a standalone host, and every feature
|
||||
module — Signage included — still do not exist, and nothing in Phase 8 claims otherwise.
|
||||
|
||||
## Quick start
|
||||
|
||||
Prerequisites: Node.js 24 LTS, pnpm 11.12, and Docker with Compose.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@ekklesia/api",
|
||||
"version": "0.1.0",
|
||||
"version": "0.1.2",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"files": [
|
||||
@@ -28,11 +28,13 @@
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "2.5.3",
|
||||
"@ekklesia/module-ui-host": "workspace:*",
|
||||
"@ekklesia/module-ui-sdk": "workspace:*",
|
||||
"@ekklesia/typescript-config": "workspace:*",
|
||||
"@types/node": "24.13.3",
|
||||
"@types/pg": "8.20.0",
|
||||
"@types/smtp-server": "3.5.13",
|
||||
"better-auth": "1.6.23",
|
||||
"happy-dom": "20.0.11",
|
||||
"pg": "8.22.0",
|
||||
"smtp-server": "3.19.3",
|
||||
"typescript": "7.0.2",
|
||||
|
||||
@@ -27,10 +27,13 @@ import type { Context, MiddlewareHandler } from "hono";
|
||||
import { Hono } from "hono";
|
||||
import { cors } from "hono/cors";
|
||||
import { AUTH_REQUEST_MAX_BODY_BYTES, platformBodyLimit } from "./body-limits.js";
|
||||
import type { DashboardHostAssets } from "./dashboard-host-assets.js";
|
||||
import { createDashboardHostRoutes } from "./dashboard-host-routes.js";
|
||||
import { createDashboardRoutes } from "./dashboard-routes.js";
|
||||
import { createDeviceRoutes } from "./device-routes.js";
|
||||
import type { DisplayHostAssets } from "./display-host-assets.js";
|
||||
import { createGrantsRoutes } from "./grants-routes.js";
|
||||
import { type ApiHealthOptions, createHealthRoutes } from "./health-routes.js";
|
||||
import { createModuleConfigurationRoutes } from "./module-configuration-routes.js";
|
||||
import { mountModuleDisplayRoutes } from "./module-display-routes.js";
|
||||
import { canonicalizeVaryHeader, mountModuleHttpRoutes } from "./module-http-routes.js";
|
||||
@@ -137,6 +140,20 @@ export interface CreateApiAppOptions {
|
||||
* presentation only — nothing reachable through this store influences discovery, dependency
|
||||
* ordering, module startup, or any authorization decision. */
|
||||
readonly dashboard: DashboardStore;
|
||||
/**
|
||||
* The built Dashboard's two files, read once by production startup (ADR 0044). Required rather
|
||||
* than optional, unlike `displayHostAssets`: every installation has a Dashboard, and a missing
|
||||
* shell build fails startup instead of leaving this origin answering `/` with a 404. See
|
||||
* `./dashboard-host-routes.js`.
|
||||
*/
|
||||
readonly dashboardHostAssets: DashboardHostAssets;
|
||||
/**
|
||||
* The readiness inputs behind the unauthenticated health routes (ADR 0044). Required rather than
|
||||
* defaulted, like every other dependency here: a composition that cannot reach a database says so
|
||||
* with a check that rejects, never falls through to an implicitly healthy one. `checkDatabase` is
|
||||
* the bare query; the routes themselves bound and serialize it. See `./health-routes.js`.
|
||||
*/
|
||||
readonly health: ApiHealthOptions;
|
||||
readonly runtime: EkklesiaIdentityRuntime;
|
||||
/** Exact allowed browser origins. Never a wildcard; combined with `credentials: true`, only an
|
||||
* exact, reflected match is ever returned in `Access-Control-Allow-Origin`. Also the trusted set
|
||||
@@ -341,6 +358,16 @@ export function createApiApp(options: CreateApiAppOptions): Hono {
|
||||
}
|
||||
});
|
||||
|
||||
// Unauthenticated liveness/readiness under a platform-owned sibling prefix: like every route below,
|
||||
// it is mounted before module routes and never inside `/api/v1/modules/`, which installed modules
|
||||
// own entirely. See `./health-routes.js`.
|
||||
app.route("/", createHealthRoutes(options.health));
|
||||
|
||||
// The Dashboard page and its one script, at exact literal paths outside `/api/` (ADR 0044). They
|
||||
// grant no authority and resolve no principal; the page asks `/api/v1/principal` itself. See
|
||||
// `./dashboard-host-routes.js`.
|
||||
app.route("/", createDashboardHostRoutes(options.dashboardHostAssets));
|
||||
|
||||
app.route(
|
||||
"/",
|
||||
createGrantsRoutes({
|
||||
|
||||
+187
-17
@@ -1,6 +1,10 @@
|
||||
import { MODULE_ROUTE_MAX_BODY_BYTES_CEILING } from "@ekklesia/contracts";
|
||||
import type { Context } from "hono";
|
||||
import { bodyLimit } from "hono/body-limit";
|
||||
import {
|
||||
MODULE_ROUTE_ABSOLUTE_MAX_BODY_BYTES,
|
||||
MODULE_ROUTE_MAX_BODY_BYTES_CEILING,
|
||||
MODULE_ROUTE_STREAMED_MAX_BODY_BYTES_CEILING,
|
||||
type ModuleRouteBodyMode,
|
||||
} from "@ekklesia/contracts";
|
||||
import type { Context, MiddlewareHandler } from "hono";
|
||||
|
||||
/**
|
||||
* The platform's one request-body policy, centralized here rather than scattered as magic numbers
|
||||
@@ -19,15 +23,20 @@ import { bodyLimit } from "hono/body-limit";
|
||||
* field individually bounded well under 1 KiB combined; 16 KiB leaves headroom without inviting
|
||||
* abuse.
|
||||
* - `MODULE_ROUTE_MAX_BODY_BYTES_CEILING` (1 MiB, re-exported from `@ekklesia/contracts` — the same
|
||||
* constant a module's own route declaration is validated against at registration) is the hard
|
||||
* ceiling no module route may exceed and doubles as the platform's overall request-body ceiling:
|
||||
* nothing in this platform ever declares, or enforces, a larger single request body than this.
|
||||
* Large media transfer is explicitly out of scope for ordinary module HTTP bodies — it belongs to
|
||||
* a future dedicated storage design, not to widening this ceiling.
|
||||
* constant a module's own **buffered** route declaration is validated against at registration) is
|
||||
* the ceiling for every body the platform materializes before a handler runs. It has not moved.
|
||||
* - `MODULE_ROUTE_STREAMED_MAX_BODY_BYTES_CEILING` (32 MiB) is what a route may declare when it
|
||||
* explicitly opts into `bodyMode: "streamed"` (ADR 0042), and doubles as
|
||||
* `MODULE_ROUTE_ABSOLUTE_MAX_BODY_BYTES`: no route, in any mode, may declare or accept a larger
|
||||
* single request body, and no unbounded option exists anywhere in the contract.
|
||||
*/
|
||||
export const AUTH_REQUEST_MAX_BODY_BYTES = 8 * 1024;
|
||||
export const CORE_MUTATION_MAX_BODY_BYTES = 16 * 1024;
|
||||
export { MODULE_ROUTE_MAX_BODY_BYTES_CEILING };
|
||||
export {
|
||||
MODULE_ROUTE_ABSOLUTE_MAX_BODY_BYTES,
|
||||
MODULE_ROUTE_MAX_BODY_BYTES_CEILING,
|
||||
MODULE_ROUTE_STREAMED_MAX_BODY_BYTES_CEILING,
|
||||
};
|
||||
|
||||
/** The one stable, sanitized public contract for every request-body-size rejection across the
|
||||
* platform — auth, Core, and module routes alike. Never leaks the configured limit, the offending
|
||||
@@ -37,12 +46,173 @@ export function requestBodyTooLargeResponse(c: Context): Response {
|
||||
return c.json({ code: "REQUEST_BODY_TOO_LARGE" }, 413);
|
||||
}
|
||||
|
||||
/** A single shared `hono/body-limit` factory so every call site uses the identical sanitized 413
|
||||
* contract rather than redefining `onError` per route. Covers both a declared oversized
|
||||
* `Content-Length` and an oversized chunked/streamed body with no declared length — see
|
||||
* `hono/body-limit`'s pinned implementation, which checks `Content-Length` synchronously before
|
||||
* ever reading the stream, and otherwise reads and counts the stream incrementally, rejecting as
|
||||
* soon as the running total exceeds `maxSize` rather than buffering the whole body first. */
|
||||
export function platformBodyLimit(maxSize: number) {
|
||||
return bodyLimit({ maxSize, onError: requestBodyTooLargeResponse });
|
||||
/**
|
||||
* A declared `Content-Length`, or `undefined` when there is not exactly one well-formed one.
|
||||
*
|
||||
* A malformed or contradictory value is treated as *absent* rather than as a rejection, deliberately:
|
||||
* the byte counting below bounds the request either way, so refusing here could only ever turn a
|
||||
* legitimate request away without making an illegitimate one any less bounded. Node's own HTTP
|
||||
* parser rejects genuinely malformed framing before this code sees the request at all.
|
||||
*/
|
||||
function declaredContentLength(request: Request): number | undefined {
|
||||
const header = request.headers.get("content-length");
|
||||
if (header === null || !/^\d{1,15}$/u.test(header.trim())) {
|
||||
return undefined;
|
||||
}
|
||||
return Number.parseInt(header.trim(), 10);
|
||||
}
|
||||
|
||||
/** Releases a body nothing consumed, so an abandoned or oversized upload does not hold a socket
|
||||
* open waiting to be read. Safe on a stream that is locked, already drained, or already errored. */
|
||||
function releaseUnreadBody(request: Request): void {
|
||||
const body = request.body;
|
||||
if (!body || body.locked) {
|
||||
return;
|
||||
}
|
||||
void body.cancel().catch(() => undefined);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads at most `maxSize` bytes, stopping and cancelling the source the moment the running total
|
||||
* exceeds it. Never allocates beyond what it has already accepted, so an over-limit body costs one
|
||||
* chunk past the limit rather than the whole upload.
|
||||
*/
|
||||
async function readBounded(
|
||||
body: ReadableStream<Uint8Array>,
|
||||
maxSize: number,
|
||||
): Promise<readonly Uint8Array[] | "over-limit"> {
|
||||
const reader = body.getReader();
|
||||
const chunks: Uint8Array[] = [];
|
||||
let size = 0;
|
||||
try {
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) {
|
||||
return chunks;
|
||||
}
|
||||
size += value.byteLength;
|
||||
if (size > maxSize) {
|
||||
await reader.cancel().catch(() => undefined);
|
||||
return "over-limit";
|
||||
}
|
||||
chunks.push(value);
|
||||
}
|
||||
} finally {
|
||||
reader.releaseLock();
|
||||
}
|
||||
}
|
||||
|
||||
function streamOf(chunks: readonly Uint8Array[]): ReadableStream<Uint8Array> {
|
||||
return new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
for (const chunk of chunks) {
|
||||
controller.enqueue(chunk);
|
||||
}
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The platform's one request-body limiter, for every route in the application.
|
||||
*
|
||||
* ## Both modes reject a declared oversize before anything else happens
|
||||
*
|
||||
* A `Content-Length` above the limit is refused synchronously, before authorization, before the
|
||||
* handler, and before a single body byte is read. That is the cheap rejection an ordinary
|
||||
* too-large upload takes.
|
||||
*
|
||||
* ## `buffered` — the body is materialized, bounded, before the handler runs
|
||||
*
|
||||
* The exact behaviour every route had before streaming existed, now with the counting done here
|
||||
* rather than by a dependency. Bytes are counted as they arrive and the request is rejected the
|
||||
* moment the running total passes the limit, so a lying or absent `Content-Length` is bounded by
|
||||
* what actually arrived rather than by what was claimed. The handler then receives a request whose
|
||||
* body is fully available and provably within its declared allowance — which is what makes
|
||||
* `await request.json()` safe in a module handler.
|
||||
*
|
||||
* The platform holds at most the route's own declared allowance in memory, and a buffered route may
|
||||
* declare at most 1 MiB. That bound is the whole reason the buffered ceiling has not moved.
|
||||
*
|
||||
* ## `streamed` — the body is counted as the handler consumes it, and never materialized
|
||||
*
|
||||
* Nothing is buffered. The body is wrapped in a counting transform and handed to the handler, which
|
||||
* reads it as a stream; the moment the running total passes the limit the stream errors, the source
|
||||
* is cancelled, and the response is replaced with the same sanitized `413` — even if the handler
|
||||
* caught the error and tried to answer anyway. That last part matters: a limit a handler can swallow
|
||||
* is not a limit.
|
||||
*
|
||||
* The consequence a module author must know is that in this mode a `413` can arrive **after** the
|
||||
* handler has started, so a handler that persists as it reads has to be prepared for a partially
|
||||
* consumed upload. Writing to a temporary key and committing only after the read completes is the
|
||||
* pattern; the platform cannot do it for you, because only the module knows what a partial write
|
||||
* means to its own data.
|
||||
*
|
||||
* ## Ordering
|
||||
*
|
||||
* This runs before permission evaluation for both modes, so a request that is *declared* too large
|
||||
* never reaches authorization work. In `streamed` mode no body byte is read until the handler reads
|
||||
* it, and the handler only runs after authorization succeeds — so an unauthorized caller can never
|
||||
* push a large upload through the platform at all.
|
||||
*
|
||||
* ## Timeouts
|
||||
*
|
||||
* Body reading is bounded in time by the server's own `headersTimeout`/`requestTimeout`
|
||||
* (`apps/api/src/server.ts`), not by anything here. A slow upload is a slow *request*, and the
|
||||
* platform already has one answer for that rather than a second per-route one.
|
||||
*/
|
||||
export function platformBodyLimit(
|
||||
maxSize: number,
|
||||
mode: ModuleRouteBodyMode = "buffered",
|
||||
): MiddlewareHandler {
|
||||
return async function enforceBodyLimit(c, next) {
|
||||
const request = c.req.raw;
|
||||
const declared = declaredContentLength(request);
|
||||
if (declared !== undefined && declared > maxSize) {
|
||||
return requestBodyTooLargeResponse(c);
|
||||
}
|
||||
if (!request.body) {
|
||||
return next();
|
||||
}
|
||||
|
||||
if (mode === "buffered") {
|
||||
const chunks = await readBounded(request.body, maxSize);
|
||||
if (chunks === "over-limit") {
|
||||
return requestBodyTooLargeResponse(c);
|
||||
}
|
||||
c.req.raw = new Request(request, {
|
||||
body: streamOf(chunks),
|
||||
// Required by undici whenever a `Request` is constructed from a stream.
|
||||
duplex: "half",
|
||||
} as RequestInit);
|
||||
return next();
|
||||
}
|
||||
|
||||
let overLimit = false;
|
||||
let total = 0;
|
||||
const counted = request.body.pipeThrough(
|
||||
new TransformStream<Uint8Array, Uint8Array>({
|
||||
transform(chunk, controller) {
|
||||
total += chunk.byteLength;
|
||||
if (total > maxSize) {
|
||||
overLimit = true;
|
||||
// Erroring the transform propagates back to the source and cancels it, so the socket is
|
||||
// released rather than left draining an upload nobody will read.
|
||||
controller.error(new Error("REQUEST_BODY_TOO_LARGE"));
|
||||
return;
|
||||
}
|
||||
controller.enqueue(chunk);
|
||||
},
|
||||
}),
|
||||
);
|
||||
c.req.raw = new Request(request, { body: counted, duplex: "half" } as RequestInit);
|
||||
|
||||
await next();
|
||||
|
||||
if (overLimit) {
|
||||
c.res = requestBodyTooLargeResponse(c);
|
||||
}
|
||||
releaseUnreadBody(c.req.raw);
|
||||
return;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
/**
|
||||
* RFC 9110 weak comparison for a GET/HEAD `If-None-Match` precondition, shared by the host-owned
|
||||
* asset routes that answer `304` against a strong validator they computed from their own bytes.
|
||||
*/
|
||||
export function etagMatches(headerValue: string | undefined, etag: string): boolean {
|
||||
if (headerValue === undefined) {
|
||||
return false;
|
||||
}
|
||||
if (headerValue.trim() === "*") {
|
||||
return true;
|
||||
}
|
||||
return headerValue.split(",").some((candidate) => {
|
||||
const value = candidate.trim();
|
||||
return (value.startsWith("W/") ? value.slice(2) : value) === etag;
|
||||
});
|
||||
}
|
||||
@@ -200,3 +200,19 @@ export function createApiRuntimeConfig(
|
||||
storage,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The exact browser origins the API trusts: CORS on `/api/auth/*` and `/api/v1/*`, and every
|
||||
* mutation route's origin check (ADR 0045).
|
||||
*
|
||||
* The configured trusted origins plus the identity base URL's own origin, which is where the API
|
||||
* lives. Better Auth already trusts its base URL implicitly; this makes the API's own checks agree,
|
||||
* so a page the API itself serves (the display host, and the Dashboard in the same-origin topology)
|
||||
* passes without an operator listing the API's origin. Order is preserved and duplicates removed;
|
||||
* every entry is already a canonical origin.
|
||||
*/
|
||||
export function apiTrustedOrigins(
|
||||
identity: Pick<IdentityConfig, "baseUrl" | "trustedOrigins">,
|
||||
): readonly string[] {
|
||||
return Object.freeze([...new Set([...identity.trustedOrigins, identity.baseUrl])]);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { dashboardHostAssetUrls } from "@ekklesia/shell/dashboard-assets";
|
||||
|
||||
/**
|
||||
* The two immutable, platform-owned files that make up the built Ekklesia Dashboard (ADR 0044).
|
||||
*
|
||||
* Exactly the model `./display-host-assets.js` uses: read once, before the HTTP listener is bound,
|
||||
* from the exact URLs the surface package exports. Request input is never involved in this read, the
|
||||
* package is never exposed as a static directory, and the source map beside the script is never
|
||||
* read. Text rather than a mutable `Buffer` keeps the frozen snapshot a genuinely immutable value
|
||||
* every response can reuse.
|
||||
*/
|
||||
export interface DashboardHostAssets {
|
||||
readonly html: string;
|
||||
readonly htmlEtag: string;
|
||||
readonly script: string;
|
||||
readonly scriptEtag: string;
|
||||
}
|
||||
|
||||
/** A startup failure with a fixed, non-secret shape. The originating filesystem exception, package
|
||||
* location, and machine path are intentionally not retained or surfaced. */
|
||||
export class DashboardHostAssetError extends Error {
|
||||
readonly code = "DASHBOARD_HOST_ASSET_UNAVAILABLE" as const;
|
||||
|
||||
constructor() {
|
||||
super("The Dashboard host browser assets are unavailable.");
|
||||
this.name = "DashboardHostAssetError";
|
||||
this.stack = `${this.name}: ${this.message}`;
|
||||
Object.freeze(this);
|
||||
}
|
||||
}
|
||||
|
||||
function strongEtag(text: string): string {
|
||||
return `"${createHash("sha256").update(text).digest("base64url")}"`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Captures one immutable, internally consistent Dashboard asset snapshot.
|
||||
*
|
||||
* Each strong validator is derived from the exact text in the same snapshot, so bytes from one build
|
||||
* can never be paired with an ETag from another. Empty output is a failed shell build rather than a
|
||||
* valid page that can never render anything.
|
||||
*/
|
||||
export function createDashboardHostAssets(html: string, script: string): DashboardHostAssets {
|
||||
if (html.trim().length === 0 || script.trim().length === 0) {
|
||||
throw new DashboardHostAssetError();
|
||||
}
|
||||
return Object.freeze({
|
||||
html,
|
||||
htmlEtag: strongEtag(html),
|
||||
script,
|
||||
scriptEtag: strongEtag(script),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The exact element the built Dashboard page carries for its API origin (ADR 0045), empty. The shell
|
||||
* reads the filled-in value at run time; empty means the page's own origin.
|
||||
*/
|
||||
export const DASHBOARD_API_ORIGIN_PLACEHOLDER = '<meta content="" name="ekklesia-api-origin" />';
|
||||
|
||||
function isCanonicalHttpOrigin(value: string): boolean {
|
||||
try {
|
||||
const url = new URL(value);
|
||||
return (url.protocol === "https:" || url.protocol === "http:") && url.origin === value;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes the API's own origin into the Dashboard page, once, before the page's `ETag` is computed.
|
||||
*
|
||||
* The origin is the identity base URL's, which is where `/api` lives in either topology: the page's
|
||||
* own origin when the Dashboard and the API share one, the API hostname when they are split. The
|
||||
* built page must carry exactly one empty placeholder; anything else is a shell build this API does
|
||||
* not understand, and startup fails rather than serving a page that might send credentials somewhere
|
||||
* unintended. The value is escaped for an attribute even though a canonical origin cannot contain
|
||||
* any character that needs it.
|
||||
*/
|
||||
export function renderDashboardPage(html: string, apiOrigin: string): string {
|
||||
const parts = html.split(DASHBOARD_API_ORIGIN_PLACEHOLDER);
|
||||
if (parts.length !== 2 || !isCanonicalHttpOrigin(apiOrigin)) {
|
||||
throw new DashboardHostAssetError();
|
||||
}
|
||||
const escaped = apiOrigin
|
||||
.replaceAll("&", "&")
|
||||
.replaceAll('"', """)
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">");
|
||||
return parts.join(`<meta content="${escaped}" name="ekklesia-api-origin" />`);
|
||||
}
|
||||
|
||||
/** Loads only the two surface-declared files, and renders the API origin into the page. A missing,
|
||||
* unreadable, or incompatible build fails startup rather than leaving an API whose own origin answers
|
||||
* `/` with a 404 or a page that cannot find its API. */
|
||||
export async function loadDashboardHostAssets(apiOrigin: string): Promise<DashboardHostAssets> {
|
||||
try {
|
||||
// Sequential deliberately, as for the display host: diagnostics stay deterministic and no
|
||||
// rejected sibling read is left running after this function has already failed.
|
||||
const html = await readFile(new URL(dashboardHostAssetUrls.html), "utf8");
|
||||
const script = await readFile(new URL(dashboardHostAssetUrls.script), "utf8");
|
||||
return createDashboardHostAssets(renderDashboardPage(html, apiOrigin), script);
|
||||
} catch {
|
||||
throw new DashboardHostAssetError();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
import { Hono } from "hono";
|
||||
import { etagMatches } from "./conditional-request.js";
|
||||
import { createDashboardHostAssets, type DashboardHostAssets } from "./dashboard-host-assets.js";
|
||||
|
||||
/**
|
||||
* The Dashboard's two host-owned routes (ADR 0044). Neither can collide with anything else the API
|
||||
* serves: `/` is the only page the Dashboard has — it has no client router, and every emailed
|
||||
* verification or reset link lands on `/` with a query (ADR 0038) — and the script lives beside, never
|
||||
* inside, the display host's `/assets/ekklesia-display/` namespace. Module routes all live under
|
||||
* `/api/v1/modules/<module-id>`.
|
||||
*/
|
||||
export const DASHBOARD_HOST_PAGE_PATH = "/";
|
||||
export const DASHBOARD_HOST_SCRIPT_PATH = "/assets/ekklesia-dashboard/app.js";
|
||||
|
||||
/**
|
||||
* Revalidate-always with a strong `ETag`, exactly the module UI and display-host script policy. The
|
||||
* shell emits fixed, unhashed file names, so no response here may be cached as `immutable`: a
|
||||
* rebuilt Dashboard must reach a browser on its next load, and an unchanged one costs a `304`.
|
||||
*/
|
||||
const DASHBOARD_HOST_CACHE_CONTROL = "no-cache";
|
||||
|
||||
/**
|
||||
* Headers the Dashboard page carries on every answer, including a `304`, so a revalidated cache
|
||||
* entry never keeps an older set.
|
||||
*
|
||||
* The page holds the platform sign-in form, so it refuses to be framed by anyone — `X-Frame-Options`
|
||||
* for older browsers, and CSP `frame-ancestors`, the one directive a policy here carries until the
|
||||
* full Content-Security-Policy is decided separately. `no-referrer` keeps the page's URL, which can
|
||||
* briefly hold an emailed reset token before the shell strips it, out of every request it makes.
|
||||
*/
|
||||
const DASHBOARD_PAGE_SECURITY_HEADERS = Object.freeze({
|
||||
"Content-Security-Policy": "frame-ancestors 'none'",
|
||||
"Referrer-Policy": "no-referrer",
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
"X-Frame-Options": "DENY",
|
||||
});
|
||||
|
||||
/** A composition error, detected before any Dashboard route is registered. Carries no file path,
|
||||
* package location, or asset content. */
|
||||
export class DashboardHostConfigurationError extends Error {
|
||||
constructor() {
|
||||
super("The Dashboard host configuration is inconsistent.");
|
||||
this.name = "DashboardHostConfigurationError";
|
||||
this.stack = `${this.name}: ${this.message}`;
|
||||
Object.freeze(this);
|
||||
}
|
||||
}
|
||||
|
||||
/** Copies and validates injected assets before registering any route, as the display host does, so
|
||||
* a composition can never pair bytes with a validator computed from different bytes. */
|
||||
function snapshotAssets(input: DashboardHostAssets): DashboardHostAssets {
|
||||
try {
|
||||
const snapshot = createDashboardHostAssets(input.html, input.script);
|
||||
if (snapshot.htmlEtag !== input.htmlEtag || snapshot.scriptEtag !== input.scriptEtag) {
|
||||
throw new DashboardHostConfigurationError();
|
||||
}
|
||||
return snapshot;
|
||||
} catch {
|
||||
throw new DashboardHostConfigurationError();
|
||||
}
|
||||
}
|
||||
|
||||
function fixedAsset(
|
||||
ifNoneMatch: string | undefined,
|
||||
body: string,
|
||||
etag: string,
|
||||
contentType: string,
|
||||
extraHeaders: Readonly<Record<string, string>>,
|
||||
): Response {
|
||||
if (etagMatches(ifNoneMatch, etag)) {
|
||||
return new Response(null, {
|
||||
headers: { ...extraHeaders, "Cache-Control": DASHBOARD_HOST_CACHE_CONTROL, ETag: etag },
|
||||
status: 304,
|
||||
});
|
||||
}
|
||||
return new Response(body, {
|
||||
headers: {
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
...extraHeaders,
|
||||
"Cache-Control": DASHBOARD_HOST_CACHE_CONTROL,
|
||||
"Content-Type": contentType,
|
||||
ETag: etag,
|
||||
},
|
||||
status: 200,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Serves the built Dashboard from the same origin as `/api`, so the identity system keeps its
|
||||
* default same-origin shape.
|
||||
*
|
||||
* Two exact literal routes over one frozen snapshot: never a static directory, never a path or file
|
||||
* name taken from the request, and never the source map. Any other path — `/index.html`, `/app.js`,
|
||||
* the `.map`, a traversal spelling — has no route and falls through to the application's ordinary
|
||||
* sanitized `404`. Like the display host's code, these bytes grant no authority: the page renders
|
||||
* the sign-in form until `/api/v1/principal` says otherwise.
|
||||
*/
|
||||
export function createDashboardHostRoutes(assets: DashboardHostAssets): Hono {
|
||||
const snapshot = snapshotAssets(assets);
|
||||
const routes = new Hono();
|
||||
|
||||
routes.get(DASHBOARD_HOST_PAGE_PATH, (c) =>
|
||||
fixedAsset(
|
||||
c.req.header("if-none-match"),
|
||||
snapshot.html,
|
||||
snapshot.htmlEtag,
|
||||
"text/html; charset=utf-8",
|
||||
DASHBOARD_PAGE_SECURITY_HEADERS,
|
||||
),
|
||||
);
|
||||
|
||||
routes.get(DASHBOARD_HOST_SCRIPT_PATH, (c) =>
|
||||
fixedAsset(
|
||||
c.req.header("if-none-match"),
|
||||
snapshot.script,
|
||||
snapshot.scriptEtag,
|
||||
"text/javascript; charset=utf-8",
|
||||
{},
|
||||
),
|
||||
);
|
||||
|
||||
return routes;
|
||||
}
|
||||
@@ -26,11 +26,31 @@ export interface ApiDatabaseHandle {
|
||||
readonly module: ModuleDatabase;
|
||||
readonly moduleConfiguration: ModuleConfigurationDrizzleDatabase;
|
||||
readonly moduleLifecycle: ModuleLifecycleDrizzleDatabase;
|
||||
/**
|
||||
* One trivial round trip on the shared pool, for readiness (`./health-routes.js`). The `SELECT 1`
|
||||
* runs in a read-only transaction carrying its own `SET LOCAL statement_timeout`, so PostgreSQL
|
||||
* cancels a stalled statement itself rather than the caller merely abandoning it; the pool's
|
||||
* session-wide `statement_timeout`/`query_timeout` remain the outer bound. The four statements
|
||||
* travel as one simple-protocol message, so the check costs a single round trip. On any failure
|
||||
* the pool discards that connection instead of returning it, so a transaction left open by a
|
||||
* cancelled statement never reaches another caller. Rejects with whatever the driver raised; no
|
||||
* caller may surface it.
|
||||
*/
|
||||
readonly ping: (statementTimeoutMs: number) => Promise<void>;
|
||||
/** Closes the one shared pool exactly once, regardless of how many typed views were created
|
||||
* over it. Safe to call from both a startup-failure cleanup path and normal shutdown. */
|
||||
readonly close: () => Promise<void>;
|
||||
}
|
||||
|
||||
function pingStatement(statementTimeoutMs: number): string {
|
||||
// Interpolated rather than bound: neither SET nor a multi-statement simple query accepts
|
||||
// parameters. Only a positive safe integer can ever reach the SQL text.
|
||||
if (!Number.isSafeInteger(statementTimeoutMs) || statementTimeoutMs <= 0) {
|
||||
throw new RangeError("The ping statement timeout must be a positive integer.");
|
||||
}
|
||||
return `BEGIN READ ONLY; SET LOCAL statement_timeout = ${statementTimeoutMs}; SELECT 1; COMMIT`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Composes the one physical PostgreSQL pool this process uses for Core's identity and
|
||||
* authorization schemas plus the module database facade: one validated, timeout-bounded
|
||||
@@ -58,5 +78,8 @@ export function createApiDatabase(
|
||||
module: createModuleDatabaseView(pool),
|
||||
moduleConfiguration: createModuleConfigurationView(pool),
|
||||
moduleLifecycle: createModuleLifecycleView(pool),
|
||||
ping: async (statementTimeoutMs: number) => {
|
||||
await pool.query(pingStatement(statementTimeoutMs));
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { readFile } from "node:fs/promises";
|
||||
import { displayHostAssetUrls } from "@ekklesia/shell/display-assets";
|
||||
|
||||
/**
|
||||
* The two immutable, platform-owned files that make up Ekklesia's display browser host.
|
||||
* The three immutable, platform-owned files that make up Ekklesia's display browser host.
|
||||
*
|
||||
* They are read once, before the HTTP listener is bound, from the exact URLs exported by the
|
||||
* surface package. Request input is never involved in this read and the package is never exposed as
|
||||
@@ -14,6 +14,10 @@ export interface DisplayHostAssets {
|
||||
readonly html: string;
|
||||
readonly script: string;
|
||||
readonly scriptEtag: string;
|
||||
/** The display's offline service worker (ADR 0043) — a third fixed, platform-owned file, served
|
||||
* at a path whose directory is its scope so it controls display pages and nothing else. */
|
||||
readonly serviceWorker: string;
|
||||
readonly serviceWorkerEtag: string;
|
||||
}
|
||||
|
||||
/** A startup failure with a fixed, non-secret shape. The originating filesystem exception, package
|
||||
@@ -36,18 +40,24 @@ export class DisplayHostAssetError extends Error {
|
||||
* cannot accidentally pair bytes from one build with an ETag from another. Empty output is a
|
||||
* failed host build rather than a valid page that can never mount anything.
|
||||
*/
|
||||
export function createDisplayHostAssets(html: string, script: string): DisplayHostAssets {
|
||||
if (html.trim().length === 0 || script.trim().length === 0) {
|
||||
export function createDisplayHostAssets(
|
||||
html: string,
|
||||
script: string,
|
||||
serviceWorker: string,
|
||||
): DisplayHostAssets {
|
||||
if (html.trim().length === 0 || script.trim().length === 0 || serviceWorker.trim().length === 0) {
|
||||
throw new DisplayHostAssetError();
|
||||
}
|
||||
return Object.freeze({
|
||||
html,
|
||||
script,
|
||||
scriptEtag: `"${createHash("sha256").update(script).digest("base64url")}"`,
|
||||
serviceWorker,
|
||||
serviceWorkerEtag: `"${createHash("sha256").update(serviceWorker).digest("base64url")}"`,
|
||||
});
|
||||
}
|
||||
|
||||
/** Loads only the two surface-declared files. A missing or unreadable build fails startup rather
|
||||
/** Loads only the three surface-declared files. A missing or unreadable build fails startup rather
|
||||
* than leaving a display route that can authorize successfully and then return a broken shell. */
|
||||
export async function loadDisplayHostAssets(): Promise<DisplayHostAssets> {
|
||||
try {
|
||||
@@ -55,7 +65,8 @@ export async function loadDisplayHostAssets(): Promise<DisplayHostAssets> {
|
||||
// rejected sibling promise is left running after this function has already failed.
|
||||
const html = await readFile(new URL(displayHostAssetUrls.html), "utf8");
|
||||
const script = await readFile(new URL(displayHostAssetUrls.script), "utf8");
|
||||
return createDisplayHostAssets(html, script);
|
||||
const serviceWorker = await readFile(new URL(displayHostAssetUrls.serviceWorker), "utf8");
|
||||
return createDisplayHostAssets(html, script, serviceWorker);
|
||||
} catch {
|
||||
throw new DisplayHostAssetError();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
import { Hono } from "hono";
|
||||
|
||||
/**
|
||||
* The longest a readiness probe waits on the database before answering `503` (ADR 0044). Short
|
||||
* enough for a Compose health check or an external monitor to treat a stall as "not ready" rather
|
||||
* than as a hung probe, and well inside the shared pool's own 10s connection and operation
|
||||
* timeouts, which bound the underlying query independently.
|
||||
*/
|
||||
export const READINESS_DATABASE_TIMEOUT_MS = 2_000;
|
||||
|
||||
export const HEALTH_LIVE_PATH = "/api/v1/health/live";
|
||||
export const HEALTH_READY_PATH = "/api/v1/health/ready";
|
||||
|
||||
export interface ApiHealthOptions {
|
||||
/**
|
||||
* One trivial database round trip. Resolves when the database answered; rejects on any failure.
|
||||
* The rejection is never read — not its message, code, or cause — so nothing a driver says can
|
||||
* reach a response. The route bounds the wait and never overlaps two calls; this function only
|
||||
* has to perform the query.
|
||||
*/
|
||||
readonly checkDatabase: () => Promise<void>;
|
||||
/** Whether module startup has completed. `false` again from the moment shutdown begins, so a
|
||||
* draining instance stops reporting ready while it finishes in-flight requests. */
|
||||
readonly isModuleStartupComplete: () => boolean;
|
||||
}
|
||||
|
||||
const LIVE_BODY = Object.freeze({ status: "live" });
|
||||
const READY_BODY = Object.freeze({ status: "ready" });
|
||||
const NOT_READY_BODY = Object.freeze({ code: "NOT_READY" });
|
||||
|
||||
/**
|
||||
* Wraps `checkDatabase` so at most one call is ever unsettled, and no probe waits longer than
|
||||
* `timeoutMs`.
|
||||
*
|
||||
* Every probe arriving while a check is unsettled shares that check's outcome, including after its
|
||||
* deadline has passed: the deadline resolves the shared outcome to `false`, so those later probes
|
||||
* answer immediately. A new check starts only once the previous call has actually settled. A timed-
|
||||
* out query still occupies a pool connection until the database or the pool's own timeouts end it,
|
||||
* so starting a fresh query on every probe would let a stalled database drain the pool one health
|
||||
* check at a time.
|
||||
*/
|
||||
function createBoundedDatabaseCheck(
|
||||
checkDatabase: () => Promise<void>,
|
||||
timeoutMs: number,
|
||||
): () => Promise<boolean> {
|
||||
let inFlight: Promise<boolean> | undefined;
|
||||
|
||||
return () => {
|
||||
if (inFlight !== undefined) {
|
||||
return inFlight;
|
||||
}
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
// The executor runs `checkDatabase` synchronously, so a synchronous throw is a rejection like
|
||||
// any other rather than an exception escaping the route.
|
||||
const settled = new Promise<void>((resolve) => resolve(checkDatabase())).then(
|
||||
() => true,
|
||||
() => false,
|
||||
);
|
||||
const outcome = Promise.race([
|
||||
settled,
|
||||
new Promise<false>((resolve) => {
|
||||
timer = setTimeout(() => resolve(false), timeoutMs);
|
||||
}),
|
||||
]);
|
||||
inFlight = outcome;
|
||||
void settled.then(() => {
|
||||
clearTimeout(timer);
|
||||
inFlight = undefined;
|
||||
});
|
||||
return outcome;
|
||||
};
|
||||
}
|
||||
|
||||
/** The same fixed refusal `./module-realtime-routes.js` uses: Hono lets a GET handler answer HEAD,
|
||||
* and these routes accept GET alone. */
|
||||
function methodNotAllowed(): Response {
|
||||
return new Response(null, {
|
||||
headers: { Allow: "GET", "Cache-Control": "no-store" },
|
||||
status: 405,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Unauthenticated liveness and readiness (ADR 0044), for Compose health checks and external
|
||||
* monitoring. Neither route resolves a principal or reads a cookie, and both return fixed bodies
|
||||
* that name no module, version, error, or connection detail; `pnpm doctor` remains the detailed
|
||||
* operator report. They live under `/api/v1/health/`, a sibling of the `/api/v1/modules/` prefix that
|
||||
* installed modules own entirely, so no module route can shadow them.
|
||||
*/
|
||||
export function createHealthRoutes(options: ApiHealthOptions): Hono {
|
||||
const checkDatabase = createBoundedDatabaseCheck(
|
||||
options.checkDatabase,
|
||||
READINESS_DATABASE_TIMEOUT_MS,
|
||||
);
|
||||
const routes = new Hono();
|
||||
|
||||
routes.all(HEALTH_LIVE_PATH, (c) => {
|
||||
if (c.req.method !== "GET") {
|
||||
return methodNotAllowed();
|
||||
}
|
||||
c.header("Cache-Control", "no-store");
|
||||
return c.json(LIVE_BODY, 200);
|
||||
});
|
||||
|
||||
routes.all(HEALTH_READY_PATH, async (c) => {
|
||||
if (c.req.method !== "GET") {
|
||||
return methodNotAllowed();
|
||||
}
|
||||
c.header("Cache-Control", "no-store");
|
||||
if (!options.isModuleStartupComplete()) {
|
||||
return c.json(NOT_READY_BODY, 503);
|
||||
}
|
||||
const databaseReady = await checkDatabase();
|
||||
// Checked again afterwards: shutdown may have begun while the query was in flight.
|
||||
return databaseReady && options.isModuleStartupComplete()
|
||||
? c.json(READY_BODY, 200)
|
||||
: c.json(NOT_READY_BODY, 503);
|
||||
});
|
||||
|
||||
return routes;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
/**
|
||||
* Canonical browser routes owned by the platform's display host.
|
||||
*
|
||||
* Kept separate from the route registration module so both the display host and module-UI
|
||||
* inventory can publish the same path without introducing a dependency cycle.
|
||||
*/
|
||||
export const MODULE_DISPLAY_DESCRIPTOR_PREFIX = "/api/v1/module-displays";
|
||||
export const MODULE_DISPLAY_PAGE_PREFIX = "/display";
|
||||
|
||||
export function moduleDisplayPageUrl(moduleId: string): string {
|
||||
return `${MODULE_DISPLAY_PAGE_PREFIX}/${encodeURIComponent(moduleId)}`;
|
||||
}
|
||||
|
||||
export function moduleDisplayDescriptorUrl(moduleId: string): string {
|
||||
return `${MODULE_DISPLAY_DESCRIPTOR_PREFIX}/${encodeURIComponent(moduleId)}`;
|
||||
}
|
||||
@@ -6,25 +6,39 @@ import {
|
||||
getAuthorizedPrincipal,
|
||||
requirePermission,
|
||||
} from "./authorization-middleware.js";
|
||||
import { etagMatches } from "./conditional-request.js";
|
||||
import { createDisplayHostAssets, type DisplayHostAssets } from "./display-host-assets.js";
|
||||
export {
|
||||
MODULE_DISPLAY_DESCRIPTOR_PREFIX,
|
||||
MODULE_DISPLAY_PAGE_PREFIX,
|
||||
moduleDisplayDescriptorUrl,
|
||||
moduleDisplayPageUrl,
|
||||
} from "./module-display-path.js";
|
||||
import { moduleDisplayDescriptorUrl, moduleDisplayPageUrl } from "./module-display-path.js";
|
||||
import { moduleApiBaseUrl } from "./module-http-routes.js";
|
||||
import { moduleRealtimeBaseUrl } from "./module-realtime-routes.js";
|
||||
import { grantedModulePermissionIds } from "./module-ui-inventory.js";
|
||||
import {
|
||||
describeModuleContributions,
|
||||
grantedModulePermissionIds,
|
||||
type HostModuleContributionDescriptor,
|
||||
} from "./module-ui-inventory.js";
|
||||
import { moduleDisplayUiArtifactUrl } from "./module-ui-routes.js";
|
||||
|
||||
/** Host-owned namespaces. Neither can collide with module HTTP routes, all of which live under
|
||||
* `/api/v1/modules/<module-id>`, and neither string enters the module-facing UI ABI. */
|
||||
export const MODULE_DISPLAY_DESCRIPTOR_PREFIX = "/api/v1/module-displays";
|
||||
export const MODULE_DISPLAY_PAGE_PREFIX = "/display";
|
||||
export const DISPLAY_HOST_SCRIPT_PATH = "/assets/ekklesia-display/app.js";
|
||||
|
||||
export function moduleDisplayPageUrl(moduleId: string): string {
|
||||
return `${MODULE_DISPLAY_PAGE_PREFIX}/${encodeURIComponent(moduleId)}`;
|
||||
}
|
||||
|
||||
export function moduleDisplayDescriptorUrl(moduleId: string): string {
|
||||
return `${MODULE_DISPLAY_DESCRIPTOR_PREFIX}/${encodeURIComponent(moduleId)}`;
|
||||
}
|
||||
/**
|
||||
* Where the display's offline service worker is served (ADR 0043).
|
||||
*
|
||||
* The path is chosen for its **directory**: a service worker's default scope is the directory it was
|
||||
* served from, so `/display/sw.js` controls display pages and nothing else. Serving it from
|
||||
* `/assets/…` beside the host script would scope it to `/assets/`, where no page lives, and widening
|
||||
* it with `Service-Worker-Allowed` would put the Dashboard under a worker it has no use for.
|
||||
*
|
||||
* `sw.js` cannot collide with a display page: a display page is registered at the exact literal
|
||||
* `/display/<module-id>` for each display-capable module, and `sw.js` is not a valid module id.
|
||||
*/
|
||||
export const DISPLAY_SERVICE_WORKER_PATH = "/display/sw.js";
|
||||
|
||||
/** The portable facts a browser host needs to mount this module's one display artifact. It mirrors
|
||||
* the primary UI descriptor while naming the role explicitly, so a host cannot accidentally load a
|
||||
@@ -34,9 +48,15 @@ export interface HostModuleDisplayDescriptor {
|
||||
readonly id: string;
|
||||
readonly version: string;
|
||||
readonly display: { readonly url: string };
|
||||
/** The public page a screen opens to mount this display. This is distinct from `display.url`,
|
||||
* which is the compiled artifact imported by the platform host. */
|
||||
readonly page: { readonly url: string };
|
||||
readonly api: { readonly baseUrl: string };
|
||||
readonly realtime: { readonly baseUrl: string };
|
||||
readonly permissions: readonly string[];
|
||||
/** Renderers other modules contributed to capabilities this module provides (ADR 0041) — the
|
||||
* mechanism a signage-style display uses to show another module's content without importing it. */
|
||||
readonly contributions: readonly HostModuleContributionDescriptor[];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -81,27 +101,16 @@ function displayFailure(c: Context): Response {
|
||||
return c.json({ code: "AUTHORIZATION_EVALUATION_FAILED" }, 503);
|
||||
}
|
||||
|
||||
/** RFC 9110 weak comparison for a GET/HEAD `If-None-Match` precondition. */
|
||||
function etagMatches(headerValue: string | undefined, etag: string): boolean {
|
||||
if (headerValue === undefined) {
|
||||
return false;
|
||||
}
|
||||
if (headerValue.trim() === "*") {
|
||||
return true;
|
||||
}
|
||||
return headerValue.split(",").some((candidate) => {
|
||||
const value = candidate.trim();
|
||||
return (value.startsWith("W/") ? value.slice(2) : value) === etag;
|
||||
});
|
||||
}
|
||||
|
||||
/** Copies and validates injected assets before registering any route. Production's loader already
|
||||
* creates this exact shape, while this check keeps a test/composition caller from pairing mutable
|
||||
* bytes with a stale validator. */
|
||||
function snapshotAssets(input: DisplayHostAssets): DisplayHostAssets {
|
||||
try {
|
||||
const snapshot = createDisplayHostAssets(input.html, input.script);
|
||||
if (snapshot.scriptEtag !== input.scriptEtag) {
|
||||
const snapshot = createDisplayHostAssets(input.html, input.script, input.serviceWorker);
|
||||
if (
|
||||
snapshot.scriptEtag !== input.scriptEtag ||
|
||||
snapshot.serviceWorkerEtag !== input.serviceWorkerEtag
|
||||
) {
|
||||
throw new ModuleDisplayConfigurationError();
|
||||
}
|
||||
return snapshot;
|
||||
@@ -132,8 +141,10 @@ async function describeDisplay(
|
||||
viewer,
|
||||
module: {
|
||||
api: { baseUrl: moduleApiBaseUrl(display.moduleId) },
|
||||
contributions: describeModuleContributions(options.moduleUi, display.moduleId),
|
||||
display: { url: moduleDisplayUiArtifactUrl(display) },
|
||||
id: display.moduleId,
|
||||
page: { url: moduleDisplayPageUrl(display.moduleId) },
|
||||
permissions: await grantedModulePermissionIds(
|
||||
options.evaluator,
|
||||
principal,
|
||||
@@ -244,6 +255,33 @@ export function mountModuleDisplayRoutes(app: Hono, options: ModuleDisplayRoutes
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* The worker itself is `no-cache` with a strong validator, exactly like the host script: a browser
|
||||
* re-checks it on its own schedule, and the worker deliberately never caches *itself*, which is
|
||||
* how a screen avoids being trapped on an obsolete worker.
|
||||
*
|
||||
* Like the host script and module artifact bytes, fetching this code grants no authority. It
|
||||
* caches only public code and never a permission decision, so serving it anonymously withholds
|
||||
* nothing the platform was protecting.
|
||||
*/
|
||||
app.get(DISPLAY_SERVICE_WORKER_PATH, (c) => {
|
||||
if (etagMatches(c.req.header("if-none-match"), assets.serviceWorkerEtag)) {
|
||||
return new Response(null, {
|
||||
headers: { "Cache-Control": "no-cache", ETag: assets.serviceWorkerEtag },
|
||||
status: 304,
|
||||
});
|
||||
}
|
||||
return new Response(assets.serviceWorker, {
|
||||
headers: {
|
||||
"Cache-Control": "no-cache",
|
||||
"Content-Type": "text/javascript; charset=utf-8",
|
||||
ETag: assets.serviceWorkerEtag,
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
},
|
||||
status: 200,
|
||||
});
|
||||
});
|
||||
|
||||
for (const display of displays) {
|
||||
mountDisplay(app, { ...options, assets }, display);
|
||||
}
|
||||
|
||||
@@ -235,9 +235,12 @@ export function mountModuleHttpRoutes(
|
||||
|
||||
const requireRoutePermission = requirePermission(middlewareOptions, route.permission);
|
||||
// The route's own conscious body contract (`route.maxBodyBytes` — 0 means no body accepted at
|
||||
// all) is enforced before permission evaluation ever runs, so an oversized request never
|
||||
// reaches authorization work, let alone the handler or the module database.
|
||||
const routeBodyLimit = platformBodyLimit(route.maxBodyBytes);
|
||||
// all) is enforced before permission evaluation ever runs, so a request *declared* oversized
|
||||
// never reaches authorization work, let alone the handler or the module database. In
|
||||
// `streamed` mode no body byte is read until the handler reads it, and the handler runs only
|
||||
// after authorization succeeds, so an unauthorized caller can never push an upload through
|
||||
// (ADR 0042).
|
||||
const routeBodyLimit = platformBodyLimit(route.maxBodyBytes, route.bodyMode);
|
||||
const path = moduleRoutePath(route.moduleId, route.path);
|
||||
const finalHandler = async (c: Context): Promise<Response> => {
|
||||
const context: ModuleRouteHandlerContext = Object.freeze({
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import type { EkklesiaPrincipal, PermissionDeclaration } from "@ekklesia/contracts";
|
||||
import { CORE_MODULES_UI_READ } from "@ekklesia/core/authorization";
|
||||
import type { ModuleUiPublicationRegistry, PublishableModuleUi } from "@ekklesia/core/modules";
|
||||
import type {
|
||||
ModuleUiPublicationRegistry,
|
||||
PublishableModuleContribution,
|
||||
PublishableModuleUi,
|
||||
} from "@ekklesia/core/modules";
|
||||
import { Hono } from "hono";
|
||||
import type { Context } from "hono";
|
||||
import {
|
||||
@@ -9,8 +13,13 @@ import {
|
||||
requirePermission,
|
||||
} from "./authorization-middleware.js";
|
||||
import { moduleApiBaseUrl } from "./module-http-routes.js";
|
||||
import { moduleDisplayPageUrl } from "./module-display-path.js";
|
||||
import { moduleRealtimeBaseUrl } from "./module-realtime-routes.js";
|
||||
import { MODULE_UI_ASSET_PREFIX, moduleUiArtifactUrl } from "./module-ui-routes.js";
|
||||
import {
|
||||
MODULE_UI_ASSET_PREFIX,
|
||||
moduleContributionArtifactUrl,
|
||||
moduleUiArtifactUrl,
|
||||
} from "./module-ui-routes.js";
|
||||
|
||||
/**
|
||||
* The collection root of the module UI resource: `/api/v1/module-ui` lists what a browser host may
|
||||
@@ -40,6 +49,10 @@ export interface HostModuleUiDescriptor {
|
||||
* canonical helper (`moduleUiArtifactUrl`), never re-spelled in the browser, so publication
|
||||
* routing can change without touching a single line of host or module code. */
|
||||
readonly ui: { readonly url: string };
|
||||
/** Canonical page for this module's separately declared display artifact, or `null` when the
|
||||
* module publishes no display. Server-produced so neither shell nor module spells platform
|
||||
* routing or infers deployment topology. */
|
||||
readonly display: { readonly url: string } | null;
|
||||
/** The root this module's own HTTP API is mounted at, so the host can resolve the relative paths
|
||||
* a module UI asks for (`context.api.request("records")`). Server-produced for the same reason
|
||||
* `ui.url` is: `/api/v1/modules/<id>` is Ekklesia's private deployment detail, and neither the
|
||||
@@ -64,12 +77,56 @@ export interface HostModuleUiDescriptor {
|
||||
* nor trusts anything in this array.
|
||||
*/
|
||||
readonly permissions: readonly string[];
|
||||
/** Renderers other modules contributed to capabilities this module provides (ADR 0041). Empty for
|
||||
* the overwhelming majority of modules, which provide no extension point. */
|
||||
readonly contributions: readonly HostModuleContributionDescriptor[];
|
||||
}
|
||||
|
||||
/**
|
||||
* One renderer another module contributed to a capability **this** module provides (ADR 0041).
|
||||
*
|
||||
* The URL is server-produced, exactly like `ui.url`, `api.baseUrl`, and `realtime.baseUrl`: a
|
||||
* consuming module names a contribution and the host resolves it, so no module ever builds
|
||||
* Ekklesia's publication URL and every one of them stays hostable elsewhere.
|
||||
*
|
||||
* There is no payload schema, slot, order, or rendering hint here, and there will not be: what a
|
||||
* contributed renderer draws and what shape its input takes are the consuming module's contract,
|
||||
* carried in the browser between two modules without passing through the platform.
|
||||
*/
|
||||
export interface HostModuleContributionDescriptor {
|
||||
readonly id: string;
|
||||
readonly version: string;
|
||||
/** The capability it was contributed to — one this module declares. */
|
||||
readonly capability: string;
|
||||
readonly url: string;
|
||||
}
|
||||
|
||||
export interface HostModuleUiInventory {
|
||||
readonly modules: readonly HostModuleUiDescriptor[];
|
||||
}
|
||||
|
||||
/**
|
||||
* The one place a publishable contribution becomes a browser descriptor, shared by the primary-UI
|
||||
* inventory and the display descriptor so the two can never disagree about what a consuming module
|
||||
* may mount.
|
||||
*/
|
||||
export function describeModuleContributions(
|
||||
registry: ModuleUiPublicationRegistry,
|
||||
moduleId: string,
|
||||
): readonly HostModuleContributionDescriptor[] {
|
||||
return registry
|
||||
.contributionsForModule(moduleId)
|
||||
.map((contribution: PublishableModuleContribution) => ({
|
||||
capability: contribution.capability,
|
||||
id: contribution.contributionId,
|
||||
url: moduleContributionArtifactUrl(
|
||||
{ moduleId: contribution.moduleId, version: contribution.moduleVersion },
|
||||
contribution.contributionId,
|
||||
),
|
||||
version: contribution.version,
|
||||
}));
|
||||
}
|
||||
|
||||
export interface CreateModuleUiInventoryRoutesOptions extends AuthorizationMiddlewareOptions {
|
||||
/** The same publication registry the artifact route serves from — the single accepted/publishable
|
||||
* module set (`createModuleUiPublicationRegistry`, `@ekklesia/core/modules`). This route performs
|
||||
@@ -116,9 +173,13 @@ async function describeModule(
|
||||
evaluator: AuthorizationMiddlewareOptions["evaluator"],
|
||||
principal: EkklesiaPrincipal,
|
||||
published: PublishableModuleUi,
|
||||
registry: ModuleUiPublicationRegistry,
|
||||
): Promise<HostModuleUiDescriptor> {
|
||||
const display = registry.resolveDisplay(published.moduleId, published.version);
|
||||
return {
|
||||
api: { baseUrl: moduleApiBaseUrl(published.moduleId) },
|
||||
contributions: describeModuleContributions(registry, published.moduleId),
|
||||
display: display === undefined ? null : { url: moduleDisplayPageUrl(published.moduleId) },
|
||||
id: published.moduleId,
|
||||
permissions: await grantedModulePermissionIds(evaluator, principal, published.permissions),
|
||||
realtime: { baseUrl: moduleRealtimeBaseUrl(published.moduleId) },
|
||||
@@ -153,7 +214,7 @@ export async function describeMountableModules(
|
||||
): Promise<readonly MountableModuleDescription[]> {
|
||||
return Promise.all(
|
||||
registry.list().map(async (published) => ({
|
||||
descriptor: await describeModule(evaluator, principal, published),
|
||||
descriptor: await describeModule(evaluator, principal, published, registry),
|
||||
name: published.name,
|
||||
})),
|
||||
);
|
||||
|
||||
@@ -89,6 +89,26 @@ export function moduleDisplayUiArtifactUrl(identity: ModuleUiIdentity): string {
|
||||
return `${moduleUiArtifactUrl(identity)}/display`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Canonical browser URL for one of a module's declared browser contributions (ADR 0041).
|
||||
*
|
||||
* The literal `contributions` segment is host-owned and cannot collide with the two-segment artifact
|
||||
* route or the `display` sibling. The contribution id is percent-encoded for the same reason the
|
||||
* module id and version are: a value that somehow did not come from a validated manifest can only
|
||||
* ever become one segment's worth of text — never a new segment, a traversal, a query, or a
|
||||
* fragment — and a value that needed encoding simply fails to resolve against the registry.
|
||||
*
|
||||
* Like every other publication helper, this takes **identity only**. There is no parameter for a
|
||||
* filename or any other artifact selector, so no caller can use it to reach a second file inside a
|
||||
* module package.
|
||||
*/
|
||||
export function moduleContributionArtifactUrl(
|
||||
identity: ModuleUiIdentity,
|
||||
contributionId: string,
|
||||
): string {
|
||||
return `${moduleUiArtifactUrl(identity)}/contributions/${encodeURIComponent(contributionId)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* One 404 for every "there is nothing to publish here" case: the module is not installed, the
|
||||
* installed module declares no artifact for the requested UI role, or the requested version is not
|
||||
@@ -191,9 +211,9 @@ export interface CreateModuleUiRoutesOptions {
|
||||
*
|
||||
* ## The request never touches the filesystem
|
||||
*
|
||||
* `:moduleId` and `:version` are **lookup keys, not path components**. They are handed to
|
||||
* `registry.resolve` and, on an exact match, the already-canonical `artifactPath` that Core's
|
||||
* discovery resolved and validated is read directly. No request value is ever joined, resolved,
|
||||
* `:moduleId`, `:version`, and (for a contribution) `:contributionId` are **lookup keys, not path
|
||||
* components**. They are handed to the registry and, on an exact match, the already-canonical
|
||||
* `artifactPath` that Core's discovery resolved and validated is read directly. No request value is ever joined, resolved,
|
||||
* normalized, or concatenated into a path, and there is no wildcard segment for one to hide in — so
|
||||
* traversal, an encoded separator, a symlink, a sibling file, a source map, and a package-root file
|
||||
* are all unreachable by construction rather than by filtering. An installed module directory never
|
||||
@@ -228,6 +248,24 @@ export interface CreateModuleUiRoutesOptions {
|
||||
export function createModuleUiRoutes(options: CreateModuleUiRoutesOptions): Hono {
|
||||
const routes = new Hono();
|
||||
|
||||
routes.get(
|
||||
`${MODULE_UI_ASSET_PREFIX}/:moduleId/:version/contributions/:contributionId`,
|
||||
async (c) => {
|
||||
// Three lookup keys, one exact registry match, one already-validated canonical path. Nothing
|
||||
// is composed, so a contribution artifact is reachable on exactly the same terms as a module
|
||||
// UI artifact and on no others.
|
||||
const published = options.registry.resolveContribution(
|
||||
c.req.param("moduleId"),
|
||||
c.req.param("version"),
|
||||
c.req.param("contributionId"),
|
||||
);
|
||||
if (!published) {
|
||||
return notPublished(c);
|
||||
}
|
||||
return serveArtifact(c, published);
|
||||
},
|
||||
);
|
||||
|
||||
routes.get(`${MODULE_UI_ASSET_PREFIX}/:moduleId/:version/display`, async (c) => {
|
||||
const published = options.registry.resolveDisplay(
|
||||
c.req.param("moduleId"),
|
||||
|
||||
+23
-2
@@ -44,10 +44,12 @@ import {
|
||||
createClientAddressResolver,
|
||||
wrapFetchWithClientAddress,
|
||||
} from "./client-address.js";
|
||||
import { type ApiRuntimeConfig, createApiRuntimeConfig } from "./config.js";
|
||||
import { type ApiRuntimeConfig, apiTrustedOrigins, createApiRuntimeConfig } from "./config.js";
|
||||
import { loadDashboardHostAssets } from "./dashboard-host-assets.js";
|
||||
import { type ApiDatabaseHandle, createApiDatabase } from "./database.js";
|
||||
import { withDevicePrincipals } from "./device-principal.js";
|
||||
import { loadDisplayHostAssets } from "./display-host-assets.js";
|
||||
import { READINESS_DATABASE_TIMEOUT_MS } from "./health-routes.js";
|
||||
import { prepareStartupModuleInventory } from "./platform-permissions.js";
|
||||
|
||||
const connectionTimeoutMs = 10_000;
|
||||
@@ -542,9 +544,15 @@ export async function startApi(
|
||||
// starts and application composition succeeds.
|
||||
const moduleUi = createModuleUiPublicationRegistry(eligibility.eligibleModules);
|
||||
let displayHostAssets: Awaited<ReturnType<typeof loadDisplayHostAssets>> | undefined;
|
||||
let dashboardHostAssets: Awaited<ReturnType<typeof loadDashboardHostAssets>>;
|
||||
try {
|
||||
displayHostAssets =
|
||||
moduleUi.listDisplays().length === 0 ? undefined : await loadDisplayHostAssets();
|
||||
// Unconditional, unlike the display host: every installation serves its Dashboard (ADR 0044),
|
||||
// so a missing shell build fails here, before any module code is imported.
|
||||
// The page learns its API origin from the API that serves it, so one build serves either
|
||||
// topology (ADR 0045).
|
||||
dashboardHostAssets = await loadDashboardHostAssets(config.identity.baseUrl);
|
||||
} catch (error) {
|
||||
await safeCloseStorage(storageProvider);
|
||||
await safeCloseMail(mailProvider);
|
||||
@@ -576,6 +584,10 @@ export async function startApi(
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Set by the first `shutdown()` call, before anything is drained or stopped, so readiness turns
|
||||
// `503` while the instance still finishes in-flight requests.
|
||||
let shuttingDown = false;
|
||||
|
||||
let app: Hono;
|
||||
try {
|
||||
app = createApiApp({
|
||||
@@ -620,6 +632,13 @@ export async function startApi(
|
||||
},
|
||||
// A typed view over the same single pool as identity, authorization, and the module facade.
|
||||
dashboard: createDashboardStore(databaseHandle.dashboard),
|
||||
dashboardHostAssets,
|
||||
// The app is only composed after `startDiscoveredModuleRuntime` resolved, so module startup
|
||||
// has completed whenever it can answer; it stops counting as complete once shutdown begins.
|
||||
health: {
|
||||
checkDatabase: () => databaseHandle.ping(READINESS_DATABASE_TIMEOUT_MS),
|
||||
isModuleStartupComplete: () => !shuttingDown,
|
||||
},
|
||||
devices: {
|
||||
// `Secure` follows the deployment's own scheme, read from the already-validated identity
|
||||
// base URL rather than from any request. Production is https by configuration policy, so a
|
||||
@@ -631,7 +650,8 @@ export async function startApi(
|
||||
// Principal resolution, extended with managed devices. A human session still wins, and this
|
||||
// is the only place a device credential ever becomes a principal.
|
||||
runtime: withDevicePrincipals({ devices: deviceStore, runtime }),
|
||||
trustedOrigins: config.identity.trustedOrigins,
|
||||
// The configured origins plus the API's own (ADR 0045).
|
||||
trustedOrigins: apiTrustedOrigins(config.identity),
|
||||
});
|
||||
} catch (error) {
|
||||
await safeStopModuleRuntime(moduleRuntime);
|
||||
@@ -677,6 +697,7 @@ export async function startApi(
|
||||
let shutdownPromise: Promise<void> | undefined;
|
||||
const shutdown = (): Promise<void> => {
|
||||
if (!shutdownPromise) {
|
||||
shuttingDown = true;
|
||||
shutdownPromise = (async () => {
|
||||
// Close long-lived streams so HTTP can drain, but leave publishers active until the drain
|
||||
// completes: an in-flight module command may still commit and publish its resulting state.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { request as httpRequest } from "node:http";
|
||||
import net from "node:net";
|
||||
import { tmpdir } from "node:os";
|
||||
@@ -26,7 +26,19 @@ import {
|
||||
import { Client, type QueryResultRow } from "pg";
|
||||
import { afterAll, describe, expect, it, vi } from "vitest";
|
||||
import type { ClientAddressConfigInput } from "../src/client-address.js";
|
||||
import { dashboardHostAssetUrls } from "@ekklesia/shell/dashboard-assets";
|
||||
import { createApiRuntimeConfig } from "../src/config.js";
|
||||
import { createApiDatabase } from "../src/database.js";
|
||||
import { DASHBOARD_API_ORIGIN_PLACEHOLDER } from "../src/dashboard-host-assets.js";
|
||||
import {
|
||||
DASHBOARD_HOST_PAGE_PATH,
|
||||
DASHBOARD_HOST_SCRIPT_PATH,
|
||||
} from "../src/dashboard-host-routes.js";
|
||||
import {
|
||||
HEALTH_LIVE_PATH,
|
||||
HEALTH_READY_PATH,
|
||||
READINESS_DATABASE_TIMEOUT_MS,
|
||||
} from "../src/health-routes.js";
|
||||
import {
|
||||
MODULE_UI_CACHE_CONTROL,
|
||||
MODULE_UI_CONTENT_TYPE,
|
||||
@@ -330,7 +342,7 @@ async function createGatedRouteModule(
|
||||
compatibility: { moduleSdk: "^0.1.0", platform: "^0.1.0" },
|
||||
entrypoints: { server: "./dist/server/index.js" },
|
||||
id,
|
||||
manifestVersion: 6,
|
||||
manifestVersion: 7,
|
||||
name: `${id} module`,
|
||||
permissions: [{ baseline: "public", description: "Gated drain probe.", id: permissionId }],
|
||||
version: "0.1.0",
|
||||
@@ -374,7 +386,7 @@ async function createFixtureModule(
|
||||
): Promise<void> {
|
||||
const moduleDirectory = path.join(modulesDirectory, id);
|
||||
const manifest = {
|
||||
manifestVersion: 6,
|
||||
manifestVersion: 7,
|
||||
id,
|
||||
name: `${id} module`,
|
||||
version: "0.1.0",
|
||||
@@ -432,6 +444,39 @@ async function createFixtureModule(
|
||||
async handler(request, context) {
|
||||
return Response.json({ created: true, principal: context.principal }, { status: 201 });
|
||||
},
|
||||
});
|
||||
// An image-sized streamed upload (ADR 0042). The handler counts bytes as they
|
||||
// arrive and never materializes the body, which is exactly what a real module
|
||||
// storing an upload would do.
|
||||
http.route({
|
||||
method: "POST",
|
||||
path: "/uploads",
|
||||
permission: "calendar.events.read",
|
||||
maxBodyBytes: 33554432,
|
||||
bodyMode: "streamed",
|
||||
async handler(request) {
|
||||
let bytes = 0;
|
||||
if (request.body) {
|
||||
const reader = request.body.getReader();
|
||||
for (;;) {
|
||||
const chunk = await reader.read();
|
||||
if (chunk.done) { break; }
|
||||
bytes += chunk.value.byteLength;
|
||||
}
|
||||
}
|
||||
return Response.json({ bytes }, { status: 201 });
|
||||
},
|
||||
});
|
||||
http.route({
|
||||
method: "POST",
|
||||
path: "/small-uploads",
|
||||
permission: "calendar.events.read",
|
||||
maxBodyBytes: 64,
|
||||
bodyMode: "streamed",
|
||||
async handler(request) {
|
||||
await request.arrayBuffer().catch(() => undefined);
|
||||
return Response.json({ swallowed: true }, { status: 201 });
|
||||
},
|
||||
});`
|
||||
: ""
|
||||
}
|
||||
@@ -479,7 +524,7 @@ async function createUiModule(
|
||||
compatibility: { moduleSdk: "^0.1.0", platform: "^0.1.0" },
|
||||
entrypoints: { server: "./dist/server/index.js", ui: "./dist/ui/index.js" },
|
||||
id,
|
||||
manifestVersion: 6,
|
||||
manifestVersion: 7,
|
||||
name: `${id} module`,
|
||||
version,
|
||||
}),
|
||||
@@ -2685,6 +2730,73 @@ describe.sequential("live API — installed module permission end-to-end", () =>
|
||||
expect(await trustedResponse.json()).toMatchObject({ created: true });
|
||||
});
|
||||
|
||||
it("streams one image-sized upload end to end over real HTTP", async () => {
|
||||
// 25 MiB — an ordinary photograph, and the size the streamed ceiling exists to accommodate. It
|
||||
// travels through a real socket, a real Node server, and the platform's byte counter, and the
|
||||
// platform holds none of it (ADR 0042).
|
||||
const image = new Uint8Array(25 * 1024 * 1024).fill(0x41);
|
||||
|
||||
const response = await fetch(`${origin}/api/v1/modules/calendar/uploads`, {
|
||||
body: image,
|
||||
headers: {
|
||||
"content-type": "application/octet-stream",
|
||||
cookie: ownerCookie,
|
||||
origin: dashboardOrigin,
|
||||
},
|
||||
method: "POST",
|
||||
});
|
||||
|
||||
expect(response.status).toBe(201);
|
||||
expect(await response.json()).toEqual({ bytes: image.byteLength });
|
||||
}, 60_000);
|
||||
|
||||
it("refuses a declared oversize on a streamed route with a sanitized 413", async () => {
|
||||
const response = await fetch(`${origin}/api/v1/modules/calendar/small-uploads`, {
|
||||
body: "a".repeat(65),
|
||||
headers: {
|
||||
"content-type": "text/plain",
|
||||
cookie: ownerCookie,
|
||||
origin: dashboardOrigin,
|
||||
},
|
||||
method: "POST",
|
||||
});
|
||||
|
||||
expect(response.status).toBe(413);
|
||||
expect(await response.json()).toEqual({ code: "REQUEST_BODY_TOO_LARGE" });
|
||||
});
|
||||
|
||||
it("terminates a chunked streamed body that exceeds the limit, even when the handler swallowed it", async () => {
|
||||
// No declared `Content-Length`, so only the running byte count can bound this — and the
|
||||
// handler deliberately ignores the stream error and answers `201`. The platform's `413` still
|
||||
// wins: a limit a handler can swallow is not a limit.
|
||||
const oversize = new ReadableStream<Uint8Array>({
|
||||
pull(controller) {
|
||||
controller.enqueue(new Uint8Array(512).fill(0x41));
|
||||
},
|
||||
});
|
||||
|
||||
const response = await fetch(`${origin}/api/v1/modules/calendar/small-uploads`, {
|
||||
body: oversize,
|
||||
duplex: "half",
|
||||
headers: { cookie: ownerCookie, origin: dashboardOrigin },
|
||||
method: "POST",
|
||||
} as RequestInit);
|
||||
|
||||
expect(response.status).toBe(413);
|
||||
expect(await response.json()).toEqual({ code: "REQUEST_BODY_TOO_LARGE" });
|
||||
}, 30_000);
|
||||
|
||||
it("keeps the server healthy after an oversized upload", async () => {
|
||||
// The socket was released rather than left draining, so the very next request is served
|
||||
// normally.
|
||||
const response = await fetch(`${origin}/api/v1/modules/calendar/events/evt-43`, {
|
||||
headers: { cookie: ownerCookie },
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(await response.json()).toMatchObject({ eventId: "evt-43" });
|
||||
});
|
||||
|
||||
it("rejects a well-formed permission absent from the composed registry, writing no grant or audit row", async () => {
|
||||
const admin = await openAdministrativeClient(database.url);
|
||||
try {
|
||||
@@ -2877,6 +2989,106 @@ describe.sequential("live API — published module UI artifact", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe.sequential("live API — the built Dashboard on the API's own origin", () => {
|
||||
it("serves the built shell's index.html at /, with only its API origin written in, and its exact script, and nothing beside them", async () => {
|
||||
const database = await createDisposableDatabase("dashboard_host");
|
||||
await applyCoreMigrations(database.url);
|
||||
const running = await startMigratedApi(database.url);
|
||||
try {
|
||||
const origin = apiOrigin(running);
|
||||
const builtHtml = await readFile(new URL(dashboardHostAssetUrls.html), "utf8");
|
||||
const builtScript = await readFile(new URL(dashboardHostAssetUrls.script), "utf8");
|
||||
|
||||
const page = await fetch(`${origin}${DASHBOARD_HOST_PAGE_PATH}`);
|
||||
const html = await page.text();
|
||||
expect(page.status).toBe(200);
|
||||
expect(page.headers.get("content-type")).toBe("text/html; charset=utf-8");
|
||||
expect(page.headers.get("cache-control")).toBe("no-cache");
|
||||
expect(page.headers.get("x-frame-options")).toBe("DENY");
|
||||
expect(page.headers.get("content-security-policy")).toBe("frame-ancestors 'none'");
|
||||
expect(page.headers.get("referrer-policy")).toBe("no-referrer");
|
||||
// The built page carries one empty API-origin element; the API fills in its own origin
|
||||
// (the identity base URL) and changes nothing else (ADR 0045).
|
||||
expect(builtHtml.split(DASHBOARD_API_ORIGIN_PLACEHOLDER)).toHaveLength(2);
|
||||
expect(html).toBe(
|
||||
builtHtml.replace(
|
||||
DASHBOARD_API_ORIGIN_PLACEHOLDER,
|
||||
`<meta content="${identityBaseUrl}" name="ekklesia-api-origin" />`,
|
||||
),
|
||||
);
|
||||
expect(html).toContain('id="ekklesia-host"');
|
||||
expect(html).toContain(`<script src="${DASHBOARD_HOST_SCRIPT_PATH}" type="module"></script>`);
|
||||
|
||||
const revalidated = await fetch(`${origin}${DASHBOARD_HOST_PAGE_PATH}`, {
|
||||
headers: { "if-none-match": page.headers.get("etag") ?? "" },
|
||||
});
|
||||
expect(revalidated.status).toBe(304);
|
||||
|
||||
const script = await fetch(`${origin}${DASHBOARD_HOST_SCRIPT_PATH}`);
|
||||
expect(script.status).toBe(200);
|
||||
expect(script.headers.get("content-type")).toBe("text/javascript; charset=utf-8");
|
||||
expect(await script.text()).toBe(builtScript);
|
||||
|
||||
// The source map sits beside the script on disk and is never published.
|
||||
const sourceMap = await fetch(`${origin}${DASHBOARD_HOST_SCRIPT_PATH}.map`);
|
||||
expect(sourceMap.status).toBe(404);
|
||||
expect(await sourceMap.json()).toEqual({ code: "NOT_FOUND" });
|
||||
} finally {
|
||||
await running.shutdown();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe.sequential("live API — health", () => {
|
||||
it("answers liveness and readiness with 200 against a migrated disposable PostgreSQL", async () => {
|
||||
const database = await createDisposableDatabase("health");
|
||||
await applyCoreMigrations(database.url);
|
||||
const running = await startMigratedApi(database.url);
|
||||
try {
|
||||
const origin = apiOrigin(running);
|
||||
|
||||
const live = await fetch(`${origin}${HEALTH_LIVE_PATH}`);
|
||||
expect(live.status).toBe(200);
|
||||
expect(live.headers.get("cache-control")).toBe("no-store");
|
||||
expect(await live.json()).toEqual({ status: "live" });
|
||||
|
||||
const ready = await fetch(`${origin}${HEALTH_READY_PATH}`);
|
||||
expect(ready.status).toBe(200);
|
||||
expect(ready.headers.get("cache-control")).toBe("no-store");
|
||||
expect(await ready.json()).toEqual({ status: "ready" });
|
||||
} finally {
|
||||
await running.shutdown();
|
||||
}
|
||||
});
|
||||
|
||||
it("pings through the shared pool, and PostgreSQL itself cancels a statement that outlives the ping's transaction-local timeout", async () => {
|
||||
const database = await createDisposableDatabase("health_ping");
|
||||
const handle = createApiDatabase(database.url);
|
||||
try {
|
||||
await expect(handle.ping(READINESS_DATABASE_TIMEOUT_MS)).resolves.toBeUndefined();
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
|
||||
// The ping's exact statement shape, with a statement that cannot finish inside its timeout: the
|
||||
// server cancels it (query_canceled) long before the sleep would end, on a client that has no
|
||||
// client-side timeout of its own.
|
||||
const client = new Client({ connectionString: database.url });
|
||||
await client.connect();
|
||||
try {
|
||||
const startedAt = Date.now();
|
||||
await expect(
|
||||
client.query(
|
||||
"BEGIN READ ONLY; SET LOCAL statement_timeout = 50; SELECT pg_sleep(5); COMMIT",
|
||||
),
|
||||
).rejects.toMatchObject({ code: "57014" });
|
||||
expect(Date.now() - startedAt).toBeLessThan(2_000);
|
||||
} finally {
|
||||
await client.end();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe.sequential("live API — public signup is disabled by default", () => {
|
||||
it("rejects sign-up with a stable sanitized 400 when publicSignupEnabled is not set, creating no user row", async () => {
|
||||
const database = await createDisposableDatabase("signup_disabled");
|
||||
|
||||
@@ -3,11 +3,16 @@ import {
|
||||
createAuthenticatedPrincipal,
|
||||
type ModuleDatabase,
|
||||
type ModuleRuntimeServices,
|
||||
moduleIdSchema,
|
||||
parseEkklesiaUserId,
|
||||
parsePermissionId,
|
||||
} from "@ekklesia/contracts";
|
||||
import {
|
||||
type AuthorizationEvaluator,
|
||||
type AuthorizationStore,
|
||||
CORE_PERMISSION_DECLARATIONS,
|
||||
CORE_PERMISSION_REGISTRY,
|
||||
createPermissionRegistry,
|
||||
type PermissionRegistry,
|
||||
} from "@ekklesia/core/authorization";
|
||||
import type { EkklesiaIdentityRuntime } from "@ekklesia/core/identity";
|
||||
@@ -16,6 +21,7 @@ import {
|
||||
createModuleUiPublicationRegistry,
|
||||
EMPTY_MODULE_REALTIME_REGISTRY,
|
||||
type ModuleUiPublicationRegistry,
|
||||
StagedModuleRealtimeRegistry,
|
||||
} from "@ekklesia/core/modules";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
@@ -25,6 +31,8 @@ import {
|
||||
} from "../src/app.js";
|
||||
import { AUTH_REQUEST_MAX_BODY_BYTES } from "../src/body-limits.js";
|
||||
import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
|
||||
import { testDashboardHostAssets } from "./support/dashboard-host-assets.js";
|
||||
import { readyHealth } from "./support/ready-health.js";
|
||||
import { emptyModuleConfiguration } from "./support/in-memory-module-configuration.js";
|
||||
import { emptyModuleLifecycle } from "./support/in-memory-module-lifecycle.js";
|
||||
import { emptyModuleStorage } from "./support/in-memory-module-storage.js";
|
||||
@@ -104,6 +112,8 @@ function createAuthWiringApp(runtime: EkklesiaIdentityRuntime) {
|
||||
moduleConfiguration: emptyModuleConfiguration(),
|
||||
moduleStorage: emptyModuleStorage(),
|
||||
dashboard: createInMemoryDashboardStore(),
|
||||
dashboardHostAssets: testDashboardHostAssets(),
|
||||
health: readyHealth(),
|
||||
moduleServices: fakeModuleServices(),
|
||||
authorization: fakeAuthorization({}),
|
||||
runtime,
|
||||
@@ -121,6 +131,8 @@ describe("Ekklesia API Hono application", () => {
|
||||
moduleConfiguration: emptyModuleConfiguration(),
|
||||
moduleStorage: emptyModuleStorage(),
|
||||
dashboard: createInMemoryDashboardStore(),
|
||||
dashboardHostAssets: testDashboardHostAssets(),
|
||||
health: readyHealth(),
|
||||
moduleServices: fakeModuleServices(),
|
||||
authorization: fakeAuthorization({}),
|
||||
runtime,
|
||||
@@ -355,6 +367,8 @@ describe("Ekklesia API Hono application", () => {
|
||||
moduleConfiguration: emptyModuleConfiguration(),
|
||||
moduleStorage: emptyModuleStorage(),
|
||||
dashboard: createInMemoryDashboardStore(),
|
||||
dashboardHostAssets: testDashboardHostAssets(),
|
||||
health: readyHealth(),
|
||||
moduleServices: fakeModuleServices(),
|
||||
authorization: fakeAuthorization({}),
|
||||
runtime,
|
||||
@@ -378,6 +392,8 @@ describe("Ekklesia API Hono application", () => {
|
||||
moduleConfiguration: emptyModuleConfiguration(),
|
||||
moduleStorage: emptyModuleStorage(),
|
||||
dashboard: createInMemoryDashboardStore(),
|
||||
dashboardHostAssets: testDashboardHostAssets(),
|
||||
health: readyHealth(),
|
||||
moduleServices: fakeModuleServices(),
|
||||
authorization: fakeAuthorization({}),
|
||||
runtime,
|
||||
@@ -403,6 +419,8 @@ describe("Ekklesia API Hono application", () => {
|
||||
moduleConfiguration: emptyModuleConfiguration(),
|
||||
moduleStorage: emptyModuleStorage(),
|
||||
dashboard: createInMemoryDashboardStore(),
|
||||
dashboardHostAssets: testDashboardHostAssets(),
|
||||
health: readyHealth(),
|
||||
moduleServices: fakeModuleServices(),
|
||||
authorization: fakeAuthorization({}),
|
||||
runtime,
|
||||
@@ -430,6 +448,8 @@ describe("Ekklesia API Hono application", () => {
|
||||
moduleConfiguration: emptyModuleConfiguration(),
|
||||
moduleStorage: emptyModuleStorage(),
|
||||
dashboard: createInMemoryDashboardStore(),
|
||||
dashboardHostAssets: testDashboardHostAssets(),
|
||||
health: readyHealth(),
|
||||
moduleServices: fakeModuleServices(),
|
||||
authorization: fakeAuthorization({}),
|
||||
runtime,
|
||||
@@ -457,6 +477,8 @@ describe("Ekklesia API Hono application", () => {
|
||||
moduleConfiguration: emptyModuleConfiguration(),
|
||||
moduleStorage: emptyModuleStorage(),
|
||||
dashboard: createInMemoryDashboardStore(),
|
||||
dashboardHostAssets: testDashboardHostAssets(),
|
||||
health: readyHealth(),
|
||||
moduleServices: fakeModuleServices(),
|
||||
authorization: fakeAuthorization({}),
|
||||
runtime,
|
||||
@@ -481,6 +503,8 @@ describe("Ekklesia API Hono application", () => {
|
||||
moduleConfiguration: emptyModuleConfiguration(),
|
||||
moduleStorage: emptyModuleStorage(),
|
||||
dashboard: createInMemoryDashboardStore(),
|
||||
dashboardHostAssets: testDashboardHostAssets(),
|
||||
health: readyHealth(),
|
||||
moduleServices: fakeModuleServices(),
|
||||
authorization: fakeAuthorization({}),
|
||||
runtime,
|
||||
@@ -507,6 +531,8 @@ describe("Ekklesia API Hono application", () => {
|
||||
moduleConfiguration: emptyModuleConfiguration(),
|
||||
moduleStorage: emptyModuleStorage(),
|
||||
dashboard: createInMemoryDashboardStore(),
|
||||
dashboardHostAssets: testDashboardHostAssets(),
|
||||
health: readyHealth(),
|
||||
moduleServices: fakeModuleServices(),
|
||||
authorization: fakeAuthorization({}),
|
||||
runtime,
|
||||
@@ -530,6 +556,8 @@ describe("Ekklesia API Hono application", () => {
|
||||
moduleConfiguration: emptyModuleConfiguration(),
|
||||
moduleStorage: emptyModuleStorage(),
|
||||
dashboard: createInMemoryDashboardStore(),
|
||||
dashboardHostAssets: testDashboardHostAssets(),
|
||||
health: readyHealth(),
|
||||
moduleServices: fakeModuleServices(),
|
||||
authorization: fakeAuthorization({}),
|
||||
runtime,
|
||||
@@ -555,6 +583,8 @@ describe("Ekklesia API Hono application", () => {
|
||||
moduleConfiguration: emptyModuleConfiguration(),
|
||||
moduleStorage: emptyModuleStorage(),
|
||||
dashboard: createInMemoryDashboardStore(),
|
||||
dashboardHostAssets: testDashboardHostAssets(),
|
||||
health: readyHealth(),
|
||||
moduleServices: fakeModuleServices(),
|
||||
authorization: fakeAuthorization({}),
|
||||
runtime,
|
||||
@@ -587,6 +617,8 @@ describe("Ekklesia API Hono application", () => {
|
||||
moduleConfiguration: emptyModuleConfiguration(),
|
||||
moduleStorage: emptyModuleStorage(),
|
||||
dashboard: createInMemoryDashboardStore(),
|
||||
dashboardHostAssets: testDashboardHostAssets(),
|
||||
health: readyHealth(),
|
||||
moduleServices: fakeModuleServices(),
|
||||
authorization: fakeAuthorization({}),
|
||||
runtime,
|
||||
@@ -620,6 +652,8 @@ describe("Ekklesia API Hono application", () => {
|
||||
moduleConfiguration: emptyModuleConfiguration(),
|
||||
moduleStorage: emptyModuleStorage(),
|
||||
dashboard: createInMemoryDashboardStore(),
|
||||
dashboardHostAssets: testDashboardHostAssets(),
|
||||
health: readyHealth(),
|
||||
moduleServices: fakeModuleServices(),
|
||||
authorization: fakeAuthorization({}),
|
||||
runtime,
|
||||
@@ -658,6 +692,8 @@ describe("Ekklesia API Hono application", () => {
|
||||
moduleConfiguration: emptyModuleConfiguration(),
|
||||
moduleStorage: emptyModuleStorage(),
|
||||
dashboard: createInMemoryDashboardStore(),
|
||||
dashboardHostAssets: testDashboardHostAssets(),
|
||||
health: readyHealth(),
|
||||
moduleServices: fakeModuleServices(),
|
||||
authorization: fakeAuthorization({}),
|
||||
runtime: fakeRuntime(),
|
||||
@@ -678,6 +714,8 @@ describe("Ekklesia API Hono application", () => {
|
||||
moduleConfiguration: emptyModuleConfiguration(),
|
||||
moduleStorage: emptyModuleStorage(),
|
||||
dashboard: createInMemoryDashboardStore(),
|
||||
dashboardHostAssets: testDashboardHostAssets(),
|
||||
health: readyHealth(),
|
||||
moduleServices: fakeModuleServices(),
|
||||
authorization: fakeAuthorization({}),
|
||||
runtime: fakeRuntime(),
|
||||
@@ -700,6 +738,8 @@ describe("Ekklesia API Hono application", () => {
|
||||
moduleConfiguration: emptyModuleConfiguration(),
|
||||
moduleStorage: emptyModuleStorage(),
|
||||
dashboard: createInMemoryDashboardStore(),
|
||||
dashboardHostAssets: testDashboardHostAssets(),
|
||||
health: readyHealth(),
|
||||
moduleServices: fakeModuleServices(),
|
||||
authorization: fakeAuthorization({}),
|
||||
runtime: fakeRuntime(),
|
||||
@@ -721,6 +761,8 @@ describe("Ekklesia API Hono application", () => {
|
||||
moduleConfiguration: emptyModuleConfiguration(),
|
||||
moduleStorage: emptyModuleStorage(),
|
||||
dashboard: createInMemoryDashboardStore(),
|
||||
dashboardHostAssets: testDashboardHostAssets(),
|
||||
health: readyHealth(),
|
||||
moduleServices: fakeModuleServices(),
|
||||
authorization: fakeAuthorization({}),
|
||||
runtime: fakeRuntime(),
|
||||
@@ -740,6 +782,8 @@ describe("Ekklesia API Hono application", () => {
|
||||
moduleConfiguration: emptyModuleConfiguration(),
|
||||
moduleStorage: emptyModuleStorage(),
|
||||
dashboard: createInMemoryDashboardStore(),
|
||||
dashboardHostAssets: testDashboardHostAssets(),
|
||||
health: readyHealth(),
|
||||
moduleServices: fakeModuleServices(),
|
||||
authorization: fakeAuthorization({}),
|
||||
runtime: fakeRuntime(),
|
||||
@@ -766,6 +810,8 @@ describe("Ekklesia API Hono application", () => {
|
||||
moduleConfiguration: emptyModuleConfiguration(),
|
||||
moduleStorage: emptyModuleStorage(),
|
||||
dashboard: createInMemoryDashboardStore(),
|
||||
dashboardHostAssets: testDashboardHostAssets(),
|
||||
health: readyHealth(),
|
||||
moduleServices: fakeModuleServices(),
|
||||
authorization: fakeAuthorization({}),
|
||||
runtime: fakeRuntime(),
|
||||
@@ -795,6 +841,8 @@ describe("Ekklesia API Hono application", () => {
|
||||
moduleConfiguration: emptyModuleConfiguration(),
|
||||
moduleStorage: emptyModuleStorage(),
|
||||
dashboard: createInMemoryDashboardStore(),
|
||||
dashboardHostAssets: testDashboardHostAssets(),
|
||||
health: readyHealth(),
|
||||
moduleServices: fakeModuleServices(),
|
||||
authorization: fakeAuthorization({ evaluator: { authorize: evaluatorAuthorize } }),
|
||||
runtime: fakeRuntime(),
|
||||
@@ -827,6 +875,8 @@ describe("Ekklesia API Hono application", () => {
|
||||
moduleConfiguration: emptyModuleConfiguration(),
|
||||
moduleStorage: emptyModuleStorage(),
|
||||
dashboard: createInMemoryDashboardStore(),
|
||||
dashboardHostAssets: testDashboardHostAssets(),
|
||||
health: readyHealth(),
|
||||
moduleServices: fakeModuleServices(),
|
||||
authorization: fakeAuthorization({
|
||||
evaluator: { authorize: evaluatorAuthorize },
|
||||
@@ -850,6 +900,8 @@ describe("Ekklesia API Hono application", () => {
|
||||
moduleConfiguration: emptyModuleConfiguration(),
|
||||
moduleStorage: emptyModuleStorage(),
|
||||
dashboard: createInMemoryDashboardStore(),
|
||||
dashboardHostAssets: testDashboardHostAssets(),
|
||||
health: readyHealth(),
|
||||
moduleServices: fakeModuleServices(),
|
||||
authorization: fakeAuthorization({}),
|
||||
runtime: fakeRuntime(),
|
||||
@@ -864,3 +916,101 @@ describe("Ekklesia API Hono application", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("split Dashboard and API origins (ADR 0045)", () => {
|
||||
const moduleId = moduleIdSchema.parse("announcements");
|
||||
const permission = parsePermissionId("announcements.slideshow.view");
|
||||
const endpoint = "/api/v1/module-realtime/announcements/state";
|
||||
|
||||
/** The full application with one live channel, a signed-in caller, and an evaluator that allows
|
||||
* it: the same composition a Dashboard on another origin reaches. */
|
||||
function realtimeApp() {
|
||||
const staged = new StagedModuleRealtimeRegistry();
|
||||
const registration = staged.createRegistrationStage(moduleId, new Set([permission]));
|
||||
registration.realtime.channel({ channel: "state", permission });
|
||||
registration.commit();
|
||||
staged.publisherFor(moduleId).publish("state", { mode: "on" } as never);
|
||||
const moduleRealtime = staged.publish();
|
||||
const app = createApiAppWithModules({
|
||||
moduleRoutes: Object.freeze([]),
|
||||
moduleUi: emptyModuleUiPublication(),
|
||||
moduleLifecycle: emptyModuleLifecycle(),
|
||||
moduleConfiguration: emptyModuleConfiguration(),
|
||||
moduleStorage: emptyModuleStorage(),
|
||||
moduleRealtime,
|
||||
dashboard: createInMemoryDashboardStore(),
|
||||
dashboardHostAssets: testDashboardHostAssets(),
|
||||
health: readyHealth(),
|
||||
moduleServices: fakeModuleServices(),
|
||||
authorization: fakeAuthorization({
|
||||
evaluator: { authorize: vi.fn().mockResolvedValue({ allowed: true }) },
|
||||
registry: createPermissionRegistry([
|
||||
...CORE_PERMISSION_DECLARATIONS,
|
||||
{
|
||||
baseline: "restricted",
|
||||
description: "View the announcement slideshow.",
|
||||
id: permission,
|
||||
},
|
||||
]),
|
||||
}),
|
||||
runtime: fakeRuntime({
|
||||
resolvePrincipal: vi
|
||||
.fn()
|
||||
.mockResolvedValue(
|
||||
createAuthenticatedPrincipal(
|
||||
parseEkklesiaUserId("88888888-8888-4888-8888-888888888888"),
|
||||
),
|
||||
),
|
||||
}),
|
||||
trustedOrigins: [trustedOrigin],
|
||||
});
|
||||
return { app, moduleRealtime };
|
||||
}
|
||||
|
||||
it("lets the Dashboard's origin open a credentialed realtime stream", async () => {
|
||||
const { app, moduleRealtime } = realtimeApp();
|
||||
|
||||
const response = await app.request(endpoint, {
|
||||
headers: { cookie: "session=1", origin: trustedOrigin },
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get("content-type")).toBe("text/event-stream; charset=utf-8");
|
||||
expect(response.headers.get("access-control-allow-origin")).toBe(trustedOrigin);
|
||||
expect(response.headers.get("access-control-allow-credentials")).toBe("true");
|
||||
expect(response.headers.get("vary")).toContain("Origin");
|
||||
await response.body?.cancel();
|
||||
moduleRealtime.closeSubscriptions();
|
||||
});
|
||||
|
||||
it("gives an untrusted origin's realtime request no CORS permission to read the stream", async () => {
|
||||
const { app, moduleRealtime } = realtimeApp();
|
||||
|
||||
const response = await app.request(endpoint, {
|
||||
headers: { cookie: "session=1", origin: untrustedOrigin },
|
||||
});
|
||||
|
||||
expect(response.headers.get("access-control-allow-origin")).toBeNull();
|
||||
await response.body?.cancel();
|
||||
moduleRealtime.closeSubscriptions();
|
||||
});
|
||||
|
||||
it("allows only Content-Type beyond the CORS-safelisted request headers, and exposes none", async () => {
|
||||
const app = createAuthWiringApp(fakeRuntime());
|
||||
|
||||
const response = await app.request("/api/v1/principal", {
|
||||
headers: {
|
||||
"access-control-request-headers": "content-type, x-module-custom",
|
||||
"access-control-request-method": "PATCH",
|
||||
origin: trustedOrigin,
|
||||
},
|
||||
method: "OPTIONS",
|
||||
});
|
||||
|
||||
expect(response.headers.get("access-control-allow-origin")).toBe(trustedOrigin);
|
||||
expect(response.headers.get("access-control-allow-headers")?.toLowerCase()).toBe(
|
||||
"content-type",
|
||||
);
|
||||
expect(response.headers.get("access-control-expose-headers")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { ApiConfigError, createApiRuntimeConfig } from "../src/config.js";
|
||||
import { ApiConfigError, apiTrustedOrigins, createApiRuntimeConfig } from "../src/config.js";
|
||||
|
||||
const validSecret = "correct-horse-battery-staple-0123456789";
|
||||
|
||||
@@ -337,3 +337,24 @@ describe("API runtime configuration", () => {
|
||||
expect(publicText).not.toContain("db.internal.example");
|
||||
});
|
||||
});
|
||||
|
||||
describe("the API's trusted browser origins (ADR 0045)", () => {
|
||||
it("adds the API's own origin to the configured ones, as Better Auth does", () => {
|
||||
const origins = apiTrustedOrigins({
|
||||
baseUrl: "https://api.example.test",
|
||||
trustedOrigins: ["https://dash.example.test"],
|
||||
});
|
||||
|
||||
expect(origins).toEqual(["https://dash.example.test", "https://api.example.test"]);
|
||||
expect(Object.isFrozen(origins)).toBe(true);
|
||||
});
|
||||
|
||||
it("does not duplicate an API origin already listed (the same-origin topology)", () => {
|
||||
expect(
|
||||
apiTrustedOrigins({
|
||||
baseUrl: "https://ekklesia.example.test",
|
||||
trustedOrigins: ["https://ekklesia.example.test"],
|
||||
}),
|
||||
).toEqual(["https://ekklesia.example.test"]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const readFileMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("node:fs/promises", () => ({ readFile: readFileMock }));
|
||||
vi.mock("@ekklesia/shell/dashboard-assets", () => ({
|
||||
dashboardHostAssetUrls: Object.freeze({
|
||||
html: "file:///surface/browser/index.html",
|
||||
script: "file:///surface/browser/assets/ekklesia-dashboard/app.js",
|
||||
}),
|
||||
}));
|
||||
|
||||
import {
|
||||
createDashboardHostAssets,
|
||||
DASHBOARD_API_ORIGIN_PLACEHOLDER,
|
||||
DashboardHostAssetError,
|
||||
loadDashboardHostAssets,
|
||||
renderDashboardPage,
|
||||
} from "../src/dashboard-host-assets.js";
|
||||
|
||||
const API_ORIGIN = "https://api.example.test";
|
||||
|
||||
function sha256Etag(text: string): string {
|
||||
return `"${createHash("sha256").update(text).digest("base64url")}"`;
|
||||
}
|
||||
|
||||
describe("Dashboard host asset loading", () => {
|
||||
beforeEach(() => readFileMock.mockReset());
|
||||
|
||||
it("reads only the two declared file URLs in order and freezes one consistent snapshot", async () => {
|
||||
const html = `<!doctype html>${DASHBOARD_API_ORIGIN_PLACEHOLDER}<div id="ekklesia-host"></div>`;
|
||||
const script = "export const dashboardHost = true;\n";
|
||||
readFileMock.mockResolvedValueOnce(html).mockResolvedValueOnce(script);
|
||||
|
||||
const assets = await loadDashboardHostAssets(API_ORIGIN);
|
||||
|
||||
expect(readFileMock.mock.calls).toEqual([
|
||||
[new URL("file:///surface/browser/index.html"), "utf8"],
|
||||
[new URL("file:///surface/browser/assets/ekklesia-dashboard/app.js"), "utf8"],
|
||||
]);
|
||||
const rendered = `<!doctype html><meta content="${API_ORIGIN}" name="ekklesia-api-origin" /><div id="ekklesia-host"></div>`;
|
||||
expect(assets).toEqual({
|
||||
html: rendered,
|
||||
htmlEtag: sha256Etag(rendered),
|
||||
script,
|
||||
scriptEtag: sha256Etag(script),
|
||||
});
|
||||
expect(Object.isFrozen(assets)).toBe(true);
|
||||
});
|
||||
|
||||
it("fails deterministically without attempting a neighboring file or exposing the read error", async () => {
|
||||
readFileMock.mockRejectedValueOnce(
|
||||
new Error("ENOENT /home/operator/private/surface-build secret-token"),
|
||||
);
|
||||
|
||||
const failure = await loadDashboardHostAssets(API_ORIGIN).catch((error: unknown) => error);
|
||||
|
||||
expect(readFileMock).toHaveBeenCalledTimes(1);
|
||||
expect(failure).toBeInstanceOf(DashboardHostAssetError);
|
||||
expect(failure).toMatchObject({
|
||||
code: "DASHBOARD_HOST_ASSET_UNAVAILABLE",
|
||||
message: "The Dashboard host browser assets are unavailable.",
|
||||
name: "DashboardHostAssetError",
|
||||
});
|
||||
expect((failure as Error).stack).toBe(
|
||||
"DashboardHostAssetError: The Dashboard host browser assets are unavailable.",
|
||||
);
|
||||
const publicText = `${String(failure)}\n${JSON.stringify(failure)}\n${(failure as Error).stack}`;
|
||||
for (const secret of ["/home/", "operator", "ENOENT", "secret-token"]) {
|
||||
expect(publicText).not.toContain(secret);
|
||||
}
|
||||
});
|
||||
|
||||
it("fails when the script is missing even though the page was read", async () => {
|
||||
readFileMock
|
||||
.mockResolvedValueOnce(`<!doctype html>${DASHBOARD_API_ORIGIN_PLACEHOLDER}`)
|
||||
.mockRejectedValueOnce(new Error("ENOENT app.js"));
|
||||
|
||||
await expect(loadDashboardHostAssets(API_ORIGIN)).rejects.toBeInstanceOf(
|
||||
DashboardHostAssetError,
|
||||
);
|
||||
expect(readFileMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("rejects an empty page or script as an unavailable shell build", () => {
|
||||
for (const [html, script] of [
|
||||
["", "export {};"],
|
||||
["<html></html>", " \n"],
|
||||
] as const) {
|
||||
expect(() => createDashboardHostAssets(html, script)).toThrow(DashboardHostAssetError);
|
||||
}
|
||||
});
|
||||
|
||||
it("fails startup on a page without exactly one empty API-origin element (ADR 0045)", async () => {
|
||||
for (const html of [
|
||||
"<!doctype html>",
|
||||
`<!doctype html>${DASHBOARD_API_ORIGIN_PLACEHOLDER}${DASHBOARD_API_ORIGIN_PLACEHOLDER}`,
|
||||
'<!doctype html><meta content="https://elsewhere.test" name="ekklesia-api-origin" />',
|
||||
]) {
|
||||
readFileMock.mockReset();
|
||||
readFileMock.mockResolvedValueOnce(html).mockResolvedValueOnce("export {};\n");
|
||||
await expect(loadDashboardHostAssets(API_ORIGIN)).rejects.toBeInstanceOf(
|
||||
DashboardHostAssetError,
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("Dashboard page API origin (ADR 0045)", () => {
|
||||
const page = `<head>${DASHBOARD_API_ORIGIN_PLACEHOLDER}</head>`;
|
||||
|
||||
it("writes the API's origin into the one placeholder, in either topology", () => {
|
||||
expect(renderDashboardPage(page, "https://api.example.test")).toBe(
|
||||
'<head><meta content="https://api.example.test" name="ekklesia-api-origin" /></head>',
|
||||
);
|
||||
expect(renderDashboardPage(page, "http://127.0.0.1:3000")).toBe(
|
||||
'<head><meta content="http://127.0.0.1:3000" name="ekklesia-api-origin" /></head>',
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
"https://api.example.test/",
|
||||
"https://api.example.test/api",
|
||||
'https://api.example.test"><script>',
|
||||
"javascript:alert(1)",
|
||||
"",
|
||||
])("refuses a value that is not a canonical http(s) origin: %s", (origin) => {
|
||||
expect(() => renderDashboardPage(page, origin)).toThrow(DashboardHostAssetError);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,347 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import {
|
||||
ANONYMOUS_PRINCIPAL,
|
||||
type ModuleDatabase,
|
||||
type ModuleRouteHandler,
|
||||
type ModuleRuntimeServices,
|
||||
parsePermissionId,
|
||||
type RegisteredModuleHttpRoute,
|
||||
} from "@ekklesia/contracts";
|
||||
import {
|
||||
type AuthorizationEvaluator,
|
||||
type AuthorizationStore,
|
||||
createPermissionRegistry,
|
||||
} from "@ekklesia/core/authorization";
|
||||
import type { EkklesiaIdentityRuntime } from "@ekklesia/core/identity";
|
||||
import {
|
||||
createModuleUiPublicationRegistry,
|
||||
EMPTY_MODULE_REALTIME_REGISTRY,
|
||||
} from "@ekklesia/core/modules";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
// Every byte these routes serve must come from the injected startup snapshot. Any filesystem read
|
||||
// while answering a request — the hallmark of a static directory — fails these tests.
|
||||
const readFileMock = vi.hoisted(() => vi.fn());
|
||||
vi.mock("node:fs/promises", () => ({ readFile: readFileMock }));
|
||||
|
||||
import { createApiApp } from "../src/app.js";
|
||||
import {
|
||||
createDashboardHostAssets,
|
||||
type DashboardHostAssets,
|
||||
} from "../src/dashboard-host-assets.js";
|
||||
import {
|
||||
DASHBOARD_HOST_PAGE_PATH,
|
||||
DASHBOARD_HOST_SCRIPT_PATH,
|
||||
DashboardHostConfigurationError,
|
||||
} from "../src/dashboard-host-routes.js";
|
||||
import { DISPLAY_HOST_SCRIPT_PATH } from "../src/module-display-routes.js";
|
||||
import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
|
||||
import { emptyModuleConfiguration } from "./support/in-memory-module-configuration.js";
|
||||
import { emptyModuleLifecycle } from "./support/in-memory-module-lifecycle.js";
|
||||
import { emptyModuleStorage } from "./support/in-memory-module-storage.js";
|
||||
import { readyHealth } from "./support/ready-health.js";
|
||||
|
||||
const trustedOrigin = "https://dashboard.example.test";
|
||||
const modulePermission = parsePermissionId("dashboard.page.read");
|
||||
|
||||
const PAGE_HTML =
|
||||
'<!doctype html><html lang="en"><body><div id="ekklesia-host"></div><script src="/assets/ekklesia-dashboard/app.js" type="module"></script></body></html>\n';
|
||||
const SCRIPT = "export const dashboard = 'built shell';\n";
|
||||
|
||||
function sha256Etag(text: string): string {
|
||||
return `"${createHash("sha256").update(text).digest("base64url")}"`;
|
||||
}
|
||||
|
||||
const PAGE_ETAG = sha256Etag(PAGE_HTML);
|
||||
const SCRIPT_ETAG = sha256Etag(SCRIPT);
|
||||
|
||||
function fakeRuntime(): EkklesiaIdentityRuntime {
|
||||
return {
|
||||
handleAuthRequest: vi.fn(),
|
||||
resolvePrincipal: vi.fn().mockResolvedValue(ANONYMOUS_PRINCIPAL),
|
||||
};
|
||||
}
|
||||
|
||||
function fakeStore(): AuthorizationStore {
|
||||
return {
|
||||
createGrant: vi.fn(),
|
||||
grantPlatformOwner: vi.fn(),
|
||||
hasActiveGrant: vi.fn().mockResolvedValue(false),
|
||||
isPlatformOwner: vi.fn().mockResolvedValue(false),
|
||||
listGrantsForUser: vi.fn().mockResolvedValue({ grants: [] }),
|
||||
revokeGrant: vi.fn(),
|
||||
revokePlatformOwner: vi.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
function fakeModuleServices(): ModuleRuntimeServices {
|
||||
return Object.freeze({ database: Object.freeze({}) as unknown as ModuleDatabase });
|
||||
}
|
||||
|
||||
function buildApp(
|
||||
options: {
|
||||
readonly assets?: DashboardHostAssets;
|
||||
readonly moduleRoutes?: readonly RegisteredModuleHttpRoute[];
|
||||
} = {},
|
||||
) {
|
||||
const evaluator: AuthorizationEvaluator = {
|
||||
authorize: vi.fn().mockResolvedValue({ allowed: true }),
|
||||
};
|
||||
const runtime = fakeRuntime();
|
||||
const app = createApiApp({
|
||||
authorization: {
|
||||
evaluator,
|
||||
registry: createPermissionRegistry([
|
||||
{ baseline: "public", description: "Test permission.", id: modulePermission },
|
||||
]),
|
||||
store: fakeStore(),
|
||||
},
|
||||
moduleRoutes: options.moduleRoutes ?? Object.freeze([]),
|
||||
moduleRealtime: EMPTY_MODULE_REALTIME_REGISTRY,
|
||||
moduleServices: fakeModuleServices(),
|
||||
moduleUi: createModuleUiPublicationRegistry([]),
|
||||
moduleLifecycle: emptyModuleLifecycle(),
|
||||
moduleConfiguration: emptyModuleConfiguration(),
|
||||
moduleStorage: emptyModuleStorage(),
|
||||
dashboard: createInMemoryDashboardStore(),
|
||||
dashboardHostAssets: options.assets ?? createDashboardHostAssets(PAGE_HTML, SCRIPT),
|
||||
health: readyHealth(),
|
||||
runtime,
|
||||
trustedOrigins: [trustedOrigin],
|
||||
});
|
||||
return { app, evaluator, runtime };
|
||||
}
|
||||
|
||||
function expectPageSecurityHeaders(response: Response): void {
|
||||
expect(response.headers.get("x-frame-options")).toBe("DENY");
|
||||
expect(response.headers.get("content-security-policy")).toBe("frame-ancestors 'none'");
|
||||
expect(response.headers.get("referrer-policy")).toBe("no-referrer");
|
||||
expect(response.headers.get("x-content-type-options")).toBe("nosniff");
|
||||
}
|
||||
|
||||
async function expectSanitizedNotFound(response: Response, label: string): Promise<void> {
|
||||
expect(response.status, label).toBe(404);
|
||||
expect(response.headers.get("content-type"), label).toMatch(/^application\/json/u);
|
||||
expect(await response.json(), label).toEqual({ code: "NOT_FOUND" });
|
||||
}
|
||||
|
||||
describe("GET / (the Dashboard page)", () => {
|
||||
it("serves the exact startup HTML bytes with revalidation, a strong ETag, and the page security headers", async () => {
|
||||
const { app } = buildApp();
|
||||
|
||||
const response = await app.request(DASHBOARD_HOST_PAGE_PATH);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(await response.text()).toBe(PAGE_HTML);
|
||||
expect(response.headers.get("content-type")).toBe("text/html; charset=utf-8");
|
||||
expect(response.headers.get("cache-control")).toBe("no-cache");
|
||||
expect(response.headers.get("etag")).toBe(PAGE_ETAG);
|
||||
expectPageSecurityHeaders(response);
|
||||
// The one CSP directive is the only one: the full policy is a separate decision.
|
||||
expect(response.headers.get("content-security-policy")).not.toContain(";");
|
||||
});
|
||||
|
||||
it("serves the same page for an emailed-link landing, since the query never selects a file", async () => {
|
||||
const { app } = buildApp();
|
||||
|
||||
const response = await app.request("/?ekklesia_auth=reset-password&token=abc123");
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(await response.text()).toBe(PAGE_HTML);
|
||||
expect(response.headers.get("etag")).toBe(PAGE_ETAG);
|
||||
});
|
||||
|
||||
it("answers a matching If-None-Match with an empty 304 that keeps the validator and every page security header", async () => {
|
||||
const { app } = buildApp();
|
||||
|
||||
for (const ifNoneMatch of [PAGE_ETAG, `W/${PAGE_ETAG}`, `"other", ${PAGE_ETAG}`, "*"]) {
|
||||
const response = await app.request(DASHBOARD_HOST_PAGE_PATH, {
|
||||
headers: { "If-None-Match": ifNoneMatch },
|
||||
});
|
||||
|
||||
expect(response.status, ifNoneMatch).toBe(304);
|
||||
expect(await response.text(), ifNoneMatch).toBe("");
|
||||
expect(response.headers.get("etag")).toBe(PAGE_ETAG);
|
||||
expect(response.headers.get("cache-control")).toBe("no-cache");
|
||||
expectPageSecurityHeaders(response);
|
||||
}
|
||||
});
|
||||
|
||||
it("serves the full page again when the browser holds a different build's validator", async () => {
|
||||
const { app } = buildApp();
|
||||
|
||||
const response = await app.request(DASHBOARD_HOST_PAGE_PATH, {
|
||||
headers: { "If-None-Match": sha256Etag("an older build") },
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(await response.text()).toBe(PAGE_HTML);
|
||||
});
|
||||
|
||||
it("is unauthenticated and outside the API's CORS policy: it resolves no principal and reflects no origin", async () => {
|
||||
const { app, evaluator, runtime } = buildApp();
|
||||
|
||||
const response = await app.request(DASHBOARD_HOST_PAGE_PATH, {
|
||||
headers: { Cookie: "better-auth.session_token=anything", Origin: trustedOrigin },
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(runtime.resolvePrincipal).not.toHaveBeenCalled();
|
||||
expect(evaluator.authorize).not.toHaveBeenCalled();
|
||||
expect(response.headers.get("access-control-allow-origin")).toBeNull();
|
||||
expect(response.headers.get("set-cookie")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /assets/ekklesia-dashboard/app.js (the Dashboard script)", () => {
|
||||
it("serves the exact startup script bytes as JavaScript with revalidation and a strong ETag", async () => {
|
||||
const { app } = buildApp();
|
||||
|
||||
const response = await app.request(DASHBOARD_HOST_SCRIPT_PATH);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(await response.text()).toBe(SCRIPT);
|
||||
expect(response.headers.get("content-type")).toBe("text/javascript; charset=utf-8");
|
||||
expect(response.headers.get("cache-control")).toBe("no-cache");
|
||||
expect(response.headers.get("etag")).toBe(SCRIPT_ETAG);
|
||||
expect(response.headers.get("x-content-type-options")).toBe("nosniff");
|
||||
});
|
||||
|
||||
it("answers a matching If-None-Match with an empty 304 carrying the script's own validator", async () => {
|
||||
const { app } = buildApp();
|
||||
|
||||
const response = await app.request(DASHBOARD_HOST_SCRIPT_PATH, {
|
||||
headers: { "If-None-Match": SCRIPT_ETAG },
|
||||
});
|
||||
|
||||
expect(response.status).toBe(304);
|
||||
expect(await response.text()).toBe("");
|
||||
expect(response.headers.get("etag")).toBe(SCRIPT_ETAG);
|
||||
expect(response.headers.get("cache-control")).toBe("no-cache");
|
||||
});
|
||||
|
||||
it("does not accept the page's validator as the script's", async () => {
|
||||
const { app } = buildApp();
|
||||
|
||||
const response = await app.request(DASHBOARD_HOST_SCRIPT_PATH, {
|
||||
headers: { "If-None-Match": PAGE_ETAG },
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Dashboard host boundaries", () => {
|
||||
it("serves nothing but its two exact paths, answering every other spelling with the sanitized 404", async () => {
|
||||
const { app } = buildApp();
|
||||
|
||||
for (const path of [
|
||||
"/index.html",
|
||||
"/app.js",
|
||||
"/app.js.map",
|
||||
"/assets/ekklesia-dashboard/app.js.map",
|
||||
"/assets/ekklesia-dashboard/",
|
||||
"/assets/ekklesia-dashboard",
|
||||
"/assets/ekklesia-dashboard/app.js/",
|
||||
"/assets/ekklesia-dashboard/index.html",
|
||||
"/assets/",
|
||||
"/assets/ekklesia-dashboard/APP.JS",
|
||||
"/dashboard",
|
||||
"/login",
|
||||
]) {
|
||||
await expectSanitizedNotFound(await app.request(path), path);
|
||||
}
|
||||
});
|
||||
|
||||
it("cannot be walked out of: encoded traversal, separators, and double slashes reach no file", async () => {
|
||||
const { app } = buildApp();
|
||||
|
||||
for (const path of [
|
||||
"/assets/ekklesia-dashboard/%2e%2e/%2e%2e/package.json",
|
||||
"/assets/ekklesia-dashboard/..%2fapp.js.map",
|
||||
"/assets/ekklesia-dashboard/%2e%2e%2fapp.js.map",
|
||||
"/assets/ekklesia-dashboard/app.js%2f..%2f..%2fpackage.json",
|
||||
"/assets/ekklesia-dashboard/..%5c..%5cpackage.json",
|
||||
"/%2e%2e/%2e%2e/etc/passwd",
|
||||
"/../../etc/passwd",
|
||||
"//assets/ekklesia-dashboard/app.js",
|
||||
"/assets//ekklesia-dashboard/app.js",
|
||||
"/assets/ekklesia-dashboard/app.js%00.map",
|
||||
]) {
|
||||
const response = await app.request(path);
|
||||
expect(await response.text(), path).not.toContain(SCRIPT);
|
||||
expect(response.status, path).toBe(404);
|
||||
}
|
||||
expect(readFileMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("never touches the filesystem while answering, even across every served path", async () => {
|
||||
const { app } = buildApp();
|
||||
|
||||
await app.request(DASHBOARD_HOST_PAGE_PATH);
|
||||
await app.request(DASHBOARD_HOST_SCRIPT_PATH);
|
||||
await app.request("/assets/ekklesia-dashboard/app.js.map");
|
||||
|
||||
expect(readFileMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("serves GET (and Hono's implicit HEAD) only; every other method is the ordinary 404", async () => {
|
||||
const { app } = buildApp();
|
||||
|
||||
const head = await app.request(DASHBOARD_HOST_PAGE_PATH, { method: "HEAD" });
|
||||
expect(head.status).toBe(200);
|
||||
expect(head.headers.get("etag")).toBe(PAGE_ETAG);
|
||||
expect(await head.text()).toBe("");
|
||||
|
||||
for (const path of [DASHBOARD_HOST_PAGE_PATH, DASHBOARD_HOST_SCRIPT_PATH]) {
|
||||
for (const method of ["POST", "PUT", "PATCH", "DELETE"]) {
|
||||
await expectSanitizedNotFound(await app.request(path, { method }), `${method} ${path}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("leaves the API, the display host's namespace, and module routes exactly where they were", async () => {
|
||||
const moduleHandler = vi.fn<ModuleRouteHandler>(
|
||||
async () => new Response("module", { status: 200 }),
|
||||
);
|
||||
const { app } = buildApp({
|
||||
moduleRoutes: [
|
||||
Object.freeze({
|
||||
bodyMode: "buffered",
|
||||
handler: moduleHandler,
|
||||
maxBodyBytes: 1024,
|
||||
method: "GET",
|
||||
moduleId: "dashboard",
|
||||
path: "/",
|
||||
permission: modulePermission,
|
||||
}) as RegisteredModuleHttpRoute,
|
||||
],
|
||||
});
|
||||
|
||||
expect(await (await app.request("/api/v1/principal")).json()).toEqual({ kind: "anonymous" });
|
||||
expect(await (await app.request("/api/v1/health/live")).json()).toEqual({ status: "live" });
|
||||
expect(await (await app.request("/api/v1/modules/dashboard")).text()).toBe("module");
|
||||
await expectSanitizedNotFound(await app.request("/api/v1/unknown"), "/api/v1/unknown");
|
||||
// No display is published here, so the display host's script is absent rather than answered
|
||||
// by the Dashboard's.
|
||||
await expectSanitizedNotFound(
|
||||
await app.request(DISPLAY_HOST_SCRIPT_PATH),
|
||||
DISPLAY_HOST_SCRIPT_PATH,
|
||||
);
|
||||
expect(DASHBOARD_HOST_SCRIPT_PATH.startsWith("/assets/ekklesia-display/")).toBe(false);
|
||||
expect(await (await app.request(DASHBOARD_HOST_PAGE_PATH)).text()).toBe(PAGE_HTML);
|
||||
expect(moduleHandler).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("refuses at composition a snapshot whose validator does not match its bytes, or empty bytes", () => {
|
||||
const consistent = createDashboardHostAssets(PAGE_HTML, SCRIPT);
|
||||
|
||||
for (const assets of [
|
||||
{ ...consistent, html: `${PAGE_HTML}<!-- tampered -->` },
|
||||
{ ...consistent, scriptEtag: PAGE_ETAG },
|
||||
{ html: "", htmlEtag: sha256Etag(""), script: SCRIPT, scriptEtag: SCRIPT_ETAG },
|
||||
]) {
|
||||
expect(() => buildApp({ assets })).toThrow(DashboardHostConfigurationError);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -27,6 +27,8 @@ import { createApiApp } from "../src/app.js";
|
||||
import { DASHBOARD_NAVIGATION_PATH } from "../src/dashboard-routes.js";
|
||||
import { MODULE_UI_INVENTORY_PATH } from "../src/module-ui-inventory.js";
|
||||
import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
|
||||
import { testDashboardHostAssets } from "./support/dashboard-host-assets.js";
|
||||
import { readyHealth } from "./support/ready-health.js";
|
||||
import {
|
||||
createInMemoryModuleLifecycleStore,
|
||||
moduleLifecycleSnapshotFor,
|
||||
@@ -75,7 +77,7 @@ async function createModule(modulesDirectory: string, fixture: ModuleFixture): P
|
||||
...(fixture.withUi === false ? {} : { ui: "./dist/ui/index.js" }),
|
||||
},
|
||||
id: fixture.id,
|
||||
manifestVersion: 6,
|
||||
manifestVersion: 7,
|
||||
name: fixture.name,
|
||||
version: "1.0.0",
|
||||
}),
|
||||
@@ -139,6 +141,8 @@ async function install(options: InstallOptions = {}): Promise<{ app: Hono }> {
|
||||
const app = createApiApp({
|
||||
authorization: { evaluator, registry: composePlatformPermissionRegistry(modules), store },
|
||||
dashboard: createInMemoryDashboardStore(options.configuration ?? {}),
|
||||
dashboardHostAssets: testDashboardHostAssets(),
|
||||
health: readyHealth(),
|
||||
moduleRoutes: Object.freeze([]),
|
||||
moduleRealtime: EMPTY_MODULE_REALTIME_REGISTRY,
|
||||
moduleServices: Object.freeze({
|
||||
@@ -269,7 +273,9 @@ describe("Dashboard navigation — reading", () => {
|
||||
|
||||
// Presentation wraps the host-neutral descriptor; it never rewrites it.
|
||||
expect(livestream?.module).toEqual({
|
||||
contributions: [],
|
||||
api: { baseUrl: "/api/v1/modules/livestream" },
|
||||
display: null,
|
||||
id: "livestream",
|
||||
permissions: [],
|
||||
realtime: { baseUrl: "/api/v1/module-realtime/livestream" },
|
||||
|
||||
@@ -124,7 +124,7 @@ async function createDisplayModuleDirectory(): Promise<string> {
|
||||
server: "./dist/server/index.js",
|
||||
},
|
||||
id: "announcements",
|
||||
manifestVersion: 6,
|
||||
manifestVersion: 7,
|
||||
name: "Announcements fixture",
|
||||
permissions: [
|
||||
{
|
||||
|
||||
@@ -8,6 +8,7 @@ vi.mock("@ekklesia/shell/display-assets", () => ({
|
||||
displayHostAssetUrls: Object.freeze({
|
||||
html: "file:///surface/browser/display/index.html",
|
||||
script: "file:///surface/browser/display/app.js",
|
||||
serviceWorker: "file:///surface/browser/display/sw.js",
|
||||
}),
|
||||
}));
|
||||
|
||||
@@ -20,21 +21,28 @@ import {
|
||||
describe("display host asset loading", () => {
|
||||
beforeEach(() => readFileMock.mockReset());
|
||||
|
||||
it("reads only the two declared file URLs in order and freezes one consistent snapshot", async () => {
|
||||
it("reads only the three declared file URLs in order and freezes one consistent snapshot", async () => {
|
||||
const html = '<!doctype html><div id="ekklesia-display"></div>';
|
||||
const script = "export const displayHost = true;\n";
|
||||
readFileMock.mockResolvedValueOnce(html).mockResolvedValueOnce(script);
|
||||
const serviceWorker = "export const displayWorker = true;\n";
|
||||
readFileMock
|
||||
.mockResolvedValueOnce(html)
|
||||
.mockResolvedValueOnce(script)
|
||||
.mockResolvedValueOnce(serviceWorker);
|
||||
|
||||
const assets = await loadDisplayHostAssets();
|
||||
|
||||
expect(readFileMock.mock.calls).toEqual([
|
||||
[new URL("file:///surface/browser/display/index.html"), "utf8"],
|
||||
[new URL("file:///surface/browser/display/app.js"), "utf8"],
|
||||
[new URL("file:///surface/browser/display/sw.js"), "utf8"],
|
||||
]);
|
||||
expect(assets).toEqual({
|
||||
html,
|
||||
script,
|
||||
scriptEtag: `"${createHash("sha256").update(script).digest("base64url")}"`,
|
||||
serviceWorker,
|
||||
serviceWorkerEtag: `"${createHash("sha256").update(serviceWorker).digest("base64url")}"`,
|
||||
});
|
||||
expect(Object.isFrozen(assets)).toBe(true);
|
||||
});
|
||||
@@ -62,12 +70,15 @@ describe("display host asset loading", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects an empty page or script as an unavailable host build", () => {
|
||||
for (const [html, script] of [
|
||||
["", "export {};"],
|
||||
["<html></html>", " \n"],
|
||||
it("rejects an empty page, script, or service worker as an unavailable host build", () => {
|
||||
for (const [html, script, serviceWorker] of [
|
||||
["", "export {};", "export {};"],
|
||||
["<html></html>", " \n", "export {};"],
|
||||
["<html></html>", "export {};", " \n"],
|
||||
] as const) {
|
||||
expect(() => createDisplayHostAssets(html, script)).toThrow(DisplayHostAssetError);
|
||||
expect(() => createDisplayHostAssets(html, script, serviceWorker)).toThrow(
|
||||
DisplayHostAssetError,
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,373 @@
|
||||
import {
|
||||
ANONYMOUS_PRINCIPAL,
|
||||
type ModuleDatabase,
|
||||
type ModuleRouteHandler,
|
||||
type ModuleRuntimeServices,
|
||||
parsePermissionId,
|
||||
type RegisteredModuleHttpRoute,
|
||||
} from "@ekklesia/contracts";
|
||||
import {
|
||||
type AuthorizationEvaluator,
|
||||
type AuthorizationStore,
|
||||
createPermissionRegistry,
|
||||
} from "@ekklesia/core/authorization";
|
||||
import type { EkklesiaIdentityRuntime } from "@ekklesia/core/identity";
|
||||
import {
|
||||
createModuleUiPublicationRegistry,
|
||||
EMPTY_MODULE_REALTIME_REGISTRY,
|
||||
} from "@ekklesia/core/modules";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { createApiApp } from "../src/app.js";
|
||||
import {
|
||||
type ApiHealthOptions,
|
||||
HEALTH_LIVE_PATH,
|
||||
HEALTH_READY_PATH,
|
||||
READINESS_DATABASE_TIMEOUT_MS,
|
||||
} from "../src/health-routes.js";
|
||||
import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
|
||||
import { testDashboardHostAssets } from "./support/dashboard-host-assets.js";
|
||||
import { emptyModuleConfiguration } from "./support/in-memory-module-configuration.js";
|
||||
import { emptyModuleLifecycle } from "./support/in-memory-module-lifecycle.js";
|
||||
import { emptyModuleStorage } from "./support/in-memory-module-storage.js";
|
||||
|
||||
const trustedOrigin = "https://dashboard.example.test";
|
||||
const modulePermission = parsePermissionId("health.status.read");
|
||||
|
||||
/** What a real driver failure can carry, and what must therefore never reach a response. */
|
||||
const DRIVER_FAILURE_DETAIL =
|
||||
"connect ECONNREFUSED postgresql://ekklesia:[email protected]:5432/ekklesia SELECT 1";
|
||||
|
||||
function fakeRuntime(): EkklesiaIdentityRuntime {
|
||||
return {
|
||||
handleAuthRequest: vi.fn(),
|
||||
resolvePrincipal: vi.fn().mockResolvedValue(ANONYMOUS_PRINCIPAL),
|
||||
};
|
||||
}
|
||||
|
||||
function fakeStore(): AuthorizationStore {
|
||||
return {
|
||||
createGrant: vi.fn(),
|
||||
grantPlatformOwner: vi.fn(),
|
||||
hasActiveGrant: vi.fn().mockResolvedValue(false),
|
||||
isPlatformOwner: vi.fn().mockResolvedValue(false),
|
||||
listGrantsForUser: vi.fn().mockResolvedValue({ grants: [] }),
|
||||
revokeGrant: vi.fn(),
|
||||
revokePlatformOwner: vi.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
function fakeModuleServices(): ModuleRuntimeServices {
|
||||
return Object.freeze({ database: Object.freeze({}) as unknown as ModuleDatabase });
|
||||
}
|
||||
|
||||
function healthOptions(overrides: Partial<ApiHealthOptions> = {}) {
|
||||
return {
|
||||
checkDatabase: vi.fn<() => Promise<void>>().mockResolvedValue(undefined),
|
||||
isModuleStartupComplete: vi.fn<() => boolean>().mockReturnValue(true),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function buildApp(
|
||||
health: ApiHealthOptions,
|
||||
moduleRoutes: readonly RegisteredModuleHttpRoute[] = Object.freeze([]),
|
||||
) {
|
||||
const evaluator: AuthorizationEvaluator = {
|
||||
authorize: vi.fn().mockResolvedValue({ allowed: true }),
|
||||
};
|
||||
const runtime = fakeRuntime();
|
||||
const app = createApiApp({
|
||||
authorization: {
|
||||
evaluator,
|
||||
registry: createPermissionRegistry([
|
||||
{ baseline: "public", description: "Test permission.", id: modulePermission },
|
||||
]),
|
||||
store: fakeStore(),
|
||||
},
|
||||
moduleRoutes,
|
||||
moduleRealtime: EMPTY_MODULE_REALTIME_REGISTRY,
|
||||
moduleServices: fakeModuleServices(),
|
||||
moduleUi: createModuleUiPublicationRegistry([]),
|
||||
moduleLifecycle: emptyModuleLifecycle(),
|
||||
moduleConfiguration: emptyModuleConfiguration(),
|
||||
moduleStorage: emptyModuleStorage(),
|
||||
dashboard: createInMemoryDashboardStore(),
|
||||
dashboardHostAssets: testDashboardHostAssets(),
|
||||
health,
|
||||
runtime,
|
||||
trustedOrigins: [trustedOrigin],
|
||||
});
|
||||
return { app, evaluator, runtime };
|
||||
}
|
||||
|
||||
/** Resolves once every already-queued microtask has run, using the real `setImmediate` — the only
|
||||
* timer functions these tests fake are `setTimeout` and `clearTimeout`. */
|
||||
function drainMicrotasks(): Promise<void> {
|
||||
return new Promise((resolve) => setImmediate(resolve));
|
||||
}
|
||||
|
||||
/** Whether `promise` settled with no fake time advanced at all: "promptly", made exact. */
|
||||
async function settlesWithoutWaiting(promise: Response | Promise<Response>): Promise<boolean> {
|
||||
let settled = false;
|
||||
void Promise.resolve(promise).then(
|
||||
() => {
|
||||
settled = true;
|
||||
},
|
||||
() => {
|
||||
settled = true;
|
||||
},
|
||||
);
|
||||
await drainMicrotasks();
|
||||
return settled;
|
||||
}
|
||||
|
||||
async function expectNotReady(response: Response): Promise<void> {
|
||||
expect(response.status).toBe(503);
|
||||
expect(response.headers.get("cache-control")).toBe("no-store");
|
||||
expect(await response.json()).toEqual({ code: "NOT_READY" });
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe("GET /api/v1/health/live", () => {
|
||||
it("returns 200 with a fixed body and Cache-Control: no-store", async () => {
|
||||
const health = healthOptions();
|
||||
const { app } = buildApp(health);
|
||||
|
||||
const response = await app.request(HEALTH_LIVE_PATH);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get("cache-control")).toBe("no-store");
|
||||
expect(response.headers.get("content-type")).toMatch(/^application\/json/u);
|
||||
expect(await response.json()).toEqual({ status: "live" });
|
||||
});
|
||||
|
||||
it("does not consult the database or module startup, so it stays 200 while readiness is 503", async () => {
|
||||
const health = healthOptions({
|
||||
checkDatabase: vi.fn().mockRejectedValue(new Error(DRIVER_FAILURE_DETAIL)),
|
||||
isModuleStartupComplete: vi.fn().mockReturnValue(false),
|
||||
});
|
||||
const { app } = buildApp(health);
|
||||
|
||||
const response = await app.request(HEALTH_LIVE_PATH);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(health.checkDatabase).not.toHaveBeenCalled();
|
||||
expect(health.isModuleStartupComplete).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /api/v1/health/ready", () => {
|
||||
it("returns 200 with a fixed body when the database answers and module startup completed", async () => {
|
||||
const health = healthOptions();
|
||||
const { app } = buildApp(health);
|
||||
|
||||
const response = await app.request(HEALTH_READY_PATH);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get("cache-control")).toBe("no-store");
|
||||
expect(await response.json()).toEqual({ status: "ready" });
|
||||
expect(health.checkDatabase).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("returns a fixed 503 when the database query fails, carrying nothing from the failure", async () => {
|
||||
const health = healthOptions({
|
||||
checkDatabase: vi.fn().mockRejectedValue(new Error(DRIVER_FAILURE_DETAIL)),
|
||||
});
|
||||
const { app } = buildApp(health);
|
||||
|
||||
const response = await app.request(HEALTH_READY_PATH);
|
||||
const text = await response.clone().text();
|
||||
|
||||
await expectNotReady(response);
|
||||
for (const fragment of ["ECONNREFUSED", "hunter2", "db.internal", "SELECT", "Error"]) {
|
||||
expect(text).not.toContain(fragment);
|
||||
}
|
||||
});
|
||||
|
||||
it("treats a check that throws synchronously exactly like one that rejects", async () => {
|
||||
const health = healthOptions({
|
||||
checkDatabase: vi.fn(() => {
|
||||
throw new Error(DRIVER_FAILURE_DETAIL);
|
||||
}),
|
||||
});
|
||||
const { app } = buildApp(health);
|
||||
|
||||
await expectNotReady(await app.request(HEALTH_READY_PATH));
|
||||
});
|
||||
|
||||
it("returns 503 without touching the database before module startup completed", async () => {
|
||||
const health = healthOptions({ isModuleStartupComplete: vi.fn().mockReturnValue(false) });
|
||||
const { app } = buildApp(health);
|
||||
|
||||
await expectNotReady(await app.request(HEALTH_READY_PATH));
|
||||
expect(health.checkDatabase).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns 503 when shutdown began while the database query was in flight", async () => {
|
||||
let startupComplete = true;
|
||||
const health = healthOptions({
|
||||
checkDatabase: vi.fn(async () => {
|
||||
startupComplete = false;
|
||||
}),
|
||||
isModuleStartupComplete: () => startupComplete,
|
||||
});
|
||||
const { app } = buildApp(health);
|
||||
|
||||
await expectNotReady(await app.request(HEALTH_READY_PATH));
|
||||
});
|
||||
|
||||
it("starts a fresh check for each sequential probe rather than caching a success", async () => {
|
||||
const health = healthOptions();
|
||||
const { app } = buildApp(health);
|
||||
|
||||
await app.request(HEALTH_READY_PATH);
|
||||
await app.request(HEALTH_READY_PATH);
|
||||
|
||||
expect(health.checkDatabase).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("reports ready for a slow query that still answers inside the bound", async () => {
|
||||
vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
|
||||
let answer: () => void = () => undefined;
|
||||
const health = healthOptions({
|
||||
checkDatabase: vi.fn(
|
||||
() =>
|
||||
new Promise<void>((resolve) => {
|
||||
answer = resolve;
|
||||
}),
|
||||
),
|
||||
});
|
||||
const { app } = buildApp(health);
|
||||
|
||||
const pending = app.request(HEALTH_READY_PATH);
|
||||
await vi.advanceTimersByTimeAsync(READINESS_DATABASE_TIMEOUT_MS - 1);
|
||||
answer();
|
||||
|
||||
expect((await pending).status).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
describe("readiness never piles up database checks", () => {
|
||||
it("keeps exactly one check in flight for a query that never settles, while every probe answers 503 promptly", async () => {
|
||||
vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
|
||||
const health = healthOptions({
|
||||
checkDatabase: vi.fn(() => new Promise<void>(() => undefined)),
|
||||
});
|
||||
const { app } = buildApp(health);
|
||||
|
||||
// Probes arriving inside the first check's window share it and its single deadline.
|
||||
const first = app.request(HEALTH_READY_PATH);
|
||||
const concurrent = app.request(HEALTH_READY_PATH);
|
||||
await drainMicrotasks();
|
||||
expect(health.checkDatabase).toHaveBeenCalledTimes(1);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(READINESS_DATABASE_TIMEOUT_MS);
|
||||
await expectNotReady(await first);
|
||||
await expectNotReady(await concurrent);
|
||||
|
||||
// After the deadline the query is still unsettled: no new check starts, and each probe answers
|
||||
// without waiting out another bound.
|
||||
for (let probe = 0; probe < 20; probe += 1) {
|
||||
const pending = app.request(HEALTH_READY_PATH);
|
||||
expect(await settlesWithoutWaiting(pending)).toBe(true);
|
||||
await expectNotReady(await pending);
|
||||
}
|
||||
expect(health.checkDatabase).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("starts a new check only once the timed-out query has actually settled", async () => {
|
||||
vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
|
||||
let settleHungQuery: (error: Error) => void = () => undefined;
|
||||
const checkDatabase = vi
|
||||
.fn<() => Promise<void>>()
|
||||
.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise<void>((_resolve, reject) => {
|
||||
settleHungQuery = reject;
|
||||
}),
|
||||
)
|
||||
.mockResolvedValue(undefined);
|
||||
const { app } = buildApp(healthOptions({ checkDatabase }));
|
||||
|
||||
const first = app.request(HEALTH_READY_PATH);
|
||||
await vi.advanceTimersByTimeAsync(READINESS_DATABASE_TIMEOUT_MS);
|
||||
await expectNotReady(await first);
|
||||
await expectNotReady(await app.request(HEALTH_READY_PATH));
|
||||
expect(checkDatabase).toHaveBeenCalledTimes(1);
|
||||
|
||||
// The database cancels the abandoned statement; the next probe may finally ask again.
|
||||
settleHungQuery(new Error(DRIVER_FAILURE_DETAIL));
|
||||
await drainMicrotasks();
|
||||
|
||||
const recovered = await app.request(HEALTH_READY_PATH);
|
||||
expect(recovered.status).toBe(200);
|
||||
expect(checkDatabase).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("health route boundaries", () => {
|
||||
it.each([
|
||||
HEALTH_LIVE_PATH,
|
||||
HEALTH_READY_PATH,
|
||||
])("%s accepts GET only, answering every other method with a fixed 405 and never checking readiness", async (path) => {
|
||||
const health = healthOptions();
|
||||
const { app } = buildApp(health);
|
||||
|
||||
for (const method of ["HEAD", "POST", "PUT", "PATCH", "DELETE"]) {
|
||||
const response = await app.request(path, { method });
|
||||
expect(response.status, method).toBe(405);
|
||||
expect(response.headers.get("allow")).toBe("GET");
|
||||
expect(response.headers.get("cache-control")).toBe("no-store");
|
||||
expect(await response.text()).toBe("");
|
||||
}
|
||||
expect(health.checkDatabase).not.toHaveBeenCalled();
|
||||
expect(health.isModuleStartupComplete).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
HEALTH_LIVE_PATH,
|
||||
HEALTH_READY_PATH,
|
||||
])("%s is unauthenticated: it never resolves a principal or evaluates authorization, even with a cookie", async (path) => {
|
||||
const { app, evaluator, runtime } = buildApp(healthOptions());
|
||||
|
||||
const response = await app.request(path, {
|
||||
headers: { Cookie: "better-auth.session_token=anything", Origin: trustedOrigin },
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(runtime.resolvePrincipal).not.toHaveBeenCalled();
|
||||
expect(runtime.handleAuthRequest).not.toHaveBeenCalled();
|
||||
expect(evaluator.authorize).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('cannot be shadowed by a module, even one whose id is "health"', async () => {
|
||||
const moduleHandler = vi.fn<ModuleRouteHandler>(
|
||||
async () => new Response("module", { status: 200 }),
|
||||
);
|
||||
const moduleRoutes = ["/live", "/ready"].map(
|
||||
(path) =>
|
||||
Object.freeze({
|
||||
bodyMode: "buffered",
|
||||
handler: moduleHandler,
|
||||
maxBodyBytes: 1024,
|
||||
method: "GET",
|
||||
moduleId: "health",
|
||||
path,
|
||||
permission: modulePermission,
|
||||
}) as RegisteredModuleHttpRoute,
|
||||
);
|
||||
const { app } = buildApp(healthOptions(), moduleRoutes);
|
||||
|
||||
expect(await (await app.request(HEALTH_LIVE_PATH)).json()).toEqual({ status: "live" });
|
||||
expect(await (await app.request(HEALTH_READY_PATH)).json()).toEqual({ status: "ready" });
|
||||
expect(moduleHandler).not.toHaveBeenCalled();
|
||||
|
||||
// The module's own routes still live where every module route lives, and only there.
|
||||
const moduleResponse = await app.request("/api/v1/modules/health/ready");
|
||||
expect(await moduleResponse.text()).toBe("module");
|
||||
expect(moduleHandler).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -9,11 +9,21 @@ const drizzleAdapterMock = vi.hoisted(() => vi.fn());
|
||||
const serveMock = vi.hoisted(() => vi.fn());
|
||||
const processOnSpy = vi.hoisted(() => vi.fn());
|
||||
const loadDisplayHostAssetsMock = vi.hoisted(() => vi.fn());
|
||||
const loadDashboardHostAssetsMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("pg", () => ({ Pool: poolConstructor }));
|
||||
vi.mock("better-auth", () => ({ betterAuth: betterAuthMock }));
|
||||
vi.mock("better-auth/adapters/drizzle", () => ({ drizzleAdapter: drizzleAdapterMock }));
|
||||
vi.mock("@hono/node-server", () => ({ serve: serveMock }));
|
||||
// Every startup reads the built Dashboard (ADR 0044), and this file never depends on a built shell,
|
||||
// so the loader answers a small consistent snapshot unless a test makes it fail.
|
||||
vi.mock("../src/dashboard-host-assets.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../src/dashboard-host-assets.js")>();
|
||||
loadDashboardHostAssetsMock.mockImplementation(async () =>
|
||||
actual.createDashboardHostAssets("<!doctype html>", "export {};\n"),
|
||||
);
|
||||
return { ...actual, loadDashboardHostAssets: loadDashboardHostAssetsMock };
|
||||
});
|
||||
vi.mock("../src/display-host-assets.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../src/display-host-assets.js")>();
|
||||
return { ...actual, loadDisplayHostAssets: loadDisplayHostAssetsMock };
|
||||
@@ -35,7 +45,13 @@ import {
|
||||
import { PermissionRegistryError } from "@ekklesia/core/authorization";
|
||||
import { createApiApp } from "../src/app.js";
|
||||
import { createApiRuntimeConfig } from "../src/config.js";
|
||||
import { DashboardHostAssetError } from "../src/dashboard-host-assets.js";
|
||||
import { DisplayHostAssetError } from "../src/display-host-assets.js";
|
||||
import {
|
||||
HEALTH_LIVE_PATH,
|
||||
HEALTH_READY_PATH,
|
||||
READINESS_DATABASE_TIMEOUT_MS,
|
||||
} from "../src/health-routes.js";
|
||||
import { installShutdownSignalHandlers, startApi } from "../src/server.js";
|
||||
|
||||
function validConfig() {
|
||||
@@ -88,7 +104,7 @@ async function createFixtureModule(
|
||||
): Promise<void> {
|
||||
const moduleDirectory = path.join(modulesDirectory, id);
|
||||
const manifest = {
|
||||
manifestVersion: 6,
|
||||
manifestVersion: 7,
|
||||
id,
|
||||
name: `${id} module`,
|
||||
version: "0.1.0",
|
||||
@@ -254,12 +270,13 @@ function fakeDiscoveredModule(
|
||||
compatibility: { moduleSdk: "^0.1.0", platform: "^0.1.0" },
|
||||
entrypoints: { server: "./dist/does-not-exist/index.js" },
|
||||
id,
|
||||
manifestVersion: 6,
|
||||
manifestVersion: 7,
|
||||
name: `${id} module`,
|
||||
permissions,
|
||||
version: "0.1.0",
|
||||
});
|
||||
return {
|
||||
contributions: [],
|
||||
directory: `/modules/${id}`,
|
||||
entrypoints: { server: `/modules/${id}/dist/does-not-exist/index.js` },
|
||||
manifest,
|
||||
@@ -446,6 +463,36 @@ describe("Ekklesia API shutdown lifecycle", () => {
|
||||
expect(poolEnd).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("answers readiness with one bounded ping on the shared pool, and turns it 503 the moment shutdown begins", async () => {
|
||||
const query = emptyQuery();
|
||||
poolConstructor.mockImplementation(function fakePool() {
|
||||
return { end: vi.fn().mockResolvedValue(undefined), on: vi.fn(), query };
|
||||
});
|
||||
betterAuthMock.mockReturnValue({ api: { getSession: vi.fn() }, handler: vi.fn() });
|
||||
mockSuccessfulServe();
|
||||
|
||||
const running = await startApi(validConfig());
|
||||
query.mockClear();
|
||||
|
||||
const ready = await running.app.request(HEALTH_READY_PATH);
|
||||
expect(ready.status).toBe(200);
|
||||
expect(query).toHaveBeenCalledTimes(1);
|
||||
// The statement timeout is server-side and scoped to this one transaction, so PostgreSQL itself
|
||||
// cancels a stalled ping rather than the route merely abandoning it.
|
||||
expect(query).toHaveBeenCalledWith(
|
||||
`BEGIN READ ONLY; SET LOCAL statement_timeout = ${READINESS_DATABASE_TIMEOUT_MS}; SELECT 1; COMMIT`,
|
||||
);
|
||||
|
||||
const shutdown = running.shutdown();
|
||||
const draining = await running.app.request(HEALTH_READY_PATH);
|
||||
expect(draining.status).toBe(503);
|
||||
expect(await draining.json()).toEqual({ code: "NOT_READY" });
|
||||
// Draining is not dead: liveness still answers while in-flight requests finish.
|
||||
expect((await running.app.request(HEALTH_LIVE_PATH)).status).toBe(200);
|
||||
expect(query).toHaveBeenCalledTimes(1);
|
||||
await shutdown;
|
||||
});
|
||||
|
||||
it("keeps shutdown idempotent for calls made after the first has already resolved", async () => {
|
||||
const poolEnd = vi.fn().mockResolvedValue(undefined);
|
||||
poolConstructor.mockImplementation(function fakePool() {
|
||||
@@ -1021,6 +1068,30 @@ describe("Ekklesia API module runtime startup and shutdown", () => {
|
||||
expect(serveMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("fails on missing Dashboard assets before importing module code and still closes startup resources", async () => {
|
||||
const modulesDirectory = await createTemporaryModulesDirectory();
|
||||
const recorder = createEventRecorder();
|
||||
await createWorkingFixtureModule(modulesDirectory, "calendar", recorder);
|
||||
const { poolEnd } = mockPoolAndAuth();
|
||||
loadDashboardHostAssetsMock.mockRejectedValueOnce(new DashboardHostAssetError());
|
||||
|
||||
const failure = await startApi(validConfig(), {
|
||||
modulesDirectoryOverride: modulesDirectory,
|
||||
}).catch((error: unknown) => error);
|
||||
|
||||
expect(failure).toMatchObject({
|
||||
code: "DASHBOARD_HOST_ASSET_UNAVAILABLE",
|
||||
message: "The Dashboard host browser assets are unavailable.",
|
||||
name: "DashboardHostAssetError",
|
||||
});
|
||||
expect((failure as Error).stack).toBe(
|
||||
"DashboardHostAssetError: The Dashboard host browser assets are unavailable.",
|
||||
);
|
||||
expect(recorder.events).toEqual([]);
|
||||
expect(poolEnd).toHaveBeenCalledTimes(1);
|
||||
expect(serveMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not read display-host assets when the eligible set declares no display", async () => {
|
||||
const modulesDirectory = await createTemporaryModulesDirectory();
|
||||
const recorder = createEventRecorder();
|
||||
|
||||
@@ -35,6 +35,8 @@ import { CORE_MUTATION_MAX_BODY_BYTES } from "../src/body-limits.js";
|
||||
import { MODULE_CONFIGURATION_PATH } from "../src/module-configuration-routes.js";
|
||||
import { MODULE_LIFECYCLE_PATH } from "../src/module-lifecycle-routes.js";
|
||||
import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
|
||||
import { testDashboardHostAssets } from "./support/dashboard-host-assets.js";
|
||||
import { readyHealth } from "./support/ready-health.js";
|
||||
import {
|
||||
createFakeModuleSecretSource,
|
||||
createInMemoryModuleConfigurationStore,
|
||||
@@ -106,7 +108,7 @@ async function createModule(modulesDirectory: string, fixture: ModuleFixture): P
|
||||
configuration: fixture.configuration ?? [],
|
||||
entrypoints: { server: "./dist/server/index.js" },
|
||||
id: fixture.id,
|
||||
manifestVersion: 6,
|
||||
manifestVersion: 7,
|
||||
name: `${fixture.id} module`,
|
||||
version: "1.0.0",
|
||||
}),
|
||||
@@ -189,6 +191,8 @@ async function install(options: InstallOptions = {}): Promise<Installation> {
|
||||
store: authorizationStore,
|
||||
},
|
||||
dashboard: createInMemoryDashboardStore(),
|
||||
dashboardHostAssets: testDashboardHostAssets(),
|
||||
health: readyHealth(),
|
||||
moduleConfiguration: {
|
||||
bootSettings,
|
||||
discoveredModules: modules,
|
||||
|
||||
@@ -28,6 +28,7 @@ import { createApiApp } from "../src/app.js";
|
||||
import { createDisplayHostAssets, type DisplayHostAssets } from "../src/display-host-assets.js";
|
||||
import {
|
||||
DISPLAY_HOST_SCRIPT_PATH,
|
||||
DISPLAY_SERVICE_WORKER_PATH,
|
||||
ModuleDisplayConfigurationError,
|
||||
moduleDisplayDescriptorUrl,
|
||||
moduleDisplayPageUrl,
|
||||
@@ -37,6 +38,8 @@ import { moduleRealtimeBaseUrl } from "../src/module-realtime-routes.js";
|
||||
import { MODULE_UI_INVENTORY_PATH } from "../src/module-ui-inventory.js";
|
||||
import { moduleDisplayUiArtifactUrl } from "../src/module-ui-routes.js";
|
||||
import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
|
||||
import { testDashboardHostAssets } from "./support/dashboard-host-assets.js";
|
||||
import { readyHealth } from "./support/ready-health.js";
|
||||
import { moduleConfigurationOptionFor } from "./support/in-memory-module-configuration.js";
|
||||
import {
|
||||
createInMemoryModuleLifecycleStore,
|
||||
@@ -51,6 +54,7 @@ const displayPermission = "announcements.display.view";
|
||||
const displayHostAssets: DisplayHostAssets = createDisplayHostAssets(
|
||||
`<!doctype html><html><body><div id="ekklesia-display"></div><script type="module" src="${DISPLAY_HOST_SCRIPT_PATH}"></script></body></html>`,
|
||||
"globalThis.__ekklesiaDisplayHostLoaded = true;\n",
|
||||
"globalThis.__ekklesiaDisplayWorkerLoaded = true;\n",
|
||||
);
|
||||
|
||||
const temporaryDirectories: string[] = [];
|
||||
@@ -101,7 +105,7 @@ async function createModule(modulesDirectory: string, fixture: ModuleFixture = {
|
||||
: {}),
|
||||
},
|
||||
id,
|
||||
manifestVersion: 6,
|
||||
manifestVersion: 7,
|
||||
name: `${id} module`,
|
||||
permissions: fixture.display
|
||||
? [
|
||||
@@ -196,6 +200,8 @@ async function install(options: InstallOptions): Promise<Installation> {
|
||||
store: fakeAuthorizationStore(),
|
||||
},
|
||||
dashboard: createInMemoryDashboardStore(),
|
||||
dashboardHostAssets: testDashboardHostAssets(),
|
||||
health: readyHealth(),
|
||||
...(moduleUi.listDisplays().length > 0 && options.withAssets !== false
|
||||
? { displayHostAssets: options.assets ?? displayHostAssets }
|
||||
: {}),
|
||||
@@ -266,11 +272,13 @@ describe("module display routes — authorization and descriptor", () => {
|
||||
expect(await descriptor.json()).toEqual({
|
||||
viewer: { kind: "authenticated", userId: viewerId },
|
||||
module: {
|
||||
contributions: [],
|
||||
api: { baseUrl: moduleApiBaseUrl("announcements") },
|
||||
display: {
|
||||
url: moduleDisplayUiArtifactUrl({ moduleId: "announcements", version: "1.4.2" }),
|
||||
},
|
||||
id: "announcements",
|
||||
page: { url: moduleDisplayPageUrl("announcements") },
|
||||
permissions: [displayPermission],
|
||||
realtime: { baseUrl: moduleRealtimeBaseUrl("announcements") },
|
||||
version: "1.4.2",
|
||||
@@ -348,9 +356,14 @@ describe("module display routes — role and lifecycle separation", () => {
|
||||
expect((await app.request(moduleDisplayPageUrl("announcements"))).status).toBe(200);
|
||||
expect((await app.request(MODULE_UI_INVENTORY_PATH)).status).toBe(200);
|
||||
expect(
|
||||
((await (await app.request(MODULE_UI_INVENTORY_PATH)).json()) as { modules: unknown[] })
|
||||
.modules,
|
||||
).toHaveLength(1);
|
||||
(
|
||||
(await (await app.request(MODULE_UI_INVENTORY_PATH)).json()) as {
|
||||
modules: Array<{ display: { url: string } | null }>;
|
||||
}
|
||||
).modules,
|
||||
).toEqual([
|
||||
expect.objectContaining({ display: { url: moduleDisplayPageUrl("announcements") } }),
|
||||
]);
|
||||
});
|
||||
|
||||
it("has no display page, descriptor, or artifact when the module is outside the eligible set", async () => {
|
||||
@@ -476,7 +489,11 @@ describe("module display host asset publication", () => {
|
||||
});
|
||||
|
||||
it("snapshots injected assets and rejects a validator that does not describe its script", async () => {
|
||||
const source = createDisplayHostAssets("<html>display host</html>", "export const host = 1;\n");
|
||||
const source = createDisplayHostAssets(
|
||||
"<html>display host</html>",
|
||||
"export const host = 1;\n",
|
||||
"export const worker = 1;\n",
|
||||
);
|
||||
const mutable = { ...source };
|
||||
const { app } = await install({ assets: mutable, display: true });
|
||||
mutable.html = "<html>mutated secret</html>";
|
||||
@@ -497,4 +514,55 @@ describe("module display host asset publication", () => {
|
||||
|
||||
expect((await app.request(DISPLAY_HOST_SCRIPT_PATH)).status).toBe(404);
|
||||
});
|
||||
|
||||
it("serves the offline service worker from the path whose directory is its scope", async () => {
|
||||
const { app } = await install({ display: true });
|
||||
|
||||
const response = await app.request(DISPLAY_SERVICE_WORKER_PATH);
|
||||
|
||||
// `/display/sw.js`, so the worker's default scope is `/display/` — display pages and nothing
|
||||
// else. The Dashboard is never controlled by it (ADR 0043).
|
||||
expect(DISPLAY_SERVICE_WORKER_PATH).toBe("/display/sw.js");
|
||||
expect(response.status).toBe(200);
|
||||
expect(await response.text()).toBe(displayHostAssets.serviceWorker);
|
||||
expect(response.headers.get("content-type")).toBe("text/javascript; charset=utf-8");
|
||||
// `no-cache` with a strong validator, exactly like the host script: a browser re-checks the
|
||||
// worker on its own schedule, which is how a screen avoids being trapped on an obsolete one.
|
||||
expect(response.headers.get("cache-control")).toBe("no-cache");
|
||||
expect(response.headers.get("etag")).toBe(displayHostAssets.serviceWorkerEtag);
|
||||
expect(response.headers.get("x-content-type-options")).toBe("nosniff");
|
||||
});
|
||||
|
||||
it("revalidates the service worker with its own validator, never the host script's", async () => {
|
||||
const { app } = await install({ display: true });
|
||||
|
||||
const notModified = await app.request(DISPLAY_SERVICE_WORKER_PATH, {
|
||||
headers: { "if-none-match": displayHostAssets.serviceWorkerEtag },
|
||||
});
|
||||
expect(notModified.status).toBe(304);
|
||||
|
||||
const wrongValidator = await app.request(DISPLAY_SERVICE_WORKER_PATH, {
|
||||
headers: { "if-none-match": displayHostAssets.scriptEtag },
|
||||
});
|
||||
expect(wrongValidator.status).toBe(200);
|
||||
});
|
||||
|
||||
it("does not publish the service worker when no eligible display uses it", async () => {
|
||||
const { app } = await install({ ui: true, withAssets: false });
|
||||
|
||||
expect((await app.request(DISPLAY_SERVICE_WORKER_PATH)).status).toBe(404);
|
||||
});
|
||||
|
||||
it("cannot be reached through a neighbouring or request-selected path", async () => {
|
||||
const { app } = await install({ display: true });
|
||||
|
||||
for (const candidate of [
|
||||
`${DISPLAY_SERVICE_WORKER_PATH}.map`,
|
||||
"/display/sw.js/",
|
||||
"/sw.js",
|
||||
"/assets/ekklesia-display/sw.js",
|
||||
]) {
|
||||
expect((await app.request(candidate)).status, candidate).toBe(404);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -23,6 +23,8 @@ import {
|
||||
} from "@ekklesia/core/modules";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
|
||||
import { testDashboardHostAssets } from "./support/dashboard-host-assets.js";
|
||||
import { readyHealth } from "./support/ready-health.js";
|
||||
import { createApiApp } from "../src/app.js";
|
||||
import {
|
||||
canonicalizeVaryHeader,
|
||||
@@ -58,14 +60,17 @@ function fakeStore(): AuthorizationStore {
|
||||
};
|
||||
}
|
||||
|
||||
/** Defaults `maxBodyBytes` to a generous value for tests that are not themselves exercising the
|
||||
/** Defaults `maxBodyBytes` and `bodyMode` for tests that are not themselves exercising the
|
||||
* body-size contract, so the many pre-existing routing/authorization/CORS scenarios below do not
|
||||
* each need to restate an unrelated body-size choice; the dedicated "module route body limits"
|
||||
* suite further down overrides it explicitly to prove the contract itself. */
|
||||
* suite further down overrides them explicitly to prove the contract itself. */
|
||||
function moduleRoute(
|
||||
fields: Omit<RegisteredModuleHttpRoute, "maxBodyBytes"> & { maxBodyBytes?: number },
|
||||
fields: Omit<RegisteredModuleHttpRoute, "bodyMode" | "maxBodyBytes"> & {
|
||||
bodyMode?: RegisteredModuleHttpRoute["bodyMode"];
|
||||
maxBodyBytes?: number;
|
||||
},
|
||||
): RegisteredModuleHttpRoute {
|
||||
return Object.freeze({ maxBodyBytes: 1024, ...fields });
|
||||
return Object.freeze({ bodyMode: "buffered", maxBodyBytes: 1024, ...fields });
|
||||
}
|
||||
|
||||
/** A small typed fake `ModuleRuntimeServices` — these tests exercise route mounting,
|
||||
@@ -120,6 +125,8 @@ function buildModuleApp(options: {
|
||||
moduleConfiguration: emptyModuleConfiguration(),
|
||||
moduleStorage: emptyModuleStorage(),
|
||||
dashboard: createInMemoryDashboardStore(),
|
||||
dashboardHostAssets: testDashboardHostAssets(),
|
||||
health: readyHealth(),
|
||||
runtime,
|
||||
trustedOrigins: options.trustedOrigins ?? [trustedOrigin],
|
||||
});
|
||||
@@ -1634,3 +1641,375 @@ describe("module route overlap can never reach mounting", () => {
|
||||
expect(moduleRoutesOverlap(paramRoute, staticRoute)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Explicit, bounded streaming uploads (ADR 0042).
|
||||
*
|
||||
* The distinction being proven throughout is *when* the platform knows a body is too large, and what
|
||||
* it holds while finding out. In `buffered` mode the answer is "before the handler runs, holding at
|
||||
* most the route's own declared allowance". In `streamed` mode it is "as the handler consumes it,
|
||||
* holding nothing" — and the `413` still wins, even when the handler tried to answer anyway.
|
||||
*/
|
||||
describe("module route streamed body limits", () => {
|
||||
const oneMebibyte = 1024 * 1024;
|
||||
|
||||
/** A body of `size` bytes delivered in realistic chunks, with no declared `Content-Length` — the
|
||||
* chunked-transfer shape a browser `fetch` of a `ReadableStream` actually produces. */
|
||||
function chunkedBody(size: number, chunkSize = 64 * 1024): ReadableStream<Uint8Array> {
|
||||
let sent = 0;
|
||||
return new ReadableStream<Uint8Array>({
|
||||
pull(controller) {
|
||||
if (sent >= size) {
|
||||
controller.close();
|
||||
return;
|
||||
}
|
||||
const next = Math.min(chunkSize, size - sent);
|
||||
sent += next;
|
||||
controller.enqueue(new Uint8Array(next).fill(97));
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function streamingApp(options: {
|
||||
readonly handler: ModuleRouteHandler;
|
||||
readonly maxBodyBytes: number;
|
||||
readonly evaluator?: AuthorizationEvaluator;
|
||||
}) {
|
||||
return buildModuleApp({
|
||||
...(options.evaluator ? { evaluator: options.evaluator } : {}),
|
||||
registry: permissionRegistry([{ id: writePermission, baseline: "public" }]),
|
||||
routes: [
|
||||
moduleRoute({
|
||||
bodyMode: "streamed",
|
||||
handler: options.handler,
|
||||
maxBodyBytes: options.maxBodyBytes,
|
||||
method: "POST",
|
||||
moduleId: "calendar",
|
||||
path: "/uploads",
|
||||
permission: writePermission,
|
||||
}),
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
/** Counts what the handler actually received, which is the only honest way to tell "streamed" from
|
||||
* "buffered then handed over". */
|
||||
async function countingHandler(received: { bytes: number }): Promise<ModuleRouteHandler> {
|
||||
return async (request) => {
|
||||
const body = request.body;
|
||||
if (!body) {
|
||||
return Response.json({ bytes: 0 });
|
||||
}
|
||||
const reader = body.getReader();
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) {
|
||||
break;
|
||||
}
|
||||
received.bytes += value.byteLength;
|
||||
}
|
||||
return Response.json({ bytes: received.bytes });
|
||||
};
|
||||
}
|
||||
|
||||
it("accepts a body of exactly the declared limit", async () => {
|
||||
const received = { bytes: 0 };
|
||||
const { app } = streamingApp({
|
||||
handler: await countingHandler(received),
|
||||
maxBodyBytes: oneMebibyte,
|
||||
});
|
||||
|
||||
const response = await app.request("/api/v1/modules/calendar/uploads", {
|
||||
body: chunkedBody(oneMebibyte),
|
||||
duplex: "half",
|
||||
headers: { origin: trustedOrigin },
|
||||
method: "POST",
|
||||
} as RequestInit);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(await response.json()).toEqual({ bytes: oneMebibyte });
|
||||
});
|
||||
|
||||
it("rejects a declared Content-Length over the limit before the handler runs", async () => {
|
||||
const received = { bytes: 0 };
|
||||
const counting = await countingHandler(received);
|
||||
const handler = vi.fn<ModuleRouteHandler>(counting);
|
||||
const { app } = streamingApp({ handler, maxBodyBytes: 1024 });
|
||||
|
||||
const response = await app.request("/api/v1/modules/calendar/uploads", {
|
||||
body: chunkedBody(2048, 16),
|
||||
duplex: "half",
|
||||
headers: { "content-length": String(1025), origin: trustedOrigin },
|
||||
method: "POST",
|
||||
} as RequestInit);
|
||||
|
||||
expect(response.status).toBe(413);
|
||||
expect(await response.json()).toEqual({ code: "REQUEST_BODY_TOO_LARGE" });
|
||||
// The cheap rejection: the declared size alone decided it, so no handler work happened and no
|
||||
// byte of the upload reached module code.
|
||||
expect(handler).not.toHaveBeenCalled();
|
||||
expect(received.bytes).toBe(0);
|
||||
});
|
||||
|
||||
it("terminates a body whose actual size exceeds a smaller declared Content-Length", async () => {
|
||||
// A misleading `Content-Length` buys nothing: the running byte count is what bounds the request,
|
||||
// so a header claiming 8 bytes cannot smuggle a megabyte past the limit.
|
||||
const received = { bytes: 0 };
|
||||
const { app } = streamingApp({
|
||||
handler: await countingHandler(received),
|
||||
maxBodyBytes: 64,
|
||||
});
|
||||
|
||||
const response = await app.request("/api/v1/modules/calendar/uploads", {
|
||||
body: chunkedBody(4096, 32),
|
||||
duplex: "half",
|
||||
headers: { "content-length": "8", origin: trustedOrigin },
|
||||
method: "POST",
|
||||
} as RequestInit);
|
||||
|
||||
expect(response.status).toBe(413);
|
||||
expect(await response.json()).toEqual({ code: "REQUEST_BODY_TOO_LARGE" });
|
||||
// It stopped promptly rather than draining the whole upload first.
|
||||
expect(received.bytes).toBeLessThanOrEqual(96);
|
||||
});
|
||||
|
||||
it("answers 413 even when the handler swallowed the stream error and tried to respond", async () => {
|
||||
// A limit a handler can swallow is not a limit.
|
||||
const handler = vi.fn<ModuleRouteHandler>(async (request) => {
|
||||
try {
|
||||
await request.arrayBuffer();
|
||||
} catch {
|
||||
// Deliberately ignored, exactly as a careless module might.
|
||||
}
|
||||
return Response.json({ ok: "handler won" });
|
||||
});
|
||||
const { app } = streamingApp({ handler, maxBodyBytes: 64 });
|
||||
|
||||
const response = await app.request("/api/v1/modules/calendar/uploads", {
|
||||
body: chunkedBody(4096, 32),
|
||||
duplex: "half",
|
||||
headers: { origin: trustedOrigin },
|
||||
method: "POST",
|
||||
} as RequestInit);
|
||||
|
||||
expect(handler).toHaveBeenCalledTimes(1);
|
||||
expect(response.status).toBe(413);
|
||||
expect(await response.json()).toEqual({ code: "REQUEST_BODY_TOO_LARGE" });
|
||||
});
|
||||
|
||||
it("never buffers the body: the handler sees the first bytes before the last are sent", async () => {
|
||||
let released: (() => void) | undefined;
|
||||
const finished = new Promise<void>((resolve) => {
|
||||
released = resolve;
|
||||
});
|
||||
let firstChunkSeen = false;
|
||||
let secondChunkQueued = false;
|
||||
const body = new ReadableStream<Uint8Array>({
|
||||
async pull(controller) {
|
||||
if (!secondChunkQueued) {
|
||||
secondChunkQueued = true;
|
||||
controller.enqueue(new Uint8Array(16).fill(97));
|
||||
return;
|
||||
}
|
||||
// Only produce the rest once the handler has demonstrably consumed the first chunk. A
|
||||
// buffering platform would deadlock here rather than reach the assertion below.
|
||||
await finished;
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
const handler = vi.fn<ModuleRouteHandler>(async (request) => {
|
||||
const reader = (request.body as ReadableStream<Uint8Array>).getReader();
|
||||
const first = await reader.read();
|
||||
firstChunkSeen = !first.done;
|
||||
released?.();
|
||||
for (;;) {
|
||||
const next = await reader.read();
|
||||
if (next.done) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return Response.json({ ok: true });
|
||||
});
|
||||
const { app } = streamingApp({ handler, maxBodyBytes: oneMebibyte });
|
||||
|
||||
const response = await app.request("/api/v1/modules/calendar/uploads", {
|
||||
body,
|
||||
duplex: "half",
|
||||
headers: { origin: trustedOrigin },
|
||||
method: "POST",
|
||||
} as RequestInit);
|
||||
|
||||
expect(firstChunkSeen).toBe(true);
|
||||
expect(response.status).toBe(200);
|
||||
});
|
||||
|
||||
it("releases an aborted upload without taking the platform down", async () => {
|
||||
const aborted = new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
controller.enqueue(new Uint8Array(16).fill(97));
|
||||
controller.error(new Error("client went away"));
|
||||
},
|
||||
});
|
||||
const handler = vi.fn<ModuleRouteHandler>(async (request) => {
|
||||
await request.arrayBuffer();
|
||||
return Response.json({ ok: true });
|
||||
});
|
||||
const { app } = streamingApp({ handler, maxBodyBytes: oneMebibyte });
|
||||
|
||||
const response = await app.request("/api/v1/modules/calendar/uploads", {
|
||||
body: aborted,
|
||||
duplex: "half",
|
||||
headers: { origin: trustedOrigin },
|
||||
method: "POST",
|
||||
} as RequestInit);
|
||||
|
||||
// An abort is a failed request, never an unhandled rejection or a 413: nothing exceeded a limit.
|
||||
expect(response.status).toBe(500);
|
||||
expect(await response.json()).toEqual({ code: "MODULE_HANDLER_FAILED" });
|
||||
});
|
||||
|
||||
it("releases a body the handler never read", async () => {
|
||||
let cancelled = false;
|
||||
const body = new ReadableStream<Uint8Array>({
|
||||
cancel() {
|
||||
cancelled = true;
|
||||
},
|
||||
pull(controller) {
|
||||
controller.enqueue(new Uint8Array(16).fill(97));
|
||||
},
|
||||
});
|
||||
const { app } = streamingApp({
|
||||
handler: async () => Response.json({ ok: true }),
|
||||
maxBodyBytes: oneMebibyte,
|
||||
});
|
||||
|
||||
const response = await app.request("/api/v1/modules/calendar/uploads", {
|
||||
body,
|
||||
duplex: "half",
|
||||
headers: { origin: trustedOrigin },
|
||||
method: "POST",
|
||||
} as RequestInit);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(cancelled).toBe(true);
|
||||
});
|
||||
|
||||
it("refuses a declared oversize before authorization, and never streams for a denied caller", async () => {
|
||||
const evaluator: AuthorizationEvaluator = {
|
||||
authorize: vi.fn().mockResolvedValue({ allowed: false }),
|
||||
};
|
||||
const handler = vi.fn<ModuleRouteHandler>(async () => Response.json({ ok: true }));
|
||||
const { app } = buildModuleApp({
|
||||
evaluator,
|
||||
registry: permissionRegistry([{ id: writePermission, baseline: "restricted" }]),
|
||||
routes: [
|
||||
moduleRoute({
|
||||
bodyMode: "streamed",
|
||||
handler,
|
||||
maxBodyBytes: 64,
|
||||
method: "POST",
|
||||
moduleId: "calendar",
|
||||
path: "/uploads",
|
||||
permission: writePermission,
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
const declaredOversize = await app.request("/api/v1/modules/calendar/uploads", {
|
||||
body: "a".repeat(65),
|
||||
headers: { "content-length": "65", origin: trustedOrigin },
|
||||
method: "POST",
|
||||
});
|
||||
expect(declaredOversize.status).toBe(413);
|
||||
expect(evaluator.authorize).not.toHaveBeenCalled();
|
||||
|
||||
// A within-limit body from a denied caller is refused by authorization, and the handler — the
|
||||
// only thing that ever reads a byte in streamed mode — never runs.
|
||||
const response = await app.request("/api/v1/modules/calendar/uploads", {
|
||||
body: chunkedBody(32, 8),
|
||||
duplex: "half",
|
||||
headers: { origin: trustedOrigin },
|
||||
method: "POST",
|
||||
} as RequestInit);
|
||||
expect(response.status).toBe(403);
|
||||
expect(handler).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("still rejects an untrusted Origin before any body handling", async () => {
|
||||
const handler = vi.fn<ModuleRouteHandler>(async () => Response.json({ ok: true }));
|
||||
const { app } = streamingApp({ handler, maxBodyBytes: oneMebibyte });
|
||||
|
||||
const response = await app.request("/api/v1/modules/calendar/uploads", {
|
||||
body: chunkedBody(4096),
|
||||
duplex: "half",
|
||||
headers: { origin: untrustedOrigin },
|
||||
method: "POST",
|
||||
} as RequestInit);
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
expect(await response.json()).toEqual({ code: "UNTRUSTED_ORIGIN" });
|
||||
expect(handler).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps every rejection sanitized, naming no limit, size, or body content", async () => {
|
||||
const { app } = streamingApp({
|
||||
handler: async () => Response.json({ ok: true }),
|
||||
maxBodyBytes: 64,
|
||||
});
|
||||
|
||||
const response = await app.request("/api/v1/modules/calendar/uploads", {
|
||||
body: "secret-payload-".repeat(64),
|
||||
headers: { "content-length": String(15 * 64), origin: trustedOrigin },
|
||||
method: "POST",
|
||||
});
|
||||
const body = await response.text();
|
||||
|
||||
expect(response.status).toBe(413);
|
||||
expect(body).toBe('{"code":"REQUEST_BODY_TOO_LARGE"}');
|
||||
expect(body).not.toContain("secret-payload");
|
||||
expect(body).not.toContain("64");
|
||||
expect(response.headers.get("cache-control")).toBe("no-store");
|
||||
});
|
||||
|
||||
it("still materializes a buffered body before the handler runs", async () => {
|
||||
// The pre-existing promise, unchanged: a buffered handler may call `request.json()` and know the
|
||||
// body is already there and already within its declared allowance.
|
||||
const handler = vi.fn<ModuleRouteHandler>(async (request) => {
|
||||
const text = await request.text();
|
||||
return Response.json({ length: text.length });
|
||||
});
|
||||
const { app } = buildModuleApp({
|
||||
registry: permissionRegistry([{ id: writePermission, baseline: "public" }]),
|
||||
routes: [
|
||||
moduleRoute({
|
||||
bodyMode: "buffered",
|
||||
handler,
|
||||
maxBodyBytes: 128,
|
||||
method: "POST",
|
||||
moduleId: "calendar",
|
||||
path: "/events",
|
||||
permission: writePermission,
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
const withinLimit = await app.request("/api/v1/modules/calendar/events", {
|
||||
body: chunkedBody(128, 16),
|
||||
duplex: "half",
|
||||
headers: { origin: trustedOrigin },
|
||||
method: "POST",
|
||||
} as RequestInit);
|
||||
expect(withinLimit.status).toBe(200);
|
||||
expect(await withinLimit.json()).toEqual({ length: 128 });
|
||||
|
||||
const overLimit = await app.request("/api/v1/modules/calendar/events", {
|
||||
body: chunkedBody(129, 16),
|
||||
duplex: "half",
|
||||
headers: { origin: trustedOrigin },
|
||||
method: "POST",
|
||||
} as RequestInit);
|
||||
expect(overLimit.status).toBe(413);
|
||||
// A buffered route's handler never sees an over-limit request at all.
|
||||
expect(handler).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -33,6 +33,8 @@ import { CORE_MUTATION_MAX_BODY_BYTES } from "../src/body-limits.js";
|
||||
import { MODULE_LIFECYCLE_PATH } from "../src/module-lifecycle-routes.js";
|
||||
import { MODULE_UI_INVENTORY_PATH } from "../src/module-ui-inventory.js";
|
||||
import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
|
||||
import { testDashboardHostAssets } from "./support/dashboard-host-assets.js";
|
||||
import { readyHealth } from "./support/ready-health.js";
|
||||
import { createInMemoryModuleLifecycleStore } from "./support/in-memory-module-lifecycle.js";
|
||||
import { moduleConfigurationOptionFor } from "./support/in-memory-module-configuration.js";
|
||||
import { moduleStorageOptionFor } from "./support/in-memory-module-storage.js";
|
||||
@@ -77,7 +79,7 @@ async function createModule(modulesDirectory: string, fixture: ModuleFixture): P
|
||||
...(fixture.dependencies ? { dependencies: fixture.dependencies } : {}),
|
||||
entrypoints: { server: "./dist/server/index.js", ui: "./dist/ui/index.js" },
|
||||
id: fixture.id,
|
||||
manifestVersion: 6,
|
||||
manifestVersion: 7,
|
||||
name: `${fixture.id} module`,
|
||||
version: "1.0.0",
|
||||
}),
|
||||
@@ -171,6 +173,8 @@ async function install(options: InstallOptions = {}): Promise<Installation> {
|
||||
store: authorizationStore,
|
||||
},
|
||||
dashboard: createInMemoryDashboardStore(),
|
||||
dashboardHostAssets: testDashboardHostAssets(),
|
||||
health: readyHealth(),
|
||||
moduleConfiguration: moduleConfigurationOptionFor(modules),
|
||||
moduleStorage: moduleStorageOptionFor(modules),
|
||||
moduleLifecycle: {
|
||||
|
||||
@@ -188,7 +188,7 @@ async function createFixtureModule(control: FixtureControl): Promise<{
|
||||
compatibility: { moduleSdk: "^0.1.0", platform: "^0.1.0" },
|
||||
entrypoints: { server: "./dist/server/index.js" },
|
||||
id: "announcements",
|
||||
manifestVersion: 6,
|
||||
manifestVersion: 7,
|
||||
name: "Announcements fixture",
|
||||
permissions: [
|
||||
{
|
||||
|
||||
@@ -20,7 +20,13 @@ import {
|
||||
discoverModules,
|
||||
startDiscoveredModuleRuntime,
|
||||
} from "@ekklesia/core/modules";
|
||||
import { loadModuleUi } from "@ekklesia/module-ui-host";
|
||||
import {
|
||||
loadModuleUi,
|
||||
MODULE_UI_ISOLATION_ATTRIBUTE,
|
||||
MODULE_UI_ISOLATION_ROOT_ATTRIBUTE,
|
||||
} from "@ekklesia/module-ui-host";
|
||||
import { MODULE_UI_ABI_VERSION } from "@ekklesia/module-ui-sdk";
|
||||
import { Window } from "happy-dom";
|
||||
import {
|
||||
bootstrapDisplayHost,
|
||||
bootstrapHost,
|
||||
@@ -30,6 +36,8 @@ import {
|
||||
} from "@ekklesia/shell";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
|
||||
import { testDashboardHostAssets } from "./support/dashboard-host-assets.js";
|
||||
import { readyHealth } from "./support/ready-health.js";
|
||||
import { createApiApp } from "../src/app.js";
|
||||
import { createDisplayHostAssets } from "../src/display-host-assets.js";
|
||||
import {
|
||||
@@ -121,7 +129,7 @@ async function createFixtureModule(
|
||||
: {}),
|
||||
},
|
||||
id,
|
||||
manifestVersion: 6,
|
||||
manifestVersion: 7,
|
||||
name,
|
||||
permissions: [
|
||||
{
|
||||
@@ -165,6 +173,7 @@ async function createFixtureModule(
|
||||
await writeFile(
|
||||
path.join(moduleDirectory, "dist/ui/index.js"),
|
||||
`export default {
|
||||
abi: ${MODULE_UI_ABI_VERSION},
|
||||
id: ${JSON.stringify(id)},
|
||||
version: "0.1.0",
|
||||
async mount(container, context) {
|
||||
@@ -184,6 +193,7 @@ async function createFixtureModule(
|
||||
await writeFile(
|
||||
path.join(moduleDirectory, "dist/display/index.js"),
|
||||
`export default {
|
||||
abi: ${MODULE_UI_ABI_VERSION},
|
||||
id: ${JSON.stringify(id)},
|
||||
version: "0.1.0",
|
||||
async mount(container, context) {
|
||||
@@ -270,6 +280,7 @@ async function startHarness(options: HarnessOptions = {}): Promise<Harness> {
|
||||
displayHostAssets: createDisplayHostAssets(
|
||||
'<!doctype html><div id="ekklesia-display"></div><script type="module" src="/assets/ekklesia-display/app.js"></script>',
|
||||
"export const displayHost = true;\n",
|
||||
"export const displayWorker = true;\n",
|
||||
),
|
||||
}),
|
||||
moduleLifecycle: {
|
||||
@@ -279,6 +290,8 @@ async function startHarness(options: HarnessOptions = {}): Promise<Harness> {
|
||||
moduleConfiguration: moduleConfigurationOptionFor(modules),
|
||||
moduleStorage: moduleStorageOptionFor(modules),
|
||||
dashboard: createInMemoryDashboardStore(),
|
||||
dashboardHostAssets: testDashboardHostAssets(),
|
||||
health: readyHealth(),
|
||||
runtime,
|
||||
trustedOrigins: ["https://dashboard.example.test"],
|
||||
});
|
||||
@@ -310,10 +323,35 @@ async function publishedArtifactUrl(harness: Harness, sourceUrl: string): Promis
|
||||
return `data:text/javascript;base64,${bytes.toString("base64")}`;
|
||||
}
|
||||
|
||||
/** The loader hands the container straight to the module and never inspects it, so a plain sink is
|
||||
* enough and no DOM implementation is needed in this Node-environment suite. */
|
||||
function containerSink(): { textContent: string } {
|
||||
return { textContent: "" };
|
||||
/**
|
||||
* A real host-owned container, built from `happy-dom` **as a library** rather than as this suite's
|
||||
* test environment.
|
||||
*
|
||||
* Since ADR 0040 the generic loader builds an isolated Shadow DOM root inside whatever container it
|
||||
* is given, so a plain object sink is no longer a faithful stand-in — and the isolation is exactly
|
||||
* what an end-to-end proof should exercise rather than stub out. Installing happy-dom as the
|
||||
* *environment* is not an option here: it replaces the module runner's resolution, and this suite
|
||||
* dynamically imports real module server entrypoints from temporary directories, which then fail to
|
||||
* resolve. Constructing one `Window` gives the DOM these few assertions need and leaves every global
|
||||
* this server-side suite depends on exactly as Node provides it.
|
||||
*/
|
||||
function containerSink(): HostContainer {
|
||||
return new Window().document.createElement("div") as unknown as HostContainer;
|
||||
}
|
||||
|
||||
interface HostContainer {
|
||||
readonly children: { readonly length: number };
|
||||
querySelector(selectors: string): HostContainer | null;
|
||||
readonly shadowRoot: { querySelector(selectors: string): { textContent: string } | null } | null;
|
||||
}
|
||||
|
||||
/** What the mounted artifact rendered, read through its open isolated root. */
|
||||
function mountedText(container: HostContainer): string {
|
||||
const shadowHost = container.querySelector(`[${MODULE_UI_ISOLATION_ATTRIBUTE}]`);
|
||||
return (
|
||||
shadowHost?.shadowRoot?.querySelector(`[${MODULE_UI_ISOLATION_ROOT_ATTRIBUTE}]`)?.textContent ??
|
||||
""
|
||||
);
|
||||
}
|
||||
|
||||
type LoaderContainer = Parameters<typeof loadModuleUi>[0]["container"];
|
||||
@@ -340,6 +378,7 @@ describe("end to end: a discovered module's UI mounts in the Ekklesia host", ()
|
||||
// 2. The URLs came from the server, not from anything the browser composed.
|
||||
expect(descriptor).toEqual({
|
||||
apiBaseUrl: "/api/v1/modules/example",
|
||||
contributions: [],
|
||||
id: "example",
|
||||
permissions: ["example.records.read"],
|
||||
realtimeBaseUrl: "/api/v1/module-realtime/example",
|
||||
@@ -365,13 +404,13 @@ describe("end to end: a discovered module's UI mounts in the Ekklesia host", ()
|
||||
});
|
||||
|
||||
// 5. The module rendered, having reached its own backend through the host.
|
||||
expect(container.textContent).toBe("example:200:2");
|
||||
expect(mountedText(container)).toBe("example:200:2");
|
||||
// 6. It asked for "records"; the host resolved that onto Ekklesia's module mount prefix.
|
||||
expect(harness.moduleApiCalls).toEqual(["GET /api/v1/modules/example/records"]);
|
||||
|
||||
// 7. And it tears down cleanly.
|
||||
await mounted.unmount();
|
||||
expect(container.textContent).toBe("");
|
||||
expect(mountedText(container)).toBe("");
|
||||
expect(mounted.mounted).toBe(false);
|
||||
} finally {
|
||||
await harness.stop();
|
||||
@@ -403,6 +442,8 @@ describe("end to end: a discovered module's UI mounts in the Ekklesia host", ()
|
||||
}
|
||||
expect(bootstrap.descriptor).toEqual({
|
||||
apiBaseUrl: "/api/v1/modules/example",
|
||||
contributions: [],
|
||||
displayPageUrl: "/display/example",
|
||||
id: "example",
|
||||
permissions: ["example.records.read", "example.display.view"],
|
||||
realtimeBaseUrl: "/api/v1/module-realtime/example",
|
||||
@@ -418,8 +459,8 @@ describe("end to end: a discovered module's UI mounts in the Ekklesia host", ()
|
||||
);
|
||||
const artifactUrl = await publishedArtifactUrl(harness, sourceUrl);
|
||||
|
||||
// 4. The generic loader receives the unchanged five-member browser context and mounts the
|
||||
// display artifact independently of the Dashboard artifact.
|
||||
// 4. The generic loader receives the host-owned browser context, including the canonical
|
||||
// display page, and mounts the display artifact independently of the Dashboard artifact.
|
||||
const context = createModuleUiContext({
|
||||
config: harness.config,
|
||||
descriptor: bootstrap.descriptor,
|
||||
@@ -427,6 +468,8 @@ describe("end to end: a discovered module's UI mounts in the Ekklesia host", ()
|
||||
});
|
||||
expect(Object.keys(context).sort()).toEqual([
|
||||
"api",
|
||||
"contributions",
|
||||
"display",
|
||||
"moduleId",
|
||||
"permissions",
|
||||
"principal",
|
||||
@@ -443,10 +486,10 @@ describe("end to end: a discovered module's UI mounts in the Ekklesia host", ()
|
||||
},
|
||||
});
|
||||
|
||||
expect(container.textContent).toBe("example:display:200:2");
|
||||
expect(mountedText(container)).toBe("example:display:200:2");
|
||||
expect(harness.moduleApiCalls).toEqual(["GET /api/v1/modules/example/records"]);
|
||||
await mounted.unmount();
|
||||
expect(container.textContent).toBe("");
|
||||
expect(mountedText(container)).toBe("");
|
||||
expect(mounted.mounted).toBe(false);
|
||||
} finally {
|
||||
await harness.stop();
|
||||
@@ -514,7 +557,7 @@ describe("end to end: a discovered module's UI mounts in the Ekklesia host", ()
|
||||
});
|
||||
|
||||
// Hiding a control is a usability improvement: the module simply did not ask.
|
||||
expect(container.textContent).toBe("example:denied");
|
||||
expect(mountedText(container)).toBe("example:denied");
|
||||
expect(harness.moduleApiCalls).toEqual([]);
|
||||
} finally {
|
||||
await harness.stop();
|
||||
@@ -593,7 +636,7 @@ describe("end to end: a discovered module's UI mounts in the Ekklesia host", ()
|
||||
version: groupedEntry.descriptor.version,
|
||||
},
|
||||
});
|
||||
expect(container.textContent).toBe("example:200:2");
|
||||
expect(mountedText(container)).toBe("example:200:2");
|
||||
await mounted.unmount();
|
||||
|
||||
// Now move it somewhere else entirely.
|
||||
@@ -645,7 +688,7 @@ describe("end to end: a discovered module's UI mounts in the Ekklesia host", ()
|
||||
version: movedEntry.descriptor.version,
|
||||
},
|
||||
});
|
||||
expect(movedContainer.textContent).toBe("example:200:2");
|
||||
expect(mountedText(movedContainer)).toBe("example:200:2");
|
||||
expect(harness.moduleApiCalls.slice(callsBeforeRemount)).toEqual([
|
||||
"GET /api/v1/modules/example/records",
|
||||
]);
|
||||
|
||||
@@ -25,6 +25,8 @@ import {
|
||||
import type { Hono } from "hono";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
|
||||
import { testDashboardHostAssets } from "./support/dashboard-host-assets.js";
|
||||
import { readyHealth } from "./support/ready-health.js";
|
||||
import { createApiApp } from "../src/app.js";
|
||||
import { moduleApiBaseUrl } from "../src/module-http-routes.js";
|
||||
import { moduleRealtimeBaseUrl } from "../src/module-realtime-routes.js";
|
||||
@@ -59,6 +61,12 @@ interface ModuleFixture {
|
||||
readonly id: string;
|
||||
readonly baseline: "authenticated" | "public" | "restricted";
|
||||
}>;
|
||||
readonly capabilities?: readonly { readonly id: string; readonly version: string }[];
|
||||
readonly contributions?: readonly {
|
||||
readonly localName: string;
|
||||
readonly capability: string;
|
||||
readonly capabilityRange: string;
|
||||
}[];
|
||||
}
|
||||
|
||||
async function createModule(modulesDirectory: string, fixture: ModuleFixture): Promise<void> {
|
||||
@@ -82,8 +90,16 @@ async function createModule(modulesDirectory: string, fixture: ModuleFixture): P
|
||||
server: "./dist/server/index.js",
|
||||
...(fixture.withUi ? { ui: "./dist/ui/index.js" } : {}),
|
||||
},
|
||||
capabilities: fixture.capabilities ?? [],
|
||||
contributions: (fixture.contributions ?? []).map((contribution) => ({
|
||||
artifact: `./dist/contributions/${contribution.localName}/index.js`,
|
||||
capability: contribution.capability,
|
||||
capabilityRange: contribution.capabilityRange,
|
||||
id: `${fixture.id}.${contribution.localName}`,
|
||||
version: "1.0.0",
|
||||
})),
|
||||
id: fixture.id,
|
||||
manifestVersion: 6,
|
||||
manifestVersion: 7,
|
||||
name: `${fixture.id} module`,
|
||||
permissions: (fixture.permissions ?? []).map((permission) => ({
|
||||
...permission,
|
||||
@@ -103,6 +119,15 @@ async function createModule(modulesDirectory: string, fixture: ModuleFixture): P
|
||||
`export default { id: ${JSON.stringify(fixture.id)}, version: ${JSON.stringify(version)}, mount() {} };\n`,
|
||||
);
|
||||
}
|
||||
for (const contribution of fixture.contributions ?? []) {
|
||||
const contributionDirectory = path.join(
|
||||
moduleDirectory,
|
||||
"dist/contributions",
|
||||
contribution.localName,
|
||||
);
|
||||
await mkdir(contributionDirectory, { recursive: true });
|
||||
await writeFile(path.join(contributionDirectory, "index.js"), "export default {};\n");
|
||||
}
|
||||
}
|
||||
|
||||
interface InstallOptions {
|
||||
@@ -185,6 +210,8 @@ async function install(options: InstallOptions): Promise<Installation> {
|
||||
moduleConfiguration: moduleConfigurationOptionFor(modules),
|
||||
moduleStorage: moduleStorageOptionFor(modules),
|
||||
dashboard: createInMemoryDashboardStore(),
|
||||
dashboardHostAssets: testDashboardHostAssets(),
|
||||
health: readyHealth(),
|
||||
runtime,
|
||||
trustedOrigins: [trustedOrigin],
|
||||
});
|
||||
@@ -219,7 +246,9 @@ describe("module UI inventory — what it lists", () => {
|
||||
expect(response.status).toBe(200);
|
||||
expect(modules).toEqual([
|
||||
{
|
||||
contributions: [],
|
||||
api: { baseUrl: "/api/v1/modules/example" },
|
||||
display: null,
|
||||
id: "example",
|
||||
permissions: [],
|
||||
realtime: { baseUrl: "/api/v1/module-realtime/example" },
|
||||
@@ -315,6 +344,8 @@ describe("module UI inventory — what it lists", () => {
|
||||
// The exact key set, so a future field cannot be added without a deliberate decision here.
|
||||
expect(Object.keys(modules[0] ?? {}).sort()).toEqual([
|
||||
"api",
|
||||
"contributions",
|
||||
"display",
|
||||
"id",
|
||||
"permissions",
|
||||
"realtime",
|
||||
@@ -520,3 +551,84 @@ describe("module UI inventory — it is not a module management API", () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* The inventory is how a consuming module learns what was contributed to its own capabilities
|
||||
* (ADR 0041). It is the *only* way: a module never queries a registry and never builds a URL.
|
||||
*/
|
||||
describe("module UI inventory — contributions", () => {
|
||||
const signage = {
|
||||
capabilities: [{ id: "signage.slide-renderer", version: "1.2.0" }],
|
||||
id: "signage",
|
||||
permissions: [],
|
||||
withUi: true,
|
||||
};
|
||||
const announcements = (capabilityRange = "^1.0.0") => ({
|
||||
contributions: [
|
||||
{ capability: "signage.slide-renderer", capabilityRange, localName: "signage-slide" },
|
||||
],
|
||||
id: "announcements",
|
||||
permissions: [],
|
||||
withUi: true,
|
||||
});
|
||||
|
||||
it("gives the providing module the contribution and its server-produced URL", async () => {
|
||||
const { app } = await install({
|
||||
fixtures: [signage, announcements()],
|
||||
principal: signedIn(),
|
||||
});
|
||||
|
||||
const { modules } = await inventoryOf(app);
|
||||
const consumer = modules.find((module) => module.id === "signage");
|
||||
|
||||
expect(consumer?.contributions).toEqual([
|
||||
{
|
||||
capability: "signage.slide-renderer",
|
||||
id: "announcements.signage-slide",
|
||||
url: "/api/v1/module-ui/announcements/0.1.0/contributions/announcements.signage-slide",
|
||||
version: "1.0.0",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("gives the contributing module nothing: it provides no capability that was targeted", async () => {
|
||||
const { app } = await install({
|
||||
fixtures: [signage, announcements()],
|
||||
principal: signedIn(),
|
||||
});
|
||||
|
||||
const { modules } = await inventoryOf(app);
|
||||
|
||||
expect(modules.find((module) => module.id === "announcements")?.contributions).toEqual([]);
|
||||
});
|
||||
|
||||
it("withholds a contribution whose provider is outside its declared range", async () => {
|
||||
const { app } = await install({
|
||||
fixtures: [signage, announcements("^2.0.0")],
|
||||
principal: signedIn(),
|
||||
});
|
||||
|
||||
const { modules } = await inventoryOf(app);
|
||||
|
||||
expect(modules.find((module) => module.id === "signage")?.contributions).toEqual([]);
|
||||
});
|
||||
|
||||
it("carries no filesystem path or domain vocabulary in a contribution descriptor", async () => {
|
||||
const { app, modulesDirectory } = await install({
|
||||
fixtures: [signage, announcements()],
|
||||
principal: signedIn(),
|
||||
});
|
||||
|
||||
const { body, modules } = await inventoryOf(app);
|
||||
const consumer = modules.find((module) => module.id === "signage");
|
||||
const contributions = (consumer?.contributions ?? []) as ReadonlyArray<Record<string, unknown>>;
|
||||
|
||||
expect(body).not.toContain(modulesDirectory);
|
||||
expect(Object.keys(contributions[0] ?? {}).sort()).toEqual([
|
||||
"capability",
|
||||
"id",
|
||||
"url",
|
||||
"version",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -16,15 +16,20 @@ import {
|
||||
EMPTY_MODULE_REALTIME_REGISTRY,
|
||||
} from "@ekklesia/core/modules";
|
||||
import { loadModuleUi } from "@ekklesia/module-ui-host";
|
||||
import { MODULE_UI_ABI_VERSION, NO_MODULE_UI_CONTRIBUTIONS } from "@ekklesia/module-ui-sdk";
|
||||
import { Window } from "happy-dom";
|
||||
import type { Hono } from "hono";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
|
||||
import { testDashboardHostAssets } from "./support/dashboard-host-assets.js";
|
||||
import { readyHealth } from "./support/ready-health.js";
|
||||
import { createApiApp } from "../src/app.js";
|
||||
import { createDisplayHostAssets } from "../src/display-host-assets.js";
|
||||
import {
|
||||
MODULE_UI_ASSET_PREFIX,
|
||||
MODULE_UI_CACHE_CONTROL,
|
||||
MODULE_UI_CONTENT_TYPE,
|
||||
moduleContributionArtifactUrl,
|
||||
moduleDisplayUiArtifactUrl,
|
||||
moduleUiArtifactUrl,
|
||||
} from "../src/module-ui-routes.js";
|
||||
@@ -59,6 +64,7 @@ async function createModulesDirectory(): Promise<string> {
|
||||
* sequence whose exact publication and loader-compatibility can be asserted. */
|
||||
function artifactSource(id: string, version: string): string {
|
||||
return `export default {
|
||||
abi: ${MODULE_UI_ABI_VERSION},
|
||||
id: ${JSON.stringify(id)},
|
||||
version: ${JSON.stringify(version)},
|
||||
mount(container, context) {
|
||||
@@ -83,6 +89,15 @@ interface ModuleFixture {
|
||||
* chunk, an operator secret — so "only the declared artifact is reachable" is asserted against
|
||||
* files that genuinely exist on disk rather than against hypothetical ones. */
|
||||
readonly extraFiles?: Readonly<Record<string, string>>;
|
||||
/** Capabilities this module provides — what another module's contributions target. */
|
||||
readonly capabilities?: readonly { readonly id: string; readonly version: string }[];
|
||||
/** Browser renderers this module contributes to another module's capability. */
|
||||
readonly contributions?: readonly {
|
||||
readonly localName: string;
|
||||
readonly capability: string;
|
||||
readonly capabilityRange: string;
|
||||
readonly source: string;
|
||||
}[];
|
||||
}
|
||||
|
||||
async function createModule(modulesDirectory: string, fixture: ModuleFixture): Promise<void> {
|
||||
@@ -115,8 +130,16 @@ async function createModule(modulesDirectory: string, fixture: ModuleFixture): P
|
||||
},
|
||||
}),
|
||||
},
|
||||
capabilities: fixture.capabilities ?? [],
|
||||
contributions: (fixture.contributions ?? []).map((contribution) => ({
|
||||
artifact: `./dist/contributions/${contribution.localName}/index.js`,
|
||||
capability: contribution.capability,
|
||||
capabilityRange: contribution.capabilityRange,
|
||||
id: `${fixture.id}.${contribution.localName}`,
|
||||
version: "1.0.0",
|
||||
})),
|
||||
id: fixture.id,
|
||||
manifestVersion: 6,
|
||||
manifestVersion: 7,
|
||||
name: `${fixture.id} module`,
|
||||
permissions:
|
||||
fixture.display === undefined
|
||||
@@ -139,6 +162,16 @@ async function createModule(modulesDirectory: string, fixture: ModuleFixture): P
|
||||
await mkdir(path.join(moduleDirectory, "dist/ui"), { recursive: true });
|
||||
await writeFile(path.join(moduleDirectory, "dist/ui/index.js"), fixture.ui);
|
||||
}
|
||||
for (const contribution of fixture.contributions ?? []) {
|
||||
const contributionDirectory = path.join(
|
||||
moduleDirectory,
|
||||
"dist/contributions",
|
||||
contribution.localName,
|
||||
);
|
||||
await mkdir(contributionDirectory, { recursive: true });
|
||||
await writeFile(path.join(contributionDirectory, "index.js"), contribution.source);
|
||||
await writeFile(path.join(contributionDirectory, "index.js.map"), '{"sources":["secret"]}');
|
||||
}
|
||||
if (fixture.display !== undefined) {
|
||||
await mkdir(path.join(moduleDirectory, "dist/display"), { recursive: true });
|
||||
await writeFile(path.join(moduleDirectory, "dist/display/index.js"), fixture.display);
|
||||
@@ -209,6 +242,7 @@ async function install(
|
||||
displayHostAssets: createDisplayHostAssets(
|
||||
'<!doctype html><div id="display-root"></div>',
|
||||
"export {};\n",
|
||||
"export {};\n",
|
||||
),
|
||||
}
|
||||
: {}),
|
||||
@@ -223,6 +257,8 @@ async function install(
|
||||
moduleConfiguration: moduleConfigurationOptionFor(modules),
|
||||
moduleStorage: moduleStorageOptionFor(modules),
|
||||
dashboard: createInMemoryDashboardStore(),
|
||||
dashboardHostAssets: testDashboardHostAssets(),
|
||||
health: readyHealth(),
|
||||
runtime,
|
||||
trustedOrigins: [trustedOrigin],
|
||||
});
|
||||
@@ -862,6 +898,12 @@ describe("module UI artifact URL generation", () => {
|
||||
});
|
||||
});
|
||||
|
||||
interface HostContainer {
|
||||
readonly children: { readonly length: number };
|
||||
querySelector(selectors: string): HostContainer | null;
|
||||
readonly shadowRoot: { querySelector(selectors: string): { textContent: string } | null } | null;
|
||||
}
|
||||
|
||||
describe("module UI artifact publication — the generic loader consumes what is published", () => {
|
||||
/**
|
||||
* Node cannot `import()` an `http:` URL, so the two halves of the chain are proven separately and
|
||||
@@ -880,21 +922,36 @@ describe("module UI artifact publication — the generic loader consumes what is
|
||||
}
|
||||
|
||||
/**
|
||||
* The loader forwards the container untouched to the module's own `mount` and never inspects it,
|
||||
* so a plain sink is enough and no DOM implementation is required. The container's type is read
|
||||
* back off `loadModuleUi` itself rather than written as `Element`, because `apps/api` compiles
|
||||
* against Node libs only — naming a DOM type here would be the same browser/server confusion this
|
||||
* route exists to keep apart.
|
||||
* A real host-owned container, because since ADR 0040 the loader builds an isolated Shadow DOM
|
||||
* root inside whatever it is given. The container's type is read back off `loadModuleUi` itself
|
||||
* rather than written as `Element`, because `apps/api` compiles against Node libs only — naming a
|
||||
* DOM type here would be the same browser/server confusion this route exists to keep apart.
|
||||
*/
|
||||
type LoaderContainer = Parameters<typeof loadModuleUi>[0]["container"];
|
||||
|
||||
function containerSink(): { textContent: string } {
|
||||
return { textContent: "" };
|
||||
/**
|
||||
* `happy-dom` used as a library rather than as this suite's environment: the loader now builds a
|
||||
* real isolated root inside the container, and installing a DOM environment here would replace
|
||||
* the module runner's resolution for a server-side suite that dynamically imports real module
|
||||
* entrypoints.
|
||||
*/
|
||||
function containerSink(): HostContainer {
|
||||
return new Window().document.createElement("div") as unknown as HostContainer;
|
||||
}
|
||||
|
||||
/** What the mounted artifact rendered, read through its open isolated root. */
|
||||
function mountedText(container: HostContainer): string {
|
||||
const shadowHost = container.querySelector("[data-ekklesia-module-ui]");
|
||||
return (
|
||||
shadowHost?.shadowRoot?.querySelector("[data-ekklesia-module-ui-root]")?.textContent ?? ""
|
||||
);
|
||||
}
|
||||
|
||||
function hostContext() {
|
||||
return {
|
||||
api: { request: async () => new Response(null, { status: 200 }) },
|
||||
contributions: NO_MODULE_UI_CONTRIBUTIONS,
|
||||
display: undefined,
|
||||
moduleId: "announcements",
|
||||
permissions: { has: () => false },
|
||||
principal: { kind: "anonymous" as const },
|
||||
@@ -915,9 +972,9 @@ describe("module UI artifact publication — the generic loader consumes what is
|
||||
source: { moduleId: "announcements", url, version: "1.4.2" },
|
||||
});
|
||||
|
||||
expect(container.textContent).toBe("announcements:mounted");
|
||||
expect(mountedText(container)).toBe("announcements:mounted");
|
||||
await mounted.unmount();
|
||||
expect(container.textContent).toBe("");
|
||||
expect(container.children).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("still rejects the published artifact when the host expects a different identity", async () => {
|
||||
@@ -935,3 +992,109 @@ describe("module UI artifact publication — the generic loader consumes what is
|
||||
).rejects.toMatchObject({ code: "MODULE_UI_IDENTITY_MISMATCH" });
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Contribution artifact publication (ADR 0041), on exactly the same terms as every other declared
|
||||
* artifact: three lookup keys, one exact registry match, one already-validated canonical path.
|
||||
*/
|
||||
describe("module contribution artifact publication", () => {
|
||||
const signage = {
|
||||
capabilities: [{ id: "signage.slide-renderer", version: "1.2.0" }],
|
||||
id: "signage",
|
||||
};
|
||||
const contributionSource = "export default { contributed: true };\n";
|
||||
const announcements = (capabilityRange = "^1.0.0") => ({
|
||||
contributions: [
|
||||
{
|
||||
capability: "signage.slide-renderer",
|
||||
capabilityRange,
|
||||
localName: "signage-slide",
|
||||
source: contributionSource,
|
||||
},
|
||||
],
|
||||
id: "announcements",
|
||||
});
|
||||
const contributionUrl = moduleContributionArtifactUrl(
|
||||
{ moduleId: "announcements", version: "0.1.0" },
|
||||
"announcements.signage-slide",
|
||||
);
|
||||
|
||||
it("serves the declared contribution artifact byte for byte", async () => {
|
||||
const { app } = await install([signage, announcements()]);
|
||||
|
||||
const response = await app.request(contributionUrl);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get("content-type")).toBe(MODULE_UI_CONTENT_TYPE);
|
||||
expect(response.headers.get("cache-control")).toBe(MODULE_UI_CACHE_CONTROL);
|
||||
expect(response.headers.get("x-content-type-options")).toBe("nosniff");
|
||||
expect(await response.text()).toBe(contributionSource);
|
||||
});
|
||||
|
||||
it("builds a URL that cannot select a neighbouring file", () => {
|
||||
expect(contributionUrl).toBe(
|
||||
"/api/v1/module-ui/announcements/0.1.0/contributions/announcements.signage-slide",
|
||||
);
|
||||
// The contribution id is one segment's worth of text, always — a value that needed encoding
|
||||
// simply fails to resolve.
|
||||
expect(
|
||||
moduleContributionArtifactUrl(
|
||||
{ moduleId: "announcements", version: "0.1.0" },
|
||||
"../../../etc/passwd",
|
||||
),
|
||||
).toBe("/api/v1/module-ui/announcements/0.1.0/contributions/..%2F..%2F..%2Fetc%2Fpasswd");
|
||||
});
|
||||
|
||||
it.each([
|
||||
["an unknown contribution id", "announcements.nope"],
|
||||
["another module's contribution id", "signage.slide-renderer"],
|
||||
["a path traversal", "../../../etc/passwd"],
|
||||
["the artifact's source map", "announcements.signage-slide.map"],
|
||||
])("refuses %s with the same indistinguishable 404", async (_label, contributionId) => {
|
||||
const { app } = await install([signage, announcements()]);
|
||||
|
||||
const response = await app.request(
|
||||
moduleContributionArtifactUrl(
|
||||
{ moduleId: "announcements", version: "0.1.0" },
|
||||
contributionId,
|
||||
),
|
||||
);
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(await response.json()).toEqual({ code: "MODULE_UI_NOT_PUBLISHED" });
|
||||
});
|
||||
|
||||
it("refuses a version the platform does not have installed", async () => {
|
||||
const { app } = await install([signage, announcements()]);
|
||||
|
||||
const response = await app.request(
|
||||
moduleContributionArtifactUrl(
|
||||
{ moduleId: "announcements", version: "9.9.9" },
|
||||
"announcements.signage-slide",
|
||||
),
|
||||
);
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
});
|
||||
|
||||
it("publishes nothing when no installed module provides the target capability", async () => {
|
||||
const { app } = await install([announcements()]);
|
||||
|
||||
expect((await app.request(contributionUrl)).status).toBe(404);
|
||||
});
|
||||
|
||||
it("publishes nothing when the provider is outside the declared compatible range", async () => {
|
||||
const { app } = await install([signage, announcements("^2.0.0")]);
|
||||
|
||||
expect((await app.request(contributionUrl)).status).toBe(404);
|
||||
});
|
||||
|
||||
it("keeps the sibling source map and the module's own directory unreachable", async () => {
|
||||
const { app } = await install([signage, announcements()]);
|
||||
|
||||
for (const suffix of [".map", "/index.js.map", "/../ui/index.js"]) {
|
||||
const response = await app.request(`${contributionUrl}${suffix}`);
|
||||
expect(response.status).toBe(404);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import {
|
||||
createDashboardHostAssets,
|
||||
type DashboardHostAssets,
|
||||
} from "../../src/dashboard-host-assets.js";
|
||||
|
||||
/** A small, internally consistent Dashboard snapshot for compositions whose tests do not exercise
|
||||
* the Dashboard host. Its own behavior is tested in `../dashboard-host-routes.test.ts`. */
|
||||
export function testDashboardHostAssets(): DashboardHostAssets {
|
||||
return createDashboardHostAssets(
|
||||
'<!doctype html><div id="ekklesia-host"></div><script src="/assets/ekklesia-dashboard/app.js" type="module"></script>',
|
||||
"export const dashboardHost = true;\n",
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import type { ApiHealthOptions } from "../../src/health-routes.js";
|
||||
|
||||
/** Readiness inputs for a composition whose tests do not exercise health: the database answers and
|
||||
* module startup has completed. Health behavior itself is tested in `../health-routes.test.ts`. */
|
||||
export function readyHealth(): ApiHealthOptions {
|
||||
return {
|
||||
checkDatabase: async () => undefined,
|
||||
isModuleStartupComplete: () => true,
|
||||
};
|
||||
}
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@ekklesia/core",
|
||||
"version": "0.1.0",
|
||||
"version": "0.1.2",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"files": [
|
||||
|
||||
@@ -3,6 +3,7 @@ import path from "node:path";
|
||||
import { PermissionRegistryError } from "../authorization/index.js";
|
||||
import {
|
||||
composePlatformPermissionRegistry,
|
||||
createModuleUiPublicationRegistry,
|
||||
discoverModuleSchemas,
|
||||
ModuleDiscoveryError,
|
||||
ModulePermissionCompositionError,
|
||||
@@ -45,11 +46,32 @@ async function main(): Promise<void> {
|
||||
return;
|
||||
}
|
||||
|
||||
// Built from the same registry the API publishes from, so this command can never disagree with
|
||||
// what a running platform would actually serve. A contribution whose target capability nobody
|
||||
// provides — or whose provider moved outside its declared range — is otherwise silent: the
|
||||
// manifest is valid, the artifact exists, and nothing renders (ADR 0041).
|
||||
const publication = createModuleUiPublicationRegistry(modules);
|
||||
const compatibleContributions = new Set(
|
||||
publication
|
||||
.listContributions()
|
||||
.map((contribution) => `${contribution.moduleId}\u0000${contribution.contributionId}`),
|
||||
);
|
||||
|
||||
console.log(
|
||||
`Module validation passed (${modules.length} modules, ${schemaAggregate.tables.length} tables):`,
|
||||
);
|
||||
for (const discoveredModule of modules) {
|
||||
console.log(`- ${discoveredModule.manifest.id}@${discoveredModule.manifest.version}`);
|
||||
for (const contribution of discoveredModule.contributions) {
|
||||
const resolved = compatibleContributions.has(
|
||||
`${discoveredModule.manifest.id}\u0000${contribution.id}`,
|
||||
);
|
||||
console.log(
|
||||
` contributes ${contribution.id}@${contribution.version} to ${contribution.capability} ${contribution.capabilityRange} — ${
|
||||
resolved ? "compatible provider installed" : "no compatible provider installed"
|
||||
}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -57,6 +57,7 @@ import {
|
||||
createModuleConfigurationStore,
|
||||
createModuleLifecycleDatabase,
|
||||
createModuleLifecycleStore,
|
||||
createModuleUiPublicationRegistry,
|
||||
type DiscoveredModule,
|
||||
discoverModuleSchemas,
|
||||
discoverModules,
|
||||
@@ -316,7 +317,26 @@ function publishReport(checks: readonly DoctorCheck[]): DoctorReport {
|
||||
return Object.freeze({ checks: frozenChecks, result: deriveDoctorResult(frozenChecks) });
|
||||
}
|
||||
|
||||
/**
|
||||
* One line per installed module, plus — for a module that declares browser contributions (ADR 0041)
|
||||
* — how many of them have a compatible provider among the installed set.
|
||||
*
|
||||
* Worth reporting because the failure it names is otherwise silent: a contribution whose target
|
||||
* capability nobody provides, or whose provider moved outside its declared range, is simply not
|
||||
* published, and an operator sees a screen that renders nothing with no error anywhere. The counts
|
||||
* are derived from the same publication registry the API serves from, so this can never disagree
|
||||
* with what the running platform would actually publish.
|
||||
*
|
||||
* Static only, like every other Doctor check: it reports what the *manifests* support and never
|
||||
* claims a module is running. Enablement and dependency blocking are the lifecycle check's job.
|
||||
*/
|
||||
function safeModuleDetails(modules: readonly DiscoveredModule[]): readonly string[] {
|
||||
const publication = createModuleUiPublicationRegistry(modules);
|
||||
const compatible = new Set(
|
||||
publication
|
||||
.listContributions()
|
||||
.map((contribution) => `${contribution.moduleId}\u0000${contribution.contributionId}`),
|
||||
);
|
||||
return modules.map((module) => {
|
||||
const id = module.manifest.id;
|
||||
const version = module.manifest.version;
|
||||
@@ -326,7 +346,16 @@ function safeModuleDetails(modules: readonly DiscoveredModule[]): readonly strin
|
||||
) {
|
||||
throw new TypeError("Unsafe module diagnostic value.");
|
||||
}
|
||||
return `${id} ${version}`;
|
||||
const declared = module.contributions.length;
|
||||
if (declared === 0) {
|
||||
return `${id} ${version}`;
|
||||
}
|
||||
const resolved = module.contributions.filter((contribution) =>
|
||||
compatible.has(`${id}\u0000${contribution.id}`),
|
||||
).length;
|
||||
return `${id} ${version} · ${declared} browser contribution${
|
||||
declared === 1 ? "" : "s"
|
||||
} (${resolved} with a compatible provider)`;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -728,6 +757,35 @@ function identitySchemaSummary(): string {
|
||||
return `Core identity schema defines ${tableNames.length} ${plural(tableNames.length, "table")} (${tableNames.join(", ")}).`;
|
||||
}
|
||||
|
||||
/**
|
||||
* A heuristic for "same site" between two canonical origins, for a warning only (ADR 0045).
|
||||
*
|
||||
* The same hostname on another port is the same site. Otherwise the two hostnames must be siblings:
|
||||
* identical after their first label, with that parent itself holding at least one dot
|
||||
* (`dash.example.org` and `api.example.org`). Without the Public Suffix List this can be wrong in
|
||||
* both directions for unusual names, which is why its result is a warning and never a failure.
|
||||
*/
|
||||
function plausiblySameSite(dashboardOrigin: string, apiOrigin: string): boolean {
|
||||
const dashboard = new URL(dashboardOrigin).hostname;
|
||||
const api = new URL(apiOrigin).hostname;
|
||||
if (dashboard === api) {
|
||||
return true;
|
||||
}
|
||||
// An IP address is a site of its own; two different ones never share one.
|
||||
const isIpAddress = (hostname: string): boolean =>
|
||||
hostname.startsWith("[") || /^[0-9]+(\.[0-9]+){3}$/u.test(hostname);
|
||||
if (isIpAddress(dashboard) || isIpAddress(api)) {
|
||||
return false;
|
||||
}
|
||||
const parent = (hostname: string): string => hostname.slice(hostname.indexOf(".") + 1);
|
||||
return (
|
||||
dashboard.includes(".") &&
|
||||
api.includes(".") &&
|
||||
parent(dashboard) === parent(api) &&
|
||||
parent(dashboard).includes(".")
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure configuration validation only: never constructs Better Auth, never opens a database
|
||||
* connection, and never prints the configured secret, base URL, or trusted origins. Missing
|
||||
@@ -740,6 +798,7 @@ function identityConfigurationCheck(
|
||||
const hasAnyValue =
|
||||
input?.secret !== undefined ||
|
||||
input?.baseUrl !== undefined ||
|
||||
(input?.dashboardOrigin ?? "") !== "" ||
|
||||
input?.publicSignupEnabled !== undefined ||
|
||||
(input?.trustedOrigins?.length ?? 0) > 0;
|
||||
|
||||
@@ -754,17 +813,43 @@ function identityConfigurationCheck(
|
||||
const config = createIdentityConfig(
|
||||
{
|
||||
baseUrl: input?.baseUrl,
|
||||
// Validated exactly as the API validates it at startup, so a Dashboard origin the API would
|
||||
// refuse (untrusted, insecure, malformed) fails here first.
|
||||
dashboardOrigin: input?.dashboardOrigin,
|
||||
publicSignupEnabled: input?.publicSignupEnabled,
|
||||
secret: input?.secret,
|
||||
trustedOrigins: input?.trustedOrigins,
|
||||
},
|
||||
{ environment: input?.environment ?? "production" },
|
||||
);
|
||||
const signupDetail = config.publicSignupEnabled
|
||||
? "Public signup is explicitly ENABLED."
|
||||
: "Public signup is disabled (safe default).";
|
||||
if (config.dashboardOrigin === config.baseUrl) {
|
||||
return freezeCheck("identity.configuration", "pass", "Identity configuration valid", [
|
||||
schemaDetail,
|
||||
signupDetail,
|
||||
"Topology: same-origin (the Dashboard is served from the API's origin).",
|
||||
]);
|
||||
}
|
||||
const topologyDetail = "Topology: split (the Dashboard and the API are on separate origins).";
|
||||
if (!plausiblySameSite(config.dashboardOrigin, config.baseUrl)) {
|
||||
return freezeCheck(
|
||||
"identity.configuration",
|
||||
"warning",
|
||||
"Identity configuration valid; split origins may not share a site",
|
||||
[
|
||||
schemaDetail,
|
||||
signupDetail,
|
||||
topologyDetail,
|
||||
"The Dashboard and API hostnames are not siblings under one parent domain. Browsers send the session cookie across split origins only within one registrable domain (ADR 0045); Doctor cannot check the Public Suffix List, so confirm it.",
|
||||
],
|
||||
);
|
||||
}
|
||||
return freezeCheck("identity.configuration", "pass", "Identity configuration valid", [
|
||||
schemaDetail,
|
||||
config.publicSignupEnabled
|
||||
? "Public signup is explicitly ENABLED."
|
||||
: "Public signup is disabled (safe default).",
|
||||
signupDetail,
|
||||
topologyDetail,
|
||||
]);
|
||||
} catch (error) {
|
||||
const code =
|
||||
@@ -820,17 +905,25 @@ function databaseTransportCheck(
|
||||
}
|
||||
}
|
||||
|
||||
/** Doctor reads only its own process's environment, so it says so: run as the reference
|
||||
* deployment's `migrate`, it is given the API's values by the proxy overlay, but run any other way
|
||||
* it may be checking a configuration the API is not running. */
|
||||
const CLIENT_ADDRESS_SCOPE =
|
||||
"Checked: the environment Doctor runs with. In the reference deployment `migrate` is given the API's client-address values; a running API's own value is shown by `docker compose exec api printenv EKKLESIA_CLIENT_ADDRESS_MODE`.";
|
||||
|
||||
function clientAddressCheck(input: ClientAddressConfigInput | undefined): DoctorCheck {
|
||||
try {
|
||||
const config = createClientAddressConfig(input, TRUSTED_CLIENT_ADDRESS_HEADER);
|
||||
if (config.mode === "direct") {
|
||||
return freezeCheck("identity.client-address", "pass", "Direct client-address mode", [
|
||||
"Trusts only the actual immediate socket peer for rate limiting; no forwarded header is trusted.",
|
||||
CLIENT_ADDRESS_SCOPE,
|
||||
]);
|
||||
}
|
||||
return freezeCheck("identity.client-address", "pass", "Trusted-proxy client-address mode", [
|
||||
`Trusts header "${config.header}" only from a request whose immediate socket peer matches one of ${config.trustedProxies.length} configured trusted ${plural(config.trustedProxies.length, "proxy")}.`,
|
||||
"The reverse proxy must overwrite (never append to) this header, and the origin must not be reachable except through it.",
|
||||
CLIENT_ADDRESS_SCOPE,
|
||||
]);
|
||||
} catch (error) {
|
||||
const code =
|
||||
@@ -841,7 +934,7 @@ function clientAddressCheck(input: ClientAddressConfigInput | undefined): Doctor
|
||||
"identity.client-address",
|
||||
"failure",
|
||||
"Client-address configuration invalid",
|
||||
[`[${code}] Configured client-address values failed validation.`],
|
||||
[`[${code}] Configured client-address values failed validation.`, CLIENT_ADDRESS_SCOPE],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,6 +57,19 @@ export interface ModuleDiscoveryIssue {
|
||||
readonly path?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* One declared browser contribution, with its artifact resolved and validated exactly like every
|
||||
* other compiled entrypoint (ADR 0041). Discovery never imports or evaluates it, and never learns
|
||||
* what it renders.
|
||||
*/
|
||||
export interface ResolvedModuleContribution {
|
||||
readonly id: string;
|
||||
readonly version: string;
|
||||
readonly capability: string;
|
||||
readonly capabilityRange: string;
|
||||
readonly artifactPath: string;
|
||||
}
|
||||
|
||||
export interface ResolvedModuleEntrypoints {
|
||||
readonly server: string;
|
||||
readonly schema?: string;
|
||||
@@ -92,6 +105,9 @@ export type ReadonlyModuleManifest = DeepReadonly<ModuleManifest>;
|
||||
|
||||
export interface DiscoveredModule {
|
||||
readonly directory: string;
|
||||
/** Declared browser contributions, in manifest order, each resolved to a canonical artifact path
|
||||
* inside this module package. Empty for a module that contributes nothing. */
|
||||
readonly contributions: readonly ResolvedModuleContribution[];
|
||||
readonly entrypoints: ResolvedModuleEntrypoints;
|
||||
readonly manifest: ReadonlyModuleManifest;
|
||||
readonly manifestPath: string;
|
||||
@@ -110,6 +126,7 @@ interface ModulePackageData {
|
||||
}
|
||||
|
||||
interface ModuleCandidate {
|
||||
contributions: readonly ResolvedModuleContribution[];
|
||||
directory: string;
|
||||
directoryName: string;
|
||||
entrypoints: ResolvedModuleEntrypoints;
|
||||
@@ -257,13 +274,19 @@ function validatePackageData(
|
||||
return { name, version };
|
||||
}
|
||||
|
||||
interface ResolvedModuleArtifacts {
|
||||
readonly contributions: readonly ResolvedModuleContribution[];
|
||||
readonly entrypoints: ResolvedModuleEntrypoints;
|
||||
}
|
||||
|
||||
async function resolveEntrypoints(
|
||||
moduleDirectory: string,
|
||||
directoryName: string,
|
||||
manifest: ModuleManifest,
|
||||
issues: ModuleDiscoveryIssue[],
|
||||
): Promise<ResolvedModuleEntrypoints | undefined> {
|
||||
): Promise<ResolvedModuleArtifacts | undefined> {
|
||||
const resolved: Partial<Record<keyof ModuleManifest["entrypoints"], string>> = {};
|
||||
const resolvedContributions = new Map<string, string>();
|
||||
const canonicalModuleDirectory = await realpath(moduleDirectory);
|
||||
const { display, ...ordinaryEntrypoints } = manifest.entrypoints;
|
||||
const ordinaryConfiguredEntrypoints = Object.entries(ordinaryEntrypoints)
|
||||
@@ -273,6 +296,15 @@ async function resolveEntrypoints(
|
||||
issuePath: `entrypoints.${entrypointName}`,
|
||||
relativeEntrypoint,
|
||||
}));
|
||||
// A contribution artifact is resolved through the identical loop as every other compiled
|
||||
// entrypoint, deliberately: the path, symlink, containment, extension, and file-kind protections
|
||||
// are one implementation, so a contribution can never quietly receive weaker checks than
|
||||
// `entrypoints.ui` (ADR 0041).
|
||||
const configuredContributions = manifest.contributions.map((contribution, index) => ({
|
||||
entrypointName: `contribution:${contribution.id}`,
|
||||
issuePath: `contributions.${index}.artifact`,
|
||||
relativeEntrypoint: contribution.artifact,
|
||||
}));
|
||||
const configuredEntrypoints = [
|
||||
...ordinaryConfiguredEntrypoints,
|
||||
...(display === undefined
|
||||
@@ -284,6 +316,7 @@ async function resolveEntrypoints(
|
||||
relativeEntrypoint: display.artifact,
|
||||
},
|
||||
]),
|
||||
...configuredContributions,
|
||||
];
|
||||
let entrypointCollision = false;
|
||||
|
||||
@@ -361,7 +394,14 @@ async function resolveEntrypoints(
|
||||
});
|
||||
continue;
|
||||
}
|
||||
resolved[entrypointName as keyof ModuleManifest["entrypoints"]] = canonicalEntrypoint;
|
||||
if (entrypointName.startsWith("contribution:")) {
|
||||
resolvedContributions.set(
|
||||
entrypointName.slice("contribution:".length),
|
||||
canonicalEntrypoint,
|
||||
);
|
||||
} else {
|
||||
resolved[entrypointName as keyof ModuleManifest["entrypoints"]] = canonicalEntrypoint;
|
||||
}
|
||||
} catch (error) {
|
||||
const code = errorCode(error);
|
||||
issues.push({
|
||||
@@ -394,11 +434,46 @@ async function resolveEntrypoints(
|
||||
}
|
||||
}
|
||||
|
||||
if (!resolved.server || entrypointCollision) {
|
||||
// A contribution artifact must be its own file. Two declarations resolving to one path — through
|
||||
// a link, since the manifest already rejects two identical declared paths — would make one
|
||||
// contribution silently serve another's bytes, and the loader's identity check would then reject
|
||||
// whichever one lost.
|
||||
const canonicalContributionPaths = new Map<string, string>();
|
||||
for (const [contributionId, canonicalPath] of resolvedContributions) {
|
||||
const claimedBy = canonicalContributionPaths.get(canonicalPath);
|
||||
if (claimedBy !== undefined || Object.values(resolved).includes(canonicalPath)) {
|
||||
entrypointCollision = true;
|
||||
issues.push({
|
||||
code: "MODULE_ENTRYPOINT_INVALID",
|
||||
message: `contribution ${contributionId} must not resolve to the same file as ${claimedBy ?? "another entrypoint"}.`,
|
||||
moduleDirectory: directoryName,
|
||||
moduleId: manifest.id,
|
||||
path: "contributions",
|
||||
});
|
||||
continue;
|
||||
}
|
||||
canonicalContributionPaths.set(canonicalPath, contributionId);
|
||||
}
|
||||
|
||||
if (
|
||||
!resolved.server ||
|
||||
entrypointCollision ||
|
||||
resolvedContributions.size !== manifest.contributions.length
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
const contributions = manifest.contributions.map((contribution) =>
|
||||
Object.freeze({
|
||||
artifactPath: resolvedContributions.get(contribution.id) as string,
|
||||
capability: contribution.capability,
|
||||
capabilityRange: contribution.capabilityRange,
|
||||
id: contribution.id,
|
||||
version: contribution.version,
|
||||
}),
|
||||
);
|
||||
|
||||
const entrypoints: ResolvedModuleEntrypoints = {
|
||||
server: resolved.server,
|
||||
...(resolved.schema ? { schema: resolved.schema } : {}),
|
||||
...(resolved.migrations ? { migrations: resolved.migrations } : {}),
|
||||
@@ -413,6 +488,8 @@ async function resolveEntrypoints(
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
|
||||
return { contributions: Object.freeze(contributions), entrypoints };
|
||||
}
|
||||
|
||||
async function scanModuleDirectory(
|
||||
@@ -510,15 +587,16 @@ async function scanModuleDirectory(
|
||||
});
|
||||
}
|
||||
|
||||
const entrypoints = await resolveEntrypoints(moduleDirectory, directoryName, manifest, issues);
|
||||
if (!entrypoints) {
|
||||
const artifacts = await resolveEntrypoints(moduleDirectory, directoryName, manifest, issues);
|
||||
if (!artifacts) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
contributions: artifacts.contributions,
|
||||
directory: canonicalModuleDirectory,
|
||||
directoryName,
|
||||
entrypoints,
|
||||
entrypoints: artifacts.entrypoints,
|
||||
manifest,
|
||||
manifestPath: canonicalManifestPath,
|
||||
packageName: packageData.name,
|
||||
|
||||
@@ -60,6 +60,7 @@ export type ModuleRuntimeIssueCode =
|
||||
| "MODULE_ROLLBACK_TIMEOUT"
|
||||
| "MODULE_ROUTE_DEFINITION_INVALID"
|
||||
| "MODULE_ROUTE_DUPLICATE"
|
||||
| "MODULE_ROUTE_BODY_MODE_INVALID"
|
||||
| "MODULE_ROUTE_MAX_BODY_BYTES_INVALID"
|
||||
| "MODULE_ROUTE_METHOD_INVALID"
|
||||
| "MODULE_ROUTE_OVERLAP"
|
||||
@@ -467,6 +468,7 @@ class StagedModuleHttpRoutes {
|
||||
shapes.add(shapeKey);
|
||||
local.push(
|
||||
Object.freeze({
|
||||
bodyMode: parsed.data.bodyMode,
|
||||
handler: parsed.data.handler,
|
||||
maxBodyBytes: parsed.data.maxBodyBytes,
|
||||
method: parsed.data.method,
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import type { PermissionDeclaration, PermissionId } from "@ekklesia/contracts";
|
||||
import {
|
||||
isVersionCompatible,
|
||||
type PermissionDeclaration,
|
||||
type PermissionId,
|
||||
} from "@ekklesia/contracts";
|
||||
import type { DiscoveredModule } from "./discovery.js";
|
||||
|
||||
/**
|
||||
@@ -60,6 +64,34 @@ export interface PublishableModuleDisplay {
|
||||
readonly permissions: readonly PermissionDeclaration[];
|
||||
}
|
||||
|
||||
/**
|
||||
* One installed module's browser contribution that the platform is willing to publish (ADR 0041).
|
||||
*
|
||||
* Publishable means three things, and nothing beyond them: the contributing module survived
|
||||
* discovery and is in the eligible set, some **other** eligible module provides the targeted
|
||||
* capability, and that provider's declared capability version satisfies the contribution's declared
|
||||
* range. A contribution whose capability nobody provides, or whose provider moved outside its range,
|
||||
* is simply absent — never published-but-broken, and never mounted with a shrug.
|
||||
*
|
||||
* Note what is *not* here. There is no payload schema, no slot, no ordering, no rendering hint, and
|
||||
* no domain vocabulary of any kind: Core carries identity, version, compatibility, and one file
|
||||
* path. What a contributed renderer draws, and what shape its input takes, are the consumer module's
|
||||
* business and travel between the two modules in the browser without passing through the platform.
|
||||
*/
|
||||
export interface PublishableModuleContribution {
|
||||
/** The module that *contributes* the renderer. */
|
||||
readonly moduleId: string;
|
||||
readonly moduleVersion: string;
|
||||
readonly contributionId: string;
|
||||
/** The contribution's own version, independent of its module's. */
|
||||
readonly version: string;
|
||||
readonly capability: string;
|
||||
/** The version the providing module declares for that capability — the value the contribution's
|
||||
* declared range was actually checked against. */
|
||||
readonly capabilityVersion: string;
|
||||
readonly artifactPath: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The platform's answer to "may this module's UI artifact be served, and if so, which exact file?"
|
||||
*
|
||||
@@ -89,6 +121,38 @@ export interface ModuleUiPublicationRegistry {
|
||||
* a display merely because a caller appends `/display` to its publication URL.
|
||||
*/
|
||||
resolveDisplay(moduleId: unknown, version: unknown): PublishableModuleDisplay | undefined;
|
||||
/** Every publishable browser contribution, in discovery order then manifest order. Frozen. */
|
||||
listContributions(): readonly PublishableModuleContribution[];
|
||||
/**
|
||||
* Every publishable contribution targeting one capability, in the same stable order.
|
||||
*
|
||||
* This is the lookup a *consumer* module's descriptor is built from, and the reason a consumer can
|
||||
* only ever learn about renderers aimed at a capability it actually provides: the caller passes
|
||||
* its own declared capability id and gets back exactly the contributions for it.
|
||||
*/
|
||||
contributionsForCapability(capabilityId: unknown): readonly PublishableModuleContribution[];
|
||||
/**
|
||||
* Every publishable contribution aimed at **any** capability the named module provides.
|
||||
*
|
||||
* This is the one lookup a descriptor for a consuming module is built from. Framing it per module
|
||||
* rather than per capability is what keeps capability ownership inside Core: a caller hands over a
|
||||
* module id and gets that module's own extension points filled in, with no way to ask about
|
||||
* someone else's.
|
||||
*/
|
||||
contributionsForModule(moduleId: unknown): readonly PublishableModuleContribution[];
|
||||
/**
|
||||
* The publishable record for an exact contributing module id, installed module version, and
|
||||
* contribution id, or `undefined`.
|
||||
*
|
||||
* All three must match. A contribution that is not installed, one whose module is at a different
|
||||
* version, one whose capability has no compatible provider, and an id that was never declared are
|
||||
* all indistinguishable here — each returns `undefined`.
|
||||
*/
|
||||
resolveContribution(
|
||||
moduleId: unknown,
|
||||
moduleVersion: unknown,
|
||||
contributionId: unknown,
|
||||
): PublishableModuleContribution | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -124,6 +188,41 @@ export function createModuleUiPublicationRegistry(
|
||||
): ModuleUiPublicationRegistry {
|
||||
const byModuleId = new Map<string, PublishableModuleUi>();
|
||||
const displaysByModuleId = new Map<string, PublishableModuleDisplay>();
|
||||
const contributions: PublishableModuleContribution[] = [];
|
||||
// Every capability the eligible set actually provides, at the version its provider declares. Built
|
||||
// from the same list everything else here is built from, so a contribution can never be checked
|
||||
// against a provider the platform is not running.
|
||||
const capabilityVersions = new Map<string, string>();
|
||||
for (const module of modules) {
|
||||
for (const capability of module.manifest.capabilities) {
|
||||
capabilityVersions.set(capability.id, capability.version);
|
||||
}
|
||||
}
|
||||
|
||||
for (const module of modules) {
|
||||
for (const contribution of module.contributions) {
|
||||
const capabilityVersion = capabilityVersions.get(contribution.capability);
|
||||
// Fails closed on both halves: an absent provider and an incompatible one are equally
|
||||
// unpublishable, so a range mismatch can never be papered over by mounting anyway.
|
||||
if (
|
||||
capabilityVersion === undefined ||
|
||||
!isVersionCompatible(capabilityVersion, contribution.capabilityRange)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
contributions.push(
|
||||
Object.freeze({
|
||||
artifactPath: contribution.artifactPath,
|
||||
capability: contribution.capability,
|
||||
capabilityVersion,
|
||||
contributionId: contribution.id,
|
||||
moduleId: module.manifest.id,
|
||||
moduleVersion: module.manifest.version,
|
||||
version: contribution.version,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
for (const module of modules) {
|
||||
const artifactPath = module.entrypoints.ui;
|
||||
@@ -160,8 +259,63 @@ export function createModuleUiPublicationRegistry(
|
||||
|
||||
const published = Object.freeze([...byModuleId.values()]);
|
||||
const publishedDisplays = Object.freeze([...displaysByModuleId.values()]);
|
||||
const publishedContributions = Object.freeze([...contributions]);
|
||||
const contributionsByCapability = new Map<string, PublishableModuleContribution[]>();
|
||||
const contributionsByKey = new Map<string, PublishableModuleContribution>();
|
||||
for (const contribution of publishedContributions) {
|
||||
const forCapability = contributionsByCapability.get(contribution.capability) ?? [];
|
||||
forCapability.push(contribution);
|
||||
contributionsByCapability.set(contribution.capability, forCapability);
|
||||
// Module id and contribution id are both namespaced and unique, so this key cannot collide.
|
||||
contributionsByKey.set(
|
||||
`${contribution.moduleId}\u0000${contribution.contributionId}`,
|
||||
contribution,
|
||||
);
|
||||
}
|
||||
const frozenByCapability = new Map(
|
||||
[...contributionsByCapability].map(([capability, list]) => [capability, Object.freeze(list)]),
|
||||
);
|
||||
const noContributions: readonly PublishableModuleContribution[] = Object.freeze([]);
|
||||
const byConsumingModule = new Map<string, readonly PublishableModuleContribution[]>(
|
||||
modules.map((module) => [
|
||||
module.manifest.id,
|
||||
Object.freeze(
|
||||
module.manifest.capabilities.flatMap(
|
||||
(capability) => contributionsByCapability.get(capability.id) ?? [],
|
||||
),
|
||||
),
|
||||
]),
|
||||
);
|
||||
|
||||
return Object.freeze({
|
||||
contributionsForCapability: (
|
||||
capabilityId: unknown,
|
||||
): readonly PublishableModuleContribution[] =>
|
||||
typeof capabilityId === "string"
|
||||
? (frozenByCapability.get(capabilityId) ?? noContributions)
|
||||
: noContributions,
|
||||
contributionsForModule: (moduleId: unknown): readonly PublishableModuleContribution[] =>
|
||||
typeof moduleId === "string"
|
||||
? (byConsumingModule.get(moduleId) ?? noContributions)
|
||||
: noContributions,
|
||||
listContributions: (): readonly PublishableModuleContribution[] => publishedContributions,
|
||||
resolveContribution: (
|
||||
moduleId: unknown,
|
||||
moduleVersion: unknown,
|
||||
contributionId: unknown,
|
||||
): PublishableModuleContribution | undefined => {
|
||||
if (
|
||||
typeof moduleId !== "string" ||
|
||||
typeof moduleVersion !== "string" ||
|
||||
typeof contributionId !== "string"
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
const candidate = contributionsByKey.get(`${moduleId}\u0000${contributionId}`);
|
||||
// Exact installed-version equality, exactly as the UI and display lookups require: a request
|
||||
// naming a version the platform does not have installed resolves to nothing at all.
|
||||
return candidate?.moduleVersion === moduleVersion ? candidate : undefined;
|
||||
},
|
||||
list: (): readonly PublishableModuleUi[] => published,
|
||||
listDisplays: (): readonly PublishableModuleDisplay[] => publishedDisplays,
|
||||
resolve: (moduleId: unknown, version: unknown): PublishableModuleUi | undefined => {
|
||||
|
||||
@@ -91,7 +91,7 @@ async function createModule(
|
||||
writeFile(
|
||||
path.join(moduleDirectory, "ekklesia.module.json"),
|
||||
JSON.stringify({
|
||||
manifestVersion: 6,
|
||||
manifestVersion: 7,
|
||||
id,
|
||||
name: `${id} module`,
|
||||
version: "0.1.0",
|
||||
|
||||
@@ -59,7 +59,7 @@ async function createModule(modulesDirectory: string, fixture: ModuleFixture): P
|
||||
: {}),
|
||||
entrypoints: { server: "./dist/server/index.js", ui: "./dist/ui/index.js" },
|
||||
id: fixture.id,
|
||||
manifestVersion: 6,
|
||||
manifestVersion: 7,
|
||||
name: fixture.name,
|
||||
version: "1.0.0",
|
||||
}),
|
||||
|
||||
@@ -69,6 +69,7 @@ const mailEnvironment = Object.freeze({
|
||||
|
||||
function discoveredModule(id: string, overrides: Record<string, unknown> = {}): DiscoveredModule {
|
||||
return {
|
||||
contributions: [],
|
||||
directory: `/unpublished/${id}`,
|
||||
entrypoints: { server: `/unpublished/${id}/server.js` },
|
||||
manifest: {
|
||||
@@ -81,7 +82,7 @@ function discoveredModule(id: string, overrides: Record<string, unknown> = {}):
|
||||
},
|
||||
entrypoints: { server: "./dist/server.js" },
|
||||
id,
|
||||
manifestVersion: 6,
|
||||
manifestVersion: 7,
|
||||
name: `${id} module`,
|
||||
permissions: [],
|
||||
storage: { buckets: [] },
|
||||
|
||||
@@ -117,6 +117,7 @@ Identity
|
||||
Identity runtime configuration (signing key, base URL, trusted origins) is not set.
|
||||
✓ Direct client-address mode
|
||||
Trusts only the actual immediate socket peer for rate limiting; no forwarded header is trusted.
|
||||
Checked: the environment Doctor runs with. In the reference deployment \`migrate\` is given the API's client-address values; a running API's own value is shown by \`docker compose exec api printenv EKKLESIA_CLIENT_ADDRESS_MODE\`.
|
||||
|
||||
Object storage
|
||||
○ Object storage not configured
|
||||
|
||||
+187
-1
@@ -30,6 +30,7 @@ const checksumC = "c".repeat(64);
|
||||
|
||||
function discoveredModule(id: string, version = "0.1.0"): DiscoveredModule {
|
||||
return {
|
||||
contributions: [],
|
||||
directory: `/unpublished/${id}`,
|
||||
entrypoints: { server: `/unpublished/${id}/server.js` },
|
||||
manifest: {
|
||||
@@ -42,7 +43,7 @@ function discoveredModule(id: string, version = "0.1.0"): DiscoveredModule {
|
||||
},
|
||||
entrypoints: { server: "./dist/server.js" },
|
||||
id,
|
||||
manifestVersion: 6,
|
||||
manifestVersion: 7,
|
||||
name: `${id} module`,
|
||||
permissions: [],
|
||||
storage: { buckets: [] },
|
||||
@@ -704,6 +705,88 @@ describe("Ekklesia Doctor diagnostics", () => {
|
||||
expect(report.result).toBe("ready-with-warnings"); // PostgreSQL is still skipped in this fixture
|
||||
});
|
||||
|
||||
describe("Dashboard and API topology (ADR 0045)", () => {
|
||||
const secret = "correct-horse-battery-staple-0123456789";
|
||||
async function identityCheck(identity: {
|
||||
readonly baseUrl: string;
|
||||
readonly dashboardOrigin?: string;
|
||||
readonly environment?: "development" | "production";
|
||||
readonly trustedOrigins: readonly string[];
|
||||
}) {
|
||||
const report = await diagnoseEkklesia(
|
||||
{
|
||||
...options(),
|
||||
identityConfiguration: { environment: "production", secret, ...identity },
|
||||
},
|
||||
dependencies(),
|
||||
);
|
||||
return { check: check(report, "identity.configuration"), report };
|
||||
}
|
||||
|
||||
it("reports the same-origin topology when no Dashboard origin is set", async () => {
|
||||
const { check: identity } = await identityCheck({
|
||||
baseUrl: "https://ekklesia.example.test",
|
||||
trustedOrigins: ["https://ekklesia.example.test"],
|
||||
});
|
||||
expect(identity.status).toBe("pass");
|
||||
expect(identity.details).toContain(
|
||||
"Topology: same-origin (the Dashboard is served from the API's origin).",
|
||||
);
|
||||
});
|
||||
|
||||
it("reports the split topology for sibling hostnames without printing either", async () => {
|
||||
const { check: identity, report } = await identityCheck({
|
||||
baseUrl: "https://api.example.test",
|
||||
dashboardOrigin: "https://dash.example.test",
|
||||
trustedOrigins: ["https://dash.example.test"],
|
||||
});
|
||||
expect(identity.status).toBe("pass");
|
||||
expect(identity.details).toContain(
|
||||
"Topology: split (the Dashboard and the API are on separate origins).",
|
||||
);
|
||||
expect(JSON.stringify(report)).not.toContain("dash.example.test");
|
||||
expect(JSON.stringify(report)).not.toContain("api.example.test");
|
||||
});
|
||||
|
||||
it.each([
|
||||
["different registrable domains", "https://dash.example.test", "https://api.example.org"],
|
||||
["two IP addresses", "https://10.0.0.2", "https://10.0.0.1"],
|
||||
["a parent that is only a top-level domain", "https://dash.test", "https://api.test"],
|
||||
])("warns, without failing, when split hostnames may not share a site: %s", async (_case, dashboardOrigin, baseUrl) => {
|
||||
const { check: identity, report } = await identityCheck({
|
||||
baseUrl,
|
||||
dashboardOrigin,
|
||||
trustedOrigins: [dashboardOrigin],
|
||||
});
|
||||
expect(identity.status).toBe("warning");
|
||||
expect(identity.details.join("\n")).toContain("not siblings under one parent domain");
|
||||
expect(doctorExitCode(report)).toBe(0);
|
||||
});
|
||||
|
||||
it("treats another port on the same host as the same site (split development)", async () => {
|
||||
const { check: identity } = await identityCheck({
|
||||
baseUrl: "http://localhost:3000",
|
||||
dashboardOrigin: "http://localhost:5173",
|
||||
environment: "development",
|
||||
trustedOrigins: ["http://localhost:5173"],
|
||||
});
|
||||
expect(identity.status).toBe("pass");
|
||||
});
|
||||
|
||||
it("fails, as the API's startup would, for a Dashboard origin the API does not trust", async () => {
|
||||
const { check: identity, report } = await identityCheck({
|
||||
baseUrl: "https://api.example.test",
|
||||
dashboardOrigin: "https://dash.example.test",
|
||||
trustedOrigins: ["https://other.example.test"],
|
||||
});
|
||||
expect(identity.status).toBe("failure");
|
||||
expect(identity.details.some((detail) => detail.includes("DASHBOARD_ORIGIN_UNTRUSTED"))).toBe(
|
||||
true,
|
||||
);
|
||||
expect(doctorExitCode(report)).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
it("fails identity configuration for an invalid secret without leaking it", async () => {
|
||||
const hostileSecret = "s3cr3t-do-not-log";
|
||||
const report = await diagnoseEkklesia(
|
||||
@@ -875,6 +958,21 @@ describe("Ekklesia Doctor diagnostics", () => {
|
||||
expect(report.result).toBe("ready-with-warnings");
|
||||
});
|
||||
|
||||
it("says whose client-address configuration it checked: this process's, not a running API's", async () => {
|
||||
for (const clientAddress of [
|
||||
undefined,
|
||||
{ header: "x-forwarded-for", mode: "trusted-proxy" as const, trustedProxies: ["10.0.0.1"] },
|
||||
{ header: "x-forwarded-for", mode: "trusted-proxy" as const, trustedProxies: ["bad"] },
|
||||
]) {
|
||||
const report = await diagnoseEkklesia(
|
||||
{ ...options(), ...(clientAddress ? { clientAddress } : {}) },
|
||||
dependencies(),
|
||||
);
|
||||
const details = check(report, "identity.client-address").details;
|
||||
expect(details.some((detail) => detail.includes("environment Doctor runs with"))).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("passes client-address configuration with an explicitly configured trusted-proxy mode", async () => {
|
||||
const report = await diagnoseEkklesia(
|
||||
{
|
||||
@@ -1471,3 +1569,91 @@ describe("Ekklesia Doctor — object storage", () => {
|
||||
expect(check(report, "object-storage.bindings").status).toBe("skipped");
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* A browser contribution whose target capability nobody provides is otherwise silent: the manifest
|
||||
* is valid, the artifact exists, and the screen renders nothing with no error anywhere (ADR 0041).
|
||||
* Doctor names it.
|
||||
*/
|
||||
describe("Doctor reports declared browser contributions", () => {
|
||||
function contributing(
|
||||
id: string,
|
||||
contribution: { capability: string; capabilityRange: string },
|
||||
): DiscoveredModule {
|
||||
const base = discoveredModule(id);
|
||||
return {
|
||||
...base,
|
||||
contributions: [
|
||||
{
|
||||
artifactPath: `/unpublished/${id}/contributions/slide.js`,
|
||||
capability: contribution.capability,
|
||||
capabilityRange: contribution.capabilityRange,
|
||||
id: `${id}.slide`,
|
||||
version: "1.0.0",
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function providing(id: string, capability: { id: string; version: string }): DiscoveredModule {
|
||||
const base = discoveredModule(id);
|
||||
return {
|
||||
...base,
|
||||
manifest: { ...base.manifest, capabilities: [capability] } as DiscoveredModule["manifest"],
|
||||
};
|
||||
}
|
||||
|
||||
async function detailsFor(modules: readonly DiscoveredModule[]): Promise<readonly string[]> {
|
||||
const report = await diagnoseEkklesia(
|
||||
options(),
|
||||
dependencies({
|
||||
discoverModules: vi.fn(async () => Object.freeze([...modules])),
|
||||
discoverSchemas: vi.fn(async () => schemaAggregate(modules)),
|
||||
}),
|
||||
);
|
||||
return check(report, "modules.discovery").details;
|
||||
}
|
||||
|
||||
it("says nothing extra for a module that contributes nothing", async () => {
|
||||
expect(await detailsFor([discoveredModule("people")])).toEqual(["people 0.1.0"]);
|
||||
});
|
||||
|
||||
it("reports a contribution whose provider satisfies its declared range", async () => {
|
||||
const details = await detailsFor([
|
||||
providing("signage", { id: "signage.slide-renderer", version: "1.2.0" }),
|
||||
contributing("announcements", {
|
||||
capability: "signage.slide-renderer",
|
||||
capabilityRange: "^1.0.0",
|
||||
}),
|
||||
]);
|
||||
|
||||
expect(details).toEqual([
|
||||
"signage 0.1.0",
|
||||
"announcements 0.1.0 · 1 browser contribution (1 with a compatible provider)",
|
||||
]);
|
||||
});
|
||||
|
||||
it("reports a contribution nobody provides, and one outside its range, as unmatched", async () => {
|
||||
expect(
|
||||
await detailsFor([
|
||||
contributing("announcements", {
|
||||
capability: "signage.slide-renderer",
|
||||
capabilityRange: "^1.0.0",
|
||||
}),
|
||||
]),
|
||||
).toEqual(["announcements 0.1.0 · 1 browser contribution (0 with a compatible provider)"]);
|
||||
|
||||
expect(
|
||||
await detailsFor([
|
||||
providing("signage", { id: "signage.slide-renderer", version: "3.0.0" }),
|
||||
contributing("announcements", {
|
||||
capability: "signage.slide-renderer",
|
||||
capabilityRange: "^1.0.0",
|
||||
}),
|
||||
]),
|
||||
).toEqual([
|
||||
"signage 0.1.0",
|
||||
"announcements 0.1.0 · 1 browser contribution (0 with a compatible provider)",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"manifestVersion": 6,
|
||||
"manifestVersion": 7,
|
||||
"id": "db-facade",
|
||||
"name": "Database Facade Integration Test Module",
|
||||
"description": "Test-only installed module fixture proving ModuleDatabase against real PostgreSQL. Never discovered by production startup, Doctor, or normal migration aggregation — only apps/api/tests/module-database.integration.ts points discovery at it explicitly.",
|
||||
|
||||
@@ -17,7 +17,7 @@ function fakeModule(
|
||||
} = {},
|
||||
): DiscoveredModule {
|
||||
const manifest = parseModuleManifest({
|
||||
manifestVersion: 6,
|
||||
manifestVersion: 7,
|
||||
id,
|
||||
name: `${id} module`,
|
||||
version: "1.0.0",
|
||||
@@ -27,6 +27,7 @@ function fakeModule(
|
||||
entrypoints: { server: "./dist/does-not-exist/index.js" },
|
||||
});
|
||||
return {
|
||||
contributions: [],
|
||||
directory: `/modules/${id}`,
|
||||
entrypoints: { server: `/modules/${id}/dist/does-not-exist/index.js` },
|
||||
manifest,
|
||||
|
||||
@@ -194,7 +194,7 @@ afterAll(async () => {
|
||||
|
||||
function manifestWith(configuration: readonly Record<string, unknown>[], id = "announcements") {
|
||||
return parseModuleManifest({
|
||||
manifestVersion: 6,
|
||||
manifestVersion: 7,
|
||||
id,
|
||||
name: `${id} module`,
|
||||
version: "1.0.0",
|
||||
|
||||
@@ -21,7 +21,7 @@ function manifestWith(
|
||||
id = "announcements",
|
||||
) {
|
||||
return parseModuleManifest({
|
||||
manifestVersion: 6,
|
||||
manifestVersion: 7,
|
||||
id,
|
||||
name: `${id} module`,
|
||||
version: "1.0.0",
|
||||
@@ -92,7 +92,7 @@ describe("module configuration declarations", () => {
|
||||
|
||||
it("rejects two declarations of the same key, so an ordinary key and a secret can never collide", () => {
|
||||
const result = safeParseModuleManifest({
|
||||
manifestVersion: 6,
|
||||
manifestVersion: 7,
|
||||
id: "announcements",
|
||||
name: "announcements module",
|
||||
version: "1.0.0",
|
||||
@@ -114,7 +114,7 @@ describe("module configuration declarations", () => {
|
||||
|
||||
it("rejects a default on a secret, because a checked-in default credential is a leaked one", () => {
|
||||
const result = safeParseModuleManifest({
|
||||
manifestVersion: 6,
|
||||
manifestVersion: 7,
|
||||
id: "announcements",
|
||||
name: "announcements module",
|
||||
version: "1.0.0",
|
||||
@@ -130,7 +130,7 @@ describe("module configuration declarations", () => {
|
||||
|
||||
it("rejects a key that is both required and defaulted, which states an intent nothing can honour", () => {
|
||||
const result = safeParseModuleManifest({
|
||||
manifestVersion: 6,
|
||||
manifestVersion: 7,
|
||||
id: "announcements",
|
||||
name: "announcements module",
|
||||
version: "1.0.0",
|
||||
@@ -169,7 +169,7 @@ describe("module configuration declarations", () => {
|
||||
],
|
||||
])("rejects %s", (_label, declaration) => {
|
||||
const result = safeParseModuleManifest({
|
||||
manifestVersion: 6,
|
||||
manifestVersion: 7,
|
||||
id: "announcements",
|
||||
name: "announcements module",
|
||||
version: "1.0.0",
|
||||
@@ -189,7 +189,7 @@ describe("module configuration declarations", () => {
|
||||
"valueOf",
|
||||
])("rejects the reserved object property name %s as a configuration key", (key) => {
|
||||
const result = safeParseModuleManifest({
|
||||
manifestVersion: 6,
|
||||
manifestVersion: 7,
|
||||
id: "announcements",
|
||||
name: "announcements module",
|
||||
version: "1.0.0",
|
||||
@@ -210,7 +210,7 @@ describe("module configuration declarations", () => {
|
||||
"",
|
||||
])("rejects the malformed configuration key %s", (key) => {
|
||||
const result = safeParseModuleManifest({
|
||||
manifestVersion: 6,
|
||||
manifestVersion: 7,
|
||||
id: "announcements",
|
||||
name: "announcements module",
|
||||
version: "1.0.0",
|
||||
@@ -591,7 +591,7 @@ describe("configuration over a real discovered inventory", () => {
|
||||
writeFile(
|
||||
path.join(moduleDirectory, "ekklesia.module.json"),
|
||||
JSON.stringify({
|
||||
manifestVersion: 6,
|
||||
manifestVersion: 7,
|
||||
id,
|
||||
name: `${id} module`,
|
||||
version: "1.0.0",
|
||||
|
||||
@@ -34,7 +34,7 @@ async function createModulesDirectory(): Promise<string> {
|
||||
|
||||
function manifestFor(id: string): Record<string, unknown> {
|
||||
return {
|
||||
manifestVersion: 6,
|
||||
manifestVersion: 7,
|
||||
id,
|
||||
name: `${id} module`,
|
||||
version: "0.1.0",
|
||||
@@ -957,3 +957,179 @@ describe("module discovery", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* A contribution artifact is resolved through the identical loop as every other compiled entrypoint
|
||||
* (ADR 0041), so it inherits every protection `entrypoints.ui` already had rather than receiving a
|
||||
* second, weaker implementation of them.
|
||||
*/
|
||||
describe("browser contribution artifact resolution", () => {
|
||||
function contributingManifest(overrides: Record<string, unknown> = {}): Record<string, unknown> {
|
||||
return {
|
||||
...manifestFor("announcements"),
|
||||
contributions: [
|
||||
{
|
||||
id: "announcements.signage-slide",
|
||||
version: "1.0.0",
|
||||
capability: "signage.slide-renderer",
|
||||
capabilityRange: "^1.0.0",
|
||||
artifact: "./dist/contributions/signage-slide/index.js",
|
||||
...overrides,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
it("resolves a declared contribution to a canonical path inside the package", async () => {
|
||||
const modulesDirectory = await createModulesDirectory();
|
||||
await createModule(modulesDirectory, "announcements", {
|
||||
extraFiles: { "dist/contributions/signage-slide/index.js": "export default {};" },
|
||||
manifest: contributingManifest(),
|
||||
});
|
||||
|
||||
const modules = await discoverModules({
|
||||
moduleSdkVersion: "0.1.0",
|
||||
modulesDirectory,
|
||||
platformVersion: "0.1.0",
|
||||
});
|
||||
|
||||
const contribution = modules[0]?.contributions[0];
|
||||
expect(contribution).toMatchObject({
|
||||
capability: "signage.slide-renderer",
|
||||
capabilityRange: "^1.0.0",
|
||||
id: "announcements.signage-slide",
|
||||
version: "1.0.0",
|
||||
});
|
||||
expect(contribution?.artifactPath.endsWith("dist/contributions/signage-slide/index.js")).toBe(
|
||||
true,
|
||||
);
|
||||
expect(path.isAbsolute(contribution?.artifactPath ?? "")).toBe(true);
|
||||
});
|
||||
|
||||
it("reports an empty contribution list for a module that contributes nothing", async () => {
|
||||
const modulesDirectory = await createModulesDirectory();
|
||||
await createModule(modulesDirectory, "calendar");
|
||||
|
||||
const modules = await discoverModules({
|
||||
moduleSdkVersion: "0.1.0",
|
||||
modulesDirectory,
|
||||
platformVersion: "0.1.0",
|
||||
});
|
||||
|
||||
expect(modules[0]?.contributions).toEqual([]);
|
||||
});
|
||||
|
||||
it("rejects a missing contribution artifact", async () => {
|
||||
const modulesDirectory = await createModulesDirectory();
|
||||
await createModule(modulesDirectory, "announcements", { manifest: contributingManifest() });
|
||||
|
||||
await expect(
|
||||
discoverModules({
|
||||
moduleSdkVersion: "0.1.0",
|
||||
modulesDirectory,
|
||||
platformVersion: "0.1.0",
|
||||
}),
|
||||
).rejects.toMatchObject({
|
||||
issues: expect.arrayContaining([
|
||||
expect.objectContaining({ code: "MODULE_ENTRYPOINT_MISSING" }),
|
||||
]),
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects a contribution artifact that is not compiled JavaScript", async () => {
|
||||
const modulesDirectory = await createModulesDirectory();
|
||||
await createModule(modulesDirectory, "announcements", {
|
||||
extraFiles: { "src/contributions/signage-slide/index.tsx": "export default {};" },
|
||||
manifest: contributingManifest({ artifact: "./src/contributions/signage-slide/index.tsx" }),
|
||||
});
|
||||
|
||||
await expect(
|
||||
discoverModules({
|
||||
moduleSdkVersion: "0.1.0",
|
||||
modulesDirectory,
|
||||
platformVersion: "0.1.0",
|
||||
}),
|
||||
).rejects.toMatchObject({
|
||||
issues: expect.arrayContaining([
|
||||
expect.objectContaining({ code: "MODULE_ENTRYPOINT_INVALID" }),
|
||||
]),
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects a contribution artifact that escapes the package through a link", async () => {
|
||||
const modulesDirectory = await createModulesDirectory();
|
||||
const outside = await createModulesDirectory();
|
||||
await writeFile(path.join(outside, "elsewhere.js"), "export default {};");
|
||||
await createModule(modulesDirectory, "announcements", {
|
||||
manifest: contributingManifest(),
|
||||
});
|
||||
await mkdir(path.join(modulesDirectory, "announcements/dist/contributions/signage-slide"), {
|
||||
recursive: true,
|
||||
});
|
||||
await symlink(
|
||||
path.join(outside, "elsewhere.js"),
|
||||
path.join(modulesDirectory, "announcements/dist/contributions/signage-slide/index.js"),
|
||||
);
|
||||
|
||||
await expect(
|
||||
discoverModules({
|
||||
moduleSdkVersion: "0.1.0",
|
||||
modulesDirectory,
|
||||
platformVersion: "0.1.0",
|
||||
}),
|
||||
).rejects.toMatchObject({
|
||||
issues: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
code: "MODULE_ENTRYPOINT_INVALID",
|
||||
path: "contributions.0.artifact",
|
||||
}),
|
||||
]),
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects two contributions that resolve through a link to the same file", async () => {
|
||||
const modulesDirectory = await createModulesDirectory();
|
||||
const manifest = {
|
||||
...manifestFor("announcements"),
|
||||
contributions: [
|
||||
{
|
||||
id: "announcements.first",
|
||||
version: "1.0.0",
|
||||
capability: "signage.slide-renderer",
|
||||
capabilityRange: "^1.0.0",
|
||||
artifact: "./dist/contributions/first/index.js",
|
||||
},
|
||||
{
|
||||
id: "announcements.second",
|
||||
version: "1.0.0",
|
||||
capability: "signage.slide-renderer",
|
||||
capabilityRange: "^1.0.0",
|
||||
artifact: "./dist/contributions/second/index.js",
|
||||
},
|
||||
],
|
||||
};
|
||||
await createModule(modulesDirectory, "announcements", {
|
||||
extraFiles: { "dist/contributions/first/index.js": "export default {};" },
|
||||
manifest,
|
||||
});
|
||||
await mkdir(path.join(modulesDirectory, "announcements/dist/contributions/second"), {
|
||||
recursive: true,
|
||||
});
|
||||
await symlink(
|
||||
path.join(modulesDirectory, "announcements/dist/contributions/first/index.js"),
|
||||
path.join(modulesDirectory, "announcements/dist/contributions/second/index.js"),
|
||||
);
|
||||
|
||||
await expect(
|
||||
discoverModules({
|
||||
moduleSdkVersion: "0.1.0",
|
||||
modulesDirectory,
|
||||
platformVersion: "0.1.0",
|
||||
}),
|
||||
).rejects.toMatchObject({
|
||||
issues: expect.arrayContaining([
|
||||
expect.objectContaining({ code: "MODULE_ENTRYPOINT_INVALID", path: "contributions" }),
|
||||
]),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -200,7 +200,7 @@ afterAll(async () => {
|
||||
* by eligibility resolution. */
|
||||
function fakeModule(id: string, dependencies?: Record<string, unknown>): DiscoveredModule {
|
||||
const manifest = parseModuleManifest({
|
||||
manifestVersion: 6,
|
||||
manifestVersion: 7,
|
||||
id,
|
||||
name: `${id} module`,
|
||||
version: "1.0.0",
|
||||
@@ -209,6 +209,7 @@ function fakeModule(id: string, dependencies?: Record<string, unknown>): Discove
|
||||
entrypoints: { server: "./dist/does-not-exist/index.js" },
|
||||
});
|
||||
return {
|
||||
contributions: [],
|
||||
directory: `/modules/${id}`,
|
||||
entrypoints: { server: `/modules/${id}/dist/does-not-exist/index.js` },
|
||||
manifest,
|
||||
|
||||
@@ -22,7 +22,7 @@ function fakeModule(
|
||||
} = {},
|
||||
): DiscoveredModule {
|
||||
const manifest = parseModuleManifest({
|
||||
manifestVersion: 6,
|
||||
manifestVersion: 7,
|
||||
id,
|
||||
name: `${id} module`,
|
||||
version: "0.1.0",
|
||||
@@ -36,6 +36,7 @@ function fakeModule(
|
||||
},
|
||||
});
|
||||
return {
|
||||
contributions: [],
|
||||
directory: `/modules/${id}`,
|
||||
entrypoints: { server: `/modules/${id}/dist/does-not-exist/index.js` },
|
||||
manifest,
|
||||
@@ -343,7 +344,7 @@ describe("resolveModuleEligibility — over a real discovered inventory", () =>
|
||||
writeFile(
|
||||
path.join(moduleDirectory, "ekklesia.module.json"),
|
||||
JSON.stringify({
|
||||
manifestVersion: 6,
|
||||
manifestVersion: 7,
|
||||
id,
|
||||
name: `${id} module`,
|
||||
version: "0.1.0",
|
||||
|
||||
@@ -65,7 +65,7 @@ function manifestFor(
|
||||
} = {},
|
||||
): Record<string, unknown> {
|
||||
return {
|
||||
manifestVersion: 6,
|
||||
manifestVersion: 7,
|
||||
id,
|
||||
name: `${id} module`,
|
||||
version: "0.1.0",
|
||||
|
||||
@@ -28,7 +28,7 @@ interface PermissionInput {
|
||||
* is read by `composePlatformPermissionRegistry`; the rest exist only to satisfy the type. */
|
||||
function fakeModule(id: string, permissions: readonly PermissionInput[] = []): DiscoveredModule {
|
||||
const manifest = parseModuleManifest({
|
||||
manifestVersion: 6,
|
||||
manifestVersion: 7,
|
||||
id,
|
||||
name: `${id} module`,
|
||||
version: "0.1.0",
|
||||
@@ -41,6 +41,7 @@ function fakeModule(id: string, permissions: readonly PermissionInput[] = []): D
|
||||
},
|
||||
});
|
||||
return {
|
||||
contributions: [],
|
||||
directory: `/modules/${id}`,
|
||||
entrypoints: { server: `/modules/${id}/dist/does-not-exist/index.js` },
|
||||
manifest,
|
||||
|
||||
@@ -31,7 +31,7 @@ function manifest(
|
||||
permissions: readonly Record<string, string>[] = [],
|
||||
): Record<string, unknown> {
|
||||
return {
|
||||
manifestVersion: 6,
|
||||
manifestVersion: 7,
|
||||
id,
|
||||
name: `${id} module`,
|
||||
version: "0.1.0",
|
||||
|
||||
@@ -57,7 +57,7 @@ function manifestFor(
|
||||
} = {},
|
||||
): Record<string, unknown> {
|
||||
return {
|
||||
manifestVersion: 6,
|
||||
manifestVersion: 7,
|
||||
id,
|
||||
name: `${id} module`,
|
||||
version: "0.1.0",
|
||||
@@ -861,6 +861,7 @@ describe("module HTTP routes", () => {
|
||||
expect(runtime.routes).toHaveLength(3);
|
||||
for (const route of runtime.routes) {
|
||||
expect(Object.keys(route).sort()).toEqual([
|
||||
"bodyMode",
|
||||
"handler",
|
||||
"maxBodyBytes",
|
||||
"method",
|
||||
|
||||
@@ -41,7 +41,7 @@ function manifestFor(
|
||||
options: { dependencies?: Record<string, unknown>; schema?: string | false } = {},
|
||||
): Record<string, unknown> {
|
||||
return {
|
||||
manifestVersion: 6,
|
||||
manifestVersion: 7,
|
||||
id,
|
||||
name: `${id} module`,
|
||||
version: "0.1.0",
|
||||
|
||||
@@ -18,7 +18,7 @@ function fakeModule(
|
||||
} = {},
|
||||
): DiscoveredModule {
|
||||
const manifest = parseModuleManifest({
|
||||
manifestVersion: 6,
|
||||
manifestVersion: 7,
|
||||
id,
|
||||
name: `${id} module`,
|
||||
version: "1.0.0",
|
||||
@@ -29,6 +29,7 @@ function fakeModule(
|
||||
entrypoints: { server: "./dist/does-not-exist/index.js" },
|
||||
});
|
||||
return {
|
||||
contributions: [],
|
||||
directory: `/modules/${id}`,
|
||||
entrypoints: { server: `/modules/${id}/dist/does-not-exist/index.js` },
|
||||
manifest,
|
||||
|
||||
@@ -29,6 +29,15 @@ interface ModuleFixture {
|
||||
readonly ui?: string;
|
||||
/** A dedicated full-viewport artifact, independent of the primary UI. */
|
||||
readonly display?: string;
|
||||
/** Capabilities this module provides — what another module's contributions can target. */
|
||||
readonly capabilities?: readonly { readonly id: string; readonly version: string }[];
|
||||
/** Browser renderers this module contributes to another module's capability. */
|
||||
readonly contributions?: readonly {
|
||||
readonly localName: string;
|
||||
readonly version: string;
|
||||
readonly capability: string;
|
||||
readonly capabilityRange: string;
|
||||
}[];
|
||||
}
|
||||
|
||||
async function createModule(modulesDirectory: string, fixture: ModuleFixture): Promise<void> {
|
||||
@@ -61,8 +70,16 @@ async function createModule(modulesDirectory: string, fixture: ModuleFixture): P
|
||||
},
|
||||
}),
|
||||
},
|
||||
capabilities: fixture.capabilities ?? [],
|
||||
contributions: (fixture.contributions ?? []).map((contribution) => ({
|
||||
artifact: `./dist/contributions/${contribution.localName}/index.js`,
|
||||
capability: contribution.capability,
|
||||
capabilityRange: contribution.capabilityRange,
|
||||
id: `${fixture.id}.${contribution.localName}`,
|
||||
version: contribution.version,
|
||||
})),
|
||||
id: fixture.id,
|
||||
manifestVersion: 6,
|
||||
manifestVersion: 7,
|
||||
name: `${fixture.id} module`,
|
||||
permissions:
|
||||
fixture.display === undefined
|
||||
@@ -89,6 +106,15 @@ async function createModule(modulesDirectory: string, fixture: ModuleFixture): P
|
||||
await mkdir(path.join(moduleDirectory, "dist/display"), { recursive: true });
|
||||
await writeFile(path.join(moduleDirectory, "dist/display/index.js"), fixture.display);
|
||||
}
|
||||
for (const contribution of fixture.contributions ?? []) {
|
||||
const contributionDirectory = path.join(
|
||||
moduleDirectory,
|
||||
"dist/contributions",
|
||||
contribution.localName,
|
||||
);
|
||||
await mkdir(contributionDirectory, { recursive: true });
|
||||
await writeFile(path.join(contributionDirectory, "index.js"), "export default {};");
|
||||
}
|
||||
}
|
||||
|
||||
/** Real discovery, never a hand-built `DiscoveredModule`: the whole point of the registry is that
|
||||
@@ -294,3 +320,128 @@ describe("module display UI publication registry", () => {
|
||||
expect(registry.listDisplays()).toBe(registry.listDisplays());
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* The generic contribution mechanism (ADR 0041), exercised through the exact relationship it exists
|
||||
* for and with no platform knowledge of what any of it means: `signage` provides a capability,
|
||||
* `announcements` contributes a renderer to it, and neither module imports the other.
|
||||
*/
|
||||
describe("module contribution publication", () => {
|
||||
const signage = {
|
||||
capabilities: [{ id: "signage.slide-renderer", version: "1.2.0" }],
|
||||
id: "signage",
|
||||
};
|
||||
const contributor = (capabilityRange: string) => ({
|
||||
contributions: [
|
||||
{
|
||||
capability: "signage.slide-renderer",
|
||||
capabilityRange,
|
||||
localName: "signage-slide",
|
||||
version: "1.0.0",
|
||||
},
|
||||
],
|
||||
id: "announcements",
|
||||
});
|
||||
|
||||
it("publishes a contribution whose target capability is provided within range", async () => {
|
||||
const modules = await discover([signage, contributor("^1.0.0")]);
|
||||
const registry = createModuleUiPublicationRegistry(modules);
|
||||
|
||||
expect(registry.listContributions()).toHaveLength(1);
|
||||
expect(
|
||||
registry.resolveContribution("announcements", "0.1.0", "announcements.signage-slide"),
|
||||
).toMatchObject({
|
||||
capability: "signage.slide-renderer",
|
||||
// The provider's declared version, so the range was checked against something real.
|
||||
capabilityVersion: "1.2.0",
|
||||
contributionId: "announcements.signage-slide",
|
||||
moduleId: "announcements",
|
||||
version: "1.0.0",
|
||||
});
|
||||
});
|
||||
|
||||
it("serves exactly the canonical artifact path discovery validated", async () => {
|
||||
const modules = await discover([signage, contributor("^1.0.0")]);
|
||||
const announcements = modules.find((module) => module.manifest.id === "announcements");
|
||||
const registry = createModuleUiPublicationRegistry(modules);
|
||||
|
||||
const published = registry.resolveContribution(
|
||||
"announcements",
|
||||
"0.1.0",
|
||||
"announcements.signage-slide",
|
||||
);
|
||||
expect(published?.artifactPath).toBe(announcements?.contributions[0]?.artifactPath);
|
||||
expect(path.isAbsolute(published?.artifactPath ?? "")).toBe(true);
|
||||
});
|
||||
|
||||
it("withholds a contribution whose capability no installed module provides", async () => {
|
||||
const modules = await discover([contributor("^1.0.0")]);
|
||||
const registry = createModuleUiPublicationRegistry(modules);
|
||||
|
||||
expect(registry.listContributions()).toEqual([]);
|
||||
expect(
|
||||
registry.resolveContribution("announcements", "0.1.0", "announcements.signage-slide"),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("withholds a contribution whose provider is outside its declared compatible range", async () => {
|
||||
// Fails closed: an incompatible renderer is absent rather than mounted and broken.
|
||||
const modules = await discover([signage, contributor("^2.0.0")]);
|
||||
const registry = createModuleUiPublicationRegistry(modules);
|
||||
|
||||
expect(registry.listContributions()).toEqual([]);
|
||||
expect(registry.contributionsForModule("signage")).toEqual([]);
|
||||
});
|
||||
|
||||
it("gives a consuming module exactly the contributions aimed at its own capabilities", async () => {
|
||||
const modules = await discover([
|
||||
signage,
|
||||
{ capabilities: [{ id: "calendar.week-view", version: "1.0.0" }], id: "calendar" },
|
||||
contributor("^1.0.0"),
|
||||
]);
|
||||
const registry = createModuleUiPublicationRegistry(modules);
|
||||
|
||||
expect(registry.contributionsForModule("signage").map((entry) => entry.contributionId)).toEqual(
|
||||
["announcements.signage-slide"],
|
||||
);
|
||||
// A module that provides a different capability sees nothing, and the *contributing* module
|
||||
// does not see its own contribution: it provides no capability it was aimed at.
|
||||
expect(registry.contributionsForModule("calendar")).toEqual([]);
|
||||
expect(registry.contributionsForModule("announcements")).toEqual([]);
|
||||
});
|
||||
|
||||
it("fails a lookup closed for an unknown, mismatched, or non-string identity", async () => {
|
||||
const modules = await discover([signage, contributor("^1.0.0")]);
|
||||
const registry = createModuleUiPublicationRegistry(modules);
|
||||
|
||||
expect(
|
||||
registry.resolveContribution("announcements", "9.9.9", "announcements.signage-slide"),
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
registry.resolveContribution("signage", "0.1.0", "announcements.signage-slide"),
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
registry.resolveContribution("announcements", "0.1.0", "announcements.nope"),
|
||||
).toBeUndefined();
|
||||
expect(registry.resolveContribution(7, "0.1.0", "announcements.signage-slide")).toBeUndefined();
|
||||
expect(registry.contributionsForCapability(undefined)).toEqual([]);
|
||||
});
|
||||
|
||||
it("carries no domain vocabulary at all", async () => {
|
||||
const modules = await discover([signage, contributor("^1.0.0")]);
|
||||
const registry = createModuleUiPublicationRegistry(modules);
|
||||
const published = registry.listContributions()[0];
|
||||
|
||||
// Core knows identity, version, compatibility, and one path. There is deliberately no payload
|
||||
// schema, slot, ordering, or rendering hint — what a renderer draws is not the platform's.
|
||||
expect(Object.keys(published ?? {}).sort()).toEqual([
|
||||
"artifactPath",
|
||||
"capability",
|
||||
"capabilityVersion",
|
||||
"contributionId",
|
||||
"moduleId",
|
||||
"moduleVersion",
|
||||
"version",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
# Ekklesia reference proxy configuration (ADR 0044). Copy to deploy/Caddyfile, which the proxy
|
||||
# overlay mounts read-only. Hostnames, the ACME contact, and the tunnel connector's address come from
|
||||
# the proxy service's environment (deploy/.env), so this file normally needs no edits.
|
||||
{
|
||||
# ACME account contact. Certificates use the HTTP-01 challenge on port 80 by default, which needs
|
||||
# the Ekklesia and storage hostnames to resolve publicly to this host with 80 and 443 reachable.
|
||||
email {$EKKLESIA_ACME_EMAIL}
|
||||
|
||||
# DNS-01, for an installation that keeps Ekklesia off the public Internet, serves only a LAN
|
||||
# (split-horizon DNS), or is reachable only through Cloudflare Tunnel. Cloudflare terminates TLS at
|
||||
# its edge, so HTTP-01 cannot reach this proxy through a tunnel.
|
||||
#
|
||||
# DNS-01 needs a DNS provider module that the stock Caddy image does not contain. Build an image
|
||||
# with it (for example `xcaddy build --with github.com/caddy-dns/cloudflare`), pin that image by
|
||||
# digest in your own overlay, give it CLOUDFLARE_API_TOKEN through a root-owned env file, then
|
||||
# enable:
|
||||
#
|
||||
# acme_dns cloudflare {env.CLOUDFLARE_API_TOKEN}
|
||||
|
||||
servers {
|
||||
# A client-address header is honored only from the tunnel connector's fixed address
|
||||
# (compose.tunnel.yaml). From every other peer, the client address is the TCP peer itself and
|
||||
# any such header is ignored. Without a tunnel, nothing has this address.
|
||||
trusted_proxies static {$EKKLESIA_TUNNEL_ADDRESS}/32
|
||||
client_ip_headers CF-Connecting-IP
|
||||
trusted_proxies_strict
|
||||
}
|
||||
|
||||
# Access logs are off unless enabled. If you enable them, keep request headers out: they carry
|
||||
# cookies and forwarding chains (docs/development/security.md, launch checklist item 7).
|
||||
}
|
||||
|
||||
# What every request to the API carries upstream: exactly one forwarding header, overwritten with
|
||||
# the client address Caddy resolved, and none of the headers a caller could use to choose its own.
|
||||
(ekklesia_forwarding) {
|
||||
header_up X-Forwarded-For {client_ip}
|
||||
header_up -CF-Connecting-IP
|
||||
header_up -X-Real-IP
|
||||
header_up -Forwarded
|
||||
header_up -True-Client-IP
|
||||
}
|
||||
|
||||
{$EKKLESIA_HOSTNAME} {
|
||||
# Everything the API serves: the Dashboard at /, the API, display pages and their service
|
||||
# worker, and the Dashboard and display host scripts. Any other path is refused here.
|
||||
@ekklesia path / /api/* /display/* /assets/*
|
||||
|
||||
# Module realtime is Server-Sent Events: flush every event immediately.
|
||||
@realtime path /api/v1/module-realtime/*
|
||||
|
||||
handle @realtime {
|
||||
reverse_proxy api:3000 {
|
||||
import ekklesia_forwarding
|
||||
flush_interval -1
|
||||
}
|
||||
}
|
||||
|
||||
handle @ekklesia {
|
||||
# Request bodies stream through unbuffered and unlimited here; the API enforces the platform's
|
||||
# ceilings itself (8 KiB for authentication, up to 32 MiB for a streamed module route).
|
||||
reverse_proxy api:3000 {
|
||||
import ekklesia_forwarding
|
||||
}
|
||||
}
|
||||
|
||||
handle {
|
||||
respond 404
|
||||
}
|
||||
}
|
||||
|
||||
# Browsers fetch signed object URLs here (S3_DELIVERY_ENDPOINT, ADR 0031).
|
||||
{$EKKLESIA_STORAGE_HOSTNAME} {
|
||||
# Delivery-layer CORS belongs to the deployment, not to the object store: this block decides it,
|
||||
# so it stays the same with any S3 backend behind it. Only the origins whose pages read objects
|
||||
# with fetch() get CORS: the Ekklesia origin, which serves the Dashboard and the displays. An <img> needs none. Every object
|
||||
# URL is still a signed, expiring capability; CORS only decides which pages may read the bytes.
|
||||
@cors_origin header Origin https://{$EKKLESIA_HOSTNAME}
|
||||
|
||||
# A preflight is answered here and never reaches storage. Only GET and HEAD are ever allowed, and
|
||||
# no request header beyond the CORS-safelisted ones.
|
||||
@preflight method OPTIONS
|
||||
handle @preflight {
|
||||
header @cors_origin {
|
||||
Access-Control-Allow-Origin {http.request.header.Origin}
|
||||
Access-Control-Allow-Methods "GET, HEAD"
|
||||
Access-Control-Max-Age 600
|
||||
}
|
||||
header Vary Origin
|
||||
respond 204
|
||||
}
|
||||
|
||||
handle {
|
||||
# Set when the response is written, after the upstream's own CORS headers were removed below.
|
||||
header @cors_origin >Access-Control-Allow-Origin {http.request.header.Origin}
|
||||
header >Vary Origin
|
||||
|
||||
reverse_proxy https://object-storage:8443 {
|
||||
# An S3 signature covers the Host header: pass the original through unchanged, or every
|
||||
# signed URL fails verification.
|
||||
header_up Host {host}
|
||||
header_up -CF-Connecting-IP
|
||||
header_up -X-Real-IP
|
||||
header_up -Forwarded
|
||||
header_up -True-Client-IP
|
||||
# The object store's own CORS answer (SeaweedFS reflects any origin, with every method)
|
||||
# is not the deployment's policy.
|
||||
header_down -Access-Control-Allow-Origin
|
||||
header_down -Access-Control-Allow-Methods
|
||||
header_down -Access-Control-Allow-Headers
|
||||
header_down -Access-Control-Expose-Headers
|
||||
header_down -Access-Control-Allow-Credentials
|
||||
header_down -Access-Control-Max-Age
|
||||
header_down -Vary
|
||||
transport http {
|
||||
# object-storage presents a certificate from the installation-private CA.
|
||||
tls_trust_pool file /etc/caddy/ekklesia-ca.crt
|
||||
tls_server_name object-storage
|
||||
# HTTP/1.1 only. Over HTTP/2, SeaweedFS answers a conditional request with a 304 that
|
||||
# declares a Content-Length it never sends; Caddy then aborts the browser's stream,
|
||||
# which a browser reports as ERR_HTTP2_PROTOCOL_ERROR on every revalidation. Its
|
||||
# HTTP/1.1 304 is clean. Browsers still reach this proxy over HTTP/2 and HTTP/3.
|
||||
versions 1.1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
# Ekklesia reference proxy configuration for the split topology (ADR 0045): the Dashboard and the API
|
||||
# on separate hostnames of one site, for example dash.example.org and api.example.org. For the
|
||||
# default same-origin topology use Caddyfile.example instead. Copy this file to deploy/Caddyfile.
|
||||
#
|
||||
# Hostnames, the ACME contact, and the tunnel connector's address come from the proxy service's
|
||||
# environment (deploy/.env): EKKLESIA_DASHBOARD_HOSTNAME is the Dashboard's, EKKLESIA_HOSTNAME the
|
||||
# API's (which also serves the display host), EKKLESIA_STORAGE_HOSTNAME the storage origin's. Only
|
||||
# the two site blocks below differ from Caddyfile.example.
|
||||
{
|
||||
# ACME account contact. Certificates use the HTTP-01 challenge on port 80 by default, which needs
|
||||
# all three hostnames to resolve publicly to this host with 80 and 443 reachable.
|
||||
email {$EKKLESIA_ACME_EMAIL}
|
||||
|
||||
# DNS-01, for an installation that keeps Ekklesia off the public Internet, serves only a LAN
|
||||
# (split-horizon DNS), or is reachable only through Cloudflare Tunnel. Cloudflare terminates TLS at
|
||||
# its edge, so HTTP-01 cannot reach this proxy through a tunnel.
|
||||
#
|
||||
# DNS-01 needs a DNS provider module that the stock Caddy image does not contain. Build an image
|
||||
# with it (for example `xcaddy build --with github.com/caddy-dns/cloudflare`), pin that image by
|
||||
# digest in your own overlay, give it CLOUDFLARE_API_TOKEN through a root-owned env file, then
|
||||
# enable:
|
||||
#
|
||||
# acme_dns cloudflare {env.CLOUDFLARE_API_TOKEN}
|
||||
|
||||
servers {
|
||||
# A client-address header is honored only from the tunnel connector's fixed address
|
||||
# (compose.tunnel.yaml). From every other peer, the client address is the TCP peer itself and
|
||||
# any such header is ignored. Without a tunnel, nothing has this address.
|
||||
trusted_proxies static {$EKKLESIA_TUNNEL_ADDRESS}/32
|
||||
client_ip_headers CF-Connecting-IP
|
||||
trusted_proxies_strict
|
||||
}
|
||||
|
||||
# Access logs are off unless enabled. If you enable them, keep request headers out: they carry
|
||||
# cookies and forwarding chains (docs/development/security.md, launch checklist item 7).
|
||||
}
|
||||
|
||||
# What every request to the API carries upstream: exactly one forwarding header, overwritten with
|
||||
# the client address Caddy resolved, and none of the headers a caller could use to choose its own.
|
||||
(ekklesia_forwarding) {
|
||||
header_up X-Forwarded-For {client_ip}
|
||||
header_up -CF-Connecting-IP
|
||||
header_up -X-Real-IP
|
||||
header_up -Forwarded
|
||||
header_up -True-Client-IP
|
||||
}
|
||||
|
||||
# The Dashboard: its page and its one script, nothing else. Emailed verification and reset links land
|
||||
# on `/` with a query. `/api/*` is refused here on purpose, so the session cookie has exactly one
|
||||
# home, the API hostname, and the Dashboard can never fall back to a same-origin API.
|
||||
{$EKKLESIA_DASHBOARD_HOSTNAME} {
|
||||
@dashboard path / /assets/ekklesia-dashboard/*
|
||||
|
||||
handle @dashboard {
|
||||
reverse_proxy api:3000 {
|
||||
import ekklesia_forwarding
|
||||
}
|
||||
}
|
||||
|
||||
handle {
|
||||
respond 404
|
||||
}
|
||||
}
|
||||
|
||||
# The API, and the display host on the API's own origin: a screen's pages, service worker, device
|
||||
# cookies, and requests are all same-origin (ADR 0045). Nothing is served at `/`.
|
||||
{$EKKLESIA_HOSTNAME} {
|
||||
@api path /api/* /display/* /assets/ekklesia-display/*
|
||||
|
||||
# Module realtime is Server-Sent Events: flush every event immediately.
|
||||
@realtime path /api/v1/module-realtime/*
|
||||
|
||||
handle @realtime {
|
||||
reverse_proxy api:3000 {
|
||||
import ekklesia_forwarding
|
||||
flush_interval -1
|
||||
}
|
||||
}
|
||||
|
||||
handle @api {
|
||||
# Request bodies stream through unbuffered and unlimited here; the API enforces the platform's
|
||||
# ceilings itself (8 KiB for authentication, up to 32 MiB for a streamed module route).
|
||||
reverse_proxy api:3000 {
|
||||
import ekklesia_forwarding
|
||||
}
|
||||
}
|
||||
|
||||
handle {
|
||||
respond 404
|
||||
}
|
||||
}
|
||||
|
||||
# Browsers fetch signed object URLs here (S3_DELIVERY_ENDPOINT, ADR 0031).
|
||||
{$EKKLESIA_STORAGE_HOSTNAME} {
|
||||
# Delivery-layer CORS belongs to the deployment, not to the object store: this block decides it,
|
||||
# so it stays the same with any S3 backend behind it. Only the origins whose pages read objects
|
||||
# with fetch() get CORS: the API origin, where displays run, and the Dashboard origin,
|
||||
# where a module's preview may cache images too. An <img> needs none. Every object
|
||||
# URL is still a signed, expiring capability; CORS only decides which pages may read the bytes.
|
||||
@cors_origin {
|
||||
header Origin https://{$EKKLESIA_HOSTNAME}
|
||||
header Origin https://{$EKKLESIA_DASHBOARD_HOSTNAME}
|
||||
}
|
||||
|
||||
# A preflight is answered here and never reaches storage. Only GET and HEAD are ever allowed, and
|
||||
# no request header beyond the CORS-safelisted ones.
|
||||
@preflight method OPTIONS
|
||||
handle @preflight {
|
||||
header @cors_origin {
|
||||
Access-Control-Allow-Origin {http.request.header.Origin}
|
||||
Access-Control-Allow-Methods "GET, HEAD"
|
||||
Access-Control-Max-Age 600
|
||||
}
|
||||
header Vary Origin
|
||||
respond 204
|
||||
}
|
||||
|
||||
handle {
|
||||
# Set when the response is written, after the upstream's own CORS headers were removed below.
|
||||
header @cors_origin >Access-Control-Allow-Origin {http.request.header.Origin}
|
||||
header >Vary Origin
|
||||
|
||||
reverse_proxy https://object-storage:8443 {
|
||||
# An S3 signature covers the Host header: pass the original through unchanged, or every
|
||||
# signed URL fails verification.
|
||||
header_up Host {host}
|
||||
header_up -CF-Connecting-IP
|
||||
header_up -X-Real-IP
|
||||
header_up -Forwarded
|
||||
header_up -True-Client-IP
|
||||
# The object store's own CORS answer (SeaweedFS reflects any origin, with every method)
|
||||
# is not the deployment's policy.
|
||||
header_down -Access-Control-Allow-Origin
|
||||
header_down -Access-Control-Allow-Methods
|
||||
header_down -Access-Control-Allow-Headers
|
||||
header_down -Access-Control-Expose-Headers
|
||||
header_down -Access-Control-Allow-Credentials
|
||||
header_down -Access-Control-Max-Age
|
||||
header_down -Vary
|
||||
transport http {
|
||||
# object-storage presents a certificate from the installation-private CA.
|
||||
tls_trust_pool file /etc/caddy/ekklesia-ca.crt
|
||||
tls_server_name object-storage
|
||||
# HTTP/1.1 only. Over HTTP/2, SeaweedFS answers a conditional request with a 304 that
|
||||
# declares a Content-Length it never sends; Caddy then aborts the browser's stream,
|
||||
# which a browser reports as ERR_HTTP2_PROTOCOL_ERROR on every revalidation. Its
|
||||
# HTTP/1.1 304 is clean. Browsers still reach this proxy over HTTP/2 and HTTP/3.
|
||||
versions 1.1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
# Ekklesia's one production image (ADR 0044): the `api` service and every operator-run CLI, from one
|
||||
# build. See deploy/README.md for how to build and run it.
|
||||
#
|
||||
# The build context is an already-assembled workspace: the platform, zero or more module packages in
|
||||
# `modules/<id>`, and the `pnpm-lock.yaml` that describes exactly that set. This file clones nothing
|
||||
# and chooses no modules. With an empty or absent `modules/`, it builds the platform alone.
|
||||
#
|
||||
# No `# syntax=` directive, deliberately: it would make BuildKit pull an unpinned frontend image.
|
||||
# The built-in frontend supports everything below, including cache mounts.
|
||||
|
||||
# Node.js 24.18.0 (matching `.node-version`) on Debian 13 slim, pinned by its multi-architecture
|
||||
# index digest. glibc rather than musl, because module dependencies such as sharp ship glibc
|
||||
# prebuilt binaries. Every stage starts from this exact image.
|
||||
FROM node:24.18.0-trixie-slim@sha256:ae91dcc111a68c9d2d81ff2a17bda61be126426176fde6fe7d08ab13b7f50573 AS base
|
||||
|
||||
# ---------------------------------------------------------------------------------------------------
|
||||
# toolchain: pnpm through Corepack, at exactly the root `packageManager` version.
|
||||
# ---------------------------------------------------------------------------------------------------
|
||||
FROM base AS toolchain
|
||||
ENV COREPACK_ENABLE_DOWNLOAD_PROMPT=0 \
|
||||
PNPM_HOME=/pnpm \
|
||||
TURBO_TELEMETRY_DISABLED=1 \
|
||||
DO_NOT_TRACK=1
|
||||
WORKDIR /opt/ekklesia
|
||||
COPY package.json ./
|
||||
# Corepack reads `packageManager` (pnpm@<exact version>) from the root manifest, so the version is
|
||||
# declared once, in the repository, and never restated here.
|
||||
RUN corepack enable && corepack install
|
||||
|
||||
# ---------------------------------------------------------------------------------------------------
|
||||
# manifests: the lockfile, workspace configuration, and every workspace package.json, at their
|
||||
# repository paths. Dependency installation keys on these alone, so a source-only change reuses the
|
||||
# installed layers.
|
||||
# ---------------------------------------------------------------------------------------------------
|
||||
FROM base AS manifests
|
||||
WORKDIR /context
|
||||
COPY . .
|
||||
RUN set -eu; \
|
||||
mkdir /manifests; \
|
||||
cp package.json pnpm-lock.yaml pnpm-workspace.yaml .npmrc /manifests/; \
|
||||
for manifest in apps/*/package.json builders/*/package.json core/package.json \
|
||||
modules/*/package.json packages/*/package.json surfaces/*/package.json \
|
||||
templates/*/package.json; do \
|
||||
if [ -f "$manifest" ]; then install -D -m 0644 "$manifest" "/manifests/$manifest"; fi; \
|
||||
done
|
||||
|
||||
# ---------------------------------------------------------------------------------------------------
|
||||
# build: full install, validation, build, then the exact runtime file set.
|
||||
# ---------------------------------------------------------------------------------------------------
|
||||
FROM toolchain AS build
|
||||
# Sources first, then one install: workspace packages publish `bin` commands from source files (the
|
||||
# module UI builder's, which every module build invokes), and pnpm links a bin only when its target
|
||||
# already exists. The store cache mount keeps a rebuild off the network all the same.
|
||||
COPY . .
|
||||
RUN --mount=type=cache,id=ekklesia-pnpm-store,target=/pnpm/store \
|
||||
pnpm install --frozen-lockfile --store-dir /pnpm/store
|
||||
# The same gates `pnpm verify` runs for boundaries, modules, and migrations, against exactly the
|
||||
# module set in this context. `pnpm boundaries` comes first and reads only sources: among its rules,
|
||||
# a module that imports something at run time without declaring it a production dependency fails
|
||||
# here, because the production-only install below would leave it unresolvable at API startup. Each
|
||||
# validate script builds first; the final build is a Turbo cache hit that keeps the intent explicit.
|
||||
RUN pnpm boundaries && pnpm modules:validate && pnpm migrations:validate && pnpm build
|
||||
# What the runtime resolves at startup, at the paths it resolves them from:
|
||||
# - every workspace package's `dist/` (every package exports only from `dist/`), which includes
|
||||
# the Dashboard and display host assets under `surfaces/shell/dist/browser/`;
|
||||
# - Core's migrations, `core/drizzle/` with its `meta/` journal;
|
||||
# - each module directory whole, minus `node_modules`, because each manifest names its own
|
||||
# entrypoints, UI artifacts, and migrations directory.
|
||||
# `node_modules` comes from the production-only install below, never from this stage.
|
||||
RUN set -eu; \
|
||||
mkdir -p /runtime/core /runtime/modules; \
|
||||
for package in apps/* core packages/* surfaces/*; do \
|
||||
if [ -d "$package/dist" ]; then \
|
||||
mkdir -p "/runtime/$package"; \
|
||||
cp -a "$package/dist" "/runtime/$package/dist"; \
|
||||
fi; \
|
||||
done; \
|
||||
cp -a core/drizzle /runtime/core/drizzle; \
|
||||
if [ -d modules ]; then \
|
||||
tar -C modules --exclude='node_modules' -cf - . | tar -C /runtime/modules -xf -; \
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------------------------------
|
||||
# prod-deps: the same lockfile, production dependencies only. No compiler, bundler, or test runner
|
||||
# reaches the runtime image.
|
||||
# ---------------------------------------------------------------------------------------------------
|
||||
FROM toolchain AS prod-deps
|
||||
COPY --from=manifests /manifests/ ./
|
||||
RUN --mount=type=cache,id=ekklesia-pnpm-store,target=/pnpm/store \
|
||||
pnpm install --frozen-lockfile --prod --store-dir /pnpm/store
|
||||
|
||||
# ---------------------------------------------------------------------------------------------------
|
||||
# runtime
|
||||
# ---------------------------------------------------------------------------------------------------
|
||||
FROM base AS runtime
|
||||
|
||||
ARG VERSION=""
|
||||
ARG REVISION=""
|
||||
ARG SOURCE=""
|
||||
LABEL org.opencontainers.image.title="Ekklesia" \
|
||||
org.opencontainers.image.description="Ekklesia API, Dashboard, display host, and operator CLIs" \
|
||||
org.opencontainers.image.version="${VERSION}" \
|
||||
org.opencontainers.image.revision="${REVISION}" \
|
||||
org.opencontainers.image.source="${SOURCE}" \
|
||||
org.opencontainers.image.base.name="docker.io/library/node:24.18.0-trixie-slim" \
|
||||
org.opencontainers.image.base.digest="sha256:ae91dcc111a68c9d2d81ff2a17bda61be126426176fde6fe7d08ab13b7f50573"
|
||||
|
||||
# Only the container's own listen address differs from the platform default: outside a container
|
||||
# the API still binds 127.0.0.1. `production` is stated rather than relied on as the default.
|
||||
ENV NODE_ENV=production \
|
||||
EKKLESIA_API_HOST=0.0.0.0 \
|
||||
EKKLESIA_API_PORT=3000
|
||||
|
||||
# The repository layout, preserved: the API finds the repository root three directories above
|
||||
# `apps/api/dist/`, and every CLI uses its working directory as the root.
|
||||
WORKDIR /opt/ekklesia
|
||||
COPY --from=prod-deps /opt/ekklesia/ ./
|
||||
COPY --from=build /runtime/ ./
|
||||
|
||||
# Files stay root-owned: the unprivileged runtime user can read the installation, never modify it.
|
||||
USER node
|
||||
EXPOSE 3000
|
||||
|
||||
# Readiness, not liveness: an instance whose database is unreachable or that is shutting down is
|
||||
# unhealthy. Node's own fetch, so the image needs no curl.
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=30s --retries=3 \
|
||||
CMD ["node", "-e", "fetch('http://127.0.0.1:' + (process.env.EKKLESIA_API_PORT || '3000') + '/api/v1/health/ready', { signal: AbortSignal.timeout(4000) }).then((response) => process.exit(response.status === 200 ? 0 : 1), () => process.exit(1))"]
|
||||
|
||||
# `pnpm api:start` without its build step and without `--env-file`: configuration comes only from
|
||||
# the container environment. The same image runs every CLI: `node core/dist/cli/<command>.js`.
|
||||
CMD ["node", "apps/api/dist/server.js"]
|
||||
@@ -0,0 +1,503 @@
|
||||
# Deployment
|
||||
|
||||
This directory holds Ekklesia's reference self-hosted deployment
|
||||
([ADR 0044](../docs/architecture/decisions/0044-self-hosted-reference-deployment.md)): one Linux
|
||||
host, one Docker Compose project, and the production container image it runs. Nothing here is
|
||||
specific to one organization.
|
||||
|
||||
| File | What it is |
|
||||
| --- | --- |
|
||||
| [`compose.yaml`](compose.yaml) | The base: `api`, `postgres`, `object-storage`, and the operator-only `migrate`. Publishes no port. |
|
||||
| [`compose.proxy.yaml`](compose.proxy.yaml) | Overlay: Caddy as the TLS proxy, the only service with published ports. |
|
||||
| [`compose.tunnel.yaml`](compose.tunnel.yaml) | Overlay on top of the proxy: a Cloudflare Tunnel connector. |
|
||||
| [`Caddyfile.example`](Caddyfile.example) | The proxy's configuration for the default same-origin topology; copy to `Caddyfile`. |
|
||||
| [`Caddyfile.split.example`](Caddyfile.split.example) | The proxy's configuration when the Dashboard and the API have separate hostnames ([Split Dashboard and API origins](#split-dashboard-and-api-origins)); copy to `Caddyfile` instead. |
|
||||
| [`postgres/pg_hba.conf`](postgres/pg_hba.conf) | PostgreSQL client authentication: TLS only, SCRAM passwords. |
|
||||
| [`env/`](env/) | Commented examples of every env file. No values. |
|
||||
| [`scripts/init-tls.sh`](scripts/init-tls.sh) | Creates the installation-private CA and the `postgres` and `object-storage` server certificates. |
|
||||
| [`scripts/gen-secrets.sh`](scripts/gen-secrets.sh) | Prints freshly generated secrets to paste into the env files. Writes nothing. |
|
||||
| [`Dockerfile`](Dockerfile) | The one production image, for the API and every operator command ([The production image](#the-production-image)). |
|
||||
|
||||
## The reference deployment
|
||||
|
||||
### Shape
|
||||
|
||||
```sh
|
||||
cd deploy
|
||||
# Base and proxy: the reference.
|
||||
docker compose -f compose.yaml -f compose.proxy.yaml up -d
|
||||
# Base, proxy, and Cloudflare Tunnel.
|
||||
docker compose -f compose.yaml -f compose.proxy.yaml -f compose.tunnel.yaml up -d
|
||||
# Base only, behind a reverse proxy the installation already runs.
|
||||
docker compose -f compose.yaml -f compose.myproxy.yaml up -d
|
||||
```
|
||||
|
||||
Pass the same `-f` files to every later `up`, `down`, `ps`, and `logs`. `migrate` commands need only
|
||||
the base: `docker compose run --rm migrate …` from this directory uses `compose.yaml` by default.
|
||||
|
||||
| Network | Kind | Members |
|
||||
| --- | --- | --- |
|
||||
| `data` | internal | `postgres`, `api`, `migrate` |
|
||||
| `storage` | internal | `object-storage`, `api`, `migrate` |
|
||||
| `egress` | bridge | `api`, `migrate` (outbound only, for example an SMTP relay) |
|
||||
| `edge` | bridge, fixed subnet (proxy overlay) | `proxy` (fixed address), `api`, `object-storage`, `tunnel` (fixed address) |
|
||||
|
||||
PostgreSQL is only ever on `data`, so no proxy and no tunnel can reach it. `internal` networks have
|
||||
no route off the host. Object storage joins `edge` only when the proxy overlay attaches it.
|
||||
|
||||
TLS is everywhere, without any exception in Core:
|
||||
|
||||
- **Browsers to the proxy:** publicly trusted ACME certificates.
|
||||
- **API to PostgreSQL:** `verify-full` against the installation-private CA, passed as PEM text in
|
||||
`EKKLESIA_DATABASE_CA`. PostgreSQL accepts only TLS 1.3 network connections.
|
||||
- **API (and proxy) to object storage:** HTTPS on port 8443 with a certificate from the same CA.
|
||||
Production storage must be https (ADR 0030). Core's S3 client uses Node's default trust store, so
|
||||
Compose adds the CA with `NODE_EXTRA_CA_CERTS`.
|
||||
|
||||
### Where configuration and secrets live
|
||||
|
||||
| File (on the host) | Holds | Read by |
|
||||
| --- | --- | --- |
|
||||
| `deploy/.env` (from [`env/compose.env.example`](env/compose.env.example)) | Non-secret settings: the image, hostnames, paths, the edge subnet | Compose itself |
|
||||
| `/etc/ekklesia/ekklesia.env` | Everything the API needs, including `DATABASE_URL` and the S3 keys | `api`, `migrate` |
|
||||
| `/etc/ekklesia/postgres.env` | `POSTGRES_USER`, `POSTGRES_PASSWORD`, `POSTGRES_DB` | `postgres` |
|
||||
| `/etc/ekklesia/object-storage.env` | The S3 key pair | `object-storage` |
|
||||
| `/etc/ekklesia/tunnel.env` | `TUNNEL_TOKEN` | `tunnel` |
|
||||
| `/etc/ekklesia/tls/` | The CA and server certificates and keys | mounted file by file |
|
||||
|
||||
The secret files are owned by root with mode 600, one per service, so no container receives another
|
||||
service's credentials. None is committed, and `deploy/.env` is ignored by Git. `EKKLESIA_ENV_DIR`
|
||||
and `EKKLESIA_TLS_DIR` in `deploy/.env` move the directories.
|
||||
|
||||
### First boot
|
||||
|
||||
1. **Get the image.** Build it ([Building](#building)) or pull it, and set `EKKLESIA_IMAGE` in
|
||||
`deploy/.env`.
|
||||
2. **Create the TLS material**, as root:
|
||||
|
||||
```sh
|
||||
sudo sh deploy/scripts/init-tls.sh /etc/ekklesia/tls
|
||||
```
|
||||
|
||||
It creates the CA once and a certificate for each of `postgres` and `object-storage`, gives each
|
||||
key to its server's uid (PostgreSQL 70, SeaweedFS 1000) with mode 600, and prints the exact
|
||||
`EKKLESIA_DATABASE_CA="…"` line. It never overwrites an existing key or certificate and never
|
||||
prints a private key. `ca.key` is needed only to issue certificates: keep a copy offline.
|
||||
3. **Generate secrets** with `sh deploy/scripts/gen-secrets.sh`, and paste each line into the file
|
||||
named above it.
|
||||
4. **Write the env files** from [`env/`](env/), then install each one owned by root with mode 600:
|
||||
|
||||
```sh
|
||||
sudo install -d -m 755 /etc/ekklesia
|
||||
sudo install -o root -g root -m 600 ekklesia.env /etc/ekklesia/ekklesia.env
|
||||
```
|
||||
|
||||
`EKKLESIA_IDENTITY_BASE_URL` and `EKKLESIA_IDENTITY_TRUSTED_ORIGINS` are `https://` and your
|
||||
Ekklesia hostname. `S3_DELIVERY_ENDPOINT` is `https://` and your storage hostname.
|
||||
5. **Configure the proxy:** copy `Caddyfile.example` to `Caddyfile`, and set `EKKLESIA_HOSTNAME`,
|
||||
`EKKLESIA_STORAGE_HOSTNAME`, and `EKKLESIA_ACME_EMAIL` in `deploy/.env`. Both hostnames must
|
||||
resolve to this host with ports 80 and 443 reachable for HTTP-01, or see [DNS-01](#dns-01).
|
||||
6. **Start the data services, then migrate explicitly:**
|
||||
|
||||
```sh
|
||||
docker compose -f compose.yaml -f compose.proxy.yaml up -d --wait postgres object-storage
|
||||
docker compose run --rm migrate # plan: read-only
|
||||
docker compose run --rm migrate node core/dist/cli/apply-migrations.js
|
||||
```
|
||||
|
||||
Nothing ever applies migrations automatically: the API never does, and `migrate` runs only when
|
||||
you run it.
|
||||
7. **Start everything:** `docker compose -f compose.yaml -f compose.proxy.yaml up -d --wait`.
|
||||
8. **Check it:** `curl https://<hostname>/api/v1/health/ready` answers `{"status":"ready"}`, and
|
||||
`docker compose run --rm migrate node core/dist/cli/doctor.js` reports `READY` (with warnings
|
||||
for anything optional you left unconfigured, such as email).
|
||||
|
||||
### The initial owner
|
||||
|
||||
```sh
|
||||
docker compose run --rm -it migrate node core/dist/cli/create-initial-owner.js --email owner@example.org
|
||||
```
|
||||
|
||||
It prompts for the password with hidden input and never accepts it on the command line. The owner
|
||||
is created already verified, so they can sign in before any email relay exists
|
||||
([ADR 0035](../docs/architecture/decisions/0035-initial-platform-owner-bootstrap.md)). To supply the
|
||||
password from a secret manager instead, pipe it on standard input and use `-T` instead of `-it`.
|
||||
|
||||
### Upgrading
|
||||
|
||||
1. **Pull or build the new image**, and change `EKKLESIA_IMAGE` in `deploy/.env`. Keep the old tag.
|
||||
2. **Back up** ([Backups](#backups)).
|
||||
3. **Plan, review, then apply:** `docker compose run --rm migrate` with the new image, then
|
||||
`docker compose run --rm migrate node core/dist/cli/apply-migrations.js`.
|
||||
4. **Restart on the new image:** `docker compose -f compose.yaml -f compose.proxy.yaml up -d --wait`.
|
||||
|
||||
### Rolling back
|
||||
|
||||
Set `EKKLESIA_IMAGE` back to the previous tag and run `up -d --wait` again. That is enough only when
|
||||
the upgrade applied no migrations (the plan in step 3 showed none pending). Migrations are
|
||||
forward-only. If any were applied, restore the backup taken in step 2 before starting the previous
|
||||
image, because it was not built for the newer schema.
|
||||
|
||||
### Backups
|
||||
|
||||
Slice 6 adds the scheduled backup job and the restore procedure (ADR 0044, Backups). Until then,
|
||||
take a `pg_dump` and a copy of every bucket before each upgrade by hand.
|
||||
|
||||
### Split Dashboard and API origins
|
||||
|
||||
By default one hostname serves the Dashboard (`/`), the API (`/api/*`), and displays (`/display/*`).
|
||||
An installation may instead give the Dashboard its own hostname, for example `dash.example.org`
|
||||
beside `api.example.org` ([ADR 0045](../docs/architecture/decisions/0045-split-dashboard-and-api-origins.md)).
|
||||
The same image serves both; only configuration differs.
|
||||
|
||||
| Hostname | Serves | Refuses (`404`) |
|
||||
| --- | --- | --- |
|
||||
| Dashboard (`EKKLESIA_DASHBOARD_HOSTNAME`) | `/` and `/assets/ekklesia-dashboard/*` | everything else, including `/api/*` |
|
||||
| API (`EKKLESIA_HOSTNAME`) | `/api/*`, `/display/*`, `/assets/ekklesia-display/*` | everything else, including `/` |
|
||||
| Storage (`EKKLESIA_STORAGE_HOSTNAME`) | signed object URLs, unchanged | |
|
||||
|
||||
Displays live on the API's hostname (`https://api.example.org/display/<module>`), because their
|
||||
device cookies are bound to it and their offline cache keeps only same-origin responses. That
|
||||
hostname is what every screen and paired device depends on: choose it once.
|
||||
|
||||
To run split:
|
||||
|
||||
1. Copy `Caddyfile.split.example` to `Caddyfile` instead of `Caddyfile.example`.
|
||||
2. In `deploy/.env`, set `EKKLESIA_HOSTNAME` to the API's hostname and `EKKLESIA_DASHBOARD_HOSTNAME`
|
||||
to the Dashboard's.
|
||||
3. In `ekklesia.env`, set `EKKLESIA_IDENTITY_BASE_URL` to `https://` and the API's hostname,
|
||||
`EKKLESIA_DASHBOARD_ORIGIN` to `https://` and the Dashboard's, and
|
||||
`EKKLESIA_IDENTITY_TRUSTED_ORIGINS` to the Dashboard's origin. The API always trusts its own
|
||||
origin, so it need not be listed.
|
||||
|
||||
**Both hostnames must belong to one registrable domain** (`dash.example.org` and `api.example.org`,
|
||||
not `dash.example.org` and `api.example.net`). The session cookie stays host-only on the API's
|
||||
hostname; browsers send it from the Dashboard only between origins of the same site. Doctor reports
|
||||
the topology and warns when the two hostnames are not siblings.
|
||||
|
||||
The Dashboard learns the API's origin at run time: the API writes its identity base URL into the page
|
||||
it serves (`<meta name="ekklesia-api-origin">`). Nothing about the topology is built into the image.
|
||||
|
||||
### Storage delivery: CORS is the deployment's
|
||||
|
||||
Browsers fetch objects directly from the storage hostname through signed, expiring URLs (ADR 0031).
|
||||
**Which pages may read those bytes with `fetch()` is decided by the delivery layer, the proxy, and
|
||||
never by the object store.** That policy therefore stays the same with SeaweedFS, R2, or any other
|
||||
S3 backend behind it, and an installation that changes backends changes nothing about CORS.
|
||||
|
||||
The reference proxy's storage block:
|
||||
|
||||
- grants CORS only to the origins whose pages read objects with `fetch()`: the Ekklesia origin in the
|
||||
same-origin topology; the API origin (displays) and the Dashboard origin (a module's preview) in
|
||||
the split topology. An `<img>` needs no CORS and is unaffected;
|
||||
- allows `GET` and `HEAD` only, and no request header beyond the CORS-safelisted ones, always with
|
||||
`Vary: Origin`;
|
||||
- answers preflight `OPTIONS` requests itself, so they never reach storage;
|
||||
- removes the object store's own CORS headers. SeaweedFS's default reflects any origin and
|
||||
advertises `PUT`, `POST`, and `DELETE`.
|
||||
|
||||
The proxy reaches storage over **HTTP/1.1 only**. Over HTTP/2, SeaweedFS answers a conditional
|
||||
request with a `304` that declares a `Content-Length` it never sends. The proxy then resets the
|
||||
browser's stream, and every reload of a cached image fails with `ERR_HTTP2_PROTOCOL_ERROR`.
|
||||
Browsers still reach the proxy itself over HTTP/2 and HTTP/3.
|
||||
|
||||
### Behind an existing reverse proxy
|
||||
|
||||
Run the base alone and attach `api` and `object-storage` to your proxy's network with an overlay of
|
||||
your own, for example `compose.myproxy.yaml`:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
api:
|
||||
networks:
|
||||
proxy: {}
|
||||
environment:
|
||||
# Exactly one trusted peer: your proxy, at an address that cannot change.
|
||||
EKKLESIA_CLIENT_ADDRESS_MODE: trusted-proxy
|
||||
EKKLESIA_CLIENT_ADDRESS_HEADER: x-forwarded-for
|
||||
EKKLESIA_CLIENT_ADDRESS_TRUSTED_PROXIES: 10.0.100.254/32
|
||||
object-storage:
|
||||
networks:
|
||||
proxy: {}
|
||||
|
||||
networks:
|
||||
proxy:
|
||||
external: true
|
||||
name: your-proxy-network
|
||||
```
|
||||
|
||||
Then make your proxy do what `Caddyfile.example` does:
|
||||
|
||||
- forward `/`, `/api/*`, `/display/*`, and `/assets/*` on the Ekklesia hostname to `api:3000`;
|
||||
- **overwrite** `X-Forwarded-For` with the one client address it resolved, and strip
|
||||
`CF-Connecting-IP`, `X-Real-IP`, `Forwarded`, and `True-Client-IP`;
|
||||
- stream responses on `/api/v1/module-realtime/*` without buffering (Server-Sent Events);
|
||||
- set no request-body limit below 32 MiB; the API enforces its own ceilings;
|
||||
- forward the storage hostname to `https://object-storage:8443`, verifying it against
|
||||
`/etc/ekklesia/tls/ca.crt` with server name `object-storage`, and **pass the original `Host`
|
||||
through unchanged**, or every signed URL fails (ADR 0031).
|
||||
- reach `object-storage` over **HTTP/1.1** only, and own the storage hostname's CORS as described in
|
||||
[Storage delivery](#storage-delivery-cors-is-the-deployments).
|
||||
|
||||
The proxy's address must be fixed and must never be handed to anything else. With Docker, give its
|
||||
network an explicit subnet, limit `ip_range` to part of it, and fix the proxy's address outside that
|
||||
range, as `compose.proxy.yaml` does.
|
||||
|
||||
### Fixed addresses on `edge`
|
||||
|
||||
`compose.proxy.yaml` gives `edge` the subnet `10.249.250.0/24` and lets Docker assign dynamic
|
||||
addresses only from its lower half (`10.249.250.0/25`). The proxy (`.254`) and the tunnel connector
|
||||
(`.253`) take fixed addresses from the top of the upper half, which Docker never assigns to anything
|
||||
else. That is what makes "only the proxy" and "only the connector" expressible as `/32` trust
|
||||
entries. If the subnet collides with one on your host, change `EKKLESIA_EDGE_SUBNET`,
|
||||
`EKKLESIA_EDGE_DYNAMIC_RANGE`, `EKKLESIA_PROXY_ADDRESS`, and `EKKLESIA_TUNNEL_ADDRESS` together,
|
||||
keeping the fixed addresses at the top and outside the dynamic range.
|
||||
|
||||
### Cloudflare Tunnel
|
||||
|
||||
`compose.tunnel.yaml` runs `cloudflared` with a token, so the tunnel is managed in the Cloudflare
|
||||
dashboard. No Cloudflare API is called from here.
|
||||
|
||||
1. In Cloudflare Zero Trust, under **Networks → Tunnels**, create a Cloudflared tunnel. Copy its
|
||||
token into `/etc/ekklesia/tunnel.env` as `TUNNEL_TOKEN`.
|
||||
2. Add a **public hostname** (shown as a *published application route* in newer dashboards) for
|
||||
the Ekklesia hostname. Service type **HTTPS**, URL `proxy:443`.
|
||||
3. Under that hostname's **TLS** settings, set **Origin Server Name** to the same Ekklesia hostname,
|
||||
and leave **No TLS Verify** off. The connector then verifies the proxy's certificate for that name.
|
||||
4. Add a public hostname for the storage hostname, the same way, with its own origin server name.
|
||||
In the [split topology](#split-dashboard-and-api-origins), add one for the Dashboard's hostname
|
||||
too: three in all, each with its own origin server name.
|
||||
5. Start with all three files. The proxy honors `CF-Connecting-IP` only from the connector's fixed
|
||||
address. From any other peer it is stripped.
|
||||
|
||||
The proxy's certificate must still be publicly trusted, and an HTTP-01 challenge cannot reach the
|
||||
proxy through a tunnel, because Cloudflare terminates TLS at its edge. A tunnel-only installation
|
||||
therefore uses [DNS-01](#dns-01). An installation that also serves local displays should give the
|
||||
same hostnames a LAN path to the proxy (split-horizon DNS), so displays do not depend on the
|
||||
Internet link.
|
||||
|
||||
### DNS-01
|
||||
|
||||
For an installation off the public Internet, LAN-only, or tunnel-only. DNS-01 needs a DNS provider
|
||||
module, which the stock Caddy image does not contain:
|
||||
|
||||
1. Build a Caddy image with the module (for example
|
||||
`xcaddy build --with github.com/caddy-dns/cloudflare`) and pin it by digest.
|
||||
2. Override the proxy's `image:` in an overlay of your own. Give it `CLOUDFLARE_API_TOKEN` (a token
|
||||
scoped to DNS edits for the zone) through a root-owned env file.
|
||||
3. Enable the commented `acme_dns` line in your `Caddyfile`.
|
||||
|
||||
### Launch checklist
|
||||
|
||||
The ten items in [Security § launch checklist](../docs/development/security.md) mapped to this
|
||||
deployment:
|
||||
|
||||
| # | Item | Where it is met |
|
||||
| --- | --- | --- |
|
||||
| 1 | The proxy forwards the Dashboard and `/api` on the intended origin | `Caddyfile.example` routes `/`, `/api/*`, `/display/*`, `/assets/*` on one hostname to `api`; the API serves the Dashboard itself. In the split topology, `Caddyfile.split.example` gives each hostname exactly its own paths and the exact trusted-origin CORS of ADR 0045 covers the rest. |
|
||||
| 2 | The origin port is unreachable from the Internet | The base publishes no port; only `proxy` publishes 80 and 443. |
|
||||
| 3 | The origin port is unreachable from untrusted networks | `api` is reachable only on Docker networks; `EKKLESIA_PUBLISH_ADDRESS` limits the proxy to one interface. Firewall the host itself. |
|
||||
| 4 | The proxy overwrites the trusted client-IP header | `header_up X-Forwarded-For {client_ip}` in `Caddyfile.example`. |
|
||||
| 5 | Rotating forwarding headers cannot rotate the rate-limit bucket | The same, plus stripping `CF-Connecting-IP`, `X-Real-IP`, `Forwarded`, and `True-Client-IP`; the API overwrites its internal header. Proven by the smoke test. |
|
||||
| 6 | The proxy peer is the only trusted proxy | `compose.proxy.yaml` sets `EKKLESIA_CLIENT_ADDRESS_TRUSTED_PROXIES` to the proxy's fixed `/32`. |
|
||||
| 7 | Proxy logs record no cookies, credentials, bodies, or forwarding chains | Caddy access logs are off in `Caddyfile.example`. If you enable them, exclude request headers. |
|
||||
| 8 | PostgreSQL only from the API, over verified TLS | `postgres` is only on internal `data`; `pg_hba.conf` rejects non-TLS; `verify-full` with `EKKLESIA_DATABASE_CA`. Proven by the smoke test. |
|
||||
| 9 | The deployed login and logout flow keeps its contract | Unchanged by the proxy; the smoke test signs in and out through it. |
|
||||
| 10 | A supported way to create the first production identity | `create-initial-owner.js` through `migrate` ([The initial owner](#the-initial-owner)). Exercised by the smoke test. |
|
||||
|
||||
### Testing this deployment
|
||||
|
||||
- `pnpm deploy:config` validates the base, base + proxy, and base + proxy + tunnel with the example
|
||||
env files. CI runs it in `pnpm ci:verify`.
|
||||
- `pnpm deploy:smoke` (`scripts/smoke-deploy.sh`) runs the whole deployment end to end, against a
|
||||
platform-only image and a disposable PostgreSQL and SeaweedFS that exist only for that run. It
|
||||
follows the first boot above and checks the following through the proxy:
|
||||
- readiness and the Dashboard;
|
||||
- a signed object URL fetched through the storage hostname, and the same image loaded twice over
|
||||
HTTP/2, the second time as a conditional request that must be a clean `304`;
|
||||
- storage CORS: exactly the allowed origins, `GET` and `HEAD` only, preflights answered by the
|
||||
proxy, and none of the object store's own CORS headers;
|
||||
- the client-address chain, both from an ordinary peer and from the tunnel connector's address;
|
||||
- initial-owner sign-in and sign-out;
|
||||
- that the API's database connection is TLS 1.3, and that it is refused by a foreign CA and by a
|
||||
wrong host name;
|
||||
- Doctor.
|
||||
|
||||
`EKKLESIA_SMOKE_TOPOLOGY=split` runs the [split topology](#split-dashboard-and-api-origins)
|
||||
instead: an image with the repository's module template installed as a test-only module, every
|
||||
hostname's served and refused paths, exact CORS for the Dashboard's origin and none for others,
|
||||
and then a real headless Chromium (pinned by digest, driven over the DevTools protocol) that signs
|
||||
in on the Dashboard's hostname, checks the session cookie lives only on the API's, mounts the
|
||||
module's UI from the API's origin and sees its own credentialed requests succeed, mounts its display
|
||||
on the API's hostname with its service worker, and signs out.
|
||||
|
||||
CI runs both topologies on every change.
|
||||
- **Test-only:** the smoke test runs Caddy with its internal CA (`local_certs`), because a test run
|
||||
has no public hostname. Never use it in an installation; `Caddyfile.example` does not contain it.
|
||||
|
||||
The smoke test uses no module, so it does not exercise realtime streams or large uploads. Through
|
||||
this same proxy configuration, a scratch module confirmed both:
|
||||
|
||||
- a Server-Sent Event crossed the proxy in milliseconds;
|
||||
- a 32 MiB streamed upload passed with and without `Content-Length`;
|
||||
- one byte more was refused by the API's own `413 REQUEST_BODY_TOO_LARGE`.
|
||||
|
||||
## The production image
|
||||
|
||||
[`Dockerfile`](Dockerfile) builds **one image** for two jobs: the long-running `api` service, and
|
||||
every operator-run command (migrations, Doctor, initial-owner creation, module enablement). There
|
||||
is no second image and no second build.
|
||||
|
||||
### What it builds
|
||||
|
||||
The build context is an **already-assembled workspace**: this repository, plus zero or more module
|
||||
packages already present in `modules/<id>`, plus a `pnpm-lock.yaml` describing exactly that set.
|
||||
The Dockerfile clones nothing and chooses no modules. It builds what it is given. With no
|
||||
`modules/` directory, it builds the platform alone; CI builds that case on every change.
|
||||
|
||||
Assembling the context (checking out the platform at a release tag, each module at a pinned tag or
|
||||
commit, and the instance's own lockfile) belongs to the installation's instance composition, not to
|
||||
this file.
|
||||
|
||||
| Stage | Does |
|
||||
| --- | --- |
|
||||
| `base` | `node:24.18.0-trixie-slim`, pinned by its multi-architecture index digest. Debian, so glibc, because module dependencies such as sharp ship glibc prebuilt binaries. Every stage starts here. |
|
||||
| `toolchain` | Enables pnpm through Corepack, at exactly the root `packageManager` version. |
|
||||
| `manifests` | Collects the lockfile, workspace configuration, `.npmrc`, and every workspace `package.json` at its repository path. |
|
||||
| `build` | `pnpm install --frozen-lockfile`, then `pnpm boundaries`, `pnpm modules:validate`, `pnpm migrations:validate`, and `pnpm build`, against exactly the modules in the context. Never uses host `node_modules`: `.dockerignore` excludes them. |
|
||||
| `prod-deps` | The same lockfile with `--prod`: production dependencies only. |
|
||||
| `runtime` | Production dependencies plus built outputs, owned by root and run as the unprivileged `node` user (uid 1000). No pnpm, no compiler, no curl. |
|
||||
|
||||
The runtime keeps the repository layout at `/opt/ekklesia`, because that layout is how the platform
|
||||
finds its files:
|
||||
|
||||
- the API takes the repository root as three directories above `apps/api/dist/`, and reads
|
||||
`package.json`, `packages/module-sdk/package.json`, and `modules/` beneath it;
|
||||
- every CLI takes its **working directory** as the root and also reads Core's migrations in
|
||||
`core/drizzle/`, so commands must run from `/opt/ekklesia`, the image's `WORKDIR`;
|
||||
- the Dashboard and display host files are read from `surfaces/shell/dist/browser/`;
|
||||
- each module directory is copied whole, minus `node_modules`, because each manifest names its
|
||||
own entrypoints, UI artifacts, and migrations directory.
|
||||
|
||||
### Building
|
||||
|
||||
```sh
|
||||
docker build \
|
||||
--file deploy/Dockerfile \
|
||||
--build-arg VERSION=0.1.2 \
|
||||
--build-arg REVISION="$(git rev-parse HEAD)" \
|
||||
--build-arg SOURCE=https://git.example.org/your-org/ekklesia \
|
||||
--tag ekklesia:0.1.2 \
|
||||
.
|
||||
```
|
||||
|
||||
The three build arguments become the OCI labels `org.opencontainers.image.version`, `.revision`,
|
||||
and `.source`. They have no defaults: the repository cannot know an installation's Git host. The
|
||||
image also records its base image name and digest. BuildKit is required (Docker's default builder);
|
||||
set `DOCKER_BUILDKIT=1` if your Docker CLI has no buildx plugin.
|
||||
|
||||
No secret is a build argument, and none enters any layer. `.env` files are excluded from the build
|
||||
context entirely, and no registry credentials are needed.
|
||||
|
||||
**A module missing a production dependency fails the build, not the API.** The runtime receives
|
||||
production dependencies only, so a module that imports a package at run time but declares it only
|
||||
as a development or peer dependency would build and then fail at startup. `pnpm boundaries` runs
|
||||
in the build stage and rejects that module first
|
||||
(`runtime-imports-are-production-dependencies`, see
|
||||
[Repository boundaries](../docs/architecture/repository-boundaries.md)). The same applies to every
|
||||
other boundary rule: a module the platform's own CI would reject cannot be built into an image.
|
||||
|
||||
**A platform-only image does not exercise native module dependencies.** With no modules in the
|
||||
context, nothing like sharp is installed, so the CI image build proves nothing about them. An
|
||||
instance build that includes such a module must smoke-test the built image itself: start it,
|
||||
confirm `/api/v1/health/ready` answers `200`, and exercise the module's native code path (for
|
||||
example, process one image).
|
||||
|
||||
### Configuration
|
||||
|
||||
Configuration comes **only from the container environment**, never from a file inside the image.
|
||||
The image sets exactly three variables itself:
|
||||
|
||||
| Variable | Image value | Why |
|
||||
| --- | --- | --- |
|
||||
| `NODE_ENV` | `production` | Stated rather than relied on. Anything other than `development` already validates as production. |
|
||||
| `EKKLESIA_API_HOST` | `0.0.0.0` | Reachable from other containers. Outside a container the platform default stays `127.0.0.1`. |
|
||||
| `EKKLESIA_API_PORT` | `3000` | The platform default, made explicit; `EXPOSE 3000`. |
|
||||
|
||||
Everything else is the ordinary host configuration in
|
||||
[API § Required configuration](../docs/development/api.md#required-configuration), supplied by the
|
||||
reference deployment's `ekklesia.env`. `EKKLESIA_DATABASE_CA` is the CA's PEM text itself, on one
|
||||
double-quoted line with `\n` escapes. Compose expands those escapes; plain `docker run --env-file`
|
||||
does not, which is why every command here goes through Compose.
|
||||
|
||||
Never publish the API's port to the host or the Internet. Only the proxy publishes ports.
|
||||
|
||||
### Process, signals, and the read-only posture
|
||||
|
||||
`compose.yaml` runs `api` and `migrate` with `init: true`, `read_only: true`, `tmpfs: /tmp`,
|
||||
`cap_drop: [ALL]`, and `no-new-privileges`. That is the supported production posture:
|
||||
|
||||
- **`init: true`** runs a minimal init as PID 1 that forwards signals and reaps children, so
|
||||
`docker stop` reaches the API's own SIGTERM handler promptly. The API then drains in-flight
|
||||
requests, stops modules in reverse order, and closes its pool.
|
||||
- **Read-only:** the image was verified under exactly these flags. The API reached `healthy`, and
|
||||
the migration and Doctor commands ran. Nothing wrote to `/tmp`, which stays mounted as headroom
|
||||
for module code that needs scratch space. The installation, `/home/node` included, is not
|
||||
writable, so anything that needs a writable path other than `/tmp` is a defect to report, not to
|
||||
work around.
|
||||
|
||||
### Health
|
||||
|
||||
The image's `HEALTHCHECK` calls `GET /api/v1/health/ready` on `127.0.0.1` with Node's built-in
|
||||
`fetch`, every 30 seconds after a 30-second start period, with three retries. Readiness means the
|
||||
database answered within 2 seconds and the instance is not shutting down; see
|
||||
[API § Health](../docs/development/api.md#health). Because it probes `127.0.0.1`, keep
|
||||
`EKKLESIA_API_HOST` at `0.0.0.0` (or any address that includes loopback) inside the container. The
|
||||
API trusts only the proxy's address for client-address headers, so the loopback probe is an
|
||||
ordinary direct peer.
|
||||
|
||||
An external monitor can use the same endpoint through the proxy. Use `/api/v1/health/live` to ask
|
||||
only whether the process is serving.
|
||||
|
||||
### Operator commands
|
||||
|
||||
The same image runs every CLI, directly with `node core/dist/cli/<command>.js`, through the
|
||||
`migrate` service. The `pnpm` scripts of the same names rebuild first and read `.env`, so they are
|
||||
for a development checkout, not for the image.
|
||||
|
||||
`migrate` has `profiles: ["ops"]`, so `up` never starts it. It never restarts, has no health check,
|
||||
and its default command is the read-only plan. **Nothing applies migrations automatically.**
|
||||
|
||||
Run operator commands with the same Compose files as the stack. `COMPOSE_FILE` in `deploy/.env`
|
||||
(set in [`env/compose.env.example`](env/compose.env.example)) does that for every plain
|
||||
`docker compose` command below. It matters for Doctor in particular: Doctor checks the
|
||||
client-address configuration of the process it runs in. The proxy overlay gives `migrate` the
|
||||
API's trusted-proxy settings, so Doctor reports what the API runs. Without the overlay, Doctor
|
||||
reports direct mode for an API that trusts its proxy. The running API's own value is shown by
|
||||
`docker compose exec api printenv EKKLESIA_CLIENT_ADDRESS_MODE`.
|
||||
|
||||
| Task | Command (from `deploy/`) |
|
||||
| --- | --- |
|
||||
| Plan migrations (read-only) | `docker compose run --rm migrate` |
|
||||
| Apply migrations | `docker compose run --rm migrate node core/dist/cli/apply-migrations.js` |
|
||||
| Doctor (read-only report) | `docker compose run --rm migrate node core/dist/cli/doctor.js` |
|
||||
| Create the initial owner (interactive) | `docker compose run --rm -it migrate node core/dist/cli/create-initial-owner.js --email [email protected]` |
|
||||
| Enable or disable a module | `docker compose run --rm migrate node core/dist/cli/set-module-enabled.js --enable --module <id>` (or `--disable`) |
|
||||
|
||||
The plan, apply, Doctor, and initial-owner commands are exercised by the smoke test; module
|
||||
enablement is not.
|
||||
|
||||
### Size
|
||||
|
||||
The platform-only image is about 378 MB: the Debian slim Node base (about 230 MB), production
|
||||
dependencies (about 141 MB), and built outputs (about 6 MB). A rebuild that reuses a warm pnpm store
|
||||
cache can come out about 21 MB larger. When esbuild's install script runs, it hard-links its
|
||||
`bin/esbuild` to the native binary it ships beside it; when pnpm restores esbuild from the store's
|
||||
cached post-install state instead, that file is a second full copy. Both are esbuild, one of the
|
||||
unused tools below.
|
||||
|
||||
About 57 MB of the production dependencies are development tools that no runtime code imports:
|
||||
`vitest`, `vite`, `drizzle-kit`, `tsx`, `esbuild`, and their native bindings. They arrive as
|
||||
optional peer dependencies of Better Auth (and `esbuild` through the module UI builder), which pnpm
|
||||
resolves because the workspace already has them. Excluding them means changing dependency
|
||||
resolution and the lockfile, which is separate work.
|
||||
@@ -0,0 +1,91 @@
|
||||
# The reference TLS proxy overlay (ADR 0044's `proxy` service). Use it on top of the base:
|
||||
#
|
||||
# docker compose -f compose.yaml -f compose.proxy.yaml up -d
|
||||
#
|
||||
# Caddy terminates TLS with publicly trusted ACME certificates and publishes the deployment's only
|
||||
# ports. It reaches `api` and `object-storage` on the `edge` network, which this overlay creates and
|
||||
# attaches them to; `postgres` is never on it.
|
||||
#
|
||||
# Client addresses. Caddy is the only peer the API trusts to say who the client is: this overlay
|
||||
# puts the API in trusted-proxy mode for exactly the proxy's fixed address, and the Caddyfile
|
||||
# overwrites X-Forwarded-For with the one address Caddy resolved. Because the address must be
|
||||
# fixed for "only the proxy" to be expressible, `edge` has an explicit subnet whose dynamic range is
|
||||
# its lower half; the proxy (and the tunnel connector, if used) take addresses from the upper half,
|
||||
# which Docker never hands to another container.
|
||||
|
||||
# Exactly one trusted peer: this proxy. Set from the same variable that fixes the proxy's address,
|
||||
# so the two cannot drift apart. These override any value in ekklesia.env.
|
||||
x-ekklesia-client-address: &ekklesia-client-address
|
||||
EKKLESIA_CLIENT_ADDRESS_MODE: trusted-proxy
|
||||
EKKLESIA_CLIENT_ADDRESS_HEADER: x-forwarded-for
|
||||
EKKLESIA_CLIENT_ADDRESS_TRUSTED_PROXIES: ${EKKLESIA_PROXY_ADDRESS:-10.249.250.254}/32
|
||||
|
||||
services:
|
||||
proxy:
|
||||
image: caddy:2.11.4-alpine@sha256:6aeddd44c3078b0f9a35206472a11420648a79c184603ef95957d0a20044cb2b
|
||||
restart: unless-stopped
|
||||
read_only: true
|
||||
tmpfs:
|
||||
- /tmp
|
||||
cap_drop:
|
||||
- ALL
|
||||
cap_add:
|
||||
- NET_BIND_SERVICE
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
ports:
|
||||
# 80 answers ACME HTTP-01 challenges and redirects everything else to HTTPS. Set
|
||||
# EKKLESIA_PUBLISH_ADDRESS to bind one host interface instead of all of them.
|
||||
- "${EKKLESIA_PUBLISH_ADDRESS:-0.0.0.0}:${EKKLESIA_HTTP_PORT:-80}:80"
|
||||
- "${EKKLESIA_PUBLISH_ADDRESS:-0.0.0.0}:${EKKLESIA_HTTPS_PORT:-443}:443"
|
||||
- "${EKKLESIA_PUBLISH_ADDRESS:-0.0.0.0}:${EKKLESIA_HTTPS_PORT:-443}:443/udp"
|
||||
environment:
|
||||
EKKLESIA_HOSTNAME: ${EKKLESIA_HOSTNAME:?EKKLESIA_HOSTNAME is required, for example ekklesia.example.org}
|
||||
EKKLESIA_STORAGE_HOSTNAME: ${EKKLESIA_STORAGE_HOSTNAME:?EKKLESIA_STORAGE_HOSTNAME is required, for example storage.ekklesia.example.org}
|
||||
# The split topology only (ADR 0045, Caddyfile.split.example), where EKKLESIA_HOSTNAME is the
|
||||
# API's hostname and this is the Dashboard's. Caddyfile.example ignores it; the split
|
||||
# configuration refuses to start without it.
|
||||
EKKLESIA_DASHBOARD_HOSTNAME: ${EKKLESIA_DASHBOARD_HOSTNAME:-}
|
||||
EKKLESIA_ACME_EMAIL: ${EKKLESIA_ACME_EMAIL:?EKKLESIA_ACME_EMAIL is required for ACME account notices}
|
||||
EKKLESIA_TUNNEL_ADDRESS: ${EKKLESIA_TUNNEL_ADDRESS:-10.249.250.253}
|
||||
volumes:
|
||||
- ${EKKLESIA_CADDYFILE:-./Caddyfile}:/etc/caddy/Caddyfile:ro
|
||||
# The installation-private CA, so Caddy can verify object-storage's certificate.
|
||||
- ${EKKLESIA_TLS_DIR:-/etc/ekklesia/tls}/ca.crt:/etc/caddy/ekklesia-ca.crt:ro
|
||||
- proxy-data:/data
|
||||
- proxy-config:/config
|
||||
networks:
|
||||
edge:
|
||||
ipv4_address: ${EKKLESIA_PROXY_ADDRESS:-10.249.250.254}
|
||||
depends_on:
|
||||
api:
|
||||
condition: service_healthy
|
||||
object-storage:
|
||||
condition: service_healthy
|
||||
|
||||
api:
|
||||
networks:
|
||||
edge: {}
|
||||
environment: *ekklesia-client-address
|
||||
|
||||
# Operator commands (migrations, Doctor) run as `migrate`. It gets the API's client-address
|
||||
# configuration too, so that Doctor checks what the API actually runs rather than an empty one.
|
||||
migrate:
|
||||
environment: *ekklesia-client-address
|
||||
|
||||
object-storage:
|
||||
networks:
|
||||
edge: {}
|
||||
|
||||
networks:
|
||||
edge:
|
||||
ipam:
|
||||
config:
|
||||
- subnet: ${EKKLESIA_EDGE_SUBNET:-10.249.250.0/24}
|
||||
# Docker assigns dynamic addresses only from this range. Keep the fixed proxy and tunnel
|
||||
# addresses outside it.
|
||||
ip_range: ${EKKLESIA_EDGE_DYNAMIC_RANGE:-10.249.250.0/25}
|
||||
|
||||
volumes:
|
||||
proxy-data:
|
||||
proxy-config:
|
||||
@@ -0,0 +1,30 @@
|
||||
# The Cloudflare Tunnel overlay (ADR 0044, optional ingress). Use it on top of base and proxy:
|
||||
#
|
||||
# docker compose -f compose.yaml -f compose.proxy.yaml -f compose.tunnel.yaml up -d
|
||||
#
|
||||
# `cloudflared` makes an outbound connection to Cloudflare and forwards visitors to the proxy over
|
||||
# HTTPS. The tunnel is token-managed, so its public hostnames and origin settings live in the
|
||||
# Cloudflare dashboard: each hostname's service is `https://proxy:443` with the matching origin
|
||||
# server name and TLS verification left on (deploy/README.md, "Cloudflare Tunnel").
|
||||
#
|
||||
# The connector has a fixed address on `edge`. The Caddyfile trusts a client-address header
|
||||
# (CF-Connecting-IP) only from that address, so no other peer can choose its own client address.
|
||||
|
||||
services:
|
||||
tunnel:
|
||||
image: cloudflare/cloudflared:2026.9.3@sha256:072c067d25ccbe61d46e18f0d0723255f2bb5304f7317caa95b27031520ff92c
|
||||
restart: unless-stopped
|
||||
read_only: true
|
||||
cap_drop:
|
||||
- ALL
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
# The image's entrypoint is `cloudflared --no-autoupdate`; the token comes from TUNNEL_TOKEN.
|
||||
command: ["tunnel", "run"]
|
||||
env_file:
|
||||
- ${EKKLESIA_ENV_DIR:-/etc/ekklesia}/tunnel.env
|
||||
networks:
|
||||
edge:
|
||||
ipv4_address: ${EKKLESIA_TUNNEL_ADDRESS:-10.249.250.253}
|
||||
depends_on:
|
||||
- proxy
|
||||
@@ -0,0 +1,153 @@
|
||||
# Ekklesia's reference self-hosted deployment: the base project (ADR 0044).
|
||||
#
|
||||
# `api`, `postgres`, and `object-storage`, plus the operator-only `migrate` service. Nothing here
|
||||
# publishes a port. Add `compose.proxy.yaml` for the reference TLS proxy, and `compose.tunnel.yaml`
|
||||
# on top of that for a Cloudflare Tunnel connector; or attach `api` and `object-storage` to an
|
||||
# existing proxy's network yourself (deploy/README.md).
|
||||
#
|
||||
# Non-secret settings come from `deploy/.env` (copy `env/compose.env.example`). Secrets live in
|
||||
# root-owned, mode-600 files under EKKLESIA_ENV_DIR, one per service, so no container receives
|
||||
# another service's credentials. TLS material comes from `scripts/init-tls.sh`.
|
||||
|
||||
name: ekklesia
|
||||
|
||||
x-ekklesia-hardening: &ekklesia-hardening
|
||||
init: true
|
||||
read_only: true
|
||||
tmpfs:
|
||||
- /tmp
|
||||
cap_drop:
|
||||
- ALL
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
|
||||
x-ekklesia-app: &ekklesia-app
|
||||
<<: *ekklesia-hardening
|
||||
image: ${EKKLESIA_IMAGE:?EKKLESIA_IMAGE is required, for example registry.example.org/ekklesia:0.1.0}
|
||||
env_file:
|
||||
- ${EKKLESIA_ENV_DIR:-/etc/ekklesia}/ekklesia.env
|
||||
environment:
|
||||
# Core's object-storage client trusts Node's default CA set. Adding the installation-private CA
|
||||
# to it, from the file below, is what lets the API verify `object-storage`'s certificate
|
||||
# without any storage TLS exception. The database CA travels separately, as PEM text in
|
||||
# EKKLESIA_DATABASE_CA, and is the only CA the database connection trusts.
|
||||
NODE_EXTRA_CA_CERTS: /run/ekklesia/tls/ca.crt
|
||||
volumes:
|
||||
- ${EKKLESIA_TLS_DIR:-/etc/ekklesia/tls}/ca.crt:/run/ekklesia/tls/ca.crt:ro
|
||||
networks:
|
||||
data: {}
|
||||
storage: {}
|
||||
egress: {}
|
||||
|
||||
services:
|
||||
api:
|
||||
<<: *ekklesia-app
|
||||
restart: unless-stopped
|
||||
# The image's own HEALTHCHECK (GET /api/v1/health/ready) applies unchanged.
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
object-storage:
|
||||
condition: service_healthy
|
||||
|
||||
# Operator commands only. `profiles` keeps `docker compose up` from ever starting it; it runs only
|
||||
# through `docker compose run --rm migrate …`. The default command is the read-only plan.
|
||||
migrate:
|
||||
<<: *ekklesia-app
|
||||
profiles:
|
||||
- ops
|
||||
restart: "no"
|
||||
healthcheck:
|
||||
disable: true
|
||||
command: ["node", "core/dist/cli/plan-migrations.js"]
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
object-storage:
|
||||
condition: service_healthy
|
||||
|
||||
postgres:
|
||||
image: postgres:18.4-alpine3.24@sha256:9a8afca54e7861fd90fab5fdf4c42477a6b1cb7d293595148e674e0a3181de15
|
||||
restart: unless-stopped
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
env_file:
|
||||
- ${EKKLESIA_ENV_DIR:-/etc/ekklesia}/postgres.env
|
||||
environment:
|
||||
POSTGRES_INITDB_ARGS: --data-checksums --auth-host=scram-sha-256 --auth-local=trust
|
||||
TZ: UTC
|
||||
# TLS with the installation-private CA's server certificate for the name `postgres`, TLS 1.3
|
||||
# only, and a host-based authentication file that rejects every non-TLS network connection.
|
||||
# The API connects with verify-full; there is no plaintext path to fall back to.
|
||||
command:
|
||||
- postgres
|
||||
- -c
|
||||
- ssl=on
|
||||
- -c
|
||||
- ssl_cert_file=/run/ekklesia/tls/postgres.crt
|
||||
- -c
|
||||
- ssl_key_file=/run/ekklesia/tls/postgres.key
|
||||
- -c
|
||||
- ssl_min_protocol_version=TLSv1.3
|
||||
- -c
|
||||
- hba_file=/etc/ekklesia/pg_hba.conf
|
||||
volumes:
|
||||
- postgres-data:/var/lib/postgresql
|
||||
- ${EKKLESIA_TLS_DIR:-/etc/ekklesia/tls}/postgres.crt:/run/ekklesia/tls/postgres.crt:ro
|
||||
- ${EKKLESIA_TLS_DIR:-/etc/ekklesia/tls}/postgres.key:/run/ekklesia/tls/postgres.key:ro
|
||||
- ./postgres/pg_hba.conf:/etc/ekklesia/pg_hba.conf:ro
|
||||
# Only `data`: never a proxy-facing network, and no route out of the host.
|
||||
networks:
|
||||
data: {}
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U \"$${POSTGRES_USER}\" -d \"$${POSTGRES_DB}\""]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 12
|
||||
start_period: 20s
|
||||
|
||||
object-storage:
|
||||
image: chrislusf/seaweedfs:4.28@sha256:d071979843142451d41566bc09f6adaa36902c112683d900a417247c82e9c3c1
|
||||
restart: unless-stopped
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
env_file:
|
||||
- ${EKKLESIA_ENV_DIR:-/etc/ekklesia}/object-storage.env
|
||||
environment:
|
||||
TZ: UTC
|
||||
# The S3 API over HTTPS on 8443, with the installation-private CA's certificate for the name
|
||||
# `object-storage`: production storage must be https (ADR 0030), for the API and for the proxy.
|
||||
command:
|
||||
- mini
|
||||
- -dir=/data
|
||||
- -s3.port.https=8443
|
||||
- -s3.cert.file=/run/ekklesia/tls/object-storage.crt
|
||||
- -s3.key.file=/run/ekklesia/tls/object-storage.key
|
||||
volumes:
|
||||
- object-storage-data:/data
|
||||
- ${EKKLESIA_TLS_DIR:-/etc/ekklesia/tls}/object-storage.crt:/run/ekklesia/tls/object-storage.crt:ro
|
||||
- ${EKKLESIA_TLS_DIR:-/etc/ekklesia/tls}/object-storage.key:/run/ekklesia/tls/object-storage.key:ro
|
||||
networks:
|
||||
storage: {}
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "wget -q -O /dev/null http://127.0.0.1:9333/cluster/status"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 18
|
||||
start_period: 15s
|
||||
|
||||
networks:
|
||||
# The database network: postgres, api, and migrate. Internal, so nothing on it reaches beyond the
|
||||
# host and no proxy is ever attached to it.
|
||||
data:
|
||||
internal: true
|
||||
# The storage network: object-storage, api, and migrate. Internal for the same reason; browsers
|
||||
# reach storage only through a proxy that joins it on `edge`.
|
||||
storage:
|
||||
internal: true
|
||||
# Outbound access for api and migrate (an SMTP relay, for example). Nothing listens on it.
|
||||
egress: {}
|
||||
|
||||
volumes:
|
||||
postgres-data:
|
||||
object-storage-data:
|
||||
Vendored
+40
@@ -0,0 +1,40 @@
|
||||
# Non-secret settings for the reference deployment's Compose project. Copy to deploy/.env, which
|
||||
# Compose reads automatically from this directory. Nothing here is a secret; secrets live in the
|
||||
# per-service files under EKKLESIA_ENV_DIR.
|
||||
|
||||
# Which Compose files make up this installation, so that every plain `docker compose …` command
|
||||
# run from this directory, operator commands through `migrate` included, sees the same services and
|
||||
# settings as the running stack. Add compose.tunnel.yaml if you use the tunnel connector. Without
|
||||
# the proxy overlay here, Doctor checks a `migrate` that lacks the API's client-address settings.
|
||||
COMPOSE_FILE=compose.yaml:compose.proxy.yaml
|
||||
|
||||
# The image to run, by registry, name, and tag (or digest). Required.
|
||||
EKKLESIA_IMAGE=
|
||||
|
||||
# Where the root-owned, mode-600 env files and the TLS directory live on the host.
|
||||
EKKLESIA_ENV_DIR=/etc/ekklesia
|
||||
EKKLESIA_TLS_DIR=/etc/ekklesia/tls
|
||||
|
||||
# --- compose.proxy.yaml ---------------------------------------------------------------------------
|
||||
# The Ekklesia hostname and the storage hostname (signed object URLs). In the default same-origin
|
||||
# topology (Caddyfile.example) EKKLESIA_HOSTNAME serves the Dashboard, the API, and displays.
|
||||
EKKLESIA_HOSTNAME=
|
||||
EKKLESIA_STORAGE_HOSTNAME=
|
||||
# The split topology only (ADR 0045, Caddyfile.split.example): the Dashboard's own hostname, while
|
||||
# EKKLESIA_HOSTNAME is the API's and the displays'. Both must share one registrable domain, for
|
||||
# example dash.example.org and api.example.org. Leave empty for the same-origin topology.
|
||||
EKKLESIA_DASHBOARD_HOSTNAME=
|
||||
# Contact address for the ACME account (certificate expiry notices).
|
||||
EKKLESIA_ACME_EMAIL=
|
||||
# EKKLESIA_CADDYFILE=./Caddyfile
|
||||
# EKKLESIA_PUBLISH_ADDRESS=0.0.0.0
|
||||
# EKKLESIA_HTTP_PORT=80
|
||||
# EKKLESIA_HTTPS_PORT=443
|
||||
|
||||
# The edge network. Change the subnet only if it collides with one already on the host. Docker
|
||||
# assigns dynamic addresses only from EKKLESIA_EDGE_DYNAMIC_RANGE (the lower half); the proxy and
|
||||
# the tunnel connector take fixed addresses from the upper half, so nothing else can ever hold them.
|
||||
# EKKLESIA_EDGE_SUBNET=10.249.250.0/24
|
||||
# EKKLESIA_EDGE_DYNAMIC_RANGE=10.249.250.0/25
|
||||
# EKKLESIA_PROXY_ADDRESS=10.249.250.254
|
||||
# EKKLESIA_TUNNEL_ADDRESS=10.249.250.253
|
||||
Vendored
+75
@@ -0,0 +1,75 @@
|
||||
# Ekklesia API and operator commands (`api` and `migrate`). Reference deployment, ADR 0044.
|
||||
#
|
||||
# Install as EKKLESIA_ENV_DIR/ekklesia.env (default /etc/ekklesia/ekklesia.env), owned by root with
|
||||
# mode 600:
|
||||
# install -o root -g root -m 600 env/ekklesia.env.example /etc/ekklesia/ekklesia.env
|
||||
# Never commit the filled-in file. Generate secrets with scripts/gen-secrets.sh, and the database CA
|
||||
# line with scripts/init-tls.sh.
|
||||
#
|
||||
# The image already sets NODE_ENV=production, EKKLESIA_API_HOST=0.0.0.0, and EKKLESIA_API_PORT=3000.
|
||||
# Compose sets NODE_EXTRA_CA_CERTS. The proxy overlay sets the three EKKLESIA_CLIENT_ADDRESS_*
|
||||
# variables. None of those belong here.
|
||||
#
|
||||
# Values that are part of the reference topology are filled in; everything installation-specific is
|
||||
# blank. Full reference: docs/development/api.md#required-configuration.
|
||||
|
||||
# --- Database (ADR 0018) ------------------------------------------------------------------------
|
||||
# The host must be exactly `postgres`: the server certificate is issued for that name and
|
||||
# verify-full checks it. User, password, and database must match postgres.env. Percent-encode any
|
||||
# reserved character in the password. Never add sslmode or another TLS parameter to this URL.
|
||||
DATABASE_URL=postgresql://<user>:<password>@postgres:5432/<database>
|
||||
EKKLESIA_DATABASE_TLS_MODE=verify-full
|
||||
# The installation-private CA as PEM text (not a path), on one double-quoted line with \n escapes.
|
||||
# scripts/init-tls.sh prints this exact line. It is the only CA the database connection trusts.
|
||||
EKKLESIA_DATABASE_CA=
|
||||
|
||||
# --- Identity (ADR 0011, ADR 0018, ADR 0034, ADR 0045) -----------------------------------------
|
||||
# The public https origin of the API, with no path: the Ekklesia hostname in the same-origin
|
||||
# topology, the API's hostname in the split one. Production rejects http and loopback origins.
|
||||
EKKLESIA_IDENTITY_BASE_URL=
|
||||
# Comma-separated exact origins allowed to make credentialed requests. The API's own origin is
|
||||
# always trusted and need not be listed. Same-origin topology: the base URL. Split topology: the
|
||||
# Dashboard's origin.
|
||||
EKKLESIA_IDENTITY_TRUSTED_ORIGINS=
|
||||
# At least 32 random characters. Rotating it signs every user out.
|
||||
EKKLESIA_IDENTITY_SECRET=
|
||||
# Same-origin topology: leave unset; the Dashboard is served from the base URL (ADR 0044).
|
||||
# Split topology: the Dashboard's https origin, which must also be in the trusted origins. Emailed
|
||||
# verification and reset links land here (ADR 0045).
|
||||
# EKKLESIA_DASHBOARD_ORIGIN=
|
||||
# Production refuses public sign-up. Leave unset.
|
||||
# EKKLESIA_PUBLIC_SIGNUP_ENABLED=
|
||||
|
||||
# --- Client address (ADR 0017) ------------------------------------------------------------------
|
||||
# Set by compose.proxy.yaml. Only a base-only deployment behind its own proxy sets these here, with
|
||||
# that proxy's fixed address as the only trusted entry (deploy/README.md):
|
||||
# EKKLESIA_CLIENT_ADDRESS_MODE=trusted-proxy
|
||||
# EKKLESIA_CLIENT_ADDRESS_HEADER=x-forwarded-for
|
||||
# EKKLESIA_CLIENT_ADDRESS_TRUSTED_PROXIES=<proxy address>/32
|
||||
|
||||
# --- Object storage (ADR 0030, ADR 0031) --------------------------------------------------------
|
||||
# The API reaches SeaweedFS over HTTPS on the private network; production storage must be https.
|
||||
S3_ENDPOINT=https://object-storage:8443
|
||||
# The https origin browsers fetch signed object URLs from: the storage hostname on the proxy.
|
||||
S3_DELIVERY_ENDPOINT=
|
||||
# Must match object-storage.env.
|
||||
S3_ACCESS_KEY=
|
||||
S3_SECRET_KEY=
|
||||
S3_REGION=us-east-1
|
||||
S3_FORCE_PATH_STYLE=true
|
||||
S3_BUCKET_PREFIX=ekklesia
|
||||
|
||||
# --- Outbound email (ADR 0037), optional --------------------------------------------------------
|
||||
# Without a relay, email verification and password recovery cannot deliver. See
|
||||
# docs/development/email.md.
|
||||
# EKKLESIA_SMTP_HOST=
|
||||
# EKKLESIA_SMTP_PORT=587
|
||||
# EKKLESIA_SMTP_SECURITY=starttls
|
||||
# EKKLESIA_SMTP_USERNAME=
|
||||
# EKKLESIA_SMTP_PASSWORD=
|
||||
# EKKLESIA_SMTP_FROM_ADDRESS=
|
||||
# EKKLESIA_SMTP_FROM_NAME=Ekklesia
|
||||
# EKKLESIA_SMTP_HELO_NAME=
|
||||
|
||||
# --- Module secrets (ADR 0029), as installed modules declare them --------------------------------
|
||||
# EKKLESIA_MODULE_SECRET__<MODULE_ID>__<KEY>=
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
# SeaweedFS S3 credentials (`object-storage` only). Install as EKKLESIA_ENV_DIR/object-storage.env,
|
||||
# owned by root with mode 600. Must match S3_ACCESS_KEY and S3_SECRET_KEY in ekklesia.env.
|
||||
AWS_ACCESS_KEY_ID=
|
||||
AWS_SECRET_ACCESS_KEY=
|
||||
Vendored
+8
@@ -0,0 +1,8 @@
|
||||
# PostgreSQL superuser for the reference deployment (`postgres` only). Install as
|
||||
# EKKLESIA_ENV_DIR/postgres.env, owned by root with mode 600. Read only on the database's first
|
||||
# start; changing it later does not change the stored password.
|
||||
#
|
||||
# Must match the user, password, and database in ekklesia.env's DATABASE_URL.
|
||||
POSTGRES_USER=ekklesia
|
||||
POSTGRES_DB=ekklesia
|
||||
POSTGRES_PASSWORD=
|
||||
Vendored
+4
@@ -0,0 +1,4 @@
|
||||
# Cloudflare Tunnel connector token (`tunnel` only, compose.tunnel.yaml). Install as
|
||||
# EKKLESIA_ENV_DIR/tunnel.env, owned by root with mode 600. Copy the token from the tunnel's page in
|
||||
# the Cloudflare dashboard; it is the tunnel's credential.
|
||||
TUNNEL_TOKEN=
|
||||
@@ -0,0 +1,16 @@
|
||||
# Host-based authentication for the reference deployment's PostgreSQL (ADR 0018, ADR 0044).
|
||||
#
|
||||
# Mounted read-only and selected with `-c hba_file=...` (deploy/compose.yaml), so it replaces the
|
||||
# image's generated file entirely. Rules are matched top to bottom; the first match decides.
|
||||
|
||||
# TYPE DATABASE USER ADDRESS METHOD
|
||||
|
||||
# The container's own Unix socket: the image's first-start initialization and the health check.
|
||||
# Nothing outside the container can reach it.
|
||||
local all all trust
|
||||
|
||||
# Every network connection must be TLS (the server enforces TLS 1.3) and authenticate with SCRAM.
|
||||
hostssl all all all scram-sha-256
|
||||
|
||||
# A network connection without TLS is refused outright, before authentication.
|
||||
hostnossl all all all reject
|
||||
Executable
+39
@@ -0,0 +1,39 @@
|
||||
#!/bin/sh
|
||||
# Prints freshly generated secrets for the reference deployment's env files (ADR 0044). Writes
|
||||
# nothing: paste each line into the file named above it, then install that file root-owned with
|
||||
# mode 600.
|
||||
#
|
||||
# sh deploy/scripts/gen-secrets.sh
|
||||
#
|
||||
# Every value is URL-safe (hexadecimal), so the database password needs no percent-encoding in
|
||||
# DATABASE_URL.
|
||||
|
||||
set -eu
|
||||
|
||||
command -v openssl >/dev/null 2>&1 || {
|
||||
echo "gen-secrets: openssl is required." >&2
|
||||
exit 69
|
||||
}
|
||||
|
||||
hex() {
|
||||
openssl rand -hex "$1"
|
||||
}
|
||||
|
||||
postgres_password=$(hex 24)
|
||||
s3_access_key=$(hex 10)
|
||||
s3_secret_key=$(hex 32)
|
||||
|
||||
cat <<EOF
|
||||
# ekklesia.env
|
||||
EKKLESIA_IDENTITY_SECRET=$(hex 32)
|
||||
DATABASE_URL=postgresql://ekklesia:$postgres_password@postgres:5432/ekklesia
|
||||
S3_ACCESS_KEY=$s3_access_key
|
||||
S3_SECRET_KEY=$s3_secret_key
|
||||
|
||||
# postgres.env
|
||||
POSTGRES_PASSWORD=$postgres_password
|
||||
|
||||
# object-storage.env
|
||||
AWS_ACCESS_KEY_ID=$s3_access_key
|
||||
AWS_SECRET_ACCESS_KEY=$s3_secret_key
|
||||
EOF
|
||||
Executable
+148
@@ -0,0 +1,148 @@
|
||||
#!/bin/sh
|
||||
# Creates the installation-private CA (once) and the two server certificates the reference
|
||||
# deployment needs (ADR 0044): `postgres` for the database and `object-storage` for SeaweedFS.
|
||||
#
|
||||
# sudo sh deploy/scripts/init-tls.sh /etc/ekklesia/tls
|
||||
#
|
||||
# Idempotent. An existing key or certificate is never overwritten: delete a file deliberately to
|
||||
# reissue it. Ownership and modes are re-applied on every run, which needs root; a non-root run
|
||||
# creates what is missing and then stops before that step, saying so. No private key is ever
|
||||
# printed.
|
||||
#
|
||||
# Output (in the directory given):
|
||||
# ca.key the CA's private key. Root only. Needed only to issue certificates; keep a
|
||||
# copy offline and consider removing it from the server afterwards.
|
||||
# ca.crt the CA certificate. Public. Mounted into api, migrate, and proxy.
|
||||
# postgres.crt/.key for the name `postgres`; the key is owned by PostgreSQL's uid (70).
|
||||
# object-storage.crt/.key for the name `object-storage`; the key is owned by SeaweedFS's uid (1000).
|
||||
|
||||
set -eu
|
||||
|
||||
umask 077
|
||||
|
||||
usage() {
|
||||
echo "Usage: sh init-tls.sh <output directory>" >&2
|
||||
exit 64
|
||||
}
|
||||
|
||||
[ "$#" -eq 1 ] || usage
|
||||
out=$1
|
||||
case "$out" in
|
||||
-*) usage ;;
|
||||
esac
|
||||
|
||||
# The uids the two images run their servers as: postgres:18.4-alpine3.24 (postgres) and
|
||||
# chrislusf/seaweedfs:4.28 (seaweed). Each server refuses or fails to read a key it cannot own.
|
||||
POSTGRES_UID=${EKKLESIA_POSTGRES_UID:-70}
|
||||
SEAWEED_UID=${EKKLESIA_SEAWEED_UID:-1000}
|
||||
|
||||
CA_DAYS=3650
|
||||
SERVER_DAYS=825
|
||||
|
||||
mkdir -p "$out"
|
||||
chmod 700 "$out"
|
||||
|
||||
created=""
|
||||
|
||||
# Only generating needs openssl. A later root-only pass that applies ownership to files that already
|
||||
# exist runs without it, in any image that has a POSIX shell.
|
||||
require_openssl() {
|
||||
command -v openssl >/dev/null 2>&1 || {
|
||||
echo "init-tls: openssl is required to create keys and certificates." >&2
|
||||
exit 69
|
||||
}
|
||||
}
|
||||
|
||||
note_created() {
|
||||
created="$created $1"
|
||||
}
|
||||
|
||||
# A file this script would create already exists: keep it, and say so.
|
||||
exists() {
|
||||
if [ -e "$out/$1" ]; then
|
||||
echo "init-tls: keeping existing $out/$1"
|
||||
return 0
|
||||
fi
|
||||
return 1
|
||||
}
|
||||
|
||||
if ! exists ca.key; then
|
||||
require_openssl
|
||||
openssl ecparam -name prime256v1 -genkey -noout -out "$out/ca.key"
|
||||
note_created ca.key
|
||||
fi
|
||||
|
||||
if ! exists ca.crt; then
|
||||
require_openssl
|
||||
openssl req -x509 -new -sha256 -days "$CA_DAYS" -key "$out/ca.key" -out "$out/ca.crt" \
|
||||
-subj "/CN=Ekklesia installation CA" \
|
||||
-addext "basicConstraints=critical,CA:TRUE,pathlen:0" \
|
||||
-addext "keyUsage=critical,keyCertSign,cRLSign" \
|
||||
-addext "subjectKeyIdentifier=hash"
|
||||
note_created ca.crt
|
||||
fi
|
||||
|
||||
# issue <name>: a key and a certificate whose only subject alternative name is <name>, the Compose
|
||||
# service name the client verifies against.
|
||||
issue() {
|
||||
name=$1
|
||||
if ! exists "$name.key"; then
|
||||
require_openssl
|
||||
openssl ecparam -name prime256v1 -genkey -noout -out "$out/$name.key"
|
||||
note_created "$name.key"
|
||||
fi
|
||||
if ! exists "$name.crt"; then
|
||||
require_openssl
|
||||
extensions=$(mktemp)
|
||||
request=$(mktemp)
|
||||
trap 'rm -f "$extensions" "$request"' EXIT
|
||||
cat >"$extensions" <<EOF
|
||||
basicConstraints=critical,CA:FALSE
|
||||
keyUsage=critical,digitalSignature
|
||||
extendedKeyUsage=serverAuth
|
||||
subjectAltName=DNS:$name
|
||||
subjectKeyIdentifier=hash
|
||||
authorityKeyIdentifier=keyid
|
||||
EOF
|
||||
openssl req -new -sha256 -key "$out/$name.key" -subj "/CN=$name" -out "$request"
|
||||
openssl x509 -req -sha256 -days "$SERVER_DAYS" -in "$request" \
|
||||
-CA "$out/ca.crt" -CAkey "$out/ca.key" -CAcreateserial -CAserial "$out/ca.srl" \
|
||||
-extfile "$extensions" -out "$out/$name.crt" 2>/dev/null
|
||||
rm -f "$extensions" "$request"
|
||||
trap - EXIT
|
||||
note_created "$name.crt"
|
||||
fi
|
||||
}
|
||||
|
||||
issue postgres
|
||||
issue object-storage
|
||||
|
||||
# Public material is world-readable; private keys are readable only by the process that needs them.
|
||||
chmod 644 "$out/ca.crt" "$out/postgres.crt" "$out/object-storage.crt"
|
||||
chmod 600 "$out/ca.key" "$out/postgres.key" "$out/object-storage.key"
|
||||
[ -e "$out/ca.srl" ] && chmod 600 "$out/ca.srl"
|
||||
|
||||
if [ -n "$created" ]; then
|
||||
echo "init-tls: created$created in $out"
|
||||
else
|
||||
echo "init-tls: nothing to create in $out"
|
||||
fi
|
||||
|
||||
if [ "$(id -u)" -ne 0 ]; then
|
||||
echo "init-tls: ownership not applied: re-run as root to give postgres.key to uid $POSTGRES_UID and object-storage.key to uid $SEAWEED_UID." >&2
|
||||
exit 77
|
||||
fi
|
||||
|
||||
chown root:root "$out" "$out/ca.key" "$out/ca.crt" "$out/postgres.crt" "$out/object-storage.crt"
|
||||
[ -e "$out/ca.srl" ] && chown root:root "$out/ca.srl"
|
||||
chown "$POSTGRES_UID:$POSTGRES_UID" "$out/postgres.key"
|
||||
chown "$SEAWEED_UID:$SEAWEED_UID" "$out/object-storage.key"
|
||||
# The directory itself must be traversable by the two server uids for their bind mounts.
|
||||
chmod 755 "$out"
|
||||
|
||||
echo "init-tls: postgres.key is owned by uid $POSTGRES_UID and object-storage.key by uid $SEAWEED_UID (mode 600)."
|
||||
echo
|
||||
echo "Set EKKLESIA_TLS_DIR=$out in deploy/.env, and put this line in ekklesia.env (the CA"
|
||||
echo "certificate is public; this is not a secret):"
|
||||
echo
|
||||
printf 'EKKLESIA_DATABASE_CA="%s"\n' "$(awk 'BEGIN { ORS = "\\n" } { print }' "$out/ca.crt")"
|
||||
@@ -100,6 +100,15 @@ This is deliberately the smallest viable styling story. A real stylesheet contra
|
||||
field, a host loading mechanism, and a style-isolation decision, none of which exist — and inventing
|
||||
them here would expand the manifest and runtime contract before a host can validate them.
|
||||
|
||||
> **Correction — this rule is superseded.**
|
||||
> [ADR 0040](0040-module-stylesheets-and-artifact-isolation.md) supplies the three missing pieces and
|
||||
> reverses this one decision: a module may import plain `.css` from a browser-artifact source root,
|
||||
> the canonical builder compiles it **into** the declared artifact as text (so no sidecar is ever
|
||||
> emitted, which is what this section was protecting), and the host installs it into a Shadow DOM
|
||||
> root it creates for every artifact. No manifest field was needed after all. The rest of this ADR —
|
||||
> esbuild, bundled React, fixed source paths, no author bundler configuration, unminified output — is
|
||||
> unchanged.
|
||||
|
||||
### Browser TypeScript stays scoped to UI source
|
||||
|
||||
`@ekklesia/typescript-config/browser-react.json` extends the existing `browser.json` and sets
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
# 0039: Shared design tokens and foundational elements
|
||||
|
||||
- Status: Accepted
|
||||
- Date: 2026-08-27
|
||||
- Supersedes: none
|
||||
- Related: [ADR 0021](0021-modules-as-hostable-products.md),
|
||||
[ADR 0022](0022-module-ui-authoring-and-host-contract.md),
|
||||
[ADR 0024](0024-browser-safe-ui-sdk-and-host-loader.md),
|
||||
[ADR 0026](0026-minimal-ekklesia-module-ui-host.md),
|
||||
[ADR 0033](0033-display-kiosk-host-contract.md)
|
||||
|
||||
## Context
|
||||
|
||||
ADR 0021 made a module an independently versioned product that owns its own UI, and ADR 0022 chose
|
||||
React as the way to author it. Since then the platform has been able to *mount* a module's screen but
|
||||
has said nothing at all about what that screen should look like. The consequences are now concrete:
|
||||
|
||||
- Every screen in this repository — the Dashboard shell, the display host, the module template —
|
||||
hardcodes its own hex colours, font stack, spacing, and radii. They already disagree with each
|
||||
other.
|
||||
- A module author has no vocabulary for "the ordinary panel background" or "the danger text colour",
|
||||
so the only available answer is to invent one. Two modules will invent two.
|
||||
- Deferred repeatedly and named in ADR 0022, ADR 0023, and ADR 0033's deferral lists: "a design
|
||||
system, component library, or theme/design-token contract."
|
||||
- A theme editor is a real product requirement for a church platform, and it is only implementable
|
||||
if the values it would edit are enumerable with human-readable names — which is a property of the
|
||||
*contract*, not something that can be retrofitted onto a pile of literals.
|
||||
|
||||
The constraint that shapes everything below is ADR 0024's browser boundary. Whatever the shared
|
||||
design layer is, module UI imports it, so it must not be able to pull a backend type graph — and with
|
||||
it Node's global declarations — into a browser program.
|
||||
|
||||
## Decision
|
||||
|
||||
Add two shared browser-safe packages and open exactly two boundary roles to module UI.
|
||||
|
||||
### `@ekklesia/design-tokens` — the semantic token registry
|
||||
|
||||
A dependency-free package (like `@ekklesia/module-ui-sdk`, and for the same reason) holding one
|
||||
validated registry of **semantic** tokens: `background.page`, `text.danger`, `state.hover-background`,
|
||||
`typography.size-heading-1`, and so on. Nothing is named for the colour it currently happens to be,
|
||||
because the point is that a value can change without a consumer's meaning changing with it.
|
||||
|
||||
Each token declares a name, a human-readable label, a description, and a default value. Its
|
||||
**category and CSS custom-property name are derived** from the name rather than declared, so a token
|
||||
can never claim one category while being named for another, and there is exactly one spelling of
|
||||
`--ekklesia-<name>`. That is what makes the registry enumerable: a future theme editor reads
|
||||
`list()`, groups by `category`, labels each field with `label`, explains it with `description`, and
|
||||
needs no second source of truth.
|
||||
|
||||
Construction validates every rule rather than leaving it to review: an invalid or single-segment
|
||||
name, an unknown category, a malformed derived identifier, an empty or overlong label or description,
|
||||
a duplicate name, and a value that is not a **bounded literal** each throw. A value may not contain a
|
||||
declaration terminator, a rule block, a comment, an at-rule, `url(`, `!important`, a backslash
|
||||
escape, a control character, or markup, and is length-bounded. A registry that constructs is
|
||||
therefore a registry that can be emitted as CSS without escaping, because nothing needing escaping
|
||||
can be in it.
|
||||
|
||||
The registry emits its defaults as one CSS rule, scoped to `:root` or `:host` — a closed set, never a
|
||||
caller-supplied selector, since an arbitrary selector string is exactly the unbounded value the
|
||||
package refuses everywhere else.
|
||||
|
||||
### `@ekklesia/ui` — the foundational elements
|
||||
|
||||
A small React package whose every colour, type, spacing, radius, shadow, and interaction state is a
|
||||
`var(--ekklesia-…)` reference. It contains the set a real module dashboard cannot be built without:
|
||||
button and icon button, text input, select, checkbox, field wrapper, panel, dialog, tabs, table and
|
||||
list primitives, badge, toast presentation, and empty/error/loading states, with `:focus-visible`
|
||||
focus treatment on every interactive element.
|
||||
|
||||
Each element is a thin, accessible shell over the real HTML element rather than a re-implementation
|
||||
of it, so native attributes, form association, and assistive-technology semantics pass straight
|
||||
through. **This is deliberately not a large catalogue**: an element earns its place when two
|
||||
independent consumers need the same one.
|
||||
|
||||
**Status is never signalled by colour alone.** Every status family carries a symbol token beside its
|
||||
colours, and the elements render it. A status that is only red is invisible to a person who cannot
|
||||
distinguish it, and unreadable on a projector or a washed-out lobby screen (WCAG 2.2 §1.4.1).
|
||||
|
||||
### The stylesheet is a string, not a `.css` file
|
||||
|
||||
These elements are mounted in two very different places: a document (the Dashboard shell, a
|
||||
standalone host page) and an isolated root (a module's own artifact). A `.css` file would need a
|
||||
bundler on both sides and a served asset on one of them. A string is installed into whichever root
|
||||
the caller actually has, with identical bytes — and it is what lets `:focus-visible`, `:hover`, and
|
||||
`:disabled` be real CSS rather than JavaScript approximations of them, which is the difference
|
||||
between a focus treatment that works for a keyboard user and one that only looks like it does.
|
||||
|
||||
`<EkklesiaUiStyles />` renders that string into whichever root its React tree lives in. There is
|
||||
deliberately no `document.head` hoisting and no `precedence` attribute: hoisting an isolated
|
||||
artifact's styles into the host document is precisely the leak the platform must not have.
|
||||
|
||||
### Modules consume tokens; they never invent global ones
|
||||
|
||||
A module may reference any registered token and may derive **private, differently-prefixed aliases**
|
||||
from them inside its own artifact. A module may not define an `--ekklesia-*` custom property: that
|
||||
would add a global theme variable outside the registry, which a theme editor could never enumerate
|
||||
and another module could unknowingly depend on. The typed `designToken(name)` reference makes an
|
||||
unregistered name a compile error and a runtime failure.
|
||||
|
||||
### The boundary opens by name, not by category
|
||||
|
||||
`pnpm boundaries` adds `design-tokens` and `ui` to the shared roles a module may depend on. It is a
|
||||
named allowlist, not "browser-safe shared packages are fine": `api-client`, `auth-client`,
|
||||
`surface-sdk`, and `module-ui-host` all stay rejected for modules, and `contracts`/`module-sdk`
|
||||
remain excluded from surfaces for the reason ADR 0024 measured.
|
||||
|
||||
The two new packages are pinned at the bottom of the presentation stack by their own rule:
|
||||
`design-tokens` may depend on nothing but dev-time tooling, and `ui` may depend on `design-tokens`
|
||||
and dev-time tooling. A design package that could reach an API client would put that client inside
|
||||
every module artifact that renders a button.
|
||||
|
||||
### The host installs the theme once, at its root
|
||||
|
||||
`surfaces/shell` installs the default token values at the document for both the Dashboard and the
|
||||
display application. CSS custom properties inherit through a shadow boundary, so every module
|
||||
artifact — including one mounted in an isolated root — reads `var(--ekklesia-…)` without the host
|
||||
reaching into the artifact or the artifact importing the host.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Two packages exist where none did; both are browser-safe by construction and proven so by a real
|
||||
compile of module-shaped source under the exact project a module uses for UI, where `process.env`
|
||||
must fail to typecheck.
|
||||
- The Dashboard shell and display host no longer hardcode a colour, and a module can match them.
|
||||
- A theme editor becomes implementable without a contract change: the values it would edit are
|
||||
already enumerable, labelled, described, categorised, and validated.
|
||||
- A module now has more it *may* import. The allowlist is exactly two roles and is enforced, with
|
||||
fixtures proving both the acceptance and the continued rejection of everything else.
|
||||
- Every module artifact bundles its own copy of the elements, exactly as it already bundles React
|
||||
(ADR 0023). That cost is accepted for now and is optimisable later without a contract change,
|
||||
because the contract is still a mount function.
|
||||
|
||||
## Alternatives
|
||||
|
||||
- **Ship a full design system.** Rejected: a large catalogue is a large maintenance and review
|
||||
surface, and most of it would be speculative. The set here is what a real module dashboard needs.
|
||||
- **Put the tokens in `@ekklesia/module-ui-sdk`.** Rejected: that package is the *host contract*, and
|
||||
a host that renders nothing still has to satisfy it. Design values are not something a host
|
||||
supplies, and merging them would make the mount ABI carry a theme.
|
||||
- **Expose tokens through `ModuleUiHostContext`.** Rejected: a host handing a module a token map
|
||||
makes every module re-implement styling from data, and makes the tokens a runtime negotiation
|
||||
rather than inherited CSS. Custom properties already inherit into artifacts for free.
|
||||
- **Ship a `.css` file.** Rejected for this pass: it forces a bundler and a served asset on every
|
||||
consumer, including the isolated artifacts that most need the styles. A *module's own* stylesheets
|
||||
are a separate question — the module's build already exists to compile them — and are decided by
|
||||
their own ADR.
|
||||
- **A theme editor now.** Deferred deliberately. The registry is the prerequisite; the editor needs
|
||||
persistence, an authorization story, per-installation overrides, and a preview surface, none of
|
||||
which exist.
|
||||
|
||||
## Deferred
|
||||
|
||||
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
|
||||
documentation site, and a focus-trap utility.
|
||||
@@ -0,0 +1,166 @@
|
||||
# 0040: Module stylesheets, and every browser artifact gets an isolated root
|
||||
|
||||
- Status: Accepted
|
||||
- Date: 2026-08-27
|
||||
- Supersedes: none
|
||||
- Related: [ADR 0021](0021-modules-as-hostable-products.md),
|
||||
[ADR 0022](0022-module-ui-authoring-and-host-contract.md),
|
||||
[ADR 0023](0023-module-ui-build-pipeline.md),
|
||||
[ADR 0024](0024-browser-safe-ui-sdk-and-host-loader.md),
|
||||
[ADR 0033](0033-display-kiosk-host-contract.md),
|
||||
[ADR 0039](0039-shared-design-tokens-and-elements.md)
|
||||
|
||||
## Context
|
||||
|
||||
ADR 0023 rejected stylesheet imports in module UI, and gave an honest reason: esbuild would emit a
|
||||
`.css` sidecar beside the artifact, nothing would declare it, no host would fetch it, and it would
|
||||
*work in a local test and silently do nothing in a real host*. Inline style objects were the whole
|
||||
supported story, and a stylesheet contract was deferred to "a manifest field, a host loading
|
||||
mechanism, and a style-isolation decision, none of which exist yet."
|
||||
|
||||
Two of those three now exist or are unnecessary, and the remaining gap is blocking real work:
|
||||
|
||||
- Inline style objects cannot express `:hover`, `:focus-visible`, `:disabled`, `@media`, or
|
||||
`@keyframes`. A production management screen needs all of them, and an accessible focus treatment
|
||||
needs `:focus-visible` specifically. This is not a preference; it is a capability inline styles do
|
||||
not have.
|
||||
- ADR 0039's shared elements are built from real CSS for exactly that reason, so a module using them
|
||||
already needs somewhere for a stylesheet to live.
|
||||
- A contributed renderer (a later ADR) makes the isolation question urgent rather than theoretical:
|
||||
one module's renderer mounted inside another module's screen is precisely where a leaked selector
|
||||
does damage nobody can trace.
|
||||
|
||||
The manifest field turns out not to be needed, and the loading mechanism and the isolation decision
|
||||
turn out to be the same decision.
|
||||
|
||||
## Decision
|
||||
|
||||
### A stylesheet is compiled **into** the declared artifact, as text
|
||||
|
||||
A module imports its local `.css` files. `@ekklesia/module-ui-build` compiles each one — resolving
|
||||
local `@import`s through a nested bundle — into a JavaScript module whose default export is that
|
||||
stylesheet's text. The author passes the text to `defineModuleUi({ styles })`.
|
||||
|
||||
That is why no manifest field is required. There is still exactly **one** declared file per browser
|
||||
role, containing everything: nothing new to publish, nothing for a host to fetch, no second URL, and
|
||||
no way for a stylesheet to become an undeclared sidecar — the failure ADR 0023 named. The builder
|
||||
additionally fails if any CSS output file is ever produced beside an artifact, so a future option or
|
||||
loader change cannot quietly reintroduce one.
|
||||
|
||||
Two rules are enforced on the compiled text, each with its own sanitized error code:
|
||||
|
||||
- **A module stylesheet may reference Ekklesia's global theme variables and must never declare one.**
|
||||
Declaring `--ekklesia-*` would invent a global outside the registry a future theme editor
|
||||
enumerates (ADR 0039). A private alias under the module's own prefix is the supported way to name
|
||||
a local value, and is explicitly allowed.
|
||||
- **An `@import` that survives compilation is rejected.** A local import is inlined and leaves
|
||||
nothing behind, so anything remaining is a remote or unresolvable reference, and an artifact that
|
||||
fetches a stylesheet at run time is not self-contained.
|
||||
|
||||
Plain `.css` only. `.scss`, `.less`, `.styl`, and friends stay rejected: adding a preprocessor is a
|
||||
language and toolchain decision needing its own ADR, not a detail of a build pass.
|
||||
|
||||
`pnpm boundaries` allows `.css` inside a module **only** under the browser-artifact source roots the
|
||||
canonical builder actually compiles (`src/ui/`, `src/display/`, and the contribution sources a later
|
||||
ADR adds). Anywhere else in a module a stylesheet is a file no build reads and no host loads — the
|
||||
undeclared sidecar again, just relocated. Every other stylesheet dialect stays rejected everywhere.
|
||||
|
||||
### Every browser artifact mounts inside an isolated root
|
||||
|
||||
The generic loader creates a Shadow DOM root inside the host's container, installs the artifact's
|
||||
stylesheet there, and hands the artifact an element inside that root:
|
||||
|
||||
```text
|
||||
host container ← the host's element; the loader never mutates it beyond this one child
|
||||
└─ <div data-ekklesia-module-ui> ← shadow host, created and removed by the loader
|
||||
#shadow-root (open)
|
||||
├─ <style data-ekklesia-module-ui-styles> base rules, then the artifact's declared CSS
|
||||
└─ <div data-ekklesia-module-ui-root> ← the Element handed to mount()
|
||||
```
|
||||
|
||||
Shadow DOM is chosen because it is the one mechanism in a browser that **enforces** the rule rather
|
||||
than describing it: rules inside a shadow root do not apply outside it, and document rules do not
|
||||
apply inside it. A class-name convention, a build-time prefix, or a "please scope your selectors"
|
||||
instruction are conventions that one mistake defeats, and the failure mode is a module silently
|
||||
restyling the Dashboard or another module's renderer.
|
||||
|
||||
This applies to **every** role — primary UI, display UI, and contributed renderers — unconditionally,
|
||||
including artifacts that declare no stylesheet. One mounting path, not a styled one and an unstyled
|
||||
one, because a conditional isolation boundary is one a module can accidentally fall out of.
|
||||
|
||||
Both wrapper elements are `display: contents` through a base stylesheet the loader installs *before*
|
||||
the artifact's own, so the loader's two elements contribute no boxes and an artifact lays out exactly
|
||||
as it did before isolation existed — and an artifact that genuinely needs a box (a full-viewport
|
||||
display) overrides `:host` by ordinary cascade order rather than by anything the host special-cases.
|
||||
|
||||
`mode: "open"` is deliberate. **This is not a security boundary, and must not be described as one.**
|
||||
An installed module is trusted first-party code running in the host's own realm (ADR 0022, ADR 0024);
|
||||
it could reach `document` directly if it wanted to. `closed` would buy no protection while making the
|
||||
artifact's DOM unreachable to the host's own tests and diagnostics. This is a correctness boundary
|
||||
for *styles*.
|
||||
|
||||
Design tokens still reach the artifact: CSS custom properties inherit through a shadow boundary, so a
|
||||
host that installed the theme at its root (ADR 0039) has already supplied every artifact on the page.
|
||||
|
||||
### Mount and unmount are deterministic, in both directions
|
||||
|
||||
Each mount builds a fresh root with exactly one `<style>`. Unmount runs the module's cleanup and then
|
||||
removes the whole root — content and styles together — in a `finally`, so a module whose cleanup
|
||||
throws still has its stylesheet taken out of the page. Remounting cannot accumulate a second copy
|
||||
because there is nothing left to accumulate onto. A mount that throws disposes the root it built, so
|
||||
a failed artifact leaves neither partial DOM nor installed styles behind.
|
||||
|
||||
### The mount ABI is versioned, and the cutover is hard
|
||||
|
||||
An artifact's DOM is no longer a direct child of the host's element, and the definition gained a
|
||||
field. That is observable, so `MODULE_UI_ABI_VERSION` is now `2` and `defineModuleUi` stamps it —
|
||||
the author never writes it, so it can never drift from the SDK the artifact actually compiled
|
||||
against.
|
||||
|
||||
There is no dual acceptance, exactly as there is none for a manifest version (ADR 0006, ADR 0014,
|
||||
ADR 0021, ADR 0033). A version-1 artifact fails with `MODULE_UI_ABI_UNSUPPORTED` — checked first and
|
||||
on its own, so the message is "rebuild your module" rather than a developer hunting for a typo.
|
||||
Migration is one `pnpm build`: the canonical builder owns every option, and a module that declares no
|
||||
stylesheet needs no source change at all.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Module UI can express hover, focus-visible, disabled, media queries, and keyframes, so ADR 0039's
|
||||
accessible focus treatment actually works inside a module artifact.
|
||||
- One module's stylesheet provably cannot reach the Dashboard, another module, or another renderer,
|
||||
and the Dashboard's cannot reach in. Two artifacts of the *same* module are isolated from each
|
||||
other too: they share styles only if they share a source file.
|
||||
- A host's own tests reach an artifact's DOM through its open root rather than through
|
||||
`container.querySelector`. Every mounting test in this repository was updated accordingly; that
|
||||
churn is the visible price of the boundary being real.
|
||||
- Every module must be rebuilt once. Nothing else changes for an author who uses no stylesheet.
|
||||
- The artifact is larger by the size of its CSS, exactly as it is larger by the size of React
|
||||
(ADR 0023). It remains one file.
|
||||
- A module still cannot reference a bundled image or font from CSS: no asset loader is configured, so
|
||||
such a reference fails the build. Data URLs work; object storage remains the answer for real media.
|
||||
|
||||
## Alternatives
|
||||
|
||||
- **Keep inline styles only.** Rejected: it cannot express the pseudo-classes an accessible control
|
||||
needs, and the workaround — JavaScript hover and focus tracking — is worse in every way.
|
||||
- **Publish a `.css` sidecar and have the host `<link>` it.** Rejected: it needs a manifest field, a
|
||||
second publication route, a second cache policy, and a second failure mode, and it reintroduces the
|
||||
exact "a file nothing declares" hazard ADR 0023 refused. Compiling into the one declared artifact
|
||||
costs none of that.
|
||||
- **Scope styles by build-time class prefixing instead of Shadow DOM.** Rejected: a convention a
|
||||
single unprefixed selector defeats, with a silent failure mode and no way for the host to prove
|
||||
containment. It also cannot stop the *host's* rules from reaching into an artifact.
|
||||
- **Isolate only artifacts that declare a stylesheet.** Rejected: an artifact would fall in and out
|
||||
of isolation as a source file changed, and a module rendering the shared elements' `<style>` from
|
||||
inside React would leak into the host document without ever importing a `.css` file.
|
||||
- **`mode: "closed"`.** Rejected as security theatre: the artifact is same-realm trusted code, and
|
||||
closing the root only blinds the host's own diagnostics.
|
||||
- **Accept both ABI versions.** Rejected for consistency with every manifest cutover this repository
|
||||
has made: dual acceptance means one artifact can be read two ways, and the migration here is a
|
||||
rebuild rather than an edit.
|
||||
|
||||
## Deferred
|
||||
|
||||
CSS preprocessors, CSS Modules, bundled image and font assets, `adoptedStyleSheets` for shared
|
||||
constructable stylesheets across artifacts, a host-supplied reset inside artifacts beyond the two
|
||||
layout rules, and any claim that Shadow DOM confines module *code*.
|
||||
@@ -0,0 +1,193 @@
|
||||
# 0041: One module may contribute a browser renderer to another module's capability
|
||||
|
||||
- Status: Accepted
|
||||
- Date: 2026-08-27
|
||||
- Supersedes: none
|
||||
- Related: [ADR 0006](0006-versioned-capabilities-and-compiled-module-runtime.md),
|
||||
[ADR 0021](0021-modules-as-hostable-products.md),
|
||||
[ADR 0022](0022-module-ui-authoring-and-host-contract.md),
|
||||
[ADR 0024](0024-browser-safe-ui-sdk-and-host-loader.md),
|
||||
[ADR 0025](0025-trusted-module-ui-asset-publication.md),
|
||||
[ADR 0033](0033-display-kiosk-host-contract.md),
|
||||
[ADR 0040](0040-module-stylesheets-and-artifact-isolation.md)
|
||||
|
||||
## Context
|
||||
|
||||
The platform already has a complete story for one module using another's *backend*: capabilities,
|
||||
versioned dependency ranges, and provider registration (ADR 0006). It has no story at all for one
|
||||
module using another's *browser* code, and the first real product needs one.
|
||||
|
||||
The concrete shape, stated so the mechanism can be judged against it rather than against an
|
||||
abstraction:
|
||||
|
||||
```text
|
||||
Signage provides a backend capability and owns a screen made of timeline slots
|
||||
Announcements requires that capability and registers a backend provider for it
|
||||
Announcements also ships a React renderer that knows how an announcement should look
|
||||
Signage mounts that renderer for one slot, drives it, and takes it down
|
||||
```
|
||||
|
||||
Every alternative available today is wrong in a way that matters. Signage cannot import
|
||||
Announcements: modules are independently installed, independently versioned peers, and
|
||||
`pnpm boundaries` rejects it outright. Signage cannot re-implement the renderer: it does not own the
|
||||
domain and the two would drift on the first change. Announcements cannot declare a second primary UI:
|
||||
`entrypoints.ui` and `entrypoints.display` are artifacts a *host* mounts in surfaces the host owns,
|
||||
and nothing about "a slot inside another module's timeline" is a host concern. And the platform must
|
||||
not learn what a slide is — the moment Core has a `slide` type, every future consumer inherits
|
||||
Signage's vocabulary.
|
||||
|
||||
## Decision
|
||||
|
||||
Add a generic **browser contribution**: a compiled renderer one module declares for a capability
|
||||
another module provides, mounted by that other module inside a container it owns.
|
||||
|
||||
### The manifest declaration is inert and small
|
||||
|
||||
`manifestVersion: 7` adds a top-level `contributions` array. Each entry declares:
|
||||
|
||||
| Field | Meaning |
|
||||
| --- | --- |
|
||||
| `id` | `<module-id>.<local-name>` — namespaced, so uniqueness needs no registry |
|
||||
| `version` | The contribution's own version, independent of its module's |
|
||||
| `capability` | The capability it plugs into, owned by whichever module provides it |
|
||||
| `capabilityRange` | The provider versions it is compatible with |
|
||||
| `artifact` | One compiled browser artifact, package-relative |
|
||||
|
||||
That is the complete list, and the omissions are the decision. There is no payload schema, no slot,
|
||||
no ordering, no rendering hint, and no domain vocabulary of any kind. **Core cannot learn what a
|
||||
slide is from this declaration, because the declaration cannot express it.**
|
||||
|
||||
Validation is static and fails closed: the id must be namespaced to the declaring module and have
|
||||
exactly one local segment; ids and artifact paths must be unique within a module; the artifact must
|
||||
not collide with any entrypoint; and a contribution may not target a capability its own module
|
||||
provides — a module contributing to itself needs no platform mechanism and would make the
|
||||
compatibility check a comparison of a module with itself.
|
||||
|
||||
Version 7 is a hard cutover, exactly as every prior manifest version has been (ADR 0006, 0014, 0021,
|
||||
0033). Upgrading a module that contributes nothing is a one-character edit.
|
||||
|
||||
### Publication is the existing mechanism, extended
|
||||
|
||||
Discovery resolves each contribution artifact through **the identical loop** every other compiled
|
||||
entrypoint uses, so a contribution inherits the path grammar, containment, symlink-resolution,
|
||||
extension, and file-kind checks rather than receiving a second, weaker implementation of them. Two
|
||||
contributions that resolve through a link to one file are rejected.
|
||||
|
||||
The publication registry adds contributions on three conditions, all of which must hold: the
|
||||
contributing module is in the eligible set, *some other* eligible module provides the target
|
||||
capability, and that provider's declared capability version satisfies the declared range. A
|
||||
contribution that fails any of them is **absent** — never published-but-broken, and never mounted
|
||||
with a shrug.
|
||||
|
||||
`apps/api` serves it at `GET /api/v1/module-ui/<module-id>/<version>/contributions/<contribution-id>`
|
||||
— a sibling of the artifact and display routes, under the same prefix, with the same policy: three
|
||||
lookup keys, one exact registry match, one already-validated canonical path, no request value ever
|
||||
composed into a filesystem path, and one indistinguishable `404` for unknown, undeclared, stale, and
|
||||
incompatible alike. Source maps, sibling chunks, and package-root files stay unreachable by
|
||||
construction.
|
||||
|
||||
### A consumer discovers through the host, never through a URL
|
||||
|
||||
The consuming module's descriptor gains a `contributions` array: identity, version, capability, and
|
||||
the **server-produced** URL. A module UI sees only the first three; the URL lives in the host, which
|
||||
resolves it exactly as it resolves `api.request("records")`. A module that built
|
||||
`/api/v1/module-ui/<id>/<version>/contributions/<id>` itself would be welded to Ekklesia's private
|
||||
deployment detail and could never run under a standalone host.
|
||||
|
||||
`ModuleUiHostContext` gains a sixth member, `contributions`, with `list(capabilityId)` and
|
||||
`mount({ contributionId, container, input })`. A consumer can only ever see contributions aimed at a
|
||||
capability **it** provides, because that is the only thing the server put in its descriptor — and it
|
||||
cannot name its way to anything else, because a `mount` for an id the server withheld fails without
|
||||
fetching anything.
|
||||
|
||||
### The consumer owns the domain contract, entirely
|
||||
|
||||
`input` is opaque JSON. The platform validates that it *is* genuinely JSON-serializable — structurally,
|
||||
so a `Date`, a function, `undefined`, a non-finite number, or a cycle fails at the boundary rather
|
||||
than vanishing somewhere far from the mistake — and validates nothing about its shape or meaning. A
|
||||
consumer publishes its own input contract alongside its capability and versions it through its own
|
||||
capability version. Neither the manifest, the registry, the route, nor the SDK has a place to put a
|
||||
schema, which is what stops one from appearing.
|
||||
|
||||
### The contribution ABI is separate and versioned
|
||||
|
||||
`MODULE_CONTRIBUTION_ABI_VERSION` is stamped by `defineModuleContribution`, never written by an
|
||||
author. It is deliberately **not** the same number as `MODULE_UI_ABI_VERSION`: the two contracts have
|
||||
different lifecycles — one receives a host context, the other receives a consumer's input and can be
|
||||
updated in place — and versioning them together would force every module to rebuild whenever either
|
||||
moved. A mismatched artifact fails with its own code before mounting.
|
||||
|
||||
The ABI supports exactly what a long-running screen needs:
|
||||
|
||||
- **asynchronous readiness** — `mount` may return a promise, so a consumer can sequence a transition
|
||||
instead of guessing when a renderer has decoded its media;
|
||||
- **update without remounting** — remounting per change would destroy DOM, restart animations, and
|
||||
reload media on every tick. A renderer that genuinely cannot update in place omits `update`, and
|
||||
the consumer is told to remount rather than left rendering stale content behind a silently ignored
|
||||
call;
|
||||
- **deterministic cleanup** — the loader runs the renderer's cleanup and removes its isolated root in
|
||||
a `finally`, so a renderer that fails to clean up still has its styles taken out of the page;
|
||||
- **sanitized failure** — every code is fixed, and no message, stack, or `cause` ever carries the
|
||||
consumer's domain data.
|
||||
|
||||
Each contribution mounts inside its own Shadow DOM root (ADR 0040) with its own compiled stylesheet,
|
||||
so a contributed renderer cannot restyle the consuming module's screen around it, and that module's
|
||||
rules cannot reach in.
|
||||
|
||||
### What a contribution is not given
|
||||
|
||||
No database, no object storage, no credential, no session, no API client, and no reach into any
|
||||
module — including the one that contributed it. Its context is two members: its own id and the
|
||||
consumer's input.
|
||||
|
||||
**Loading a contribution grants its module nothing.** Server-side authorization is unchanged and
|
||||
remains the only authority. Everything a contributed renderer needs arrives through the consumer's
|
||||
input, because the consumer is the party that was authorized to obtain it.
|
||||
|
||||
A contribution artifact is still installed, trusted, first-party code in the host's realm (ADR 0022,
|
||||
ADR 0024). Its isolated root is a correctness boundary for styles and must never be described as a
|
||||
sandbox.
|
||||
|
||||
## Consequences
|
||||
|
||||
- One module can extend another's screen without either importing the other, and without the
|
||||
platform learning either one's vocabulary.
|
||||
- The Dashboard bundle now contains two dynamic imports rather than one, both the generic loader's:
|
||||
a module UI artifact and a contributed renderer.
|
||||
- Manifest version 7 requires every module to update one field. Nothing else changes for a module
|
||||
that contributes nothing.
|
||||
- `ModuleUiHostContext` grew from five members to six. It is present on every context and empty for
|
||||
the overwhelming majority of modules, so a module writes the same code whether or not anything was
|
||||
contributed — and a standalone host running one module supplies an empty implementation that is
|
||||
the *correct* one rather than a stub.
|
||||
- A consuming module must handle an empty list: a contribution can be absent because nobody
|
||||
contributed, because the contributor is disabled, or because it is outside the compatible range.
|
||||
That is the same discipline an optional capability dependency already requires.
|
||||
|
||||
## Alternatives
|
||||
|
||||
- **Let the consumer import the contributor.** Rejected: it is the layer violation the boundary
|
||||
checker exists to prevent, and it would make two independently installable modules one unit.
|
||||
- **Put the renderer in the consumer.** Rejected: the consumer does not own the domain, so the
|
||||
renderer and the data it draws would drift on the first change to either.
|
||||
- **A second `entrypoints` role.** Rejected: the two existing roles are artifacts a *host* mounts in
|
||||
surfaces the host owns. A contribution is mounted by a module, at a moment and in a place only that
|
||||
module understands; calling it an entrypoint would put the platform in charge of deciding when a
|
||||
slot should be filled.
|
||||
- **A platform-defined payload schema.** Rejected, emphatically. It would make Core the owner of a
|
||||
vocabulary it cannot possibly own, force every future consumer to inherit the first one's shape,
|
||||
and require a platform release to change a domain contract between two modules.
|
||||
- **Give the contribution its own API client.** Rejected: a renderer with its own authority is a
|
||||
privilege the consumer never granted, and a second authorization path to reason about. The consumer
|
||||
was authorized to fetch the data; it passes what it fetched.
|
||||
- **Let a consumer construct the artifact URL.** Rejected for the same reason a module may not build
|
||||
its own API prefix (ADR 0022): it welds the module to Ekklesia's deployment detail.
|
||||
- **One shared ABI version for UI artifacts and contributions.** Rejected: it couples two contracts
|
||||
with different shapes and lifecycles, and would make every module rebuild whenever either moved.
|
||||
|
||||
## Deferred
|
||||
|
||||
Contribution ordering and priority, a consumer-declared "extension point" manifest declaration,
|
||||
per-contribution permissions, contributions to *backend* extension points, host-mediated
|
||||
communication back from a contribution to its own module, contribution-level configuration, hot
|
||||
reload, and a module store or installer that would let contributions arrive without a restart.
|
||||
@@ -0,0 +1,160 @@
|
||||
# 0042: Explicit, bounded streaming request bodies
|
||||
|
||||
- Status: Accepted
|
||||
- Date: 2026-08-27
|
||||
- Supersedes: none
|
||||
- Related: [ADR 0015](0015-static-module-http-routing-and-runtime-lifecycle.md),
|
||||
[ADR 0017](0017-public-request-perimeter-hardening.md),
|
||||
[ADR 0030](0030-host-provided-module-object-storage.md),
|
||||
[ADR 0031](0031-direct-object-delivery.md)
|
||||
|
||||
## Context
|
||||
|
||||
ADR 0017 gave every public route an enforced request-body ceiling and capped a module route at
|
||||
1 MiB, with an explicit note that "large media transfer belongs to a future dedicated storage
|
||||
design, never to widening this ceiling."
|
||||
|
||||
The storage design arrived — ADR 0030 gave modules a scoped object store, ADR 0031 took Ekklesia out
|
||||
of the *read* path with signed delivery URLs — and it did not solve the *write* path. ADR 0031
|
||||
deliberately declined to offer signed write URLs, so the only way bytes reach a module's bucket is
|
||||
through that module's own HTTP route. That route is capped at 1 MiB, and an ordinary photograph is
|
||||
not 1 MiB. A church uploading a slide for a lobby screen cannot, today, get the file in.
|
||||
|
||||
Two properties of the existing implementation also need naming, because they are what the change has
|
||||
to preserve and what it has to fix:
|
||||
|
||||
- The pinned body-limit middleware **fully buffers** any body with no declared `Content-Length`
|
||||
before deciding. At 1 MiB that is a bounded, acceptable cost. At image sizes it would mean the
|
||||
platform holding every concurrent upload in memory.
|
||||
- With a declared `Content-Length` within the limit it *counts nothing at all*, trusting the header.
|
||||
Node's HTTP framing makes that safe today, but it is trust the platform does not need to extend.
|
||||
|
||||
## Decision
|
||||
|
||||
Extend the existing route contract. There is no second HTTP system, no upload endpoint, no signed
|
||||
write URL, and no new middleware stack — one route declaration, one limiter, one `413`.
|
||||
|
||||
### A route declares how its body reaches it
|
||||
|
||||
`ModuleRouteDefinitionInput` gains one optional field:
|
||||
|
||||
```ts
|
||||
http.route({
|
||||
method: "POST",
|
||||
path: "/uploads",
|
||||
permission: "signage.slides.write",
|
||||
maxBodyBytes: 33_554_432,
|
||||
bodyMode: "streamed",
|
||||
handler,
|
||||
});
|
||||
```
|
||||
|
||||
- **`buffered`** (the default, and what every route written before this meant) — the platform reads
|
||||
and counts the whole body **before** the handler runs, so the handler is promised a fully
|
||||
available, bounded body and an oversized request is rejected before any authorization or handler
|
||||
work happens. Ceiling: 1 MiB, unchanged.
|
||||
- **`streamed`** — the platform counts bytes **as the handler consumes them** and never materializes
|
||||
the body. Ceiling: 32 MiB.
|
||||
|
||||
The mode is **explicit rather than inferred from the size**. Inferring it — "over 1 MiB means
|
||||
streamed" — would mean an author who raised a limit silently changed how their own handler receives
|
||||
its body, and when a `413` can arrive relative to their own code. That is exactly the class of
|
||||
invisible change this repository refuses.
|
||||
|
||||
Omitting the field means `buffered`, so no existing route changed behaviour, and the canonical parsed
|
||||
form always carries a mode so nothing downstream re-applies the default or disagrees about it.
|
||||
|
||||
### The ceilings, and why 32 MiB
|
||||
|
||||
`MODULE_ROUTE_MAX_BODY_BYTES_CEILING` stays at 1 MiB for buffered bodies: a body the platform
|
||||
materializes is a body the platform is holding, and 1 MiB is the most a JSON request has any business
|
||||
being.
|
||||
|
||||
`MODULE_ROUTE_STREAMED_MAX_BODY_BYTES_CEILING` is 32 MiB, sized to hold one 25 MiB image plus bounded
|
||||
overhead — a multipart envelope, its boundaries and part headers, and room for a client that pads. It
|
||||
is also `MODULE_ROUTE_ABSOLUTE_MAX_BODY_BYTES`: **no route, in any mode, may declare or accept a
|
||||
larger single request body, and no unbounded option exists anywhere in the contract.** A route
|
||||
declaring more than its mode's ceiling fails registration, exactly as an over-ceiling value always
|
||||
has.
|
||||
|
||||
The unit is deliberately **one file per request**. A UI offering twenty images issues twenty
|
||||
individually bounded requests, each of which the platform can reject cheaply and each of which fails
|
||||
alone; a 500 MiB aggregate request would make one bad byte waste everything already sent, and nothing
|
||||
in this contract can express one.
|
||||
|
||||
### Enforcement, in one limiter
|
||||
|
||||
The platform's own limiter replaces the buffering dependency. It is the same code for both modes and
|
||||
differs only in *when* it counts:
|
||||
|
||||
1. A declared `Content-Length` above the limit is refused synchronously — before authorization,
|
||||
before the handler, before a single body byte is read. That is the cheap rejection an ordinary
|
||||
too-large upload takes. A malformed or contradictory header is treated as *absent* rather than as
|
||||
a rejection, because the byte counting bounds the request either way and refusing could only turn
|
||||
a legitimate request away.
|
||||
2. `buffered`: bytes are counted as they arrive and the request is rejected the moment the running
|
||||
total passes the limit — so a lying or absent `Content-Length` is bounded by what actually
|
||||
arrived rather than by what was claimed, which the previous implementation did not do. At most
|
||||
the route's own declared allowance is ever held.
|
||||
3. `streamed`: the body is wrapped in a counting transform and handed to the handler. The moment the
|
||||
total passes the limit the stream errors, the source is cancelled, and the response is replaced
|
||||
with the same sanitized `413` — **even if the handler caught the error and answered anyway**. A
|
||||
limit a handler can swallow is not a limit.
|
||||
|
||||
A body no handler read is cancelled after the request, so an abandoned upload releases its socket
|
||||
rather than draining. An aborted upload surfaces as an ordinary failed request, never a `413`:
|
||||
nothing exceeded a limit.
|
||||
|
||||
Ordering is unchanged and remains deliberate: trusted-origin check, then the body contract, then
|
||||
permission evaluation, then the handler. In `streamed` mode no body byte is read until the handler
|
||||
reads it and the handler runs only after authorization succeeds, so an unauthorized caller can never
|
||||
push an upload through the platform at all — strictly better than the buffered path.
|
||||
|
||||
Timeouts stay where they were: the server's own `headersTimeout`/`requestTimeout`. A slow upload is a
|
||||
slow *request*, and the platform already has one answer for that rather than a second per-route one.
|
||||
|
||||
### What a module author must know
|
||||
|
||||
In `streamed` mode a `413` can arrive **after** the handler has started. A handler that persists as
|
||||
it reads must be prepared for a partially consumed upload — write to a temporary key and commit only
|
||||
after the read completes. The platform cannot do that for the module, because only the module knows
|
||||
what a partial write means to its own data.
|
||||
|
||||
## Consequences
|
||||
|
||||
- An ordinary image upload works, through the route contract a module already had, with the platform
|
||||
holding none of it.
|
||||
- The buffered path now counts bytes it previously trusted a header for, closing a gap rather than
|
||||
opening one.
|
||||
- Every existing route and every existing test is unchanged: the default is the old behaviour, and
|
||||
the `413` contract, ordering, and sanitization are identical.
|
||||
- `apps/api` owns ~60 lines of stream plumbing it previously delegated. That is the price of a
|
||||
limiter that neither buffers a large body nor can be swallowed by a handler, and it is enforced by
|
||||
tests rather than by reading the dependency's source.
|
||||
- Media larger than 32 MiB — a video — still has no path in. That is deliberate and named below.
|
||||
|
||||
## Alternatives
|
||||
|
||||
- **Raise the single ceiling to 32 MiB.** Rejected: every existing route would silently gain the
|
||||
ability to have 32 MiB buffered in memory on its behalf, and `await request.json()` in a module
|
||||
handler would stop being a bounded operation.
|
||||
- **Infer streaming from `maxBodyBytes`.** Rejected: it makes a size change silently change delivery
|
||||
semantics and error timing for the author's own code.
|
||||
- **A separate upload endpoint or second HTTP system.** Rejected: a second route table, a second
|
||||
authorization path, a second CORS and origin policy, and a second failure vocabulary, to solve
|
||||
something the existing route contract can express with one field.
|
||||
- **Signed write URLs to object storage.** Deliberately still deferred (ADR 0031). A signed write URL
|
||||
is a bearer capability to *create* an object, which needs quota, content-type, and lifecycle
|
||||
answers this pass does not have — and a module that wants to validate, transform, or authorize per
|
||||
byte cannot use one anyway.
|
||||
- **Let the handler enforce its own limit.** Rejected: a limit enforced by the party being protected
|
||||
against is not a limit, and every module would reimplement it slightly differently.
|
||||
- **Trust `Content-Length` in streamed mode.** Rejected: the running byte count costs nothing and
|
||||
removes the need to reason about framing at all.
|
||||
|
||||
## Deferred
|
||||
|
||||
Signed write URLs, resumable or chunked multi-request uploads, per-module or per-installation upload
|
||||
quotas, content-type or magic-byte validation, virus scanning, image transformation, progress
|
||||
reporting, and any body larger than the absolute ceiling — a video pipeline is its own design, not a
|
||||
larger number here.
|
||||
@@ -0,0 +1,173 @@
|
||||
# 0043: The display caches its shell and declared code, and nothing else
|
||||
|
||||
- Status: Accepted
|
||||
- Date: 2026-08-27
|
||||
- Supersedes: none
|
||||
- Related: [ADR 0025](0025-trusted-module-ui-asset-publication.md),
|
||||
[ADR 0031](0031-direct-object-delivery.md),
|
||||
[ADR 0032](0032-native-module-realtime-transport.md),
|
||||
[ADR 0033](0033-display-kiosk-host-contract.md),
|
||||
[ADR 0036](0036-managed-device-identity-and-pairing.md),
|
||||
[ADR 0041](0041-module-browser-contributions.md)
|
||||
|
||||
## Context
|
||||
|
||||
A display is the one surface in this platform that is expected to outlive its network. It is a
|
||||
television on a wall in a building whose uplink is a consumer router, nobody is standing in front of
|
||||
it, and the person who could press a button is not in the building on a Tuesday.
|
||||
|
||||
Today, when that network drops and the screen reboots — a power blip, a scheduled restart, a
|
||||
janitor's foot — the browser shows **its own error page**. Not the display shell, not an Ekklesia
|
||||
message, not a retry: a network failure with no Ekklesia in it at all, until someone notices and
|
||||
does something about it. Nothing about that page recovers on its own, because there is no Ekklesia
|
||||
code running on it to recover.
|
||||
|
||||
The temptation is to solve this by caching more: the descriptor, the principal, the module's data,
|
||||
so a screen can keep showing content through an outage. That is exactly the wrong instinct. A cached
|
||||
descriptor is a cached permission decision, and a screen replaying one is a revoked device still
|
||||
showing content it is no longer entitled to.
|
||||
|
||||
## Decision
|
||||
|
||||
Cache **the shell and the declared code**, and nothing else, in a service worker scoped to the
|
||||
display.
|
||||
|
||||
### What is cached
|
||||
|
||||
- The display page (`/display/<module-id>`).
|
||||
- The fixed display host script.
|
||||
- The versioned browser artifacts the display actually loaded: its module's display artifact, and
|
||||
every contributed renderer it mounted (ADR 0041).
|
||||
|
||||
All three are code. All three are already public bytes that confer no authority (ADR 0025, ADR 0041).
|
||||
All three are addressed by a URL that carries an exact version, so a cache entry is unambiguous about
|
||||
what it is.
|
||||
|
||||
### What is never cached, under any condition
|
||||
|
||||
The display descriptor, `/api/v1/principal`, every other platform and module API response, the
|
||||
realtime stream, pairing requests and responses, and **every cross-origin request** — which is what a
|
||||
signed object-delivery URL is (ADR 0031), a bearer capability with an expiry that a cache would
|
||||
outlive.
|
||||
|
||||
This is an **allowlist**, not a deny list. A deny list has to be updated every time the platform gains
|
||||
an endpoint, and the cost of forgetting is a stored permission decision. Here the cost of forgetting
|
||||
is that a new kind of code is simply not cached, which is a bug rather than a vulnerability.
|
||||
|
||||
### The consequence, stated rather than papered over
|
||||
|
||||
**A display cannot render module content while its network is down.** Mounting requires a descriptor;
|
||||
a descriptor is authorization-bearing; it is never served from a cache. A revoked device therefore
|
||||
stays revoked, and cached code can never manufacture current authorization — the two properties this
|
||||
pass must not trade away.
|
||||
|
||||
What the cache buys is real and worth having on its own:
|
||||
|
||||
- A kiosk that reboots during an outage boots into **Ekklesia's** honest offline state instead of the
|
||||
browser's error page, and keeps retrying by itself.
|
||||
- The moment connectivity returns it resolves and mounts with **nothing left to download** — the
|
||||
600 KB artifact is already there.
|
||||
- A momentary failure fetching an artifact, after a descriptor already resolved, falls back to the
|
||||
identical bytes instead of failing the whole display. The descriptor is tiny and the artifact is
|
||||
not, so this is the failure that actually happens.
|
||||
|
||||
Module-owned offline content — a module persisting its own state and media so a screen can keep
|
||||
showing yesterday's slides — remains the module's responsibility. This is the code foundation it
|
||||
would build on.
|
||||
|
||||
### Network first, always
|
||||
|
||||
Every cached kind is fetched from the network first and only falls back when that fetch fails. A
|
||||
kiosk therefore picks up new code the moment the server returns and can never be trapped on an
|
||||
obsolete build — which is precisely the failure mode a cache-first worker produces on an unattended
|
||||
screen, and the one that requires physically visiting it to fix.
|
||||
|
||||
Only a complete, successful, same-origin `200` is ever stored. A `304`, a `404`, a redirect, an
|
||||
opaque response, and a `503` from an API mid-restart are all things a screen must never later replay
|
||||
as though they were the artifact.
|
||||
|
||||
### Versioned keys and pruning
|
||||
|
||||
Cache names carry a policy version; superseded generations are deleted on activation, so a policy
|
||||
change cannot leave a screen serving entries from rules that no longer apply.
|
||||
|
||||
Artifact URLs already carry an exact module version, so a new build lands *beside* the old one. After
|
||||
storing an artifact the worker removes every entry for the same module at a different version — and
|
||||
deliberately keeps every entry at the current version, because a module's display artifact and each
|
||||
contributed renderer are separate URLs under one version and dropping any of them would half-cache a
|
||||
working display. Another module's artifacts are never touched.
|
||||
|
||||
The worker never caches **itself**, which is the other way a screen gets stuck: it is served
|
||||
`no-cache` with a strong validator, exactly like the host script.
|
||||
|
||||
### Scope is the directory
|
||||
|
||||
The worker is served at `/display/sw.js`, chosen for its *directory*: a service worker's default
|
||||
scope is where it was served from, so this one controls display pages and nothing else. The Dashboard
|
||||
is never controlled by it and no Dashboard request is ever intercepted. Serving it beside the host
|
||||
script under `/assets/` would scope it to a directory where no page lives; widening it with
|
||||
`Service-Worker-Allowed` would put the Dashboard under a worker it has no use for.
|
||||
|
||||
A controlled page's requests are intercepted wherever they point, which is how an artifact under
|
||||
`/api/v1/module-ui/` is cached without widening the scope to the whole origin.
|
||||
|
||||
### Reporting, not deciding
|
||||
|
||||
The page gets a small browser-safe signal built from two independent sources, because either alone
|
||||
would lie: `navigator.onLine` (cheap, immediate, famously optimistic — a captive portal reports
|
||||
`true`) and the worker's own report that it actually served from cache (ground truth about this
|
||||
application). A display renders a quiet corner indicator, never a full-page failure: a screen whose
|
||||
switch rebooted must not blank a lobby wall.
|
||||
|
||||
When the browser reports the network is back, the display resolves its descriptor again on its own.
|
||||
Nobody presses Retry on a lobby television, and that fresh descriptor is also what stops a screen
|
||||
sitting on cached code after a deployment — it names the current version, and the network-first worker
|
||||
fetches it.
|
||||
|
||||
Registration is an **enhancement and never a prerequisite**: a browser without service workers, a page
|
||||
served over plain HTTP in development, and a policy that refuses all leave a display that works
|
||||
exactly as it did before, online only.
|
||||
|
||||
### Testable by construction
|
||||
|
||||
Every caching decision lives in a module that touches no global: the cache storage, `fetch`, and the
|
||||
way a status reaches the page all arrive as an environment. The worker file is thin wiring that
|
||||
supplies the real ones. That is what lets the behaviour be exercised directly — first load, offline
|
||||
reload, a missing uncached artifact, contribution fallback, version upgrade and pruning, reconnect,
|
||||
and the proof that nothing authorization-bearing is ever stored — rather than through a service
|
||||
worker a test runner cannot host.
|
||||
|
||||
## Consequences
|
||||
|
||||
- An unattended screen degrades to an Ekklesia message and recovers by itself, instead of degrading
|
||||
to a browser error page and staying there.
|
||||
- The platform serves a third fixed display asset, read once before the listener binds like the other
|
||||
two.
|
||||
- A display page is now controlled by a service worker, which is a real operational surface: an
|
||||
operator debugging a screen must know a worker is in front of it. It is `no-cache`, claims clients
|
||||
immediately, and caches only code, so the ways it can go wrong are bounded.
|
||||
- Offline *content* is still absent, and that is the honest state of this foundation rather than an
|
||||
oversight.
|
||||
|
||||
## Alternatives
|
||||
|
||||
- **Cache the descriptor so a display can mount offline.** Rejected outright. It is a permission
|
||||
decision and a device identity; replaying one is a revoked screen still showing content.
|
||||
- **Cache-first for speed.** Rejected: it is how an unattended screen ends up serving a build from
|
||||
months ago with no way to notice, and fixing it means visiting the screen.
|
||||
- **Precache everything at install.** Rejected: the worker would have to know which module a screen
|
||||
runs and at which version — facts only the descriptor carries — and an install would fail whenever
|
||||
an asset a future version needs did not exist yet.
|
||||
- **Cache at the whole origin with `Service-Worker-Allowed`.** Rejected: the Dashboard gains a worker
|
||||
it has no use for, for no benefit.
|
||||
- **Use `navigator.onLine` alone.** Rejected: it reports `true` behind a captive portal and a dead
|
||||
uplink, so a screen would insist it was fine while showing nothing.
|
||||
- **Store artifacts in IndexedDB and import them from a blob URL.** Rejected: it costs a host its
|
||||
`script-src` CSP, exactly as ADR 0024 rejected for the loader, and reimplements what a cache
|
||||
already does correctly.
|
||||
|
||||
## Deferred
|
||||
|
||||
Module-owned offline content and media persistence, background sync, periodic background update,
|
||||
push, a Dashboard service worker, offline pairing, precaching a display's artifact before its first
|
||||
visit, and any cached response that carries identity or authorization.
|
||||
@@ -0,0 +1,189 @@
|
||||
# 0044: Ekklesia ships one self-hosted reference deployment, composed from pinned sources
|
||||
|
||||
- Status: Accepted
|
||||
- Date: 2026-10-01
|
||||
- Supersedes: none
|
||||
- Related: [ADR 0003](0003-development-infrastructure.md),
|
||||
[ADR 0004](0004-replace-unmaintained-minio.md),
|
||||
[ADR 0017](0017-public-request-perimeter-hardening.md),
|
||||
[ADR 0018](0018-identity-session-resolution-and-database-transport.md),
|
||||
[ADR 0021](0021-modules-as-hostable-products.md),
|
||||
[ADR 0030](0030-host-provided-module-object-storage.md),
|
||||
[ADR 0031](0031-direct-object-delivery.md),
|
||||
[ADR 0032](0032-native-module-realtime-transport.md),
|
||||
[ADR 0034](0034-host-owned-browser-authentication.md),
|
||||
[ADR 0035](0035-initial-platform-owner-bootstrap.md),
|
||||
[ADR 0043](0043-display-offline-code-fallback.md)
|
||||
|
||||
## Context
|
||||
|
||||
Phases 1–8 built and verified the platform contracts, but `README.md` and
|
||||
`docs/development/security.md` both record that Ekklesia is not deployed: there is no production
|
||||
instance, reverse proxy, public listener, Dashboard asset hosting, or deployment automation. The
|
||||
first real feature module, Signage, now exists in its own repository and needs a production host.
|
||||
|
||||
Several gaps only become concrete with a real deployment:
|
||||
|
||||
- Production requires certificate-verified PostgreSQL TLS (`verify-full` is the only production
|
||||
mode; ADR 0018), including for a database on the same host or Docker network.
|
||||
- Browsers require a secure context for the display service worker (ADR 0043), and production
|
||||
identity rejects `http` and loopback origins. A real, publicly trusted certificate is therefore
|
||||
required even on a private network.
|
||||
- Signage declares a storage bucket, so object storage is no longer optional for this deployment,
|
||||
and ADR 0004 left the production provider undecided.
|
||||
- A module repository is installed by cloning it into `modules/`. It depends on platform packages
|
||||
through `workspace:` ranges, so it can only be built inside an Ekklesia workspace, and the
|
||||
platform's committed `pnpm-lock.yaml` does not describe it.
|
||||
- There is no health endpoint, no backup procedure, and no upgrade procedure.
|
||||
|
||||
## Decision
|
||||
|
||||
Ekklesia provides **one reference self-hosted deployment**, kept in this repository under
|
||||
`deploy/`, that any organization can run on a single Linux host with Docker Compose. River of
|
||||
Glory Church is its first instance, but nothing church-specific enters this repository.
|
||||
|
||||
### Topology
|
||||
|
||||
One host, one Compose project, four long-running services on a private network:
|
||||
|
||||
| Service | Role |
|
||||
| --- | --- |
|
||||
| `proxy` | Caddy. Terminates TLS, serves the only published ports (443; and 80, which answers ACME HTTP-01 challenges and redirects everything else to HTTPS), routes `/api/*`, `/display/*`, `/assets/*` and the Dashboard to `api`, and a separate storage origin to `object-storage`. |
|
||||
| `api` | The `apps/api` image: Core, the enabled modules, and the built Dashboard and display host. Listens on the private network only. |
|
||||
| `postgres` | PostgreSQL 18 with TLS enabled, using a server certificate issued by an installation-private CA. |
|
||||
| `object-storage` | SeaweedFS, the same image family as development, reachable by browsers only through the proxy. |
|
||||
|
||||
Plus one operator-run, non-restarting service, `migrate`, described below.
|
||||
|
||||
### The API serves the Dashboard
|
||||
|
||||
The built Dashboard (`surfaces/shell`, `index.html` and its bundle) is served by `apps/api` from
|
||||
the same origin as `/api`, exactly as the display host already is. This keeps the identity
|
||||
system's default same-origin shape (`EKKLESIA_DASHBOARD_ORIGIN` unset) and avoids a second static
|
||||
server. The proxy never serves files from disk.
|
||||
|
||||
### TLS everywhere, without weakening ADR 0018
|
||||
|
||||
- **Browser-facing:** the proxy obtains publicly trusted certificates through ACME. The reference
|
||||
configuration uses the HTTP-01 challenge on port 80; the DNS-01 challenge is documented for
|
||||
installations that keep Ekklesia off the public Internet. A LAN-only installation can still hold a
|
||||
real certificate for an internal hostname this way. DNS-01 requires a Caddy build that includes a
|
||||
DNS provider module, which the stock Caddy image does not; such an installation builds and pins
|
||||
its own proxy image. A tunnel-only installation needs DNS-01 too: Cloudflare terminates TLS at its
|
||||
edge, so an HTTP-01 challenge cannot reach the proxy through the tunnel.
|
||||
- **Tunnel ingress (optional):** an installation may publish the proxy through an outbound tunnel
|
||||
(the reference documents `cloudflared`) instead of opening inbound ports. The tunnel connector
|
||||
is a separate Compose service that connects to the proxy over HTTPS and verifies the proxy's
|
||||
certificate. With a token-managed tunnel, those origin settings (the service `https://proxy:443`,
|
||||
the origin server name, and TLS verification left on) live in the Cloudflare dashboard, not in the
|
||||
repository; the deployment README documents them. The proxy accepts a tunnel-supplied
|
||||
client-address header **only** from the connector's network identity. It overwrites the single
|
||||
forwarding header it sends to `api`, and `api` trusts only the proxy. An installation that serves
|
||||
local displays should give the same hostname a LAN path to the proxy (split-horizon DNS), so
|
||||
displays do not depend on the Internet link.
|
||||
- **Database:** the deployment's setup script creates an installation-private CA once, issues a
|
||||
server certificate for the `postgres` service name, and gives the API only the CA certificate
|
||||
(`EKKLESIA_DATABASE_CA`). The API connects with `verify-full`. No plaintext or encryption-only
|
||||
exception is added to Core.
|
||||
- **Object storage:** production storage must be `https` (ADR 0030), so the API reaches SeaweedFS
|
||||
over HTTPS on the private network as well. The same setup script issues a server certificate for
|
||||
the `object-storage` service name from the same CA. Core's storage client trusts Node's default CA
|
||||
set, so the API adds the installation CA to it with `NODE_EXTRA_CA_CERTS`. No storage TLS
|
||||
exception is added to Core either.
|
||||
|
||||
### Object storage
|
||||
|
||||
SeaweedFS is the reference provider for this deployment. Before the first production instance
|
||||
holds data, the existing storage integration suite (`core/tests/module-storage.integration.ts`,
|
||||
already run by `pnpm test:migrations:integration`) must pass against the exact SeaweedFS image the
|
||||
deployment pins. Browsers reach signed object URLs on a dedicated storage origin
|
||||
(`S3_DELIVERY_ENDPOINT`); the proxy forwards the original `Host` header unchanged so S3 signatures
|
||||
remain valid (ADR 0031). Any other S3-compatible provider remains a supported substitution.
|
||||
|
||||
### Composition from pinned sources
|
||||
|
||||
A running installation is defined by an **instance composition**: the platform version and the
|
||||
exact module sources it includes. The image build:
|
||||
|
||||
1. checks out the platform at a release tag;
|
||||
2. checks out each listed module at a pinned tag or commit SHA into `modules/<id>`;
|
||||
3. installs with `pnpm install --frozen-lockfile` against a lockfile **committed with the
|
||||
instance composition**, not the platform's own lockfile;
|
||||
4. runs `pnpm verify`-equivalent module and migration validation, then builds.
|
||||
|
||||
The instance composition (module list, pins, lockfile, non-secret configuration) lives in a
|
||||
separate repository owned by the installation. The reference deployment documents its shape and
|
||||
provides an empty example. Two builds from the same composition produce the same installed code.
|
||||
|
||||
This is a deployment mechanism, not a module distribution format. A packaged, installable module
|
||||
artifact (built outside an Ekklesia workspace, against published SDK versions) is a separate
|
||||
future decision.
|
||||
|
||||
### Migrations stay explicit
|
||||
|
||||
The `migrate` service runs `migrations:plan` then, only when an operator invokes it,
|
||||
`migrations:apply`, from the same image as `api`. The `api` service never applies migrations. The
|
||||
documented upgrade procedure is: build image → back up → run `migrate` → restart `api`.
|
||||
|
||||
### Health
|
||||
|
||||
`apps/api` gains two unauthenticated endpoints that reveal nothing about installed modules:
|
||||
|
||||
- `GET /api/v1/health/live` — the process is serving HTTP.
|
||||
- `GET /api/v1/health/ready` — the database is reachable and module startup completed.
|
||||
|
||||
Both return a fixed JSON body and `Cache-Control: no-store`. Compose health checks and external
|
||||
monitoring use them. `pnpm doctor` remains the detailed operator report.
|
||||
|
||||
### Backups
|
||||
|
||||
The reference deployment includes a scheduled backup job producing a `pg_dump` custom-format
|
||||
archive and a copy of every bucket under the installation's `S3_BUCKET_PREFIX`, written to a
|
||||
location outside the host. A restore procedure is documented and must be exercised once before
|
||||
an installation is treated as in production.
|
||||
|
||||
### Secrets
|
||||
|
||||
Secrets are supplied to Compose from root-owned environment files on the host, never committed:
|
||||
one per service, so no container receives another service's credentials. They include
|
||||
`EKKLESIA_IDENTITY_SECRET`, database and storage credentials, SMTP credentials, and
|
||||
`EKKLESIA_MODULE_SECRET__*` values. A secret manager is not required.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Ekklesia has a reproducible, documented way to run in production for the first time, and the
|
||||
ten-item launch checklist in `docs/development/security.md` becomes checkable against concrete
|
||||
configuration.
|
||||
- No Core guarantee is relaxed. The database TLS rule, explicit migrations, and the trusted-code
|
||||
module model are unchanged.
|
||||
- The platform and modules begin release tagging, because an instance composition can only pin
|
||||
what has a stable name.
|
||||
- An installation that wants Cloudflare Tunnel, a different proxy, or a managed database can
|
||||
substitute it, but the reference configuration is the one CI exercises.
|
||||
- Building modules inside a platform workspace remains a known limitation until a module
|
||||
packaging decision replaces the per-instance lockfile.
|
||||
|
||||
## Alternatives
|
||||
|
||||
- **Bare-metal systemd services.** Rejected as the reference: harder to reproduce and pin. Remains
|
||||
possible for installations that prefer it.
|
||||
- **Caddy-internal CA for browsers.** Rejected: every display device and admin browser would need
|
||||
the CA installed, which is unrealistic for volunteer-run screens.
|
||||
- **Relaxing database TLS inside a private Docker network.** Rejected: ADR 0018 deliberately has no
|
||||
such exception.
|
||||
- **Applying migrations automatically at API startup.** Rejected: contradicts the explicit-operator
|
||||
rule.
|
||||
- **Using `pnpm install` without a frozen lockfile when adding modules.** Rejected: the installed
|
||||
dependency graph would differ between builds.
|
||||
- **Kubernetes.** Rejected for the reference: disproportionate for a single church server.
|
||||
|
||||
## Deferred
|
||||
|
||||
- Module packaging and distribution (published SDK packages, signed module artifacts, an
|
||||
installer).
|
||||
- Multi-host, high availability, and managed-database guidance.
|
||||
- Container image publishing and automated deployment from CI.
|
||||
- Remote-access gates in front of the Dashboard (for example Cloudflare Access), documented as
|
||||
installation choices rather than platform requirements.
|
||||
- An authorized offline display descriptor or lease (tracked separately; required before relying
|
||||
on displays to cold-start without the server).
|
||||
@@ -0,0 +1,233 @@
|
||||
# 0045: The Dashboard and the API may live on separate origins
|
||||
|
||||
- Status: Accepted
|
||||
- Date: 2026-10-03
|
||||
- Amends: [0034](0034-host-owned-browser-authentication.md)
|
||||
- Extends: [0044](0044-self-hosted-reference-deployment.md)
|
||||
- Related: [ADR 0011](0011-identity-http-transport-and-public-enrollment.md),
|
||||
[ADR 0012](0012-identity-origin-csrf-protection-under-test.md),
|
||||
[ADR 0018](0018-identity-session-resolution-and-database-transport.md),
|
||||
[ADR 0025](0025-trusted-module-ui-asset-publication.md),
|
||||
[ADR 0032](0032-native-module-realtime-transport.md),
|
||||
[ADR 0033](0033-display-kiosk-host-contract.md),
|
||||
[ADR 0036](0036-managed-device-identity-and-pairing.md),
|
||||
[ADR 0038](0038-email-verification-and-password-recovery.md),
|
||||
[ADR 0043](0043-display-offline-code-fallback.md)
|
||||
|
||||
## Context
|
||||
|
||||
ADR 0044's reference deployment serves everything a browser needs from one origin: the Dashboard at
|
||||
`/`, the API at `/api/*`, and the display host at `/display/*`. ADR 0034 kept a deliberately split
|
||||
browser/API origin possible through the exact trusted-origin allowlist and credentialed CORS of
|
||||
ADR 0011, ADR 0012, and ADR 0018, but left that topology undesigned: "any eventual choice to place
|
||||
the API and Dashboard on different sibling subdomains must be designed and tested rather than
|
||||
assumed to share a session."
|
||||
|
||||
The first production installation needs exactly that: the Dashboard and the API on sibling
|
||||
hostnames (for example `dash.example.org` and `api.example.org`), plus the existing storage
|
||||
hostname. The choice has to be made before the first deployment, because a display's bookmark and a
|
||||
paired device's cookies are bound to a hostname: moving them later re-pairs every screen.
|
||||
|
||||
Most of the platform was already written for this. The shell sends every platform and module request
|
||||
with `credentials: "include"` and opens realtime with `withCredentials: true`; CORS on `/api/auth/*`
|
||||
and `/api/v1/*` reflects only exact trusted origins with credentials; module artifacts are anonymous
|
||||
by design so that cross-origin `import()` works (ADR 0025); emailed links already pass through the
|
||||
API and land on `EKKLESIA_DASHBOARD_ORIGIN` (ADR 0038); and display page URLs are resolved against the
|
||||
API's origin. Four things were not:
|
||||
|
||||
- the Dashboard learns its API origin at **build** time (`EKKLESIA_API_BASE_URL`), so one image
|
||||
cannot serve both topologies;
|
||||
- the API's own set of trusted browser origins is exactly `EKKLESIA_IDENTITY_TRUSTED_ORIGINS`, while
|
||||
Better Auth implicitly adds the identity base URL to its own: a page served by the API itself would
|
||||
fail the API's origin check unless an operator listed the API's own origin too;
|
||||
- the reference proxy has no routing for two browser-facing hostnames;
|
||||
- nothing proves a split deployment end to end, and a Node.js client cannot prove what matters most
|
||||
here: what a real browser sends across the two origins.
|
||||
|
||||
## Decision
|
||||
|
||||
### Two topologies, one image
|
||||
|
||||
An installation runs in exactly one of two shapes, chosen by configuration alone:
|
||||
|
||||
| Topology | Dashboard | API and display host | Storage |
|
||||
| --- | --- | --- | --- |
|
||||
| **Same-origin** (default) | `https://ekklesia.example.org/` | `https://ekklesia.example.org/api/*`, `/display/*` | its own hostname |
|
||||
| **Split** | `https://dash.example.org/` | `https://api.example.org/api/*`, `/display/*` | its own hostname |
|
||||
|
||||
Same-origin remains the default and keeps working unchanged for every installation that does not
|
||||
opt in. The same image and the same built Dashboard serve both.
|
||||
|
||||
In both, the API process serves the Dashboard's bytes (ADR 0044 is unchanged in that). In the split
|
||||
topology the proxy forwards the Dashboard hostname's two Dashboard paths to the same `api` service;
|
||||
the API does not inspect `Host`, and which hostname serves which path is the proxy's job, as every
|
||||
other perimeter rule already is.
|
||||
|
||||
### The Dashboard learns its API origin at run time
|
||||
|
||||
The API writes its own origin into the Dashboard page it serves, as one meta element:
|
||||
|
||||
```html
|
||||
<meta name="ekklesia-api-origin" content="https://api.example.org" />
|
||||
```
|
||||
|
||||
The value is the identity base URL's origin (`EKKLESIA_IDENTITY_BASE_URL`), which is by definition
|
||||
where `/api` lives; no new setting is introduced. The built page carries the element with an empty
|
||||
value; the API fills it once at startup, before computing the page's strong `ETag`, and refuses to
|
||||
start if the built page does not contain exactly one such element. The shell reads it before
|
||||
anything renders, accepts only an absolute `http(s)` origin with no path, and otherwise fails closed
|
||||
with its existing sanitized bootstrap failure. An empty value (a static development server with no
|
||||
API) means the page's own origin.
|
||||
|
||||
A meta element rather than an inline script keeps the page free of inline script, so a future strict
|
||||
`script-src` needs no exception. The build-time `EKKLESIA_API_BASE_URL` is removed; the development
|
||||
server injects the same element instead. The display host needs neither: it is always served from
|
||||
the API's origin (below) and uses its own.
|
||||
|
||||
### The display host lives on the API's origin
|
||||
|
||||
In the split topology the display host stays where it is: `https://api.example.org/display/<module>`,
|
||||
with its script under `/assets/ekklesia-display/` and its service worker at `/display/sw.js`. A
|
||||
screen's bookmark names the API hostname.
|
||||
|
||||
- **Device cookies are already bound to the API's hostname.** They are host-only, `HttpOnly`, and
|
||||
`SameSite=Strict` (ADR 0036), and they must accompany device requests to the API, so they live on
|
||||
whichever hostname answers those requests. Serving the display page from that same origin makes it
|
||||
the only hostname a screen depends on; serving it anywhere else adds a second one.
|
||||
- **Offline code fallback is unchanged.** The display's service worker never caches a cross-origin
|
||||
response (ADR 0043), which is what keeps signed storage URLs, bearer capabilities, out of the
|
||||
cache. On the API's origin, every artifact it must cache is same-origin. On any other origin, every
|
||||
artifact would be cross-origin, and the offline guarantee would need a weaker rule.
|
||||
- **No device request crosses an origin.** Pairing, heartbeats, descriptors, module routes, and
|
||||
realtime from an unattended screen stay same-origin, with no CORS dependence.
|
||||
|
||||
The Dashboard keeps linking to display pages exactly as now: their URLs are resolved against the API
|
||||
origin.
|
||||
|
||||
### The API trusts its own origin
|
||||
|
||||
The API's set of trusted browser origins, used for CORS on `/api/auth/*` and `/api/v1/*` and by every
|
||||
mutation route's origin check, becomes `EKKLESIA_IDENTITY_TRUSTED_ORIGINS` **plus the identity base
|
||||
URL's origin**, matching what Better Auth already does for its own checks. In the split topology the
|
||||
display page on the API's origin therefore passes the API's origin check without an operator having
|
||||
to list it. Trusted origins remain exact: no wildcard, no pattern, and no parent domain.
|
||||
|
||||
### Cookies and identity: unchanged, with one stated requirement
|
||||
|
||||
The session cookie keeps every property ADR 0034 gave it: `HttpOnly`, `SameSite=Lax`, `Secure` under
|
||||
HTTPS, `Path=/`, and **host-only on the API's hostname**. No `Domain` attribute is set, and the
|
||||
Dashboard's hostname never holds a session credential. The Dashboard reaches the API with credentialed
|
||||
CORS, and browsers send the cookie because the two hostnames are different origins of the **same
|
||||
site**.
|
||||
|
||||
That is a requirement of the split topology: the Dashboard and API hostnames must share a registrable
|
||||
domain (`dash.example.org` and `api.example.org` do; `dash.example.org` and `api.example.net` do not).
|
||||
Across sites, browsers withhold `SameSite=Lax` cookies from cross-origin requests and may block or
|
||||
partition third-party cookies altogether. The documentation states it, and Doctor reports the
|
||||
topology and warns when the two hostnames are not siblings under one parent domain, because Doctor
|
||||
cannot see the Public Suffix List and says so.
|
||||
|
||||
In the split topology `EKKLESIA_IDENTITY_BASE_URL` is the API's origin, `EKKLESIA_DASHBOARD_ORIGIN` is
|
||||
the Dashboard's, and `EKKLESIA_IDENTITY_TRUSTED_ORIGINS` lists the Dashboard's. Email verification and
|
||||
password reset links already point at the API and land on `EKKLESIA_DASHBOARD_ORIGIN` (ADR 0038);
|
||||
nothing about them changes.
|
||||
|
||||
### CORS stays narrow, and that limit is part of the module contract
|
||||
|
||||
Credentialed CORS keeps its current shape: exact reflected origin, `Access-Control-Allow-Credentials:
|
||||
true`, the existing method list, `Content-Type` as the only allowed request header beyond the
|
||||
CORS-safelisted ones, and no exposed response headers beyond the safelisted ones. The API does not
|
||||
reflect arbitrary requested headers.
|
||||
|
||||
A module UI's requests go through the host's `api.request` and inherit this policy. The module UI
|
||||
contract therefore states it: portable module UI code uses only CORS-safelisted request headers plus
|
||||
`Content-Type`, and reads only CORS-safelisted response headers. That is already true of every
|
||||
existing module, and it keeps one module artifact working under either topology. Widening it for a
|
||||
real module need is a later, explicit change.
|
||||
|
||||
### Routing per hostname
|
||||
|
||||
The reference proxy gains a second configuration for the split topology, beside the existing one:
|
||||
|
||||
| Hostname | Forwarded to `api` | Refused with `404` |
|
||||
| --- | --- | --- |
|
||||
| Dashboard | `/` (emailed links add only a query) and `/assets/ekklesia-dashboard/*` | everything else, including `/api/*` |
|
||||
| API | `/api/*` (realtime flushed per event, as now), `/display/*`, `/assets/ekklesia-display/*` | everything else, including `/` and `/assets/ekklesia-dashboard/*` |
|
||||
| Storage | unchanged (ADR 0031) | unchanged |
|
||||
|
||||
The Dashboard hostname refuses `/api/*` so that the session cookie has exactly one home: a Dashboard
|
||||
cannot fall back to a same-origin API on its own hostname. The API hostname answers nothing at `/`.
|
||||
Both hostnames keep every forwarding-header rule ADR 0044 established (one overwritten
|
||||
`X-Forwarded-For`, the stripped client-chosen headers, and tunnel trust only from the connector's
|
||||
address). The proxy overlay takes the Dashboard hostname from one more setting, which the split
|
||||
configuration requires and the same-origin configuration ignores.
|
||||
|
||||
### What a future Content-Security-Policy must allow
|
||||
|
||||
This decision does not add a Content-Security-Policy; the Dashboard page keeps `frame-ancestors
|
||||
'none'` and its other headers. When a full policy is designed, the split topology requires the
|
||||
Dashboard's to allow the API origin in `script-src` (module artifacts and contributions are imported
|
||||
from it) and `connect-src` (platform and module requests, realtime), and the storage origin in
|
||||
`img-src`. The display page, being same-origin with the API, needs only its own origin and the
|
||||
storage origin.
|
||||
|
||||
### Both topologies are proven end to end
|
||||
|
||||
The deployment smoke test runs in either topology, and CI runs both. The split run proves, through
|
||||
the proxy, the routing table above on every hostname, the API origin in the Dashboard page, exact
|
||||
CORS reflection with credentials for the Dashboard origin and none for an untrusted origin, and the
|
||||
host-only session cookie.
|
||||
|
||||
Because those are browser behaviors, the split run also drives a **real browser**: a headless
|
||||
Chromium from an image pinned by digest, controlled over the Chrome DevTools Protocol by the
|
||||
smoke-test client with no added dependency. In it, a person signs in through the real form on the
|
||||
Dashboard hostname, the session cookie is present only for the API hostname, the Dashboard imports a
|
||||
module's UI from the API hostname and the module's own credentialed requests succeed under CORS, no
|
||||
request is refused, the module's display page mounts on the API hostname with its service worker, and
|
||||
sign-out ends the session. The split run installs the repository's module template as a test-only
|
||||
module so these paths exist; installations' own smoke tests cover their real modules. Contribution
|
||||
artifacts are imported exactly as UI artifacts are, and the API's tests cover their CORS headers and
|
||||
those of realtime streams.
|
||||
|
||||
## Consequences
|
||||
|
||||
- One image serves both topologies; an installation chooses with configuration and the matching proxy
|
||||
configuration.
|
||||
- Same-origin installations change nothing.
|
||||
- A split installation depends on exactly two browser-facing hostnames plus storage. Screens and
|
||||
paired devices depend only on the API hostname, which therefore must not change once screens are
|
||||
paired.
|
||||
- No credential is broadened: the session cookie stays host-only on the API hostname, device cookies
|
||||
stay host-only and strict, and trusted origins stay exact.
|
||||
- Module UI code is held to CORS-safe headers in both topologies, so a module that works in one
|
||||
works in the other.
|
||||
- CI gains a second deployment smoke run and a browser in it.
|
||||
|
||||
## Alternatives
|
||||
|
||||
- **A parent-domain session cookie (`Domain=example.org`).** Rejected, as ADR 0034 already rejected
|
||||
it: it sends the credential to every sibling host and is unnecessary between same-site origins.
|
||||
- **The display host on the Dashboard hostname, or on its own hostname.** Rejected: device cookies
|
||||
are bound to the API's hostname regardless, so either adds a second hostname every screen depends
|
||||
on, puts every device request behind CORS, and breaks ADR 0043's same-origin-only offline cache.
|
||||
- **The API serving the display host and the device API on a second hostname.** Rejected: two API
|
||||
origins for one identity base URL, with the same cookies split between them.
|
||||
- **Inject the API origin with an inline script, or a configuration request.** Rejected: an inline
|
||||
script needs a CSP exception, and a request is a round trip and a failure mode before the first
|
||||
render; a meta element is static and inert.
|
||||
- **Keep the API origin a build-time setting.** Rejected: the image would have to be built per
|
||||
installation and per topology.
|
||||
- **Require operators to list the API's own origin in the trusted origins.** Rejected: an omission
|
||||
silently breaks every display mutation; Better Auth already trusts its own base URL.
|
||||
- **Reflect any requested CORS header.** Rejected for now: no module needs it, and a module that
|
||||
relied on it would be tied to its host's CORS policy.
|
||||
- **Prove split mode with HTTP checks only.** Rejected: whether a browser sends the cookie, imports
|
||||
a cross-origin module, and opens a credentialed stream is exactly what is at stake.
|
||||
|
||||
## Deferred
|
||||
|
||||
- A full Content-Security-Policy, with the requirements above.
|
||||
- A broader CORS header policy, when a module needs one.
|
||||
- More than one Dashboard origin per installation.
|
||||
- Moving an installation between topologies, and the re-pairing that moving the API hostname
|
||||
implies.
|
||||
@@ -0,0 +1,110 @@
|
||||
# 0046: Ekklesia's branding is never forced on an installation
|
||||
|
||||
- Status: Proposed
|
||||
- Date: 2026-10-03
|
||||
- Related: [ADR 0021](0021-modules-as-hostable-products.md),
|
||||
[ADR 0034](0034-host-owned-browser-authentication.md),
|
||||
[ADR 0037](0037-host-provided-outbound-email.md),
|
||||
[ADR 0038](0038-email-verification-and-password-recovery.md),
|
||||
[ADR 0039](0039-shared-design-tokens-and-elements.md),
|
||||
[ADR 0043](0043-display-offline-code-fallback.md),
|
||||
[ADR 0045](0045-split-dashboard-and-api-origins.md)
|
||||
|
||||
## Context
|
||||
|
||||
Ekklesia is software a church runs for its own people. The people who use an installation (members,
|
||||
visitors, volunteers running a screen) know the church, not the software behind it, and the church
|
||||
decides how it presents itself to them. Today the platform names itself in places those people see:
|
||||
the sign-in screen asks them to "Sign in to Ekklesia", the Dashboard's heading and page title read
|
||||
"Ekklesia", emails ask them to verify "your Ekklesia email address", and an offline screen in the
|
||||
sanctuary says it is "waiting for Ekklesia".
|
||||
|
||||
None of those were decided; they accumulated. This records the rule they should have followed, so
|
||||
every later screen, message, and module is held to it.
|
||||
|
||||
## Decision
|
||||
|
||||
### The principle
|
||||
|
||||
**Ekklesia's branding is never forced on an installation.** Anything a member, visitor, or volunteer
|
||||
can see is configurable by the installation and removable entirely. That includes, and is not
|
||||
limited to:
|
||||
|
||||
- page titles, headings, and the name of the application in a browser tab, an installed web app, or
|
||||
a bookmark;
|
||||
- sign-in, verification, recovery, and every other authentication screen;
|
||||
- emails: the sender's display name, the subject, and the body;
|
||||
- display screens, pairing screens, and offline and failure screens on a display;
|
||||
- error pages and error messages a person reads;
|
||||
- application names and icons, and any "powered by" or attribution text.
|
||||
|
||||
"Removable entirely" means an installation can show its own name, or no product name at all, and in
|
||||
either case no screen or message a person sees names Ekklesia.
|
||||
|
||||
### What is exempt
|
||||
|
||||
Internal identifiers are not branding and stay as they are: package names, module ids, URL paths
|
||||
(`/api/v1/…`, `/assets/ekklesia-dashboard/…`), HTTP header names, environment variable names, cookie
|
||||
names, CSS custom property names, element ids and `data-` attributes, storage, cache, and database
|
||||
names, and log field names. A person can find them only by opening developer tools or reading
|
||||
configuration, and renaming them would break installations for no one's benefit.
|
||||
|
||||
Tooling an operator runs to administer the installation (the command-line tools, Doctor, server logs,
|
||||
and startup messages) names the software being operated, as a package name does, and is exempt too.
|
||||
So are messages written only for developers: thrown errors that reach a console and never a page, and
|
||||
build-time diagnostics for module authors.
|
||||
|
||||
### How installations configure it
|
||||
|
||||
The platform takes the installation's name from configuration, once, and uses it everywhere a
|
||||
person-facing string would otherwise have said "Ekklesia". When no name is configured, the wording is
|
||||
neutral ("Sign in", "Verify your email address", "Waiting for the server") rather than falling back to
|
||||
"Ekklesia". The default installation therefore names nothing, which is what makes the principle
|
||||
checkable: a test can render every person-facing screen and message with no name configured and fail
|
||||
on any occurrence of "Ekklesia".
|
||||
|
||||
Browser pages receive the name the way they receive the API origin (ADR 0045): the API renders it
|
||||
into the page it serves at startup, so the image stays generic and nothing is decided at build time.
|
||||
An email's sender display name is the existing `EKKLESIA_SMTP_FROM_NAME` (ADR 0037); its subject and
|
||||
body use the same installation name.
|
||||
|
||||
Visual identity follows the same rule. The platform ships no logo, favicon, or icon today; any it
|
||||
ships later must be replaceable by the installation and removable entirely, and must not appear by
|
||||
default. Colours and type are the installation's theme (ADR 0039), not a mark.
|
||||
|
||||
### It applies to modules
|
||||
|
||||
A module's user-visible text follows the same principle. A module that names its host ("Open the
|
||||
Ekklesia Dashboard") also ties itself to one host, which ADR 0021 already asks modules not to do.
|
||||
Modules refer to the host generically ("the Dashboard") or not at all.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Every person-facing string that names the platform today becomes either the configured name or
|
||||
neutral wording. An implementation slice makes that change and adds the test that keeps it true.
|
||||
- New screens, emails, and modules are reviewed against this rule; "Ekklesia" in a person-facing
|
||||
string is a defect.
|
||||
- Internal identifiers, operator tooling, and developer diagnostics keep the name, so nothing
|
||||
operational is renamed.
|
||||
- An installation that wants to credit Ekklesia can do so in its own words; the platform never
|
||||
inserts that credit.
|
||||
|
||||
## Alternatives
|
||||
|
||||
- **Keep "Ekklesia" as the default and let installations override it.** Rejected: an installation
|
||||
that never thought to configure it would show the software's name to its people, which is forcing
|
||||
it by default, and "no occurrence of Ekklesia" could not be tested in the default state.
|
||||
- **Rename internal identifiers too.** Rejected: invisible to the people this protects, and costly
|
||||
to every installation and module.
|
||||
- **A configurable "powered by" line, on by default.** Rejected for the same reason as the first
|
||||
alternative.
|
||||
- **Per-string customization of every message.** Not needed for this principle: one installation
|
||||
name and neutral wording remove the product's name everywhere. Richer copy customization, if
|
||||
wanted, is separate work.
|
||||
|
||||
## Deferred
|
||||
|
||||
- The implementation: the setting, its delivery to pages and emails, the rewording, and the
|
||||
regression test.
|
||||
- Installation-supplied logos, icons, and favicons, and a web app manifest.
|
||||
- Translation and per-string copy customization.
|
||||
@@ -0,0 +1,256 @@
|
||||
# 0047: Every module proves itself inside Ekklesia, with one harness the platform publishes
|
||||
|
||||
- Status: Proposed
|
||||
- Date: 2026-10-05
|
||||
- Supersedes: none
|
||||
- Related: [ADR 0016](0016-module-database-access.md),
|
||||
[ADR 0021](0021-modules-as-hostable-products.md),
|
||||
[ADR 0028](0028-module-operational-lifecycle.md),
|
||||
[ADR 0030](0030-host-provided-module-object-storage.md),
|
||||
[ADR 0033](0033-display-kiosk-host-contract.md),
|
||||
[ADR 0036](0036-managed-device-identity-and-pairing.md),
|
||||
[ADR 0041](0041-module-browser-contributions.md),
|
||||
[ADR 0043](0043-display-offline-code-fallback.md),
|
||||
[ADR 0044](0044-self-hosted-reference-deployment.md),
|
||||
[ADR 0045](0045-split-dashboard-and-api-origins.md)
|
||||
|
||||
## Context
|
||||
|
||||
Modules are independently versioned products (ADR 0021). They live in their own repositories, and
|
||||
none of them can be built, run, or rendered without Ekklesia. The first production module, Signage,
|
||||
reached a church with three defects that only show when it runs **inside** Ekklesia, in a real
|
||||
browser:
|
||||
|
||||
- every heartbeat its display sent was refused with a 400, because the display and the server
|
||||
disagreed about `null`;
|
||||
- a display that failed one request at start-up never recovered without a reload;
|
||||
- its media cache leaked one blob per slide per refresh, until the browser refused the display's own
|
||||
requests.
|
||||
|
||||
Signage had unit tests, browser tests against a stubbed host, and no CI. Every test passed, because
|
||||
each half was tested against its own idea of the other, and nothing ever ran the module in the
|
||||
platform it ships into.
|
||||
|
||||
The platform's own deployment smoke (ADR 0044, ADR 0045) does run a module in a real browser, but
|
||||
only the repository's template, and only far enough to mount it. The instance repository runs the
|
||||
smoke against its real modules, but only at release time, after a module has already been tagged.
|
||||
Each module repository that wants more than that has to assemble the platform, build an image,
|
||||
start a deployment, and drive a browser itself. Signage is about to do exactly that by hand. The
|
||||
second module, Classroom, would copy it, and the copies would drift from the platform they test.
|
||||
|
||||
## Decision
|
||||
|
||||
### The platform publishes a versioned module harness
|
||||
|
||||
Each platform release publishes one OCI image, `ekklesia-module-harness:<platform version>`. It is
|
||||
built from the platform at that release tag by the platform's own release CI, and pushed to the
|
||||
organization's registry. A version is never overwritten. Modules reference it **by digest**.
|
||||
|
||||
The harness contains the platform source at that version: its `deploy/` directory, its Dockerfile,
|
||||
its lockfile, and its smoke scripts. It also contains the pinned Node.js and pnpm, the browser
|
||||
driver, and the pinned headless Chromium image reference. Given a module checkout, it:
|
||||
|
||||
1. **assembles** the module into the platform at `modules/<id>`, exactly as an instance does (ADR
|
||||
0044), and installs with `--frozen-lockfile`;
|
||||
2. runs the platform's own gates against that set: `pnpm boundaries`, `modules:validate`,
|
||||
`migrations:validate`, and the build;
|
||||
3. **builds** the deployment image from the assembled workspace with the platform's Dockerfile;
|
||||
4. starts the **reference deployment** in the split topology (ADR 0045), with a disposable
|
||||
PostgreSQL and object store, Caddy's internal CA, and freshly generated secrets;
|
||||
5. plans, then **explicitly applies**, migrations against that fresh database. This is the only
|
||||
database the harness ever migrates;
|
||||
6. creates the initial owner, grants the module's declared permissions to a test person, and
|
||||
**mounts every UI surface the manifest declares**, in headless Chromium. That covers:
|
||||
- the primary UI in the Dashboard;
|
||||
- the display, paired as a device with no human session (ADR 0036);
|
||||
- each browser contribution, whenever the module that hosts it is present (ADR 0041);
|
||||
7. runs a short **display soak**, and the **no-dead-ends** injections below;
|
||||
8. tears everything down: containers, networks, volumes, and the work directory.
|
||||
|
||||
It **fails** on any of the following:
|
||||
|
||||
- a `console.error` or uncaught exception on any page;
|
||||
- an unhandled promise rejection;
|
||||
- any 4xx or 5xx response that the harness did not itself expect. Expected ones are listed in the
|
||||
harness, for example a display's descriptor before it is paired;
|
||||
- a page that never mounts;
|
||||
- a failed or refused request (CORS, blocked, network error);
|
||||
- a soak leak.
|
||||
|
||||
Each failure line names the surface, the request path (never its query), and the step.
|
||||
|
||||
### The display soak and the no-dead-ends checks are generic
|
||||
|
||||
These checks come from what Signage got wrong, and they apply to any module with a display.
|
||||
|
||||
- **Soak**, two minutes by default. The display stays mounted with no remount and no offline
|
||||
indicator. Its realtime streams stay healthy. Live object URLs stay bounded. JavaScript heap
|
||||
growth, measured over DevTools, stays within a fixed ratio.
|
||||
- **No dead ends.** One at a time, the harness refuses the first request to each module route the
|
||||
display calls at start-up, and the first realtime connection. Within a bounded time and with no
|
||||
reload, the display must make that request again and succeed. A display has nobody to press
|
||||
Retry.
|
||||
|
||||
Module-specific behaviour, such as Signage's heartbeat being recorded, is not guessed by the
|
||||
harness. A module declares it (below).
|
||||
|
||||
### A module is tested against both ends of its compatibility range
|
||||
|
||||
A module's CI runs the harness twice: against the **lowest** and the **newest** published platform
|
||||
version within its manifest's `compatibility.platform` range. Testing every version in between adds
|
||||
cost and finds little. Testing only the newest misses the floor the manifest promises to work on.
|
||||
|
||||
The two versions, and their harness digests, are pinned in the module's repository. The harness
|
||||
refuses to run if:
|
||||
|
||||
- a pinned version lies outside the module's range;
|
||||
- the lowest pinned version is not the range's floor.
|
||||
|
||||
Moving to a newer platform is a deliberate, reviewed pin change, as an instance's is (ADR 0044). It
|
||||
is never a moving tag.
|
||||
|
||||
A frozen install of the module assembled into each of those two platforms needs a lockfile per
|
||||
pinned version. The module keeps them (`ci/lock/<platform version>.yaml`), and the harness provides
|
||||
the one command that regenerates them inside its own pinned Node.js. A stale lockfile fails the
|
||||
frozen install, which is the signal to regenerate it in a reviewed change.
|
||||
|
||||
### Modules declare extra smoke steps, beside their manifest rather than in it
|
||||
|
||||
A module may add steps of its own in `ekklesia.harness.json`, next to `ekklesia.module.json`. Steps
|
||||
are declarative first:
|
||||
|
||||
- open a surface;
|
||||
- wait for a test id;
|
||||
- call one of the module's routes as a named role, expecting a status;
|
||||
- soak for N minutes;
|
||||
- inject a failure into a named route;
|
||||
- check a value in the module's own table, through its own route.
|
||||
|
||||
For what a declaration cannot express, a step may name a driver script in the module's repository.
|
||||
The harness runs that script with the same DevTools client and the same failure rules.
|
||||
|
||||
**This is deliberately not a manifest field.** Manifest versions are strict, hard cutovers that every
|
||||
host validates (ADR 0033). A test step is not a runtime contract, and a host must never read one.
|
||||
Putting steps in the manifest would force a manifest version bump on every module for something
|
||||
that has no meaning outside CI.
|
||||
|
||||
### Later: the harness checks what a module actually touches against what it declares
|
||||
|
||||
In a later phase, the harness records what the module does while it runs:
|
||||
|
||||
- the tables and schemas it reads and writes;
|
||||
- the buckets it uses;
|
||||
- its outbound network connections, with the egress network blocked and every attempt logged.
|
||||
|
||||
It compares them with what the manifest declares (ADR 0016, ADR 0030). A module that touches what it
|
||||
did not declare fails. This is the module-trust check. The egress half needs a manifest declaration
|
||||
of outbound destinations that does not exist yet, and is a separate decision. This ADR fixes only
|
||||
the harness's shape, so that the recording phases can be added without changing how modules call
|
||||
it.
|
||||
|
||||
### The template's workflow stays short, and calls the harness by digest
|
||||
|
||||
`templates/module` ships a workflow that:
|
||||
|
||||
- checks out the module with `persist-credentials: false` and pinned actions;
|
||||
- runs the module's own unit tests, typecheck, and lint;
|
||||
- sweeps stale CI stacks from the runner, then runs the harness, once per pinned platform version,
|
||||
with one `docker run` of the harness image **by digest**, and finally, always, removes this job's
|
||||
own stacks. The module directory is mounted read-only, and the runner's Docker is used exactly
|
||||
as the platform's smoke uses it.
|
||||
|
||||
It references no secret, needs no registry credential (the harness image is public within the
|
||||
organization), and uses no `pull_request_target`. The trusted-code-only rule for self-hosted runners
|
||||
applies to module repositories exactly as it does to the platform (see `docs/development/security.md`).
|
||||
|
||||
**Why `docker run`, and not a reusable workflow or a composite action.** The server runs Gitea
|
||||
1.27.2. (This proposal first assumed 1.24.2; the reasons below hold on the actual version.)
|
||||
|
||||
- Its runner can call a reusable workflow from another repository by cloning that repository, and
|
||||
since 1.26 that includes private repositories (go-gitea/gitea#32562). That support is still thinly
|
||||
documented, and has known problems in practice.
|
||||
- A reusable workflow would make the harness's behaviour depend on how this server and runner
|
||||
version expand it. An image runs the same everywhere it runs.
|
||||
- Composite actions from another repository work through absolute `uses:` URLs. That would still
|
||||
wrap the same `docker run`, adding a second pinned reference and nothing else.
|
||||
|
||||
An image digest is the one reference that is equally immutable, equally reviewable, and works
|
||||
unchanged on any Gitea release and on GitHub Actions. A composite action can be added later as a
|
||||
convenience over the same image, without changing what a module pins.
|
||||
|
||||
### Nothing the harness starts outlives the job that started it
|
||||
|
||||
The harness starts Docker stacks on a shared runner, and a runner can kill a job outright: a
|
||||
cancelled or superseded job (`concurrency` cancels superseded runs on this server), or the runner's
|
||||
own time limit. A script's own cleanup trap does not run then. The platform's CI leaked a whole
|
||||
migration stack that way, and it ran until someone removed it by hand. So the harness follows the
|
||||
platform's rule (`scripts/ci-sweep.sh`, `docs/development/security.md`):
|
||||
|
||||
- everything it starts is named and labeled as a CI stack of its run, attempt, and job key;
|
||||
- it removes its own stacks in a trap;
|
||||
- the template workflow runs the harness's **stale sweep** before the harness starts, and its
|
||||
**job sweep** in a final `if: always()` step, so a killed run's stacks are removed by that run's
|
||||
last step, or failing that by the next job on the runner.
|
||||
|
||||
The sweep ships inside the harness image, so a module never copies it.
|
||||
|
||||
### Publishing follows the instance's release pattern
|
||||
|
||||
The harness image is pushed only by a `publish` job that runs on `v*` tags, after the platform's CI
|
||||
has built and tested that exact image (checked by image ID). That job:
|
||||
|
||||
- uses a write-only package token, the only secret in the platform's workflow;
|
||||
- refuses to overwrite an existing version;
|
||||
- prints the digest.
|
||||
|
||||
Pull requests and branch builds build the harness and run it against the template, but never push.
|
||||
The registry is reached exactly as the instance's publish job reaches it.
|
||||
|
||||
### Adoption
|
||||
|
||||
- **Signage** uses an interim CI job until the first harness is published. That job assembles
|
||||
Signage into a pinned platform commit and runs the platform's display-device smoke scenarios.
|
||||
Signage switches to the harness as soon as one is published.
|
||||
- **Classroom** follows.
|
||||
- **The template** carries the workflow from the first harness release on, so every new module has
|
||||
it from its first commit.
|
||||
|
||||
## Consequences
|
||||
|
||||
- A module cannot reach a release without having run inside Ekklesia, in a real browser, at both
|
||||
ends of the platform range it claims.
|
||||
- The defects that reached the first production Signage deployment fail a module's CI instead:
|
||||
refused requests, dead ends, leaks, and console errors.
|
||||
- The platform owns one harness, versioned with itself. Modules do not copy deployment scripts that
|
||||
then drift.
|
||||
- Every module's CI needs Docker on its runner, and takes minutes rather than seconds. The harness
|
||||
runs as separate jobs from the unit tests, so a quick failure still reports quickly.
|
||||
- The platform's release process gains one published artifact, one credential, and one more failure
|
||||
mode at release time.
|
||||
- A module now carries a lockfile for each pinned platform version. The harness makes regenerating
|
||||
them one command, and a stale one fails visibly.
|
||||
|
||||
## Alternatives
|
||||
|
||||
- **Each module keeps its own copy of the smoke scripts.** Rejected. That is today's situation in
|
||||
embryo, and it drifts.
|
||||
- **A reusable workflow in the platform repository.** Rejected for now, for the reasons above: thin
|
||||
support in practice, and a harness whose behaviour depends on whichever runner and server version
|
||||
happens to expand it.
|
||||
- **Publish the harness as an npm package.** Rejected. The harness needs Docker, a browser, and the
|
||||
platform's deployment files anyway, so a package would ship a fraction of it and assume the rest.
|
||||
- **Test only the newest platform version.** Rejected. A module's range floor would go untested
|
||||
until an installation that has not upgraded meets it.
|
||||
- **Put smoke steps in the manifest.** Rejected. Manifest versions are hard cutovers with runtime
|
||||
meaning, and test steps have neither.
|
||||
- **Run the harness in the platform's CI against every known module.** Rejected. The platform would
|
||||
need every module's source and would fail on modules it does not own. The direction is module
|
||||
tests platform, not the reverse.
|
||||
|
||||
## Deferred
|
||||
|
||||
- The data-access and egress recording phases, and the manifest declaration egress needs.
|
||||
- A composite action over the harness image.
|
||||
- Running the harness against a module's declared dependencies at their own pinned versions, for
|
||||
contributions hosted by another module.
|
||||
- Visual regression of mounted surfaces.
|
||||
@@ -47,3 +47,12 @@ ADRs capture durable choices that affect more than one package or phase.
|
||||
- [0036: A display device is its own principal, paired by an administrator](0036-managed-device-identity-and-pairing.md) — Accepted
|
||||
- [0037: Outbound email is a host-provided transport, and SMTP is its first provider](0037-host-provided-outbound-email.md) — Accepted
|
||||
- [0038: Email verification and password recovery, with a trusted-bootstrap exception](0038-email-verification-and-password-recovery.md) — Accepted
|
||||
- [0039: Shared design tokens and foundational elements](0039-shared-design-tokens-and-elements.md) — Accepted
|
||||
- [0040: Module stylesheets, and every browser artifact gets an isolated root](0040-module-stylesheets-and-artifact-isolation.md) — Accepted
|
||||
- [0041: One module may contribute a browser renderer to another module's capability](0041-module-browser-contributions.md) — Accepted
|
||||
- [0042: Explicit, bounded streaming request bodies](0042-bounded-streaming-request-bodies.md) — Accepted
|
||||
- [0043: The display caches its shell and declared code, and nothing else](0043-display-offline-code-fallback.md) — Accepted
|
||||
- [0044: Ekklesia ships one self-hosted reference deployment, composed from pinned sources](0044-self-hosted-reference-deployment.md) — Accepted
|
||||
- [0045: The Dashboard and the API may live on separate origins](0045-split-dashboard-and-api-origins.md) — Accepted
|
||||
- [0046: Ekklesia's branding is never forced on an installation](0046-installation-owned-branding.md) — Proposed
|
||||
- [0047: Every module proves itself inside Ekklesia, with one harness the platform publishes](0047-module-ci-harness.md) — Proposed
|
||||
|
||||
@@ -228,7 +228,8 @@ takes a DOM element and returns an unmount function, so a module's framework and
|
||||
independent.
|
||||
|
||||
The contract lives in `@ekklesia/module-ui-sdk`, a browser-safe shared package with no runtime
|
||||
dependencies, consumed by module authors and hosts alike. Its host context is five members —
|
||||
dependencies, consumed by module authors and hosts alike. Its host context was five members at that
|
||||
checkpoint and is six today (ADR 0041) —
|
||||
`moduleId`, `api`, `principal`, `permissions`, and `realtime`. Module UI reaches its own backend through
|
||||
`api.request(path)` with a path relative to the module's own API root, never by constructing
|
||||
Ekklesia's `/api/v1/modules/<module-id>` prefix, which is what keeps a standalone host possible.
|
||||
@@ -244,7 +245,8 @@ it, and hosts perform their own dynamic import.
|
||||
`src/ui/index.tsx` into the browser ESM artifact its manifest declares, and puts every bundler
|
||||
option in one `tooling` package so module authors write no build configuration. React is bundled
|
||||
rather than externalized, keeping `mount()` the ABI and a module artifact hostable by any host;
|
||||
styling is inline style objects only, and a stylesheet import fails the build rather than emitting an
|
||||
styling was inline style objects only at that checkpoint and is real compiled CSS today (ADR 0040),
|
||||
and a stylesheet import then failed the build rather than emitting an
|
||||
asset nothing declares. Building remains an authoring step — Core never builds, and discovery still
|
||||
only validates the artifact a module shipped. At that checkpoint the Dashboard shell and UI runtime
|
||||
were still future work; later Phase 6 passes supplied them. A design system, stylesheet loading
|
||||
@@ -573,3 +575,80 @@ An authenticated result preserves the response, session, and mounted UI. Display
|
||||
remains deferred and the display bundle gains no Dashboard login. Public Website routing,
|
||||
production Dashboard asset hosting/DNS/TLS/proxy/CSP, enrollment and recovery, shared throttling,
|
||||
and the missing production first-user creation path are also outside this pass.
|
||||
|
||||
**Phase 8 supplies the five platform prerequisites the first production feature module needs.** None
|
||||
of it is Signage, and none of it names Signage: each capability is generic, and each is exercised by
|
||||
something other than a feature module.
|
||||
|
||||
[ADR 0039](decisions/0039-shared-design-tokens-and-elements.md) adds the design layer the platform
|
||||
never had. `@ekklesia/design-tokens` is one validated, enumerable registry of **semantic** tokens —
|
||||
`background.raised`, `text.danger`, `state.hover-background` — where a token's category and
|
||||
`--ekklesia-*` identifier are *derived* from its name and every value must be a bounded literal, so a
|
||||
registry that constructs is one that can be emitted as CSS without escaping. That validation is
|
||||
exactly what makes a future theme editor implementable rather than aspirational: the values it would
|
||||
edit are already labelled, described, categorised, and enumerable. `@ekklesia/ui` is the small set of
|
||||
elements a real module dashboard cannot be built without, every value a token reference, with real
|
||||
`:focus-visible` treatment and status that never depends on colour alone. `pnpm boundaries` opens
|
||||
exactly two shared roles to modules **by name**, and pins both design packages beneath everything
|
||||
else. The Dashboard and display install the defaults at their document root; custom properties
|
||||
inherit into every module artifact, so a module reads `var(--ekklesia-…)` with nothing to import at
|
||||
run time and nothing for a host to forget to pass.
|
||||
|
||||
[ADR 0040](decisions/0040-module-stylesheets-and-artifact-isolation.md) reverses ADR 0023's
|
||||
inline-styles-only rule, and supplies the three things that ADR said were missing. A module imports
|
||||
plain `.css`; the canonical builder compiles it **into** the declared artifact as text, so there is
|
||||
still exactly one file per role and no sidecar can exist — the failure ADR 0023 was protecting
|
||||
against. Two rules are enforced with their own codes: a module stylesheet may reference
|
||||
`--ekklesia-*` and never declare one, and every `@import` must resolve locally. The generic loader
|
||||
then mounts **every** browser artifact inside its own open Shadow DOM root with its own stylesheet
|
||||
installed, unconditionally, because a conditional isolation boundary is one a module can fall out of
|
||||
by editing a source file. That root is a correctness boundary for *styles* and is never described as
|
||||
a sandbox: it is `open` precisely because module code is trusted, same-realm code that could reach
|
||||
`document` anyway. The browser mount ABI is versioned (`MODULE_UI_ABI_VERSION`, stamped by
|
||||
`defineModuleUi`) with no dual acceptance; migration is one `pnpm build`.
|
||||
|
||||
[ADR 0041](decisions/0041-module-browser-contributions.md) lets one module contribute a browser
|
||||
renderer to a capability another module provides — the browser half of the capability system Core has
|
||||
had since ADR 0006. `manifestVersion: 7` declares a namespaced id, its own version, a target
|
||||
capability, a compatible range, and one artifact. That is the complete list: there is no payload
|
||||
schema, slot, ordering, or rendering hint anywhere in the platform, so Core cannot learn what a
|
||||
"slide" is because the declaration cannot express it. Discovery resolves the artifact through the
|
||||
identical loop every other compiled entrypoint uses; publication requires that *another* eligible
|
||||
module provides the capability within the declared range, and a contribution that fails is absent
|
||||
rather than broken. A consumer discovers through `ModuleUiHostContext.contributions` and mounts by
|
||||
id — never by building a publication URL — and can only see contributions aimed at a capability it
|
||||
itself provides. The input is opaque JSON validated for serializability and nothing else, so the
|
||||
domain contract stays entirely between the two modules. A contributed renderer receives its own id
|
||||
and that input: no API client, realtime, principal, configuration, database, storage, or credential,
|
||||
and loading one grants its module nothing.
|
||||
|
||||
[ADR 0042](decisions/0042-bounded-streaming-request-bodies.md) closes the write path ADR 0031 left
|
||||
open. ADR 0030 gave modules a bucket and ADR 0031 took Ekklesia out of the *read* path, but declined
|
||||
signed write URLs — so bytes reach a bucket only through a module's own route, which was capped at
|
||||
1 MiB. A route now declares `bodyMode`: `buffered` (the default, and what every existing route
|
||||
meant) keeps that ceiling and materializes the body before the handler runs; `streamed` raises it to
|
||||
32 MiB and counts bytes as the handler consumes them, holding nothing. The mode is explicit rather
|
||||
than inferred from the size, because inferring it would silently change how an author's own handler
|
||||
receives its body. 32 MiB is also the platform's absolute ceiling in every mode, sized for one
|
||||
25 MiB image plus bounded overhead, and the unit is deliberately one file per request. The platform's
|
||||
own limiter replaces the buffering dependency: a declared oversize is refused before authorization,
|
||||
the buffered path now counts what actually arrived rather than trusting a header, and a streamed
|
||||
overflow errors the stream, cancels the source, and replaces the response with the same sanitized
|
||||
`413` even if the handler swallowed the error.
|
||||
|
||||
[ADR 0043](decisions/0043-display-offline-code-fallback.md) gives the display host-owned offline
|
||||
resilience, scoped precisely to **the shell and the declared code**. A service worker scoped to
|
||||
`/display/` caches the page, the host script, the module's display artifact, and every contributed
|
||||
renderer that display mounted — and never the descriptor, `/api/v1/principal`, any API response, the
|
||||
realtime stream, a pairing exchange, or any cross-origin request, which is what a signed
|
||||
object-delivery URL is. The consequence is stated rather than papered over: a display cannot render
|
||||
module content while offline, because mounting needs an authorization-bearing descriptor that is
|
||||
never cached, so a revoked device stays revoked and cached code can never manufacture current
|
||||
authorization. What it buys is that a kiosk rebooting mid-outage boots into Ekklesia's own offline
|
||||
state and recovers by itself, and that reconnecting mounts with nothing left to download. Caching is
|
||||
network-first everywhere, so a screen can never be trapped on obsolete code.
|
||||
|
||||
Still deliberately absent after Phase 8: a theme editor, a brand builder, or any second theme; a
|
||||
module store or installer; a standalone host; signed write URLs, resumable uploads, or any body above
|
||||
the absolute ceiling; module-owned offline *content*; and every real feature module, Signage
|
||||
included. Nothing in this pass claims any of them exists.
|
||||
|
||||
@@ -7,7 +7,7 @@ These rules map platform ownership to filesystem and package boundaries. The exe
|
||||
| --- | --- | --- |
|
||||
| `apps/*` | Thin deployable composition roots | Public Core APIs, generated bootstrap, or a surface as required by the app |
|
||||
| `core` | Feature-agnostic platform implementations | Public contracts and external infrastructure libraries |
|
||||
| `modules/*` | Domain capability, data, and the module's own UI | Module SDK, module UI SDK, public contracts, React, and external backend libraries |
|
||||
| `modules/*` | Domain capability, data, and the module's own UI | Module SDK, module UI SDK, the approved design packages, public contracts, React, and external backend libraries |
|
||||
| `surfaces/*` | The application shell, UI, renderers, navigation, and interaction | Surface SDK, API/auth clients, the module UI SDK and host loader, design packages, and a browser framework |
|
||||
| `builders/*` | Declarative surface composition tooling | Builder/surface SDKs and public contracts |
|
||||
| `packages/*` | Explicitly scoped SDKs, contracts, clients, design, testing, or tooling | Only dependencies appropriate to the package's declared level |
|
||||
@@ -20,10 +20,14 @@ These rules map platform ownership to filesystem and package boundaries. The exe
|
||||
- A module may own UI ([ADR 0021](decisions/0021-modules-as-hostable-products.md)) and authors it in
|
||||
React and TypeScript ([ADR 0022](decisions/0022-module-ui-authoring-and-host-contract.md)). A
|
||||
module may therefore contain `.tsx` source and depend on `react`, `react-dom`, and
|
||||
`@ekklesia/module-ui-sdk`. It still cannot contain `.jsx`, stylesheets, single-file components,
|
||||
`@ekklesia/module-ui-sdk`. Since
|
||||
[ADR 0040](decisions/0040-module-stylesheets-and-artifact-isolation.md) it may also contain plain
|
||||
`.css`, but **only** inside a browser-artifact source root the canonical builder compiles
|
||||
(`src/ui/`, `src/display/`, `src/contributions/`); a stylesheet anywhere else in a module is a file
|
||||
no build reads and no host loads. It still cannot contain `.jsx`, other stylesheet dialects, single-file components,
|
||||
Astro/Marko/MDX/HTML views, or SVG presentation assets, and cannot depend on any other UI framework
|
||||
(including `react-router-dom`, `react-native`, and `@vitejs/plugin-react`) or on shared UI-role
|
||||
packages. A module needs no bundler plugin: `@ekklesia/module-ui-build` — a `tooling` package,
|
||||
(including `react-router-dom`, `react-native`, and `@vitejs/plugin-react`) or on any shared role
|
||||
beyond the two approved design packages named below. A module needs no bundler plugin: `@ekklesia/module-ui-build` — a `tooling` package,
|
||||
a role modules could already depend on — owns the entire module UI build
|
||||
([ADR 0023](decisions/0023-module-ui-build-pipeline.md)). An unsupported framework aliased behind an allowed name is resolved and rejected by its
|
||||
real target.
|
||||
@@ -31,12 +35,47 @@ These rules map platform ownership to filesystem and package boundaries. The exe
|
||||
import Core, an app, a surface, a builder, or another module. It also may not import
|
||||
`@ekklesia/module-ui-host`: that package is host infrastructure, and a module bundling the loader
|
||||
would blur the author/host split ([ADR 0024](decisions/0024-browser-safe-ui-sdk-and-host-loader.md)).
|
||||
- A module may consume exactly two shared **design** roles: `design-tokens` (`@ekklesia/design-tokens`)
|
||||
and `ui` (`@ekklesia/ui`), added by
|
||||
[ADR 0039](decisions/0039-shared-design-tokens-and-elements.md). This is a named allowlist, not a
|
||||
general "browser-safe shared packages are fine" relaxation: `api-client`, `auth-client`,
|
||||
`surface-sdk`, and `module-ui-host` remain rejected inside a module. Both approved packages are
|
||||
browser-safe by construction — `design-tokens` has no dependencies at all and `ui` depends only on
|
||||
it and React — so a module UI file that imports them still cannot typecheck `process.env`, proven by a real
|
||||
compile under the exact browser project a module uses. Both are also pinned at the bottom of the
|
||||
presentation stack by `design-packages-depend-downward-only`: neither may reach an implementation
|
||||
layer, an API/auth client, or the backend contracts barrel, because a design package that could
|
||||
would put all of it inside every module artifact that renders a button.
|
||||
- A module **references** registered global semantic tokens and may derive private,
|
||||
differently-prefixed aliases from them inside its own artifact. It may never define an
|
||||
`--ekklesia-*` custom property: that would invent a global theme variable outside the registry,
|
||||
which a future theme editor could not enumerate and another module could unknowingly depend on.
|
||||
- `@ekklesia/module-ui-sdk` has no dependencies at all, and `@ekklesia/module-ui-host` depends only
|
||||
on it. Both are browser-safe by construction: neither can pull a backend type graph — and with it
|
||||
Node's global declarations — into browser code. Core still owns the containing
|
||||
application shell and all platform policy, and module UI reaches the host only through
|
||||
`@ekklesia/module-ui-sdk`.
|
||||
- Manifest v6 permits independently optional primary (`entrypoints.ui`) and full-viewport display
|
||||
- A module stylesheet is compiled **into** the one declared JavaScript artifact as text and installed
|
||||
by the host at mount; there is no sidecar file, no second publication route, and no manifest field
|
||||
for one. Every browser artifact is mounted inside its own Shadow DOM root the generic loader
|
||||
creates, so one module's styles cannot reach the Dashboard, another module, or another renderer,
|
||||
and the host's cannot reach in. That root is a correctness boundary for styles, never a sandbox:
|
||||
module code remains trusted, same-realm code. The browser mount ABI is versioned
|
||||
(`MODULE_UI_ABI_VERSION`) and a mismatched artifact fails closed rather than mounting under guessed
|
||||
semantics.
|
||||
- A module may declare **browser contributions** (`manifest.contributions`, v7): compiled renderers
|
||||
it offers to capabilities *other* modules provide
|
||||
([ADR 0041](decisions/0041-module-browser-contributions.md)). Core validates identity,
|
||||
compatibility, and the artifact path through the identical resolution every other compiled
|
||||
entrypoint uses, and knows nothing about what a contribution renders — there is no payload schema,
|
||||
slot, ordering, or rendering hint anywhere in the platform. A contribution is published only when
|
||||
another eligible module provides its target capability within the declared range; otherwise it is
|
||||
absent rather than broken. A consuming module discovers contributions through
|
||||
`ModuleUiHostContext.contributions` and mounts them by id; it never constructs a publication URL,
|
||||
and it can only ever see contributions aimed at a capability it itself provides. A contributed
|
||||
renderer receives its own id and the consumer's opaque JSON input, and no database, storage,
|
||||
credential, session, or API client of its own.
|
||||
- Manifest v7 permits independently optional primary (`entrypoints.ui`) and full-viewport display
|
||||
(`entrypoints.display`) artifacts. Modules own both feature experiences; surfaces own their
|
||||
containers, routes, navigation/chrome, credentials, error states, and publication wiring. The
|
||||
fixed authoring sources are `src/ui/index.tsx` and `src/display/index.tsx`; both use the same
|
||||
@@ -80,7 +119,13 @@ These rules map platform ownership to filesystem and package boundaries. The exe
|
||||
receiving only the host-neutral principal and permission hints already in the UI SDK.
|
||||
- The display browser application is a separate surface entrypoint under `surfaces/shell`; it also
|
||||
statically imports no module and mounts only the exact display descriptor/artifact supplied by the
|
||||
API. It contains no Dashboard chrome and does not move display rendering into the surface—the
|
||||
API. It ships a third fixed asset, a service worker scoped to `/display/`, that caches **only** the
|
||||
display shell and the declared browser artifacts it actually loaded
|
||||
([ADR 0043](decisions/0043-display-offline-code-fallback.md)). It never caches the descriptor,
|
||||
`/api/v1/principal`, any module or platform API response, the realtime stream, a pairing exchange,
|
||||
or any cross-origin request — so a revoked device stays revoked and cached code can never
|
||||
manufacture current authorization. Caching is network-first, so a screen cannot be trapped on
|
||||
obsolete code. It contains no Dashboard chrome and does not move display rendering into the surface—the
|
||||
module still owns that renderer.
|
||||
- Separate surfaces do not import each other's internals.
|
||||
- Builder code composes contracts and surface renderer identifiers; it does not import module or
|
||||
@@ -89,6 +134,14 @@ These rules map platform ownership to filesystem and package boundaries. The exe
|
||||
- Workspace package consumers use declared `exports`, not private subpaths.
|
||||
- Every internal dependency uses a `workspace:` range and every imported workspace package is
|
||||
declared by the consumer.
|
||||
- Code a workspace package ships (its `src/` and `bin/`, excluding tests and declaration files)
|
||||
declares every runtime import in `dependencies` or `optionalDependencies`. A peer declaration may
|
||||
stay beside it, but never replaces it: a workspace package is linked into its consumers as a
|
||||
directory, so its imports resolve from its own `node_modules`, where a consumer never satisfies a
|
||||
peer, and a production install (`pnpm install --prod`, as the production image runs) omits
|
||||
development dependencies. Type-only imports are exempt; a `.tsx`/`.jsx` file counts as importing
|
||||
`react`, because the automatic JSX runtime imports `react/jsx-runtime` implicitly
|
||||
(`runtime-imports-are-production-dependencies`).
|
||||
- Workspace aliases are forbidden because they obscure package ownership in the dependency graph.
|
||||
- Workspace package names are unique and shared packages declare a recognized architectural role.
|
||||
- Circular source and workspace-package dependencies are rejected.
|
||||
@@ -96,6 +149,10 @@ These rules map platform ownership to filesystem and package boundaries. The exe
|
||||
direct table imports and foreign keys. Modules may not import another module's Drizzle tables,
|
||||
columns, relations, schema entrypoint, or migration internals.
|
||||
|
||||
The design packages are covered the same way: one fixture proves a module may depend on both, one
|
||||
proves every other shared role stays rejected, and one proves neither design package may depend
|
||||
upward.
|
||||
|
||||
The boundary test suite includes intentionally invalid fixture graphs. This proves that the guard
|
||||
rejects every currently machine-checkable repository rule even before production packages fill
|
||||
every directory. The UI guard rejects component, view, template, stylesheet, shared UI-role, and
|
||||
|
||||
+173
-19
@@ -62,7 +62,7 @@ No real values are shown here — configure per environment and never commit the
|
||||
| `EKKLESIA_API_PORT` | Optional. Listen port, `0`–`65535` (`0` requests an OS-assigned port). Defaults to `3000`. |
|
||||
| `EKKLESIA_IDENTITY_SECRET` | At least 32 characters, high entropy (for example `openssl rand -base64 32`). Same requirement as Phase 4A. |
|
||||
| `EKKLESIA_IDENTITY_BASE_URL` | Exactly one canonical `http`/`https` origin — scheme, host, and non-default port only, with no path, trailing slash, query, fragment, credentials, or explicit default port. `https` is required in production; loopback and unspecified hosts are rejected in production. |
|
||||
| `EKKLESIA_IDENTITY_TRUSTED_ORIGINS` | Comma-separated list of canonical origins, validated identically to the base URL (no wildcards). Used for both Better Auth's own CSRF/origin checks and the API's CORS policy — there is only one trusted-origin list. |
|
||||
| `EKKLESIA_IDENTITY_TRUSTED_ORIGINS` | Comma-separated list of canonical origins, validated identically to the base URL (no wildcards). Used for both Better Auth's own CSRF/origin checks and the API's CORS policy — there is only one trusted-origin list. The base URL's own origin is always trusted as well and need not be listed ([ADR 0045](../architecture/decisions/0045-split-dashboard-and-api-origins.md)). |
|
||||
| `EKKLESIA_PUBLIC_SIGNUP_ENABLED` | Optional, **development/test only**. Exactly `"true"` enables public sign-up when `NODE_ENV=development`; defaults to disabled, and a production configuration rejects `"true"` outright. Omitted, misspelled, or any other value never enables it. See [Public registration policy](#public-registration-policy). |
|
||||
| `EKKLESIA_CLIENT_ADDRESS_MODE` | Optional. `"direct"` (default when unset) or `"trusted-proxy"`. See [Client-address resolution](#client-address-resolution). |
|
||||
| `EKKLESIA_CLIENT_ADDRESS_HEADER` | Required only when `EKKLESIA_CLIENT_ADDRESS_MODE=trusted-proxy`. The one forwarded-header name the reverse proxy is trusted to set (for example `x-forwarded-for`). |
|
||||
@@ -77,6 +77,10 @@ No real values are shown here — configure per environment and never commit the
|
||||
| `/api/auth/{get-session,list-sessions,update-session,change-password}` | Deliberately unavailable | Token-bearing native session inspection/mutation endpoints are denied at the Ekklesia HTTP boundary with sanitized `404 {"code":"NOT_FOUND"}`. Use `/api/v1/principal`; no browser session-list/update or password-management contract exists. |
|
||||
| `POST\|GET /api/auth/*` | Better Auth (mounted directly) | Supported Better Auth routes retain their native shapes except for the successful email sign-in normalization above. The four denied paths above never reach Better Auth. See [Better Auth's email/password docs](https://www.better-auth.com/docs/authentication/email-password) for supported endpoints such as `/sign-up/email` and `/sign-out`. |
|
||||
| `GET /api/v1/principal` | Ekklesia | Returns the minimal `EkklesiaPrincipal` contract as JSON. `Cache-Control: no-store`. |
|
||||
| `GET /` | Ekklesia | The built Dashboard page (`surfaces/shell`'s `index.html`), with the API's origin written into it. Served from the API's origin, or from the Dashboard's own hostname through the proxy in the split topology. `no-cache` with a strong `ETag`; framing refused. See [Dashboard host](#dashboard-host). |
|
||||
| `GET /assets/ekklesia-dashboard/app.js` | Ekklesia | The Dashboard's one script. `no-cache` with a strong `ETag`; its source map is never served. See [Dashboard host](#dashboard-host). |
|
||||
| `GET /api/v1/health/live` | Ekklesia | Unauthenticated liveness: fixed `200 {"status":"live"}` whenever the process is serving HTTP. `Cache-Control: no-store`; GET only. See [Health](#health). |
|
||||
| `GET /api/v1/health/ready` | Ekklesia | Unauthenticated readiness: fixed `200 {"status":"ready"}` only when a bounded database ping succeeds and module startup completed, otherwise `503 {"code":"NOT_READY"}`. `Cache-Control: no-store`; GET only. See [Health](#health). |
|
||||
| `GET\|POST /api/v1/grants*` | Ekklesia | Explicit authorization grant management, gated by `core.authorization.grants.manage`. Listing is bounded keyset pagination; creation is unscoped-only and conflicts (`409`) on an equivalent effective grant; revocation clears every unrevoked equivalent. See [Authorization](authorization.md#explicit-grant-http-api). |
|
||||
| `/api/v1/modules/<module-id>/*` | Installed modules | Every route an installed module registered during its `register` hook, gated by that route's own declared permission. See [Module HTTP routes](#module-http-routes). |
|
||||
| `GET /api/v1/module-realtime/<module-id>/<channel>` | Installed modules | Snapshot-first Server-Sent Events for one registered module-local channel, gated by that channel's declared permission. See [Module realtime](#module-realtime). |
|
||||
@@ -134,6 +138,77 @@ a new raw token when asked to revoke other sessions. The principal response neve
|
||||
contains email, name, image, session id or expiry, session token, account/provider details, or any
|
||||
role, permission, membership, or volunteer data.
|
||||
|
||||
## Dashboard host
|
||||
|
||||
`apps/api` serves the built Dashboard itself
|
||||
([ADR 0044](../architecture/decisions/0044-self-hosted-reference-deployment.md)). In the default
|
||||
same-origin topology it does so from the same origin as `/api`: `EKKLESIA_DASHBOARD_ORIGIN` stays
|
||||
unset, the session cookie stays host-only, and no second static server or proxy file root is
|
||||
needed. In the split topology
|
||||
([ADR 0045](../architecture/decisions/0045-split-dashboard-and-api-origins.md)) the proxy forwards
|
||||
the Dashboard's own hostname to the same two routes; the API does not inspect `Host`. It works
|
||||
exactly like the display host:
|
||||
|
||||
- **A closed set of files, read once at startup.** `@ekklesia/shell/dashboard-assets` exports the
|
||||
exact URLs of two built files, `dist/browser/index.html` and
|
||||
`dist/browser/assets/ekklesia-dashboard/app.js`. `startApi()` reads both before importing any
|
||||
module code or binding the listener, and keeps one frozen snapshot of their text with a SHA-256
|
||||
strong `ETag` for each.
|
||||
- **The API's origin is written in at startup.** The built page carries one empty
|
||||
`<meta content="" name="ekklesia-api-origin" />`. Before computing the page's `ETag`, `startApi()`
|
||||
fills it with the identity base URL's origin, which is where `/api` lives in either topology. The
|
||||
shell reads it before rendering, uses the page's own origin when it is empty, and fails closed,
|
||||
making no request, when it is anything but a canonical `http(s)` origin. Nothing about the topology
|
||||
is built into the bundle.
|
||||
- **A missing or incompatible build fails startup.** An absent, unreadable, or empty file, or a page
|
||||
without exactly one empty API-origin element, stops `startApi()` with a fixed
|
||||
`DashboardHostAssetError` (`DASHBOARD_HOST_ASSET_UNAVAILABLE`), after closing the resources
|
||||
already opened. It never carries a path or the filesystem error. Build the shell first;
|
||||
`pnpm api:start` already runs the full build.
|
||||
- **Two exact routes, never a directory.** `GET /` answers the page and
|
||||
`GET /assets/ekklesia-dashboard/app.js` answers the script. No request value selects a file, and
|
||||
nothing touches the filesystem while answering. Every other path, including `/index.html`,
|
||||
`/app.js`, the script's `.map`, and any traversal spelling, falls through to the ordinary
|
||||
`404 {"code":"NOT_FOUND"}`. There is no catch-all HTML fallback: the Dashboard has no client
|
||||
router, and emailed links land on `/` with a query (see
|
||||
[The four anonymous screens](#the-four-anonymous-screens)). Methods other than `GET` (and Hono's
|
||||
implicit `HEAD`) are also the ordinary `404`.
|
||||
- **No source maps.** The build emits `app.js.map` beside the script, but it is not one of the two
|
||||
files, so it is never served, matching module UI publication (ADR 0025). Browser devtools receive
|
||||
a `404` for it.
|
||||
|
||||
Neither route resolves a principal or reads a cookie, and neither is under `/api/`, so the API's
|
||||
CORS policy does not apply. The bytes grant no authority: the page renders sign-in until
|
||||
`/api/v1/principal` says otherwise.
|
||||
|
||||
| Header | `GET /` | `GET /assets/ekklesia-dashboard/app.js` |
|
||||
| --- | --- | --- |
|
||||
| `Content-Type` | `text/html; charset=utf-8` | `text/javascript; charset=utf-8` |
|
||||
| `Cache-Control` | `no-cache` | `no-cache` |
|
||||
| `ETag` | strong SHA-256 of the page | strong SHA-256 of the script |
|
||||
| `X-Content-Type-Options` | `nosniff` | `nosniff` |
|
||||
| `X-Frame-Options` | `DENY` | — |
|
||||
| `Content-Security-Policy` | `frame-ancestors 'none'` | — |
|
||||
| `Referrer-Policy` | `no-referrer` | — |
|
||||
|
||||
**Caching.** The shell emits fixed, unhashed file names, so both files are revalidated on every
|
||||
load, exactly like module UI artifacts and the display host's script. A matching `If-None-Match`
|
||||
(strong, weak `W/`, a list, or `*`) answers an empty `304` carrying the same `ETag` and
|
||||
`Cache-Control`. The page's `304` also carries its security headers, so a revalidated cache entry
|
||||
keeps them. Neither file is ever `immutable`: a rebuilt Dashboard reaches a browser on its next load.
|
||||
|
||||
**Page security headers.** The page holds the platform sign-in form, so it refuses to be framed,
|
||||
through `X-Frame-Options` and CSP `frame-ancestors`. That is the only directive the policy carries:
|
||||
the full Content-Security-Policy is a separate decision. `no-referrer` keeps the page URL out of
|
||||
every request the page makes, which matters while an emailed reset token is briefly in it. The shell
|
||||
strips that token from the address bar before it renders anything.
|
||||
|
||||
**Development.** `pnpm --filter @ekklesia/shell dev` serves `dist/browser` at the same absolute
|
||||
paths, because the build emits the script at `assets/ekklesia-dashboard/app.js`. `index.html` names
|
||||
that path absolutely, so the page never depends on the URL that served it. For a split-origin
|
||||
development API, set `EKKLESIA_API_BASE_URL` for the development server: it writes that origin into
|
||||
its copy of the page exactly as the API does in production. A production build ignores it.
|
||||
|
||||
## Dashboard browser authentication
|
||||
|
||||
The Dashboard flow is owned by `surfaces/shell`; modules never drive these routes and the
|
||||
@@ -277,7 +352,10 @@ real server: 7 characters rejected, 8 accepted, 128 accepted, 129 rejected.
|
||||
## Origin, CORS, and cookies
|
||||
|
||||
The API reuses the same `EKKLESIA_IDENTITY_TRUSTED_ORIGINS` list for both CORS and Better Auth's
|
||||
own origin/CSRF checks — there is no second, competing configuration. CORS reflects a request's
|
||||
own origin/CSRF checks — there is no second, competing configuration. Like Better Auth, the API also
|
||||
trusts the identity base URL's own origin without it being listed, so a page the API itself serves
|
||||
(the display host, or the Dashboard in the same-origin topology) passes the API's origin checks
|
||||
([ADR 0045](../architecture/decisions/0045-split-dashboard-and-api-origins.md)). CORS reflects a request's
|
||||
`Origin` header back only when it exactly matches a configured trusted origin (never a wildcard,
|
||||
since `Access-Control-Allow-Origin: *` cannot be combined with `Access-Control-Allow-Credentials:
|
||||
true`); a disallowed origin never receives the header at all, though the request itself may still
|
||||
@@ -311,10 +389,18 @@ Cookies keep Better Auth's defaults: `HttpOnly` always, `SameSite=Lax` always, `
|
||||
configured base URL is `https://`. Cross-subdomain cookies are not enabled and no cookie `Domain`
|
||||
attribute is set, so every cookie is host-only. See [Client-address resolution](#client-address-resolution)
|
||||
for how (and whether) a reverse proxy's forwarded headers are trusted — a separate concern from
|
||||
CORS/CSRF above. The Dashboard does not broaden this policy: same-origin `/api` proxying is the
|
||||
simplest production topology, while a deliberately split browser/API origin must use the exact
|
||||
trusted-origin allowlist and credentialed CORS. Sibling subdomains do not implicitly share the
|
||||
cookie. No production Dashboard hostname or proxy deployment exists in this repository.
|
||||
CORS/CSRF above. The Dashboard does not broaden this policy. In the split topology
|
||||
([ADR 0045](../architecture/decisions/0045-split-dashboard-and-api-origins.md)) the cookie stays
|
||||
host-only on the API's hostname, and the Dashboard on a sibling hostname reaches the API with
|
||||
credentialed CORS; browsers send the cookie because the two hostnames are origins of one site, which
|
||||
is therefore a requirement of that topology. The reference deployment provides both topologies
|
||||
([`deploy/README.md`](../../deploy/README.md#split-dashboard-and-api-origins)).
|
||||
|
||||
**CORS request and response headers are deliberately narrow, and part of the module UI contract.**
|
||||
Beyond the CORS-safelisted request headers, only `Content-Type` is allowed, and no response header is
|
||||
exposed beyond the safelisted ones. A module UI's `api.request` inherits this policy, so portable
|
||||
module UI code sends only safelisted headers plus `Content-Type` and reads only safelisted response
|
||||
headers. That keeps one module artifact working in either topology.
|
||||
|
||||
## Logging
|
||||
|
||||
@@ -408,16 +494,28 @@ protection across restarts or replicas.
|
||||
|
||||
## Request body limits
|
||||
|
||||
Every public body-consuming route has an explicit, platform-enforced byte ceiling
|
||||
(`apps/api/src/body-limits.ts`, built on Hono's pinned `hono/body-limit` middleware — verified
|
||||
directly against its source to reject both an oversized declared `Content-Length` and an oversized
|
||||
declared-length-free streamed body without ever buffering the whole payload first):
|
||||
Every public body-consuming route has an explicit, platform-enforced byte ceiling, enforced by the
|
||||
platform's own limiter (`apps/api/src/body-limits.ts`):
|
||||
|
||||
| Surface | Limit |
|
||||
| --- | --- |
|
||||
| `/api/auth/*` (every Better Auth route) | 8 KiB |
|
||||
| `POST /api/v1/grants`, `POST /api/v1/grants/:grantId/revoke` | 16 KiB |
|
||||
| A module route (`http.route(...)`'s own `maxBodyBytes`) | Up to 1 MiB, the platform ceiling (`MODULE_ROUTE_MAX_BODY_BYTES_CEILING`, `@ekklesia/contracts`) |
|
||||
| A module route declaring `bodyMode: "buffered"` (the default) | Up to 1 MiB (`MODULE_ROUTE_MAX_BODY_BYTES_CEILING`, `@ekklesia/contracts`) |
|
||||
| A module route declaring `bodyMode: "streamed"` | Up to 32 MiB (`MODULE_ROUTE_STREAMED_MAX_BODY_BYTES_CEILING`), which is also `MODULE_ROUTE_ABSOLUTE_MAX_BODY_BYTES` |
|
||||
|
||||
A declared `Content-Length` over the limit is refused synchronously, before authorization and before
|
||||
a single body byte is read. Beyond that the two module modes differ only in *when* the platform
|
||||
counts ([ADR 0042](../architecture/decisions/0042-bounded-streaming-request-bodies.md)):
|
||||
|
||||
- **`buffered`** materializes the body before the handler runs, counting as it arrives, so a lying or
|
||||
absent `Content-Length` is bounded by what actually arrived. The handler is promised a fully
|
||||
available body within its declared allowance, and the platform holds at most that allowance.
|
||||
- **`streamed`** never materializes the body: it is counted as the handler consumes it, and the
|
||||
moment the total passes the limit the stream errors, the source is cancelled, and the response
|
||||
becomes the same `413` — even if the handler caught the error and answered anyway. A `413` can
|
||||
therefore arrive *after* a streamed handler has started, which is the one consequence a module
|
||||
author must design for.
|
||||
|
||||
An oversized request always receives the identical stable, sanitized contract —
|
||||
`413 {"code":"REQUEST_BODY_TOO_LARGE"}` with `Cache-Control: no-store` — and never invokes Better
|
||||
@@ -425,10 +523,20 @@ Auth's business logic, a Core mutation, permission evaluation (where avoidable),
|
||||
or module database behavior. For a state-changing route the body limit is enforced after
|
||||
`requireTrustedOrigin` but before permission evaluation, so a rejected cross-origin mutation's body
|
||||
is never read for size at all, while an oversized same-origin request still fails before any
|
||||
authorization work runs. See [Module HTTP routes](#module-http-routes) for the module-route body
|
||||
contract itself, and [Module development](../module-development/README.md#http-routes) for the
|
||||
module-authoring side of it. Large media transfer is explicitly out of scope for ordinary module
|
||||
HTTP bodies — that belongs to a future dedicated storage design, never to widening this ceiling.
|
||||
authorization work runs. In `streamed` mode no body byte is read until the handler reads it, and the
|
||||
handler runs only after authorization succeeds, so an unauthorized caller can never push an upload
|
||||
through the platform at all.
|
||||
|
||||
Body reading is bounded in time by the server's own `headersTimeout`/`requestTimeout` below, not by a
|
||||
second per-route timeout. A body no handler read is cancelled after the request, so an abandoned
|
||||
upload releases its socket rather than draining; an aborted upload surfaces as an ordinary failed
|
||||
request rather than a `413`, because nothing exceeded a limit.
|
||||
|
||||
See [Module HTTP routes](#module-http-routes) for the module-route body contract itself, and
|
||||
[Module development](../module-development/README.md#http-routes) for the module-authoring side of
|
||||
it. The absolute ceiling is one image-sized file per request: aggregate uploads and anything larger
|
||||
than 32 MiB remain out of scope, and signed write URLs are still deliberately not offered
|
||||
([ADR 0031](../architecture/decisions/0031-direct-object-delivery.md)).
|
||||
|
||||
## Node request and header timeouts
|
||||
|
||||
@@ -1210,7 +1318,7 @@ its role-specific URL only from the protected descriptor below.
|
||||
|
||||
## Module display host
|
||||
|
||||
Manifest version 6 adds one independently optional display declaration
|
||||
The manifest carries one independently optional display declaration
|
||||
([ADR 0033](../architecture/decisions/0033-display-kiosk-host-contract.md)):
|
||||
|
||||
```json
|
||||
@@ -1398,6 +1506,51 @@ TLS protects the *connection*, not the exposure: it does not make an internet-re
|
||||
safe, and the network boundary remains the deployment's own responsibility. See
|
||||
[ADR 0018](../architecture/decisions/0018-identity-session-resolution-and-database-transport.md).
|
||||
|
||||
## Health
|
||||
|
||||
Two unauthenticated endpoints exist for Compose health checks and external monitoring
|
||||
([ADR 0044](../architecture/decisions/0044-self-hosted-reference-deployment.md)). They are
|
||||
deliberately uninformative: neither names a module, version, dependency, error, or connection
|
||||
detail, and `pnpm doctor` remains the detailed operator report.
|
||||
|
||||
| Request | Answer |
|
||||
| --- | --- |
|
||||
| `GET /api/v1/health/live` | Always `200 {"status":"live"}`. Liveness consults nothing; if the process can answer, it is live. |
|
||||
| `GET /api/v1/health/ready`, ready | `200 {"status":"ready"}` |
|
||||
| `GET /api/v1/health/ready`, not ready | `503 {"code":"NOT_READY"}` |
|
||||
| Any other method on either path, including `HEAD` | `405` with `Allow: GET` and no body |
|
||||
|
||||
Every answer sets `Cache-Control: no-store`. Neither route resolves a principal, reads a cookie, or
|
||||
evaluates authorization. Both live under `/api/v1/health/`, a sibling of the `/api/v1/modules/`
|
||||
prefix, so no module route can shadow them, including a module whose id is `health`. As for every
|
||||
`/api/v1/*` route, an `OPTIONS` request is answered by the platform's CORS preflight and never
|
||||
reaches the handlers.
|
||||
|
||||
Readiness is `200` only when **both** of the following hold:
|
||||
|
||||
- **Module startup completed, and shutdown has not begun.** `startApi()` binds the HTTP listener
|
||||
only after the module runtime has started, so this holds whenever the server is answering. The
|
||||
first `shutdown()` call clears it before anything drains, so a draining instance reports `503`
|
||||
while liveness still reports `200`. It is checked again after the database ping, in case shutdown
|
||||
began meanwhile.
|
||||
- **The database answered a trivial query within 2 seconds** (`READINESS_DATABASE_TIMEOUT_MS`).
|
||||
The ping runs on the one shared pool as a single message,
|
||||
`BEGIN READ ONLY; SET LOCAL statement_timeout = 2000; SELECT 1; COMMIT`. The transaction-local
|
||||
timeout means PostgreSQL cancels a stalled statement itself rather than leaving it running. On
|
||||
any failure the pool discards that connection instead of reusing it. The pool's own 10-second
|
||||
connection, query, and statement timeouts remain the outer bound, for example for a database
|
||||
that cannot be reached at all.
|
||||
|
||||
**Pings never pile up.** At most one ping is ever unsettled. Probes that arrive while it runs share
|
||||
its result and its single 2-second deadline. Once the deadline passes they answer `503` immediately,
|
||||
without starting another query, until that ping has actually settled. A stalled database therefore
|
||||
holds at most one pool connection on behalf of health checks, however often a monitor polls. A
|
||||
successful result is never cached: every probe after a settled ping runs a new one.
|
||||
|
||||
A failed ping is never inspected. The response is the same fixed `503` whether the database
|
||||
refused the connection, timed out, rejected the credentials, or was mid-restart, and nothing about
|
||||
the failure is logged by the route.
|
||||
|
||||
## Graceful shutdown
|
||||
|
||||
`startApi()` (the composition function `apps/api/src/server.ts` exports, used by both the
|
||||
@@ -1482,7 +1635,8 @@ Cookie: better-auth.session_token=<redacted, now revoked>
|
||||
|
||||
See the repository root [README](../../README.md#current-status) and
|
||||
[platform architecture](../architecture/platform-architecture.md#current-implementation-status)
|
||||
for what has landed across every phase and what remains. Surface work is underway; production
|
||||
Dashboard asset hosting, enrollment/recovery, display device identity, and public Website routing
|
||||
remain separate work. This document tracks `apps/api`'s own HTTP, module-runtime, and
|
||||
for what has landed across every phase and what remains. Surface work is underway; the API now
|
||||
serves the built Dashboard ([Dashboard host](#dashboard-host)) in either topology, while the full
|
||||
Content-Security-Policy, enrollment/recovery, display device identity, and public Website
|
||||
routing remain separate work. This document tracks `apps/api`'s own HTTP, module-runtime, and
|
||||
database-composition contracts as they evolve.
|
||||
|
||||
@@ -137,7 +137,8 @@ enforces those permissions while the module is disabled, because it mounts no ro
|
||||
|
||||
## Module permission composition
|
||||
|
||||
A module manifest's `permissions` array (`manifestVersion: 4`,
|
||||
A module manifest's `permissions` array (introduced in `manifestVersion: 4`; the current strict
|
||||
manifest is version 7 — see
|
||||
[Module development](../module-development/README.md#canonical-manifest)) declares permissions in
|
||||
the exact same `PermissionDeclaration` shape — but declaring one there only proves it is
|
||||
well-formed and correctly namespaced under the module's own id; it does not, by itself, register
|
||||
@@ -667,7 +668,7 @@ module database usage), and exactly one `pool.end()` call regardless of how many
|
||||
- `packages/contracts/tests/authorization.test.ts` — `PermissionDeclaration` shape validation:
|
||||
every baseline, unknown-property/missing-baseline/invalid-baseline/blank-or-oversized-description
|
||||
rejection, malformed permission id rejection.
|
||||
- `packages/contracts/tests/module-manifest.test.ts` — `manifestVersion: 4` acceptance and version
|
||||
- `packages/contracts/tests/module-manifest.test.ts` — exact-version acceptance and version
|
||||
2/string-array rejection, per-declaration validation (mirroring the contracts-level cases above),
|
||||
module-namespace enforcement, duplicate permission id at its exact `permissions[index].id` issue
|
||||
path, and default-empty-list isolation across separate parses.
|
||||
|
||||
@@ -37,7 +37,10 @@ the root verification command discovers them automatically.
|
||||
|
||||
`pnpm modules:validate` builds the workspace, then scans `modules/*` without a central registry. A
|
||||
missing or empty modules directory is valid and reports zero installed modules. Installed modules
|
||||
must produce the compiled JavaScript entrypoints declared by their static manifests. Optional
|
||||
must produce the compiled JavaScript entrypoints declared by their static manifests. Each module's
|
||||
declared browser contributions are listed with their target capability and range, and marked
|
||||
according to whether a compatible provider is installed — a contribution nobody provides is otherwise
|
||||
silent, since the manifest is valid and the artifact exists while nothing renders (ADR 0041). Optional
|
||||
schema entrypoints are loaded and validated for table ownership, namespacing, duplicates, and
|
||||
foreign-key isolation; this command does not connect to PostgreSQL or run migrations.
|
||||
|
||||
@@ -82,7 +85,8 @@ not a broken deployment).
|
||||
Doctor reports a fixed, deterministic set of checks:
|
||||
|
||||
- The active Node.js version against the repository's declared engine range.
|
||||
- Static module discovery and dependency-first ordering.
|
||||
- Static module discovery and dependency-first ordering. A module that declares browser
|
||||
contributions also reports how many of them have a compatible provider among the installed set.
|
||||
- Schema discovery, including validated module and table counts. This imports compiled schema
|
||||
definitions, which the module contract requires to perform no configuration reads, I/O, or
|
||||
database connection.
|
||||
|
||||
@@ -153,7 +153,8 @@ enforced by `requirePermission` regardless; the test suite proves it by having a
|
||||
"denied" call its API anyway and still receive `403`.
|
||||
|
||||
**The host executes trusted module code in its own page and does not pretend otherwise.** The loader
|
||||
performs a same-origin dynamic `import()` — no `eval`, no `new Function`, no `blob:` URL — and the
|
||||
performs a dynamic `import()` from the API's origin (the page's own in the same-origin topology, the
|
||||
API hostname in the split one, ADR 0045) — no `eval`, no `new Function`, no `blob:` URL — and the
|
||||
shell owns the container, the loading state, and every error, so a failing module costs its own panel
|
||||
rather than the shell. None of that is containment: once imported, a module UI is ordinary same-realm
|
||||
JavaScript, exactly as described above.
|
||||
@@ -201,7 +202,7 @@ separate deployment and threat-model decision.
|
||||
|
||||
### A display page is authorized; its artifact bytes are not authority
|
||||
|
||||
Manifest version 6 binds a display artifact to one permission declared by the same module
|
||||
The manifest binds a display artifact to one permission declared by the same module
|
||||
([ADR 0033](../architecture/decisions/0033-display-kiosk-host-contract.md)). The declaration rejects
|
||||
a `public` baseline: the current display host requires an ordinary authenticated cookie principal,
|
||||
and public/device identity is not approximated with an anonymous exception. Both the stable
|
||||
@@ -463,10 +464,13 @@ Until that exists, the trusted-code model above is the security model.
|
||||
|
||||
## Current deployment state
|
||||
|
||||
As of this branch, **Ekklesia is not deployed**. There is no production API instance, no reverse
|
||||
proxy, no public listener, and no deployment automation of any kind in this repository — no deploy
|
||||
script, no CI deployment step, no container publish, no orchestration manifest. No `.env` has ever
|
||||
been committed; only `.env.example`.
|
||||
As of this branch, **Ekklesia is not deployed**. There is no production API instance, no running
|
||||
reverse proxy, no public listener, and no deployment automation in this repository — no deploy
|
||||
script, no CI deployment step, no container publish. The repository provides a **reference deployment**
|
||||
([`deploy/`](../../deploy/README.md)): a production image and the Compose project that runs it, which
|
||||
CI builds and smoke-tests end to end on every change against disposable services. Nothing pushes,
|
||||
publishes, or runs it against a real installation. No `.env` has ever been committed; only
|
||||
`.env.example`, which the image build context excludes.
|
||||
|
||||
Two production defaults are relevant but are **not** by themselves a deployment guarantee:
|
||||
|
||||
@@ -486,9 +490,13 @@ This checklist gates a **public launch**. It does not gate development: Surfaces
|
||||
work all proceed against the local development infrastructure while every item below is still
|
||||
outstanding.
|
||||
|
||||
1. The reverse proxy serves the built Dashboard assets and `/api` on the intended origin, or an
|
||||
explicitly split origin is covered by the exact trusted-origin/CORS configuration. The
|
||||
repository currently supplies neither deployment.
|
||||
1. The reverse proxy forwards the Dashboard and `/api` on the intended origin, or an explicitly
|
||||
split origin is covered by the exact trusted-origin/CORS configuration. `apps/api` serves the
|
||||
built Dashboard itself at `/` ([API § Dashboard host](api.md#dashboard-host)), and the
|
||||
reference deployment's proxy routes it ([deploy/README.md](../../deploy/README.md#launch-checklist)
|
||||
maps every item here to its configuration). A split installation (ADR 0045) puts its Dashboard
|
||||
and API hostnames on one registrable domain and uses `Caddyfile.split.example`, which refuses
|
||||
`/api/*` on the Dashboard's hostname so the session cookie has exactly one home.
|
||||
2. The origin port is unreachable from the public Internet.
|
||||
3. The origin port is also unreachable from any untrusted network/VLAN — only the intended private
|
||||
proxy path connects. Security must not depend on an unpublished DNS name or origin IP.
|
||||
@@ -685,8 +693,10 @@ public Dashboard login must be assessed with the actual edge/proxy controls befo
|
||||
|
||||
## Continuous integration boundary
|
||||
|
||||
CI runs on self-hosted Gitea Actions runners. The inventoried server is **Gitea 1.24.2**, and that
|
||||
version determines which parts of the workflow are real guarantees and which are not.
|
||||
CI runs on self-hosted Gitea Actions runners. The server reports **Gitea 1.27.2** (`GET
|
||||
/api/v1/version`, checked 2026-10-06; this document previously said 1.24.2, which was stale). The
|
||||
server's actual version, not the documentation of an earlier one, determines which parts of the
|
||||
workflow are real guarantees and which are not.
|
||||
|
||||
### The current operating rule: trusted code only
|
||||
|
||||
@@ -719,24 +729,53 @@ so a later step in the same job cannot read or reuse it from there.
|
||||
It does **not** remove the short-lived `GITEA_TOKEN` from the job environment, and it does **not**
|
||||
reduce that token's server-side authority. A step that wants the token can still read it.
|
||||
|
||||
### What Gitea 1.24 ignores — so the branch cannot claim it
|
||||
### What this server honours, and what is not yet verified
|
||||
|
||||
Per the official comparison for this version
|
||||
(<https://docs.gitea.com/1.24/usage/actions/comparison>), Gitea 1.24's unsupported workflow syntax
|
||||
includes `permissions` and `jobs.<job_id>.permissions`, `concurrency`, and
|
||||
`jobs.<job_id>.timeout-minutes`. On this server those declarations are inert. Therefore:
|
||||
Each item separates what Gitea documents for 1.27
|
||||
(<https://docs.gitea.com/1.27/usage/actions/comparison>) from what has been observed on this server:
|
||||
|
||||
- **The workflow's `permissions: contents: read` does not restrict the job token.** This branch
|
||||
cannot claim the `GITEA_TOKEN` is read-only, because nothing in workflow YAML makes it so on
|
||||
1.24. Job-token permission controls, repository/owner defaults, and server-side
|
||||
maximum-permission clamping arrive in the 1.26 feature line
|
||||
(<https://docs.gitea.com/1.26/usage/actions/token-permissions>).
|
||||
- **`concurrency` and `timeout-minutes` are not bounded-execution guarantees here.** Cancellation,
|
||||
timeout, and teardown must be enforced by a trusted provisioner and reaper outside the workflow.
|
||||
- **`concurrency` is honoured.** Gitea supports it since 1.26 (go-gitea/gitea#32751), and on this
|
||||
server a newer push to a pull request cancels the run it supersedes. Observed: Ekklesia run 133 was
|
||||
cancelled when run 134 started for the same pull request, and run 135 when run 137 did. A
|
||||
superseded run's own cleanup is therefore never guaranteed to run, which is one reason for the
|
||||
stack sweep below.
|
||||
- **`permissions` is supported by 1.27, with Gitea's own scopes, but is not verified here.** The
|
||||
server-side token-permission settings are not visible through the API, and nothing has yet proven
|
||||
what the job token can and cannot do on this server. Until that proof exists (requirement 3
|
||||
below), this repository still does not claim the `GITEA_TOKEN` is read-only.
|
||||
- **`timeout-minutes` is no longer listed as unsupported for 1.27, but is not verified here.**
|
||||
Teardown does not rely on it: the stack sweep and a trusted reaper do.
|
||||
- **Still ignored:** `jobs.<job_id>.environment`, and any `runs-on` other than a single label.
|
||||
|
||||
The declarations are kept deliberately: they are already correct for a supported Gitea release and
|
||||
for GitHub Actions, and keeping them means the upgrade does not require re-deriving the intent. They
|
||||
are documented as ignored, not removed.
|
||||
The declarations stay in the workflow either way: they state the intent, and are correct on GitHub
|
||||
Actions as well.
|
||||
|
||||
### Disposable stacks never outlive their job
|
||||
|
||||
Every CI script removes its own Compose stack in an `EXIT` trap, but a trap never runs when the runner
|
||||
kills the job container: a cancelled job, or the runner's own time limit. One such stack ran on the
|
||||
shared runner for two days. So stack cleanup has three layers:
|
||||
|
||||
- **The script's own trap**, for every ordinary failure.
|
||||
- **A final `if: always()` step** in every job that starts stacks: `scripts/ci-sweep.sh --job` removes
|
||||
everything that job started, at any age, even after a failed step.
|
||||
- **A sweep before every such job starts**: `scripts/ci-sweep.sh --stale` removes anything a CI job
|
||||
left more than three hours ago, which catches whatever a killed job could not clean up itself.
|
||||
|
||||
A stack is found by name and label, never by guesswork (`scripts/lib/ci-project.sh`):
|
||||
|
||||
- every CI Compose project is named `ekklesia-ci-<run>-<attempt>-<job key>-…`, and Compose labels
|
||||
its containers, networks, and volumes with that name;
|
||||
- anything a script starts outside Compose carries `org.riverofglorymn.ekklesia.ci.project`;
|
||||
- images also carry the time CI built them, because an image's own creation time can be its base's.
|
||||
|
||||
The job key is per matrix leg, so one leg's cleanup cannot match another's running stack. Nothing
|
||||
outside the `ekklesia-ci-` prefix is ever removed. A job container attached to a stack's network is
|
||||
disconnected, never removed.
|
||||
|
||||
**This is not a substitute for a runner-side reaper.** A stack killed with its job is removed only
|
||||
when another job that sweeps runs on the same runner. Bounded execution and teardown are still the
|
||||
runner host's to enforce, as above.
|
||||
|
||||
### Runner topology is not verified
|
||||
|
||||
@@ -758,16 +797,17 @@ they are not conditions on merging trusted, reviewed work.
|
||||
None of them can be satisfied by editing this repository; each requires access to the Gitea server
|
||||
and runner hosts, and each must be proven there.
|
||||
|
||||
1. Upgrade Gitea to a reviewed supported release with job-token permission controls — at least the
|
||||
1.26 feature line. Select the actual release during infrastructure work, after backup and
|
||||
compatibility review; do not pin a version from this document.
|
||||
1. ~~Upgrade Gitea to a reviewed supported release with job-token permission controls — at least
|
||||
the 1.26 feature line.~~ **Done:** the server runs 1.27.2. Items 2 and 3 now configure and prove
|
||||
what it makes possible.
|
||||
2. Configure repository and owner defaults, and the server-side maximum permission, so workflow
|
||||
code cannot request more than read-only repository access.
|
||||
3. Prove against the real server that the job's `GITEA_TOKEN` cannot push code, create or update
|
||||
releases, packages, issues, or pull requests, change Actions configuration, or reach another
|
||||
private repository.
|
||||
4. Enforce job timeout, cancellation, concurrency, and teardown in the trusted
|
||||
provisioner/reaper rather than relying on the ignored workflow keys above.
|
||||
4. Enforce job timeout and teardown in the trusted provisioner/reaper. `concurrency` is honoured
|
||||
by the server; `timeout-minutes` is not yet verified, and the in-workflow stack sweep only
|
||||
complements a reaper.
|
||||
5. Complete a disposable-guest runner isolation proof: one throwaway guest per job, torn down by a
|
||||
trusted reaper outside workflow YAML, holding no deployment credential, registration token, or
|
||||
reusable key.
|
||||
|
||||
@@ -69,6 +69,77 @@ protocol-relative, backslash-prefixed, and scheme-bearing values cannot become n
|
||||
Modularity checks keep authentication out of the module UI SDK/context and Dashboard login code out
|
||||
of the separate display bundle.
|
||||
|
||||
Phase 8 adds evidence for the five platform prerequisites.
|
||||
|
||||
**Design tokens and shared elements** (ADR 0039). The token registry's tests prove it is
|
||||
deterministic and enumerable, that every token derives a unique `--ekklesia-*` identifier from its
|
||||
name and carries a label, description, and category, that each status family pairs a colour with a
|
||||
non-colour symbol, and that a duplicate name, an unknown category, an invalid or single-segment name,
|
||||
and a value carrying a declaration terminator, a rule block, a comment, an at-rule, `url(`,
|
||||
`!important`, an escape, a control character, or markup are each rejected — with an error that never
|
||||
echoes the rejected declaration. The elements' tests assert accessible structure (label/hint/error
|
||||
association, icon-button naming, tab roles and arrow-key movement, announced error and loading
|
||||
states), that status never depends on colour alone, that a dialog renders in place rather than
|
||||
portalling out of an isolated root, and — mechanically — that the stylesheet and every component
|
||||
source contain no literal colour, font stack, or theme value and reference only registered tokens.
|
||||
Both packages compile module-shaped source under the exact browser project a module uses, where
|
||||
`process.env` must fail to typecheck, so the browser boundary is measured rather than asserted.
|
||||
|
||||
**Module stylesheets and artifact isolation** (ADR 0040). The builder's tests prove a plain
|
||||
stylesheet is compiled into the declared artifact with no `.css` emitted beside it, that a local
|
||||
`@import` is inlined, and that an unresolved `@import`, a stylesheet declaring an `--ekklesia-*`
|
||||
variable, and a non-CSS dialect are each rejected with their own sanitized code — while a private
|
||||
alias derived from a registered token is accepted. The loader's tests prove every artifact mounts
|
||||
inside an open shadow root, that the artifact receives that root rather than the host's element, that
|
||||
exactly one `<style>` is installed per mount and never accumulates across remounts, that the host's
|
||||
base rules precede the artifact's so a display can override `:host`, that unmount removes the whole
|
||||
root even when the module's cleanup throws, that a failed mount leaves neither DOM nor styles behind,
|
||||
that an environment without Shadow DOM fails closed rather than mounting unisolated, and that an
|
||||
artifact built against the previous ABI is refused with `MODULE_UI_ABI_UNSUPPORTED`. The template's
|
||||
suite builds real React artifacts through the canonical pipeline and asserts the compiled stylesheet
|
||||
travels inside the one declared file.
|
||||
|
||||
**Browser contributions** (ADR 0041). Manifest tests cover namespacing, the single local segment,
|
||||
duplicate ids and artifact paths, collision with an entrypoint, and a contribution targeting a
|
||||
capability its own module provides. Discovery tests prove a contribution artifact inherits the exact
|
||||
path, symlink, containment, extension, and file-kind protections every other compiled entrypoint has,
|
||||
including two contributions linked to one file. Publication tests prove a contribution is published
|
||||
only when another eligible module provides its capability *within the declared range*, that the
|
||||
contributing module never sees its own contribution, that a lookup fails closed on an unknown,
|
||||
mismatched, or non-string identity, and that the published record carries no domain vocabulary at
|
||||
all. Route tests prove the artifact is served byte-for-byte at a URL that cannot select a neighbour,
|
||||
and that an unknown id, another module's id, a traversal, a source map, a wrong version, an absent
|
||||
provider, and an incompatible provider all collapse into one indistinguishable `404`. The loader's
|
||||
tests cover mount, update-without-remount, a renderer that declares no `update`, deterministic and
|
||||
idempotent unmount, a sanitized update failure, and input that is not genuinely JSON — a `Date`, a
|
||||
function, `NaN`, `undefined`, and a cycle — refused *before* anything is imported and without ever
|
||||
echoing the consumer's data. The flagship shell fixture drives the whole relationship: a `signage`
|
||||
module discovers, mounts, updates, and unmounts an `announcements` React renderer with neither module
|
||||
importing the other, the consumer seeing identity but never a URL, the host resolving publication,
|
||||
and a contribution id the server withheld refused without a fetch.
|
||||
|
||||
**Bounded streaming uploads** (ADR 0042). Contract tests pin the two ceilings, that `maxBodyBytes` is
|
||||
checked against the ceiling the declared mode selects, that 32 MiB holds a 25 MiB image and one byte
|
||||
more is refused, and that no mode admits an unbounded body. HTTP tests cover exact-boundary success,
|
||||
a declared `Content-Length` refused before the handler runs, a misleading smaller `Content-Length`
|
||||
that cannot smuggle a megabyte past the limit, a chunked overflow, a `413` that still wins when the
|
||||
handler swallowed the stream error, proof that the body is genuinely not buffered (a handler sees the
|
||||
first chunk before the last is produced), an aborted upload surfacing as an ordinary failure rather
|
||||
than a `413`, an unread body being released, authorization ordering in both directions, and a
|
||||
rejection that names no limit, size, or body content. The live suite uploads a real 25 MiB body over
|
||||
a real socket end to end and proves the server is healthy immediately afterwards.
|
||||
|
||||
**Display offline fallback** (ADR 0043). The worker's caching logic is exercised directly, because it
|
||||
touches no global: first online load, offline reload after one successful visit, a missing uncached
|
||||
artifact failing honestly, contributed-renderer fallback, version upgrade with pruning that keeps
|
||||
every artifact of the current version and never touches another module's, superseded cache
|
||||
generations removed while unrelated ones survive, network-first behaviour picking up rebuilt bytes,
|
||||
and an explicit inventory proving the descriptor, principal, module API, realtime stream, pairing
|
||||
exchange, auth routes, cross-origin signed object URLs, and the worker itself are **never** stored —
|
||||
so a revoked device's refusal is what a reconnected display sees. Page-level tests cover the quiet
|
||||
offline indicator, a cache fallback reported while the browser still claims to be online, automatic
|
||||
re-resolution when the network returns, and a revoked device dropping back to pairing on reconnect.
|
||||
|
||||
`pnpm verify` is intentionally Docker-free. It never plans or applies migrations against a live
|
||||
database.
|
||||
|
||||
@@ -88,6 +159,12 @@ also receives a safely named disposable database. Direct host runs use a Docker-
|
||||
port bound only to `127.0.0.1`; a containerized Gitea job joins the isolated Compose network and
|
||||
uses the PostgreSQL service name.
|
||||
|
||||
In CI, a stack whose job was killed before its own cleanup ran is still removed: by that job's final
|
||||
`if: always()` step, or by the stale sweep before a later job starts (`scripts/ci-sweep.sh`, see
|
||||
[Disposable stacks never outlive their job](security.md#disposable-stacks-never-outlive-their-job)).
|
||||
`bash scripts/test-ci-sweep.sh` proves what the sweep removes and what it leaves alone, against real
|
||||
Docker resources.
|
||||
|
||||
The live suite proves fresh read-only planning, initial and incremental application, genuine
|
||||
no-op behavior, exact applied checksums and history, edited or corrupt history rejection,
|
||||
whole-batch DDL rollback, Drizzle breakpoint execution, real advisory-lock concurrency and
|
||||
|
||||
@@ -73,7 +73,7 @@ without executing module code:
|
||||
|
||||
```json
|
||||
{
|
||||
"manifestVersion": 6,
|
||||
"manifestVersion": 7,
|
||||
"id": "calendar",
|
||||
"name": "Calendar",
|
||||
"version": "0.1.0",
|
||||
@@ -122,6 +122,16 @@ without executing module code:
|
||||
}
|
||||
]
|
||||
},
|
||||
"contributions": [
|
||||
{
|
||||
"id": "calendar.signage-slide",
|
||||
"version": "1.0.0",
|
||||
"capability": "signage.slide-renderer",
|
||||
"capabilityRange": "^1.0.0",
|
||||
"artifact": "./dist/contributions/signage-slide/index.js",
|
||||
"description": "Renders an upcoming-events slide for a signage timeline."
|
||||
}
|
||||
],
|
||||
"entrypoints": {
|
||||
"server": "./dist/server/index.js",
|
||||
"schema": "./dist/schema/index.js",
|
||||
@@ -140,12 +150,13 @@ has its own exact semantic version because its public contract can evolve indepe
|
||||
module release. Capability dependencies use semantic-version ranges. Optional means the provider
|
||||
may be absent; if it is installed, its capability version must be compatible.
|
||||
|
||||
`manifestVersion: 6` (`packages/contracts/src/module-manifest.ts`) is a required, exact literal — a
|
||||
version 5 manifest, and any other version, fails validation outright rather than being silently
|
||||
`manifestVersion: 7` (`packages/contracts/src/module-manifest.ts`) is a required, exact literal — a
|
||||
version 6 manifest, and any other version, fails validation outright rather than being silently
|
||||
reinterpreted; there is no compatibility adapter or automatic migration between manifest versions.
|
||||
Version 6 adds the optional `entrypoints.display` object. A version 5 module that declares no display
|
||||
changes only the version literal; `ui` remains independently optional
|
||||
([ADR 0033](../architecture/decisions/0033-display-kiosk-host-contract.md)).
|
||||
Version 7 adds the optional `contributions` array
|
||||
([ADR 0041](../architecture/decisions/0041-module-browser-contributions.md)). A version 6 module that
|
||||
contributes nothing changes only the version literal; every browser artifact remains independently
|
||||
optional ([ADR 0033](../architecture/decisions/0033-display-kiosk-host-contract.md)).
|
||||
Each `permissions` entry is a complete declaration, not a bare identifier string: `id` (must begin
|
||||
with `<module-id>.`, exactly like a capability id), `baseline` (`public`, `authenticated`, or
|
||||
`restricted` — see [Authorization](../development/authorization.md#permission-registry) for what
|
||||
@@ -214,6 +225,16 @@ are separate artifacts because a full-viewport display is not a Dashboard screen
|
||||
|
||||
```json
|
||||
{
|
||||
"contributions": [
|
||||
{
|
||||
"id": "calendar.signage-slide",
|
||||
"version": "1.0.0",
|
||||
"capability": "signage.slide-renderer",
|
||||
"capabilityRange": "^1.0.0",
|
||||
"artifact": "./dist/contributions/signage-slide/index.js",
|
||||
"description": "Renders an upcoming-events slide for a signage timeline."
|
||||
}
|
||||
],
|
||||
"entrypoints": {
|
||||
"server": "./dist/server/index.js",
|
||||
"schema": "./dist/schema/index.js",
|
||||
@@ -271,7 +292,7 @@ artifact is the role selection.
|
||||
|
||||
### What the host gives you
|
||||
|
||||
`mount` receives exactly five members, and nothing else is available:
|
||||
`mount` receives exactly six members, and nothing else is available:
|
||||
|
||||
| Member | What it is |
|
||||
| --- | --- |
|
||||
@@ -280,10 +301,17 @@ artifact is the role selection.
|
||||
| `principal` | Who is viewing: `ModuleUiPrincipal`, either `{ kind: "anonymous" }` or `{ kind: "authenticated", userId }` |
|
||||
| `permissions` | `has(permissionId)` — a presentation hint, never a security check |
|
||||
| `realtime` | `subscribe(channel, handlers)` against **your own** module's live state — see [Realtime state](#realtime-state) |
|
||||
| `contributions` | Renderers other modules contributed to capabilities **you** provide — see [Browser contributions](#browser-contributions) |
|
||||
|
||||
Navigation, host theme and design tokens, module configuration, organization context, and
|
||||
diagnostics reporting are **not** in the contract yet. They need designs that do not exist, and a
|
||||
speculative field frozen into a public contract is harder to remove than to add.
|
||||
Navigation, module configuration, organization context, and diagnostics reporting are **not** in the
|
||||
contract. They need designs that do not exist, and a speculative field frozen into a public contract
|
||||
is harder to remove than to add.
|
||||
|
||||
The host **theme** is deliberately not a context member either, and that is not a gap: design tokens
|
||||
reach your artifact as inherited CSS custom properties rather than as data (ADR 0039). You import
|
||||
`@ekklesia/design-tokens` and write `var(--ekklesia-…)`; the host installs the values at its own
|
||||
root. Nothing is negotiated at run time, so there is no token map to keep in sync and no host that
|
||||
can forget to pass one.
|
||||
|
||||
### Never build your own API URL
|
||||
|
||||
@@ -413,20 +441,157 @@ loads several React copies, and artifacts are large (the template's is roughly 5
|
||||
an operator can review it). That is accepted for now and can be optimized later **without changing
|
||||
the contract**, because the contract is a mount function rather than a component.
|
||||
|
||||
### Styling: inline style objects only, for now
|
||||
### Design tokens and shared elements
|
||||
|
||||
Style with React's `style` prop:
|
||||
Your UI may import two shared design packages, and only those two
|
||||
([ADR 0039](../architecture/decisions/0039-shared-design-tokens-and-elements.md)):
|
||||
|
||||
| Package | What it is |
|
||||
| --- | --- |
|
||||
| `@ekklesia/design-tokens` | The platform's global semantic theme — a validated, enumerable registry of named values and the `var(--ekklesia-…)` references for them |
|
||||
| `@ekklesia/ui` | The foundational Ekklesia elements, every one of them built from those tokens |
|
||||
|
||||
```tsx
|
||||
<section style={{ display: "grid", gap: "0.5rem" }}>…</section>
|
||||
import { designToken } from "@ekklesia/design-tokens";
|
||||
import { Badge, Button, EkklesiaUiStyles, Panel } from "@ekklesia/ui";
|
||||
|
||||
export function Slides({ context }: { readonly context: ModuleUiHostContext }) {
|
||||
return (
|
||||
<>
|
||||
<EkklesiaUiStyles />
|
||||
<Panel actions={<Button variant="primary">Add slide</Button>} title="Slides">
|
||||
<p style={{ color: designToken("text.secondary") }}>Nothing scheduled yet.</p>
|
||||
<Badge tone="warning">Paused</Badge>
|
||||
</Panel>
|
||||
</>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
**Stylesheet imports are not supported, and the build fails on one.** That is deliberate rather than
|
||||
an oversight: esbuild would emit a `.css` sidecar beside a primary or display artifact, but no
|
||||
manifest field declares it and no host loads it — so it would work in your tests and silently do
|
||||
nothing in a real host. A stylesheet contract needs a manifest field, a host loading mechanism, and
|
||||
a style-isolation decision, none of which exist yet. Inline styles are the whole supported story for
|
||||
this phase.
|
||||
Render `<EkklesiaUiStyles />` once, near the top of your tree. It puts the element stylesheet into
|
||||
whichever root your artifact is mounted in, so you never have to know whether that is a document or
|
||||
an isolated root.
|
||||
|
||||
**Use semantic names, not colours.** `designToken("text.danger")` says what the text *means*;
|
||||
`#9f1239` says what it looks like today. An installation that changes its theme changes the first
|
||||
and not the second. `designToken` only accepts registered names, so a typo is a compile error.
|
||||
|
||||
**Never define an `--ekklesia-*` custom property.** Those are the platform's global theme variables,
|
||||
and inventing one would create a global a future theme editor could not enumerate and another module
|
||||
could unknowingly depend on. You may derive as many *private* aliases as you like inside your own
|
||||
artifact, under your own prefix:
|
||||
|
||||
```tsx
|
||||
<div style={{ "--calendar-row-gap": designToken("spacing.sm") } as CSSProperties}>
|
||||
```
|
||||
|
||||
The host installs the default token values at its own root, and CSS custom properties inherit
|
||||
through an isolated artifact's boundary — so the tokens are simply there, with nothing to import at
|
||||
run time and nothing for the host to hand you.
|
||||
|
||||
Every other shared package stays rejected inside a module: `@ekklesia/api-client`,
|
||||
`@ekklesia/auth-client`, `@ekklesia/surface-sdk`, and `@ekklesia/module-ui-host` are not yours to
|
||||
import, and `pnpm boundaries` says so.
|
||||
|
||||
### Styling: a stylesheet compiled into your artifact
|
||||
|
||||
Author plain `.css` beside your UI source, import it, and declare it
|
||||
([ADR 0040](../architecture/decisions/0040-module-stylesheets-and-artifact-isolation.md)):
|
||||
|
||||
```text
|
||||
src/ui/index.tsx
|
||||
src/ui/styles.css
|
||||
```
|
||||
|
||||
```tsx
|
||||
import { defineModuleUi } from "@ekklesia/module-ui-sdk";
|
||||
import { EKKLESIA_UI_STYLESHEET } from "@ekklesia/ui";
|
||||
import styles from "./styles.css";
|
||||
|
||||
export default defineModuleUi({
|
||||
id: "announcements",
|
||||
version: "0.1.0",
|
||||
styles: `${EKKLESIA_UI_STYLESHEET}\n${styles}`,
|
||||
mount(container, context) { /* … */ },
|
||||
});
|
||||
```
|
||||
|
||||
TypeScript needs to know what a `.css` import is. Copy the template's `src/module-styles.d.ts` into
|
||||
your module and include it in your UI TypeScript project:
|
||||
|
||||
```ts
|
||||
declare module "*.css" {
|
||||
const stylesheet: string;
|
||||
export default stylesheet;
|
||||
}
|
||||
```
|
||||
|
||||
**There is no sidecar file.** `@ekklesia/module-ui-build` compiles each stylesheet — resolving local
|
||||
`@import`s — into your declared JavaScript artifact as text. One declared file still contains
|
||||
everything, so there is nothing extra to publish, nothing for a host to fetch, and no way for your
|
||||
CSS to become an asset nobody serves. That was the exact hazard that kept stylesheets out until now.
|
||||
|
||||
Two rules are enforced by the build, each with its own error:
|
||||
|
||||
- **Reference Ekklesia's global theme variables; never declare one.** `--ekklesia-*` belongs to the
|
||||
registry a future theme editor enumerates (ADR 0039). Derive private aliases under your own prefix
|
||||
freely: `--announcements-slide-gap: var(--ekklesia-spacing-sm)`.
|
||||
- **Every `@import` must resolve to a local file.** A remote import would make your artifact fetch a
|
||||
stylesheet at run time, and a declared artifact has to be self-contained.
|
||||
|
||||
Plain `.css` only. `.scss`, `.less`, and other dialects stay rejected — adding a preprocessor is a
|
||||
toolchain decision that needs its own ADR. A CSS `url()` pointing at a local image or font also fails
|
||||
the build: no asset loader is configured. Use a data URL, or serve real media from your module's
|
||||
object storage.
|
||||
|
||||
`pnpm boundaries` allows `.css` inside `src/ui/` and `src/display/` only. A stylesheet anywhere else
|
||||
in your module is a file no build reads and no host loads.
|
||||
|
||||
### Your artifact renders in its own isolated root
|
||||
|
||||
Every browser artifact — primary UI, display UI, and any renderer you contribute — is mounted inside
|
||||
a Shadow DOM root the host creates, with your stylesheet installed in it:
|
||||
|
||||
```text
|
||||
host container
|
||||
└─ <div data-ekklesia-module-ui>
|
||||
#shadow-root (open)
|
||||
├─ <style> host base rules, then your declared CSS
|
||||
└─ <div> ← the container your mount() receives
|
||||
```
|
||||
|
||||
What that buys you, and what it costs:
|
||||
|
||||
- **Your selectors cannot leak out**, and the Dashboard's cannot leak in. Write `.slide` without a
|
||||
prefix and without worrying about another module's `.slide`.
|
||||
- **Design tokens still reach you.** CSS custom properties inherit through the boundary, so
|
||||
`var(--ekklesia-…)` resolves from the values the host installed at its root. Nothing to import at
|
||||
run time, nothing for the host to pass.
|
||||
- **Two artifacts of your own module are isolated from each other too.** Your management UI and your
|
||||
display share styles only if they share a source file.
|
||||
- **Your DOM is not in the host document's tree.** `document.querySelector` from outside will not
|
||||
find your elements — including in your own tests, which should mount the artifact and query inside
|
||||
the root the loader hands `mount`.
|
||||
- **You get no box by default.** The host's two wrapper elements are `display: contents`, so you lay
|
||||
out exactly where the host put you. A full-viewport display that needs a real box overrides it:
|
||||
`:host { display: block }`, which works because the host installs its base rules *before* yours.
|
||||
- **Mount and unmount are deterministic.** The host installs your stylesheet once per mount and
|
||||
removes the whole root on unmount, so repeated mounts never accumulate styles and a cleanup that
|
||||
throws still cannot leave your CSS behind. You never install or remove your own `<style>`.
|
||||
|
||||
**This is not a sandbox.** Your artifact is trusted first-party code in the host's own JavaScript
|
||||
realm and can reach `document` if it wants to; the root is `open` for exactly that reason. It is a
|
||||
correctness boundary for styles, and nothing more. See
|
||||
[Security model and deployment trust boundaries](../development/security.md).
|
||||
|
||||
### The mount ABI is versioned
|
||||
|
||||
`defineModuleUi` stamps the browser mount ABI version onto your artifact — you never write it. A host
|
||||
verifies it before mounting, and an artifact built against an older SDK fails with
|
||||
`MODULE_UI_ABI_UNSUPPORTED` rather than being mounted under guessed semantics.
|
||||
|
||||
Migrating from ABI 1 is one `pnpm build`. If you declare no stylesheet, no source change is needed at
|
||||
all; if your own tests assert on module DOM, they need to look inside the isolated root.
|
||||
|
||||
### What you may and may not use today
|
||||
|
||||
@@ -435,9 +600,9 @@ the build pipeline** — adding the pipeline required no boundary relaxation at
|
||||
|
||||
| Allowed in a module | Still rejected |
|
||||
| --- | --- |
|
||||
| `.tsx` source | `.jsx` (strict TypeScript only), `.css`, `.vue`, `.svelte`, `.astro`, `.mdx`, `.svg`, `.html` |
|
||||
| `.tsx` source, and plain `.css` inside `src/ui/` or `src/display/` | `.jsx` (strict TypeScript only), `.css` outside a browser source root, `.scss`/`.less`/`.styl`, `.vue`, `.svelte`, `.astro`, `.mdx`, `.svg`, `.html` |
|
||||
| `react`, `react-dom` | Every other framework, plus `react-router-dom` (routing is the host's), `react-native`, `@vitejs/plugin-react` |
|
||||
| `@ekklesia/module-ui-sdk`, `@ekklesia/module-ui-build` | `@ekklesia/ui` and other shared UI-role packages |
|
||||
| `@ekklesia/module-ui-sdk`, `@ekklesia/module-ui-build`, `@ekklesia/design-tokens`, `@ekklesia/ui` | Every other shared role: `api-client`, `auth-client`, `surface-sdk`, `module-ui-host` |
|
||||
|
||||
You do not need a bundler plugin — `@vitejs/plugin-react` and friends stay rejected because
|
||||
`@ekklesia/module-ui-build` owns the bundler entirely.
|
||||
@@ -523,8 +688,9 @@ Four things about it matter to you as a module author:
|
||||
- **Only the artifact that role declares is served.** The module id and version in either URL are
|
||||
lookup keys, never path components — there is no way to request a second file from your package.
|
||||
Your source map, any sibling chunk, and every package-root file stay unreachable. If your UI needs
|
||||
an asset, it must be inside the bundle; there is no sidecar contract yet, which is also why a
|
||||
stylesheet import still fails the build.
|
||||
an asset, it must be inside the bundle: that is exactly why your stylesheet is compiled into the
|
||||
artifact as text rather than published beside it (ADR 0040), and why a bundled image or font is
|
||||
still not supported.
|
||||
- **The version must match exactly.** A request for any other version is refused rather than served
|
||||
your installed artifact, so a stale host cannot mount something it did not ask for.
|
||||
- **Your artifact bytes are public.** These two routes require no authentication — fetching code
|
||||
@@ -580,7 +746,7 @@ eligible, running, and declares `entrypoints.display`, Ekklesia registers exactl
|
||||
|
||||
The page and descriptor both require the manifest's display permission through the ordinary Core
|
||||
authorization evaluator and an authenticated cookie session. A `public` display permission is
|
||||
rejected in manifest version 6; there is no anonymous exception or device token. Artifact bytes are
|
||||
rejected; there is no anonymous exception or device token. Artifact bytes are
|
||||
anonymous for the same reason as primary UI bytes: code alone carries no data authority. Unknown,
|
||||
primary-only, disabled, and dependency-blocked modules have no display routes and return the normal
|
||||
sanitized `404`. Enabling, disabling, installing, or changing the manifest takes effect on restart,
|
||||
@@ -598,8 +764,31 @@ artifact remains compatible with a future standalone host.
|
||||
The display host fills the viewport but deliberately does **not** invoke the browser Fullscreen API.
|
||||
It shows fixed loading/failure states and offers manual retry; your module owns only the rendered
|
||||
feature experience and cleanup inside its container. There is one display per module. Display
|
||||
inventory/management, named variants, targeting, public/device identity, a standalone host, shared
|
||||
CSS, Announcements migration, and Companion integration are deferred.
|
||||
inventory/management, named variants, targeting, public/device identity, a standalone host,
|
||||
Announcements migration, and Companion integration are deferred.
|
||||
|
||||
### Your display survives a network outage — as code, not as content
|
||||
|
||||
A display page is controlled by a service worker that caches **the shell and the declared code**: the
|
||||
page, the host script, your display artifact, and every contributed renderer that display actually
|
||||
mounted ([ADR 0043](../architecture/decisions/0043-display-offline-code-fallback.md)). Caching is
|
||||
network-first, so a screen picks up a new build the moment the server returns and can never be
|
||||
trapped on an obsolete one.
|
||||
|
||||
What that means for you, concretely:
|
||||
|
||||
- **Your artifact will be served from cache when a fetch fails.** The bytes are identical and the
|
||||
loader still verifies the artifact's declared id and version, so a fallback can never mount
|
||||
something the descriptor did not name.
|
||||
- **Your display cannot render while the network is down.** Mounting requires the descriptor, the
|
||||
descriptor is authorization-bearing, and it is never cached — so a device whose access was revoked
|
||||
stays revoked, and cached code can never manufacture authorization it no longer has. The screen
|
||||
shows Ekklesia's own quiet offline indicator and resolves again by itself when connectivity
|
||||
returns.
|
||||
- **Nothing of yours is cached but code.** Your module's API responses, your realtime stream, and
|
||||
every signed object-delivery URL are deliberately never stored. If you want a screen to keep
|
||||
showing content through an outage, persisting that content is *your module's* job — this is the
|
||||
foundation it would build on, not a substitute for it.
|
||||
|
||||
### What a module UI will never own
|
||||
|
||||
@@ -626,6 +815,132 @@ A Dashboard may later group modules into folders (`Media` holding Video and Live
|
||||
configuration an administrator can reorganize freely. They are not dependencies, they are not
|
||||
declared in your manifest, and your module must never require one to work correctly.
|
||||
|
||||
## Browser contributions
|
||||
|
||||
Sometimes another module owns a screen, and *you* own what belongs on part of it. A signage module
|
||||
owns a timeline; an announcements module knows what an announcement should look like. Neither can
|
||||
import the other — modules are independently installed, independently versioned peers — and neither
|
||||
should re-implement the other's job.
|
||||
|
||||
A **browser contribution** is the mechanism for exactly that
|
||||
([ADR 0041](../architecture/decisions/0041-module-browser-contributions.md)): a compiled renderer you
|
||||
declare for a capability *another* module provides, which that module mounts inside a container it
|
||||
owns.
|
||||
|
||||
```text
|
||||
signage provides the capability signage.slide-renderer, and publishes its input contract
|
||||
announcements contributes a React renderer for it
|
||||
signage mounts that renderer in one timeline slot, updates it, and takes it down
|
||||
```
|
||||
|
||||
### Contributing a renderer
|
||||
|
||||
Declare it in your manifest and put the source at the fixed path your contribution id implies:
|
||||
|
||||
```json
|
||||
{
|
||||
"contributions": [
|
||||
{
|
||||
"id": "announcements.signage-slide",
|
||||
"version": "1.0.0",
|
||||
"capability": "signage.slide-renderer",
|
||||
"capabilityRange": "^1.0.0",
|
||||
"artifact": "./dist/contributions/signage-slide/index.js"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
```text
|
||||
src/contributions/signage-slide/index.tsx → the contribution's compiled artifact
|
||||
```
|
||||
|
||||
```tsx
|
||||
import { defineModuleContribution } from "@ekklesia/module-ui-sdk";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import styles from "./styles.css";
|
||||
import { Slide, type SlideInput } from "./Slide.js";
|
||||
|
||||
export default defineModuleContribution({
|
||||
id: "announcements.signage-slide",
|
||||
version: "1.0.0",
|
||||
styles,
|
||||
mount(container, context) {
|
||||
const root = createRoot(container);
|
||||
root.render(<Slide input={context.input as SlideInput} />);
|
||||
return {
|
||||
update(input) {
|
||||
root.render(<Slide input={input as SlideInput} />);
|
||||
},
|
||||
unmount() {
|
||||
root.unmount();
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
The id is `<your-module-id>.<local-name>`, which is what makes it unique platform-wide without a
|
||||
registry. Your contribution carries **its own version**, independent of your module's, and the
|
||||
platform verifies the artifact claims it before mounting — a stale bundle is caught rather than
|
||||
rendered. `capabilityRange` says which provider versions you work with: if no installed module
|
||||
provides that capability inside your range, your contribution is simply not published. It is never
|
||||
mounted and broken.
|
||||
|
||||
`pnpm build` compiles it alongside your other artifacts. You configure no paths and no bundler.
|
||||
|
||||
### What a contributed renderer receives
|
||||
|
||||
Two things: its own id, and the consumer's `input`.
|
||||
|
||||
That is the whole context. **No API client, no realtime subscription, no principal, no permissions,
|
||||
no configuration, no database, no storage, and no credential.** Mounting your renderer grants your
|
||||
module nothing — server-side authorization is untouched, and everything the renderer needs arrives
|
||||
through the consumer's input, because the consumer is the party that was authorized to fetch it.
|
||||
|
||||
The input is **opaque JSON to the platform**. Ekklesia validates that it is genuinely
|
||||
JSON-serializable and nothing else: not its shape, not its meaning, not its version. The consuming
|
||||
module publishes that contract alongside its capability and versions it through its capability
|
||||
version. Validate what you receive and render a sensible default when it is not what you expected —
|
||||
you may be running against an older or newer consumer than the one you developed against.
|
||||
|
||||
`update` is what lets a long-running screen change content without restarting animations or reloading
|
||||
media on every tick. Implement it if you can render new input in place; omit it if you genuinely
|
||||
cannot, and the consumer will unmount and mount you again.
|
||||
|
||||
Your renderer mounts in its own isolated root with its own stylesheet, exactly like a module UI
|
||||
artifact (ADR 0040): your rules cannot reach the consuming module's screen around you, and its rules
|
||||
cannot reach in.
|
||||
|
||||
### Consuming contributions
|
||||
|
||||
If *your* module provides a capability other modules can render for, ask the host what was
|
||||
contributed:
|
||||
|
||||
```tsx
|
||||
const renderers = context.contributions.list("signage.slide-renderer");
|
||||
|
||||
const slide = await context.contributions.mount({
|
||||
container: slotElement, // an element your module owns
|
||||
contributionId: renderers[0].id,
|
||||
input: { headline, seconds }, // your own published contract
|
||||
});
|
||||
|
||||
await slide.update({ headline: next, seconds });
|
||||
await slide.unmount();
|
||||
```
|
||||
|
||||
You see identity, version, and capability — never a URL. Asking the host to mount by id is what keeps
|
||||
your module hostable somewhere other than Ekklesia, exactly as `context.api.request("records")` does.
|
||||
|
||||
You will only ever see contributions aimed at a capability **your** module provides, and only ones
|
||||
the server judged compatible. Handle an empty list: a contributor may be absent, disabled, or outside
|
||||
your compatible range, and that is ordinary rather than exceptional.
|
||||
|
||||
You own the input contract completely. Publish it beside your capability, version it with your
|
||||
capability version, and remember that a renderer written against an older version of it may still be
|
||||
installed.
|
||||
|
||||
## Runtime server definition
|
||||
|
||||
The source entrypoint exports one typed runtime definition and does not repeat the static manifest:
|
||||
@@ -713,8 +1028,8 @@ export default defineModuleRuntime({
|
||||
});
|
||||
```
|
||||
|
||||
A route definition has exactly five fields — `method`, `path`, `permission`, `maxBodyBytes`,
|
||||
`handler` — and nothing else; an unknown field (a baseline, a mount prefix, CORS/origin policy,
|
||||
A route definition has exactly five required fields — `method`, `path`, `permission`,
|
||||
`maxBodyBytes`, `handler` — plus one optional `bodyMode`, and nothing else; an unknown field (a baseline, a mount prefix, CORS/origin policy,
|
||||
middleware, audit metadata, authentication behavior, or another module's namespace) fails
|
||||
registration outright rather than being ignored. `method` is one of `GET`, `POST`, `PUT`, `PATCH`,
|
||||
`DELETE`, always uppercase; there is no `OPTIONS` (the platform owns CORS preflight) and no
|
||||
@@ -779,15 +1094,61 @@ ones. The platform continues to own authentication, authorization, `Origin`, COR
|
||||
cache behavior, and uncaught-handler sanitization, and permission enforcement always completes
|
||||
before your handler runs — an unauthorized caller can never reach your validation at all.
|
||||
|
||||
### Request bodies: `maxBodyBytes` and `bodyMode`
|
||||
|
||||
`maxBodyBytes` is a conscious, required request-body contract, not an optional or defaulted field:
|
||||
`0` means the route accepts no body at all, and a non-empty declared (`Content-Length`) or streamed
|
||||
body is rejected before the handler ever runs; any other value is the exact maximum number of bytes
|
||||
accepted. It must be a non-negative integer no greater than the platform ceiling,
|
||||
`MODULE_ROUTE_MAX_BODY_BYTES_CEILING` (`@ekklesia/contracts`, currently 1 MiB) — a negative,
|
||||
non-integer, non-finite, or over-ceiling value fails registration with
|
||||
`MODULE_ROUTE_MAX_BODY_BYTES_INVALID`. The platform enforces this limit itself, ahead of permission
|
||||
evaluation and the handler, using the identical sanitized `413 {"code":"REQUEST_BODY_TOO_LARGE"}`
|
||||
contract every other body-consuming route in the platform uses — see
|
||||
accepted. A negative, non-integer, non-finite, or over-ceiling value fails registration with
|
||||
`MODULE_ROUTE_MAX_BODY_BYTES_INVALID`.
|
||||
|
||||
`bodyMode` chooses how that body reaches you, and therefore which ceiling applies
|
||||
([ADR 0042](../architecture/decisions/0042-bounded-streaming-request-bodies.md)):
|
||||
|
||||
| `bodyMode` | Ceiling | What the platform does |
|
||||
| --- | --- | --- |
|
||||
| `"buffered"` (default) | 1 MiB | Reads and counts the whole body **before** your handler runs |
|
||||
| `"streamed"` | 32 MiB | Counts bytes **as you consume them**, and never materializes the body |
|
||||
|
||||
```ts
|
||||
http.route({
|
||||
method: "POST",
|
||||
path: "/slides",
|
||||
permission: "signage.slides.write",
|
||||
maxBodyBytes: 33_554_432,
|
||||
bodyMode: "streamed",
|
||||
async handler(request, context) { /* read request.body as a stream */ },
|
||||
});
|
||||
```
|
||||
|
||||
Omitting `bodyMode` means `buffered`, which is what every route written before this existed meant.
|
||||
|
||||
**Memory.** In `buffered` mode the platform holds at most your declared allowance, which is why that
|
||||
ceiling is small and why `await request.json()` is safe there. In `streamed` mode the platform holds
|
||||
nothing: read `request.body` as a stream. Calling `request.arrayBuffer()` on a streamed route works
|
||||
but throws away the entire point — you have just buffered 32 MiB yourself.
|
||||
|
||||
**Streaming.** A declared `Content-Length` over your limit is refused synchronously, before
|
||||
authorization and before a byte is read. Otherwise the platform counts what actually arrives, so a
|
||||
misleading or absent `Content-Length` bounds nothing.
|
||||
|
||||
**A `413` can arrive after your handler starts.** This is the one consequence of `streamed` mode you
|
||||
must design for. When the running total passes your limit the stream errors, the source is cancelled,
|
||||
and the platform replaces your response with the sanitized `413` — even if you caught the error and
|
||||
answered anyway. If you persist as you read, write to a temporary key and commit only once the read
|
||||
completes; the platform cannot do that for you, because only your module knows what a partial write
|
||||
means to its own data.
|
||||
|
||||
**Timeouts.** Body reading is bounded by the server's own header and request timeouts, not by a
|
||||
per-route setting. A slow upload is a slow request, and the platform has one answer for that.
|
||||
|
||||
**One file per request.** 32 MiB is the platform's absolute ceiling in every mode — there is no
|
||||
unbounded option anywhere in the contract. A UI uploading twenty images issues twenty individually
|
||||
bounded requests; nothing here can express a 500 MiB aggregate upload, deliberately, because one bad
|
||||
byte in one would waste everything already sent.
|
||||
|
||||
Every rejection uses the identical sanitized `413 {"code":"REQUEST_BODY_TOO_LARGE"}` contract every
|
||||
other body-consuming route in the platform uses — see
|
||||
[API](../development/api.md#request-body-limits).
|
||||
|
||||
`path` is local to the module, never the path a caller actually sends. The platform alone owns the
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user